Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
80 changes: 56 additions & 24 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,55 @@ const zodToJsonSchemaOptions = {
$refStrategy: 'none',
} as const;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hasOwnProperty<T, K extends PropertyKey>(obj: T, prop: K): obj is T & Record<K, any> {
return Object.prototype.hasOwnProperty.call(obj, prop);
}

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

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformSchema(responseElement: any) {
const schema = resolveSchema(responseElement);
return zodToJsonSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema as any,
zodToJsonSchemaOptions,
);
}

/**
* Takes a 'content' response element that defines different response schemas for different
* response content types and returns a new version of that content element with the schemas
* converted from zod schemas to json schemas.
*
* @param responseElementContent a 'content' element, nested under a response code, that defines different schemas for
* the different content types that the endpoint can return. For example:
* <pre>
* {
* 'application/json': { schema: some-zod-schema },
* 'text/csv': { schema: some-zod-schema }
* }
* </pre>
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const transformMultipleSchemas: any = (responseElementContent: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content: any = {};
for (const property in responseElementContent) {
content[property] = { ...responseElementContent[property], schema: transformSchema(responseElementContent[property].schema) };
}
return content;
};

export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly string[] }) => {
return ({ schema, url }: { schema: Schema; url: string }) => {
if (!schema) {
Expand Down Expand Up @@ -69,16 +118,14 @@ export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly str
transformed.response = {};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const prop in response as any) {
for (const responseCode in response as any) {
// 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,
);
transformed.response[prop] = transformedResponse;
const responseElement = (response as any)[responseCode];
if (responseElement.content) {
transformed.response[responseCode] = { ...responseElement, content: transformMultipleSchemas(responseElement.content) };
} else {
transformed.response[responseCode] = transformSchema(responseElement);
}
}
}

Expand Down Expand Up @@ -108,21 +155,6 @@ export const validatorCompiler: FastifySchemaCompiler<ZodAny> =
}
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hasOwnProperty<T, K extends PropertyKey>(obj: T, prop: K): obj is T & Record<K, any> {
return Object.prototype.hasOwnProperty.call(obj, prop);
}

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

export class ResponseValidationError extends Error {
public details: FreeformRecord;

Expand Down
98 changes: 98 additions & 0 deletions test/response-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,102 @@ describe('response schema', () => {
expect(response.json()).toMatchSnapshot();
});
});

describe('correctly processes different response schemas', () => {
let app: FastifyInstance;
beforeEach(async () => {
const REPLY_SCHEMA = z.object({
name: z.string(),
});
const TEXT_REPLY_SCHEMA = z.string();

app = Fastify();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);

app.after(() => {
app.withTypeProvider<ZodTypeProvider>().route({
method: 'GET',
url: '/',
schema: {
response: {
200: {
content: {
'application/json': {
schema: REPLY_SCHEMA,
},
'text/plain': {
schema: TEXT_REPLY_SCHEMA,
},
}
},
},
},
handler: (req, res) => {
if(req.headers.accept == 'application/json'){
res.send({
name: 'test',
});
}else{
res.send('test');
}
},
});

app.withTypeProvider<ZodTypeProvider>().route({
method: 'GET',
url: '/incorrect',
schema: {
response: {
200: {
content: {
'application/json': {
schema: REPLY_SCHEMA,
},
'text/plain': {
schema: TEXT_REPLY_SCHEMA,
},
}
},
},
},
handler: (req, res) => {
if(req.headers.accept == 'application/json'){
res.send('test');
}else{
res.send({
name: 'test',
});
}
},
});
});

await app.ready();
});
afterAll(async () => {
await app.close();
});

it('returns 200 for correct response', async () => {
let response = await app.inject({method: 'get', headers: {accept: 'application/json'}, url: '/' });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
name: 'test',
});

response = await app.inject({method: 'get', headers: {accept: 'text/plain'}, url: '/' });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual('test');
});

// FixMe https://github.com/turkerdev/fastify-type-provider-zod/issues/16
it.skip('returns 500 for incorrect response', async () => {
let response = await app.inject({method: 'get', headers: {accept: 'application/json'}, url: '/incorrect' });
expect(response.statusCode).toBe(500);

response = await app.inject({method: 'get', headers: {accept: 'text/plain'}, url: '/incorrect' });
expect(response.statusCode).toBe(500);
});
});
});