From 66853e18f59704cbc625a7d9db2000b847681a67 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 26 Nov 2024 13:33:20 +0100 Subject: [PATCH 01/46] Changing trigger branches in workflows. --- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/node.js.yml | 4 ++-- .github/workflows/swagger.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3cd4845052..4b0a2e1036 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, make-v22 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21, make-v22 ] schedule: - cron: '26 8 * * 1' diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 56fde19642..c9b0d36022 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, make-v22 ] pull_request: - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21, make-v22 ] jobs: build: diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index b88ade3043..c68a8dad23 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, make-v22 ] pull_request: - branches: [ master, v18, v19, v20 ] + branches: [ master, v19, v20, v21, make-v22 ] jobs: From 5b7df35e75793a58dd68ada0fa69e77b4563de6b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 26 Nov 2024 13:43:04 +0100 Subject: [PATCH 02/46] Drop Node 18 (#2063) Planned for next major after 30.04.2025 https://endoflife.date/nodejs Including refactoring time units formatting using Intl built-ins. ~~The part of using `Intl` for profiling can be cherry-picked if the following issue fixed:~~ can not be fixed in Node 18: ```url https://github.com/nodejs/node/issues/55833 ``` --- .github/workflows/minor.yml | 2 +- .github/workflows/node.js.yml | 2 +- .github/workflows/npm-publish.yml | 2 +- .github/workflows/patch.yml | 2 +- CHANGELOG.md | 7 +++ package.json | 4 +- src/logger-helpers.ts | 46 ++++++++-------- tests/bench/experiment.bench.ts | 54 ++++++++----------- .../system/__snapshots__/system.spec.ts.snap | 2 +- tests/system/system.spec.ts | 4 +- .../__snapshots__/logger-helpers.spec.ts.snap | 6 +-- tests/unit/builtin-logger.spec.ts | 2 +- tests/unit/logger-helpers.spec.ts | 43 +++++++++++++-- tsconfig.base.json | 2 +- yarn.lock | 8 +-- 15 files changed, 110 insertions(+), 76 deletions(-) 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 c9b0d36022..4aa613382c 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -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/CHANGELOG.md b/CHANGELOG.md index 997ee0241e..008752e5d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Version 22 + +### v22.0.0 + +- Minimum supported Node versions: 20.9.0 and 22.0.0; +- `BuiltinLogger::profile()` behavior changed for picoseconds: expressing them through nanoseconds. + ## Version 21 ### v21.1.0 diff --git a/package.json b/package.json index f61fb96f3a..b3029dda54 100644 --- a/package.json +++ b/package.json @@ -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/logger-helpers.ts b/src/logger-helpers.ts index c15faf1065..7d757dc77e 100644 --- a/src/logger-helpers.ts +++ b/src/logger-helpers.ts @@ -1,3 +1,4 @@ +import { memoizeWith } from "ramda"; import { isObject } from "./common-helpers"; const severity = { @@ -34,32 +35,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/tests/bench/experiment.bench.ts b/tests/bench/experiment.bench.ts index d4121f8839..89b47a12a0 100644 --- a/tests/bench/experiment.bench.ts +++ b/tests/bench/experiment.bench.ts @@ -1,36 +1,28 @@ import { bench } from "vitest"; -import { retrieveUserEndpoint } from "../../example/endpoints/retrieve-user"; -import { DependsOnMethod } from "../../src"; -import { walkRouting } from "../../src/routing-walker"; +import { BuiltinLogger } from "../../src"; -const routing = { - a: { - b: { - c: { - d: { - e: { - f: { - g: { - h: { - i: { - j: new DependsOnMethod({ - post: retrieveUserEndpoint, - }), - }, - }, - }, - }, - }, - }, - }, - }, - k: { l: {} }, - m: {}, - }, -}; +describe("Experiment for builtin logger", () => { + const fixed = (a: string, b?: number) => `${a}${b}`; + const generic = (...args: unknown[]) => args.join(); + const logger = new BuiltinLogger(); -describe("Experiment for routing walker", () => { - bench("featured", () => { - walkRouting({ routing, onEndpoint: vi.fn() }); + bench("fixed 2", () => { + fixed("second", 2); + }); + + bench("fixed 1", () => { + fixed("second"); + }); + + bench("generic 2", () => { + generic("second", 2); + }); + + bench("generic 1", () => { + generic("second"); + }); + + bench(".child", () => { + logger.child({}); }); }); 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/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__/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/builtin-logger.spec.ts b/tests/unit/builtin-logger.spec.ts index e096f7a87a..471cd643ca 100644 --- a/tests/unit/builtin-logger.spec.ts +++ b/tests/unit/builtin-logger.spec.ts @@ -146,7 +146,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/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/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 ae01f8bf84..521bc9d047 100644 --- a/yarn.lock +++ b/yarn.lock @@ -706,10 +706,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" From 9e4535d2fc5050f586fde9915c4f64809bbc3423 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 27 Nov 2024 15:23:40 +0100 Subject: [PATCH 03/46] rm redundant import --- tests/bench/experiment.bench.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/bench/experiment.bench.ts b/tests/bench/experiment.bench.ts index 2d1eb91529..89b47a12a0 100644 --- a/tests/bench/experiment.bench.ts +++ b/tests/bench/experiment.bench.ts @@ -1,5 +1,3 @@ -import { chain, prop } from "ramda"; -import ts from "typescript"; import { bench } from "vitest"; import { BuiltinLogger } from "../../src"; From 429e0e3df2f7018cdfae831500d5740f5231f19c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 1 Dec 2024 18:17:27 +0100 Subject: [PATCH 04/46] Cleanup migration for v22. --- package.json | 2 +- src/migration.ts | 273 +----------------- .../unit/__snapshots__/migration.spec.ts.snap | 2 +- tests/unit/migration.spec.ts | 190 +----------- 4 files changed, 13 insertions(+), 454 deletions(-) diff --git a/package.json b/package.json index b9e1fc49f4..f88da2ef45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "21.2.0", + "version": "22.0.0-beta.0", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { diff --git a/src/migration.ts b/src/migration.ts index c3d596f2af..06ecd7d301 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -1,72 +1,12 @@ import { ESLintUtils, - AST_NODE_TYPES as NT, + // AST_NODE_TYPES as NT, type TSESLint, - type TSESTree, + // type TSESTree, } from "@typescript-eslint/utils"; -import { name as importName } 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"; - -const changedProps = { - [serverPropName]: "http", - [httpServerPropName]: "servers", - [httpsServerPropName]: "servers", - [originalErrorPropName]: "cause", - [loggerPropName]: "getLogger", - [getChildLoggerPropName]: "getLogger", - [methodsPropName]: "method", - [tagsPropName]: "tag", - [scopesPropName]: "scope", - [statusCodesPropName]: "statusCode", - [mimeTypesPropName]: "mimeType", -}; - -const changedMethods = { - [getStatusCodeFromErrorMethod]: "ensureHttpError", -}; - -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})/]`, -}; +// import { name as importName } from "../package.json"; +/* type PropWithId = TSESTree.Property & { key: TSESTree.Identifier; }; @@ -86,8 +26,9 @@ const propByName = (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", @@ -98,205 +39,7 @@ const v21 = ESLintUtils.RuleCreator.withoutDocs({ }, }, 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]; - ctx.report({ - node: node.property, - messageId: "change", - data: { - subject: "property", - from: node.property.name, - to: replacement, - }, - }); - } - }, - [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]; - ctx.report({ - node: node.callee, - messageId: "change", - data: { - subject: "method", - from: node.callee.name, - to: `${replacement}().statusCode`, - }, - fix: (fixer) => [ - fixer.replaceText(node.callee, replacement), - fixer.insertTextAfter(node, ".statusCode"), - ], - }); - } - }, - [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), - }); - }, - }), + create: () => ({}), }); /** @@ -312,5 +55,5 @@ const v21 = ESLintUtils.RuleCreator.withoutDocs({ * ]; * */ export default { - rules: { v21 }, + rules: { v22 }, } satisfies TSESLint.Linter.Plugin; diff --git a/tests/unit/__snapshots__/migration.spec.ts.snap b/tests/unit/__snapshots__/migration.spec.ts.snap index e84a2e203d..fbd0edc639 100644 --- a/tests/unit/__snapshots__/migration.spec.ts.snap +++ b/tests/unit/__snapshots__/migration.spec.ts.snap @@ -3,7 +3,7 @@ exports[`Migration > should consist of one rule being the major version of the package 1`] = ` { "rules": { - "v21": { + "v22": { "create": [Function], "defaultOptions": [], "meta": { diff --git a/tests/unit/migration.spec.ts b/tests/unit/migration.spec.ts index a84fd3909a..fd2b5f91d2 100644 --- a/tests/unit/migration.spec.ts +++ b/tests/unit/migration.spec.ts @@ -17,192 +17,8 @@ describe("Migration", () => { expect(migration).toMatchSnapshot(); }); - tester.run("v21", migration.rules.v21, { - 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"] }] })`, - ], - invalid: [ - { - code: `createConfig({ server: {} });`, - output: `createConfig({ http: {} });`, - 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", - }, - }, - ], - }, - { - code: `createConfig({ beforeRouting: ({ logger }) => { logger.warn() } });`, - output: `createConfig({ beforeRouting: ({ getLogger }) => { getLogger().warn() } });`, - errors: [ - { - messageId: "change", - data: { - subject: "property", - from: "logger", - to: "getLogger", - }, - }, - { - messageId: "change", - data: { - subject: "const", - from: "logger", - to: "getLogger()", - }, - }, - ], - }, - { - code: `createConfig({ beforeRouting: ({ getChildLogger }) => { getChildLogger(request).warn() } });`, - output: `createConfig({ beforeRouting: ({ getLogger }) => { getLogger(request).warn() } });`, - 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" }, - }, - ], - }, - { - code: `console.error(error.originalError?.message);`, - errors: [ - { - messageId: "change", - data: { subject: "property", from: "originalError", to: "cause" }, - }, - ], - }, - { - code: `import { getStatusCodeFromError } from "express-zod-api";`, - output: `import { ensureHttpError } from "express-zod-api";`, - errors: [ - { - messageId: "change", - data: { - subject: "import", - from: "getStatusCodeFromError", - to: "ensureHttpError", - }, - }, - ], - }, - { - code: `getStatusCodeFromError(error);`, - output: `ensureHttpError(error).statusCode;`, - errors: [ - { - messageId: "change", - data: { - subject: "method", - from: "getStatusCodeFromError", - to: "ensureHttpError().statusCode", - }, - }, - ], - }, - { - code: `factory.build({ methods: ['get', 'post'] })`, - output: `factory.build({ method: ['get', 'post'] })`, - errors: [ - { - messageId: "change", - data: { subject: "property", from: "methods", to: "method" }, - }, - ], - }, - { - code: `factory.build({ tags: ['files', 'users'] })`, - output: `factory.build({ tag: ['files', 'users'] })`, - 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", - }, - }, - ], - }, - ], + tester.run("v22", migration.rules.v22, { + valid: [``], + invalid: [], }); }); From 2ec5a75ad83a2c1c1d118251ac04c9265bce30b6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 1 Dec 2024 18:29:43 +0100 Subject: [PATCH 05/46] Cleanup compat test for migration. --- example/example.documentation.yaml | 2 +- tests/compat/eslint.config.js | 2 +- tests/compat/migration.spec.ts | 4 +--- tests/compat/package.json | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 3e7f27b58b..a1d6291aad 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.2.0 + version: 22.0.0-beta.0 paths: /v1/user/retrieve: get: 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..12b519aa20 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("\n"); }); }); diff --git a/tests/compat/package.json b/tests/compat/package.json index 3f1c3fd658..127da90085 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 '' > sample.ts", "test": "eslint --fix && vitest --run && rm sample.ts" } } From 5221f862cb8735d9b0ff756b4ee50e07a356a46e Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 1 Dec 2024 19:28:11 +0100 Subject: [PATCH 06/46] Drop `provider()` overload having 3 arguments (#2206) After merging #2200 Targets the future v22 --- CHANGELOG.md | 4 +- eslint.config.js | 139 ++++--- example/example.client.ts | 31 +- src/integration-helpers.ts | 79 ++-- src/integration.ts | 380 ++++++------------ src/migration.ts | 63 ++- tests/compat/migration.spec.ts | 2 +- tests/compat/package.json | 2 +- tests/system/example.spec.ts | 19 +- .../__snapshots__/integration.spec.ts.snap | 186 ++------- tests/unit/migration.spec.ts | 19 +- 11 files changed, 332 insertions(+), 592 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce339a7c6..c88516717b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ ### v22.0.0 - Minimum supported Node versions: 20.9.0 and 22.0.0; -- `BuiltinLogger::profile()` behavior changed for picoseconds: expressing them through nanoseconds. +- `BuiltinLogger::profile()` behavior changed for picoseconds: expressing them through nanoseconds; +- Changes to client generated by `Integration`: + - The overload of `ExpressZodAPIClient::provide()` having 3 arguments and the `Provider` type are removed. ## Version 21 diff --git a/eslint.config.js b/eslint.config.js index 57cc5c94ea..b978c50463 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,79 @@ const peformanceConcerns = [ }, ]; +const tsFactoryConcerns = [ + { + selector: "Identifier[name='createConditionalExpression']", + message: "use makeTernary() helper", + }, + { + selector: "Identifier[name='createArrowFunction']", + message: "use makeArrowFn() helper", + }, + { + selector: "Identifier[name='createTypeParameterDeclaration']", + message: "use makeTypeParams() helper", + }, + { + selector: "Identifier[name='createInterfaceDeclaration']", + message: "use makePublicInterface() helper", + }, + { + selector: "Identifier[name='createClassDeclaration']", + message: "use makePublicClass() helper", + }, + { + selector: "Identifier[name='createMethodDeclaration']", + message: "use makePublicMethod() helper", + }, + { + selector: "Identifier[name='createTypeAliasDeclaration']", + message: "use makePublicType() or makePublicLiteralType() helpers", + }, + { + selector: "Identifier[name='createVariableStatement']", + message: "use makeConst() helper", + }, + { + selector: "Identifier[name='createArrayBindingPattern']", + message: "use makeDeconstruction() helper", + }, + { + selector: "Identifier[name='createPropertySignature']", + message: "use makeInterfaceProp() helper", + }, + { + selector: "Identifier[name='createConstructorDeclaration']", + message: "use makeEmptyInitializingConstructor() helper", + }, + { + selector: "Identifier[name='createParameterDeclaration']", + message: "use makeParam() or makeParams() helpers", + }, + { + selector: + "CallExpression[callee.property.name='createCallExpression']" + + "[arguments.0.callee.property.name='createPropertyAccessExpression']", + message: "use makePropCall() helper", + }, + { + selector: "Identifier[name='AmpersandAmpersandToken']", + message: "use makeAnd() helper", + }, + { + selector: "Identifier[name='EqualsEqualsEqualsToken']", + message: "use makeEqual() helper", + }, + { + selector: "Identifier[name='createTemplateExpression']", + message: "use makeTemplate() helper", + }, + { + selector: "Identifier[name='createNewExpression']", + message: "use makeNew() helper", + }, +]; + export default tsPlugin.config( { languageOptions: { globals: globals.node }, @@ -76,71 +149,7 @@ export default tsPlugin.config( name: "source/integration", files: ["src/integration.ts"], rules: { - "no-restricted-syntax": [ - "warn", - { - selector: "Identifier[name='createConditionalExpression']", - message: "use makeTernary() helper", - }, - { - selector: "Identifier[name='createArrowFunction']", - message: "use makeArrowFn() helper", - }, - { - selector: "Identifier[name='createTypeParameterDeclaration']", - message: "use makeTypeParams() helper", - }, - { - selector: "Identifier[name='createInterfaceDeclaration']", - message: "use makePublicInterface() helper", - }, - { - selector: "Identifier[name='createClassDeclaration']", - message: "use makePublicClass() helper", - }, - { - selector: "Identifier[name='createMethodDeclaration']", - message: "use makePublicMethod() helper", - }, - { - selector: "Identifier[name='createTypeAliasDeclaration']", - message: "use makePublicType() or makePublicLiteralType() helpers", - }, - { - selector: "Identifier[name='createVariableDeclarationList']", - message: "use makeConst() helper", - }, - { - selector: "Identifier[name='createArrayBindingPattern']", - message: "use makeDeconstruction() helper", - }, - { - selector: "Identifier[name='createPropertySignature']", - message: "use makeInterfaceProp() helper", - }, - { - selector: "Identifier[name='createConstructorDeclaration']", - message: "use makeEmptyInitializingConstructor() helper", - }, - { - selector: "Identifier[name='createParameterDeclaration']", - message: "use makeParam() or makeParams() helpers", - }, - { - selector: - "CallExpression[callee.property.name='createCallExpression']" + - "[arguments.0.callee.property.name='createPropertyAccessExpression']", - message: "use makePropCall() helper", - }, - { - selector: "Identifier[name='AmpersandAmpersandToken']", - message: "use makeAnd() helper", - }, - { - selector: "Identifier[name='EqualsEqualsEqualsToken']", - message: "use makeEqual() helper", - }, - ], + "no-restricted-syntax": ["warn", ...tsFactoryConcerns], }, }, { diff --git a/example/example.client.ts b/example/example.client.ts index d0f521fe64..5bbf24a6f7 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -204,39 +204,25 @@ export type Implementation = ( 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 - >; 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]; + ): Promise { + const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; return this.implementation( method, Object.keys(params).reduce( - (acc, key) => acc.replace(`:${key}`, params[key]), + (acc, key) => + acc.replace(`:${key}`, (params as Record)[key]), path, ), Object.keys(params).reduce( (acc, key) => Object.assign( acc, - !path.includes(`:${key}`) && { [key]: params[key] }, + !path.includes(`:${key}`) && { + [key]: (params as Record)[key], + }, ), {}, ), @@ -244,9 +230,6 @@ export class ExpressZodAPIClient { } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts index a4c5d368ea..bf778560aa 100644 --- a/src/integration-helpers.ts +++ b/src/integration-helpers.ts @@ -3,7 +3,7 @@ import { Method } from "./method"; export const f = ts.factory; -export const exportModifier = [f.createModifier(ts.SyntaxKind.ExportKeyword)]; +const exportModifier = [f.createModifier(ts.SyntaxKind.ExportKeyword)]; const asyncModifier = [f.createModifier(ts.SyntaxKind.AsyncKeyword)]; @@ -14,11 +14,21 @@ export const protectedReadonlyModifier = [ 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(""); +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( @@ -29,27 +39,14 @@ export const recordStringAny = f.createExpressionWithTypeArguments( ], ); -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, + mod?: ts.Modifier[], ) => f.createParameterDeclaration( - Array.isArray(features) ? features : undefined, - Array.isArray(features) ? undefined : features, + mod, + undefined, name, undefined, type, @@ -58,10 +55,10 @@ export const makeParam = ( export const makeParams = ( params: Record, - features?: ts.Modifier[] | ts.DotDotDotToken, + mod?: ts.Modifier[], ) => Object.entries(params).map(([name, node]) => - makeParam(f.createIdentifier(name), node, features), + makeParam(f.createIdentifier(name), node, mod), ); export const makeEmptyInitializingConstructor = ( @@ -88,11 +85,14 @@ export const makeDeconstruction = ( export const makeConst = ( name: ts.Identifier | ts.ArrayBindingPattern, value: ts.Expression, - type?: ts.TypeNode, + { type, expose }: { type?: ts.TypeNode; expose?: true } = {}, ) => - f.createVariableDeclarationList( - [f.createVariableDeclaration(name, undefined, type, value)], - ts.NodeFlags.Const, + f.createVariableStatement( + expose && exportModifier, + f.createVariableDeclarationList( + [f.createVariableDeclaration(name, undefined, type, value)], + ts.NodeFlags.Const, + ), ); export const makePublicLiteralType = ( @@ -139,21 +139,6 @@ export const makePublicClass = ( ...statements, ]); -export const makeConditionalIndex = ( - subject: ts.Identifier, - key: ts.TypeNode, - fallback: ts.TypeNode, -) => - f.createConditionalTypeNode( - key, - f.createTypeOperatorNode( - ts.SyntaxKind.KeyOfKeyword, - f.createTypeReferenceNode(subject), - ), - f.createIndexedAccessTypeNode(f.createTypeReferenceNode(subject), key), - fallback, - ); - export const makePromise = (subject: ts.TypeNode | "any") => f.createTypeReferenceNode(Promise.name, [ subject === "any" @@ -262,9 +247,5 @@ export const makeAnd = (left: ts.Expression, right: ts.Expression) => right, ); -export const makeEqual = (left: ts.Expression, right: ts.Expression) => - f.createBinaryExpression( - left, - f.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), - right, - ); +export const makeNew = (cls: ts.Identifier, ...args: ts.Expression[]) => + f.createNewExpression(cls, undefined, args); diff --git a/src/integration.ts b/src/integration.ts index f4c0ad1b5a..b563ca7291 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -2,12 +2,9 @@ import ts from "typescript"; import { z } from "zod"; import { ResponseVariant } from "./api-response"; import { - emptyTail, - exportModifier, f, makePromise, makeArrowFn, - makeConditionalIndex, makeConst, makeDeconstruction, makeEmptyInitializingConstructor, @@ -23,14 +20,13 @@ import { makePublicType, makeTernary, makeTypeParams, - parametricIndexNode, propOf, protectedReadonlyModifier, quoteProp, recordStringAny, - restToken, makeAnd, - makeEqual, + makeTemplate, + makeNew, } from "./integration-helpers"; import { makeCleanId } from "./common-helpers"; import { Method, methods } from "./method"; @@ -40,12 +36,7 @@ import { Routing } from "./routing"; import { walkRouting } from "./routing-walker"; import { HandlingRules } from "./schema-walker"; import { zodToTs } from "./zts"; -import { - ZTSContext, - createTypeAlias, - printNode, - addJsDocComment, -} from "./zts-helpers"; +import { ZTSContext, createTypeAlias, printNode } from "./zts-helpers"; import type Prettier from "prettier"; type IOKind = "input" | "response" | ResponseVariant; @@ -126,8 +117,6 @@ export class Integration { responseInterface: f.createIdentifier("Response"), 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"), @@ -135,8 +124,6 @@ export class Integration { 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"), @@ -331,21 +318,17 @@ export class Integration { if (variant === "types") return; // export const jsonEndpoints = { "get /v1/user/retrieve": true } - const jsonEndpointsConst = f.createVariableStatement( - exportModifier, - makeConst( - this.ids.jsonEndpointsConst, - f.createObjectLiteralExpression(jsonEndpoints), - ), + const jsonEndpointsConst = makeConst( + this.ids.jsonEndpointsConst, + f.createObjectLiteralExpression(jsonEndpoints), + { expose: true }, ); // export const endpointTags = { "get /v1/user/retrieve": ["users"] } - const endpointTagsConst = f.createVariableStatement( - exportModifier, - makeConst( - this.ids.endpointTagsConst, - f.createObjectLiteralExpression(endpointTags), - ), + const endpointTagsConst = makeConst( + this.ids.endpointTagsConst, + f.createObjectLiteralExpression(endpointTags), + { expose: true }, ); // export type Implementation = (method: Method, path: string, params: Record) => Promise; @@ -367,10 +350,7 @@ export class Integration { ); // `:${key}` - const keyParamExpression = f.createTemplateExpression( - f.createTemplateHead(":"), - [f.createTemplateSpan(this.ids.keyParameter, emptyTail)], - ); + const keyParamExpression = makeTemplate(":", [this.ids.keyParameter]); // Object.keys(params).reduce((acc, key) => acc.replace(___, params[key]), path) const pathArgument = makeObjectKeysReducer( @@ -378,7 +358,7 @@ export class Integration { makePropCall(this.ids.accumulator, propOf("replace"), [ keyParamExpression, f.createElementAccessExpression( - this.ids.paramsArgument, + f.createAsExpression(this.ids.paramsArgument, recordStringAny), this.ids.keyParameter, ), ]), @@ -406,7 +386,10 @@ export class Integration { f.createPropertyAssignment( f.createComputedPropertyName(this.ids.keyParameter), f.createElementAccessExpression( - this.ids.paramsArgument, + f.createAsExpression( + this.ids.paramsArgument, + recordStringAny, + ), this.ids.keyParameter, ), ), @@ -419,47 +402,8 @@ export class Integration { 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, - f.createTypeOperatorNode( - ts.SyntaxKind.KeyOfKeyword, - f.createTypeReferenceNode(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( + // public provide(request: K, params: Input[K]): Promise { + const providerMethod = makePublicMethod( this.ids.provideMethod, makeParams({ [this.ids.requestParameter.text]: f.createTypeReferenceNode("K"), @@ -468,81 +412,20 @@ export class Integration { f.createTypeReferenceNode("K"), ), }), - undefined, // overload - makeTypeParams({ - K: this.ids.methodPathType, - }), - 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.createBlock([ + makeConst( + // const [method, path, params] = + makeDeconstruction(this.ids.methodParameter, this.ids.pathParameter), + // request.split(/ (.+)/, 2) as [Method, Path]; + f.createAsExpression( + makePropCall(this.ids.requestParameter, propOf("split"), [ + f.createRegularExpressionLiteral("/ (.+)/"), // split once + f.createNumericLiteral(2), // excludes third empty element ]), f.createTupleTypeNode([ - f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - recordStringAny, + f.createTypeReferenceNode(this.ids.methodType), + f.createTypeReferenceNode(this.ids.pathType), ]), - ]), - }, - 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(___) @@ -554,6 +437,13 @@ export class Integration { ]), ), ]), + makeTypeParams({ K: this.ids.methodPathType }), + makePromise( + f.createIndexedAccessTypeNode( + f.createTypeReferenceNode(this.ids.responseInterface), + f.createTypeReferenceNode("K"), + ), + ), ); // export class ExpressZodAPIClient { ___ } @@ -567,21 +457,7 @@ export class Integration { protectedReadonlyModifier, ), ]), - [providerOverload1, providerOverload2, actualProvider], - ); - - // @todo remove in v22 - const providerType = addJsDocComment( - makePublicType( - this.ids.providerType, - f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.clientClass), - f.createLiteralTypeNode( - f.createStringLiteral(this.ids.provideMethod.text), - ), - ), - ), - "@deprecated will be removed in v22", + [providerMethod], ); this.program.push( @@ -589,7 +465,6 @@ export class Integration { endpointTagsConst, implementationType, clientClass, - providerType, ); // method: method.toUpperCase() @@ -626,82 +501,61 @@ export class Integration { ); // const response = await fetch(`https://example.com${path}${searchParams}`, { ___ }); - const responseStatement = f.createVariableStatement( - undefined, - makeConst( - this.ids.responseConst, - f.createAwaitExpression( - f.createCallExpression(f.createIdentifier(fetch.name), undefined, [ - f.createTemplateExpression( - f.createTemplateHead("https://example.com"), - [ - f.createTemplateSpan( - this.ids.pathParameter, - f.createTemplateMiddle(""), - ), - f.createTemplateSpan(this.ids.searchParamsConst, emptyTail), - ], - ), - f.createObjectLiteralExpression([ - methodProperty, - headersProperty, - bodyProperty, - ]), + const responseStatement = makeConst( + this.ids.responseConst, + f.createAwaitExpression( + f.createCallExpression(f.createIdentifier(fetch.name), undefined, [ + makeTemplate( + "https://example.com", + [this.ids.pathParameter], + [this.ids.searchParamsConst], + ), + 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 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 = 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 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 = f.createVariableStatement( - undefined, - makeConst( - this.ids.contentTypeConst, - makePropCall( - [this.ids.responseConst, this.ids.headersProperty], - propOf("get"), - [f.createStringLiteral("content-type")], - ), + const contentTypeStatement = makeConst( + this.ids.contentTypeConst, + makePropCall( + [this.ids.responseConst, this.ids.headersProperty], + propOf("get"), + [f.createStringLiteral("content-type")], ), ); @@ -716,20 +570,17 @@ export class Integration { ); // 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"), - ), + const parserStatement = makeConst( + this.ids.isJsonConst, + f.createCallChain( + f.createPropertyAccessChain( + this.ids.contentTypeConst, undefined, - undefined, - [f.createStringLiteral(contentTypes.json)], + propOf("startsWith"), ), + undefined, + undefined, + [f.createStringLiteral(contentTypes.json)], ), ); @@ -750,29 +601,29 @@ export class Integration { ); // 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), + const exampleImplStatement = makeConst( + this.ids.exampleImplementationConst, + makeArrowFn( + [ + this.ids.methodParameter, + this.ids.pathParameter, + this.ids.paramsArgument, + ], + f.createBlock([ + hasBodyStatement, + searchParamsStatement, + responseStatement, + contentTypeStatement, + noBodyStatement, + parserStatement, + returnStatement, + ]), + true, ), + { + expose: true, + type: f.createTypeReferenceNode(this.ids.implementationType), + }, ); // client.provide("get /v1/user/retrieve", { id: "10" }); @@ -786,14 +637,9 @@ export class Integration { ); // const client = new ExpressZodAPIClient(exampleImplementation); - const clientInstanceStatement = f.createVariableStatement( - undefined, - makeConst( - this.ids.clientConst, - f.createNewExpression(this.ids.clientClass, undefined, [ - this.ids.exampleImplementationConst, - ]), - ), + const clientInstanceStatement = makeConst( + this.ids.clientConst, + makeNew(this.ids.clientClass, this.ids.exampleImplementationConst), ); this.usage.push( diff --git a/src/migration.ts b/src/migration.ts index 06ecd7d301..e90e5526bd 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -1,32 +1,33 @@ import { ESLintUtils, - // AST_NODE_TYPES as NT, + AST_NODE_TYPES as NT, type TSESLint, - // type TSESTree, + type TSESTree, } from "@typescript-eslint/utils"; -// import { name as importName } from "../package.json"; +import { Method, methods } from "./method"; -/* -type PropWithId = TSESTree.Property & { - key: TSESTree.Identifier; -}; +interface Queries { + provide: TSESTree.CallExpression & { + arguments: [ + TSESTree.Literal & { value: Method }, + TSESTree.Literal, + TSESTree.ObjectExpression, + ]; + }; +} -const isPropWithId = (subject: TSESTree.Node): subject is PropWithId => - subject.type === NT.Property && subject.key.type === NT.Identifier; +type Query = keyof Queries; -const isAssignment = ( - parent: TSESTree.Node, -): parent is TSESTree.VariableDeclarator & { id: TSESTree.ObjectPattern } => - parent.type === NT.VariableDeclarator && parent.id.type === NT.ObjectPattern; +const queries: Record = { + provide: + `${NT.CallExpression}[callee.property.name='provide'][arguments.length=3]` + + `:has(${NT.Literal}[value=/^${methods.join("|")}$/] + ${NT.Literal} + ${NT.ObjectExpression})`, +}; -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 makeQuery = ( + key: K, + fn: (node: Queries[K]) => void, +) => ({ [queries[key]]: fn }); const v22 = ESLintUtils.RuleCreator.withoutDocs({ meta: { @@ -39,7 +40,25 @@ const v22 = ESLintUtils.RuleCreator.withoutDocs({ }, }, defaultOptions: [], - create: () => ({}), + create: (ctx) => ({ + ...makeQuery("provide", (node) => { + const { + arguments: [method, path], + } = node; + const request = `"${method.value} ${path.value}"`; + ctx.report({ + messageId: "change", + node, + data: { + subject: "arguments", + from: `"${method.value}", "${path.value}"`, + to: request, + }, + fix: (fixer) => + fixer.replaceTextRange([method.range[0], path.range[1]], request), + }); + }), + }), }); /** diff --git a/tests/compat/migration.spec.ts b/tests/compat/migration.spec.ts index 12b519aa20..089fa662a9 100644 --- a/tests/compat/migration.spec.ts +++ b/tests/compat/migration.spec.ts @@ -3,6 +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("\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 127da90085..a7e38d576f 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ }, "scripts": { "preinstall": "rm -rf node_modules", - "pretest": "echo '' > 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/system/example.spec.ts b/tests/system/example.spec.ts index 4a2da9304c..7be92059a8 100644 --- a/tests/system/example.spec.ts +++ b/tests/system/example.spec.ts @@ -442,20 +442,10 @@ describe("Example", async () => { ); 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 } } @@ -463,7 +453,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", @@ -481,13 +471,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/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index 6a916a52bc..e6230d5477 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -45,39 +45,25 @@ export type Implementation = ( 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 - >; 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]; + ): Promise { + const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; return this.implementation( method, Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), + (acc, key) => + acc.replace(\`:\${key}\`, (params as Record)[key]), path, ), Object.keys(params).reduce( (acc, key) => Object.assign( acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, + !path.includes(\`:\${key}\`) && { + [key]: (params as Record)[key], + }, ), {}, ), @@ -85,9 +71,6 @@ export class ExpressZodAPIClient { } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -158,39 +141,25 @@ export type Implementation = ( 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 - >; 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]; + ): Promise { + const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; return this.implementation( method, Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), + (acc, key) => + acc.replace(\`:\${key}\`, (params as Record)[key]), path, ), Object.keys(params).reduce( (acc, key) => Object.assign( acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, + !path.includes(\`:\${key}\`) && { + [key]: (params as Record)[key], + }, ), {}, ), @@ -198,9 +167,6 @@ export class ExpressZodAPIClient { } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -271,39 +237,25 @@ export type Implementation = ( 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 - >; 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]; + ): Promise { + const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; return this.implementation( method, Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), + (acc, key) => + acc.replace(\`:\${key}\`, (params as Record)[key]), path, ), Object.keys(params).reduce( (acc, key) => Object.assign( acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, + !path.includes(\`:\${key}\`) && { + [key]: (params as Record)[key], + }, ), {}, ), @@ -311,9 +263,6 @@ export class ExpressZodAPIClient { } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -396,39 +345,25 @@ export type Implementation = ( 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 - >; 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]; + ): Promise { + const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; return this.implementation( method, Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), + (acc, key) => + acc.replace(\`:\${key}\`, (params as Record)[key]), path, ), Object.keys(params).reduce( (acc, key) => Object.assign( acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, + !path.includes(\`:\${key}\`) && { + [key]: (params as Record)[key], + }, ), {}, ), @@ -436,9 +371,6 @@ export class ExpressZodAPIClient { } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -719,39 +651,25 @@ export type Implementation = ( 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 - >; 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]; + ): Promise { + const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; return this.implementation( method, Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), + (acc, key) => + acc.replace(\`:\${key}\`, (params as Record)[key]), path, ), Object.keys(params).reduce( (acc, key) => Object.assign( acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, + !path.includes(\`:\${key}\`) && { + [key]: (params as Record)[key], + }, ), {}, ), @@ -759,9 +677,6 @@ export class ExpressZodAPIClient { } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( @@ -1061,39 +976,25 @@ export type Implementation = ( 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 - >; 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]; + ): Promise { + const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; return this.implementation( method, Object.keys(params).reduce( - (acc, key) => acc.replace(\`:\${key}\`, params[key]), + (acc, key) => + acc.replace(\`:\${key}\`, (params as Record)[key]), path, ), Object.keys(params).reduce( (acc, key) => Object.assign( acc, - !path.includes(\`:\${key}\`) && { [key]: params[key] }, + !path.includes(\`:\${key}\`) && { + [key]: (params as Record)[key], + }, ), {}, ), @@ -1101,9 +1002,6 @@ export class ExpressZodAPIClient { } } -/** @deprecated will be removed in v22 */ -export type Provider = ExpressZodAPIClient["provide"]; - // Usage example: /* export const exampleImplementation: Implementation = async ( diff --git a/tests/unit/migration.spec.ts b/tests/unit/migration.spec.ts index fd2b5f91d2..ec79f879af 100644 --- a/tests/unit/migration.spec.ts +++ b/tests/unit/migration.spec.ts @@ -18,7 +18,22 @@ describe("Migration", () => { }); tester.run("v22", migration.rules.v22, { - valid: [``], - invalid: [], + valid: [`client.provide("get /v1/test", {id: 10});`], + invalid: [ + { + code: `client.provide("get", "/v1/test", {id: 10});`, + output: `client.provide("get /v1/test", {id: 10});`, + errors: [ + { + messageId: "change", + data: { + subject: "arguments", + from: `"get", "/v1/test"`, + to: `"get /v1/test"`, + }, + }, + ], + }, + ], }); }); From 8d216aecb235ee8c202dc808b2eeaa636b6fadec Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 2 Dec 2024 13:15:31 +0100 Subject: [PATCH 07/46] Drop `splitResponse` (#2228) with migration --- src/integration.ts | 5 -- src/migration.ts | 68 ++++++++++++------- .../unit/__snapshots__/migration.spec.ts.snap | 2 +- tests/unit/migration.spec.ts | 15 +++- 4 files changed, 58 insertions(+), 32 deletions(-) diff --git a/src/integration.ts b/src/integration.ts index ac736b5e60..8a74ecf188 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -50,11 +50,6 @@ interface IntegrationParams { * @default "client" * */ variant?: "types" | "client"; - /** - * @todo remove in v22 - * @deprecated - * */ - splitResponse?: boolean; /** * @desc configures the style of object's optional properties * @default { withQuestionMark: true, withUndefined: true } diff --git a/src/migration.ts b/src/migration.ts index e90e5526bd..3358686ca7 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -14,20 +14,30 @@ interface Queries { TSESTree.ObjectExpression, ]; }; + splitResponse: TSESTree.Property & { key: TSESTree.Identifier }; } -type Query = keyof Queries; +type Listener = keyof Queries; -const queries: Record = { +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']`, }; -const makeQuery = ( - key: K, - fn: (node: Queries[K]) => void, -) => ({ [queries[key]]: fn }); +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 v22 = ESLintUtils.RuleCreator.withoutDocs({ meta: { @@ -36,29 +46,37 @@ const v22 = ESLintUtils.RuleCreator.withoutDocs({ schema: [], messages: { change: "Change {{subject}} {{from}} to {{to}}.", - move: "Move {{subject}} from {{from}} to {{to}}.", + remove: "Remove {{subject}} {{name}}.", }, }, defaultOptions: [], - create: (ctx) => ({ - ...makeQuery("provide", (node) => { - const { - arguments: [method, path], - } = node; - const request = `"${method.value} ${path.value}"`; - ctx.report({ - messageId: "change", - node, - data: { - subject: "arguments", - from: `"${method.value}", "${path.value}"`, - to: request, - }, - fix: (fixer) => - fixer.replaceTextRange([method.range[0], path.range[1]], request), - }); + create: (ctx) => + listen({ + provide: (node) => { + const { + arguments: [method, path], + } = node; + const request = `"${method.value} ${path.value}"`; + ctx.report({ + messageId: "change", + node, + data: { + subject: "arguments", + from: `"${method.value}", "${path.value}"`, + to: request, + }, + fix: (fixer) => + fixer.replaceTextRange([method.range[0], path.range[1]], request), + }); + }, + splitResponse: (node) => + ctx.report({ + messageId: "remove", + node, + data: { subject: "property", name: node.key.name }, + fix: (fixer) => fixer.remove(node), + }), }), - }), }); /** diff --git a/tests/unit/__snapshots__/migration.spec.ts.snap b/tests/unit/__snapshots__/migration.spec.ts.snap index fbd0edc639..e348aee577 100644 --- a/tests/unit/__snapshots__/migration.spec.ts.snap +++ b/tests/unit/__snapshots__/migration.spec.ts.snap @@ -10,7 +10,7 @@ exports[`Migration > should consist of one rule being the major version of the p "fixable": "code", "messages": { "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/migration.spec.ts b/tests/unit/migration.spec.ts index ec79f879af..71b6d4ee9d 100644 --- a/tests/unit/migration.spec.ts +++ b/tests/unit/migration.spec.ts @@ -18,7 +18,10 @@ describe("Migration", () => { }); tester.run("v22", migration.rules.v22, { - valid: [`client.provide("get /v1/test", {id: 10});`], + valid: [ + `client.provide("get /v1/test", {id: 10});`, + `new Integration({ routing });`, + ], invalid: [ { code: `client.provide("get", "/v1/test", {id: 10});`, @@ -34,6 +37,16 @@ describe("Migration", () => { }, ], }, + { + code: `new Integration({ routing, splitResponse: true });`, + output: `new Integration({ routing, });`, + errors: [ + { + messageId: "remove", + data: { subject: "property", name: "splitResponse" }, + }, + ], + }, ], }); }); From dc0494dc0eb2a0621b280b15355f3a4dd7777b67 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 20 Dec 2024 18:35:31 +0100 Subject: [PATCH 08/46] Drop `jsonEndpoints` (#2259) Deprecated in #2258 --- example/example.client.ts | 10 ----- src/integration.ts | 39 ++----------------- .../__snapshots__/integration.spec.ts.snap | 22 ----------- 3 files changed, 4 insertions(+), 67 deletions(-) diff --git a/example/example.client.ts b/example/example.client.ts index a0c4ceb55f..b578410a44 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -350,16 +350,6 @@ export interface Response { export type MethodPath = keyof Input; -/** @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"], diff --git a/src/integration.ts b/src/integration.ts index a73101d674..2fe06a55e8 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -39,7 +39,7 @@ import { Routing } from "./routing"; import { OnEndpoint, walkRouting } from "./routing-walker"; import { HandlingRules } from "./schema-walker"; import { zodToTs } from "./zts"; -import { ZTSContext, printNode, addJsDocComment } from "./zts-helpers"; +import { ZTSContext, printNode } from "./zts-helpers"; import type Prettier from "prettier"; type IOKind = "input" | "response" | ResponseVariant | "encoded"; @@ -100,10 +100,7 @@ export class Integration { protected usage: Array = []; protected registry = new Map< ReturnType, // method+path - Record & { - isJson: boolean; - tags: ReadonlyArray; - } + Record & { tags: ReadonlyArray } >(); protected paths = new Set(); protected aliases = new Map(); @@ -116,8 +113,6 @@ export class Integration { negResponseInterface: f.createIdentifier("NegativeResponse"), encResponseInterface: f.createIdentifier("EncodedResponse"), responseInterface: f.createIdentifier("Response"), - /** @todo remove in v22 */ - jsonEndpointsConst: f.createIdentifier("jsonEndpoints"), endpointTagsConst: f.createIdentifier("endpointTags"), implementationType: f.createIdentifier("Implementation"), clientClass: f.createIdentifier("ExpressZodAPIClient"), @@ -209,9 +204,6 @@ export class Integration { {} as Record, ); this.paths.add(path); - const isJson = endpoint - .getResponses("positive") - .some(({ mimeTypes }) => mimeTypes?.includes(contentTypes.json)); const methodPath = quoteProp(method, path); this.registry.set(methodPath, { input: f.createTypeReferenceNode(input.name), @@ -232,7 +224,6 @@ export class Integration { f.createTypeReferenceNode(dictionaries.negative.name), ]), tags: endpoint.getTags(), - isJson, }); }; walkRouting({ routing, onEndpoint }); @@ -263,19 +254,12 @@ export class Integration { ); // Single walk through the registry for making properties for the next three objects - const jsonEndpoints: ts.PropertyAssignment[] = []; const endpointTags: ts.PropertyAssignment[] = []; - for (const [propName, { isJson, tags, ...rest }] of this.registry) { + for (const [propName, { tags, ...rest }] of this.registry) { // "get /v1/user/retrieve": GetV1UserRetrieveInput for (const face of this.interfaces) face.props.push(makeInterfaceProp(propName, rest[face.kind])); if (variant !== "types") { - if (isJson) { - // "get /v1/user/retrieve": true - jsonEndpoints.push( - f.createPropertyAssignment(propName, f.createTrue()), - ); - } // "get /v1/user/retrieve": ["users"] endpointTags.push( f.createPropertyAssignment( @@ -301,16 +285,6 @@ export class Integration { if (variant === "types") return; - // export const jsonEndpoints = { "get /v1/user/retrieve": true } - const jsonEndpointsConst = addJsDocComment( - makeConst( - this.ids.jsonEndpointsConst, - f.createObjectLiteralExpression(jsonEndpoints), - { expose: true }, - ), - "@deprecated use content-type header of an actual response", - ); - // export const endpointTags = { "get /v1/user/retrieve": ["users"] } const endpointTagsConst = makeConst( this.ids.endpointTagsConst, @@ -448,12 +422,7 @@ export class Integration { [providerMethod], ); - this.program.push( - jsonEndpointsConst, - endpointTagsConst, - implementationType, - clientClass, - ); + this.program.push(endpointTagsConst, implementationType, clientClass); // method: method.toUpperCase() const methodProperty = f.createPropertyAssignment( diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index b492533ce2..872fbaa97e 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -58,9 +58,6 @@ export interface Response { export type MethodPath = keyof Input; -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { "post /v1/test-with-dashes": true }; - export const endpointTags = { "post /v1/test-with-dashes": [] }; export type Implementation = ( @@ -180,9 +177,6 @@ export interface Response { export type MethodPath = keyof Input; -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { "post /v1/test-with-dashes": true }; - export const endpointTags = { "post /v1/test-with-dashes": [] }; export type Implementation = ( @@ -302,9 +296,6 @@ export interface Response { export type MethodPath = keyof Input; -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { "post /v1/test-with-dashes": true }; - export const endpointTags = { "post /v1/test-with-dashes": [] }; export type Implementation = ( @@ -780,16 +771,6 @@ export interface Response { export type MethodPath = keyof Input; -/** @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"], @@ -1341,9 +1322,6 @@ export interface Response { export type MethodPath = keyof Input; -/** @deprecated use content-type header of an actual response */ -export const jsonEndpoints = { "post /v1/test-with-dashes": true }; - export const endpointTags = { "post /v1/test-with-dashes": [] }; export type Implementation = ( From 44ad5a88f6abae734f80e73e999a5fc8403d4954 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 21 Dec 2024 19:26:45 +0100 Subject: [PATCH 09/46] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab54cae1f..d87ef793ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ - Minimum supported Node versions: 20.9.0 and 22.0.0; - `BuiltinLogger::profile()` behavior changed for picoseconds: expressing them through nanoseconds; - Changes to client generated by `Integration`: - - The overload of `ExpressZodAPIClient::provide()` having 3 arguments and the `Provider` type are removed. + - The overload of `ExpressZodAPIClient::provide()` having 3 arguments and the `Provider` type are removed; + - The public `jsonEndpoints` const is removed. ## Version 21 From eee45fe92813fe6b51346d654c38a8db6f6d3c3b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 21 Dec 2024 19:27:57 +0100 Subject: [PATCH 10/46] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d87ef793ed..cac14f6f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minimum supported Node versions: 20.9.0 and 22.0.0; - `BuiltinLogger::profile()` behavior changed for picoseconds: expressing them through nanoseconds; - Changes to client generated by `Integration`: + - The `splitResponse` property on the constructor argument is removed; - The overload of `ExpressZodAPIClient::provide()` having 3 arguments and the `Provider` type are removed; - The public `jsonEndpoints` const is removed. From 52b6eb613876c719e8c05586e47d861a8f94bc92 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 22 Dec 2024 21:21:02 +0100 Subject: [PATCH 11/46] SECURITY: planning for feb'25 --- SECURITY.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 61174ccbbc..24e37c382a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,10 +4,11 @@ | 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: | -| 18.x.x | 04.2024 | :white_check_mark: | +| 18.x.x | 04.2024 | :x: | | 17.x.x | 02.2024 | :x: | | 16.x.x | 12.2023 | :x: | | 15.x.x | 12.2023 | :x: | From b21559061c881c1a77801b7ffd8f856967e0fd16 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 27 Dec 2024 09:29:27 +0100 Subject: [PATCH 12/46] =?UTF-8?q?Dedicated=20to=20Tai=E2=80=99Vion=20Latha?= =?UTF-8?q?n.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/startup-logo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"); From bdf12da2d729f3f1ab562f34f114b1cfb30a2120 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 31 Dec 2024 21:11:31 +0100 Subject: [PATCH 13/46] Drop `MethodPath` type (#2276) caused by #2275 --- CHANGELOG.md | 3 ++- example/example.client.ts | 3 --- src/integration.ts | 10 -------- src/migration.ts | 11 +++++++++ .../__snapshots__/integration.spec.ts.snap | 24 ------------------- tests/unit/migration.spec.ts | 11 +++++++++ 6 files changed, 24 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 261af1a12f..a3983f34cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ - Changes to client generated by `Integration`: - The `splitResponse` property on the constructor argument is removed; - The overload of `ExpressZodAPIClient::provide()` having 3 arguments and the `Provider` type are removed; - - The public `jsonEndpoints` const is removed. + - The public `jsonEndpoints` const is removed; + - The public type `MethodPath` is removed — use the `Request` type instead. ## Version 21 diff --git a/example/example.client.ts b/example/example.client.ts index e06e643b21..4ad8a9a0c2 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -401,9 +401,6 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; - export const endpointTags = { "get /v1/user/retrieve": ["users"], "delete /v1/user/:id/remove": ["users"], diff --git a/src/integration.ts b/src/integration.ts index 5b8d441e75..5db320c533 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -106,8 +106,6 @@ export class Integration { 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"), @@ -288,14 +286,6 @@ export class Integration { 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" }, - ), - ); if (variant === "types") return; diff --git a/src/migration.ts b/src/migration.ts index 3358686ca7..cc85d64c19 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -15,6 +15,7 @@ interface Queries { ]; }; splitResponse: TSESTree.Property & { key: TSESTree.Identifier }; + methodPath: TSESTree.ImportSpecifier & { imported: TSESTree.Identifier }; } type Listener = keyof Queries; @@ -24,6 +25,7 @@ const queries: Record = { `${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']`, }; const listen = < @@ -76,6 +78,15 @@ const v22 = ESLintUtils.RuleCreator.withoutDocs({ data: { subject: "property", name: node.key.name }, fix: (fixer) => fixer.remove(node), }), + methodPath: (node) => { + const replacement = "Request"; + ctx.report({ + messageId: "change", + node: node.imported, + data: { subject: "type", from: node.imported.name, to: replacement }, + fix: (fixer) => fixer.replaceText(node.imported, replacement), + }); + }, }), }); diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index cd4819adee..8e62f70d70 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -63,9 +63,6 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; - export const endpointTags = { "post /v1/test-with-dashes": [] }; export type Implementation = ( @@ -190,9 +187,6 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; - export const endpointTags = { "post /v1/test-with-dashes": [] }; export type Implementation = ( @@ -317,9 +311,6 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; - export const endpointTags = { "post /v1/test-with-dashes": [] }; export type Implementation = ( @@ -444,9 +435,6 @@ export interface Response { } export type Request = keyof Input; - -/** @deprecated use Request instead */ -export type MethodPath = Request; " `; @@ -854,9 +842,6 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; - export const endpointTags = { "get /v1/user/retrieve": ["users"], "delete /v1/user/:id/remove": ["users"], @@ -1332,9 +1317,6 @@ export interface Response { } export type Request = keyof Input; - -/** @deprecated use Request instead */ -export type MethodPath = Request; " `; @@ -1408,9 +1390,6 @@ export interface Response { } export type Request = keyof Input; - -/** @deprecated use Request instead */ -export type MethodPath = Request; " `; @@ -1477,9 +1456,6 @@ export interface Response { export type Request = keyof Input; -/** @deprecated use Request instead */ -export type MethodPath = Request; - export const endpointTags = { "post /v1/test-with-dashes": [] }; export type Implementation = ( diff --git a/tests/unit/migration.spec.ts b/tests/unit/migration.spec.ts index 71b6d4ee9d..5e7a6c268f 100644 --- a/tests/unit/migration.spec.ts +++ b/tests/unit/migration.spec.ts @@ -21,6 +21,7 @@ describe("Migration", () => { valid: [ `client.provide("get /v1/test", {id: 10});`, `new Integration({ routing });`, + `import { Request } from "./client.ts";`, ], invalid: [ { @@ -47,6 +48,16 @@ describe("Migration", () => { }, ], }, + { + code: `import { MethodPath } from "./client.ts";`, + output: `import { Request } from "./client.ts";`, + errors: [ + { + messageId: "change", + data: { subject: "type", from: "MethodPath", to: "Request" }, + }, + ], + }, ], }); }); From ec11bd523d0d21aedaed0e03c9be96648030bbff Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 3 Jan 2025 13:46:23 +0100 Subject: [PATCH 14/46] Add eslint rule for makePromise(). --- eslint.config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index ec6fa931a7..e014d6fdb8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -105,6 +105,10 @@ const tsFactoryConcerns = [ selector: "Identifier[name='createNewExpression']", message: "use makeNew() helper", }, + { + selector: "Literal[value='Promise']", + message: "use makePromise() helper", + }, ]; export default tsPlugin.config( From 26e7afe864926c8f66514d57048dc94c9412b945 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 3 Jan 2025 14:15:21 +0100 Subject: [PATCH 15/46] Minor: using Partial for makeParams() for consistency. --- src/integration-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts index f6ebf490f7..94a2ad13d1 100644 --- a/src/integration-helpers.ts +++ b/src/integration-helpers.ts @@ -54,7 +54,7 @@ export const makeParam = ( ); export const makeParams = ( - params: Record, + params: Partial>, mod?: ts.Modifier[], ) => Object.entries(params).map(([name, node]) => From 4144294349c915ed8c0200fab7df590fdbccb9c8 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 3 Jan 2025 14:43:26 +0100 Subject: [PATCH 16/46] Allow explicit types for makeTypeParams(). --- src/integration-helpers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts index 94a2ad13d1..4e78413f82 100644 --- a/src/integration-helpers.ts +++ b/src/integration-helpers.ts @@ -199,13 +199,13 @@ export const makeInterface = ( }; export const makeTypeParams = ( - params: Partial>, + params: Partial>, ) => - Object.entries(params).map(([name, id]) => + Object.entries(params).map(([name, val]) => f.createTypeParameterDeclaration( [], name, - id && f.createTypeReferenceNode(id), + val && ts.isIdentifier(val) ? f.createTypeReferenceNode(val) : val, ), ); From 646bac8af48b7e76bf11fac902da8ba4d456eee0 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 3 Jan 2025 15:17:56 +0100 Subject: [PATCH 17/46] Allow type params on `makeArrowFn()` (#2281) Needed for #2280 --- src/integration-helpers.ts | 7 +++++-- src/integration.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts index 4e78413f82..6c03a01b72 100644 --- a/src/integration-helpers.ts +++ b/src/integration-helpers.ts @@ -212,11 +212,14 @@ export const makeTypeParams = ( export const makeArrowFn = ( params: ts.Identifier[], body: ts.ConciseBody, - isAsync?: boolean, + { + isAsync, + typeParams, + }: { isAsync?: boolean; typeParams?: Parameters[0] }, ) => f.createArrowFunction( isAsync ? asyncModifier : undefined, - undefined, + typeParams && makeTypeParams(typeParams), params.map((key) => makeParam(key)), undefined, undefined, diff --git a/src/integration.ts b/src/integration.ts index 3851395060..0fcb18c204 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -589,7 +589,7 @@ export class Integration { parserStatement, returnStatement, ]), - true, + { isAsync: true }, ), { expose: true, From 3da9c16c383281c2bb99e70ad75073b7299db864 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 3 Jan 2025 15:24:55 +0100 Subject: [PATCH 18/46] Also allow params as an object on makeArrowFn(). --- coverage.svg | 2 +- src/integration-helpers.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/coverage.svg b/coverage.svg index 5bb55be2e2..cf6e8e168d 100644 --- a/coverage.svg +++ b/coverage.svg @@ -1 +1 @@ -Coverage: 100%Coverage100% \ No newline at end of file +Coverage: 99.97%Coverage99.97% \ No newline at end of file diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts index 6c03a01b72..47736bb0be 100644 --- a/src/integration-helpers.ts +++ b/src/integration-helpers.ts @@ -210,7 +210,7 @@ export const makeTypeParams = ( ); export const makeArrowFn = ( - params: ts.Identifier[], + params: ts.Identifier[] | Parameters[0], body: ts.ConciseBody, { isAsync, @@ -220,7 +220,9 @@ export const makeArrowFn = ( f.createArrowFunction( isAsync ? asyncModifier : undefined, typeParams && makeTypeParams(typeParams), - params.map((key) => makeParam(key)), + Array.isArray(params) + ? params.map((key) => makeParam(key)) + : makeParams(params), undefined, undefined, body, From a74832eedae0fdc195b8cfe2e5a07251de774e13 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 3 Jan 2025 15:27:56 +0100 Subject: [PATCH 19/46] Revert "Also allow params as an object on makeArrowFn()." This reverts commit 3da9c16c383281c2bb99e70ad75073b7299db864. --- coverage.svg | 2 +- src/integration-helpers.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/coverage.svg b/coverage.svg index cf6e8e168d..5bb55be2e2 100644 --- a/coverage.svg +++ b/coverage.svg @@ -1 +1 @@ -Coverage: 99.97%Coverage99.97% \ No newline at end of file +Coverage: 100%Coverage100% \ No newline at end of file diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts index 47736bb0be..6c03a01b72 100644 --- a/src/integration-helpers.ts +++ b/src/integration-helpers.ts @@ -210,7 +210,7 @@ export const makeTypeParams = ( ); export const makeArrowFn = ( - params: ts.Identifier[] | Parameters[0], + params: ts.Identifier[], body: ts.ConciseBody, { isAsync, @@ -220,9 +220,7 @@ export const makeArrowFn = ( f.createArrowFunction( isAsync ? asyncModifier : undefined, typeParams && makeTypeParams(typeParams), - Array.isArray(params) - ? params.map((key) => makeParam(key)) - : makeParams(params), + params.map((key) => makeParam(key)), undefined, undefined, body, From 1b7b1bdecee6d1cd9badbf6d1c670bebd3739f9a Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 3 Jan 2025 15:35:28 +0100 Subject: [PATCH 20/46] Fix: optional last arg for makeArrowFn() --- src/integration-helpers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts index 6c03a01b72..f1a3b5b1d1 100644 --- a/src/integration-helpers.ts +++ b/src/integration-helpers.ts @@ -215,7 +215,10 @@ export const makeArrowFn = ( { isAsync, typeParams, - }: { isAsync?: boolean; typeParams?: Parameters[0] }, + }: { + isAsync?: boolean; + typeParams?: Parameters[0]; + } = {}, ) => f.createArrowFunction( isAsync ? asyncModifier : undefined, From 595a833db33046ed6779c232ca7eeda780e4bc66 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 4 Jan 2025 10:16:35 +0100 Subject: [PATCH 21/46] Changelog: migration guide. --- CHANGELOG.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3850c54d40..730edd8a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,20 @@ - Changes to client generated by `Integration`: - The `splitResponse` property on the constructor argument is removed; - The overload of `ExpressZodAPIClient::provide()` having 3 arguments and the `Provider` type are removed; - - The public `jsonEndpoints` const is 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. +- 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" } }, +]; +``` ## Version 21 From 3959e4944555370fdabd240a559e3e140a1235cf Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 4 Jan 2025 13:34:19 +0100 Subject: [PATCH 22/46] 22.0.0-beta.1 --- example/example.documentation.yaml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index d5d800dae8..4c1247b3d8 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 22.0.0-beta.0 + version: 22.0.0-beta.1 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index 9f2d638f4f..700b5dac48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "22.0.0-beta.0", + "version": "22.0.0-beta.1", "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": { From 26967cbab9de6a359480db0c8534f54499dd89a6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 5 Jan 2025 14:29:25 +0100 Subject: [PATCH 23/46] Minor: line split in migration. --- src/migration.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/migration.ts b/src/migration.ts index cc85d64c19..4ea618fa79 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -24,7 +24,9 @@ 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']`, + splitResponse: + `${NT.NewExpression}[callee.name='Integration'] > ` + + `${NT.ObjectExpression} > ${NT.Property}[key.name='splitResponse']`, methodPath: `${NT.ImportDeclaration} > ${NT.ImportSpecifier}[imported.name='MethodPath']`, }; From 4184232109f4578a8b28396e60411321f68702ef Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 5 Jan 2025 17:57:55 +0100 Subject: [PATCH 24/46] Moving tags to augmentation (#2284) This should make the runtime and interfaces easier --- CHANGELOG.md | 47 ++++++++++++ README.md | 47 ++++++------ example/config.ts | 16 +++-- example/endpoints/accept-raw.ts | 5 +- example/endpoints/retrieve-user.ts | 4 +- example/endpoints/upload-avatar.ts | 5 +- example/factories.ts | 49 +++++-------- example/generate-documentation.ts | 5 ++ src/common-helpers.ts | 12 ++++ src/config-type.ts | 26 ++----- src/documentation-helpers.ts | 21 +++--- src/documentation.ts | 9 ++- src/endpoints-factory.ts | 42 +++-------- src/index.ts | 5 +- src/migration.ts | 72 +++++++++++++++++++ src/sse.ts | 13 ++-- tests/issue952/tags.ts | 41 +++++++++++ .../__snapshots__/documentation.spec.ts.snap | 16 +---- .../unit/__snapshots__/migration.spec.ts.snap | 1 + tests/unit/migration.spec.ts | 49 +++++++++++++ tests/unit/sse.spec.ts | 8 +-- 21 files changed, 330 insertions(+), 163 deletions(-) create mode 100644 tests/issue952/tags.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 730edd8a15..72a90c6aed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ - The overload of `ExpressZodAPIClient::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; - Consider the automated migration using the built-in ESLint rule. ```js @@ -24,6 +29,48 @@ export default [ ]; ``` +```diff + createConfig({ +- tags: {}, + }); + + new Documentation({ ++ tags: {}, + }); + + 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.10.0 diff --git a/README.md b/README.md index dc9850310b..efd139de92 100644 --- a/README.md +++ b/README.md @@ -1206,7 +1206,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 } }) => { @@ -1322,36 +1322,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/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/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/src/common-helpers.ts b/src/common-helpers.ts index fb972a6ad5..1973d17961 100644 --- a/src/common-helpers.ts +++ b/src/common-helpers.ts @@ -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); 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..bbde3ab1bc 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -49,8 +49,9 @@ import { 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"; @@ -964,19 +965,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..4f3a17d451 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -66,6 +66,12 @@ interface DocumentationParams { * @example { MyBrand: ( schema: typeof myBrandSchema, { next } ) => ({ type: "object" }) */ brandHandling?: HandlingRules; + /** + * @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 +141,7 @@ export class Documentation extends OpenApiBuilder { serverUrl, descriptions, brandHandling, + tags, hasSummaryFromDescription = true, composition = "inline", }: DocumentationParams) { @@ -247,6 +254,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/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/migration.ts b/src/migration.ts index 4ea618fa79..6c8247f853 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -5,6 +5,7 @@ import { type TSESTree, } from "@typescript-eslint/utils"; import { Method, methods } from "./method"; +import { name as self } from "../package.json"; interface Queries { provide: TSESTree.CallExpression & { @@ -16,6 +17,13 @@ interface Queries { }; 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 }; } type Listener = keyof Queries; @@ -28,6 +36,18 @@ const queries: Record = { `${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']`, }; const listen = < @@ -49,6 +69,7 @@ const v22 = ESLintUtils.RuleCreator.withoutDocs({ fixable: "code", schema: [], messages: { + add: `Add {{subject}} to {{to}}`, change: "Change {{subject}} {{from}} to {{to}}.", remove: "Remove {{subject}} {{name}}.", }, @@ -89,6 +110,57 @@ const v22 = ESLintUtils.RuleCreator.withoutDocs({ 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.remove(node), + fixer.insertTextAfter( + ctx.sourceCode.ast, + `\n// Declaring tag constraints\ndeclare module "${self}" {\n interface TagOverrides {\n${props} }\n}`, + ), + ], + }); + }, + 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)), + }), }), }); 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/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/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__/migration.spec.ts.snap b/tests/unit/__snapshots__/migration.spec.ts.snap index e348aee577..9babad1353 100644 --- a/tests/unit/__snapshots__/migration.spec.ts.snap +++ b/tests/unit/__snapshots__/migration.spec.ts.snap @@ -9,6 +9,7 @@ exports[`Migration > should consist of one rule being the major version of the p "meta": { "fixable": "code", "messages": { + "add": "Add {{subject}} to {{to}}", "change": "Change {{subject}} {{from}} to {{to}}.", "remove": "Remove {{subject}} {{name}}.", }, diff --git a/tests/unit/migration.spec.ts b/tests/unit/migration.spec.ts index 5e7a6c268f..fb5e389578 100644 --- a/tests/unit/migration.spec.ts +++ b/tests/unit/migration.spec.ts @@ -22,6 +22,10 @@ describe("Migration", () => { `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({});`, ], invalid: [ { @@ -58,6 +62,51 @@ describe("Migration", () => { }, ], }, + { + 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: "remove", data: { subject: "property", name: "tags" } }, + ], + }, + { + code: `new Documentation({ config });`, + output: `new Documentation({ tags: { /* move from createConfig() argument if any */ }, config });`, + errors: [ + { messageId: "add", data: { subject: "tags", to: "Documentation" } }, + ], + }, + { + code: `new EndpointsFactory({config, resultHandler: new ResultHandler() });`, + output: `new EndpointsFactory(new ResultHandler());`, + errors: [ + { + messageId: "change", + data: { + subject: "argument", + from: "object", + to: "ResultHandler instance", + }, + }, + ], + }, + { + code: `new EventStreamFactory({ config, events: { some } });`, + output: `new EventStreamFactory({ some });`, + errors: [ + { + messageId: "change", + data: { subject: "argument", from: "object", to: "events map" }, + }, + ], + }, ], }); }); 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 }>(); From 61531c23f01b5107dd158bf83e7deacae60a5b1a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sun, 5 Jan 2025 18:36:53 +0100 Subject: [PATCH 25/46] 22.0.0-beta.2 --- example/example.documentation.yaml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 4c1247b3d8..3affab2281 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 22.0.0-beta.1 + version: 22.0.0-beta.2 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index 700b5dac48..e948379aab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "22.0.0-beta.1", + "version": "22.0.0-beta.2", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 2a4d52f2dd5ec9cc757db03eedccb1476d0d7b9f Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 6 Jan 2025 19:49:05 +0100 Subject: [PATCH 26/46] Organizing Typescipt API (#2287) First I wanna keep every helper using typescript factory in one place. This should allow to use "integration helpers" file for bigger and higher order entities of the code production. That will be helpful for #2280 that makes "integraton" file even bigger and less readable. --- eslint.config.js | 2 +- src/integration.ts | 6 +- ...tegration-helpers.ts => typescript-api.ts} | 56 ++++++++++++++++++- src/zts-helpers.ts | 53 +----------------- src/zts.ts | 19 ++----- tests/unit/zts.spec.ts | 4 +- 6 files changed, 67 insertions(+), 73 deletions(-) rename src/{integration-helpers.ts => typescript-api.ts} (81%) diff --git a/eslint.config.js b/eslint.config.js index e014d6fdb8..3b56dc30a9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -155,7 +155,7 @@ export default tsPlugin.config( }, { name: "source/integration", - files: ["src/integration.ts"], + files: ["src/integration.ts", "src/zts.ts"], rules: { "no-restricted-syntax": [ "warn", diff --git a/src/integration.ts b/src/integration.ts index 0fcb18c204..116db7832a 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -29,7 +29,9 @@ import { makeNew, makeKeyOf, makeSomeOfHelper, -} from "./integration-helpers"; + makePropertyIdentifier, + printNode, +} from "./typescript-api"; import { makeCleanId } from "./common-helpers"; import { Method, methods } from "./method"; import { contentTypes } from "./content-type"; @@ -38,7 +40,7 @@ import { Routing } from "./routing"; import { OnEndpoint, walkRouting } from "./routing-walker"; import { HandlingRules } from "./schema-walker"; import { zodToTs } from "./zts"; -import { ZTSContext, printNode, makePropertyIdentifier } from "./zts-helpers"; +import { ZTSContext } from "./zts-helpers"; import type Prettier from "prettier"; type IOKind = "input" | "response" | ResponseVariant | "encoded"; diff --git a/src/integration-helpers.ts b/src/typescript-api.ts similarity index 81% rename from src/integration-helpers.ts rename to src/typescript-api.ts index f1a3b5b1d1..8a3264436b 100644 --- a/src/integration-helpers.ts +++ b/src/typescript-api.ts @@ -1,5 +1,4 @@ import ts from "typescript"; -import { addJsDocComment, makePropertyIdentifier } from "./zts-helpers"; export const f = ts.factory; @@ -14,6 +13,37 @@ export const protectedReadonlyModifier = [ 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])[] @@ -65,11 +95,15 @@ export const makeEmptyInitializingConstructor = ( params: ts.ParameterDeclaration[], ) => f.createConstructorDeclaration(undefined, params, f.createBlock([])); -export const makeInterfaceProp = (name: string | number, value: ts.TypeNode) => +export const makeInterfaceProp = ( + name: string | number, + value: ts.TypeNode, + { isOptional }: { isOptional?: boolean } = {}, +) => f.createPropertySignature( undefined, makePropertyIdentifier(name), - undefined, + isOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, value, ); @@ -300,3 +334,19 @@ export const makeAnd = (left: ts.Expression, right: ts.Expression) => 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/zts-helpers.ts b/src/zts-helpers.ts index d6a892a543..69b0d122f7 100644 --- a/src/zts-helpers.ts +++ b/src/zts-helpers.ts @@ -1,10 +1,8 @@ -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 { @@ -17,52 +15,3 @@ export interface ZTSContext extends FlatObject { } 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..2b18e15317 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -11,11 +11,9 @@ import { HandlingRules, walkSchema } from "./schema-walker"; import { addJsDocComment, 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 +51,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; 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) => From 0d31e1d1ad0ff07f6fb6675169261e3f7966e70a Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 7 Jan 2025 18:29:04 +0100 Subject: [PATCH 27/46] Improve consistency of Typescript API (#2288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isPublic —> expose; - moving certain things into options - needed for #2280 --- src/integration.ts | 21 +++++++++++---------- src/typescript-api.ts | 27 ++++++++++++++++----------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/integration.ts b/src/integration.ts index 116db7832a..ffb1d544d8 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -20,7 +20,6 @@ import { makePublicMethod, makeType, makeTernary, - makeTypeParams, propOf, protectedReadonlyModifier, recordStringAny, @@ -286,12 +285,12 @@ export class Integration { // export interface Input { "get /v1/user/retrieve": GetV1UserRetrieveInput; } for (const { id, props } of this.interfaces) - this.program.push(makeInterface(id, props, { isPublic: true })); + this.program.push(makeInterface(id, props, { expose: true })); // export type Request = keyof Input; this.program.push( makeType(this.ids.requestType, makeKeyOf(this.ids.inputInterface), { - isPublic: true, + expose: true, }), ); @@ -320,7 +319,7 @@ export class Integration { }), makePromise("any"), ), - { isPublic: true }, + { expose: true }, ); // `:${key}` @@ -411,13 +410,15 @@ export class Integration { ]), ), ]), - makeTypeParams({ K: this.ids.requestType }), - makePromise( - f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.responseInterface), - f.createTypeReferenceNode("K"), + { + typeParams: { K: this.ids.requestType }, + returns: makePromise( + f.createIndexedAccessTypeNode( + f.createTypeReferenceNode(this.ids.responseInterface), + f.createTypeReferenceNode("K"), + ), ), - ), + }, ); // export class ExpressZodAPIClient { ___ } diff --git a/src/typescript-api.ts b/src/typescript-api.ts index 8a3264436b..ec10d52f34 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -140,24 +140,24 @@ export const makePublicLiteralType = ( f.createLiteralTypeNode(f.createStringLiteral(option)), ), ), - { isPublic: true }, + { expose: true }, ); export const makeType = ( name: ts.Identifier | string, value: ts.TypeNode, { - isPublic, + expose, comment, params, }: { - isPublic?: boolean; + expose?: boolean; comment?: string; params?: Parameters[0]; } = {}, ) => { const node = f.createTypeAliasDeclaration( - isPublic ? exportModifier : undefined, + expose ? exportModifier : undefined, name, params && makeTypeParams(params), value, @@ -179,18 +179,23 @@ export const makeSomeOfHelper = () => export const makePublicMethod = ( name: ts.Identifier, params: ts.ParameterDeclaration[], - body?: ts.Block, - typeParams?: ts.TypeParameterDeclaration[], - returnType?: ts.TypeNode, + body: ts.Block, + { + typeParams, + returns, + }: { + typeParams?: Parameters[0]; + returns?: ts.TypeNode; + } = {}, ) => f.createMethodDeclaration( publicModifier, undefined, name, undefined, - typeParams, + typeParams && makeTypeParams(typeParams), params, - returnType, + returns, body, ); @@ -220,10 +225,10 @@ export const makePromise = (subject: ts.TypeNode | "any") => export const makeInterface = ( name: ts.Identifier | string, props: ts.PropertySignature[], - { isPublic, comment }: { isPublic?: boolean; comment?: string } = {}, + { expose, comment }: { expose?: boolean; comment?: string } = {}, ) => { const node = f.createInterfaceDeclaration( - isPublic ? exportModifier : undefined, + expose ? exportModifier : undefined, name, undefined, undefined, From 1c479cd43dbe2df3eaf7722ba876f6733f27a06f Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 8 Jan 2025 12:22:29 +0100 Subject: [PATCH 28/46] Fix: simpler expression for Integration (#2290) --- src/integration.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/integration.ts b/src/integration.ts index ffb1d544d8..a04d4370f8 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -539,23 +539,15 @@ export class Integration { ts.SyntaxKind.ExclamationToken, this.ids.contentTypeConst, ), - f.createReturnStatement(undefined), - undefined, + f.createReturnStatement(), ); // const isJSON = contentType.startsWith("application/json"); - const parserStatement = makeConst( + const isJsonConst = makeConst( this.ids.isJsonConst, - f.createCallChain( - f.createPropertyAccessChain( - this.ids.contentTypeConst, - undefined, - propOf("startsWith"), - ), - undefined, - undefined, - [f.createStringLiteral(contentTypes.json)], - ), + makePropCall(this.ids.contentTypeConst, propOf("startsWith"), [ + f.createStringLiteral(contentTypes.json), + ]), ); // return response[isJSON ? "json" : "text"](); @@ -589,7 +581,7 @@ export class Integration { responseStatement, contentTypeStatement, noBodyStatement, - parserStatement, + isJsonConst, returnStatement, ]), { isAsync: true }, From faa19fbb68474432d0114092962ab6dc6a0db931 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 11 Jan 2025 11:48:30 +0100 Subject: [PATCH 29/46] Rev: Security: not deprecating v18 - will do it in April. --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 24e37c382a..81008b3379 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ | 21.x.x | 11.2024 | :white_check_mark: | | 20.x.x | 06.2024 | :white_check_mark: | | 19.x.x | 05.2024 | :white_check_mark: | -| 18.x.x | 04.2024 | :x: | +| 18.x.x | 04.2024 | :white_check_mark: | | 17.x.x | 02.2024 | :x: | | 16.x.x | 12.2023 | :x: | | 15.x.x | 12.2023 | :x: | From 9bf42f18c9f9cc24a1caf469a5af837bcb2ed033 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 13 Jan 2025 09:55:32 +0100 Subject: [PATCH 30/46] ref(v22): Integration base (#2299) Bricks for the building: this should make the `Integration` class a high level orchestrator of the codegen. This should organize the code for readability. Can cause minor conflicts for #2280 --- src/integration-base.ts | 455 ++++++++++++++++++++++++++++++++++++++++ src/integration.ts | 453 ++------------------------------------- src/typescript-api.ts | 11 - 3 files changed, 472 insertions(+), 447 deletions(-) create mode 100644 src/integration-base.ts diff --git a/src/integration-base.ts b/src/integration-base.ts new file mode 100644 index 0000000000..d07148ee90 --- /dev/null +++ b/src/integration-base.ts @@ -0,0 +1,455 @@ +import ts from "typescript"; +import { ResponseVariant } from "./api-response"; +import { contentTypes } from "./content-type"; +import { Method, methods } from "./method"; +import { + f, + makeAnd, + makeArrowFn, + makeConst, + makeDeconstruction, + makeEmptyInitializingConstructor, + makeInterface, + makeInterfaceProp, + makeKeyOf, + makeNew, + makeObjectKeysReducer, + makeParam, + makeParams, + makePromise, + makePropCall, + makePropertyIdentifier, + makePublicClass, + makePublicLiteralType, + makePublicMethod, + makeTemplate, + makeTernary, + makeType, + propOf, + protectedReadonlyModifier, + 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"), + methodType: f.createIdentifier("Method"), + requestType: f.createIdentifier("Request"), + inputInterface: f.createIdentifier("Input"), + posResponseInterface: f.createIdentifier("PositiveResponse"), + negResponseInterface: f.createIdentifier("NegativeResponse"), + encResponseInterface: f.createIdentifier("EncodedResponse"), + responseInterface: f.createIdentifier("Response"), + endpointTagsConst: f.createIdentifier("endpointTags"), + 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"), + 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; + }> = [ + { id: this.ids.inputInterface, kind: "input" }, + { id: this.ids.posResponseInterface, kind: "positive" }, + { id: this.ids.negResponseInterface, kind: "negative" }, + { id: this.ids.encResponseInterface, kind: "encoded" }, + { id: this.ids.responseInterface, kind: "response" }, + ]; + + // export type Method = "get" | "post" | "put" | "delete" | "patch"; + protected methodType = makePublicLiteralType(this.ids.methodType, methods); + + // type SomeOf = T[keyof T]; + protected someOfType = makeType( + "SomeOf", + f.createIndexedAccessTypeNode( + f.createTypeReferenceNode("T"), + makeKeyOf("T"), + ), + { params: { T: undefined } }, + ); + + // export type Request = keyof Input; + protected requestType = makeType( + this.ids.requestType, + makeKeyOf(this.ids.inputInterface), + { expose: true }, + ); + + protected constructor(private readonly serverUrl: string) {} + + /** @example SomeOf<_> */ + protected someOf = ({ name }: ts.TypeAliasDeclaration) => + f.createTypeReferenceNode(this.someOfType.name, [ + f.createTypeReferenceNode(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 = () => + this.interfaces.map(({ id, kind }) => + makeInterface( + id, + Array.from(this.registry).map(([request, faces]) => + makeInterfaceProp(request, faces[kind]), + ), + { expose: true }, + ), + ); + + // export const endpointTags = { "get /v1/user/retrieve": ["users"] } + protected makeEndpointTags = () => + makeConst( + this.ids.endpointTagsConst, + 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]: f.createTypeReferenceNode( + this.ids.methodType, + ), + [this.ids.pathParameter.text]: f.createKeywordTypeNode( + ts.SyntaxKind.StringKeyword, + ), + [this.ids.paramsArgument.text]: recordStringAny, + }), + makePromise("any"), + ), + { expose: true }, + ); + + // public provide(request: K, params: Input[K]): Promise {} + private makeProvider = () => { + // `:${key}` + const keyParamExpression = makeTemplate(":", [this.ids.keyParameter]); + + // 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( + f.createAsExpression(this.ids.paramsArgument, recordStringAny), + 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( + f.createAsExpression( + this.ids.paramsArgument, + recordStringAny, + ), + this.ids.keyParameter, + ), + ), + ], + false, + ), + ), + ], + ), + f.createObjectLiteralExpression(), + ); + + return 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"), + ), + }), + f.createBlock([ + makeConst( + // const [method, path, params] = + makeDeconstruction(this.ids.methodParameter, this.ids.pathParameter), + // request.split(/ (.+)/, 2) as [Method, Path]; + f.createAsExpression( + makePropCall(this.ids.requestParameter, propOf("split"), [ + f.createRegularExpressionLiteral("/ (.+)/"), // split once + f.createNumericLiteral(2), // excludes third empty element + ]), + f.createTupleTypeNode([ + f.createTypeReferenceNode(this.ids.methodType), + f.createTypeReferenceNode(this.ids.pathType), + ]), + ), + ), + // return this.implementation(___) + f.createReturnStatement( + makePropCall(f.createThis(), this.ids.implementationArgument, [ + this.ids.methodParameter, + pathArgument, + paramsArgument, + ]), + ), + ]), + { + typeParams: { K: this.ids.requestType }, + returns: makePromise( + f.createIndexedAccessTypeNode( + f.createTypeReferenceNode(this.ids.responseInterface), + f.createTypeReferenceNode("K"), + ), + ), + }, + ); + }; + + // export class ExpressZodAPIClient { ___ } + protected makeClientClass = () => + makePublicClass( + this.ids.clientClass, + // constructor(protected readonly implementation: Implementation) {} + makeEmptyInitializingConstructor([ + makeParam( + this.ids.implementationArgument, + f.createTypeReferenceNode(this.ids.implementationType), + protectedReadonlyModifier, + ), + ]), + [this.makeProvider()], + ); + + // export const exampleImplementation: Implementation = async (method,path,params) => { ___ }; + protected makeExampleImplementation = () => { + // 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 = 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, 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(), + ); + + // 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: f.createTypeReferenceNode(this.ids.implementationType), + }, + ); + }; + + protected makeUsageStatements = () => [ + // const client = new ExpressZodAPIClient(exampleImplementation); + makeConst( + this.ids.clientConst, + makeNew(this.ids.clientClass, this.ids.exampleImplementationConst), + ), + // client.provide("get /v1/user/retrieve", { id: "10" }); + 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")), + ]), + ]), + ), + ]; +} diff --git a/src/integration.ts b/src/integration.ts index 456d1d0fc6..2f7f5b14a7 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -2,38 +2,15 @@ import { chain } from "ramda"; import ts from "typescript"; import { z } from "zod"; import { ResponseVariant, responseVariants } from "./api-response"; +import { IntegrationBase } from "./integration-base"; import { f, - makePromise, - makeArrowFn, - makeConst, - makeDeconstruction, - makeEmptyInitializingConstructor, makeInterfaceProp, - makeObjectKeysReducer, - makeParam, - makeParams, - makePropCall, - makePublicClass, makeInterface, - makePublicLiteralType, - makePublicMethod, makeType, - makeTernary, - propOf, - protectedReadonlyModifier, - recordStringAny, - makeAnd, - makeTemplate, - makeNew, - makeKeyOf, - makeSomeOfHelper, - makePropertyIdentifier, printNode, } 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"; @@ -42,8 +19,6 @@ import { zodToTs } from "./zts"; import { ZTSContext } from "./zts-helpers"; import type Prettier from "prettier"; -type IOKind = "input" | "response" | ResponseVariant | "encoded"; - interface IntegrationParams { routing: Routing; /** @@ -98,58 +73,10 @@ 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 & { tags: ReadonlyArray } - >(); - protected paths = new Set(); protected aliases = new Map(); - protected ids = { - pathType: f.createIdentifier("Path"), - methodType: f.createIdentifier("Method"), - requestType: f.createIdentifier("Request"), - inputInterface: f.createIdentifier("Input"), - posResponseInterface: f.createIdentifier("PositiveResponse"), - negResponseInterface: f.createIdentifier("NegativeResponse"), - encResponseInterface: f.createIdentifier("EncodedResponse"), - responseInterface: f.createIdentifier("Response"), - endpointTagsConst: f.createIdentifier("endpointTags"), - 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"), - 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, @@ -165,12 +92,6 @@ export class Integration { return f.createTypeReferenceNode(name); } - /** @example SomeOf<_>*/ - protected makeSomeOf = ({ name }: ts.TypeAliasDeclaration) => - f.createTypeReferenceNode(this.someOf.name, [ - f.createTypeReferenceNode(name), - ]); - public constructor({ routing, brandHandling, @@ -179,6 +100,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 } }; @@ -224,8 +146,8 @@ export class Integration { ); this.registry.set(request, { input: f.createTypeReferenceNode(input.name), - positive: this.makeSomeOf(dictionaries.positive), - negative: this.makeSomeOf(dictionaries.negative), + positive: this.someOf(dictionaries.positive), + negative: this.someOf(dictionaries.negative), response: f.createUnionTypeNode([ f.createIndexedAccessTypeNode( f.createTypeReferenceNode(this.ids.posResponseInterface), @@ -240,370 +162,29 @@ export class Integration { f.createTypeReferenceNode(dictionaries.positive.name), f.createTypeReferenceNode(dictionaries.negative.name), ]), - tags: endpoint.getTags(), }); + 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 endpointTags: ts.PropertyAssignment[] = []; - for (const [request, { 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") { - // "get /v1/user/retrieve": ["users"] - endpointTags.push( - f.createPropertyAssignment( - makePropertyIdentifier(request), - 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, { expose: true })); - - // export type Request = keyof Input; - this.program.push( - makeType(this.ids.requestType, makeKeyOf(this.ids.inputInterface), { - expose: true, - }), + this.makePathType(), + this.methodType, + ...this.makePublicInterfaces(), + this.requestType, ); if (variant === "types") return; - // export const endpointTags = { "get /v1/user/retrieve": ["users"] } - const endpointTagsConst = makeConst( - this.ids.endpointTagsConst, - f.createObjectLiteralExpression(endpointTags), - { expose: true }, - ); - - // 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"), - ), - { expose: true }, - ); - - // `:${key}` - const keyParamExpression = makeTemplate(":", [this.ids.keyParameter]); - - // 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( - f.createAsExpression(this.ids.paramsArgument, recordStringAny), - 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( - f.createAsExpression( - this.ids.paramsArgument, - recordStringAny, - ), - this.ids.keyParameter, - ), - ), - ], - false, - ), - ), - ], - ), - f.createObjectLiteralExpression(), - ); - - // public provide(request: K, params: Input[K]): Promise { - const providerMethod = 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"), - ), - }), - f.createBlock([ - makeConst( - // const [method, path, params] = - makeDeconstruction(this.ids.methodParameter, this.ids.pathParameter), - // request.split(/ (.+)/, 2) as [Method, Path]; - f.createAsExpression( - makePropCall(this.ids.requestParameter, propOf("split"), [ - f.createRegularExpressionLiteral("/ (.+)/"), // split once - f.createNumericLiteral(2), // excludes third empty element - ]), - f.createTupleTypeNode([ - f.createTypeReferenceNode(this.ids.methodType), - f.createTypeReferenceNode(this.ids.pathType), - ]), - ), - ), - // return this.implementation(___) - f.createReturnStatement( - makePropCall(f.createThis(), this.ids.implementationArgument, [ - this.ids.methodParameter, - pathArgument, - paramsArgument, - ]), - ), - ]), - { - typeParams: { K: this.ids.requestType }, - returns: makePromise( - f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.responseInterface), - f.createTypeReferenceNode("K"), - ), - ), - }, - ); - - // 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, - ), - ]), - [providerMethod], - ); - - this.program.push(endpointTagsConst, implementationType, clientClass); - - // 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 = 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(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, 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(), - ); - - // 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, - [], - ), - ); - - // export const exampleImplementation: Implementation = async (method,path,params) => { ___ }; - const exampleImplStatement = 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: 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 = makeConst( - this.ids.clientConst, - makeNew(this.ids.clientClass, this.ids.exampleImplementationConst), + this.program.push( + this.makeEndpointTags(), + this.makeImplementationType(), + this.makeClientClass(), ); this.usage.push( - exampleImplStatement, - clientInstanceStatement, - provideCallingStatement, + this.makeExampleImplementation(), + ...this.makeUsageStatements(), ); } diff --git a/src/typescript-api.ts b/src/typescript-api.ts index ec10d52f34..bac5ea6517 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -165,17 +165,6 @@ export const makeType = ( 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[], From ebf8b4758d3b8a532c04937be3e0f5c722c2d23c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 13 Jan 2025 11:58:45 +0100 Subject: [PATCH 31/46] ref(v22): `parseRequest()` and `substitute()` functions (#2303) This should extract the refactoring and general improvement from #2280 --- eslint.config.js | 11 +- example/example.client.ts | 35 ++-- src/integration-base.ts | 198 ++++++++++-------- src/integration.ts | 22 +- src/typescript-api.ts | 83 +++----- src/zts-helpers.ts | 5 +- src/zts.ts | 3 +- .../__snapshots__/integration.spec.ts.snap | 175 +++++++--------- 8 files changed, 253 insertions(+), 279 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 3b56dc30a9..b06621daa1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -85,10 +85,6 @@ const tsFactoryConcerns = [ "[arguments.0.callee.property.name='createPropertyAccessExpression']", message: "use makePropCall() helper", }, - { - selector: "Identifier[name='AmpersandAmpersandToken']", - message: "use makeAnd() helper", - }, { selector: "Identifier[name='EqualsEqualsEqualsToken']", message: "use makeEqual() helper", @@ -109,6 +105,11 @@ const tsFactoryConcerns = [ selector: "Literal[value='Promise']", message: "use makePromise() helper", }, + { + selector: + "CallExpression[callee.property.name='createTypeReferenceNode'][arguments.length=1]", + message: "use ensureTypeNode() helper", + }, ]; export default tsPlugin.config( @@ -155,7 +156,7 @@ export default tsPlugin.config( }, { name: "source/integration", - files: ["src/integration.ts", "src/zts.ts"], + files: ["src/integration.ts", "src/integration-base.ts", "src/zts.ts"], rules: { "no-restricted-syntax": [ "warn", diff --git a/example/example.client.ts b/example/example.client.ts index 8e7d51e07f..584994f5ae 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -414,6 +414,20 @@ 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, @@ -426,25 +440,8 @@ export class ExpressZodAPIClient { request: K, params: Input[K], ): Promise { - const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => - acc.replace(`:${key}`, (params as Record)[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(`:${key}`) && { - [key]: (params as Record)[key], - }, - ), - {}, - ), - ); + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } diff --git a/src/integration-base.ts b/src/integration-base.ts index d07148ee90..a96a097256 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -3,8 +3,9 @@ import { ResponseVariant } from "./api-response"; import { contentTypes } from "./content-type"; import { Method, methods } from "./method"; import { + accessModifiers, + ensureTypeNode, f, - makeAnd, makeArrowFn, makeConst, makeDeconstruction, @@ -13,7 +14,6 @@ import { makeInterfaceProp, makeKeyOf, makeNew, - makeObjectKeysReducer, makeParam, makeParams, makePromise, @@ -26,7 +26,6 @@ import { makeTernary, makeType, propOf, - protectedReadonlyModifier, recordStringAny, } from "./typescript-api"; @@ -54,7 +53,8 @@ export abstract class IntegrationBase { paramsArgument: f.createIdentifier("params"), methodParameter: f.createIdentifier("method"), requestParameter: f.createIdentifier("request"), - accumulator: f.createIdentifier("acc"), + parseRequestFn: f.createIdentifier("parseRequest"), + substituteFn: f.createIdentifier("substitute"), provideMethod: f.createIdentifier("provide"), implementationArgument: f.createIdentifier("implementation"), headersProperty: f.createIdentifier("headers"), @@ -62,6 +62,7 @@ export abstract class IntegrationBase { undefinedValue: f.createIdentifier("undefined"), bodyProperty: f.createIdentifier("body"), responseConst: f.createIdentifier("response"), + restConst: f.createIdentifier("rest"), searchParamsConst: f.createIdentifier("searchParams"), exampleImplementationConst: f.createIdentifier("exampleImplementation"), clientConst: f.createIdentifier("client"), @@ -86,10 +87,7 @@ export abstract class IntegrationBase { // type SomeOf = T[keyof T]; protected someOfType = makeType( "SomeOf", - f.createIndexedAccessTypeNode( - f.createTypeReferenceNode("T"), - makeKeyOf("T"), - ), + f.createIndexedAccessTypeNode(ensureTypeNode("T"), makeKeyOf("T")), { params: { T: undefined } }, ); @@ -104,9 +102,7 @@ export abstract class IntegrationBase { /** @example SomeOf<_> */ protected someOf = ({ name }: ts.TypeAliasDeclaration) => - f.createTypeReferenceNode(this.someOfType.name, [ - f.createTypeReferenceNode(name), - ]); + f.createTypeReferenceNode(this.someOfType.name, [ensureTypeNode(name)]); // export type Path = "/v1/user/retrieve" | ___; protected makePathType = () => @@ -148,9 +144,7 @@ export abstract class IntegrationBase { f.createFunctionTypeNode( undefined, makeParams({ - [this.ids.methodParameter.text]: f.createTypeReferenceNode( - this.ids.methodType, - ), + [this.ids.methodParameter.text]: ensureTypeNode(this.ids.methodType), [this.ids.pathParameter.text]: f.createKeywordTypeNode( ts.SyntaxKind.StringKeyword, ), @@ -161,92 +155,130 @@ export abstract class IntegrationBase { { expose: true }, ); - // public provide(request: K, params: Input[K]): Promise {} - private makeProvider = () => { - // `:${key}` - const keyParamExpression = makeTemplate(":", [this.ids.keyParameter]); - - // 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( - f.createAsExpression(this.ids.paramsArgument, recordStringAny), - this.ids.keyParameter, + // 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.ids.methodType), + ensureTypeNode(this.ids.pathType), + ]), ), - ]), - 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, - ]), + // 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, ), - f.createObjectLiteralExpression( - [ - f.createPropertyAssignment( - f.createComputedPropertyName(this.ids.keyParameter), - f.createElementAccessExpression( - f.createAsExpression( - this.ids.paramsArgument, - recordStringAny, - ), - this.ids.keyParameter, + 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, + ), + ), + ]), + ), + ], ), ), - ], - false, + ), + ]), + ), + f.createReturnStatement( + f.createAsExpression( + f.createArrayLiteralExpression([ + this.ids.pathParameter, + this.ids.restConst, + ]), + ensureTypeNode("const"), ), ), - ], + ]), ), - f.createObjectLiteralExpression(), ); - return makePublicMethod( + // public provide(request: K, params: Input[K]): Promise {} + private makeProvider = () => + makePublicMethod( this.ids.provideMethod, makeParams({ - [this.ids.requestParameter.text]: f.createTypeReferenceNode("K"), + [this.ids.requestParameter.text]: ensureTypeNode("K"), [this.ids.paramsArgument.text]: f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.inputInterface), - f.createTypeReferenceNode("K"), + ensureTypeNode(this.ids.inputInterface), + ensureTypeNode("K"), ), }), f.createBlock([ makeConst( - // const [method, path, params] = + // const [method, path] = this.parseRequest(request); makeDeconstruction(this.ids.methodParameter, this.ids.pathParameter), - // request.split(/ (.+)/, 2) as [Method, Path]; - f.createAsExpression( - makePropCall(this.ids.requestParameter, propOf("split"), [ - f.createRegularExpressionLiteral("/ (.+)/"), // split once - f.createNumericLiteral(2), // excludes third empty element - ]), - f.createTupleTypeNode([ - f.createTypeReferenceNode(this.ids.methodType), - f.createTypeReferenceNode(this.ids.pathType), - ]), - ), + f.createCallExpression(this.ids.parseRequestFn, undefined, [ + this.ids.requestParameter, + ]), ), // return this.implementation(___) f.createReturnStatement( makePropCall(f.createThis(), this.ids.implementationArgument, [ this.ids.methodParameter, - pathArgument, - paramsArgument, + f.createSpreadElement( + f.createCallExpression(this.ids.substituteFn, undefined, [ + this.ids.pathParameter, + this.ids.paramsArgument, + ]), + ), ]), ), ]), @@ -254,13 +286,12 @@ export abstract class IntegrationBase { typeParams: { K: this.ids.requestType }, returns: makePromise( f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.responseInterface), - f.createTypeReferenceNode("K"), + ensureTypeNode(this.ids.responseInterface), + ensureTypeNode("K"), ), ), }, ); - }; // export class ExpressZodAPIClient { ___ } protected makeClientClass = () => @@ -270,8 +301,8 @@ export abstract class IntegrationBase { makeEmptyInitializingConstructor([ makeParam( this.ids.implementationArgument, - f.createTypeReferenceNode(this.ids.implementationType), - protectedReadonlyModifier, + ensureTypeNode(this.ids.implementationType), + accessModifiers.protectedReadonly, ), ]), [this.makeProvider()], @@ -429,10 +460,7 @@ export abstract class IntegrationBase { ]), { isAsync: true }, ), - { - expose: true, - type: f.createTypeReferenceNode(this.ids.implementationType), - }, + { expose: true, type: ensureTypeNode(this.ids.implementationType) }, ); }; diff --git a/src/integration.ts b/src/integration.ts index 2f7f5b14a7..5da65aa1cd 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -9,6 +9,7 @@ import { makeInterface, makeType, printNode, + ensureTypeNode, } from "./typescript-api"; import { makeCleanId } from "./common-helpers"; import { loadPeer } from "./peer-helpers"; @@ -81,7 +82,7 @@ export class Integration extends IntegrationBase { 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}`; @@ -89,7 +90,7 @@ export class Integration extends IntegrationBase { this.aliases.set(schema, makeType(name, temp)); this.aliases.set(schema, makeType(name, produce())); } - return f.createTypeReferenceNode(name); + return ensureTypeNode(name); } public constructor({ @@ -124,10 +125,7 @@ export class Integration extends IntegrationBase { ); 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( @@ -145,22 +143,22 @@ export class Integration extends IntegrationBase { f.createStringLiteral(request), ); this.registry.set(request, { - input: f.createTypeReferenceNode(input.name), + 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.ids.posResponseInterface), literalIdx, ), f.createIndexedAccessTypeNode( - f.createTypeReferenceNode(this.ids.negResponseInterface), + ensureTypeNode(this.ids.negResponseInterface), literalIdx, ), ]), encoded: f.createIntersectionTypeNode([ - f.createTypeReferenceNode(dictionaries.positive.name), - f.createTypeReferenceNode(dictionaries.negative.name), + ensureTypeNode(dictionaries.positive.name), + ensureTypeNode(dictionaries.negative.name), ]), }); this.tags.set(request, endpoint.getTags()); @@ -178,6 +176,8 @@ export class Integration extends IntegrationBase { this.program.push( this.makeEndpointTags(), + this.makeParseRequestFn(), + this.makeSubstituteFn(), this.makeImplementationType(), this.makeClientClass(), ); diff --git a/src/typescript-api.ts b/src/typescript-api.ts index bac5ea6517..72c7f4ac7f 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -6,12 +6,13 @@ 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 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( @@ -95,16 +96,23 @@ export const makeEmptyInitializingConstructor = ( params: ts.ParameterDeclaration[], ) => f.createConstructorDeclaration(undefined, 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: ts.TypeNode, + value: Parameters[0], { isOptional }: { isOptional?: boolean } = {}, ) => f.createPropertySignature( undefined, makePropertyIdentifier(name), isOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, - value, + ensureTypeNode(value), ); export const makeDeconstruction = ( @@ -178,7 +186,7 @@ export const makePublicMethod = ( } = {}, ) => f.createMethodDeclaration( - publicModifier, + accessModifiers.public, undefined, name, undefined, @@ -198,11 +206,8 @@ export const makePublicClass = ( ...statements, ]); -export const makeKeyOf = (id: ts.Identifier | string) => - f.createTypeOperatorNode( - ts.SyntaxKind.KeyOfKeyword, - f.createTypeReferenceNode(id), - ); +export const makeKeyOf = (subj: Parameters[0]) => + f.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, ensureTypeNode(subj)); export const makePromise = (subject: ts.TypeNode | "any") => f.createTypeReferenceNode(Promise.name, [ @@ -230,15 +235,11 @@ export const makeTypeParams = ( params: Partial>, ) => Object.entries(params).map(([name, val]) => - f.createTypeParameterDeclaration( - [], - name, - val && ts.isIdentifier(val) ? f.createTypeReferenceNode(val) : val, - ), + f.createTypeParameterDeclaration([], name, val && ensureTypeNode(val)), ); export const makeArrowFn = ( - params: ts.Identifier[], + params: ts.Identifier[] | Parameters[0], body: ts.ConciseBody, { isAsync, @@ -251,43 +252,14 @@ export const makeArrowFn = ( f.createArrowFunction( isAsync ? asyncModifier : undefined, typeParams && makeTypeParams(typeParams), - params.map((key) => makeParam(key)), + Array.isArray(params) + ? params.map((key) => makeParam(key)) + : makeParams(params), 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 = ( @@ -319,13 +291,6 @@ export const makePropCall = ( args, ); -export const makeAnd = (left: ts.Expression, right: ts.Expression) => - f.createBinaryExpression( - left, - f.createToken(ts.SyntaxKind.AmpersandAmpersandToken), - right, - ); - export const makeNew = (cls: ts.Identifier, ...args: ts.Expression[]) => f.createNewExpression(cls, undefined, args); diff --git a/src/zts-helpers.ts b/src/zts-helpers.ts index 69b0d122f7..d5fc624ea0 100644 --- a/src/zts-helpers.ts +++ b/src/zts-helpers.ts @@ -7,10 +7,7 @@ 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 }; } diff --git a/src/zts.ts b/src/zts.ts index 2b18e15317..6d16fd9c93 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -10,6 +10,7 @@ import { ezRawBrand, RawSchema } from "./raw-schema"; import { HandlingRules, walkSchema } from "./schema-walker"; import { addJsDocComment, + ensureTypeNode, isPrimitive, makeInterfaceProp, } from "./typescript-api"; @@ -206,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/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index e945351d4a..4fc533448d 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -65,6 +65,20 @@ export type Request = keyof Input; export const endpointTags = { "post /v1/test-with-dashes": [] }; +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, @@ -77,25 +91,8 @@ export class ExpressZodAPIClient { request: K, params: Input[K], ): Promise { - const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => - acc.replace(\`:\${key}\`, (params as Record)[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { - [key]: (params as Record)[key], - }, - ), - {}, - ), - ); + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } @@ -192,6 +189,20 @@ export type Request = keyof Input; export const endpointTags = { "post /v1/test-with-dashes": [] }; +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, @@ -204,25 +215,8 @@ export class ExpressZodAPIClient { request: K, params: Input[K], ): Promise { - const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => - acc.replace(\`:\${key}\`, (params as Record)[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { - [key]: (params as Record)[key], - }, - ), - {}, - ), - ); + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } @@ -319,6 +313,20 @@ export type Request = keyof Input; export const endpointTags = { "post /v1/test-with-dashes": [] }; +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, @@ -331,25 +339,8 @@ export class ExpressZodAPIClient { request: K, params: Input[K], ): Promise { - const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => - acc.replace(\`:\${key}\`, (params as Record)[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { - [key]: (params as Record)[key], - }, - ), - {}, - ), - ); + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } @@ -864,6 +855,20 @@ 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, @@ -876,25 +881,8 @@ export class ExpressZodAPIClient { request: K, params: Input[K], ): Promise { - const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => - acc.replace(\`:\${key}\`, (params as Record)[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { - [key]: (params as Record)[key], - }, - ), - {}, - ), - ); + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } @@ -1470,6 +1458,20 @@ export type Request = keyof Input; export const endpointTags = { "post /v1/test-with-dashes": [] }; +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, @@ -1482,25 +1484,8 @@ export class ExpressZodAPIClient { request: K, params: Input[K], ): Promise { - const [method, path] = request.split(/ (.+)/, 2) as [Method, Path]; - return this.implementation( - method, - Object.keys(params).reduce( - (acc, key) => - acc.replace(\`:\${key}\`, (params as Record)[key]), - path, - ), - Object.keys(params).reduce( - (acc, key) => - Object.assign( - acc, - !path.includes(\`:\${key}\`) && { - [key]: (params as Record)[key], - }, - ), - {}, - ), - ); + const [method, path] = parseRequest(request); + return this.implementation(method, ...substitute(path, params)); } } From 22c36a2ac0a0af61131ae69e45e764c3f886def7 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 13 Jan 2025 12:04:42 +0100 Subject: [PATCH 32/46] 22.0.0-beta.3 --- example/example.documentation.yaml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 3affab2281..6e2320335a 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 22.0.0-beta.2 + version: 22.0.0-beta.3 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index e948379aab..8326ad277d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "22.0.0-beta.2", + "version": "22.0.0-beta.3", "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": { From ea01e08df7db231a23b3f3e6b97f6ea88f76b384 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 13 Jan 2025 13:04:52 +0100 Subject: [PATCH 33/46] ref(v22): makeParam and makeParams API methods adjustment (#2305) For #2304 --- src/integration-base.ts | 9 ++++----- src/typescript-api.ts | 12 ++++-------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/integration-base.ts b/src/integration-base.ts index a96a097256..30404058b0 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -299,11 +299,10 @@ export abstract class IntegrationBase { this.ids.clientClass, // constructor(protected readonly implementation: Implementation) {} makeEmptyInitializingConstructor([ - makeParam( - this.ids.implementationArgument, - ensureTypeNode(this.ids.implementationType), - accessModifiers.protectedReadonly, - ), + makeParam(this.ids.implementationArgument, { + type: ensureTypeNode(this.ids.implementationType), + mod: accessModifiers.protectedReadonly, + }), ]), [this.makeProvider()], ); diff --git a/src/typescript-api.ts b/src/typescript-api.ts index 72c7f4ac7f..0ab675098b 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -72,8 +72,7 @@ export const recordStringAny = f.createExpressionWithTypeArguments( export const makeParam = ( name: ts.Identifier, - type?: ts.TypeNode, - mod?: ts.Modifier[], + { type, mod }: { type?: ts.TypeNode; mod?: ts.Modifier[] } = {}, ) => f.createParameterDeclaration( mod, @@ -84,12 +83,9 @@ export const makeParam = ( undefined, ); -export const makeParams = ( - params: Partial>, - mod?: ts.Modifier[], -) => - Object.entries(params).map(([name, node]) => - makeParam(f.createIdentifier(name), node, mod), +export const makeParams = (params: Partial>) => + Object.entries(params).map(([name, type]) => + makeParam(f.createIdentifier(name), { type }), ); export const makeEmptyInitializingConstructor = ( From 87821caee78323fe3a1cecfaa9c2a383b167a60e Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 13 Jan 2025 22:27:41 +0100 Subject: [PATCH 34/46] fix(v22): Integration cleanup and constraints adjustments (#2306) Cherry-picked from #2304 --- eslint.config.js | 4 ---- src/integration-base.ts | 10 ++++------ src/typescript-api.ts | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index b06621daa1..ba4fad0026 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -85,10 +85,6 @@ const tsFactoryConcerns = [ "[arguments.0.callee.property.name='createPropertyAccessExpression']", message: "use makePropCall() helper", }, - { - selector: "Identifier[name='EqualsEqualsEqualsToken']", - message: "use makeEqual() helper", - }, { selector: "Identifier[name='KeyOfKeyword']", message: "use makeKeyOf() helper", diff --git a/src/integration-base.ts b/src/integration-base.ts index 30404058b0..cff1847b90 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -57,10 +57,8 @@ export abstract class IntegrationBase { substituteFn: f.createIdentifier("substitute"), 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"), restConst: f.createIdentifier("rest"), searchParamsConst: f.createIdentifier("searchParams"), @@ -311,13 +309,13 @@ export abstract class IntegrationBase { protected makeExampleImplementation = () => { // method: method.toUpperCase() const methodProperty = f.createPropertyAssignment( - this.ids.methodParameter, + propOf("method"), makePropCall(this.ids.methodParameter, propOf("toUpperCase")), ); // headers: hasBody ? { "Content-Type": "application/json" } : undefined const headersProperty = f.createPropertyAssignment( - this.ids.headersProperty, + propOf("headers"), makeTernary( this.ids.hasBodyConst, f.createObjectLiteralExpression([ @@ -332,7 +330,7 @@ export abstract class IntegrationBase { // body: hasBody ? JSON.stringify(params) : undefined const bodyProperty = f.createPropertyAssignment( - this.ids.bodyProperty, + propOf("body"), makeTernary( this.ids.hasBodyConst, makePropCall( @@ -401,7 +399,7 @@ export abstract class IntegrationBase { const contentTypeStatement = makeConst( this.ids.contentTypeConst, makePropCall( - [this.ids.responseConst, this.ids.headersProperty], + [this.ids.responseConst, propOf("headers")], propOf("get"), [f.createStringLiteral("content-type")], ), diff --git a/src/typescript-api.ts b/src/typescript-api.ts index 0ab675098b..13f0a8e504 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -272,7 +272,7 @@ export const makeTernary = ( ); export const makePropCall = ( - parent: ts.Expression | [ts.Expression, ts.Identifier], + parent: ts.Expression | [ts.Expression, ts.Identifier | string], child: ts.Identifier | string, args?: ts.Expression[], ) => From d2ea9ae0e204114d6a1c61afa2bd5405bf1671b2 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 14 Jan 2025 10:02:03 +0100 Subject: [PATCH 35/46] ref(v22): Minimizing ids (#2307) Some may be retrieved from the declarations of the corresponding variables. --- src/integration-base.ts | 47 ++++++++++++++++------------------------- src/integration.ts | 4 ++-- src/typescript-api.ts | 2 +- 3 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/integration-base.ts b/src/integration-base.ts index cff1847b90..73306f0c27 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -38,14 +38,6 @@ export abstract class IntegrationBase { protected ids = { pathType: f.createIdentifier("Path"), - methodType: f.createIdentifier("Method"), - requestType: f.createIdentifier("Request"), - inputInterface: f.createIdentifier("Input"), - posResponseInterface: f.createIdentifier("PositiveResponse"), - negResponseInterface: f.createIdentifier("NegativeResponse"), - encResponseInterface: f.createIdentifier("EncodedResponse"), - responseInterface: f.createIdentifier("Response"), - endpointTagsConst: f.createIdentifier("endpointTags"), implementationType: f.createIdentifier("Implementation"), clientClass: f.createIdentifier("ExpressZodAPIClient"), keyParameter: f.createIdentifier("key"), @@ -68,19 +60,16 @@ export abstract class IntegrationBase { isJsonConst: f.createIdentifier("isJSON"), } satisfies Record; - protected interfaces: Array<{ - id: ts.Identifier; - kind: IOKind; - }> = [ - { id: this.ids.inputInterface, kind: "input" }, - { id: this.ids.posResponseInterface, kind: "positive" }, - { id: this.ids.negResponseInterface, kind: "negative" }, - { id: this.ids.encResponseInterface, kind: "encoded" }, - { id: this.ids.responseInterface, kind: "response" }, - ]; + 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(this.ids.methodType, methods); + protected methodType = makePublicLiteralType("Method", methods); // type SomeOf = T[keyof T]; protected someOfType = makeType( @@ -91,8 +80,8 @@ export abstract class IntegrationBase { // export type Request = keyof Input; protected requestType = makeType( - this.ids.requestType, - makeKeyOf(this.ids.inputInterface), + "Request", + makeKeyOf(this.interfaces.input), { expose: true }, ); @@ -108,9 +97,9 @@ export abstract class IntegrationBase { // export interface Input { "get /v1/user/retrieve": GetV1UserRetrieveInput; } protected makePublicInterfaces = () => - this.interfaces.map(({ id, kind }) => + (Object.keys(this.interfaces) as IOKind[]).map((kind) => makeInterface( - id, + this.interfaces[kind], Array.from(this.registry).map(([request, faces]) => makeInterfaceProp(request, faces[kind]), ), @@ -121,7 +110,7 @@ export abstract class IntegrationBase { // export const endpointTags = { "get /v1/user/retrieve": ["users"] } protected makeEndpointTags = () => makeConst( - this.ids.endpointTagsConst, + "endpointTags", f.createObjectLiteralExpression( Array.from(this.tags).map(([request, tags]) => f.createPropertyAssignment( @@ -142,7 +131,7 @@ export abstract class IntegrationBase { f.createFunctionTypeNode( undefined, makeParams({ - [this.ids.methodParameter.text]: ensureTypeNode(this.ids.methodType), + [this.ids.methodParameter.text]: ensureTypeNode(this.methodType.name), [this.ids.pathParameter.text]: f.createKeywordTypeNode( ts.SyntaxKind.StringKeyword, ), @@ -169,7 +158,7 @@ export abstract class IntegrationBase { f.createNumericLiteral(2), // excludes third empty element ]), f.createTupleTypeNode([ - ensureTypeNode(this.ids.methodType), + ensureTypeNode(this.methodType.name), ensureTypeNode(this.ids.pathType), ]), ), @@ -255,7 +244,7 @@ export abstract class IntegrationBase { makeParams({ [this.ids.requestParameter.text]: ensureTypeNode("K"), [this.ids.paramsArgument.text]: f.createIndexedAccessTypeNode( - ensureTypeNode(this.ids.inputInterface), + ensureTypeNode(this.interfaces.input), ensureTypeNode("K"), ), }), @@ -281,10 +270,10 @@ export abstract class IntegrationBase { ), ]), { - typeParams: { K: this.ids.requestType }, + typeParams: { K: this.requestType.name }, returns: makePromise( f.createIndexedAccessTypeNode( - ensureTypeNode(this.ids.responseInterface), + ensureTypeNode(this.interfaces.response), ensureTypeNode("K"), ), ), diff --git a/src/integration.ts b/src/integration.ts index 5da65aa1cd..753486a743 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -148,11 +148,11 @@ export class Integration extends IntegrationBase { negative: this.someOf(dictionaries.negative), response: f.createUnionTypeNode([ f.createIndexedAccessTypeNode( - ensureTypeNode(this.ids.posResponseInterface), + ensureTypeNode(this.interfaces.positive), literalIdx, ), f.createIndexedAccessTypeNode( - ensureTypeNode(this.ids.negResponseInterface), + ensureTypeNode(this.interfaces.negative), literalIdx, ), ]), diff --git a/src/typescript-api.ts b/src/typescript-api.ts index 13f0a8e504..4b8f94c6d0 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -121,7 +121,7 @@ export const makeDeconstruction = ( ); export const makeConst = ( - name: ts.Identifier | ts.ArrayBindingPattern, + name: string | ts.Identifier | ts.ArrayBindingPattern, value: ts.Expression, { type, expose }: { type?: ts.TypeNode; expose?: true } = {}, ) => From d97a25d8a6633c93b8335bfab7038aaf2d882e7b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 14 Jan 2025 17:32:24 +0100 Subject: [PATCH 36/46] Adjusting client constructor (#2312) I wanna follow the principle of consistency and ensure that all constructors have an explicit access modifier specified --- eslint.config.js | 2 +- example/example.client.ts | 2 +- src/integration-base.ts | 6 +++--- src/typescript-api.ts | 9 ++++++--- tests/unit/__snapshots__/integration.spec.ts.snap | 10 +++++----- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index ba4fad0026..75227b3d62 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -73,7 +73,7 @@ const tsFactoryConcerns = [ }, { selector: "Identifier[name='createConstructorDeclaration']", - message: "use makeEmptyInitializingConstructor() helper", + message: "use makePublicConstructor() helper", }, { selector: "Identifier[name='createParameterDeclaration']", diff --git a/example/example.client.ts b/example/example.client.ts index 584994f5ae..a509f644de 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -435,7 +435,7 @@ export type Implementation = ( ) => Promise; export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], diff --git a/src/integration-base.ts b/src/integration-base.ts index 73306f0c27..adce9756a2 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -9,7 +9,7 @@ import { makeArrowFn, makeConst, makeDeconstruction, - makeEmptyInitializingConstructor, + makePublicConstructor, makeInterface, makeInterfaceProp, makeKeyOf, @@ -284,8 +284,8 @@ export abstract class IntegrationBase { protected makeClientClass = () => makePublicClass( this.ids.clientClass, - // constructor(protected readonly implementation: Implementation) {} - makeEmptyInitializingConstructor([ + // public constructor(protected readonly implementation: Implementation) {} + makePublicConstructor([ makeParam(this.ids.implementationArgument, { type: ensureTypeNode(this.ids.implementationType), mod: accessModifiers.protectedReadonly, diff --git a/src/typescript-api.ts b/src/typescript-api.ts index 4b8f94c6d0..6bf92ed68b 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -88,9 +88,12 @@ export const makeParams = (params: Partial>) => makeParam(f.createIdentifier(name), { type }), ); -export const makeEmptyInitializingConstructor = ( - params: ts.ParameterDeclaration[], -) => f.createConstructorDeclaration(undefined, params, f.createBlock([])); +export const makePublicConstructor = (params: ts.ParameterDeclaration[]) => + f.createConstructorDeclaration( + accessModifiers.public, + params, + f.createBlock([]), + ); export const ensureTypeNode = ( subject: ts.TypeNode | ts.Identifier | string, diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index 4fc533448d..1264b658c4 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -86,7 +86,7 @@ export type Implementation = ( ) => Promise; export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], @@ -210,7 +210,7 @@ export type Implementation = ( ) => Promise; export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], @@ -334,7 +334,7 @@ export type Implementation = ( ) => Promise; export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], @@ -876,7 +876,7 @@ export type Implementation = ( ) => Promise; export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], @@ -1479,7 +1479,7 @@ export type Implementation = ( ) => Promise; export class ExpressZodAPIClient { - constructor(protected readonly implementation: Implementation) {} + public constructor(protected readonly implementation: Implementation) {} public provide( request: K, params: Input[K], From e1b1e712e0650c4a3dfb9dfa5baca97d95bca411 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 Jan 2025 17:37:43 +0100 Subject: [PATCH 37/46] Minor: imports diff reduction. --- src/integration-base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integration-base.ts b/src/integration-base.ts index adce9756a2..9b83a06a77 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -9,7 +9,6 @@ import { makeArrowFn, makeConst, makeDeconstruction, - makePublicConstructor, makeInterface, makeInterfaceProp, makeKeyOf, @@ -19,6 +18,7 @@ import { makePromise, makePropCall, makePropertyIdentifier, + makePublicConstructor, makePublicClass, makePublicLiteralType, makePublicMethod, From 88a0a66697b4290a92bbb848777fad6db8485b5b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 14 Jan 2025 21:24:23 +0100 Subject: [PATCH 38/46] Renaming `ExpressZodAPIClient` to just `Client` (#2313) 1. Since I also wanna make subscription class in #2280 2. In the era of modules there is no longer need to have a unique name --- CHANGELOG.md | 3 ++- README.md | 4 ++-- example/example.client.ts | 4 ++-- src/integration-base.ts | 2 +- src/migration.ts | 15 ++++++++++++++ tests/system/example.spec.ts | 11 +++------- .../__snapshots__/integration.spec.ts.snap | 20 +++++++++---------- tests/unit/migration.spec.ts | 15 ++++++++++++++ 8 files changed, 50 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bafa225d4..8e4a5544d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ - `BuiltinLogger::profile()` behavior changed for picoseconds: expressing them through nanoseconds; - Changes to client generated by `Integration`: - The `splitResponse` property on the constructor argument is removed; - - The overload of `ExpressZodAPIClient::provide()` having 3 arguments and the `Provider` type are removed; + - 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: diff --git a/README.md b/README.md index 906a87ef04..fcbc91c272 100644 --- a/README.md +++ b/README.md @@ -1264,9 +1264,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}`, { diff --git a/example/example.client.ts b/example/example.client.ts index a509f644de..b6f0889ddf 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -434,7 +434,7 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { +export class Client { public constructor(protected readonly implementation: Implementation) {} public provide( request: K, @@ -467,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/src/integration-base.ts b/src/integration-base.ts index 9b83a06a77..6db077d59d 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -39,7 +39,7 @@ export abstract class IntegrationBase { protected ids = { pathType: f.createIdentifier("Path"), implementationType: f.createIdentifier("Implementation"), - clientClass: f.createIdentifier("ExpressZodAPIClient"), + clientClass: f.createIdentifier("Client"), keyParameter: f.createIdentifier("key"), pathParameter: f.createIdentifier("path"), paramsArgument: f.createIdentifier("params"), diff --git a/src/migration.ts b/src/migration.ts index 6c8247f853..3aea4fe273 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -24,6 +24,7 @@ interface Queries { newDocs: TSESTree.ObjectExpression; newFactory: TSESTree.Property & { key: TSESTree.Identifier }; newSSE: TSESTree.Property & { key: TSESTree.Identifier }; + newClient: TSESTree.NewExpression; } type Listener = keyof Queries; @@ -48,6 +49,7 @@ const queries: Record = { newSSE: `${NT.NewExpression}[callee.name='EventStreamFactory'] > ` + `${NT.ObjectExpression} > ${NT.Property}[key.name='events']`, + newClient: `${NT.NewExpression}[callee.name='ExpressZodAPIClient']`, }; const listen = < @@ -161,6 +163,19 @@ const v22 = ESLintUtils.RuleCreator.withoutDocs({ 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), + }); + }, }), }); diff --git a/tests/system/example.spec.ts b/tests/system/example.spec.ts index 8b7e1090e5..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,9 +460,7 @@ 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", { diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index 1264b658c4..c58bc1f2aa 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -85,7 +85,7 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { +export class Client { public constructor(protected readonly implementation: Implementation) {} public provide( request: K, @@ -118,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" }); */ " @@ -209,7 +209,7 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { +export class Client { public constructor(protected readonly implementation: Implementation) {} public provide( request: K, @@ -242,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" }); */ " @@ -333,7 +333,7 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { +export class Client { public constructor(protected readonly implementation: Implementation) {} public provide( request: K, @@ -366,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" }); */ " @@ -875,7 +875,7 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { +export class Client { public constructor(protected readonly implementation: Implementation) {} public provide( request: K, @@ -908,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" }); */ " @@ -1478,7 +1478,7 @@ export type Implementation = ( params: Record, ) => Promise; -export class ExpressZodAPIClient { +export class Client { public constructor(protected readonly implementation: Implementation) {} public provide( request: K, @@ -1511,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/migration.spec.ts b/tests/unit/migration.spec.ts index fb5e389578..56c4e5c757 100644 --- a/tests/unit/migration.spec.ts +++ b/tests/unit/migration.spec.ts @@ -26,6 +26,7 @@ describe("Migration", () => { `new Documentation();`, `new EndpointsFactory(new ResultHandler());`, `new EventStreamFactory({});`, + `new Client();`, ], invalid: [ { @@ -107,6 +108,20 @@ describe("Migration", () => { }, ], }, + { + code: `new ExpressZodAPIClient();`, + output: `new Client();`, + errors: [ + { + messageId: "change", + data: { + subject: "class", + from: "ExpressZodAPIClient", + to: "Client", + }, + }, + ], + }, ], }); }); From f3e14501dc50e584c7514d8b3797624c4b5670d6 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 15 Jan 2025 09:54:04 +0100 Subject: [PATCH 39/46] ref(v22): makePublicClass() arguments (#2314) should make it easier to feat #2280 --- src/integration-base.ts | 7 +++---- src/typescript-api.ts | 14 ++++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/integration-base.ts b/src/integration-base.ts index 6db077d59d..d254dae381 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -282,8 +282,7 @@ export abstract class IntegrationBase { // export class ExpressZodAPIClient { ___ } protected makeClientClass = () => - makePublicClass( - this.ids.clientClass, + makePublicClass(this.ids.clientClass, [ // public constructor(protected readonly implementation: Implementation) {} makePublicConstructor([ makeParam(this.ids.implementationArgument, { @@ -291,8 +290,8 @@ export abstract class IntegrationBase { mod: accessModifiers.protectedReadonly, }), ]), - [this.makeProvider()], - ); + this.makeProvider(), + ]); // export const exampleImplementation: Implementation = async (method,path,params) => { ___ }; protected makeExampleImplementation = () => { diff --git a/src/typescript-api.ts b/src/typescript-api.ts index 6bf92ed68b..97beb3a69e 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -197,13 +197,15 @@ export const makePublicMethod = ( export const makePublicClass = ( name: ts.Identifier, - constructor: ts.ConstructorDeclaration, - statements: ts.MethodDeclaration[], + statements: ts.ClassElement[], ) => - f.createClassDeclaration(exportModifier, name, undefined, undefined, [ - constructor, - ...statements, - ]); + f.createClassDeclaration( + exportModifier, + name, + undefined, + undefined, + statements, + ); export const makeKeyOf = (subj: Parameters[0]) => f.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, ensureTypeNode(subj)); From d01bd80aab40f385ae1daaf8b3b775a1ab1c6911 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 15 Jan 2025 10:20:06 +0100 Subject: [PATCH 40/46] Fix: comment. --- src/integration-base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integration-base.ts b/src/integration-base.ts index d254dae381..914dc2a7c3 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -450,7 +450,7 @@ export abstract class IntegrationBase { }; protected makeUsageStatements = () => [ - // const client = new ExpressZodAPIClient(exampleImplementation); + // const client = new Client(exampleImplementation); makeConst( this.ids.clientConst, makeNew(this.ids.clientClass, this.ids.exampleImplementationConst), From b1c3d033cda8c57cc1a79f9b31ad09fce23119cc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 15 Jan 2025 10:29:48 +0100 Subject: [PATCH 41/46] Fix: makeUsageStatements() to return just nodes. --- src/integration-base.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/integration-base.ts b/src/integration-base.ts index 914dc2a7c3..579bd7d42b 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -449,20 +449,18 @@ export abstract class IntegrationBase { ); }; - protected makeUsageStatements = () => [ + 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" }); - 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")), - ]), + makePropCall(this.ids.clientConst, this.ids.provideMethod, [ + f.createStringLiteral(`${"get" satisfies Method} /v1/user/retrieve`), + f.createObjectLiteralExpression([ + f.createPropertyAssignment("id", f.createStringLiteral("10")), ]), - ), + ]), ]; } From c73ba3e37a78a4f2b398b0df8542e11c47bd09d5 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 15 Jan 2025 10:40:19 +0100 Subject: [PATCH 42/46] 22.0.0-beta.4 --- example/example.documentation.yaml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 6e2320335a..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: 22.0.0-beta.3 + version: 22.0.0-beta.4 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index 5c42e5bf26..e8a7ce1599 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "22.0.0-beta.3", + "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": { From 773c41729555b8d44a757659e05a752dfeb254e5 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 18 Jan 2025 13:07:19 +0100 Subject: [PATCH 43/46] Changelog: Apply suggestions from code review --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c478b5dd26..2e52d0ab98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,11 @@ ### v22.0.0 -- Minimum supported Node versions: 20.9.0 and 22.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; -- Changes to client generated by `Integration`: - - The `splitResponse` property on the constructor argument is removed; +- 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; From 6bfa2962d76fc499d93cad77e0050c28244c30db Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 22 Jan 2025 20:40:13 +0100 Subject: [PATCH 44/46] `Endpoint::getSecurity()` to return an array of containers (#2333) Cherry-picked from #2332 This should ease that PR as later fix without any breaking changes --- CHANGELOG.md | 1 + src/documentation.ts | 7 +++++-- src/endpoint.ts | 14 +++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e52d0ab98..7a39e62204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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 diff --git a/src/documentation.ts b/src/documentation.ts index 4f3a17d451..7c82b83ffa 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, @@ -226,7 +226,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( 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() { From d8f8192bb1eb3d29632f82d6fef2b233fcea3bd6 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 24 Jan 2025 06:33:15 +0100 Subject: [PATCH 45/46] feat(v22): Handle all headers (#2337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to discussion: #2326 - The idea is to handle all headers when enabled within `inputSources` - The `Documentation` would need a way to distinguish them - Therefore a list of well-known headers will be included into the distribution - And there should probably be an option to include more ⚠️ The feature is risky: it may affect the behaviour for the users having `headers` enabled within `inputSources` with a high priority (in last place), because some header can be named exactly as another input. Therefore I decided to move it to the next major (v22) --- .github/workflows/headers.yml | 54 +++++++++++++++ CHANGELOG.md | 11 +++ README.md | 85 ++++++++++++------------ dataflow.svg | 3 +- src/common-helpers.ts | 12 +--- src/documentation-helpers.ts | 52 ++++++++------- src/documentation.ts | 10 +++ src/well-known-headers.json | 1 + tests/unit/common-helpers.spec.ts | 32 ++------- tests/unit/documentation-helpers.spec.ts | 14 ++++ tools/headers.ts | 52 +++++++++++++++ 11 files changed, 222 insertions(+), 104 deletions(-) create mode 100644 .github/workflows/headers.yml create mode 100644 src/well-known-headers.json create mode 100644 tools/headers.ts 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/CHANGELOG.md b/CHANGELOG.md index 7a39e62204..0d69e29077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - 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`; @@ -35,10 +41,15 @@ export default [ ```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( diff --git a/README.md b/README.md index fcbc91c272..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 diff --git a/dataflow.svg b/dataflow.svg index fbdfee7de0..ddb28bc969 100644 --- a/dataflow.svg +++ b/dataflow.svg @@ -1,4 +1,3 @@ - -
Endpoint
Endpoint
options
options
input
schema
input...
output
schema
output...
handler
handler
Middleware N
Middleware N
options
options
middleware
middleware
input
schema
input...
   Middleware 1
   Middleware 1
middleware
middleware
input
schema
input...
Request
Request
.query
.query
.body
.body
ResultHandler
ResultHandler
Response
Response
error
error
GET & DELETE
GET & DELETE
PUT & PATCH
PUT & PATCH
.files
.files
POST
POST
.params
.params
.method
.method
.headers
.headers
 opt-in
 opt-in
custom only
custom only
Text is not SVG - cannot display
\ No newline at end of file +
Endpoint
Endpoint
options
options
input
schema
input...
output
schema
output...
handler
handler
Middleware N
Middleware N
options
options
middleware
middleware
input
schema
input...
   Middleware 1
   Middleware 1
middleware
middleware
input
schema
input...
Request
Request
.query
.query
.body
.body
ResultHandler
ResultHandler
Response
Response
error
error
GET & DELETE
GET & DELETE
PUT & PATCH
PUT & PATCH
.files
.files
POST
POST
.params
.params
.method
.method
.headers
.headers
 opt-in
 opt-in
\ No newline at end of file diff --git a/src/common-helpers.ts b/src/common-helpers.ts index 1973d17961..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"; @@ -42,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"] = {}, @@ -61,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/documentation-helpers.ts b/src/documentation-helpers.ts index bbde3ab1bc..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,7 +44,6 @@ import { combinations, getExamples, hasCoercion, - isCustomHeader, makeCleanId, tryToTransform, ucFirst, @@ -69,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; @@ -89,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; @@ -625,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, @@ -633,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); @@ -645,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, @@ -674,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< diff --git a/src/documentation.ts b/src/documentation.ts index 7c82b83ffa..c0c4f153dd 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -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,13 @@ 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 @@ -142,6 +150,7 @@ export class Documentation extends OpenApiBuilder { descriptions, brandHandling, tags, + isHeader, hasSummaryFromDescription = true, composition = "inline", }: DocumentationParams) { @@ -178,6 +187,7 @@ export class Documentation extends OpenApiBuilder { const depictedParams = depictRequestParams({ ...commons, inputSources, + isHeader, schema: endpoint.getSchema("input"), description: descriptions?.requestParameter?.call(null, { method, 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/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/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"); From 3f500e78137c59bb3af7eb30979d4a4960d86a69 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Fri, 24 Jan 2025 09:33:59 +0100 Subject: [PATCH 46/46] rev: trigger branches, no more prs --- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/node.js.yml | 4 ++-- .github/workflows/swagger.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4b0a2e1036..17191e1dac 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master, v19, v20, v21, make-v22 ] + branches: [ master, v19, v20, v21 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v19, v20, v21, make-v22 ] + branches: [ master, v19, v20, v21 ] schedule: - cron: '26 8 * * 1' diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 4aa613382c..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, v19, v20, v21, make-v22 ] + branches: [ master, v19, v20, v21 ] pull_request: - branches: [ master, v19, v20, v21, make-v22 ] + branches: [ master, v19, v20, v21 ] jobs: build: diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index c68a8dad23..3eccec5214 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -2,9 +2,9 @@ name: OpenAPI Validation on: push: - branches: [ master, v19, v20, v21, make-v22 ] + branches: [ master, v19, v20, v21 ] pull_request: - branches: [ master, v19, v20, v21, make-v22 ] + branches: [ master, v19, v20, v21 ] jobs: