diff --git a/CHANGELOG.md b/CHANGELOG.md index c77ed0c243..6ed8c670e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Version 26 +### v26.1.0 + +- Optimization to the memory consumption for your API: + - It has been discovered that static import of `typescript` within the framework consumes memory unnecessarily; + - Importing `typescript` is only necessary to generate an Integration; + - This version avoids static importing, but the solution is temporary in order to avoid breaking changes; + - The issue was found, investigated and reported by [@NicolasMahe](https://github.com/NicolasMahe). + ### v26.0.0 - Supported `http-errors` versions: `^2.0.1`; diff --git a/README.md b/README.md index fb19fd754f..4a7d044953 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Therefore, many basic tasks can be accomplished faster and easier, in particular These people contributed to the improvement of the framework by reporting bugs, making changes and suggesting ideas: +[@NicolasMahe](https://github.com/NicolasMahe) [@shadone](https://github.com/shadone) [@squishykid](https://github.com/squishykid) [@jakub-msqt](https://github.com/jakub-msqt) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 2e9da92e40..bf9eee0303 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "26.0.0", + "version": "26.1.0-beta.2", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { diff --git a/express-zod-api/src/integration-base.ts b/express-zod-api/src/integration-base.ts index c5ae09c388..7a96582f77 100644 --- a/express-zod-api/src/integration-base.ts +++ b/express-zod-api/src/integration-base.ts @@ -1,51 +1,18 @@ import * as R from "ramda"; -import ts from "typescript"; +import type ts from "typescript"; import { ResponseVariant } from "./api-response"; import { contentTypes } from "./content-type"; import { ClientMethod, clientMethods } from "./method"; import type { makeEventSchema } from "./sse"; -import { - accessModifiers, - ensureTypeNode, - f, - makeArrowFn, - makeConst, - makeDeconstruction, - makeExtract, - makeInterface, - makeInterfaceProp, - makeKeyOf, - makeNew, - makeOneLine, - makeParam, - makeParams, - makePromise, - makeCall, - makePropertyIdentifier, - makePublicConstructor, - makePublicClass, - makePublicLiteralType, - makePublicMethod, - makeTemplate, - makeTernary, - makeType, - propOf, - recordStringAny, - makeAssignment, - makePublicProperty, - makeIndexed, - makeMaybeAsync, - Typeable, - makeFnType, - makeLiteralType, - literally, -} from "./typescript-api"; +import { propOf, Typeable, TypescriptAPI } from "./typescript-api"; type IOKind = "input" | "response" | ResponseVariant | "encoded"; type SSEShape = ReturnType["shape"]; type Store = Record; export abstract class IntegrationBase { + /** @internal */ + protected api = new TypescriptAPI(); /** @internal */ protected paths = new Set(); /** @internal */ @@ -57,65 +24,72 @@ export abstract class IntegrationBase { >(); readonly #ids = { - pathType: f.createIdentifier("Path"), - implementationType: f.createIdentifier("Implementation"), - keyParameter: f.createIdentifier("key"), - pathParameter: f.createIdentifier("path"), - paramsArgument: f.createIdentifier("params"), - ctxArgument: f.createIdentifier("ctx"), - methodParameter: f.createIdentifier("method"), - requestParameter: f.createIdentifier("request"), - eventParameter: f.createIdentifier("event"), - dataParameter: f.createIdentifier("data"), - handlerParameter: f.createIdentifier("handler"), - msgParameter: f.createIdentifier("msg"), - parseRequestFn: f.createIdentifier("parseRequest"), - substituteFn: f.createIdentifier("substitute"), - provideMethod: f.createIdentifier("provide"), - onMethod: f.createIdentifier("on"), - implementationArgument: f.createIdentifier("implementation"), - hasBodyConst: f.createIdentifier("hasBody"), - undefinedValue: f.createIdentifier("undefined"), - responseConst: f.createIdentifier("response"), - restConst: f.createIdentifier("rest"), - searchParamsConst: f.createIdentifier("searchParams"), - defaultImplementationConst: f.createIdentifier("defaultImplementation"), - clientConst: f.createIdentifier("client"), - contentTypeConst: f.createIdentifier("contentType"), - isJsonConst: f.createIdentifier("isJSON"), - sourceProp: f.createIdentifier("source"), + 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; /** @internal */ protected interfaces: Record = { - input: f.createIdentifier("Input"), - positive: f.createIdentifier("PositiveResponse"), - negative: f.createIdentifier("NegativeResponse"), - encoded: f.createIdentifier("EncodedResponse"), - response: f.createIdentifier("Response"), + 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"), }; /** * @example export type Method = "get" | "post" | "put" | "delete" | "patch" | "head"; * @internal * */ - protected methodType = makePublicLiteralType("Method", clientMethods); + protected methodType = this.api.makePublicLiteralType( + "Method", + clientMethods, + ); /** * @example type SomeOf = T[keyof T]; * @internal * */ - protected someOfType = makeType("SomeOf", makeIndexed("T", makeKeyOf("T")), { - params: ["T"], - }); + protected someOfType = this.api.makeType( + "SomeOf", + this.api.makeIndexed("T", this.api.makeKeyOf("T")), + { params: ["T"] }, + ); /** * @example export type Request = keyof Input; * @internal * */ - protected requestType = makeType( + protected requestType = this.api.makeType( "Request", - makeKeyOf(this.interfaces.input), + this.api.makeKeyOf(this.interfaces.input), { expose: true }, ); @@ -126,14 +100,14 @@ export abstract class IntegrationBase { * @internal **/ protected someOf = ({ name }: ts.TypeAliasDeclaration) => - ensureTypeNode(this.someOfType.name, [name]); + this.api.ensureTypeNode(this.someOfType.name, [name]); /** * @example export type Path = "/v1/user/retrieve" | ___; * @internal * */ protected makePathType = () => - makePublicLiteralType(this.#ids.pathType, Array.from(this.paths)); + this.api.makePublicLiteralType(this.#ids.pathType, Array.from(this.paths)); /** * @example export interface Input { "get /v1/user/retrieve": GetV1UserRetrieveInput; } @@ -141,10 +115,10 @@ export abstract class IntegrationBase { * */ protected makePublicInterfaces = () => (Object.keys(this.interfaces) as IOKind[]).map((kind) => - makeInterface( + this.api.makeInterface( this.interfaces[kind], Array.from(this.registry).map(([request, { store, isDeprecated }]) => - makeInterfaceProp(request, store[kind], { isDeprecated }), + this.api.makeInterfaceProp(request, store[kind], { isDeprecated }), ), { expose: true }, ), @@ -155,13 +129,15 @@ export abstract class IntegrationBase { * @internal * */ protected makeEndpointTags = () => - makeConst( + this.api.makeConst( "endpointTags", - f.createObjectLiteralExpression( + this.api.f.createObjectLiteralExpression( Array.from(this.tags).map(([request, tags]) => - f.createPropertyAssignment( - makePropertyIdentifier(request), - f.createArrayLiteralExpression(R.map(literally, tags)), + this.api.f.createPropertyAssignment( + this.api.makePropertyIdentifier(request), + this.api.f.createArrayLiteralExpression( + R.map(this.api.literally.bind(this.api), tags), + ), ), ), ), @@ -173,20 +149,20 @@ export abstract class IntegrationBase { * @internal * */ protected makeImplementationType = () => - makeType( + this.api.makeType( this.#ids.implementationType, - makeFnType( + this.api.makeFnType( { [this.#ids.methodParameter.text]: this.methodType.name, - [this.#ids.pathParameter.text]: ts.SyntaxKind.StringKeyword, - [this.#ids.paramsArgument.text]: recordStringAny, + [this.#ids.pathParameter.text]: this.api.ts.SyntaxKind.StringKeyword, + [this.#ids.paramsArgument.text]: this.api.makeRecordStringAny(), [this.#ids.ctxArgument.text]: { optional: true, type: "T" }, }, - makePromise(ts.SyntaxKind.AnyKeyword), + this.api.makePromise(this.api.ts.SyntaxKind.AnyKeyword), ), { expose: true, - params: { T: { init: ts.SyntaxKind.UnknownKeyword } }, + params: { T: { init: this.api.ts.SyntaxKind.UnknownKeyword } }, }, ); @@ -195,18 +171,24 @@ export abstract class IntegrationBase { * @internal * */ protected makeParseRequestFn = () => - makeConst( + this.api.makeConst( this.#ids.parseRequestFn, - makeArrowFn( - { [this.#ids.requestParameter.text]: ts.SyntaxKind.StringKeyword }, - f.createAsExpression( - makeCall(this.#ids.requestParameter, propOf("split"))( - f.createRegularExpressionLiteral("/ (.+)/"), // split once - literally(2), // excludes third empty element + this.api.makeArrowFn( + { + [this.#ids.requestParameter.text]: + this.api.ts.SyntaxKind.StringKeyword, + }, + this.api.f.createAsExpression( + this.api.makeCall( + this.#ids.requestParameter, + propOf("split"), + )( + this.api.f.createRegularExpressionLiteral("/ (.+)/"), // split once + this.api.literally(2), // excludes third empty element ), - f.createTupleTypeNode([ - ensureTypeNode(this.methodType.name), - ensureTypeNode(this.#ids.pathType), + this.api.f.createTupleTypeNode([ + this.api.ensureTypeNode(this.methodType.name), + this.api.ensureTypeNode(this.#ids.pathType), ]), ), ), @@ -217,44 +199,47 @@ export abstract class IntegrationBase { * @internal * */ protected makeSubstituteFn = () => - makeConst( + this.api.makeConst( this.#ids.substituteFn, - makeArrowFn( + this.api.makeArrowFn( { - [this.#ids.pathParameter.text]: ts.SyntaxKind.StringKeyword, - [this.#ids.paramsArgument.text]: recordStringAny, + [this.#ids.pathParameter.text]: this.api.ts.SyntaxKind.StringKeyword, + [this.#ids.paramsArgument.text]: this.api.makeRecordStringAny(), }, - f.createBlock([ - makeConst( + this.api.f.createBlock([ + this.api.makeConst( this.#ids.restConst, - f.createObjectLiteralExpression([ - f.createSpreadAssignment(this.#ids.paramsArgument), + this.api.f.createObjectLiteralExpression([ + this.api.f.createSpreadAssignment(this.#ids.paramsArgument), ]), ), - f.createForInStatement( - f.createVariableDeclarationList( - [f.createVariableDeclaration(this.#ids.keyParameter)], - ts.NodeFlags.Const, + this.api.f.createForInStatement( + this.api.f.createVariableDeclarationList( + [this.api.f.createVariableDeclaration(this.#ids.keyParameter)], + this.api.ts.NodeFlags.Const, ), this.#ids.paramsArgument, - f.createBlock([ - makeAssignment( + this.api.f.createBlock([ + this.api.makeAssignment( this.#ids.pathParameter, - makeCall(this.#ids.pathParameter, propOf("replace"))( - makeTemplate(":", [this.#ids.keyParameter]), // `:${key}` - makeArrowFn( + this.api.makeCall( + this.#ids.pathParameter, + propOf("replace"), + )( + this.api.makeTemplate(":", [this.#ids.keyParameter]), // `:${key}` + this.api.makeArrowFn( [], - f.createBlock([ - f.createExpressionStatement( - f.createDeleteExpression( - f.createElementAccessExpression( + this.api.f.createBlock([ + this.api.f.createExpressionStatement( + this.api.f.createDeleteExpression( + this.api.f.createElementAccessExpression( this.#ids.restConst, this.#ids.keyParameter, ), ), ), - f.createReturnStatement( - f.createElementAccessExpression( + this.api.f.createReturnStatement( + this.api.f.createElementAccessExpression( this.#ids.paramsArgument, this.#ids.keyParameter, ), @@ -265,13 +250,13 @@ export abstract class IntegrationBase { ), ]), ), - f.createReturnStatement( - f.createAsExpression( - f.createArrayLiteralExpression([ + this.api.f.createReturnStatement( + this.api.f.createAsExpression( + this.api.f.createArrayLiteralExpression([ this.#ids.pathParameter, this.#ids.restConst, ]), - ensureTypeNode("const"), + this.api.ensureTypeNode("const"), ), ), ]), @@ -280,31 +265,36 @@ export abstract class IntegrationBase { // public provide(request: K, params: Input[K]): Promise {} #makeProvider = () => - makePublicMethod( + this.api.makePublicMethod( this.#ids.provideMethod, - makeParams({ + this.api.makeParams({ [this.#ids.requestParameter.text]: "K", - [this.#ids.paramsArgument.text]: makeIndexed( + [this.#ids.paramsArgument.text]: this.api.makeIndexed( this.interfaces.input, "K", ), [this.#ids.ctxArgument.text]: { optional: true, type: "T" }, }), [ - makeConst( + this.api.makeConst( // const [method, path] = this.parseRequest(request); - makeDeconstruction( + this.api.makeDeconstruction( this.#ids.methodParameter, this.#ids.pathParameter, ), - makeCall(this.#ids.parseRequestFn)(this.#ids.requestParameter), + this.api.makeCall(this.#ids.parseRequestFn)( + this.#ids.requestParameter, + ), ), // return this.implementation(___) - f.createReturnStatement( - makeCall(f.createThis(), this.#ids.implementationArgument)( + this.api.f.createReturnStatement( + this.api.makeCall( + this.api.f.createThis(), + this.#ids.implementationArgument, + )( this.#ids.methodParameter, - f.createSpreadElement( - makeCall(this.#ids.substituteFn)( + this.api.f.createSpreadElement( + this.api.makeCall(this.#ids.substituteFn)( this.#ids.pathParameter, this.#ids.paramsArgument, ), @@ -315,7 +305,9 @@ export abstract class IntegrationBase { ], { typeParams: { K: this.requestType.name }, - returns: makePromise(makeIndexed(this.interfaces.response, "K")), + returns: this.api.makePromise( + this.api.makeIndexed(this.interfaces.response, "K"), + ), }, ); @@ -324,14 +316,14 @@ export abstract class IntegrationBase { * @internal * */ protected makeClientClass = (name: string) => - makePublicClass( + this.api.makePublicClass( name, [ // public constructor(protected readonly implementation: Implementation = defaultImplementation) {} - makePublicConstructor([ - makeParam(this.#ids.implementationArgument, { - type: ensureTypeNode(this.#ids.implementationType, ["T"]), - mod: accessModifiers.protectedReadonly, + this.api.makePublicConstructor([ + this.api.makeParam(this.#ids.implementationArgument, { + type: this.api.ensureTypeNode(this.#ids.implementationType, ["T"]), + mod: this.api.accessModifiers.protectedReadonly, init: this.#ids.defaultImplementationConst, }), ]), @@ -342,18 +334,18 @@ export abstract class IntegrationBase { // `?${new URLSearchParams(____)}` #makeSearchParams = (from: ts.Expression) => - makeTemplate("?", [makeNew(URLSearchParams.name, from)]); + this.api.makeTemplate("?", [this.api.makeNew(URLSearchParams.name, from)]); // new URL(`${path}${searchParams}`, "http:____") #makeFetchURL = () => - makeNew( + this.api.makeNew( URL.name, - makeTemplate( + this.api.makeTemplate( "", [this.#ids.pathParameter], [this.#ids.searchParamsConst], ), - literally(this.serverUrl), + this.api.literally(this.serverUrl), ); /** @@ -362,20 +354,23 @@ export abstract class IntegrationBase { * */ protected makeDefaultImplementation = () => { // method: method.toUpperCase() - const methodProperty = f.createPropertyAssignment( + const methodProperty = this.api.f.createPropertyAssignment( propOf("method"), - makeCall(this.#ids.methodParameter, propOf("toUpperCase"))(), + this.api.makeCall( + this.#ids.methodParameter, + propOf("toUpperCase"), + )(), ); // headers: hasBody ? { "Content-Type": "application/json" } : undefined - const headersProperty = f.createPropertyAssignment( + const headersProperty = this.api.f.createPropertyAssignment( propOf("headers"), - makeTernary( + this.api.makeTernary( this.#ids.hasBodyConst, - f.createObjectLiteralExpression([ - f.createPropertyAssignment( - literally("Content-Type"), - literally(contentTypes.json), + this.api.f.createObjectLiteralExpression([ + this.api.f.createPropertyAssignment( + this.api.literally("Content-Type"), + this.api.literally(contentTypes.json), ), ]), this.#ids.undefinedValue, @@ -383,11 +378,11 @@ export abstract class IntegrationBase { ); // body: hasBody ? JSON.stringify(params) : undefined - const bodyProperty = f.createPropertyAssignment( + const bodyProperty = this.api.f.createPropertyAssignment( propOf("body"), - makeTernary( + this.api.makeTernary( this.#ids.hasBodyConst, - makeCall( + this.api.makeCall( JSON[Symbol.toStringTag], propOf("stringify"), )(this.#ids.paramsArgument), @@ -396,12 +391,12 @@ export abstract class IntegrationBase { ); // const response = await fetch(new URL(`${path}${searchParams}`, "https://example.com"), { ___ }); - const responseStatement = makeConst( + const responseStatement = this.api.makeConst( this.#ids.responseConst, - f.createAwaitExpression( - makeCall(fetch.name)( + this.api.f.createAwaitExpression( + this.api.makeCall(fetch.name)( this.#makeFetchURL(), - f.createObjectLiteralExpression([ + this.api.f.createObjectLiteralExpression([ methodProperty, headersProperty, bodyProperty, @@ -411,14 +406,14 @@ export abstract class IntegrationBase { ); // const hasBody = !["get", "delete"].includes(method); - const hasBodyStatement = makeConst( + const hasBodyStatement = this.api.makeConst( this.#ids.hasBodyConst, - f.createLogicalNot( - makeCall( - f.createArrayLiteralExpression([ - literally("get" satisfies ClientMethod), - literally("head" satisfies ClientMethod), - literally("delete" satisfies ClientMethod), + this.api.f.createLogicalNot( + this.api.makeCall( + this.api.f.createArrayLiteralExpression([ + this.api.literally("get" satisfies ClientMethod), + this.api.literally("head" satisfies ClientMethod), + this.api.literally("delete" satisfies ClientMethod), ]), propOf("includes"), )(this.#ids.methodParameter), @@ -426,64 +421,64 @@ export abstract class IntegrationBase { ); // const searchParams = hasBody ? "" : ___; - const searchParamsStatement = makeConst( + const searchParamsStatement = this.api.makeConst( this.#ids.searchParamsConst, - makeTernary( + this.api.makeTernary( this.#ids.hasBodyConst, - literally(""), + this.api.literally(""), this.#makeSearchParams(this.#ids.paramsArgument), ), ); // const contentType = response.headers.get("content-type"); - const contentTypeStatement = makeConst( + const contentTypeStatement = this.api.makeConst( this.#ids.contentTypeConst, - makeCall( + this.api.makeCall( this.#ids.responseConst, propOf("headers"), propOf("get"), - )(literally("content-type")), + )(this.api.literally("content-type")), ); // if (!contentType) return; - const noBodyStatement = f.createIfStatement( - f.createPrefixUnaryExpression( - ts.SyntaxKind.ExclamationToken, + const noBodyStatement = this.api.f.createIfStatement( + this.api.f.createPrefixUnaryExpression( + this.api.ts.SyntaxKind.ExclamationToken, this.#ids.contentTypeConst, ), - f.createReturnStatement(), + this.api.f.createReturnStatement(), ); // const isJSON = contentType.startsWith("application/json"); - const isJsonConst = makeConst( + const isJsonConst = this.api.makeConst( this.#ids.isJsonConst, - makeCall( + this.api.makeCall( this.#ids.contentTypeConst, propOf("startsWith"), - )(literally(contentTypes.json)), + )(this.api.literally(contentTypes.json)), ); // return response[isJSON ? "json" : "text"](); - const returnStatement = f.createReturnStatement( - makeCall( + const returnStatement = this.api.f.createReturnStatement( + this.api.makeCall( this.#ids.responseConst, - makeTernary( + this.api.makeTernary( this.#ids.isJsonConst, - literally(propOf("json")), - literally(propOf("text")), + this.api.literally(propOf("json")), + this.api.literally(propOf("text")), ), )(), ); - return makeConst( + return this.api.makeConst( this.#ids.defaultImplementationConst, - makeArrowFn( + this.api.makeArrowFn( [ this.#ids.methodParameter, this.#ids.pathParameter, this.#ids.paramsArgument, ], - f.createBlock([ + this.api.f.createBlock([ hasBodyStatement, searchParamsStatement, responseStatement, @@ -499,76 +494,84 @@ export abstract class IntegrationBase { }; #makeSubscriptionConstructor = () => - makePublicConstructor( - makeParams({ + this.api.makePublicConstructor( + this.api.makeParams({ request: "K", - params: makeIndexed(this.interfaces.input, "K"), + params: this.api.makeIndexed(this.interfaces.input, "K"), }), [ - makeConst( - makeDeconstruction(this.#ids.pathParameter, this.#ids.restConst), - makeCall(this.#ids.substituteFn)( - f.createElementAccessExpression( - makeCall(this.#ids.parseRequestFn)(this.#ids.requestParameter), - literally(1), + this.api.makeConst( + this.api.makeDeconstruction( + this.#ids.pathParameter, + this.#ids.restConst, + ), + this.api.makeCall(this.#ids.substituteFn)( + this.api.f.createElementAccessExpression( + this.api.makeCall(this.#ids.parseRequestFn)( + this.#ids.requestParameter, + ), + this.api.literally(1), ), this.#ids.paramsArgument, ), ), - makeConst( + this.api.makeConst( this.#ids.searchParamsConst, this.#makeSearchParams(this.#ids.restConst), ), - makeAssignment( - f.createPropertyAccessExpression( - f.createThis(), + this.api.makeAssignment( + this.api.f.createPropertyAccessExpression( + this.api.f.createThis(), this.#ids.sourceProp, ), - makeNew("EventSource", this.#makeFetchURL()), + this.api.makeNew("EventSource", this.#makeFetchURL()), ), ], ); #makeEventNarrow = (value: Typeable) => - f.createTypeLiteralNode([ - makeInterfaceProp(propOf("event"), value), + this.api.f.createTypeLiteralNode([ + this.api.makeInterfaceProp(propOf("event"), value), ]); #makeOnMethod = () => - makePublicMethod( + this.api.makePublicMethod( this.#ids.onMethod, - makeParams({ + this.api.makeParams({ [this.#ids.eventParameter.text]: "E", - [this.#ids.handlerParameter.text]: makeFnType( + [this.#ids.handlerParameter.text]: this.api.makeFnType( { - [this.#ids.dataParameter.text]: makeIndexed( - makeExtract("R", makeOneLine(this.#makeEventNarrow("E"))), - makeLiteralType(propOf("data")), + [this.#ids.dataParameter.text]: this.api.makeIndexed( + this.api.makeExtract( + "R", + this.api.makeOneLine(this.#makeEventNarrow("E")), + ), + this.api.makeLiteralType(propOf("data")), ), }, - makeMaybeAsync(ts.SyntaxKind.VoidKeyword), + this.api.makeMaybeAsync(this.api.ts.SyntaxKind.VoidKeyword), ), }), [ - f.createExpressionStatement( - makeCall( - f.createThis(), + this.api.f.createExpressionStatement( + this.api.makeCall( + this.api.f.createThis(), this.#ids.sourceProp, propOf("addEventListener"), )( this.#ids.eventParameter, - makeArrowFn( + this.api.makeArrowFn( [this.#ids.msgParameter], - makeCall(this.#ids.handlerParameter)( - makeCall( + this.api.makeCall(this.#ids.handlerParameter)( + this.api.makeCall( JSON[Symbol.toStringTag], propOf("parse"), )( - f.createPropertyAccessExpression( - f.createParenthesizedExpression( - f.createAsExpression( + this.api.f.createPropertyAccessExpression( + this.api.f.createParenthesizedExpression( + this.api.f.createAsExpression( this.#ids.msgParameter, - ensureTypeNode(MessageEvent.name), + this.api.ensureTypeNode(MessageEvent.name), ), ), propOf("data"), @@ -578,11 +581,14 @@ export abstract class IntegrationBase { ), ), ), - f.createReturnStatement(f.createThis()), + this.api.f.createReturnStatement(this.api.f.createThis()), ], { typeParams: { - E: makeIndexed("R", makeLiteralType(propOf("event"))), + E: this.api.makeIndexed( + "R", + this.api.makeLiteralType(propOf("event")), + ), }, }, ); @@ -592,27 +598,32 @@ export abstract class IntegrationBase { * @internal * */ protected makeSubscriptionClass = (name: string) => - makePublicClass( + this.api.makePublicClass( name, [ - makePublicProperty(this.#ids.sourceProp, "EventSource"), + this.api.makePublicProperty(this.#ids.sourceProp, "EventSource"), this.#makeSubscriptionConstructor(), this.#makeOnMethod(), ], { typeParams: { - K: makeExtract( + K: this.api.makeExtract( this.requestType.name, - f.createTemplateLiteralType(f.createTemplateHead("get "), [ - f.createTemplateLiteralTypeSpan( - ensureTypeNode(ts.SyntaxKind.StringKeyword), - f.createTemplateTail(""), - ), - ]), + this.api.f.createTemplateLiteralType( + this.api.f.createTemplateHead("get "), + [ + this.api.f.createTemplateLiteralTypeSpan( + this.api.ensureTypeNode(this.api.ts.SyntaxKind.StringKeyword), + this.api.f.createTemplateTail(""), + ), + ], + ), ), - R: makeExtract( - makeIndexed(this.interfaces.positive, "K"), - makeOneLine(this.#makeEventNarrow(ts.SyntaxKind.StringKeyword)), + R: this.api.makeExtract( + this.api.makeIndexed(this.interfaces.positive, "K"), + this.api.makeOneLine( + this.#makeEventNarrow(this.api.ts.SyntaxKind.StringKeyword), + ), ), }, }, @@ -623,22 +634,28 @@ export abstract class IntegrationBase { clientClassName: string, subscriptionClassName: string, ): ts.Node[] => [ - makeConst(this.#ids.clientConst, makeNew(clientClassName)), // const client = new Client(); + this.api.makeConst( + this.#ids.clientConst, + this.api.makeNew(clientClassName), + ), // const client = new Client(); // client.provide("get /v1/user/retrieve", { id: "10" }); - makeCall(this.#ids.clientConst, this.#ids.provideMethod)( - literally(`${"get" satisfies ClientMethod} /v1/user/retrieve`), - f.createObjectLiteralExpression([ - f.createPropertyAssignment("id", literally("10")), + this.api.makeCall(this.#ids.clientConst, this.#ids.provideMethod)( + this.api.literally(`${"get" satisfies ClientMethod} /v1/user/retrieve`), + this.api.f.createObjectLiteralExpression([ + this.api.f.createPropertyAssignment("id", this.api.literally("10")), ]), ), // new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); - makeCall( - makeNew( + this.api.makeCall( + this.api.makeNew( subscriptionClassName, - literally(`${"get" satisfies ClientMethod} /v1/events/stream`), - f.createObjectLiteralExpression(), + this.api.literally(`${"get" satisfies ClientMethod} /v1/events/stream`), + this.api.f.createObjectLiteralExpression(), ), this.#ids.onMethod, - )(literally("time"), makeArrowFn(["time"], f.createBlock([]))), + )( + this.api.literally("time"), + this.api.makeArrowFn(["time"], this.api.f.createBlock([])), + ), ]; } diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index bdadcdec29..d184738b6a 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -1,19 +1,8 @@ import * as R from "ramda"; -import ts from "typescript"; +import type ts from "typescript"; import { z } from "zod"; import { ResponseVariant, responseVariants } from "./api-response"; import { IntegrationBase } from "./integration-base"; -import { - f, - makeInterfaceProp, - makeInterface, - makeType, - printNode, - ensureTypeNode, - makeIndexed, - makeLiteralType, - makeUnion, -} from "./typescript-api"; import { shouldHaveContent, makeCleanId } from "./common-helpers"; import { loadPeer } from "./peer-helpers"; import { Routing } from "./routing"; @@ -82,11 +71,11 @@ export class Integration extends IntegrationBase { let name = this.#aliases.get(key)?.name?.text; if (!name) { name = `Type${this.#aliases.size + 1}`; - const temp = makeLiteralType(null); - this.#aliases.set(key, makeType(name, temp)); - this.#aliases.set(key, makeType(name, produce())); + const temp = this.api.makeLiteralType(null); + this.#aliases.set(key, this.api.makeType(name, temp)); + this.#aliases.set(key, this.api.makeType(name, produce())); } - return ensureTypeNode(name); + return this.api.ensureTypeNode(name); } public constructor({ @@ -101,33 +90,35 @@ export class Integration extends IntegrationBase { hasHeadMethod = true, }: IntegrationParams) { super(serverUrl); - const commons = { makeAlias: this.#makeAlias.bind(this) }; + const commons = { makeAlias: this.#makeAlias.bind(this), api: this.api }; const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } }; const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; const onEndpoint: OnEndpoint = (method, path, endpoint) => { const entitle = makeCleanId.bind(null, method, path); // clean id with method+path prefix const { isDeprecated, inputSchema, tags } = endpoint; const request = `${method} ${path}`; - const input = makeType(entitle("input"), zodToTs(inputSchema, ctxIn), { - comment: request, - }); + const input = this.api.makeType( + entitle("input"), + zodToTs(inputSchema, ctxIn), + { comment: request }, + ); this.#program.push(input); const dictionaries = responseVariants.reduce( (agg, responseVariant) => { const responses = endpoint.getResponses(responseVariant); const props = R.chain(([idx, { schema, mimeTypes, statusCodes }]) => { const hasContent = shouldHaveContent(method, mimeTypes); - const variantType = makeType( + const variantType = this.api.makeType( entitle(responseVariant, "variant", `${idx + 1}`), zodToTs(hasContent ? schema : noContent, ctxOut), { comment: request }, ); this.#program.push(variantType); return statusCodes.map((code) => - makeInterfaceProp(code, variantType.name), + this.api.makeInterfaceProp(code, variantType.name), ); }, Array.from(responses.entries())); - const dict = makeInterface( + const dict = this.api.makeInterface( entitle(responseVariant, "response", "variants"), props, { comment: request }, @@ -138,18 +129,18 @@ export class Integration extends IntegrationBase { {} as Record, ); this.paths.add(path); - const literalIdx = makeLiteralType(request); + const literalIdx = this.api.makeLiteralType(request); const store = { - input: ensureTypeNode(input.name), + input: this.api.ensureTypeNode(input.name), positive: this.someOf(dictionaries.positive), negative: this.someOf(dictionaries.negative), - response: makeUnion([ - makeIndexed(this.interfaces.positive, literalIdx), - makeIndexed(this.interfaces.negative, literalIdx), + response: this.api.makeUnion([ + this.api.makeIndexed(this.interfaces.positive, literalIdx), + this.api.makeIndexed(this.interfaces.negative, literalIdx), ]), - encoded: f.createIntersectionTypeNode([ - ensureTypeNode(dictionaries.positive.name), - ensureTypeNode(dictionaries.negative.name), + encoded: this.api.f.createIntersectionTypeNode([ + this.api.ensureTypeNode(dictionaries.positive.name), + this.api.ensureTypeNode(dictionaries.negative.name), ]), }; this.registry.set(request, { isDeprecated, store }); @@ -191,7 +182,7 @@ export class Integration extends IntegrationBase { .map((entry) => typeof entry === "string" ? entry - : printNode(entry, printerOptions), + : this.api.printNode(entry, printerOptions), ) .join("\n") : undefined; @@ -201,19 +192,19 @@ export class Integration extends IntegrationBase { const usageExampleText = this.#printUsage(printerOptions); const commentNode = usageExampleText && - ts.addSyntheticLeadingComment( - ts.addSyntheticLeadingComment( - f.createEmptyStatement(), - ts.SyntaxKind.SingleLineCommentTrivia, + this.api.ts.addSyntheticLeadingComment( + this.api.ts.addSyntheticLeadingComment( + this.api.f.createEmptyStatement(), + this.api.ts.SyntaxKind.SingleLineCommentTrivia, " Usage example:", ), - ts.SyntaxKind.MultiLineCommentTrivia, + this.api.ts.SyntaxKind.MultiLineCommentTrivia, `\n${usageExampleText}`, ); return this.#program .concat(commentNode || []) .map((node, index) => - printNode( + this.api.printNode( node, index < this.#program.length ? printerOptions diff --git a/express-zod-api/src/typescript-api.ts b/express-zod-api/src/typescript-api.ts index b4aa43df9b..a196589021 100644 --- a/express-zod-api/src/typescript-api.ts +++ b/express-zod-api/src/typescript-api.ts @@ -1,5 +1,6 @@ +import { createRequire } from "node:module"; import * as R from "ramda"; -import ts from "typescript"; +import type ts from "typescript"; export type Typeable = | ts.TypeNode @@ -11,410 +12,453 @@ type TypeParams = | string[] | Partial>; -export const f = ts.factory; - -const exportModifier = [f.createModifier(ts.SyntaxKind.ExportKeyword)]; - -const asyncModifier = [f.createModifier(ts.SyntaxKind.AsyncKeyword)]; - -export const accessModifiers = { - public: [f.createModifier(ts.SyntaxKind.PublicKeyword)], - protectedReadonly: [ - f.createModifier(ts.SyntaxKind.ProtectedKeyword), - f.createModifier(ts.SyntaxKind.ReadonlyKeyword), - ], -}; - -export const addJsDoc = (node: T, text: string) => - ts.addSyntheticLeadingComment( - node, - ts.SyntaxKind.MultiLineCommentTrivia, - `* ${text} `, - true, - ); - -export const printNode = ( - node: ts.Node, - printerOptions?: ts.PrinterOptions, -) => { - const sourceFile = ts.createSourceFile( - "print.ts", - "", - ts.ScriptTarget.Latest, - false, - ts.ScriptKind.TS, - ); - const printer = ts.createPrinter(printerOptions); - return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); -}; - -const safePropRegex = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -export const makePropertyIdentifier = (name: string | number) => - typeof name === "string" && safePropRegex.test(name) - ? f.createIdentifier(name) - : literally(name); - -export const makeTemplate = ( - head: string, - ...rest: ([ts.Expression] | [ts.Expression, string])[] -) => - f.createTemplateExpression( - f.createTemplateHead(head), - rest.map(([id, str = ""], idx) => - f.createTemplateSpan( - id, - idx === rest.length - 1 - ? f.createTemplateTail(str) - : f.createTemplateMiddle(str), +export class TypescriptAPI { + public ts: typeof ts; + public f: typeof ts.factory; + public exportModifier: ts.ModifierToken[]; + public asyncModifier: ts.ModifierToken[]; + public accessModifiers: Record<"public" | "protectedReadonly", ts.Modifier[]>; + #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 + this.f = this.ts.factory; + this.exportModifier = [ + this.f.createModifier(this.ts.SyntaxKind.ExportKeyword), + ]; + this.asyncModifier = [ + this.f.createModifier(this.ts.SyntaxKind.AsyncKeyword), + ]; + this.accessModifiers = { + public: [this.f.createModifier(this.ts.SyntaxKind.PublicKeyword)], + protectedReadonly: [ + this.f.createModifier(this.ts.SyntaxKind.ProtectedKeyword), + this.f.createModifier(this.ts.SyntaxKind.ReadonlyKeyword), + ], + }; + this.#primitives = [ + this.ts.SyntaxKind.AnyKeyword, + this.ts.SyntaxKind.BigIntKeyword, + this.ts.SyntaxKind.BooleanKeyword, + this.ts.SyntaxKind.NeverKeyword, + this.ts.SyntaxKind.NumberKeyword, + this.ts.SyntaxKind.ObjectKeyword, + this.ts.SyntaxKind.StringKeyword, + this.ts.SyntaxKind.SymbolKeyword, + this.ts.SyntaxKind.UndefinedKeyword, + this.ts.SyntaxKind.UnknownKeyword, + this.ts.SyntaxKind.VoidKeyword, + ]; + } + + public addJsDoc = (node: T, text: string) => + this.ts.addSyntheticLeadingComment( + node, + this.ts.SyntaxKind.MultiLineCommentTrivia, + `* ${text} `, + true, + ); + + public printNode = (node: ts.Node, printerOptions?: ts.PrinterOptions) => { + const sourceFile = this.ts.createSourceFile( + "print.ts", + "", + this.ts.ScriptTarget.Latest, + false, + this.ts.ScriptKind.TS, + ); + const printer = this.ts.createPrinter(printerOptions); + return printer.printNode(this.ts.EmitHint.Unspecified, node, sourceFile); + }; + + public makePropertyIdentifier = (name: string | number) => + typeof name === "string" && TypescriptAPI.#safePropRegex.test(name) + ? this.f.createIdentifier(name) + : this.literally(name); + + public makeTemplate = ( + head: string, + ...rest: ([ts.Expression] | [ts.Expression, string])[] + ) => + this.f.createTemplateExpression( + this.f.createTemplateHead(head), + rest.map(([id, str = ""], idx) => + this.f.createTemplateSpan( + id, + idx === rest.length - 1 + ? this.f.createTemplateTail(str) + : this.f.createTemplateMiddle(str), + ), ), - ), - ); - -export const makeParam = ( - name: string | ts.Identifier, - { - type, - mod, - init, - optional, - }: { - type?: Typeable; - mod?: ts.Modifier[]; - init?: ts.Expression; - optional?: boolean; - } = {}, -) => - f.createParameterDeclaration( - mod, - undefined, - name, - optional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, - type ? ensureTypeNode(type) : undefined, - init, - ); - -export const makeParams = ( - params: Partial[1]>>, -) => - Object.entries(params).map(([name, value]) => - makeParam( + ); + + public makeParam = ( + name: string | ts.Identifier, + { + type, + mod, + init, + optional, + }: { + type?: Typeable; + mod?: ts.Modifier[]; + init?: ts.Expression; + optional?: boolean; + } = {}, + ) => + this.f.createParameterDeclaration( + mod, + undefined, name, - typeof value === "string" || - typeof value === "number" || - (typeof value === "object" && "kind" in value) - ? { type: value } - : value, - ), - ); - -export const makePublicConstructor = ( - params: ts.ParameterDeclaration[], - statements: ts.Statement[] = [], -) => - f.createConstructorDeclaration( - accessModifiers.public, - params, - f.createBlock(statements), - ); - -export const ensureTypeNode = ( - subject: Typeable, - args?: Typeable[], // only for string and id -): ts.TypeNode => - typeof subject === "number" - ? f.createKeywordTypeNode(subject) - : typeof subject === "string" || ts.isIdentifier(subject) - ? f.createTypeReferenceNode(subject, args && R.map(ensureTypeNode, args)) - : subject; - -// Record -export const recordStringAny = ensureTypeNode("Record", [ - ts.SyntaxKind.StringKeyword, - ts.SyntaxKind.AnyKeyword, -]); - -/** ensures distinct union (unique primitives) */ -export const makeUnion = (entries: ts.TypeNode[]) => { - const nodes = new Map(); - for (const entry of entries) - nodes.set(isPrimitive(entry) ? entry.kind : entry, entry); - return f.createUnionTypeNode(Array.from(nodes.values())); -}; - -export const makeInterfaceProp = ( - name: string | number, - value: Typeable, - { - isOptional, - isDeprecated, - comment, - }: { isOptional?: boolean; isDeprecated?: boolean; comment?: string } = {}, -) => { - const propType = ensureTypeNode(value); - const node = f.createPropertySignature( - undefined, - makePropertyIdentifier(name), - isOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, - isOptional - ? makeUnion([propType, ensureTypeNode(ts.SyntaxKind.UndefinedKeyword)]) - : propType, - ); - const jsdoc = R.reject(R.isNil, [ - isDeprecated ? "@deprecated" : undefined, - comment, - ]); - return jsdoc.length ? addJsDoc(node, jsdoc.join(" ")) : node; -}; - -export const makeOneLine = (subject: ts.TypeNode) => - ts.setEmitFlags(subject, ts.EmitFlags.SingleLine); - -export const makeDeconstruction = ( - ...names: ts.Identifier[] -): ts.ArrayBindingPattern => - f.createArrayBindingPattern( - names.map( - (name) => f.createBindingElement(undefined, undefined, name), // can also add default value at last - ), - ); - -export const makeConst = ( - name: string | ts.Identifier | ts.ArrayBindingPattern, - value: ts.Expression, - { type, expose }: { type?: Typeable; expose?: true } = {}, -) => - f.createVariableStatement( - expose && exportModifier, - f.createVariableDeclarationList( - [ - f.createVariableDeclaration( - name, - undefined, - type ? ensureTypeNode(type) : undefined, - value, - ), - ], - ts.NodeFlags.Const, - ), - ); - -export const makePublicLiteralType = ( - name: ts.Identifier | string, - literals: string[], -) => - makeType(name, makeUnion(R.map(makeLiteralType, literals)), { expose: true }); - -export const makeType = ( - name: ts.Identifier | string, - value: ts.TypeNode, - { - expose, - comment, - params, - }: { expose?: boolean; comment?: string; params?: TypeParams } = {}, -) => { - const node = f.createTypeAliasDeclaration( - expose ? exportModifier : undefined, - name, - params && makeTypeParams(params), - value, - ); - return comment ? addJsDoc(node, comment) : node; -}; - -export const makePublicProperty = ( - name: string | ts.PropertyName, - type: Typeable, -) => - f.createPropertyDeclaration( - accessModifiers.public, - name, - undefined, - ensureTypeNode(type), - undefined, - ); - -export const makePublicMethod = ( - name: ts.Identifier, - params: ts.ParameterDeclaration[], - statements: ts.Statement[], - { - typeParams, - returns, - }: { typeParams?: TypeParams; returns?: ts.TypeNode } = {}, -) => - f.createMethodDeclaration( - accessModifiers.public, - undefined, - name, - undefined, - typeParams && makeTypeParams(typeParams), - params, - returns, - f.createBlock(statements), - ); - -export const makePublicClass = ( - name: string, - statements: ts.ClassElement[], - { typeParams }: { typeParams?: TypeParams } = {}, -) => - f.createClassDeclaration( - exportModifier, - name, - typeParams && makeTypeParams(typeParams), - undefined, - statements, - ); - -export const makeKeyOf = (subj: Typeable) => - f.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, ensureTypeNode(subj)); - -export const makePromise = (subject: Typeable) => - ensureTypeNode(Promise.name, [subject]); - -export const makeInterface = ( - name: ts.Identifier | string, - props: ts.PropertySignature[], - { expose, comment }: { expose?: boolean; comment?: string } = {}, -) => { - const node = f.createInterfaceDeclaration( - expose ? exportModifier : undefined, - name, - undefined, - undefined, - props, - ); - return comment ? addJsDoc(node, comment) : node; -}; - -export const makeTypeParams = ( - params: - | string[] - | Partial< - Record - >, -) => - (Array.isArray(params) - ? params.map((name) => R.pair(name, undefined)) - : Object.entries(params) - ).map(([name, val]) => { - const { type, init } = - typeof val === "object" && "init" in val ? val : { type: val }; - return f.createTypeParameterDeclaration( - [], + optional + ? this.f.createToken(this.ts.SyntaxKind.QuestionToken) + : undefined, + type ? this.ensureTypeNode(type) : undefined, + init, + ); + + public makeParams = ( + params: Partial< + Record[1]> + >, + ) => + Object.entries(params).map(([name, value]) => + this.makeParam( + name, + typeof value === "string" || + typeof value === "number" || + (typeof value === "object" && "kind" in value) + ? { type: value } + : value, + ), + ); + + public makePublicConstructor = ( + params: ts.ParameterDeclaration[], + statements: ts.Statement[] = [], + ) => + this.f.createConstructorDeclaration( + this.accessModifiers.public, + params, + this.f.createBlock(statements), + ); + + public ensureTypeNode = ( + subject: Typeable, + args?: Typeable[], // only for string and id + ): ts.TypeNode => + typeof subject === "number" + ? this.f.createKeywordTypeNode(subject) + : typeof subject === "string" || this.ts.isIdentifier(subject) + ? this.f.createTypeReferenceNode( + subject, + args && R.map(this.ensureTypeNode.bind(this), args), + ) + : subject; + + /** + * @internal + * @example Record + * */ + public makeRecordStringAny = () => + this.ensureTypeNode("Record", [ + this.ts.SyntaxKind.StringKeyword, + this.ts.SyntaxKind.AnyKeyword, + ]); + + /** + * @internal + * ensures distinct union (unique primitives) + * */ + public makeUnion = (entries: ts.TypeNode[]) => { + const nodes = new Map< + ts.TypeNode | ts.KeywordTypeSyntaxKind, + ts.TypeNode + >(); + for (const entry of entries) + nodes.set(this.isPrimitive(entry) ? entry.kind : entry, entry); + return this.f.createUnionTypeNode(Array.from(nodes.values())); + }; + + public makeInterfaceProp = ( + name: string | number, + value: Typeable, + { + isOptional, + isDeprecated, + comment, + }: { isOptional?: boolean; isDeprecated?: boolean; comment?: string } = {}, + ) => { + const propType = this.ensureTypeNode(value); + const node = this.f.createPropertySignature( + undefined, + this.makePropertyIdentifier(name), + isOptional + ? this.f.createToken(this.ts.SyntaxKind.QuestionToken) + : undefined, + isOptional + ? this.makeUnion([ + propType, + this.ensureTypeNode(this.ts.SyntaxKind.UndefinedKeyword), + ]) + : propType, + ); + const jsdoc = R.reject(R.isNil, [ + isDeprecated ? "@deprecated" : undefined, + comment, + ]); + return jsdoc.length ? this.addJsDoc(node, jsdoc.join(" ")) : node; + }; + + public makeOneLine = (subject: ts.TypeNode) => + this.ts.setEmitFlags(subject, this.ts.EmitFlags.SingleLine); + + public makeDeconstruction = ( + ...names: ts.Identifier[] + ): ts.ArrayBindingPattern => + this.f.createArrayBindingPattern( + names.map( + (name) => this.f.createBindingElement(undefined, undefined, name), // can also add default value at last + ), + ); + + public makeConst = ( + name: string | ts.Identifier | ts.ArrayBindingPattern, + value: ts.Expression, + { type, expose }: { type?: Typeable; expose?: true } = {}, + ) => + this.f.createVariableStatement( + expose && this.exportModifier, + this.f.createVariableDeclarationList( + [ + this.f.createVariableDeclaration( + name, + undefined, + type ? this.ensureTypeNode(type) : undefined, + value, + ), + ], + this.ts.NodeFlags.Const, + ), + ); + + public makePublicLiteralType = ( + name: ts.Identifier | string, + literals: string[], + ) => + this.makeType( name, - type ? ensureTypeNode(type) : undefined, - init ? ensureTypeNode(init) : undefined, + this.makeUnion(R.map(this.makeLiteralType.bind(this), literals)), + { expose: true }, ); - }); - -export const makeArrowFn = ( - params: - | Array[0]> - | Parameters[0], - body: ts.ConciseBody, - { isAsync }: { isAsync?: boolean } = {}, -) => - f.createArrowFunction( - isAsync ? asyncModifier : undefined, - undefined, - Array.isArray(params) ? R.map(makeParam, params) : makeParams(params), - undefined, - undefined, - body, - ); -export const propOf = (name: keyof NoInfer) => name as string; + public makeType = ( + name: ts.Identifier | string, + value: ts.TypeNode, + { + expose, + comment, + params, + }: { expose?: boolean; comment?: string; params?: TypeParams } = {}, + ) => { + const node = this.f.createTypeAliasDeclaration( + expose ? this.exportModifier : undefined, + name, + params && this.makeTypeParams(params), + value, + ); + return comment ? this.addJsDoc(node, comment) : node; + }; + + public makePublicProperty = ( + name: string | ts.PropertyName, + type: Typeable, + ) => + this.f.createPropertyDeclaration( + this.accessModifiers.public, + name, + undefined, + this.ensureTypeNode(type), + undefined, + ); + + public makePublicMethod = ( + name: ts.Identifier, + params: ts.ParameterDeclaration[], + statements: ts.Statement[], + { + typeParams, + returns, + }: { typeParams?: TypeParams; returns?: ts.TypeNode } = {}, + ) => + this.f.createMethodDeclaration( + this.accessModifiers.public, + undefined, + name, + undefined, + typeParams && this.makeTypeParams(typeParams), + params, + returns, + this.f.createBlock(statements), + ); + + public makePublicClass = ( + name: string, + statements: ts.ClassElement[], + { typeParams }: { typeParams?: TypeParams } = {}, + ) => + this.f.createClassDeclaration( + this.exportModifier, + name, + typeParams && this.makeTypeParams(typeParams), + undefined, + statements, + ); + + public makeKeyOf = (subj: Typeable) => + this.f.createTypeOperatorNode( + this.ts.SyntaxKind.KeyOfKeyword, + this.ensureTypeNode(subj), + ); + + public makePromise = (subject: Typeable) => + this.ensureTypeNode(Promise.name, [subject]); -export const makeTernary = ( - condition: ts.Expression, - positive: ts.Expression, - negative: ts.Expression, -) => - f.createConditionalExpression( - condition, - f.createToken(ts.SyntaxKind.QuestionToken), - positive, - f.createToken(ts.SyntaxKind.ColonToken), - negative, - ); - -export const makeCall = - ( - first: ts.Expression | string, - ...rest: Array + public makeInterface = ( + name: ts.Identifier | string, + props: ts.PropertySignature[], + { expose, comment }: { expose?: boolean; comment?: string } = {}, + ) => { + const node = this.f.createInterfaceDeclaration( + expose ? this.exportModifier : undefined, + name, + undefined, + undefined, + props, + ); + return comment ? this.addJsDoc(node, comment) : node; + }; + + public makeTypeParams = ( + params: + | string[] + | Partial< + Record + >, + ) => + (Array.isArray(params) + ? params.map((name) => R.pair(name, undefined)) + : Object.entries(params) + ).map(([name, val]) => { + const { type, init } = + typeof val === "object" && "init" in val ? val : { type: val }; + return this.f.createTypeParameterDeclaration( + [], + name, + type ? this.ensureTypeNode(type) : undefined, + init ? this.ensureTypeNode(init) : undefined, + ); + }); + + public makeArrowFn = ( + params: + | Array[0]> + | Parameters[0], + body: ts.ConciseBody, + { isAsync }: { isAsync?: boolean } = {}, ) => - (...args: ts.Expression[]) => - f.createCallExpression( - rest.reduce( - (acc, entry) => - typeof entry === "string" || ts.isIdentifier(entry) - ? f.createPropertyAccessExpression(acc, entry) - : f.createElementAccessExpression(acc, entry), - typeof first === "string" ? f.createIdentifier(first) : first, + this.f.createArrowFunction( + isAsync ? this.asyncModifier : undefined, + undefined, + Array.isArray(params) + ? R.map(this.makeParam.bind(this), params) + : this.makeParams(params), + undefined, + undefined, + body, + ); + + public makeTernary = ( + condition: ts.Expression, + positive: ts.Expression, + negative: ts.Expression, + ) => + 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[]) => + 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, + ), + undefined, + args, + ); + + public makeNew = (cls: string, ...args: ts.Expression[]) => + this.f.createNewExpression(this.f.createIdentifier(cls), undefined, args); + + public makeExtract = (base: Typeable, narrow: ts.TypeNode) => + this.ensureTypeNode("Extract", [base, narrow]); + + public makeAssignment = (left: ts.Expression, right: ts.Expression) => + this.f.createExpressionStatement( + this.f.createBinaryExpression( + left, + this.f.createToken(this.ts.SyntaxKind.EqualsToken), + right, ), + ); + + public makeIndexed = (subject: Typeable, index: Typeable) => + this.f.createIndexedAccessTypeNode( + this.ensureTypeNode(subject), + this.ensureTypeNode(index), + ); + + public makeMaybeAsync = (subj: Typeable) => + this.makeUnion([this.ensureTypeNode(subj), this.makePromise(subj)]); + + public makeFnType = ( + params: Parameters[0], + returns: Typeable, + ) => + this.f.createFunctionTypeNode( undefined, - args, + this.makeParams(params), + this.ensureTypeNode(returns), ); -export const makeNew = (cls: string, ...args: ts.Expression[]) => - f.createNewExpression(f.createIdentifier(cls), undefined, args); - -export const makeExtract = (base: Typeable, narrow: ts.TypeNode) => - ensureTypeNode("Extract", [base, narrow]); - -export const makeAssignment = (left: ts.Expression, right: ts.Expression) => - f.createExpressionStatement( - f.createBinaryExpression( - left, - f.createToken(ts.SyntaxKind.EqualsToken), - right, - ), - ); - -export const makeIndexed = (subject: Typeable, index: Typeable) => - f.createIndexedAccessTypeNode(ensureTypeNode(subject), ensureTypeNode(index)); - -export const makeMaybeAsync = (subj: Typeable) => - makeUnion([ensureTypeNode(subj), makePromise(subj)]); - -export const makeFnType = ( - params: Parameters[0], - returns: Typeable, -) => - f.createFunctionTypeNode( - undefined, - makeParams(params), - ensureTypeNode(returns), - ); - -/* eslint-disable prettier/prettier -- shorter and works better this way than overrides */ -export const literally = (subj: T) => ( - typeof subj === "number" ? f.createNumericLiteral(subj) - : typeof subj === "bigint" ? f.createBigIntLiteral(subj.toString()) - : typeof subj === "boolean" ? subj ? f.createTrue() : f.createFalse() - : subj === null ? f.createNull() : f.createStringLiteral(subj) + /* eslint-disable prettier/prettier -- shorter and works better this way than overrides */ + public literally = (subj: T) => ( + typeof subj === "number" ? this.f.createNumericLiteral(subj) + : typeof subj === "bigint" ? this.f.createBigIntLiteral(subj.toString()) + : typeof subj === "boolean" ? subj ? this.f.createTrue() : this.f.createFalse() + : subj === null ? this.f.createNull() : this.f.createStringLiteral(subj) ) as T extends string ? ts.StringLiteral : T extends number ? ts.NumericLiteral : T extends boolean ? ts.BooleanLiteral : ts.NullLiteral; -/* eslint-enable prettier/prettier */ - -export const makeLiteralType = (subj: Parameters[0]) => - f.createLiteralTypeNode(literally(subj)); - -const primitives: ts.KeywordTypeSyntaxKind[] = [ - ts.SyntaxKind.AnyKeyword, - ts.SyntaxKind.BigIntKeyword, - ts.SyntaxKind.BooleanKeyword, - ts.SyntaxKind.NeverKeyword, - ts.SyntaxKind.NumberKeyword, - ts.SyntaxKind.ObjectKeyword, - ts.SyntaxKind.StringKeyword, - ts.SyntaxKind.SymbolKeyword, - ts.SyntaxKind.UndefinedKeyword, - ts.SyntaxKind.UnknownKeyword, - ts.SyntaxKind.VoidKeyword, -]; - -const isPrimitive = (node: ts.TypeNode): node is ts.KeywordTypeNode => - (primitives as ts.SyntaxKind[]).includes(node.kind); + /* eslint-enable prettier/prettier */ + + public makeLiteralType = (subj: Parameters[0]) => + this.f.createLiteralTypeNode(this.literally(subj)); + + public isPrimitive = (node: ts.TypeNode): node is ts.KeywordTypeNode => + (this.#primitives as ts.SyntaxKind[]).includes(node.kind); +} + +export const propOf = (name: keyof NoInfer) => name as string; diff --git a/express-zod-api/src/zts-helpers.ts b/express-zod-api/src/zts-helpers.ts index 484c68fb0f..4778b8deac 100644 --- a/express-zod-api/src/zts-helpers.ts +++ b/express-zod-api/src/zts-helpers.ts @@ -1,10 +1,13 @@ import type ts from "typescript"; import { FlatObject } from "./common-helpers"; import { SchemaHandler } from "./schema-walker"; +import type { TypescriptAPI } from "./typescript-api"; export interface ZTSContext extends FlatObject { isResponse: boolean; makeAlias: (key: object, produce: () => ts.TypeNode) => ts.TypeNode; + /** @internal */ + api: TypescriptAPI; } export type Producer = SchemaHandler; diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 681920a2e6..dc47637ae9 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -1,5 +1,5 @@ import * as R from "ramda"; -import ts from "typescript"; +import type ts from "typescript"; import { globalRegistry, z } from "zod"; import { ezBufferBrand } from "./buffer-schema"; import { getTransformedType, isSchema } from "./common-helpers"; @@ -9,26 +9,9 @@ import { hasCycle } from "./deep-checks"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand, RawSchema } from "./raw-schema"; import { FirstPartyKind, HandlingRules, walkSchema } from "./schema-walker"; -import { - ensureTypeNode, - makeInterfaceProp, - makeLiteralType, - makeUnion, -} from "./typescript-api"; +import type { TypescriptAPI } from "./typescript-api"; import { Producer, ZTSContext } from "./zts-helpers"; -const { factory: f } = ts; - -const samples = { - [ts.SyntaxKind.AnyKeyword]: "", - [ts.SyntaxKind.BigIntKeyword]: BigInt(0), - [ts.SyntaxKind.BooleanKeyword]: false, - [ts.SyntaxKind.NumberKeyword]: 0, - [ts.SyntaxKind.ObjectKeyword]: {}, - [ts.SyntaxKind.StringKeyword]: "", - [ts.SyntaxKind.UndefinedKeyword]: undefined, -} satisfies Partial>; - const nodePath = { name: R.path([ "name" satisfies keyof ts.TypeElement, @@ -41,18 +24,21 @@ const nodePath = { optional: R.path(["questionToken" satisfies keyof ts.TypeElement]), }; -const onLiteral: Producer = ({ _zod: { def } }: z.core.$ZodLiteral) => { +const onLiteral: Producer = ( + { _zod: { def } }: z.core.$ZodLiteral, + { api }, +) => { const values = def.values.map((entry) => entry === undefined - ? ensureTypeNode(ts.SyntaxKind.UndefinedKeyword) - : makeLiteralType(entry), + ? api.ensureTypeNode(api.ts.SyntaxKind.UndefinedKeyword) + : api.makeLiteralType(entry), ); - return values.length === 1 ? values[0] : makeUnion(values); + return values.length === 1 ? values[0] : api.makeUnion(values); }; const onTemplateLiteral: Producer = ( { _zod: { def } }: z.core.$ZodTemplateLiteral, - { next }, + { next, api }, ) => { const parts = [...def.parts]; const shiftText = () => { @@ -67,30 +53,30 @@ const onTemplateLiteral: Producer = ( } return text; }; - const head = f.createTemplateHead(shiftText()); + const head = api.f.createTemplateHead(shiftText()); const spans: ts.TemplateLiteralTypeSpan[] = []; while (parts.length) { const schema = next(parts.shift() as z.core.$ZodType); const text = shiftText(); const textWrapper = parts.length - ? f.createTemplateMiddle - : f.createTemplateTail; - spans.push(f.createTemplateLiteralTypeSpan(schema, textWrapper(text))); + ? api.f.createTemplateMiddle + : api.f.createTemplateTail; + spans.push(api.f.createTemplateLiteralTypeSpan(schema, textWrapper(text))); } - if (!spans.length) return makeLiteralType(head.text); - return f.createTemplateLiteralType(head, spans); + if (!spans.length) return api.makeLiteralType(head.text); + return api.f.createTemplateLiteralType(head, spans); }; const onObject: Producer = ( obj: z.core.$ZodObject, - { isResponse, next, makeAlias }, + { isResponse, next, makeAlias, api }, ) => { const produce = () => { const members = Object.entries(obj._zod.def.shape).map( ([key, value]) => { const { description: comment, deprecated: isDeprecated } = globalRegistry.get(value) || {}; - return makeInterfaceProp(key, next(value), { + return api.makeInterfaceProp(key, next(value), { comment, isDeprecated, isOptional: @@ -98,45 +84,51 @@ const onObject: Producer = ( }); }, ); - return f.createTypeLiteralNode(members); + return api.f.createTypeLiteralNode(members); }; return hasCycle(obj, { io: isResponse ? "output" : "input" }) ? makeAlias(obj, produce) : produce(); }; -const onArray: Producer = ({ _zod: { def } }: z.core.$ZodArray, { next }) => - f.createArrayTypeNode(next(def.element)); +const onArray: Producer = ( + { _zod: { def } }: z.core.$ZodArray, + { next, api }, +) => api.f.createArrayTypeNode(next(def.element)); -const onEnum: Producer = ({ _zod: { def } }: z.core.$ZodEnum) => - makeUnion(Object.values(def.entries).map(makeLiteralType)); +const onEnum: Producer = ({ _zod: { def } }: z.core.$ZodEnum, { api }) => + api.makeUnion(Object.values(def.entries).map(api.makeLiteralType.bind(api))); const onSomeUnion: Producer = ( { _zod: { def } }: z.core.$ZodUnion | z.core.$ZodDiscriminatedUnion, - { next }, -) => makeUnion(def.options.map(next)); - -const makeSample = (produced: ts.TypeNode) => - samples?.[produced.kind as keyof typeof samples]; + { next, api }, +) => api.makeUnion(def.options.map(next)); const onNullable: Producer = ( { _zod: { def } }: z.core.$ZodNullable, - { next }, -) => makeUnion([next(def.innerType), makeLiteralType(null)]); + { next, api }, +) => api.makeUnion([next(def.innerType), api.makeLiteralType(null)]); -const onTuple: Producer = ({ _zod: { def } }: z.core.$ZodTuple, { next }) => - f.createTupleTypeNode( +const onTuple: Producer = ( + { _zod: { def } }: z.core.$ZodTuple, + { next, api }, +) => + api.f.createTupleTypeNode( def.items .map(next) - .concat(def.rest === null ? [] : f.createRestTypeNode(next(def.rest))), + .concat( + def.rest === null ? [] : api.f.createRestTypeNode(next(def.rest)), + ), ); -const onRecord: Producer = ({ _zod: { def } }: z.core.$ZodRecord, { next }) => - ensureTypeNode("Record", [def.keyType, def.valueType].map(next)); +const onRecord: Producer = ( + { _zod: { def } }: z.core.$ZodRecord, + { next, api }, +) => api.ensureTypeNode("Record", [def.keyType, def.valueType].map(next)); const intersect = R.tryCatch( - (nodes: ts.TypeNode[]) => { - if (!nodes.every(ts.isTypeLiteralNode)) throw new Error("Not objects"); + (api: TypescriptAPI, nodes: ts.TypeNode[]) => { + if (!nodes.every(api.ts.isTypeLiteralNode)) throw new Error("Not objects"); const members = R.chain(R.prop("members"), nodes); const uniqs = R.uniqWith((...props) => { if (!R.eqBy(nodePath.name, ...props)) return false; @@ -144,20 +136,31 @@ const intersect = R.tryCatch( return true; throw new Error("Has conflicting prop"); }, members); - return f.createTypeLiteralNode(uniqs); + return api.f.createTypeLiteralNode(uniqs); }, - (_err, nodes) => f.createIntersectionTypeNode(nodes), + (_err, api, nodes) => api.f.createIntersectionTypeNode(nodes), ); const onIntersection: Producer = ( { _zod: { def } }: z.core.$ZodIntersection, - { next }, -) => intersect([def.left, def.right].map(next)); + { next, api }, +) => intersect(api, [def.left, def.right].map(next)); const onPrimitive = - (syntaxKind: ts.KeywordTypeSyntaxKind): Producer => - () => - ensureTypeNode(syntaxKind); + ( + syntaxKind: + | "AnyKeyword" + | "BigIntKeyword" + | "BooleanKeyword" + | "NeverKeyword" + | "NumberKeyword" + | "StringKeyword" + | "UndefinedKeyword" + | "UnknownKeyword" + | "VoidKeyword", + ): Producer => + ({}, { api }) => + api.ensureTypeNode(api.ts.SyntaxKind[syntaxKind]); const onWrapped: Producer = ( { @@ -171,43 +174,55 @@ const onWrapped: Producer = ( { next }, ) => next(def.innerType); -const getFallback = (isResponse: boolean) => - ensureTypeNode( - isResponse ? ts.SyntaxKind.UnknownKeyword : ts.SyntaxKind.AnyKeyword, +const getFallback = (api: TypescriptAPI, isResponse: boolean) => + api.ensureTypeNode( + isResponse + ? api.ts.SyntaxKind.UnknownKeyword + : api.ts.SyntaxKind.AnyKeyword, ); const onPipeline: Producer = ( { _zod: { def } }: z.core.$ZodPipe, - { next, isResponse }, + { next, isResponse, api }, ) => { const target = def[isResponse ? "out" : "in"]; const opposite = def[isResponse ? "in" : "out"]; if (!isSchema(target, "transform")) return next(target); const opposingType = next(opposite); - const targetType = getTransformedType(target, makeSample(opposingType)); + const samples = { + [api.ts.SyntaxKind.AnyKeyword]: "", + [api.ts.SyntaxKind.BigIntKeyword]: BigInt(0), + [api.ts.SyntaxKind.BooleanKeyword]: false, + [api.ts.SyntaxKind.NumberKeyword]: 0, + [api.ts.SyntaxKind.ObjectKeyword]: {}, + [api.ts.SyntaxKind.StringKeyword]: "", + [api.ts.SyntaxKind.UndefinedKeyword]: undefined, + } satisfies Partial>; + const sample = samples[opposingType.kind as keyof typeof samples]; + const targetType = getTransformedType(target, sample); const resolutions: Partial< Record, ts.KeywordTypeSyntaxKind> > = { - number: ts.SyntaxKind.NumberKeyword, - bigint: ts.SyntaxKind.BigIntKeyword, - boolean: ts.SyntaxKind.BooleanKeyword, - string: ts.SyntaxKind.StringKeyword, - undefined: ts.SyntaxKind.UndefinedKeyword, - object: ts.SyntaxKind.ObjectKeyword, + number: api.ts.SyntaxKind.NumberKeyword, + bigint: api.ts.SyntaxKind.BigIntKeyword, + boolean: api.ts.SyntaxKind.BooleanKeyword, + string: api.ts.SyntaxKind.StringKeyword, + undefined: api.ts.SyntaxKind.UndefinedKeyword, + object: api.ts.SyntaxKind.ObjectKeyword, }; - return ensureTypeNode( - (targetType && resolutions[targetType]) || getFallback(isResponse), + return api.ensureTypeNode( + (targetType && resolutions[targetType]) || getFallback(api, isResponse), ); }; -const onNull: Producer = () => makeLiteralType(null); +const onNull: Producer = ({}, { api }) => api.makeLiteralType(null); const onLazy: Producer = ( { _zod: { def } }: z.core.$ZodLazy, { makeAlias, next }, ) => makeAlias(def.getter, () => next(def.getter())); -const onBuffer: Producer = () => ensureTypeNode("Buffer"); +const onBuffer: Producer = ({}, { api }) => api.ensureTypeNode("Buffer"); const onRaw: Producer = (schema: RawSchema, { next }) => next(schema._zod.def.shape.raw); @@ -217,17 +232,17 @@ const producers: HandlingRules< ZTSContext, FirstPartyKind | ProprietaryBrand > = { - string: onPrimitive(ts.SyntaxKind.StringKeyword), - number: onPrimitive(ts.SyntaxKind.NumberKeyword), - bigint: onPrimitive(ts.SyntaxKind.BigIntKeyword), - boolean: onPrimitive(ts.SyntaxKind.BooleanKeyword), - any: onPrimitive(ts.SyntaxKind.AnyKeyword), - undefined: onPrimitive(ts.SyntaxKind.UndefinedKeyword), - [ezDateInBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), - [ezDateOutBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), - never: onPrimitive(ts.SyntaxKind.NeverKeyword), - void: onPrimitive(ts.SyntaxKind.UndefinedKeyword), - unknown: onPrimitive(ts.SyntaxKind.UnknownKeyword), + string: onPrimitive("StringKeyword"), + number: onPrimitive("NumberKeyword"), + bigint: onPrimitive("BigIntKeyword"), + boolean: onPrimitive("BooleanKeyword"), + any: onPrimitive("AnyKeyword"), + undefined: onPrimitive("UndefinedKeyword"), + [ezDateInBrand]: onPrimitive("StringKeyword"), + [ezDateOutBrand]: onPrimitive("StringKeyword"), + never: onPrimitive("NeverKeyword"), + void: onPrimitive("UndefinedKeyword"), + unknown: onPrimitive("UnknownKeyword"), null: onNull, array: onArray, tuple: onTuple, @@ -262,6 +277,6 @@ export const zodToTs = ( ) => walkSchema(schema, { rules: { ...brandHandling, ...producers }, - onMissing: ({}, { isResponse }) => getFallback(isResponse), + onMissing: ({}, { isResponse, api }) => getFallback(api, isResponse), ctx, }); diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 218aeab24a..21ed739f2c 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -1,16 +1,18 @@ import ts from "typescript"; import { z } from "zod"; import { ez } from "../src"; -import { f, printNode } from "../src/typescript-api"; +import { TypescriptAPI } from "../src/typescript-api"; import { zodToTs } from "../src/zts"; import { ZTSContext } from "../src/zts-helpers"; describe("zod-to-ts", () => { + const api = new TypescriptAPI(); const printNodeTest = (node: ts.Node) => - printNode(node, { newLine: ts.NewLineKind.LineFeed }); + api.printNode(node, { newLine: ts.NewLineKind.LineFeed }); const ctx: ZTSContext = { isResponse: false, - makeAlias: vi.fn(() => f.createTypeReferenceNode("SomeType")), + makeAlias: vi.fn(() => api.f.createTypeReferenceNode("SomeType")), + api, }; describe("z.array()", () => { @@ -173,7 +175,7 @@ describe("zod-to-ts", () => { test("should produce the expected results", () => { const node = zodToTs(example, { ctx }); - expect(printNode(node)).toMatchSnapshot(); + expect(api.printNode(node)).toMatchSnapshot(); }); });