diff --git a/CHANGELOG.md b/CHANGELOG.md index 2083529c9..440b8d61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ ### v28.0.0 - Supported Node.js versions: `^22.19.0 || ^24.0.0`; +- Breaking change to the `createConfig()` argument (object): + - property `wrongMethodBehavior` (number) changed to `hintAllowedMethods` (boolean); +- Consider using [the automated migration](https://www.npmjs.com/package/@express-zod-api/migration). + +```diff + createConfig({ +- wrongMethodBehavior: 404, ++ hintAllowedMethods: false, + }); +``` ## Version 27 diff --git a/README.md b/README.md index 93e59ab9e..88bac2495 100644 --- a/README.md +++ b/README.md @@ -920,7 +920,7 @@ it normalizes errors into consistent HTTP responses with sensible status codes. - Routing, parsing and upload issues: - Handled by `ResultHandler` configured as `errorHandler` (the defaults is `defaultResultHandler`); - Parsing errors: passed through as-is (typically `HttpError` with `4XX` code used for response by default); - - Routing errors: `404` or `405`, based on `wrongMethodBehavior` configuration; + - Routing errors: `404` or `405`, based on `hintAllowedMethods` configuration; - Upload issues: thrown only if `upload.limitError` is configured (`HttpError::statusCode` can be used for response); - For other errors the default status code is `500`; - `ResultHandler` failures: diff --git a/compat-test/migration.spec.ts b/compat-test/migration.spec.ts index 5909e17af..71563430d 100644 --- a/compat-test/migration.spec.ts +++ b/compat-test/migration.spec.ts @@ -1,10 +1,9 @@ -// import { readFile } from "node:fs/promises"; -import { describe, test } from "vitest"; +import { readFile } from "node:fs/promises"; +import { describe, test, expect } from "vitest"; -/** @todo update when migration ready */ describe("Migration", () => { test("should migrate", async () => { - // const fixed = await readFile("./sample.ts", "utf-8"); - // expect(fixed).toBe(); + const fixed = await readFile("./sample.ts", "utf-8"); + expect(fixed.split("\n")[0]).toBe(`createConfig({ hintAllowedMethods: false });`); }); }); diff --git a/compat-test/package.json b/compat-test/package.json index 528647422..2e907039f 100644 --- a/compat-test/package.json +++ b/compat-test/package.json @@ -3,7 +3,7 @@ "type": "module", "private": true, "scripts": { - "pretest": "echo 'new Integration();' > sample.ts", + "pretest": "echo 'createConfig({ wrongMethodBehavior: 404 });' > sample.ts", "test": "eslint --fix && vitest --run", "posttest": "rm sample.ts" }, diff --git a/express-zod-api/src/config-type.ts b/express-zod-api/src/config-type.ts index d1b8c6585..c0c76882a 100644 --- a/express-zod-api/src/config-type.ts +++ b/express-zod-api/src/config-type.ts @@ -40,12 +40,12 @@ export interface CommonConfig { */ cors: boolean | HeadersProvider; /** - * @desc How to respond to a request that uses a wrong method to an existing endpoint - * @example 404 — Not found - * @example 405 — Method not allowed, incl. the "Allow" header with a list of methods - * @default 405 - * */ - wrongMethodBehavior?: 404 | 405; + * @desc Controls how to respond to a request to an existing endpoint with an invalid HTTP method. + * @example true — respond with status code 405 and "Allow" header containing a list of valid methods + * @example false — respond with status code 404 (Not found) + * @default true + */ + hintAllowedMethods?: boolean; /** * @desc How to treat Routing keys that look like methods (when assigned with an Endpoint) * @see Method diff --git a/express-zod-api/src/routing.ts b/express-zod-api/src/routing.ts index a52533e4e..aebe28d57 100644 --- a/express-zod-api/src/routing.ts +++ b/express-zod-api/src/routing.ts @@ -110,7 +110,7 @@ export const initRouting = ({ app, config, getLogger, ...rest }: InitProps) => { } app[method](path, ...handlers); } - if (config.wrongMethodBehavior === 404) continue; + if (config.hintAllowedMethods === false) continue; deprioritized.set(path, createWrongMethodHandler(accessMethods)); } for (const [path, handler] of deprioritized) app.all(path, handler); diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index 691be65e3..5e6bc583e 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -31,14 +31,14 @@ describe("Routing", () => { vi.clearAllMocks(); // resets call counters on mocked methods }); - test.each([404, 405] as const)( + test.each([true, false, undefined])( "Should set right methods %#", - (wrongMethodBehavior) => { + (hintAllowedMethods) => { const handlerMock = vi.fn(); const configMock = { cors: true, startupLogo: false, - wrongMethodBehavior, + hintAllowedMethods, methodLikeRouteBehavior: "path" as const, }; const factory = new EndpointsFactory(defaultResultHandler); @@ -85,7 +85,7 @@ describe("Routing", () => { expect(appMock.options.mock.calls[0][0]).toBe("/v1/user/get"); expect(appMock.options.mock.calls[1][0]).toBe("/v1/user/set"); expect(appMock.options.mock.calls[2][0]).toBe("/v1/user/universal"); - if (wrongMethodBehavior !== 405) return; + if (hintAllowedMethods === false) return; expect(appMock.all).toHaveBeenCalledTimes(3); expect(appMock.all.mock.calls[0][0]).toBe("/v1/user/get"); expect(appMock.all.mock.calls[1][0]).toBe("/v1/user/set"); diff --git a/migration/index.spec.ts b/migration/index.spec.ts index 088031306..86fa92abf 100644 --- a/migration/index.spec.ts +++ b/migration/index.spec.ts @@ -22,7 +22,53 @@ describe("Migration", async () => { }); tester.run(ruleName, theRule, { - valid: [`new Integration({ typescript, config, routing });`], - invalid: [], + valid: [`createConfig({ hintAllowedMethods: false });`], + invalid: [ + { + name: "wrongMethodBehavior=404", + code: `createConfig({ wrongMethodBehavior: 404 });`, + output: `createConfig({ hintAllowedMethods: false });`, + errors: [ + { + messageId: "change", + data: { + subject: "property", + from: "wrongMethodBehavior", + to: "hintAllowedMethods", + }, + }, + ], + }, + { + name: "wrongMethodBehavior=405", + code: `createConfig({ wrongMethodBehavior: 405 });`, + output: `createConfig({ hintAllowedMethods: true });`, + errors: [ + { + messageId: "change", + data: { + subject: "property", + from: "wrongMethodBehavior", + to: "hintAllowedMethods", + }, + }, + ], + }, + { + name: "wrongMethodBehavior=undefined", + code: `createConfig({ wrongMethodBehavior: undefined });`, + output: `createConfig({ hintAllowedMethods: undefined });`, + errors: [ + { + messageId: "change", + data: { + subject: "property", + from: "wrongMethodBehavior", + to: "hintAllowedMethods", + }, + }, + ], + }, + ], }); }); diff --git a/migration/index.ts b/migration/index.ts index 6a60c6233..91ad10de2 100644 --- a/migration/index.ts +++ b/migration/index.ts @@ -1,21 +1,28 @@ import { ESLintUtils, - // AST_NODE_TYPES as NT, + AST_NODE_TYPES as NT, type TSESLint, - // type TSESTree, + type TSESTree, } from "@typescript-eslint/utils"; // eslint-disable-line allowed/dependencies -- assumed transitive dependency -/* type NamedProp = TSESTree.PropertyNonComputedName & { key: TSESTree.Identifier | TSESTree.StringLiteral; }; -interface Queries {} +interface Queries { + wrongMethodBehavior: NamedProp; +} type Listener = keyof Queries; -const queries: Record = {}; +const queries: Record = { + wrongMethodBehavior: + `${NT.CallExpression}[callee.name="createConfig"] > ` + + `${NT.ObjectExpression} > ` + + `${NT.Property}[key.name="wrongMethodBehavior"]`, +}; +/* const isNamedProp = (prop: TSESTree.ObjectLiteralElement): prop is NamedProp => prop.type === NT.Property && !prop.computed && @@ -24,6 +31,8 @@ const isNamedProp = (prop: TSESTree.ObjectLiteralElement): prop is NamedProp => const getPropName = (prop: NamedProp): string => prop.key.type === NT.Identifier ? prop.key.name : prop.key.value; +*/ + const listen = < S extends { [K in Listener]: TSESLint.RuleFunction }, >( @@ -36,7 +45,6 @@ const listen = < }), {}, ); -*/ const ruleName = `v${process.env.TSDOWN_VERSION?.split(".")[0] ?? "0"}`; // fail-safe for bumpp @@ -54,7 +62,32 @@ const theRule = ESLintUtils.RuleCreator.withoutDocs({ }, defaultOptions: [], }, - create: () => ({}), // (ctx) => listen({}), + create: (ctx) => + listen({ + wrongMethodBehavior: (node) => { + const value = node.value; + const newKey = "hintAllowedMethods"; + let newValue: string; + if (value.type === NT.Literal && typeof value.value === "number") + newValue = value.value === 405 ? "true" : "false"; + else if (value.type === NT.Identifier && value.name === "undefined") + newValue = "undefined"; + else return; + ctx.report({ + node, + messageId: "change", + data: { + subject: "property", + from: "wrongMethodBehavior", + to: newKey, + }, + fix: (fixer) => [ + fixer.replaceText(node.key, newKey), + fixer.replaceText(value, newValue), + ], + }); + }, + }), }); export default {