Skip to content

Commit

Permalink
feat: added refs (#99)
Browse files Browse the repository at this point in the history
  • Loading branch information
Bram-dc authored Sep 24, 2024
1 parent f911c56 commit 0aa6156
Show file tree
Hide file tree
Showing 8 changed files with 301 additions and 21 deletions.
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ app.register(fastifySwagger, {
servers: [],
},
transform: jsonSchemaTransform,

// You can also create transform with custom skiplist of endpoints that should not be included in the specification:
//
// transform: createJsonSchemaTransform({
Expand Down Expand Up @@ -107,6 +108,80 @@ async function run() {
run();
```

## How to create refs to the schemas?
It is possible to create refs to the schemas by using the `createJsonSchemaTransformObject` function. You provide the schemas as an object and fastifySwagger will create a OpenAPI document in which the schemas are referenced. The following example creates a ref to the `User` schema and will include the `User` schema in the OpenAPI document.

```ts
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUI from '@fastify/swagger-ui';
import fastify from 'fastify';
import { z } from 'zod';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
import {
createJsonSchemaTransformObject,
jsonSchemaTransform,
serializerCompiler,
validatorCompiler,
} from 'fastify-type-provider-zod';

const USER_SCHEMA = z.object({
id: z.number().int().positive(),
name: z.string().describe('The name of the user'),
});

const app = fastify();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);

app.register(fastifySwagger, {
openapi: {
info: {
title: 'SampleApi',
description: 'Sample backend service',
version: '1.0.0',
},
servers: [],
},
transform: jsonSchemaTransform,
transformObject: createJsonSchemaTransformObject({
schemas: {
User: USER_SCHEMA,
},
}),
});

app.register(fastifySwaggerUI, {
routePrefix: '/documentation',
});

app.after(() => {
app.withTypeProvider<ZodTypeProvider>().route({
method: 'GET',
url: '/users',
schema: {
response: {
200: USER_SCHEMA.array(),
},
},
handler: (req, res) => {
res.send([]);
},
});
});

async function run() {
await app.ready();

await app.listen({
port: 4949,
});

console.log(`Documentation running at http://localhost:4949/documentation`);
}

run();
```

## How to create a plugin?

```ts
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"fastify": "^4.27.0",
"fastify-plugin": "^5.0.1",
"oas-validator": "^5.0.8",
"openapi-types": "^12.1.3",
"prettier": "^3.2.5",
"tsd": "^0.31.0",
"typescript": "^5.4.5",
Expand Down
48 changes: 29 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import type {
RawServerDefault,
} from 'fastify';
import type { FastifySerializerCompiler } from 'fastify/types/schema';
import type { ZodAny, ZodTypeAny, z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import type { z } from 'zod';

import { ResponseValidationError } from './ResponseValidationError';
import { resolveRefs } from './ref';
import { convertZodToJsonSchema } from './zod-to-json';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FreeformRecord = Record<string, any>;
Expand All @@ -28,18 +30,13 @@ const defaultSkipList = [
];

export interface ZodTypeProvider extends FastifyTypeProvider {
output: this['input'] extends ZodTypeAny ? z.infer<this['input']> : unknown;
output: this['input'] extends z.ZodTypeAny ? z.infer<this['input']> : unknown;
}

interface Schema extends FastifySchema {
hide?: boolean;
}

const zodToJsonSchemaOptions = {
target: 'openApi3',
$refStrategy: 'none',
} as const;

export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly string[] }) => {
return ({ schema, url }: { schema: Schema; url: string }) => {
if (!schema) {
Expand All @@ -63,7 +60,7 @@ export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly str
for (const prop in zodSchemas) {
const zodSchema = zodSchemas[prop];
if (zodSchema) {
transformed[prop] = zodToJsonSchema(zodSchema, zodToJsonSchemaOptions);
transformed[prop] = convertZodToJsonSchema(zodSchema);
}
}

Expand All @@ -75,11 +72,7 @@ export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly str
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema = resolveSchema((response as any)[prop]);

const transformedResponse = zodToJsonSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema as any,
zodToJsonSchemaOptions,
);
const transformedResponse = convertZodToJsonSchema(schema);
transformed.response[prop] = transformedResponse;
}
}
Expand All @@ -99,7 +92,22 @@ export const jsonSchemaTransform = createJsonSchemaTransform({
skipList: defaultSkipList,
});

export const validatorCompiler: FastifySchemaCompiler<ZodAny> =
export const createJsonSchemaTransformObject =
({ schemas }: { schemas: Record<string, z.ZodTypeAny> }) =>
(
input:
| { swaggerObject: Partial<OpenAPIV2.Document> }
| { openapiObject: Partial<OpenAPIV3.Document | OpenAPIV3_1.Document> },
) => {
if ('swaggerObject' in input) {
console.warn('This package currently does not support component references for Swagger 2.0');
return input.swaggerObject;
}

return resolveRefs(input.openapiObject, schemas);
};

export const validatorCompiler: FastifySchemaCompiler<z.ZodTypeAny> =
({ schema }) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data): any => {
Expand All @@ -115,20 +123,22 @@ function hasOwnProperty<T, K extends PropertyKey>(obj: T, prop: K): obj is T & R
return Object.prototype.hasOwnProperty.call(obj, prop);
}

function resolveSchema(maybeSchema: ZodAny | { properties: ZodAny }): Pick<ZodAny, 'safeParse'> {
function resolveSchema(maybeSchema: z.ZodTypeAny | { properties: z.ZodTypeAny }): z.ZodTypeAny {
if (hasOwnProperty(maybeSchema, 'safeParse')) {
return maybeSchema;
return maybeSchema as z.ZodTypeAny;
}
if (hasOwnProperty(maybeSchema, 'properties')) {
return maybeSchema.properties;
}
throw new Error(`Invalid schema passed: ${JSON.stringify(maybeSchema)}`);
}

export const serializerCompiler: FastifySerializerCompiler<ZodAny | { properties: ZodAny }> =
export const serializerCompiler: FastifySerializerCompiler<
z.ZodTypeAny | { properties: z.ZodTypeAny }
> =
({ schema: maybeSchema, method, url }) =>
(data) => {
const schema: Pick<ZodAny, 'safeParse'> = resolveSchema(maybeSchema);
const schema = resolveSchema(maybeSchema);

const result = schema.safeParse(data);
if (result.success) {
Expand Down
70 changes: 70 additions & 0 deletions src/ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
import type { z } from 'zod';

import { convertZodToJsonSchema } from './zod-to-json';

const createComponentMap = (
schemas: Record<string, OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject>,
) => {
const map = new Map<string, string>();

Object.entries(schemas).forEach(([key, value]) => map.set(JSON.stringify(value), key));

return map;
};

const createComponentReplacer = (componentMapVK: Map<string, string>, schemasObject: object) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function componentReplacer(this: any, key: string, value: any) {
if (typeof value !== 'object') return value;

// Check if the parent is the schemas object, if so, return the value as is. This is where the schemas are defined.
if (this === schemasObject) return value;

const stringifiedValue = JSON.stringify(value);
if (componentMapVK.has(stringifiedValue))
return { $ref: `#/components/schemas/${componentMapVK.get(stringifiedValue)}` };

if (value.nullable === true) {
const nonNullableValue = { ...value };
delete nonNullableValue.nullable;
const stringifiedNonNullableValue = JSON.stringify(nonNullableValue);
if (componentMapVK.has(stringifiedNonNullableValue))
return {
anyOf: [
{ $ref: `#/components/schemas/${componentMapVK.get(stringifiedNonNullableValue)}` },
],
nullable: true,
};
}

return value;
};

export const resolveRefs = (
openapiObject: Partial<OpenAPIV3.Document | OpenAPIV3_1.Document>,
zodSchemas: Record<string, z.ZodTypeAny>,
) => {
const schemas: Record<string, OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject> = {};
for (const key in zodSchemas) {
schemas[key] = convertZodToJsonSchema(zodSchemas[key]);
}

const document = {
...openapiObject,
components: {
...openapiObject.components,
schemas: {
...openapiObject.components?.schemas,
...schemas,
},
},
};

const componentMapVK = createComponentMap(schemas);
const componentReplacer = createComponentReplacer(componentMapVK, document.components.schemas);

// Using the componentReplacer function we deep check if the document has any schemas that are the same as the zod schemas provided
// When a match is found replace them with a $ref.
return JSON.parse(JSON.stringify(document, componentReplacer));
};
11 changes: 11 additions & 0 deletions src/zod-to-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

const zodToJsonSchemaOptions = {
target: 'openApi3',
$refStrategy: 'none',
} as const;

export const convertZodToJsonSchema = (zodSchema: z.ZodTypeAny) => {
return zodToJsonSchema(zodSchema, zodToJsonSchemaOptions);
};
55 changes: 55 additions & 0 deletions test/__snapshots__/fastify-swagger.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,61 @@ exports[`transformer > generates types for fastify-swagger correctly 1`] = `
}
`;

exports[`transformer > should generate ref correctly 1`] = `
{
"components": {
"schemas": {
"Token": {
"maxLength": 12,
"minLength": 12,
"type": "string",
},
},
},
"info": {
"description": "Sample backend service",
"title": "SampleApi",
"version": "1.0.0",
},
"openapi": "3.0.3",
"paths": {
"/login": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"access_token": {
"$ref": "#/components/schemas/Token",
},
"refresh_token": {
"$ref": "#/components/schemas/Token",
},
},
"required": [
"access_token",
"refresh_token",
],
"type": "object",
},
},
},
"required": true,
},
"responses": {
"200": {
"description": "Default Response",
},
},
},
},
},
"servers": [],
}
`;

exports[`transformer > should not generate ref 1`] = `
{
"components": {
Expand Down
Loading

0 comments on commit 0aa6156

Please sign in to comment.