diff --git a/CHANGELOG.md b/CHANGELOG.md index 66171abdf..0d2a50176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,32 @@ ### v27.0.0 -- to be announced. +- Breaking change to the `Integration` class: + - Either import and assign the `typescript` property to constructor argument; + - Or use the new static async method `create()` to delegate the import; + +```diff + /** Option 1: import and assign */ + import { Integration } from "express-zod-api"; ++ import typescript from "typescript"; + + const client = new Integration({ + routing, + config, ++ typescript, + }); +``` + +```diff + /** Option 2: delegate asynchronously */ + import { Integration } from "express-zod-api"; + +- const client = new Integration({ ++ const client = await Integration.create({ + routing, + config, + }); +``` ## Version 26 diff --git a/README.md b/README.md index 4a7d04495..d155d64ae 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ Install the framework, its peer dependencies and type assistance packages using ```shell # example for pnpm: -pnpm add express-zod-api express zod typescript http-errors +pnpm add express-zod-api express zod http-errors pnpm add -D @types/express @types/node @types/http-errors ``` @@ -1074,13 +1074,15 @@ adding the runtime helpers the framework relies on. ## Generating a Frontend Client -You can generate a Typescript file containing the IO types of your API and a client for it. -Consider installing `prettier` and using the async `printFormatted()` method. +You can generate a Typescript file containing the IO types of your API and a client for it. Make sure you have +`typescript` installed. Consider also installing `prettier` and using the async `printFormatted()` method. ```ts +import typescript from "typescript"; import { Integration } from "express-zod-api"; const client = new Integration({ + typescript, // or await Integration.create() to delegate importing routing, config, variant: "client", // <— optional, see also "types" for a DIY solution diff --git a/eslint.config.js b/eslint.config.js index 28fa2cddf..66aa6a6fc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -176,6 +176,10 @@ const tsFactoryConcerns = [ selector: "Identifier[name='createUnionTypeNode']", message: "use makeUnion() helper", }, + { + selector: "Identifier[name='createIdentifier']", + message: "use makeId() helper", + }, ]; export default tsPlugin.config( @@ -238,11 +242,7 @@ export default tsPlugin.config( files: ["migration/index.ts"], rules: { "allowed/dependencies": ["error", { packageDir: migrationDir }], - "no-restricted-syntax": [ - "warn", - ...importConcerns, - ...performanceConcerns, - ], + "no-restricted-syntax": ["warn", ...importConcerns], }, }, { diff --git a/example/generate-client.ts b/example/generate-client.ts index dc7c7112d..16f7e3057 100644 --- a/example/generate-client.ts +++ b/example/generate-client.ts @@ -2,10 +2,12 @@ import { writeFile } from "node:fs/promises"; import { Integration } from "express-zod-api"; import { routing } from "./routing"; import { config } from "./config"; +import typescript from "typescript"; await writeFile( "example.client.ts", await new Integration({ + typescript, routing, config, serverUrl: `http://localhost:${config.http!.listen}`, diff --git a/express-zod-api/package.json b/express-zod-api/package.json index f9b559f61..b60930a42 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -78,6 +78,9 @@ }, "express-fileupload": { "optional": true + }, + "typescript": { + "optional": true } }, "devDependencies": { diff --git a/express-zod-api/src/integration-base.ts b/express-zod-api/src/integration-base.ts index 7a96582f7..a7a6495c8 100644 --- a/express-zod-api/src/integration-base.ts +++ b/express-zod-api/src/integration-base.ts @@ -12,7 +12,7 @@ type Store = Record; export abstract class IntegrationBase { /** @internal */ - protected api = new TypescriptAPI(); + protected readonly api: TypescriptAPI; /** @internal */ protected paths = new Set(); /** @internal */ @@ -23,84 +23,90 @@ export abstract class IntegrationBase { { store: Store; isDeprecated: boolean } >(); + protected constructor( + typescript: typeof ts, + protected readonly serverUrl: string, + ) { + this.api = new TypescriptAPI(typescript); + } + readonly #ids = { - pathType: this.api.f.createIdentifier("Path"), - implementationType: this.api.f.createIdentifier("Implementation"), - keyParameter: this.api.f.createIdentifier("key"), - pathParameter: this.api.f.createIdentifier("path"), - paramsArgument: this.api.f.createIdentifier("params"), - ctxArgument: this.api.f.createIdentifier("ctx"), - methodParameter: this.api.f.createIdentifier("method"), - requestParameter: this.api.f.createIdentifier("request"), - eventParameter: this.api.f.createIdentifier("event"), - dataParameter: this.api.f.createIdentifier("data"), - handlerParameter: this.api.f.createIdentifier("handler"), - msgParameter: this.api.f.createIdentifier("msg"), - parseRequestFn: this.api.f.createIdentifier("parseRequest"), - substituteFn: this.api.f.createIdentifier("substitute"), - provideMethod: this.api.f.createIdentifier("provide"), - onMethod: this.api.f.createIdentifier("on"), - implementationArgument: this.api.f.createIdentifier("implementation"), - hasBodyConst: this.api.f.createIdentifier("hasBody"), - undefinedValue: this.api.f.createIdentifier("undefined"), - responseConst: this.api.f.createIdentifier("response"), - restConst: this.api.f.createIdentifier("rest"), - searchParamsConst: this.api.f.createIdentifier("searchParams"), - defaultImplementationConst: this.api.f.createIdentifier( - "defaultImplementation", - ), - clientConst: this.api.f.createIdentifier("client"), - contentTypeConst: this.api.f.createIdentifier("contentType"), - isJsonConst: this.api.f.createIdentifier("isJSON"), - sourceProp: this.api.f.createIdentifier("source"), - } satisfies Record; + pathType: "Path", + implementationType: "Implementation", + keyParameter: "key", + pathParameter: "path", + paramsArgument: "params", + ctxArgument: "ctx", + methodParameter: "method", + requestParameter: "request", + eventParameter: "event", + dataParameter: "data", + handlerParameter: "handler", + msgParameter: "msg", + parseRequestFn: "parseRequest", + substituteFn: "substitute", + provideMethod: "provide", + onMethod: "on", + implementationArgument: "implementation", + hasBodyConst: "hasBody", + undefinedValue: "undefined", + responseConst: "response", + restConst: "rest", + searchParamsConst: "searchParams", + defaultImplementationConst: "defaultImplementation", + clientConst: "client", + contentTypeConst: "contentType", + isJsonConst: "isJSON", + sourceProp: "source", + methodType: "Method", + someOfType: "SomeOf", + requestType: "Request", + } satisfies Record; /** @internal */ - protected interfaces: Record = { - input: this.api.f.createIdentifier("Input"), - positive: this.api.f.createIdentifier("PositiveResponse"), - negative: this.api.f.createIdentifier("NegativeResponse"), - encoded: this.api.f.createIdentifier("EncodedResponse"), - response: this.api.f.createIdentifier("Response"), + protected interfaces: Record = { + input: "Input", + positive: "PositiveResponse", + negative: "NegativeResponse", + encoded: "EncodedResponse", + response: "Response", }; /** * @example export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; * @internal * */ - protected methodType = this.api.makePublicLiteralType( - "Method", - clientMethods, - ); + protected makeMethodType = () => + this.api.makePublicLiteralType(this.#ids.methodType, clientMethods); /** * @example type SomeOf = T[keyof T]; * @internal * */ - protected someOfType = this.api.makeType( - "SomeOf", - this.api.makeIndexed("T", this.api.makeKeyOf("T")), - { params: ["T"] }, - ); + protected makeSomeOfType = () => + this.api.makeType( + this.#ids.someOfType, + this.api.makeIndexed("T", this.api.makeKeyOf("T")), + { params: ["T"] }, + ); /** * @example export type Request = keyof Input; * @internal * */ - protected requestType = this.api.makeType( - "Request", - this.api.makeKeyOf(this.interfaces.input), - { expose: true }, - ); - - protected constructor(private readonly serverUrl: string) {} + protected makeRequestType = () => + this.api.makeType( + this.#ids.requestType, + this.api.makeKeyOf(this.interfaces.input), + { expose: true }, + ); /** * @example SomeOf<_> * @internal **/ protected someOf = ({ name }: ts.TypeAliasDeclaration) => - this.api.ensureTypeNode(this.someOfType.name, [name]); + this.api.ensureTypeNode(this.#ids.someOfType, [name]); /** * @example export type Path = "/v1/user/retrieve" | ___; @@ -136,7 +142,7 @@ export abstract class IntegrationBase { this.api.f.createPropertyAssignment( this.api.makePropertyIdentifier(request), this.api.f.createArrayLiteralExpression( - R.map(this.api.literally.bind(this.api), tags), + R.map(this.api.literally, tags), ), ), ), @@ -153,10 +159,10 @@ export abstract class IntegrationBase { this.#ids.implementationType, this.api.makeFnType( { - [this.#ids.methodParameter.text]: this.methodType.name, - [this.#ids.pathParameter.text]: this.api.ts.SyntaxKind.StringKeyword, - [this.#ids.paramsArgument.text]: this.api.makeRecordStringAny(), - [this.#ids.ctxArgument.text]: { optional: true, type: "T" }, + [this.#ids.methodParameter]: this.#ids.methodType, + [this.#ids.pathParameter]: this.api.ts.SyntaxKind.StringKeyword, + [this.#ids.paramsArgument]: this.api.makeRecordStringAny(), + [this.#ids.ctxArgument]: { optional: true, type: "T" }, }, this.api.makePromise(this.api.ts.SyntaxKind.AnyKeyword), ), @@ -174,10 +180,7 @@ export abstract class IntegrationBase { this.api.makeConst( this.#ids.parseRequestFn, this.api.makeArrowFn( - { - [this.#ids.requestParameter.text]: - this.api.ts.SyntaxKind.StringKeyword, - }, + { [this.#ids.requestParameter]: this.api.ts.SyntaxKind.StringKeyword }, this.api.f.createAsExpression( this.api.makeCall( this.#ids.requestParameter, @@ -187,7 +190,7 @@ export abstract class IntegrationBase { this.api.literally(2), // excludes third empty element ), this.api.f.createTupleTypeNode([ - this.api.ensureTypeNode(this.methodType.name), + this.api.ensureTypeNode(this.#ids.methodType), this.api.ensureTypeNode(this.#ids.pathType), ]), ), @@ -203,14 +206,16 @@ export abstract class IntegrationBase { this.#ids.substituteFn, this.api.makeArrowFn( { - [this.#ids.pathParameter.text]: this.api.ts.SyntaxKind.StringKeyword, - [this.#ids.paramsArgument.text]: this.api.makeRecordStringAny(), + [this.#ids.pathParameter]: this.api.ts.SyntaxKind.StringKeyword, + [this.#ids.paramsArgument]: this.api.makeRecordStringAny(), }, this.api.f.createBlock([ this.api.makeConst( this.#ids.restConst, this.api.f.createObjectLiteralExpression([ - this.api.f.createSpreadAssignment(this.#ids.paramsArgument), + this.api.f.createSpreadAssignment( + this.api.makeId(this.#ids.paramsArgument), + ), ]), ), this.api.f.createForInStatement( @@ -218,7 +223,7 @@ export abstract class IntegrationBase { [this.api.f.createVariableDeclaration(this.#ids.keyParameter)], this.api.ts.NodeFlags.Const, ), - this.#ids.paramsArgument, + this.api.makeId(this.#ids.paramsArgument), this.api.f.createBlock([ this.api.makeAssignment( this.#ids.pathParameter, @@ -233,15 +238,15 @@ export abstract class IntegrationBase { this.api.f.createExpressionStatement( this.api.f.createDeleteExpression( this.api.f.createElementAccessExpression( - this.#ids.restConst, - this.#ids.keyParameter, + this.api.makeId(this.#ids.restConst), + this.api.makeId(this.#ids.keyParameter), ), ), ), this.api.f.createReturnStatement( this.api.f.createElementAccessExpression( - this.#ids.paramsArgument, - this.#ids.keyParameter, + this.api.makeId(this.#ids.paramsArgument), + this.api.makeId(this.#ids.keyParameter), ), ), ]), @@ -253,8 +258,8 @@ export abstract class IntegrationBase { this.api.f.createReturnStatement( this.api.f.createAsExpression( this.api.f.createArrayLiteralExpression([ - this.#ids.pathParameter, - this.#ids.restConst, + this.api.makeId(this.#ids.pathParameter), + this.api.makeId(this.#ids.restConst), ]), this.api.ensureTypeNode("const"), ), @@ -268,12 +273,12 @@ export abstract class IntegrationBase { this.api.makePublicMethod( this.#ids.provideMethod, this.api.makeParams({ - [this.#ids.requestParameter.text]: "K", - [this.#ids.paramsArgument.text]: this.api.makeIndexed( + [this.#ids.requestParameter]: "K", + [this.#ids.paramsArgument]: this.api.makeIndexed( this.interfaces.input, "K", ), - [this.#ids.ctxArgument.text]: { optional: true, type: "T" }, + [this.#ids.ctxArgument]: { optional: true, type: "T" }, }), [ this.api.makeConst( @@ -304,7 +309,7 @@ export abstract class IntegrationBase { ), ], { - typeParams: { K: this.requestType.name }, + typeParams: { K: this.#ids.requestType }, returns: this.api.makePromise( this.api.makeIndexed(this.interfaces.response, "K"), ), @@ -324,7 +329,7 @@ export abstract class IntegrationBase { this.api.makeParam(this.#ids.implementationArgument, { type: this.api.ensureTypeNode(this.#ids.implementationType, ["T"]), mod: this.api.accessModifiers.protectedReadonly, - init: this.#ids.defaultImplementationConst, + initId: this.#ids.defaultImplementationConst, }), ]), this.#makeProvider(), @@ -333,8 +338,10 @@ export abstract class IntegrationBase { ); // `?${new URLSearchParams(____)}` - #makeSearchParams = (from: ts.Expression) => - this.api.makeTemplate("?", [this.api.makeNew(URLSearchParams.name, from)]); + #makeSearchParams = (fromId: string) => + this.api.makeTemplate("?", [ + this.api.makeNew(URLSearchParams.name, this.api.makeId(fromId)), + ]); // new URL(`${path}${searchParams}`, "http:____") #makeFetchURL = () => @@ -444,7 +451,7 @@ export abstract class IntegrationBase { const noBodyStatement = this.api.f.createIfStatement( this.api.f.createPrefixUnaryExpression( this.api.ts.SyntaxKind.ExclamationToken, - this.#ids.contentTypeConst, + this.api.makeId(this.#ids.contentTypeConst), ), this.api.f.createReturnStatement(), ); @@ -538,10 +545,10 @@ export abstract class IntegrationBase { this.api.makePublicMethod( this.#ids.onMethod, this.api.makeParams({ - [this.#ids.eventParameter.text]: "E", - [this.#ids.handlerParameter.text]: this.api.makeFnType( + [this.#ids.eventParameter]: "E", + [this.#ids.handlerParameter]: this.api.makeFnType( { - [this.#ids.dataParameter.text]: this.api.makeIndexed( + [this.#ids.dataParameter]: this.api.makeIndexed( this.api.makeExtract( "R", this.api.makeOneLine(this.#makeEventNarrow("E")), @@ -570,7 +577,7 @@ export abstract class IntegrationBase { this.api.f.createPropertyAccessExpression( this.api.f.createParenthesizedExpression( this.api.f.createAsExpression( - this.#ids.msgParameter, + this.api.makeId(this.#ids.msgParameter), this.api.ensureTypeNode(MessageEvent.name), ), ), @@ -608,7 +615,7 @@ export abstract class IntegrationBase { { typeParams: { K: this.api.makeExtract( - this.requestType.name, + this.#ids.requestType, this.api.f.createTemplateLiteralType( this.api.f.createTemplateHead("get "), [ diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index d184738b6..0e6079661 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -15,6 +15,7 @@ import { ClientMethod } from "./method"; import type { CommonConfig } from "./config-type"; interface IntegrationParams { + typescript: typeof ts; routing: Routing; config: CommonConfig; /** @@ -63,7 +64,7 @@ interface FormattedPrintingOptions { } export class Integration extends IntegrationBase { - readonly #program: ts.Node[] = [this.someOfType]; + readonly #program: ts.Node[] = [this.makeSomeOfType()]; readonly #aliases = new Map(); #usage: Array = []; @@ -79,6 +80,7 @@ export class Integration extends IntegrationBase { } public constructor({ + typescript, routing, config, brandHandling, @@ -89,7 +91,7 @@ export class Integration extends IntegrationBase { noContent = z.undefined(), hasHeadMethod = true, }: IntegrationParams) { - super(serverUrl); + super(typescript, serverUrl); const commons = { makeAlias: this.#makeAlias.bind(this), api: this.api }; const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } }; const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; @@ -154,9 +156,9 @@ export class Integration extends IntegrationBase { this.#program.unshift(...this.#aliases.values()); this.#program.push( this.makePathType(), - this.methodType, + this.makeMethodType(), ...this.makePublicInterfaces(), - this.requestType, + this.makeRequestType(), ); if (variant === "types") return; @@ -176,6 +178,13 @@ export class Integration extends IntegrationBase { ); } + public static async create(params: Omit) { + return new Integration({ + ...params, + typescript: await loadPeer("typescript"), + }); + } + #printUsage(printerOptions?: ts.PrinterOptions) { return this.#usage.length ? this.#usage diff --git a/express-zod-api/src/typescript-api.ts b/express-zod-api/src/typescript-api.ts index a19658902..c595e7818 100644 --- a/express-zod-api/src/typescript-api.ts +++ b/express-zod-api/src/typescript-api.ts @@ -1,4 +1,3 @@ -import { createRequire } from "node:module"; import * as R from "ramda"; import type ts from "typescript"; @@ -21,8 +20,8 @@ export class TypescriptAPI { #primitives: ts.KeywordTypeSyntaxKind[]; static #safePropRegex = /^[A-Za-z_$][A-Za-z0-9_$]*$/; - constructor() { - this.ts = createRequire(import.meta.url)("typescript"); // @todo replace with a dynamic import in next major + constructor(typescript: typeof ts) { + this.ts = typescript; this.f = this.ts.factory; this.exportModifier = [ this.f.createModifier(this.ts.SyntaxKind.ExportKeyword), @@ -72,20 +71,22 @@ export class TypescriptAPI { return printer.printNode(this.ts.EmitHint.Unspecified, node, sourceFile); }; + public makeId = (name: string) => this.f.createIdentifier(name); + public makePropertyIdentifier = (name: string | number) => typeof name === "string" && TypescriptAPI.#safePropRegex.test(name) - ? this.f.createIdentifier(name) + ? this.makeId(name) : this.literally(name); public makeTemplate = ( head: string, - ...rest: ([ts.Expression] | [ts.Expression, string])[] + ...rest: [ts.Expression | string, string?][] ) => this.f.createTemplateExpression( this.f.createTemplateHead(head), rest.map(([id, str = ""], idx) => this.f.createTemplateSpan( - id, + typeof id === "string" ? this.makeId(id) : id, idx === rest.length - 1 ? this.f.createTemplateTail(str) : this.f.createTemplateMiddle(str), @@ -98,12 +99,12 @@ export class TypescriptAPI { { type, mod, - init, + initId, optional, }: { type?: Typeable; mod?: ts.Modifier[]; - init?: ts.Expression; + initId?: string; optional?: boolean; } = {}, ) => @@ -115,7 +116,7 @@ export class TypescriptAPI { ? this.f.createToken(this.ts.SyntaxKind.QuestionToken) : undefined, type ? this.ensureTypeNode(type) : undefined, - init, + initId ? this.makeId(initId) : undefined, ); public makeParams = ( @@ -153,7 +154,7 @@ export class TypescriptAPI { : typeof subject === "string" || this.ts.isIdentifier(subject) ? this.f.createTypeReferenceNode( subject, - args && R.map(this.ensureTypeNode.bind(this), args), + args && R.map(this.ensureTypeNode, args), ) : subject; @@ -214,9 +215,7 @@ export class TypescriptAPI { public makeOneLine = (subject: ts.TypeNode) => this.ts.setEmitFlags(subject, this.ts.EmitFlags.SingleLine); - public makeDeconstruction = ( - ...names: ts.Identifier[] - ): ts.ArrayBindingPattern => + public makeDeconstruction = (...names: string[]): ts.ArrayBindingPattern => this.f.createArrayBindingPattern( names.map( (name) => this.f.createBindingElement(undefined, undefined, name), // can also add default value at last @@ -247,11 +246,9 @@ export class TypescriptAPI { name: ts.Identifier | string, literals: string[], ) => - this.makeType( - name, - this.makeUnion(R.map(this.makeLiteralType.bind(this), literals)), - { expose: true }, - ); + this.makeType(name, this.makeUnion(R.map(this.makeLiteralType, literals)), { + expose: true, + }); public makeType = ( name: ts.Identifier | string, @@ -284,7 +281,7 @@ export class TypescriptAPI { ); public makePublicMethod = ( - name: ts.Identifier, + name: string, params: ts.ParameterDeclaration[], statements: ts.Statement[], { @@ -372,7 +369,7 @@ export class TypescriptAPI { isAsync ? this.asyncModifier : undefined, undefined, Array.isArray(params) - ? R.map(this.makeParam.bind(this), params) + ? R.map(this.makeParam, params) : this.makeParams(params), undefined, undefined, @@ -380,46 +377,55 @@ export class TypescriptAPI { ); public makeTernary = ( - condition: ts.Expression, - positive: ts.Expression, - negative: ts.Expression, - ) => - this.f.createConditionalExpression( + ...args: [ + ts.Expression | string, + ts.Expression | string, + ts.Expression | string, + ] + ) => { + const [condition, positive, negative] = args.map((arg) => + typeof arg === "string" ? this.makeId(arg) : arg, + ); + return this.f.createConditionalExpression( condition, this.f.createToken(this.ts.SyntaxKind.QuestionToken), positive, this.f.createToken(this.ts.SyntaxKind.ColonToken), negative, ); + }; public makeCall = ( first: ts.Expression | string, ...rest: Array ) => - (...args: ts.Expression[]) => + (...args: Array) => this.f.createCallExpression( rest.reduce( (acc, entry) => typeof entry === "string" || this.ts.isIdentifier(entry) ? this.f.createPropertyAccessExpression(acc, entry) : this.f.createElementAccessExpression(acc, entry), - typeof first === "string" ? this.f.createIdentifier(first) : first, + typeof first === "string" ? this.makeId(first) : first, ), undefined, - args, + args.map((arg) => (typeof arg === "string" ? this.makeId(arg) : arg)), ); public makeNew = (cls: string, ...args: ts.Expression[]) => - this.f.createNewExpression(this.f.createIdentifier(cls), undefined, args); + this.f.createNewExpression(this.makeId(cls), undefined, args); public makeExtract = (base: Typeable, narrow: ts.TypeNode) => this.ensureTypeNode("Extract", [base, narrow]); - public makeAssignment = (left: ts.Expression, right: ts.Expression) => + public makeAssignment = ( + left: ts.Expression | string, + right: ts.Expression, + ) => this.f.createExpressionStatement( this.f.createBinaryExpression( - left, + typeof left === "string" ? this.makeId(left) : left, this.f.createToken(this.ts.SyntaxKind.EqualsToken), right, ), diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index dc47637ae..cb0444ae5 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -97,7 +97,7 @@ const onArray: Producer = ( ) => api.f.createArrayTypeNode(next(def.element)); const onEnum: Producer = ({ _zod: { def } }: z.core.$ZodEnum, { api }) => - api.makeUnion(Object.values(def.entries).map(api.makeLiteralType.bind(api))); + api.makeUnion(R.map(api.makeLiteralType, Object.values(def.entries))); const onSomeUnion: Producer = ( { _zod: { def } }: z.core.$ZodUnion | z.core.$ZodDiscriminatedUnion, diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index b9c21b35c..76653c35a 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -27,6 +27,7 @@ describe("Integration", () => { "Should support types variant and handle recursive schemas %#", (recursiveSchema) => { const client = new Integration({ + typescript: ts, variant: "types", config: configMock, routing: { @@ -49,7 +50,7 @@ describe("Integration", () => { ); test("Should treat optionals the same way as z.infer() by default", async () => { - const client = new Integration({ + const client = await Integration.create({ config: configMock, routing: { v1: { @@ -72,7 +73,7 @@ describe("Integration", () => { test.each([undefined, false])( "Should support HEAD method by default %#", async (hasHeadMethod) => { - const client = new Integration({ + const client = await Integration.create({ config: configMock, hasHeadMethod, variant: "types", @@ -109,7 +110,7 @@ describe("Integration", () => { handler: vi.fn(), }), ); - const client = new Integration({ + const client = await Integration.create({ config: configMock, variant: "types", routing: { @@ -135,7 +136,7 @@ describe("Integration", () => { schema._zod.bag.brand = undefined; return next(schema); }; - const client = new Integration({ + const client = await Integration.create({ config: configMock, variant: "types", brandHandling: { diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 21ed739f2..e8ee7c0c9 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -6,7 +6,7 @@ import { zodToTs } from "../src/zts"; import { ZTSContext } from "../src/zts-helpers"; describe("zod-to-ts", () => { - const api = new TypescriptAPI(); + const api = new TypescriptAPI(ts); const printNodeTest = (node: ts.Node) => api.printNode(node, { newLine: ts.NewLineKind.LineFeed }); const ctx: ZTSContext = { diff --git a/migration/index.spec.ts b/migration/index.spec.ts index f966a3dc7..957db1e6c 100644 --- a/migration/index.spec.ts +++ b/migration/index.spec.ts @@ -22,7 +22,37 @@ describe("Migration", async () => { }); tester.run(ruleName, theRule, { - valid: [`const routing = {};`], - invalid: [], + valid: [`new Integration({ typescript, config, routing });`], + invalid: [ + { + name: "should import typescript and add it as a property to constructor argument", + code: `new Integration({ config, routing });`, + output: `import typescript from "typescript";\n\nnew Integration({ typescript, config, routing });`, + errors: [ + { + messageId: "add", + data: { + subject: "typescript property", + to: "constructor argument", + }, + }, + ], + }, + { + name: "should use static create() method in async context", + code: `await new Integration({ config, routing }).printFormatted();`, + output: `await (await Integration.create({ config, routing })).printFormatted();`, + errors: [ + { + messageId: "change", + data: { + subject: "constructor", + from: "new Integration()", + to: "await Integration.create()", + }, + }, + ], + }, + ], }); }); diff --git a/migration/index.ts b/migration/index.ts index 980e57697..e00e7e129 100644 --- a/migration/index.ts +++ b/migration/index.ts @@ -1,24 +1,24 @@ import { ESLintUtils, - // AST_NODE_TYPES as NT, + AST_NODE_TYPES as NT, type TSESLint, - // type TSESTree, + type TSESTree, } from "@typescript-eslint/utils"; // eslint-disable-line allowed/dependencies -- assumed transitive dependency -/* type NamedProp = TSESTree.PropertyNonComputedName & { key: TSESTree.Identifier | TSESTree.StringLiteral; }; - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- temporary -interface Queries {} +interface Queries { + integration: TSESTree.ObjectExpression; +} type Listener = keyof Queries; -const queries: Record = {}; +const queries: Record = { + integration: `${NT.NewExpression}[callee.name="Integration"] > ${NT.ObjectExpression}`, +}; -/* const isNamedProp = (prop: TSESTree.ObjectLiteralElement): prop is NamedProp => prop.type === NT.Property && !prop.computed && @@ -27,7 +27,6 @@ const isNamedProp = (prop: TSESTree.ObjectLiteralElement): prop is NamedProp => const getPropName = (prop: NamedProp): string => prop.key.type === NT.Identifier ? prop.key.name : prop.key.value; -*/ const listen = < S extends { [K in Listener]: TSESLint.RuleFunction }, @@ -42,7 +41,6 @@ const listen = < {}, ); -// eslint-disable-next-line no-restricted-syntax -- substituted by TSDOWN and vitest const ruleName = `v${process.env.TSDOWN_VERSION?.split(".")[0] ?? "0"}`; // fail-safe for bumpp const theRule = ESLintUtils.RuleCreator.withoutDocs({ @@ -58,7 +56,56 @@ const theRule = ESLintUtils.RuleCreator.withoutDocs({ }, }, defaultOptions: [], - create: () => listen({}), + create: (ctx) => + listen({ + integration: (node) => { + const tsProp = node.properties + .filter(isNamedProp) + .find((one) => getPropName(one) === "typescript"); + if (tsProp) return; + const hasAsyncCtx = ctx.sourceCode + .getAncestors(node) + .some( + (one) => + one.type === NT.AwaitExpression || + ((one.type === NT.ArrowFunctionExpression || + one.type === NT.FunctionExpression) && + one.async), + ); + ctx.report( + hasAsyncCtx + ? { + node: node.parent, + messageId: "change", + data: { + subject: "constructor", + from: "new Integration()", + to: "await Integration.create()", + }, + fix: (fixer) => + fixer.replaceText( + node.parent, + `(await Integration.create(${ctx.sourceCode.getText(node)}))`, + ), + } + : { + node: node, + messageId: "add", + data: { + subject: "typescript property", + to: "constructor argument", + }, + fix: (fixer) => [ + fixer.insertTextBeforeRange( + ctx.sourceCode.ast.range, + `import typescript from "typescript";\n\n`, + ), + fixer.insertTextBefore(node.properties[0], "typescript, "), + ], + }, + ); + }, + }), }); export default {