Skip to content

Commit

Permalink
Complete zod plugin with proprietary brands (#1730)
Browse files Browse the repository at this point in the history
Due to #1721,
colinhacks/zod#2860 (comment), and
#1719 I'm exploring the possibility to alter the behaviour of `.brand()`
for storing the `brand` property as a way to distinguish the proprietary
schemas in runtime.

This can replace the `proprietary()` function with a call of `.brand()`.

However, so far it turned out to be breaking because `ZodBranded` does
not expose the methods of the wrapped schema, such as `.extend()`, which
is used in `accept-raw.ts` endpoint for possible route params.

——

After a series of serious considerations I realized that exposing brand
to consumers of `express-zod-api` could be a beneficial feature.
Runtime brand can be accessed via
`._def[Symbol.for("express-zod-api")].brand`
  • Loading branch information
RobinTail authored May 9, 2024
1 parent 234fec6 commit 4ef6ba6
Show file tree
Hide file tree
Showing 33 changed files with 290 additions and 253 deletions.
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- Changed the `ServerConfig` option `server.upload.beforeUpload`:
- The assigned function now accepts `request` instead of `app` and being called only for eligible requests;
- Restricting the upload can be achieved now by throwing an error from within.
- Changed interface for `ez.raw()`: additional properties should be supplied as its argument, not via `.extend()`.
- Features:
- Selective parsers equipped with a child logger:
- There are 3 types of endpoints depending on their input schema: having `ez.upload()`, having `ez.raw()`, others;
Expand All @@ -28,6 +29,8 @@
- Avoid mutating the readonly arrays;
- If you're using ~~`withMeta()`~~:
- Remove it and unwrap your schemas — you can use `.example()` method directly.
- If you're using `ez.raw().extend()` for additional properties:
- Supply them directly as an argument to `ez.raw()` — see the example below.
- If you're using `beforeUpload` in your config:
- Adjust the implementation according to the example below.

Expand Down Expand Up @@ -63,6 +66,19 @@ const after = createConfig({
});
```

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

const before = ez.raw().extend({
pathParameter: z.string(),
});

const after = ez.raw({
pathParameter: z.string(),
});
```

## Version 18

### v18.5.2
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ Some APIs may require an endpoint to be able to accept and process raw data, suc
file as an entire body of request. In order to enable this feature you need to set the `rawParser` config feature to
`express.raw()`. See also its options [in Express.js documentation](https://expressjs.com/en/4x/api.html#express.raw).
The raw data is placed into `request.body.raw` property, having type `Buffer`. Then use the proprietary `ez.raw()`
schema (which is an alias for `z.object({ raw: ez.file("buffer") })`) as the input schema of your endpoint.
schema as the input schema of your endpoint.

```typescript
import express from "express";
Expand All @@ -1015,9 +1015,9 @@ const config = createConfig({

const rawAcceptingEndpoint = defaultEndpointsFactory.build({
method: "post",
input: ez
.raw() // accepts the featured { raw: Buffer }
.extend({}), // for additional inputs, like route params, if needed
input: ez.raw({
/* the place for additional inputs, like route params, if needed */
}),
output: z.object({ length: z.number().int().nonnegative() }),
handler: async ({ input: { raw } }) => ({
length: raw.length, // raw is Buffer
Expand Down
7 changes: 4 additions & 3 deletions example/endpoints/accept-raw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { taggedEndpointsFactory } from "../factories";
export const rawAcceptingEndpoint = taggedEndpointsFactory.build({
method: "post",
tag: "files",
input: ez
.raw() // requires to enable rawParser option in server config
.extend({}), // additional inputs, route params for example, if needed
// requires to enable rawParser option in server config:
input: ez.raw({
/* the place for additional inputs, like route params, if needed */
}),
output: z.object({ length: z.number().int().nonnegative() }),
handler: async ({ input: { raw } }) => ({
length: raw.length, // input.raw is populated automatically when rawParser is set in config
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"install_hooks": "husky"
},
"type": "module",
"sideEffects": true,
"main": "dist/index.cjs",
"types": "dist/index.d.ts",
"module": "dist/index.js",
Expand Down
4 changes: 2 additions & 2 deletions src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { z } from "zod";
import { CommonConfig, InputSource, InputSources } from "./config-type";
import { InputValidationError, OutputValidationError } from "./errors";
import { AbstractLogger } from "./logger";
import { getMeta } from "./metadata";
import { metaSymbol } from "./metadata";
import { AuxMethod, Method } from "./method";
import { contentTypes } from "./content-type";

Expand Down Expand Up @@ -130,7 +130,7 @@ export const getExamples = <
* */
validate?: boolean;
}): ReadonlyArray<V extends "parsed" ? z.output<T> : z.input<T>> => {
const examples = getMeta(schema, "examples") || [];
const examples = schema._def[metaSymbol]?.examples || [];
if (!validate && variant === "original") {
return examples;
}
Expand Down
1 change: 1 addition & 0 deletions src/config-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export interface ServerConfig<TAG extends string = string>
* @desc When enabled, use ez.raw() as input schema to get input.raw in Endpoint's handler
* @default undefined
* @example express.raw()
* @todo this can be now automatic
* @link https://expressjs.com/en/4x/api.html#express.raw
* */
rawParser?: RequestHandler;
Expand Down
13 changes: 7 additions & 6 deletions src/date-in-schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { z } from "zod";
import { proprietary } from "./metadata";
import { isValidDate } from "./schema-helpers";

export const ezDateInKind = "DateIn";
export const ezDateInBrand = Symbol("DateIn");

export const dateIn = () => {
const schema = z.union([
Expand All @@ -11,8 +10,10 @@ export const dateIn = () => {
z.string().datetime({ local: true }),
]);

return proprietary(
ezDateInKind,
schema.transform((str) => new Date(str)).pipe(z.date().refine(isValidDate)),
);
return schema
.transform((str) => new Date(str))
.pipe(z.date().refine(isValidDate))
.brand(ezDateInBrand);
};

export type DateInSchema = ReturnType<typeof dateIn>;
17 changes: 8 additions & 9 deletions src/date-out-schema.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { z } from "zod";
import { proprietary } from "./metadata";
import { isValidDate } from "./schema-helpers";

export const ezDateOutKind = "DateOut";
export const ezDateOutBrand = Symbol("DateOut");

export const dateOut = () =>
proprietary(
ezDateOutKind,
z
.date()
.refine(isValidDate)
.transform((date) => date.toISOString()),
);
z
.date()
.refine(isValidDate)
.transform((date) => date.toISOString())
.brand(ezDateOutBrand);

export type DateOutSchema = ReturnType<typeof dateOut>;
10 changes: 5 additions & 5 deletions src/deep-checks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { z } from "zod";
import { IOSchema } from "./io-schema";
import { isProprietary } from "./metadata";
import { ezRawKind } from "./raw-schema";
import { metaSymbol } from "./metadata";
import { ezRawBrand } from "./raw-schema";
import { HandlingRules, SchemaHandler } from "./schema-walker";
import { ezUploadKind } from "./upload-schema";
import { ezUploadBrand } from "./upload-schema";

/** @desc Check is a schema handling rule returning boolean */
type Check<T extends z.ZodTypeAny> = SchemaHandler<T, boolean>;
Expand Down Expand Up @@ -95,12 +95,12 @@ export const hasTransformationOnTop = (subject: IOSchema): boolean =>
export const hasUpload = (subject: IOSchema) =>
hasNestedSchema({
subject,
condition: (schema) => isProprietary(schema, ezUploadKind),
condition: (schema) => schema._def[metaSymbol]?.brand === ezUploadBrand,
});

export const hasRaw = (subject: IOSchema) =>
hasNestedSchema({
subject,
condition: (schema) => isProprietary(schema, ezRawKind),
condition: (schema) => schema._def[metaSymbol]?.brand === ezRawBrand,
maxDepth: 3,
});
56 changes: 31 additions & 25 deletions src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,27 +52,27 @@ import {
ucFirst,
} from "./common-helpers";
import { InputSource, TagsConfig } from "./config-type";
import { ezDateInKind } from "./date-in-schema";
import { ezDateOutKind } from "./date-out-schema";
import { DateInSchema, ezDateInBrand } from "./date-in-schema";
import { DateOutSchema, ezDateOutBrand } from "./date-out-schema";
import { DocumentationError } from "./errors";
import { ezFileKind } from "./file-schema";
import { FileSchema, ezFileBrand } from "./file-schema";
import { IOSchema } from "./io-schema";
import {
LogicalContainer,
andToOr,
mapLogicalContainer,
} from "./logical-container";
import { getMeta } from "./metadata";
import { metaSymbol } from "./metadata";
import { Method } from "./method";
import { RawSchema, ezRawKind } from "./raw-schema";
import { RawSchema, ezRawBrand } from "./raw-schema";
import {
HandlingRules,
HandlingVariant,
SchemaHandler,
walkSchema,
} from "./schema-walker";
import { Security } from "./security";
import { ezUploadKind } from "./upload-schema";
import { UploadSchema, ezUploadBrand } from "./upload-schema";

/* eslint-disable @typescript-eslint/no-use-before-define */

Expand Down Expand Up @@ -132,7 +132,7 @@ export const depictDefault: Depicter<z.ZodDefault<z.ZodTypeAny>> = ({
next,
}) => ({
...next(schema._def.innerType),
default: getMeta(schema, "defaultLabel") || schema._def.defaultValue(),
default: schema._def[metaSymbol]?.defaultLabel || schema._def.defaultValue(),
});

export const depictCatch: Depicter<z.ZodCatch<z.ZodTypeAny>> = ({
Expand All @@ -146,7 +146,7 @@ export const depictAny: Depicter<z.ZodAny> = () => ({
format: "any",
});

export const depictUpload: Depicter<z.ZodType> = (ctx) => {
export const depictUpload: Depicter<UploadSchema> = (ctx) => {
assert(
!ctx.isResponse,
new DocumentationError({
Expand All @@ -160,15 +160,18 @@ export const depictUpload: Depicter<z.ZodType> = (ctx) => {
};
};

export const depictFile: Depicter<z.ZodType> = ({ schema }) => ({
type: "string",
format:
schema instanceof z.ZodString
? schema._def.checks.find((check) => check.kind === "base64")
? "byte"
: "file"
: "binary",
});
export const depictFile: Depicter<FileSchema> = ({ schema }) => {
const subject = schema.unwrap();
return {
type: "string",
format:
subject instanceof z.ZodString
? subject._def.checks.find((check) => check.kind === "base64")
? "byte"
: "file"
: "binary",
};
};

export const depictUnion: Depicter<z.ZodUnion<z.ZodUnionOptions>> = ({
schema: { options },
Expand Down Expand Up @@ -317,7 +320,7 @@ export const depictObject: Depicter<z.ZodObject<z.ZodRawShape>> = ({
* */
export const depictNull: Depicter<z.ZodNull> = () => ({ type: "null" });

export const depictDateIn: Depicter<z.ZodType> = (ctx) => {
export const depictDateIn: Depicter<DateInSchema> = (ctx) => {
assert(
!ctx.isResponse,
new DocumentationError({
Expand All @@ -336,7 +339,7 @@ export const depictDateIn: Depicter<z.ZodType> = (ctx) => {
};
};

export const depictDateOut: Depicter<z.ZodType> = (ctx) => {
export const depictDateOut: Depicter<DateOutSchema> = (ctx) => {
assert(
ctx.isResponse,
new DocumentationError({
Expand Down Expand Up @@ -628,7 +631,7 @@ export const depictLazy: Depicter<z.ZodLazy<z.ZodTypeAny>> = ({
};

export const depictRaw: Depicter<RawSchema> = ({ next, schema }) =>
next(schema.shape.raw);
next(schema.unwrap().shape.raw);

const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined =>
examples.length
Expand Down Expand Up @@ -669,6 +672,9 @@ export const extractObjectSchema = (
if (subject instanceof z.ZodObject) {
return subject;
}
if (subject instanceof z.ZodBranded) {
return extractObjectSchema(subject.unwrap(), tfError);
}
if (
subject instanceof z.ZodUnion ||
subject instanceof z.ZodDiscriminatedUnion
Expand Down Expand Up @@ -779,11 +785,11 @@ export const depicters: HandlingRules<
ZodPipeline: depictPipeline,
ZodLazy: depictLazy,
ZodReadonly: depictReadonly,
[ezFileKind]: depictFile,
[ezUploadKind]: depictUpload,
[ezDateOutKind]: depictDateOut,
[ezDateInKind]: depictDateIn,
[ezRawKind]: depictRaw,
[ezFileBrand]: depictFile,
[ezUploadBrand]: depictUpload,
[ezDateOutBrand]: depictDateOut,
[ezDateInBrand]: depictDateIn,
[ezRawBrand]: depictRaw,
};

export const onEach: Depicter<z.ZodTypeAny, "each"> = ({
Expand Down
13 changes: 7 additions & 6 deletions src/file-schema.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { z } from "zod";
import { proprietary } from "./metadata";

export const ezFileKind = "File";
export const ezFileBrand = Symbol("File");

const bufferSchema = z.custom<Buffer>((subject) => Buffer.isBuffer(subject), {
message: "Expected Buffer",
});

const variants = {
buffer: () => proprietary(ezFileKind, bufferSchema),
string: () => proprietary(ezFileKind, z.string()),
binary: () => proprietary(ezFileKind, bufferSchema.or(z.string())),
base64: () => proprietary(ezFileKind, z.string().base64()),
buffer: () => bufferSchema.brand(ezFileBrand),
string: () => z.string().brand(ezFileBrand),
binary: () => bufferSchema.or(z.string()).brand(ezFileBrand),
base64: () => z.string().base64().brand(ezFileBrand),
};

type Variants = typeof variants;
Expand All @@ -22,3 +21,5 @@ export function file<K extends Variant>(variant: K): ReturnType<Variants[K]>;
export function file<K extends Variant>(variant?: K) {
return variants[variant || "string"]();
}

export type FileSchema = ReturnType<typeof file>;
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import "./zod-plugin";

export { createConfig } from "./config-type";
export { AbstractEndpoint } from "./endpoint";
export {
Expand Down
4 changes: 3 additions & 1 deletion src/io-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import { copyMeta } from "./metadata";
import { AnyMiddlewareDef } from "./middleware";
import { RawSchema } from "./raw-schema";

type Refined<T extends z.ZodTypeAny> =
T extends z.ZodType<infer O> ? z.ZodEffects<T | Refined<T>, O, O> : never;
Expand All @@ -14,7 +15,8 @@ export type IOSchema<U extends z.UnknownKeysParam = any> =
| z.ZodUnion<[IOSchema<U>, ...IOSchema<U>[]]>
| z.ZodIntersection<IOSchema<U>, IOSchema<U>>
| z.ZodDiscriminatedUnion<string, z.ZodObject<any, U>[]>
| Refined<z.ZodObject<any, U>>;
| Refined<z.ZodObject<any, U>>
| RawSchema;

export type ProbableIntersection<
A extends IOSchema<"strip"> | null,
Expand Down
Loading

0 comments on commit 4ef6ba6

Please sign in to comment.