Skip to content

Commit

Permalink
Feat: support custom brands handling in Documentation and Integration (
Browse files Browse the repository at this point in the history
…#1750)

Based on #1470  and thanks to #1730 

This will be a feature in v19.
I'd like to keep changes of 19.0.0 smaller, making it easier to migrate.
Thus, I'm going to separate breaking changes from features and release
this as 19.1.0.

This will be the interface:

```ts
import { z } from "zod";
import { Documentation, Integration } from "express-zod-api";

const myBrand = Symbol("MamaToldMeImSpecial"); // I highly recommend using symbols for this purpose
const myBrandedSchema = z.string().brand(myBrand);

new Documentation({
  /* config, routing, title, version */
  brandHandling: {
    [myBrand]: (
      schema: typeof myBrandedSchema, // you should assign type yourself
      { next, path, method, isResponse }, // handle a nested schema using next()
    ) => {
      const defaultResult = next(schema.unwrap()); // { type: string }
      return { summary: "Special type of data" };
    },
  },
});

import ts from "typescript";
const { factory: f } = ts;

new Integration({
  /* routing */
  brandHandling: {
    [myBrand]: (
      schema: typeof myBrandedSchema, // you should assign type yourself
      { next, isResponse, serializer }, // handle a nested schema using next()
    ) => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword),
  },
});
```
  • Loading branch information
RobinTail authored May 14, 2024
1 parent 38e157c commit a99939b
Show file tree
Hide file tree
Showing 19 changed files with 856 additions and 755 deletions.
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,53 @@

## Version 19

### v19.1.0

- Feature: customizable handling rules for your branded schemas in Documentation and Integration:
- You can make your schemas special by branding them using `.brand()` method;
- The library (being a Zod Plugin as well) distinguishes the branded schemas in runtime;
- The constructors of `Documentation` and `Integration` now accept new property `brandHandling` (object);
- Its keys should be the brands you want to handle in a special way;
- Its values are functions having your schema as the first argument and a context in the second place;
- In case you need to reuse a handling rule for multiple brands, use the exposed types `Depicter` and `Producer`.

```ts
import ts from "typescript";
import { z } from "zod";
import {
Documentation,
Integration,
Depicter,
Producer,
} from "express-zod-api";

const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose
const myBrandedSchema = z.string().brand(myBrand);

const ruleForDocs: Depicter = (
schema: typeof myBrandedSchema, // you should assign type yourself
{ next, path, method, isResponse }, // handle a nested schema using next()
) => {
const defaultDepiction = next(schema.unwrap()); // { type: string }
return { summary: "Special type of data" };
};

const ruleForClient: Producer = (
schema: typeof myBrandedSchema, // you should assign type yourself
{ next, isResponse, serializer }, // handle a nested schema using next()
) => ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);

new Documentation({
/* config, routing, title, version */
brandHandling: { [myBrand]: ruleForDocs },
});

new Integration({
/* routing */
brandHandling: { [myBrand]: ruleForClient },
});
```

### v19.0.0

- **Breaking changes**:
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Start your API server with I/O schema validation and custom middlewares in minut
2. [Generating a Frontend Client](#generating-a-frontend-client)
3. [Creating a documentation](#creating-a-documentation)
4. [Tagging the endpoints](#tagging-the-endpoints)
5. [Customizable brands handling](#customizable-brands-handling)
8. [Caveats](#caveats)
1. [Coercive schema of Zod](#coercive-schema-of-zod)
2. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output)
Expand Down Expand Up @@ -1170,6 +1171,50 @@ const exampleEndpoint = taggedEndpointsFactory.build({
});
```

## Customizable brands handling

You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your
schema to make it special and distinguishable for the library in runtime. Using symbols is recommended for branding.
After that utilize the `brandHandling` feature of both constructors to declare your custom implementation. In case you
need to reuse a handling rule for multiple brands, use the exposed types `Depicter` and `Producer`.

```ts
import ts from "typescript";
import { z } from "zod";
import {
Documentation,
Integration,
Depicter,
Producer,
} from "express-zod-api";

const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose
const myBrandedSchema = z.string().brand(myBrand);

const ruleForDocs: Depicter = (
schema: typeof myBrandedSchema, // you should assign type yourself
{ next, path, method, isResponse }, // handle a nested schema using next()
) => {
const defaultDepiction = next(schema.unwrap()); // { type: string }
return { summary: "Special type of data" };
};

const ruleForClient: Producer = (
schema: typeof myBrandedSchema, // you should assign type yourself
{ next, isResponse, serializer }, // handle a nested schema using next()
) => ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);

new Documentation({
/* config, routing, title, version */
brandHandling: { [myBrand]: ruleForDocs },
});

new Integration({
/* routing */
brandHandling: { [myBrand]: ruleForClient },
});
```

# Caveats

There are some well-known issues and limitations, or third party bugs that cannot be fixed in the usual way, but you
Expand Down
90 changes: 42 additions & 48 deletions src/deep-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,59 +6,58 @@ import { HandlingRules, SchemaHandler } from "./schema-walker";
import { ezUploadBrand } from "./upload-schema";

/** @desc Check is a schema handling rule returning boolean */
type Check<T extends z.ZodTypeAny> = SchemaHandler<T, boolean>;
type Check = SchemaHandler<boolean>;

const onSomeUnion: Check<
| z.ZodUnion<z.ZodUnionOptions>
| z.ZodDiscriminatedUnion<string, z.ZodDiscriminatedUnionOption<string>[]>
> = ({ schema: { options }, next }) => options.some(next);
const onSomeUnion: Check = (
schema:
| z.ZodUnion<z.ZodUnionOptions>
| z.ZodDiscriminatedUnion<string, z.ZodDiscriminatedUnionOption<string>[]>,
{ next },
) => schema.options.some(next);

const onIntersection: Check<z.ZodIntersection<z.ZodTypeAny, z.ZodTypeAny>> = ({
schema: { _def },
next,
}) => [_def.left, _def.right].some(next);
const onIntersection: Check = (
{ _def }: z.ZodIntersection<z.ZodTypeAny, z.ZodTypeAny>,
{ next },
) => [_def.left, _def.right].some(next);

const onObject: Check<z.ZodObject<z.ZodRawShape>> = ({ schema, next }) =>
Object.values(schema.shape).some(next);
const onElective: Check<
z.ZodOptional<z.ZodTypeAny> | z.ZodNullable<z.ZodTypeAny>
> = ({ schema, next }) => next(schema.unwrap());
const onEffects: Check<z.ZodEffects<z.ZodTypeAny>> = ({ schema, next }) =>
next(schema.innerType());
const onRecord: Check<z.ZodRecord> = ({ schema, next }) =>
next(schema.valueSchema);
const onArray: Check<z.ZodArray<z.ZodTypeAny>> = ({ schema, next }) =>
next(schema.element);
const onDefault: Check<z.ZodDefault<z.ZodTypeAny>> = ({ schema, next }) =>
next(schema._def.innerType);
const onElective: Check = (
schema: z.ZodOptional<z.ZodTypeAny> | z.ZodNullable<z.ZodTypeAny>,
{ next },
) => next(schema.unwrap());

const checks: HandlingRules<boolean> = {
ZodObject: onObject,
const checks: HandlingRules<boolean, {}, z.ZodFirstPartyTypeKind> = {
ZodObject: ({ shape }: z.ZodObject<z.ZodRawShape>, { next }) =>
Object.values(shape).some(next),
ZodUnion: onSomeUnion,
ZodDiscriminatedUnion: onSomeUnion,
ZodIntersection: onIntersection,
ZodEffects: onEffects,
ZodEffects: (schema: z.ZodEffects<z.ZodTypeAny>, { next }) =>
next(schema.innerType()),
ZodOptional: onElective,
ZodNullable: onElective,
ZodRecord: onRecord,
ZodArray: onArray,
ZodDefault: onDefault,
ZodRecord: ({ valueSchema }: z.ZodRecord, { next }) => next(valueSchema),
ZodArray: ({ element }: z.ZodArray<z.ZodTypeAny>, { next }) => next(element),
ZodDefault: ({ _def }: z.ZodDefault<z.ZodTypeAny>, { next }) =>
next(_def.innerType),
};

/** @desc The optimized version of the schema walker for boolean checks */
export const hasNestedSchema = ({
subject,
condition,
rules = checks,
depth = 1,
maxDepth = Number.POSITIVE_INFINITY,
}: {
subject: z.ZodTypeAny;
interface NestedSchemaLookupProps {
condition: (schema: z.ZodTypeAny) => boolean;
rules?: HandlingRules<boolean>;
maxDepth?: number;
depth?: number;
}): boolean => {
}

/** @desc The optimized version of the schema walker for boolean checks */
export const hasNestedSchema = (
subject: z.ZodTypeAny,
{
condition,
rules = checks,
depth = 1,
maxDepth = Number.POSITIVE_INFINITY,
}: NestedSchemaLookupProps,
): boolean => {
if (condition(subject)) {
return true;
}
Expand All @@ -67,11 +66,9 @@ export const hasNestedSchema = ({
? rules[subject._def.typeName as keyof typeof rules]
: undefined;
if (handler) {
return handler({
schema: subject,
return handler(subject, {
next: (schema) =>
hasNestedSchema({
subject: schema,
hasNestedSchema(schema, {
condition,
rules,
maxDepth,
Expand All @@ -83,8 +80,7 @@ export const hasNestedSchema = ({
};

export const hasTransformationOnTop = (subject: IOSchema): boolean =>
hasNestedSchema({
subject,
hasNestedSchema(subject, {
maxDepth: 3,
rules: { ZodUnion: onSomeUnion, ZodIntersection: onIntersection },
condition: (schema) =>
Expand All @@ -93,14 +89,12 @@ export const hasTransformationOnTop = (subject: IOSchema): boolean =>
});

export const hasUpload = (subject: IOSchema) =>
hasNestedSchema({
subject,
hasNestedSchema(subject, {
condition: (schema) => schema._def[metaSymbol]?.brand === ezUploadBrand,
});

export const hasRaw = (subject: IOSchema) =>
hasNestedSchema({
subject,
hasNestedSchema(subject, {
condition: (schema) => schema._def[metaSymbol]?.brand === ezRawBrand,
maxDepth: 3,
});
Loading

0 comments on commit a99939b

Please sign in to comment.