Skip to content

Commit

Permalink
Merge branch 'master' into make-v19
Browse files Browse the repository at this point in the history
  • Loading branch information
RobinTail committed May 4, 2024
2 parents 7d13dc7 + 6a6d2a8 commit 4f7f31e
Show file tree
Hide file tree
Showing 19 changed files with 437 additions and 395 deletions.
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,55 @@

## Version 18

### v18.5.0

- Major update on metadata: ~~`withMeta()`~~ is no longer required, deprecated and will be removed in v19:
- ~~`withMeta()`~~ was introduced in version 2.10.0, because I didn't want to alter Zod's prototypes;
- However, the [new information](https://github.com/colinhacks/zod/pull/3445#issuecomment-2091463120) arrived
recently from the author of Zod on that matter;
- It turned out that altering Zod's prototypes is exatly the recommended approach for extending its functionality;
- Therefore `express-zod-api` from now on acts as a plugin for Zod, adding the `.example()` and `.label()` methods to
its prototypes that were previously available only after wrapping the schema in ~~`withMeta()`~~.

```ts
import { z } from "zod";
import { withMeta } from "express-zod-api";

const before = withMeta(
z
.string()
.datetime()
.default(() => new Date().toISOString()),
)
.example("2024-05-04T10:47:19.575Z")
.label("Today");

const after = z
.string()
.datetime()
.default(() => new Date().toISOString())
.example("2024-05-04T10:47:19.575Z")
.label("Today");
```

### v18.4.0

- Ability to replace the default value with a label in the generated Documentation:
- Introducing `.label()` method only available after wrapping `ZodDefault` into `withMeta()`;
- The specified label replaces the actual value of the `default` property in documentation.

```ts
import { z } from "zod";
import { withMeta } from "express-zod-api";

const labeledDefaultSchema = withMeta(
z
.string()
.datetime()
.default(() => new Date().toISOString()),
).label("Today");
```

### v18.3.0

- Changed default behaviour when using built-in logger while omitting its `color` option in config:
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1141,18 +1141,18 @@ You can add descriptions and examples to your endpoints, their I/O schemas and t
into the generated documentation of your API. Consider the following example:

```typescript
import { defaultEndpointsFactory, withMeta } from "express-zod-api";
import { defaultEndpointsFactory } from "express-zod-api";

const exampleEndpoint = defaultEndpointsFactory.build({
shortDescription: "Retrieves the user.", // <—— this becomes the summary line
description: "The detailed explanaition on what this endpoint does.",
input: withMeta(
z.object({
input: z
.object({
id: z.number().describe("the ID of the user"),
})
.example({
id: 123,
}),
).example({
id: 123,
}),
// ..., similarly for output and middlewares
});
```
Expand Down
19 changes: 9 additions & 10 deletions example/endpoints/list-users.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import z from "zod";
import { withMeta } from "../../src";
import { arrayRespondingFactory } from "../factories";

/**
Expand All @@ -10,22 +9,22 @@ export const listUsersEndpoint = arrayRespondingFactory.build({
method: "get",
tag: "users",
input: z.object({}),
output: withMeta(
z.object({
output: z
.object({
// the arrayResultHandler will take the "items" prop as the response
items: z.array(
z.object({
name: z.string(),
}),
),
})
.example({
items: [
{ name: "Hunter Schafer" },
{ name: "Laverne Cox" },
{ name: "Patti Harrison" },
],
}),
).example({
items: [
{ name: "Hunter Schafer" },
{ name: "Laverne Cox" },
{ name: "Patti Harrison" },
],
}),
handler: async () => ({
items: [
{ name: "Maria Merian" },
Expand Down
28 changes: 14 additions & 14 deletions example/endpoints/update-user.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import createHttpError from "http-errors";
import assert from "node:assert/strict";
import { z } from "zod";
import { ez, withMeta } from "../../src";
import { ez } from "../../src";
import { keyAndTokenAuthenticatedEndpointsFactory } from "../factories";

export const updateUserEndpoint =
keyAndTokenAuthenticatedEndpointsFactory.build({
method: "patch",
tag: "users",
description: "Changes the user record. Example user update endpoint.",
input: withMeta(
z.object({
input: z
.object({
// id is the route path param of /v1/user/:id
id: z
.string()
Expand All @@ -21,21 +21,21 @@ export const updateUserEndpoint =
),
name: z.string().min(1),
birthday: ez.dateIn(),
})
.example({
id: "12",
name: "John Doe",
birthday: "1963-04-21",
}),
).example({
id: "12",
name: "John Doe",
birthday: "1963-04-21",
}),
output: withMeta(
z.object({
output: z
.object({
name: z.string(),
createdAt: ez.dateOut(),
})
.example({
name: "John Doe",
createdAt: new Date("2021-12-31"),
}),
).example({
name: "John Doe",
createdAt: new Date("2021-12-31"),
}),
handler: async ({
input: { id, name, key },
options: { token },
Expand Down
2 changes: 1 addition & 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: 18.3.0
version: 18.5.0
paths:
/v1/user/retrieve:
get:
Expand Down
12 changes: 6 additions & 6 deletions example/middlewares.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import createHttpError from "http-errors";
import assert from "node:assert/strict";
import { z } from "zod";
import { Method, createMiddleware, withMeta } from "../src";
import { Method, createMiddleware } from "../src";

export const authMiddleware = createMiddleware({
security: {
Expand All @@ -10,13 +10,13 @@ export const authMiddleware = createMiddleware({
{ type: "header", name: "token" },
],
},
input: withMeta(
z.object({
input: z
.object({
key: z.string().min(1),
})
.example({
key: "1234-5678-90",
}),
).example({
key: "1234-5678-90",
}),
middleware: async ({ input: { key }, request, logger }) => {
logger.debug("Checking the key and token...");
assert.equal(key, "123", createHttpError(401, "Invalid key"));
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": "18.3.0",
"version": "18.5.0",
"description": "A Typescript library 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
10 changes: 6 additions & 4 deletions src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
andToOr,
mapLogicalContainer,
} from "./logical-container";
import { getMeta } from "./metadata";
import { Method } from "./method";
import { RawSchema, ezRawKind } from "./raw-schema";
import {
Expand Down Expand Up @@ -127,11 +128,12 @@ export const reformatParamsInPath = (path: string) =>
path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`);

export const depictDefault: Depicter<z.ZodDefault<z.ZodTypeAny>> = ({
schema: {
_def: { innerType, defaultValue },
},
schema,
next,
}) => ({ ...next(innerType), default: defaultValue() });
}) => ({
...next(schema._def.innerType),
default: getMeta(schema, "defaultLabel") || schema._def.defaultValue(),
});

export const depictCatch: Depicter<z.ZodCatch<z.ZodTypeAny>> = ({
schema: {
Expand Down
110 changes: 76 additions & 34 deletions src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,99 @@ import { z } from "zod";
import { clone, mergeDeepRight } from "ramda";
import { ProprietaryKind } from "./proprietary-schemas";

export const metaSymbol = Symbol.for("express-zod-api");

export interface Metadata<T extends z.ZodTypeAny> {
kind?: ProprietaryKind;
examples: z.input<T>[];
/** @override ZodDefault::_def.defaultValue() in depictDefault */
defaultLabel?: string;
}

export const metaProp = "expressZodApiMeta";
declare module "zod" {
interface ZodTypeDef {
[metaSymbol]?: Metadata<z.ZodTypeAny>;
}
interface ZodType {
/** @desc Add an example value (before any transformations, can be called multiple times) */
example(example: this["_input"]): this;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ZodDefault<T extends z.ZodTypeAny> {
/** @desc Change the default value in the generated Documentation to a label */
label(label: string): this;
}
}

type ExampleSetter<T extends z.ZodTypeAny> = (
example: z.input<T>,
) => WithMeta<T>;
/** @link https://github.com/colinhacks/zod/blob/3e4f71e857e75da722bd7e735b6d657a70682df2/src/types.ts#L485 */
const cloneSchema = <T extends z.ZodType>(schema: T) => {
const copy = schema.describe(schema.description as string);
copy._def[metaSymbol] = // clone for deep copy, issue #827
clone(copy._def[metaSymbol]) || ({ examples: [] } satisfies Metadata<T>);
return copy;
};

type WithMeta<T extends z.ZodTypeAny> = T & {
_def: T["_def"] & Record<typeof metaProp, Metadata<T>>;
example: ExampleSetter<T>;
const exampleSetter = function (
this: z.ZodType,
value: (typeof this)["_input"],
) {
const copy = cloneSchema(this);
copy._def[metaSymbol]!.examples.push(value);
return copy;
};

/** @link https://github.com/colinhacks/zod/blob/3e4f71e857e75da722bd7e735b6d657a70682df2/src/types.ts#L485 */
const cloneSchema = <T extends z.ZodTypeAny>(schema: T) =>
schema.describe(schema.description as string);
const defaultLabeler = function (
this: z.ZodDefault<z.ZodTypeAny>,
label: string,
) {
const copy = cloneSchema(this);
copy._def[metaSymbol]!.defaultLabel = label;
return copy;
};

export const withMeta = <T extends z.ZodTypeAny>(schema: T): WithMeta<T> => {
const copy = cloneSchema(schema) as WithMeta<T>;
copy._def[metaProp] = // clone for deep copy, issue #827
clone(copy._def[metaProp]) || ({ examples: [] } satisfies Metadata<T>);
return Object.defineProperties(copy, {
example: {
get: (): ExampleSetter<T> => (value) => {
const localCopy = withMeta<T>(copy);
(localCopy._def[metaProp] as Metadata<T>).examples.push(value);
return localCopy;
/** @see https://github.com/colinhacks/zod/blob/90efe7fa6135119224412c7081bd12ef0bccef26/plugin/effect/src/index.ts#L21-L31 */
if (!(metaSymbol in globalThis)) {
(globalThis as Record<symbol, unknown>)[metaSymbol] = true;
Object.defineProperty(
z.ZodType.prototype,
"example" satisfies keyof z.ZodType,
{
get(): z.ZodType["example"] {
return exampleSetter.bind(this);
},
},
});
};
);
Object.defineProperty(
z.ZodDefault.prototype,
"label" satisfies keyof z.ZodDefault<z.ZodTypeAny>,
{
get(): z.ZodDefault<z.ZodTypeAny>["label"] {
return defaultLabeler.bind(this);
},
},
);
}

export const hasMeta = <T extends z.ZodTypeAny>(
schema: T,
): schema is WithMeta<T> =>
metaProp in schema._def && isObject(schema._def[metaProp]);
export const hasMeta = <T extends z.ZodTypeAny>(schema: T) =>
metaSymbol in schema._def && isObject(schema._def[metaSymbol]);

export const getMeta = <T extends z.ZodTypeAny, K extends keyof Metadata<T>>(
schema: T,
meta: K,
): Readonly<Metadata<T>[K]> | undefined =>
hasMeta(schema) ? schema._def[metaProp][meta] : undefined;
hasMeta(schema) ? schema._def[metaSymbol][meta] : undefined;

export const copyMeta = <A extends z.ZodTypeAny, B extends z.ZodTypeAny>(
src: A,
dest: B,
): B | WithMeta<B> => {
): B => {
if (!hasMeta(src)) {
return dest;
}
const result = withMeta(dest);
result._def[metaProp].examples = combinations(
result._def[metaProp].examples,
src._def[metaProp].examples,
const result = cloneSchema(dest);
result._def[metaSymbol].examples = combinations(
result._def[metaSymbol].examples,
src._def[metaSymbol].examples,
([destExample, srcExample]) =>
typeof destExample === "object" && typeof srcExample === "object"
? mergeDeepRight({ ...destExample }, { ...srcExample })
Expand All @@ -72,10 +108,16 @@ export const proprietary = <T extends z.ZodTypeAny>(
kind: ProprietaryKind,
subject: T,
) => {
const schema = withMeta(subject);
schema._def[metaProp].kind = kind;
const schema = cloneSchema(subject);
schema._def[metaSymbol].kind = kind;
return schema;
};

export const isProprietary = (schema: z.ZodTypeAny, kind: ProprietaryKind) =>
getMeta(schema, "kind") === kind;

/**
* @deprecated no longer required
* @todo remove in v19
* */
export const withMeta = <T extends z.ZodTypeAny>(schema: T) => schema;
Loading

0 comments on commit 4f7f31e

Please sign in to comment.