diff --git a/examples/express/express.ts b/examples/express/express.ts index 89c6c49..85a1f09 100644 --- a/examples/express/express.ts +++ b/examples/express/express.ts @@ -27,40 +27,40 @@ const newApp = () => { // validate method is available in res.locals // validate(req).query() is equals to pathMap["/users"]["get"].query.safeParse(req.query) - const r = res.locals.validate(req).query(); - if (r.success) { + const { data, error } = res.locals.validate(req).query(); + if (data !== undefined) { // res.status(200).json() accepts only the response schema defined in pathMap["/users"]["get"].res["200"] - res.status(200).json({ userNames: [`page${r.data.page}#user1`] }); + res.status(200).json({ userNames: [`page${data.page}#user1`] }); } else { // res.status(400).json() accepts only the response schema defined in pathMap["/users"]["get"].res["400"] - res.status(400).json({ errorMessage: r.error.toString() }); + res.status(400).json({ errorMessage: error.toString() }); } }); wApp.post("/users", (req, res) => { // validate(req).body() is equals to pathMap["/users"]["post"].body.safeParse(req.body) - const r = res.locals.validate(req).body(); + const { data, error } = res.locals.validate(req).body(); { // Request header also can be validated res.locals.validate(req).headers(); } - if (r.success) { + if (data !== undefined) { // res.status(200).json() accepts only the response schema defined in pathMap["/users"]["post"].res["200"] - res.status(200).json({ userId: r.data.userName + "#0" }); + res.status(200).json({ userId: data.userName + "#0" }); } else { // res.status(400).json() accepts only the response schema defined in pathMap["/users"]["post"].res["400"] - res.status(400).json({ errorMessage: r.error.toString() }); + res.status(400).json({ errorMessage: error.toString() }); } }); const getUserHandler: Handlers["/users/:userId"]["get"] = (req, res) => { - const params = res.locals.validate(req).params(); + const { data: params, error } = res.locals.validate(req).params(); - if (params.success) { + if (params !== undefined) { // res.status(200).json() accepts only the response schema defined in pathMap["/users/:userId"]["get"].res["200"] - res.status(200).json({ userName: "user#" + params.data.userId }); + res.status(200).json({ userName: "user#" + params.userId }); } else { // res.status(400).json() accepts only the response schema defined in pathMap["/users/:userId"]["get"].res["400"] - res.status(400).json({ errorMessage: params.error.toString() }); + res.status(400).json({ errorMessage: error.toString() }); } }; wApp.get("/users/:userId", getUserHandler); diff --git a/src/common/spec.ts b/src/common/spec.ts index aaadc6c..a9ff459 100644 --- a/src/common/spec.ts +++ b/src/common/spec.ts @@ -36,7 +36,24 @@ type AsJsonApiEndpoint = { export type ApiEndpoints = { [Path in string]: ApiEndpoint }; export type AnyApiEndpoints = { [Path in string]: AnyApiEndpoint }; -export interface ApiSpec< +export interface BaseApiSpec< + Params, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Query, + Body, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ResBody, + RequestHeaders, + ResponseHeaders, +> { + query?: Query; + params?: Params; + body?: Body; + resBody: ResBody; + headers?: RequestHeaders; + resHeaders?: ResponseHeaders; +} +export type ApiSpec< ParamKeys extends string = string, Params extends Record = Record< ParamKeys, @@ -52,16 +69,9 @@ export interface ApiSpec< >, RequestHeaders extends Record = Record, ResponseHeaders extends Record = Record, -> { - query?: Query; - params?: Params; - body?: Body; - resBody: ResBody; - headers?: RequestHeaders; - resHeaders?: ResponseHeaders; -} +> = BaseApiSpec; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyApiSpec = ApiSpec; +export type AnyApiSpec = BaseApiSpec; type JsonHeader = { "Content-Type": "application/json"; @@ -89,6 +99,21 @@ export type ApiP< : never : never; +export type ApiHasP< + E extends ApiEndpoints, + Path extends keyof E & string, + M extends Method, +> = E[Path] extends ApiEndpoint + ? E[Path][M] extends ApiSpec> + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + E[Path][M]["body"] extends Record + ? true + : E[Path][M]["headers"] extends Record + ? true + : false + : never + : never; + export type ApiRes< AResponses extends ApiResponses, SC extends keyof AResponses & StatusCode, diff --git a/src/common/validate.ts b/src/common/validate.ts index d3b0422..4ff526b 100644 --- a/src/common/validate.ts +++ b/src/common/validate.ts @@ -2,6 +2,33 @@ import { Result } from "../utils"; import { AnyApiEndpoint, AnyApiEndpoints, isMethod } from "./spec"; import { ParsedQs } from "qs"; +export type Validators< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ParamsValidator extends AnyValidator | never, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryValidator extends AnyValidator | never, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + BodyValidator extends AnyValidator | never, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + HeadersValidator extends AnyValidator | never, +> = { + // FIXME: FilterNeverにしたい + params: ParamsValidator; + query: QueryValidator; + body: BodyValidator; + headers: HeadersValidator; +}; +export type AnyValidators = Validators< + AnyValidator | never, + AnyValidator | never, + AnyValidator | never, + AnyValidator | never +>; + +export type Validator = () => Result; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyValidator = Validator; + export type ValidatorsInput = { path: string; method: string; diff --git a/src/express/index.test.ts b/src/express/index.test.ts index 9f20b38..0669299 100644 --- a/src/express/index.test.ts +++ b/src/express/index.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, vi } from "vitest"; import request from "supertest"; import express from "express"; -import { asAsync, typed, ValidateLocals, validatorMiddleware } from "./index"; +import { + asAsync, + typed, + ZodValidateLocals, + validatorMiddleware, +} from "./index"; import { ZodApiEndpoints } from "../zod"; -import { z } from "zod"; +import { z, ZodError } from "zod"; import { Request } from "express"; import { ParseUrlParams } from "../common"; @@ -55,23 +60,29 @@ describe("validatorMiddleware", () => { middleware(req as Request, res, next); expect(next).toHaveBeenCalled(); expect(res.locals.validate).toEqual(expect.any(Function)); - const locals = res.locals as ValidateLocals< + const locals = res.locals as ZodValidateLocals< (typeof pathMap)["/"]["get"], ParseUrlParams<"/"> >; const validate = locals.validate(req as Request); - const query = validate.query(); - expect(query.success).toBe(true); - expect(query.data!.name).toBe("alice"); + { + const r = validate.query(); + expect(r.error).toBeUndefined(); + expect(r.data?.name).toBe("alice"); + } - const body = validate.body(); - expect(body.success).toBe(true); - expect(body.data!.name).toBe("alice"); + { + const r = validate.body(); + expect(r.error).toBeUndefined(); + expect(r.data?.name).toBe("alice"); + } - const headers = validate.headers(); - expect(headers.success).toBe(true); - expect(headers.data!["content-type"]).toBe("application/json"); + { + const r = validate.headers(); + expect(r.error).toBeUndefined(); + expect(r.data?.["content-type"]).toBe("application/json"); + } }); it("should fail if request schema is invalid", () => { @@ -89,21 +100,57 @@ describe("validatorMiddleware", () => { middleware(req as Request, res, next); expect(next).toHaveBeenCalled(); expect(res.locals.validate).toEqual(expect.any(Function)); - const locals = res.locals as ValidateLocals< + const locals = res.locals as ZodValidateLocals< (typeof pathMap)["/"]["get"], ParseUrlParams<"/"> >; const validate = locals.validate(req as Request); - console.log("validate", validate); - const query = validate.query(); - expect(query.success).toBe(false); + { + const r = validate.query(); + expect(r.error).toEqual( + new ZodError([ + { + code: "invalid_type", + expected: "string", + received: "undefined", + path: ["name"], + message: "Required", + }, + ]), + ); + expect(r.data).toBeUndefined(); + } - const body = validate.body(); - expect(body.success).toBe(false); + { + const r = validate.body(); + expect(r.error).toEqual( + new ZodError([ + { + code: "invalid_type", + expected: "string", + received: "undefined", + path: ["name"], + message: "Required", + }, + ]), + ); + expect(r.data).toBeUndefined(); + } - const headers = validate.headers(); - expect(headers.success).toBe(false); + const r = validate.headers(); + expect(r.error).toEqual( + new ZodError([ + { + code: "invalid_literal", + expected: "application/json", + received: undefined, + path: ["content-type"], + message: `Invalid literal value, expected "application/json"`, + }, + ]), + ); + expect(r.data).toBeUndefined(); }); }); @@ -123,10 +170,8 @@ describe("validatorMiddleware", () => { middleware(req as unknown as Request, res, next); expect(next).toHaveBeenCalled(); expect(res.locals.validate).toEqual(expect.any(Function)); - const locals = res.locals as ValidateLocals< - undefined, - ParseUrlParams<""> - >; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const locals = res.locals as ZodValidateLocals>; const validate = locals.validate(req as Request); const query = validate.query; @@ -157,10 +202,8 @@ describe("validatorMiddleware", () => { middleware(req as unknown as Request, res, next); expect(next).toHaveBeenCalled(); expect(res.locals.validate).toEqual(expect.any(Function)); - const locals = res.locals as ValidateLocals< - undefined, - ParseUrlParams<""> - >; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const locals = res.locals as ZodValidateLocals>; const validate = locals.validate(req as Request); const query = validate.query; @@ -220,19 +263,19 @@ describe("typed", () => { return res.json([{ id: "1", name: "alice" }]); }); wApp.post("/users", (req, res) => { - const body = res.locals.validate(req).body(); - if (!body.success) { + const { data } = res.locals.validate(req).body(); + if (data === undefined) { return res.status(400).json({ message: "invalid body" }); } - return res.json({ id: "1", name: body.data.name }); + return res.json({ id: "1", name: data.name }); }); wApp.get("/users/:id", (req, res) => { const qResult = res.locals.validate(req).query(); const pResult = res.locals.validate(req).params(); - if (!pResult.success) { + if (pResult.data === undefined) { return res.status(400).json({ message: "invalid query" }); } - if (qResult.success) { + if (qResult.data !== undefined) { return res.status(200).json({ id: pResult.data.id, name: "alice" }); } return res.status(200).json({ id: pResult.data.id, name: "alice" }); diff --git a/src/express/index.ts b/src/express/index.ts index 2da776a..e528d2f 100644 --- a/src/express/index.ts +++ b/src/express/index.ts @@ -19,6 +19,7 @@ import { import { StatusCode } from "../common"; import { ParseUrlParams } from "../common"; import { ParsedQs } from "qs"; +import { AnyValidators } from "../common/validate"; /** * Express Request Handler, but with more strict type information. @@ -46,7 +47,9 @@ export type ToHandler< M extends Method, > = Handler< ToApiEndpoints[Path][M], - ValidateLocals> + ZodE[Path][M] extends ZodApiSpec + ? ZodValidateLocals> + : Record >; /** @@ -75,16 +78,13 @@ export type ExpressResponse< ) => Response, LocalsObj, SC>; }; -export type ValidateLocals< - AS extends ZodApiSpec | undefined, - ParamKeys extends string, -> = { - validate: ( - req: Request, - ) => AS extends ZodApiSpec - ? ZodValidators - : Record; +export type ValidateLocals> = { + validate: (req: Request) => Vs; }; +export type ZodValidateLocals< + AS extends ZodApiSpec, + ParamKeys extends string, +> = ValidateLocals>; /** * Express Router, but with more strict type information. diff --git a/src/fetch/index.t-test.ts b/src/fetch/index.t-test.ts index 941c544..b96d2cf 100644 --- a/src/fetch/index.t-test.ts +++ b/src/fetch/index.t-test.ts @@ -2,6 +2,26 @@ import { AsJsonApi, DefineApiEndpoints } from "../common"; import FetchT from "./index"; import JSONT from "../json"; +{ + type Spec = DefineApiEndpoints<{ + "/users": { + get: { + resBody: { + 200: { prop: string }; + }; + }; + }; + }>; + (async () => { + const f = fetch as FetchT<"", Spec>; + { + // TODO: 今はinitを省略する場合undefinedを明示的に渡す必要があるが、なんとかしたい + // methodを省略した場合はgetとして扱う + const res = await f("/users", undefined); + (await res.json()).prop; + } + })(); +} { type Spec = DefineApiEndpoints<{ "/users": { @@ -42,7 +62,9 @@ import JSONT from "../json"; { // methodを省略した場合はgetとして扱う - const res = await f("/users"); + const res = await f("/users", { + headers: { "Content-Type": "application/json" }, + }); (await res.json()).prop; } @@ -63,12 +85,7 @@ import JSONT from "../json"; { // AsJsonApiを利用していない場合、Content-Typeがapplication/jsonでなくてもエラーにならない - await f2("/users", { headers: undefined }); - } - - { - // TODO: headersを定義している場合でもRequestInitが省略できてしまう - await f("/users"); + await f2("/users", {}); } { @@ -85,6 +102,7 @@ import JSONT from "../json"; // TODO: 余剰プロパティチェックを今は受け付けてしまうがなんとかしたい unknownProp: "a", }), + headers: { "Content-Type": "application/json" }, }); if (res.ok) { (await res.json()).postProp; @@ -93,16 +111,6 @@ import JSONT from "../json"; } } - { - // TODO: bodyを省略できてしまうがなんとかしたい - const res = await f("/users", { method: "post" }); - if (res.ok) { - (await res.json()).postProp; - } else { - (await res.json()).error; - } - } - { // TODO: 今は定義していないメソッドを受け付けてしまうが、いつかなんとかしたい await f("/users", { method: "patch" }); diff --git a/src/fetch/index.ts b/src/fetch/index.ts index bd47d82..020c2ed 100644 --- a/src/fetch/index.ts +++ b/src/fetch/index.ts @@ -1,7 +1,9 @@ import { ApiEndpoints, + ApiHasP, ApiP, CaseInsensitiveMethod, + FilterNever, MatchedPatterns, MergeApiResponses, Method, @@ -13,19 +15,20 @@ import { import { UrlPrefixPattern, ToUrlParamPattern } from "../common"; import { TypedString } from "../json"; -export interface RequestInitT< +export type RequestInitT< InputMethod extends CaseInsensitiveMethod, // eslint-disable-next-line @typescript-eslint/no-explicit-any - Body extends Record | undefined, + Body extends Record | never, HeadersObj extends Record | undefined, -> extends RequestInit { +> = Omit & { method?: InputMethod; - body?: TypedString; - // FIXME: no optional - headers?: HeadersObj extends Record - ? HeadersObj | Headers - : never; -} +} & FilterNever<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: Body extends Record ? TypedString : never; + headers: HeadersObj extends Record + ? HeadersObj | Headers + : never; + }>; /** * FetchT is a type for window.fetch like function but more strict type information @@ -44,11 +47,19 @@ type FetchT = < M extends Method = Lowercase, >( input: Input, - init?: RequestInitT< - InputMethod, - ApiP, - ApiP - >, + init: ApiHasP extends true + ? RequestInitT< + InputMethod, + ApiP, + ApiP + > + : + | RequestInitT< + InputMethod, + ApiP, + ApiP + > + | undefined, ) => Promise>>; export default FetchT; diff --git a/src/index.ts b/src/index.ts index 80a3c08..066c5ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ export { AsyncRequestHandler as ExpressAsyncRequestHandler, typed as expressTyped, ExpressResponse, - ValidateLocals as ExpressValidateLocals, + ZodValidateLocals as ExpressValidateLocals, RouterT as ExpressRouterT, Handler as ExpressHandler, ToHandlers as ExpressToHandlers, diff --git a/src/zod/index.ts b/src/zod/index.ts index 7b40248..2aa6ee1 100644 --- a/src/zod/index.ts +++ b/src/zod/index.ts @@ -1,26 +1,34 @@ -import { z, ZodType } from "zod"; -import { Method, StatusCode } from "../common"; -import { FilterNever } from "../common"; -import { getApiSpec, ValidatorsInput } from "../common/validate"; +import { SafeParseReturnType, z, ZodError, ZodType } from "zod"; +import { BaseApiSpec, Method, StatusCode } from "../common"; +import { + getApiSpec, + Validator, + Validators, + ValidatorsInput, +} from "../common/validate"; +import { Result } from "../utils"; export const anyZ = () => z.any() as ZodType; -type SafeParse = ReturnType; export type ZodValidator = - V extends z.ZodTypeAny ? () => ReturnType : never; + V extends z.ZodTypeAny + ? Validator< + NonNullable["data"]>, + NonNullable["error"]> + > + : never; export type ZodValidators< AS extends ZodApiSpec, ParamKeys extends string, -> = FilterNever<{ - params: ParamKeys extends never +> = Validators< + ParamKeys extends never ? never : AS["params"] extends z.ZodTypeAny - ? () => SafeParse - : () => SafeParse>>; - query: ZodValidator; - body: ZodValidator; - headers: ZodValidator; - // resHeaders: ZodValidator; -}>; + ? ZodValidator + : ZodValidator>>, + ZodValidator, + ZodValidator, + ZodValidator +>; type ZodTypeWithKey = z.ZodType< // eslint-disable-next-line @typescript-eslint/no-explicit-any Record, @@ -34,7 +42,7 @@ export type InferOrUndefined = T extends z.ZodTypeAny // -- spec -- export type ZodApiEndpoints = { [Path in string]: ZodApiEndpoint }; type ZodApiEndpoint = Partial>; -export interface ZodApiSpec< +export type ZodApiSpec< ParamKeys extends string = string, Params extends ZodTypeWithKey> = ZodTypeWithKey< NoInfer @@ -44,14 +52,7 @@ export interface ZodApiSpec< ResBody extends ZodApiResponses = Partial>, RequestHeaders extends z.ZodTypeAny = z.ZodTypeAny, ResponseHeaders extends z.ZodTypeAny = z.ZodTypeAny, -> { - query?: Query; - params?: Params; - body?: Body; - resBody: ResBody; - headers?: RequestHeaders; - resHeaders?: ResponseHeaders; -} +> = BaseApiSpec; export type ZodApiResponses = Partial>; export type ZodApiResSchema< AResponses extends ZodApiResponses, @@ -103,22 +104,33 @@ export const newZodValidator = (endpoints: E) => { const s = spec as Partial; if (s.params !== undefined) { const params = s.params; - zodValidators["params"] = () => params.safeParse(input.params); + zodValidators["params"] = () => toResult(params.safeParse(input.params)); } if (s.query !== undefined) { const query = s.query; - zodValidators["query"] = () => query.safeParse(input.query); + zodValidators["query"] = () => toResult(query.safeParse(input.query)); } if (s.body !== undefined) { const body = s.body; - zodValidators["body"] = () => body.safeParse(input.body); + zodValidators["body"] = () => toResult(body.safeParse(input.body)); } if (s.headers !== undefined) { const headers = s.headers; - zodValidators["headers"] = () => headers.safeParse(input.headers); + zodValidators["headers"] = () => + toResult(headers.safeParse(input.headers)); } return zodValidators as E[Path][M] extends ZodApiSpec ? ZodValidators : Record; }; }; + +const toResult = ( + res: SafeParseReturnType, +): Result> => { + if (res.success) { + return Result.data(res.data); + } else { + return Result.error(res.error); + } +};