Skip to content

Commit

Permalink
Feat: Empty response support (#2191)
Browse files Browse the repository at this point in the history
The following HTTP status codes imply no content in response:
- 204
- redirects, such as 302
  • Loading branch information
RobinTail authored Nov 20, 2024
1 parent 944df41 commit 5e6354e
Show file tree
Hide file tree
Showing 18 changed files with 282 additions and 59 deletions.
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

## Version 21

### v21.1.0

- Featuring empty response support:
- For some REST APIs, empty responses are typical: with status code `204` (No Content) and redirects (302);
- Previously, the framework did not offer a straightforward way to describe such responses, but now there is one;
- The `mimeType` property can now be assigned with `null` in `ResultHandler` definition;
- Both `Documentation` and `Integration` generators ignore such entries so that the `schema` can be `z.never()`:
- The body of such response will not be depicted by `Documentation`;
- The type of such response will be described as `undefined` (configurable) by `Integration`.

```ts
import { z } from "zod";
import {
ResultHandler,
ensureHttpError,
EndpointsFactory,
Integration,
} from "express-zod-api";

const resultHandler = new ResultHandler({
positive: { statusCode: 204, mimeType: null, schema: z.never() },
negative: { statusCode: 404, mimeType: null, schema: z.never() },
handler: ({ error, response }) => {
response.status(error ? ensureHttpError(error).statusCode : 204).end(); // no content
},
});

new Integration({ noContent: z.undefined() }); // undefined is default
```

### v21.0.0

- Minimum supported versions of `express`: 4.21.1 and 5.0.1 (fixed vulnerabilities);
Expand Down
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ Start your API server with I/O schema validation and custom middlewares in minut
3. [Route path params](#route-path-params)
4. [Multiple schemas for one route](#multiple-schemas-for-one-route)
5. [Response customization](#response-customization)
6. [Error handling](#error-handling)
7. [Production mode](#production-mode)
8. [Non-object response](#non-object-response) including file downloads
9. [File uploads](#file-uploads)
10. [Serving static files](#serving-static-files)
11. [Connect to your own express app](#connect-to-your-own-express-app)
12. [Testing endpoints](#testing-endpoints)
13. [Testing middlewares](#testing-middlewares)
6. [Empty response](#empty-response)
7. [Error handling](#error-handling)
8. [Production mode](#production-mode)
9. [Non-object response](#non-object-response) including file downloads
10. [File uploads](#file-uploads)
11. [Serving static files](#serving-static-files)
12. [Connect to your own express app](#connect-to-your-own-express-app)
13. [Testing endpoints](#testing-endpoints)
14. [Testing middlewares](#testing-middlewares)
6. [Special needs](#special-needs)
1. [Different responses for different status codes](#different-responses-for-different-status-codes)
2. [Array response](#array-response) for migrating legacy APIs
Expand Down Expand Up @@ -843,6 +844,18 @@ import { EndpointsFactory } from "express-zod-api";
const endpointsFactory = new EndpointsFactory(yourResultHandler);
```

## Empty response

For some REST APIs, empty responses are typical: with status code `204` (No Content) and redirects (302). In order to
describe it set the `mimeType` to `null` and `schema` to `z.never()`:

```typescript
const resultHandler = new ResultHandler({
positive: { statusCode: 204, mimeType: null, schema: z.never() },
negative: { statusCode: 404, mimeType: null, schema: z.never() },
});
```

## Error handling

`ResultHandler` is designed to be the entity responsible for centralized error handling. By default, that center is
Expand Down
22 changes: 22 additions & 0 deletions example/endpoints/delete-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import createHttpError from "http-errors";
import assert from "node:assert/strict";
import { z } from "zod";
import { noContentFactory } from "../factories";

/** @desc The endpoint demonstrates no content response established by its factory */
export const deleteUserEndpoint = noContentFactory.build({
method: "delete",
tag: "users",
input: z.object({
id: z
.string()
.regex(/\d+/)
.transform((id) => parseInt(id, 10))
.describe("numeric string"),
}),
output: z.object({}),
handler: async ({ input: { id } }) => {
assert(id <= 100, createHttpError(404, "User not found"));
return {};
},
});
17 changes: 14 additions & 3 deletions example/example.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ type GetV1UserRetrieveResponse =
};
};

type DeleteV1UserIdRemoveInput = {
/** numeric string */
id: string;
};

type DeleteV1UserIdRemoveResponse = undefined;

type PatchV1UserIdInput = {
key: string;
id: string;
Expand Down Expand Up @@ -131,6 +138,7 @@ type PostV1AvatarRawResponse =

export type Path =
| "/v1/user/retrieve"
| "/v1/user/:id/remove"
| "/v1/user/:id"
| "/v1/user/create"
| "/v1/user/list"
Expand All @@ -145,6 +153,7 @@ export type MethodPath = `${Method} ${Path}`;

export interface Input extends Record<MethodPath, any> {
"get /v1/user/retrieve": GetV1UserRetrieveInput;
"delete /v1/user/:id/remove": DeleteV1UserIdRemoveInput;
"patch /v1/user/:id": PatchV1UserIdInput;
"post /v1/user/create": PostV1UserCreateInput;
"get /v1/user/list": GetV1UserListInput;
Expand All @@ -156,6 +165,7 @@ export interface Input extends Record<MethodPath, any> {

export interface Response extends Record<MethodPath, any> {
"get /v1/user/retrieve": GetV1UserRetrieveResponse;
"delete /v1/user/:id/remove": DeleteV1UserIdRemoveResponse;
"patch /v1/user/:id": PatchV1UserIdResponse;
"post /v1/user/create": PostV1UserCreateResponse;
"get /v1/user/list": GetV1UserListResponse;
Expand All @@ -176,6 +186,7 @@ export const jsonEndpoints = {

export const endpointTags = {
"get /v1/user/retrieve": ["users"],
"delete /v1/user/:id/remove": ["users"],
"patch /v1/user/:id": ["users"],
"post /v1/user/create": ["users"],
"get /v1/user/list": ["users"],
Expand Down Expand Up @@ -228,9 +239,9 @@ export const exampleImplementation: Implementation = async (
headers: hasBody ? { "Content-Type": "application/json" } : undefined,
body: hasBody ? JSON.stringify(params) : undefined,
});
const isJSON = response.headers
.get("content-type")
?.startsWith("application/json");
const contentType = response.headers.get("content-type");
if (!contentType) return;
const isJSON = contentType.startsWith("application/json");
return response[isJSON ? "json" : "text"]();
};
const client = new ExpressZodAPIClient(exampleImplementation);
Expand Down
21 changes: 20 additions & 1 deletion example/example.documentation.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Example API
version: 21.0.0
version: 21.1.0-beta.1
paths:
/v1/user/retrieve:
get:
Expand Down Expand Up @@ -85,6 +85,25 @@ paths:
status: error
error:
message: Sample error message
/v1/user/{id}/remove:
delete:
operationId: DeleteV1UserIdRemove
tags:
- users
parameters:
- name: id
in: path
required: true
description: numeric string
schema:
type: string
pattern: \d+
description: numeric string
responses:
"204":
description: DELETE /v1/user/:id/remove Positive response
"404":
description: DELETE /v1/user/:id/remove Negative response
/v1/user/{id}:
patch:
operationId: PatchV1UserId
Expand Down
12 changes: 12 additions & 0 deletions example/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,15 @@ export const statusDependingFactory = new EndpointsFactory({
},
}),
});

/** @desc This factory demonstrates response without body, such as 204 No Content */
export const noContentFactory = new EndpointsFactory({
config,
resultHandler: new ResultHandler({
positive: { statusCode: 204, mimeType: null, schema: z.never() },
negative: { statusCode: 404, mimeType: null, schema: z.never() },
handler: ({ error, response }) => {
response.status(error ? ensureHttpError(error).statusCode : 204).end(); // no content
},
}),
});
3 changes: 3 additions & 0 deletions example/routing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DependsOnMethod, Routing, ServeStatic } from "../src";
import { rawAcceptingEndpoint } from "./endpoints/accept-raw";
import { createUserEndpoint } from "./endpoints/create-user";
import { deleteUserEndpoint } from "./endpoints/delete-user";
import { listUsersEndpoint } from "./endpoints/list-users";
import { uploadAvatarEndpoint } from "./endpoints/upload-avatar";
import { retrieveUserEndpoint } from "./endpoints/retrieve-user";
Expand All @@ -17,6 +18,8 @@ export const routing: Routing = {
// syntax 2: methods are defined within the route (id is the route path param by the way)
":id": new DependsOnMethod({
patch: updateUserEndpoint, // demonstrates authentication
}).nest({
remove: deleteUserEndpoint, // nested path: /v1/user/:id/remove
}),
// demonstrates different response schemas depending on status code
create: createUserEndpoint,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "express-zod-api",
"version": "21.0.0",
"version": "21.1.0-beta.1",
"description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.",
"license": "MIT",
"repository": {
Expand Down
9 changes: 6 additions & 3 deletions src/api-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ export interface ApiResponse<S extends z.ZodTypeAny> {
schema: S;
/** @default 200 for a positive and 400 for a negative response */
statusCode?: number | [number, ...number[]];
/** @default "application/json" */
mimeType?: string | [string, ...string[]];
/**
* @example null is for no content, such as 204 and 302
* @default "application/json"
* */
mimeType?: string | [string, ...string[]] | null;
/** @deprecated use statusCode */
statusCodes?: never;
/** @deprecated use mimeType */
Expand All @@ -27,5 +30,5 @@ export interface ApiResponse<S extends z.ZodTypeAny> {
export interface NormalizedResponse {
schema: z.ZodTypeAny;
statusCodes: [number, ...number[]];
mimeTypes: [string, ...string[]];
mimeTypes: [string, ...string[]] | null;
}
2 changes: 1 addition & 1 deletion src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class Diagnostics {
}
for (const variant of ["positive", "negative"] as const) {
for (const { mimeTypes, schema } of endpoint.getResponses(variant)) {
if (mimeTypes.includes(contentTypes.json)) {
if (mimeTypes?.includes(contentTypes.json)) {
try {
assertJsonCompatible(schema, "out");
} catch (reason) {
Expand Down
3 changes: 2 additions & 1 deletion src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,11 +785,12 @@ export const depictResponse = ({
hasMultipleStatusCodes ? statusCode : ""
}`.trim(),
}: ReqResHandlingProps<z.ZodTypeAny> & {
mimeTypes: ReadonlyArray<string>;
mimeTypes: ReadonlyArray<string> | null;
variant: ResponseVariant;
statusCode: number;
hasMultipleStatusCodes: boolean;
}): ResponseObject => {
if (!mimeTypes) return { description };
const depictedSchema = excludeExamplesFromDepiction(
walkSchema(schema, {
rules: { ...brandHandling, ...depicters },
Expand Down
Loading

0 comments on commit 5e6354e

Please sign in to comment.