Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Complete zod plugin with proprietary brands #1730

Merged
merged 19 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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