From a9180e75011fbddc2839d7085b2ea7f45619e398 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 13 Feb 2025 14:59:38 +0100 Subject: [PATCH] feat: Deprecations (#2390) Based on #2389 Featuring: - `Endpoint::deprecated()`, - `DependsOnMethod::deprecated()`, - `EndpointsFactory::build({ deprecated })` Deprecated route in the generated OpenAPI Documentation: ![2025-02-13_14-45-59](https://github.com/user-attachments/assets/c9b9a010-eb87-4922-9f46-d3fc992e30a5) Deprecated parameter in the OpenAPI Documentation: ![2025-02-13_14-46-30](https://github.com/user-attachments/assets/22000cf5-df73-4f84-8cb6-c94352018568) --- CHANGELOG.md | 28 +++++ README.md | 29 ++++- example/endpoints/time-subscription.ts | 6 +- example/example.client.ts | 6 + example/example.documentation.yaml | 6 +- example/routing.ts | 2 +- express-zod-api/src/common-helpers.ts | 3 - express-zod-api/src/depends-on-method.ts | 27 +++-- express-zod-api/src/documentation-helpers.ts | 10 +- express-zod-api/src/documentation.ts | 28 ++--- express-zod-api/src/endpoint.ts | 106 ++++++++---------- express-zod-api/src/endpoints-factory.ts | 9 +- express-zod-api/src/integration-base.ts | 10 +- express-zod-api/src/integration.ts | 6 +- express-zod-api/src/metadata.ts | 1 + .../src/{nesting.ts => routable.ts} | 5 +- express-zod-api/src/typescript-api.ts | 20 +++- express-zod-api/src/zod-plugin.ts | 14 ++- express-zod-api/src/zts.ts | 5 +- .../documentation-helpers.spec.ts.snap | 7 ++ .../__snapshots__/documentation.spec.ts.snap | 79 ++++++++++++- .../tests/__snapshots__/endpoint.spec.ts.snap | 4 +- .../__snapshots__/integration.spec.ts.snap | 7 +- .../tests/__snapshots__/sse.spec.ts.snap | 57 +++++++++- .../tests/__snapshots__/zts.spec.ts.snap | 9 ++ .../tests/depends-on-method.spec.ts | 14 +++ express-zod-api/tests/documentation.spec.ts | 44 ++++---- express-zod-api/tests/endpoint.spec.ts | 80 ++++++++----- .../tests/endpoints-factory.spec.ts | 2 + express-zod-api/tests/integration.spec.ts | 20 ++-- .../{nesting.spec.ts => routable.spec.ts} | 13 ++- express-zod-api/tests/sse.spec.ts | 75 +++++++------ express-zod-api/tests/zod-plugin.spec.ts | 17 +++ express-zod-api/tests/zts.spec.ts | 9 ++ 34 files changed, 546 insertions(+), 212 deletions(-) rename express-zod-api/src/{nesting.ts => routable.ts} (59%) rename express-zod-api/tests/{nesting.spec.ts => routable.spec.ts} (61%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f449a23ea..284c3c767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## Version 22 +### v22.9.0 + +- Featuring Deprecations: + - You can deprecate all usage of an `Endpoint` using `EndpointsFactory::build({ deprecated: true })`; + - You can deprecate a route using the assigned `Endpoint::deprecated()` or `DependsOnMethod::deprecated()`; + - You can deprecate a schema using `ZodType::deprecated()`; + - All `.deprecated()` methods are immutable — they create a new copy of the subject; + - Deprecated schemas and endpoints are reflected in the generated `Documentation` and `Integration`; + - The feature suggested by [@mlms13](https://github.com/mlms13). + +```ts +import { Routing, DependsOnMethod } from "express-zod-api"; +import { z } from "zod"; + +const someEndpoint = factory.build({ + deprecated: true, // deprecates all routes the endpoint assigned to + input: z.object({ + prop: z.string().deprecated(), // deprecates the property or a path parameter + }), +}); + +const routing: Routing = { + v1: oldEndpoint.deprecated(), // deprecates the /v1 path + v2: new DependsOnMethod({ get: oldEndpoint }).deprecated(), // deprecates the /v2 path + v3: someEndpoint, // the path is assigned with initially deprecated endpoint (also deprecated) +}; +``` + ### v22.8.0 - Feature: warning about the endpoint input scheme ignoring the parameters of the route to which it is assigned: diff --git a/README.md b/README.md index cbaee81cb..60b3eb411 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ Start your API server with I/O schema validation and custom middlewares in minut 2. [Generating a Frontend Client](#generating-a-frontend-client) 3. [Creating a documentation](#creating-a-documentation) 4. [Tagging the endpoints](#tagging-the-endpoints) - 5. [Customizable brands handling](#customizable-brands-handling) + 5. [Deprecated schemas and routes](#deprecated-schemas-and-routes) + 6. [Customizable brands handling](#customizable-brands-handling) 8. [Caveats](#caveats) 1. [Coercive schema of Zod](#coercive-schema-of-zod) 2. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output) @@ -86,6 +87,7 @@ Therefore, many basic tasks can be accomplished faster and easier, in particular These people contributed to the improvement of the framework by reporting bugs, making changes and suggesting ideas: +[@mlms13](https://github.com/mlms13) [@bobgubko](https://github.com/bobgubko) [@LucWag](https://github.com/LucWag) [@HenriJ](https://github.com/HenriJ) @@ -1236,6 +1238,7 @@ framework, [Zod Sockets](https://github.com/RobinTail/zod-sockets), which has si Express Zod API acts as a plugin for Zod, extending its functionality once you import anything from `express-zod-api`: - Adds `.example()` method to all Zod schemas for storing examples and reflecting them in the generated documentation; +- Adds `.deprecated()` method to all schemas for marking properties and request parameters as deprecated; - Adds `.label()` method to `ZodDefault` for replacing the default value in documentation with a label; - Adds `.remap()` method to `ZodObject` for renaming object properties in a suitable way for making documentation; - Alters the `.brand()` method on all Zod schemas by making the assigned brand available in runtime. @@ -1340,6 +1343,30 @@ new Documentation({ }); ``` +## Deprecated schemas and routes + +As your API evolves, you may need to mark some parameters or routes as deprecated before deleting them. For this +purpose, the `.deprecated()` method is available on each schema, `Endpoint` and `DependsOnMethod`, it's immutable. +You can also deprecate all routes the `Endpoint` assigned to by setting `EndpointsFactory::build({ deprecated: true })`. + +```ts +import { Routing, DependsOnMethod } from "express-zod-api"; +import { z } from "zod"; + +const someEndpoint = factory.build({ + deprecated: true, // deprecates all routes the endpoint assigned to + input: z.object({ + prop: z.string().deprecated(), // deprecates the property or a path parameter + }), +}); + +const routing: Routing = { + v1: oldEndpoint.deprecated(), // deprecates the /v1 path + v2: new DependsOnMethod({ get: oldEndpoint }).deprecated(), // deprecates the /v2 path + v3: someEndpoint, // the path is assigned with initially deprecated endpoint (also deprecated) +}; +``` + ## Customizable brands handling You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your diff --git a/example/endpoints/time-subscription.ts b/example/endpoints/time-subscription.ts index 9d38a2e80..ca8625630 100644 --- a/example/endpoints/time-subscription.ts +++ b/example/endpoints/time-subscription.ts @@ -6,7 +6,11 @@ import { eventsFactory } from "../factories"; export const subscriptionEndpoint = eventsFactory.buildVoid({ tag: "subscriptions", input: z.object({ - trigger: z.string().optional(), + trigger: z + .string() + .optional() + .deprecated() + .describe("for testing error response"), }), handler: async ({ input: { trigger }, diff --git a/example/example.client.ts b/example/example.client.ts index 55b052186..157a59d35 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -267,6 +267,7 @@ interface PostV1AvatarRawNegativeResponseVariants { /** get /v1/events/stream */ type GetV1EventsStreamInput = { + /** @deprecated for testing error response */ trigger?: string | undefined; }; @@ -311,6 +312,7 @@ export interface Input { "patch /v1/user/:id": PatchV1UserIdInput; "post /v1/user/create": PostV1UserCreateInput; "get /v1/user/list": GetV1UserListInput; + /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendInput; "get /v1/avatar/stream": GetV1AvatarStreamInput; "post /v1/avatar/upload": PostV1AvatarUploadInput; @@ -324,6 +326,7 @@ export interface PositiveResponse { "patch /v1/user/:id": SomeOf; "post /v1/user/create": SomeOf; "get /v1/user/list": SomeOf; + /** @deprecated */ "get /v1/avatar/send": SomeOf; "get /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; @@ -337,6 +340,7 @@ export interface NegativeResponse { "patch /v1/user/:id": SomeOf; "post /v1/user/create": SomeOf; "get /v1/user/list": SomeOf; + /** @deprecated */ "get /v1/avatar/send": SomeOf; "get /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; @@ -355,6 +359,7 @@ export interface EncodedResponse { PostV1UserCreateNegativeResponseVariants; "get /v1/user/list": GetV1UserListPositiveResponseVariants & GetV1UserListNegativeResponseVariants; + /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendPositiveResponseVariants & GetV1AvatarSendNegativeResponseVariants; "get /v1/avatar/stream": GetV1AvatarStreamPositiveResponseVariants & @@ -383,6 +388,7 @@ export interface Response { "get /v1/user/list": | PositiveResponse["get /v1/user/list"] | NegativeResponse["get /v1/user/list"]; + /** @deprecated */ "get /v1/avatar/send": | PositiveResponse["get /v1/avatar/send"] | NegativeResponse["get /v1/avatar/send"]; diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 5993def56..143d9b364 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -389,6 +389,7 @@ paths: get: operationId: GetV1AvatarSend summary: Sends a file content. + deprecated: true tags: - files - users @@ -595,10 +596,13 @@ paths: parameters: - name: trigger in: query + deprecated: true required: false - description: GET /v1/events/stream Parameter + description: for testing error response schema: type: string + description: for testing error response + deprecated: true responses: "200": description: GET /v1/events/stream Positive response diff --git a/example/routing.ts b/example/routing.ts index 6b5584876..be862338d 100644 --- a/example/routing.ts +++ b/example/routing.ts @@ -28,7 +28,7 @@ export const routing: Routing = { }, avatar: { // custom result handler examples with a file serving - send: sendAvatarEndpoint, + send: sendAvatarEndpoint.deprecated(), // demo for deprecated route stream: streamAvatarEndpoint, // file upload example upload: uploadAvatarEndpoint, diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 36433f819..26fae26fa 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -182,6 +182,3 @@ export const isProduction = memoizeWith( () => 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 ); - -export const nonEmpty = (subject: T[] | ReadonlyArray) => - subject.length ? subject : undefined; diff --git a/express-zod-api/src/depends-on-method.ts b/express-zod-api/src/depends-on-method.ts index cd6a2faf0..68db62a55 100644 --- a/express-zod-api/src/depends-on-method.ts +++ b/express-zod-api/src/depends-on-method.ts @@ -1,21 +1,34 @@ import { keys, reject, equals } from "ramda"; import { AbstractEndpoint } from "./endpoint"; import { Method } from "./method"; -import { Nesting } from "./nesting"; +import { Routable } from "./routable"; -export class DependsOnMethod extends Nesting { - /** @desc [method, endpoint, siblingMethods] */ - public readonly entries: ReadonlyArray<[Method, AbstractEndpoint, Method[]]>; +export class DependsOnMethod extends Routable { + readonly #endpoints: ConstructorParameters[0]; constructor(endpoints: Partial>) { super(); + this.#endpoints = endpoints; + } + + /** @desc [method, endpoint, siblingMethods] */ + public get entries(): ReadonlyArray<[Method, AbstractEndpoint, Method[]]> { const entries: Array<(typeof this.entries)[number]> = []; - const methods = keys(endpoints); // eslint-disable-line no-restricted-syntax -- liternal type required + const methods = keys(this.#endpoints); // eslint-disable-line no-restricted-syntax -- literal type required for (const method of methods) { - const endpoint = endpoints[method]; + const endpoint = this.#endpoints[method]; if (endpoint) entries.push([method, endpoint, reject(equals(method), methods)]); } - this.entries = Object.freeze(entries); + return Object.freeze(entries); + } + + public override deprecated() { + const deprecatedEndpoints = Object.entries(this.#endpoints).reduce( + (agg, [method, endpoint]) => + Object.assign(agg, { [method]: endpoint.deprecated() }), + {} as ConstructorParameters[0], + ); + return new DependsOnMethod(deprecatedEndpoints) as this; } } diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index ec6b44030..b2f1b750a 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -665,9 +665,11 @@ export const depictRequestParams = ({ composition === "components" ? makeRef(paramSchema, depicted, makeCleanId(description, name)) : depicted; + const { _def } = paramSchema as z.ZodType; return acc.concat({ name, in: location, + deprecated: _def[metaSymbol]?.isDeprecated, required: !paramSchema.isOptional(), description: depicted.description || description, schema: result, @@ -720,9 +722,9 @@ export const onEach: SchemaHandler< SchemaObject | ReferenceObject, OpenAPIContext, "each" -> = (schema: z.ZodTypeAny, { isResponse, prev }) => { +> = (schema: z.ZodType, { isResponse, prev }) => { if (isReferenceObject(prev)) return {}; - const { description } = schema; + const { description, _def } = schema; const shouldAvoidParsing = schema instanceof z.ZodLazy; const hasTypePropertyInDepiction = prev.type !== undefined; const isResponseHavingCoercion = isResponse && hasCoercion(schema); @@ -733,6 +735,7 @@ export const onEach: SchemaHandler< schema.isNullable(); const result: SchemaObject = {}; if (description) result.description = description; + if (_def[metaSymbol]?.isDeprecated) result.deprecated = true; if (isActuallyNullable) result.type = makeNullableType(prev); if (!shouldAvoidParsing) { const examples = getExamples({ @@ -969,3 +972,6 @@ export const ensureShortDescription = (description: string) => description.length <= shortDescriptionLimit ? description : description.slice(0, shortDescriptionLimit - 1) + "…"; + +export const nonEmpty = (subject: T[] | ReadonlyArray) => + subject.length ? subject.slice() : undefined; diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 2c41f02e6..9336a4e34 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -1,5 +1,6 @@ import { OpenApiBuilder, + OperationObject, ReferenceObject, ResponsesObject, SchemaObject, @@ -11,7 +12,7 @@ import { z } from "zod"; import { responseVariants } from "./api-response"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; -import { defaultInputSources, makeCleanId, nonEmpty } from "./common-helpers"; +import { defaultInputSources, makeCleanId } from "./common-helpers"; import { CommonConfig } from "./config-type"; import { processContainers } from "./logical-container"; import { Method } from "./method"; @@ -26,6 +27,7 @@ import { ensureShortDescription, reformatParamsInPath, IsHeader, + nonEmpty, } from "./documentation-helpers"; import { Routing } from "./routing"; import { OnEndpoint, walkRouting } from "./routing-walker"; @@ -245,18 +247,18 @@ export class Documentation extends OpenApiBuilder { }, ); - this.addPath(reformatParamsInPath(path), { - [method]: { - operationId, - summary, - description, - tags: nonEmpty(endpoint.getTags()), - parameters: nonEmpty(depictedParams), - requestBody, - security: nonEmpty(securityRefs), - responses, - }, - }); + const operation: OperationObject = { + operationId, + summary, + description, + deprecated: endpoint.isDeprecated || undefined, + tags: nonEmpty(endpoint.getTags()), + parameters: nonEmpty(depictedParams), + requestBody, + security: nonEmpty(securityRefs), + responses, + }; + this.addPath(reformatParamsInPath(path), { [method]: operation }); }; walkRouting({ routing, onEndpoint }); if (tags) this.rootDoc.tags = depictTags(tags); diff --git a/express-zod-api/src/endpoint.ts b/express-zod-api/src/endpoint.ts index f1234ef04..80bb44700 100644 --- a/express-zod-api/src/endpoint.ts +++ b/express-zod-api/src/endpoint.ts @@ -21,7 +21,7 @@ import { LogicalContainer } from "./logical-container"; import { AuxMethod, Method } from "./method"; import { AbstractMiddleware, ExpressMiddleware } from "./middleware"; import { ContentType } from "./content-type"; -import { Nesting } from "./nesting"; +import { Routable } from "./routable"; import { AbstractResultHandler } from "./result-handler"; import { Security } from "./security"; @@ -34,7 +34,8 @@ export type Handler = (params: { type DescriptionVariant = "short" | "long"; type IOVariant = "input" | "output"; -export abstract class AbstractEndpoint extends Nesting { +// @todo consider getters in v23 +export abstract class AbstractEndpoint extends Routable { public abstract execute(params: { request: Request; response: Response; @@ -49,11 +50,13 @@ export abstract class AbstractEndpoint extends Nesting { public abstract getResponses( variant: ResponseVariant, ): ReadonlyArray; + // @todo should return ReadonlyArray public abstract getSecurity(): LogicalContainer[]; public abstract getScopes(): ReadonlyArray; public abstract getTags(): ReadonlyArray; public abstract getOperationId(method: Method): string | undefined; public abstract getRequestType(): ContentType; + public abstract get isDeprecated(): boolean; } export class Endpoint< @@ -61,34 +64,10 @@ export class Endpoint< OUT extends IOSchema, OPT extends FlatObject, > extends AbstractEndpoint { - readonly #descriptions: Record; - readonly #methods?: ReadonlyArray; - readonly #middlewares: AbstractMiddleware[]; - readonly #responses: Record< - ResponseVariant, - ReadonlyArray - >; - readonly #handler: Handler, z.input, OPT>; - readonly #resultHandler: AbstractResultHandler; - readonly #schemas: { input: IN; output: OUT }; - readonly #scopes: ReadonlyArray; - readonly #tags: ReadonlyArray; - readonly #getOperationId: (method: Method) => string | undefined; - readonly #requestType: ContentType; + readonly #def: ConstructorParameters>[0]; - constructor({ - methods, - inputSchema, - outputSchema, - handler, - resultHandler, - getOperationId = () => undefined, - scopes = [], - middlewares = [], - tags = [], - description: long, - shortDescription: short, - }: { + constructor(def: { + deprecated?: boolean; middlewares?: AbstractMiddleware[]; inputSchema: IN; outputSchema: OUT; @@ -102,69 +81,74 @@ export class Endpoint< tags?: string[]; }) { super(); - this.#handler = handler; - this.#resultHandler = resultHandler; - this.#middlewares = middlewares; - this.#getOperationId = getOperationId; - this.#methods = Object.freeze(methods); - this.#scopes = Object.freeze(scopes); - this.#tags = Object.freeze(tags); - this.#descriptions = { long, short }; - this.#schemas = { input: inputSchema, output: outputSchema }; - this.#responses = { - positive: Object.freeze(resultHandler.getPositiveResponse(outputSchema)), - negative: Object.freeze(resultHandler.getNegativeResponse()), - }; - this.#requestType = hasUpload(inputSchema) - ? "upload" - : hasRaw(inputSchema) - ? "raw" - : "json"; + this.#def = def; + } + + #clone( + inc?: Partial>[0]>, + ) { + return new Endpoint({ ...this.#def, ...inc }); + } + + public override deprecated() { + return this.#clone({ deprecated: true }) as this; + } + + public override get isDeprecated(): boolean { + return this.#def.deprecated || false; } public override getDescription(variant: DescriptionVariant) { - return this.#descriptions[variant]; + return this.#def[variant === "short" ? "shortDescription" : "description"]; } public override getMethods() { - return this.#methods; + return Object.freeze(this.#def.methods); } public override getSchema(variant: "input"): IN; public override getSchema(variant: "output"): OUT; public override getSchema(variant: IOVariant) { - return this.#schemas[variant]; + return this.#def[variant === "output" ? "outputSchema" : "inputSchema"]; } public override getRequestType() { - return this.#requestType; + return hasUpload(this.#def.inputSchema) + ? "upload" + : hasRaw(this.#def.inputSchema) + ? "raw" + : "json"; } public override getResponses(variant: ResponseVariant) { - return this.#responses[variant]; + return Object.freeze( + variant === "negative" + ? this.#def.resultHandler.getNegativeResponse() + : this.#def.resultHandler.getPositiveResponse(this.#def.outputSchema), + ); } public override getSecurity() { - return this.#middlewares + return (this.#def.middlewares || []) .map((middleware) => middleware.getSecurity()) .filter((entry) => entry !== undefined); } public override getScopes() { - return this.#scopes; + return Object.freeze(this.#def.scopes || []); } public override getTags() { - return this.#tags; + return Object.freeze(this.#def.tags || []); } public override getOperationId(method: Method): string | undefined { - return this.#getOperationId(method); + return this.#def.getOperationId?.(method); } async #parseOutput(output: z.input) { try { - return (await this.#schemas.output.parseAsync(output)) as FlatObject; + return (await this.#def.outputSchema.parseAsync(output)) as FlatObject; } catch (e) { throw e instanceof z.ZodError ? new OutputValidationError(e) : e; } @@ -184,7 +168,7 @@ export class Endpoint< logger: ActualLogger; options: Partial; }) { - for (const mw of this.#middlewares) { + for (const mw of this.#def.middlewares || []) { if (method === "options" && !(mw instanceof ExpressMiddleware)) continue; Object.assign( options, @@ -210,13 +194,13 @@ export class Endpoint< }) { let finalInput: z.output; // final input types transformations for handler try { - finalInput = (await this.#schemas.input.parseAsync( + finalInput = (await this.#def.inputSchema.parseAsync( input, )) as z.output; } catch (e) { throw e instanceof z.ZodError ? new InputValidationError(e) : e; } - return this.#handler({ ...rest, input: finalInput }); + return this.#def.handler({ ...rest, input: finalInput }); } async #handleResult({ @@ -232,7 +216,7 @@ export class Endpoint< options: Partial; }) { try { - await this.#resultHandler.execute({ ...rest, error }); + await this.#def.resultHandler.execute({ ...rest, error }); } catch (e) { lastResortHandler({ ...rest, diff --git a/express-zod-api/src/endpoints-factory.ts b/express-zod-api/src/endpoints-factory.ts index 7e38609e7..91af23ce1 100644 --- a/express-zod-api/src/endpoints-factory.ts +++ b/express-zod-api/src/endpoints-factory.ts @@ -31,6 +31,7 @@ interface BuildProps< method?: Method | [Method, ...Method[]]; scope?: SCO | SCO[]; tag?: Tag | Tag[]; + deprecated?: boolean; } export class EndpointsFactory< @@ -94,14 +95,12 @@ export class EndpointsFactory< public build({ input = z.object({}) as BIN, - handler, output: outputSchema, - description, - shortDescription, operationId, scope, tag, method, + ...rest }: BuildProps) { const { middlewares, resultHandler } = this; const methods = typeof method === "string" ? [method] : method; @@ -110,7 +109,7 @@ export class EndpointsFactory< const scopes = typeof scope === "string" ? [scope] : scope || []; const tags = typeof tag === "string" ? [tag] : tag || []; return new Endpoint({ - handler, + ...rest, middlewares, outputSchema, resultHandler, @@ -118,8 +117,6 @@ export class EndpointsFactory< tags, methods, getOperationId, - description, - shortDescription, inputSchema: getFinalEndpointInputSchema(middlewares, input), }); } diff --git a/express-zod-api/src/integration-base.ts b/express-zod-api/src/integration-base.ts index 774c9c785..ce63a838b 100644 --- a/express-zod-api/src/integration-base.ts +++ b/express-zod-api/src/integration-base.ts @@ -43,11 +43,15 @@ import { type IOKind = "input" | "response" | ResponseVariant | "encoded"; type SSEShape = ReturnType["shape"]; +type Store = Record; export abstract class IntegrationBase { protected paths = new Set(); protected tags = new Map>(); - protected registry = new Map>(); + protected registry = new Map< + string, + { store: Store; isDeprecated: boolean } + >(); protected ids = { pathType: f.createIdentifier("Path"), @@ -117,8 +121,8 @@ export abstract class IntegrationBase { (Object.keys(this.interfaces) as IOKind[]).map((kind) => makeInterface( this.interfaces[kind], - Array.from(this.registry).map(([request, faces]) => - makeInterfaceProp(request, faces[kind]), + Array.from(this.registry).map(([request, { store, isDeprecated }]) => + makeInterfaceProp(request, store[kind], { isDeprecated }), ), { expose: true }, ), diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 3ee45c0ba..ab30e6df5 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -115,6 +115,7 @@ export class Integration extends IntegrationBase { const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; const onEndpoint: OnEndpoint = (endpoint, path, method) => { const entitle = makeCleanId.bind(null, method, path); // clean id with method+path prefix + const { isDeprecated } = endpoint; const request = `${method} ${path}`; const input = makeType( entitle("input"), @@ -148,7 +149,7 @@ export class Integration extends IntegrationBase { ); this.paths.add(path); const literalIdx = makeLiteralType(request); - this.registry.set(request, { + const store = { input: ensureTypeNode(input.name), positive: this.someOf(dictionaries.positive), negative: this.someOf(dictionaries.negative), @@ -160,7 +161,8 @@ export class Integration extends IntegrationBase { ensureTypeNode(dictionaries.positive.name), ensureTypeNode(dictionaries.negative.name), ]), - }); + }; + this.registry.set(request, { isDeprecated, store }); this.tags.set(request, endpoint.getTags()); }; walkRouting({ routing, onEndpoint }); diff --git a/express-zod-api/src/metadata.ts b/express-zod-api/src/metadata.ts index 5f4512f96..b40d24b96 100644 --- a/express-zod-api/src/metadata.ts +++ b/express-zod-api/src/metadata.ts @@ -9,6 +9,7 @@ export interface Metadata { /** @override ZodDefault::_def.defaultValue() in depictDefault */ defaultLabel?: string; brand?: string | number | symbol; + isDeprecated?: boolean; } /** @link https://github.com/colinhacks/zod/blob/3e4f71e857e75da722bd7e735b6d657a70682df2/src/types.ts#L485 */ diff --git a/express-zod-api/src/nesting.ts b/express-zod-api/src/routable.ts similarity index 59% rename from express-zod-api/src/nesting.ts rename to express-zod-api/src/routable.ts index 30b66bd06..707645af7 100644 --- a/express-zod-api/src/nesting.ts +++ b/express-zod-api/src/routable.ts @@ -1,6 +1,9 @@ import { Routing } from "./routing"; -export abstract class Nesting { +export abstract class Routable { + /** @desc Marks the route as deprecated (makes a copy of the endpoint) */ + public abstract deprecated(): this; + /** @desc Enables nested routes within the path assigned to the subject */ public nest(routing: Routing): Routing { return Object.assign(routing, { "": this }); diff --git a/express-zod-api/src/typescript-api.ts b/express-zod-api/src/typescript-api.ts index 69defed8e..41e49ab97 100644 --- a/express-zod-api/src/typescript-api.ts +++ b/express-zod-api/src/typescript-api.ts @@ -1,4 +1,4 @@ -import { map, pair } from "ramda"; +import { isNil, map, pair, reject } from "ramda"; import ts from "typescript"; export type Typeable = @@ -25,7 +25,7 @@ export const accessModifiers = { ], }; -export const addJsDocComment = (node: T, text: string) => +export const addJsDoc = (node: T, text: string) => ts.addSyntheticLeadingComment( node, ts.SyntaxKind.MultiLineCommentTrivia, @@ -136,7 +136,11 @@ export const recordStringAny = ensureTypeNode("Record", [ export const makeInterfaceProp = ( name: string | number, value: Typeable, - { isOptional, comment }: { isOptional?: boolean; comment?: string } = {}, + { + isOptional, + isDeprecated, + comment, + }: { isOptional?: boolean; isDeprecated?: boolean; comment?: string } = {}, ) => { const node = f.createPropertySignature( undefined, @@ -144,7 +148,11 @@ export const makeInterfaceProp = ( isOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, ensureTypeNode(value), ); - return comment ? addJsDocComment(node, comment) : node; + const jsdoc = reject(isNil, [ + isDeprecated ? "@deprecated" : undefined, + comment, + ]); + return jsdoc.length ? addJsDoc(node, jsdoc.join(" ")) : node; }; export const makeOneLine = (subject: ts.TypeNode) => @@ -202,7 +210,7 @@ export const makeType = ( params && makeTypeParams(params), value, ); - return comment ? addJsDocComment(node, comment) : node; + return comment ? addJsDoc(node, comment) : node; }; export const makePublicProperty = ( @@ -268,7 +276,7 @@ export const makeInterface = ( undefined, props, ); - return comment ? addJsDocComment(node, comment) : node; + return comment ? addJsDoc(node, comment) : node; }; export const makeTypeParams = ( diff --git a/express-zod-api/src/zod-plugin.ts b/express-zod-api/src/zod-plugin.ts index 78569a862..04a9d4699 100644 --- a/express-zod-api/src/zod-plugin.ts +++ b/express-zod-api/src/zod-plugin.ts @@ -2,7 +2,7 @@ * @fileoverview Zod Runtime Plugin * @see https://github.com/colinhacks/zod/blob/90efe7fa6135119224412c7081bd12ef0bccef26/plugin/effect/src/index.ts#L21-L31 * @desc This code modifies and extends zod's functionality immediately when importing express-zod-api - * @desc Enables .example() on all schemas (ZodType) + * @desc Enables .example() and .deprecated() on all schemas (ZodType) * @desc Enables .label() on ZodDefault * @desc Enables .remap() on ZodObject * @desc Stores the argument supplied to .brand() on all schema (runtime distinguishable branded types) @@ -20,6 +20,7 @@ declare module "zod" { interface ZodType { /** @desc Add an example value (before any transformations, can be called multiple times) */ example(example: this["_input"]): this; + deprecated(): this; } interface ZodDefault { /** @desc Change the default value in the generated Documentation to a label */ @@ -53,6 +54,12 @@ const exampleSetter = function ( return copy; }; +const deprecationSetter = function (this: z.ZodType) { + const copy = cloneSchema(this); + copy._def[metaSymbol]!.isDeprecated = true; + return copy; +}; + const labelSetter = function (this: z.ZodDefault, label: string) { const copy = cloneSchema(this); copy._def[metaSymbol]!.defaultLabel = label; @@ -99,6 +106,11 @@ if (!(metaSymbol in globalThis)) { return exampleSetter.bind(this); }, }, + ["deprecated" satisfies keyof z.ZodType]: { + get(): z.ZodType["deprecated"] { + return deprecationSetter.bind(this); + }, + }, ["brand" satisfies keyof z.ZodType]: { set() {}, // this is required to override the existing method get() { diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index 2da6a54ef..ccac11681 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -5,6 +5,7 @@ import { hasCoercion, tryToTransform } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { ezFileBrand, FileSchema } from "./file-schema"; +import { metaSymbol } from "./metadata"; import { ProprietaryBrand } from "./proprietary-schemas"; import { ezRawBrand, RawSchema } from "./raw-schema"; import { HandlingRules, walkSchema } from "./schema-walker"; @@ -52,13 +53,15 @@ const onObject: Producer = ( }, ) => { const members = Object.entries(shape).map(([key, value]) => { + const { description: comment, _def } = value as z.ZodType; const isOptional = isResponse && hasCoercion(value) ? value instanceof z.ZodOptional : value.isOptional(); return makeInterfaceProp(key, next(value), { + comment, isOptional: isOptional && hasQuestionMark, - comment: value.description, + isDeprecated: _def[metaSymbol]?.isDeprecated, }); }); return f.createTypeLiteralNode(members); diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index bc576edd2..b2b622fcf 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -875,6 +875,7 @@ exports[`Documentation helpers > depictRecord() > should set properties+required exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: should depict header params when enabled 1`] = ` [ { + "deprecated": undefined, "description": "GET /v1/user/:id Parameter", "examples": undefined, "in": "header", @@ -885,6 +886,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: }, }, { + "deprecated": undefined, "description": "GET /v1/user/:id Parameter", "examples": undefined, "in": "path", @@ -895,6 +897,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: }, }, { + "deprecated": undefined, "description": "GET /v1/user/:id Parameter", "examples": undefined, "in": "query", @@ -905,6 +908,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: }, }, { + "deprecated": undefined, "description": "GET /v1/user/:id Parameter", "examples": undefined, "in": "header", @@ -922,6 +926,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict none if b exports[`Documentation helpers > depictRequestParams() > should depict only path params if query is disabled 1`] = ` [ { + "deprecated": undefined, "description": "GET /v1/user/:id Parameter", "examples": undefined, "in": "path", @@ -937,6 +942,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict only path exports[`Documentation helpers > depictRequestParams() > should depict query and path params 1`] = ` [ { + "deprecated": undefined, "description": "GET /v1/user/:id Parameter", "examples": undefined, "in": "path", @@ -947,6 +953,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict query and }, }, { + "deprecated": undefined, "description": "GET /v1/user/:id Parameter", "examples": undefined, "in": "query", diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index ed9816e13..5be34ce97 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3126,7 +3126,84 @@ servers: " `; -exports[`Documentation > Metadata > Issue #827: withMeta() should be immutable 1`] = ` +exports[`Documentation > Metadata > Feature #2390: should support deprecations 1`] = ` +"openapi: 3.1.0 +info: + title: Testing Metadata:deprecations + version: 3.4.5 +paths: + /v1/getSomething: + get: + operationId: GetV1GetSomething + deprecated: true + parameters: + - name: str + in: query + deprecated: true + required: true + description: GET /v1/getSomething Parameter + schema: + type: string + deprecated: true + responses: + "200": + description: GET /v1/getSomething Positive response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: success + data: + type: object + required: + - status + - data + "400": + description: GET /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + required: + - status + - error + examples: + example1: + value: + status: error + error: + message: Sample error message +components: + schemas: {} + responses: {} + parameters: {} + examples: {} + requestBodies: {} + headers: {} + securitySchemes: {} + links: {} + callbacks: {} +tags: [] +servers: + - url: https://example.com +" +`; + +exports[`Documentation > Metadata > Issue #827: .example() should be immutable 1`] = ` "openapi: 3.1.0 info: title: Testing Metadata:example on IO parameter diff --git a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap index 3dce886b2..597fda663 100644 --- a/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap @@ -14,7 +14,7 @@ exports[`Endpoint > #handleResult > Should handle errors within ResultHandler 1` ] `; -exports[`Endpoint > .getResponses() > should return the negative responses 1`] = ` +exports[`Endpoint > .getResponses() > should return the negative responses (readonly) 1`] = ` [ { "mimeTypes": [ @@ -44,7 +44,7 @@ exports[`Endpoint > .getResponses() > should return the negative responses 1`] = ] `; -exports[`Endpoint > .getResponses() > should return the positive responses 1`] = ` +exports[`Endpoint > .getResponses() > should return the positive responses (readonly) 1`] = ` [ { "mimeTypes": [ diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index 27c7a5e36..c227fc318 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -586,7 +586,7 @@ export type Request = keyof Input; " `; -exports[`Integration > Should support types variant and handle recirsive schemas 1`] = ` +exports[`Integration > Should support types variant and handle recursive schemas 1`] = ` "type Type1 = { name: string; features: Type1; @@ -628,22 +628,27 @@ export type Path = "/v1/test"; export type Method = "get" | "post" | "put" | "delete" | "patch"; export interface Input { + /** @deprecated */ "post /v1/test": PostV1TestInput; } export interface PositiveResponse { + /** @deprecated */ "post /v1/test": SomeOf; } export interface NegativeResponse { + /** @deprecated */ "post /v1/test": SomeOf; } export interface EncodedResponse { + /** @deprecated */ "post /v1/test": PostV1TestPositiveResponseVariants & PostV1TestNegativeResponseVariants; } export interface Response { + /** @deprecated */ "post /v1/test": PositiveResponse["post /v1/test"] | NegativeResponse["post /v1/test"]; } diff --git a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap index 8007b48d9..c183aed34 100644 --- a/express-zod-api/tests/__snapshots__/sse.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/sse.spec.ts.snap @@ -27,7 +27,7 @@ exports[`SSE > makeEventSchema() > should make a valid schema of SSE event 1`] = } `; -exports[`SSE > makeResultHandler() > should create ResultHandler describing possible events and handling generic errors 1`] = ` +exports[`SSE > makeResultHandler() > should create ResultHandler describing possible events and handling generic errors 0 1`] = ` [ { "mimeTypes": [ @@ -94,7 +94,60 @@ exports[`SSE > makeResultHandler() > should create ResultHandler describing poss ] `; -exports[`SSE > makeResultHandler() > should create ResultHandler describing possible events and handling generic errors 2`] = ` +exports[`SSE > makeResultHandler() > should create ResultHandler describing possible events and handling generic errors 0 2`] = ` +[ + { + "mimeTypes": [ + "text/plain", + ], + "schema": { + "_type": "ZodString", + }, + "statusCodes": [ + 400, + ], + }, +] +`; + +exports[`SSE > makeResultHandler() > should create ResultHandler describing possible events and handling generic errors 1 1`] = ` +[ + { + "mimeTypes": [ + "text/event-stream", + ], + "schema": { + "_type": "ZodObject", + "shape": { + "data": { + "_type": "ZodString", + }, + "event": { + "_type": "ZodLiteral", + "value": "single", + }, + "id": { + "_type": "ZodOptional", + "value": { + "_type": "ZodString", + }, + }, + "retry": { + "_type": "ZodOptional", + "value": { + "_type": "ZodNumber", + }, + }, + }, + }, + "statusCodes": [ + 200, + ], + }, +] +`; + +exports[`SSE > makeResultHandler() > should create ResultHandler describing possible events and handling generic errors 1 2`] = ` [ { "mimeTypes": [ diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index b0da68664..7260dd0e2 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -248,6 +248,15 @@ exports[`zod-to-ts > z.object() > supports string literal properties 1`] = ` }" `; +exports[`zod-to-ts > z.object() > supports zod.deprecated() 1`] = ` +"{ + /** @deprecated */ + one: string; + /** @deprecated with description */ + two: string; +}" +`; + exports[`zod-to-ts > z.object() > supports zod.describe() 1`] = ` "{ /** The name of the item */ diff --git a/express-zod-api/tests/depends-on-method.spec.ts b/express-zod-api/tests/depends-on-method.spec.ts index 51038c02a..32c9d2866 100644 --- a/express-zod-api/tests/depends-on-method.spec.ts +++ b/express-zod-api/tests/depends-on-method.spec.ts @@ -49,4 +49,18 @@ describe("DependsOnMethod", () => { }); expect(instance.entries).toEqual([]); }); + + test("should be able to deprecate the assigned endpoints within a copy of itself", () => { + const instance = new DependsOnMethod({ + post: new EndpointsFactory(defaultResultHandler).build({ + method: "post", + output: z.object({}), + handler: async () => ({}), + }), + }); + expect(instance.entries[0][1].isDeprecated).toBe(false); + const copy = instance.deprecated(); + expect(copy.entries[0][1].isDeprecated).toBe(true); + expect(copy).not.toBe(instance); + }); }); diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index ebd30787f..e31c6b1bf 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -1046,6 +1046,24 @@ describe("Documentation", () => { expect(spec).toMatchSnapshot(); }); + test("Feature #2390: should support deprecations", () => { + const endpoint = defaultEndpointsFactory.build({ + input: z.object({ + str: z.string().deprecated(), + }), + output: z.object({}), + handler: vi.fn(), + }); + const spec = new Documentation({ + config: sampleConfig, + routing: { v1: { getSomething: endpoint.deprecated() } }, + version: "3.4.5", + title: "Testing Metadata:deprecations", + serverUrl: "https://example.com", + }).getSpecAsYaml(); + expect(spec).toMatchSnapshot(); + }); + test("Issue #929: the location of the custom description should be on the param level", () => { const spec = new Documentation({ composition: "components", @@ -1174,30 +1192,14 @@ describe("Documentation", () => { getSomething: defaultEndpointsFactory .addMiddleware({ input: z - .object({ - key: z.string(), - }) - .example({ - key: "1234-56789-01", - }), + .object({ key: z.string() }) + .example({ key: "1234-56789-01" }), handler: vi.fn(), }) .build({ method: "post", - input: z - .object({ - str: z.string(), - }) - .example({ - str: "test", - }), - output: z - .object({ - num: z.number(), - }) - .example({ - num: 123, - }), + input: z.object({ str: z.string() }).example({ str: "test" }), + output: z.object({ num: z.number() }).example({ num: 123 }), handler: async () => ({ num: 123 }), }), }, @@ -1209,7 +1211,7 @@ describe("Documentation", () => { expect(spec).toMatchSnapshot(); }); - test("Issue #827: withMeta() should be immutable", () => { + test("Issue #827: .example() should be immutable", () => { const zodSchema = z.object({ a: z.string() }); const spec = new Documentation({ config: sampleConfig, diff --git a/express-zod-api/tests/endpoint.spec.ts b/express-zod-api/tests/endpoint.spec.ts index f22887e4a..a57320e80 100644 --- a/express-zod-api/tests/endpoint.spec.ts +++ b/express-zod-api/tests/endpoint.spec.ts @@ -12,7 +12,7 @@ import { AbstractEndpoint, Endpoint } from "../src/endpoint"; describe("Endpoint", () => { describe(".getMethods()", () => { - test("Should return the correct set of methods", () => { + test("Should return the correct set of methods (readonly)", () => { const endpointMock = new Endpoint({ methods: ["get", "post", "put", "delete", "patch"], inputSchema: z.object({}), @@ -24,28 +24,9 @@ describe("Endpoint", () => { handler: vi.fn(), }), }); - expect(endpointMock.getMethods()).toEqual([ - "get", - "post", - "put", - "delete", - "patch", - ]); - }); - - test("Should return the array for a single method also", () => { - const endpointMock = new Endpoint({ - methods: ["patch"], - inputSchema: z.object({}), - outputSchema: z.object({}), - handler: vi.fn(), - resultHandler: new ResultHandler({ - positive: z.string(), - negative: z.string(), - handler: vi.fn(), - }), - }); - expect(endpointMock.getMethods()).toEqual(["patch"]); + const methods = endpointMock.getMethods(); + expect(methods).toEqual(["get", "post", "put", "delete", "patch"]); + expect(() => (methods as any[]).push()).toThrowError(/read only/); }); }); @@ -137,6 +118,19 @@ describe("Endpoint", () => { }); }); + describe(".deprecated()", () => { + test("should make a deprecated copy of the endpoint", () => { + const endpointMock = defaultEndpointsFactory.build({ + output: z.object({}), + handler: vi.fn(), + }); + expect(endpointMock.isDeprecated).toBe(false); + const copy = endpointMock.deprecated(); + expect(copy.isDeprecated).toBe(true); + expect(copy).not.toBe(endpointMock); + }); + }); + describe("#parseOutput", () => { test("Should throw on output validation failure", async () => { const endpoint = defaultEndpointsFactory.build({ @@ -276,14 +270,50 @@ describe("Endpoint", () => { describe(".getResponses()", () => { test.each(["positive", "negative"] as const)( - "should return the %s responses", + "should return the %s responses (readonly)", (variant) => { const factory = new EndpointsFactory(defaultResultHandler); const endpoint = factory.build({ output: z.object({ something: z.number() }), handler: vi.fn(), }); - expect(endpoint.getResponses(variant)).toMatchSnapshot(); + const responses = endpoint.getResponses(variant); + expect(responses).toMatchSnapshot(); + expect(() => (responses as any[]).push()).toThrowError(/read only/); + }, + ); + }); + + describe(".getScopes", () => { + test.each(["test", ["one", "two"]])( + "should return the scopes (readonly) %#", + (scope) => { + const factory = new EndpointsFactory(defaultResultHandler); + const endpoint = factory.build({ + output: z.object({ something: z.number() }), + handler: vi.fn(), + scope, + }); + const scopes = endpoint.getScopes(); + expect(scopes).toEqual(typeof scope === "string" ? [scope] : scope); + expect(() => (scopes as any[]).push()).toThrowError(/read only/); + }, + ); + }); + + describe(".getTags", () => { + test.each(["test", ["one", "two"]])( + "should return the tags (readonly) %#", + (tag) => { + const factory = new EndpointsFactory(defaultResultHandler); + const endpoint = factory.build({ + output: z.object({ something: z.number() }), + handler: vi.fn(), + tag, + }); + const tags = endpoint.getTags(); + expect(tags).toEqual(typeof tag === "string" ? [tag] : tag); + expect(() => (tags as any[]).push()).toThrowError(/read only/); }, ); }); diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index ea5b42e3e..bfaacd171 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -334,12 +334,14 @@ describe("EndpointsFactory", () => { const factory = new EndpointsFactory(resultHandlerMock); const endpoint = factory.build({ method: "get", + deprecated: true, output: z.object({}), handler: vi.fn(), }); expectTypeOf( endpoint.getSchema("input")._output, ).toEqualTypeOf(); + expect(endpoint.isDeprecated).toBe(true); }); }); diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index 56e1cea0e..c721d5d56 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -9,7 +9,7 @@ import { } from "../src"; describe("Integration", () => { - test("Should support types variant and handle recirsive schemas", () => { + test("Should support types variant and handle recursive schemas", () => { const recursiveSchema: z.ZodTypeAny = z.lazy(() => z.object({ name: z.string(), @@ -21,14 +21,16 @@ describe("Integration", () => { variant: "types", routing: { v1: { - test: defaultEndpointsFactory.build({ - method: "post", - input: z.object({ - features: recursiveSchema, - }), - output: z.object({}), - handler: async () => ({}), - }), + test: defaultEndpointsFactory + .build({ + method: "post", + input: z.object({ + features: recursiveSchema, + }), + output: z.object({}), + handler: async () => ({}), + }) + .deprecated(), }, }, }); diff --git a/express-zod-api/tests/nesting.spec.ts b/express-zod-api/tests/routable.spec.ts similarity index 61% rename from express-zod-api/tests/nesting.spec.ts rename to express-zod-api/tests/routable.spec.ts index ce6a0963a..ac14ac457 100644 --- a/express-zod-api/tests/nesting.spec.ts +++ b/express-zod-api/tests/routable.spec.ts @@ -6,15 +6,16 @@ const endpoint = defaultEndpointsFactory.build({ handler: vi.fn(), }); -describe.each([new DependsOnMethod({ get: endpoint }), endpoint])( - "Nesting mixin %#", - (subject) => { - test("should have .nest() method returning Routing arrangement", () => { +const methodDepending = new DependsOnMethod({ get: endpoint }); + +describe.each([methodDepending, endpoint])("Routable mixin %#", (subject) => { + describe(".nest()", () => { + test("should return Routing arrangement", () => { expect(subject).toHaveProperty("nest", expect.any(Function)); expect(subject.nest({ subpath: endpoint })).toEqual({ "": subject, subpath: endpoint, }); }); - }, -); + }); +}); diff --git a/express-zod-api/tests/sse.spec.ts b/express-zod-api/tests/sse.spec.ts index c42603937..733bc69a8 100644 --- a/express-zod-api/tests/sse.spec.ts +++ b/express-zod-api/tests/sse.spec.ts @@ -104,40 +104,45 @@ describe("SSE", () => { }); describe("makeResultHandler()", () => { - test("should create ResultHandler describing possible events and handling generic errors", () => { - const resultHandler = makeResultHandler({ - test: z.string(), - another: z.number(), - }); - expect(resultHandler).toBeInstanceOf(ResultHandler); - expect(resultHandler.getPositiveResponse(z.object({}))).toMatchSnapshot(); - expect(resultHandler.getNegativeResponse()).toMatchSnapshot(); - const positiveResponse = makeResponseMock(); - const commons = { - input: {}, - output: {}, - options: {}, - request: makeRequestMock(), - logger: makeLoggerMock(), - }; - resultHandler.execute({ - ...commons, - response: positiveResponse, - error: null, - }); - expect(positiveResponse.statusCode).toBe(200); - expect(positiveResponse._getData()).toBe(""); - expect(positiveResponse.writableEnded).toBeTruthy(); - const negativeResponse = makeResponseMock(); - resultHandler.execute({ - ...commons, - response: negativeResponse, - error: new Error("failure"), - }); - expect(negativeResponse.statusCode).toBe(500); - expect(negativeResponse._getData()).toBe("failure"); - expect(negativeResponse.writableEnded).toBeTruthy(); - }); + test.each[0]>([ + { test: z.string(), another: z.number() }, + { single: z.string() }, + ])( + "should create ResultHandler describing possible events and handling generic errors %#", + (events) => { + const resultHandler = makeResultHandler(events); + expect(resultHandler).toBeInstanceOf(ResultHandler); + expect( + resultHandler.getPositiveResponse(z.object({})), + ).toMatchSnapshot(); + expect(resultHandler.getNegativeResponse()).toMatchSnapshot(); + const positiveResponse = makeResponseMock(); + const commons = { + input: {}, + output: {}, + options: {}, + request: makeRequestMock(), + logger: makeLoggerMock(), + }; + resultHandler.execute({ + ...commons, + response: positiveResponse, + error: null, + }); + expect(positiveResponse.statusCode).toBe(200); + expect(positiveResponse._getData()).toBe(""); + expect(positiveResponse.writableEnded).toBeTruthy(); + const negativeResponse = makeResponseMock(); + resultHandler.execute({ + ...commons, + response: negativeResponse, + error: new Error("failure"), + }); + expect(negativeResponse.statusCode).toBe(500); + expect(negativeResponse._getData()).toBe("failure"); + expect(negativeResponse.writableEnded).toBeTruthy(); + }, + ); }); describe("EventStreamFactory()", () => { @@ -145,7 +150,7 @@ describe("SSE", () => { expect(new EventStreamFactory({})).toBeInstanceOf(EndpointsFactory); }); - test("should combine SSE Middlware with corresponding ResultHandler and return Endpoint", async () => { + test("should combine SSE Middleware with corresponding ResultHandler and return Endpoint", async () => { const endpoint = new EventStreamFactory({ test: z.string() }).buildVoid({ input: z.object({ some: z.string().optional() }), handler: async ({ input, options }) => { diff --git a/express-zod-api/tests/zod-plugin.spec.ts b/express-zod-api/tests/zod-plugin.spec.ts index ab10ed964..2b14e72c7 100644 --- a/express-zod-api/tests/zod-plugin.spec.ts +++ b/express-zod-api/tests/zod-plugin.spec.ts @@ -49,6 +49,23 @@ describe("Zod Runtime Plugin", () => { }); }); + describe(".deprecated()", () => { + test("should be present", () => { + const schema = z.string(); + expect(schema).toHaveProperty("deprecated"); + expect(typeof schema.deprecated).toBe("function"); + }); + + test("should set the corresponding metadata in the schema definition", () => { + const schema = z.string(); + const schemaWithMeta = schema.deprecated(); + expect(schemaWithMeta._def[metaSymbol]).toHaveProperty( + "isDeprecated", + true, + ); + }); + }); + describe(".label()", () => { test("should set the corresponding metadata in the schema definition", () => { const schema = z diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 76b1f3a7d..c4ef50a9e 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -265,6 +265,15 @@ describe("zod-to-ts", () => { expect(printNodeTest(node)).toMatchSnapshot(); }); + test("supports zod.deprecated()", () => { + const schema = z.object({ + one: z.string().deprecated(), + two: z.string().deprecated().describe("with description"), + }); + const node = zodToTs(schema, { ctx }); + expect(printNodeTest(node)).toMatchSnapshot(); + }); + test("specially handles coercive schema in response", () => { const schema = z.object({ prop: z.coerce.string(),