From b6cd78eec3721f641f6b21956dcda7e12d0dc8d1 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 22 Apr 2024 08:10:44 +0200 Subject: [PATCH 01/40] Adjusting workflow triggers. --- .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 ad4176ab7..a066109ed 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master, v15, v16, v17 ] + branches: [ master, v16, v17, v18, make-v19 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v15, v16, v17 ] + branches: [ master, v16, v17, v18, make-v19 ] schedule: - cron: '26 8 * * 1' diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 0c495c25c..d48430701 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, v15, v16, v17 ] + branches: [ master, v16, v17, v18, make-v19 ] pull_request: - branches: [ master, v15, v16, v17 ] + branches: [ master, v16, v17, v18, make-v19 ] jobs: build: diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index f8a65a788..46d9bb170 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -2,9 +2,9 @@ name: OpenAPI Validation on: push: - branches: [ master, v15, v16, v17 ] + branches: [ master, v16, v17, v18, make-v19 ] pull_request: - branches: [ master, v15, v16, v17 ] + branches: [ master, v16, v17, v18, make-v19 ] jobs: From 4308342dbb760c8c2c85586ac80472afa6108350 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 22 Apr 2024 08:34:17 +0200 Subject: [PATCH 02/40] Next: Drop support `zod` 3.22 (#1693) Due to #1689 --- package.json | 2 +- src/date-in-schema.ts | 12 +++--- src/documentation-helpers.ts | 8 +--- src/file-schema.ts | 15 +------ src/schema-helpers.ts | 11 ------ .../__snapshots__/date-in-schema.spec.ts.snap | 39 ++----------------- .../__snapshots__/file-schema.spec.ts.snap | 13 +------ tests/unit/date-in-schema.spec.ts | 13 +------ tests/unit/file-schema.spec.ts | 13 +------ 9 files changed, 18 insertions(+), 108 deletions(-) diff --git a/package.json b/package.json index 7b8ee1ecc..9747bf74d 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "prettier": "^3.1.0", "typescript": "^5.1.3", "vitest": "^1.0.4", - "zod": "^3.22.3" + "zod": "^3.23.0" }, "peerDependenciesMeta": { "@types/compression": { diff --git a/src/date-in-schema.ts b/src/date-in-schema.ts index 116758652..a2737e4bf 100644 --- a/src/date-in-schema.ts +++ b/src/date-in-schema.ts @@ -1,15 +1,15 @@ import { z } from "zod"; import { proprietary } from "./metadata"; -import { isValidDate, isoDateRegex } from "./schema-helpers"; +import { isValidDate } from "./schema-helpers"; export const ezDateInKind = "DateIn"; export const dateIn = () => { - const base = z.string(); - const hasDateMethod = base.date?.() instanceof z.ZodString; - const schema = hasDateMethod - ? z.union([base.date(), base.datetime(), base.datetime({ local: true })]) - : base.regex(isoDateRegex); // @todo remove after min zod v3.23 (v19) + const schema = z.union([ + z.string().date(), + z.string().datetime(), + z.string().datetime({ local: true }), + ]); return proprietary( ezDateInKind, diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 0acf86858..2dc40247b 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -64,7 +64,6 @@ import { } from "./logical-container"; import { Method } from "./method"; import { RawSchema, ezRawKind } from "./raw-schema"; -import { isoDateRegex } from "./schema-helpers"; import { HandlingRules, HandlingVariant, @@ -163,10 +162,7 @@ export const depictFile: Depicter = ({ schema }) => ({ type: "string", format: schema instanceof z.ZodString - ? schema._def.checks.find( - /** @todo remove regex check when min zod v3.23 (v19) */ - (check) => check.kind === "regex" || check.kind === "base64", - ) + ? schema._def.checks.find((check) => check.kind === "base64") ? "byte" : "file" : "binary", @@ -331,7 +327,7 @@ export const depictDateIn: Depicter = (ctx) => { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", format: "date-time", - pattern: isoDateRegex.source, + pattern: /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/.source, externalDocs: { url: isoDateDocumentationUrl, }, diff --git a/src/file-schema.ts b/src/file-schema.ts index e02500d8c..d79d88dfe 100644 --- a/src/file-schema.ts +++ b/src/file-schema.ts @@ -7,24 +7,11 @@ const bufferSchema = z.custom((subject) => Buffer.isBuffer(subject), { message: "Expected Buffer", }); -/** @todo remove after min zod v3.23 (v19) */ -const base64Regex = - /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; - const variants = { buffer: () => proprietary(ezFileKind, bufferSchema), string: () => proprietary(ezFileKind, z.string()), binary: () => proprietary(ezFileKind, bufferSchema.or(z.string())), - base64: () => { - const base = z.string(); - const hasBase64Method = base.base64?.() instanceof z.ZodString; - return proprietary( - ezFileKind, - hasBase64Method - ? base.base64() - : base.regex(base64Regex, "Does not match base64 encoding"), // @todo remove after min zod v3.23 (v19) - ); - }, + base64: () => proprietary(ezFileKind, z.string().base64()), }; type Variants = typeof variants; diff --git a/src/schema-helpers.ts b/src/schema-helpers.ts index 9a7aaf7b8..9d576ace7 100644 --- a/src/schema-helpers.ts +++ b/src/schema-helpers.ts @@ -1,12 +1 @@ export const isValidDate = (date: Date): boolean => !isNaN(date.getTime()); - -/** - * @example 2021-01-01T00:00:00.000Z - * @example 2021-01-01T00:00:00.0Z - * @example 2021-01-01T00:00:00Z - * @example 2021-01-01T00:00:00 - * @example 2021-01-01 - * @todo remove after min zod v3.23 (v19) - */ -export const isoDateRegex = - /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/; diff --git a/tests/unit/__snapshots__/date-in-schema.spec.ts.snap b/tests/unit/__snapshots__/date-in-schema.spec.ts.snap index a81fbe0c3..dcd1a3d09 100644 --- a/tests/unit/__snapshots__/date-in-schema.spec.ts.snap +++ b/tests/unit/__snapshots__/date-in-schema.spec.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ez.dateIn() current mode > parsing > should handle invalid date 1`] = ` +exports[`ez.dateIn() > parsing > should handle invalid date 1`] = ` [ { "code": "invalid_string", @@ -11,7 +11,7 @@ exports[`ez.dateIn() current mode > parsing > should handle invalid date 1`] = ` ] `; -exports[`ez.dateIn() current mode > parsing > should handle invalid format 1`] = ` +exports[`ez.dateIn() > parsing > should handle invalid format 1`] = ` [ { "code": "invalid_string", @@ -22,7 +22,7 @@ exports[`ez.dateIn() current mode > parsing > should handle invalid format 1`] = ] `; -exports[`ez.dateIn() current mode > parsing > should handle wrong parsed type 1`] = ` +exports[`ez.dateIn() > parsing > should handle wrong parsed type 1`] = ` [ { "code": "invalid_union", @@ -60,36 +60,3 @@ exports[`ez.dateIn() current mode > parsing > should handle wrong parsed type 1` }, ] `; - -exports[`ez.dateIn() legacy mode > parsing > should handle invalid date 1`] = ` -[ - { - "code": "invalid_date", - "message": "Invalid date", - "path": [], - }, -] -`; - -exports[`ez.dateIn() legacy mode > parsing > should handle invalid format 1`] = ` -[ - { - "code": "invalid_string", - "message": "Invalid", - "path": [], - "validation": "regex", - }, -] -`; - -exports[`ez.dateIn() legacy mode > parsing > should handle wrong parsed type 1`] = ` -[ - { - "code": "invalid_type", - "expected": "string", - "message": "Expected string, received number", - "path": [], - "received": "number", - }, -] -`; diff --git a/tests/unit/__snapshots__/file-schema.spec.ts.snap b/tests/unit/__snapshots__/file-schema.spec.ts.snap index 86fd2fccb..dc3971287 100644 --- a/tests/unit/__snapshots__/file-schema.spec.ts.snap +++ b/tests/unit/__snapshots__/file-schema.spec.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ez.file() current mode > parsing > should perform additional check for base64 file 1`] = ` +exports[`ez.file() > parsing > should perform additional check for base64 file 1`] = ` [ { "code": "invalid_string", @@ -10,14 +10,3 @@ exports[`ez.file() current mode > parsing > should perform additional check for }, ] `; - -exports[`ez.file() legacy mode > parsing > should perform additional check for base64 file 1`] = ` -[ - { - "code": "invalid_string", - "message": "Does not match base64 encoding", - "path": [], - "validation": "regex", - }, -] -`; diff --git a/tests/unit/date-in-schema.spec.ts b/tests/unit/date-in-schema.spec.ts index 6da35f11e..578f81e5b 100644 --- a/tests/unit/date-in-schema.spec.ts +++ b/tests/unit/date-in-schema.spec.ts @@ -1,18 +1,9 @@ import { z } from "zod"; import { getMeta } from "../../src/metadata"; import { ez } from "../../src"; -import { beforeAll, describe, expect, test, vi } from "vitest"; - -describe.each(["current", "legacy"])("ez.dateIn() %s mode", (mode) => { - // @todo remove after min zod v3.23 (v19) - beforeAll(() => { - if (mode === "legacy") { - vi.spyOn(z.ZodString.prototype, "date").mockImplementation( - () => null as unknown as z.ZodString, - ); - } - }); +import { describe, expect, test } from "vitest"; +describe("ez.dateIn()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateIn(); diff --git a/tests/unit/file-schema.spec.ts b/tests/unit/file-schema.spec.ts index 990a903e1..72c4ba036 100644 --- a/tests/unit/file-schema.spec.ts +++ b/tests/unit/file-schema.spec.ts @@ -3,18 +3,9 @@ import { z } from "zod"; import { getMeta } from "../../src/metadata"; import { ez } from "../../src"; import { readFile } from "node:fs/promises"; -import { beforeAll, describe, expect, test, vi } from "vitest"; - -describe.each(["current", "legacy"])("ez.file() %s mode", (mode) => { - // @todo remove after min zod v3.23 (v19) - beforeAll(() => { - if (mode === "legacy") { - vi.spyOn(z.ZodString.prototype, "base64").mockImplementation( - () => null as unknown as z.ZodString, - ); - } - }); +import { describe, expect, test } from "vitest"; +describe("ez.file()", () => { describe("creation", () => { test("should create an instance being string by default", () => { const schema = ez.file(); From addfdaed6b6f27a608a09de6eec1dfd43978494e Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 22 Apr 2024 09:12:40 +0200 Subject: [PATCH 03/40] Dedication: for Dime. --- 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 87f2c5ac2..362261c36 100644 --- a/src/startup-logo.ts +++ b/src/startup-logo.ts @@ -10,7 +10,7 @@ export const getStartupLogo = () => { const thanks = italic( "Thank you for choosing Express Zod API for your project.".padStart(132), ); - const dedicationMessage = italic("for Victoria".padEnd(20)); + const dedicationMessage = italic("for Dime".padEnd(20)); const pink = hex("#F5A9B8"); const blue = hex("#5BCEFA"); From c08f7b6910d14f48099f1daab87f522716cd3117 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 25 Apr 2024 22:03:32 +0200 Subject: [PATCH 04/40] Drop Node below 18.18 (#1705) --- .github/workflows/node.js.yml | 10 ++-------- package.json | 2 +- tests/system/example.spec.ts | 7 ++----- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index e4a34438b..9b31ff2e3 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.0.0, 18.x, 20.0.0, 20.x, 22.0.0, 22.x] + node-version: [18.18.0, 18.x, 20.0.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 @@ -41,13 +41,7 @@ jobs: timeout_seconds: 15 max_attempts: 3 on_retry_command: yarn config set registry https://registry.npmjs.org - # todo use regular "yarn install" when min Node version increased to 18.18 - # @typescript/eslint group compatibility issue fixed by ignoring engines for dev dependencies only: - command: | - npm pkg delete devDependencies - yarn install - git checkout -- . - yarn install --ignore-engines + command: yarn install - name: Lint run: yarn lint - name: Test diff --git a/package.json b/package.json index 95cb1e3e3..a0de043f5 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "*.md" ], "engines": { - "node": "^18.0.0 || ^20.0.0 || ^22.0.0" + "node": "^18.18.0 || ^20.0.0 || ^22.0.0" }, "dependencies": { "ansis": "^3.1.0", diff --git a/tests/system/example.spec.ts b/tests/system/example.spec.ts index 55afc030a..e6b4b48b4 100644 --- a/tests/system/example.spec.ts +++ b/tests/system/example.spec.ts @@ -196,7 +196,7 @@ describe("Example", async () => { const data = new FormData(); data.append( "avatar", - new Blob([logo], { type: "image/svg+xml" }), // FormData mime is buggy in Node 18.0.0 + new Blob([logo], { type: "image/svg+xml" }), filename, ); data.append("str", "test string value"); @@ -212,10 +212,7 @@ describe("Example", async () => { expect(json).toEqual({ data: { hash: "f39beeff92379dc935586d726211c2620be6f879", - mime: - process.versions.node === "18.0.0" - ? "application/octet-stream" // Node 18.0.0 FormData bug // @todo remove it when dropped - : "image/svg+xml", + mime: "image/svg+xml", name: "logo.svg", otherInputs: { arr: ["456", "789"], From 839668a7db755ca3194a2aa3e3a9218e088fe9e5 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 25 Apr 2024 22:08:02 +0200 Subject: [PATCH 05/40] Changelog: 19.0.0 draft. --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 451cbf2d0..c75d5f5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Version 19 + +### v19.0.0 + +- Minimum supported versions: + - Node: 18.18.0, + - `zod`: 3.23.0. + ## Version 18 ### v18.3.0 From f5178d61d64078dbe9e61d87ac67f6e5be5d1b5c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 27 Apr 2024 12:00:49 +0200 Subject: [PATCH 06/40] Removing condition on compat test for jest in v19 (#1707) Always compatible now --- .github/workflows/node.js.yml | 8 -------- tests/compat/package.json | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 9b31ff2e3..c0731a394 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -69,15 +69,7 @@ jobs: max_attempts: 3 on_retry_command: yarn config set registry https://registry.npmjs.org command: yarn test:esm - - name: Check Jest 30 compatibility - uses: madhead/semver-utils@v4 - id: jest30compat - with: - version: ${{ steps.setup-node.outputs.node-version }} - satisfies: '>=18.12.0' - lenient: false # require to parse or fail - name: Compatibility test - if: steps.jest30compat.outputs.satisfies == 'true' uses: nick-fields/retry@v3 with: timeout_seconds: 15 diff --git a/tests/compat/package.json b/tests/compat/package.json index 7dc327ae3..37ac8950f 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ "devDependencies": { "jest": "^30.0.0-alpha.3", "@types/jest": "^29.5.12", - "@swc/core": "^1.3.100", + "@swc/core": "1.5.0", "@swc/jest": "^0.2.29" } } From 9d2e3dca1d21630ed427959bf1f0e4bd3d74cc38 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 27 Apr 2024 12:17:05 +0200 Subject: [PATCH 07/40] Drop Node 20 below 20.9.0 (#1708) This will be most likely required for other dependencies, like #1631 --- .github/workflows/node.js.yml | 2 +- CHANGELOG.md | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index c0731a394..442bc8c57 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.0.0, 20.x, 22.0.0, 22.x] + node-version: [18.18.0, 18.x, 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/CHANGELOG.md b/CHANGELOG.md index c75d5f5f7..4dcee4a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### v19.0.0 - Minimum supported versions: - - Node: 18.18.0, + - Node: 18.18.0 or 20.9.0, - `zod`: 3.23.0. ## Version 18 diff --git a/package.json b/package.json index a0de043f5..5677f3c8a 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "*.md" ], "engines": { - "node": "^18.18.0 || ^20.0.0 || ^22.0.0" + "node": "^18.18.0 || ^20.9.0 || ^22.0.0" }, "dependencies": { "ansis": "^3.1.0", From 90ec6411d3d97edeb536bde00434aea3e3a3a996 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 30 Apr 2024 22:34:39 +0200 Subject: [PATCH 08/40] Rev: fixed swc code in compat test --- tests/compat/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/compat/package.json b/tests/compat/package.json index 37ac8950f..ca9951e1f 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ "devDependencies": { "jest": "^30.0.0-alpha.3", "@types/jest": "^29.5.12", - "@swc/core": "1.5.0", + "@swc/core": "^1.5.0", "@swc/jest": "^0.2.29" } } From 7315c8332b28a0a82015e04df4304884ea6c32bf Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 30 Apr 2024 22:39:33 +0200 Subject: [PATCH 09/40] Cancel rev: fixed swc core in compat test --- tests/compat/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/compat/package.json b/tests/compat/package.json index ca9951e1f..37ac8950f 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ "devDependencies": { "jest": "^30.0.0-alpha.3", "@types/jest": "^29.5.12", - "@swc/core": "^1.5.0", + "@swc/core": "1.5.0", "@swc/jest": "^0.2.29" } } From f66de3f0607cea2af0f4564a0fdf50e9d0d866e6 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 4 May 2024 13:42:03 +0200 Subject: [PATCH 10/40] Removing the deprecated withMeta() (#1726) Due to #1719 --- CHANGELOG.md | 2 ++ src/index.ts | 1 - src/metadata.ts | 6 ------ tests/unit/__snapshots__/index.spec.ts.snap | 3 --- tests/unit/metadata.spec.ts | 11 ----------- 5 files changed, 2 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb24c3b66..cf26497a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Minimum supported versions: - Node: 18.18.0 or 20.9.0, - `zod`: 3.23.0. +- The deprecated ~~`withMeta()`~~ is removed: + - See the changes to [v18.5.0](#v1850) on details. ## Version 18 diff --git a/src/index.ts b/src/index.ts index c8a143655..950a1b923 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,6 @@ export { InputValidationError, MissingPeerError, } from "./errors"; -export { withMeta } from "./metadata"; export { testEndpoint } from "./testing"; export { Integration } from "./integration"; diff --git a/src/metadata.ts b/src/metadata.ts index f3a08f4f5..e83000022 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -115,9 +115,3 @@ export const proprietary = ( export const isProprietary = (schema: z.ZodTypeAny, kind: ProprietaryKind) => getMeta(schema, "kind") === kind; - -/** - * @deprecated no longer required - * @todo remove in v19 - * */ -export const withMeta = (schema: T) => schema; diff --git a/tests/unit/__snapshots__/index.spec.ts.snap b/tests/unit/__snapshots__/index.spec.ts.snap index fac2b0574..fed50bd21 100644 --- a/tests/unit/__snapshots__/index.spec.ts.snap +++ b/tests/unit/__snapshots__/index.spec.ts.snap @@ -115,7 +115,6 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = "OutputValidationError", "InputValidationError", "MissingPeerError", - "withMeta", "testEndpoint", "Integration", "ez", @@ -123,5 +122,3 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = `; exports[`Index Entrypoint > exports > testEndpoint should have certain value 1`] = `[Function]`; - -exports[`Index Entrypoint > exports > withMeta should have certain value 1`] = `[Function]`; diff --git a/tests/unit/metadata.spec.ts b/tests/unit/metadata.spec.ts index 33a6cc824..8d372bcfd 100644 --- a/tests/unit/metadata.spec.ts +++ b/tests/unit/metadata.spec.ts @@ -1,19 +1,8 @@ -import { expectType } from "tsd"; import { z } from "zod"; -import { withMeta } from "../../src"; import { copyMeta, getMeta, hasMeta, metaSymbol } from "../../src/metadata"; import { describe, expect, test } from "vitest"; describe("Metadata", () => { - describe("withMeta()", () => { - test("should be present for backward compatibility", () => { - const schema = z.string(); - const schemaWithMeta = withMeta(schema); - expect(schemaWithMeta).toEqual(schema); - expectType(schemaWithMeta); - }); - }); - describe(".example()", () => { test("should be present", () => { const schema = z.string(); From 3a29e71b625725c54d8332b0aa264897be6cb514 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 4 May 2024 21:35:04 +0200 Subject: [PATCH 11/40] Security: planning for June, deprecating v15 --- SECURITY.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 4dd050f73..5b4bc8b42 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,10 +4,11 @@ | Version | Release | Supported | | ------: | :------ | :----------------: | +| 19.x.x | 06.2024 | :white_check_mark: | | 18.x.x | 04.2024 | :white_check_mark: | | 17.x.x | 02.2024 | :white_check_mark: | | 16.x.x | 12.2023 | :white_check_mark: | -| 15.x.x | 12.2023 | :white_check_mark: | +| 15.x.x | 12.2023 | :x: | | 14.x.x | 10.2023 | :x: | | 12.x.x | 09.2023 | :x: | | 11.x.x | 06.2023 | :x: | From 2e0c2e1697eb230166202f2464e7f762d2cd0fdc Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 7 May 2024 11:58:12 +0200 Subject: [PATCH 12/40] Fix: freezing arrays returned by public methods (#1736) This is a fix that prevents changing arrays after they have been assigned upon building an Endpoint, however, it could be potentially breaking, therefore I'm addressing it to v19. --- CHANGELOG.md | 3 ++ src/depends-on-method.ts | 16 +++++--- src/documentation-helpers.ts | 2 +- src/documentation.ts | 2 +- src/endpoint.ts | 77 +++++++++++++++++++++--------------- src/integration.ts | 2 +- src/routing-walker.ts | 2 +- 7 files changed, 63 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f16906351..cbdba6c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ - `zod`: 3.23.0. - The deprecated ~~`withMeta()`~~ is removed: - See the changes to [v18.5.0](#v1850) on details. +- Several public methods and properties exposing arrays from class instances made readonly and frozen: + - On `Endpoint`: `.getMethods()`, `.getMimeTypes()`, `.getResponses()`, `.getScopes()`, `.getTags()`, + - On `DependsOnMethod`: `.pairs`, `.siblingMethods`. ## Version 18 diff --git a/src/depends-on-method.ts b/src/depends-on-method.ts index c0d9a2ed5..07f51da19 100644 --- a/src/depends-on-method.ts +++ b/src/depends-on-method.ts @@ -3,16 +3,20 @@ import { AbstractEndpoint } from "./endpoint"; import { Method } from "./method"; export class DependsOnMethod { - public readonly pairs: [Method, AbstractEndpoint][]; + public readonly pairs: ReadonlyArray<[Method, AbstractEndpoint]>; public readonly firstEndpoint: AbstractEndpoint | undefined; - public readonly siblingMethods: Method[]; + public readonly siblingMethods: ReadonlyArray; constructor(endpoints: Partial>) { - this.pairs = toPairs(endpoints).filter( - (pair): pair is [Method, AbstractEndpoint] => - pair !== undefined && pair[1] !== undefined, + this.pairs = Object.freeze( + toPairs(endpoints).filter( + (pair): pair is [Method, AbstractEndpoint] => + pair !== undefined && pair[1] !== undefined, + ), ); this.firstEndpoint = head(this.pairs)?.[1]; - this.siblingMethods = tail(this.pairs).map(([method]) => method); + this.siblingMethods = Object.freeze( + tail(this.pairs).map(([method]) => method), + ); } } diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 6d89b1700..4c7a2a73d 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -99,7 +99,7 @@ interface ReqResDepictHelperCommonProps "serializer" | "getRef" | "makeRef" | "path" | "method" > { schema: z.ZodTypeAny; - mimeTypes: string[]; + mimeTypes: ReadonlyArray; composition: "inline" | "components"; description?: string; } diff --git a/src/documentation.ts b/src/documentation.ts index 620afc8ae..29d343a47 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -229,7 +229,7 @@ export class Documentation extends OpenApiBuilder { const scopes = ["oauth2", "openIdConnect"].includes( securitySchema.type, ) - ? endpoint.getScopes() + ? endpoint.getScopes().slice() : []; this.addSecurityScheme(name, securitySchema); return { name, scopes }; diff --git a/src/endpoint.ts b/src/endpoint.ts index 04b7038ea..1fb73d6db 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -47,19 +47,21 @@ export abstract class AbstractEndpoint { response: Response; logger: AbstractLogger; config: CommonConfig; - siblingMethods?: Method[]; + siblingMethods?: ReadonlyArray; }): Promise; public abstract getDescription( variant: DescriptionVariant, ): string | undefined; - public abstract getMethods(): Method[]; + public abstract getMethods(): ReadonlyArray; public abstract getSchema(variant: IOVariant): IOSchema; public abstract getSchema(variant: ResponseVariant): z.ZodTypeAny; - public abstract getMimeTypes(variant: MimeVariant): string[]; - public abstract getResponses(variant: ResponseVariant): NormalizedResponse[]; + public abstract getMimeTypes(variant: MimeVariant): ReadonlyArray; + public abstract getResponses( + variant: ResponseVariant, + ): ReadonlyArray; public abstract getSecurity(): LogicalContainer; - public abstract getScopes(): string[]; - public abstract getTags(): string[]; + public abstract getScopes(): ReadonlyArray; + public abstract getTags(): ReadonlyArray; public abstract getOperationId(method: Method): string | undefined; } @@ -71,15 +73,18 @@ export class Endpoint< TAG extends string, > extends AbstractEndpoint { readonly #descriptions: Record; - readonly #methods: Method[]; + readonly #methods: ReadonlyArray; readonly #middlewares: AnyMiddlewareDef[]; - readonly #mimeTypes: Record; - readonly #responses: Record; + readonly #mimeTypes: Record>; + readonly #responses: Record< + ResponseVariant, + ReadonlyArray + >; readonly #handler: Handler, z.input, OPT>; readonly #resultHandler: AnyResultHandlerDefinition; readonly #schemas: { input: IN; output: OUT }; - readonly #scopes: SCO[]; - readonly #tags: TAG[]; + readonly #scopes: ReadonlyArray; + readonly #tags: ReadonlyArray; readonly #getOperationId: (method: Method) => string | undefined; constructor({ @@ -112,9 +117,9 @@ export class Endpoint< this.#resultHandler = resultHandler; this.#middlewares = middlewares; this.#getOperationId = getOperationId; - this.#methods = methods; - this.#scopes = scopes; - this.#tags = tags; + this.#methods = Object.freeze(methods); + this.#scopes = Object.freeze(scopes); + this.#tags = Object.freeze(tags); this.#descriptions = { long, short }; this.#schemas = { input: inputSchema, output: outputSchema }; for (const [variant, schema] of Object.entries(this.#schemas)) { @@ -126,14 +131,18 @@ export class Endpoint< ); } this.#responses = { - positive: normalizeApiResponse( - resultHandler.getPositiveResponse(outputSchema), - { mimeTypes: [mimeJson], statusCodes: [defaultStatusCodes.positive] }, + positive: Object.freeze( + normalizeApiResponse(resultHandler.getPositiveResponse(outputSchema), { + mimeTypes: [mimeJson], + statusCodes: [defaultStatusCodes.positive], + }), + ), + negative: Object.freeze( + normalizeApiResponse(resultHandler.getNegativeResponse(), { + mimeTypes: [mimeJson], + statusCodes: [defaultStatusCodes.negative], + }), ), - negative: normalizeApiResponse(resultHandler.getNegativeResponse(), { - mimeTypes: [mimeJson], - statusCodes: [defaultStatusCodes.negative], - }), }; for (const [variant, responses] of Object.entries(this.#responses)) { assert( @@ -144,13 +153,19 @@ export class Endpoint< ); } this.#mimeTypes = { - input: hasUpload(inputSchema) - ? [mimeMultipart] - : hasRaw(inputSchema) - ? [mimeRaw] - : [mimeJson], - positive: this.#responses.positive.flatMap(({ mimeTypes }) => mimeTypes), - negative: this.#responses.negative.flatMap(({ mimeTypes }) => mimeTypes), + input: Object.freeze( + hasUpload(inputSchema) + ? [mimeMultipart] + : hasRaw(inputSchema) + ? [mimeRaw] + : [mimeJson], + ), + positive: Object.freeze( + this.#responses.positive.flatMap(({ mimeTypes }) => mimeTypes), + ), + negative: Object.freeze( + this.#responses.negative.flatMap(({ mimeTypes }) => mimeTypes), + ), }; } @@ -158,7 +173,7 @@ export class Endpoint< return this.#descriptions[variant]; } - public override getMethods(): Method[] { + public override getMethods() { return this.#methods; } @@ -190,11 +205,11 @@ export class Endpoint< ); } - public override getScopes(): SCO[] { + public override getScopes() { return this.#scopes; } - public override getTags(): TAG[] { + public override getTags() { return this.#tags; } diff --git a/src/integration.ts b/src/integration.ts index 9fed5d7e6..45c5a1471 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -93,7 +93,7 @@ export class Integration { { method: Method; path: string }, Partial> & { isJson: boolean; - tags: string[]; + tags: ReadonlyArray; } >(); protected paths: string[] = []; diff --git a/src/routing-walker.ts b/src/routing-walker.ts index a67e633fa..715e32f14 100644 --- a/src/routing-walker.ts +++ b/src/routing-walker.ts @@ -12,7 +12,7 @@ export interface RoutingWalkerParams { endpoint: AbstractEndpoint, path: string, method: Method | AuxMethod, - siblingMethods?: Method[], + siblingMethods?: ReadonlyArray, ) => void; onStatic?: (path: string, handler: StaticHandler) => void; parentPath?: string; From 9cc3ec7164d2bd508004a0407b40eabf6ac87d02 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 8 May 2024 14:13:55 +0200 Subject: [PATCH 13/40] Feat: Selective parsers with child logger (#1741) Enabled by #1739 Addressing v19 This PR does the following changes: - connects only the selected parsers needed for particular endpoint depending on its expected request type (json, raw, upload); - provides those parsers and middlewares with a request-level (child) logger; - reverts #1733 --- CHANGELOG.md | 41 ++++++++++ src/config-type.ts | 20 +++-- src/routing.ts | 34 ++++---- src/server-helpers.ts | 100 +++++++++++++++++------ src/server.ts | 78 +++++++----------- src/testing.ts | 3 + tests/system/example.spec.ts | 2 +- tests/system/system.spec.ts | 7 +- tests/unit/routing.spec.ts | 13 ++- tests/unit/server-helpers.spec.ts | 129 ++++++++++++++++++++++++++---- tests/unit/server.spec.ts | 97 ++++++++++++---------- 11 files changed, 361 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d80ad776..adfc20e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,47 @@ - Several public methods and properties exposing arrays from class instances made readonly and frozen: - On `Endpoint`: `.getMethods()`, `.getMimeTypes()`, `.getResponses()`, `.getScopes()`, `.getTags()`, - On `DependsOnMethod`: `.pairs`, `.siblingMethods`. +- The config option `server.upload.beforeUpload` changed: + - The assigned function now accepts `request` instead of `app` and being called only for eligible requests; + - Restricting upload can be achieved now by throwing an error from within. +- Request logging now reflects the actual path requested rather than the configured route: + - It is also placed in front of parsing. +- Featuring selective parsers with child loggers: + - There are three types of endpoints depending on their input schema: those with upload, those with raw, and others; + - Depending on the type, only the parsers needed for certain endpoint are processed; + - This reverts changes on muting uploader logs related to non-eligible requests made in v18.5.2 (all eligible now). + +```ts +import createHttpError from "http-errors"; +import { createConfig } from "express-zod-api"; + +const before = createConfig({ + server: { + upload: { + beforeUpload: ({ app, logger }) => { + app.use((req, res, next) => { + if (req.is("multipart/form-data") && !canUpload(req)) { + return next(createHttpError(403, "Not authorized")); + } + next(); + }); + }, + }, + }, +}); + +const after = createConfig({ + server: { + upload: { + beforeUpload: ({ request, logger }) => { + if (!canUpload(request)) { + throw createHttpError(403, "Not authorized"); + } + }, + }, + }, +}); +``` ## Version 18 diff --git a/src/config-type.ts b/src/config-type.ts index af80d0313..ac95987b0 100644 --- a/src/config-type.ts +++ b/src/config-type.ts @@ -75,6 +75,11 @@ export interface CommonConfig { tags?: TagsConfig; } +type BeforeUpload = (params: { + request: Request; + logger: AbstractLogger; +}) => void | Promise; + type UploadOptions = Pick< fileUpload.Options, | "createParentPath" @@ -95,12 +100,11 @@ type UploadOptions = Pick< * */ limitError?: Error; /** - * @desc A code to execute before connecting the upload middleware. - * @desc It can be used to connect a middleware that restricts the ability to upload. + * @desc A handler to execute before uploading — it can be used for restrictions by throwing an error. * @default undefined - * @example ({ app }) => { app.use( ... ); } + * @example ({ request }) => { throw createHttpError(403, "Not authorized"); } * */ - beforeUpload?: AppExtension; + beforeUpload?: BeforeUpload; }; type CompressionOptions = Pick< @@ -108,7 +112,7 @@ type CompressionOptions = Pick< "threshold" | "level" | "strategy" | "chunkSize" | "memLevel" >; -type AppExtension = (params: { +type BeforeRouting = (params: { app: IRouter; logger: AbstractLogger; }) => void | Promise; @@ -127,13 +131,13 @@ export interface ServerConfig jsonParser?: RequestHandler; /** * @desc Enable or configure uploads handling. - * @default false + * @default undefined * @requires express-fileupload * */ upload?: boolean | UploadOptions; /** * @desc Enable or configure response compression. - * @default false + * @default undefined * @requires compression */ compression?: boolean | CompressionOptions; @@ -152,7 +156,7 @@ export interface ServerConfig * @default undefined * @example ({ app }) => { app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); } * */ - beforeRouting?: AppExtension; + beforeRouting?: BeforeRouting; }; /** @desc Enables HTTPS server as well. */ https?: { diff --git a/src/routing.ts b/src/routing.ts index edeb72b4d..72ff2e5db 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -1,43 +1,49 @@ -import { IRouter } from "express"; +import { IRouter, RequestHandler } from "express"; import { CommonConfig } from "./config-type"; +import { ContentType } from "./content-type"; import { DependsOnMethod } from "./depends-on-method"; import { AbstractEndpoint } from "./endpoint"; import { AbstractLogger } from "./logger"; +import { metaSymbol } from "./metadata"; import { walkRouting } from "./routing-walker"; import { ServeStatic } from "./serve-static"; +import { LocalResponse } from "./server-helpers"; export interface Routing { [SEGMENT: string]: Routing | DependsOnMethod | AbstractEndpoint | ServeStatic; } +export type Parsers = Record; + export const initRouting = ({ app, rootLogger, config, routing, + parsers, }: { app: IRouter; rootLogger: AbstractLogger; config: CommonConfig; routing: Routing; + parsers?: Parsers; }) => walkRouting({ routing, hasCors: !!config.cors, onEndpoint: (endpoint, path, method, siblingMethods) => { - app[method](path, async (request, response) => { - const logger = config.childLoggerProvider - ? await config.childLoggerProvider({ request, parent: rootLogger }) - : rootLogger; - logger.info(`${request.method}: ${path}`); - await endpoint.execute({ - request, - response, - logger, - config, - siblingMethods, - }); - }); + app[method]( + path, + ...(parsers?.[endpoint.getRequestType()] || []), + async (request, response: LocalResponse) => + endpoint.execute({ + request, + response, + logger: response.locals[metaSymbol]?.logger || rootLogger, + config, + siblingMethods, + }), + ); }, onStatic: (path, handler) => { app.use(path, handler); diff --git a/src/server-helpers.ts b/src/server-helpers.ts index eebfbcf63..479ef1d6a 100644 --- a/src/server-helpers.ts +++ b/src/server-helpers.ts @@ -1,7 +1,10 @@ +import type fileUpload from "express-fileupload"; +import { metaSymbol } from "./metadata"; +import { loadPeer } from "./peer-helpers"; import { AnyResultHandlerDefinition } from "./result-handler"; import { AbstractLogger } from "./logger"; -import { CommonConfig } from "./config-type"; -import { ErrorRequestHandler, RequestHandler } from "express"; +import { ServerConfig } from "./config-type"; +import { ErrorRequestHandler, RequestHandler, Response } from "express"; import createHttpError, { isHttpError } from "http-errors"; import { lastResortHandler } from "./last-resort"; import { ResultHandlerError } from "./errors"; @@ -10,16 +13,16 @@ import { makeErrorFromAnything } from "./common-helpers"; interface HandlerCreatorParams { errorHandler: AnyResultHandlerDefinition; rootLogger: AbstractLogger; - getChildLogger: CommonConfig["childLoggerProvider"]; } +export type LocalResponse = Response< + unknown, + { [metaSymbol]?: { logger: AbstractLogger } } +>; + export const createParserFailureHandler = - ({ - errorHandler, - rootLogger, - getChildLogger, - }: HandlerCreatorParams): ErrorRequestHandler => - async (error, request, response, next) => { + ({ errorHandler, rootLogger }: HandlerCreatorParams): ErrorRequestHandler => + async (error, request, response: LocalResponse, next) => { if (!error) { return next(); } @@ -32,26 +35,18 @@ export const createParserFailureHandler = input: null, output: null, options: {}, - logger: getChildLogger - ? await getChildLogger({ request, parent: rootLogger }) - : rootLogger, + logger: response.locals[metaSymbol]?.logger || rootLogger, }); }; export const createNotFoundHandler = - ({ - errorHandler, - getChildLogger, - rootLogger, - }: HandlerCreatorParams): RequestHandler => - async (request, response) => { + ({ errorHandler, rootLogger }: HandlerCreatorParams): RequestHandler => + async (request, response: LocalResponse) => { const error = createHttpError( 404, `Can not ${request.method} ${request.path}`, ); - const logger = getChildLogger - ? await getChildLogger({ request, parent: rootLogger }) - : rootLogger; + const logger = response.locals[metaSymbol]?.logger || rootLogger; try { errorHandler.handler({ request, @@ -86,9 +81,62 @@ export const createUploadFailueHandler = export const createUploadLogger = ( logger: AbstractLogger, ): Pick => ({ - log: (message, ...rest) => { - if (!/not eligible/.test(message)) { - logger.debug(message, ...rest); - } - }, + log: logger.debug.bind(logger), }); + +export const createUploadParsers = async ({ + rootLogger, + config, +}: { + rootLogger: AbstractLogger; + config: ServerConfig; +}): Promise => { + const uploader = await loadPeer("express-fileupload"); + const { limitError, beforeUpload, ...options } = { + ...(typeof config.server.upload === "object" && config.server.upload), + }; + const parsers: RequestHandler[] = []; + parsers.push(async (request, response: LocalResponse, next) => { + const logger = response.locals[metaSymbol]?.logger || rootLogger; + try { + await beforeUpload?.({ request, logger }); + } catch (error) { + return next(error); + } + uploader({ + ...options, + abortOnLimit: false, + parseNested: true, + logger: createUploadLogger(logger), + })(request, response, next); + }); + if (limitError) { + parsers.push(createUploadFailueHandler(limitError)); + } + return parsers; +}; + +export const moveRaw: RequestHandler = (req, {}, next) => { + if (Buffer.isBuffer(req.body)) { + req.body = { raw: req.body }; + } + next(); +}; + +/** @since v19 prints the actual path of the request, not a configured route */ +export const createLoggingMiddleware = + ({ + rootLogger, + config, + }: { + rootLogger: AbstractLogger; + config: ServerConfig; + }): RequestHandler => + async (request, response: LocalResponse, next) => { + const logger = config.childLoggerProvider + ? await config.childLoggerProvider({ request, parent: rootLogger }) + : rootLogger; + logger.info(`${request.method}: ${request.path}`); + response.locals[metaSymbol] = { logger }; + next(); + }; diff --git a/src/server.ts b/src/server.ts index 6c326616d..e64c3606b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,18 +1,18 @@ import express from "express"; import type compression from "compression"; -import type fileUpload from "express-fileupload"; import http from "node:http"; import https from "node:https"; import { AppConfig, CommonConfig, ServerConfig } from "./config-type"; -import { AbstractLogger, createLogger, isBuiltinLoggerConfig } from "./logger"; +import { createLogger, isBuiltinLoggerConfig } from "./logger"; import { loadPeer } from "./peer-helpers"; import { defaultResultHandler } from "./result-handler"; -import { Routing, initRouting } from "./routing"; +import { Parsers, Routing, initRouting } from "./routing"; import { + createLoggingMiddleware, createNotFoundHandler, createParserFailureHandler, - createUploadFailueHandler, - createUploadLogger, + createUploadParsers, + moveRaw, } from "./server-helpers"; import { getStartupLogo } from "./startup-logo"; @@ -20,16 +20,16 @@ const makeCommonEntities = (config: CommonConfig) => { if (config.startupLogo !== false) { console.log(getStartupLogo()); } - const rootLogger: AbstractLogger = isBuiltinLoggerConfig(config.logger) - ? createLogger(config.logger) - : config.logger; - rootLogger.debug("Running", process.env.TSUP_BUILD || "from sources"); - const errorHandler = config.errorHandler || defaultResultHandler; - const { childLoggerProvider: getChildLogger } = config; - const creatorParams = { errorHandler, rootLogger, getChildLogger }; - const notFoundHandler = createNotFoundHandler(creatorParams); - const parserFailureHandler = createParserFailureHandler(creatorParams); - return { rootLogger, errorHandler, notFoundHandler, parserFailureHandler }; + const commons = { + errorHandler: config.errorHandler || defaultResultHandler, + rootLogger: isBuiltinLoggerConfig(config.logger) + ? createLogger(config.logger) + : config.logger, + }; + commons.rootLogger.debug("Running", process.env.TSUP_BUILD || "from sources"); + const notFoundHandler = createNotFoundHandler(commons); + const parserFailureHandler = createParserFailureHandler(commons); + return { ...commons, notFoundHandler, parserFailureHandler }; }; export const attachRouting = (config: AppConfig, routing: Routing) => { @@ -40,6 +40,10 @@ export const attachRouting = (config: AppConfig, routing: Routing) => { export const createServer = async (config: ServerConfig, routing: Routing) => { const app = express().disable("x-powered-by"); + const { rootLogger, notFoundHandler, parserFailureHandler } = + makeCommonEntities(config); + app.use(createLoggingMiddleware({ rootLogger, config })); + if (config.server.compression) { const compressor = await loadPeer("compression"); app.use( @@ -50,46 +54,20 @@ export const createServer = async (config: ServerConfig, routing: Routing) => { ), ); } - app.use(config.server.jsonParser || express.json()); - const { rootLogger, notFoundHandler, parserFailureHandler } = - makeCommonEntities(config); + const parsers: Parsers = { + json: [config.server.jsonParser || express.json()], + raw: config.server.rawParser ? [config.server.rawParser, moveRaw] : [], + upload: config.server.upload + ? await createUploadParsers({ config, rootLogger }) + : [], + }; - if (config.server.upload) { - const uploader = await loadPeer("express-fileupload"); - const { limitError, beforeUpload, ...derivedConfig } = { - ...(typeof config.server.upload === "object" && config.server.upload), - }; - if (beforeUpload) { - beforeUpload({ app, logger: rootLogger }); - } - app.use( - uploader({ - ...derivedConfig, - abortOnLimit: false, - parseNested: true, - logger: createUploadLogger(rootLogger), - }), - ); - if (limitError) { - app.use(createUploadFailueHandler(limitError)); - } - } - if (config.server.rawParser) { - app.use(config.server.rawParser); - app.use((req, {}, next) => { - if (Buffer.isBuffer(req.body)) { - req.body = { raw: req.body }; - } - next(); - }); - } - app.use(parserFailureHandler); if (config.server.beforeRouting) { await config.server.beforeRouting({ app, logger: rootLogger }); } - initRouting({ app, routing, rootLogger, config }); - app.use(notFoundHandler); + initRouting({ app, routing, rootLogger, config, parsers }); + app.use(parserFailureHandler, notFoundHandler); const starter = ( server: T, diff --git a/src/testing.ts b/src/testing.ts index 78dda1420..8c2aa40cb 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -5,6 +5,7 @@ import { AbstractEndpoint } from "./endpoint"; import { AbstractLogger } from "./logger"; import { contentTypes } from "./content-type"; import { loadAlternativePeer } from "./peer-helpers"; +import { LocalResponse } from "./server-helpers"; /** * @desc Using module augmentation approach you can set the Mock type of your actual testing framework. @@ -54,11 +55,13 @@ export const makeResponseMock = >({ responseMock.writableEnded = true; return responseMock; }), + locals: {}, ...responseProps, } as { writableEnded: boolean; statusCode: number; statusMessage: string; + locals: LocalResponse["locals"]; } & Record< "set" | "setHeader" | "header" | "status" | "json" | "send" | "end", MockOverrides diff --git a/tests/system/example.spec.ts b/tests/system/example.spec.ts index e6b4b48b4..d336482e3 100644 --- a/tests/system/example.spec.ts +++ b/tests/system/example.spec.ts @@ -88,7 +88,7 @@ describe("Example", async () => { createdAt: "2022-01-22T00:00:00.000Z", }, }); - await waitFor(() => /v1\/user\/:id/.test(out)); + await waitFor(() => /v1\/user\/50/.test(out)); await waitFor(() => /50, 123, 456/.test(out)); expect(true).toBeTruthy(); }); diff --git a/tests/system/system.spec.ts b/tests/system/system.spec.ts index 118f8afb7..9cf18cbce 100644 --- a/tests/system/system.spec.ts +++ b/tests/system/system.spec.ts @@ -327,18 +327,19 @@ describe("App", async () => { test("Should fail on malformed body", async () => { const response = await fetch(`http://127.0.0.1:${port}/v1/test`, { - method: "PUT", + method: "POST", // valid method this time headers: { "Content-Type": "application/json", }, - body: '{"key": "123", "something', + body: '{"key": "123", "something', // no closing bracket }); expect(response.status).toBe(400); // Issue #907 const json = await response.json(); expect(json).toMatchSnapshot({ error: { message: expect.stringMatching( - // the 2nd option is for Node 19 + // @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)/, ), }, diff --git a/tests/unit/routing.spec.ts b/tests/unit/routing.spec.ts index fee81edbe..2cff5fb9a 100644 --- a/tests/unit/routing.spec.ts +++ b/tests/unit/routing.spec.ts @@ -1,3 +1,4 @@ +import { metaSymbol } from "../../src/metadata"; import { appMock, expressMock, @@ -382,7 +383,6 @@ describe("Routing", () => { ); expect(nextMock).toHaveBeenCalledTimes(0); expect(handlerMock).toHaveBeenCalledTimes(1); - expect(loggerMock.info).toHaveBeenCalledWith("POST: /v1/user/set"); expect(loggerMock.error).toHaveBeenCalledTimes(0); expect(handlerMock).toHaveBeenCalledWith({ input: { @@ -400,7 +400,7 @@ describe("Routing", () => { }); }); - test("should override the logger with a child logger if provider is specified in config", async () => { + test("should override the logger with a child logger if present in response.locals", async () => { const loggerMock = makeLoggerMock({ fnMethod: vi.fn }); const config: CommonConfig = { cors: false, @@ -425,7 +425,14 @@ describe("Routing", () => { expect(appMock.get).toHaveBeenCalledTimes(1); const routeHandler = appMock.get.mock.calls[0][1] as RequestHandler; const requestMock = makeRequestMock({ fnMethod: vi.fn }); - const responseMock = makeResponseMock({ fnMethod: vi.fn }); + const responseMock = makeResponseMock({ + fnMethod: vi.fn, + responseProps: { + locals: { + [metaSymbol]: { logger: { ...loggerMock, isChild: true } }, + }, + }, + }); await routeHandler( requestMock as unknown as Request, responseMock as unknown as Response, diff --git a/tests/unit/server-helpers.spec.ts b/tests/unit/server-helpers.spec.ts index ee29c7807..9d6a693ff 100644 --- a/tests/unit/server-helpers.spec.ts +++ b/tests/unit/server-helpers.spec.ts @@ -1,8 +1,12 @@ +import { fileUploadMock } from "../express-mock"; +import { metaSymbol } from "../../src/metadata"; import { createNotFoundHandler, createParserFailureHandler, createUploadFailueHandler, createUploadLogger, + createUploadParsers, + moveRaw, } from "../../src/server-helpers"; import { describe, expect, test, vi } from "vitest"; import { defaultResultHandler } from "../../src"; @@ -22,7 +26,6 @@ describe("Server helpers", () => { const handler = createParserFailureHandler({ errorHandler: defaultResultHandler, rootLogger, - getChildLogger: undefined, }); const next = vi.fn(); handler( @@ -36,15 +39,22 @@ describe("Server helpers", () => { test("the handler should call error handler with a child logger", async () => { const errorHandler = { ...defaultResultHandler, handler: vi.fn() }; + const rootLogger = makeLoggerMock({ fnMethod: vi.fn }); const handler = createParserFailureHandler({ errorHandler, - rootLogger: makeLoggerMock({ fnMethod: vi.fn }), - getChildLogger: ({ parent }) => ({ ...parent, isChild: true }), + rootLogger, }); await handler( new SyntaxError("Unexpected end of JSON input"), null as unknown as Request, - null as unknown as Response, + makeResponseMock({ + fnMethod: vi.fn, + responseProps: { + locals: { + [metaSymbol]: { logger: { ...rootLogger, isChild: true } }, + }, + }, + }) as unknown as Response, vi.fn(), ); expect(errorHandler.handler).toHaveBeenCalledTimes(1); @@ -64,10 +74,10 @@ describe("Server helpers", () => { ...defaultResultHandler, handler: vi.fn(), }; + const rootLogger = makeLoggerMock({ fnMethod: vi.fn }); const handler = createNotFoundHandler({ errorHandler, - rootLogger: makeLoggerMock({ fnMethod: vi.fn }), - getChildLogger: async ({ parent }) => ({ ...parent, isChild: true }), + rootLogger, }); const next = vi.fn(); const requestMock = makeRequestMock({ @@ -78,7 +88,14 @@ describe("Server helpers", () => { body: { n: 453 }, }, }); - const responseMock = makeResponseMock({ fnMethod: vi.fn }); + const responseMock = makeResponseMock({ + fnMethod: vi.fn, + responseProps: { + locals: { + [metaSymbol]: { logger: { ...rootLogger, isChild: true } }, + }, + } as unknown as Response, + }); await handler( requestMock as unknown as Request, responseMock as unknown as Response, @@ -114,7 +131,6 @@ describe("Server helpers", () => { const handler = createNotFoundHandler({ errorHandler, rootLogger, - getChildLogger: undefined, }); const next = vi.fn(); const requestMock = makeRequestMock({ @@ -169,22 +185,101 @@ describe("Server helpers", () => { }); }); - describe("createUploadLogger", () => { + describe("createUploadLogger()", () => { const rootLogger = makeLoggerMock({ fnMethod: vi.fn }); const uploadLogger = createUploadLogger(rootLogger); - test("should mute 'not eligible' message", () => { - uploadLogger.log( - "Express-file-upload: Request is not eligible for file upload!", - ); - expect(rootLogger.debug).not.toHaveBeenCalled(); - }); - - test("should debug other messages", () => { + test("should debug the messages", () => { uploadLogger.log("Express-file-upload: Busboy finished parsing request."); expect(rootLogger.debug).toHaveBeenCalledWith( "Express-file-upload: Busboy finished parsing request.", ); }); }); + + describe("createUploadParsers()", async () => { + const rootLogger = makeLoggerMock({ fnMethod: vi.fn }); + const beforeUploadMock = vi.fn(); + const parsers = await createUploadParsers({ + config: { + server: { + listen: 8090, + upload: { + limits: { fileSize: 1024 }, + limitError: new Error("Too heavy"), + beforeUpload: beforeUploadMock, + }, + }, + cors: false, + logger: rootLogger, + }, + rootLogger, + }); + const requestMock = makeRequestMock({ fnMethod: vi.fn }); + const responseMock = makeResponseMock({ fnMethod: vi.fn }); + const nextMock = vi.fn(); + + test("should return an array of RequestHandler", () => { + expect(parsers).toEqual([ + expect.any(Function), // uploader with logger + expect.any(Function), // createUploadFailueHandler() + ]); + }); + + test("should handle errors thrown by beforeUpload", async () => { + const error = createHttpError(403, "Not authorized"); + beforeUploadMock.mockImplementationOnce(() => { + throw error; + }); + await parsers[0]( + requestMock as unknown as Request, + responseMock as unknown as Response, + nextMock, + ); + expect(nextMock).toHaveBeenCalledWith(error); + }); + + test("should install the uploader with its special logger", async () => { + const interalMw = vi.fn(); + fileUploadMock.mockImplementationOnce(() => interalMw); + await parsers[0]( + requestMock as unknown as Request, + responseMock as unknown as Response, + nextMock, + ); + expect(beforeUploadMock).toHaveBeenCalledWith({ + request: requestMock, + logger: rootLogger, + }); + expect(fileUploadMock).toHaveBeenCalledTimes(1); + expect(fileUploadMock).toHaveBeenCalledWith({ + abortOnLimit: false, + parseNested: true, + limits: { fileSize: 1024 }, + logger: { log: expect.any(Function) }, // @see createUploadLogger test + }); + expect(interalMw).toHaveBeenCalledWith( + requestMock, + responseMock, + nextMock, + ); + }); + }); + + describe("moveRaw()", () => { + test("should place the body into the raw prop of the body object", () => { + const buffer = Buffer.from([]); + const requestMock = makeRequestMock({ + fnMethod: vi.fn, + requestProps: { + method: "POST", + body: buffer, + }, + }); + const nextMock = vi.fn(); + moveRaw(requestMock as unknown as Request, {} as Response, nextMock); + expect(requestMock.body).toEqual({ raw: buffer }); + expect(nextMock).toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/server.spec.ts b/tests/unit/server.spec.ts index fd5939f39..7ea0d041b 100644 --- a/tests/unit/server.spec.ts +++ b/tests/unit/server.spec.ts @@ -1,11 +1,10 @@ -import { makeRequestMock } from "../../src/testing"; +import { moveRaw } from "../../src/server-helpers"; import { givePort } from "../helpers"; import { appMock, compressionMock, expressJsonMock, expressMock, - fileUploadMock, } from "../express-mock"; import { createHttpsServerSpy, @@ -21,6 +20,7 @@ import { createLogger, createServer, defaultResultHandler, + ez, } from "../../src"; import express from "express"; import { afterAll, describe, expect, test, vi } from "vitest"; @@ -62,14 +62,25 @@ describe("Server", () => { await createServer(configMock, routingMock); expect(appMock).toBeTruthy(); expect(appMock.disable).toHaveBeenCalledWith("x-powered-by"); - expect(appMock.use).toHaveBeenCalledTimes(3); - expect(appMock.use.mock.calls[0][0]).toBe(expressJsonMock); + expect(appMock.use).toHaveBeenCalledTimes(2); expect(appMock.get).toHaveBeenCalledTimes(1); - expect(appMock.get.mock.calls[0][0]).toBe("/v1/test"); + expect(appMock.get).toHaveBeenCalledWith( + "/v1/test", + expressJsonMock, + expect.any(Function), // endpoint + ); expect(appMock.post).toHaveBeenCalledTimes(1); - expect(appMock.post.mock.calls[0][0]).toBe("/v1/test"); + expect(appMock.post).toHaveBeenCalledWith( + "/v1/test", + expressJsonMock, + expect.any(Function), // endpoint + ); expect(appMock.options).toHaveBeenCalledTimes(1); - expect(appMock.options.mock.calls[0][0]).toBe("/v1/test"); + expect(appMock.options).toHaveBeenCalledWith( + "/v1/test", + expressJsonMock, + expect.any(Function), // endpoint + ); expect(httpListenSpy).toHaveBeenCalledTimes(1); expect(httpListenSpy).toHaveBeenCalledWith(port, expect.any(Function)); }); @@ -112,8 +123,7 @@ describe("Server", () => { expect(logger).toEqual(customLogger); expect(app).toEqual(appMock); expect(appMock).toBeTruthy(); - expect(appMock.use).toHaveBeenCalledTimes(3); - expect(appMock.use.mock.calls[0][0]).toBe(configMock.server.jsonParser); + expect(appMock.use).toHaveBeenCalledTimes(2); expect(configMock.errorHandler.handler).toHaveBeenCalledTimes(0); expect(configMock.server.beforeRouting).toHaveBeenCalledWith({ app: appMock, @@ -122,11 +132,23 @@ describe("Server", () => { expect(infoMethod).toHaveBeenCalledTimes(1); expect(infoMethod).toHaveBeenCalledWith(`Listening`, { port }); expect(appMock.get).toHaveBeenCalledTimes(1); - expect(appMock.get.mock.calls[0][0]).toBe("/v1/test"); + expect(appMock.get).toHaveBeenCalledWith( + "/v1/test", + configMock.server.jsonParser, + expect.any(Function), // endpoint + ); expect(appMock.post).toHaveBeenCalledTimes(1); - expect(appMock.post.mock.calls[0][0]).toBe("/v1/test"); + expect(appMock.post).toHaveBeenCalledWith( + "/v1/test", + configMock.server.jsonParser, + expect.any(Function), // endpoint + ); expect(appMock.options).toHaveBeenCalledTimes(1); - expect(appMock.options.mock.calls[0][0]).toBe("/v1/test"); + expect(appMock.options).toHaveBeenCalledWith( + "/v1/test", + configMock.server.jsonParser, + expect.any(Function), // endpoint + ); expect(httpListenSpy).toHaveBeenCalledTimes(1); expect(httpListenSpy).toHaveBeenCalledWith( { port }, @@ -193,7 +215,7 @@ describe("Server", () => { }, }; await createServer(configMock, routingMock); - expect(appMock.use).toHaveBeenCalledTimes(4); + expect(appMock.use).toHaveBeenCalledTimes(3); expect(compressionMock).toHaveBeenCalledTimes(1); expect(compressionMock).toHaveBeenCalledWith(undefined); }); @@ -216,25 +238,23 @@ describe("Server", () => { v1: { test: new EndpointsFactory(defaultResultHandler).build({ method: "get", - input: z.object({}), + input: z.object({ + file: ez.upload(), + }), output: z.object({}), handler: vi.fn(), }), }, }; - const { logger } = await createServer(configMock, routingMock); - expect(appMock.use).toHaveBeenCalledTimes(5); - expect(configMock.server.upload.beforeUpload).toHaveBeenCalledWith({ - app: appMock, - logger, - }); - expect(fileUploadMock).toHaveBeenCalledTimes(1); - expect(fileUploadMock).toHaveBeenCalledWith({ - abortOnLimit: false, - parseNested: true, - limits: { fileSize: 1024 }, - logger: { log: expect.any(Function) }, - }); + await createServer(configMock, routingMock); + expect(appMock.use).toHaveBeenCalledTimes(2); + expect(appMock.get).toHaveBeenCalledTimes(1); + expect(appMock.get).toHaveBeenCalledWith( + "/v1/test", + expect.any(Function), // uploader with logger + expect.any(Function), // createUploadFailueHandler() + expect.any(Function), // endpoint + ); }); test("should enable raw on request", async () => { @@ -252,26 +272,21 @@ describe("Server", () => { v1: { test: new EndpointsFactory(defaultResultHandler).build({ method: "get", - input: z.object({}), + input: ez.raw(), output: z.object({}), handler: vi.fn(), }), }, }; await createServer(configMock, routingMock); - expect(appMock.use).toHaveBeenCalledTimes(5); - const rawPropMw = appMock.use.mock.calls[2][0]; // custom middleware for raw - expect(typeof rawPropMw).toBe("function"); - const buffer = Buffer.from([]); - const requestMock = makeRequestMock({ - fnMethod: vi.fn, - requestProps: { - method: "POST", - body: buffer, - }, - }); - rawPropMw(requestMock, {}, vi.fn()); - expect(requestMock.body).toEqual({ raw: buffer }); + expect(appMock.use).toHaveBeenCalledTimes(2); + expect(appMock.get).toHaveBeenCalledTimes(1); + expect(appMock.get).toHaveBeenCalledWith( + "/v1/test", + rawParserMock, + moveRaw, + expect.any(Function), // endpoint + ); }); }); From 5fbc249a00cd15d10902cb7d8f859d17eb890efa Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 8 May 2024 14:35:44 +0200 Subject: [PATCH 14/40] Changelog: structuring the notes for 19.0.0. --- CHANGELOG.md | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adfc20e42..4fe1cea82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,32 @@ ### v19.0.0 -- Minimum supported versions: - - Node: 18.18.0 or 20.9.0, - - `zod`: 3.23.0. -- The deprecated ~~`withMeta()`~~ is removed: - - See the changes to [v18.5.0](#v1850) on details. -- Several public methods and properties exposing arrays from class instances made readonly and frozen: - - On `Endpoint`: `.getMethods()`, `.getMimeTypes()`, `.getResponses()`, `.getScopes()`, `.getTags()`, - - On `DependsOnMethod`: `.pairs`, `.siblingMethods`. -- The config option `server.upload.beforeUpload` changed: - - The assigned function now accepts `request` instead of `app` and being called only for eligible requests; - - Restricting upload can be achieved now by throwing an error from within. -- Request logging now reflects the actual path requested rather than the configured route: - - It is also placed in front of parsing. -- Featuring selective parsers with child loggers: - - There are three types of endpoints depending on their input schema: those with upload, those with raw, and others; - - Depending on the type, only the parsers needed for certain endpoint are processed; - - This reverts changes on muting uploader logs related to non-eligible requests made in v18.5.2 (all eligible now). +- **Breaking changes**: + - Increased minimum supported versions: + - Node: 18.18.0 or 20.9.0; + - `zod`: 3.23.0. + - Removed the deprecated ~~`withMeta()`~~ is removed (see [v18.5.0](#v1850) for details); + - Freezed the arrays returned by the following methods or exposed by properties that supposed to be readonly: + - For `Endpoint` class: `getMethods()`, `getMimeTypes()`, `getResponses()`, `getScopes()`, `getTags()`; + - For `DependsOnMethod` class: `pairs`, `siblingMethods`. + - Changed the `ServerConfig` option `server.upload.beforeUpload`: + - The assigned function now accepts `request` instead of `app` and being called only for eligible requests; + - Restricting the upload can be achieved now by throwing an error from within. +- Features: + - Selective parsers equipped with a child logger: + - There are 3 types of endpoints depending on their input schema: having `ez.upload()`, having `ez.raw()`, others; + - Depending on that type, only the parsers needed for certain endpoint are processed; + - This makes all requests eligible for the assigned parsers and reverts changes made in [v18.5.2](#v1852). +- Non-breaking significant changes: + - Request logging reflects the actual path instead of the configured route, and it's placed in front of parsing. +- How to migrate confidently: + - Upgrade Node to latest version of 18.x, 20.x or 22.x; + - Upgrade `zod` to its latest version of 3.x; + - Avoid mutating the readonly arrays; + - If you're using ~~`withMeta()`~~: + - Remove it and unwrap your schemas — you can use `.example()` method directly. + - If you're using `beforeUpload` in your config: + - Adjust the implementation according to the example below. ```ts import createHttpError from "http-errors"; From 1a146faa9fd45243f074d1094c720ddbc6574f4d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 8 May 2024 14:39:39 +0200 Subject: [PATCH 15/40] Readme: adjusting the beforeUpload example. --- README.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ebc2b01d7..1caa05d2e 100644 --- a/README.md +++ b/README.md @@ -805,13 +805,10 @@ const config = createConfig({ upload: { limits: { fileSize: 51200 }, // 50 KB limitError: createHttpError(413, "The file is too large"), // handled by errorHandler in config - beforeUpload: ({ app, logger }) => { - app.use((req, res, next) => { - if (req.is("multipart/form-data") && !canUpload(req)) { - return next(createHttpError(403, "Not authorized")); - } - next(); - }); + beforeUpload: ({ request, logger }) => { + if (!canUpload(request)) { + throw createHttpError(403, "Not authorized"); + } }, }, }, From 4ef6ba6c402e9d07d4a82467107dc934f636dc6f Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 9 May 2024 16:13:23 +0200 Subject: [PATCH 16/40] Complete `zod` plugin with proprietary brands (#1730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Due to #1721, https://github.com/colinhacks/zod/pull/2860#issuecomment-2092003453, and #1719 I'm exploring the possibility to alter the behaviour of `.brand()` for storing the `brand` property as a way to distinguish the proprietary schemas in runtime. This can replace the `proprietary()` function with a call of `.brand()`. However, so far it turned out to be breaking because `ZodBranded` does not expose the methods of the wrapped schema, such as `.extend()`, which is used in `accept-raw.ts` endpoint for possible route params. —— After a series of serious considerations I realized that exposing brand to consumers of `express-zod-api` could be a beneficial feature. Runtime brand can be accessed via `._def[Symbol.for("express-zod-api")].brand` --- CHANGELOG.md | 16 ++++ README.md | 8 +- example/endpoints/accept-raw.ts | 7 +- package.json | 1 + src/common-helpers.ts | 4 +- src/config-type.ts | 1 + src/date-in-schema.ts | 13 +-- src/date-out-schema.ts | 17 ++-- src/deep-checks.ts | 10 +-- src/documentation-helpers.ts | 56 ++++++------ src/file-schema.ts | 13 +-- src/index.ts | 2 + src/io-schema.ts | 4 +- src/metadata.ts | 86 +------------------ src/proprietary-schemas.ts | 22 ++--- src/raw-schema.ts | 10 ++- src/schema-walker.ts | 11 +-- src/upload-schema.ts | 14 +-- src/zod-plugin.ts | 81 +++++++++++++++++ src/zts.ts | 26 +++--- tests/helpers.ts | 2 + .../documentation-helpers.spec.ts.snap | 12 +++ tests/unit/checks.spec.ts | 6 +- tests/unit/date-in-schema.spec.ts | 7 +- tests/unit/date-out-schema.spec.ts | 7 +- tests/unit/documentation-helpers.spec.ts | 6 ++ tests/unit/file-schema.spec.ts | 15 ++-- tests/unit/io-schema.spec.ts | 10 ++- tests/unit/metadata.spec.ts | 62 +++---------- tests/unit/upload-schema.spec.ts | 7 +- tsconfig.json | 2 +- vitest.config.ts | 3 + vitest.setup.ts | 2 + 33 files changed, 290 insertions(+), 253 deletions(-) create mode 100644 src/zod-plugin.ts create mode 100644 vitest.setup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe1cea82..0574bd19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Changed the `ServerConfig` option `server.upload.beforeUpload`: - The assigned function now accepts `request` instead of `app` and being called only for eligible requests; - Restricting the upload can be achieved now by throwing an error from within. + - Changed interface for `ez.raw()`: additional properties should be supplied as its argument, not via `.extend()`. - Features: - Selective parsers equipped with a child logger: - There are 3 types of endpoints depending on their input schema: having `ez.upload()`, having `ez.raw()`, others; @@ -28,6 +29,8 @@ - Avoid mutating the readonly arrays; - If you're using ~~`withMeta()`~~: - Remove it and unwrap your schemas — you can use `.example()` method directly. + - If you're using `ez.raw().extend()` for additional properties: + - Supply them directly as an argument to `ez.raw()` — see the example below. - If you're using `beforeUpload` in your config: - Adjust the implementation according to the example below. @@ -63,6 +66,19 @@ const after = createConfig({ }); ``` +```ts +import { z } from "zod"; +import { ez } from "express-zod-api"; + +const before = ez.raw().extend({ + pathParameter: z.string(), +}); + +const after = ez.raw({ + pathParameter: z.string(), +}); +``` + ## Version 18 ### v18.5.2 diff --git a/README.md b/README.md index 1caa05d2e..fd11de7e6 100644 --- a/README.md +++ b/README.md @@ -1001,7 +1001,7 @@ Some APIs may require an endpoint to be able to accept and process raw data, suc file as an entire body of request. In order to enable this feature you need to set the `rawParser` config feature to `express.raw()`. See also its options [in Express.js documentation](https://expressjs.com/en/4x/api.html#express.raw). The raw data is placed into `request.body.raw` property, having type `Buffer`. Then use the proprietary `ez.raw()` -schema (which is an alias for `z.object({ raw: ez.file("buffer") })`) as the input schema of your endpoint. +schema as the input schema of your endpoint. ```typescript import express from "express"; @@ -1015,9 +1015,9 @@ const config = createConfig({ const rawAcceptingEndpoint = defaultEndpointsFactory.build({ method: "post", - input: ez - .raw() // accepts the featured { raw: Buffer } - .extend({}), // for additional inputs, like route params, if needed + input: ez.raw({ + /* the place for additional inputs, like route params, if needed */ + }), output: z.object({ length: z.number().int().nonnegative() }), handler: async ({ input: { raw } }) => ({ length: raw.length, // raw is Buffer diff --git a/example/endpoints/accept-raw.ts b/example/endpoints/accept-raw.ts index a6eb3faa7..b97dc4b32 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -5,9 +5,10 @@ import { taggedEndpointsFactory } from "../factories"; export const rawAcceptingEndpoint = taggedEndpointsFactory.build({ method: "post", tag: "files", - input: ez - .raw() // requires to enable rawParser option in server config - .extend({}), // additional inputs, route params for example, if needed + // requires to enable rawParser option in server config: + input: ez.raw({ + /* the place for additional inputs, like route params, if needed */ + }), output: z.object({ length: z.number().int().nonnegative() }), handler: async ({ input: { raw } }) => ({ length: raw.length, // input.raw is populated automatically when rawParser is set in config diff --git a/package.json b/package.json index d278c0d6a..c0a07f9e8 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "install_hooks": "husky" }, "type": "module", + "sideEffects": true, "main": "dist/index.cjs", "types": "dist/index.d.ts", "module": "dist/index.js", diff --git a/src/common-helpers.ts b/src/common-helpers.ts index 005512b7b..51d2e3b53 100644 --- a/src/common-helpers.ts +++ b/src/common-helpers.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { CommonConfig, InputSource, InputSources } from "./config-type"; import { InputValidationError, OutputValidationError } from "./errors"; import { AbstractLogger } from "./logger"; -import { getMeta } from "./metadata"; +import { metaSymbol } from "./metadata"; import { AuxMethod, Method } from "./method"; import { contentTypes } from "./content-type"; @@ -130,7 +130,7 @@ export const getExamples = < * */ validate?: boolean; }): ReadonlyArray : z.input> => { - const examples = getMeta(schema, "examples") || []; + const examples = schema._def[metaSymbol]?.examples || []; if (!validate && variant === "original") { return examples; } diff --git a/src/config-type.ts b/src/config-type.ts index ac95987b0..90e00aaf1 100644 --- a/src/config-type.ts +++ b/src/config-type.ts @@ -146,6 +146,7 @@ export interface ServerConfig * @desc When enabled, use ez.raw() as input schema to get input.raw in Endpoint's handler * @default undefined * @example express.raw() + * @todo this can be now automatic * @link https://expressjs.com/en/4x/api.html#express.raw * */ rawParser?: RequestHandler; diff --git a/src/date-in-schema.ts b/src/date-in-schema.ts index a2737e4bf..68e3c34e4 100644 --- a/src/date-in-schema.ts +++ b/src/date-in-schema.ts @@ -1,8 +1,7 @@ import { z } from "zod"; -import { proprietary } from "./metadata"; import { isValidDate } from "./schema-helpers"; -export const ezDateInKind = "DateIn"; +export const ezDateInBrand = Symbol("DateIn"); export const dateIn = () => { const schema = z.union([ @@ -11,8 +10,10 @@ export const dateIn = () => { z.string().datetime({ local: true }), ]); - return proprietary( - ezDateInKind, - schema.transform((str) => new Date(str)).pipe(z.date().refine(isValidDate)), - ); + return schema + .transform((str) => new Date(str)) + .pipe(z.date().refine(isValidDate)) + .brand(ezDateInBrand); }; + +export type DateInSchema = ReturnType; diff --git a/src/date-out-schema.ts b/src/date-out-schema.ts index 6b832aab4..466e88a77 100644 --- a/src/date-out-schema.ts +++ b/src/date-out-schema.ts @@ -1,14 +1,13 @@ import { z } from "zod"; -import { proprietary } from "./metadata"; import { isValidDate } from "./schema-helpers"; -export const ezDateOutKind = "DateOut"; +export const ezDateOutBrand = Symbol("DateOut"); export const dateOut = () => - proprietary( - ezDateOutKind, - z - .date() - .refine(isValidDate) - .transform((date) => date.toISOString()), - ); + z + .date() + .refine(isValidDate) + .transform((date) => date.toISOString()) + .brand(ezDateOutBrand); + +export type DateOutSchema = ReturnType; diff --git a/src/deep-checks.ts b/src/deep-checks.ts index 4f97f572a..9438313ab 100644 --- a/src/deep-checks.ts +++ b/src/deep-checks.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { IOSchema } from "./io-schema"; -import { isProprietary } from "./metadata"; -import { ezRawKind } from "./raw-schema"; +import { metaSymbol } from "./metadata"; +import { ezRawBrand } from "./raw-schema"; import { HandlingRules, SchemaHandler } from "./schema-walker"; -import { ezUploadKind } from "./upload-schema"; +import { ezUploadBrand } from "./upload-schema"; /** @desc Check is a schema handling rule returning boolean */ type Check = SchemaHandler; @@ -95,12 +95,12 @@ export const hasTransformationOnTop = (subject: IOSchema): boolean => export const hasUpload = (subject: IOSchema) => hasNestedSchema({ subject, - condition: (schema) => isProprietary(schema, ezUploadKind), + condition: (schema) => schema._def[metaSymbol]?.brand === ezUploadBrand, }); export const hasRaw = (subject: IOSchema) => hasNestedSchema({ subject, - condition: (schema) => isProprietary(schema, ezRawKind), + condition: (schema) => schema._def[metaSymbol]?.brand === ezRawBrand, maxDepth: 3, }); diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 4c7a2a73d..d939948e0 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -52,19 +52,19 @@ import { ucFirst, } from "./common-helpers"; import { InputSource, TagsConfig } from "./config-type"; -import { ezDateInKind } from "./date-in-schema"; -import { ezDateOutKind } from "./date-out-schema"; +import { DateInSchema, ezDateInBrand } from "./date-in-schema"; +import { DateOutSchema, ezDateOutBrand } from "./date-out-schema"; import { DocumentationError } from "./errors"; -import { ezFileKind } from "./file-schema"; +import { FileSchema, ezFileBrand } from "./file-schema"; import { IOSchema } from "./io-schema"; import { LogicalContainer, andToOr, mapLogicalContainer, } from "./logical-container"; -import { getMeta } from "./metadata"; +import { metaSymbol } from "./metadata"; import { Method } from "./method"; -import { RawSchema, ezRawKind } from "./raw-schema"; +import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, HandlingVariant, @@ -72,7 +72,7 @@ import { walkSchema, } from "./schema-walker"; import { Security } from "./security"; -import { ezUploadKind } from "./upload-schema"; +import { UploadSchema, ezUploadBrand } from "./upload-schema"; /* eslint-disable @typescript-eslint/no-use-before-define */ @@ -132,7 +132,7 @@ export const depictDefault: Depicter> = ({ next, }) => ({ ...next(schema._def.innerType), - default: getMeta(schema, "defaultLabel") || schema._def.defaultValue(), + default: schema._def[metaSymbol]?.defaultLabel || schema._def.defaultValue(), }); export const depictCatch: Depicter> = ({ @@ -146,7 +146,7 @@ export const depictAny: Depicter = () => ({ format: "any", }); -export const depictUpload: Depicter = (ctx) => { +export const depictUpload: Depicter = (ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -160,15 +160,18 @@ export const depictUpload: Depicter = (ctx) => { }; }; -export const depictFile: Depicter = ({ schema }) => ({ - type: "string", - format: - schema instanceof z.ZodString - ? schema._def.checks.find((check) => check.kind === "base64") - ? "byte" - : "file" - : "binary", -}); +export const depictFile: Depicter = ({ schema }) => { + const subject = schema.unwrap(); + return { + type: "string", + format: + subject instanceof z.ZodString + ? subject._def.checks.find((check) => check.kind === "base64") + ? "byte" + : "file" + : "binary", + }; +}; export const depictUnion: Depicter> = ({ schema: { options }, @@ -317,7 +320,7 @@ export const depictObject: Depicter> = ({ * */ export const depictNull: Depicter = () => ({ type: "null" }); -export const depictDateIn: Depicter = (ctx) => { +export const depictDateIn: Depicter = (ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -336,7 +339,7 @@ export const depictDateIn: Depicter = (ctx) => { }; }; -export const depictDateOut: Depicter = (ctx) => { +export const depictDateOut: Depicter = (ctx) => { assert( ctx.isResponse, new DocumentationError({ @@ -628,7 +631,7 @@ export const depictLazy: Depicter> = ({ }; export const depictRaw: Depicter = ({ next, schema }) => - next(schema.shape.raw); + next(schema.unwrap().shape.raw); const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => examples.length @@ -669,6 +672,9 @@ export const extractObjectSchema = ( if (subject instanceof z.ZodObject) { return subject; } + if (subject instanceof z.ZodBranded) { + return extractObjectSchema(subject.unwrap(), tfError); + } if ( subject instanceof z.ZodUnion || subject instanceof z.ZodDiscriminatedUnion @@ -779,11 +785,11 @@ export const depicters: HandlingRules< ZodPipeline: depictPipeline, ZodLazy: depictLazy, ZodReadonly: depictReadonly, - [ezFileKind]: depictFile, - [ezUploadKind]: depictUpload, - [ezDateOutKind]: depictDateOut, - [ezDateInKind]: depictDateIn, - [ezRawKind]: depictRaw, + [ezFileBrand]: depictFile, + [ezUploadBrand]: depictUpload, + [ezDateOutBrand]: depictDateOut, + [ezDateInBrand]: depictDateIn, + [ezRawBrand]: depictRaw, }; export const onEach: Depicter = ({ diff --git a/src/file-schema.ts b/src/file-schema.ts index d79d88dfe..d37515654 100644 --- a/src/file-schema.ts +++ b/src/file-schema.ts @@ -1,17 +1,16 @@ import { z } from "zod"; -import { proprietary } from "./metadata"; -export const ezFileKind = "File"; +export const ezFileBrand = Symbol("File"); const bufferSchema = z.custom((subject) => Buffer.isBuffer(subject), { message: "Expected Buffer", }); const variants = { - buffer: () => proprietary(ezFileKind, bufferSchema), - string: () => proprietary(ezFileKind, z.string()), - binary: () => proprietary(ezFileKind, bufferSchema.or(z.string())), - base64: () => proprietary(ezFileKind, z.string().base64()), + buffer: () => bufferSchema.brand(ezFileBrand), + string: () => z.string().brand(ezFileBrand), + binary: () => bufferSchema.or(z.string()).brand(ezFileBrand), + base64: () => z.string().base64().brand(ezFileBrand), }; type Variants = typeof variants; @@ -22,3 +21,5 @@ export function file(variant: K): ReturnType; export function file(variant?: K) { return variants[variant || "string"](); } + +export type FileSchema = ReturnType; diff --git a/src/index.ts b/src/index.ts index 950a1b923..d2722aefc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import "./zod-plugin"; + export { createConfig } from "./config-type"; export { AbstractEndpoint } from "./endpoint"; export { diff --git a/src/io-schema.ts b/src/io-schema.ts index cf993fc5c..6199700ea 100644 --- a/src/io-schema.ts +++ b/src/io-schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { copyMeta } from "./metadata"; import { AnyMiddlewareDef } from "./middleware"; +import { RawSchema } from "./raw-schema"; type Refined = T extends z.ZodType ? z.ZodEffects, O, O> : never; @@ -14,7 +15,8 @@ export type IOSchema = | z.ZodUnion<[IOSchema, ...IOSchema[]]> | z.ZodIntersection, IOSchema> | z.ZodDiscriminatedUnion[]> - | Refined>; + | Refined> + | RawSchema; export type ProbableIntersection< A extends IOSchema<"strip"> | null, diff --git a/src/metadata.ts b/src/metadata.ts index e83000022..b971e53c2 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,95 +1,29 @@ -import { combinations, isObject } from "./common-helpers"; +import { combinations } from "./common-helpers"; import { z } from "zod"; import { clone, mergeDeepRight } from "ramda"; -import { ProprietaryKind } from "./proprietary-schemas"; export const metaSymbol = Symbol.for("express-zod-api"); export interface Metadata { - kind?: ProprietaryKind; examples: z.input[]; /** @override ZodDefault::_def.defaultValue() in depictDefault */ defaultLabel?: string; -} - -declare module "zod" { - interface ZodTypeDef { - [metaSymbol]?: Metadata; - } - interface ZodType { - /** @desc Add an example value (before any transformations, can be called multiple times) */ - example(example: this["_input"]): this; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - interface ZodDefault { - /** @desc Change the default value in the generated Documentation to a label */ - label(label: string): this; - } + brand?: string | number | symbol; } /** @link https://github.com/colinhacks/zod/blob/3e4f71e857e75da722bd7e735b6d657a70682df2/src/types.ts#L485 */ -const cloneSchema = (schema: T) => { +export const cloneSchema = (schema: T) => { const copy = schema.describe(schema.description as string); copy._def[metaSymbol] = // clone for deep copy, issue #827 clone(copy._def[metaSymbol]) || ({ examples: [] } satisfies Metadata); return copy; }; -const exampleSetter = function ( - this: z.ZodType, - value: (typeof this)["_input"], -) { - const copy = cloneSchema(this); - copy._def[metaSymbol]!.examples.push(value); - return copy; -}; - -const defaultLabeler = function ( - this: z.ZodDefault, - label: string, -) { - const copy = cloneSchema(this); - copy._def[metaSymbol]!.defaultLabel = label; - return copy; -}; - -/** @see https://github.com/colinhacks/zod/blob/90efe7fa6135119224412c7081bd12ef0bccef26/plugin/effect/src/index.ts#L21-L31 */ -if (!(metaSymbol in globalThis)) { - (globalThis as Record)[metaSymbol] = true; - Object.defineProperty( - z.ZodType.prototype, - "example" satisfies keyof z.ZodType, - { - get(): z.ZodType["example"] { - return exampleSetter.bind(this); - }, - }, - ); - Object.defineProperty( - z.ZodDefault.prototype, - "label" satisfies keyof z.ZodDefault, - { - get(): z.ZodDefault["label"] { - return defaultLabeler.bind(this); - }, - }, - ); -} - -export const hasMeta = (schema: T) => - metaSymbol in schema._def && isObject(schema._def[metaSymbol]); - -export const getMeta = >( - schema: T, - meta: K, -): Readonly[K]> | undefined => - hasMeta(schema) ? schema._def[metaSymbol][meta] : undefined; - export const copyMeta = ( src: A, dest: B, ): B => { - if (!hasMeta(src)) { + if (!(metaSymbol in src._def)) { return dest; } const result = cloneSchema(dest); @@ -103,15 +37,3 @@ export const copyMeta = ( ); return result; }; - -export const proprietary = ( - kind: ProprietaryKind, - subject: T, -) => { - const schema = cloneSchema(subject); - schema._def[metaSymbol].kind = kind; - return schema; -}; - -export const isProprietary = (schema: z.ZodTypeAny, kind: ProprietaryKind) => - getMeta(schema, "kind") === kind; diff --git a/src/proprietary-schemas.ts b/src/proprietary-schemas.ts index 6ea9f2256..5d151eb8e 100644 --- a/src/proprietary-schemas.ts +++ b/src/proprietary-schemas.ts @@ -1,14 +1,14 @@ -import { dateIn, ezDateInKind } from "./date-in-schema"; -import { dateOut, ezDateOutKind } from "./date-out-schema"; -import { ezFileKind, file } from "./file-schema"; -import { ezRawKind, raw } from "./raw-schema"; -import { ezUploadKind, upload } from "./upload-schema"; +import { dateIn, ezDateInBrand } from "./date-in-schema"; +import { dateOut, ezDateOutBrand } from "./date-out-schema"; +import { ezFileBrand, file } from "./file-schema"; +import { ezRawBrand, raw } from "./raw-schema"; +import { ezUploadBrand, upload } from "./upload-schema"; export const ez = { dateIn, dateOut, file, upload, raw }; -export type ProprietaryKind = - | typeof ezFileKind - | typeof ezDateInKind - | typeof ezDateOutKind - | typeof ezUploadKind - | typeof ezRawKind; +export type ProprietaryBrand = + | typeof ezFileBrand + | typeof ezDateInBrand + | typeof ezDateOutBrand + | typeof ezUploadBrand + | typeof ezRawBrand; diff --git a/src/raw-schema.ts b/src/raw-schema.ts index 4ad7e5c63..9575e086a 100644 --- a/src/raw-schema.ts +++ b/src/raw-schema.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import { file } from "./file-schema"; -import { proprietary } from "./metadata"; -export const ezRawKind = "Raw"; +export const ezRawBrand = Symbol("Raw"); /** Shorthand for z.object({ raw: ez.file("buffer") }) */ -export const raw = () => - proprietary(ezRawKind, z.object({ raw: file("buffer") })); +export const raw = (extra: S = {} as S) => + z + .object({ raw: file("buffer") }) + .extend(extra) + .brand(ezRawBrand); export type RawSchema = ReturnType; diff --git a/src/schema-walker.ts b/src/schema-walker.ts index 3fc1c9481..3156eb7e7 100644 --- a/src/schema-walker.ts +++ b/src/schema-walker.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { FlatObject } from "./common-helpers"; -import { getMeta } from "./metadata"; -import { ProprietaryKind } from "./proprietary-schemas"; +import { metaSymbol } from "./metadata"; +import { ProprietaryBrand } from "./proprietary-schemas"; interface VariantDependingProps { regular: { next: (schema: z.ZodTypeAny) => U }; @@ -30,7 +30,7 @@ export type SchemaHandler< export type HandlingRules = Partial< Record< - z.ZodFirstPartyTypeKind | ProprietaryKind, + z.ZodFirstPartyTypeKind | ProprietaryBrand, SchemaHandler // keeping "any" here in order to avoid excessive complexity > >; @@ -47,8 +47,9 @@ export const walkSchema = ({ rules: HandlingRules; onMissing: SchemaHandler; }): U => { - const kind = getMeta(schema, "kind") || schema._def.typeName; - const handler = kind ? rules[kind as keyof typeof rules] : undefined; + const handler = + rules[schema._def[metaSymbol]?.brand as keyof typeof rules] || + rules[schema._def.typeName as keyof typeof rules]; const ctx = rest as unknown as Context; const next = (subject: z.ZodTypeAny) => walkSchema({ schema: subject, ...ctx, onEach, rules, onMissing }); diff --git a/src/upload-schema.ts b/src/upload-schema.ts index c88456c03..8a58fbf52 100644 --- a/src/upload-schema.ts +++ b/src/upload-schema.ts @@ -1,13 +1,11 @@ import type { UploadedFile } from "express-fileupload"; import { z } from "zod"; -import { proprietary } from "./metadata"; -export const ezUploadKind = "Upload"; +export const ezUploadBrand = Symbol("Upload"); export const upload = () => - proprietary( - ezUploadKind, - z.custom( + z + .custom( (subject) => typeof subject === "object" && subject !== null && @@ -32,5 +30,7 @@ export const upload = () => (input) => ({ message: `Expected file upload, received ${typeof input}`, }), - ), - ); + ) + .brand(ezUploadBrand); + +export type UploadSchema = ReturnType; diff --git a/src/zod-plugin.ts b/src/zod-plugin.ts new file mode 100644 index 000000000..371838df7 --- /dev/null +++ b/src/zod-plugin.ts @@ -0,0 +1,81 @@ +/** + * @fileoverview Zod Runtime Plugin + * @see https://github.com/colinhacks/zod/blob/90efe7fa6135119224412c7081bd12ef0bccef26/plugin/effect/src/index.ts#L21-L31 + * @desc This code modifies and extends zod's functionality immediately when importing express-zod-api + * @desc Enables .examples() on all schemas (ZodType) + * @desc Enables .label() on ZodDefault + * @desc Stores the argument supplied to .brand() on all schema (runtime distinguishable branded types) + * */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { clone } from "ramda"; +import { z } from "zod"; +import { Metadata, cloneSchema, metaSymbol } from "./metadata"; + +declare module "zod" { + interface ZodTypeDef { + [metaSymbol]?: Metadata; + } + interface ZodType { + /** @desc Add an example value (before any transformations, can be called multiple times) */ + example(example: this["_input"]): this; + } + interface ZodDefault { + /** @desc Change the default value in the generated Documentation to a label */ + label(label: string): this; + } +} + +const exampleSetter = function ( + this: z.ZodType, + value: (typeof this)["_input"], +) { + const copy = cloneSchema(this); + copy._def[metaSymbol]!.examples.push(value); + return copy; +}; + +const labelSetter = function (this: z.ZodDefault, label: string) { + const copy = cloneSchema(this); + copy._def[metaSymbol]!.defaultLabel = label; + return copy; +}; + +const brandSetter = function ( + this: z.ZodType, + brand?: string | number | symbol, +) { + return new z.ZodBranded({ + typeName: z.ZodFirstPartyTypeKind.ZodBranded, + type: this, + description: this._def.description, + errorMap: this._def.errorMap, + [metaSymbol]: { examples: [], ...clone(this._def[metaSymbol]), brand }, + }); +}; + +if (!(metaSymbol in globalThis)) { + (globalThis as Record)[metaSymbol] = true; + Object.defineProperties(z.ZodType.prototype, { + ["example" satisfies keyof z.ZodType]: { + get(): z.ZodType["example"] { + return exampleSetter.bind(this); + }, + }, + ["brand" satisfies keyof z.ZodType]: { + set() {}, // this is required to override the existing method + get() { + return brandSetter.bind(this) as z.ZodType["brand"]; + }, + }, + }); + Object.defineProperty( + z.ZodDefault.prototype, + "label" satisfies keyof z.ZodDefault, + { + get(): z.ZodDefault["label"] { + return labelSetter.bind(this); + }, + }, + ); +} diff --git a/src/zts.ts b/src/zts.ts index c1e8261d6..1d687c083 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -1,10 +1,10 @@ import ts from "typescript"; import { z } from "zod"; import { hasCoercion, tryToTransform } from "./common-helpers"; -import { ezDateInKind } from "./date-in-schema"; -import { ezDateOutKind } from "./date-out-schema"; -import { ezFileKind } from "./file-schema"; -import { RawSchema, ezRawKind } from "./raw-schema"; +import { ezDateInBrand } from "./date-in-schema"; +import { ezDateOutBrand } from "./date-out-schema"; +import { FileSchema, ezFileBrand } from "./file-schema"; +import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, walkSchema } from "./schema-walker"; import { LiteralType, @@ -216,18 +216,20 @@ const onLazy: Producer> = ({ ); }; -const onFile: Producer = ({ schema }) => { +const onFile: Producer = ({ schema }) => { + const subject = schema.unwrap(); const stringType = f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); const bufferType = f.createTypeReferenceNode("Buffer"); const unionType = f.createUnionTypeNode([stringType, bufferType]); - return schema instanceof z.ZodString + return subject instanceof z.ZodString ? stringType - : schema instanceof z.ZodUnion + : subject instanceof z.ZodUnion ? unionType : bufferType; }; -const onRaw: Producer = ({ next, schema }) => next(schema.shape.raw); +const onRaw: Producer = ({ next, schema }) => + next(schema.unwrap().shape.raw); const producers: HandlingRules = { ZodString: onPrimitive(ts.SyntaxKind.StringKeyword), @@ -235,8 +237,8 @@ const producers: HandlingRules = { ZodBigInt: onPrimitive(ts.SyntaxKind.BigIntKeyword), ZodBoolean: onPrimitive(ts.SyntaxKind.BooleanKeyword), ZodAny: onPrimitive(ts.SyntaxKind.AnyKeyword), - [ezDateInKind]: onPrimitive(ts.SyntaxKind.StringKeyword), - [ezDateOutKind]: onPrimitive(ts.SyntaxKind.StringKeyword), + [ezDateInBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), + [ezDateOutBrand]: onPrimitive(ts.SyntaxKind.StringKeyword), ZodNull: onNull, ZodArray: onArray, ZodTuple: onTuple, @@ -257,8 +259,8 @@ const producers: HandlingRules = { ZodPipeline: onPipeline, ZodLazy: onLazy, ZodReadonly: onReadonly, - [ezFileKind]: onFile, - [ezRawKind]: onRaw, + [ezFileBrand]: onFile, + [ezRawBrand]: onRaw, }; export const zodToTs = ({ diff --git a/tests/helpers.ts b/tests/helpers.ts index c985fedc2..56e331544 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,5 +1,6 @@ import { map } from "ramda"; import { z } from "zod"; +import { ezFileBrand } from "../src/file-schema"; import { SchemaHandler, walkSchema } from "../src/schema-walker"; let lastGivenPort = 8010; @@ -82,6 +83,7 @@ export const serializeSchemaForTest = ( from: next(schema._def.in), to: next(schema._def.out), }), + [ezFileBrand]: () => ({ brand: ezFileBrand }), }, onEach: ({ schema }) => ({ _type: schema._def.typeName }), onMissing: ({ schema }) => { diff --git a/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap b/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap index 07d2ba2f9..1531efb9c 100644 --- a/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap +++ b/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap @@ -1441,3 +1441,15 @@ exports[`Documentation helpers > extractObjectSchema() > should return object sc }, } `; + +exports[`Documentation helpers > extractObjectSchema() > should support ez.raw() 1`] = ` +{ + "_type": "ZodObject", + "shape": { + "raw": { + "_type": "ZodBranded", + "brand": Symbol(File), + }, + }, +} +`; diff --git a/tests/unit/checks.spec.ts b/tests/unit/checks.spec.ts index 7271aa2fe..b91963c1b 100644 --- a/tests/unit/checks.spec.ts +++ b/tests/unit/checks.spec.ts @@ -3,13 +3,13 @@ import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { ez } from "../../src"; import { hasNestedSchema, hasTransformationOnTop } from "../../src/deep-checks"; -import { isProprietary } from "../../src/metadata"; -import { ezUploadKind } from "../../src/upload-schema"; +import { metaSymbol } from "../../src/metadata"; +import { ezUploadBrand } from "../../src/upload-schema"; describe("Checks", () => { describe("hasNestedSchema()", () => { const condition = (subject: z.ZodTypeAny) => - isProprietary(subject, ezUploadKind); + subject._def[metaSymbol]?.brand === ezUploadBrand; test("should return true for given argument satisfying condition", () => { expect(hasNestedSchema({ subject: ez.upload(), condition })).toBeTruthy(); diff --git a/tests/unit/date-in-schema.spec.ts b/tests/unit/date-in-schema.spec.ts index 578f81e5b..4db7ca4b5 100644 --- a/tests/unit/date-in-schema.spec.ts +++ b/tests/unit/date-in-schema.spec.ts @@ -1,14 +1,15 @@ import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezDateInBrand } from "../../src/date-in-schema"; import { ez } from "../../src"; import { describe, expect, test } from "vitest"; +import { metaSymbol } from "../../src/metadata"; describe("ez.dateIn()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateIn(); - expect(schema).toBeInstanceOf(z.ZodPipeline); - expect(getMeta(schema, "kind")).toEqual("DateIn"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toEqual(ezDateInBrand); }); }); diff --git a/tests/unit/date-out-schema.spec.ts b/tests/unit/date-out-schema.spec.ts index 64052b70b..4a679c874 100644 --- a/tests/unit/date-out-schema.spec.ts +++ b/tests/unit/date-out-schema.spec.ts @@ -1,14 +1,15 @@ import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezDateOutBrand } from "../../src/date-out-schema"; import { ez } from "../../src"; import { describe, expect, test } from "vitest"; +import { metaSymbol } from "../../src/metadata"; describe("ez.dateOut()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateOut(); - expect(schema).toBeInstanceOf(z.ZodEffects); - expect(getMeta(schema, "kind")).toEqual("DateOut"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toEqual(ezDateOutBrand); }); }); diff --git a/tests/unit/documentation-helpers.spec.ts b/tests/unit/documentation-helpers.spec.ts index 6c9dd53de..e3b6c287c 100644 --- a/tests/unit/documentation-helpers.spec.ts +++ b/tests/unit/documentation-helpers.spec.ts @@ -152,6 +152,12 @@ describe("Documentation helpers", () => { expect(serializeSchemaForTest(subject)).toMatchSnapshot(); }); + test("should support ez.raw()", () => { + const subject = extractObjectSchema(ez.raw(), tfError); + expect(subject).toBeInstanceOf(z.ZodObject); + expect(serializeSchemaForTest(subject)).toMatchSnapshot(); + }); + describe("Feature #600: Top level refinements", () => { test("should handle refined object schema", () => { const subject = extractObjectSchema( diff --git a/tests/unit/file-schema.spec.ts b/tests/unit/file-schema.spec.ts index 72c4ba036..1ed592503 100644 --- a/tests/unit/file-schema.spec.ts +++ b/tests/unit/file-schema.spec.ts @@ -1,39 +1,40 @@ import { expectType } from "tsd"; import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezFileBrand } from "../../src/file-schema"; import { ez } from "../../src"; import { readFile } from "node:fs/promises"; import { describe, expect, test } from "vitest"; +import { metaSymbol } from "../../src/metadata"; describe("ez.file()", () => { describe("creation", () => { test("should create an instance being string by default", () => { const schema = ez.file(); - expect(schema).toBeInstanceOf(z.ZodString); - expect(getMeta(schema, "kind")).toBe("File"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toBe(ezFileBrand); }); test("should create a string file", () => { const schema = ez.file("string"); - expect(schema).toBeInstanceOf(z.ZodString); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a buffer file", () => { const schema = ez.file("buffer"); - expect(schema).toBeInstanceOf(z.ZodEffects); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a binary file", () => { const schema = ez.file("binary"); - expect(schema).toBeInstanceOf(z.ZodUnion); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a base64 file", () => { const schema = ez.file("base64"); - expect(schema).toBeInstanceOf(z.ZodString); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); }); diff --git a/tests/unit/io-schema.spec.ts b/tests/unit/io-schema.spec.ts index e8d725086..984dab8eb 100644 --- a/tests/unit/io-schema.spec.ts +++ b/tests/unit/io-schema.spec.ts @@ -1,8 +1,8 @@ import { expectNotType, expectType } from "tsd"; import { z } from "zod"; -import { IOSchema, createMiddleware } from "../../src"; +import { IOSchema, createMiddleware, ez } from "../../src"; import { getFinalEndpointInputSchema } from "../../src/io-schema"; -import { getMeta } from "../../src/metadata"; +import { metaSymbol } from "../../src/metadata"; import { AnyMiddlewareDef } from "../../src/middleware"; import { serializeSchemaForTest } from "../helpers"; import { describe, expect, test, vi } from "vitest"; @@ -16,6 +16,10 @@ describe("I/O Schema and related helpers", () => { expectType>(z.object({}).passthrough()); expectType>(z.object({}).strip()); }); + test("accepts ez.raw()", () => { + expectType(ez.raw()); + expectType(ez.raw({ something: z.any() })); + }); test("respects the UnknownKeys type argument", () => { expectNotType>(z.object({})); }); @@ -224,7 +228,7 @@ describe("I/O Schema and related helpers", () => { .object({ five: z.string() }) .example({ five: "some" }); const result = getFinalEndpointInputSchema(middlewares, endpointInput); - expect(getMeta(result, "examples")).toEqual([ + expect(result._def[metaSymbol]?.examples).toEqual([ { one: "test", two: 123, diff --git a/tests/unit/metadata.spec.ts b/tests/unit/metadata.spec.ts index 8d372bcfd..96af2590f 100644 --- a/tests/unit/metadata.spec.ts +++ b/tests/unit/metadata.spec.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { copyMeta, getMeta, hasMeta, metaSymbol } from "../../src/metadata"; +import { copyMeta, metaSymbol } from "../../src/metadata"; import { describe, expect, test } from "vitest"; describe("Metadata", () => { @@ -62,45 +62,9 @@ describe("Metadata", () => { }); }); - describe("hasMeta()", () => { - test("should return false if the schema definition has no meta prop", () => { - expect(hasMeta(z.string())).toBeFalsy(); - }); - test("should return false if the meta prop has invalid type", () => { - const schema1 = z.string(); - const schema2 = z.string(); - Object.defineProperty(schema1._def, metaSymbol, { value: null }); - expect(hasMeta(schema1)).toBeFalsy(); - Object.defineProperty(schema2._def, metaSymbol, { value: 123 }); - expect(hasMeta(schema2)).toBeFalsy(); - }); - test("should return true if proprietary method has been used", () => { - expect(hasMeta(z.string().example(""))).toBeTruthy(); - }); - }); - - describe("getMeta()", () => { - test("should return undefined on a regular Zod schema or the malformed one", () => { - expect(getMeta(z.string(), "examples")).toBeUndefined(); - }); - test("should return undefined on malformed schema", () => { - const schema1 = z.string(); - const schema2 = z.string(); - Object.defineProperty(schema1._def, metaSymbol, { value: null }); - expect(getMeta(schema1, "examples")).toBeUndefined(); - Object.defineProperty(schema2._def, metaSymbol, { value: 123 }); - expect(getMeta(schema2, "examples")).toBeUndefined(); - }); - test("should return undefined if the value not set", () => { - expect(getMeta(z.string(), "examples")).toBeUndefined(); - }); - test("should return the value that has been set", () => { - expect(getMeta(z.string().example("test"), "examples")).toEqual(["test"]); - }); - test("should return an array of examples", () => { - expect( - getMeta(z.string().example("test1").example("test2"), "examples"), - ).toEqual(["test1", "test2"]); + describe(".brand()", () => { + test("should set the brand", () => { + expect(z.string().brand("test")._def[metaSymbol]?.brand).toEqual("test"); }); }); @@ -110,15 +74,17 @@ describe("Metadata", () => { const dest = z.number(); const result = copyMeta(src, dest); expect(result).toEqual(dest); - expect(hasMeta(result)).toBeFalsy(); - expect(hasMeta(dest)).toBeFalsy(); + expect(result._def[metaSymbol]).toBeFalsy(); + expect(dest._def[metaSymbol]).toBeFalsy(); }); test("should copy meta from src to dest in case meta is defined", () => { const src = z.string().example("some"); const dest = z.number(); const result = copyMeta(src, dest); - expect(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual(getMeta(src, "examples")); + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual( + src._def[metaSymbol]?.examples, + ); }); test("should merge the meta from src to dest", () => { @@ -132,8 +98,8 @@ describe("Metadata", () => { .example({ b: 456 }) .example({ b: 789 }); const result = copyMeta(src, dest); - expect(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual([ + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual([ { a: "some", b: 123 }, { a: "another", b: 123 }, { a: "some", b: 456 }, @@ -154,8 +120,8 @@ describe("Metadata", () => { .example({ a: { c: 456 } }) .example({ a: { c: 789 } }); const result = copyMeta(src, dest); - expect(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual([ + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual([ { a: { b: "some", c: 123 } }, { a: { b: "another", c: 123 } }, { a: { b: "some", c: 456 } }, diff --git a/tests/unit/upload-schema.spec.ts b/tests/unit/upload-schema.spec.ts index 034ea45f8..8e11a6d2a 100644 --- a/tests/unit/upload-schema.spec.ts +++ b/tests/unit/upload-schema.spec.ts @@ -1,14 +1,15 @@ -import { getMeta } from "../../src/metadata"; import { z } from "zod"; import { ez } from "../../src"; import { describe, expect, test, vi } from "vitest"; +import { metaSymbol } from "../../src/metadata"; +import { ezUploadBrand } from "../../src/upload-schema"; describe("ez.upload()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.upload(); - expect(schema).toBeInstanceOf(z.ZodEffects); - expect(getMeta(schema, "kind")).toBe("Upload"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toBe(ezUploadBrand); }); }); diff --git a/tsconfig.json b/tsconfig.json index d9fdaa0cb..379fd6c5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,5 @@ "moduleResolution": "Bundler", "resolveJsonModule": true }, - "include": ["src", "example", "tests/unit", "tests/system", "tests/bench", "tests/*.ts", "tools", "*.config.ts"] + "include": ["src", "example", "tests/unit", "tests/system", "tests/bench", "tests/*.ts", "tools", "*.config.ts", "*.setup.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts index a410d6041..874c2b553 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from "vitest/config"; +const isIntegrationTest = process.argv.includes("-r"); + export default defineConfig({ test: { env: { @@ -12,5 +14,6 @@ export default defineConfig({ reporter: [["text", { maxCols: 120 }], "json-summary", "html", "lcov"], include: ["src/**"], }, + setupFiles: isIntegrationTest ? [] : ["./vitest.setup.ts"], }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..54cc0964b --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,2 @@ +// required to apply the plugin before running the tests, because some tests do not import from entrypoint +import "./src/zod-plugin"; From 93aa7b6d03336d8b4c1be04b783972fd9a6e59ec Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 9 May 2024 16:44:51 +0200 Subject: [PATCH 17/40] Automatic default raw parser (#1745) Due to #1741 , the default raw parser can now be set automatically, so that the config option can now be only used for customizations. --- CHANGELOG.md | 5 ++++- README.md | 16 ++++------------ example/endpoints/accept-raw.ts | 1 - src/config-type.ts | 7 ++----- src/server.ts | 2 +- tests/express-mock.ts | 3 +++ tests/unit/server.spec.ts | 32 ++++++++++++++++++++++++++------ 7 files changed, 40 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0574bd19b..8aa508ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ - Depending on that type, only the parsers needed for certain endpoint are processed; - This makes all requests eligible for the assigned parsers and reverts changes made in [v18.5.2](#v1852). - Non-breaking significant changes: - - Request logging reflects the actual path instead of the configured route, and it's placed in front of parsing. + - Request logging reflects the actual path instead of the configured route, and it's placed in front of parsing; + - Specifying `rawParser` in config is no longer needed to enable the feature. - How to migrate confidently: - Upgrade Node to latest version of 18.x, 20.x or 22.x; - Upgrade `zod` to its latest version of 3.x; @@ -33,6 +34,8 @@ - Supply them directly as an argument to `ez.raw()` — see the example below. - If you're using `beforeUpload` in your config: - Adjust the implementation according to the example below. + - If you're having `rawParser: express.raw()` in your config: + - You can now remove this line (it's the default value now), unless you're having any customizations. ```ts import createHttpError from "http-errors"; diff --git a/README.md b/README.md index fd11de7e6..d0c374fe0 100644 --- a/README.md +++ b/README.md @@ -998,20 +998,12 @@ defaultEndpointsFactory.build({ ## 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 -file as an entire body of request. In order to enable this feature you need to set the `rawParser` config feature to -`express.raw()`. See also its options [in Express.js documentation](https://expressjs.com/en/4x/api.html#express.raw). -The raw data is placed into `request.body.raw` property, having type `Buffer`. Then use the proprietary `ez.raw()` -schema as the input schema of your endpoint. +file as an entire body of request. Use the proprietary `ez.raw()` schema as the input schema of your endpoint. +The default parser in this case is `express.raw()`. You can customize it by assigning the `rawParser` option in config. +The raw data is placed into `request.body.raw` property, having type `Buffer`. ```typescript -import express from "express"; -import { createConfig, defaultEndpointsFactory, ez } from "express-zod-api"; - -const config = createConfig({ - server: { - rawParser: express.raw(), // enables the feature - }, -}); +import { defaultEndpointsFactory, ez } from "express-zod-api"; const rawAcceptingEndpoint = defaultEndpointsFactory.build({ method: "post", diff --git a/example/endpoints/accept-raw.ts b/example/endpoints/accept-raw.ts index b97dc4b32..2e04d0877 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -5,7 +5,6 @@ import { taggedEndpointsFactory } from "../factories"; export const rawAcceptingEndpoint = taggedEndpointsFactory.build({ method: "post", tag: "files", - // requires to enable rawParser option in server config: input: ez.raw({ /* the place for additional inputs, like route params, if needed */ }), diff --git a/src/config-type.ts b/src/config-type.ts index 90e00aaf1..7edbac349 100644 --- a/src/config-type.ts +++ b/src/config-type.ts @@ -142,11 +142,8 @@ export interface ServerConfig */ compression?: boolean | CompressionOptions; /** - * @desc Enables parsing certain request payloads into raw Buffers (application/octet-stream by default) - * @desc When enabled, use ez.raw() as input schema to get input.raw in Endpoint's handler - * @default undefined - * @example express.raw() - * @todo this can be now automatic + * @desc Custom raw parser (assigns Buffer to request body) + * @default express.raw() * @link https://expressjs.com/en/4x/api.html#express.raw * */ rawParser?: RequestHandler; diff --git a/src/server.ts b/src/server.ts index e64c3606b..08e0758ef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -57,7 +57,7 @@ export const createServer = async (config: ServerConfig, routing: Routing) => { const parsers: Parsers = { json: [config.server.jsonParser || express.json()], - raw: config.server.rawParser ? [config.server.rawParser, moveRaw] : [], + raw: [config.server.rawParser || express.raw(), moveRaw], upload: config.server.upload ? await createUploadParsers({ config, rootLogger }) : [], diff --git a/tests/express-mock.ts b/tests/express-mock.ts index 400d5fbd5..df91b3b3a 100644 --- a/tests/express-mock.ts +++ b/tests/express-mock.ts @@ -2,6 +2,7 @@ import { Mock, vi } from "vitest"; const expressJsonMock = vi.fn(); +const expressRawMock = vi.fn(); const compressionMock = vi.fn(); const fileUploadMock = vi.fn(); @@ -30,6 +31,7 @@ const expressMock = () => { return appMock; }; expressMock.json = () => expressJsonMock; +expressMock.raw = () => expressRawMock; expressMock.static = staticMock; vi.mock("express", () => ({ default: expressMock })); @@ -40,6 +42,7 @@ export { expressMock, appMock, expressJsonMock, + expressRawMock, staticMock, staticHandler, }; diff --git a/tests/unit/server.spec.ts b/tests/unit/server.spec.ts index 7ea0d041b..a04998b89 100644 --- a/tests/unit/server.spec.ts +++ b/tests/unit/server.spec.ts @@ -5,6 +5,7 @@ import { compressionMock, expressJsonMock, expressMock, + expressRawMock, } from "../express-mock"; import { createHttpsServerSpy, @@ -85,7 +86,7 @@ describe("Server", () => { expect(httpListenSpy).toHaveBeenCalledWith(port, expect.any(Function)); }); - test("Should create server with custom JSON parser, logger, error handler and beforeRouting", async () => { + test("Should create server with custom JSON parser, raw parser, logger, error handler and beforeRouting", async () => { const customLogger = createLogger({ level: "silent" }); const infoMethod = vi.spyOn(customLogger, "info"); const port = givePort(); @@ -93,6 +94,7 @@ describe("Server", () => { server: { listen: { port }, // testing Net::ListenOptions jsonParser: vi.fn(), + rawParser: vi.fn(), beforeRouting: vi.fn(), }, cors: true, @@ -102,9 +104,10 @@ describe("Server", () => { }, logger: customLogger, }; + const factory = new EndpointsFactory(defaultResultHandler); const routingMock = { v1: { - test: new EndpointsFactory(defaultResultHandler).build({ + test: factory.build({ methods: ["get", "post"], input: z.object({ n: z.number(), @@ -114,6 +117,12 @@ describe("Server", () => { }), handler: vi.fn(), }), + raw: factory.build({ + method: "patch", + input: ez.raw(), + output: z.object({}), + handler: vi.fn(), + }), }, }; const { logger, app } = await createServer( @@ -143,12 +152,25 @@ describe("Server", () => { configMock.server.jsonParser, expect.any(Function), // endpoint ); - expect(appMock.options).toHaveBeenCalledTimes(1); + expect(appMock.patch).toHaveBeenCalledTimes(1); + expect(appMock.patch).toHaveBeenCalledWith( + "/v1/raw", + configMock.server.rawParser, + moveRaw, + expect.any(Function), // endpoint + ); + expect(appMock.options).toHaveBeenCalledTimes(2); expect(appMock.options).toHaveBeenCalledWith( "/v1/test", configMock.server.jsonParser, expect.any(Function), // endpoint ); + expect(appMock.options).toHaveBeenCalledWith( + "/v1/raw", + configMock.server.rawParser, + moveRaw, + expect.any(Function), // endpoint + ); expect(httpListenSpy).toHaveBeenCalledTimes(1); expect(httpListenSpy).toHaveBeenCalledWith( { port }, @@ -258,11 +280,9 @@ describe("Server", () => { }); test("should enable raw on request", async () => { - const rawParserMock = vi.fn(); const configMock = { server: { listen: givePort(), - rawParser: rawParserMock, }, cors: true, startupLogo: false, @@ -283,7 +303,7 @@ describe("Server", () => { expect(appMock.get).toHaveBeenCalledTimes(1); expect(appMock.get).toHaveBeenCalledWith( "/v1/test", - rawParserMock, + expressRawMock, moveRaw, expect.any(Function), // endpoint ); From 944eca1973ee1e4b89234cf40dd29e7b8d596718 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 9 May 2024 16:47:44 +0200 Subject: [PATCH 18/40] 19.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 6a7fe135f..a3180cea8 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 18.5.2 + version: 19.0.0-beta.1 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index c0a07f9e8..f7e09e7e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "18.5.2", + "version": "19.0.0-beta.1", "description": "A Typescript library to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 976347fc83684551c0abd50374baea72cae8f2c5 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 9 May 2024 18:08:14 +0200 Subject: [PATCH 19/40] Fix TS2527 for unique symbol (v19) (#1747) https://github.com/RobinTail/express-zod-api/pull/1692#issuecomment-2102867495 --- .gitignore | 1 + src/date-in-schema.ts | 2 +- src/date-out-schema.ts | 2 +- src/file-schema.ts | 12 ++++++++---- src/raw-schema.ts | 2 +- src/upload-schema.ts | 2 +- tests/issue952/package.json | 2 +- tests/issue952/symbols.ts | 9 +++++++++ 8 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 tests/issue952/symbols.ts diff --git a/.gitignore b/.gitignore index b6fbb9f47..cf7893ebe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ yarn-error.log coverage tests/**/yarn.lock tests/**/quick-start.ts +tests/issue952/*.d.ts diff --git a/src/date-in-schema.ts b/src/date-in-schema.ts index 68e3c34e4..e12598599 100644 --- a/src/date-in-schema.ts +++ b/src/date-in-schema.ts @@ -13,7 +13,7 @@ export const dateIn = () => { return schema .transform((str) => new Date(str)) .pipe(z.date().refine(isValidDate)) - .brand(ezDateInBrand); + .brand(ezDateInBrand as symbol); }; export type DateInSchema = ReturnType; diff --git a/src/date-out-schema.ts b/src/date-out-schema.ts index 466e88a77..114037033 100644 --- a/src/date-out-schema.ts +++ b/src/date-out-schema.ts @@ -8,6 +8,6 @@ export const dateOut = () => .date() .refine(isValidDate) .transform((date) => date.toISOString()) - .brand(ezDateOutBrand); + .brand(ezDateOutBrand as symbol); export type DateOutSchema = ReturnType; diff --git a/src/file-schema.ts b/src/file-schema.ts index d37515654..b5382f812 100644 --- a/src/file-schema.ts +++ b/src/file-schema.ts @@ -7,10 +7,14 @@ const bufferSchema = z.custom((subject) => Buffer.isBuffer(subject), { }); const variants = { - buffer: () => bufferSchema.brand(ezFileBrand), - string: () => z.string().brand(ezFileBrand), - binary: () => bufferSchema.or(z.string()).brand(ezFileBrand), - base64: () => z.string().base64().brand(ezFileBrand), + buffer: () => bufferSchema.brand(ezFileBrand as symbol), + string: () => z.string().brand(ezFileBrand as symbol), + binary: () => bufferSchema.or(z.string()).brand(ezFileBrand as symbol), + base64: () => + z + .string() + .base64() + .brand(ezFileBrand as symbol), }; type Variants = typeof variants; diff --git a/src/raw-schema.ts b/src/raw-schema.ts index 9575e086a..9cc492c7c 100644 --- a/src/raw-schema.ts +++ b/src/raw-schema.ts @@ -8,6 +8,6 @@ export const raw = (extra: S = {} as S) => z .object({ raw: file("buffer") }) .extend(extra) - .brand(ezRawBrand); + .brand(ezRawBrand as symbol); export type RawSchema = ReturnType; diff --git a/src/upload-schema.ts b/src/upload-schema.ts index 8a58fbf52..aa94ccb7b 100644 --- a/src/upload-schema.ts +++ b/src/upload-schema.ts @@ -31,6 +31,6 @@ export const upload = () => message: `Expected file upload, received ${typeof input}`, }), ) - .brand(ezUploadBrand); + .brand(ezUploadBrand as symbol); export type UploadSchema = ReturnType; diff --git a/tests/issue952/package.json b/tests/issue952/package.json index 844b77619..8beb9c302 100644 --- a/tests/issue952/package.json +++ b/tests/issue952/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "scripts": { "test": "tsc -p tsconfig.json", - "posttest": "rm quick-start.d.ts" + "posttest": "rm *.d.ts" }, "dependencies": { "express-zod-api": "link:../.." diff --git a/tests/issue952/symbols.ts b/tests/issue952/symbols.ts new file mode 100644 index 000000000..b059f02fb --- /dev/null +++ b/tests/issue952/symbols.ts @@ -0,0 +1,9 @@ +import { ez } from "express-zod-api"; + +export const schemas = { + raw: ez.raw(), + file: ez.file(), + dateIn: ez.dateIn(), + dateOut: ez.dateOut(), + upload: ez.upload(), +}; From 4cbbaa5e67badf8f49540eb2f4bf3bdd39b0ab50 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 9 May 2024 18:10:46 +0200 Subject: [PATCH 20/40] Reverting stict version for SWC core in compat test. --- tests/compat/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/compat/package.json b/tests/compat/package.json index 37ac8950f..ca9951e1f 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ "devDependencies": { "jest": "^30.0.0-alpha.3", "@types/jest": "^29.5.12", - "@swc/core": "1.5.0", + "@swc/core": "^1.5.0", "@swc/jest": "^0.2.29" } } From fc974389aef8cd5e214c7b9471b0fd820408d876 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 9 May 2024 18:23:40 +0200 Subject: [PATCH 21/40] 19.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 a3180cea8..962c1c932 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 19.0.0-beta.1 + version: 19.0.0-beta.2 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index f7e09e7e6..fb9eaeba2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "19.0.0-beta.1", + "version": "19.0.0-beta.2", "description": "A Typescript library to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 3ca550261aa6bbcd22caeb63a458f7845af29614 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 9 May 2024 19:16:40 +0200 Subject: [PATCH 22/40] Logger adjustments for v19 (#1748) related to discussion #1716 and due to #1741 --- CHANGELOG.md | 5 ++++- README.md | 12 ++---------- example/config.ts | 1 - src/logger.ts | 4 ++-- src/server-helpers.ts | 5 +++-- tests/unit/logger.spec.ts | 10 +++++++++- tests/unit/server-helpers.spec.ts | 1 + 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa508ffe..92aca4903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,15 @@ - Restricting the upload can be achieved now by throwing an error from within. - Changed interface for `ez.raw()`: additional properties should be supplied as its argument, not via `.extend()`. - Features: + - New configurable level `info` for built-in logger (higher than `debug`, but lower than `warn`); - Selective parsers equipped with a child logger: - There are 3 types of endpoints depending on their input schema: having `ez.upload()`, having `ez.raw()`, others; - Depending on that type, only the parsers needed for certain endpoint are processed; - This makes all requests eligible for the assigned parsers and reverts changes made in [v18.5.2](#v1852). - Non-breaking significant changes: - - Request logging reflects the actual path instead of the configured route, and it's placed in front of parsing; + - Request logging reflects the actual path instead of the configured route, and it's placed in front of parsing: + - The severity of those messaged reduced from `info` to `debug`; + - The debug messages from uploader are enabled by default when the logger level is set to `debug`; - Specifying `rawParser` in config is no longer needed to enable the feature. - How to migrate confidently: - Upgrade Node to latest version of 18.x, 20.x or 22.x; diff --git a/README.md b/README.md index d0c374fe0..d750a8491 100644 --- a/README.md +++ b/README.md @@ -783,16 +783,8 @@ const config = createConfig({ ``` Refer to [documentation](https://www.npmjs.com/package/express-fileupload#available-options) on available options. -Some options are forced in order to ensure the correct workflow: - -```json5 -{ - abortOnLimit: false, - parseNested: true, - logger: {}, // the configured logger, using its .debug() method -} -``` - +Some options are forced in order to ensure the correct workflow: `abortOnLimit: false`, `parseNested: true`, `logger` +is assigned with `.debug()` method of the configured logger, and `debug` is enabled by default. The `limitHandler` option is replaced by the `limitError` one. You can also connect an additional middleware for restricting the ability to upload using the `beforeUpload` option. So the configuration for the limited and restricted upload might look this way: diff --git a/example/config.ts b/example/config.ts index c08214fb4..4bbb4ca31 100644 --- a/example/config.ts +++ b/example/config.ts @@ -13,7 +13,6 @@ export const config = createConfig({ server: { listen: 8090, upload: { - debug: true, limits: { fileSize: 51200 }, limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint }, diff --git a/src/logger.ts b/src/logger.ts index 3da580f01..4b872cb01 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -22,7 +22,7 @@ export interface BuiltinLoggerConfig { * @desc The minimal severity to log or "silent" to disable logging * @example "debug" also enables pretty output for inspected entities * */ - level: "silent" | "warn" | "debug"; + level: "silent" | "warn" | "info" | "debug"; /** * @desc Enables colors on printed severity and inspected entities * @default Ansis::isSupported() @@ -54,7 +54,7 @@ export const isBuiltinLoggerConfig = ( ? typeof subject.depth === "number" || subject.depth === null : true) && typeof subject.level === "string" && - ["silent", "warn", "debug"].includes(subject.level) && + ["silent", "warn", "info", "debug"].includes(subject.level) && !Object.values(subject).some((prop) => typeof prop === "function"); /** diff --git a/src/server-helpers.ts b/src/server-helpers.ts index 479ef1d6a..f4d967fb3 100644 --- a/src/server-helpers.ts +++ b/src/server-helpers.ts @@ -104,6 +104,7 @@ export const createUploadParsers = async ({ return next(error); } uploader({ + debug: true, ...options, abortOnLimit: false, parseNested: true, @@ -123,7 +124,7 @@ export const moveRaw: RequestHandler = (req, {}, next) => { next(); }; -/** @since v19 prints the actual path of the request, not a configured route */ +/** @since v19 prints the actual path of the request, not a configured route, severity decreased to debug level */ export const createLoggingMiddleware = ({ rootLogger, @@ -136,7 +137,7 @@ export const createLoggingMiddleware = const logger = config.childLoggerProvider ? await config.childLoggerProvider({ request, parent: rootLogger }) : rootLogger; - logger.info(`${request.method}: ${request.path}`); + logger.debug(`${request.method}: ${request.path}`); response.locals[metaSymbol] = { logger }; next(); }; diff --git a/tests/unit/logger.spec.ts b/tests/unit/logger.spec.ts index f52785f76..6583eb166 100644 --- a/tests/unit/logger.spec.ts +++ b/tests/unit/logger.spec.ts @@ -48,6 +48,14 @@ describe("Logger", () => { expect(logSpy.mock.calls).toMatchSnapshot(); }); + test("Should create info logger", () => { + const { logger, logSpy } = makeLogger({ level: "info", color: false }); + logger.debug("testing debug message"); + expect(logSpy).not.toHaveBeenCalled(); + logger.warn("testing warn message"); + expect(logSpy).toHaveBeenCalledTimes(1); + }); + test.each(["debug", "info", "warn", "error"] as const)( "Should create debug logger %#", (method) => { @@ -100,7 +108,7 @@ describe("Logger", () => { test.each([ { level: "silent" }, { level: "debug", color: false }, - { level: "warn", color: true }, + { level: "info", color: true }, { level: "warn", depth: 5 }, { level: "warn", depth: null }, { level: "warn", depth: Infinity }, diff --git a/tests/unit/server-helpers.spec.ts b/tests/unit/server-helpers.spec.ts index 9d6a693ff..51a62e2f8 100644 --- a/tests/unit/server-helpers.spec.ts +++ b/tests/unit/server-helpers.spec.ts @@ -253,6 +253,7 @@ describe("Server helpers", () => { }); expect(fileUploadMock).toHaveBeenCalledTimes(1); expect(fileUploadMock).toHaveBeenCalledWith({ + debug: true, abortOnLimit: false, parseNested: true, limits: { fileSize: 1024 }, From 909d414fe73fffef0683877cc969c9c5f3140b84 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 9 May 2024 19:25:19 +0200 Subject: [PATCH 23/40] Ref: removing type argument from Metadata. --- src/metadata.ts | 6 +++--- src/zod-plugin.ts | 2 +- tests/unit/index.spec.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/metadata.ts b/src/metadata.ts index b971e53c2..7f9ce1e1c 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -4,8 +4,8 @@ import { clone, mergeDeepRight } from "ramda"; export const metaSymbol = Symbol.for("express-zod-api"); -export interface Metadata { - examples: z.input[]; +export interface Metadata { + examples: unknown[]; /** @override ZodDefault::_def.defaultValue() in depictDefault */ defaultLabel?: string; brand?: string | number | symbol; @@ -15,7 +15,7 @@ export interface Metadata { export const cloneSchema = (schema: T) => { const copy = schema.describe(schema.description as string); copy._def[metaSymbol] = // clone for deep copy, issue #827 - clone(copy._def[metaSymbol]) || ({ examples: [] } satisfies Metadata); + clone(copy._def[metaSymbol]) || ({ examples: [] } satisfies Metadata); return copy; }; diff --git a/src/zod-plugin.ts b/src/zod-plugin.ts index 371838df7..9ff71656a 100644 --- a/src/zod-plugin.ts +++ b/src/zod-plugin.ts @@ -14,7 +14,7 @@ import { Metadata, cloneSchema, metaSymbol } from "./metadata"; declare module "zod" { interface ZodTypeDef { - [metaSymbol]?: Metadata; + [metaSymbol]?: Metadata; } interface ZodType { /** @desc Add an example value (before any transformations, can be called multiple times) */ diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts index 42a7b34db..d73f69d9e 100644 --- a/tests/unit/index.spec.ts +++ b/tests/unit/index.spec.ts @@ -50,7 +50,7 @@ describe("Index Entrypoint", () => { expectType({}); expectType({}); expectType({}); - expectType>({ examples: [] }); + expectType({ examples: [] }); expectType({ cors: true, logger: { level: "silent" } }); expectType({ app: {} as IRouter, From d172bc7c3f6db0ba1f617a67a3a060c518219b72 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 9 May 2024 19:27:16 +0200 Subject: [PATCH 24/40] Ref: unexposing Metadata (no longer needed after removing withMeta). --- src/index.ts | 1 - tests/unit/index.spec.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index d2722aefc..5bf6ce251 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,6 @@ export type { LoggerOverrides } from "./logger"; export type { FlatObject } from "./common-helpers"; export type { Method } from "./method"; export type { IOSchema } from "./io-schema"; -export type { Metadata } from "./metadata"; export type { CommonConfig, AppConfig, ServerConfig } from "./config-type"; export type { MiddlewareDefinition } from "./middleware"; export type { ResultHandlerDefinition } from "./result-handler"; diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts index d73f69d9e..9468b2ee5 100644 --- a/tests/unit/index.spec.ts +++ b/tests/unit/index.spec.ts @@ -14,7 +14,6 @@ import { IOSchema, InputSecurity, LoggerOverrides, - Metadata, Method, MiddlewareDefinition, MockOverrides, @@ -50,7 +49,6 @@ describe("Index Entrypoint", () => { expectType({}); expectType({}); expectType({}); - expectType({ examples: [] }); expectType({ cors: true, logger: { level: "silent" } }); expectType({ app: {} as IRouter, From ce7971fcff48b30e043717f3e0fb74674140bd38 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 11 May 2024 00:28:39 +0200 Subject: [PATCH 25/40] 19.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 962c1c932..174038f13 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 19.0.0-beta.2 + version: 19.0.0-beta.3 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index 59006d70f..434804392 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "19.0.0-beta.2", + "version": "19.0.0-beta.3", "description": "A Typescript library to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From f5d0f518f5f98e42bbde447c96fb3025b8c71570 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 11 May 2024 09:37:45 +0200 Subject: [PATCH 26/40] Readme: Notice on Zod Plugin. --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d750a8491..c8344c9bd 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Therefore, many basic tasks can be accomplished faster and easier, in particular - [Typescript](https://www.typescriptlang.org/) first. - Web server — [Express.js](https://expressjs.com/). -- Schema validation — [Zod 3.x](https://github.com/colinhacks/zod). +- Schema validation — [Zod 3.x](https://github.com/colinhacks/zod) including [Zod Plugin](#zod-plugin). - Supports any logger having `info()`, `debug()`, `error()` and `warn()` methods; - Built-in console logger with colorful and pretty inspections by default. - Generators: @@ -1057,6 +1057,14 @@ https://github.com/RobinTail/zod-sockets#subscriptions # Integration and Documentation +## Zod Plugin + +Express Zod API acts as a plugin for Zod, extending its functionality once you import anything from `express-zod-api`: + +- Adds `.example()` method to all Zod schemas for storing examples and reflecting them in the generated documentation; +- Adds `.label()` method to `ZodDefault` for replacing the default value in documentation with a label; +- Alters the `.brand()` method on all Zod schemas by making the assigned brand available in runtime. + ## Generating a Frontend Client You can generate a Typescript file containing the IO types of your API and a client for it. From 438e8f12c62c0e727fd2cc315fa084bde1caec7f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 11 May 2024 10:00:13 +0200 Subject: [PATCH 27/40] Readme: add plugin article to index. --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c8344c9bd..47388c106 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,10 @@ Start your API server with I/O schema validation and custom middlewares in minut 5. [Resources cleanup](#resources-cleanup) 6. [Subscriptions](#subscriptions) 7. [Integration and Documentation](#integration-and-documentation) - 1. [Generating a Frontend Client](#generating-a-frontend-client) - 2. [Creating a documentation](#creating-a-documentation) - 3. [Tagging the endpoints](#tagging-the-endpoints) + 1. [Zod Plugin](#zod-plugin) + 2. [Generating a Frontend Client](#generating-a-frontend-client) + 3. [Creating a documentation](#creating-a-documentation) + 4. [Tagging the endpoints](#tagging-the-endpoints) 8. [Caveats](#caveats) 1. [Coercive schema of Zod](#coercive-schema-of-zod) 2. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output) From b6fccb48405e1e987b23d657714c86dd141034a2 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 11 May 2024 11:35:53 +0200 Subject: [PATCH 28/40] Drop static options (#1755) Removing the deprecated argument --- CHANGELOG.md | 17 ++++---- src/endpoints-factory.ts | 16 +------ tests/unit/endpoints-factory.spec.ts | 65 ++++++++++++---------------- 3 files changed, 38 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c234bd6e..75df3347d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,10 @@ - Increased minimum supported versions: - Node: 18.18.0 or 20.9.0; - `zod`: 3.23.0. - - Removed the deprecated ~~`withMeta()`~~ is removed (see [v18.5.0](#v1850) for details); - - Freezed the arrays returned by the following methods or exposed by properties that supposed to be readonly: - - For `Endpoint` class: `getMethods()`, `getMimeTypes()`, `getResponses()`, `getScopes()`, `getTags()`; - - For `DependsOnMethod` class: `pairs`, `siblingMethods`. - - Changed the `ServerConfig` option `server.upload.beforeUpload`: - - The assigned function now accepts `request` instead of `app` and being called only for eligible requests; - - Restricting the upload can be achieved now by throwing an error from within. + - Removed the deprecated ~~`withMeta()`~~ (see [v18.5.0](#v1850) for details); + - Removed support for static options by `EndpointsFactory::addOptions()` (see [v18.6.0](#v1860) for details); + - Freezed the arrays returned by the methods or exposed by properties of `Endpoint` and `DependsOnMethod`; + - Changed the `ServerConfig` option `server.upload.beforeUpload`: accepts `request` instead of `app`; - Changed interface for `ez.raw()`: additional properties should be supplied as its argument, not via `.extend()`. - Features: - New configurable level `info` for built-in logger (higher than `debug`, but lower than `warn`); @@ -28,11 +25,13 @@ - The debug messages from uploader are enabled by default when the logger level is set to `debug`; - Specifying `rawParser` in config is no longer needed to enable the feature. - How to migrate confidently: - - Upgrade Node to latest version of 18.x, 20.x or 22.x; - - Upgrade `zod` to its latest version of 3.x; + - Upgrade Node to latest version of 18.x, 20.x or 22.x and `zod` to its latest 3.x; - Avoid mutating the readonly arrays; - If you're using ~~`withMeta()`~~: - Remove it and unwrap your schemas — you can use `.example()` method directly. + - If you're using `.addOptions()` on `EndpointsFactory` instance: + - Replace the argument with an async function returning those options; + - Or assign those options to `const` and import them where needed. - If you're using `ez.raw().extend()` for additional properties: - Supply them directly as an argument to `ez.raw()` — see the example below. - If you're using `beforeUpload` in your config: diff --git a/src/endpoints-factory.ts b/src/endpoints-factory.ts index d297a56fb..6e6606395 100644 --- a/src/endpoints-factory.ts +++ b/src/endpoints-factory.ts @@ -126,24 +126,12 @@ export class EndpointsFactory< ); } - /** @todo remove the static options in v19 - it makes no sense */ - public addOptions( - options: AOUT | (() => Promise), - ) { + public addOptions(getOptions: () => Promise) { return EndpointsFactory.#create( this.middlewares.concat( createMiddleware({ input: z.object({}), - middleware: - typeof options === "function" - ? options - : async ({ logger }) => { - logger.warn( - "addOptions: Static options are deprecated. " + - "Replace with async function or just import the const.", - ); - return options; - }, + middleware: getOptions, }), ), this.resultHandler, diff --git a/tests/unit/endpoints-factory.spec.ts b/tests/unit/endpoints-factory.spec.ts index 722828ad8..f07f895ab 100644 --- a/tests/unit/endpoints-factory.spec.ts +++ b/tests/unit/endpoints-factory.spec.ts @@ -92,47 +92,38 @@ describe("EndpointsFactory", () => { }); describe(".addOptions()", () => { - test.each([ - { + test("Should create a new factory with an empty-input middleware and the same result handler", async () => { + const resultHandlerMock = createResultHandler({ + getPositiveResponse: () => z.string(), + getNegativeResponse: () => z.string(), + handler: vi.fn(), + }); + const factory = new EndpointsFactory(resultHandlerMock); + const newFactory = factory.addOptions(async () => ({ option1: "some value", option2: "other value", - }, - async () => ({ + })); + expect(factory["middlewares"]).toStrictEqual([]); + expect(factory["resultHandler"]).toStrictEqual(resultHandlerMock); + expect(newFactory["middlewares"].length).toBe(1); + expect(newFactory["middlewares"][0].input).toBeInstanceOf(z.ZodObject); + expect( + (newFactory["middlewares"][0].input as z.AnyZodObject).shape, + ).toEqual({}); + expect( + await newFactory["middlewares"][0].middleware({ + input: {}, + options: {}, + request: {} as Request, + response: {} as Response, + logger: makeLoggerMock({ fnMethod: vi.fn }), + }), + ).toEqual({ option1: "some value", option2: "other value", - }), - ])( - "Should create a new factory with an empty-input middleware and the same result handler", - async (options) => { - const resultHandlerMock = createResultHandler({ - getPositiveResponse: () => z.string(), - getNegativeResponse: () => z.string(), - handler: vi.fn(), - }); - const factory = new EndpointsFactory(resultHandlerMock); - const newFactory = factory.addOptions(options); - expect(factory["middlewares"]).toStrictEqual([]); - expect(factory["resultHandler"]).toStrictEqual(resultHandlerMock); - expect(newFactory["middlewares"].length).toBe(1); - expect(newFactory["middlewares"][0].input).toBeInstanceOf(z.ZodObject); - expect( - (newFactory["middlewares"][0].input as z.AnyZodObject).shape, - ).toEqual({}); - expect( - await newFactory["middlewares"][0].middleware({ - input: {}, - options: {}, - request: {} as Request, - response: {} as Response, - logger: makeLoggerMock({ fnMethod: vi.fn }), - }), - ).toEqual({ - option1: "some value", - option2: "other value", - }); - expect(newFactory["resultHandler"]).toStrictEqual(resultHandlerMock); - }, - ); + }); + expect(newFactory["resultHandler"]).toStrictEqual(resultHandlerMock); + }); }); describe.each(["addExpressMiddleware" as const, "use" as const])( From 2c33a666ebd309c6a44326c4dc42783a0aa51ecc Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 11 May 2024 11:40:05 +0200 Subject: [PATCH 29/40] Changelog: shortening. --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75df3347d..5cce8a8ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,7 @@ ### v19.0.0 - **Breaking changes**: - - Increased minimum supported versions: - - Node: 18.18.0 or 20.9.0; - - `zod`: 3.23.0. + - Minimum supported versions: Node 18.18.0, 20.9.0 or 22.0.0; `zod` 3.23.0. - Removed the deprecated ~~`withMeta()`~~ (see [v18.5.0](#v1850) for details); - Removed support for static options by `EndpointsFactory::addOptions()` (see [v18.6.0](#v1860) for details); - Freezed the arrays returned by the methods or exposed by properties of `Endpoint` and `DependsOnMethod`; From cb5fb585e4a94032621b9ba29b471e651ba52e3c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 11 May 2024 11:41:48 +0200 Subject: [PATCH 30/40] 19.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 174038f13..eb828c1ad 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 19.0.0-beta.3 + version: 19.0.0-beta.4 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index 434804392..0da48f825 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "19.0.0-beta.3", + "version": "19.0.0-beta.4", "description": "A Typescript library to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 8d32a37f101abb14292d3f19a7bab4786c302c89 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sat, 11 May 2024 13:34:16 +0200 Subject: [PATCH 31/40] Fix: child logger should be available when using `attachRouting()` (#1756) Due to #1748 and #1741 --- src/server-helpers.ts | 4 ++-- src/server.ts | 47 ++++++++++++++++++++++++++------------- tests/unit/server.spec.ts | 2 +- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/server-helpers.ts b/src/server-helpers.ts index f4d967fb3..1371f321a 100644 --- a/src/server-helpers.ts +++ b/src/server-helpers.ts @@ -3,7 +3,7 @@ import { metaSymbol } from "./metadata"; import { loadPeer } from "./peer-helpers"; import { AnyResultHandlerDefinition } from "./result-handler"; import { AbstractLogger } from "./logger"; -import { ServerConfig } from "./config-type"; +import { CommonConfig, ServerConfig } from "./config-type"; import { ErrorRequestHandler, RequestHandler, Response } from "express"; import createHttpError, { isHttpError } from "http-errors"; import { lastResortHandler } from "./last-resort"; @@ -131,7 +131,7 @@ export const createLoggingMiddleware = config, }: { rootLogger: AbstractLogger; - config: ServerConfig; + config: CommonConfig; }): RequestHandler => async (request, response: LocalResponse, next) => { const logger = config.childLoggerProvider diff --git a/src/server.ts b/src/server.ts index 08e0758ef..a560231c2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,29 +20,46 @@ const makeCommonEntities = (config: CommonConfig) => { if (config.startupLogo !== false) { console.log(getStartupLogo()); } - const commons = { - errorHandler: config.errorHandler || defaultResultHandler, - rootLogger: isBuiltinLoggerConfig(config.logger) - ? createLogger(config.logger) - : config.logger, + const errorHandler = config.errorHandler || defaultResultHandler; + const rootLogger = isBuiltinLoggerConfig(config.logger) + ? createLogger(config.logger) + : config.logger; + rootLogger.debug("Running", process.env.TSUP_BUILD || "from sources"); + const loggingMiddleware = createLoggingMiddleware({ rootLogger, config }); + const notFoundHandler = createNotFoundHandler({ rootLogger, errorHandler }); + const parserFailureHandler = createParserFailureHandler({ + rootLogger, + errorHandler, + }); + return { + rootLogger, + errorHandler, + notFoundHandler, + parserFailureHandler, + loggingMiddleware, }; - commons.rootLogger.debug("Running", process.env.TSUP_BUILD || "from sources"); - const notFoundHandler = createNotFoundHandler(commons); - const parserFailureHandler = createParserFailureHandler(commons); - return { ...commons, notFoundHandler, parserFailureHandler }; }; export const attachRouting = (config: AppConfig, routing: Routing) => { - const { rootLogger, notFoundHandler } = makeCommonEntities(config); - initRouting({ app: config.app, routing, rootLogger, config }); + const { rootLogger, notFoundHandler, loggingMiddleware } = + makeCommonEntities(config); + initRouting({ + app: config.app.use(loggingMiddleware), + routing, + rootLogger, + config, + }); return { notFoundHandler, logger: rootLogger }; }; export const createServer = async (config: ServerConfig, routing: Routing) => { - const app = express().disable("x-powered-by"); - const { rootLogger, notFoundHandler, parserFailureHandler } = - makeCommonEntities(config); - app.use(createLoggingMiddleware({ rootLogger, config })); + const { + rootLogger, + notFoundHandler, + parserFailureHandler, + loggingMiddleware, + } = makeCommonEntities(config); + const app = express().disable("x-powered-by").use(loggingMiddleware); if (config.server.compression) { const compressor = await loadPeer("compression"); diff --git a/tests/unit/server.spec.ts b/tests/unit/server.spec.ts index a04998b89..8c223218b 100644 --- a/tests/unit/server.spec.ts +++ b/tests/unit/server.spec.ts @@ -345,7 +345,7 @@ describe("Server", () => { ); expect(logger).toEqual(customLogger); expect(typeof notFoundHandler).toBe("function"); - expect(appMock.use).toHaveBeenCalledTimes(0); + expect(appMock.use).toHaveBeenCalledTimes(1); // createLoggingMiddleware expect(configMock.errorHandler.handler).toHaveBeenCalledTimes(0); expect(infoMethod).toHaveBeenCalledTimes(0); expect(appMock.get).toHaveBeenCalledTimes(1); From 9a5f45eb69f88987889917e2b0038a788218b2de Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 12 May 2024 20:10:11 +0200 Subject: [PATCH 32/40] Increasing minimal versions of express and upload peers (#1760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I almost forget to do an important thing for v19: - Express should be at least 4.19.2 (lower ones are [vulnurable](https://github.com/expressjs/express/security/advisories/GHSA-rv95-896h-c2vc)) - File uploads should be at least 1.5.0 — it has customizible logger that I made and that is being used by the library --- CHANGELOG.md | 8 ++++++-- package.json | 12 ++++++------ yarn.lock | 6 +++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afd46d828..c20bab3e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ ### v19.0.0 - **Breaking changes**: - - Minimum supported versions: Node 18.18.0, 20.9.0 or 22.0.0; `zod` 3.23.0. + - Minimum supported versions: + - For Node.js: 18.18.0, 20.9.0 or 22.0.0; + - For `zod`: 3.23.0; + - For `express`: [4.19.2](https://github.com/expressjs/express/security/advisories/GHSA-rv95-896h-c2vc); + - For `express-fileupload` and `@types/express-fileupload`: 1.5.0. - Removed the deprecated ~~`withMeta()`~~ (see [v18.5.0](#v1850) for details); - Removed support for static options by `EndpointsFactory::addOptions()` (see [v18.6.0](#v1860) for details); - Freezed the arrays returned by the methods or exposed by properties of `Endpoint` and `DependsOnMethod`; @@ -23,7 +27,7 @@ - The debug messages from uploader are enabled by default when the logger level is set to `debug`; - Specifying `rawParser` in config is no longer needed to enable the feature. - How to migrate confidently: - - Upgrade Node to latest version of 18.x, 20.x or 22.x and `zod` to its latest 3.x; + - Upgrade Node.js, `zod`, `express`, `express-fileupload` and `@types/express-fileupload` accordingly; - Avoid mutating the readonly arrays; - If you're using ~~`withMeta()`~~: - Remove it and unwrap your schemas — you can use `.example()` method directly. diff --git a/package.json b/package.json index 0da48f825..d07bd89e4 100644 --- a/package.json +++ b/package.json @@ -73,13 +73,13 @@ "peerDependencies": { "@types/compression": "^1.7.5", "@types/express": "^4.17.13", - "@types/express-fileupload": "^1.4.4", + "@types/express-fileupload": "^1.5.0", "@types/http-errors": "^2.0.2", "@types/jest": "*", "@types/node": "*", "compression": "^1.7.4", - "express": "^4.18.2", - "express-fileupload": "^1.4.3", + "express": "^4.19.2", + "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "jest": ">=28 <30", "prettier": "^3.1.0", @@ -128,7 +128,7 @@ "@types/compression": "^1.7.5", "@types/cors": "^2.8.14", "@types/express": "^4.17.17", - "@types/express-fileupload": "^1.4.4", + "@types/express-fileupload": "^1.5.0", "@types/http-errors": "^2.0.2", "@types/node": "^20.8.4", "@types/ramda": "^0.30.0", @@ -146,8 +146,8 @@ "eslint-plugin-import": "^2.28.1", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-unicorn": "^53.0.0", - "express": "^4.18.2", - "express-fileupload": "^1.4.3", + "express": "^4.19.2", + "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "husky": "^9.0.5", "make-coverage-badge": "^1.2.0", diff --git a/yarn.lock b/yarn.lock index 2d23937b9..c3db87d60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -757,7 +757,7 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== -"@types/express-fileupload@^1.4.4": +"@types/express-fileupload@^1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@types/express-fileupload/-/express-fileupload-1.5.0.tgz#ef7c98bbe525db7f1daa3f4f3a3067b5cdc774bf" integrity sha512-Y9v88IC5ItAxkKwfnyIi1y0jSZwTMY4jqXUQLZ3jFhYJlLdRnN919bKBNM8jbVVD2cxywA/uEC1kNNpZQGwx7Q== @@ -2178,14 +2178,14 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" -express-fileupload@^1.4.3: +express-fileupload@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/express-fileupload/-/express-fileupload-1.5.0.tgz#afce078126c5393fc24b5ab9a79f02006f70e274" integrity sha512-jSW3w9evqM37VWkEPkL2Ck5wUo2a8qa03MH+Ou/0ZSTpNlQFBvSLjU12k2nYcHhaMPv4JVvv6+Ac1OuLgUZb7w== dependencies: busboy "^1.6.0" -express@^4.18.2: +express@^4.19.2: version "4.19.2" resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== From 28ed00e069f5bc2ac19a34376edbac3e94f4e6a8 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Sun, 12 May 2024 22:39:55 +0200 Subject: [PATCH 33/40] Cleanup: Removing the requirement on configuring rawParser (#1762) Due to #1741 and #1745 --- example/config.ts | 2 -- example/endpoints/accept-raw.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/example/config.ts b/example/config.ts index 0f58cf705..b87236862 100644 --- a/example/config.ts +++ b/example/config.ts @@ -1,4 +1,3 @@ -import express from "express"; import { createConfig } from "../src"; import ui from "swagger-ui-express"; import yaml from "yaml"; @@ -13,7 +12,6 @@ export const config = createConfig({ limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint }, compression: true, // affects sendAvatarEndpoint - rawParser: express.raw(), // required for rawAcceptingEndpoint beforeRouting: async ({ app }) => { // third-party middlewares serving their own routes or establishing their own routing besides the API const documentation = yaml.parse( diff --git a/example/endpoints/accept-raw.ts b/example/endpoints/accept-raw.ts index 2e04d0877..1eaba31c2 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -10,6 +10,6 @@ export const rawAcceptingEndpoint = taggedEndpointsFactory.build({ }), output: z.object({ length: z.number().int().nonnegative() }), handler: async ({ input: { raw } }) => ({ - length: raw.length, // input.raw is populated automatically when rawParser is set in config + length: raw.length, // input.raw is populated automatically by the corresponding parser }), }); From 7d39981c15744fe9ccc969cb399f67357cf8a4b6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 13 May 2024 19:28:58 +0200 Subject: [PATCH 34/40] Fix test name. --- tests/system/system.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/system.spec.ts b/tests/system/system.spec.ts index 9cf18cbce..f1dddcc95 100644 --- a/tests/system/system.spec.ts +++ b/tests/system/system.spec.ts @@ -289,7 +289,7 @@ describe("App", async () => { ); }); - test("Should treat custom errors in middleware input validations as they are", async () => { + test("Should treat custom errors in endpoint input validations as they are", async () => { const response = await fetch( `http://127.0.0.1:${port}/v1/faulty?epError=1`, { From e1ca69e4f622a7df6d55a8640fc93c5ffbb2f3bd Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 13 May 2024 19:31:36 +0200 Subject: [PATCH 35/40] Security: releasing earlier --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 5b4bc8b42..a56c9014d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Release | Supported | | ------: | :------ | :----------------: | -| 19.x.x | 06.2024 | :white_check_mark: | +| 19.x.x | 05.2024 | :white_check_mark: | | 18.x.x | 04.2024 | :white_check_mark: | | 17.x.x | 02.2024 | :white_check_mark: | | 16.x.x | 12.2023 | :white_check_mark: | From ba1e5596efae2c27b330de425b00a72401044405 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 13 May 2024 19:52:00 +0200 Subject: [PATCH 36/40] Changelog: reviewed --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c20bab3e1..1cb89760e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,12 @@ ### v19.0.0 - **Breaking changes**: - - Minimum supported versions: + - Increased the minimum supported versions: - For Node.js: 18.18.0, 20.9.0 or 22.0.0; - For `zod`: 3.23.0; - For `express`: [4.19.2](https://github.com/expressjs/express/security/advisories/GHSA-rv95-896h-c2vc); - For `express-fileupload` and `@types/express-fileupload`: 1.5.0. - - Removed the deprecated ~~`withMeta()`~~ (see [v18.5.0](#v1850) for details); + - Removed the deprecated method ~~`withMeta()`~~ (see [v18.5.0](#v1850) for details); - Removed support for static options by `EndpointsFactory::addOptions()` (see [v18.6.0](#v1860) for details); - Freezed the arrays returned by the methods or exposed by properties of `Endpoint` and `DependsOnMethod`; - Changed the `ServerConfig` option `server.upload.beforeUpload`: accepts `request` instead of `app`; @@ -20,12 +20,12 @@ - Selective parsers equipped with a child logger: - There are 3 types of endpoints depending on their input schema: having `ez.upload()`, having `ez.raw()`, others; - Depending on that type, only the parsers needed for certain endpoint are processed; - - This makes all requests eligible for the assigned parsers and reverts changes made in [v18.5.2](#v1852). + - This makes all requests eligible for the assigned parsers and reverts changes made in [v18.5.2](#v1852); + - Specifying `rawParser` in config is no longer needed to enable the feature. - Non-breaking significant changes: - Request logging reflects the actual path instead of the configured route, and it's placed in front of parsing: - The severity of those messaged reduced from `info` to `debug`; - The debug messages from uploader are enabled by default when the logger level is set to `debug`; - - Specifying `rawParser` in config is no longer needed to enable the feature. - How to migrate confidently: - Upgrade Node.js, `zod`, `express`, `express-fileupload` and `@types/express-fileupload` accordingly; - Avoid mutating the readonly arrays; From 994101742dbd749eb94d2ce3e15c6f9722ca69e8 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 13 May 2024 22:25:00 +0200 Subject: [PATCH 37/40] 19.0.0-beta.5 --- 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 eb828c1ad..5c6993060 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 19.0.0-beta.4 + version: 19.0.0-beta.5 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index d07bd89e4..93234462d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "19.0.0-beta.4", + "version": "19.0.0-beta.5", "description": "A Typescript library to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 6d84c5a0b18ef471d6f3afd20ce74849006b2cd6 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 13 May 2024 22:50:32 +0200 Subject: [PATCH 38/40] Notice: `beforeRouting` now called before parsing as well (#1766) --- CHANGELOG.md | 8 ++++++-- src/config-type.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb89760e..36791fa74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,10 @@ - Removed the deprecated method ~~`withMeta()`~~ (see [v18.5.0](#v1850) for details); - Removed support for static options by `EndpointsFactory::addOptions()` (see [v18.6.0](#v1860) for details); - Freezed the arrays returned by the methods or exposed by properties of `Endpoint` and `DependsOnMethod`; - - Changed the `ServerConfig` option `server.upload.beforeUpload`: accepts `request` instead of `app`; - - Changed interface for `ez.raw()`: additional properties should be supplied as its argument, not via `.extend()`. + - Changed interface for `ez.raw()`: additional properties should be supplied as its argument, not via `.extend()`; + - Changed the following config options: + - The function assigned to `server.upload.beforeUpload` now accepts `request` instead of `app`; + - The function assigned to `server.beforeRouting` is now called before parsing too. - Features: - New configurable level `info` for built-in logger (higher than `debug`, but lower than `warn`); - Selective parsers equipped with a child logger: @@ -38,6 +40,8 @@ - Supply them directly as an argument to `ez.raw()` — see the example below. - If you're using `beforeUpload` in your config: - Adjust the implementation according to the example below. + - If you're using `beforeRouting` in your config for anything that requires a parsed request body: + - Add the required parsers using `app.use()` statements to the assigned function. - If you're having `rawParser: express.raw()` in your config: - You can now remove this line (it's the default value now), unless you're having any customizations. diff --git a/src/config-type.ts b/src/config-type.ts index 7edbac349..c8e8dc6b5 100644 --- a/src/config-type.ts +++ b/src/config-type.ts @@ -148,7 +148,7 @@ export interface ServerConfig * */ rawParser?: RequestHandler; /** - * @desc A code to execute after parsing the request body but before processing the Routing of your API. + * @desc A code to execute before processing the Routing of your API (and before parsing). * @desc This can be a good place for express middlewares establishing their own routes. * @desc It can help to avoid making a DIY solution based on the attachRouting() approach. * @default undefined From 2ad5b19920023beb8639f63088a743b063e13b4e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 13 May 2024 22:51:14 +0200 Subject: [PATCH 39/40] 19.0.0-beta.6 --- 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 5c6993060..280089450 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 19.0.0-beta.5 + version: 19.0.0-beta.6 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index 93234462d..ae94fe70a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "19.0.0-beta.5", + "version": "19.0.0-beta.6", "description": "A Typescript library to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { From 02a10efa9dd0572b7edab6e9a02234950330c814 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Mon, 13 May 2024 23:20:25 +0200 Subject: [PATCH 40/40] CI: no more PRs to v19 --- .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 66c4aed77..5b2c515a7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master, v16, v17, v18, make-v19 ] + branches: [ master, v16, v17, v18 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v16, v17, v18, make-v19 ] + branches: [ master, v16, v17, v18 ] schedule: - cron: '26 8 * * 1' diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 442bc8c57..c5dcff3c4 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, v16, v17, v18, make-v19 ] + branches: [ master, v16, v17, v18 ] pull_request: - branches: [ master, v16, v17, v18, make-v19 ] + branches: [ master, v16, v17, v18 ] jobs: build: diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 46d9bb170..d2539c0ae 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -2,9 +2,9 @@ name: OpenAPI Validation on: push: - branches: [ master, v16, v17, v18, make-v19 ] + branches: [ master, v16, v17, v18 ] pull_request: - branches: [ master, v16, v17, v18, make-v19 ] + branches: [ master, v16, v17, v18 ] jobs: