Skip to content

Commit

Permalink
Proprietary schemas using native zod methods and Metadata (#1442)
Browse files Browse the repository at this point in the history
I was dreaming about it.
Unfortunately `zod` does not provide any way to make a custom or branded
or third-party schema that can be identified as one programmatically.
Developer of `zod` also does not want to make any method to store
metadata in schemas, instead, recommends to wrap schemas in some other
structures, which is not suitable for the purposes of `express-zod-api`.
There are many small inconvenient things in making custom schema
classes, that I'd like to replace into native methods, and use
`withMeta` wrapper for storing proprietary identifier, so the generators
and walkers could still handle it.

Related issues:

```
colinhacks/zod#1718
colinhacks/zod#2413
colinhacks/zod#273
colinhacks/zod#71
colinhacks/zod#37
```

PR I've been waiting for months to merged (programmatically
distinguishable branding):

```
colinhacks/zod#2860
```
  • Loading branch information
RobinTail authored Jan 10, 2024
1 parent 80fbd21 commit 60500c6
Show file tree
Hide file tree
Showing 28 changed files with 350 additions and 479 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Version 16

### 16.3.0

- Switching to using native `zod` methods for proprietary schemas instead of custom classes (`ez` namespace):
- Each proprietary schema now relies on internal Metadata;
- Validation errors for `ez.file()` are changed slightly;
- The following refinements of `ez.file()` are deprecated and will be removed later:
- ~~`ez.file().string()`~~ — use `ez.file("string")` instead,
- ~~`ez.file().buffer()`~~ — use `ez.file("buffer")` instead,
- ~~`ez.file().base64()`~~ — use `ez.file("base64")` instead,
- ~~`ez.file().binary()`~~ — use `ez.file("binary")` instead.

### 16.2.2

- Fixed issue #1458 reported by [@elee1766](https://github.com/elee1766):
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,14 +708,14 @@ You can find two approaches to `EndpointsFactory` and `ResultHandler` implementa
[in this example](https://github.com/RobinTail/express-zod-api/blob/master/example/factories.ts).
One of them implements file streaming, in this case the endpoint just has to provide the filename.
The response schema generally may be just `z.string()`, but I made more specific `ez.file()` that also supports
`.binary()` and `.base64()` refinements which are reflected in the
`ez.file("binary")` and `ez.file("base64")` variants which are reflected in the
[generated documentation](#creating-a-documentation).

```typescript
const fileStreamingEndpointsFactory = new EndpointsFactory(
createResultHandler({
getPositiveResponse: () => ({
schema: ez.file().buffer(),
schema: ez.file("buffer"),
mimeType: "image/*",
}),
getNegativeResponse: () => ({ schema: z.string(), mimeType: "text/plain" }),
Expand Down Expand Up @@ -899,7 +899,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 (which is an alias for `z.object({ raw: ez.file("buffer") })`) as the input schema of your endpoint.

```typescript
import express from "express";
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: 16.2.2
version: 16.3.0-beta1
paths:
/v1/user/retrieve:
get:
Expand Down
2 changes: 1 addition & 1 deletion example/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const fileStreamingEndpointsFactory = new EndpointsFactory({
config,
resultHandler: createResultHandler({
getPositiveResponse: () => ({
schema: ez.file().buffer(),
schema: ez.file("buffer"),
mimeType: "image/*",
}),
getNegativeResponse: () => ({
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": "16.2.2",
"version": "16.3.0-beta1",
"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: 5 additions & 5 deletions src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { xprod } from "ramda";
import { z } from "zod";
import { CommonConfig, InputSource, InputSources } from "./config-type";
import { InputValidationError, OutputValidationError } from "./errors";
import { ZodFile } from "./file-schema";
import { IOSchema } from "./io-schema";
import { AbstractLogger } from "./logger";
import { getMeta } from "./metadata";
import { getMeta, isProprietary } from "./metadata";
import { AuxMethod, Method } from "./method";
import { mimeMultipart } from "./mime";
import { ZodUpload } from "./upload-schema";
import { ezRawKind } from "./raw-schema";
import { ezUploadKind } from "./upload-schema";

export type FlatObject = Record<string, unknown>;

Expand Down Expand Up @@ -237,13 +237,13 @@ export const hasNestedSchema = ({
export const hasUpload = (subject: IOSchema) =>
hasNestedSchema({
subject,
condition: (schema) => schema instanceof ZodUpload,
condition: (schema) => isProprietary(schema, ezUploadKind),
});

export const hasRaw = (subject: IOSchema) =>
hasNestedSchema({
subject,
condition: (schema) => schema instanceof ZodFile,
condition: (schema) => isProprietary(schema, ezRawKind),
maxDepth: 3,
});

Expand Down
79 changes: 15 additions & 64 deletions src/date-in-schema.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,15 @@
import {
INVALID,
ParseInput,
ParseReturnType,
ZodIssueCode,
ZodParsedType,
ZodType,
ZodTypeDef,
addIssueToContext,
} from "zod";
import { isValidDate } from "./schema-helpers";

// simple regex for ISO date, supports the following formats:
// 2021-01-01T00:00:00.000Z
// 2021-01-01T00:00:00.0Z
// 2021-01-01T00:00:00Z
// 2021-01-01T00:00:00
// 2021-01-01
export const isoDateRegex =
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/;

const zodDateInKind = "ZodDateIn";

export interface ZodDateInDef extends ZodTypeDef {
typeName: typeof zodDateInKind;
}

export class ZodDateIn extends ZodType<Date, ZodDateInDef, string> {
_parse(input: ParseInput): ParseReturnType<Date> {
const { status, ctx } = this._processInputParams(input);
if (ctx.parsedType !== ZodParsedType.string) {
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_type,
expected: ZodParsedType.string,
received: ctx.parsedType,
});
return INVALID;
}

if (!isoDateRegex.test(ctx.data as string)) {
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_string,
validation: "regex",
});
status.dirty();
}

const date = new Date(ctx.data);

if (!isValidDate(date)) {
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_date,
});
return INVALID;
}

return { status: status.value, value: date };
}

static create = () =>
new ZodDateIn({
typeName: zodDateInKind,
});
}
import { z } from "zod";
import { proprietary } from "./metadata";
import { isValidDate, isoDateRegex } from "./schema-helpers";

export const ezDateInKind = "DateIn";

export const dateIn = () =>
proprietary(
ezDateInKind,
z
.string()
.regex(isoDateRegex)
.transform((str) => new Date(str))
.pipe(z.date().refine(isValidDate)),
);
53 changes: 11 additions & 42 deletions src/date-out-schema.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,14 @@
import {
INVALID,
ParseInput,
ParseReturnType,
ZodIssueCode,
ZodParsedType,
ZodType,
ZodTypeDef,
addIssueToContext,
} from "zod";
import { z } from "zod";
import { proprietary } from "./metadata";
import { isValidDate } from "./schema-helpers";

const zodDateOutKind = "ZodDateOut";
export const ezDateOutKind = "DateOut";

export interface ZodDateOutDef extends ZodTypeDef {
typeName: typeof zodDateOutKind;
}

export class ZodDateOut extends ZodType<string, ZodDateOutDef, Date> {
_parse(input: ParseInput): ParseReturnType<string> {
const { status, ctx } = this._processInputParams(input);
if (ctx.parsedType !== ZodParsedType.date) {
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_type,
expected: ZodParsedType.date,
received: ctx.parsedType,
});
return INVALID;
}

if (!isValidDate(ctx.data)) {
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_date,
});
return INVALID;
}

return { status: status.value, value: (ctx.data as Date).toISOString() };
}

static create = () =>
new ZodDateOut({
typeName: zodDateOutKind,
});
}
export const dateOut = () =>
proprietary(
ezDateOutKind,
z
.date()
.refine(isValidDate)
.transform((date) => date.toISOString()),
);
42 changes: 25 additions & 17 deletions src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,17 @@ import {
FlatObject,
getExamples,
hasCoercion,
hasRaw,
hasTopLevelTransformingEffect,
isCustomHeader,
makeCleanId,
tryToTransform,
ucFirst,
} from "./common-helpers";
import { InputSource, TagsConfig } from "./config-type";
import { ZodDateIn, isoDateRegex } from "./date-in-schema";
import { ZodDateOut } from "./date-out-schema";
import { ezDateInKind } from "./date-in-schema";
import { ezDateOutKind } from "./date-out-schema";
import { DocumentationError } from "./errors";
import { ZodFile } from "./file-schema";
import { ezFileKind } from "./file-schema";
import { IOSchema } from "./io-schema";
import {
LogicalContainer,
Expand All @@ -42,14 +41,16 @@ import {
} from "./logical-container";
import { copyMeta } from "./metadata";
import { Method } from "./method";
import { RawSchema, ezRawKind } from "./raw-schema";
import { isoDateRegex } from "./schema-helpers";
import {
HandlingRules,
HandlingVariant,
SchemaHandler,
walkSchema,
} from "./schema-walker";
import { Security } from "./security";
import { ZodUpload } from "./upload-schema";
import { ezUploadKind } from "./upload-schema";

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

Expand Down Expand Up @@ -130,7 +131,7 @@ export const depictAny: Depicter<z.ZodAny> = () => ({
format: "any",
});

export const depictUpload: Depicter<ZodUpload> = (ctx) => {
export const depictUpload: Depicter<z.ZodType> = (ctx) => {
assert(
!ctx.isResponse,
new DocumentationError({
Expand All @@ -144,11 +145,14 @@ export const depictUpload: Depicter<ZodUpload> = (ctx) => {
};
};

export const depictFile: Depicter<ZodFile> = ({
schema: { isBinary, isBase64, isBuffer },
}) => ({
export const depictFile: Depicter<z.ZodType> = ({ schema }) => ({
type: "string",
format: isBuffer || isBinary ? "binary" : isBase64 ? "byte" : "file",
format:
schema instanceof z.ZodString
? schema._def.checks.find((check) => check.kind === "regex")
? "byte"
: "file"
: "binary",
});

export const depictUnion: Depicter<z.ZodUnion<z.ZodUnionOptions>> = ({
Expand Down Expand Up @@ -245,7 +249,7 @@ export const depictObject: Depicter<z.AnyZodObject> = ({
* */
export const depictNull: Depicter<z.ZodNull> = () => ({ type: "null" });

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

export const depictDateOut: Depicter<ZodDateOut> = (ctx) => {
export const depictDateOut: Depicter<z.ZodType> = (ctx) => {
assert(
ctx.isResponse,
new DocumentationError({
Expand Down Expand Up @@ -587,6 +591,9 @@ export const depictLazy: Depicter<z.ZodLazy<z.ZodTypeAny>> = ({
);
};

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

export const depictExamples = (
schema: z.ZodTypeAny,
isResponse: boolean,
Expand Down Expand Up @@ -744,8 +751,6 @@ export const depicters: HandlingRules<
ZodNumber: depictNumber,
ZodBigInt: depictBigInt,
ZodBoolean: depictBoolean,
ZodDateIn: depictDateIn,
ZodDateOut: depictDateOut,
ZodNull: depictNull,
ZodArray: depictArray,
ZodTuple: depictTuple,
Expand All @@ -754,8 +759,6 @@ export const depicters: HandlingRules<
ZodLiteral: depictLiteral,
ZodIntersection: depictIntersection,
ZodUnion: depictUnion,
ZodFile: depictFile,
ZodUpload: depictUpload,
ZodAny: depictAny,
ZodDefault: depictDefault,
ZodEnum: depictEnum,
Expand All @@ -770,6 +773,11 @@ export const depicters: HandlingRules<
ZodPipeline: depictPipeline,
ZodLazy: depictLazy,
ZodReadonly: depictReadonly,
[ezFileKind]: depictFile,
[ezUploadKind]: depictUpload,
[ezDateOutKind]: depictDateOut,
[ezDateInKind]: depictDateIn,
[ezRawKind]: depictRaw,
};

export const onEach: Depicter<z.ZodTypeAny, "each"> = ({
Expand Down Expand Up @@ -1048,7 +1056,7 @@ export const depictRequest = ({
const bodyDepiction = excludeExamplesFromDepiction(
excludeParamsFromDepiction(
walkSchema({
schema: hasRaw(schema) ? ZodFile.create().buffer() : schema,
schema,
isResponse: false,
rules: depicters,
onEach,
Expand Down
Loading

0 comments on commit 60500c6

Please sign in to comment.