From 65113d2131ba18f6f5e5dd9b4d19b57d219118a3 Mon Sep 17 00:00:00 2001 From: Alex McKinney Date: Wed, 8 Jan 2025 09:54:00 -0500 Subject: [PATCH] feat(typescript): Add DynamicTypeLiteralMapper (#5551) --- ...AbstractDynamicSnippetsGeneratorContext.ts | 50 ++- .../typescript-v2/ast/src/ast/Reference.ts | 11 +- .../typescript-v2/ast/src/ast/TypeLiteral.ts | 113 ++++- .../dynamic-snippets/package.json | 1 + .../src/DynamicSnippetsGenerator.ts | 1 - .../src/EndpointSnippetGenerator.ts | 150 ++++++- .../__test__/__snapshots__/imdb.test.ts.snap | 4 +- .../multi-url-environment.test.ts.snap | 54 +++ ...ingle-url-environment-default.test.ts.snap | 51 +++ .../__test__/multi-url-environment.test.ts | 142 ++++++ .../single-url-environment-default.test.ts | 137 ++++++ .../DynamicSnippetsGeneratorContext.ts | 87 +++- .../src/context/DynamicTypeLiteralMapper.ts | 410 ++++++++++++++++++ pnpm-lock.yaml | 3 + 14 files changed, 1178 insertions(+), 36 deletions(-) create mode 100644 generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/multi-url-environment.test.ts.snap create mode 100644 generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/single-url-environment-default.test.ts.snap create mode 100644 generators/typescript-v2/dynamic-snippets/src/__test__/multi-url-environment.test.ts create mode 100644 generators/typescript-v2/dynamic-snippets/src/__test__/single-url-environment-default.test.ts create mode 100644 generators/typescript-v2/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts diff --git a/generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGeneratorContext.ts b/generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGeneratorContext.ts index 94a0e771d43..473015945b4 100644 --- a/generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGeneratorContext.ts +++ b/generators/browser-compatible-base/src/dynamic-snippets/AbstractDynamicSnippetsGeneratorContext.ts @@ -1,4 +1,4 @@ -import { assertNever } from "@fern-api/core-utils"; +import { assertNever, keys } from "@fern-api/core-utils"; import { FernIr } from "@fern-api/dynamic-ir-sdk"; import { HttpEndpointReferenceParser } from "@fern-api/fern-definition-schema"; @@ -245,6 +245,54 @@ export abstract class AbstractDynamicSnippetsGeneratorContext { return typeof environment === "object"; } + public validateMultiEnvironmentUrlValues( + multiEnvironmentUrlValues: FernIr.dynamic.MultipleEnvironmentUrlValues + ): boolean { + if (this._ir.environments == null) { + this.errors.add({ + severity: Severity.Critical, + message: + "Multiple environments are not supported for single base URL environments; use the baseUrl option instead" + }); + return false; + } + const environments = this._ir.environments.environments; + switch (environments.type) { + case "singleBaseUrl": { + this.errors.add({ + severity: Severity.Critical, + message: + "Multiple environments are not supported for single base URL environments; use the baseUrl option instead" + }); + return false; + } + case "multipleBaseUrls": { + const firstEnvironment = environments.environments[0]; + if (firstEnvironment == null) { + this.errors.add({ + severity: Severity.Critical, + message: "Multiple environments are not supported; use the baseUrl option instead" + }); + return false; + } + const expectedKeys = new Set(keys(firstEnvironment.urls)); + for (const key of keys(multiEnvironmentUrlValues)) { + if (expectedKeys.has(key)) { + expectedKeys.delete(key); + } + } + if (expectedKeys.size > 0) { + this.errors.add({ + severity: Severity.Critical, + message: `The provided environments are invalid; got: [${Object.keys(multiEnvironmentUrlValues).join(", ")}], expected: [${keys(firstEnvironment.urls).join(", ")}]` + }); + return false; + } + return true; + } + } + } + public newAuthMismatchError({ auth, values diff --git a/generators/typescript-v2/ast/src/ast/Reference.ts b/generators/typescript-v2/ast/src/ast/Reference.ts index 96a4dcd21ad..831d8ef70ef 100644 --- a/generators/typescript-v2/ast/src/ast/Reference.ts +++ b/generators/typescript-v2/ast/src/ast/Reference.ts @@ -22,6 +22,8 @@ export declare namespace Reference { interface Args { /* The name of the reference */ name: string; + /* The member name within the imported reference, if any (e.g. 'Address' in 'User.Address') */ + memberName?: string; /* The module it's from, if it's imported */ importFrom?: ModuleImport; } @@ -29,12 +31,14 @@ export declare namespace Reference { export class Reference extends AstNode { public readonly name: string; - public readonly importFrom?: Reference.ModuleImport; + public readonly importFrom: Reference.ModuleImport | undefined; + public readonly memberName: string | undefined; - constructor({ name, importFrom }: Reference.Args) { + constructor({ name, importFrom, memberName }: Reference.Args) { super(); this.name = name; this.importFrom = importFrom; + this.memberName = memberName; } public write(writer: Writer): void { @@ -42,6 +46,7 @@ export class Reference extends AstNode { writer.addImport(this); } const prefix = this.importFrom?.type === "star" ? `${this.importFrom.starImportAlias}.` : ""; - writer.write(`${prefix}${this.name}`); + const suffix = this.memberName != null ? `.${this.memberName}` : ""; + writer.write(`${prefix}${this.name}${suffix}`); } } diff --git a/generators/typescript-v2/ast/src/ast/TypeLiteral.ts b/generators/typescript-v2/ast/src/ast/TypeLiteral.ts index a2ef447a5f7..8147747b79c 100644 --- a/generators/typescript-v2/ast/src/ast/TypeLiteral.ts +++ b/generators/typescript-v2/ast/src/ast/TypeLiteral.ts @@ -2,7 +2,17 @@ import { assertNever } from "@fern-api/core-utils"; import { AstNode, Writer } from "./core"; -type InternalTypeLiteral = Array_ | Boolean_ | BigInt_ | Number_ | Object_ | String_ | Tuple | Nop; +type InternalTypeLiteral = + | Array_ + | Boolean_ + | BigInt_ + | Number_ + | Object_ + | Reference + | String_ + | Tuple + | Unkonwn_ + | Nop; interface Array_ { type: "array"; @@ -34,6 +44,11 @@ export interface ObjectField { value: TypeLiteral; } +interface Reference { + type: "reference"; + value: AstNode; +} + interface String_ { type: "string"; value: string; @@ -44,6 +59,11 @@ interface Tuple { values: TypeLiteral[]; } +interface Unkonwn_ { + type: "unknown"; + value: unknown; +} + interface Nop { type: "nop"; } @@ -64,18 +84,21 @@ export class TypeLiteral extends AstNode { break; } case "number": { - // N.B. Defaults to decimal; further work needed to support alternatives like hex, binary, octal, etc. writer.write(this.internalType.value.toString()); break; } case "bigint": { - writer.write(this.internalType.value.toString()); + writer.write(`BigInt(${this.internalType.value.toString()})`); break; } case "object": { this.writeObject({ writer, object: this.internalType }); break; } + case "reference": { + writer.writeNode(this.internalType.value); + break; + } case "string": { if (this.internalType.value.includes("\n")) { this.writeStringWithBackticks({ writer, value: this.internalType.value }); @@ -88,6 +111,10 @@ export class TypeLiteral extends AstNode { this.writeIterable({ writer, iterable: this.internalType }); break; } + case "unknown": { + this.writeUnknown({ writer, value: this.internalType.value }); + break; + } case "nop": break; default: { @@ -149,6 +176,10 @@ export class TypeLiteral extends AstNode { }); } + public static bigint(value: bigint): TypeLiteral { + return new this({ type: "bigint", value }); + } + public static boolean(value: boolean): TypeLiteral { return new this({ type: "boolean", value }); } @@ -164,6 +195,13 @@ export class TypeLiteral extends AstNode { }); } + public static reference(value: AstNode): TypeLiteral { + return new this({ + type: "reference", + value + }); + } + public static string(value: string): TypeLiteral { return new this({ type: "string", @@ -178,8 +216,8 @@ export class TypeLiteral extends AstNode { }); } - public static bigint(value: bigint): TypeLiteral { - return new this({ type: "bigint", value }); + public static unknown(value: unknown): TypeLiteral { + return new this({ type: "unknown", value }); } public static nop(): TypeLiteral { @@ -189,6 +227,71 @@ export class TypeLiteral extends AstNode { public static isNop(typeLiteral: TypeLiteral): boolean { return typeLiteral.internalType.type === "nop"; } + + private writeUnknown({ writer, value }: { writer: Writer; value: unknown }): void { + switch (typeof value) { + case "boolean": + writer.write(value.toString()); + return; + case "string": + writer.write(value.includes('"') ? `\`${value}\`` : `"${value}"`); + return; + case "number": + writer.write(value.toString()); + return; + case "object": + if (value == null) { + writer.write("null"); + return; + } + if (Array.isArray(value)) { + this.writeUnknownArray({ writer, value }); + return; + } + this.writeUnknownObject({ writer, value }); + return; + default: + throw new Error(`Internal error; unsupported unknown type: ${typeof value}`); + } + } + + private writeUnknownArray({ + writer, + value + }: { + writer: Writer; + value: any[]; // eslint-disable-line @typescript-eslint/no-explicit-any + }): void { + if (value.length === 0) { + writer.write("[]"); + return; + } + writer.writeLine("["); + writer.indent(); + for (const element of value) { + writer.writeNode(TypeLiteral.unknown(element)); + writer.writeLine(","); + } + writer.dedent(); + writer.write("]"); + } + + private writeUnknownObject({ writer, value }: { writer: Writer; value: object }): void { + const entries = Object.entries(value); + if (entries.length === 0) { + writer.write("{}"); + return; + } + writer.writeLine("{"); + writer.indent(); + for (const [key, val] of entries) { + writer.write(`${key}: `); + writer.writeNode(TypeLiteral.unknown(val)); + writer.writeLine(","); + } + writer.dedent(); + writer.write("}"); + } } function filterNopObjectFields({ fields }: { fields: ObjectField[] }): ObjectField[] { diff --git a/generators/typescript-v2/dynamic-snippets/package.json b/generators/typescript-v2/dynamic-snippets/package.json index 5d3787bc651..b275e7dbd11 100644 --- a/generators/typescript-v2/dynamic-snippets/package.json +++ b/generators/typescript-v2/dynamic-snippets/package.json @@ -30,6 +30,7 @@ "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", "@fern-api/browser-compatible-base-generator": "workspace:*", + "@fern-api/core-utils": "workspace:*", "@fern-api/dynamic-ir-sdk": "^53.23.0", "@fern-api/path-utils": "workspace:*", "@fern-api/typescript-ast": "workspace:*", diff --git a/generators/typescript-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts b/generators/typescript-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts index 2892b7d07f6..2b5262462ed 100644 --- a/generators/typescript-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts +++ b/generators/typescript-v2/dynamic-snippets/src/DynamicSnippetsGenerator.ts @@ -1,6 +1,5 @@ import { AbstractDynamicSnippetsGenerator, - AbstractFormatter, FernGeneratorExec, Result } from "@fern-api/browser-compatible-base-generator"; diff --git a/generators/typescript-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/typescript-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts index 6f3628737e4..b148621e350 100644 --- a/generators/typescript-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/typescript-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -1,29 +1,20 @@ -import { Severity } from "@fern-api/browser-compatible-base-generator"; +import { Scope, Severity } from "@fern-api/browser-compatible-base-generator"; import { FernIr } from "@fern-api/dynamic-ir-sdk"; import { ts } from "@fern-api/typescript-ast"; -import { NpmPackage, constructNpmPackage, getNamespaceExport } from "@fern-api/typescript-browser-compatible-base"; import { DynamicSnippetsGeneratorContext } from "./context/DynamicSnippetsGeneratorContext"; const CLIENT_VAR_NAME = "client"; +const STRING_TYPE_REFERENCE: FernIr.dynamic.TypeReference = { + type: "primitive", + value: "STRING" +}; export class EndpointSnippetGenerator { private context: DynamicSnippetsGeneratorContext; - private namespaceExport: string; - private moduleName: string; constructor({ context }: { context: DynamicSnippetsGeneratorContext }) { this.context = context; - this.namespaceExport = getNamespaceExport({ - organization: this.context.config.organization, - workspaceName: this.context.config.workspaceName, - namespaceExport: this.context.customConfig?.namespaceExport - }); - this.moduleName = - constructNpmPackage({ - generatorConfig: context.config, - isPackagePrivate: context.customConfig?.private ?? false - })?.packageName ?? this.context.config.organization; } public async generateSnippet({ @@ -74,11 +65,8 @@ export class EndpointSnippetGenerator { const: true, initializer: ts.instantiateClass({ class_: ts.reference({ - name: this.namespaceExport, - importFrom: { - type: "named", - moduleName: this.moduleName - } + name: this.context.getRootClientName(), + importFrom: this.context.getModuleImport() }), arguments_: [this.getConstructorArgs({ endpoint, snippet })] }) @@ -110,6 +98,13 @@ export class EndpointSnippetGenerator { snippet: FernIr.dynamic.EndpointSnippetRequest; }): ts.AstNode { const fields: ts.ObjectField[] = []; + const environmentArgs = this.getConstructorEnvironmentArgs({ + baseUrl: snippet.baseURL, + environment: snippet.environment + }); + if (environmentArgs.length > 0) { + fields.push(...environmentArgs); + } if (endpoint.auth != null) { if (snippet.auth != null) { fields.push(...this.getConstructorAuthArgs({ auth: endpoint.auth, values: snippet.auth })); @@ -120,12 +115,85 @@ export class EndpointSnippetGenerator { }); } } + this.context.errors.scope(Scope.Headers); + if (this.context.ir.headers != null && snippet.headers != null) { + fields.push( + ...this.getConstructorHeaderArgs({ headers: this.context.ir.headers, values: snippet.headers }) + ); + } + this.context.errors.unscope(); if (fields.length === 0) { return ts.TypeLiteral.nop(); } return ts.TypeLiteral.object({ fields }); } + private getConstructorEnvironmentArgs({ + baseUrl, + environment + }: { + baseUrl: string | undefined; + environment: FernIr.dynamic.EnvironmentValues | undefined; + }): ts.ObjectField[] { + const environmentValue = this.getEnvironmentValue({ baseUrl, environment }); + if (environmentValue == null) { + return []; + } + return [ + { + name: "environment", + value: environmentValue + } + ]; + } + + private getEnvironmentValue({ + baseUrl, + environment + }: { + baseUrl: string | undefined; + environment: FernIr.dynamic.EnvironmentValues | undefined; + }): ts.TypeLiteral | undefined { + if (baseUrl != null && environment != null) { + this.context.errors.add({ + severity: Severity.Critical, + message: "Cannot specify both baseUrl and environment options" + }); + return undefined; + } + if (baseUrl != null) { + return ts.TypeLiteral.string(baseUrl); + } + if (environment != null) { + if (this.context.isSingleEnvironmentID(environment)) { + const environmentTypeReference = this.context.getEnvironmentTypeReferenceFromID(environment); + if (environmentTypeReference == null) { + this.context.errors.add({ + severity: Severity.Warning, + message: `Environment ${JSON.stringify(environment)} was not found` + }); + return undefined; + } + return ts.TypeLiteral.reference(environmentTypeReference); + } + if (this.context.isMultiEnvironmentValues(environment)) { + if (!this.context.validateMultiEnvironmentUrlValues(environment)) { + return undefined; + } + return ts.TypeLiteral.object({ + fields: Object.entries(environment).map(([key, value]) => ({ + name: key, + value: this.context.dynamicTypeLiteralMapper.convert({ + typeReference: STRING_TYPE_REFERENCE, + value + }) + })) + }); + } + } + return undefined; + } + private getConstructorAuthArgs({ auth, values @@ -208,11 +276,53 @@ export class EndpointSnippetGenerator { return [ { name: this.context.getPropertyName(auth.header.name.name), - value: ts.TypeLiteral.string("TODO: Implement me!") + value: this.context.dynamicTypeLiteralMapper.convert({ + typeReference: auth.header.typeReference, + value: values.value + }) } ]; } + private getConstructorHeaderArgs({ + headers, + values + }: { + headers: FernIr.dynamic.NamedParameter[]; + values: FernIr.dynamic.Values; + }): ts.ObjectField[] { + const fields: ts.ObjectField[] = []; + for (const header of headers) { + const field = this.getConstructorHeaderArg({ header, value: values.value }); + if (field != null) { + fields.push(field); + } + } + return fields; + } + + private getConstructorHeaderArg({ + header, + value + }: { + header: FernIr.dynamic.NamedParameter; + value: unknown; + }): ts.ObjectField | undefined { + const typeLiteral = this.context.dynamicTypeLiteralMapper.convert({ + typeReference: header.typeReference, + value + }); + if (ts.TypeLiteral.isNop(typeLiteral)) { + // Literal header values (e.g. "X-API-Version") should not be included in the + // client constructor. + return undefined; + } + return { + name: this.context.getPropertyName(header.name.name), + value: typeLiteral + }; + } + private getMethod({ endpoint }: { endpoint: FernIr.dynamic.Endpoint }): string { if (endpoint.declaration.fernFilepath.allParts.length > 0) { return `${endpoint.declaration.fernFilepath.allParts diff --git a/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/imdb.test.ts.snap b/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/imdb.test.ts.snap index 5facf141070..60a67c12823 100644 --- a/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/imdb.test.ts.snap +++ b/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/imdb.test.ts.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`imdb (success) > 'GET /movies/{movieId} (simple)' 1`] = ` -"import { Acme } from "acme"; +"import { AcmeClient } from "acme"; -const client = new Acme({ +const client = new AcmeClient({ token: "", }) await client.imdb.getMovie()" diff --git a/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/multi-url-environment.test.ts.snap b/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/multi-url-environment.test.ts.snap new file mode 100644 index 00000000000..1b055a621d2 --- /dev/null +++ b/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/multi-url-environment.test.ts.snap @@ -0,0 +1,54 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`multi-url-environment > custom environment 1`] = ` +"import { AcmeClient } from "acme"; + +const client = new AcmeClient({ + environment: { + ec2: "https://custom.ec2.aws.com", + s3: "https://custom.s3.aws.com", + }, + token: "", +}) +await client.s3.getPresignedURL()" +`; + +exports[`multi-url-environment > invalid environment id 1`] = ` +[ + { + "message": "Environment "Unrecognized" was not found", + "path": [], + "severity": "WARNING", + }, +] +`; + +exports[`multi-url-environment > invalid multi url environments 1`] = ` +[ + { + "message": "The provided environments are invalid; got: [ec2], expected: [ec2, s3]", + "path": [], + "severity": "CRITICAL", + }, +] +`; + +exports[`multi-url-environment > production environment 1`] = ` +"import { AcmeClient, AcmeEnvironments } from "acme"; + +const client = new AcmeClient({ + environment: AcmeEnvironments.Production, + token: "", +}) +await client.s3.getPresignedURL()" +`; + +exports[`multi-url-environment > staging environment 1`] = ` +"import { AcmeClient, AcmeEnvironments } from "acme"; + +const client = new AcmeClient({ + environment: AcmeEnvironments.Staging, + token: "", +}) +await client.s3.getPresignedURL()" +`; diff --git a/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/single-url-environment-default.test.ts.snap b/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/single-url-environment-default.test.ts.snap new file mode 100644 index 00000000000..9f4fb555816 --- /dev/null +++ b/generators/typescript-v2/dynamic-snippets/src/__test__/__snapshots__/single-url-environment-default.test.ts.snap @@ -0,0 +1,51 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`single-url-environment-default > custom baseURL 1`] = ` +"import { AcmeClient } from "acme"; + +const client = new AcmeClient({ + environment: "http://localhost:8080", + token: "", +}) +await client.dummy.getDummy()" +`; + +exports[`single-url-environment-default > invalid baseURL and environment 1`] = ` +[ + { + "message": "Cannot specify both baseUrl and environment options", + "path": [], + "severity": "CRITICAL", + }, +] +`; + +exports[`single-url-environment-default > invalid environment 1`] = ` +[ + { + "message": "Environment "Unrecognized" was not found", + "path": [], + "severity": "WARNING", + }, +] +`; + +exports[`single-url-environment-default > production environment 1`] = ` +"import { AcmeClient, AcmeEnvironments } from "acme"; + +const client = new AcmeClient({ + environment: AcmeEnvironments.Production, + token: "", +}) +await client.dummy.getDummy()" +`; + +exports[`single-url-environment-default > staging environment 1`] = ` +"import { AcmeClient, AcmeEnvironments } from "acme"; + +const client = new AcmeClient({ + environment: AcmeEnvironments.Staging, + token: "", +}) +await client.dummy.getDummy()" +`; diff --git a/generators/typescript-v2/dynamic-snippets/src/__test__/multi-url-environment.test.ts b/generators/typescript-v2/dynamic-snippets/src/__test__/multi-url-environment.test.ts new file mode 100644 index 00000000000..06dcd339038 --- /dev/null +++ b/generators/typescript-v2/dynamic-snippets/src/__test__/multi-url-environment.test.ts @@ -0,0 +1,142 @@ +import { AbsoluteFilePath } from "@fern-api/path-utils"; + +import { buildDynamicSnippetsGenerator } from "./utils/buildDynamicSnippetsGenerator"; +import { buildGeneratorConfig } from "./utils/buildGeneratorConfig"; +import { DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY } from "./utils/constant"; + +describe("multi-url-environment", () => { + it("production environment", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of(`${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/multi-url-environment.json`), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "POST", + path: "/s3/presigned-url" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: undefined, + environment: "Production", + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: { + s3Key: "xyz" + } + }); + expect(response.snippet).toMatchSnapshot(); + }); + + it("staging environment", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of(`${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/multi-url-environment.json`), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "POST", + path: "/s3/presigned-url" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: undefined, + environment: "Staging", + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: { + s3Key: "xyz" + } + }); + expect(response.snippet).toMatchSnapshot(); + }); + + it("custom environment", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of(`${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/multi-url-environment.json`), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "POST", + path: "/s3/presigned-url" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: undefined, + environment: { + ec2: "https://custom.ec2.aws.com", + s3: "https://custom.s3.aws.com" + }, + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: { + s3Key: "xyz" + } + }); + expect(response.snippet).toMatchSnapshot(); + }); + + it("invalid environment id", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of(`${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/multi-url-environment.json`), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "POST", + path: "/s3/presigned-url" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: undefined, + environment: "Unrecognized", + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: { + s3Key: "xyz" + } + }); + expect(response.errors).toMatchSnapshot(); + }); + + it("invalid multi url environments", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of(`${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/multi-url-environment.json`), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "POST", + path: "/s3/presigned-url" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: undefined, + environment: { + ec2: "https://custom.ec2.aws.com" + }, + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: { + s3Key: "xyz" + } + }); + expect(response.errors).toMatchSnapshot(); + }); +}); diff --git a/generators/typescript-v2/dynamic-snippets/src/__test__/single-url-environment-default.test.ts b/generators/typescript-v2/dynamic-snippets/src/__test__/single-url-environment-default.test.ts new file mode 100644 index 00000000000..cd6f27546fe --- /dev/null +++ b/generators/typescript-v2/dynamic-snippets/src/__test__/single-url-environment-default.test.ts @@ -0,0 +1,137 @@ +import { AbsoluteFilePath } from "@fern-api/path-utils"; + +import { buildDynamicSnippetsGenerator } from "./utils/buildDynamicSnippetsGenerator"; +import { buildGeneratorConfig } from "./utils/buildGeneratorConfig"; +import { DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY } from "./utils/constant"; + +describe("single-url-environment-default", () => { + it("production environment", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of( + `${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/single-url-environment-default.json` + ), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "GET", + path: "/dummy" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: undefined, + environment: "Production", + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: undefined + }); + expect(response.snippet).toMatchSnapshot(); + }); + + it("staging environment", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of( + `${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/single-url-environment-default.json` + ), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "GET", + path: "/dummy" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: undefined, + environment: "Staging", + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: undefined + }); + expect(response.snippet).toMatchSnapshot(); + }); + + it("custom baseURL", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of( + `${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/single-url-environment-default.json` + ), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "GET", + path: "/dummy" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: "http://localhost:8080", + environment: undefined, + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: undefined + }); + expect(response.snippet).toMatchSnapshot(); + }); + + it("invalid environment", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of( + `${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/single-url-environment-default.json` + ), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "GET", + path: "/dummy" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: undefined, + environment: "Unrecognized", + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: undefined + }); + expect(response.errors).toMatchSnapshot(); + }); + + it("invalid baseURL and environment", async () => { + const generator = buildDynamicSnippetsGenerator({ + irFilepath: AbsoluteFilePath.of( + `${DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY}/single-url-environment-default.json` + ), + config: buildGeneratorConfig() + }); + const response = await generator.generate({ + endpoint: { + method: "GET", + path: "/dummy" + }, + auth: { + type: "bearer", + token: "" + }, + baseURL: "http://localhost:8080", + environment: "Production", + pathParameters: undefined, + queryParameters: undefined, + headers: undefined, + requestBody: undefined + }); + expect(response.errors).toMatchSnapshot(); + }); +}); diff --git a/generators/typescript-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts b/generators/typescript-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts index 2373be29e08..47d7fd01b4b 100644 --- a/generators/typescript-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts +++ b/generators/typescript-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts @@ -1,13 +1,21 @@ import { AbstractDynamicSnippetsGeneratorContext, - FernGeneratorExec + FernGeneratorExec, + Severity } from "@fern-api/browser-compatible-base-generator"; +import { assertNever, keys } from "@fern-api/core-utils"; import { FernIr } from "@fern-api/dynamic-ir-sdk"; -import { TypescriptCustomConfigSchema } from "@fern-api/typescript-ast"; +import { TypescriptCustomConfigSchema, ts } from "@fern-api/typescript-ast"; +import { constructNpmPackage, getNamespaceExport } from "@fern-api/typescript-browser-compatible-base"; + +import { DynamicTypeLiteralMapper } from "./DynamicTypeLiteralMapper"; export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGeneratorContext { public ir: FernIr.dynamic.DynamicIntermediateRepresentation; public customConfig: TypescriptCustomConfigSchema | undefined; + public dynamicTypeLiteralMapper: DynamicTypeLiteralMapper; + public moduleName: string; + public namespaceExport: string; constructor({ ir, @@ -20,6 +28,13 @@ export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGene this.ir = ir; this.customConfig = config.customConfig != null ? (config.customConfig as TypescriptCustomConfigSchema) : undefined; + this.dynamicTypeLiteralMapper = new DynamicTypeLiteralMapper({ context: this }); + this.moduleName = getModuleName({ config, customConfig: this.customConfig }); + this.namespaceExport = getNamespaceExport({ + organization: config.organization, + workspaceName: config.workspaceName, + namespaceExport: this.customConfig?.namespaceExport + }); } public clone(): DynamicSnippetsGeneratorContext { @@ -29,11 +44,75 @@ export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGene }); } - public getMethodName(name: FernIr.Name): string { - return name.camelCase.unsafeName; + public getModuleImport(): ts.Reference.ModuleImport { + return { + type: "named", + moduleName: this.moduleName + }; + } + + public getRootClientName(): string { + return `${this.namespaceExport}Client`; } public getPropertyName(name: FernIr.Name): string { + if (this.customConfig?.retainOriginalCasing || this.customConfig?.noSerdeLayer) { + return name.originalName; + } return name.camelCase.safeName; } + + public getMethodName(name: FernIr.Name): string { + return name.camelCase.unsafeName; + } + + public getTypeName(name: FernIr.Name): string { + return name.pascalCase.unsafeName; + } + + public getEnvironmentTypeReferenceFromID(environmentID: string): ts.Reference | undefined { + if (this.ir.environments == null) { + return undefined; + } + const environments = this.ir.environments.environments; + switch (environments.type) { + case "singleBaseUrl": { + const environment = environments.environments.find((env) => env.id === environmentID); + if (environment == null) { + return undefined; + } + return this.getEnvironmentsTypeReference(environment.name); + } + case "multipleBaseUrls": { + const environment = environments.environments.find((env) => env.id === environmentID); + if (environment == null) { + return undefined; + } + return this.getEnvironmentsTypeReference(environment.name); + } + } + } + + private getEnvironmentsTypeReference(name: FernIr.Name): ts.Reference { + return ts.reference({ + name: `${this.namespaceExport}Environments`, + importFrom: this.getModuleImport(), + memberName: this.getTypeName(name) + }); + } +} + +function getModuleName({ + config, + customConfig +}: { + config: FernGeneratorExec.GeneratorConfig; + customConfig: TypescriptCustomConfigSchema | undefined; +}): string { + return ( + constructNpmPackage({ + generatorConfig: config, + isPackagePrivate: customConfig?.private ?? false + })?.packageName ?? config.organization + ); } diff --git a/generators/typescript-v2/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts b/generators/typescript-v2/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts new file mode 100644 index 00000000000..01f71d303e5 --- /dev/null +++ b/generators/typescript-v2/dynamic-snippets/src/context/DynamicTypeLiteralMapper.ts @@ -0,0 +1,410 @@ +import { DiscriminatedUnionTypeInstance, Severity } from "@fern-api/browser-compatible-base-generator"; +import { assertNever } from "@fern-api/core-utils"; +import { FernIr } from "@fern-api/dynamic-ir-sdk"; +import { ts } from "@fern-api/typescript-ast"; + +import { DynamicSnippetsGeneratorContext } from "./DynamicSnippetsGeneratorContext"; + +export declare namespace DynamicTypeLiteralMapper { + interface Args { + typeReference: FernIr.dynamic.TypeReference; + value: unknown; + } +} + +export class DynamicTypeLiteralMapper { + private context: DynamicSnippetsGeneratorContext; + + constructor({ context }: { context: DynamicSnippetsGeneratorContext }) { + this.context = context; + } + + public convert(args: DynamicTypeLiteralMapper.Args): ts.TypeLiteral { + if (args.value == null) { + return ts.TypeLiteral.nop(); + } + switch (args.typeReference.type) { + case "list": + return this.convertList({ list: args.typeReference.value, value: args.value }); + case "literal": + return ts.TypeLiteral.nop(); + case "map": + return this.convertMap({ map: args.typeReference, value: args.value }); + case "named": { + const named = this.context.resolveNamedType({ typeId: args.typeReference.value }); + if (named == null) { + return ts.TypeLiteral.nop(); + } + return this.convertNamed({ named, value: args.value }); + } + case "optional": + return this.convert({ typeReference: args.typeReference.value, value: args.value }); + case "primitive": + return this.convertPrimitive({ primitive: args.typeReference.value, value: args.value }); + case "set": + return this.convertList({ list: args.typeReference.value, value: args.value }); + case "unknown": + return this.convertUnknown({ value: args.value }); + default: + assertNever(args.typeReference); + } + } + + private convertList({ list, value }: { list: FernIr.dynamic.TypeReference; value: unknown }): ts.TypeLiteral { + if (!Array.isArray(value)) { + this.context.errors.add({ + severity: Severity.Critical, + message: `Expected array but got: ${typeof value}` + }); + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.array({ + values: value.map((v, index) => { + this.context.errors.scope({ index }); + try { + return this.convert({ typeReference: list, value: v }); + } finally { + this.context.errors.unscope(); + } + }) + }); + } + + private convertMap({ map, value }: { map: FernIr.dynamic.MapType; value: unknown }): ts.TypeLiteral { + if (typeof value !== "object" || value == null) { + this.context.errors.add({ + severity: Severity.Critical, + message: `Expected object but got: ${value == null ? "null" : typeof value}` + }); + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.object({ + fields: Object.entries(value).map(([key, value]) => { + this.context.errors.scope(key); + try { + return { + name: key, + value: this.convert({ typeReference: map.value, value }) + }; + } finally { + this.context.errors.unscope(); + } + }) + }); + } + + private convertNamed({ named, value }: { named: FernIr.dynamic.NamedType; value: unknown }): ts.TypeLiteral { + switch (named.type) { + case "alias": + return this.convert({ typeReference: named.typeReference, value }); + case "discriminatedUnion": + return this.convertDiscriminatedUnion({ + discriminatedUnion: named, + value + }); + case "enum": + return this.convertEnum({ enum_: named, value }); + case "object": + return this.convertObject({ object_: named, value }); + case "undiscriminatedUnion": + return this.convertUndicriminatedUnion({ undicriminatedUnion: named, value }); + default: + assertNever(named); + } + } + + private convertDiscriminatedUnion({ + discriminatedUnion, + value + }: { + discriminatedUnion: FernIr.dynamic.DiscriminatedUnionType; + value: unknown; + }): ts.TypeLiteral { + const discriminatedUnionTypeInstance = this.context.resolveDiscriminatedUnionTypeInstance({ + discriminatedUnion, + value + }); + if (discriminatedUnionTypeInstance == null) { + return ts.TypeLiteral.nop(); + } + const unionVariant = discriminatedUnionTypeInstance.singleDiscriminatedUnionType; + const baseFields = this.getBaseFields({ + discriminatedUnionTypeInstance, + singleDiscriminatedUnionType: unionVariant + }); + switch (unionVariant.type) { + case "samePropertiesAsObject": { + const named = this.context.resolveNamedType({ + typeId: unionVariant.typeId + }); + if (named == null) { + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.object({ + fields: [ + ...baseFields, + { + name: this.context.getPropertyName(unionVariant.discriminantValue.name), + value: this.convertNamed({ named, value: discriminatedUnionTypeInstance.value }) + } + ] + }); + } + case "singleProperty": { + const record = this.context.getRecord(discriminatedUnionTypeInstance.value); + if (record == null) { + return ts.TypeLiteral.nop(); + } + try { + this.context.errors.scope(unionVariant.discriminantValue.wireValue); + return ts.TypeLiteral.object({ + fields: [ + ...baseFields, + { + name: this.context.getPropertyName(unionVariant.discriminantValue.name), + value: this.convert({ + typeReference: unionVariant.typeReference, + value: record[unionVariant.discriminantValue.wireValue] + }) + } + ] + }); + } finally { + this.context.errors.unscope(); + } + } + case "noProperties": + return ts.TypeLiteral.object({ + fields: [...baseFields] + }); + default: + assertNever(unionVariant); + } + } + + private getBaseFields({ + discriminatedUnionTypeInstance, + singleDiscriminatedUnionType + }: { + discriminatedUnionTypeInstance: DiscriminatedUnionTypeInstance; + singleDiscriminatedUnionType: FernIr.dynamic.SingleDiscriminatedUnionType; + }): ts.ObjectField[] { + const discriminantProperty = { + name: this.context.getPropertyName(discriminatedUnionTypeInstance.discriminantValue.name), + value: ts.TypeLiteral.string(singleDiscriminatedUnionType.discriminantValue.wireValue) + }; + const properties = this.context.associateByWireValue({ + parameters: singleDiscriminatedUnionType.properties ?? [], + values: this.context.getRecord(discriminatedUnionTypeInstance.value) ?? {}, + + // We're only selecting the base properties here. The rest of the properties + // are handled by the union variant. + ignoreMissingParameters: true + }); + return [ + discriminantProperty, + ...properties.map((property) => { + this.context.errors.scope(property.name.wireValue); + try { + return { + name: this.context.getPropertyName(property.name.name), + value: this.convert(property) + }; + } finally { + this.context.errors.unscope(); + } + }) + ]; + } + + private convertObject({ object_, value }: { object_: FernIr.dynamic.ObjectType; value: unknown }): ts.TypeLiteral { + const properties = this.context.associateByWireValue({ + parameters: object_.properties, + values: this.context.getRecord(value) ?? {} + }); + return ts.TypeLiteral.object({ + fields: properties.map((property) => { + this.context.errors.scope(property.name.wireValue); + try { + return { + name: this.context.getPropertyName(property.name.name), + value: this.convert(property) + }; + } finally { + this.context.errors.unscope(); + } + }) + }); + } + + private convertEnum({ enum_, value }: { enum_: FernIr.dynamic.EnumType; value: unknown }): ts.TypeLiteral { + const enumValue = this.getEnumValue({ enum_, value }); + if (enumValue == null) { + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.string(enumValue); + } + + private getEnumValue({ enum_, value }: { enum_: FernIr.dynamic.EnumType; value: unknown }): string | undefined { + if (typeof value !== "string") { + this.context.errors.add({ + severity: Severity.Critical, + message: `Expected enum value string, ts.: ${typeof value}` + }); + return undefined; + } + const enumValue = enum_.values.find((v) => v.wireValue === value); + if (enumValue == null) { + this.context.errors.add({ + severity: Severity.Critical, + message: `An enum value named "${value}" does not exist in this context` + }); + return undefined; + } + return value; + } + + private convertUndicriminatedUnion({ + undicriminatedUnion, + value + }: { + undicriminatedUnion: FernIr.dynamic.UndiscriminatedUnionType; + value: unknown; + }): ts.TypeLiteral { + const result = this.findMatchingUndiscriminatedUnionType({ + undicriminatedUnion, + value + }); + if (result == null) { + return ts.TypeLiteral.nop(); + } + return result; + } + + private findMatchingUndiscriminatedUnionType({ + undicriminatedUnion, + value + }: { + undicriminatedUnion: FernIr.dynamic.UndiscriminatedUnionType; + value: unknown; + }): ts.TypeLiteral | undefined { + for (const typeReference of undicriminatedUnion.types) { + try { + return this.convert({ typeReference, value }); + } catch (e) { + continue; + } + } + this.context.errors.add({ + severity: Severity.Critical, + message: `None of the types in the undicriminated union matched the given "${typeof value}" value` + }); + return undefined; + } + + private convertUnknown({ value }: { value: unknown }): ts.TypeLiteral { + return ts.TypeLiteral.unknown(value); + } + + private convertPrimitive({ + primitive, + value + }: { + primitive: FernIr.PrimitiveTypeV1; + value: unknown; + }): ts.TypeLiteral { + switch (primitive) { + case "INTEGER": + case "UINT": { + const num = this.getValueAsNumber({ value }); + if (num == null) { + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.number(num); + } + case "LONG": + case "UINT_64": { + const num = this.getValueAsNumber({ value }); + if (num == null) { + return ts.TypeLiteral.nop(); + } + if (this.context.customConfig?.useBigInt) { + return ts.TypeLiteral.bigint(BigInt(num)); + } + return ts.TypeLiteral.number(num); + } + case "FLOAT": + case "DOUBLE": { + const num = this.getValueAsNumber({ value }); + if (num == null) { + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.number(num); + } + case "BOOLEAN": { + const bool = this.getValueAsBoolean({ value }); + if (bool == null) { + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.boolean(bool); + } + case "BASE_64": + case "DATE": + case "DATE_TIME": + case "UUID": + case "STRING": { + const str = this.getValueAsString({ value }); + if (str == null) { + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.string(str); + } + case "BIG_INTEGER": { + const bigInt = this.getValueAsString({ value }); + if (bigInt == null) { + return ts.TypeLiteral.nop(); + } + return ts.TypeLiteral.bigint(BigInt(bigInt)); + } + default: + assertNever(primitive); + } + } + + private getValueAsNumber({ value }: { value: unknown }): number | undefined { + if (typeof value !== "number") { + this.context.errors.add({ + severity: Severity.Critical, + message: this.newTypeMismatchError({ expected: "number", value }).message + }); + return undefined; + } + return value; + } + + private getValueAsBoolean({ value }: { value: unknown }): boolean | undefined { + if (typeof value !== "boolean") { + this.context.errors.add({ + severity: Severity.Critical, + message: this.newTypeMismatchError({ expected: "boolean", value }).message + }); + return undefined; + } + return value; + } + + private getValueAsString({ value }: { value: unknown }): string | undefined { + if (typeof value !== "string") { + this.context.errors.add({ + severity: Severity.Critical, + message: this.newTypeMismatchError({ expected: "string", value }).message + }); + return undefined; + } + return value; + } + + private newTypeMismatchError({ expected, value }: { expected: string; value: unknown }): Error { + return new Error(`Expected ${expected} but got ${typeof value}`); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a14ec8303a..c8f4f7ed61b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1523,6 +1523,9 @@ importers: '@fern-api/browser-compatible-base-generator': specifier: workspace:* version: link:../../browser-compatible-base + '@fern-api/core-utils': + specifier: workspace:* + version: link:../../../packages/commons/core-utils '@fern-api/dynamic-ir-sdk': specifier: ^53.23.0 version: 53.24.0