diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8c0fa6c14..3cd484505 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master, v17, v18, v19 ] + branches: [ master, v18, v19, v20 ] pull_request: # The branches below must be a subset of the branches above - branches: [ master, v17, v18, v19 ] + branches: [ master, v18, v19, v20 ] schedule: - cron: '26 8 * * 1' diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 6fa800804..56fde1964 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,9 +5,9 @@ name: Node.js CI on: push: - branches: [ master, v17, v18, v19 ] + branches: [ master, v18, v19, v20 ] pull_request: - branches: [ master, v17, v18, v19 ] + branches: [ master, v18, v19, v20 ] jobs: build: diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index ab5861619..e55ae9207 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -2,9 +2,9 @@ name: OpenAPI Validation on: push: - branches: [ master, v17, v18, v19 ] + branches: [ master, v18, v19, v20 ] pull_request: - branches: [ master, v17, v18, v19 ] + branches: [ master, v18, v19, v20 ] jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a19d54c..44a014b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## Version 21 + +### v21.0.0 + +- Minimum supported versions of `express`: 4.21.1 and 5.0.1 (fixed vulnerabilities); +- Breaking changes to `createConfig()` argument: + - The `server` property renamed to `http` and made optional — (can now configure HTTPS only); + - These properties moved to the top level: `jsonParser`, `upload`, `compression`, `rawParser` and `beforeRouting`; + - Both `logger` and `getChildLogger` arguments of `beforeRouting` function are replaced with all-purpose `getLogger`. +- Breaking changes to `createServer()` resolved return: + - Both `httpServer` and `httpsServer` are combined into single `servers` property (array, same order). +- Breaking changes to `EndpointsFactory::build()` argument: + - Plural `methods`, `tags` and `scopes` properties replaced with singular `method`, `tag`, `scope` accordingly; + - The `method` property also made optional and can now be derived from `DependsOnMethod` or imply `GET` by default; + - When `method` is assigned with an array, it must be non-empty. +- Breaking changes to `positive` and `negative` propeties of `ResultHandler` constructor argument: + - Plural `statusCodes` and `mimeTypes` props within the values are replaced with singular `statusCode` and `mimeType`. +- Other breaking changes: + - The `serializer` property of `Documentation` and `Integration` constructor argument removed; + - The `originalError` property of `InputValidationError` and `OutputValidationError` removed (use `cause` instead); + - The `getStatusCodeFromError()` method removed (use the `ensureHttpError().statusCode` instead); + - The `testEndpoint()` method can no longer test CORS headers — that function moved to `Routing` traverse; + - For `Endpoint`: `getMethods()` may return `undefined`, `getMimeTypes()` removed, `getSchema()` variants reduced; + - Public properties `pairs`, `firstEndpoint` and `siblingMethods` of `DependsOnMethod` replaced with `entries`. +- Consider the automated migration using the built-in ESLint rule. + +```js +// eslint.config.mjs — minimal ESLint 9 config to apply migrations automatically using "eslint --fix" +import parser from "@typescript-eslint/parser"; +import migration from "express-zod-api/migration"; + +export default [ + { languageOptions: { parser }, plugins: { migration } }, + { files: ["**/*.ts"], rules: { "migration/v21": "error" } }, +]; +``` + +```ts +// The sample of new structure +const config = createConfig({ + http: { listen: 80 }, // became optional + https: { listen: 443, options: {} }, + upload: true, + compression: true, + beforeRouting: ({ app, getLogger }) => { + const logger = getLogger(); + app.use((req, res, next) => { + const childLogger = getLogger(req); + }); + }, +}); +const { servers } = await createServer(config, {}); +``` + ## Version 20 ### v20.22.1 @@ -1179,7 +1233,7 @@ export const config = createConfig({ - **Breaking changes**: - `DependsOnMethod::endpoints` removed; - - Refinment methods of `ez.file()` removed; + - Refinement methods of `ez.file()` removed; - Minimum version of `vitest` supported is 1.0.4. - How to migrate confidently: - If you're using refinement methods of `ez.file()`: diff --git a/README.md b/README.md index f82e2fb11..426ea95f4 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Create a minimal configuration. _See all available options import { createConfig } from "express-zod-api"; const config = createConfig({ - server: { + http: { listen: 8090, // port, UNIX socket or options }, cors: true, @@ -208,7 +208,7 @@ The endpoint responds with "Hello, World" or "Hello, {name}" if the name is supp import { z } from "zod"; const helloWorldEndpoint = defaultEndpointsFactory.build({ - method: "get", // or methods: ["get", "post", ...] + // method: "get" (default) or array ["get", "post", ...] input: z.object({ name: z.string().optional(), }), @@ -288,12 +288,9 @@ const authMiddleware = new Middleware({ handler: async ({ input: { key }, request, logger }) => { logger.debug("Checking the key and token"); const user = await db.Users.findOne({ key }); - if (!user) { - throw createHttpError(401, "Invalid key"); - } - if (request.headers.token !== user.token) { + if (!user) throw createHttpError(401, "Invalid key"); + if (request.headers.token !== user.token) throw createHttpError(401, "Invalid token"); - } return { user }; // provides endpoints with options.user }, }); @@ -305,10 +302,9 @@ By using `.addMiddleware()` method before `.build()` you can connect it to the e const yourEndpoint = defaultEndpointsFactory .addMiddleware(authMiddleware) .build({ - // ..., - handler: async ({ options }) => { - // options.user is the user returned by authMiddleware - }, + handler: async ({ options: { user } }) => { + // user is the one returned by authMiddleware + }, // ... }); ``` @@ -322,7 +318,7 @@ import { defaultEndpointsFactory } from "express-zod-api"; const factory = defaultEndpointsFactory .addMiddleware(authMiddleware) // add Middleware instance or use shorter syntax: .addMiddleware({ - handler: async ({ options: { user } }) => ({}), // options.user from authMiddleware + handler: async ({ options: { user } }) => ({}), // user from authMiddleware }); ``` @@ -372,12 +368,13 @@ import { createConfig } from "express-zod-api"; import ui from "swagger-ui-express"; const config = createConfig({ - server: { - listen: 80, - beforeRouting: ({ app, logger, getChildLogger }) => { - logger.info("Serving the API documentation at https://example.com/docs"); - app.use("/docs", ui.serve, ui.setup(documentation)); - }, + beforeRouting: ({ app, getLogger }) => { + const logger = getLogger(); + logger.info("Serving the API documentation at https://example.com/docs"); + app.use("/docs", ui.serve, ui.setup(documentation)); + app.use("/custom", (req, res, next) => { + const childLogger = getLogger(req); // if childLoggerProvider is configured + }); }, }); ``` @@ -447,7 +444,6 @@ arrays of numbers. import { z } from "zod"; const getUserEndpoint = endpointsFactory.build({ - method: "get", input: z.object({ id: z.string().transform((id) => parseInt(id, 10)), ids: z @@ -479,7 +475,6 @@ import snakify from "snakify-ts"; import { z } from "zod"; const endpoint = endpointsFactory.build({ - method: "get", input: z .object({ user_id: z.string() }) .transform((inputs) => camelize(inputs, /* shallow: */ true)), @@ -537,18 +532,14 @@ const updateUserEndpoint = defaultEndpointsFactory.build({ method: "post", input: z.object({ userId: z.string(), - birthday: ez.dateIn(), // string -> Date + birthday: ez.dateIn(), // string -> Date in handler }), output: z.object({ - createdAt: ez.dateOut(), // Date -> string + createdAt: ez.dateOut(), // Date -> string in response + }), + handler: async ({ input }) => ({ + createdAt: new Date("2022-01-22"), // 2022-01-22T00:00:00.000Z }), - handler: async ({ input }) => { - // input.birthday is Date - return { - // transmitted as "2022-01-22T00:00:00.000Z" - createdAt: new Date("2022-01-22"), - }; - }, }); ``` @@ -566,7 +557,6 @@ That function has several parameters and can be asynchronous. import { createConfig } from "express-zod-api"; const config = createConfig({ - // ... other options cors: ({ defaultHeaders, request, endpoint, logger }) => ({ ...defaultHeaders, "Access-Control-Max-Age": "5000", @@ -580,32 +570,24 @@ Please note: If you only want to send specific headers on requests to a specific ## Enabling HTTPS The modern API standard often assumes the use of a secure data transfer protocol, confirmed by a TLS certificate, also -often called an SSL certificate in habit. When using the `createServer()` method, you can additionally configure and -run the HTTPS server. +often called an SSL certificate in habit. This way you can additionally (or solely) configure and run the HTTPS server: ```typescript import { createConfig, createServer } from "express-zod-api"; const config = createConfig({ - server: { - listen: 80, - }, https: { options: { cert: fs.readFileSync("fullchain.pem", "utf-8"), key: fs.readFileSync("privkey.pem", "utf-8"), }, listen: 443, // port, UNIX socket or options - }, - // ... cors, logger, etc + }, // ... cors, logger, etc }); // 'await' is only needed if you're going to use the returned entities. // For top level CJS you can wrap you code with (async () => { ... })() -const { app, httpServer, httpsServer, logger } = await createServer( - config, - routing, -); +const { app, servers, logger } = await createServer(config, routing); ``` Ensure having `@types/node` package installed. At least you need to specify the port (usually it is 443) or UNIX socket, @@ -713,10 +695,8 @@ Install the following additional packages: `compression` and `@types/compression import { createConfig } from "express-zod-api"; const config = createConfig({ - server: { - /** @link https://www.npmjs.com/package/compression#options */ - compression: { threshold: "1kb" }, // or true - }, + /** @link https://www.npmjs.com/package/compression#options */ + compression: { threshold: "1kb" }, // or true }); ``` @@ -729,14 +709,13 @@ In order to receive a compressed response the client should include the followin You can customize the list of `request` properties that are combined into `input` that is being validated and available to your endpoints and middlewares. The order here matters: each next item in the array has a higher priority than its -previous sibling. +previous sibling. The following arrangement is default: ```typescript import { createConfig } from "express-zod-api"; createConfig({ inputSources: { - // the defaults are: get: ["query", "params"], post: ["body", "params", "files"], put: ["body", "params"], @@ -780,26 +759,22 @@ You then need to specify these parameters in the endpoint input schema in the us ```typescript const getUserEndpoint = endpointsFactory.build({ - method: "get", input: z.object({ // id is the route path param, always string id: z.string().transform((value) => parseInt(value, 10)), // other inputs (in query): withExtendedInformation: z.boolean().optional(), }), - output: z.object({ - /* ... */ - }), - handler: async ({ input: { id } }) => { - // id is the route path param, number - }, + output: z.object({}), + handler: async ({ input: { id } }) => ({}), // id is number, }); ``` ## Multiple schemas for one route Thanks to the `DependsOnMethod` class a route may have multiple Endpoints attached depending on different methods. -It can also be the same Endpoint that handles multiple methods as well. +It can also be the same Endpoint that handles multiple methods as well. The `method` and `methods` properties can be +omitted for `EndpointsFactory::build()` so that the method determination would be delegated to the `Routing`. ```typescript import { DependsOnMethod } from "express-zod-api"; @@ -809,10 +784,10 @@ import { DependsOnMethod } from "express-zod-api"; const routing: Routing = { v1: { user: new DependsOnMethod({ - get: yourEndpointA, - delete: yourEndpointA, - post: yourEndpointB, - patch: yourEndpointB, + get: endpointA, + delete: endpointA, + post: endpointB, + patch: endpointB, }), }, }; @@ -844,7 +819,7 @@ import { const yourResultHandler = new ResultHandler({ positive: (data) => ({ schema: z.object({ data }), - mimeType: "application/json", // optinal, or mimeTypes for array + mimeType: "application/json", // optinal or array }), negative: z.object({ error: z.string() }), handler: ({ error, input, output, request, response, logger }) => { @@ -928,17 +903,12 @@ const fileStreamingEndpointsFactory = new EndpointsFactory( positive: { schema: ez.file("buffer"), mimeType: "image/*" }, negative: { schema: z.string(), mimeType: "text/plain" }, handler: ({ response, error, output }) => { - if (error) { - response.status(400).send(error.message); - return; - } - if ("filename" in output) { + if (error) return void response.status(400).send(error.message); + if ("filename" in output) fs.createReadStream(output.filename).pipe( response.type(output.filename), ); - } else { - response.status(400).send("Filename is missing"); - } + else response.status(400).send("Filename is missing"); }, }), ); @@ -953,9 +923,7 @@ configure file uploads: import { createConfig } from "express-zod-api"; const config = createConfig({ - server: { - upload: true, // or options - }, + upload: true, // or options }); ``` @@ -970,15 +938,11 @@ upload might look this way: import createHttpError from "http-errors"; const config = createConfig({ - server: { - upload: { - limits: { fileSize: 51200 }, // 50 KB - limitError: createHttpError(413, "The file is too large"), // handled by errorHandler in config - beforeUpload: ({ request, logger }) => { - if (!canUpload(request)) { - throw createHttpError(403, "Not authorized"); - } - }, + upload: { + limits: { fileSize: 51200 }, // 50 KB + limitError: createHttpError(413, "The file is too large"), // handled by errorHandler in config + beforeUpload: ({ request, logger }) => { + if (!canUpload(request)) throw createHttpError(403, "Not authorized"); }, }, }); @@ -1122,7 +1086,7 @@ import { ResultHandler } from "express-zod-api"; new ResultHandler({ positive: (data) => ({ - statusCodes: [201, 202], // created or will be created + statusCode: [201, 202], // created or will be created schema: z.object({ status: z.literal("created"), data }), }), negative: [ @@ -1131,7 +1095,7 @@ new ResultHandler({ schema: z.object({ status: z.literal("exists"), id: z.number().int() }), }, { - statusCodes: [400, 500], // validation or internal error + statusCode: [400, 500], // validation or internal error schema: z.object({ status: z.literal("error"), reason: z.string() }), }, ], @@ -1170,7 +1134,6 @@ createConfig({ }); defaultEndpointsFactory.build({ - method: "get", input: z.object({ "x-request-id": z.string(), // this one is from request.headers id: z.string(), // this one is from request.query @@ -1312,9 +1275,7 @@ const exampleEndpoint = defaultEndpointsFactory.build({ .object({ id: z.number().describe("the ID of the user"), }) - .example({ - id: 123, - }), + .example({ id: 123 }), // ..., similarly for output and middlewares }); ``` @@ -1337,9 +1298,8 @@ import { } from "express-zod-api"; const config = createConfig({ - // ..., use the simple or the advanced syntax: tags: { - users: "Everything about the users", + users: "Everything about the users", // or advanced syntax: files: { description: "Everything about the files processing", url: "https://example.com", @@ -1354,8 +1314,7 @@ const taggedEndpointsFactory = new EndpointsFactory({ }); const exampleEndpoint = taggedEndpointsFactory.build({ - // ... - tag: "users", // or tags: ["users", "files"] + tag: "users", // or array ["users", "files"] }); ``` @@ -1393,12 +1352,10 @@ const ruleForClient: Producer = ( ) => ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); new Documentation({ - /* config, routing, title, version */ brandHandling: { [myBrand]: ruleForDocs }, }); new Integration({ - /* routing */ brandHandling: { [myBrand]: ruleForClient }, }); ``` @@ -1433,7 +1390,7 @@ const output = z.object({ }); endpointsFactory.build({ - methods, + method, input, output, handler: async (): Promise> => ({ diff --git a/SECURITY.md b/SECURITY.md index a8def4aa1..61174ccbb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,10 +4,11 @@ | Version | Release | Supported | | ------: | :------ | :----------------: | +| 21.x.x | 11.2024 | :white_check_mark: | | 20.x.x | 06.2024 | :white_check_mark: | | 19.x.x | 05.2024 | :white_check_mark: | | 18.x.x | 04.2024 | :white_check_mark: | -| 17.x.x | 02.2024 | :white_check_mark: | +| 17.x.x | 02.2024 | :x: | | 16.x.x | 12.2023 | :x: | | 15.x.x | 12.2023 | :x: | | 14.x.x | 10.2023 | :x: | diff --git a/eslint.config.js b/eslint.config.js index a5febc590..3bf806e98 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -42,9 +42,27 @@ export default [ "no-restricted-syntax": [ "warn", { + // https://github.com/RobinTail/express-zod-api/pull/2169 selector: "ImportDeclaration[source.value=/assert/]", message: "assert is slow, use throw", }, + { + // https://github.com/RobinTail/express-zod-api/pull/2144 + selector: + "MemberExpression[object.name='process'][property.name='env']", + message: "Reading process.env is slow and must be memoized", + }, + { + // https://github.com/RobinTail/express-zod-api/pull/2168 + selector: "CallExpression > Identifier[name='toPairs']", + message: "R.toPairs() is 1.1x slower than Object.entries()", + }, + { + // https://github.com/RobinTail/express-zod-api/pull/2168 + selector: + "CallExpression[callee.name='keys'], CallExpression[callee.name='keysIn']", + message: "R.keys() and keysIn() are 1.2x slower than Object.keys()", + }, ], }, }, diff --git a/example/config.ts b/example/config.ts index e2f8b60fd..c98bb42d7 100644 --- a/example/config.ts +++ b/example/config.ts @@ -5,20 +5,18 @@ import { readFile } from "node:fs/promises"; import createHttpError from "http-errors"; export const config = createConfig({ - server: { - listen: 8090, - upload: { - limits: { fileSize: 51200 }, - limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint - }, - compression: true, // affects sendAvatarEndpoint - beforeRouting: async ({ app }) => { - // third-party middlewares serving their own routes or establishing their own routing besides the API - const documentation = yaml.parse( - await readFile("example/example.documentation.yaml", "utf-8"), - ); - app.use("/docs", ui.serve, ui.setup(documentation)); - }, + http: { listen: 8090 }, + upload: { + limits: { fileSize: 51200 }, + limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint + }, + compression: true, // affects sendAvatarEndpoint + beforeRouting: async ({ app }) => { + // third-party middlewares serving their own routes or establishing their own routing besides the API + const documentation = yaml.parse( + await readFile("example/example.documentation.yaml", "utf-8"), + ); + app.use("/docs", ui.serve, ui.setup(documentation)); }, cors: true, tags: { diff --git a/example/endpoints/list-users.ts b/example/endpoints/list-users.ts index c3e4b9685..bc75221cd 100644 --- a/example/endpoints/list-users.ts +++ b/example/endpoints/list-users.ts @@ -6,7 +6,6 @@ import { arrayRespondingFactory } from "../factories"; * Avoid doing this in new projects. This feature is only for easier migration of legacy APIs. * */ export const listUsersEndpoint = arrayRespondingFactory.build({ - method: "get", tag: "users", output: z .object({ diff --git a/example/endpoints/retrieve-user.ts b/example/endpoints/retrieve-user.ts index 9357c7906..04f538fd2 100644 --- a/example/endpoints/retrieve-user.ts +++ b/example/endpoints/retrieve-user.ts @@ -18,7 +18,6 @@ const feature: z.ZodType = baseFeature.extend({ export const retrieveUserEndpoint = taggedEndpointsFactory .addMiddleware(methodProviderMiddleware) .build({ - method: "get", tag: "users", shortDescription: "Retrieves the user.", description: "Example user retrieval endpoint.", diff --git a/example/endpoints/send-avatar.ts b/example/endpoints/send-avatar.ts index 05c306bd7..3be4f9fce 100644 --- a/example/endpoints/send-avatar.ts +++ b/example/endpoints/send-avatar.ts @@ -3,9 +3,8 @@ import { fileSendingEndpointsFactory } from "../factories"; import { readFile } from "node:fs/promises"; export const sendAvatarEndpoint = fileSendingEndpointsFactory.build({ - method: "get", shortDescription: "Sends a file content.", - tags: ["files", "users"], + tag: ["files", "users"], input: z.object({ userId: z .string() diff --git a/example/endpoints/stream-avatar.ts b/example/endpoints/stream-avatar.ts index 218a05852..e13db6ff5 100644 --- a/example/endpoints/stream-avatar.ts +++ b/example/endpoints/stream-avatar.ts @@ -2,9 +2,8 @@ import { z } from "zod"; import { fileStreamingEndpointsFactory } from "../factories"; export const streamAvatarEndpoint = fileStreamingEndpointsFactory.build({ - method: "get", shortDescription: "Streams a file content.", - tags: ["users", "files"], + tag: ["users", "files"], input: z.object({ userId: z .string() diff --git a/example/endpoints/update-user.ts b/example/endpoints/update-user.ts index 545ef5d30..da57ab05e 100644 --- a/example/endpoints/update-user.ts +++ b/example/endpoints/update-user.ts @@ -6,7 +6,6 @@ import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories"; export const updateUserEndpoint = keyAndTokenAuthenticatedEndpointsFactory.build({ - method: "patch", tag: "users", description: "Changes the user record. Example user update endpoint.", input: z diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 58b1b69ee..8b8d8e3fa 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Example API - version: 20.22.1 + version: 21.0.0-beta.6 paths: /v1/user/retrieve: get: diff --git a/example/factories.ts b/example/factories.ts index 7777c7f7d..cf0b617fa 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -66,7 +66,7 @@ export const statusDependingFactory = new EndpointsFactory({ config, resultHandler: new ResultHandler({ positive: (data) => ({ - statusCodes: [201, 202], + statusCode: [201, 202], schema: z.object({ status: z.literal("created"), data }), }), negative: [ @@ -75,7 +75,7 @@ export const statusDependingFactory = new EndpointsFactory({ schema: z.object({ status: z.literal("exists"), id: z.number().int() }), }, { - statusCodes: [400, 500], + statusCode: [400, 500], schema: z.object({ status: z.literal("error"), reason: z.string() }), }, ], diff --git a/example/routing.ts b/example/routing.ts index de90ffcee..a6bc010a5 100644 --- a/example/routing.ts +++ b/example/routing.ts @@ -16,7 +16,6 @@ export const routing: Routing = { retrieve: retrieveUserEndpoint, // path: /v1/user/retrieve // syntax 2: methods are defined within the route (id is the route path param by the way) ":id": new DependsOnMethod({ - // the endpoints listed here must support at least the same method they are assigned to patch: updateUserEndpoint, // demonstrates authentication }), // demonstrates different response schemas depending on status code diff --git a/package.json b/package.json index 06b2e7c40..d4792c07f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-zod-api", - "version": "20.22.1", + "version": "21.0.0-beta.6", "description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.", "license": "MIT", "repository": { @@ -85,7 +85,7 @@ "@types/express-fileupload": "^1.5.0", "@types/http-errors": "^2.0.2", "compression": "^1.7.4", - "express": "^4.18.2 || ^5.0.0", + "express": "^4.21.1 || 5.0.1", "express-fileupload": "^1.5.0", "http-errors": "^2.0.0", "typescript": "^5.1.3", diff --git a/src/api-response.ts b/src/api-response.ts index 9036099c4..44abdebd3 100644 --- a/src/api-response.ts +++ b/src/api-response.ts @@ -7,28 +7,25 @@ export const defaultStatusCodes = { export type ResponseVariant = keyof typeof defaultStatusCodes; +/** @public this is the user facing configuration */ export interface ApiResponse { schema: S; - /** - * @default 200 for a positive response - * @default 400 for a negative response - * @override statusCodes - * */ - statusCode?: number; - /** - * @default [200] for positive response - * @default [400] for negative response - * */ - statusCodes?: [number, ...number[]]; - /** - * @default "application/json" - * @override mimeTypes - * */ - mimeType?: string; - /** @default [ "application/json" ] */ - mimeTypes?: [string, ...string[]]; + /** @default 200 for a positive and 400 for a negative response */ + statusCode?: number | [number, ...number[]]; + /** @default "application/json" */ + mimeType?: string | [string, ...string[]]; + /** @deprecated use statusCode */ + statusCodes?: never; + /** @deprecated use mimeType */ + mimeTypes?: never; } -export type NormalizedResponse = Required< - Pick, "schema" | "statusCodes" | "mimeTypes"> ->; +/** + * @private This is what the framework entities operate + * @see normalize + * */ +export interface NormalizedResponse { + schema: z.ZodTypeAny; + statusCodes: [number, ...number[]]; + mimeTypes: [string, ...string[]]; +} diff --git a/src/common-helpers.ts b/src/common-helpers.ts index 9849bff54..8d84f6d5e 100644 --- a/src/common-helpers.ts +++ b/src/common-helpers.ts @@ -148,6 +148,6 @@ export const isObject = (subject: unknown) => typeof subject === "object" && subject !== null; export const isProduction = memoizeWith( - () => process.env.TSUP_STATIC as string, // dynamic in tests, but static in build - () => process.env.NODE_ENV === "production", + () => process.env.TSUP_STATIC as string, // eslint-disable-line no-restricted-syntax -- substituted by TSUP + () => process.env.NODE_ENV === "production", // eslint-disable-line no-restricted-syntax -- memoized ); diff --git a/src/config-type.ts b/src/config-type.ts index 9c48023c2..519dad5f7 100644 --- a/src/config-type.ts +++ b/src/config-type.ts @@ -8,7 +8,7 @@ import { AbstractLogger, ActualLogger } from "./logger-helpers"; import { Method } from "./method"; import { AbstractResultHandler } from "./result-handler"; import { ListenOptions } from "node:net"; -import { ChildLoggerExtractor } from "./server-helpers"; +import { GetLogger } from "./server-helpers"; export type InputSource = keyof Pick< Request, @@ -131,61 +131,58 @@ interface GracefulOptions { type BeforeRouting = (params: { app: IRouter; - /** - * @desc Root logger, same for all requests - * @todo reconsider the naming in v21 - * */ - logger: ActualLogger; - /** @desc Returns a child logger if childLoggerProvider is configured (otherwise root logger) */ - getChildLogger: ChildLoggerExtractor; + /** @desc Returns child logger for the given request (if configured) or the configured logger otherwise */ + getLogger: GetLogger; }) => void | Promise; +export interface HttpConfig { + /** @desc Port, UNIX socket or custom options. */ + listen: number | string | ListenOptions; +} + +interface HttpsConfig extends HttpConfig { + /** @desc At least "cert" and "key" options required. */ + options: ServerOptions; +} + export interface ServerConfig extends CommonConfig { - /** @desc Server configuration. */ - server: { - /** @desc Port, UNIX socket or custom options. */ - listen: number | string | ListenOptions; - /** - * @desc Custom JSON parser. - * @default express.json() - * @link https://expressjs.com/en/4x/api.html#express.json - * */ - jsonParser?: RequestHandler; - /** - * @desc Enable or configure uploads handling. - * @default undefined - * @requires express-fileupload - * */ - upload?: boolean | UploadOptions; - /** - * @desc Enable or configure response compression. - * @default undefined - * @requires compression - */ - compression?: boolean | CompressionOptions; - /** - * @desc Custom raw parser (assigns Buffer to request body) - * @default express.raw() - * @link https://expressjs.com/en/4x/api.html#express.raw - * */ - rawParser?: RequestHandler; - /** - * @desc A code to execute before processing the Routing of your API (and before parsing). - * @desc This can be a good place for express middlewares establishing their own routes. - * @desc It can help to avoid making a DIY solution based on the attachRouting() approach. - * @default undefined - * @example ({ app }) => { app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); } - * */ - beforeRouting?: BeforeRouting; - }; - /** @desc Enables HTTPS server as well. */ - https?: { - /** @desc At least "cert" and "key" options required. */ - options: ServerOptions; - /** @desc Port, UNIX socket or custom options. */ - listen: number | string | ListenOptions; - }; + /** @desc HTTP server configuration. */ + http?: HttpConfig; + /** @desc HTTPS server configuration. */ + https?: HttpsConfig; + /** + * @desc Custom JSON parser. + * @default express.json() + * @link https://expressjs.com/en/4x/api.html#express.json + * */ + jsonParser?: RequestHandler; + /** + * @desc Enable or configure uploads handling. + * @default undefined + * @requires express-fileupload + * */ + upload?: boolean | UploadOptions; + /** + * @desc Enable or configure response compression. + * @default undefined + * @requires compression + */ + compression?: boolean | CompressionOptions; + /** + * @desc Custom raw parser (assigns Buffer to request body) + * @default express.raw() + * @link https://expressjs.com/en/4x/api.html#express.raw + * */ + rawParser?: RequestHandler; + /** + * @desc A code to execute before processing the Routing of your API (and before parsing). + * @desc This can be a good place for express middlewares establishing their own routes. + * @desc It can help to avoid making a DIY solution based on the attachRouting() approach. + * @default undefined + * @example ({ app }) => { app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); } + * */ + beforeRouting?: BeforeRouting; /** * @desc Rejects new connections and attempts to finish ongoing ones in the specified time before exit. * @default undefined diff --git a/src/depends-on-method.ts b/src/depends-on-method.ts index 1b8810761..cd6a2faf0 100644 --- a/src/depends-on-method.ts +++ b/src/depends-on-method.ts @@ -1,24 +1,21 @@ -import { head, tail, toPairs } from "ramda"; +import { keys, reject, equals } from "ramda"; import { AbstractEndpoint } from "./endpoint"; import { Method } from "./method"; import { Nesting } from "./nesting"; export class DependsOnMethod extends Nesting { - public readonly pairs: ReadonlyArray<[Method, AbstractEndpoint]>; - public readonly firstEndpoint: AbstractEndpoint | undefined; - public readonly siblingMethods: ReadonlyArray; + /** @desc [method, endpoint, siblingMethods] */ + public readonly entries: ReadonlyArray<[Method, AbstractEndpoint, Method[]]>; constructor(endpoints: Partial>) { super(); - this.pairs = Object.freeze( - toPairs(endpoints).filter( - (pair): pair is [Method, AbstractEndpoint] => - pair !== undefined && pair[1] !== undefined, - ), - ); - this.firstEndpoint = head(this.pairs)?.[1]; - this.siblingMethods = Object.freeze( - tail(this.pairs).map(([method]) => method), - ); + const entries: Array<(typeof this.entries)[number]> = []; + const methods = keys(endpoints); // eslint-disable-line no-restricted-syntax -- liternal type required + for (const method of methods) { + const endpoint = endpoints[method]; + if (endpoint) + entries.push([method, endpoint, reject(equals(method), methods)]); + } + this.entries = Object.freeze(entries); } } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 69e33f10f..382c450de 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -6,32 +6,31 @@ import { ActualLogger } from "./logger-helpers"; export class Diagnostics { #verified = new WeakSet(); + constructor(protected logger: ActualLogger) {} - public check( - endpoint: AbstractEndpoint, - logger: ActualLogger, - ctx: FlatObject, - ): void { + public check(endpoint: AbstractEndpoint, ctx: FlatObject): void { if (!this.#verified.has(endpoint)) { if (endpoint.getRequestType() === "json") { try { assertJsonCompatible(endpoint.getSchema("input"), "in"); } catch (reason) { - logger.warn( + this.logger.warn( "The final input schema of the endpoint contains an unsupported JSON payload type.", Object.assign(ctx, { reason }), ); } } for (const variant of ["positive", "negative"] as const) { - if (endpoint.getMimeTypes(variant).includes(contentTypes.json)) { - try { - assertJsonCompatible(endpoint.getSchema(variant), "out"); - } catch (reason) { - logger.warn( - `The final ${variant} response schema of the endpoint contains an unsupported JSON payload type.`, - Object.assign(ctx, { reason }), - ); + for (const { mimeTypes, schema } of endpoint.getResponses(variant)) { + if (mimeTypes.includes(contentTypes.json)) { + try { + assertJsonCompatible(schema, "out"); + } catch (reason) { + this.logger.warn( + `The final ${variant} response schema of the endpoint contains an unsupported JSON payload type.`, + Object.assign(ctx, { reason }), + ); + } } } } diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index e3cde49b1..9c2aa6a2a 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -135,12 +135,8 @@ export const depictCatch: Depicter = ( export const depictAny: Depicter = () => ({ format: "any" }); export const depictUpload: Depicter = ({}: UploadSchema, ctx) => { - if (ctx.isResponse) { - throw new DocumentationError({ - message: "Please use ez.upload() only for input.", - ...ctx, - }); - } + if (ctx.isResponse) + throw new DocumentationError("Please use ez.upload() only for input.", ctx); return { type: "string", format: "binary" }; }; @@ -297,12 +293,8 @@ export const depictObject: Depicter = ( export const depictNull: Depicter = () => ({ type: "null" }); export const depictDateIn: Depicter = ({}: DateInSchema, ctx) => { - if (ctx.isResponse) { - throw new DocumentationError({ - message: "Please use ez.dateOut() for output.", - ...ctx, - }); - } + if (ctx.isResponse) + throw new DocumentationError("Please use ez.dateOut() for output.", ctx); return { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", @@ -315,12 +307,8 @@ export const depictDateIn: Depicter = ({}: DateInSchema, ctx) => { }; export const depictDateOut: Depicter = ({}: DateOutSchema, ctx) => { - if (!ctx.isResponse) { - throw new DocumentationError({ - message: "Please use ez.dateIn() for input.", - ...ctx, - }); - } + if (!ctx.isResponse) + throw new DocumentationError("Please use ez.dateIn() for input.", ctx); return { description: "YYYY-MM-DDTHH:mm:ss.sssZ", type: "string", @@ -333,14 +321,14 @@ export const depictDateOut: Depicter = ({}: DateOutSchema, ctx) => { /** @throws DocumentationError */ export const depictDate: Depicter = ({}: z.ZodDate, ctx) => { - throw new DocumentationError({ - message: `Using z.date() within ${ + throw new DocumentationError( + `Using z.date() within ${ ctx.isResponse ? "output" : "input" } schema is forbidden. Please use ez.date${ ctx.isResponse ? "Out" : "In" }() instead. Check out the documentation for details.`, - ...ctx, - }); + ctx, + ); }; export const depictBoolean: Depicter = () => ({ type: "boolean" }); @@ -747,10 +735,10 @@ export const onMissing: SchemaHandler< OpenAPIContext, "last" > = (schema: z.ZodTypeAny, ctx) => { - throw new DocumentationError({ - message: `Zod type ${schema.constructor.name} is unsupported.`, - ...ctx, - }); + throw new DocumentationError( + `Zod type ${schema.constructor.name} is unsupported.`, + ctx, + ); }; export const excludeParamsFromDepiction = ( @@ -923,14 +911,14 @@ export const depictBody = ({ method, path, schema, - mimeTypes, + mimeType, makeRef, composition, brandHandling, paramNames, description = `${method.toUpperCase()} ${path} Request body`, }: ReqResHandlingProps & { - mimeTypes: ReadonlyArray; + mimeType: string; paramNames: string[]; }): RequestBodyObject => { const bodyDepiction = excludeExamplesFromDepiction( @@ -951,7 +939,7 @@ export const depictBody = ({ : bodyDepiction, examples: depictExamples(schema, false, paramNames), }; - return { description, content: fromPairs(xprod(mimeTypes, [media])) }; + return { description, content: { [mimeType]: media } }; }; export const depictTags = ( diff --git a/src/documentation.ts b/src/documentation.ts index 9f8475db9..510ed79c4 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -9,6 +9,7 @@ import { import { keys, pluck } from "ramda"; import { z } from "zod"; import { defaultStatusCodes } from "./api-response"; +import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { defaultInputSources, makeCleanId } from "./common-helpers"; import { CommonConfig } from "./config-type"; @@ -58,11 +59,6 @@ interface DocumentationParams { hasSummaryFromDescription?: boolean; /** @default inline */ composition?: "inline" | "components"; - /** - * @deprecated no longer used - * @todo remove in v21 - * */ - serializer?: (schema: z.ZodTypeAny) => string; /** * @desc Handling rules for your own branded schemas. * @desc Keys: brands (recommended to use unique symbols). @@ -75,7 +71,7 @@ interface DocumentationParams { export class Documentation extends OpenApiBuilder { protected lastSecuritySchemaIds = new Map(); protected lastOperationIdSuffixes = new Map(); - protected responseVariants = keys(defaultStatusCodes); + protected responseVariants = keys(defaultStatusCodes); // eslint-disable-line no-restricted-syntax -- literal protected references = new Map(); protected makeRef( @@ -107,8 +103,7 @@ export class Documentation extends OpenApiBuilder { return operationId; } if (userDefined) { - throw new DocumentationError({ - message: `Duplicated operationId: "${userDefined}"`, + throw new DocumentationError(`Duplicated operationId: "${userDefined}"`, { method, isResponse: false, path, @@ -151,9 +146,8 @@ export class Documentation extends OpenApiBuilder { const onEndpoint: RoutingWalkerParams["onEndpoint"] = ( endpoint, path, - _method, + method, ) => { - const method = _method as Method; const commons = { path, method, @@ -219,7 +213,7 @@ export class Documentation extends OpenApiBuilder { ...commons, paramNames: pluck("name", depictedParams), schema: endpoint.getSchema("input"), - mimeTypes: endpoint.getMimeTypes("input"), + mimeType: contentTypes[endpoint.getRequestType()], description: descriptions?.requestBody?.call(null, { method, path, diff --git a/src/endpoint.ts b/src/endpoint.ts index 23cbcded8..5ef4e62f8 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -20,7 +20,7 @@ import { ActualLogger } from "./logger-helpers"; import { LogicalContainer, combineContainers } from "./logical-container"; import { AuxMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; -import { ContentType, contentTypes } from "./content-type"; +import { ContentType } from "./content-type"; import { Nesting } from "./nesting"; import { AbstractResultHandler } from "./result-handler"; import { Security } from "./security"; @@ -33,7 +33,6 @@ export type Handler = (params: { type DescriptionVariant = "short" | "long"; type IOVariant = "input" | "output"; -type MimeVariant = Extract | ResponseVariant; export abstract class AbstractEndpoint extends Nesting { public abstract execute(params: { @@ -41,15 +40,12 @@ export abstract class AbstractEndpoint extends Nesting { response: Response; logger: ActualLogger; config: CommonConfig; - siblingMethods?: ReadonlyArray; }): Promise; public abstract getDescription( variant: DescriptionVariant, ): string | undefined; - public abstract getMethods(): ReadonlyArray; + public abstract getMethods(): ReadonlyArray | undefined; public abstract getSchema(variant: IOVariant): IOSchema; - public abstract getSchema(variant: ResponseVariant): z.ZodTypeAny; - public abstract getMimeTypes(variant: MimeVariant): ReadonlyArray; public abstract getResponses( variant: ResponseVariant, ): ReadonlyArray; @@ -68,9 +64,8 @@ export class Endpoint< TAG extends string, > extends AbstractEndpoint { readonly #descriptions: Record; - readonly #methods: ReadonlyArray; + readonly #methods?: ReadonlyArray; readonly #middlewares: AbstractMiddleware[]; - readonly #mimeTypes: Record>; readonly #responses: Record< ResponseVariant, ReadonlyArray @@ -104,7 +99,7 @@ export class Endpoint< description?: string; shortDescription?: string; getOperationId?: (method: Method) => string | undefined; - methods: Method[]; + methods?: Method[]; scopes?: SCO[]; tags?: TAG[]; }) { @@ -127,15 +122,6 @@ export class Endpoint< : hasRaw(inputSchema) ? "raw" : "json"; - this.#mimeTypes = { - input: Object.freeze([contentTypes[this.#requestType]]), - positive: Object.freeze( - this.#responses.positive.flatMap(({ mimeTypes }) => mimeTypes), - ), - negative: Object.freeze( - this.#responses.negative.flatMap(({ mimeTypes }) => mimeTypes), - ), - }; } public override getDescription(variant: DescriptionVariant) { @@ -148,17 +134,8 @@ export class Endpoint< public override getSchema(variant: "input"): IN; public override getSchema(variant: "output"): OUT; - public override getSchema(variant: ResponseVariant): z.ZodTypeAny; - public override getSchema(variant: IOVariant | ResponseVariant) { - if (variant === "input" || variant === "output") - return this.#schemas[variant]; - return this.getResponses(variant) - .map(({ schema }) => schema) - .reduce((agg, schema) => agg.or(schema)); - } - - public override getMimeTypes(variant: MimeVariant) { - return this.#mimeTypes[variant]; + public override getSchema(variant: IOVariant) { + return this.#schemas[variant]; } public override getRequestType() { @@ -191,19 +168,6 @@ export class Endpoint< return this.#getOperationId(method); } - #getDefaultCorsHeaders(siblingMethods: Method[]): Record { - const accessMethods = (this.#methods as Array) - .concat(siblingMethods) - .concat("options") - .join(", ") - .toUpperCase(); - return { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": accessMethods, - "Access-Control-Allow-Headers": "content-type", - }; - } - async #parseOutput(output: z.input) { try { return (await this.#schemas.output.parseAsync(output)) as FlatObject; @@ -288,30 +252,16 @@ export class Endpoint< response, logger, config, - siblingMethods = [], }: { request: Request; response: Response; logger: ActualLogger; config: CommonConfig; - siblingMethods?: Method[]; }) { const method = getActualMethod(request); const options: Partial = {}; let output: FlatObject | null = null; let error: Error | null = null; - if (config.cors) { - let headers = this.#getDefaultCorsHeaders(siblingMethods); - if (typeof config.cors === "function") { - headers = await config.cors({ - request, - logger, - endpoint: this, - defaultHeaders: headers, - }); - } - for (const key in headers) response.set(key, headers[key]); - } const input = getInput(request, config.inputSources); try { await this.#runMiddlewares({ diff --git a/src/endpoints-factory.ts b/src/endpoints-factory.ts index d58a5de82..977f3a680 100644 --- a/src/endpoints-factory.ts +++ b/src/endpoints-factory.ts @@ -30,9 +30,10 @@ type BuildProps< description?: string; shortDescription?: string; operationId?: string | ((method: Method) => string); -} & ({ method: Method } | { methods: Method[] }) & - ({ scopes?: SCO[] } | { scope?: SCO }) & - ({ tags?: TAG[] } | { tag?: TAG }); + method?: Method | [Method, ...Method[]]; + scope?: SCO | SCO[]; + tag?: TAG | TAG[]; +}; export class EndpointsFactory< IN extends IOSchema<"strip"> = EmptySchema, @@ -121,7 +122,9 @@ export class EndpointsFactory< description, shortDescription, operationId, - ...rest + scope, + tag, + method, }: BuildProps): Endpoint< z.ZodIntersection, BOUT, @@ -130,17 +133,11 @@ export class EndpointsFactory< TAG > { const { middlewares, resultHandler } = this; - const methods = "methods" in rest ? rest.methods : [rest.method]; + const methods = typeof method === "string" ? [method] : method; const getOperationId = typeof operationId === "function" ? operationId : () => operationId; - const scopes = - "scopes" in rest - ? rest.scopes - : "scope" in rest && rest.scope - ? [rest.scope] - : []; - const tags = - "tags" in rest ? rest.tags : "tag" in rest && rest.tag ? [rest.tag] : []; + const scopes = typeof scope === "string" ? [scope] : scope || []; + const tags = typeof tag === "string" ? [tag] : tag || []; return new Endpoint({ handler, middlewares, diff --git a/src/errors.ts b/src/errors.ts index 4aeb195ec..39794d62e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,15 +14,14 @@ export class DocumentationError extends Error { public override name = "DocumentationError"; public override readonly cause: string; - constructor({ - message, - method, - path, - isResponse, - }: { message: string } & Pick< - OpenAPIContext, - "path" | "method" | "isResponse" - >) { + constructor( + message: string, + { + method, + path, + isResponse, + }: Pick, + ) { super(message); this.cause = `${ isResponse ? "Response" : "Input" @@ -42,14 +41,6 @@ export class OutputValidationError extends IOSchemaError { constructor(public override readonly cause: z.ZodError) { super(getMessageFromError(cause), { cause }); } - - /** - * @deprecated use the cause property instead - * @todo remove in v21 - * */ - public get originalError() { - return this.cause; - } } /** @desc An error of validating the input sources against the Middleware or Endpoint input schema */ @@ -59,14 +50,6 @@ export class InputValidationError extends IOSchemaError { constructor(public override readonly cause: z.ZodError) { super(getMessageFromError(cause), { cause }); } - - /** - * @deprecated use the cause property instead - * @todo remove in v21 - * */ - public get originalError() { - return this.cause; - } } /** @desc An error related to the execution or incorrect configuration of ResultHandler */ diff --git a/src/index.ts b/src/index.ts index 9beaf3c54..b2f0e546f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ export { arrayEndpointsFactory, } from "./endpoints-factory"; export { getExamples, getMessageFromError } from "./common-helpers"; -export { getStatusCodeFromError, ensureHttpError } from "./result-helpers"; +export { ensureHttpError } from "./result-helpers"; export { BuiltinLogger } from "./builtin-logger"; export { Middleware } from "./middleware"; export { @@ -42,7 +42,7 @@ export type { FlatObject } from "./common-helpers"; export type { Method } from "./method"; export type { IOSchema } from "./io-schema"; export type { CommonConfig, AppConfig, ServerConfig } from "./config-type"; -export type { ApiResponse } from "./api-response"; +export type { ApiResponse, NormalizedResponse } from "./api-response"; export type { BasicSecurity, BearerSecurity, diff --git a/src/integration-helpers.ts b/src/integration-helpers.ts index c5e569c30..3ad999c1e 100644 --- a/src/integration-helpers.ts +++ b/src/integration-helpers.ts @@ -1,5 +1,5 @@ import ts from "typescript"; -import { chain, toPairs } from "ramda"; +import { chain } from "ramda"; import { Method } from "./method"; export const f = ts.factory; @@ -55,7 +55,7 @@ export const makeParams = ( ) => chain( ([name, node]) => [makeParam(f.createIdentifier(name), node, mod)], - toPairs(params), + Object.entries(params), ); export const makeRecord = ( @@ -159,7 +159,7 @@ const aggregateDeclarations = chain(([name, id]: [string, ts.Identifier]) => [ f.createTypeParameterDeclaration([], name, f.createTypeReferenceNode(id)), ]); export const makeTypeParams = (params: Record) => - aggregateDeclarations(toPairs(params)); + aggregateDeclarations(Object.entries(params)); export const makeArrowFn = ( params: ts.Identifier[], diff --git a/src/integration.ts b/src/integration.ts index 53e42d8f0..41727e514 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -54,11 +54,6 @@ interface IntegrationParams { * @default false * */ splitResponse?: boolean; - /** - * @deprecated no longer used - * @todo remove in v21 - * */ - serializer?: (schema: z.ZodTypeAny) => string; /** * @desc configures the style of object's optional properties * @default { withQuestionMark: true, withUndefined: true } @@ -178,7 +173,10 @@ export class Integration { const positiveResponseId = splitResponse ? makeCleanId(method, path, "positive.response") : undefined; - const positiveSchema = endpoint.getSchema("positive"); + const positiveSchema = endpoint + .getResponses("positive") + .map(({ schema }) => schema) + .reduce((agg, schema) => agg.or(schema)); const positiveResponse = splitResponse ? zodToTs(positiveSchema, { brandHandling, @@ -188,7 +186,10 @@ export class Integration { const negativeResponseId = splitResponse ? makeCleanId(method, path, "negative.response") : undefined; - const negativeSchema = endpoint.getSchema("negative"); + const negativeSchema = endpoint + .getResponses("negative") + .map(({ schema }) => schema) + .reduce((agg, schema) => agg.or(schema)); const negativeResponse = splitResponse ? zodToTs(negativeSchema, { brandHandling, @@ -218,22 +219,22 @@ export class Integration { ); } this.program.push(createTypeAlias(genericResponse, genericResponseId)); - if (method !== "options") { - this.paths.push(path); - this.registry.set( - { method, path }, - { - input: inputId, - positive: positiveResponseId, - negative: negativeResponseId, - response: genericResponseId, - isJson: endpoint - .getMimeTypes("positive") - .includes(contentTypes.json), - tags: endpoint.getTags(), - }, - ); - } + this.paths.push(path); + this.registry.set( + { method, path }, + { + input: inputId, + positive: positiveResponseId, + negative: negativeResponseId, + response: genericResponseId, + isJson: endpoint + .getResponses("positive") + .some((response) => + response.mimeTypes.includes(contentTypes.json), + ), + tags: endpoint.getTags(), + }, + ); }, }); diff --git a/src/migration.ts b/src/migration.ts index 28e1f83c4..c3d596f2a 100644 --- a/src/migration.ts +++ b/src/migration.ts @@ -1,49 +1,114 @@ -import { ESLintUtils, type TSESLint } from "@typescript-eslint/utils"; +import { + ESLintUtils, + AST_NODE_TYPES as NT, + type TSESLint, + type TSESTree, +} from "@typescript-eslint/utils"; import { name as importName } from "../package.json"; -const testerName = "testEndpoint"; +const createConfigName = "createConfig"; +const createServerName = "createServer"; +const serverPropName = "server"; +const beforeRoutingPropName = "beforeRouting"; +const httpServerPropName = "httpServer"; +const httpsServerPropName = "httpsServer"; +const originalErrorPropName = "originalError"; +const getStatusCodeFromErrorMethod = "getStatusCodeFromError"; +const loggerPropName = "logger"; +const getChildLoggerPropName = "getChildLogger"; +const methodsPropName = "methods"; +const tagsPropName = "tags"; +const scopesPropName = "scopes"; +const statusCodesPropName = "statusCodes"; +const mimeTypesPropName = "mimeTypes"; +const buildMethod = "build"; +const resultHandlerClass = "ResultHandler"; +const handlerMethod = "handler"; + +const changedProps = { + [serverPropName]: "http", + [httpServerPropName]: "servers", + [httpsServerPropName]: "servers", + [originalErrorPropName]: "cause", + [loggerPropName]: "getLogger", + [getChildLoggerPropName]: "getLogger", + [methodsPropName]: "method", + [tagsPropName]: "tag", + [scopesPropName]: "scope", + [statusCodesPropName]: "statusCode", + [mimeTypesPropName]: "mimeType", +}; const changedMethods = { - createLogger: "BuiltinLogger", - createResultHandler: "ResultHandler", - createMiddleware: "Middleware", + [getStatusCodeFromErrorMethod]: "ensureHttpError", }; -const changedProps = { - getPositiveResponse: "positive", - getNegativeResponse: "negative", - responseProps: "responseOptions", - middleware: "handler", +const movedProps = [ + "jsonParser", + "upload", + "compression", + "rawParser", + "beforeRouting", +] as const; + +const esQueries = { + loggerArgument: + `${NT.Property}[key.name="${beforeRoutingPropName}"] ` + + `${NT.ArrowFunctionExpression} ` + + `${NT.Identifier}[name="${loggerPropName}"]`, + getChildLoggerArgument: + `${NT.Property}[key.name="${beforeRoutingPropName}"] ` + + `${NT.ArrowFunctionExpression} ` + + `${NT.Identifier}[name="${getChildLoggerPropName}"]`, + responseFeatures: + `${NT.NewExpression}[callee.name='${resultHandlerClass}'] > ` + + `${NT.ObjectExpression} > ` + + `${NT.Property}[key.name!='${handlerMethod}'] ` + + `${NT.Property}[key.name=/(${statusCodesPropName}|${mimeTypesPropName})/]`, }; -const removedProps = { fnMethod: null }; +type PropWithId = TSESTree.Property & { + key: TSESTree.Identifier; +}; + +const isPropWithId = (subject: TSESTree.Node): subject is PropWithId => + subject.type === NT.Property && subject.key.type === NT.Identifier; + +const isAssignment = ( + parent: TSESTree.Node, +): parent is TSESTree.VariableDeclarator & { id: TSESTree.ObjectPattern } => + parent.type === NT.VariableDeclarator && parent.id.type === NT.ObjectPattern; -const shouldAct = >( - subject: unknown, - scope: T, -): subject is keyof T => typeof subject === "string" && subject in scope; +const propByName = + (subject: T | ReadonlyArray) => + (entry: TSESTree.Node): entry is PropWithId & { key: { name: T } } => + isPropWithId(entry) && + (Array.isArray(subject) + ? subject.includes(entry.key.name) + : entry.key.name === subject); -const v20 = ESLintUtils.RuleCreator.withoutDocs({ +const v21 = ESLintUtils.RuleCreator.withoutDocs({ meta: { type: "problem", fixable: "code", schema: [], messages: { change: "Change {{subject}} {{from}} to {{to}}.", - remove: "Remove {{subject}} {{name}}.", + move: "Move {{subject}} from {{from}} to {{to}}.", }, }, defaultOptions: [], create: (ctx) => ({ - ImportDeclaration: (node) => { + [NT.ImportDeclaration]: (node) => { if (node.source.value === importName) { for (const spec of node.specifiers) { if ( - spec.type === "ImportSpecifier" && - spec.imported.type === "Identifier" && - shouldAct(spec.imported.name, changedMethods) + spec.type === NT.ImportSpecifier && + spec.imported.type === NT.Identifier && + spec.imported.name in changedMethods ) { - const replacement = changedMethods[spec.imported.name]; + const replacement = + changedMethods[spec.imported.name as keyof typeof changedMethods]; ctx.report({ node: spec.imported, messageId: "change", @@ -58,99 +123,179 @@ const v20 = ESLintUtils.RuleCreator.withoutDocs({ } } }, - CallExpression: (node) => { + [NT.MemberExpression]: (node) => { if ( - node.callee.type === "Identifier" && - shouldAct(node.callee.name, changedMethods) + node.property.type === NT.Identifier && + node.property.name === originalErrorPropName && + node.object.type === NT.Identifier && + node.object.name.match(/err/i) // this is probably an error instance, but we don't do type checking ) { - const replacement = `new ${changedMethods[node.callee.name]}`; + const replacement = changedProps[node.property.name]; ctx.report({ - node: node.callee, + node: node.property, messageId: "change", - data: { subject: "call", from: node.callee.name, to: replacement }, - fix: (fixer) => fixer.replaceText(node.callee, replacement), + data: { + subject: "property", + from: node.property.name, + to: replacement, + }, }); } + }, + [NT.CallExpression]: (node) => { if ( - node.callee.type === "Identifier" && - node.callee.name === testerName && + node.callee.type === NT.MemberExpression && + node.callee.property.type === NT.Identifier && + node.callee.property.name === buildMethod && node.arguments.length === 1 && - node.arguments[0].type === "ObjectExpression" + node.arguments[0].type === NT.ObjectExpression ) { - for (const prop of node.arguments[0].properties) { - if (prop.type === "Property" && prop.key.type === "Identifier") { - if (shouldAct(prop.key.name, changedProps)) { - const replacement = changedProps[prop.key.name]; + const changed = node.arguments[0].properties.filter( + propByName([methodsPropName, tagsPropName, scopesPropName] as const), + ); + for (const prop of changed) { + const replacement = changedProps[prop.key.name]; + ctx.report({ + node: prop, + messageId: "change", + data: { subject: "property", from: prop.key.name, to: replacement }, + fix: (fixer) => fixer.replaceText(prop.key, replacement), + }); + } + } + if (node.callee.type !== NT.Identifier) return; + if ( + node.callee.name === createConfigName && + node.arguments.length === 1 + ) { + const argument = node.arguments[0]; + if (argument.type === NT.ObjectExpression) { + const serverProp = argument.properties.find( + propByName(serverPropName), + ); + if (serverProp) { + const replacement = changedProps[serverProp.key.name]; + ctx.report({ + node: serverProp, + messageId: "change", + data: { + subject: "property", + from: serverProp.key.name, + to: replacement, + }, + fix: (fixer) => fixer.replaceText(serverProp.key, replacement), + }); + } + const httpProp = argument.properties.find( + propByName(changedProps.server), + ); + if (httpProp && httpProp.value.type === NT.ObjectExpression) { + const nested = httpProp.value.properties; + const movable = nested.filter(propByName(movedProps)); + for (const prop of movable) { + const propText = ctx.sourceCode.text.slice(...prop.range); + const comma = ctx.sourceCode.getTokenAfter(prop); ctx.report({ - node: prop, - messageId: "change", + node: httpProp, + messageId: "move", data: { - subject: "property", - from: prop.key.name, - to: replacement, + subject: isPropWithId(prop) ? prop.key.name : "the property", + from: httpProp.key.name, + to: `the top level of ${node.callee.name} argument`, }, - fix: (fixer) => fixer.replaceText(prop.key, replacement), - }); - } - if (shouldAct(prop.key.name, removedProps)) { - ctx.report({ - node: prop, - messageId: "remove", - data: { subject: "property", name: prop.key.name }, - fix: (fixer) => - ctx.sourceCode.getTokenAfter(prop)?.value === "," - ? fixer.removeRange([prop.range[0], prop.range[1] + 1]) - : fixer.remove(prop), + fix: (fixer) => [ + fixer.insertTextAfter(httpProp, `, ${propText}`), + fixer.removeRange([ + prop.range[0], + comma?.value === "," ? comma.range[1] : prop.range[1], + ]), + ], }); } } } } - }, - NewExpression: (node) => { - if ( - node.callee.type === "Identifier" && - [ - changedMethods.createResultHandler, - changedMethods.createMiddleware, - ].includes(node.callee.name) && - node.arguments.length === 1 && - node.arguments[0].type === "ObjectExpression" - ) { - for (const prop of node.arguments[0].properties) { - if ( - prop.type === "Property" && - prop.key.type === "Identifier" && - shouldAct(prop.key.name, changedProps) - ) { - const replacement = changedProps[prop.key.name]; + if (node.callee.name === createServerName) { + const assignment = ctx.sourceCode + .getAncestors(node) + .findLast(isAssignment); + if (assignment) { + const removable = assignment.id.properties.filter( + propByName([httpServerPropName, httpsServerPropName] as const), + ); + for (const prop of removable) { ctx.report({ node: prop, messageId: "change", data: { subject: "property", from: prop.key.name, - to: replacement, + to: changedProps[prop.key.name], }, - fix: (fixer) => fixer.replaceText(prop.key, replacement), }); } } } - }, - Identifier: (node) => { - if ( - node.name === "MockOverrides" && - node.parent.type === "TSInterfaceDeclaration" - ) { + if (node.callee.name === getStatusCodeFromErrorMethod) { + const replacement = changedMethods[node.callee.name]; ctx.report({ - node, - messageId: "remove", - data: { subject: "augmentation", name: node.name }, - fix: (fixer) => fixer.remove(node.parent), + node: node.callee, + messageId: "change", + data: { + subject: "method", + from: node.callee.name, + to: `${replacement}().statusCode`, + }, + fix: (fixer) => [ + fixer.replaceText(node.callee, replacement), + fixer.insertTextAfter(node, ".statusCode"), + ], }); } }, + [esQueries.loggerArgument]: (node: TSESTree.Identifier) => { + const { parent } = node; + const isProp = isPropWithId(parent); + if (isProp && parent.value === node) return; // not for renames + const replacement = `${changedProps[node.name as keyof typeof changedProps]}${isProp ? "" : "()"}`; + ctx.report({ + node, + messageId: "change", + data: { + subject: isProp ? "property" : "const", + from: node.name, + to: replacement, + }, + fix: (fixer) => fixer.replaceText(node, replacement), + }); + }, + [esQueries.getChildLoggerArgument]: (node: TSESTree.Identifier) => { + const { parent } = node; + const isProp = isPropWithId(parent); + if (isProp && parent.value === node) return; // not for renames + const replacement = changedProps[node.name as keyof typeof changedProps]; + ctx.report({ + node, + messageId: "change", + data: { + subject: isProp ? "property" : "method", + from: node.name, + to: replacement, + }, + fix: (fixer) => fixer.replaceText(node, replacement), + }); + }, + [esQueries.responseFeatures]: (node: TSESTree.Property) => { + if (!isPropWithId(node)) return; + const replacement = + changedProps[node.key.name as keyof typeof changedProps]; + ctx.report({ + node, + messageId: "change", + data: { subject: "property", from: node.key.name, to: replacement }, + fix: (fixer) => fixer.replaceText(node.key, replacement), + }); + }, }), }); @@ -163,9 +308,9 @@ const v20 = ESLintUtils.RuleCreator.withoutDocs({ * import migration from "express-zod-api/migration"; * export default [ * { languageOptions: {parser}, plugins: {migration} }, - * { files: ["**\/*.ts"], rules: { "migration/v20": "error" } } + * { files: ["**\/*.ts"], rules: { "migration/v21": "error" } } * ]; * */ export default { - rules: { v20 }, + rules: { v21 }, } satisfies TSESLint.Linter.Plugin; diff --git a/src/result-handler.ts b/src/result-handler.ts index 820b64438..a2597fbd3 100644 --- a/src/result-handler.ts +++ b/src/result-handler.ts @@ -24,7 +24,6 @@ type Handler = (params: { output: FlatObject | null; /** can be empty: check presence of the required property using "in" operator */ options: FlatObject; - /** @todo consider moving to HttpError in v21 */ error: Error | null; request: Request; response: Response; @@ -75,7 +74,7 @@ export class ResultHandler< public override getPositiveResponse(output: IOSchema) { return normalize(this.#positive, { variant: "positive", - arguments: [output], + args: [output], statusCodes: [defaultStatusCodes.positive], mimeTypes: [contentTypes.json], }); @@ -84,7 +83,7 @@ export class ResultHandler< public override getNegativeResponse() { return normalize(this.#negative, { variant: "negative", - arguments: [], + args: [], statusCodes: [defaultStatusCodes.negative], mimeTypes: [contentTypes.json], }); diff --git a/src/result-helpers.ts b/src/result-helpers.ts index b04e2394b..6bb88fb7c 100644 --- a/src/result-helpers.ts +++ b/src/result-helpers.ts @@ -17,28 +17,32 @@ export type ResultSchema = /** @throws ResultHandlerError when Result is an empty array */ export const normalize = ( subject: Result | LazyResult, - features: { + { + variant, + args, + ...fallback + }: Omit & { variant: ResponseVariant; - arguments: A; - statusCodes: [number, ...number[]]; - mimeTypes: [string, ...string[]]; + args: A; }, ): NormalizedResponse[] => { - if (typeof subject === "function") - return normalize(subject(...features.arguments), features); - if (subject instanceof z.ZodType) return [{ ...features, schema: subject }]; + if (typeof subject === "function") subject = subject(...args); + if (subject instanceof z.ZodType) return [{ schema: subject, ...fallback }]; if (Array.isArray(subject) && !subject.length) { - throw new ResultHandlerError( - new Error(`At least one ${features.variant} response schema required.`), - ); + const err = new Error(`At least one ${variant} response schema required.`); + throw new ResultHandlerError(err); } return (Array.isArray(subject) ? subject : [subject]).map( - ({ schema, statusCodes, statusCode, mimeTypes, mimeType }) => ({ + ({ schema, statusCode, mimeType }) => ({ schema, - statusCodes: statusCode - ? [statusCode] - : statusCodes || features.statusCodes, - mimeTypes: mimeType ? [mimeType] : mimeTypes || features.mimeTypes, + statusCodes: + typeof statusCode === "number" + ? [statusCode] + : statusCode || fallback.statusCodes, + mimeTypes: + typeof mimeType === "string" + ? [mimeType] + : mimeType || fallback.mimeTypes, }), ); }; @@ -51,13 +55,6 @@ export const logServerError = ( ) => !error.expose && logger.error("Server side error", { error, url, payload }); -/** - * @deprecated use ensureHttpError().statusCode instead - * @todo remove in v21 - * */ -export const getStatusCodeFromError = (error: Error): number => - ensureHttpError(error).statusCode; - /** * @example InputValidationError —> BadRequest(400) * @example Error —> InternalServerError(500) diff --git a/src/routing-walker.ts b/src/routing-walker.ts index 0bda89186..5522d945f 100644 --- a/src/routing-walker.ts +++ b/src/routing-walker.ts @@ -1,7 +1,7 @@ import { DependsOnMethod } from "./depends-on-method"; import { AbstractEndpoint } from "./endpoint"; import { RoutingError } from "./errors"; -import { AuxMethod, Method } from "./method"; +import { Method } from "./method"; import { Routing } from "./routing"; import { ServeStatic, StaticHandler } from "./serve-static"; @@ -10,62 +10,49 @@ export interface RoutingWalkerParams { onEndpoint: ( endpoint: AbstractEndpoint, path: string, - method: Method | AuxMethod, + method: Method, siblingMethods?: ReadonlyArray, ) => void; onStatic?: (path: string, handler: StaticHandler) => void; parentPath?: string; - hasCors?: boolean; } -export const walkRouting = ({ - routing, - onEndpoint, - onStatic, - parentPath, - hasCors, -}: RoutingWalkerParams) => { - const pairs = Object.entries(routing).map( - ([key, value]) => [key.trim(), value] as const, - ); - for (const [segment, element] of pairs) { +const makePairs = (subject: Routing, parent?: string) => + Object.entries(subject).map(([segment, item]) => { if (segment.includes("/")) { throw new RoutingError( `The entry '${segment}' must avoid having slashes — use nesting instead.`, ); } - const path = `${parentPath || ""}${segment ? `/${segment}` : ""}`; + const trimmed = segment.trim(); + return [`${parent || ""}${trimmed ? `/${trimmed}` : ""}`, item] as const; + }); + +export const walkRouting = ({ + routing, + onEndpoint, + onStatic, +}: RoutingWalkerParams) => { + const stack = makePairs(routing); + while (stack.length) { + const [path, element] = stack.shift()!; if (element instanceof AbstractEndpoint) { - const methods: (Method | AuxMethod)[] = element.getMethods().slice(); - if (hasCors) methods.push("options"); + const methods = element.getMethods() || ["get"]; for (const method of methods) onEndpoint(element, path, method); } else if (element instanceof ServeStatic) { if (onStatic) element.apply(path, onStatic); } else if (element instanceof DependsOnMethod) { - for (const [method, endpoint] of element.pairs) { - if (!endpoint.getMethods().includes(method)) { + for (const [method, endpoint, siblingMethods] of element.entries) { + const supportedMethods = endpoint.getMethods(); + if (supportedMethods && !supportedMethods.includes(method)) { throw new RoutingError( `Endpoint assigned to ${method} method of ${path} must support ${method} method.`, ); } - onEndpoint(endpoint, path, method); - } - if (hasCors && element.firstEndpoint) { - onEndpoint( - element.firstEndpoint, - path, - "options", - element.siblingMethods, - ); + onEndpoint(endpoint, path, method, siblingMethods); } } else { - walkRouting({ - onEndpoint, - onStatic, - hasCors, - routing: element, - parentPath: path, - }); + stack.unshift(...makePairs(element, path)); } } }; diff --git a/src/routing.ts b/src/routing.ts index 2d9fe68ce..104f66be5 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -5,10 +5,10 @@ import { ContentType } from "./content-type"; import { DependsOnMethod } from "./depends-on-method"; import { Diagnostics } from "./diagnostics"; import { AbstractEndpoint } from "./endpoint"; -import { ActualLogger } from "./logger-helpers"; +import { AuxMethod, Method } from "./method"; import { walkRouting } from "./routing-walker"; import { ServeStatic } from "./serve-static"; -import { ChildLoggerExtractor } from "./server-helpers"; +import { GetLogger } from "./server-helpers"; export interface Routing { [SEGMENT: string]: Routing | DependsOnMethod | AbstractEndpoint | ServeStatic; @@ -18,40 +18,52 @@ export type Parsers = Record; export const initRouting = ({ app, - rootLogger, - getChildLogger, + getLogger, config, routing, parsers, }: { app: IRouter; - rootLogger: ActualLogger; - getChildLogger: ChildLoggerExtractor; + getLogger: GetLogger; config: CommonConfig; routing: Routing; parsers?: Parsers; }) => { - const doc = new Diagnostics(); + const doc = new Diagnostics(getLogger()); + const corsedPaths = new Set(); walkRouting({ routing, - hasCors: !!config.cors, + onStatic: (path, handler) => void app.use(path, handler), onEndpoint: (endpoint, path, method, siblingMethods) => { - if (!isProduction()) doc.check(endpoint, rootLogger, { path, method }); - app[method]( - path, - ...(parsers?.[endpoint.getRequestType()] || []), - async (request, response) => - endpoint.execute({ - request, - response, - logger: getChildLogger(request), - config, - siblingMethods, - }), - ); - }, - onStatic: (path, handler) => { - app.use(path, handler); + if (!isProduction()) doc.check(endpoint, { path, method }); + const matchingParsers = parsers?.[endpoint.getRequestType()] || []; + const handler: RequestHandler = async (request, response) => { + const logger = getLogger(request); + if (config.cors) { + const accessMethods: Array = [ + method, + ...(siblingMethods || []), + "options", + ]; + const methodsLine = accessMethods.join(", ").toUpperCase(); + const defaultHeaders: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": methodsLine, + "Access-Control-Allow-Headers": "content-type", + }; + const headers = + typeof config.cors === "function" + ? await config.cors({ request, endpoint, logger, defaultHeaders }) + : defaultHeaders; + for (const key in headers) response.set(key, headers[key]); + } + return endpoint.execute({ request, response, logger, config }); + }; + if (config.cors && !corsedPaths.has(path)) { + app.options(path, ...matchingParsers, handler); + corsedPaths.add(path); + } + app[method](path, ...matchingParsers, handler); }, }); }; diff --git a/src/server-helpers.ts b/src/server-helpers.ts index 877436d79..551f0b8fb 100644 --- a/src/server-helpers.ts +++ b/src/server-helpers.ts @@ -19,18 +19,16 @@ type EquippedRequest = Request< { [metaSymbol]?: { logger: ActualLogger } } >; -export type ChildLoggerExtractor = (request: Request) => ActualLogger; +/** @desc Returns child logger for the given request (if configured) or the configured logger otherwise */ +export type GetLogger = (request?: Request) => ActualLogger; interface HandlerCreatorParams { errorHandler: AbstractResultHandler; - getChildLogger: ChildLoggerExtractor; + getLogger: GetLogger; } export const createParserFailureHandler = - ({ - errorHandler, - getChildLogger, - }: HandlerCreatorParams): ErrorRequestHandler => + ({ errorHandler, getLogger }: HandlerCreatorParams): ErrorRequestHandler => async (error, request, response, next) => { if (!error) return next(); return errorHandler.execute({ @@ -42,18 +40,18 @@ export const createParserFailureHandler = input: null, output: null, options: {}, - logger: getChildLogger(request), + logger: getLogger(request), }); }; export const createNotFoundHandler = - ({ errorHandler, getChildLogger }: HandlerCreatorParams): RequestHandler => + ({ errorHandler, getLogger }: HandlerCreatorParams): RequestHandler => async (request, response) => { const error = createHttpError( 404, `Can not ${request.method} ${request.path}`, ); - const logger = getChildLogger(request); + const logger = getLogger(request); try { errorHandler.execute({ request, @@ -88,19 +86,19 @@ export const createUploadLogger = ( ): Pick => ({ log: logger.debug.bind(logger) }); export const createUploadParsers = async ({ - getChildLogger, + getLogger, config, }: { - getChildLogger: ChildLoggerExtractor; + getLogger: GetLogger; config: ServerConfig; }): Promise => { const uploader = await loadPeer("express-fileupload"); const { limitError, beforeUpload, ...options } = { - ...(typeof config.server.upload === "object" && config.server.upload), + ...(typeof config.upload === "object" && config.upload), }; const parsers: RequestHandler[] = []; parsers.push(async (request, response, next) => { - const logger = getChildLogger(request); + const logger = getLogger(request); try { await beforeUpload?.({ request, logger }); } catch (error) { @@ -126,26 +124,26 @@ export const moveRaw: RequestHandler = (req, {}, next) => { /** @since v19 prints the actual path of the request, not a configured route, severity decreased to debug level */ export const createLoggingMiddleware = ({ - rootLogger, + logger: parent, config, }: { - rootLogger: ActualLogger; + logger: ActualLogger; config: CommonConfig; }): RequestHandler => async (request, response, next) => { - const logger = config.childLoggerProvider - ? await config.childLoggerProvider({ request, parent: rootLogger }) - : rootLogger; + const logger = + (await config.childLoggerProvider?.({ request, parent })) || parent; logger.debug(`${request.method}: ${request.path}`); if (request.res) (request as EquippedRequest).res!.locals[metaSymbol] = { logger }; next(); }; -export const makeChildLoggerExtractor = - (fallback: ActualLogger): ChildLoggerExtractor => +export const makeGetLogger = + (fallback: ActualLogger): GetLogger => (request) => - (request as EquippedRequest).res?.locals[metaSymbol]?.logger || fallback; + (request as EquippedRequest | undefined)?.res?.locals[metaSymbol]?.logger || + fallback; export const installDeprecationListener = (logger: ActualLogger) => process.on("deprecation", ({ message, namespace, name, stack }) => diff --git a/src/server.ts b/src/server.ts index 4788c4f6a..2ae4c183d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,7 +3,12 @@ import type compression from "compression"; import http from "node:http"; import https from "node:https"; import { BuiltinLogger } from "./builtin-logger"; -import { AppConfig, CommonConfig, ServerConfig } from "./config-type"; +import { + AppConfig, + CommonConfig, + HttpConfig, + ServerConfig, +} from "./config-type"; import { isLoggerInstance } from "./logger-helpers"; import { loadPeer } from "./peer-helpers"; import { defaultResultHandler } from "./result-handler"; @@ -13,7 +18,7 @@ import { createNotFoundHandler, createParserFailureHandler, createUploadParsers, - makeChildLoggerExtractor, + makeGetLogger, installDeprecationListener, moveRaw, installTerminationListener, @@ -23,22 +28,22 @@ import { printStartupLogo } from "./startup-logo"; const makeCommonEntities = (config: CommonConfig) => { if (config.startupLogo !== false) printStartupLogo(process.stdout); const errorHandler = config.errorHandler || defaultResultHandler; - const rootLogger = isLoggerInstance(config.logger) + const logger = isLoggerInstance(config.logger) ? config.logger : new BuiltinLogger(config.logger); - rootLogger.debug("Running", { - build: process.env.TSUP_BUILD || "from sources", - env: process.env.NODE_ENV || "development", + logger.debug("Running", { + build: process.env.TSUP_BUILD || "from sources", // eslint-disable-line no-restricted-syntax -- substituted by TSUP + env: process.env.NODE_ENV || "development", // eslint-disable-line no-restricted-syntax -- intentionally for debug }); - installDeprecationListener(rootLogger); - const loggingMiddleware = createLoggingMiddleware({ rootLogger, config }); - const getChildLogger = makeChildLoggerExtractor(rootLogger); - const commons = { getChildLogger, errorHandler }; + installDeprecationListener(logger); + const loggingMiddleware = createLoggingMiddleware({ logger, config }); + const getLogger = makeGetLogger(logger); + const commons = { getLogger, errorHandler }; const notFoundHandler = createNotFoundHandler(commons); const parserFailureHandler = createParserFailureHandler(commons); return { ...commons, - rootLogger, + logger, notFoundHandler, parserFailureHandler, loggingMiddleware, @@ -46,78 +51,72 @@ const makeCommonEntities = (config: CommonConfig) => { }; export const attachRouting = (config: AppConfig, routing: Routing) => { - const { rootLogger, getChildLogger, notFoundHandler, loggingMiddleware } = + const { logger, getLogger, notFoundHandler, loggingMiddleware } = makeCommonEntities(config); initRouting({ app: config.app.use(loggingMiddleware), - rootLogger, routing, - getChildLogger, + getLogger, config, }); - return { notFoundHandler, logger: rootLogger }; + return { notFoundHandler, logger }; }; export const createServer = async (config: ServerConfig, routing: Routing) => { const { - rootLogger, - getChildLogger, + logger, + getLogger, notFoundHandler, parserFailureHandler, loggingMiddleware, } = makeCommonEntities(config); const app = express().disable("x-powered-by").use(loggingMiddleware); - if (config.server.compression) { + if (config.compression) { const compressor = await loadPeer("compression"); app.use( compressor( - typeof config.server.compression === "object" - ? config.server.compression - : undefined, + typeof config.compression === "object" ? config.compression : undefined, ), ); } const parsers: Parsers = { - json: [config.server.jsonParser || express.json()], - raw: [config.server.rawParser || express.raw(), moveRaw], - upload: config.server.upload - ? await createUploadParsers({ config, getChildLogger }) + json: [config.jsonParser || express.json()], + raw: [config.rawParser || express.raw(), moveRaw], + upload: config.upload + ? await createUploadParsers({ config, getLogger }) : [], }; - if (config.server.beforeRouting) { - await config.server.beforeRouting({ - app, - logger: rootLogger, - getChildLogger, - }); - } - initRouting({ app, routing, rootLogger, getChildLogger, config, parsers }); + await config.beforeRouting?.({ app, getLogger }); + initRouting({ app, routing, getLogger, config, parsers }); app.use(parserFailureHandler, notFoundHandler); - const starter = ( - server: T, - subject?: typeof config.server.listen, - ) => server.listen(subject, () => rootLogger.info("Listening", subject)) as T; + const created: Array = []; + const makeStarter = + (server: (typeof created)[number], subject: HttpConfig["listen"]) => () => + server.listen(subject, () => logger.info("Listening", subject)); - const httpServer = http.createServer(app); - const httpsServer = - config.https && https.createServer(config.https.options, app); + const starters: Array> = []; + if (config.http) { + const httpServer = http.createServer(app); + created.push(httpServer); + starters.push(makeStarter(httpServer, config.http.listen)); + } + if (config.https) { + const httpsServer = https.createServer(config.https.options, app); + created.push(httpsServer); + starters.push(makeStarter(httpsServer, config.https.listen)); + } if (config.gracefulShutdown) { installTerminationListener({ - servers: [httpServer].concat(httpsServer || []), - logger: rootLogger, + logger, + servers: created, options: config.gracefulShutdown === true ? {} : config.gracefulShutdown, }); } - return { - app, - logger: rootLogger, - httpServer: starter(httpServer, config.server.listen), - httpsServer: httpsServer && starter(httpsServer, config.https?.listen), - }; + return { app, logger, servers: starters.map((starter) => starter()) }; }; diff --git a/src/startup-logo.ts b/src/startup-logo.ts index 2dc212174..38eba841c 100644 --- a/src/startup-logo.ts +++ b/src/startup-logo.ts @@ -12,7 +12,7 @@ export const printStartupLogo = (stream: WriteStream) => { const thanks = italic( "Thank you for choosing Express Zod API for your project.".padStart(132), ); - const dedicationMessage = italic("for Zoey".padEnd(20)); + const dedicationMessage = italic("for Kesaria".padEnd(20)); const pink = hex("#F5A9B8"); const blue = hex("#5BCEFA"); diff --git a/src/zod-plugin.ts b/src/zod-plugin.ts index a3303fd61..78569a862 100644 --- a/src/zod-plugin.ts +++ b/src/zod-plugin.ts @@ -82,7 +82,7 @@ const objectMapper = function ( typeof tool === "function" ? tool : pipe( - toPairs, + toPairs, // eslint-disable-line no-restricted-syntax -- strict key type required map(([key, value]) => pair(tool[String(key)] || key, value)), fromPairs, ); diff --git a/tests/bench/experiment.bench.ts b/tests/bench/experiment.bench.ts index 035b85d10..d4121f883 100644 --- a/tests/bench/experiment.bench.ts +++ b/tests/bench/experiment.bench.ts @@ -1,37 +1,36 @@ -import assert from "node:assert/strict"; import { bench } from "vitest"; -import { RoutingError } from "../../src"; +import { retrieveUserEndpoint } from "../../example/endpoints/retrieve-user"; +import { DependsOnMethod } from "../../src"; +import { walkRouting } from "../../src/routing-walker"; -describe.each([false, true])("Experiment for errors %s", (ass) => { - const notAss = !ass; +const routing = { + a: { + b: { + c: { + d: { + e: { + f: { + g: { + h: { + i: { + j: new DependsOnMethod({ + post: retrieveUserEndpoint, + }), + }, + }, + }, + }, + }, + }, + }, + }, + k: { l: {} }, + m: {}, + }, +}; - bench("assert with text", () => { - try { - assert(ass, "text"); - } catch {} - }); - - bench("assert with Error", () => { - try { - assert(ass, new Error()); - } catch {} - }); - - bench("assert with custom error", () => { - try { - assert(ass, new RoutingError()); - } catch {} - }); - - bench("throwing Error", () => { - try { - if (notAss) throw new Error(); - } catch {} - }); - - bench("throwing custom error", () => { - try { - if (notAss) throw new RoutingError(); - } catch {} +describe("Experiment for routing walker", () => { + bench("featured", () => { + walkRouting({ routing, onEndpoint: vi.fn() }); }); }); diff --git a/tests/compat/eslint.config.js b/tests/compat/eslint.config.js index bb877b2fb..de53d248e 100644 --- a/tests/compat/eslint.config.js +++ b/tests/compat/eslint.config.js @@ -3,5 +3,5 @@ import migration from "express-zod-api/migration"; export default [ { languageOptions: { parser }, plugins: { migration } }, - { files: ["**/*.ts"], rules: { "migration/v20": "error" } }, + { files: ["**/*.ts"], rules: { "migration/v21": "error" } }, ]; diff --git a/tests/compat/migration.spec.ts b/tests/compat/migration.spec.ts index ee0c917f6..6e4013a7c 100644 --- a/tests/compat/migration.spec.ts +++ b/tests/compat/migration.spec.ts @@ -3,6 +3,8 @@ import { readFile } from "node:fs/promises"; describe("Migration", () => { test("should fix the import", async () => { const fixed = await readFile("./sample.ts", "utf-8"); - expect(fixed).toMatch(/BuiltinLogger/); + expect(fixed).toBe( + "createConfig({ http: { listen: 8090, }, beforeRouting: () => {}, upload: true });\n", + ); }); }); diff --git a/tests/compat/package.json b/tests/compat/package.json index 493bed24d..3f1c3fd65 100644 --- a/tests/compat/package.json +++ b/tests/compat/package.json @@ -11,7 +11,7 @@ }, "scripts": { "preinstall": "rm -rf node_modules", - "pretest": "echo 'import { createLogger } from \"express-zod-api\";' > sample.ts", + "pretest": "echo 'createConfig({ server: { listen: 8090, upload: true, beforeRouting: () => {}, } });' > sample.ts", "test": "eslint --fix && vitest --run && rm sample.ts" } } diff --git a/tests/system/system.spec.ts b/tests/system/system.spec.ts index b5cdad4c3..7089be3e8 100644 --- a/tests/system/system.spec.ts +++ b/tests/system/system.spec.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { EndpointsFactory, Method, + createConfig, createServer, defaultResultHandler, ResultHandler, @@ -30,7 +31,6 @@ describe("App in production mode", async () => { { provider: () => ({ corsDone: true }) }, ) .build({ - method: "get", output: z.object({ corsDone: z.boolean() }), handler: async ({ options: { corsDone } }) => ({ corsDone }), }); @@ -53,7 +53,6 @@ describe("App in production mode", async () => { const faultyEndpoint = new EndpointsFactory(faultyResultHandler) .addMiddleware(faultyMiddleware) .build({ - method: "get", input: z.object({ epError: z .any() @@ -79,7 +78,7 @@ describe("App in production mode", async () => { }), }) .build({ - methods: ["get", "post"], + method: ["get", "post"], input: z.object({ something: z.string() }), output: z.object({ anything: z.number().positive() }).passthrough(), // allow excessive keys handler: async ({ @@ -98,7 +97,6 @@ describe("App in production mode", async () => { }, }); const longEndpoint = new EndpointsFactory(defaultResultHandler).build({ - method: "get", output: z.object({}), handler: async () => setTimeout(5000, {}), }); @@ -111,34 +109,30 @@ describe("App in production mode", async () => { }, }; vi.spyOn(process.stdout, "write").mockImplementation(vi.fn()); // mutes logo output - const server = ( - await createServer( - { - server: { - listen: port, - compression: { threshold: 1 }, - beforeRouting: ({ app, getChildLogger }) => { - depd("express")("Sample deprecation message"); - app.use((req, {}, next) => { - const childLogger = getChildLogger(req); - assert("isChild" in childLogger && childLogger.isChild); - next(); - }); - }, - }, - cors: false, - startupLogo: true, - gracefulShutdown: { events: ["FAKE"] }, - logger, - childLoggerProvider: ({ parent }) => - Object.defineProperty(parent, "isChild", { value: true }), - inputSources: { - post: ["query", "body", "files"], - }, - }, - routing, - ) - ).httpServer; + const config = createConfig({ + http: { listen: port }, + compression: { threshold: 1 }, + beforeRouting: ({ app, getLogger }) => { + depd("express")("Sample deprecation message"); + app.use((req, {}, next) => { + const childLogger = getLogger(req); + assert("isChild" in childLogger && childLogger.isChild); + next(); + }); + }, + cors: false, + startupLogo: true, + gracefulShutdown: { events: ["FAKE"] }, + logger, + childLoggerProvider: ({ parent }) => + Object.defineProperty(parent, "isChild", { value: true }), + inputSources: { + post: ["query", "body", "files"], + }, + }); + const { + servers: [server], + } = await createServer(config, routing); await vi.waitFor(() => assert(server.listening), { timeout: 1e4 }); expect(warnMethod).toHaveBeenCalledWith( "DeprecationError (express): Sample deprecation message", diff --git a/tests/unit/__snapshots__/endpoint.spec.ts.snap b/tests/unit/__snapshots__/endpoint.spec.ts.snap index 7c4f343af..c1017fa74 100644 --- a/tests/unit/__snapshots__/endpoint.spec.ts.snap +++ b/tests/unit/__snapshots__/endpoint.spec.ts.snap @@ -1,41 +1,61 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Endpoint > .getSchema() > should return the negative response schema 1`] = ` -{ - "_type": "ZodObject", - "shape": { - "error": { +exports[`Endpoint > .getResponses() > should return the negative responses 1`] = ` +[ + { + "mimeTypes": [ + "application/json", + ], + "schema": { "_type": "ZodObject", "shape": { - "message": { - "_type": "ZodString", + "error": { + "_type": "ZodObject", + "shape": { + "message": { + "_type": "ZodString", + }, + }, + }, + "status": { + "_type": "ZodLiteral", + "value": "error", }, }, }, - "status": { - "_type": "ZodLiteral", - "value": "error", - }, + "statusCodes": [ + 400, + ], }, -} +] `; -exports[`Endpoint > .getSchema() > should return the positive response schema 1`] = ` -{ - "_type": "ZodObject", - "shape": { - "data": { +exports[`Endpoint > .getResponses() > should return the positive responses 1`] = ` +[ + { + "mimeTypes": [ + "application/json", + ], + "schema": { "_type": "ZodObject", "shape": { - "something": { - "_type": "ZodNumber", + "data": { + "_type": "ZodObject", + "shape": { + "something": { + "_type": "ZodNumber", + }, + }, + }, + "status": { + "_type": "ZodLiteral", + "value": "success", }, }, }, - "status": { - "_type": "ZodLiteral", - "value": "success", - }, + "statusCodes": [ + 200, + ], }, -} +] `; diff --git a/tests/unit/__snapshots__/index.spec.ts.snap b/tests/unit/__snapshots__/index.spec.ts.snap index a7894c743..54fb23bdf 100644 --- a/tests/unit/__snapshots__/index.spec.ts.snap +++ b/tests/unit/__snapshots__/index.spec.ts.snap @@ -68,8 +68,6 @@ exports[`Index Entrypoint > exports > getExamples should have certain value 1`] exports[`Index Entrypoint > exports > getMessageFromError should have certain value 1`] = `[Function]`; -exports[`Index Entrypoint > exports > getStatusCodeFromError should have certain value 1`] = `[Function]`; - exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = ` [ "createConfig", @@ -78,7 +76,6 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = "arrayEndpointsFactory", "getExamples", "getMessageFromError", - "getStatusCodeFromError", "ensureHttpError", "BuiltinLogger", "Middleware", diff --git a/tests/unit/__snapshots__/migration.spec.ts.snap b/tests/unit/__snapshots__/migration.spec.ts.snap index f5444c768..e84a2e203 100644 --- a/tests/unit/__snapshots__/migration.spec.ts.snap +++ b/tests/unit/__snapshots__/migration.spec.ts.snap @@ -3,14 +3,14 @@ exports[`Migration > should consist of one rule being the major version of the package 1`] = ` { "rules": { - "v20": { + "v21": { "create": [Function], "defaultOptions": [], "meta": { "fixable": "code", "messages": { "change": "Change {{subject}} {{from}} to {{to}}.", - "remove": "Remove {{subject}} {{name}}.", + "move": "Move {{subject}} from {{from}} to {{to}}.", }, "schema": [], "type": "problem", diff --git a/tests/unit/config-type.spec.ts b/tests/unit/config-type.spec.ts index dbc31c4b3..d9d7e7345 100644 --- a/tests/unit/config-type.spec.ts +++ b/tests/unit/config-type.spec.ts @@ -3,17 +3,22 @@ import { createConfig } from "../../src"; describe("ConfigType", () => { describe("createConfig()", () => { - test("should create a config with server", () => { - const argument = { - server: { - listen: 3333, - }, - cors: true, - logger: { level: "debug" as const }, - }; - const config = createConfig(argument); - expect(config).toEqual(argument); - }); + const httpConfig = { http: { listen: 3333 } }; + const httpsConfig = { https: { options: {}, listen: 4444 } }; + const both = { ...httpConfig, ...httpsConfig }; + + test.each([httpConfig, httpsConfig, both])( + "should create a config with server %#", + (inc) => { + const argument = { + ...inc, + cors: true, + logger: { level: "debug" as const }, + }; + const config = createConfig(argument); + expect(config).toEqual(argument); + }, + ); test("should create a config with app", () => { const argument = { diff --git a/tests/unit/depends-on-method.spec.ts b/tests/unit/depends-on-method.spec.ts index b62e6b0ca..0df3e7f01 100644 --- a/tests/unit/depends-on-method.spec.ts +++ b/tests/unit/depends-on-method.spec.ts @@ -10,9 +10,7 @@ describe("DependsOnMethod", () => { test("should accept empty object", () => { const instance = new DependsOnMethod({}); expect(instance).toBeInstanceOf(DependsOnMethod); - expect(instance.firstEndpoint).toBeUndefined(); - expect(instance.siblingMethods).toEqual([]); - expect(instance.pairs).toEqual([]); + expect(instance.entries).toEqual([]); }); test("should accept an endpoint with a corresponding method", () => { @@ -23,35 +21,32 @@ describe("DependsOnMethod", () => { handler: async () => ({}), }), }); - expect(instance).toBeInstanceOf(DependsOnMethod); - expect(instance.firstEndpoint).toBeInstanceOf(AbstractEndpoint); - expect(instance.siblingMethods).toEqual([]); - expect(instance.pairs).toHaveLength(1); + expect(instance.entries).toEqual([ + ["post", expect.any(AbstractEndpoint), []], + ]); }); - test("should accept an endpoint with additional methods", () => { - const endpoint = new EndpointsFactory(defaultResultHandler).build({ - methods: ["get", "post"], - output: z.object({}), - handler: async () => ({}), - }); - const instance = new DependsOnMethod({ - get: endpoint, - post: endpoint, - }); - expect(instance).toBeInstanceOf(DependsOnMethod); - expect(instance.firstEndpoint).toBe(endpoint); - expect(instance.siblingMethods).toEqual(["post"]); - expect(instance.pairs).toHaveLength(2); - }); + test.each([{ methods: ["get", "post"] } as const, {}])( + "should accept an endpoint capable to handle multiple methods %#", + (inc) => { + const endpoint = new EndpointsFactory(defaultResultHandler).build({ + ...inc, + output: z.object({}), + handler: async () => ({}), + }); + const instance = new DependsOnMethod({ get: endpoint, post: endpoint }); + expect(instance.entries).toEqual([ + ["get", expect.any(AbstractEndpoint), ["post"]], + ["post", expect.any(AbstractEndpoint), ["get"]], + ]); + }, + ); test("should reject empty assignments", () => { const instance = new DependsOnMethod({ get: undefined, post: undefined, }); - expect(instance.pairs).toEqual([]); - expect(instance.firstEndpoint).toBeUndefined(); - expect(instance.siblingMethods).toEqual([]); + expect(instance.entries).toEqual([]); }); }); diff --git a/tests/unit/documentation.spec.ts b/tests/unit/documentation.spec.ts index 3177a283e..dc31031b5 100644 --- a/tests/unit/documentation.spec.ts +++ b/tests/unit/documentation.spec.ts @@ -21,7 +21,7 @@ describe("Documentation", () => { const sampleConfig = createConfig({ cors: true, logger: { level: "silent" }, - server: { listen: givePort() }, + http: { listen: givePort() }, }); describe("Basic cases", () => { @@ -48,7 +48,7 @@ describe("Documentation", () => { routing: { v1: { deleteSomething: defaultEndpointsFactory.build({ - methods: ["delete"], + method: "delete", output: z.object({ whatever: z.number(), }), @@ -73,7 +73,6 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - methods: ["get"], input: z.object({ array: z.array(z.number().int().positive()).min(1).max(3), unlimited: z.array(z.boolean()), @@ -103,7 +102,6 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - methods: ["get"], input: z.object({ optional: z.string().optional(), optDefault: z.string().optional().default("test"), @@ -132,7 +130,7 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - methods: ["post"], + method: "post", input: z.object({ intersection: z.intersection( z.object({ @@ -176,7 +174,7 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - methods: ["post"], + method: "post", input: z.object({ union: z.union([ z.object({ @@ -211,7 +209,7 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - methods: ["post"], + method: "post", input: z.discriminatedUnion("type", [ z.object({ type: z.literal("a"), a: z.string() }), z.object({ type: z.literal("b"), b: z.string() }), @@ -243,7 +241,7 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - methods: ["post"], + method: "post", input: z.object({ one: z.string(), two: z.number().int().positive(), @@ -329,7 +327,6 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - method: "get", input: z.object({ any: z.any(), }), @@ -489,7 +486,6 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - method: "get", input: z.object({ string, number }), output: z.object({ boolean }), handler: async () => ({ boolean: [] }), @@ -584,12 +580,14 @@ describe("Documentation", () => { serverUrl: "https://example.com", }), ).toThrow( - new DocumentationError({ - method: "post", - path: "/v1/getSomething", - isResponse: false, - message: `Zod type ${zodType._def.typeName} is unsupported.`, - }), + new DocumentationError( + `Zod type ${zodType._def.typeName} is unsupported.`, + { + method: "post", + path: "/v1/getSomething", + isResponse: false, + }, + ), ); }); @@ -629,8 +627,7 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.addMiddleware(mw1).build({ - scopes: ["this should be omitted"], - method: "get", + scope: "this should be omitted", input: z.object({ str: z.string(), }), @@ -646,7 +643,7 @@ describe("Documentation", () => { handler: async () => ({}), }), updateSomething: defaultEndpointsFactory.addMiddleware(mw3).build({ - scopes: ["this should be omitted"], + scope: "this should be omitted", method: "put", output: z.object({}), handler: async () => ({}), @@ -668,13 +665,11 @@ describe("Documentation", () => { getSome: { thing: defaultEndpointsFactory.build({ description: "thing is the path segment", - method: "get", output: z.object({}), handler: async () => ({}), }), ":thing": defaultEndpointsFactory.build({ description: "thing is the path parameter", - method: "get", output: z.object({}), handler: async () => ({}), }), @@ -697,7 +692,6 @@ describe("Documentation", () => { getSome: { thing: defaultEndpointsFactory.build({ description: "thing is the path segment", - method: "get", operationId, output: z.object({}), handler: async () => ({}), @@ -722,7 +716,7 @@ describe("Documentation", () => { getSome: { thing: defaultEndpointsFactory.build({ description: "thing is the path segment", - methods: ["get", "post"], + method: ["get", "post"], operationId: (method) => `${method}${operationId}`, output: z.object({}), handler: async () => ({}), @@ -740,12 +734,14 @@ describe("Documentation", () => { test("should not be able to specify duplicated operation", () => { const operationId = "coolOperationId"; - const expectedError = new DocumentationError({ - message: 'Duplicated operationId: "coolOperationId"', - isResponse: false, - method: "get", - path: "/v1/getSomeTwo/thing", - }); + const expectedError = new DocumentationError( + 'Duplicated operationId: "coolOperationId"', + { + isResponse: false, + method: "get", + path: "/v1/getSomeTwo/thing", + }, + ); expect( () => new Documentation({ @@ -755,7 +751,6 @@ describe("Documentation", () => { getSome: { thing: defaultEndpointsFactory.build({ description: "thing is the path segment", - method: "get", operationId, output: z.object({}), handler: async () => ({}), @@ -764,7 +759,6 @@ describe("Documentation", () => { getSomeTwo: { thing: defaultEndpointsFactory.build({ description: "thing is the path segment", - method: "get", operationId, output: z.object({}), handler: async () => ({}), @@ -783,7 +777,7 @@ describe("Documentation", () => { const resultHandler = new ResultHandler({ positive: (result) => ({ schema: z.object({ status: z.literal("OK"), result }), - mimeTypes: [contentTypes.json, "text/vnd.yaml"], + mimeType: [contentTypes.json, "text/vnd.yaml"], statusCode: 201, }), negative: { @@ -799,7 +793,6 @@ describe("Documentation", () => { routing: { v1: { getSomething: factory.build({ - method: "get", output: z.object({}), handler: async () => ({}), }), @@ -823,7 +816,7 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - methods: ["get", "post"], + method: ["get", "post"], input: z.object({ arr: z.array(z.string()).min(1), }), @@ -956,7 +949,6 @@ describe("Documentation", () => { routing: { v1: { ":name": defaultEndpointsFactory.build({ - method: "get", input: z.object({ name: z.literal("John").or(z.literal("Jane")), other: z.boolean(), @@ -1060,7 +1052,6 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - method: "get", input: z.object({ str: z.string().describe("here is the test"), }), @@ -1089,7 +1080,6 @@ describe("Documentation", () => { routing: { hris: { employees: defaultEndpointsFactory.build({ - method: "get", input: z.object({ cursor: z .string() @@ -1117,7 +1107,6 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - method: "get", input: z.object({ strNum: z .string() @@ -1147,7 +1136,6 @@ describe("Documentation", () => { routing: { v1: { getSomething: defaultEndpointsFactory.build({ - method: "get", input: z .object({ strNum: z.string().transform((v) => parseInt(v, 10)), @@ -1285,7 +1273,6 @@ describe("Documentation", () => { routing: { v1: { ":name": defaultEndpointsFactory.build({ - method: "get", input: z.object({ name: z.string().brand("CUSTOM"), other: z.boolean().brand("CUSTOM"), @@ -1319,7 +1306,6 @@ describe("Documentation", () => { routing: { v1: { test: defaultEndpointsFactory.build({ - method: "get", input: z .object({ user_id: z.string() }) .transform((inputs) => camelize(inputs, true)), @@ -1345,7 +1331,6 @@ describe("Documentation", () => { routing: { v1: { test: defaultEndpointsFactory.build({ - method: "get", input: z .object({ user_id: z.string(), at: ez.dateIn() }) .remap({ user_id: "userId" }), // partial mapping diff --git a/tests/unit/endpoint.spec.ts b/tests/unit/endpoint.spec.ts index ca2aabb4b..e686d207f 100644 --- a/tests/unit/endpoint.spec.ts +++ b/tests/unit/endpoint.spec.ts @@ -71,10 +71,8 @@ describe("Endpoint", () => { transform: "test", })); const endpoint = factory.build({ - methods: ["post"], - input: z.object({ - n: z.number(), - }), + method: "post", + input: z.object({ n: z.number() }), output: z.object({ inc2: z.number(), str: z.string(), @@ -125,31 +123,17 @@ describe("Endpoint", () => { test("should close the stream on OPTIONS request", async () => { const handlerMock = vi.fn(); const endpoint = defaultEndpointsFactory.build({ - method: "get", output: z.object({}), handler: handlerMock, }); const { responseMock, loggerMock } = await testEndpoint({ endpoint, - requestProps: { - method: "OPTIONS", - }, - configProps: { - cors: ({ defaultHeaders }) => ({ - ...defaultHeaders, - "X-Custom-Header": "Testing", - }), - }, + requestProps: { method: "OPTIONS" }, }); expect(loggerMock._getLogs().error).toHaveLength(0); expect(responseMock._getStatusCode()).toBe(200); expect(handlerMock).toHaveBeenCalledTimes(0); - expect(responseMock._getHeaders()).toEqual({ - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, OPTIONS", - "access-control-allow-headers": "content-type", - "x-custom-header": "Testing", - }); + expect(responseMock.writableEnded).toBeTruthy(); }); }); @@ -240,10 +224,7 @@ describe("Endpoint", () => { const spy = vi.spyOn(resultHandler, "execute"); const factory = new EndpointsFactory(resultHandler); const endpoint = factory.build({ - method: "get", - output: z.object({ - test: z.string(), - }), + output: z.object({ test: z.string() }), handler: async () => ({ test: "OK" }), }); const { loggerMock, responseMock, requestMock } = await testEndpoint({ @@ -284,7 +265,6 @@ describe("Endpoint", () => { something: z.number(), }); const endpoint = factory.build({ - method: "get", input, output, handler: vi.fn(), @@ -294,32 +274,18 @@ describe("Endpoint", () => { ); }, ); - - test.each(["positive", "negative"] as const)( - "should return the %s response schema", - (variant) => { - const factory = new EndpointsFactory(defaultResultHandler); - const endpoint = factory.build({ - method: "get", - output: z.object({ something: z.number() }), - handler: vi.fn(), - }); - expect(endpoint.getSchema(variant)).toMatchSnapshot(); - }, - ); }); - describe("getMimeTypes()", () => { - test.each(["input", "positive", "negative"] as const)( - "should return the %s mime types", + describe(".getResponses()", () => { + test.each(["positive", "negative"] as const)( + "should return the %s responses", (variant) => { const factory = new EndpointsFactory(defaultResultHandler); const endpoint = factory.build({ - method: "get", output: z.object({ something: z.number() }), handler: vi.fn(), }); - expect(endpoint.getMimeTypes(variant)).toEqual(["application/json"]); + expect(endpoint.getResponses(variant)).toMatchSnapshot(); }, ); }); @@ -334,7 +300,6 @@ describe("Endpoint", () => { ({ input, expected }) => { const factory = new EndpointsFactory(defaultResultHandler); const endpoint = factory.build({ - method: "get", input, output: z.object({}), handler: vi.fn(), @@ -348,7 +313,6 @@ describe("Endpoint", () => { test("should return undefined if its not defined upon creation", () => { expect( new Endpoint({ - methods: ["get"], inputSchema: z.object({}), outputSchema: z.object({}), handler: async () => ({}), @@ -368,7 +332,7 @@ describe("Endpoint", () => { handler: async () => ({}), }) .build({ - methods: ["post"], + method: "post", input: z.object({ n: z.number().refine(async (n) => n > 100), }), @@ -407,7 +371,7 @@ describe("Endpoint", () => { next(); }) .build({ - methods: ["post"], + method: "post", input: z.object({ shouldNotBeThere: z.boolean(), }), @@ -461,10 +425,7 @@ describe("Endpoint", () => { }), ); const endpoint = factory.build({ - method: "get", - output: z.object({ - test: z.string(), - }), + output: z.object({ test: z.string() }), handler: async () => ({ test: "OK" }), }); const { loggerMock, responseMock } = await testEndpoint({ endpoint }); @@ -482,7 +443,7 @@ describe("Endpoint", () => { handler: async () => assert.fail("Something went wrong"), }); const endpoint = factory.build({ - methods: ["post"], + method: "post", output: z.object({}), handler: async () => ({}), }); @@ -658,7 +619,6 @@ describe("Endpoint", () => { const endpoint = defaultEndpointsFactory .addMiddleware(dateInputMiddleware) .build({ - method: "get", output: z.object({}), handler: async ({ input: { middleware_date_input }, logger }) => { logger.debug( diff --git a/tests/unit/endpoints-factory.spec.ts b/tests/unit/endpoints-factory.spec.ts index 19e9850a8..d97aa05c8 100644 --- a/tests/unit/endpoints-factory.spec.ts +++ b/tests/unit/endpoints-factory.spec.ts @@ -236,13 +236,12 @@ describe("EndpointsFactory", () => { ); const handlerMock = vi.fn(); const endpoint = factory.build({ - method: "get", input: z.object({ s: z.string() }), output: z.object({ b: z.boolean() }), handler: handlerMock, }); expect(endpoint).toBeInstanceOf(Endpoint); - expect(endpoint.getMethods()).toStrictEqual(["get"]); + expect(endpoint.getMethods()).toBeUndefined(); expect(endpoint.getSchema("input")).toMatchSnapshot(); expect(endpoint.getSchema("output")).toMatchSnapshot(); expectTypeOf(endpoint.getSchema("input")._output).toMatchTypeOf<{ @@ -267,7 +266,6 @@ describe("EndpointsFactory", () => { middleware, ); const endpoint = factory.build({ - method: "get", input: z.object({ i: z.string() }), output: z.object({ o: z.boolean() }), handler: vi.fn(), @@ -291,13 +289,12 @@ describe("EndpointsFactory", () => { ); const handlerMock = vi.fn(); const endpoint = factory.build({ - methods: ["get"], input: z.object({ s: z.string() }), output: z.object({ b: z.boolean() }), handler: handlerMock, }); expect(endpoint).toBeInstanceOf(Endpoint); - expect(endpoint.getMethods()).toStrictEqual(["get"]); + expect(endpoint.getMethods()).toBeUndefined(); expect(endpoint.getSchema("input")).toMatchSnapshot(); expect(endpoint.getSchema("output")).toMatchSnapshot(); expectTypeOf(endpoint.getSchema("input")._output).toMatchTypeOf<{ @@ -320,13 +317,12 @@ describe("EndpointsFactory", () => { b: true, })); const endpoint = factory.build({ - methods: ["get"], input: z.object({ s: z.string() }), output: z.object({ b: z.boolean() }), handler: handlerMock, }); expect(endpoint).toBeInstanceOf(Endpoint); - expect(endpoint.getMethods()).toStrictEqual(["get"]); + expect(endpoint.getMethods()).toBeUndefined(); expect(endpoint.getSchema("input")).toMatchSnapshot(); expect(endpoint.getSchema("output")).toMatchSnapshot(); expectTypeOf(endpoint.getSchema("input")._output).toMatchTypeOf< diff --git a/tests/unit/errors.spec.ts b/tests/unit/errors.spec.ts index f5d912039..bff91f37e 100644 --- a/tests/unit/errors.spec.ts +++ b/tests/unit/errors.spec.ts @@ -22,8 +22,7 @@ describe("Errors", () => { }); describe("DocumentationError", () => { - const error = new DocumentationError({ - message: "test", + const error = new DocumentationError("test", { path: "/v1/testPath", method: "get", isResponse: true, @@ -75,7 +74,6 @@ describe("Errors", () => { test("should have .cause property matching the one used for constructing", () => { expect(error.cause).toEqual(zodError); - expect(error.originalError).toEqual(zodError); }); }); @@ -94,7 +92,6 @@ describe("Errors", () => { test("should have .cause property matching the one used for constructing", () => { expect(error.cause).toEqual(zodError); - expect(error.originalError).toEqual(zodError); }); }); diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts index e6dfeaacd..282f4d5e6 100644 --- a/tests/unit/index.spec.ts +++ b/tests/unit/index.spec.ts @@ -61,7 +61,7 @@ describe("Index Entrypoint", () => { logger: { level: "silent" }; }>().toMatchTypeOf(); expectTypeOf<{ - server: { listen: 8090 }; + http: { listen: 8090 }; logger: { level: "silent" }; cors: false; }>().toMatchTypeOf(); diff --git a/tests/unit/migration.spec.ts b/tests/unit/migration.spec.ts index 17c890264..a84fd3909 100644 --- a/tests/unit/migration.spec.ts +++ b/tests/unit/migration.spec.ts @@ -16,166 +16,193 @@ describe("Migration", () => { expect(migration.rules).toHaveProperty(`v${version.split(".")[0]}`); expect(migration).toMatchSnapshot(); }); -}); -tester.run("v20", migration.rules.v20, { - valid: [ - { code: `import { BuiltinLogger } from "express-zod-api"` }, - { code: `import { ResultHandler } from "express-zod-api"` }, - { code: `import { Middleware } from "express-zod-api"` }, - { code: `new BuiltinLogger({})` }, - { code: `new ResultHandler({ positive: {}, negative: {} })` }, - { code: `new Middleware({ handler: {} })` }, - { code: `testEndpoint({})` }, - ], - invalid: [ - { - code: `import { createLogger } from "express-zod-api"`, - output: `import { BuiltinLogger } from "express-zod-api"`, - errors: [ - { - messageId: "change", - data: { - subject: "import", - from: "createLogger", - to: "BuiltinLogger", - }, - }, - ], - }, - { - code: `import { createResultHandler } from "express-zod-api"`, - output: `import { ResultHandler } from "express-zod-api"`, - errors: [ - { - messageId: "change", - data: { - subject: "import", - from: "createResultHandler", - to: "ResultHandler", - }, - }, - ], - }, - { - code: `import { createMiddleware } from "express-zod-api"`, - output: `import { Middleware } from "express-zod-api"`, - errors: [ - { - messageId: "change", - data: { - subject: "import", - from: "createMiddleware", - to: "Middleware", - }, - }, - ], - }, - { - code: `createLogger({})`, - output: `new BuiltinLogger({})`, - errors: [ - { - messageId: "change", - data: { - subject: "call", - from: "createLogger", - to: "new BuiltinLogger", - }, - }, - ], - }, - { - code: `createResultHandler({})`, - output: `new ResultHandler({})`, - errors: [ - { - messageId: "change", - data: { - subject: "call", - from: "createResultHandler", - to: "new ResultHandler", - }, - }, - ], - }, - { - code: `new ResultHandler({ getPositiveResponse: {}, getNegativeResponse: {} })`, - output: `new ResultHandler({ positive: {}, negative: {} })`, - errors: [ - { - messageId: "change", - data: { - subject: "property", - from: "getPositiveResponse", - to: "positive", - }, - }, - { - messageId: "change", - data: { - subject: "property", - from: "getNegativeResponse", - to: "negative", - }, - }, - ], - }, - { - code: `new Middleware({ middleware: {} })`, - output: `new Middleware({ handler: {} })`, - errors: [ - { - messageId: "change", - data: { subject: "property", from: "middleware", to: "handler" }, - }, - ], - }, - { - code: `testEndpoint({ fnMethod: {}, responseProps: {} })`, // with comma - output: `testEndpoint({ responseOptions: {} })`, - errors: [ - { - messageId: "remove", - data: { subject: "property", name: "fnMethod" }, - }, - { - messageId: "change", - data: { - subject: "property", - from: "responseProps", - to: "responseOptions", - }, - }, - ], - }, - { - code: `testEndpoint({ responseProps: {}, fnMethod: {} })`, // without comma - output: `testEndpoint({ responseOptions: {}, })`, - errors: [ - { - messageId: "change", - data: { - subject: "property", - from: "responseProps", - to: "responseOptions", - }, - }, - { - messageId: "remove", - data: { subject: "property", name: "fnMethod" }, - }, - ], - }, - { - code: `interface MockOverrides extends Mock {}`, - output: ``, - errors: [ - { - messageId: "remove", - data: { subject: "augmentation", name: "MockOverrides" }, - }, - ], - }, - ], + tester.run("v21", migration.rules.v21, { + valid: [ + `(() => {})()`, + `createConfig({ http: {} });`, + `createConfig({ http: { listen: 8090 }, upload: true });`, + `createConfig({ beforeRouting: ({ getLogger }) => { getLogger().warn() } });`, + `const { app, servers, logger } = await createServer();`, + `console.error(error.cause?.message);`, + `import { ensureHttpError } from "express-zod-api";`, + `ensureHttpError(error).statusCode;`, + `factory.build({ method: ['get', 'post'] })`, + `factory.build({ tag: ['files', 'users'] })`, + `factory.build({ scope: ['admin', 'permissions'] })`, + `new ResultHandler({ positive: () => ({ statusCode: [201, 202] }), negative: [{ mimeType: ["application/json"] }] })`, + ], + invalid: [ + { + code: `createConfig({ server: {} });`, + output: `createConfig({ http: {} });`, + errors: [ + { + messageId: "change", + data: { subject: "property", from: "server", to: "http" }, + }, + ], + }, + { + code: `createConfig({ http: { listen: 8090, upload: true } });`, + output: `createConfig({ http: { listen: 8090, }, upload: true });`, + errors: [ + { + messageId: "move", + data: { + subject: "upload", + from: "http", + to: "the top level of createConfig argument", + }, + }, + ], + }, + { + code: `createConfig({ beforeRouting: ({ logger }) => { logger.warn() } });`, + output: `createConfig({ beforeRouting: ({ getLogger }) => { getLogger().warn() } });`, + errors: [ + { + messageId: "change", + data: { + subject: "property", + from: "logger", + to: "getLogger", + }, + }, + { + messageId: "change", + data: { + subject: "const", + from: "logger", + to: "getLogger()", + }, + }, + ], + }, + { + code: `createConfig({ beforeRouting: ({ getChildLogger }) => { getChildLogger(request).warn() } });`, + output: `createConfig({ beforeRouting: ({ getLogger }) => { getLogger(request).warn() } });`, + errors: [ + { + messageId: "change", + data: { + subject: "property", + from: "getChildLogger", + to: "getLogger", + }, + }, + { + messageId: "change", + data: { + subject: "method", + from: "getChildLogger", + to: "getLogger", + }, + }, + ], + }, + { + code: `const { app, httpServer, httpsServer, logger } = await createServer();`, + errors: [ + { + messageId: "change", + data: { subject: "property", from: "httpServer", to: "servers" }, + }, + { + messageId: "change", + data: { subject: "property", from: "httpsServer", to: "servers" }, + }, + ], + }, + { + code: `console.error(error.originalError?.message);`, + errors: [ + { + messageId: "change", + data: { subject: "property", from: "originalError", to: "cause" }, + }, + ], + }, + { + code: `import { getStatusCodeFromError } from "express-zod-api";`, + output: `import { ensureHttpError } from "express-zod-api";`, + errors: [ + { + messageId: "change", + data: { + subject: "import", + from: "getStatusCodeFromError", + to: "ensureHttpError", + }, + }, + ], + }, + { + code: `getStatusCodeFromError(error);`, + output: `ensureHttpError(error).statusCode;`, + errors: [ + { + messageId: "change", + data: { + subject: "method", + from: "getStatusCodeFromError", + to: "ensureHttpError().statusCode", + }, + }, + ], + }, + { + code: `factory.build({ methods: ['get', 'post'] })`, + output: `factory.build({ method: ['get', 'post'] })`, + errors: [ + { + messageId: "change", + data: { subject: "property", from: "methods", to: "method" }, + }, + ], + }, + { + code: `factory.build({ tags: ['files', 'users'] })`, + output: `factory.build({ tag: ['files', 'users'] })`, + errors: [ + { + messageId: "change", + data: { subject: "property", from: "tags", to: "tag" }, + }, + ], + }, + { + code: `factory.build({ scopes: ['admin', 'permissions'] })`, + output: `factory.build({ scope: ['admin', 'permissions'] })`, + errors: [ + { + messageId: "change", + data: { subject: "property", from: "scopes", to: "scope" }, + }, + ], + }, + { + code: `new ResultHandler({ positive: () => ({ statusCodes: [201, 202] }), negative: [{ mimeTypes: ["application/json"] }] })`, + output: `new ResultHandler({ positive: () => ({ statusCode: [201, 202] }), negative: [{ mimeType: ["application/json"] }] })`, + errors: [ + { + messageId: "change", + data: { + subject: "property", + from: "statusCodes", + to: "statusCode", + }, + }, + { + messageId: "change", + data: { + subject: "property", + from: "mimeTypes", + to: "mimeType", + }, + }, + ], + }, + ], + }); }); diff --git a/tests/unit/nesting.spec.ts b/tests/unit/nesting.spec.ts index 99310767d..95bae6984 100644 --- a/tests/unit/nesting.spec.ts +++ b/tests/unit/nesting.spec.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { defaultEndpointsFactory, DependsOnMethod } from "../../src"; const endpoint = defaultEndpointsFactory.build({ - method: "get", // @todo remove the line in v21 output: z.object({}), handler: vi.fn(), }); diff --git a/tests/unit/result-helpers.spec.ts b/tests/unit/result-helpers.spec.ts index 5392e4da4..50e361409 100644 --- a/tests/unit/result-helpers.spec.ts +++ b/tests/unit/result-helpers.spec.ts @@ -4,12 +4,69 @@ import { InputValidationError, OutputValidationError } from "../../src"; import { ensureHttpError, getPublicErrorMessage, - getStatusCodeFromError, logServerError, + normalize, } from "../../src/result-helpers"; import { makeLoggerMock, makeRequestMock } from "../../src/testing"; describe("Result helpers", () => { + describe("normalize()", () => { + const schema = z.string(); + + test.each([schema, () => schema])( + "should handle a plain schema %#", + (subject) => { + expect( + normalize(subject, { + variant: "positive", + args: [], + statusCodes: [200], + mimeTypes: ["text/plain"], + }), + ).toEqual([{ schema, statusCodes: [200], mimeTypes: ["text/plain"] }]); + }, + ); + + test.each([{ schema }, () => ({ schema })])( + "should handle an object %#", + (subject) => { + expect( + normalize(subject, { + variant: "positive", + args: [], + statusCodes: [200], + mimeTypes: ["text/plain"], + }), + ).toEqual([{ schema, statusCodes: [200], mimeTypes: ["text/plain"] }]); + }, + ); + + test.each([[{ schema }], () => [{ schema }]])( + "should handle an array of objects %#", + (subject) => { + expect( + normalize(subject, { + variant: "positive", + args: [], + statusCodes: [200], + mimeTypes: ["text/plain"], + }), + ).toEqual([{ schema, statusCodes: [200], mimeTypes: ["text/plain"] }]); + }, + ); + + test("should not mutate the subject when it's a function", () => { + const subject = () => schema; + normalize(subject, { + variant: "positive", + args: [], + statusCodes: [200], + mimeTypes: ["text/plain"], + }); + expect(typeof subject).toBe("function"); + }); + }); + describe("logServerError()", () => { test("should log server side error", () => { const error = createHttpError(501, "test"); @@ -25,44 +82,6 @@ describe("Result helpers", () => { }); }); - describe("getStatusCodeFromError()", () => { - test("should get status code from HttpError", () => { - expect( - getStatusCodeFromError(createHttpError(403, "Access denied")), - ).toEqual(403); - }); - - test("should return 400 for InputValidationError", () => { - const error = new InputValidationError( - new z.ZodError([ - { - code: "invalid_type", - path: ["user", "id"], - message: "expected number, got string", - expected: "number", - received: "string", - }, - ]), - ); - expect(getStatusCodeFromError(error)).toEqual(400); - }); - - test.each([ - new Error("something went wrong"), - new z.ZodError([ - { - code: "invalid_type", - path: ["user", "id"], - message: "expected number, got string", - expected: "number", - received: "string", - }, - ]), - ])("should return 500 for other errors %#", (error) => { - expect(getStatusCodeFromError(error)).toEqual(500); - }); - }); - describe("ensureHttpError()", () => { test.each([ new Error("basic"), diff --git a/tests/unit/routing.spec.ts b/tests/unit/routing.spec.ts index 55a2bdb39..f192ead2f 100644 --- a/tests/unit/routing.spec.ts +++ b/tests/unit/routing.spec.ts @@ -6,7 +6,6 @@ import { } from "../express-mock"; import { z } from "zod"; import { - BuiltinLogger, DependsOnMethod, CommonConfig, EndpointsFactory, @@ -41,17 +40,16 @@ describe("Routing", () => { }; const factory = new EndpointsFactory(defaultResultHandler); const getEndpoint = factory.build({ - methods: ["get"], output: z.object({}), handler: handlerMock, }); const postEndpoint = factory.build({ - methods: ["post"], + method: "post", output: z.object({}), handler: handlerMock, }); const getAndPostEndpoint = factory.build({ - methods: ["get", "post"], + method: ["get", "post"], output: z.object({}), handler: handlerMock, }); @@ -64,13 +62,12 @@ describe("Routing", () => { }, }, }; - const rootLogger = new BuiltinLogger({ level: "silent" }); + const logger = makeLoggerMock(); initRouting({ app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing, - rootLogger, }); expect(appMock.get).toHaveBeenCalledTimes(2); expect(appMock.post).toHaveBeenCalledTimes(2); @@ -95,13 +92,12 @@ describe("Routing", () => { cors: true, startupLogo: false, }; - const rootLogger = new BuiltinLogger({ level: "silent" }); + const logger = makeLoggerMock(); initRouting({ app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing, - rootLogger, }); expect(staticMock).toHaveBeenCalledWith(__dirname, { dotfiles: "deny" }); expect(appMock.use).toHaveBeenCalledTimes(1); @@ -116,17 +112,14 @@ describe("Routing", () => { }; const factory = new EndpointsFactory(defaultResultHandler); const getEndpoint = factory.build({ - methods: ["get"], output: z.object({}), handler: handlerMock, }); const postEndpoint = factory.build({ - methods: ["post"], output: z.object({}), handler: handlerMock, }); const putAndPatchEndpoint = factory.build({ - methods: ["put", "patch"], output: z.object({}), handler: handlerMock, }); @@ -140,13 +133,12 @@ describe("Routing", () => { }), }, }; - const rootLogger = new BuiltinLogger({ level: "silent" }); + const logger = makeLoggerMock(); initRouting({ app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing, - rootLogger, }); expect(appMock.get).toHaveBeenCalledTimes(1); expect(appMock.post).toHaveBeenCalledTimes(1); @@ -165,7 +157,7 @@ describe("Routing", () => { const configMock = { cors: true, startupLogo: false }; const factory = new EndpointsFactory(defaultResultHandler); const putAndPatchEndpoint = factory.build({ - methods: ["put", "patch"], + method: ["put", "patch"], output: z.object({}), handler: vi.fn(), }); @@ -178,14 +170,13 @@ describe("Routing", () => { }), }, }; - const rootLogger = new BuiltinLogger({ level: "silent" }); + const logger = makeLoggerMock(); expect(() => initRouting({ app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing, - rootLogger, }), ).toThrowErrorMatchingSnapshot(); }); @@ -193,26 +184,26 @@ describe("Routing", () => { test("Issue 705: should set all DependsOnMethod' methods for CORS", async () => { const handler = vi.fn(async () => ({})); const configMock = { - cors: true, + cors: (params: { defaultHeaders: Record }) => ({ + ...params.defaultHeaders, + "X-Custom-Header": "Testing", + }), startupLogo: false, }; const factory = new EndpointsFactory(defaultResultHandler); const input = z.object({}); const output = z.object({}); const getEndpoint = factory.build({ - method: "get", input, output, handler, }); const postEndpoint = factory.build({ - method: "post", input, output, handler, }); const putAndPatchEndpoint = factory.build({ - methods: ["put", "patch"], input, output, handler, @@ -225,13 +216,12 @@ describe("Routing", () => { patch: putAndPatchEndpoint, }), }; - const rootLogger = new BuiltinLogger({ level: "silent" }); + const logger = makeLoggerMock(); initRouting({ app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing, - rootLogger, }); expect(appMock.options).toHaveBeenCalledTimes(1); expect(appMock.options.mock.calls[0][0]).toBe("/hello"); @@ -241,17 +231,19 @@ describe("Routing", () => { const responseMock = makeResponseMock(); await fn(requestMock, responseMock); expect(responseMock._getStatusCode()).toBe(200); - expect(responseMock._getHeaders()).toHaveProperty( - "access-control-allow-methods", - "GET, POST, PUT, PATCH, OPTIONS", - ); + expect(responseMock._getHeaders()).toEqual({ + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, PUT, PATCH, OPTIONS", + "access-control-allow-headers": "content-type", + "content-type": "application/json", + "x-custom-header": "Testing", + }); }); test("Should accept parameters", () => { const handlerMock = vi.fn(); const configMock = { startupLogo: false }; const endpointMock = new EndpointsFactory(defaultResultHandler).build({ - methods: ["get"], output: z.object({}), handler: handlerMock, }); @@ -262,13 +254,12 @@ describe("Routing", () => { }, }, }; - const rootLogger = new BuiltinLogger({ level: "silent" }); + const logger = makeLoggerMock(); initRouting({ app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing, - rootLogger, }); expect(appMock.get).toHaveBeenCalledTimes(1); expect(appMock.get.mock.calls[0][0]).toBe("/v1/user/:id"); @@ -278,7 +269,6 @@ describe("Routing", () => { const handlerMock = vi.fn(); const configMock = { startupLogo: false }; const endpointMock = new EndpointsFactory(defaultResultHandler).build({ - methods: ["get"], output: z.object({}), handler: handlerMock, }); @@ -291,13 +281,12 @@ describe("Routing", () => { }, }, }; - const rootLogger = new BuiltinLogger({ level: "silent" }); + const logger = makeLoggerMock(); initRouting({ app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing, - rootLogger, }); expect(appMock.get).toHaveBeenCalledTimes(2); expect(appMock.get).toHaveBeenCalledWith( @@ -314,16 +303,14 @@ describe("Routing", () => { const handlerMock = vi.fn(); const configMock = { startupLogo: false }; const endpointMock = new EndpointsFactory(defaultResultHandler).build({ - methods: ["get"], output: z.object({}), handler: handlerMock, }); - const rootLogger = new BuiltinLogger({ level: "silent" }); + const logger = makeLoggerMock(); expect(() => initRouting({ - rootLogger, app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing: { v1: { @@ -334,9 +321,8 @@ describe("Routing", () => { ).toThrowErrorMatchingSnapshot(); expect(() => initRouting({ - rootLogger, app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing: { "v1/user/retrieve": endpointMock, @@ -351,13 +337,9 @@ describe("Routing", () => { .mockImplementationOnce(() => ({ result: true })); const configMock = { cors: true, startupLogo: false }; const setEndpoint = new EndpointsFactory(defaultResultHandler).build({ - methods: ["post"], - input: z.object({ - test: z.number(), - }), - output: z.object({ - result: z.boolean(), - }), + method: "post", + input: z.object({ test: z.number() }), + output: z.object({ result: z.boolean() }), handler: handlerMock, }); const routing: Routing = { @@ -367,12 +349,10 @@ describe("Routing", () => { }, }, }; - const rootLogger = makeLoggerMock(); - const childLogger = makeLoggerMock(); + const getLoggerMock = vi.fn(() => makeLoggerMock()); initRouting({ - rootLogger, app: appMock as unknown as IRouter, - getChildLogger: () => childLogger, + getLogger: getLoggerMock, config: configMock as CommonConfig, routing, }); @@ -385,15 +365,16 @@ describe("Routing", () => { const responseMock = makeResponseMock(); const nextMock = vi.fn(); await routeHandler(requestMock, responseMock, nextMock); + expect(getLoggerMock).toHaveBeenCalledWith(requestMock); expect(nextMock).toHaveBeenCalledTimes(0); expect(handlerMock).toHaveBeenCalledTimes(1); - expect(childLogger._getLogs().error).toHaveLength(0); + expect( + getLoggerMock.mock.results.pop()!.value._getLogs().error, + ).toHaveLength(0); expect(handlerMock).toHaveBeenCalledWith({ - input: { - test: 123, - }, + input: { test: 123 }, options: {}, - logger: childLogger, + logger: getLoggerMock.mock.results.pop()!.value, }); expect(responseMock._getStatusCode()).toBe(200); expect(responseMock._getJSONData()).toEqual({ @@ -413,21 +394,19 @@ describe("Routing", () => { [z.never(), z.tuple([ez.file()]).rest(z.nan())], ])("should warn about JSON incompatible schemas %#", (input, output) => { const endpoint = new EndpointsFactory(defaultResultHandler).build({ - method: "get", input: z.object({ input }), output: z.object({ output }), handler: vi.fn(), }); const configMock = { cors: false, startupLogo: false }; - const rootLogger = makeLoggerMock(); + const logger = makeLoggerMock(); initRouting({ - rootLogger, app: appMock as unknown as IRouter, - getChildLogger: () => rootLogger, + getLogger: () => logger, config: configMock as CommonConfig, routing: { path: endpoint }, }); - expect(rootLogger._getLogs().warn).toEqual([ + expect(logger._getLogs().warn).toEqual([ [ "The final input schema of the endpoint contains an unsupported JSON payload type.", { method: "get", path: "/path", reason: expect.any(Error) }, diff --git a/tests/unit/server-helpers.spec.ts b/tests/unit/server-helpers.spec.ts index 25dcc7ee5..6e835558e 100644 --- a/tests/unit/server-helpers.spec.ts +++ b/tests/unit/server-helpers.spec.ts @@ -7,7 +7,7 @@ import { createUploadFailureHandler, createUploadLogger, createUploadParsers, - makeChildLoggerExtractor, + makeGetLogger, moveRaw, installDeprecationListener, installTerminationListener, @@ -26,7 +26,7 @@ describe("Server helpers", () => { test("the handler should call next if there is no error", () => { const handler = createParserFailureHandler({ errorHandler: defaultResultHandler, - getChildLogger: () => makeLoggerMock(), + getLogger: () => makeLoggerMock(), }); const next = vi.fn(); handler(undefined, makeRequestMock(), makeResponseMock(), next); @@ -47,7 +47,7 @@ describe("Server helpers", () => { const spy = vi.spyOn(errorHandler, "execute"); const handler = createParserFailureHandler({ errorHandler, - getChildLogger: () => makeLoggerMock(), + getLogger: () => makeLoggerMock(), }); await handler( error, @@ -73,7 +73,7 @@ describe("Server helpers", () => { const spy = vi.spyOn(errorHandler, "execute"); const handler = createNotFoundHandler({ errorHandler, - getChildLogger: () => makeLoggerMock(), + getLogger: () => makeLoggerMock(), }); const next = vi.fn(); const requestMock = makeRequestMock({ @@ -104,7 +104,7 @@ describe("Server helpers", () => { const spy = vi.spyOn(errorHandler, "execute"); const handler = createNotFoundHandler({ errorHandler, - getChildLogger: () => makeLoggerMock(), + getLogger: () => makeLoggerMock(), }); const next = vi.fn(); const requestMock = makeRequestMock({ @@ -151,12 +151,12 @@ describe("Server helpers", () => { }); describe("createUploadLogger()", () => { - const rootLogger = makeLoggerMock(); - const uploadLogger = createUploadLogger(rootLogger); + const logger = makeLoggerMock(); + const uploadLogger = createUploadLogger(logger); test("should debug the messages", () => { uploadLogger.log("Express-file-upload: Busboy finished parsing request."); - expect(rootLogger._getLogs().debug).toEqual([ + expect(logger._getLogs().debug).toEqual([ ["Express-file-upload: Busboy finished parsing request."], ]); }); @@ -167,18 +167,16 @@ describe("Server helpers", () => { const beforeUploadMock = vi.fn(); const parsers = await createUploadParsers({ config: { - server: { - listen: 8090, - upload: { - limits: { fileSize: 1024 }, - limitError: new Error("Too heavy"), - beforeUpload: beforeUploadMock, - }, + http: { listen: 8090 }, + upload: { + limits: { fileSize: 1024 }, + limitError: new Error("Too heavy"), + beforeUpload: beforeUploadMock, }, cors: false, logger: { level: "silent" }, }, - getChildLogger: () => loggerMock, + getLogger: () => loggerMock, }); const requestMock = makeRequestMock(); const responseMock = makeResponseMock(); @@ -239,13 +237,13 @@ describe("Server helpers", () => { }); describe("createLoggingMiddleware", () => { - const rootLogger = makeLoggerMock(); + const logger = makeLoggerMock(); const child = makeLoggerMock({ isChild: true }); test.each([undefined, () => child, async () => child])( "should make RequestHandler writing logger to res.locals %#", async (childLoggerProvider) => { const config = { childLoggerProvider } as CommonConfig; - const handler = createLoggingMiddleware({ rootLogger, config }); + const handler = createLoggingMiddleware({ logger, config }); expect(typeof handler).toBe("function"); const nextMock = vi.fn(); const response = makeResponseMock(); @@ -254,18 +252,18 @@ describe("Server helpers", () => { await handler(request, response, nextMock); expect(nextMock).toHaveBeenCalled(); expect( - (childLoggerProvider ? child : rootLogger)._getLogs().debug.pop(), + (childLoggerProvider ? child : logger)._getLogs().debug.pop(), ).toEqual(["GET: /test"]); expect(request.res).toHaveProperty("locals", { - [metaSymbol]: { logger: childLoggerProvider ? child : rootLogger }, + [metaSymbol]: { logger: childLoggerProvider ? child : logger }, }); }, ); }); - describe("makeChildLoggerExtractor()", () => { - const rootLogger = makeLoggerMock(); - const getChildLogger = makeChildLoggerExtractor(rootLogger); + describe("makeGetLogger()", () => { + const logger = makeLoggerMock(); + const getLogger = makeGetLogger(logger); test("should extract child logger from request", () => { const request = makeRequestMock({ @@ -275,13 +273,15 @@ describe("Server helpers", () => { }, }, }); - expect(getChildLogger(request)).toHaveProperty("isChild", true); + expect(getLogger(request)).toHaveProperty("isChild", true); }); - test("should fall back to root", () => { - const request = makeRequestMock(); - expect(getChildLogger(request)).toEqual(rootLogger); - }); + test.each([makeRequestMock(), undefined])( + "should fall back to root %#", + (request) => { + expect(getLogger(request)).toEqual(logger); + }, + ); }); describe("installDeprecationListener()", () => { diff --git a/tests/unit/server.spec.ts b/tests/unit/server.spec.ts index 5c2ec8c36..7ae9622b9 100644 --- a/tests/unit/server.spec.ts +++ b/tests/unit/server.spec.ts @@ -41,29 +41,25 @@ describe("Server", () => { describe("createServer()", () => { test("Should create server with minimal config", async () => { const port = givePort(); - const configMock: ServerConfig = { - server: { - listen: port, - }, + const configMock = { + http: { listen: port }, cors: true, startupLogo: false, - logger: { level: "warn" }, + logger: { level: "warn" as const }, }; const routingMock = { v1: { test: new EndpointsFactory(defaultResultHandler).build({ - methods: ["get", "post"], - input: z.object({ - n: z.number(), - }), - output: z.object({ - b: z.boolean(), - }), + method: ["get", "post"], + input: z.object({ n: z.number() }), + output: z.object({ b: z.boolean() }), handler: vi.fn(), }), }, }; - await createServer(configMock, routingMock); + const { servers } = await createServer(configMock, routingMock); + expect(servers).toHaveLength(1); + expect(servers[0]).toBeTruthy(); expect(appMock).toBeTruthy(); expect(appMock.disable).toHaveBeenCalledWith("x-powered-by"); expect(appMock.use).toHaveBeenCalledTimes(2); @@ -94,12 +90,10 @@ describe("Server", () => { const infoMethod = vi.spyOn(customLogger, "info"); const port = givePort(); const configMock = { - server: { - listen: { port }, // testing Net::ListenOptions - jsonParser: vi.fn(), - rawParser: vi.fn(), - beforeRouting: vi.fn(), - }, + http: { listen: { port } }, // testing Net::ListenOptions + jsonParser: vi.fn(), + rawParser: vi.fn(), + beforeRouting: vi.fn(), cors: true, startupLogo: false, errorHandler: { @@ -111,7 +105,7 @@ describe("Server", () => { const routingMock = { v1: { test: factory.build({ - methods: ["get", "post"], + method: ["get", "post"], input: z.object({ n: z.number(), }), @@ -137,41 +131,40 @@ describe("Server", () => { expect(appMock).toBeTruthy(); expect(appMock.use).toHaveBeenCalledTimes(2); expect(configMock.errorHandler.handler).toHaveBeenCalledTimes(0); - expect(configMock.server.beforeRouting).toHaveBeenCalledWith({ + expect(configMock.beforeRouting).toHaveBeenCalledWith({ app: appMock, - logger: customLogger, - getChildLogger: expect.any(Function), + getLogger: expect.any(Function), }); expect(infoMethod).toHaveBeenCalledTimes(1); expect(infoMethod).toHaveBeenCalledWith(`Listening`, { port }); expect(appMock.get).toHaveBeenCalledTimes(1); expect(appMock.get).toHaveBeenCalledWith( "/v1/test", - configMock.server.jsonParser, + configMock.jsonParser, expect.any(Function), // endpoint ); expect(appMock.post).toHaveBeenCalledTimes(1); expect(appMock.post).toHaveBeenCalledWith( "/v1/test", - configMock.server.jsonParser, + configMock.jsonParser, expect.any(Function), // endpoint ); expect(appMock.patch).toHaveBeenCalledTimes(1); expect(appMock.patch).toHaveBeenCalledWith( "/v1/raw", - configMock.server.rawParser, + configMock.rawParser, moveRaw, expect.any(Function), // endpoint ); expect(appMock.options).toHaveBeenCalledTimes(2); expect(appMock.options).toHaveBeenCalledWith( "/v1/test", - configMock.server.jsonParser, + configMock.jsonParser, expect.any(Function), // endpoint ); expect(appMock.options).toHaveBeenCalledWith( "/v1/raw", - configMock.server.rawParser, + configMock.rawParser, moveRaw, expect.any(Function), // endpoint ); @@ -184,30 +177,26 @@ describe("Server", () => { test("should create a HTTPS server on request", async () => { const configMock = { - server: { listen: givePort() }, https: { listen: givePort(), - options: { - cert: "cert", - key: "key", - }, + options: { cert: "cert", key: "key" }, }, cors: true, startupLogo: false, - logger: { level: "warn" }, - } satisfies ServerConfig; + logger: { level: "warn" as const }, + }; const routingMock = { v1: { test: new EndpointsFactory(defaultResultHandler).build({ - method: "get", output: z.object({}), handler: vi.fn(), }), }, }; - const { httpsServer } = await createServer(configMock, routingMock); - expect(httpsServer).toBeTruthy(); + const { servers } = await createServer(configMock, routingMock); + expect(servers).toHaveLength(1); + expect(servers[0]).toBeTruthy(); expect(createHttpsServerSpy).toHaveBeenCalledWith( configMock.https.options, appMock, @@ -219,20 +208,34 @@ describe("Server", () => { ); }); - test("should enable compression on request", async () => { + test("should create both HTTP and HTTPS servers", async () => { const configMock = { - server: { + http: { listen: givePort() }, + https: { listen: givePort(), - compression: true, + options: { cert: "cert", key: "key" }, }, cors: true, startupLogo: false, + logger: { level: "warn" as const }, + }; + const { servers } = await createServer(configMock, {}); + expect(servers).toHaveLength(2); + expect(servers[0]).toBeTruthy(); + expect(servers[1]).toBeTruthy(); + }); + + test("should enable compression on request", async () => { + const configMock = { + http: { listen: givePort() }, + compression: true, + cors: true, + startupLogo: false, logger: { level: "warn" }, } satisfies ServerConfig; const routingMock = { v1: { test: new EndpointsFactory(defaultResultHandler).build({ - method: "get", output: z.object({}), handler: vi.fn(), }), @@ -246,13 +249,11 @@ describe("Server", () => { test("should enable uploads on request", async () => { const configMock = { - server: { - listen: givePort(), - upload: { - limits: { fileSize: 1024 }, - limitError: new Error("Too heavy"), - beforeUpload: vi.fn(), - }, + http: { listen: givePort() }, + upload: { + limits: { fileSize: 1024 }, + limitError: new Error("Too heavy"), + beforeUpload: vi.fn(), }, cors: true, startupLogo: false, @@ -261,7 +262,6 @@ describe("Server", () => { const routingMock = { v1: { test: new EndpointsFactory(defaultResultHandler).build({ - method: "get", input: z.object({ file: ez.upload(), }), @@ -283,9 +283,7 @@ describe("Server", () => { test("should enable raw on request", async () => { const configMock = { - server: { - listen: givePort(), - }, + http: { listen: givePort() }, cors: true, startupLogo: false, logger: { level: "warn" }, @@ -293,7 +291,6 @@ describe("Server", () => { const routingMock = { v1: { test: new EndpointsFactory(defaultResultHandler).build({ - method: "get", input: ez.raw(), output: z.object({}), handler: vi.fn(), @@ -330,13 +327,9 @@ describe("Server", () => { const routingMock = { v1: { test: new EndpointsFactory(defaultResultHandler).build({ - methods: ["get", "post"], - input: z.object({ - n: z.number(), - }), - output: z.object({ - b: z.boolean(), - }), + method: ["get", "post"], + input: z.object({ n: z.number() }), + output: z.object({ b: z.boolean() }), handler: vi.fn(), }), }, diff --git a/tests/unit/testing.spec.ts b/tests/unit/testing.spec.ts index 5327b56ad..0a7d9f5f2 100644 --- a/tests/unit/testing.spec.ts +++ b/tests/unit/testing.spec.ts @@ -21,7 +21,6 @@ describe("Testing", () => { }, }) .build({ - method: "get", output: z.object({}), handler: async () => ({}), });