From a383067a42083db9ec89bed5c50ad399972b936d Mon Sep 17 00:00:00 2001 From: Swain Molster Date: Wed, 7 Jun 2023 23:57:52 -0400 Subject: [PATCH] feat: support modifications during parsing of inputs --- src/koa-utils.ts | 17 +++++++++- src/router.test.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++-- src/router.ts | 11 ++++-- 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/koa-utils.ts b/src/koa-utils.ts index f9ab76f..ff4c56d 100644 --- a/src/koa-utils.ts +++ b/src/koa-utils.ts @@ -116,7 +116,22 @@ export const implementRoute = < // 1. Validate the input data. // 1a. For GET and DELETE, validate the query params. if (['GET', 'DELETE'].includes(method)) { - ctx.request.query = parse(ctx, ctx.request.query); + // We want to allow data modifications during `parse`. But, Koa + // will not let us re-set the `.query` property entirely. So, we + // have to manually remove each key, then use Object.assign(...) + // to re-add the parsed data. + + // This spread operator is important. Why: + // Some simple `parse` implementations will simply validate the + // ctx.request.query data, then return the same object as the "parsed" + // response. In that scenario, we need to make a copy of the data before + // deleting keys on ctx.request.query, because that would _also_ delete + // the keys on the object returned from parse(...) + const query = { ...parse(ctx, ctx.request.query) }; + for (const key in ctx.request.query) { + delete ctx.request.query[key]; + } + Object.assign(ctx.request.query, query); } else { // 1b. Otherwise, use the body. ctx.request.body = parse(ctx, ctx.request.body); diff --git a/src/router.test.ts b/src/router.test.ts index 1fd9355..64d6ade 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -5,7 +5,7 @@ import { format } from 'prettier'; import Koa = require('koa'); import Router = require('@koa/router'); import bodyparser = require('koa-bodyparser'); -import { OneSchemaRouter } from './router'; +import { NamedClientFor, OneSchemaRouter } from './router'; import { z } from 'zod'; import { generateAxiosClient } from './generate-axios-client'; @@ -16,11 +16,18 @@ afterEach(() => { const setup = >( expose: (router: OneSchemaRouter<{}, Router>) => T, -): { client: AxiosInstance } => { +): { client: AxiosInstance; typed: NamedClientFor } => { const router = expose( OneSchemaRouter.create({ using: new Router(), introspection: undefined }), ); - return serve(router); + + const { client } = serve(router); + + return { + client, + // @ts-expect-error TS is too dumb to know that this is right. + typed: router.client(client), + }; }; const serve = ( @@ -248,6 +255,76 @@ describe('type inference', () => { }), ); }); + + test('type inference when using Zod transforms', async () => { + const clients = setup((router) => + router + .declare({ + name: 'getItems', + route: 'GET /items', + request: z.object({ + // API should enforce a string, but the code should receive a number. + message: z.string().transform((val) => Number(val)), + }), + response: z.object({ + // The code should return a number, and the API should return a string. + message: z.number(), + }), + }) + .implement('GET /items', (ctx) => { + // this statement helps us validate that the message is typed as a Number + ctx.request.query.message.toFixed(); + // This should compile -- it's enforcing a number. + return { message: 1 }; + }) + .declare({ + name: 'createItem', + route: 'POST /items', + request: z.object({ + // API should enforce a string, but the code should receive a number. + message: z.string().transform((val) => Number(val)), + }), + response: z.object({ + // The code should return a number, and the API should return a string. + message: z.number(), + }), + }) + .implement('POST /items', (ctx) => { + // this statement helps us validate that the message is typed as a Number + ctx.request.body.message.toFixed(); + // This should compile -- it's enforcing a number. + return { message: 2 }; + }), + ); + // ---- GET VALIDATIONS ---- + + // Assert that client types enforce the API types. + const getInput: Parameters[0] = {} as any; + // @ts-expect-error This should fail -- the API requires a string + getInput.message = 1; + + // This should compile -- the API requires a string. + const getResult = await clients.typed.getItems({ message: '1' }); + + // Confirm response is runtime correct. + expect(getResult.data).toStrictEqual({ message: 1 }); + // this statement helps us validate that the response is typed correctly, as a number. + getResult.data.message.toFixed(); + + // ---- POST VALIDATIONS ---- + // Assert that client types enforce the API types. + const postInput: Parameters[0] = {} as any; + // @ts-expect-error This should fail -- the API requires a string + postInput.message = 1; + + // This should compile -- the API requires a string. + const postResult = await clients.typed.createItem({ message: '1' }); + + // Confirm response is runtime correct. + expect(postResult.data).toStrictEqual({ message: 2 }); + // this statement helps us validate that the response is typed correctly, as a number. + postResult.data.message.toFixed(); + }); }); describe('input validation', () => { diff --git a/src/router.ts b/src/router.ts index 7170485..13c4580 100644 --- a/src/router.ts +++ b/src/router.ts @@ -33,11 +33,18 @@ export type OneSchemaRouterConfig> = { export type NamedClient = { [Route in keyof Schema as Schema[Route]['name']]: ( - request: z.infer & PathParamsOf, + request: z.input & PathParamsOf, config?: AxiosRequestConfig, ) => Promise>>; }; +export type NamedClientFor = Router extends OneSchemaRouter< + infer Schema, + any +> + ? NamedClient + : never; + export class OneSchemaRouter< Schema extends ZodSchema, R extends Router, @@ -100,7 +107,7 @@ export class OneSchemaRouter< route: Route, implementation: EndpointImplementation< Route, - z.infer, + z.output, z.infer, R >,