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 @@
-
-
\ No newline at end of file
+
\ 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"