diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3cd4845052..17191e1dac 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21 ] schedule: - cron: '26 8 * * 1' diff --git a/.github/workflows/headers.yml b/.github/workflows/headers.yml new file mode 100644 index 0000000000..411612722f --- /dev/null +++ b/.github/workflows/headers.yml @@ -0,0 +1,54 @@ +name: Headers update + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * 0" # Runs every Sunday at midnight UTC + +jobs: + run-bash-and-pr: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: fregante/setup-git-user@v2 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: yarn install + + - name: Check for new headers on IANA.ORG + run: yarn tsx tools/headers.ts + + - name: Check for changes + id: git-state + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "changes=true" >> $GITHUB_OUTPUT + else + echo "changes=false" >> $GITHUB_OUTPUT + fi + + - name: Create branch, commit, and push changes + if: steps.git-state.outputs.changes == 'true' + run: | + BRANCH_NAME="headers-update-$(date +%Y%m%d)" + git checkout -b $BRANCH_NAME + git add . + git commit -m "Changed well-known headers on $(date +%Y-%m-%d)" + git push origin $BRANCH_NAME + echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT + + - name: Create a pull request + if: steps.git-state.outputs.changes == 'true' + uses: peter-evans/create-pull-request@v5 + with: + base: "master" + branch: ${{ steps.create-branch.outputs.branch-name }} + title: "Well-known headers update" + body: "This PR contains automated updates generated by the weekly workflow." diff --git a/.github/workflows/minor.yml b/.github/workflows/minor.yml index d756128db3..506c638951 100644 --- a/.github/workflows/minor.yml +++ b/.github/workflows/minor.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - uses: fregante/setup-git-user@v2 - run: | yarn install diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 56fde19642..20db7d3148 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,9 +5,9 @@ name: Node.js CI on: push: - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21 ] pull_request: - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21 ] jobs: build: @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [18.18.0, 18.x, 20.9.0, 20.x, 22.0.0, 22.x] + node-version: [20.9.0, 20.x, 22.0.0, 22.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - name: Get yarn cache dir diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 91b3b9a972..09b448b48a 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 registry-url: https://registry.npmjs.org/ - run: yarn install - run: npm publish --provenance --tag ${{ inputs.tag }} diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 1a4e487978..4413a8faa0 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 - uses: fregante/setup-git-user@v2 - run: | yarn install diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index b88ade3043..3eccec5214 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -2,9 +2,9 @@ name: OpenAPI Validation on: push: - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21 ] pull_request: - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21 ] jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index eb86be97db..0d69e29077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,90 @@ # Changelog +## Version 22 + +### v22.0.0 + +- Minimum supported Node versions: 20.9.0 and 22.0.0: + - Node 18 is no longer supported, its end of life is April 30, 2025. +- `BuiltinLogger::profile()` behavior changed for picoseconds: expressing them through nanoseconds; +- Feature: handling all headers as input source (when enabled): + - Behavior changed for `headers` inside `inputSources` config option: all headers are addressed to the `input` object; + - This change is motivated by the deprecation of `x-` prefixed headers; + - Since the order inside `inputSources` matters, consider moving `headers` to the first place to avoid overwrites; + - The generated `Documentation` recognizes both `x-` prefixed inputs and well-known headers listed on IANA.ORG; + - You can customize that behavior by using the new option `isHeader` of the `Documentation::constructor()`. +- The `splitResponse` property on the `Integration::constructor()` argument is removed; +- Changes to the client code generated by `Integration`: + - The class name changed from `ExpressZodAPIClient` to just `Client`; + - The overload of the `Client::provide()` having 3 arguments and the `Provider` type are removed; + - The public `jsonEndpoints` const is removed — use the `content-type` header of an actual response instead; + - The public type `MethodPath` is removed — use the `Request` type instead. +- The approach on tagging endpoints changed: + - The `tags` property moved from the argument of `createConfig()` to `Documentation::constructor()`; + - The overload of `EndpointsFactory::constructor()` accepting `config` property is removed; + - The argument of `EventStreamFactory::constructor()` is now the events map (formerly assigned to `events` property); + - Tags should be declared as the keys of the augmented interface `TagOverrides` instead; +- The public method `Endpoint::getSecurity()` now returns an array; +- Consider the automated migration using the built-in ESLint rule. + +```js +// eslint.config.mjs — minimal ESLint 9 config to apply migrations automatically using "eslint --fix" +import parser from "@typescript-eslint/parser"; +import migration from "express-zod-api/migration"; + +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v22": "error" } }, +]; +``` + +```diff + createConfig({ +- tags: {}, + inputSources: { +- get: ["query", "headers"] // if you have headers on last place ++ get: ["headers", "query"] // move headers to avoid overwrites + } + }); + + new Documentation({ ++ tags: {}, ++ isHeader: (name, method, path) => {} // optional + }); + + new EndpointsFactory( +- { config, resultHandler: new ResultHandler() } ++ new ResultHandler() + ); + + new EventStreamFactory( +- { config, events: {} } ++ {} // events map only + ); +``` + +```ts +// new tagging approach +import { defaultEndpointsFactory, Documentation } from "express-zod-api"; + +// Add similar declaration once, somewhere in your code, preferably near config +declare module "express-zod-api" { + interface TagOverrides { + users: unknown; + files: unknown; + subscriptions: unknown; + } +} + +// Add extended description of the tags to Documentation (optional) +new Documentation({ + tags: { + users: "All about users", + files: { description: "All about files", url: "https://example.com" }, + }, +}); +``` + ## Version 21 ### v21.11.1 diff --git a/README.md b/README.md index a9c492cb29..4ce8556798 100644 --- a/README.md +++ b/README.md @@ -32,26 +32,26 @@ Start your API server with I/O schema validation and custom middlewares in minut 13. [Enabling compression](#enabling-compression) 5. [Advanced features](#advanced-features) 1. [Customizing input sources](#customizing-input-sources) - 2. [Nested routes](#nested-routes) - 3. [Route path params](#route-path-params) - 4. [Multiple schemas for one route](#multiple-schemas-for-one-route) - 5. [Response customization](#response-customization) - 6. [Empty response](#empty-response) - 7. [Error handling](#error-handling) - 8. [Production mode](#production-mode) - 9. [Non-object response](#non-object-response) including file downloads - 10. [File uploads](#file-uploads) - 11. [Serving static files](#serving-static-files) - 12. [Connect to your own express app](#connect-to-your-own-express-app) - 13. [Testing endpoints](#testing-endpoints) - 14. [Testing middlewares](#testing-middlewares) + 2. [Headers as input source](#headers-as-input-source) + 3. [Nested routes](#nested-routes) + 4. [Route path params](#route-path-params) + 5. [Multiple schemas for one route](#multiple-schemas-for-one-route) + 6. [Response customization](#response-customization) + 7. [Empty response](#empty-response) + 8. [Error handling](#error-handling) + 9. [Production mode](#production-mode) + 10. [Non-object response](#non-object-response) including file downloads + 11. [File uploads](#file-uploads) + 12. [Serving static files](#serving-static-files) + 13. [Connect to your own express app](#connect-to-your-own-express-app) + 14. [Testing endpoints](#testing-endpoints) + 15. [Testing middlewares](#testing-middlewares) 6. [Special needs](#special-needs) 1. [Different responses for different status codes](#different-responses-for-different-status-codes) 2. [Array response](#array-response) for migrating legacy APIs - 3. [Headers as input source](#headers-as-input-source) - 4. [Accepting raw data](#accepting-raw-data) - 5. [Graceful shutdown](#graceful-shutdown) - 6. [Subscriptions](#subscriptions) + 3. [Accepting raw data](#accepting-raw-data) + 4. [Graceful shutdown](#graceful-shutdown) + 5. [Subscriptions](#subscriptions) 7. [Integration and Documentation](#integration-and-documentation) 1. [Zod Plugin](#zod-plugin) 2. [Generating a Frontend Client](#generating-a-frontend-client) @@ -726,6 +726,31 @@ createConfig({ }); ``` +## Headers as input source + +In a similar way you can enable request headers as the input source. This is an opt-in feature. Please note: + +- consider giving `headers` the lowest priority among other `inputSources` to avoid overwrites; +- the request headers acquired that way are always lowercase when describing their validation schemas. + +```typescript +import { createConfig, defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod"; + +createConfig({ + inputSources: { + get: ["headers", "query"], // headers have lowest priority + }, // ... +}); + +defaultEndpointsFactory.build({ + input: z.object({ + "x-request-id": z.string(), // this one is from request.headers + id: z.string(), // this one is from request.query + }), // ... +}); +``` + ## Nested routes Suppose you want to assign both `/v1/path` and `/v1/path/subpath` routes with Endpoints: @@ -1128,32 +1153,6 @@ The `arrayResultHandler` expects your endpoint to have `items` property in the ` assigned to that property is used as the response. This approach also supports examples, as well as documentation and client generation. Check out [the example endpoint](/example/endpoints/list-users.ts) for more details. -## Headers as input source - -In a similar way you can enable the inclusion of request headers into the input sources. This is an opt-in feature. -Please note: - -- only the custom headers (the ones having `x-` prefix) will be combined into the `input`, -- the request headers acquired that way are lowercase when describing their validation schemas. - -```typescript -import { createConfig, defaultEndpointsFactory } from "express-zod-api"; -import { z } from "zod"; - -createConfig({ - inputSources: { - get: ["query", "headers"], - }, // ... -}); - -defaultEndpointsFactory.build({ - input: z.object({ - "x-request-id": z.string(), // this one is from request.headers - id: z.string(), // this one is from request.query - }), // ... -}); -``` - ## Accepting raw data Some APIs may require an endpoint to be able to accept and process raw data, such as streaming or uploading a binary @@ -1206,7 +1205,7 @@ import { EventStreamFactory } from "express-zod-api"; import { setTimeout } from "node:timers/promises"; const subscriptionEndpoint = EventStreamFactory({ - events: { time: z.number().int().positive() }, + time: z.number().int().positive(), }).buildVoid({ input: z.object({}), // optional input schema handler: async ({ options: { emit, isClosed } }) => { @@ -1264,9 +1263,9 @@ Consuming the generated client requires Typescript version 4.1 or higher. ```typescript // example frontend, simple implementation based on fetch() -import { ExpressZodAPIClient } from "./client.ts"; // the generated file +import { Client } from "./client.ts"; // the generated file -const client = new ExpressZodAPIClient(async (method, path, params) => { +const client = new Client(async (method, path, params) => { const hasBody = !["get", "delete"].includes(method); const searchParams = hasBody ? "" : `?${new URLSearchParams(params)}`; const response = await fetch(`https://example.com${path}${searchParams}`, { @@ -1322,36 +1321,33 @@ _See the example of the generated documentation ## Tagging the endpoints -When generating documentation, you may find it necessary to classify endpoints into groups. For this, the -possibility of tagging endpoints is provided. In order to achieve the consistency of tags across all endpoints, the -possible tags should be declared in the configuration first and another instantiation approach of the -`EndpointsFactory` is required. Consider the following example: +When generating documentation, you may find it necessary to classify endpoints into groups. The possibility of tagging +endpoints is available for that purpose. In order to establish the constraints on tags across all the endpoints, they +should be declared as keys of `TagOverrides` interface. Consider the following example: ```typescript -import { - createConfig, - EndpointsFactory, - defaultResultHandler, -} from "express-zod-api"; +import { defaultEndpointsFactory, Documentation } from "express-zod-api"; -const config = createConfig({ - tags: { - users: "Everything about the users", // or advanced syntax: - files: { - description: "Everything about the files processing", - url: "https://example.com", - }, - }, -}); +// Add similar declaration once, somewhere in your code, preferably near config +declare module "express-zod-api" { + interface TagOverrides { + users: unknown; + files: unknown; + subscriptions: unknown; + } +} -// instead of defaultEndpointsFactory use the following approach: -const taggedEndpointsFactory = new EndpointsFactory({ - resultHandler: defaultResultHandler, // or use your custom one - config, // <—— supply your config here +// Use the declared tags for endpoints +const exampleEndpoint = defaultEndpointsFactory.build({ + tag: "users", // or array ["users", "files"] }); -const exampleEndpoint = taggedEndpointsFactory.build({ - tag: "users", // or array ["users", "files"] +// Add extended description of the tags to Documentation (optional) +new Documentation({ + tags: { + users: "All about users", + files: { description: "All about files", url: "https://example.com" }, + }, }); ``` diff --git a/SECURITY.md b/SECURITY.md index 61174ccbbc..81008b3379 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Release | Supported | | ------: | :------ | :----------------: | +| 22.x.x | 02.2025 | :white_check_mark: | | 21.x.x | 11.2024 | :white_check_mark: | | 20.x.x | 06.2024 | :white_check_mark: | | 19.x.x | 05.2024 | :white_check_mark: | diff --git a/dataflow.svg b/dataflow.svg index fbdfee7de0..ddb28bc969 100644 --- a/dataflow.svg +++ b/dataflow.svg @@ -1,4 +1,3 @@ - -
Endpoint
Endpoint
options
options
input
schema
input...
output
schema
output...
handler
handler
Middleware N
Middleware N
options
options
middleware
middleware
input
schema
input...
   Middleware 1
   Middleware 1
middleware
middleware
input
schema
input...
Request
Request
.query
.query
.body
.body
ResultHandler
ResultHandler
Response
Response
error
error
GET & DELETE
GET & DELETE
PUT & PATCH
PUT & PATCH
.files
.files
POST
POST
.params
.params
.method
.method
.headers
.headers
 opt-in
 opt-in
custom only
custom only
Text is not SVG - cannot display
\ No newline at end of file +
Endpoint
Endpoint
options
options
input
schema
input...
output
schema
output...
handler
handler
Middleware N
Middleware N
options
options
middleware
middleware
input
schema
input...
   Middleware 1
   Middleware 1
middleware
middleware
input
schema
input...
Request
Request
.query
.query
.body
.body
ResultHandler
ResultHandler
Response
Response
error
error
GET & DELETE
GET & DELETE
PUT & PATCH
PUT & PATCH
.files
.files
POST
POST
.params
.params
.method
.method
.headers
.headers
 opt-in
 opt-in
\ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 2f9b569c1a..75227b3d62 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -60,7 +60,7 @@ const tsFactoryConcerns = [ message: "use makeType() or makePublicLiteralType() helpers", }, { - selector: "Identifier[name='createVariableDeclarationList']", + selector: "Identifier[name='createVariableStatement']", message: "use makeConst() helper", }, { @@ -73,7 +73,7 @@ const tsFactoryConcerns = [ }, { selector: "Identifier[name='createConstructorDeclaration']", - message: "use makeEmptyInitializingConstructor() helper", + message: "use makePublicConstructor() helper", }, { selector: "Identifier[name='createParameterDeclaration']", @@ -86,16 +86,25 @@ const tsFactoryConcerns = [ message: "use makePropCall() helper", }, { - selector: "Identifier[name='AmpersandAmpersandToken']", - message: "use makeAnd() helper", + selector: "Identifier[name='KeyOfKeyword']", + message: "use makeKeyOf() helper", }, { - selector: "Identifier[name='EqualsEqualsEqualsToken']", - message: "use makeEqual() helper", + selector: "Identifier[name='createTemplateExpression']", + message: "use makeTemplate() helper", }, { - selector: "Identifier[name='KeyOfKeyword']", - message: "use makeKeyOf() helper", + selector: "Identifier[name='createNewExpression']", + message: "use makeNew() helper", + }, + { + selector: "Literal[value='Promise']", + message: "use makePromise() helper", + }, + { + selector: + "CallExpression[callee.property.name='createTypeReferenceNode'][arguments.length=1]", + message: "use ensureTypeNode() helper", }, ]; @@ -143,7 +152,7 @@ export default tsPlugin.config( }, { name: "source/integration", - files: ["src/integration.ts"], + files: ["src/integration.ts", "src/integration-base.ts", "src/zts.ts"], rules: { "no-restricted-syntax": [ "warn", diff --git a/example/config.ts b/example/config.ts index 3700f274a8..947f0c5591 100644 --- a/example/config.ts +++ b/example/config.ts @@ -19,11 +19,6 @@ export const config = createConfig({ app.use("/docs", ui.serve, ui.setup(documentation)); }, cors: true, - tags: { - users: "Everything about the users", - files: "Everything about the files processing", - subscriptions: "Everything about the subscriptions", - }, }); // Uncomment these lines when using a custom logger, for example winston: @@ -39,3 +34,14 @@ declare module "express-zod-api" { interface LoggerOverrides extends BuiltinLogger {} } */ + +// Uncomment these lines for introducing constraints on tags +/* +declare module "express-zod-api" { + interface TagOverrides { + users: unknown; + files: unknown; + subscriptions: unknown; + } +} +*/ diff --git a/example/endpoints/accept-raw.ts b/example/endpoints/accept-raw.ts index 1eaba31c2c..68d85d84af 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -1,8 +1,7 @@ import { z } from "zod"; -import { ez } from "../../src"; -import { taggedEndpointsFactory } from "../factories"; +import { defaultEndpointsFactory, ez } from "../../src"; -export const rawAcceptingEndpoint = taggedEndpointsFactory.build({ +export const rawAcceptingEndpoint = defaultEndpointsFactory.build({ method: "post", tag: "files", input: ez.raw({ diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 04f538fd24..24c0d66cc0 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -1,7 +1,7 @@ import createHttpError from "http-errors"; import assert from "node:assert/strict"; import { z } from "zod"; -import { taggedEndpointsFactory } from "../factories"; +import { defaultEndpointsFactory } from "../../src"; import { methodProviderMiddleware } from "../middlewares"; // Demonstrating circular schemas using z.lazy() @@ -15,7 +15,7 @@ const feature: z.ZodType = baseFeature.extend({ features: z.lazy(() => feature.array()), }); -export const retrieveUserEndpoint = taggedEndpointsFactory +export const retrieveUserEndpoint = defaultEndpointsFactory .addMiddleware(methodProviderMiddleware) .build({ tag: "users", diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts index a915fc336c..103a7179f7 100644 --- a/example/endpoints/upload-avatar.ts +++ b/example/endpoints/upload-avatar.ts @@ -1,9 +1,8 @@ import { z } from "zod"; -import { ez } from "../../src"; +import { defaultEndpointsFactory, ez } from "../../src"; import { createHash } from "node:crypto"; -import { taggedEndpointsFactory } from "../factories"; -export const uploadAvatarEndpoint = taggedEndpointsFactory.build({ +export const uploadAvatarEndpoint = defaultEndpointsFactory.build({ method: "post", tag: "files", description: "Handles a file upload.", diff --git a/example/example.client.ts b/example/example.client.ts index 9893a1d9ab..b6f0889ddf 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -401,19 +401,6 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; - -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { - "get /v1/user/retrieve": true, - "patch /v1/user/:id": true, - "post /v1/user/create": true, - "get /v1/user/list": true, - "post /v1/avatar/upload": true, - "post /v1/avatar/raw": true, -}; - export const endpointTags = { "get /v1/user/retrieve": ["users"], "delete /v1/user/:id/remove": ["users"], @@ -427,57 +414,37 @@ export const endpointTags = { "get /v1/events/time": ["subscriptions"], }; +const parseRequest = (request: string) => + request.split(/ (.+)/, 2) as [Method, Path]; + +const substitute = (path: string, params: Record) => { + const rest = { ...params }; + for (const key in params) { + path = path.replace(`:${key}`, () => { + delete rest[key]; + return params[key]; + }); + } + return [path, rest] as const; +}; + export type Implementation = ( method: Method, path: string, params: Record, ) => Promise; -export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} - /** @deprecated use the overload with 2 arguments instead */ - public provide( - method: M, - path: P, - params: `${M} ${P}` extends keyof Input - ? Input[`${M} ${P}`] - : Record, - ): Promise< - `${M} ${P}` extends keyof Response ? Response[`${M} ${P}`] : unknown - >; +export class Client { + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], - ): Promise; - public provide( - ...args: - | [string, string, Record] - | [string, Record] - ) { - const [method, path, params] = ( - args.length === 2 ? [...args[0].split(/ (.+)/, 2), args[1]] : args - ) as [Method, Path, Record]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => acc.replace(`:${key}`, params[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(`:${key}`) && { [key]: params[key] }, - ), - {}, - ), - ); + ): Promise { + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -500,6 +467,6 @@ export const exampleImplementation: Implementation = async ( const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; -const client = new ExpressZodAPIClient(exampleImplementation); +const client = new Client(exampleImplementation); client.provide("get /v1/user/retrieve", { id: "10" }); */ diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index b4842829b8..3f857322f1 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 21.11.1 + version: 22.0.0-beta.4 paths: /v1/user/retrieve: get: diff --git a/example/factories.ts b/example/factories.ts index 3e312428e2..1d0efafc7c 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -2,30 +2,22 @@ import { EndpointsFactory, arrayResultHandler, ResultHandler, - defaultResultHandler, ez, ensureHttpError, EventStreamFactory, + defaultEndpointsFactory, } from "../src"; -import { config } from "./config"; import { authMiddleware } from "./middlewares"; import { createReadStream } from "node:fs"; import { z } from "zod"; -/** @desc The factory assures the endpoints tagging constraints from config */ -export const taggedEndpointsFactory = new EndpointsFactory({ - resultHandler: defaultResultHandler, - config, -}); - -/** @desc This one extends the previous one by enforcing the authentication using the specified middleware */ +/** @desc This factory extends the default one by enforcing the authentication using the specified middleware */ export const keyAndTokenAuthenticatedEndpointsFactory = - taggedEndpointsFactory.addMiddleware(authMiddleware); + defaultEndpointsFactory.addMiddleware(authMiddleware); /** @desc This factory sends the file as string located in the "data" property of the endpoint's output */ -export const fileSendingEndpointsFactory = new EndpointsFactory({ - config, - resultHandler: new ResultHandler({ +export const fileSendingEndpointsFactory = new EndpointsFactory( + new ResultHandler({ positive: { schema: z.string(), mimeType: "image/svg+xml" }, negative: { schema: z.string(), mimeType: "text/plain" }, handler: ({ response, error, output }) => { @@ -35,12 +27,11 @@ export const fileSendingEndpointsFactory = new EndpointsFactory({ else response.status(400).send("Data is missing"); }, }), -}); +); /** @desc This one streams the file using the "filename" property of the endpoint's output */ -export const fileStreamingEndpointsFactory = new EndpointsFactory({ - config, - resultHandler: new ResultHandler({ +export const fileStreamingEndpointsFactory = new EndpointsFactory( + new ResultHandler({ positive: { schema: ez.file("buffer"), mimeType: "image/*" }, negative: { schema: z.string(), mimeType: "text/plain" }, handler: ({ response, error, output }) => { @@ -50,22 +41,18 @@ export const fileStreamingEndpointsFactory = new EndpointsFactory({ else response.status(400).send("Filename is missing"); }, }), -}); +); /** * @desc This factory demonstrates the ability to respond with array. * @deprecated Avoid doing this in new projects. This feature is only for easier migration of legacy APIs. * @alias arrayEndpointsFactory */ -export const arrayRespondingFactory = new EndpointsFactory({ - config, - resultHandler: arrayResultHandler, -}); +export const arrayRespondingFactory = new EndpointsFactory(arrayResultHandler); /** @desc The factory demonstrates slightly different response schemas depending on the negative status code */ -export const statusDependingFactory = new EndpointsFactory({ - config, - resultHandler: new ResultHandler({ +export const statusDependingFactory = new EndpointsFactory( + new ResultHandler({ positive: (data) => ({ statusCode: [201, 202], schema: z.object({ status: z.literal("created"), data }), @@ -98,21 +85,19 @@ export const statusDependingFactory = new EndpointsFactory({ response.status(201).json({ status: "created", data: output }); }, }), -}); +); /** @desc This factory demonstrates response without body, such as 204 No Content */ -export const noContentFactory = new EndpointsFactory({ - config, - resultHandler: new ResultHandler({ +export const noContentFactory = new EndpointsFactory( + new ResultHandler({ positive: { statusCode: 204, mimeType: null, schema: z.never() }, negative: { statusCode: 404, mimeType: null, schema: z.never() }, handler: ({ error, response }) => { response.status(error ? ensureHttpError(error).statusCode : 204).end(); // no content }, }), -}); +); export const eventsFactory = new EventStreamFactory({ - config, - events: { time: z.number().int().positive() }, + time: z.number().int().positive(), }); diff --git a/example/generate-documentation.ts b/example/generate-documentation.ts index 4694a49299..39e0c55c3e 100644 --- a/example/generate-documentation.ts +++ b/example/generate-documentation.ts @@ -12,6 +12,11 @@ await writeFile( version: manifest.version, title: "Example API", serverUrl: "https://example.com", + tags: { + users: "Everything about the users", + files: "Everything about the files processing", + subscriptions: "Everything about the subscriptions", + }, }).getSpecAsYaml(), "utf-8", ); diff --git a/package.json b/package.json index 04e8d9da0c..029d06f85e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "21.11.1", + "version": "22.0.0-beta.4", "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": { @@ -72,7 +72,7 @@ "*.md" ], "engines": { - "node": "^18.18.0 || ^20.9.0 || ^22.0.0" + "node": "^20.9.0 || ^22.0.0" }, "dependencies": { "ansis": "^3.2.0", @@ -115,7 +115,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.17.0", "@eslint/eslintrc": "^3", - "@tsconfig/node18": "^18.2.1", + "@tsconfig/node20": "^20.1.4", "@types/compression": "^1.7.5", "@types/cors": "^2.8.14", "@types/depd": "^1.1.36", diff --git a/src/common-helpers.ts b/src/common-helpers.ts index fb972a6ad5..a2a43f9266 100644 --- a/src/common-helpers.ts +++ b/src/common-helpers.ts @@ -1,5 +1,5 @@ import { Request } from "express"; -import { chain, memoizeWith, pickBy, xprod } from "ramda"; +import { chain, memoizeWith, xprod } from "ramda"; import { z } from "zod"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { contentTypes } from "./content-type"; @@ -12,6 +12,18 @@ export type EmptyObject = Record; export type EmptySchema = z.ZodObject; export type FlatObject = Record; +/** @link https://stackoverflow.com/a/65492934 */ +type NoNever = [T] extends [never] ? F : T; + +/** + * @desc Using module augmentation approach you can specify tags as the keys of this interface + * @example declare module "express-zod-api" { interface TagOverrides { users: unknown } } + * @link https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + * */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation +export interface TagOverrides {} +export type Tag = NoNever; + const areFilesAvailable = (request: Request): boolean => { const contentType = request.header("content-type") || ""; const isUpload = contentType.toLowerCase().startsWith(contentTypes.upload); @@ -30,13 +42,6 @@ const fallbackInputSource: InputSource[] = ["body", "query", "params"]; export const getActualMethod = (request: Request) => request.method.toLowerCase() as Method | AuxMethod; -export const isCustomHeader = (name: string): name is `x-${string}` => - name.startsWith("x-"); - -/** @see https://nodejs.org/api/http.html#messageheaders */ -export const getCustomHeaders = (headers: FlatObject): FlatObject => - pickBy((_, key) => isCustomHeader(key), headers); // twice faster than flip() - export const getInput = ( req: Request, userDefined: CommonConfig["inputSources"] = {}, @@ -49,8 +54,7 @@ export const getInput = ( fallbackInputSource ) .filter((src) => (src === "files" ? areFilesAvailable(req) : true)) - .map((src) => (src === "headers" ? getCustomHeaders(req[src]) : req[src])) - .reduce((agg, obj) => Object.assign(agg, obj), {}); + .reduce((agg, src) => Object.assign(agg, req[src]), {}); }; export const ensureError = (subject: unknown): Error => diff --git a/src/config-type.ts b/src/config-type.ts index 519dad5f78..ce46d61e0c 100644 --- a/src/config-type.ts +++ b/src/config-type.ts @@ -25,17 +25,12 @@ type HeadersProvider = (params: { logger: ActualLogger; }) => Headers | Promise; -export type TagsConfig = Record< - TAG, - string | { description: string; url?: string } ->; - type ChildLoggerProvider = (params: { request: Request; parent: ActualLogger; }) => ActualLogger | Promise; -export interface CommonConfig { +export interface CommonConfig { /** * @desc Enables cross-origin resource sharing. * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS @@ -71,11 +66,6 @@ export interface CommonConfig { * @see defaultInputSources */ inputSources?: Partial; - /** - * @desc Optional endpoints tagging configuration. - * @example: { users: "Everything about the users" } - */ - tags?: TagsConfig; } type BeforeUpload = (params: { @@ -145,8 +135,7 @@ interface HttpsConfig extends HttpConfig { options: ServerOptions; } -export interface ServerConfig - extends CommonConfig { +export interface ServerConfig extends CommonConfig { /** @desc HTTP server configuration. */ http?: HttpConfig; /** @desc HTTPS server configuration. */ @@ -190,18 +179,13 @@ export interface ServerConfig gracefulShutdown?: boolean | GracefulOptions; } -export interface AppConfig - extends CommonConfig { +export interface AppConfig extends CommonConfig { /** @desc Your custom express app or express router instead. */ app: IRouter; } -export function createConfig( - config: ServerConfig, -): ServerConfig; -export function createConfig( - config: AppConfig, -): AppConfig; +export function createConfig(config: ServerConfig): ServerConfig; +export function createConfig(config: AppConfig): AppConfig; export function createConfig(config: AppConfig | ServerConfig) { return config; } diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 226d48ec30..40bffb0cd2 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -2,7 +2,6 @@ import { ExamplesObject, MediaTypeObject, OAuthFlowObject, - ParameterLocation, ParameterObject, ReferenceObject, RequestBodyObject, @@ -45,12 +44,12 @@ import { combinations, getExamples, hasCoercion, - isCustomHeader, makeCleanId, tryToTransform, ucFirst, + Tag, } from "./common-helpers"; -import { InputSource, TagsConfig } from "./config-type"; +import { InputSource } from "./config-type"; import { DateInSchema, ezDateInBrand } from "./date-in-schema"; import { DateOutSchema, ezDateOutBrand } from "./date-out-schema"; import { DocumentationError } from "./errors"; @@ -68,6 +67,7 @@ import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, SchemaHandler, walkSchema } from "./schema-walker"; import { Security } from "./security"; import { UploadSchema, ezUploadBrand } from "./upload-schema"; +import wellKnownHeaders from "./well-known-headers.json"; export interface OpenAPIContext extends FlatObject { isResponse: boolean; @@ -88,6 +88,13 @@ export type Depicter = SchemaHandler< OpenAPIContext >; +/** @desc Using defaultIsHeader when returns null or undefined */ +export type IsHeader = ( + name: string, + method: Method, + path: string, +) => boolean | null | undefined; + interface ReqResHandlingProps extends Pick { schema: S; @@ -624,6 +631,9 @@ export const extractObjectSchema = ( ); }; +export const defaultIsHeader = (name: string): name is `x-${string}` => + name.startsWith("x-") || wellKnownHeaders.includes(name); + export const depictRequestParams = ({ path, method, @@ -632,9 +642,11 @@ export const depictRequestParams = ({ makeRef, composition, brandHandling, + isHeader, description = `${method.toUpperCase()} ${path} Parameter`, }: ReqResHandlingProps & { inputSources: InputSource[]; + isHeader?: IsHeader; }) => { const { shape } = extractObjectSchema(schema); const pathParams = getRoutePathParams(path); @@ -644,25 +656,18 @@ export const depictRequestParams = ({ const isPathParam = (name: string) => areParamsEnabled && pathParams.includes(name); const isHeaderParam = (name: string) => - areHeadersEnabled && isCustomHeader(name); - - const parameters = Object.keys(shape) - .map<{ name: string; location?: ParameterLocation }>((name) => ({ - name, - location: isPathParam(name) - ? "path" - : isHeaderParam(name) - ? "header" - : isQueryEnabled - ? "query" - : undefined, - })) - .filter( - (parameter): parameter is Required => - parameter.location !== undefined, - ); - - return parameters.map(({ name, location }) => { + areHeadersEnabled && + (isHeader?.(name, method, path) ?? defaultIsHeader(name)); + + return Object.keys(shape).reduce((acc, name) => { + const location = isPathParam(name) + ? "path" + : isHeaderParam(name) + ? "header" + : isQueryEnabled + ? "query" + : undefined; + if (!location) return acc; const depicted = walkSchema(shape[name], { rules: { ...brandHandling, ...depicters }, onEach, @@ -673,15 +678,15 @@ export const depictRequestParams = ({ composition === "components" ? makeRef(shape[name], depicted, makeCleanId(description, name)) : depicted; - return { + return acc.concat({ name, in: location, required: !shape[name].isOptional(), description: depicted.description || description, schema: result, examples: depictParamExamples(schema, name), - }; - }); + }); + }, []); }; export const depicters: HandlingRules< @@ -964,19 +969,19 @@ export const depictBody = ({ return { description, content: { [mimeType]: media } }; }; -export const depictTags = ( - tags: TagsConfig, -): TagObject[] => - (Object.keys(tags) as TAG[]).map((tag) => { - const def = tags[tag]; - const result: TagObject = { +export const depictTags = ( + tags: Partial>, +) => + Object.entries(tags).reduce((agg, [tag, def]) => { + if (!def) return agg; + const entry: TagObject = { name: tag, description: typeof def === "string" ? def : def.description, }; if (typeof def === "object" && def.url) - result.externalDocs = { url: def.url }; - return result; - }); + entry.externalDocs = { url: def.url }; + return agg.concat(entry); + }, []); export const ensureShortDescription = (description: string) => description.length <= shortDescriptionLimit diff --git a/src/documentation.ts b/src/documentation.ts index 709f591398..c0c4f153dd 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -13,7 +13,7 @@ import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { defaultInputSources, makeCleanId } from "./common-helpers"; import { CommonConfig } from "./config-type"; -import { mapLogicalContainer } from "./logical-container"; +import { combineContainers, mapLogicalContainer } from "./logical-container"; import { Method } from "./method"; import { OpenAPIContext, @@ -25,6 +25,7 @@ import { depictTags, ensureShortDescription, reformatParamsInPath, + IsHeader, } from "./documentation-helpers"; import { Routing } from "./routing"; import { OnEndpoint, walkRouting } from "./routing-walker"; @@ -66,6 +67,19 @@ interface DocumentationParams { * @example { MyBrand: ( schema: typeof myBrandSchema, { next } ) => ({ type: "object" }) */ brandHandling?: HandlingRules; + /** + * @desc Ability to configure recognition of headers among other input data + * @desc Only applicable when "headers" is present within inputSources config option + * @see defaultIsHeader + * @link https://www.iana.org/assignments/http-fields/http-fields.xhtml + * */ + isHeader?: IsHeader; + /** + * @desc Extended description of tags used in endpoints. For enforcing constraints: + * @see TagOverrides + * @example { users: "About users", files: { description: "About files", url: "https://example.com" } } + * */ + tags?: Parameters[0]; } export class Documentation extends OpenApiBuilder { @@ -135,6 +149,8 @@ export class Documentation extends OpenApiBuilder { serverUrl, descriptions, brandHandling, + tags, + isHeader, hasSummaryFromDescription = true, composition = "inline", }: DocumentationParams) { @@ -171,6 +187,7 @@ export class Documentation extends OpenApiBuilder { const depictedParams = depictRequestParams({ ...commons, inputSources, + isHeader, schema: endpoint.getSchema("input"), description: descriptions?.requestParameter?.call(null, { method, @@ -219,7 +236,10 @@ export class Documentation extends OpenApiBuilder { const securityRefs = depictSecurityRefs( mapLogicalContainer( - depictSecurity(endpoint.getSecurity(), inputSources), + depictSecurity( + endpoint.getSecurity().reduce(combineContainers, { and: [] }), + inputSources, + ), (securitySchema) => { const name = this.ensureUniqSecuritySchemaName(securitySchema); const scopes = ["oauth2", "openIdConnect"].includes( @@ -247,6 +267,6 @@ export class Documentation extends OpenApiBuilder { }); }; walkRouting({ routing, onEndpoint }); - this.rootDoc.tags = config.tags ? depictTags(config.tags) : []; + if (tags) this.rootDoc.tags = depictTags(tags); } } diff --git a/src/endpoint.ts b/src/endpoint.ts index 027f6c25b6..f1234ef04d 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -17,7 +17,7 @@ import { import { IOSchema } from "./io-schema"; import { lastResortHandler } from "./last-resort"; import { ActualLogger } from "./logger-helpers"; -import { LogicalContainer, combineContainers } from "./logical-container"; +import { LogicalContainer } from "./logical-container"; import { AuxMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; import { ContentType } from "./content-type"; @@ -49,7 +49,7 @@ export abstract class AbstractEndpoint extends Nesting { public abstract getResponses( variant: ResponseVariant, ): ReadonlyArray; - public abstract getSecurity(): LogicalContainer; + public abstract getSecurity(): LogicalContainer[]; public abstract getScopes(): ReadonlyArray; public abstract getTags(): ReadonlyArray; public abstract getOperationId(method: Method): string | undefined; @@ -145,13 +145,9 @@ export class Endpoint< } public override getSecurity() { - return this.#middlewares.reduce>( - (acc, middleware) => { - const security = middleware.getSecurity(); - return security ? combineContainers(acc, security) : acc; - }, - { and: [] }, - ); + return this.#middlewares + .map((middleware) => middleware.getSecurity()) + .filter((entry) => entry !== undefined); } public override getScopes() { diff --git a/src/endpoints-factory.ts b/src/endpoints-factory.ts index 888129474b..7e38609e70 100644 --- a/src/endpoints-factory.ts +++ b/src/endpoints-factory.ts @@ -1,7 +1,6 @@ import { Request, Response } from "express"; import { z } from "zod"; -import { EmptyObject, EmptySchema, FlatObject } from "./common-helpers"; -import { CommonConfig } from "./config-type"; +import { EmptyObject, EmptySchema, FlatObject, Tag } from "./common-helpers"; import { Endpoint, Handler } from "./endpoint"; import { IOSchema, getFinalEndpointInputSchema } from "./io-schema"; import { Method } from "./method"; @@ -22,7 +21,6 @@ interface BuildProps< MIN extends IOSchema<"strip">, OPT extends FlatObject, SCO extends string, - TAG extends string, > { input?: IN; output: OUT; @@ -32,44 +30,23 @@ interface BuildProps< operationId?: string | ((method: Method) => string); method?: Method | [Method, ...Method[]]; scope?: SCO | SCO[]; - tag?: TAG | TAG[]; + tag?: Tag | Tag[]; } export class EndpointsFactory< IN extends IOSchema<"strip"> = EmptySchema, OUT extends FlatObject = EmptyObject, SCO extends string = string, - TAG extends string = string, > { - protected resultHandler: AbstractResultHandler; protected middlewares: AbstractMiddleware[] = []; - - /** @desc Consider using the "config" prop with the "tags" option to enforce constraints on tagging the endpoints */ - constructor(resultHandler: AbstractResultHandler); - /** @todo consider migrating tags into augmentation approach in v22 */ - constructor(params: { - resultHandler: AbstractResultHandler; - config?: CommonConfig; - }); - constructor( - subject: - | AbstractResultHandler - | { - resultHandler: AbstractResultHandler; - config?: CommonConfig; - }, - ) { - this.resultHandler = - "resultHandler" in subject ? subject.resultHandler : subject; - } + constructor(protected resultHandler: AbstractResultHandler) {} static #create< CIN extends IOSchema<"strip">, COUT extends FlatObject, CSCO extends string, - CTAG extends string, >(middlewares: AbstractMiddleware[], resultHandler: AbstractResultHandler) { - const factory = new EndpointsFactory(resultHandler); + const factory = new EndpointsFactory(resultHandler); factory.middlewares = middlewares; return factory; } @@ -86,8 +63,7 @@ export class EndpointsFactory< return EndpointsFactory.#create< z.ZodIntersection, OUT & AOUT, - SCO & ASCO, - TAG + SCO & ASCO >( this.middlewares.concat( subject instanceof Middleware ? subject : new Middleware(subject), @@ -103,14 +79,14 @@ export class EndpointsFactory< S extends Response, AOUT extends FlatObject = EmptyObject, >(...params: ConstructorParameters>) { - return EndpointsFactory.#create( + return EndpointsFactory.#create( this.middlewares.concat(new ExpressMiddleware(...params)), this.resultHandler, ); } public addOptions(getOptions: () => Promise) { - return EndpointsFactory.#create( + return EndpointsFactory.#create( this.middlewares.concat(new Middleware({ handler: getOptions })), this.resultHandler, ); @@ -126,7 +102,7 @@ export class EndpointsFactory< scope, tag, method, - }: BuildProps) { + }: BuildProps) { const { middlewares, resultHandler } = this; const methods = typeof method === "string" ? [method] : method; const getOperationId = @@ -152,7 +128,7 @@ export class EndpointsFactory< public buildVoid({ handler, ...rest - }: Omit, "output">) { + }: Omit, "output">) { return this.build({ ...rest, output: z.object({}), diff --git a/src/index.ts b/src/index.ts index fa6d315912..15493efd45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,9 +36,12 @@ export { ez } from "./proprietary-schemas"; export type { Depicter } from "./documentation-helpers"; export type { Producer } from "./zts-helpers"; +// Interfaces exposed for augmentation +export type { LoggerOverrides } from "./logger-helpers"; +export type { TagOverrides } from "./common-helpers"; + // Issues 952, 1182, 1269: Insufficient exports for consumer's declaration export type { Routing } from "./routing"; -export type { LoggerOverrides } from "./logger-helpers"; export type { FlatObject } from "./common-helpers"; export type { Method } from "./method"; export type { IOSchema } from "./io-schema"; diff --git a/src/integration-base.ts b/src/integration-base.ts new file mode 100644 index 0000000000..579bd7d42b --- /dev/null +++ b/src/integration-base.ts @@ -0,0 +1,466 @@ +import ts from "typescript"; +import { ResponseVariant } from "./api-response"; +import { contentTypes } from "./content-type"; +import { Method, methods } from "./method"; +import { + accessModifiers, + ensureTypeNode, + f, + makeArrowFn, + makeConst, + makeDeconstruction, + makeInterface, + makeInterfaceProp, + makeKeyOf, + makeNew, + makeParam, + makeParams, + makePromise, + makePropCall, + makePropertyIdentifier, + makePublicConstructor, + makePublicClass, + makePublicLiteralType, + makePublicMethod, + makeTemplate, + makeTernary, + makeType, + propOf, + recordStringAny, +} from "./typescript-api"; + +type IOKind = "input" | "response" | ResponseVariant | "encoded"; + +export abstract class IntegrationBase { + protected paths = new Set(); + protected tags = new Map>(); + protected registry = new Map>(); + + protected ids = { + pathType: f.createIdentifier("Path"), + implementationType: f.createIdentifier("Implementation"), + clientClass: f.createIdentifier("Client"), + keyParameter: f.createIdentifier("key"), + pathParameter: f.createIdentifier("path"), + paramsArgument: f.createIdentifier("params"), + methodParameter: f.createIdentifier("method"), + requestParameter: f.createIdentifier("request"), + parseRequestFn: f.createIdentifier("parseRequest"), + substituteFn: f.createIdentifier("substitute"), + provideMethod: f.createIdentifier("provide"), + implementationArgument: f.createIdentifier("implementation"), + hasBodyConst: f.createIdentifier("hasBody"), + undefinedValue: f.createIdentifier("undefined"), + responseConst: f.createIdentifier("response"), + restConst: f.createIdentifier("rest"), + searchParamsConst: f.createIdentifier("searchParams"), + exampleImplementationConst: f.createIdentifier("exampleImplementation"), + clientConst: f.createIdentifier("client"), + contentTypeConst: f.createIdentifier("contentType"), + isJsonConst: f.createIdentifier("isJSON"), + } satisfies Record; + + protected interfaces: Record = { + input: f.createIdentifier("Input"), + positive: f.createIdentifier("PositiveResponse"), + negative: f.createIdentifier("NegativeResponse"), + encoded: f.createIdentifier("EncodedResponse"), + response: f.createIdentifier("Response"), + }; + + // export type Method = "get" | "post" | "put" | "delete" | "patch"; + protected methodType = makePublicLiteralType("Method", methods); + + // type SomeOf = T[keyof T]; + protected someOfType = makeType( + "SomeOf", + f.createIndexedAccessTypeNode(ensureTypeNode("T"), makeKeyOf("T")), + { params: { T: undefined } }, + ); + + // export type Request = keyof Input; + protected requestType = makeType( + "Request", + makeKeyOf(this.interfaces.input), + { expose: true }, + ); + + protected constructor(private readonly serverUrl: string) {} + + /** @example SomeOf<_> */ + protected someOf = ({ name }: ts.TypeAliasDeclaration) => + f.createTypeReferenceNode(this.someOfType.name, [ensureTypeNode(name)]); + + // export type Path = "/v1/user/retrieve" | ___; + protected makePathType = () => + makePublicLiteralType(this.ids.pathType, Array.from(this.paths)); + + // export interface Input { "get /v1/user/retrieve": GetV1UserRetrieveInput; } + protected makePublicInterfaces = () => + (Object.keys(this.interfaces) as IOKind[]).map((kind) => + makeInterface( + this.interfaces[kind], + Array.from(this.registry).map(([request, faces]) => + makeInterfaceProp(request, faces[kind]), + ), + { expose: true }, + ), + ); + + // export const endpointTags = { "get /v1/user/retrieve": ["users"] } + protected makeEndpointTags = () => + makeConst( + "endpointTags", + f.createObjectLiteralExpression( + Array.from(this.tags).map(([request, tags]) => + f.createPropertyAssignment( + makePropertyIdentifier(request), + f.createArrayLiteralExpression( + tags.map((tag) => f.createStringLiteral(tag)), + ), + ), + ), + ), + { expose: true }, + ); + + // export type Implementation = (method: Method, path: string, params: Record) => Promise; + protected makeImplementationType = () => + makeType( + this.ids.implementationType, + f.createFunctionTypeNode( + undefined, + makeParams({ + [this.ids.methodParameter.text]: ensureTypeNode(this.methodType.name), + [this.ids.pathParameter.text]: f.createKeywordTypeNode( + ts.SyntaxKind.StringKeyword, + ), + [this.ids.paramsArgument.text]: recordStringAny, + }), + makePromise("any"), + ), + { expose: true }, + ); + + // const parseRequest = (request: string) => request.split(/ (.+)/, 2) as [Method, Path]; + protected makeParseRequestFn = () => + makeConst( + this.ids.parseRequestFn, + makeArrowFn( + { + [this.ids.requestParameter.text]: f.createKeywordTypeNode( + ts.SyntaxKind.StringKeyword, + ), + }, + f.createAsExpression( + makePropCall(this.ids.requestParameter, propOf("split"), [ + f.createRegularExpressionLiteral("/ (.+)/"), // split once + f.createNumericLiteral(2), // excludes third empty element + ]), + f.createTupleTypeNode([ + ensureTypeNode(this.methodType.name), + ensureTypeNode(this.ids.pathType), + ]), + ), + ), + ); + + // const substitute = (path: string, params: Record) => { ___ return [path, rest] as const; } + protected makeSubstituteFn = () => + makeConst( + this.ids.substituteFn, + makeArrowFn( + { + [this.ids.pathParameter.text]: f.createKeywordTypeNode( + ts.SyntaxKind.StringKeyword, + ), + [this.ids.paramsArgument.text]: recordStringAny, + }, + f.createBlock([ + makeConst( + this.ids.restConst, + f.createObjectLiteralExpression([ + f.createSpreadAssignment(this.ids.paramsArgument), + ]), + ), + f.createForInStatement( + f.createVariableDeclarationList( + [f.createVariableDeclaration(this.ids.keyParameter)], + ts.NodeFlags.Const, + ), + this.ids.paramsArgument, + f.createBlock([ + f.createExpressionStatement( + f.createBinaryExpression( + this.ids.pathParameter, + f.createToken(ts.SyntaxKind.EqualsToken), + makePropCall( + this.ids.pathParameter, + propOf("replace"), + [ + makeTemplate(":", [this.ids.keyParameter]), // `:${key}` + makeArrowFn( + [], + f.createBlock([ + f.createExpressionStatement( + f.createDeleteExpression( + f.createElementAccessExpression( + f.createIdentifier("rest"), + this.ids.keyParameter, + ), + ), + ), + f.createReturnStatement( + f.createElementAccessExpression( + this.ids.paramsArgument, + this.ids.keyParameter, + ), + ), + ]), + ), + ], + ), + ), + ), + ]), + ), + f.createReturnStatement( + f.createAsExpression( + f.createArrayLiteralExpression([ + this.ids.pathParameter, + this.ids.restConst, + ]), + ensureTypeNode("const"), + ), + ), + ]), + ), + ); + + // public provide(request: K, params: Input[K]): Promise {} + private makeProvider = () => + makePublicMethod( + this.ids.provideMethod, + makeParams({ + [this.ids.requestParameter.text]: ensureTypeNode("K"), + [this.ids.paramsArgument.text]: f.createIndexedAccessTypeNode( + ensureTypeNode(this.interfaces.input), + ensureTypeNode("K"), + ), + }), + f.createBlock([ + makeConst( + // const [method, path] = this.parseRequest(request); + makeDeconstruction(this.ids.methodParameter, this.ids.pathParameter), + f.createCallExpression(this.ids.parseRequestFn, undefined, [ + this.ids.requestParameter, + ]), + ), + // return this.implementation(___) + f.createReturnStatement( + makePropCall(f.createThis(), this.ids.implementationArgument, [ + this.ids.methodParameter, + f.createSpreadElement( + f.createCallExpression(this.ids.substituteFn, undefined, [ + this.ids.pathParameter, + this.ids.paramsArgument, + ]), + ), + ]), + ), + ]), + { + typeParams: { K: this.requestType.name }, + returns: makePromise( + f.createIndexedAccessTypeNode( + ensureTypeNode(this.interfaces.response), + ensureTypeNode("K"), + ), + ), + }, + ); + + // export class ExpressZodAPIClient { ___ } + protected makeClientClass = () => + makePublicClass(this.ids.clientClass, [ + // public constructor(protected readonly implementation: Implementation) {} + makePublicConstructor([ + makeParam(this.ids.implementationArgument, { + type: ensureTypeNode(this.ids.implementationType), + mod: accessModifiers.protectedReadonly, + }), + ]), + this.makeProvider(), + ]); + + // export const exampleImplementation: Implementation = async (method,path,params) => { ___ }; + protected makeExampleImplementation = () => { + // method: method.toUpperCase() + const methodProperty = f.createPropertyAssignment( + propOf("method"), + makePropCall(this.ids.methodParameter, propOf("toUpperCase")), + ); + + // headers: hasBody ? { "Content-Type": "application/json" } : undefined + const headersProperty = f.createPropertyAssignment( + propOf("headers"), + makeTernary( + this.ids.hasBodyConst, + f.createObjectLiteralExpression([ + f.createPropertyAssignment( + f.createStringLiteral("Content-Type"), + f.createStringLiteral(contentTypes.json), + ), + ]), + this.ids.undefinedValue, + ), + ); + + // body: hasBody ? JSON.stringify(params) : undefined + const bodyProperty = f.createPropertyAssignment( + propOf("body"), + makeTernary( + this.ids.hasBodyConst, + makePropCall( + f.createIdentifier(JSON[Symbol.toStringTag]), + propOf("stringify"), + [this.ids.paramsArgument], + ), + this.ids.undefinedValue, + ), + ); + + // const response = await fetch(new URL(`${path}${searchParams}`, "https://example.com"), { ___ }); + const responseStatement = makeConst( + this.ids.responseConst, + f.createAwaitExpression( + f.createCallExpression(f.createIdentifier(fetch.name), undefined, [ + makeNew( + f.createIdentifier(URL.name), + makeTemplate( + "", + [this.ids.pathParameter], + [this.ids.searchParamsConst], + ), + f.createStringLiteral(this.serverUrl), + ), + f.createObjectLiteralExpression([ + methodProperty, + headersProperty, + bodyProperty, + ]), + ]), + ), + ); + + // const hasBody = !["get", "delete"].includes(method); + const hasBodyStatement = makeConst( + this.ids.hasBodyConst, + f.createLogicalNot( + makePropCall( + f.createArrayLiteralExpression([ + f.createStringLiteral("get" satisfies Method), + f.createStringLiteral("delete" satisfies Method), + ]), + propOf("includes"), + [this.ids.methodParameter], + ), + ), + ); + + // const searchParams = hasBody ? "" : `?${new URLSearchParams(params)}`; + const searchParamsStatement = makeConst( + this.ids.searchParamsConst, + makeTernary( + this.ids.hasBodyConst, + f.createStringLiteral(""), + makeTemplate("?", [ + makeNew( + f.createIdentifier(URLSearchParams.name), + this.ids.paramsArgument, + ), + ]), + ), + ); + + // const contentType = response.headers.get("content-type"); + const contentTypeStatement = makeConst( + this.ids.contentTypeConst, + makePropCall( + [this.ids.responseConst, propOf("headers")], + propOf("get"), + [f.createStringLiteral("content-type")], + ), + ); + + // if (!contentType) return; + const noBodyStatement = f.createIfStatement( + f.createPrefixUnaryExpression( + ts.SyntaxKind.ExclamationToken, + this.ids.contentTypeConst, + ), + f.createReturnStatement(), + ); + + // const isJSON = contentType.startsWith("application/json"); + const isJsonConst = makeConst( + this.ids.isJsonConst, + makePropCall(this.ids.contentTypeConst, propOf("startsWith"), [ + f.createStringLiteral(contentTypes.json), + ]), + ); + + // return response[isJSON ? "json" : "text"](); + const returnStatement = f.createReturnStatement( + f.createCallExpression( + f.createElementAccessExpression( + this.ids.responseConst, + makeTernary( + this.ids.isJsonConst, + f.createStringLiteral(propOf("json")), + f.createStringLiteral(propOf("text")), + ), + ), + undefined, + [], + ), + ); + + return makeConst( + this.ids.exampleImplementationConst, + makeArrowFn( + [ + this.ids.methodParameter, + this.ids.pathParameter, + this.ids.paramsArgument, + ], + f.createBlock([ + hasBodyStatement, + searchParamsStatement, + responseStatement, + contentTypeStatement, + noBodyStatement, + isJsonConst, + returnStatement, + ]), + { isAsync: true }, + ), + { expose: true, type: ensureTypeNode(this.ids.implementationType) }, + ); + }; + + protected makeUsageStatements = (): ts.Node[] => [ + // const client = new Client(exampleImplementation); + makeConst( + this.ids.clientConst, + makeNew(this.ids.clientClass, this.ids.exampleImplementationConst), + ), + // client.provide("get /v1/user/retrieve", { id: "10" }); + makePropCall(this.ids.clientConst, this.ids.provideMethod, [ + f.createStringLiteral(`${"get" satisfies Method} /v1/user/retrieve`), + f.createObjectLiteralExpression([ + f.createPropertyAssignment("id", f.createStringLiteral("10")), + ]), + ]), + ]; +} diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts deleted file mode 100644 index e8959e712b..0000000000 --- a/src/integration-helpers.ts +++ /dev/null @@ -1,312 +0,0 @@ -import ts from "typescript"; -import { addJsDocComment, makePropertyIdentifier } from "./zts-helpers"; - -export const f = ts.factory; - -export const exportModifier = [f.createModifier(ts.SyntaxKind.ExportKeyword)]; - -const asyncModifier = [f.createModifier(ts.SyntaxKind.AsyncKeyword)]; - -const publicModifier = [f.createModifier(ts.SyntaxKind.PublicKeyword)]; - -export const protectedReadonlyModifier = [ - f.createModifier(ts.SyntaxKind.ProtectedKeyword), - f.createModifier(ts.SyntaxKind.ReadonlyKeyword), -]; - -export const restToken = f.createToken(ts.SyntaxKind.DotDotDotToken); - -const emptyHeading = f.createTemplateHead(""); -const spacingMiddle = f.createTemplateMiddle(" "); -export const emptyTail = f.createTemplateTail(""); - -// Record -export const recordStringAny = f.createExpressionWithTypeArguments( - f.createIdentifier("Record"), - [ - f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), - ], -); - -const makeTemplateType = (names: Array) => - f.createTemplateLiteralType( - emptyHeading, - names.map((name, index) => - f.createTemplateLiteralTypeSpan( - f.createTypeReferenceNode(name), - index === names.length - 1 ? emptyTail : spacingMiddle, - ), - ), - ); - -export const parametricIndexNode = makeTemplateType(["M", "P"]); // `${M} ${P}` - -export const makeParam = ( - name: ts.Identifier, - type?: ts.TypeNode, - features?: ts.Modifier[] | ts.DotDotDotToken, -) => - f.createParameterDeclaration( - Array.isArray(features) ? features : undefined, - Array.isArray(features) ? undefined : features, - name, - undefined, - type, - undefined, - ); - -export const makeParams = ( - params: Record, - features?: ts.Modifier[] | ts.DotDotDotToken, -) => - Object.entries(params).map(([name, node]) => - makeParam(f.createIdentifier(name), node, features), - ); - -export const makeEmptyInitializingConstructor = ( - params: ts.ParameterDeclaration[], -) => f.createConstructorDeclaration(undefined, params, f.createBlock([])); - -export const makeInterfaceProp = (name: string | number, value: ts.TypeNode) => - f.createPropertySignature( - undefined, - makePropertyIdentifier(name), - undefined, - value, - ); - -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: ts.Identifier | ts.ArrayBindingPattern, - value: ts.Expression, - type?: ts.TypeNode, -) => - f.createVariableDeclarationList( - [f.createVariableDeclaration(name, undefined, type, value)], - ts.NodeFlags.Const, - ); - -export const makePublicLiteralType = ( - name: ts.Identifier | string, - literals: string[], -) => - makeType( - name, - f.createUnionTypeNode( - literals.map((option) => - f.createLiteralTypeNode(f.createStringLiteral(option)), - ), - ), - { isPublic: true }, - ); - -export const makeType = ( - name: ts.Identifier | string, - value: ts.TypeNode, - { - isPublic, - comment, - params, - }: { - isPublic?: boolean; - comment?: string; - params?: Parameters[0]; - } = {}, -) => { - const node = f.createTypeAliasDeclaration( - isPublic ? exportModifier : undefined, - name, - params && makeTypeParams(params), - value, - ); - return comment ? addJsDocComment(node, comment) : node; -}; - -/** @example type SomeOf = T[keyof T]; */ -export const makeSomeOfHelper = () => - makeType( - "SomeOf", - f.createIndexedAccessTypeNode( - f.createTypeReferenceNode("T"), - makeKeyOf("T"), - ), - { params: { T: undefined } }, - ); - -export const makePublicMethod = ( - name: ts.Identifier, - params: ts.ParameterDeclaration[], - body?: ts.Block, - typeParams?: ts.TypeParameterDeclaration[], - returnType?: ts.TypeNode, -) => - f.createMethodDeclaration( - publicModifier, - undefined, - name, - undefined, - typeParams, - params, - returnType, - body, - ); - -export const makePublicClass = ( - name: ts.Identifier, - constructor: ts.ConstructorDeclaration, - statements: ts.MethodDeclaration[], -) => - f.createClassDeclaration(exportModifier, name, undefined, undefined, [ - constructor, - ...statements, - ]); - -export const makeKeyOf = (id: ts.Identifier | string) => - f.createTypeOperatorNode( - ts.SyntaxKind.KeyOfKeyword, - f.createTypeReferenceNode(id), - ); - -export const makeConditionalIndex = ( - subject: ts.Identifier, - key: ts.TypeNode, - fallback: ts.TypeNode, -) => - f.createConditionalTypeNode( - key, - makeKeyOf(subject), - f.createIndexedAccessTypeNode(f.createTypeReferenceNode(subject), key), - fallback, - ); - -export const makePromise = (subject: ts.TypeNode | "any") => - f.createTypeReferenceNode(Promise.name, [ - subject === "any" - ? f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword) - : subject, - ]); - -export const makeInterface = ( - name: ts.Identifier | string, - props: ts.PropertySignature[], - { isPublic, comment }: { isPublic?: boolean; comment?: string } = {}, -) => { - const node = f.createInterfaceDeclaration( - isPublic ? exportModifier : undefined, - name, - undefined, - undefined, - props, - ); - return comment ? addJsDocComment(node, comment) : node; -}; - -export const makeTypeParams = ( - params: Partial>, -) => - Object.entries(params).map(([name, id]) => - f.createTypeParameterDeclaration( - [], - name, - id && f.createTypeReferenceNode(id), - ), - ); - -export const makeArrowFn = ( - params: ts.Identifier[], - body: ts.ConciseBody, - isAsync?: boolean, -) => - f.createArrowFunction( - isAsync ? asyncModifier : undefined, - undefined, - params.map((key) => makeParam(key)), - undefined, - undefined, - body, - ); - -export const makeObjectKeysReducer = ( - obj: ts.Identifier, - exp: ts.Expression, - initial: ts.Expression, -) => - f.createCallExpression( - f.createPropertyAccessExpression( - f.createCallExpression( - f.createPropertyAccessExpression( - f.createIdentifier(Object.name), - propOf("keys"), - ), - undefined, - [obj], - ), - propOf("reduce"), - ), - undefined, - [ - f.createArrowFunction( - undefined, - undefined, - makeParams({ acc: undefined, key: undefined }), - undefined, - undefined, - exp, - ), - initial, - ], - ); - -export const propOf = (name: keyof NoInfer) => name as string; - -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 makePropCall = ( - parent: ts.Expression | [ts.Expression, ts.Identifier], - child: ts.Identifier | string, - args?: ts.Expression[], -) => - f.createCallExpression( - f.createPropertyAccessExpression( - Array.isArray(parent) - ? f.createPropertyAccessExpression(...parent) - : parent, - child, - ), - undefined, - args, - ); - -export const makeAnd = (left: ts.Expression, right: ts.Expression) => - f.createBinaryExpression( - left, - f.createToken(ts.SyntaxKind.AmpersandAmpersandToken), - right, - ); - -export const makeEqual = (left: ts.Expression, right: ts.Expression) => - f.createBinaryExpression( - left, - f.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), - right, - ); diff --git a/src/integration.ts b/src/integration.ts index 8c1faae3ec..753486a743 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -2,56 +2,24 @@ import { chain } from "ramda"; import ts from "typescript"; import { z } from "zod"; import { ResponseVariant, responseVariants } from "./api-response"; +import { IntegrationBase } from "./integration-base"; import { - emptyTail, - exportModifier, f, - makePromise, - makeArrowFn, - makeConditionalIndex, - makeConst, - makeDeconstruction, - makeEmptyInitializingConstructor, makeInterfaceProp, - makeObjectKeysReducer, - makeParam, - makeParams, - makePropCall, - makePublicClass, makeInterface, - makePublicLiteralType, - makePublicMethod, makeType, - makeTernary, - makeTypeParams, - parametricIndexNode, - propOf, - protectedReadonlyModifier, - recordStringAny, - restToken, - makeAnd, - makeEqual, - makeKeyOf, - makeSomeOfHelper, -} from "./integration-helpers"; + printNode, + ensureTypeNode, +} from "./typescript-api"; import { makeCleanId } from "./common-helpers"; -import { Method, methods } from "./method"; -import { contentTypes } from "./content-type"; import { loadPeer } from "./peer-helpers"; import { Routing } from "./routing"; import { OnEndpoint, walkRouting } from "./routing-walker"; import { HandlingRules } from "./schema-walker"; import { zodToTs } from "./zts"; -import { - ZTSContext, - printNode, - addJsDocComment, - makePropertyIdentifier, -} from "./zts-helpers"; +import { ZTSContext } from "./zts-helpers"; import type Prettier from "prettier"; -type IOKind = "input" | "response" | ResponseVariant | "encoded"; - interface IntegrationParams { routing: Routing; /** @@ -66,11 +34,6 @@ interface IntegrationParams { * @default https://example.com * */ serverUrl?: string; - /** - * @todo remove in v22 - * @deprecated - * */ - splitResponse?: boolean; /** * @desc configures the style of object's optional properties * @default { withQuestionMark: true, withUndefined: true } @@ -111,74 +74,15 @@ interface FormattedPrintingOptions { format?: (program: string) => Promise; } -export class Integration { - protected someOf = makeSomeOfHelper(); - protected program: ts.Node[] = [this.someOf]; +export class Integration extends IntegrationBase { + protected program: ts.Node[] = [this.someOfType]; protected usage: Array = []; - protected registry = new Map< - string, // request (method+path) - Record & { - isJson: boolean; - tags: ReadonlyArray; - } - >(); - protected paths = new Set(); protected aliases = new Map(); - protected ids = { - pathType: f.createIdentifier("Path"), - methodType: f.createIdentifier("Method"), - requestType: f.createIdentifier("Request"), - /** @todo remove in v22 */ - methodPathType: f.createIdentifier("MethodPath"), - inputInterface: f.createIdentifier("Input"), - posResponseInterface: f.createIdentifier("PositiveResponse"), - negResponseInterface: f.createIdentifier("NegativeResponse"), - encResponseInterface: f.createIdentifier("EncodedResponse"), - responseInterface: f.createIdentifier("Response"), - /** @todo remove in v22 */ - jsonEndpointsConst: f.createIdentifier("jsonEndpoints"), - endpointTagsConst: f.createIdentifier("endpointTags"), - /** @todo remove in v22 */ - providerType: f.createIdentifier("Provider"), - implementationType: f.createIdentifier("Implementation"), - clientClass: f.createIdentifier("ExpressZodAPIClient"), - keyParameter: f.createIdentifier("key"), - pathParameter: f.createIdentifier("path"), - paramsArgument: f.createIdentifier("params"), - methodParameter: f.createIdentifier("method"), - requestParameter: f.createIdentifier("request"), - /** @todo use request and params in v22 */ - args: f.createIdentifier("args"), - accumulator: f.createIdentifier("acc"), - provideMethod: f.createIdentifier("provide"), - implementationArgument: f.createIdentifier("implementation"), - headersProperty: f.createIdentifier("headers"), - hasBodyConst: f.createIdentifier("hasBody"), - undefinedValue: f.createIdentifier("undefined"), - bodyProperty: f.createIdentifier("body"), - responseConst: f.createIdentifier("response"), - searchParamsConst: f.createIdentifier("searchParams"), - exampleImplementationConst: f.createIdentifier("exampleImplementation"), - clientConst: f.createIdentifier("client"), - contentTypeConst: f.createIdentifier("contentType"), - isJsonConst: f.createIdentifier("isJSON"), - } satisfies Record; - protected interfaces: Array<{ - id: ts.Identifier; - kind: IOKind; - props: ts.PropertySignature[]; - }> = [ - { id: this.ids.inputInterface, kind: "input", props: [] }, - { id: this.ids.posResponseInterface, kind: "positive", props: [] }, - { id: this.ids.negResponseInterface, kind: "negative", props: [] }, - { id: this.ids.encResponseInterface, kind: "encoded", props: [] }, - { id: this.ids.responseInterface, kind: "response", props: [] }, - ]; protected makeAlias( schema: z.ZodTypeAny, produce: () => ts.TypeNode, - ): ts.TypeReferenceNode { + ): ts.TypeNode { let name = this.aliases.get(schema)?.name?.text; if (!name) { name = `Type${this.aliases.size + 1}`; @@ -186,15 +90,9 @@ export class Integration { this.aliases.set(schema, makeType(name, temp)); this.aliases.set(schema, makeType(name, produce())); } - return f.createTypeReferenceNode(name); + return ensureTypeNode(name); } - /** @example SomeOf<_>*/ - protected makeSomeOf = ({ name }: ts.TypeAliasDeclaration) => - f.createTypeReferenceNode(this.someOf.name, [ - f.createTypeReferenceNode(name), - ]); - public constructor({ routing, brandHandling, @@ -203,6 +101,7 @@ export class Integration { optionalPropStyle = { withQuestionMark: true, withUndefined: true }, noContent = z.undefined(), }: IntegrationParams) { + super(serverUrl); const commons = { makeAlias: this.makeAlias.bind(this), optionalPropStyle }; const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } }; const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; @@ -226,10 +125,7 @@ export class Integration { ); this.program.push(variantType); return statusCodes.map((code) => - makeInterfaceProp( - code, - f.createTypeReferenceNode(variantType.name), - ), + makeInterfaceProp(code, variantType.name), ); }, Array.from(responses.entries())); const dict = makeInterface( @@ -243,562 +139,52 @@ export class Integration { {} as Record, ); this.paths.add(path); - const isJson = endpoint - .getResponses("positive") - .some(({ mimeTypes }) => mimeTypes?.includes(contentTypes.json)); const literalIdx = f.createLiteralTypeNode( f.createStringLiteral(request), ); this.registry.set(request, { - input: f.createTypeReferenceNode(input.name), - positive: this.makeSomeOf(dictionaries.positive), - negative: this.makeSomeOf(dictionaries.negative), + input: ensureTypeNode(input.name), + positive: this.someOf(dictionaries.positive), + negative: this.someOf(dictionaries.negative), response: f.createUnionTypeNode([ f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.posResponseInterface), + ensureTypeNode(this.interfaces.positive), literalIdx, ), f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.negResponseInterface), + ensureTypeNode(this.interfaces.negative), literalIdx, ), ]), encoded: f.createIntersectionTypeNode([ - f.createTypeReferenceNode(dictionaries.positive.name), - f.createTypeReferenceNode(dictionaries.negative.name), + ensureTypeNode(dictionaries.positive.name), + ensureTypeNode(dictionaries.negative.name), ]), - tags: endpoint.getTags(), - isJson, }); + this.tags.set(request, endpoint.getTags()); }; walkRouting({ routing, onEndpoint }); this.program.unshift(...this.aliases.values()); - - // export type Path = "/v1/user/retrieve" | ___; this.program.push( - makePublicLiteralType(this.ids.pathType, Array.from(this.paths)), - ); - - // export type Method = "get" | "post" | "put" | "delete" | "patch"; - this.program.push(makePublicLiteralType(this.ids.methodType, methods)); - - // Single walk through the registry for making properties for the next three objects - const jsonEndpoints: ts.PropertyAssignment[] = []; - const endpointTags: ts.PropertyAssignment[] = []; - for (const [request, { isJson, tags, ...rest }] of this.registry) { - // "get /v1/user/retrieve": GetV1UserRetrieveInput - for (const face of this.interfaces) - face.props.push(makeInterfaceProp(request, rest[face.kind])); - if (variant !== "types") { - const literalIdx = makePropertyIdentifier(request); - if (isJson) { - // "get /v1/user/retrieve": true - jsonEndpoints.push( - f.createPropertyAssignment(literalIdx, f.createTrue()), - ); - } - // "get /v1/user/retrieve": ["users"] - endpointTags.push( - f.createPropertyAssignment( - literalIdx, - f.createArrayLiteralExpression( - tags.map((tag) => f.createStringLiteral(tag)), - ), - ), - ); - } - } - - // export interface Input { "get /v1/user/retrieve": GetV1UserRetrieveInput; } - for (const { id, props } of this.interfaces) - this.program.push(makeInterface(id, props, { isPublic: true })); - - // export type Request = keyof Input; - this.program.push( - makeType(this.ids.requestType, makeKeyOf(this.ids.inputInterface), { - isPublic: true, - }), - ); - // export type MethodPath = Request; - this.program.push( - makeType( - this.ids.methodPathType, - f.createTypeReferenceNode(this.ids.requestType), - { isPublic: true, comment: "@deprecated use Request instead" }, - ), + this.makePathType(), + this.methodType, + ...this.makePublicInterfaces(), + this.requestType, ); if (variant === "types") return; - // export const jsonEndpoints = { "get /v1/user/retrieve": true } - const jsonEndpointsConst = addJsDocComment( - f.createVariableStatement( - exportModifier, - makeConst( - this.ids.jsonEndpointsConst, - f.createObjectLiteralExpression(jsonEndpoints), - ), - ), - "@deprecated use content-type header of an actual response", - ); - - // export const endpointTags = { "get /v1/user/retrieve": ["users"] } - const endpointTagsConst = f.createVariableStatement( - exportModifier, - makeConst( - this.ids.endpointTagsConst, - f.createObjectLiteralExpression(endpointTags), - ), - ); - - // export type Implementation = (method: Method, path: string, params: Record) => Promise; - const implementationType = makeType( - this.ids.implementationType, - f.createFunctionTypeNode( - undefined, - makeParams({ - [this.ids.methodParameter.text]: f.createTypeReferenceNode( - this.ids.methodType, - ), - [this.ids.pathParameter.text]: f.createKeywordTypeNode( - ts.SyntaxKind.StringKeyword, - ), - [this.ids.paramsArgument.text]: recordStringAny, - }), - makePromise("any"), - ), - { isPublic: true }, - ); - - // `:${key}` - const keyParamExpression = f.createTemplateExpression( - f.createTemplateHead(":"), - [f.createTemplateSpan(this.ids.keyParameter, emptyTail)], - ); - - // Object.keys(params).reduce((acc, key) => acc.replace(___, params[key]), path) - const pathArgument = makeObjectKeysReducer( - this.ids.paramsArgument, - makePropCall(this.ids.accumulator, propOf("replace"), [ - keyParamExpression, - f.createElementAccessExpression( - this.ids.paramsArgument, - this.ids.keyParameter, - ), - ]), - this.ids.pathParameter, - ); - - // Object.keys(params).reduce((acc, key) => - // Object.assign(acc, !path.includes(`:${key}`) && {[key]: params[key]} ), {}) - const paramsArgument = makeObjectKeysReducer( - this.ids.paramsArgument, - makePropCall( - f.createIdentifier(Object.name), - propOf("assign"), - [ - this.ids.accumulator, - makeAnd( - f.createPrefixUnaryExpression( - ts.SyntaxKind.ExclamationToken, - makePropCall(this.ids.pathParameter, propOf("includes"), [ - keyParamExpression, - ]), - ), - f.createObjectLiteralExpression( - [ - f.createPropertyAssignment( - f.createComputedPropertyName(this.ids.keyParameter), - f.createElementAccessExpression( - this.ids.paramsArgument, - this.ids.keyParameter, - ), - ), - ], - false, - ), - ), - ], - ), - f.createObjectLiteralExpression(), - ); - - // public provide(method: M, path: P, - // params: `${M} ${P}` extends keyof Input ? Input[`${M} ${P}`] : Record, - // ): Promise<`${M} ${P}` extends keyof Response ? Response[`${M} ${P}`] : unknown>; - // @todo consider removal in v22 - const providerOverload1 = addJsDocComment( - makePublicMethod( - this.ids.provideMethod, - makeParams({ - [this.ids.methodParameter.text]: f.createTypeReferenceNode("M"), - [this.ids.pathParameter.text]: f.createTypeReferenceNode("P"), - [this.ids.paramsArgument.text]: f.createConditionalTypeNode( - parametricIndexNode, - makeKeyOf(this.ids.inputInterface), - f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.inputInterface), - parametricIndexNode, - ), - recordStringAny, - ), - }), - undefined, // overload - makeTypeParams({ - M: this.ids.methodType, - P: this.ids.pathType, - }), - makePromise( - makeConditionalIndex( - this.ids.responseInterface, - parametricIndexNode, - f.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), - ), - ), - ), - "@deprecated use the overload with 2 arguments instead", - ); - - // public provide(request: K, params: Input[K]): Promise; - const providerOverload2 = makePublicMethod( - this.ids.provideMethod, - makeParams({ - [this.ids.requestParameter.text]: f.createTypeReferenceNode("K"), - [this.ids.paramsArgument.text]: f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.inputInterface), - f.createTypeReferenceNode("K"), - ), - }), - undefined, // overload - makeTypeParams({ K: this.ids.requestType }), - makePromise( - f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.responseInterface), - f.createTypeReferenceNode("K"), - ), - ), - ); - - // public provide(...args: [string, string, Record] | [string, Record]) { - const actualProvider = makePublicMethod( - this.ids.provideMethod, - makeParams( - { - [this.ids.args.text]: f.createUnionTypeNode([ - // @todo remove this variant in v22 - f.createTupleTypeNode([ - f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - recordStringAny, - ]), - f.createTupleTypeNode([ - f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - recordStringAny, - ]), - ]), - }, - restToken, - ), - f.createBlock([ - f.createVariableStatement( - undefined, - makeConst( - // const [method, path, params] = - makeDeconstruction( - this.ids.methodParameter, - this.ids.pathParameter, - this.ids.paramsArgument, - ), - // (args.length === 2 ? [...args[0].split((/ (.+)/,2), args[1]] : args) as [Method, Path, Record] - f.createAsExpression( - f.createParenthesizedExpression( - makeTernary( - makeEqual( - f.createPropertyAccessExpression( - this.ids.args, - propOf("length"), - ), - f.createNumericLiteral(2), - ), - f.createArrayLiteralExpression([ - f.createSpreadElement( - makePropCall( - f.createElementAccessExpression(this.ids.args, 0), - propOf("split"), - [ - f.createRegularExpressionLiteral("/ (.+)/"), // split once - f.createNumericLiteral(2), // excludes third empty element - ], - ), - ), - f.createElementAccessExpression(this.ids.args, 1), - ]), - this.ids.args, // @todo remove this in v22 - ), - ), - f.createTupleTypeNode([ - f.createTypeReferenceNode(this.ids.methodType), - f.createTypeReferenceNode(this.ids.pathType), - recordStringAny, - ]), - ), - ), - ), - // return this.implementation(___) - f.createReturnStatement( - makePropCall(f.createThis(), this.ids.implementationArgument, [ - this.ids.methodParameter, - pathArgument, - paramsArgument, - ]), - ), - ]), - ); - - // export class ExpressZodAPIClient { ___ } - const clientClass = makePublicClass( - this.ids.clientClass, - // constructor(protected readonly implementation: Implementation) {} - makeEmptyInitializingConstructor([ - makeParam( - this.ids.implementationArgument, - f.createTypeReferenceNode(this.ids.implementationType), - protectedReadonlyModifier, - ), - ]), - [providerOverload1, providerOverload2, actualProvider], - ); - - // @todo remove in v22 - const providerType = makeType( - this.ids.providerType, - f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.clientClass), - f.createLiteralTypeNode( - f.createStringLiteral(this.ids.provideMethod.text), - ), - ), - { isPublic: true, comment: "@deprecated will be removed in v22" }, - ); - this.program.push( - jsonEndpointsConst, - endpointTagsConst, - implementationType, - clientClass, - providerType, - ); - - // method: method.toUpperCase() - const methodProperty = f.createPropertyAssignment( - this.ids.methodParameter, - makePropCall(this.ids.methodParameter, propOf("toUpperCase")), - ); - - // headers: hasBody ? { "Content-Type": "application/json" } : undefined - const headersProperty = f.createPropertyAssignment( - this.ids.headersProperty, - makeTernary( - this.ids.hasBodyConst, - f.createObjectLiteralExpression([ - f.createPropertyAssignment( - f.createStringLiteral("Content-Type"), - f.createStringLiteral(contentTypes.json), - ), - ]), - this.ids.undefinedValue, - ), - ); - - // body: hasBody ? JSON.stringify(params) : undefined - const bodyProperty = f.createPropertyAssignment( - this.ids.bodyProperty, - makeTernary( - this.ids.hasBodyConst, - makePropCall( - f.createIdentifier(JSON[Symbol.toStringTag]), - propOf("stringify"), - [this.ids.paramsArgument], - ), - this.ids.undefinedValue, - ), - ); - - // const response = await fetch(new URL(`${path}${searchParams}`, "https://example.com"), { ___ }); - const responseStatement = f.createVariableStatement( - undefined, - makeConst( - this.ids.responseConst, - f.createAwaitExpression( - f.createCallExpression(f.createIdentifier(fetch.name), undefined, [ - f.createNewExpression(f.createIdentifier(URL.name), undefined, [ - f.createTemplateExpression(f.createTemplateHead(""), [ - f.createTemplateSpan( - this.ids.pathParameter, - f.createTemplateMiddle(""), - ), - f.createTemplateSpan(this.ids.searchParamsConst, emptyTail), - ]), - f.createStringLiteral(serverUrl), - ]), - f.createObjectLiteralExpression([ - methodProperty, - headersProperty, - bodyProperty, - ]), - ]), - ), - ), - ); - - // const hasBody = !["get", "delete"].includes(method); - const hasBodyStatement = f.createVariableStatement( - undefined, - makeConst( - this.ids.hasBodyConst, - f.createLogicalNot( - makePropCall( - f.createArrayLiteralExpression([ - f.createStringLiteral("get" satisfies Method), - f.createStringLiteral("delete" satisfies Method), - ]), - propOf("includes"), - [this.ids.methodParameter], - ), - ), - ), - ); - - // const searchParams = hasBody ? "" : `?${new URLSearchParams(params)}`; - const searchParamsStatement = f.createVariableStatement( - undefined, - makeConst( - this.ids.searchParamsConst, - makeTernary( - this.ids.hasBodyConst, - f.createStringLiteral(""), - f.createTemplateExpression(f.createTemplateHead("?"), [ - f.createTemplateSpan( - f.createNewExpression( - f.createIdentifier(URLSearchParams.name), - undefined, - [this.ids.paramsArgument], - ), - emptyTail, - ), - ]), - ), - ), - ); - - // const contentType = response.headers.get("content-type"); - const contentTypeStatement = f.createVariableStatement( - undefined, - makeConst( - this.ids.contentTypeConst, - makePropCall( - [this.ids.responseConst, this.ids.headersProperty], - propOf("get"), - [f.createStringLiteral("content-type")], - ), - ), - ); - - // if (!contentType) return; - const noBodyStatement = f.createIfStatement( - f.createPrefixUnaryExpression( - ts.SyntaxKind.ExclamationToken, - this.ids.contentTypeConst, - ), - f.createReturnStatement(undefined), - undefined, - ); - - // const isJSON = contentType.startsWith("application/json"); - const parserStatement = f.createVariableStatement( - undefined, - makeConst( - this.ids.isJsonConst, - f.createCallChain( - f.createPropertyAccessChain( - this.ids.contentTypeConst, - undefined, - propOf("startsWith"), - ), - undefined, - undefined, - [f.createStringLiteral(contentTypes.json)], - ), - ), - ); - - // return response[isJSON ? "json" : "text"](); - const returnStatement = f.createReturnStatement( - f.createCallExpression( - f.createElementAccessExpression( - this.ids.responseConst, - makeTernary( - this.ids.isJsonConst, - f.createStringLiteral(propOf("json")), - f.createStringLiteral(propOf("text")), - ), - ), - undefined, - [], - ), - ); - - // export const exampleImplementation: Implementation = async (method,path,params) => { ___ }; - const exampleImplStatement = f.createVariableStatement( - exportModifier, - makeConst( - this.ids.exampleImplementationConst, - makeArrowFn( - [ - this.ids.methodParameter, - this.ids.pathParameter, - this.ids.paramsArgument, - ], - f.createBlock([ - hasBodyStatement, - searchParamsStatement, - responseStatement, - contentTypeStatement, - noBodyStatement, - parserStatement, - returnStatement, - ]), - true, - ), - f.createTypeReferenceNode(this.ids.implementationType), - ), - ); - - // client.provide("get /v1/user/retrieve", { id: "10" }); - const provideCallingStatement = f.createExpressionStatement( - makePropCall(this.ids.clientConst, this.ids.provideMethod, [ - f.createStringLiteral(`${"get" satisfies Method} /v1/user/retrieve`), - f.createObjectLiteralExpression([ - f.createPropertyAssignment("id", f.createStringLiteral("10")), - ]), - ]), - ); - - // const client = new ExpressZodAPIClient(exampleImplementation); - const clientInstanceStatement = f.createVariableStatement( - undefined, - makeConst( - this.ids.clientConst, - f.createNewExpression(this.ids.clientClass, undefined, [ - this.ids.exampleImplementationConst, - ]), - ), + this.makeEndpointTags(), + this.makeParseRequestFn(), + this.makeSubstituteFn(), + this.makeImplementationType(), + this.makeClientClass(), ); this.usage.push( - exampleImplStatement, - clientInstanceStatement, - provideCallingStatement, + this.makeExampleImplementation(), + ...this.makeUsageStatements(), ); } diff --git a/src/logger-helpers.ts b/src/logger-helpers.ts index f53e6e2ae5..09d9b7bdf8 100644 --- a/src/logger-helpers.ts +++ b/src/logger-helpers.ts @@ -1,4 +1,5 @@ import { Ansis, blue, green, hex, red, cyanBright } from "ansis"; +import { memoizeWith } from "ramda"; import { isObject } from "./common-helpers"; export const styles = { @@ -43,32 +44,33 @@ export const isSeverity = (subject: PropertyKey): subject is Severity => export const isHidden = (subject: Severity, gate: Severity) => severity[subject] < severity[gate]; -/** - * @todo consider Intl units when Node 18 dropped (microsecond unit is missing, picosecond is not in list) - * @link https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers - * */ -const makeNumberFormat = (fraction = 0) => +/** @link https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers */ +type TimeUnit = + | "nanosecond" + | "microsecond" + | "millisecond" + | "second" + | "minute"; + +const _makeNumberFormat = (unit: TimeUnit, fraction = 0) => Intl.NumberFormat(undefined, { useGrouping: false, minimumFractionDigits: 0, maximumFractionDigits: fraction, + style: "unit", + unitDisplay: "long", + unit, }); +export const makeNumberFormat = memoizeWith( + (unit, fraction) => `${unit}${fraction}`, + _makeNumberFormat, +); -// creating them once increases the performance significantly -const intFormat = makeNumberFormat(); -const floatFormat = makeNumberFormat(2); - -// not using R.cond for performance optimization -const pickTimeUnit = (ms: number): [string, number, Intl.NumberFormat] => { - if (ms < 1e-6) return ["picosecond", ms / 1e-9, intFormat]; - if (ms < 1e-3) return ["nanosecond", ms / 1e-6, intFormat]; - if (ms < 1) return ["microsecond", ms / 1e-3, intFormat]; - if (ms < 1e3) return ["millisecond", ms, intFormat]; - if (ms < 6e4) return ["second", ms / 1e3, floatFormat]; - return ["minute", ms / 6e4, floatFormat]; -}; - -export const formatDuration = (durationMs: number) => { - const [unit, converted, formatter] = pickTimeUnit(durationMs); - return `${formatter.format(converted)} ${unit}${converted > 1 ? "s" : ""}`; +export const formatDuration = (ms: number) => { + if (ms < 1e-6) return makeNumberFormat("nanosecond", 3).format(ms / 1e-6); + if (ms < 1e-3) return makeNumberFormat("nanosecond").format(ms / 1e-6); + if (ms < 1) return makeNumberFormat("microsecond").format(ms / 1e-3); + if (ms < 1e3) return makeNumberFormat("millisecond").format(ms); + if (ms < 6e4) return makeNumberFormat("second", 2).format(ms / 1e3); + return makeNumberFormat("minute", 2).format(ms / 6e4); }; diff --git a/src/migration.ts b/src/migration.ts index c3d596f2af..3aea4fe273 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -4,299 +4,179 @@ import { type TSESLint, type TSESTree, } from "@typescript-eslint/utils"; -import { name as importName } from "../package.json"; +import { Method, methods } from "./method"; +import { name as self } from "../package.json"; -const createConfigName = "createConfig"; -const createServerName = "createServer"; -const serverPropName = "server"; -const beforeRoutingPropName = "beforeRouting"; -const httpServerPropName = "httpServer"; -const httpsServerPropName = "httpsServer"; -const originalErrorPropName = "originalError"; -const getStatusCodeFromErrorMethod = "getStatusCodeFromError"; -const loggerPropName = "logger"; -const getChildLoggerPropName = "getChildLogger"; -const methodsPropName = "methods"; -const tagsPropName = "tags"; -const scopesPropName = "scopes"; -const statusCodesPropName = "statusCodes"; -const mimeTypesPropName = "mimeTypes"; -const buildMethod = "build"; -const resultHandlerClass = "ResultHandler"; -const handlerMethod = "handler"; +interface Queries { + provide: TSESTree.CallExpression & { + arguments: [ + TSESTree.Literal & { value: Method }, + TSESTree.Literal, + TSESTree.ObjectExpression, + ]; + }; + splitResponse: TSESTree.Property & { key: TSESTree.Identifier }; + methodPath: TSESTree.ImportSpecifier & { imported: TSESTree.Identifier }; + createConfig: TSESTree.Property & { + key: TSESTree.Identifier; + value: TSESTree.ObjectExpression; + }; + newDocs: TSESTree.ObjectExpression; + newFactory: TSESTree.Property & { key: TSESTree.Identifier }; + newSSE: TSESTree.Property & { key: TSESTree.Identifier }; + newClient: TSESTree.NewExpression; +} -const changedProps = { - [serverPropName]: "http", - [httpServerPropName]: "servers", - [httpsServerPropName]: "servers", - [originalErrorPropName]: "cause", - [loggerPropName]: "getLogger", - [getChildLoggerPropName]: "getLogger", - [methodsPropName]: "method", - [tagsPropName]: "tag", - [scopesPropName]: "scope", - [statusCodesPropName]: "statusCode", - [mimeTypesPropName]: "mimeType", -}; +type Listener = keyof Queries; -const changedMethods = { - [getStatusCodeFromErrorMethod]: "ensureHttpError", +const queries: Record = { + provide: + `${NT.CallExpression}[callee.property.name='provide'][arguments.length=3]` + + `:has(${NT.Literal}[value=/^${methods.join("|")}$/] + ${NT.Literal} + ${NT.ObjectExpression})`, + splitResponse: + `${NT.NewExpression}[callee.name='Integration'] > ` + + `${NT.ObjectExpression} > ${NT.Property}[key.name='splitResponse']`, + methodPath: `${NT.ImportDeclaration} > ${NT.ImportSpecifier}[imported.name='MethodPath']`, + createConfig: + `${NT.CallExpression}[callee.name='createConfig'] > ${NT.ObjectExpression} > ` + + `${NT.Property}[key.name='tags'][value.type='ObjectExpression']`, + newDocs: + `${NT.NewExpression}[callee.name='Documentation'] > ` + + `${NT.ObjectExpression}[properties.length>0]:not(:has(>Property[key.name='tags']))`, + newFactory: + `${NT.NewExpression}[callee.name='EndpointsFactory'] > ` + + `${NT.ObjectExpression} > ${NT.Property}[key.name='resultHandler']`, + newSSE: + `${NT.NewExpression}[callee.name='EventStreamFactory'] > ` + + `${NT.ObjectExpression} > ${NT.Property}[key.name='events']`, + newClient: `${NT.NewExpression}[callee.name='ExpressZodAPIClient']`, }; -const movedProps = [ - "jsonParser", - "upload", - "compression", - "rawParser", - "beforeRouting", -] as const; - -const esQueries = { - loggerArgument: - `${NT.Property}[key.name="${beforeRoutingPropName}"] ` + - `${NT.ArrowFunctionExpression} ` + - `${NT.Identifier}[name="${loggerPropName}"]`, - getChildLoggerArgument: - `${NT.Property}[key.name="${beforeRoutingPropName}"] ` + - `${NT.ArrowFunctionExpression} ` + - `${NT.Identifier}[name="${getChildLoggerPropName}"]`, - responseFeatures: - `${NT.NewExpression}[callee.name='${resultHandlerClass}'] > ` + - `${NT.ObjectExpression} > ` + - `${NT.Property}[key.name!='${handlerMethod}'] ` + - `${NT.Property}[key.name=/(${statusCodesPropName}|${mimeTypesPropName})/]`, -}; - -type PropWithId = TSESTree.Property & { - key: TSESTree.Identifier; -}; +const listen = < + S extends { [K in Listener]: TSESLint.RuleFunction }, +>( + subject: S, +) => + (Object.keys(subject) as Listener[]).reduce<{ [K: string]: S[Listener] }>( + (agg, key) => + Object.assign(agg, { + [queries[key]]: subject[key], + }), + {}, + ); -const isPropWithId = (subject: TSESTree.Node): subject is PropWithId => - subject.type === NT.Property && subject.key.type === NT.Identifier; - -const isAssignment = ( - parent: TSESTree.Node, -): parent is TSESTree.VariableDeclarator & { id: TSESTree.ObjectPattern } => - parent.type === NT.VariableDeclarator && parent.id.type === NT.ObjectPattern; - -const propByName = - (subject: T | ReadonlyArray) => - (entry: TSESTree.Node): entry is PropWithId & { key: { name: T } } => - isPropWithId(entry) && - (Array.isArray(subject) - ? subject.includes(entry.key.name) - : entry.key.name === subject); - -const v21 = ESLintUtils.RuleCreator.withoutDocs({ +const v22 = ESLintUtils.RuleCreator.withoutDocs({ meta: { type: "problem", fixable: "code", schema: [], messages: { + add: `Add {{subject}} to {{to}}`, change: "Change {{subject}} {{from}} to {{to}}.", - move: "Move {{subject}} from {{from}} to {{to}}.", + remove: "Remove {{subject}} {{name}}.", }, }, defaultOptions: [], - create: (ctx) => ({ - [NT.ImportDeclaration]: (node) => { - if (node.source.value === importName) { - for (const spec of node.specifiers) { - if ( - spec.type === NT.ImportSpecifier && - spec.imported.type === NT.Identifier && - spec.imported.name in changedMethods - ) { - const replacement = - changedMethods[spec.imported.name as keyof typeof changedMethods]; - ctx.report({ - node: spec.imported, - messageId: "change", - data: { - subject: "import", - from: spec.imported.name, - to: replacement, - }, - fix: (fixer) => fixer.replaceText(spec, replacement), - }); - } - } - } - }, - [NT.MemberExpression]: (node) => { - if ( - node.property.type === NT.Identifier && - node.property.name === originalErrorPropName && - node.object.type === NT.Identifier && - node.object.name.match(/err/i) // this is probably an error instance, but we don't do type checking - ) { - const replacement = changedProps[node.property.name]; + create: (ctx) => + listen({ + provide: (node) => { + const { + arguments: [method, path], + } = node; + const request = `"${method.value} ${path.value}"`; ctx.report({ - node: node.property, messageId: "change", + node, data: { - subject: "property", - from: node.property.name, - to: replacement, + subject: "arguments", + from: `"${method.value}", "${path.value}"`, + to: request, }, + fix: (fixer) => + fixer.replaceTextRange([method.range[0], path.range[1]], request), }); - } - }, - [NT.CallExpression]: (node) => { - if ( - node.callee.type === NT.MemberExpression && - node.callee.property.type === NT.Identifier && - node.callee.property.name === buildMethod && - node.arguments.length === 1 && - node.arguments[0].type === NT.ObjectExpression - ) { - const changed = node.arguments[0].properties.filter( - propByName([methodsPropName, tagsPropName, scopesPropName] as const), - ); - for (const prop of changed) { - const replacement = changedProps[prop.key.name]; - ctx.report({ - node: prop, - messageId: "change", - data: { subject: "property", from: prop.key.name, to: replacement }, - fix: (fixer) => fixer.replaceText(prop.key, replacement), - }); - } - } - if (node.callee.type !== NT.Identifier) return; - if ( - node.callee.name === createConfigName && - node.arguments.length === 1 - ) { - const argument = node.arguments[0]; - if (argument.type === NT.ObjectExpression) { - const serverProp = argument.properties.find( - propByName(serverPropName), - ); - if (serverProp) { - const replacement = changedProps[serverProp.key.name]; - ctx.report({ - node: serverProp, - messageId: "change", - data: { - subject: "property", - from: serverProp.key.name, - to: replacement, - }, - fix: (fixer) => fixer.replaceText(serverProp.key, replacement), - }); - } - const httpProp = argument.properties.find( - propByName(changedProps.server), - ); - if (httpProp && httpProp.value.type === NT.ObjectExpression) { - const nested = httpProp.value.properties; - const movable = nested.filter(propByName(movedProps)); - for (const prop of movable) { - const propText = ctx.sourceCode.text.slice(...prop.range); - const comma = ctx.sourceCode.getTokenAfter(prop); - ctx.report({ - node: httpProp, - messageId: "move", - data: { - subject: isPropWithId(prop) ? prop.key.name : "the property", - from: httpProp.key.name, - to: `the top level of ${node.callee.name} argument`, - }, - fix: (fixer) => [ - fixer.insertTextAfter(httpProp, `, ${propText}`), - fixer.removeRange([ - prop.range[0], - comma?.value === "," ? comma.range[1] : prop.range[1], - ]), - ], - }); - } - } - } - } - if (node.callee.name === createServerName) { - const assignment = ctx.sourceCode - .getAncestors(node) - .findLast(isAssignment); - if (assignment) { - const removable = assignment.id.properties.filter( - propByName([httpServerPropName, httpsServerPropName] as const), - ); - for (const prop of removable) { - ctx.report({ - node: prop, - messageId: "change", - data: { - subject: "property", - from: prop.key.name, - to: changedProps[prop.key.name], - }, - }); - } - } - } - if (node.callee.name === getStatusCodeFromErrorMethod) { - const replacement = changedMethods[node.callee.name]; + }, + splitResponse: (node) => + ctx.report({ + messageId: "remove", + node, + data: { subject: "property", name: node.key.name }, + fix: (fixer) => fixer.remove(node), + }), + methodPath: (node) => { + const replacement = "Request"; ctx.report({ - node: node.callee, messageId: "change", - data: { - subject: "method", - from: node.callee.name, - to: `${replacement}().statusCode`, - }, + node: node.imported, + data: { subject: "type", from: node.imported.name, to: replacement }, + fix: (fixer) => fixer.replaceText(node.imported, replacement), + }); + }, + createConfig: (node) => { + const props = node.value.properties + .filter( + (prop): prop is TSESTree.Property & { key: TSESTree.Identifier } => + "key" in prop && "name" in prop.key, + ) + .map((prop) => ` "${prop.key.name}": unknown,\n`); + ctx.report({ + messageId: "remove", + node, + data: { subject: "property", name: node.key.name }, fix: (fixer) => [ - fixer.replaceText(node.callee, replacement), - fixer.insertTextAfter(node, ".statusCode"), + fixer.remove(node), + fixer.insertTextAfter( + ctx.sourceCode.ast, + `\n// Declaring tag constraints\ndeclare module "${self}" {\n interface TagOverrides {\n${props} }\n}`, + ), ], }); - } - }, - [esQueries.loggerArgument]: (node: TSESTree.Identifier) => { - const { parent } = node; - const isProp = isPropWithId(parent); - if (isProp && parent.value === node) return; // not for renames - const replacement = `${changedProps[node.name as keyof typeof changedProps]}${isProp ? "" : "()"}`; - ctx.report({ - node, - messageId: "change", - data: { - subject: isProp ? "property" : "const", - from: node.name, - to: replacement, - }, - fix: (fixer) => fixer.replaceText(node, replacement), - }); - }, - [esQueries.getChildLoggerArgument]: (node: TSESTree.Identifier) => { - const { parent } = node; - const isProp = isPropWithId(parent); - if (isProp && parent.value === node) return; // not for renames - const replacement = changedProps[node.name as keyof typeof changedProps]; - ctx.report({ - node, - messageId: "change", - data: { - subject: isProp ? "property" : "method", - from: node.name, - to: replacement, - }, - fix: (fixer) => fixer.replaceText(node, replacement), - }); - }, - [esQueries.responseFeatures]: (node: TSESTree.Property) => { - if (!isPropWithId(node)) return; - const replacement = - changedProps[node.key.name as keyof typeof changedProps]; - ctx.report({ - node, - messageId: "change", - data: { subject: "property", from: node.key.name, to: replacement }, - fix: (fixer) => fixer.replaceText(node.key, replacement), - }); - }, - }), + }, + newDocs: (node) => + ctx.report({ + messageId: "add", + node, + data: { subject: "tags", to: "Documentation" }, + fix: (fixer) => + fixer.insertTextBefore( + node.properties[0], + "tags: { /* move from createConfig() argument if any */ }, ", + ), + }), + newFactory: (node) => + ctx.report({ + messageId: "change", + node: node.parent, + data: { + subject: "argument", + from: "object", + to: "ResultHandler instance", + }, + fix: (fixer) => + fixer.replaceText(node.parent, ctx.sourceCode.getText(node.value)), + }), + newSSE: (node) => + ctx.report({ + messageId: "change", + node: node.parent, + data: { subject: "argument", from: "object", to: "events map" }, + fix: (fixer) => + fixer.replaceText(node.parent, ctx.sourceCode.getText(node.value)), + }), + newClient: (node) => { + const replacement = "Client"; + ctx.report({ + messageId: "change", + node: node.callee, + data: { + subject: "class", + from: "ExpressZodAPIClient", + to: replacement, + }, + fix: (fixer) => fixer.replaceText(node.callee, replacement), + }); + }, + }), }); /** @@ -312,5 +192,5 @@ const v21 = ESLintUtils.RuleCreator.withoutDocs({ * ]; * */ export default { - rules: { v21 }, + rules: { v22 }, } satisfies TSESLint.Linter.Plugin; diff --git a/src/sse.ts b/src/sse.ts index 9baab7142f..2d18ccf294 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -1,7 +1,6 @@ import { Response } from "express"; import { z } from "zod"; import { EmptySchema, FlatObject } from "./common-helpers"; -import { CommonConfig } from "./config-type"; import { contentTypes } from "./content-type"; import { EndpointsFactory } from "./endpoints-factory"; import { Middleware } from "./middleware"; @@ -96,12 +95,12 @@ export const makeResultHandler = (events: E) => }, }); -export class EventStreamFactory< - E extends EventsMap, - TAG extends string, -> extends EndpointsFactory, string, TAG> { - constructor({ events, config }: { events: E; config?: CommonConfig }) { - super({ config, resultHandler: makeResultHandler(events) }); +export class EventStreamFactory extends EndpointsFactory< + EmptySchema, + Emitter +> { + constructor(events: E) { + super(makeResultHandler(events)); this.middlewares = [makeMiddleware(events)]; } } diff --git a/src/startup-logo.ts b/src/startup-logo.ts index 38eba841c8..b544b6cd53 100644 --- a/src/startup-logo.ts +++ b/src/startup-logo.ts @@ -12,7 +12,7 @@ export const printStartupLogo = (stream: WriteStream) => { const thanks = italic( "Thank you for choosing Express Zod API for your project.".padStart(132), ); - const dedicationMessage = italic("for Kesaria".padEnd(20)); + const dedicationMessage = italic("for Tai".padEnd(20)); const pink = hex("#F5A9B8"); const blue = hex("#5BCEFA"); diff --git a/src/typescript-api.ts b/src/typescript-api.ts new file mode 100644 index 0000000000..97beb3a69e --- /dev/null +++ b/src/typescript-api.ts @@ -0,0 +1,312 @@ +import ts from "typescript"; + +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 addJsDocComment = (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 === "number" + ? f.createNumericLiteral(name) + : safePropRegex.test(name) + ? f.createIdentifier(name) + : f.createStringLiteral(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), + ), + ), + ); + +// Record +export const recordStringAny = f.createExpressionWithTypeArguments( + f.createIdentifier("Record"), + [ + f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), + ], +); + +export const makeParam = ( + name: ts.Identifier, + { type, mod }: { type?: ts.TypeNode; mod?: ts.Modifier[] } = {}, +) => + f.createParameterDeclaration( + mod, + undefined, + name, + undefined, + type, + undefined, + ); + +export const makeParams = (params: Partial>) => + Object.entries(params).map(([name, type]) => + makeParam(f.createIdentifier(name), { type }), + ); + +export const makePublicConstructor = (params: ts.ParameterDeclaration[]) => + f.createConstructorDeclaration( + accessModifiers.public, + params, + f.createBlock([]), + ); + +export const ensureTypeNode = ( + subject: ts.TypeNode | ts.Identifier | string, +): ts.TypeNode => + typeof subject === "string" || ts.isIdentifier(subject) + ? f.createTypeReferenceNode(subject) + : subject; + +export const makeInterfaceProp = ( + name: string | number, + value: Parameters[0], + { isOptional }: { isOptional?: boolean } = {}, +) => + f.createPropertySignature( + undefined, + makePropertyIdentifier(name), + isOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, + ensureTypeNode(value), + ); + +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?: ts.TypeNode; expose?: true } = {}, +) => + f.createVariableStatement( + expose && exportModifier, + f.createVariableDeclarationList( + [f.createVariableDeclaration(name, undefined, type, value)], + ts.NodeFlags.Const, + ), + ); + +export const makePublicLiteralType = ( + name: ts.Identifier | string, + literals: string[], +) => + makeType( + name, + f.createUnionTypeNode( + literals.map((option) => + f.createLiteralTypeNode(f.createStringLiteral(option)), + ), + ), + { expose: true }, + ); + +export const makeType = ( + name: ts.Identifier | string, + value: ts.TypeNode, + { + expose, + comment, + params, + }: { + expose?: boolean; + comment?: string; + params?: Parameters[0]; + } = {}, +) => { + const node = f.createTypeAliasDeclaration( + expose ? exportModifier : undefined, + name, + params && makeTypeParams(params), + value, + ); + return comment ? addJsDocComment(node, comment) : node; +}; + +export const makePublicMethod = ( + name: ts.Identifier, + params: ts.ParameterDeclaration[], + body: ts.Block, + { + typeParams, + returns, + }: { + typeParams?: Parameters[0]; + returns?: ts.TypeNode; + } = {}, +) => + f.createMethodDeclaration( + accessModifiers.public, + undefined, + name, + undefined, + typeParams && makeTypeParams(typeParams), + params, + returns, + body, + ); + +export const makePublicClass = ( + name: ts.Identifier, + statements: ts.ClassElement[], +) => + f.createClassDeclaration( + exportModifier, + name, + undefined, + undefined, + statements, + ); + +export const makeKeyOf = (subj: Parameters[0]) => + f.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, ensureTypeNode(subj)); + +export const makePromise = (subject: ts.TypeNode | "any") => + f.createTypeReferenceNode(Promise.name, [ + subject === "any" + ? f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword) + : 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 ? addJsDocComment(node, comment) : node; +}; + +export const makeTypeParams = ( + params: Partial>, +) => + Object.entries(params).map(([name, val]) => + f.createTypeParameterDeclaration([], name, val && ensureTypeNode(val)), + ); + +export const makeArrowFn = ( + params: ts.Identifier[] | Parameters[0], + body: ts.ConciseBody, + { + isAsync, + typeParams, + }: { + isAsync?: boolean; + typeParams?: Parameters[0]; + } = {}, +) => + f.createArrowFunction( + isAsync ? asyncModifier : undefined, + typeParams && makeTypeParams(typeParams), + Array.isArray(params) + ? params.map((key) => makeParam(key)) + : makeParams(params), + undefined, + undefined, + body, + ); + +export const propOf = (name: keyof NoInfer) => name as string; + +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 makePropCall = ( + parent: ts.Expression | [ts.Expression, ts.Identifier | string], + child: ts.Identifier | string, + args?: ts.Expression[], +) => + f.createCallExpression( + f.createPropertyAccessExpression( + Array.isArray(parent) + ? f.createPropertyAccessExpression(...parent) + : parent, + child, + ), + undefined, + args, + ); + +export const makeNew = (cls: ts.Identifier, ...args: ts.Expression[]) => + f.createNewExpression(cls, undefined, args); + +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, +]; +export const isPrimitive = (node: ts.TypeNode): node is ts.KeywordTypeNode => + (primitives as ts.SyntaxKind[]).includes(node.kind); diff --git a/src/well-known-headers.json b/src/well-known-headers.json new file mode 100644 index 0000000000..021431dadb --- /dev/null +++ b/src/well-known-headers.json @@ -0,0 +1 @@ +["a-im","accept","accept-additions","accept-ch","accept-charset","accept-datetime","accept-encoding","accept-features","accept-language","accept-patch","accept-post","accept-ranges","accept-signature","access-control","access-control-allow-credentials","access-control-allow-headers","access-control-allow-methods","access-control-allow-origin","access-control-expose-headers","access-control-max-age","access-control-request-headers","access-control-request-method","age","allow","alpn","alt-svc","alt-used","alternates","amp-cache-transform","apply-to-redirect-ref","authentication-control","authentication-info","authorization","available-dictionary","c-ext","c-man","c-opt","c-pep","c-pep-info","cache-control","cache-status","cal-managed-id","caldav-timezones","capsule-protocol","cdn-cache-control","cdn-loop","cert-not-after","cert-not-before","clear-site-data","client-cert","client-cert-chain","close","cmcd-object","cmcd-request","cmcd-session","cmcd-status","cmsd-dynamic","cmsd-static","concealed-auth-export","configuration-context","connection","content-base","content-digest","content-disposition","content-encoding","content-id","content-language","content-length","content-location","content-md5","content-range","content-script-type","content-security-policy","content-security-policy-report-only","content-style-type","content-type","content-version","cookie","cookie2","cross-origin-embedder-policy","cross-origin-embedder-policy-report-only","cross-origin-opener-policy","cross-origin-opener-policy-report-only","cross-origin-resource-policy","cta-common-access-token","dasl","date","dav","default-style","delta-base","deprecation","depth","derived-from","destination","differential-id","dictionary-id","digest","dpop","dpop-nonce","early-data","ediint-features","etag","expect","expect-ct","expires","ext","forwarded","from","getprofile","hobareg","host","http2-settings","if","if-match","if-modified-since","if-none-match","if-range","if-schedule-tag-match","if-unmodified-since","im","include-referred-token-binding-id","isolation","keep-alive","label","last-event-id","last-modified","link","link-template","location","lock-token","man","max-forwards","memento-datetime","meter","method-check","method-check-expires","mime-version","negotiate","nel","odata-entityid","odata-isolation","odata-maxversion","odata-version","opt","optional-www-authenticate","ordering-type","origin","origin-agent-cluster","oscore","oslc-core-version","overwrite","p3p","pep","pep-info","permissions-policy","pics-label","ping-from","ping-to","position","pragma","prefer","preference-applied","priority","profileobject","protocol","protocol-info","protocol-query","protocol-request","proxy-authenticate","proxy-authentication-info","proxy-authorization","proxy-features","proxy-instruction","proxy-status","public","public-key-pins","public-key-pins-report-only","range","redirect-ref","referer","referer-root","referrer-policy","refresh","repeatability-client-id","repeatability-first-sent","repeatability-request-id","repeatability-result","replay-nonce","reporting-endpoints","repr-digest","retry-after","safe","schedule-reply","schedule-tag","sec-gpc","sec-purpose","sec-token-binding","sec-websocket-accept","sec-websocket-extensions","sec-websocket-key","sec-websocket-protocol","sec-websocket-version","security-scheme","server","server-timing","set-cookie","set-cookie2","setprofile","signature","signature-input","slug","soapaction","status-uri","strict-transport-security","sunset","surrogate-capability","surrogate-control","tcn","te","timeout","timing-allow-origin","topic","traceparent","tracestate","trailer","transfer-encoding","ttl","upgrade","urgency","uri","use-as-dictionary","user-agent","variant-vary","vary","via","want-content-digest","want-digest","want-repr-digest","warning","www-authenticate","x-content-type-options","x-frame-options"] \ No newline at end of file diff --git a/src/zts-helpers.ts b/src/zts-helpers.ts index d6a892a543..d5fc624ea0 100644 --- a/src/zts-helpers.ts +++ b/src/zts-helpers.ts @@ -1,68 +1,14 @@ -import ts from "typescript"; +import type ts from "typescript"; import { z } from "zod"; import { FlatObject } from "./common-helpers"; import { SchemaHandler } from "./schema-walker"; -const { factory: f } = ts; - export type LiteralType = string | number | boolean; export interface ZTSContext extends FlatObject { isResponse: boolean; - makeAlias: ( - schema: z.ZodTypeAny, - produce: () => ts.TypeNode, - ) => ts.TypeReferenceNode; + makeAlias: (schema: z.ZodTypeAny, produce: () => ts.TypeNode) => ts.TypeNode; optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean }; } export type Producer = SchemaHandler; - -export const addJsDocComment = (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 === "number" - ? f.createNumericLiteral(name) - : safePropRegex.test(name) - ? f.createIdentifier(name) - : f.createStringLiteral(name); - -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, -]; - -export const isPrimitive = (node: ts.TypeNode): node is ts.KeywordTypeNode => - (primitives as ts.SyntaxKind[]).includes(node.kind); diff --git a/src/zts.ts b/src/zts.ts index 3fd08aa141..6d16fd9c93 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -10,12 +10,11 @@ import { ezRawBrand, RawSchema } from "./raw-schema"; import { HandlingRules, walkSchema } from "./schema-walker"; import { addJsDocComment, + ensureTypeNode, isPrimitive, - LiteralType, - makePropertyIdentifier, - Producer, - ZTSContext, -} from "./zts-helpers"; + makeInterfaceProp, +} from "./typescript-api"; +import { LiteralType, Producer, ZTSContext } from "./zts-helpers"; const { factory: f } = ts; @@ -53,14 +52,9 @@ const onObject: Producer = ( isResponse && hasCoercion(value) ? value instanceof z.ZodOptional : value.isOptional(); - const propertySignature = f.createPropertySignature( - undefined, - makePropertyIdentifier(key), - isOptional && hasQuestionMark - ? f.createToken(ts.SyntaxKind.QuestionToken) - : undefined, - next(value), - ); + const propertySignature = makeInterfaceProp(key, next(value), { + isOptional: isOptional && hasQuestionMark, + }); return value.description ? addJsDocComment(propertySignature, value.description) : propertySignature; @@ -213,7 +207,7 @@ const onLazy: Producer = (lazy: z.ZodLazy, { makeAlias, next }) => const onFile: Producer = (schema: FileSchema) => { const subject = schema.unwrap(); const stringType = f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); - const bufferType = f.createTypeReferenceNode("Buffer"); + const bufferType = ensureTypeNode("Buffer"); const unionType = f.createUnionTypeNode([stringType, bufferType]); return subject instanceof z.ZodString ? stringType diff --git a/tests/bench/experiment.bench.ts b/tests/bench/experiment.bench.ts index 8491cb1573..89b47a12a0 100644 --- a/tests/bench/experiment.bench.ts +++ b/tests/bench/experiment.bench.ts @@ -1,39 +1,28 @@ -import { chain, prop } from "ramda"; -import ts from "typescript"; import { bench } from "vitest"; -import { f } from "../../src/integration-helpers"; +import { BuiltinLogger } from "../../src"; -export const current = (nodes: ts.TypeLiteralNode[]) => - f.createTypeLiteralNode(nodes.flatMap(({ members }) => members)); +describe("Experiment for builtin logger", () => { + const fixed = (a: string, b?: number) => `${a}${b}`; + const generic = (...args: unknown[]) => args.join(); + const logger = new BuiltinLogger(); -export const feat = (nodes: ts.TypeLiteralNode[]) => - f.createTypeLiteralNode(chain(prop("members"), nodes)); + bench("fixed 2", () => { + fixed("second", 2); + }); + + bench("fixed 1", () => { + fixed("second"); + }); -describe("Experiment on flatMap", () => { - const subj = [ - f.createTypeLiteralNode([ - f.createPropertySignature( - undefined, - "test1", - undefined, - f.createTypeReferenceNode("test1"), - ), - ]), - f.createTypeLiteralNode([ - f.createPropertySignature( - undefined, - "test2", - undefined, - f.createTypeReferenceNode("test2"), - ), - ]), - ]; + bench("generic 2", () => { + generic("second", 2); + }); - bench("flatMap", () => { - current(subj); + bench("generic 1", () => { + generic("second"); }); - bench("chain+prop", () => { - feat(subj); + bench(".child", () => { + logger.child({}); }); }); diff --git a/tests/compat/eslint.config.js b/tests/compat/eslint.config.js index de53d248e6..7df418350f 100644 --- a/tests/compat/eslint.config.js +++ b/tests/compat/eslint.config.js @@ -3,5 +3,5 @@ import migration from "express-zod-api/migration"; export default [ { languageOptions: { parser }, plugins: { migration } }, - { files: ["**/*.ts"], rules: { "migration/v21": "error" } }, + { files: ["**/*.ts"], rules: { "migration/v22": "error" } }, ]; diff --git a/tests/compat/migration.spec.ts b/tests/compat/migration.spec.ts index 6e4013a7c8..089fa662a9 100644 --- a/tests/compat/migration.spec.ts +++ b/tests/compat/migration.spec.ts @@ -3,8 +3,6 @@ import { readFile } from "node:fs/promises"; describe("Migration", () => { test("should fix the import", async () => { const fixed = await readFile("./sample.ts", "utf-8"); - expect(fixed).toBe( - "createConfig({ http: { listen: 8090, }, beforeRouting: () => {}, upload: true });\n", - ); + expect(fixed).toBe(`client.provide("get /v1/test", {id: 10});\n`); }); }); diff --git a/tests/compat/package.json b/tests/compat/package.json index 3f1c3fd658..a7e38d576f 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ }, "scripts": { "preinstall": "rm -rf node_modules", - "pretest": "echo 'createConfig({ server: { listen: 8090, upload: true, beforeRouting: () => {}, } });' > sample.ts", + "pretest": "echo 'client.provide(\"get\", \"/v1/test\", {id: 10});' > sample.ts", "test": "eslint --fix && vitest --run && rm sample.ts" } } diff --git a/tests/issue952/tags.ts b/tests/issue952/tags.ts new file mode 100644 index 0000000000..ff08fa0dcd --- /dev/null +++ b/tests/issue952/tags.ts @@ -0,0 +1,41 @@ +import { + defaultEndpointsFactory, + TagOverrides, + Documentation, +} from "express-zod-api"; + +declare module "express-zod-api" { + export interface TagOverrides { + users: unknown; + files: unknown; + subscriptions: unknown; + } +} + +defaultEndpointsFactory.buildVoid({ + tag: "users", + handler: async () => {}, +}); + +defaultEndpointsFactory.buildVoid({ + tag: ["users", "files"], + handler: async () => {}, +}); + +expectTypeOf().toEqualTypeOf<{ + users: unknown; + files: unknown; + subscriptions: unknown; +}>(); + +new Documentation({ + title: "", + version: "", + serverUrl: "", + routing: {}, + config: { cors: false }, + tags: { + users: "", + files: { description: "", url: "" }, + }, +}); diff --git a/tests/system/__snapshots__/system.spec.ts.snap b/tests/system/__snapshots__/system.spec.ts.snap index 783e3e5115..8925b9baab 100644 --- a/tests/system/__snapshots__/system.spec.ts.snap +++ b/tests/system/__snapshots__/system.spec.ts.snap @@ -69,7 +69,7 @@ exports[`App in production mode > Protocol > Should fail on invalid method 1`] = exports[`App in production mode > Protocol > Should fail on malformed body 1`] = ` { "error": { - "message": StringMatching /\\(Unexpected end of JSON input\\|Unterminated string in JSON at position 25\\)/, + "message": StringMatching /Unterminated string in JSON at position 25/, }, "status": "error", } diff --git a/tests/system/example.spec.ts b/tests/system/example.spec.ts index a17f8526c5..c8953f27d3 100644 --- a/tests/system/example.spec.ts +++ b/tests/system/example.spec.ts @@ -2,10 +2,7 @@ import assert from "node:assert/strict"; import { EventSource } from "undici"; import { spawn } from "node:child_process"; import { createReadStream, readFileSync } from "node:fs"; -import { - ExpressZodAPIClient, - Implementation, -} from "../../example/example.client"; +import { Client, Implementation } from "../../example/example.client"; import { givePort } from "../helpers"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; @@ -445,7 +442,7 @@ describe("Example", async () => { }); describe("Client", () => { - const createDefaultImplementation = + const createImplementation = (host: string): Implementation => async (method, path, params) => { const hasBody = !["get", "delete"].includes(method); @@ -463,25 +460,13 @@ describe("Example", async () => { return response[isJSON ? "json" : "text"](); }; - const client = new ExpressZodAPIClient( - createDefaultImplementation(`http://localhost:${port}`), - ); + const client = new Client(createImplementation(`http://localhost:${port}`)); test("Should perform the request with a positive response", async () => { - const response = await client.provide("get", "/v1/user/retrieve", { - id: "10", - }); - expect(response).toMatchSnapshot(); - expectTypeOf(response).toMatchTypeOf< - | { status: "success"; data: { id: number; name: string } } - | { status: "error"; error: { message: string } } - >(); - }); - - test("Feature #2182: should provide using combined path+method", async () => { const response = await client.provide("get /v1/user/retrieve", { id: "10", }); + expect(response).toMatchSnapshot(); expectTypeOf(response).toMatchTypeOf< | { status: "success"; data: { id: number; name: string } } | { status: "error"; error: { message: string } } @@ -489,7 +474,7 @@ describe("Example", async () => { }); test("Issue #2177: should handle path params correctly", async () => { - const response = await client.provide("patch", "/v1/user/:id", { + const response = await client.provide("patch /v1/user/:id", { key: "123", id: "12", name: "Alan Turing", @@ -507,13 +492,10 @@ describe("Example", async () => { expectTypeOf(client.provide).toBeCallableWith("post /v1/user/create", {}); // @ts-expect-error -- can't use .toBeCallableWith with .not, see https://github.com/mmkal/expect-type expectTypeOf(client.provide).toBeCallableWith("get /v1/user/create", {}); - expectTypeOf( - client.provide("get", "/v1/user/create", {}), - ).resolves.toBeUnknown(); }); test("should handle no content (no response body)", async () => { - const response = await client.provide("delete", "/v1/user/:id/remove", { + const response = await client.provide("delete /v1/user/:id/remove", { id: "12", }); expect(response).toBeUndefined(); diff --git a/tests/system/system.spec.ts b/tests/system/system.spec.ts index 7089be3e89..3e44af9592 100644 --- a/tests/system/system.spec.ts +++ b/tests/system/system.spec.ts @@ -329,9 +329,7 @@ describe("App in production mode", async () => { expect(json).toMatchSnapshot({ error: { message: expect.stringMatching( - // @todo revisit when Node 18 dropped - // the 2nd option is for Node 20+ - /(Unexpected end of JSON input|Unterminated string in JSON at position 25)/, + /Unterminated string in JSON at position 25/, ), }, }); diff --git a/tests/unit/__snapshots__/documentation.spec.ts.snap b/tests/unit/__snapshots__/documentation.spec.ts.snap index 81330b529e..272a3e81ac 100644 --- a/tests/unit/__snapshots__/documentation.spec.ts.snap +++ b/tests/unit/__snapshots__/documentation.spec.ts.snap @@ -1794,13 +1794,7 @@ components: name: token links: {} callbacks: {} -tags: - - name: users - description: Everything about the users - - name: files - description: Everything about the files processing - - name: subscriptions - description: Everything about the subscriptions +tags: [] servers: - url: https://example.com " @@ -2513,13 +2507,7 @@ components: name: token links: {} callbacks: {} -tags: - - name: users - description: Everything about the users - - name: files - description: Everything about the files processing - - name: subscriptions - description: Everything about the subscriptions +tags: [] servers: - url: https://example.com " diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index 6d02f0760b..c58bc1f2aa 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -63,13 +63,21 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; +export const endpointTags = { "post /v1/test-with-dashes": [] }; -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { "post /v1/test-with-dashes": true }; +const parseRequest = (request: string) => + request.split(/ (.+)/, 2) as [Method, Path]; -export const endpointTags = { "post /v1/test-with-dashes": [] }; +const substitute = (path: string, params: Record) => { + const rest = { ...params }; + for (const key in params) { + path = path.replace(\`:\${key}\`, () => { + delete rest[key]; + return params[key]; + }); + } + return [path, rest] as const; +}; export type Implementation = ( method: Method, @@ -77,51 +85,17 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} - /** @deprecated use the overload with 2 arguments instead */ - public provide( - method: M, - path: P, - params: \`\${M} \${P}\` extends keyof Input - ? Input[\`\${M} \${P}\`] - : Record, - ): Promise< - \`\${M} \${P}\` extends keyof Response ? Response[\`\${M} \${P}\`] : unknown - >; +export class Client { + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], - ): Promise; - public provide( - ...args: - | [string, string, Record] - | [string, Record] - ) { - const [method, path, params] = ( - args.length === 2 ? [...args[0].split(/ (.+)/, 2), args[1]] : args - ) as [Method, Path, Record]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, - ), - {}, - ), - ); + ): Promise { + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -144,7 +118,7 @@ export const exampleImplementation: Implementation = async ( const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; -const client = new ExpressZodAPIClient(exampleImplementation); +const client = new Client(exampleImplementation); client.provide("get /v1/user/retrieve", { id: "10" }); */ " @@ -213,13 +187,21 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; +export const endpointTags = { "post /v1/test-with-dashes": [] }; -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { "post /v1/test-with-dashes": true }; +const parseRequest = (request: string) => + request.split(/ (.+)/, 2) as [Method, Path]; -export const endpointTags = { "post /v1/test-with-dashes": [] }; +const substitute = (path: string, params: Record) => { + const rest = { ...params }; + for (const key in params) { + path = path.replace(\`:\${key}\`, () => { + delete rest[key]; + return params[key]; + }); + } + return [path, rest] as const; +}; export type Implementation = ( method: Method, @@ -227,51 +209,17 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} - /** @deprecated use the overload with 2 arguments instead */ - public provide( - method: M, - path: P, - params: \`\${M} \${P}\` extends keyof Input - ? Input[\`\${M} \${P}\`] - : Record, - ): Promise< - \`\${M} \${P}\` extends keyof Response ? Response[\`\${M} \${P}\`] : unknown - >; +export class Client { + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], - ): Promise; - public provide( - ...args: - | [string, string, Record] - | [string, Record] - ) { - const [method, path, params] = ( - args.length === 2 ? [...args[0].split(/ (.+)/, 2), args[1]] : args - ) as [Method, Path, Record]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, - ), - {}, - ), - ); + ): Promise { + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -294,7 +242,7 @@ export const exampleImplementation: Implementation = async ( const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; -const client = new ExpressZodAPIClient(exampleImplementation); +const client = new Client(exampleImplementation); client.provide("get /v1/user/retrieve", { id: "10" }); */ " @@ -363,13 +311,21 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; +export const endpointTags = { "post /v1/test-with-dashes": [] }; -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { "post /v1/test-with-dashes": true }; +const parseRequest = (request: string) => + request.split(/ (.+)/, 2) as [Method, Path]; -export const endpointTags = { "post /v1/test-with-dashes": [] }; +const substitute = (path: string, params: Record) => { + const rest = { ...params }; + for (const key in params) { + path = path.replace(\`:\${key}\`, () => { + delete rest[key]; + return params[key]; + }); + } + return [path, rest] as const; +}; export type Implementation = ( method: Method, @@ -377,51 +333,17 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} - /** @deprecated use the overload with 2 arguments instead */ - public provide( - method: M, - path: P, - params: \`\${M} \${P}\` extends keyof Input - ? Input[\`\${M} \${P}\`] - : Record, - ): Promise< - \`\${M} \${P}\` extends keyof Response ? Response[\`\${M} \${P}\`] : unknown - >; +export class Client { + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], - ): Promise; - public provide( - ...args: - | [string, string, Record] - | [string, Record] - ) { - const [method, path, params] = ( - args.length === 2 ? [...args[0].split(/ (.+)/, 2), args[1]] : args - ) as [Method, Path, Record]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, - ), - {}, - ), - ); + ): Promise { + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -444,7 +366,7 @@ export const exampleImplementation: Implementation = async ( const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; -const client = new ExpressZodAPIClient(exampleImplementation); +const client = new Client(exampleImplementation); client.provide("get /v1/user/retrieve", { id: "10" }); */ " @@ -513,9 +435,6 @@ export interface Response { } export type Request = keyof Input; - -/** @deprecated use Request instead */ -export type MethodPath = Request; " `; @@ -923,19 +842,6 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; - -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { - "get /v1/user/retrieve": true, - "patch /v1/user/:id": true, - "post /v1/user/create": true, - "get /v1/user/list": true, - "post /v1/avatar/upload": true, - "post /v1/avatar/raw": true, -}; - export const endpointTags = { "get /v1/user/retrieve": ["users"], "delete /v1/user/:id/remove": ["users"], @@ -949,57 +855,37 @@ export const endpointTags = { "get /v1/events/time": ["subscriptions"], }; +const parseRequest = (request: string) => + request.split(/ (.+)/, 2) as [Method, Path]; + +const substitute = (path: string, params: Record) => { + const rest = { ...params }; + for (const key in params) { + path = path.replace(\`:\${key}\`, () => { + delete rest[key]; + return params[key]; + }); + } + return [path, rest] as const; +}; + export type Implementation = ( method: Method, path: string, params: Record, ) => Promise; -export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} - /** @deprecated use the overload with 2 arguments instead */ - public provide( - method: M, - path: P, - params: \`\${M} \${P}\` extends keyof Input - ? Input[\`\${M} \${P}\`] - : Record, - ): Promise< - \`\${M} \${P}\` extends keyof Response ? Response[\`\${M} \${P}\`] : unknown - >; +export class Client { + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], - ): Promise; - public provide( - ...args: - | [string, string, Record] - | [string, Record] - ) { - const [method, path, params] = ( - args.length === 2 ? [...args[0].split(/ (.+)/, 2), args[1]] : args - ) as [Method, Path, Record]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, - ), - {}, - ), - ); + ): Promise { + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -1022,7 +908,7 @@ export const exampleImplementation: Implementation = async ( const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; -const client = new ExpressZodAPIClient(exampleImplementation); +const client = new Client(exampleImplementation); client.provide("get /v1/user/retrieve", { id: "10" }); */ " @@ -1431,9 +1317,6 @@ export interface Response { } export type Request = keyof Input; - -/** @deprecated use Request instead */ -export type MethodPath = Request; " `; @@ -1507,9 +1390,6 @@ export interface Response { } export type Request = keyof Input; - -/** @deprecated use Request instead */ -export type MethodPath = Request; " `; @@ -1576,13 +1456,21 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; +export const endpointTags = { "post /v1/test-with-dashes": [] }; -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { "post /v1/test-with-dashes": true }; +const parseRequest = (request: string) => + request.split(/ (.+)/, 2) as [Method, Path]; -export const endpointTags = { "post /v1/test-with-dashes": [] }; +const substitute = (path: string, params: Record) => { + const rest = { ...params }; + for (const key in params) { + path = path.replace(\`:\${key}\`, () => { + delete rest[key]; + return params[key]; + }); + } + return [path, rest] as const; +}; export type Implementation = ( method: Method, @@ -1590,51 +1478,17 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} - /** @deprecated use the overload with 2 arguments instead */ - public provide( - method: M, - path: P, - params: \`\${M} \${P}\` extends keyof Input - ? Input[\`\${M} \${P}\`] - : Record, - ): Promise< - \`\${M} \${P}\` extends keyof Response ? Response[\`\${M} \${P}\`] : unknown - >; +export class Client { + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], - ): Promise; - public provide( - ...args: - | [string, string, Record] - | [string, Record] - ) { - const [method, path, params] = ( - args.length === 2 ? [...args[0].split(/ (.+)/, 2), args[1]] : args - ) as [Method, Path, Record]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, - ), - {}, - ), - ); + ): Promise { + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -1657,7 +1511,7 @@ export const exampleImplementation: Implementation = async ( const isJSON = contentType.startsWith("application/json"); return response[isJSON ? "json" : "text"](); }; -const client = new ExpressZodAPIClient(exampleImplementation); +const client = new Client(exampleImplementation); client.provide("get /v1/user/retrieve", { id: "10" }); */ " diff --git a/tests/unit/__snapshots__/logger-helpers.spec.ts.snap b/tests/unit/__snapshots__/logger-helpers.spec.ts.snap index f82b3fb72c..794ddd42cb 100644 --- a/tests/unit/__snapshots__/logger-helpers.spec.ts.snap +++ b/tests/unit/__snapshots__/logger-helpers.spec.ts.snap @@ -1,10 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Logger helpers > formatDuration() > 0 should format 1e-9 ms 1`] = `"1 picosecond"`; +exports[`Logger helpers > formatDuration() > 0 should format 1e-9 ms 1`] = `"0.001 nanoseconds"`; -exports[`Logger helpers > formatDuration() > 1 should format 1e-8 ms 1`] = `"10 picoseconds"`; +exports[`Logger helpers > formatDuration() > 1 should format 1e-8 ms 1`] = `"0.01 nanoseconds"`; -exports[`Logger helpers > formatDuration() > 2 should format 1e-7 ms 1`] = `"100 picoseconds"`; +exports[`Logger helpers > formatDuration() > 2 should format 1e-7 ms 1`] = `"0.1 nanoseconds"`; exports[`Logger helpers > formatDuration() > 3 should format 0.000001 ms 1`] = `"1 nanosecond"`; diff --git a/tests/unit/__snapshots__/migration.spec.ts.snap b/tests/unit/__snapshots__/migration.spec.ts.snap index e84a2e203d..9babad1353 100644 --- a/tests/unit/__snapshots__/migration.spec.ts.snap +++ b/tests/unit/__snapshots__/migration.spec.ts.snap @@ -3,14 +3,15 @@ exports[`Migration > should consist of one rule being the major version of the package 1`] = ` { "rules": { - "v21": { + "v22": { "create": [Function], "defaultOptions": [], "meta": { "fixable": "code", "messages": { + "add": "Add {{subject}} to {{to}}", "change": "Change {{subject}} {{from}} to {{to}}.", - "move": "Move {{subject}} from {{from}} to {{to}}.", + "remove": "Remove {{subject}} {{name}}.", }, "schema": [], "type": "problem", diff --git a/tests/unit/builtin-logger.spec.ts b/tests/unit/builtin-logger.spec.ts index 74ea8aa255..0343fceb1b 100644 --- a/tests/unit/builtin-logger.spec.ts +++ b/tests/unit/builtin-logger.spec.ts @@ -142,7 +142,7 @@ describe("BuiltinLogger", () => { stop(); expect(logSpy).toHaveBeenCalledWith( expect.stringMatching( - /2022-01-01T00:00:00.000Z debug: test '[\d.]+ (pico|micro|milli)?second(s)?'/, + /2022-01-01T00:00:00.000Z debug: test '[\d.]+ (nano|micro|milli)?second(s)?'/, ), ); }, diff --git a/tests/unit/common-helpers.spec.ts b/tests/unit/common-helpers.spec.ts index f9ec21170d..53b676cb31 100644 --- a/tests/unit/common-helpers.spec.ts +++ b/tests/unit/common-helpers.spec.ts @@ -3,12 +3,10 @@ import createHttpError from "http-errors"; import { combinations, defaultInputSources, - getCustomHeaders, getExamples, getInput, getMessageFromError, hasCoercion, - isCustomHeader, makeCleanId, ensureError, } from "../../src/common-helpers"; @@ -22,27 +20,6 @@ describe("Common Helpers", () => { }); }); - describe("isCustomHeader()", () => { - test.each([ - { name: "x-request-id", expected: true }, - { name: "authorization", expected: false }, - ])("should validate those starting with x- %#", ({ name, expected }) => { - expect(isCustomHeader(name)).toBe(expected); - }); - }); - - describe("getCustomHeaders()", () => { - test("should reduce the object to the custom headers only", () => { - expect( - getCustomHeaders({ - authorization: "Bearer ***", - "x-request-id": "test", - "x-another": "header", - }), - ).toEqual({ "x-request-id": "test", "x-another": "header" }); - }); - }); - describe("getInput()", () => { test("should return body for POST, PUT and PATCH requests by default", () => { expect( @@ -133,7 +110,7 @@ describe("Common Helpers", () => { getInput(makeRequestMock({ method: "OPTIONS" }), undefined), ).toEqual({}); }); - test("Feature 1180: should include custom headers when enabled", () => { + test("Features 1180 and 2337: should include headers when enabled", () => { expect( getInput( makeRequestMock({ @@ -143,7 +120,12 @@ describe("Common Helpers", () => { }), { post: ["body", "headers"] }, ), - ).toEqual({ a: "body", "x-request-id": "test" }); + ).toEqual({ + a: "body", + authorization: "Bearer ***", + "content-type": "application/json", + "x-request-id": "test", + }); }); }); diff --git a/tests/unit/documentation-helpers.spec.ts b/tests/unit/documentation-helpers.spec.ts index f74fbae70b..1b6a546f5f 100644 --- a/tests/unit/documentation-helpers.spec.ts +++ b/tests/unit/documentation-helpers.spec.ts @@ -45,6 +45,7 @@ import { excludeParamsFromDepiction, extractObjectSchema, getRoutePathParams, + defaultIsHeader, onEach, onMissing, reformatParamsInPath, @@ -639,6 +640,19 @@ describe("Documentation helpers", () => { }); }); + describe("defaultIsHeader()", () => { + test.each([ + { name: "x-request-id", expected: true }, + { name: "authorization", expected: true }, + { name: "unknown", expected: false }, + ])( + "should validate custom and well-known headers %#", + ({ name, expected }) => { + expect(defaultIsHeader(name)).toBe(expected); + }, + ); + }); + describe("depictRequestParams()", () => { test("should depict query and path params", () => { expect( diff --git a/tests/unit/logger-helpers.spec.ts b/tests/unit/logger-helpers.spec.ts index 2266475918..a483abb7f3 100644 --- a/tests/unit/logger-helpers.spec.ts +++ b/tests/unit/logger-helpers.spec.ts @@ -2,10 +2,11 @@ import { BuiltinLogger } from "../../src"; import { BuiltinLoggerConfig } from "../../src/builtin-logger"; import { AbstractLogger, - formatDuration, isLoggerInstance, isSeverity, isHidden, + makeNumberFormat, + formatDuration, } from "../../src/logger-helpers"; describe("Logger helpers", () => { @@ -78,12 +79,46 @@ describe("Logger helpers", () => { }); }); + describe.each([undefined, 0, 2])( + "makeNumberFormat() with %s fraction", + (fraction) => { + const defaultLocale = new Intl.NumberFormat().resolvedOptions().locale; + test.each([ + "nanosecond", + "microsecond", + "millisecond", + "second", + "minute", + ] as const)("should return Intl instance for %s unit", (unit) => { + const instance = makeNumberFormat(unit, fraction); + expect(instance).toBeInstanceOf(Intl.NumberFormat); + expect(instance.resolvedOptions()).toEqual({ + unit, + maximumFractionDigits: fraction || 0, + locale: defaultLocale, + minimumFractionDigits: 0, + minimumIntegerDigits: 1, + notation: "standard", + numberingSystem: "latn", + roundingIncrement: 1, + roundingMode: "halfExpand", + roundingPriority: "auto", + signDisplay: "auto", + style: "unit", + trailingZeroDisplay: "auto", + unitDisplay: "long", + useGrouping: false, + }); + }); + }, + ); + describe("formatDuration()", () => { test.each([ 1e-9, 1e-8, 1e-7, 1e-6, 1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1, 1e1, 1e2, 1e3, 15e2, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, - ])("%# should format %s ms", (duration) => - expect(formatDuration(duration)).toMatchSnapshot(), - ); + ])("%# should format %s ms", (duration) => { + expect(formatDuration(duration)).toMatchSnapshot(); + }); }); }); diff --git a/tests/unit/migration.spec.ts b/tests/unit/migration.spec.ts index a84fd3909a..56c4e5c757 100644 --- a/tests/unit/migration.spec.ts +++ b/tests/unit/migration.spec.ts @@ -17,188 +17,107 @@ describe("Migration", () => { expect(migration).toMatchSnapshot(); }); - tester.run("v21", migration.rules.v21, { + tester.run("v22", migration.rules.v22, { valid: [ - `(() => {})()`, - `createConfig({ http: {} });`, - `createConfig({ http: { listen: 8090 }, upload: true });`, - `createConfig({ beforeRouting: ({ getLogger }) => { getLogger().warn() } });`, - `const { app, servers, logger } = await createServer();`, - `console.error(error.cause?.message);`, - `import { ensureHttpError } from "express-zod-api";`, - `ensureHttpError(error).statusCode;`, - `factory.build({ method: ['get', 'post'] })`, - `factory.build({ tag: ['files', 'users'] })`, - `factory.build({ scope: ['admin', 'permissions'] })`, - `new ResultHandler({ positive: () => ({ statusCode: [201, 202] }), negative: [{ mimeType: ["application/json"] }] })`, + `client.provide("get /v1/test", {id: 10});`, + `new Integration({ routing });`, + `import { Request } from "./client.ts";`, + `createConfig({ cors: true });`, + `new Documentation();`, + `new EndpointsFactory(new ResultHandler());`, + `new EventStreamFactory({});`, + `new Client();`, ], invalid: [ { - code: `createConfig({ server: {} });`, - output: `createConfig({ http: {} });`, + code: `client.provide("get", "/v1/test", {id: 10});`, + output: `client.provide("get /v1/test", {id: 10});`, errors: [ { messageId: "change", - data: { subject: "property", from: "server", to: "http" }, - }, - ], - }, - { - code: `createConfig({ http: { listen: 8090, upload: true } });`, - output: `createConfig({ http: { listen: 8090, }, upload: true });`, - errors: [ - { - messageId: "move", data: { - subject: "upload", - from: "http", - to: "the top level of createConfig argument", + subject: "arguments", + from: `"get", "/v1/test"`, + to: `"get /v1/test"`, }, }, ], }, { - code: `createConfig({ beforeRouting: ({ logger }) => { logger.warn() } });`, - output: `createConfig({ beforeRouting: ({ getLogger }) => { getLogger().warn() } });`, + code: `new Integration({ routing, splitResponse: true });`, + output: `new Integration({ routing, });`, errors: [ { - messageId: "change", - data: { - subject: "property", - from: "logger", - to: "getLogger", - }, - }, - { - messageId: "change", - data: { - subject: "const", - from: "logger", - to: "getLogger()", - }, + messageId: "remove", + data: { subject: "property", name: "splitResponse" }, }, ], }, { - code: `createConfig({ beforeRouting: ({ getChildLogger }) => { getChildLogger(request).warn() } });`, - output: `createConfig({ beforeRouting: ({ getLogger }) => { getLogger(request).warn() } });`, + code: `import { MethodPath } from "./client.ts";`, + output: `import { Request } from "./client.ts";`, errors: [ { messageId: "change", - data: { - subject: "property", - from: "getChildLogger", - to: "getLogger", - }, - }, - { - messageId: "change", - data: { - subject: "method", - from: "getChildLogger", - to: "getLogger", - }, - }, - ], - }, - { - code: `const { app, httpServer, httpsServer, logger } = await createServer();`, - errors: [ - { - messageId: "change", - data: { subject: "property", from: "httpServer", to: "servers" }, - }, - { - messageId: "change", - data: { subject: "property", from: "httpsServer", to: "servers" }, + data: { subject: "type", from: "MethodPath", to: "Request" }, }, ], }, { - code: `console.error(error.originalError?.message);`, + code: `createConfig({ tags: { users: "" } });`, + output: + `createConfig({ });\n` + + `// Declaring tag constraints\n` + + `declare module "express-zod-api" {\n` + + ` interface TagOverrides {\n` + + ` "users": unknown,\n` + + ` }\n` + + `}`, errors: [ - { - messageId: "change", - data: { subject: "property", from: "originalError", to: "cause" }, - }, + { messageId: "remove", data: { subject: "property", name: "tags" } }, ], }, { - code: `import { getStatusCodeFromError } from "express-zod-api";`, - output: `import { ensureHttpError } from "express-zod-api";`, + code: `new Documentation({ config });`, + output: `new Documentation({ tags: { /* move from createConfig() argument if any */ }, config });`, errors: [ - { - messageId: "change", - data: { - subject: "import", - from: "getStatusCodeFromError", - to: "ensureHttpError", - }, - }, + { messageId: "add", data: { subject: "tags", to: "Documentation" } }, ], }, { - code: `getStatusCodeFromError(error);`, - output: `ensureHttpError(error).statusCode;`, + code: `new EndpointsFactory({config, resultHandler: new ResultHandler() });`, + output: `new EndpointsFactory(new ResultHandler());`, errors: [ { messageId: "change", data: { - subject: "method", - from: "getStatusCodeFromError", - to: "ensureHttpError().statusCode", + subject: "argument", + from: "object", + to: "ResultHandler instance", }, }, ], }, { - code: `factory.build({ methods: ['get', 'post'] })`, - output: `factory.build({ method: ['get', 'post'] })`, + code: `new EventStreamFactory({ config, events: { some } });`, + output: `new EventStreamFactory({ some });`, errors: [ { messageId: "change", - data: { subject: "property", from: "methods", to: "method" }, + data: { subject: "argument", from: "object", to: "events map" }, }, ], }, { - code: `factory.build({ tags: ['files', 'users'] })`, - output: `factory.build({ tag: ['files', 'users'] })`, + code: `new ExpressZodAPIClient();`, + output: `new Client();`, errors: [ - { - messageId: "change", - data: { subject: "property", from: "tags", to: "tag" }, - }, - ], - }, - { - code: `factory.build({ scopes: ['admin', 'permissions'] })`, - output: `factory.build({ scope: ['admin', 'permissions'] })`, - errors: [ - { - messageId: "change", - data: { subject: "property", from: "scopes", to: "scope" }, - }, - ], - }, - { - code: `new ResultHandler({ positive: () => ({ statusCodes: [201, 202] }), negative: [{ mimeTypes: ["application/json"] }] })`, - output: `new ResultHandler({ positive: () => ({ statusCode: [201, 202] }), negative: [{ mimeType: ["application/json"] }] })`, - errors: [ - { - messageId: "change", - data: { - subject: "property", - from: "statusCodes", - to: "statusCode", - }, - }, { messageId: "change", data: { - subject: "property", - from: "mimeTypes", - to: "mimeType", + subject: "class", + from: "ExpressZodAPIClient", + to: "Client", }, }, ], diff --git a/tests/unit/sse.spec.ts b/tests/unit/sse.spec.ts index 7315c27e50..56acc2f82f 100644 --- a/tests/unit/sse.spec.ts +++ b/tests/unit/sse.spec.ts @@ -138,15 +138,11 @@ describe("SSE", () => { describe("EventStreamFactory()", () => { test("should inherit from EndpointsFactory", () => { - expect(new EventStreamFactory({ events: {} })).toBeInstanceOf( - EndpointsFactory, - ); + expect(new EventStreamFactory({})).toBeInstanceOf(EndpointsFactory); }); test("should combine SSE Middlware with corresponding ResultHandler and return Endpoint", async () => { - const endpoint = new EventStreamFactory({ - events: { test: z.string() }, - }).buildVoid({ + const endpoint = new EventStreamFactory({ test: z.string() }).buildVoid({ input: z.object({ some: z.string().optional() }), handler: async ({ input, options }) => { expectTypeOf(input).toMatchTypeOf<{ some?: string }>(); diff --git a/tests/unit/zts.spec.ts b/tests/unit/zts.spec.ts index d0ec218a4d..9dfab7a34b 100644 --- a/tests/unit/zts.spec.ts +++ b/tests/unit/zts.spec.ts @@ -1,9 +1,9 @@ import ts from "typescript"; import { z } from "zod"; import { ez } from "../../src"; -import { f } from "../../src/integration-helpers"; +import { f, printNode } from "../../src/typescript-api"; import { zodToTs } from "../../src/zts"; -import { ZTSContext, printNode } from "../../src/zts-helpers"; +import { ZTSContext } from "../../src/zts-helpers"; describe("zod-to-ts", () => { const printNodeTest = (node: ts.Node) => diff --git a/tools/headers.ts b/tools/headers.ts new file mode 100644 index 0000000000..e2951c3337 --- /dev/null +++ b/tools/headers.ts @@ -0,0 +1,52 @@ +import { writeFile, stat } from "node:fs/promises"; +import { z } from "zod"; + +const dest = "src/well-known-headers.json"; +const { mtime } = await stat(dest); + +console.info("Current state", mtime); + +/** + * @link https://www.iana.org/assignments/http-fields/http-fields.xhtml + * @example https://github.com/ladjs/message-headers/blob/master/cron.js + */ +const response = await fetch( + "https://www.iana.org/assignments/http-fields/field-names.csv", +); +const lastMod = response.headers.get("last-modified"); +if (!lastMod) + throw new Error("Can not get Last-Modified headers from response"); +const state = new Date(lastMod); +console.info("Last modified", state); +if (state <= mtime) process.exit(0); + +const csv = await response.text(); + +const categories = [ + "permanent", + "deprecated", + "provisional", + "obsoleted", +] as const; + +const schema = z.object({ + name: z.string().regex(/^[\w-]+$/), + category: z.enum(categories), +}); + +const lines = csv.split("\n").slice(1, -1); +const headers = lines + .map((line) => { + const [name, category] = line.split(",").slice(0, 2); + return { name, category }; + }) + .filter((entry) => { + const { success } = schema.safeParse(entry); + if (!success) console.debug("excluding", entry); + return success; + }) + .map(({ name }) => name.toLowerCase()); + +console.debug("CRC:", headers.length); + +await writeFile(dest, JSON.stringify(headers), "utf-8"); diff --git a/tsconfig.base.json b/tsconfig.base.json index 36325242f1..0a60dc4001 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,5 +1,5 @@ { - "extends": "@tsconfig/node18/tsconfig.json", + "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "noImplicitAny": true, "noImplicitOverride": true, diff --git a/yarn.lock b/yarn.lock index ef174e2b05..4e78190d7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -604,10 +604,10 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== -"@tsconfig/node18@^18.2.1": - version "18.2.4" - resolved "https://registry.yarnpkg.com/@tsconfig/node18/-/node18-18.2.4.tgz#094efbdd70f697d37c09f34067bf41bc4a828ae3" - integrity sha512-5xxU8vVs9/FNcvm3gE07fPbn9tl6tqGGWA9tSlwsUEkBxtRnTsNmwrV8gasZ9F/EobaSv9+nu8AxUKccw77JpQ== +"@tsconfig/node20@^20.1.4": + version "20.1.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.4.tgz#3457d42eddf12d3bde3976186ab0cd22b85df928" + integrity sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg== "@types/body-parser@*": version "1.19.5"