Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@

### v26.0.0

- `DependsOnMethod` removed: use flat syntax with explicit method and a slash;
- `DependsOnMethod` removed:
- You can now specify methods as direct keys of an assigned object in `Routing`;
- That object can still contain nested paths as before;
- The keys matching lowercase HTTP methods are treated according to the new config setting `methodLikeRouteBehavior`:
- `method` — when assigned with an Endpoint the key is treated as method of its parent path (default);
- `path` — the key is always treated as a nested path segment;
- `options` property renamed to `ctx` in argument of:
- `Middleware::handler()`,
- `ResultHandler::handler()`,
- `handler` of `EndpointsFactory::build()` argument,
- `testMiddleware()`;
- `EndpointsFactory::addOptions()` renamed to `addContext()`;
- The `Integration::constructor()` argument object now requires `config` property, similar to `Documentation`;

```patch
const routing: Routing = {
- "/v1/users": new DependsOnMethod({
+ "/v1/users": {
- get: getUserEndpoint,
+ "get /": getUserEndpoint,
get: getUserEndpoint,
- }).nest({
create: makeUserEndpoint
- }),
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,10 @@ const routing: Routing = {
"delete /user/:id": deleteUserEndpoint,
// method-based routing — /v1/account
account: {
"get /": endpointA,
"delete /": endpointA,
"post /": endpointB,
"patch /": endpointB,
get: endpointA,
delete: endpointA,
post: endpointB,
patch: endpointB,
},
},
// static file serving — /public serves files from ./assets
Expand Down Expand Up @@ -1087,6 +1087,7 @@ import { Integration } from "express-zod-api";

const client = new Integration({
routing,
config,
variant: "client", // <— optional, see also "types" for a DIY solution
});

Expand Down
2 changes: 1 addition & 1 deletion compat-test/migration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import { describe, test, expect } from "vitest";
describe("Migration", () => {
test("should fix the import", async () => {
const fixed = await readFile("./sample.ts", "utf-8");
expect(fixed).toBe(`const route = {\n"get /": someEndpoint,\n}\n`);
expect(fixed).toBe(`const route = {\nget: someEndpoint,\n}\n`);
});
});
1 change: 1 addition & 0 deletions example/generate-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ await writeFile(
"example.client.ts",
await new Integration({
routing,
config,
serverUrl: `http://localhost:${config.http!.listen}`,
}).printFormatted(), // or just .print(),
"utf-8",
Expand Down
2 changes: 1 addition & 1 deletion example/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const routing: Routing = {
":id": {
remove: deleteUserEndpoint, // nested path: /v1/user/:id/remove
// syntax 2: methods are defined within the route
"patch /": updateUserEndpoint, // demonstrates authentication
patch: updateUserEndpoint, // demonstrates authentication
},
// demonstrates different response schemas depending on status code
create: createUserEndpoint,
Expand Down
8 changes: 8 additions & 0 deletions express-zod-api/src/config-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export interface CommonConfig {
* @default 405
* */
wrongMethodBehavior?: 404 | 405;
/**
* @desc How to treat Routing keys that look like methods (when assigned with an Endpoint)
* @see Method
* @example "method" — the key is treated as method of its parent path
* @example "path" — the key is treated as a nested path segment
* @default "method"
* */
methodLikeRouteBehavior?: "method" | "path";
/**
* @desc The ResultHandler to use for handling routing, parsing and upload errors
* @default defaultResultHandler
Expand Down
2 changes: 2 additions & 0 deletions express-zod-api/src/documentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface DocumentationParams {
/**
* @desc Depict the HEAD method for each Endpoint supporting the GET method (feature of Express)
* @default true
* @todo move to config
* */
hasHeadMethod?: boolean;
/** @default inline */
Expand Down Expand Up @@ -262,6 +263,7 @@ export class Documentation extends OpenApiBuilder {
};
walkRouting({
routing,
config,
onEndpoint: hasHeadMethod ? withHead(onEndpoint) : onEndpoint,
});
if (tags) this.rootDoc.tags = depictTags(tags);
Expand Down
5 changes: 5 additions & 0 deletions express-zod-api/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import { zodToTs } from "./zts.ts";
import { ZTSContext } from "./zts-helpers.ts";
import type Prettier from "prettier";
import { ClientMethod } from "./method.ts";
import { CommonConfig } from "./config-type.ts";

interface IntegrationParams {
routing: Routing;
config: CommonConfig;
/**
* @desc What should be generated
* @example "types" — types of your endpoint requests and responses (for a DIY solution)
Expand All @@ -50,6 +52,7 @@ interface IntegrationParams {
/**
* @desc Depict the HEAD method for each Endpoint supporting the GET method (feature of Express)
* @default true
* @todo move to config
* */
hasHeadMethod?: boolean;
/**
Expand Down Expand Up @@ -89,6 +92,7 @@ export class Integration extends IntegrationBase {

public constructor({
routing,
config,
brandHandling,
variant = "client",
clientClassName = "Client",
Expand Down Expand Up @@ -154,6 +158,7 @@ export class Integration extends IntegrationBase {
};
walkRouting({
routing,
config,
onEndpoint: hasHeadMethod ? withHead(onEndpoint) : onEndpoint,
});
this.#program.unshift(...this.#aliases.values());
Expand Down
22 changes: 17 additions & 5 deletions express-zod-api/src/routing-walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { RoutingError } from "./errors.ts";
import { ClientMethod, isMethod, Method } from "./method.ts";
import { Routing } from "./routing.ts";
import { ServeStatic, StaticHandler } from "./serve-static.ts";
import { CommonConfig } from "./config-type.ts";

export type OnEndpoint<M extends string = Method> = (
method: M,
Expand All @@ -20,6 +21,7 @@ export const withHead =

interface RoutingWalkerParams {
routing: Routing;
config: CommonConfig;
onEndpoint: OnEndpoint;
onStatic?: (path: string, handler: StaticHandler) => void;
}
Expand All @@ -35,14 +37,23 @@ const detachMethod = (subject: string): [string, Method?] => {
const trimPath = (path: string) =>
path.trim().split("/").filter(Boolean).join("/");

const processEntries = (subject: Routing, parent?: string) =>
Object.entries(subject).map<[string, Routing[string], Method?]>(
const processEntries = (
{ methodLikeRouteBehavior = "method" }: CommonConfig,
subject: Routing,
parent?: string,
) => {
const preferMethod = methodLikeRouteBehavior === "method";
return Object.entries(subject).map<[string, Routing[string], Method?]>(
([_key, item]) => {
const [segment, method] = detachMethod(_key);
const [segment, method] =
isMethod(_key) && preferMethod && item instanceof AbstractEndpoint
? ["/", _key]
: detachMethod(_key);
const path = [parent || ""].concat(trimPath(segment) || []).join("/");
return [path, item, method];
},
);
};

const prohibit = (method: Method, path: string) => {
throw new RoutingError(
Expand Down Expand Up @@ -74,10 +85,11 @@ const checkDuplicate = (method: Method, path: string, visited: Set<string>) => {

export const walkRouting = ({
routing,
config,
onEndpoint,
onStatic,
}: RoutingWalkerParams) => {
const stack = processEntries(routing);
const stack = processEntries(config, routing);
const visited = new Set<string>();
while (stack.length) {
const [path, element, explicitMethod] = stack.shift()!;
Expand All @@ -98,7 +110,7 @@ export const walkRouting = ({
if (element instanceof ServeStatic) {
if (onStatic) element.apply(path, onStatic);
} else {
stack.unshift(...processEntries(element, path));
stack.unshift(...processEntries(config, element, path));
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions express-zod-api/src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import * as R from "ramda";
* @example { "v1/books/:bookId": getBookEndpoint }
* @example { "get /v1/books/:bookId": getBookEndpoint }
* @example { v1: { "patch /books/:bookId": changeBookEndpoint } }
* @example { dependsOnMethod: { "get /": retrieveEndpoint, "post /": createEndpoint } }
* @example { dependsOnMethod: { get: retrieveEndpoint, post: createEndpoint } }
* @see CommonConfig.methodLikeRouteBehavior
* */
export interface Routing {
[K: string]: Routing | AbstractEndpoint | ServeStatic;
Expand Down Expand Up @@ -77,7 +78,7 @@ const collectSiblings = ({
familiar.set(path, new Map(config.cors ? [["options", value]] : []));
familiar.get(path)?.set(method, value);
};
walkRouting({ routing, onEndpoint, onStatic: app.use.bind(app) });
walkRouting({ routing, config, onEndpoint, onStatic: app.use.bind(app) });
return familiar;
};

Expand Down
6 changes: 6 additions & 0 deletions express-zod-api/tests/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ describe("Integration", () => {
return recursive2;
},
});
const configMock = { cors: false };

test.each([recursive1, recursive2])(
"Should support types variant and handle recursive schemas %#",
(recursiveSchema) => {
const client = new Integration({
variant: "types",
config: configMock,
routing: {
v1: {
test: defaultEndpointsFactory
Expand All @@ -48,6 +50,7 @@ describe("Integration", () => {

test("Should treat optionals the same way as z.infer() by default", async () => {
const client = new Integration({
config: configMock,
routing: {
v1: {
"test-with-dashes": defaultEndpointsFactory.build({
Expand All @@ -70,6 +73,7 @@ describe("Integration", () => {
"Should support HEAD method by default %#",
async (hasHeadMethod) => {
const client = new Integration({
config: configMock,
hasHeadMethod,
variant: "types",
routing: {
Expand Down Expand Up @@ -106,6 +110,7 @@ describe("Integration", () => {
}),
);
const client = new Integration({
config: configMock,
variant: "types",
routing: {
v1: {
Expand All @@ -131,6 +136,7 @@ describe("Integration", () => {
return next(schema);
};
const client = new Integration({
config: configMock,
variant: "types",
brandHandling: {
CUSTOM: () =>
Expand Down
25 changes: 13 additions & 12 deletions express-zod-api/tests/routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe("Routing", () => {
cors: true,
startupLogo: false,
wrongMethodBehavior,
methodLikeRouteBehavior: "path" as const,
};
const factory = new EndpointsFactory(defaultResultHandler);
const getEndpoint = factory.build({
Expand All @@ -58,7 +59,7 @@ describe("Routing", () => {
const routing: Routing = {
v1: {
user: {
get: getEndpoint,
get: getEndpoint, // should be treated as a path
set: postEndpoint,
universal: getAndPostEndpoint,
},
Expand Down Expand Up @@ -126,10 +127,10 @@ describe("Routing", () => {
const routing: Routing = {
v1: {
user: {
"get /": getEndpoint,
"post /": postEndpoint,
"put /": putAndPatchEndpoint,
"patch /": putAndPatchEndpoint,
get: getEndpoint,
post: postEndpoint,
put: putAndPatchEndpoint,
patch: putAndPatchEndpoint,
},
},
};
Expand Down Expand Up @@ -163,9 +164,9 @@ describe("Routing", () => {
const routing: Routing = {
v1: {
user: {
"put /": putAndPatchEndpoint,
"patch /": putAndPatchEndpoint,
"post /": putAndPatchEndpoint, // intentional
put: putAndPatchEndpoint,
patch: putAndPatchEndpoint,
post: putAndPatchEndpoint, // intentional
},
},
};
Expand Down Expand Up @@ -208,10 +209,10 @@ describe("Routing", () => {
});
const routing: Routing = {
hello: {
"get /": getEndpoint,
"post /": postEndpoint,
"put /": putAndPatchEndpoint,
"patch /": putAndPatchEndpoint,
get: getEndpoint,
post: postEndpoint,
put: putAndPatchEndpoint,
patch: putAndPatchEndpoint,
},
};
const logger = makeLoggerMock();
Expand Down
14 changes: 7 additions & 7 deletions migration/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe("Migration", async () => {

tester.run(ruleName, theRule, {
valid: [
`const routing = { "get /": someEndpoint };`,
`const routing = { get: someEndpoint };`,
`factory.build({ handler: async ({ ctx }) => {} });`,
`factory.addContext();`,
`new Middleware({ handler: async ({ ctx }) => {} });`,
Expand All @@ -34,7 +34,7 @@ describe("Migration", async () => {
{
name: "basic DependsOnMethod",
code: `const routing = new DependsOnMethod({ get: someEndpoint });`,
output: `const routing = {\n"get /": someEndpoint,\n};`,
output: `const routing = {\nget: someEndpoint,\n};`,
errors: [
{
messageId: "change",
Expand All @@ -49,7 +49,7 @@ describe("Migration", async () => {
{
name: "DependsOnMethod with literals",
code: `const routing = new DependsOnMethod({ "get": someEndpoint });`,
output: `const routing = {\n"get /": someEndpoint,\n};`,
output: `const routing = {\nget: someEndpoint,\n};`,
errors: [
{
messageId: "change",
Expand All @@ -64,7 +64,7 @@ describe("Migration", async () => {
{
name: "deprecated DependsOnMethod",
code: `const routing = new DependsOnMethod({ get: someEndpoint }).deprecated();`,
output: `const routing = {\n"get /": someEndpoint.deprecated(),\n};`,
output: `const routing = {\nget: someEndpoint.deprecated(),\n};`,
errors: [
{
messageId: "change",
Expand All @@ -79,7 +79,7 @@ describe("Migration", async () => {
{
name: "DependsOnMethod with nesting",
code: `const routing = new DependsOnMethod({ get: someEndpoint }).nest({ some: otherEndpoint });`,
output: `const routing = {\n"get /": someEndpoint,\n"some": otherEndpoint,\n};`,
output: `const routing = {\nget: someEndpoint,\nsome: otherEndpoint,\n};`,
errors: [
{
messageId: "change",
Expand All @@ -93,8 +93,8 @@ describe("Migration", async () => {
},
{
name: "DependsOnMethod both deprecated and with nesting",
code: `const routing = new DependsOnMethod({ get: someEndpoint }).deprecated().nest({ some: otherEndpoint });`,
output: `const routing = {\n"get /": someEndpoint.deprecated(),\n"some": otherEndpoint,\n};`,
code: `const routing = new DependsOnMethod({ get: someEndpoint }).deprecated().nest({ "get some": otherEndpoint });`,
output: `const routing = {\nget: someEndpoint.deprecated(),\n"get some": otherEndpoint,\n};`,
errors: [
{
messageId: "change",
Expand Down
2 changes: 1 addition & 1 deletion migration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ const theRule = ESLintUtils.RuleCreator.withoutDocs({
(feat?: "deprecated" | "nest") =>
(prop: TSESTree.ObjectLiteralElement) =>
isNamedProp(prop)
? `"${getPropName(prop)}${feat === "nest" ? "" : " /"}": ${ctx.sourceCode.getText(prop.value)}${feat === "deprecated" ? ".deprecated()" : ""},`
? `${feat === "nest" ? ctx.sourceCode.getText(prop.key) : getPropName(prop)}: ${ctx.sourceCode.getText(prop.value)}${feat === "deprecated" ? ".deprecated()" : ""},`
: `${ctx.sourceCode.getText(prop)}, /** @todo migrate manually */`;
const nextProps = argument.properties
.map(makeMapper(isDeprecated ? "deprecated" : undefined))
Expand Down