diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index cd2946687..5b2c515a7 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 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v15, v16, v17 ] + 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 9598179b8..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, v15, v16, v17 ] + branches: [ master, v16, v17, v18 ] pull_request: - branches: [ master, v15, v16, v17 ] + branches: [ master, v16, v17, v18 ] jobs: build: @@ -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.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 @@ -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 @@ -75,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/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index f8a65a788..d2539c0ae 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 ] pull_request: - branches: [ master, v15, v16, v17 ] + branches: [ master, v16, v17, v18 ] jobs: 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/CHANGELOG.md b/CHANGELOG.md index 3a8d98b0c..36791fa74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,95 @@ # Changelog +## Version 19 + +### v19.0.0 + +- **Breaking changes**: + - 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 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 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: + - 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); + - 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`; +- How to migrate confidently: + - 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. + - 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: + - 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. + +```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"); + } + }, + }, + }, +}); +``` + +```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.6.2 diff --git a/README.md b/README.md index 985d4a1c9..1673cb7a1 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,10 @@ Start your API server with I/O schema validation and custom middlewares in minut 4. [Accepting raw data](#accepting-raw-data) 5. [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) @@ -87,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: @@ -801,16 +802,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: @@ -823,13 +816,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"); + } }, }, }, @@ -1019,26 +1009,18 @@ 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 (which is an alias for `z.object({ raw: ez.file("buffer") })`) 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", - 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 @@ -1059,6 +1041,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. diff --git a/SECURITY.md b/SECURITY.md index 4dd050f73..a56c9014d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,10 +4,11 @@ | Version | Release | Supported | | ------: | :------ | :----------------: | +| 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: | -| 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: | diff --git a/example/config.ts b/example/config.ts index 4d49a12e4..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"; @@ -9,12 +8,10 @@ export const config = createConfig({ server: { listen: 8090, upload: { - debug: true, limits: { fileSize: 51200 }, 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 a6eb3faa7..1eaba31c2 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -5,11 +5,11 @@ 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 + 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 + length: raw.length, // input.raw is populated automatically by the corresponding parser }), }); diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 549f90c37..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: 18.6.2 + version: 19.0.0-beta.6 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index 0536d3456..ae94fe70a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "18.6.2", + "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": { @@ -42,6 +42,7 @@ "install_hooks": "husky" }, "type": "module", + "sideEffects": true, "main": "dist/index.cjs", "types": "dist/index.d.ts", "module": "dist/index.js", @@ -62,7 +63,7 @@ "*.md" ], "engines": { - "node": "^18.0.0 || ^20.0.0 || ^22.0.0" + "node": "^18.18.0 || ^20.9.0 || ^22.0.0" }, "dependencies": { "ansis": "^3.1.0", @@ -72,19 +73,19 @@ "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", "typescript": "^5.1.3", "vitest": "^1.0.4", - "zod": "^3.22.3" + "zod": "^3.23.0" }, "peerDependenciesMeta": { "@types/compression": { @@ -127,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", @@ -145,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/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 af80d0313..c8e8dc6b5 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,32 +131,30 @@ 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; /** - * @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() + * @desc Custom raw parser (assigns Buffer to request body) + * @default express.raw() * @link https://expressjs.com/en/4x/api.html#express.raw * */ 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 * @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/date-in-schema.ts b/src/date-in-schema.ts index 116758652..e12598599 100644 --- a/src/date-in-schema.ts +++ b/src/date-in-schema.ts @@ -1,18 +1,19 @@ 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 ezDateInBrand = Symbol("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, - 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 as symbol); }; + +export type DateInSchema = ReturnType; diff --git a/src/date-out-schema.ts b/src/date-out-schema.ts index 6b832aab4..114037033 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 as symbol); + +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/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 ee5bbe959..d939948e0 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -52,20 +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 { isoDateRegex } from "./schema-helpers"; +import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, HandlingVariant, @@ -73,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 */ @@ -100,7 +99,7 @@ interface ReqResDepictHelperCommonProps "serializer" | "getRef" | "makeRef" | "path" | "method" > { schema: z.ZodTypeAny; - mimeTypes: string[]; + mimeTypes: ReadonlyArray; composition: "inline" | "components"; description?: string; } @@ -133,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> = ({ @@ -147,7 +146,7 @@ export const depictAny: Depicter = () => ({ format: "any", }); -export const depictUpload: Depicter = (ctx) => { +export const depictUpload: Depicter = (ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -161,18 +160,18 @@ export const depictUpload: Depicter = (ctx) => { }; }; -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", - ) - ? "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 }, @@ -321,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({ @@ -333,14 +332,14 @@ 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, }, }; }; -export const depictDateOut: Depicter = (ctx) => { +export const depictDateOut: Depicter = (ctx) => { assert( ctx.isResponse, new DocumentationError({ @@ -632,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 @@ -673,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 @@ -783,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/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 564b8a817..2b297e70f 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; public abstract getRequestType(): ContentType; } @@ -72,15 +74,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; readonly #requestType: ContentType; @@ -114,9 +119,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)) { @@ -128,17 +133,18 @@ export class Endpoint< ); } this.#responses = { - positive: normalizeApiResponse( - resultHandler.getPositiveResponse(outputSchema), - { + positive: Object.freeze( + normalizeApiResponse(resultHandler.getPositiveResponse(outputSchema), { mimeTypes: [contentTypes.json], statusCodes: [defaultStatusCodes.positive], - }, + }), + ), + negative: Object.freeze( + normalizeApiResponse(resultHandler.getNegativeResponse(), { + mimeTypes: [contentTypes.json], + statusCodes: [defaultStatusCodes.negative], + }), ), - negative: normalizeApiResponse(resultHandler.getNegativeResponse(), { - mimeTypes: [contentTypes.json], - statusCodes: [defaultStatusCodes.negative], - }), }; for (const [variant, responses] of Object.entries(this.#responses)) { assert( @@ -154,9 +160,13 @@ export class Endpoint< ? "raw" : "json"; this.#mimeTypes = { - input: [contentTypes[this.#requestType]], - positive: this.#responses.positive.flatMap(({ mimeTypes }) => mimeTypes), - negative: this.#responses.negative.flatMap(({ mimeTypes }) => mimeTypes), + input: Object.freeze([contentTypes[this.#requestType]]), + positive: Object.freeze( + this.#responses.positive.flatMap(({ mimeTypes }) => mimeTypes), + ), + negative: Object.freeze( + this.#responses.negative.flatMap(({ mimeTypes }) => mimeTypes), + ), }; } @@ -164,7 +174,7 @@ export class Endpoint< return this.#descriptions[variant]; } - public override getMethods(): Method[] { + public override getMethods() { return this.#methods; } @@ -200,11 +210,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/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/src/file-schema.ts b/src/file-schema.ts index e02500d8c..b5382f812 100644 --- a/src/file-schema.ts +++ b/src/file-schema.ts @@ -1,30 +1,20 @@ 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", }); -/** @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) - ); - }, + 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; @@ -35,3 +25,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 c8a143655..5bf6ce251 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 { @@ -28,7 +30,6 @@ export { InputValidationError, MissingPeerError, } from "./errors"; -export { withMeta } from "./metadata"; export { testEndpoint } from "./testing"; export { Integration } from "./integration"; @@ -41,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/src/integration.ts b/src/integration.ts index 2b189429b..f369573cd 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/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/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/metadata.ts b/src/metadata.ts index f3a08f4f5..7f9ce1e1c 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[]; +export interface Metadata { + examples: unknown[]; /** @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; + clone(copy._def[metaSymbol]) || ({ examples: [] } satisfies Metadata); 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,21 +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; - -/** - * @deprecated no longer required - * @todo remove in v19 - * */ -export const withMeta = (schema: T) => schema; 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..9cc492c7c 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 as symbol); export type RawSchema = ReturnType; 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; 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/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/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/server-helpers.ts b/src/server-helpers.ts index eebfbcf63..1371f321a 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 { CommonConfig, 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,63 @@ 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({ + debug: true, + ...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, severity decreased to debug level */ +export const createLoggingMiddleware = + ({ + rootLogger, + config, + }: { + rootLogger: AbstractLogger; + config: CommonConfig; + }): RequestHandler => + async (request, response: LocalResponse, next) => { + const logger = config.childLoggerProvider + ? await config.childLoggerProvider({ request, parent: rootLogger }) + : rootLogger; + logger.debug(`${request.method}: ${request.path}`); + response.locals[metaSymbol] = { logger }; + next(); + }; diff --git a/src/server.ts b/src/server.ts index 6c326616d..a560231c2 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,26 +20,47 @@ const makeCommonEntities = (config: CommonConfig) => { if (config.startupLogo !== false) { console.log(getStartupLogo()); } - const rootLogger: AbstractLogger = isBuiltinLoggerConfig(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 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 loggingMiddleware = createLoggingMiddleware({ rootLogger, config }); + const notFoundHandler = createNotFoundHandler({ rootLogger, errorHandler }); + const parserFailureHandler = createParserFailureHandler({ + rootLogger, + errorHandler, + }); + return { + rootLogger, + errorHandler, + notFoundHandler, + parserFailureHandler, + loggingMiddleware, + }; }; 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, + loggingMiddleware, + } = makeCommonEntities(config); + const app = express().disable("x-powered-by").use(loggingMiddleware); + if (config.server.compression) { const compressor = await loadPeer("compression"); app.use( @@ -50,46 +71,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 || express.raw(), 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/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"); 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/src/upload-schema.ts b/src/upload-schema.ts index c88456c03..aa94ccb7b 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 as symbol); + +export type UploadSchema = ReturnType; diff --git a/src/zod-plugin.ts b/src/zod-plugin.ts new file mode 100644 index 000000000..9ff71656a --- /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/compat/package.json b/tests/compat/package.json index 83c5bff9a..d7098444a 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ "devDependencies": { "jest": "^30.0.0-alpha.4", "@types/jest": "^29.5.12", - "@swc/core": "^1.3.100", + "@swc/core": "^1.5.0", "@swc/jest": "^0.2.29" } } 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/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/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(), +}; diff --git a/tests/system/example.spec.ts b/tests/system/example.spec.ts index 55afc030a..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(); }); @@ -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"], diff --git a/tests/system/system.spec.ts b/tests/system/system.spec.ts index 118f8afb7..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`, { @@ -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/__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__/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/__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/__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/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 6da35f11e..4db7ca4b5 100644 --- a/tests/unit/date-in-schema.spec.ts +++ b/tests/unit/date-in-schema.spec.ts @@ -1,23 +1,15 @@ import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezDateInBrand } from "../../src/date-in-schema"; 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"; +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/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])( diff --git a/tests/unit/file-schema.spec.ts b/tests/unit/file-schema.spec.ts index 990a903e1..1ed592503 100644 --- a/tests/unit/file-schema.spec.ts +++ b/tests/unit/file-schema.spec.ts @@ -1,48 +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 { 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"; +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/index.spec.ts b/tests/unit/index.spec.ts index 42a7b34db..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, 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/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/metadata.spec.ts b/tests/unit/metadata.spec.ts index 33a6cc824..96af2590f 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 { copyMeta, 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(); @@ -73,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"); }); }); @@ -121,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", () => { @@ -143,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 }, @@ -165,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/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..51a62e2f8 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,102 @@ 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({ + debug: true, + 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..8c223218b 100644 --- a/tests/unit/server.spec.ts +++ b/tests/unit/server.spec.ts @@ -1,11 +1,11 @@ -import { makeRequestMock } from "../../src/testing"; +import { moveRaw } from "../../src/server-helpers"; import { givePort } from "../helpers"; import { appMock, compressionMock, expressJsonMock, expressMock, - fileUploadMock, + expressRawMock, } from "../express-mock"; import { createHttpsServerSpy, @@ -21,6 +21,7 @@ import { createLogger, createServer, defaultResultHandler, + ez, } from "../../src"; import express from "express"; import { afterAll, describe, expect, test, vi } from "vitest"; @@ -62,19 +63,30 @@ 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)); }); - 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(); @@ -82,6 +94,7 @@ describe("Server", () => { server: { listen: { port }, // testing Net::ListenOptions jsonParser: vi.fn(), + rawParser: vi.fn(), beforeRouting: vi.fn(), }, cors: true, @@ -91,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(), @@ -103,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( @@ -112,8 +132,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 +141,36 @@ 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.options).toHaveBeenCalledTimes(1); - expect(appMock.options.mock.calls[0][0]).toBe("/v1/test"); + expect(appMock.post).toHaveBeenCalledWith( + "/v1/test", + configMock.server.jsonParser, + expect.any(Function), // endpoint + ); + 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 }, @@ -193,7 +237,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,33 +260,29 @@ 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 () => { - const rawParserMock = vi.fn(); const configMock = { server: { listen: givePort(), - rawParser: rawParserMock, }, cors: true, startupLogo: false, @@ -252,26 +292,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", + expressRawMock, + moveRaw, + expect.any(Function), // endpoint + ); }); }); @@ -310,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); 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 2e8d506c8..34d9efcb3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "tests/bench", "tests/*.ts", "tools", - "*.config.ts" - ] + "*.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"; diff --git a/yarn.lock b/yarn.lock index d240e8007..7bd0b5b83 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== @@ -2161,14 +2161,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==