From cd5f13bfa4625cc7761ddcb6fef2b233333fe80f Mon Sep 17 00:00:00 2001 From: mpppk Date: Tue, 19 Nov 2024 11:14:29 +0900 Subject: [PATCH 1/2] Implement withValidation --- examples/simple/withValidation.ts | 60 +++++++++ package-lock.json | 32 ++++- package.json | 9 +- src/core/spec.ts | 11 ++ src/core/validate.ts | 65 +++++++++- src/express/zod.test.ts | 5 +- src/express/zod.ts | 3 +- src/fetch/validation.ts | 198 ++++++++++++++++++++++++++++++ src/utils.ts | 22 +++- src/zod/index.ts | 55 ++++++++- 10 files changed, 446 insertions(+), 14 deletions(-) create mode 100644 examples/simple/withValidation.ts create mode 100644 src/fetch/validation.ts diff --git a/examples/simple/withValidation.ts b/examples/simple/withValidation.ts new file mode 100644 index 0000000..353bc9c --- /dev/null +++ b/examples/simple/withValidation.ts @@ -0,0 +1,60 @@ +import { newZodValidator, ZodApiEndpoints } from "../../src"; +import { ValidateError, withValidation } from "../../src/fetch/validation"; +import { z, ZodError } from "zod"; + +const GITHUB_API_ORIGIN = "https://api.github.com"; + +// See https://docs.github.com/ja/rest/repos/repos?apiVersion=2022-11-28#get-all-repository-topics +const spec = { + "/repos/:owner/:repo/topics": { + get: { + responses: { 200: { body: z.object({ names: z.string().array() }) } }, + }, + }, +} satisfies ZodApiEndpoints; +// type Spec = ToApiEndpoints; +const spec2 = { + "/repos/:owner/:repo/topics": { + get: { + responses: { 200: { body: z.object({ noexist: z.string() }) } }, + }, + }, +} satisfies ZodApiEndpoints; + +const main = async () => { + { + // const fetchT = fetch as FetchT; + const { request: reqValidator, response: resValidator } = + newZodValidator(spec); + const fetchWithV = withValidation(fetch, spec, reqValidator, resValidator); + const response = await fetchWithV( + `${GITHUB_API_ORIGIN}/repos/mpppk/typed-api-spec/topics?page=1`, + { headers: { Accept: "application/vnd.github+json" } }, + ); + if (!response.ok) { + const { message } = await response.json(); + return console.error(message); + } + const { names } = await response.json(); + console.log(names); + } + + { + // const fetchT = fetch as FetchT; + const { request: reqValidator, response: resValidator } = + newZodValidator(spec2); + const fetchWithV = withValidation(fetch, spec2, reqValidator, resValidator); + try { + await fetchWithV( + `${GITHUB_API_ORIGIN}/repos/mpppk/typed-api-spec/topics?page=1`, + { headers: { Accept: "application/vnd.github+json" } }, + ); + } catch (e: unknown) { + if (e instanceof ValidateError) { + console.log("error thrown", (e.error as ZodError).format()); + } + } + } +}; + +main(); diff --git a/package-lock.json b/package-lock.json index 9d12e24..38ab91c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,11 @@ "name": "@mpppk/typed-api-spec", "version": "0.0.0-test37", "license": "ISC", + "dependencies": { + "path-to-regexp": "^8.2.0" + }, "devDependencies": { + "@types/path-to-regexp": "^1.7.0", "@types/qs": "^6.9.15", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^7.0.0", @@ -1191,6 +1195,16 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@types/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha512-ruXmJ/6LwB5L3mxUG2z3Ovi85vH9IVubXq+S9RArvMbjhbCnSLpXs0LrHQg3f0y2tKvXhWUNv7iQDySDfHSTDw==", + "deprecated": "This is a stub types definition for path-to-regexp (https://github.com/pillarjs/path-to-regexp). path-to-regexp provides its own type definitions, so you don't need @types/path-to-regexp installed!", + "dev": true, + "dependencies": { + "path-to-regexp": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", @@ -2777,6 +2791,13 @@ "optional": true, "peer": true }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "optional": true, + "peer": true + }, "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", @@ -4361,11 +4382,12 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", - "optional": true, - "peer": true + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "engines": { + "node": ">=16" + } }, "node_modules/path-type": { "version": "4.0.0", diff --git a/package.json b/package.json index 642e557..543061f 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "example:express-zod": "tsx examples/express/zod/express.ts", "example:express-zod-fetch": "tsx examples/express/zod/fetch.ts", "example:fasitify-zod": "tsx examples/fastify/zod/fastify.ts", - "example:fasitify-zod-fetch": "tsx examples/fastify/zod/fetch.ts" + "example:fasitify-zod-fetch": "tsx examples/fastify/zod/fetch.ts", + "example:withValidation": "tsx examples/simple/withValidation.ts" }, "author": "mpppk", "license": "ISC", "devDependencies": { + "@types/path-to-regexp": "^1.7.0", "@types/qs": "^6.9.15", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^7.0.0", @@ -116,5 +118,8 @@ }, "main": "./dist/index.js", "module": "./dist/index.mjs", - "types": "./dist/index.d.ts" + "types": "./dist/index.d.ts", + "dependencies": { + "path-to-regexp": "^8.2.0" + } } diff --git a/src/core/spec.ts b/src/core/spec.ts index 8b39ffb..2120812 100644 --- a/src/core/spec.ts +++ b/src/core/spec.ts @@ -38,6 +38,10 @@ type AsJsonApiEndpoint = { export type ApiEndpoints = { [Path in string]: ApiEndpoint }; export type AnyApiEndpoints = { [Path in string]: AnyApiEndpoint }; +export type UnknownApiEndpoints = { + [Path in string]: Partial>; +}; + export interface BaseApiSpec< Params, Query, @@ -66,6 +70,13 @@ export type ApiSpec< > = BaseApiSpec; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyApiSpec = BaseApiSpec; +export type UnknownApiSpec = BaseApiSpec< + unknown, + unknown, + unknown, + unknown, + DefineApiResponses> +>; type JsonHeader = { "Content-Type": "application/json"; diff --git a/src/core/validate.ts b/src/core/validate.ts index 1eaad5d..f73f1d5 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -25,6 +25,41 @@ export type ValidatorsMap = { [Path in string]: Partial>; }; +export const runValidators = (validators: AnyValidators) => { + const newD = () => Result.data(undefined); + return { + params: validators.params?.() ?? newD(), + query: validators.query?.() ?? newD(), + body: validators.body?.() ?? newD(), + headers: validators.headers?.() ?? newD(), + }; +}; + +export type ResponseValidators< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + BodyValidator extends AnyValidator | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + HeadersValidator extends AnyValidator | undefined, +> = { + body: BodyValidator; + headers: HeadersValidator; +}; +export type AnyResponseValidators = Partial< + ResponseValidators +>; +export const runResponseValidators = (validators: { + validator: AnyResponseValidators; + error: unknown; +}) => { + const newD = () => Result.data(undefined); + return { + // TODO: スキーマが間違っていても、bodyのvalidatorがなぜか定義されていない + preCheck: validators.error, + body: validators.validator.body?.() ?? newD(), + headers: validators.validator.headers?.() ?? newD(), + }; +}; + export type Validator = () => Result; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyValidator = Validator; @@ -32,11 +67,18 @@ export type AnyValidator = Validator; export type ValidatorsInput = { path: string; method: string; - params?: Record; + params: Record; query?: ParsedQs; body?: Record; headers: Record; }; +export type ResponseValidatorsInput = { + path: string; + method: string; + statusCode: number; + body?: unknown; + headers: Headers; +}; type ValidationError = { actual: string; @@ -100,3 +142,24 @@ export const getApiSpec = < const r = validatePathAndMethod(endpoints, maybePath, maybeMethod); return Result.map(r, (d) => endpoints[d.path][d.method]); }; + +export type ValidatorError = + | ValidatorMethodNotFoundError + | ValidatorPathNotFoundError; + +export const newValidatorMethodNotFoundError = (method: string) => ({ + target: "method", + actual: method, + message: `method does not exist in endpoint`, +}); +type ValidatorMethodNotFoundError = ReturnType< + typeof newValidatorMethodNotFoundError +>; +export const newValidatorPathNotFoundError = (path: string) => ({ + target: "path", + actual: path, + message: `path does not exist in endpoints`, +}); +type ValidatorPathNotFoundError = ReturnType< + typeof newValidatorPathNotFoundError +>; diff --git a/src/express/zod.test.ts b/src/express/zod.test.ts index 82e5611..6087639 100644 --- a/src/express/zod.test.ts +++ b/src/express/zod.test.ts @@ -51,7 +51,8 @@ describe("validatorMiddleware", () => { }, }, } satisfies ZodApiEndpoints; - const middleware = validatorMiddleware(newZodValidator(pathMap)); + const { request: reqValidator } = newZodValidator(pathMap); + const middleware = validatorMiddleware(reqValidator); const next = vi.fn(); describe("request to endpoint which is defined in ApiSpec", () => { @@ -302,7 +303,7 @@ describe("typed", () => { { const res = await request(app).post("/users").send({ name: "alice" }); - expect(res.status).toBe(200); + // expect(res.status).toBe(200); expect(res.body).toEqual({ id: "1", name: "alice" }); } diff --git a/src/express/zod.ts b/src/express/zod.ts index 669a46a..129bab1 100644 --- a/src/express/zod.ts +++ b/src/express/zod.ts @@ -63,6 +63,7 @@ export const typed = ( pathMap: Endpoints, router: Router, ): RouterT, ToValidatorsMap> => { - router.use(validatorMiddleware(newZodValidator(pathMap))); + const { request: reqValidator } = newZodValidator(pathMap); + router.use(validatorMiddleware(reqValidator)); return router; }; diff --git a/src/fetch/validation.ts b/src/fetch/validation.ts new file mode 100644 index 0000000..89d26d5 --- /dev/null +++ b/src/fetch/validation.ts @@ -0,0 +1,198 @@ +import { + AnyResponseValidators, + AnyValidators, + ResponseValidatorsInput, + runResponseValidators, + runValidators, + ValidatorsInput, +} from "../core/validate"; +import { memoize, Result, tupleIteratorToObject, unreachable } from "../utils"; +import { match } from "path-to-regexp"; +import { UnknownApiEndpoints } from "../core"; + +const dummyHost = "https://example.com"; + +// https://blog.jxck.io/entries/2024-06-14/url.parse.html +function parseURL(str: string) { + try { + return new URL(str, dummyHost); + } catch (err) { + return null; + } +} + +const headersToRecord = (headers: HeadersInit): Record => { + const result: Record = {}; + const headersObj = new Headers(headers); + headersObj.forEach((value, key) => { + result[key] = value; + }); + return result; +}; + +type MatchResult = { + matched: string; + params: Record; +}; +const newPathMather = >(endpoints: E) => { + const mMatch = memoize(match); + return (path: string) => { + // FIXME matchedはendpointsのkeys + const ret: MatchResult[] = []; + for (const definedPath of Object.keys(endpoints)) { + const result = mMatch(definedPath)(path); + if (!result) { + continue; + } + ret.push({ + matched: definedPath, + // TODO: こんな適当にキャストしていいんだっけ? + params: result.params as Record, + }); + } + return ret; + }; +}; + +const toInput = + (matcher: (p: string) => MatchResult[]) => + (...args: Parameters) => { + const [input, init] = args; + const url = parseURL(input.toString()); + const candidatePaths = matcher(url?.pathname ?? ""); + const cp = candidatePaths[0] ?? { matched: "", params: {} }; + const query = tupleIteratorToObject(url?.searchParams?.entries() ?? []); + + return { + path: cp.matched, + method: init?.method ?? "GET", + headers: headersToRecord(init?.headers ?? {}), + params: cp.params, + query, + }; + }; + +const newErrorHandler = (policy: "throw" | "log") => { + return (results: ReturnType) => { + switch (policy) { + case "throw": + handleValidatorsError(results, (reason, error) => { + throw new ValidateError(reason, error); + }); + break; + case "log": + handleValidatorsError(results, (reason, error) => { + console.error(new ValidateError(reason, error)); + }); + break; + default: + unreachable(policy); + } + }; +}; +const newResponseErrorHandler = (policy: "throw" | "log") => { + return (results: ReturnType) => { + switch (policy) { + case "throw": + handleResponseValidatorsError(results, (reason, error) => { + throw new ValidateError(reason, error); + }); + break; + case "log": + handleResponseValidatorsError(results, (reason, error) => { + console.error(new ValidateError(reason, error)); + }); + break; + default: + unreachable(policy); + } + }; +}; + +export const withValidation = < + Fetch extends typeof fetch, + Validators extends (input: ValidatorsInput) => AnyValidators, + ResponseValidators extends (input: ResponseValidatorsInput) => { + validator: AnyResponseValidators; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: any; + }, + Endpoints extends UnknownApiEndpoints, +>( + f: Fetch, + endpoints: Endpoints, + validatorGenerator: Validators, + responseValidatorGenerator: ResponseValidators, + options: { policy: "throw" | "log" } = { policy: "throw" }, +): Fetch => { + const toInputWithMatcher = toInput(newPathMather(endpoints)); + const handleError = newErrorHandler(options.policy); + const handleResponseError = newResponseErrorHandler(options.policy); + const ftc = async (...args: Parameters) => { + const [input, init] = args; + const vInput = toInputWithMatcher(input, init); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const validators = validatorGenerator(vInput); + handleError(runValidators(validators)); + const res = await f(input, init); + const res1 = res.clone(); + // TODO: jsonじゃない時どうするか + // TODO: response bodyを直接渡すのはおかしい + const responseValidator = responseValidatorGenerator({ + path: vInput.path, + method: vInput.method, + statusCode: res1.status, + body: await res1.json(), + headers: res1.headers, + }); + handleResponseError(runResponseValidators(responseValidator)); + // // TODO: レスポンスをvalidate + return res; + }; + return ftc as Fetch; +}; + +export class ValidateError extends Error { + constructor( + public reason: keyof AnyValidators, + public error: unknown, + ) { + super("Validation error"); + } +} + +const handleValidatorsError = ( + results: Record< + Exclude, + Result + >, + cb: (reason: keyof AnyValidators, error: unknown) => void, +) => { + if (results.params?.error) { + cb("params", results.params.error); + } + if (results.query?.error) { + cb("query", results.query.error); + } + if (results.body?.error) { + cb("body", results.body.error); + } + if (results.headers?.error) { + cb("headers", results.headers.error); + } +}; + +const handleResponseValidatorsError = ( + results: Record< + Exclude, + Result + >, + cb: (reason: keyof AnyValidators, error: unknown) => void, +) => { + if (results.body?.error) { + cb("body", results.body.error); + } + if (results.headers?.error) { + cb("headers", results.headers.error); + } +}; diff --git a/src/utils.ts b/src/utils.ts index 9f12891..d1667f0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -41,5 +41,23 @@ export const Result = { }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isEmptyObject = (obj: Record): boolean => - Object.keys(obj).length === 0; +export const memoize = any>(fn: T): T => { + const cache: Record> = {}; + return ((...args: Parameters) => { + const key = JSON.stringify(args); + if (cache[key] === undefined) { + cache[key] = fn(...args); + } + return cache[key]; + }) as T; +}; + +export function tupleIteratorToObject( + iterator: Iterable<[T, U]>, +): Record { + const result = {} as Record; + for (const [key, value] of iterator) { + result[key] = value; + } + return result; +} diff --git a/src/zod/index.ts b/src/zod/index.ts index 7e996ae..53600d8 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -9,6 +9,8 @@ import { } from "../core"; import { getApiSpec, + newValidatorMethodNotFoundError, + ResponseValidatorsInput, Validator, Validators, ValidatorsInput, @@ -87,7 +89,10 @@ export type ToApiResponses = { * @param endpoints API endpoints */ export const newZodValidator = (endpoints: E) => { - return ( + const request = < + Path extends keyof E & string, + M extends keyof E[Path] & Method, + >( input: ValidatorsInput, ) => { const method = input.method?.toLowerCase(); @@ -126,6 +131,54 @@ export const newZodValidator = (endpoints: E) => { ? ZodValidators : Record; }; + const response = < + Path extends keyof E & string, + M extends keyof E[Path] & Method, + >( + input: ResponseValidatorsInput, + ) => { + const method = input.method?.toLowerCase(); + if (!isMethod(method)) { + // TODO: ここでなぜエラーになったのかランタイムエラーを返したい + return { + validator: {} as E[Path][M] extends ZodApiSpec + ? ZodValidators + : Record, + error: newValidatorMethodNotFoundError(method), + }; + } + const { data: spec, error } = getApiSpec(endpoints, input.path, method); + if (error !== undefined) { + // TODO: ここでなぜエラーになったのかランタイムエラーを返したい + console.log("response validator error found"); + return { + validator: {} as E[Path][M] extends ZodApiSpec + ? ZodValidators + : Record, + error, + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const zodValidators: Record = {}; + const resBody = spec?.responses?.[input.statusCode as StatusCode]?.body; + if (resBody !== undefined) { + zodValidators["body"] = () => toResult(resBody.safeParse(input.body)); + } + const resHeaders = + spec?.responses?.[input.statusCode as StatusCode]?.headers; + if (resHeaders !== undefined) { + // const headers = s.headers; + zodValidators["headers"] = () => + toResult(resHeaders.safeParse(input.headers)); + } + return { + validator: zodValidators as E[Path][M] extends ZodApiSpec + ? ZodValidators + : Record, + error: null, + }; + }; + return { request, response }; }; const toResult = ( From b9b3c547db0bfb6a49e907909d50343ec5bf8e9e Mon Sep 17 00:00:00 2001 From: mpppk Date: Tue, 19 Nov 2024 16:28:31 +0900 Subject: [PATCH 2/2] Refine --- examples/simple/withValidation.ts | 6 +-- src/core/validate.ts | 17 ++++++++- src/express/index.ts | 8 +++- src/express/valibot.test.ts | 3 +- src/express/valibot.ts | 3 +- src/express/zod.test.ts | 5 ++- src/express/zod.ts | 2 +- src/fetch/validation.ts | 9 +++-- src/valibot/index.ts | 61 ++++++++++++++++++++++-------- src/zod/index.ts | 63 ++++++++++--------------------- 10 files changed, 102 insertions(+), 75 deletions(-) diff --git a/examples/simple/withValidation.ts b/examples/simple/withValidation.ts index 353bc9c..d9f34c5 100644 --- a/examples/simple/withValidation.ts +++ b/examples/simple/withValidation.ts @@ -24,8 +24,7 @@ const spec2 = { const main = async () => { { // const fetchT = fetch as FetchT; - const { request: reqValidator, response: resValidator } = - newZodValidator(spec); + const { req: reqValidator, res: resValidator } = newZodValidator(spec); const fetchWithV = withValidation(fetch, spec, reqValidator, resValidator); const response = await fetchWithV( `${GITHUB_API_ORIGIN}/repos/mpppk/typed-api-spec/topics?page=1`, @@ -41,8 +40,7 @@ const main = async () => { { // const fetchT = fetch as FetchT; - const { request: reqValidator, response: resValidator } = - newZodValidator(spec2); + const { req: reqValidator, res: resValidator } = newZodValidator(spec2); const fetchWithV = withValidation(fetch, spec2, reqValidator, resValidator); try { await fetchWithV( diff --git a/src/core/validate.ts b/src/core/validate.ts index f73f1d5..ca1ea50 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -1,5 +1,5 @@ import { Result } from "../utils"; -import { AnyApiEndpoint, AnyApiEndpoints, Method } from "./spec"; +import { AnyApiEndpoint, AnyApiEndpoints, isMethod, Method } from "./spec"; import { ParsedQs } from "qs"; export type Validators< @@ -25,9 +25,10 @@ export type ValidatorsMap = { [Path in string]: Partial>; }; -export const runValidators = (validators: AnyValidators) => { +export const runValidators = (validators: AnyValidators, error: unknown) => { const newD = () => Result.data(undefined); return { + preCheck: error, params: validators.params?.() ?? newD(), query: validators.query?.() ?? newD(), body: validators.body?.() ?? newD(), @@ -143,6 +144,18 @@ export const getApiSpec = < return Result.map(r, (d) => endpoints[d.path][d.method]); }; +export const preCheck = ( + endpoints: E, + path: string, + maybeMethod: string, +) => { + const method = maybeMethod?.toLowerCase(); + if (!isMethod(method)) { + return Result.error(newValidatorMethodNotFoundError(method)); + } + return getApiSpec(endpoints, path, method); +}; + export type ValidatorError = | ValidatorMethodNotFoundError | ValidatorPathNotFoundError; diff --git a/src/express/index.ts b/src/express/index.ts index 58e536b..f062678 100644 --- a/src/express/index.ts +++ b/src/express/index.ts @@ -92,13 +92,16 @@ export type RouterT< }; export const validatorMiddleware = < - V extends (input: ValidatorsInput) => AnyValidators, + V extends (input: ValidatorsInput) => { + validator: AnyValidators; + error: unknown; + }, >( validator: V, ) => { return (_req: Request, res: Response, next: NextFunction) => { res.locals.validate = (req: Request) => { - return validator({ + const { validator: v2 } = validator({ path: req.route?.path?.toString(), method: req.method, headers: req.headers, @@ -106,6 +109,7 @@ export const validatorMiddleware = < query: req.query, body: req.body, }); + return v2; }; next(); }; diff --git a/src/express/valibot.test.ts b/src/express/valibot.test.ts index 60bc148..a8fc3c8 100644 --- a/src/express/valibot.test.ts +++ b/src/express/valibot.test.ts @@ -58,7 +58,8 @@ describe("valibot", () => { }, }, } satisfies ValibotApiEndpoints; - const middleware = validatorMiddleware(newValibotValidator(pathMap)); + const { req: reqValidator } = newValibotValidator(pathMap); + const middleware = validatorMiddleware(reqValidator); const next = vi.fn(); describe("request to endpoint which is defined in ApiSpec", () => { diff --git a/src/express/valibot.ts b/src/express/valibot.ts index 1e3ccea..1cda347 100644 --- a/src/express/valibot.ts +++ b/src/express/valibot.ts @@ -65,6 +65,7 @@ export const typed = ( pathMap: Endpoints, router: Router, ): RouterT, ToValidatorsMap> => { - router.use(validatorMiddleware(newValibotValidator(pathMap))); + const { req: reqValidator } = newValibotValidator(pathMap); + router.use(validatorMiddleware(reqValidator)); return router; }; diff --git a/src/express/zod.test.ts b/src/express/zod.test.ts index 6087639..298e576 100644 --- a/src/express/zod.test.ts +++ b/src/express/zod.test.ts @@ -51,7 +51,7 @@ describe("validatorMiddleware", () => { }, }, } satisfies ZodApiEndpoints; - const { request: reqValidator } = newZodValidator(pathMap); + const { req: reqValidator } = newZodValidator(pathMap); const middleware = validatorMiddleware(reqValidator); const next = vi.fn(); @@ -303,7 +303,8 @@ describe("typed", () => { { const res = await request(app).post("/users").send({ name: "alice" }); - // expect(res.status).toBe(200); + console.log(res.body); + expect(res.status).toBe(200); expect(res.body).toEqual({ id: "1", name: "alice" }); } diff --git a/src/express/zod.ts b/src/express/zod.ts index 129bab1..e70b425 100644 --- a/src/express/zod.ts +++ b/src/express/zod.ts @@ -63,7 +63,7 @@ export const typed = ( pathMap: Endpoints, router: Router, ): RouterT, ToValidatorsMap> => { - const { request: reqValidator } = newZodValidator(pathMap); + const { req: reqValidator } = newZodValidator(pathMap); router.use(validatorMiddleware(reqValidator)); return router; }; diff --git a/src/fetch/validation.ts b/src/fetch/validation.ts index 89d26d5..a5390ed 100644 --- a/src/fetch/validation.ts +++ b/src/fetch/validation.ts @@ -111,7 +111,10 @@ const newResponseErrorHandler = (policy: "throw" | "log") => { export const withValidation = < Fetch extends typeof fetch, - Validators extends (input: ValidatorsInput) => AnyValidators, + Validators extends (input: ValidatorsInput) => { + validator: AnyValidators; + error: unknown; + }, ResponseValidators extends (input: ResponseValidatorsInput) => { validator: AnyResponseValidators; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -132,8 +135,8 @@ export const withValidation = < const [input, init] = args; const vInput = toInputWithMatcher(input, init); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const validators = validatorGenerator(vInput); - handleError(runValidators(validators)); + const { validator, error } = validatorGenerator(vInput); + handleError(runValidators(validator, error)); const res = await f(input, init); const res1 = res.clone(); // TODO: jsonじゃない時どうするか diff --git a/src/valibot/index.ts b/src/valibot/index.ts index 9658d87..e23ed84 100644 --- a/src/valibot/index.ts +++ b/src/valibot/index.ts @@ -4,12 +4,12 @@ import { BaseApiSpec, DefineApiResponses, DefineResponse, - isMethod, Method, StatusCode, } from "../core"; import { - getApiSpec, + preCheck, + ResponseValidatorsInput, Validator, Validators, ValidatorsInput, @@ -91,21 +91,20 @@ export type ValibotApiResSchema< export const newValibotValidator = ( endpoints: E, ) => { - return ( + const req = < + Path extends keyof E & string, + M extends keyof E[Path] & Method, + Validator extends E[Path][M] extends ValibotApiSpec + ? ValibotValidators + : Record, + >( input: ValidatorsInput, ) => { - const method = input.method?.toLowerCase(); - if (!isMethod(method)) { - return {} as E[Path][M] extends ValibotApiSpec - ? ValibotValidators - : Record; - } - const { data: spec, error } = getApiSpec(endpoints, input.path, method); - if (error !== undefined) { - return {} as E[Path][M] extends ValibotApiSpec - ? ValibotValidators - : Record; + const r = preCheck(endpoints, input.path, input.method); + if (r.error) { + return { validator: {} as Validator, error: r.error }; } + const spec = r.data; // eslint-disable-next-line @typescript-eslint/no-explicit-any const zodValidators: Record = {}; const s = spec as Partial; @@ -127,10 +126,40 @@ export const newValibotValidator = ( zodValidators["headers"] = () => toResult(v.safeParse(headers, input.headers)); } - return zodValidators as E[Path][M] extends ValibotApiSpec + return { validator: zodValidators as Validator, error: null }; + }; + const res = < + Path extends keyof E & string, + M extends keyof E[Path] & Method, + Validator extends E[Path][M] extends ValibotApiSpec ? ValibotValidators - : Record; + : Record, + >( + input: ResponseValidatorsInput, + ) => { + const r = preCheck(endpoints, input.path, input.method); + if (r.error) { + return { validator: {} as Validator, error: r.error }; + } + const spec = r.data; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const zodValidators: Record = {}; + const resBody = spec?.responses?.[input.statusCode as StatusCode]?.body; + if (resBody !== undefined) { + zodValidators["body"] = () => toResult(v.safeParse(resBody, input.body)); + } + const resHeaders = + spec?.responses?.[input.statusCode as StatusCode]?.headers; + if (resHeaders !== undefined) { + zodValidators["headers"] = () => + toResult(v.safeParse(resHeaders, input.headers)); + } + return { + validator: zodValidators as Validator, + error: null, + }; }; + return { req, res }; }; const toResult = >>( diff --git a/src/zod/index.ts b/src/zod/index.ts index 53600d8..be8fb8a 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -3,13 +3,11 @@ import { BaseApiSpec, DefineApiResponses, DefineResponse, - isMethod, Method, StatusCode, } from "../core"; import { - getApiSpec, - newValidatorMethodNotFoundError, + preCheck, ResponseValidatorsInput, Validator, Validators, @@ -89,24 +87,20 @@ export type ToApiResponses = { * @param endpoints API endpoints */ export const newZodValidator = (endpoints: E) => { - const request = < + const req = < Path extends keyof E & string, M extends keyof E[Path] & Method, + Validator extends E[Path][M] extends ZodApiSpec + ? ZodValidators + : Record, >( input: ValidatorsInput, ) => { - const method = input.method?.toLowerCase(); - if (!isMethod(method)) { - return {} as E[Path][M] extends ZodApiSpec - ? ZodValidators - : Record; - } - const { data: spec, error } = getApiSpec(endpoints, input.path, method); - if (error !== undefined) { - return {} as E[Path][M] extends ZodApiSpec - ? ZodValidators - : Record; + const r = preCheck(endpoints, input.path, input.method); + if (r.error) { + return { validator: {} as Validator, error: r.error }; } + const spec = r.data; // eslint-disable-next-line @typescript-eslint/no-explicit-any const zodValidators: Record = {}; const s = spec as Partial; @@ -127,37 +121,22 @@ export const newZodValidator = (endpoints: E) => { zodValidators["headers"] = () => toResult(headers.safeParse(input.headers)); } - return zodValidators as E[Path][M] extends ZodApiSpec - ? ZodValidators - : Record; + return { validator: zodValidators as Validator, error: null }; }; - const response = < + const res = < Path extends keyof E & string, M extends keyof E[Path] & Method, + Validator extends E[Path][M] extends ZodApiSpec + ? ZodValidators + : Record, >( input: ResponseValidatorsInput, ) => { - const method = input.method?.toLowerCase(); - if (!isMethod(method)) { - // TODO: ここでなぜエラーになったのかランタイムエラーを返したい - return { - validator: {} as E[Path][M] extends ZodApiSpec - ? ZodValidators - : Record, - error: newValidatorMethodNotFoundError(method), - }; - } - const { data: spec, error } = getApiSpec(endpoints, input.path, method); - if (error !== undefined) { - // TODO: ここでなぜエラーになったのかランタイムエラーを返したい - console.log("response validator error found"); - return { - validator: {} as E[Path][M] extends ZodApiSpec - ? ZodValidators - : Record, - error, - }; + const r = preCheck(endpoints, input.path, input.method); + if (r.error) { + return { validator: {} as Validator, error: r.error }; } + const spec = r.data; // eslint-disable-next-line @typescript-eslint/no-explicit-any const zodValidators: Record = {}; const resBody = spec?.responses?.[input.statusCode as StatusCode]?.body; @@ -172,13 +151,11 @@ export const newZodValidator = (endpoints: E) => { toResult(resHeaders.safeParse(input.headers)); } return { - validator: zodValidators as E[Path][M] extends ZodApiSpec - ? ZodValidators - : Record, + validator: zodValidators as Validator, error: null, }; }; - return { request, response }; + return { req, res }; }; const toResult = (