Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(go): Improve dynamic snippets error reporting #5099

Merged
merged 11 commits into from
Nov 5, 2024
159 changes: 114 additions & 45 deletions generators/go-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,24 @@ import { go } from "@fern-api/go-codegen";
import { DynamicSnippetsGeneratorContext } from "./context/DynamicSnippetsGeneratorContext";
import { dynamic as DynamicSnippets } from "@fern-fern/ir-sdk/api";
import { AbstractDynamicSnippetsGenerator } from "@fern-api/dynamic-snippets";
import { ErrorReporter, Severity } from "./context/ErrorReporter";
import { Scope } from "./Scope";

const SNIPPET_PACKAGE_NAME = "example";
const SNIPPET_IMPORT_PATH = "fern";
const SNIPPET_FUNC_NAME = "do";
const CLIENT_VAR_NAME = "client";

// TODO(amckinney): Use the latest DynamicSnippets.EndpointSnippetResponse type directly when available.
interface EndpointSnippetResponse extends DynamicSnippets.EndpointSnippetResponse {
errors:
| {
severity: "CRITICAL" | "WARNING";
message: string;
}[]
| undefined;
}

export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<DynamicSnippetsGeneratorContext> {
private formatter: AbstractFormatter | undefined;

Expand All @@ -25,35 +37,52 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<D
this.formatter = formatter;
}

public async generate(
snippet: DynamicSnippets.EndpointSnippetRequest
): Promise<DynamicSnippets.EndpointSnippetResponse> {
const endpoints = this.context.resolveEndpointLocationOrThrow(snippet.endpoint);
public async generate(request: DynamicSnippets.EndpointSnippetRequest): Promise<EndpointSnippetResponse> {
const endpoints = this.context.resolveEndpointLocationOrThrow(request.endpoint);
if (endpoints.length === 0) {
throw new Error(`No endpoints found for ${JSON.stringify(snippet.endpoint)}`);
throw new Error(`No endpoints found that match "${request.endpoint.method} ${request.endpoint.path}"`);
}

let bestReporter: ErrorReporter | undefined;
let bestSnippet: string | undefined;
let err: Error | undefined;
for (const endpoint of endpoints) {
this.context.errors.reset();
try {
const code = this.buildCodeBlock({ endpoint, snippet });
return {
snippet: await code.toString({
packageName: SNIPPET_PACKAGE_NAME,
importPath: SNIPPET_IMPORT_PATH,
rootImportPath: this.context.rootImportPath,
customConfig: this.context.customConfig ?? {},
formatter: this.formatter
})
};
const code = this.buildCodeBlock({ endpoint, snippet: request });
const snippet = await code.toString({
packageName: SNIPPET_PACKAGE_NAME,
importPath: SNIPPET_IMPORT_PATH,
rootImportPath: this.context.rootImportPath,
customConfig: this.context.customConfig ?? {},
formatter: this.formatter
});
if (this.context.errors.empty()) {
return {
snippet,
errors: undefined
};
}
if (bestReporter == null || bestReporter.size() > this.context.errors.size()) {
bestReporter = this.context.errors.clone();
bestSnippet = snippet;
}
} catch (error) {
if (err == null) {
// Report the first error that occurs.
err = error as Error;
}
}
}
throw err ?? new Error(`Failed to generate snippet for ${JSON.stringify(snippet.endpoint)}`);
if (bestSnippet != null && bestReporter != null) {
return {
snippet: bestSnippet,
errors: bestReporter.toDynamicSnippetErrors()
};
}
throw (
err ??
new Error(`Failed to generate snippet for endpoint "${request.endpoint.method} ${request.endpoint.path}"`)
);
}

private buildCodeBlock({
Expand Down Expand Up @@ -114,12 +143,17 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<D
if (snippet.auth != null) {
args.push(this.getConstructorAuthArg({ auth: endpoint.auth, values: snippet.auth }));
} else {
throw new Error(`Auth with ${endpoint.auth.type} configuration is required for this endpoint`);
this.context.errors.add({
severity: Severity.Warning,
message: `Auth with ${endpoint.auth.type} configuration is required for this endpoint`
});
}
}
this.context.errors.scope(Scope.Headers);
if (this.context.ir.headers != null && snippet.headers != null) {
args.push(...this.getConstructorHeaderArgs({ headers: this.context.ir.headers, values: snippet.headers }));
}
this.context.errors.unscope();
return args;
}

Expand All @@ -133,17 +167,29 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<D
switch (auth.type) {
case "basic":
if (values.type !== "basic") {
throw this.newAuthMismatchError({ auth, values });
this.context.errors.add({
severity: Severity.Critical,
message: this.newAuthMismatchError({ auth, values }).message
});
return go.TypeInstantiation.nop();
}
return this.getConstructorBasicAuthArg({ auth, values });
case "bearer":
if (values.type !== "bearer") {
throw this.newAuthMismatchError({ auth, values });
this.context.errors.add({
severity: Severity.Critical,
message: this.newAuthMismatchError({ auth, values }).message
});
return go.TypeInstantiation.nop();
}
return this.getConstructorBearerAuthArg({ auth, values });
case "header":
if (values.type !== "header") {
throw this.newAuthMismatchError({ auth, values });
this.context.errors.add({
severity: Severity.Critical,
message: this.newAuthMismatchError({ auth, values }).message
});
return go.TypeInstantiation.nop();
}
return this.getConstructorHeaderAuthArg({ auth, values });
}
Expand Down Expand Up @@ -286,12 +332,19 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<D
snippet: DynamicSnippets.EndpointSnippetRequest;
}): go.TypeInstantiation[] {
const args: go.TypeInstantiation[] = [];

this.context.errors.scope(Scope.PathParameters);
if (request.pathParameters != null) {
args.push(...this.getPathParameters({ namedParameters: request.pathParameters, snippet }));
}
this.context.errors.unscope();

this.context.errors.scope(Scope.RequestBody);
if (request.body != null) {
args.push(this.getBodyRequestArg({ body: request.body, value: snippet.requestBody }));
}
this.context.errors.unscope();

return args;
}

Expand All @@ -313,7 +366,11 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<D

private getBytesBodyRequestArg({ value }: { value: unknown }): go.TypeInstantiation {
if (typeof value !== "string") {
throw new Error("Expected bytes value to be a string, got " + typeof value);
this.context.errors.add({
severity: Severity.Critical,
message: `Expected bytes value to be a string, got ${typeof value}`
});
return go.TypeInstantiation.nop();
}
return go.TypeInstantiation.bytes(value as string);
}
Expand All @@ -326,9 +383,13 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<D
snippet: DynamicSnippets.EndpointSnippetRequest;
}): go.TypeInstantiation[] {
const args: go.TypeInstantiation[] = [];

this.context.errors.scope(Scope.PathParameters);
if (request.pathParameters != null) {
args.push(...this.getPathParameters({ namedParameters: request.pathParameters, snippet }));
}
this.context.errors.unscope();

args.push(this.getInlinedRequestArg({ request, snippet }));
return args;
}
Expand All @@ -342,33 +403,41 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<D
}): go.TypeInstantiation {
const fields: go.StructField[] = [];

const parameters = [
...this.context.associateQueryParametersByWireValue({
parameters: request.queryParameters ?? [],
values: snippet.queryParameters ?? {}
}),
...this.context.associateByWireValue({
parameters: request.headers ?? [],
values: snippet.headers ?? {}
})
];
for (const parameter of parameters) {
fields.push({
name: parameter.name.pascalCase.unsafeName,
value: this.context.dynamicTypeInstantiationMapper.convert(parameter)
});
}

if (request.body != null) {
fields.push(...this.getInlinedRequestBodyStructFields({ body: request.body, value: snippet.requestBody }));
}
this.context.errors.scope(Scope.QueryParameters);
const queryParameters = this.context.associateQueryParametersByWireValue({
parameters: request.queryParameters ?? [],
values: snippet.queryParameters ?? {}
});
const queryParameterFields = queryParameters.map((queryParameter) => ({
name: queryParameter.name.name.pascalCase.unsafeName,
value: this.context.dynamicTypeInstantiationMapper.convert(queryParameter)
}));
this.context.errors.unscope();

this.context.errors.scope(Scope.Headers);
const headers = this.context.associateByWireValue({
parameters: request.headers ?? [],
values: snippet.headers ?? {}
});
const headerFields = headers.map((header) => ({
name: header.name.name.pascalCase.unsafeName,
value: this.context.dynamicTypeInstantiationMapper.convert(header)
}));
this.context.errors.unscope();

this.context.errors.scope(Scope.RequestBody);
const requestBodyFields =
request.body != null
? this.getInlinedRequestBodyStructFields({ body: request.body, value: snippet.requestBody })
: [];
this.context.errors.unscope();

return go.TypeInstantiation.structPointer({
typeReference: go.typeReference({
name: this.context.getMethodName(request.declaration.name),
importPath: this.context.getImportPath(request.declaration.fernFilepath)
}),
fields
fields: [...queryParameterFields, ...headerFields, ...requestBodyFields]
});
}

Expand Down Expand Up @@ -428,11 +497,11 @@ export class DynamicSnippetsGenerator extends AbstractDynamicSnippetsGenerator<D

const bodyProperties = this.context.associateByWireValue({
parameters,
values: this.context.getRecordOrThrow(value)
values: this.context.getRecord(value) ?? {}
});
for (const parameter of bodyProperties) {
fields.push({
name: this.context.getTypeName(parameter.name),
name: this.context.getTypeName(parameter.name.name),
value: this.context.dynamicTypeInstantiationMapper.convert(parameter)
});
}
Expand Down
8 changes: 8 additions & 0 deletions generators/go-v2/dynamic-snippets/src/Scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const Scope = {
PathParameters: "pathParameters",
QueryParameters: "queryParameters",
Headers: "headers",
RequestBody: "requestBody"
} as const;

export type Scope = typeof Scope[keyof typeof Scope];
4 changes: 2 additions & 2 deletions generators/go-v2/dynamic-snippets/src/TypeInstance.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Name } from "@fern-fern/ir-sdk/api";
import { NameAndWireValue } from "@fern-fern/ir-sdk/api";
import { dynamic as DynamicSnippets } from "@fern-fern/ir-sdk/api";

/**
Expand All @@ -8,7 +8,7 @@ import { dynamic as DynamicSnippets } from "@fern-fern/ir-sdk/api";
* is (optionally) used within the dynamic snippet, e.g. for named fields.
*/
export interface TypeInstance {
name: Name;
name: NameAndWireValue;
typeReference: DynamicSnippets.TypeReference;
value: unknown;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`examples (errors) > invalid request body 1`] = `
[
{
"message": "Expected string but got number",
"path": [
"requestBody",
"title",
],
"severity": "CRITICAL",
},
]
`;

exports[`examples > 'GET /metadata (allow-multiple)' 1`] = `
"package example

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`exhaustive (errors) > invalid request body 1`] = `
[
{
"message": "Expected string but got boolean",
"path": [
"requestBody[0]",
"string",
],
"severity": "CRITICAL",
},
{
"message": ""invalid" is not a recognized parameter for this endpoint",
"path": [
"requestBody[1]",
"invalid",
],
"severity": "CRITICAL",
},
{
"message": "Expected string but got number",
"path": [
"requestBody[2]",
"string",
],
"severity": "CRITICAL",
},
]
`;

exports[`exhaustive > 'POST /container/list-of-objects (simp…' 1`] = `
"package example

Expand Down
Loading
Loading