Skip to content

Commit

Permalink
feat: Deprecations (#2390)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
RobinTail authored Feb 13, 2025
1 parent ab1b23c commit a9180e7
Show file tree
Hide file tree
Showing 34 changed files with 546 additions and 212 deletions.
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:

[<img src="https://github.com/mlms13.png" alt="@mlms13" width="50px" />](https://github.com/mlms13)
[<img src="https://github.com/bobgubko.png" alt="@bobgubko" width="50px" />](https://github.com/bobgubko)
[<img src="https://github.com/LucWag.png" alt="@LucWag" width="50px" />](https://github.com/LucWag)
[<img src="https://github.com/HenriJ.png" alt="@HenriJ" width="50px" />](https://github.com/HenriJ)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion example/endpoints/time-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
6 changes: 6 additions & 0 deletions example/example.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ interface PostV1AvatarRawNegativeResponseVariants {

/** get /v1/events/stream */
type GetV1EventsStreamInput = {
/** @deprecated for testing error response */
trigger?: string | undefined;
};

Expand Down Expand Up @@ -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;
Expand All @@ -324,6 +326,7 @@ export interface PositiveResponse {
"patch /v1/user/:id": SomeOf<PatchV1UserIdPositiveResponseVariants>;
"post /v1/user/create": SomeOf<PostV1UserCreatePositiveResponseVariants>;
"get /v1/user/list": SomeOf<GetV1UserListPositiveResponseVariants>;
/** @deprecated */
"get /v1/avatar/send": SomeOf<GetV1AvatarSendPositiveResponseVariants>;
"get /v1/avatar/stream": SomeOf<GetV1AvatarStreamPositiveResponseVariants>;
"post /v1/avatar/upload": SomeOf<PostV1AvatarUploadPositiveResponseVariants>;
Expand All @@ -337,6 +340,7 @@ export interface NegativeResponse {
"patch /v1/user/:id": SomeOf<PatchV1UserIdNegativeResponseVariants>;
"post /v1/user/create": SomeOf<PostV1UserCreateNegativeResponseVariants>;
"get /v1/user/list": SomeOf<GetV1UserListNegativeResponseVariants>;
/** @deprecated */
"get /v1/avatar/send": SomeOf<GetV1AvatarSendNegativeResponseVariants>;
"get /v1/avatar/stream": SomeOf<GetV1AvatarStreamNegativeResponseVariants>;
"post /v1/avatar/upload": SomeOf<PostV1AvatarUploadNegativeResponseVariants>;
Expand All @@ -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 &
Expand Down Expand Up @@ -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"];
Expand Down
6 changes: 5 additions & 1 deletion example/example.documentation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ paths:
get:
operationId: GetV1AvatarSend
summary: Sends a file content.
deprecated: true
tags:
- files
- users
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion example/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions express-zod-api/src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(subject: T[] | ReadonlyArray<T>) =>
subject.length ? subject : undefined;
27 changes: 20 additions & 7 deletions express-zod-api/src/depends-on-method.ts
Original file line number Diff line number Diff line change
@@ -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<typeof DependsOnMethod>[0];

constructor(endpoints: Partial<Record<Method, AbstractEndpoint>>) {
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<typeof DependsOnMethod>[0],
);
return new DependsOnMethod(deprecatedEndpoints) as this;
}
}
10 changes: 8 additions & 2 deletions express-zod-api/src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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({
Expand Down Expand Up @@ -969,3 +972,6 @@ export const ensureShortDescription = (description: string) =>
description.length <= shortDescriptionLimit
? description
: description.slice(0, shortDescriptionLimit - 1) + "…";

export const nonEmpty = <T>(subject: T[] | ReadonlyArray<T>) =>
subject.length ? subject.slice() : undefined;
28 changes: 15 additions & 13 deletions express-zod-api/src/documentation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
OpenApiBuilder,
OperationObject,
ReferenceObject,
ResponsesObject,
SchemaObject,
Expand All @@ -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";
Expand All @@ -26,6 +27,7 @@ import {
ensureShortDescription,
reformatParamsInPath,
IsHeader,
nonEmpty,
} from "./documentation-helpers";
import { Routing } from "./routing";
import { OnEndpoint, walkRouting } from "./routing-walker";
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit a9180e7

Please sign in to comment.