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 0d80ad776..92aca4903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,86 @@ ### v19.0.0 -- Minimum supported versions: - - Node: 18.18.0 or 20.9.0, - - `zod`: 3.23.0. -- The deprecated ~~`withMeta()`~~ is removed: - - See the changes to [v18.5.0](#v1850) on details. -- Several public methods and properties exposing arrays from class instances made readonly and frozen: - - On `Endpoint`: `.getMethods()`, `.getMimeTypes()`, `.getResponses()`, `.getScopes()`, `.getTags()`, - - On `DependsOnMethod`: `.pairs`, `.siblingMethods`. +- **Breaking changes**: + - Increased minimum supported versions: + - Node: 18.18.0 or 20.9.0; + - `zod`: 3.23.0. + - Removed the deprecated ~~`withMeta()`~~ is removed (see [v18.5.0](#v1850) for details); + - Freezed the arrays returned by the following methods or exposed by properties that supposed to be readonly: + - For `Endpoint` class: `getMethods()`, `getMimeTypes()`, `getResponses()`, `getScopes()`, `getTags()`; + - For `DependsOnMethod` class: `pairs`, `siblingMethods`. + - Changed the `ServerConfig` option `server.upload.beforeUpload`: + - The assigned function now accepts `request` instead of `app` and being called only for eligible requests; + - Restricting the upload can be achieved now by throwing an error from within. + - Changed interface for `ez.raw()`: additional properties should be supplied as its argument, not via `.extend()`. +- Features: + - New configurable level `info` for built-in logger (higher than `debug`, but lower than `warn`); + - Selective parsers equipped with a child logger: + - There are 3 types of endpoints depending on their input schema: having `ez.upload()`, having `ez.raw()`, others; + - Depending on that type, only the parsers needed for certain endpoint are processed; + - This makes all requests eligible for the assigned parsers and reverts changes made in [v18.5.2](#v1852). +- Non-breaking significant changes: + - Request logging reflects the actual path instead of the configured route, and it's placed in front of parsing: + - The severity of those messaged reduced from `info` to `debug`; + - The debug messages from uploader are enabled by default when the logger level is set to `debug`; + - Specifying `rawParser` in config is no longer needed to enable the feature. +- How to migrate confidently: + - Upgrade Node to latest version of 18.x, 20.x or 22.x; + - Upgrade `zod` to its latest version of 3.x; + - Avoid mutating the readonly arrays; + - If you're using ~~`withMeta()`~~: + - Remove it and unwrap your schemas — you can use `.example()` method directly. + - If you're using `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 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 diff --git a/README.md b/README.md index ebc2b01d7..d750a8491 100644 --- a/README.md +++ b/README.md @@ -783,16 +783,8 @@ const config = createConfig({ ``` Refer to [documentation](https://www.npmjs.com/package/express-fileupload#available-options) on available options. -Some options are forced in order to ensure the correct workflow: - -```json5 -{ - abortOnLimit: false, - parseNested: true, - logger: {}, // the configured logger, using its .debug() method -} -``` - +Some options are forced in order to ensure the correct workflow: `abortOnLimit: false`, `parseNested: true`, `logger` +is assigned with `.debug()` method of the configured logger, and `debug` is enabled by default. The `limitHandler` option is replaced by the `limitError` one. You can also connect an additional middleware for restricting the ability to upload using the `beforeUpload` option. So the configuration for the limited and restricted upload might look this way: @@ -805,13 +797,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"); + } }, }, }, @@ -1001,26 +990,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 diff --git a/example/config.ts b/example/config.ts index c08214fb4..4bbb4ca31 100644 --- a/example/config.ts +++ b/example/config.ts @@ -13,7 +13,6 @@ export const config = createConfig({ server: { listen: 8090, upload: { - debug: true, limits: { fileSize: 51200 }, limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint }, diff --git a/example/endpoints/accept-raw.ts b/example/endpoints/accept-raw.ts index a6eb3faa7..2e04d0877 100644 --- a/example/endpoints/accept-raw.ts +++ b/example/endpoints/accept-raw.ts @@ -5,9 +5,9 @@ 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 diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 496fce9e3..962c1c932 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 18.5.1 + version: 19.0.0-beta.2 paths: /v1/user/retrieve: get: diff --git a/package.json b/package.json index 16a43b712..60fd335a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "18.5.1", + "version": "19.0.0-beta.2", "description": "A Typescript library to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { @@ -42,6 +42,7 @@ "install_hooks": "husky" }, "type": "module", + "sideEffects": true, "main": "dist/index.cjs", "types": "dist/index.d.ts", "module": "dist/index.js", @@ -144,7 +145,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "^2.28.1", "eslint-plugin-prettier": "^5.0.0", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "express": "^4.18.2", "express-fileupload": "^1.4.3", "http-errors": "^2.0.0", diff --git a/src/common-helpers.ts b/src/common-helpers.ts index 2c61ea3d2..51d2e3b53 100644 --- a/src/common-helpers.ts +++ b/src/common-helpers.ts @@ -6,16 +6,16 @@ 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 { mimeMultipart } from "./mime"; +import { contentTypes } from "./content-type"; export type FlatObject = Record; const areFilesAvailable = (request: Request): boolean => { const contentType = request.header("content-type") || ""; - const isMultipart = contentType.toLowerCase().startsWith(mimeMultipart); - return "files" in request && isMultipart; + const isUpload = contentType.toLowerCase().startsWith(contentTypes.upload); + return "files" in request && isUpload; }; export const defaultInputSources: InputSources = { @@ -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..7edbac349 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,21 +131,19 @@ 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; @@ -152,7 +154,7 @@ export interface ServerConfig * @default undefined * @example ({ app }) => { app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); } * */ - beforeRouting?: AppExtension; + beforeRouting?: BeforeRouting; }; /** @desc Enables HTTPS server as well. */ https?: { diff --git a/src/content-type.ts b/src/content-type.ts new file mode 100644 index 000000000..f3091727f --- /dev/null +++ b/src/content-type.ts @@ -0,0 +1,7 @@ +export const contentTypes = { + json: "application/json", + upload: "multipart/form-data", + raw: "application/octet-stream", +}; + +export type ContentType = keyof typeof contentTypes; diff --git a/src/date-in-schema.ts b/src/date-in-schema.ts index a2737e4bf..e12598599 100644 --- a/src/date-in-schema.ts +++ b/src/date-in-schema.ts @@ -1,8 +1,7 @@ import { z } from "zod"; -import { proprietary } from "./metadata"; import { isValidDate } from "./schema-helpers"; -export const ezDateInKind = "DateIn"; +export const ezDateInBrand = Symbol("DateIn"); export const dateIn = () => { const schema = z.union([ @@ -11,8 +10,10 @@ export const dateIn = () => { z.string().datetime({ local: true }), ]); - return proprietary( - ezDateInKind, - schema.transform((str) => new Date(str)).pipe(z.date().refine(isValidDate)), - ); + return schema + .transform((str) => new Date(str)) + .pipe(z.date().refine(isValidDate)) + .brand(ezDateInBrand 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/documentation-helpers.ts b/src/documentation-helpers.ts index 4c7a2a73d..d939948e0 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -52,19 +52,19 @@ import { ucFirst, } from "./common-helpers"; import { InputSource, TagsConfig } from "./config-type"; -import { ezDateInKind } from "./date-in-schema"; -import { ezDateOutKind } from "./date-out-schema"; +import { DateInSchema, ezDateInBrand } from "./date-in-schema"; +import { DateOutSchema, ezDateOutBrand } from "./date-out-schema"; import { DocumentationError } from "./errors"; -import { ezFileKind } from "./file-schema"; +import { FileSchema, ezFileBrand } from "./file-schema"; import { IOSchema } from "./io-schema"; import { LogicalContainer, andToOr, mapLogicalContainer, } from "./logical-container"; -import { getMeta } from "./metadata"; +import { metaSymbol } from "./metadata"; import { Method } from "./method"; -import { RawSchema, ezRawKind } from "./raw-schema"; +import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, HandlingVariant, @@ -72,7 +72,7 @@ import { walkSchema, } from "./schema-walker"; import { Security } from "./security"; -import { ezUploadKind } from "./upload-schema"; +import { UploadSchema, ezUploadBrand } from "./upload-schema"; /* eslint-disable @typescript-eslint/no-use-before-define */ @@ -132,7 +132,7 @@ export const depictDefault: Depicter> = ({ next, }) => ({ ...next(schema._def.innerType), - default: getMeta(schema, "defaultLabel") || schema._def.defaultValue(), + default: schema._def[metaSymbol]?.defaultLabel || schema._def.defaultValue(), }); export const depictCatch: Depicter> = ({ @@ -146,7 +146,7 @@ export const depictAny: Depicter = () => ({ format: "any", }); -export const depictUpload: Depicter = (ctx) => { +export const depictUpload: Depicter = (ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -160,15 +160,18 @@ export const depictUpload: Depicter = (ctx) => { }; }; -export const depictFile: Depicter = ({ schema }) => ({ - type: "string", - format: - schema instanceof z.ZodString - ? schema._def.checks.find((check) => check.kind === "base64") - ? "byte" - : "file" - : "binary", -}); +export const depictFile: Depicter = ({ schema }) => { + const subject = schema.unwrap(); + return { + type: "string", + format: + subject instanceof z.ZodString + ? subject._def.checks.find((check) => check.kind === "base64") + ? "byte" + : "file" + : "binary", + }; +}; export const depictUnion: Depicter> = ({ schema: { options }, @@ -317,7 +320,7 @@ export const depictObject: Depicter> = ({ * */ export const depictNull: Depicter = () => ({ type: "null" }); -export const depictDateIn: Depicter = (ctx) => { +export const depictDateIn: Depicter = (ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -336,7 +339,7 @@ export const depictDateIn: Depicter = (ctx) => { }; }; -export const depictDateOut: Depicter = (ctx) => { +export const depictDateOut: Depicter = (ctx) => { assert( ctx.isResponse, new DocumentationError({ @@ -628,7 +631,7 @@ export const depictLazy: Depicter> = ({ }; export const depictRaw: Depicter = ({ next, schema }) => - next(schema.shape.raw); + next(schema.unwrap().shape.raw); const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => examples.length @@ -669,6 +672,9 @@ export const extractObjectSchema = ( if (subject instanceof z.ZodObject) { return subject; } + if (subject instanceof z.ZodBranded) { + return extractObjectSchema(subject.unwrap(), tfError); + } if ( subject instanceof z.ZodUnion || subject instanceof z.ZodDiscriminatedUnion @@ -779,11 +785,11 @@ export const depicters: HandlingRules< ZodPipeline: depictPipeline, ZodLazy: depictLazy, ZodReadonly: depictReadonly, - [ezFileKind]: depictFile, - [ezUploadKind]: depictUpload, - [ezDateOutKind]: depictDateOut, - [ezDateInKind]: depictDateIn, - [ezRawKind]: depictRaw, + [ezFileBrand]: depictFile, + [ezUploadBrand]: depictUpload, + [ezDateOutBrand]: depictDateOut, + [ezDateInBrand]: depictDateIn, + [ezRawBrand]: depictRaw, }; export const onEach: Depicter = ({ diff --git a/src/endpoint.ts b/src/endpoint.ts index 1fb73d6db..2b297e70f 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -26,7 +26,7 @@ import { AbstractLogger } from "./logger"; import { LogicalContainer, combineContainers } from "./logical-container"; import { AuxMethod, Method } from "./method"; import { AnyMiddlewareDef } from "./middleware"; -import { mimeJson, mimeMultipart, mimeRaw } from "./mime"; +import { ContentType, contentTypes } from "./content-type"; import { AnyResultHandlerDefinition } from "./result-handler"; import { Security } from "./security"; @@ -63,6 +63,7 @@ export abstract class AbstractEndpoint { public abstract getScopes(): ReadonlyArray; public abstract getTags(): ReadonlyArray; public abstract getOperationId(method: Method): string | undefined; + public abstract getRequestType(): ContentType; } export class Endpoint< @@ -86,6 +87,7 @@ export class Endpoint< readonly #scopes: ReadonlyArray; readonly #tags: ReadonlyArray; readonly #getOperationId: (method: Method) => string | undefined; + readonly #requestType: ContentType; constructor({ methods, @@ -133,13 +135,13 @@ export class Endpoint< this.#responses = { positive: Object.freeze( normalizeApiResponse(resultHandler.getPositiveResponse(outputSchema), { - mimeTypes: [mimeJson], + mimeTypes: [contentTypes.json], statusCodes: [defaultStatusCodes.positive], }), ), negative: Object.freeze( normalizeApiResponse(resultHandler.getNegativeResponse(), { - mimeTypes: [mimeJson], + mimeTypes: [contentTypes.json], statusCodes: [defaultStatusCodes.negative], }), ), @@ -152,14 +154,13 @@ export class Endpoint< ), ); } + this.#requestType = hasUpload(inputSchema) + ? "upload" + : hasRaw(inputSchema) + ? "raw" + : "json"; this.#mimeTypes = { - input: Object.freeze( - hasUpload(inputSchema) - ? [mimeMultipart] - : hasRaw(inputSchema) - ? [mimeRaw] - : [mimeJson], - ), + input: Object.freeze([contentTypes[this.#requestType]]), positive: Object.freeze( this.#responses.positive.flatMap(({ mimeTypes }) => mimeTypes), ), @@ -193,6 +194,10 @@ export class Endpoint< return this.#mimeTypes[variant]; } + public override getRequestType() { + return this.#requestType; + } + public override getResponses(variant: ResponseVariant) { return this.#responses[variant]; } diff --git a/src/file-schema.ts b/src/file-schema.ts index d79d88dfe..b5382f812 100644 --- a/src/file-schema.ts +++ b/src/file-schema.ts @@ -1,17 +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", }); const variants = { - buffer: () => proprietary(ezFileKind, bufferSchema), - string: () => proprietary(ezFileKind, z.string()), - binary: () => proprietary(ezFileKind, bufferSchema.or(z.string())), - base64: () => proprietary(ezFileKind, z.string().base64()), + buffer: () => bufferSchema.brand(ezFileBrand 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; @@ -22,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 950a1b923..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 { @@ -40,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 45c5a1471..f369573cd 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -29,7 +29,7 @@ import { } from "./integration-helpers"; import { defaultSerializer, makeCleanId } from "./common-helpers"; import { Method, methods } from "./method"; -import { mimeJson } from "./mime"; +import { contentTypes } from "./content-type"; import { loadPeer } from "./peer-helpers"; import { Routing } from "./routing"; import { walkRouting } from "./routing-walker"; @@ -219,7 +219,9 @@ export class Integration { positive: positiveResponseId, negative: negativeResponseId, response: genericResponseId, - isJson: endpoint.getMimeTypes("positive").includes(mimeJson), + isJson: endpoint + .getMimeTypes("positive") + .includes(contentTypes.json), tags: endpoint.getTags(), }, ); @@ -271,25 +273,24 @@ export class Integration { const jsonEndpoints: ts.PropertyAssignment[] = []; const endpointTags: ts.PropertyAssignment[] = []; for (const [{ method, path }, { isJson, tags, ...rest }] of this.registry) { + const propName = quoteProp(method, path); // "get /v1/user/retrieve": GetV1UserRetrieveInput for (const face of this.interfaces) { if (face.kind in rest) { - face.props.push( - makeInterfaceProp(quoteProp(method, path), rest[face.kind]!), - ); + face.props.push(makeInterfaceProp(propName, rest[face.kind]!)); } } if (variant !== "types") { if (isJson) { // "get /v1/user/retrieve": true jsonEndpoints.push( - f.createPropertyAssignment(quoteProp(method, path), f.createTrue()), + f.createPropertyAssignment(propName, f.createTrue()), ); } // "get /v1/user/retrieve": ["users"] endpointTags.push( f.createPropertyAssignment( - quoteProp(method, path), + propName, f.createArrayLiteralExpression( tags.map((tag) => f.createStringLiteral(tag)), ), @@ -483,7 +484,7 @@ export class Integration { f.createObjectLiteralExpression([ f.createPropertyAssignment( f.createStringLiteral("Content-Type"), - f.createStringLiteral(mimeJson), + f.createStringLiteral(contentTypes.json), ), ]), undefined, 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 e83000022..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,15 +37,3 @@ export const copyMeta = ( ); return result; }; - -export const proprietary = ( - kind: ProprietaryKind, - subject: T, -) => { - const schema = cloneSchema(subject); - schema._def[metaSymbol].kind = kind; - return schema; -}; - -export const isProprietary = (schema: z.ZodTypeAny, kind: ProprietaryKind) => - getMeta(schema, "kind") === kind; diff --git a/src/mime.ts b/src/mime.ts deleted file mode 100644 index c5f495c4d..000000000 --- a/src/mime.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const mimeJson = "application/json"; -export const mimeMultipart = "multipart/form-data"; -export const mimeRaw = "application/octet-stream"; 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.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-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..f4d967fb3 100644 --- a/src/server-helpers.ts +++ b/src/server-helpers.ts @@ -1,7 +1,10 @@ +import type fileUpload from "express-fileupload"; +import { metaSymbol } from "./metadata"; +import { loadPeer } from "./peer-helpers"; import { AnyResultHandlerDefinition } from "./result-handler"; import { AbstractLogger } from "./logger"; -import { CommonConfig } from "./config-type"; -import { ErrorRequestHandler, RequestHandler } from "express"; +import { ServerConfig } from "./config-type"; +import { ErrorRequestHandler, RequestHandler, Response } from "express"; import createHttpError, { isHttpError } from "http-errors"; import { lastResortHandler } from "./last-resort"; import { ResultHandlerError } from "./errors"; @@ -10,16 +13,16 @@ import { makeErrorFromAnything } from "./common-helpers"; interface HandlerCreatorParams { errorHandler: AnyResultHandlerDefinition; rootLogger: AbstractLogger; - getChildLogger: CommonConfig["childLoggerProvider"]; } +export type LocalResponse = Response< + unknown, + { [metaSymbol]?: { logger: AbstractLogger } } +>; + export const createParserFailureHandler = - ({ - errorHandler, - rootLogger, - getChildLogger, - }: HandlerCreatorParams): ErrorRequestHandler => - async (error, request, response, next) => { + ({ errorHandler, rootLogger }: HandlerCreatorParams): ErrorRequestHandler => + async (error, request, response: LocalResponse, next) => { if (!error) { return next(); } @@ -32,26 +35,18 @@ export const createParserFailureHandler = input: null, output: null, options: {}, - logger: getChildLogger - ? await getChildLogger({ request, parent: rootLogger }) - : rootLogger, + logger: response.locals[metaSymbol]?.logger || rootLogger, }); }; export const createNotFoundHandler = - ({ - errorHandler, - getChildLogger, - rootLogger, - }: HandlerCreatorParams): RequestHandler => - async (request, response) => { + ({ errorHandler, rootLogger }: HandlerCreatorParams): RequestHandler => + async (request, response: LocalResponse) => { const error = createHttpError( 404, `Can not ${request.method} ${request.path}`, ); - const logger = getChildLogger - ? await getChildLogger({ request, parent: rootLogger }) - : rootLogger; + const logger = response.locals[metaSymbol]?.logger || rootLogger; try { errorHandler.handler({ request, @@ -86,9 +81,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: ServerConfig; + }): 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..08e0758ef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,18 +1,18 @@ import express from "express"; import type compression from "compression"; -import type fileUpload from "express-fileupload"; import http from "node:http"; import https from "node:https"; import { AppConfig, CommonConfig, ServerConfig } from "./config-type"; -import { AbstractLogger, createLogger, isBuiltinLoggerConfig } from "./logger"; +import { createLogger, isBuiltinLoggerConfig } from "./logger"; import { loadPeer } from "./peer-helpers"; import { defaultResultHandler } from "./result-handler"; -import { Routing, initRouting } from "./routing"; +import { Parsers, Routing, initRouting } from "./routing"; import { + createLoggingMiddleware, createNotFoundHandler, createParserFailureHandler, - createUploadFailueHandler, - createUploadLogger, + createUploadParsers, + moveRaw, } from "./server-helpers"; import { getStartupLogo } from "./startup-logo"; @@ -20,16 +20,16 @@ const makeCommonEntities = (config: CommonConfig) => { if (config.startupLogo !== false) { console.log(getStartupLogo()); } - const rootLogger: AbstractLogger = isBuiltinLoggerConfig(config.logger) - ? createLogger(config.logger) - : config.logger; - rootLogger.debug("Running", process.env.TSUP_BUILD || "from sources"); - const errorHandler = config.errorHandler || defaultResultHandler; - const { childLoggerProvider: getChildLogger } = config; - const creatorParams = { errorHandler, rootLogger, getChildLogger }; - const notFoundHandler = createNotFoundHandler(creatorParams); - const parserFailureHandler = createParserFailureHandler(creatorParams); - return { rootLogger, errorHandler, notFoundHandler, parserFailureHandler }; + const commons = { + errorHandler: config.errorHandler || defaultResultHandler, + rootLogger: isBuiltinLoggerConfig(config.logger) + ? createLogger(config.logger) + : config.logger, + }; + commons.rootLogger.debug("Running", process.env.TSUP_BUILD || "from sources"); + const notFoundHandler = createNotFoundHandler(commons); + const parserFailureHandler = createParserFailureHandler(commons); + return { ...commons, notFoundHandler, parserFailureHandler }; }; export const attachRouting = (config: AppConfig, routing: Routing) => { @@ -40,6 +40,10 @@ export const attachRouting = (config: AppConfig, routing: Routing) => { export const createServer = async (config: ServerConfig, routing: Routing) => { const app = express().disable("x-powered-by"); + const { rootLogger, notFoundHandler, parserFailureHandler } = + makeCommonEntities(config); + app.use(createLoggingMiddleware({ rootLogger, config })); + if (config.server.compression) { const compressor = await loadPeer("compression"); app.use( @@ -50,46 +54,20 @@ export const createServer = async (config: ServerConfig, routing: Routing) => { ), ); } - app.use(config.server.jsonParser || express.json()); - const { rootLogger, notFoundHandler, parserFailureHandler } = - makeCommonEntities(config); + const parsers: Parsers = { + json: [config.server.jsonParser || express.json()], + raw: [config.server.rawParser || 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/testing.ts b/src/testing.ts index d1e128f34..8c2aa40cb 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -3,8 +3,9 @@ import http from "node:http"; import { CommonConfig } from "./config-type"; import { AbstractEndpoint } from "./endpoint"; import { AbstractLogger } from "./logger"; -import { mimeJson } from "./mime"; +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. @@ -25,7 +26,7 @@ export const makeRequestMock = >({ }) => ({ method: "GET", - header: fnMethod(() => mimeJson), + header: fnMethod(() => contentTypes.json), ...requestProps, }) as { method: string } & Record<"header", MockOverrides> & REQ; @@ -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 37ac8950f..ca9951e1f 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ "devDependencies": { "jest": "^30.0.0-alpha.3", "@types/jest": "^29.5.12", - "@swc/core": "1.5.0", + "@swc/core": "^1.5.0", "@swc/jest": "^0.2.29" } } 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 e6b4b48b4..d336482e3 100644 --- a/tests/system/example.spec.ts +++ b/tests/system/example.spec.ts @@ -88,7 +88,7 @@ describe("Example", async () => { createdAt: "2022-01-22T00:00:00.000Z", }, }); - await waitFor(() => /v1\/user\/:id/.test(out)); + await waitFor(() => /v1\/user\/50/.test(out)); await waitFor(() => /50, 123, 456/.test(out)); expect(true).toBeTruthy(); }); diff --git a/tests/system/system.spec.ts b/tests/system/system.spec.ts index 118f8afb7..9cf18cbce 100644 --- a/tests/system/system.spec.ts +++ b/tests/system/system.spec.ts @@ -327,18 +327,19 @@ describe("App", async () => { test("Should fail on malformed body", async () => { const response = await fetch(`http://127.0.0.1:${port}/v1/test`, { - method: "PUT", + method: "POST", // valid method this time headers: { "Content-Type": "application/json", }, - body: '{"key": "123", "something', + body: '{"key": "123", "something', // no closing bracket }); expect(response.status).toBe(400); // Issue #907 const json = await response.json(); expect(json).toMatchSnapshot({ error: { message: expect.stringMatching( - // the 2nd option is for Node 19 + // @todo revisit when Node 18 dropped + // the 2nd option is for Node 20+ /(Unexpected end of JSON input|Unterminated string in JSON at position 25)/, ), }, diff --git a/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap b/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap index 07d2ba2f9..1531efb9c 100644 --- a/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap +++ b/tests/unit/__snapshots__/documentation-helpers.spec.ts.snap @@ -1441,3 +1441,15 @@ exports[`Documentation helpers > extractObjectSchema() > should return object sc }, } `; + +exports[`Documentation helpers > extractObjectSchema() > should support ez.raw() 1`] = ` +{ + "_type": "ZodObject", + "shape": { + "raw": { + "_type": "ZodBranded", + "brand": Symbol(File), + }, + }, +} +`; diff --git a/tests/unit/checks.spec.ts b/tests/unit/checks.spec.ts index 7271aa2fe..b91963c1b 100644 --- a/tests/unit/checks.spec.ts +++ b/tests/unit/checks.spec.ts @@ -3,13 +3,13 @@ import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { ez } from "../../src"; import { hasNestedSchema, hasTransformationOnTop } from "../../src/deep-checks"; -import { isProprietary } from "../../src/metadata"; -import { ezUploadKind } from "../../src/upload-schema"; +import { metaSymbol } from "../../src/metadata"; +import { ezUploadBrand } from "../../src/upload-schema"; describe("Checks", () => { describe("hasNestedSchema()", () => { const condition = (subject: z.ZodTypeAny) => - isProprietary(subject, ezUploadKind); + subject._def[metaSymbol]?.brand === ezUploadBrand; test("should return true for given argument satisfying condition", () => { expect(hasNestedSchema({ subject: ez.upload(), condition })).toBeTruthy(); diff --git a/tests/unit/content-type.spec.ts b/tests/unit/content-type.spec.ts new file mode 100644 index 000000000..511834b83 --- /dev/null +++ b/tests/unit/content-type.spec.ts @@ -0,0 +1,12 @@ +import { contentTypes } from "../../src/content-type"; +import { describe, expect, test } from "vitest"; + +describe("contentTypes", () => { + test("should has predefined properties", () => { + expect(contentTypes).toEqual({ + json: "application/json", + upload: "multipart/form-data", + raw: "application/octet-stream", + }); + }); +}); diff --git a/tests/unit/date-in-schema.spec.ts b/tests/unit/date-in-schema.spec.ts index 578f81e5b..4db7ca4b5 100644 --- a/tests/unit/date-in-schema.spec.ts +++ b/tests/unit/date-in-schema.spec.ts @@ -1,14 +1,15 @@ import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezDateInBrand } from "../../src/date-in-schema"; import { ez } from "../../src"; import { describe, expect, test } from "vitest"; +import { metaSymbol } from "../../src/metadata"; describe("ez.dateIn()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateIn(); - expect(schema).toBeInstanceOf(z.ZodPipeline); - expect(getMeta(schema, "kind")).toEqual("DateIn"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toEqual(ezDateInBrand); }); }); diff --git a/tests/unit/date-out-schema.spec.ts b/tests/unit/date-out-schema.spec.ts index 64052b70b..4a679c874 100644 --- a/tests/unit/date-out-schema.spec.ts +++ b/tests/unit/date-out-schema.spec.ts @@ -1,14 +1,15 @@ import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezDateOutBrand } from "../../src/date-out-schema"; import { ez } from "../../src"; import { describe, expect, test } from "vitest"; +import { metaSymbol } from "../../src/metadata"; describe("ez.dateOut()", () => { describe("creation", () => { test("should create an instance", () => { const schema = ez.dateOut(); - expect(schema).toBeInstanceOf(z.ZodEffects); - expect(getMeta(schema, "kind")).toEqual("DateOut"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toEqual(ezDateOutBrand); }); }); diff --git a/tests/unit/documentation-helpers.spec.ts b/tests/unit/documentation-helpers.spec.ts index 6c9dd53de..e3b6c287c 100644 --- a/tests/unit/documentation-helpers.spec.ts +++ b/tests/unit/documentation-helpers.spec.ts @@ -152,6 +152,12 @@ describe("Documentation helpers", () => { expect(serializeSchemaForTest(subject)).toMatchSnapshot(); }); + test("should support ez.raw()", () => { + const subject = extractObjectSchema(ez.raw(), tfError); + expect(subject).toBeInstanceOf(z.ZodObject); + expect(serializeSchemaForTest(subject)).toMatchSnapshot(); + }); + describe("Feature #600: Top level refinements", () => { test("should handle refined object schema", () => { const subject = extractObjectSchema( diff --git a/tests/unit/documentation.spec.ts b/tests/unit/documentation.spec.ts index 82f7e362e..c62794568 100644 --- a/tests/unit/documentation.spec.ts +++ b/tests/unit/documentation.spec.ts @@ -11,7 +11,7 @@ import { ez, } from "../../src"; import { expectType } from "tsd"; -import { mimeJson } from "../../src/mime"; +import { contentTypes } from "../../src/content-type"; import { z } from "zod"; import { givePort } from "../helpers"; import { describe, expect, test, vi } from "vitest"; @@ -791,7 +791,7 @@ describe("Documentation", () => { const resultHandler = createResultHandler({ getPositiveResponse: (output) => ({ schema: z.object({ status: z.literal("OK"), result: output }), - mimeTypes: [mimeJson, "text/vnd.yaml"], + mimeTypes: [contentTypes.json, "text/vnd.yaml"], statusCode: 201, }), getNegativeResponse: () => ({ diff --git a/tests/unit/endpoint.spec.ts b/tests/unit/endpoint.spec.ts index d32469361..0d5d35602 100644 --- a/tests/unit/endpoint.spec.ts +++ b/tests/unit/endpoint.spec.ts @@ -365,6 +365,26 @@ describe("Endpoint", () => { ); }); + describe("getRequestType()", () => { + test.each([ + { input: z.object({}), expected: "json" }, + { input: ez.raw(), expected: "raw" }, + { input: z.object({ file: ez.upload() }), expected: "upload" }, + ])( + "should return the assigned one upon constructing", + ({ input, expected }) => { + const factory = new EndpointsFactory(defaultResultHandler); + const endpoint = factory.build({ + method: "get", + input, + output: z.object({}), + handler: vi.fn(), + }); + expect(endpoint.getRequestType()).toEqual(expected); + }, + ); + }); + describe(".getOperationId()", () => { test("should return undefined if its not defined upon creaton", () => { expect( diff --git a/tests/unit/file-schema.spec.ts b/tests/unit/file-schema.spec.ts index 72c4ba036..1ed592503 100644 --- a/tests/unit/file-schema.spec.ts +++ b/tests/unit/file-schema.spec.ts @@ -1,39 +1,40 @@ import { expectType } from "tsd"; import { z } from "zod"; -import { getMeta } from "../../src/metadata"; +import { ezFileBrand } from "../../src/file-schema"; import { ez } from "../../src"; import { readFile } from "node:fs/promises"; import { describe, expect, test } from "vitest"; +import { metaSymbol } from "../../src/metadata"; describe("ez.file()", () => { describe("creation", () => { test("should create an instance being string by default", () => { const schema = ez.file(); - expect(schema).toBeInstanceOf(z.ZodString); - expect(getMeta(schema, "kind")).toBe("File"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema._def[metaSymbol]?.brand).toBe(ezFileBrand); }); test("should create a string file", () => { const schema = ez.file("string"); - expect(schema).toBeInstanceOf(z.ZodString); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a buffer file", () => { const schema = ez.file("buffer"); - expect(schema).toBeInstanceOf(z.ZodEffects); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a binary file", () => { const schema = ez.file("binary"); - expect(schema).toBeInstanceOf(z.ZodUnion); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); test("should create a base64 file", () => { const schema = ez.file("base64"); - expect(schema).toBeInstanceOf(z.ZodString); + expect(schema).toBeInstanceOf(z.ZodBranded); expectType(schema._output); }); }); diff --git a/tests/unit/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 8d372bcfd..96af2590f 100644 --- a/tests/unit/metadata.spec.ts +++ b/tests/unit/metadata.spec.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { copyMeta, getMeta, hasMeta, metaSymbol } from "../../src/metadata"; +import { copyMeta, metaSymbol } from "../../src/metadata"; import { describe, expect, test } from "vitest"; describe("Metadata", () => { @@ -62,45 +62,9 @@ describe("Metadata", () => { }); }); - describe("hasMeta()", () => { - test("should return false if the schema definition has no meta prop", () => { - expect(hasMeta(z.string())).toBeFalsy(); - }); - test("should return false if the meta prop has invalid type", () => { - const schema1 = z.string(); - const schema2 = z.string(); - Object.defineProperty(schema1._def, metaSymbol, { value: null }); - expect(hasMeta(schema1)).toBeFalsy(); - Object.defineProperty(schema2._def, metaSymbol, { value: 123 }); - expect(hasMeta(schema2)).toBeFalsy(); - }); - test("should return true if proprietary method has been used", () => { - expect(hasMeta(z.string().example(""))).toBeTruthy(); - }); - }); - - describe("getMeta()", () => { - test("should return undefined on a regular Zod schema or the malformed one", () => { - expect(getMeta(z.string(), "examples")).toBeUndefined(); - }); - test("should return undefined on malformed schema", () => { - const schema1 = z.string(); - const schema2 = z.string(); - Object.defineProperty(schema1._def, metaSymbol, { value: null }); - expect(getMeta(schema1, "examples")).toBeUndefined(); - Object.defineProperty(schema2._def, metaSymbol, { value: 123 }); - expect(getMeta(schema2, "examples")).toBeUndefined(); - }); - test("should return undefined if the value not set", () => { - expect(getMeta(z.string(), "examples")).toBeUndefined(); - }); - test("should return the value that has been set", () => { - expect(getMeta(z.string().example("test"), "examples")).toEqual(["test"]); - }); - test("should return an array of examples", () => { - expect( - getMeta(z.string().example("test1").example("test2"), "examples"), - ).toEqual(["test1", "test2"]); + describe(".brand()", () => { + test("should set the brand", () => { + expect(z.string().brand("test")._def[metaSymbol]?.brand).toEqual("test"); }); }); @@ -110,15 +74,17 @@ describe("Metadata", () => { const dest = z.number(); const result = copyMeta(src, dest); expect(result).toEqual(dest); - expect(hasMeta(result)).toBeFalsy(); - expect(hasMeta(dest)).toBeFalsy(); + expect(result._def[metaSymbol]).toBeFalsy(); + expect(dest._def[metaSymbol]).toBeFalsy(); }); test("should copy meta from src to dest in case meta is defined", () => { const src = z.string().example("some"); const dest = z.number(); const result = copyMeta(src, dest); - expect(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual(getMeta(src, "examples")); + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual( + src._def[metaSymbol]?.examples, + ); }); test("should merge the meta from src to dest", () => { @@ -132,8 +98,8 @@ describe("Metadata", () => { .example({ b: 456 }) .example({ b: 789 }); const result = copyMeta(src, dest); - expect(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual([ + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual([ { a: "some", b: 123 }, { a: "another", b: 123 }, { a: "some", b: 456 }, @@ -154,8 +120,8 @@ describe("Metadata", () => { .example({ a: { c: 456 } }) .example({ a: { c: 789 } }); const result = copyMeta(src, dest); - expect(hasMeta(result)).toBeTruthy(); - expect(getMeta(result, "examples")).toEqual([ + expect(result._def[metaSymbol]).toBeTruthy(); + expect(result._def[metaSymbol]?.examples).toEqual([ { a: { b: "some", c: 123 } }, { a: { b: "another", c: 123 } }, { a: { b: "some", c: 456 } }, diff --git a/tests/unit/mime.spec.ts b/tests/unit/mime.spec.ts deleted file mode 100644 index 8e6a3afa2..000000000 --- a/tests/unit/mime.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { mimeJson, mimeMultipart, mimeRaw } from "../../src/mime"; -import { describe, expect, test } from "vitest"; - -describe("Mime", () => { - test("should export predefined types", () => { - expect(mimeJson).toBe("application/json"); - expect(mimeMultipart).toBe("multipart/form-data"); - expect(mimeRaw).toBe("application/octet-stream"); - }); -}); 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..a04998b89 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 + ); }); }); 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 85f6eba4b..395122064 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,16 @@ "moduleResolution": "Bundler", "resolveJsonModule": true }, - "include": ["src", "example", "tests/unit", "tests/system", "tests/bench", "tests/*.ts", "tools", "*.config.ts", "eslint.config.js"] + "include": [ + "src", + "example", + "tests/unit", + "tests/system", + "tests/bench", + "tests/*.ts", + "tools", + "*.config.ts", + "*.setup.ts", + "eslint.config.js" + ], } 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 166c514f8..94cdcfbf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -152,7 +152,7 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz#f99c36d3593db9540705d0739a1f10b5e20c696e" integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ== -"@babel/helper-validator-identifier@^7.22.20", "@babel/helper-validator-identifier@^7.24.5": +"@babel/helper-validator-identifier@^7.24.5": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== @@ -467,21 +467,6 @@ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== -"@eslint/eslintrc@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" - integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^9.6.0" - globals "^13.19.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - "@eslint/eslintrc@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.0.2.tgz#36180f8e85bf34d2fe3ccc2261e8e204a411ab4e" @@ -816,9 +801,9 @@ integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== "@types/node@*", "@types/node@^20.8.4": - version "20.12.9" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.9.tgz#d7234f2e7839b55fcab5048404aef0195684adff" - integrity sha512-o93r47yu04MHumPBCFg0bMPBMNgtMg3jzbhl7e68z50+BMHmRMGDJv13eBlUgOdc9i/uoJXGMGYLtJV4ReTXEg== + version "20.12.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.11.tgz#c4ef00d3507000d17690643278a60dc55a9dc9be" + integrity sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw== dependencies: undici-types "~5.26.4" @@ -1042,7 +1027,7 @@ acorn-walk@^8.3.2: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== -acorn@^8.11.3, acorn@^8.9.0: +acorn@^8.11.3: version "8.11.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== @@ -1555,7 +1540,7 @@ cookie@0.6.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== -core-js-compat@^3.34.0: +core-js-compat@^3.37.0: version "3.37.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.0.tgz#d9570e544163779bb4dff1031c7972f44918dc73" integrity sha512-vYq4L+T8aS5UuFg4UwDhc7YNRWVeVZwltad9C/jV3R2LgVOpS9BDr7l/WL6BN0dbV3k1XejPTHqqEzJgsa0frA== @@ -1998,17 +1983,17 @@ eslint-plugin-prettier@^5.0.0: prettier-linter-helpers "^1.0.0" synckit "^0.8.6" -eslint-plugin-unicorn@^52.0.0: - version "52.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz#c7a559edd52e3932cf2b3a05c3b0efc604c1eeb8" - integrity sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng== +eslint-plugin-unicorn@^53.0.0: + version "53.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz#df3a5c9ecabeb759e6fd867b2d84198466ac8c4d" + integrity sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw== dependencies: - "@babel/helper-validator-identifier" "^7.22.20" + "@babel/helper-validator-identifier" "^7.24.5" "@eslint-community/eslint-utils" "^4.4.0" - "@eslint/eslintrc" "^2.1.4" + "@eslint/eslintrc" "^3.0.2" ci-info "^4.0.0" clean-regexp "^1.0.0" - core-js-compat "^3.34.0" + core-js-compat "^3.37.0" esquery "^1.5.0" indent-string "^4.0.0" is-builtin-module "^3.2.1" @@ -2017,7 +2002,7 @@ eslint-plugin-unicorn@^52.0.0: read-pkg-up "^7.0.1" regexp-tree "^0.1.27" regjsparser "^0.10.0" - semver "^7.5.4" + semver "^7.6.1" strip-indent "^3.0.0" eslint-rule-docs@^1.1.5: @@ -2033,7 +2018,7 @@ eslint-scope@^8.0.1: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== @@ -2092,15 +2077,6 @@ espree@^10.0.1: acorn-jsx "^5.3.2" eslint-visitor-keys "^4.0.0" -espree@^9.6.0: - version "9.6.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" - integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== - dependencies: - acorn "^8.9.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.1" - esquery@^1.4.2, esquery@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" @@ -2459,13 +2435,6 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.19.0: - version "13.24.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" - integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== - dependencies: - type-fest "^0.20.2" - globals@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" @@ -3917,12 +3886,10 @@ semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.3.4, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0: - version "7.6.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" - integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== - dependencies: - lru-cache "^6.0.0" +semver@^7.0.0, semver@^7.3.4, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.1: + version "7.6.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" + integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== send@0.18.0: version "0.18.0" @@ -4087,16 +4054,7 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4142,14 +4100,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4411,9 +4362,9 @@ tsup@^8.0.0: tree-kill "^1.2.2" tsx@^4.6.2: - version "4.9.3" - resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.9.3.tgz#801ca18ca22b3d2f7acd89d4b888aa2425ea1302" - integrity sha512-czVbetlILiyJZI5zGlj2kw9vFiSeyra9liPD4nG+Thh4pKTi0AmMEQ8zdV/L2xbIVKrIqif4sUNrsMAOksx9Zg== + version "4.9.4" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.9.4.tgz#a7c37d944f0d88743c8b6bb030adb3cfc488435a" + integrity sha512-TlSJTVn2taGGDgdV3jAqCj7WQ/CafCB5p4SbG7W2Bl/0AJWH1ShJlBbc0y2lOFTjQEVAAULSTlmehw/Mwv3S/Q== dependencies: esbuild "~0.20.2" get-tsconfig "^4.7.3" @@ -4437,11 +4388,6 @@ type-fest@^0.18.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" integrity sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw== -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - type-fest@^0.21.3: version "0.21.3" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" @@ -4700,16 +4646,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -4781,6 +4718,6 @@ yocto-queue@^1.0.0: integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== zod@^3.23.0: - version "3.23.6" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.6.tgz#c08a977e2255dab1fdba933651584a05fcbf19e1" - integrity sha512-RTHJlZhsRbuA8Hmp/iNL7jnfc4nZishjsanDAfEY1QpDQZCahUp3xDzl+zfweE9BklxMUcgBgS1b7Lvie/ZVwA== + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==