Skip to content

Commit

Permalink
Add unionOneOf (#184)
Browse files Browse the repository at this point in the history
  • Loading branch information
samchungy authored Nov 3, 2023
1 parent 8808228 commit 1494fc2
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 114 deletions.
115 changes: 4 additions & 111 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Use the `.openapi()` method to add metadata to a specific Zod type. The `.openap
| `ref` | Use this to [auto register a schema](#creating-components) |
| `refType` | Use this to set the creation type for a component which is not referenced in the document. |
| `type` | Use this to override the generated type. If this is provided no metadata will be generated. |
| `unionOneOf` | Set to `true` to force a ZodUnion to output `oneOf` instead of `allOf` |

### `createDocument`

Expand Down Expand Up @@ -543,6 +544,7 @@ For example in `z.string().nullable()` will be rendered differently
- `items` mapping for `.rest()`
- `prefixItems` mapping for OpenAPI 3.1.0+
- ZodUnion
- By default it outputs an `allOf` schema. Use `unionOneOf` to change this to output `oneOf` instead.
- ZodUnknown

If this library cannot determine a type for a Zod Schema, it will throw an error. To avoid this, declare a manual `type` in the `.openapi()` section of that schema.
Expand All @@ -565,118 +567,9 @@ See the library in use in the [examples](./examples/) folder.

- [eslint-plugin-zod-openapi](https://github.com/samchungy/eslint-plugin-zod-openapi) - Eslint rules for zod-openapi. This includes features which can autogenerate Typescript comments for your Zod types based on your `description`, `example` and `deprecated` fields.

## Credits
## Comparisons

### [@asteasolutions/zod-to-openapi](https://github.com/asteasolutions/zod-to-openapi)

zod-openapi was created while trying to add a feature to support auto registering schemas. This proved to be extra challenging given the overall structure of the library so I decided re-write the whole thing. I was a big contributor to this library and love everything it's done, however I could not go past a few issues.

1. The underlying structure of the library consists of tightly coupled classes which require you to create an awkward Registry class to create references. This would mean you would need to ship a registry class instance along with your types which makes sharing types difficult.

2. No auto registering schema. Most users do not want to think about this so having to import and call `.register()` is a nuisance.
3. When you register a schema using the registry you need to use the outputted type from the `.register()` call. You do not need to do such a thing with this library.

4. No transform support or safety. You can use a `type` to override the transform type but what happens when that transform logic changes?

5. No input/output validation with components. What happens when you register a component with a transform which technically comprises of two types in a request and a response?

Did I really rewrite an entire library just for this? Absolutely. I believe that creating documentation and types should be as simple and as frictionless as possible.

#### Migration

1. Delete the OpenAPIRegistry and OpenAPIGenerator classes
2. Replace any `.register()` call made and replace them with `ref` in `.openapi()` or alternatively, add them directly to the components section of the schema.

```ts
const registry = new OpenAPIRegistry();

const foo = registry.register(
'foo',
z.string().openapi({ description: 'foo' }),
);
const bar = z.object({ foo });

// Replace with:
const foo = z.string().openapi({ ref: 'foo', description: 'foo' });
const bar = z.object({ foo });

// or
const foo = z.string().openapi({ description: 'foo' });
const bar = z.object({ foo });

const document = createDocument({
components: {
schemas: {
foo,
},
},
});
```

3. Replace `registry.registerComponent()` with a regular OpenAPI component in the document.

```ts
const registry = new OpenAPIRegistry();

registry.registerComponent('securitySchemes', 'auth', {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An auth token issued by oauth',
});
// Replace with regular component declaration

const document = createDocument({
components: {
// declare directly in components
securitySchemes: {
auth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An auth token issued by oauth',
},
},
},
});
```

4. Replace `registry.registerPath()` with a regular OpenAPI paths in the document.

```ts
const registry = new OpenAPIRegistry();

registry.registerPath({
method: 'get',
path: '/foo',
request: {
query: z.object({ a: z.string() }),
params: z.object({ b: z.string() }),
body: z.object({ c: z.string() }),
headers: z.object({ d: z.string() })
},
responses: {},
});
// Replace with regular path declaration

const getFoo: ZodOpenApiPathItemObject = {
get: {
requestParams: {
query: z.object({ a: z.string() }),
path: z.object({ b: z.string() }), // params -> path
header: z.object({ c: z.string() }) // headers -> header
}, // renamed from request -> requestParams
requestBody: z.object({c: z.string() }) // request.body -> requestBody
responses: {},
},
};

const document = createDocument({
paths: {
'/foo': getFoo,
},
});
```
### [@asteasolutions/zod-to-openapi](./docs/comparisons.md)

## Development

Expand Down
110 changes: 110 additions & 0 deletions docs/comparisons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
## Comparisons

zod-openapi was created while trying to add a feature to support auto registering schemas to ### [@asteasolutions/zod-to-openapi](https://github.com/asteasolutions/zod-to-openapi). This proved to be extra challenging given the overall structure of the library so I decided re-write the whole thing. I was a big contributor to this library and love everything it's done, however I could not go past a few issues.

1. The underlying structure of the library consists of tightly coupled classes which require you to create an awkward Registry class to create references. This would mean you would need to ship a registry class instance along with your types which makes sharing types difficult.

2. No auto registering schema. Most users do not want to think about this so having to import and call `.register()` is a nuisance.
3. When you register a schema using the registry you need to use the outputted type from the `.register()` call. You do not need to do such a thing with this library.

4. No transform support or safety. You can use a `type` to override the transform type but what happens when that transform logic changes?

5. No input/output validation with components. What happens when you register a component with a transform which technically comprises of two types in a request and a response?

Did I really rewrite an entire library just for this? Absolutely. I believe that creating documentation and types should be as simple and as frictionless as possible.

#### Migration

1. Delete the OpenAPIRegistry and OpenAPIGenerator classes
2. Replace any `.register()` call made and replace them with `ref` in `.openapi()` or alternatively, add them directly to the components section of the schema.

```ts
const registry = new OpenAPIRegistry();

const foo = registry.register(
'foo',
z.string().openapi({ description: 'foo' }),
);
const bar = z.object({ foo });

// Replace with:
const foo = z.string().openapi({ ref: 'foo', description: 'foo' });
const bar = z.object({ foo });

// or
const foo = z.string().openapi({ description: 'foo' });
const bar = z.object({ foo });

const document = createDocument({
components: {
schemas: {
foo,
},
},
});
```

3. Replace `registry.registerComponent()` with a regular OpenAPI component in the document.

```ts
const registry = new OpenAPIRegistry();

registry.registerComponent('securitySchemes', 'auth', {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An auth token issued by oauth',
});
// Replace with regular component declaration

const document = createDocument({
components: {
// declare directly in components
securitySchemes: {
auth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An auth token issued by oauth',
},
},
},
});
```

4. Replace `registry.registerPath()` with a regular OpenAPI paths in the document.

```ts
const registry = new OpenAPIRegistry();

registry.registerPath({
method: 'get',
path: '/foo',
request: {
query: z.object({ a: z.string() }),
params: z.object({ b: z.string() }),
body: z.object({ c: z.string() }),
headers: z.object({ d: z.string() })
},
responses: {},
});
// Replace with regular path declaration

const getFoo: ZodOpenApiPathItemObject = {
get: {
requestParams: {
query: z.object({ a: z.string() }),
path: z.object({ b: z.string() }), // params -> path
header: z.object({ c: z.string() }) // headers -> header
}, // renamed from request -> requestParams
requestBody: z.object({c: z.string() }) // request.body -> requestBody
responses: {},
},
};

const document = createDocument({
paths: {
'/foo': getFoo,
},
});
```
11 changes: 9 additions & 2 deletions src/create/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,15 @@ export const createNewSchema = <
);
}
newState.visited.add(zodSchema);
const { effectType, param, header, ref, refType, ...additionalMetadata } =
zodSchema._def.openapi ?? {};
const {
effectType,
param,
header,
ref,
refType,
unionOneOf,
...additionalMetadata
} = zodSchema._def.openapi ?? {};

const schema = createSchemaSwitch(zodSchema, newState);
const description = zodSchema.description;
Expand Down
22 changes: 21 additions & 1 deletion src/create/schema/parsers/union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { createUnionSchema } from './union';
extendZodWithOpenApi(z);

describe('createUnionSchema', () => {
it('creates a anyOf schema for a union', () => {
it('creates an anyOf schema for a union', () => {
const expected: oas31.SchemaObject = {
anyOf: [
{
Expand All @@ -26,4 +26,24 @@ describe('createUnionSchema', () => {

expect(result).toStrictEqual(expected);
});

it('creates an oneOf schema for a union if unionOneOf is true', () => {
const expected: oas31.SchemaObject = {
oneOf: [
{
type: 'string',
},
{
type: 'number',
},
],
};
const schema = z
.union([z.string(), z.number()])
.openapi({ unionOneOf: true });

const result = createUnionSchema(schema, createOutputState());

expect(result).toStrictEqual(expected);
});
});
7 changes: 7 additions & 0 deletions src/create/schema/parsers/union.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export const createUnionSchema = <
const schemas = zodUnion.options.map((option, index) =>
createSchemaObject(option, state, [`union option ${index}`]),
);

if (zodUnion._def.openapi?.unionOneOf) {
return {
oneOf: schemas,
};
}

return {
anyOf: schemas,
};
Expand Down
4 changes: 4 additions & 0 deletions src/extendZod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ interface ZodOpenApiMetadata<
example?: TInferred;
examples?: [TInferred, ...TInferred[]];
default?: T extends ZodDate ? string : TInferred;
/**
* Use this field to set the output of a ZodUnion to be `oneOf` instead of `allOf`
*/
unionOneOf?: boolean;
/**
* Use this field to output this Zod Schema in the components schemas section. Any usage of this Zod Schema will then be transformed into a $ref.
*/
Expand Down

0 comments on commit 1494fc2

Please sign in to comment.