Skip to content

Commit 382ee56

Browse files
authored
Refine type (#79)
* Add BaseApiSpec * Return Result * Implement zod agnostic validator type * 必要なbodyを定義していなければエラー * 必要なheadersを定義していなければエラー * initの指定が必要であれば省略不可にする
1 parent 2031124 commit 382ee56

File tree

9 files changed

+251
-125
lines changed

9 files changed

+251
-125
lines changed

examples/express/express.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,40 +27,40 @@ const newApp = () => {
2727

2828
// validate method is available in res.locals
2929
// validate(req).query() is equals to pathMap["/users"]["get"].query.safeParse(req.query)
30-
const r = res.locals.validate(req).query();
31-
if (r.success) {
30+
const { data, error } = res.locals.validate(req).query();
31+
if (data !== undefined) {
3232
// res.status(200).json() accepts only the response schema defined in pathMap["/users"]["get"].res["200"]
33-
res.status(200).json({ userNames: [`page${r.data.page}#user1`] });
33+
res.status(200).json({ userNames: [`page${data.page}#user1`] });
3434
} else {
3535
// res.status(400).json() accepts only the response schema defined in pathMap["/users"]["get"].res["400"]
36-
res.status(400).json({ errorMessage: r.error.toString() });
36+
res.status(400).json({ errorMessage: error.toString() });
3737
}
3838
});
3939
wApp.post("/users", (req, res) => {
4040
// validate(req).body() is equals to pathMap["/users"]["post"].body.safeParse(req.body)
41-
const r = res.locals.validate(req).body();
41+
const { data, error } = res.locals.validate(req).body();
4242
{
4343
// Request header also can be validated
4444
res.locals.validate(req).headers();
4545
}
46-
if (r.success) {
46+
if (data !== undefined) {
4747
// res.status(200).json() accepts only the response schema defined in pathMap["/users"]["post"].res["200"]
48-
res.status(200).json({ userId: r.data.userName + "#0" });
48+
res.status(200).json({ userId: data.userName + "#0" });
4949
} else {
5050
// res.status(400).json() accepts only the response schema defined in pathMap["/users"]["post"].res["400"]
51-
res.status(400).json({ errorMessage: r.error.toString() });
51+
res.status(400).json({ errorMessage: error.toString() });
5252
}
5353
});
5454

5555
const getUserHandler: Handlers["/users/:userId"]["get"] = (req, res) => {
56-
const params = res.locals.validate(req).params();
56+
const { data: params, error } = res.locals.validate(req).params();
5757

58-
if (params.success) {
58+
if (params !== undefined) {
5959
// res.status(200).json() accepts only the response schema defined in pathMap["/users/:userId"]["get"].res["200"]
60-
res.status(200).json({ userName: "user#" + params.data.userId });
60+
res.status(200).json({ userName: "user#" + params.userId });
6161
} else {
6262
// res.status(400).json() accepts only the response schema defined in pathMap["/users/:userId"]["get"].res["400"]
63-
res.status(400).json({ errorMessage: params.error.toString() });
63+
res.status(400).json({ errorMessage: error.toString() });
6464
}
6565
};
6666
wApp.get("/users/:userId", getUserHandler);

src/common/spec.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,24 @@ type AsJsonApiEndpoint<AE extends ApiEndpoint> = {
3636
export type ApiEndpoints = { [Path in string]: ApiEndpoint };
3737
export type AnyApiEndpoints = { [Path in string]: AnyApiEndpoint };
3838

39-
export interface ApiSpec<
39+
export interface BaseApiSpec<
40+
Params,
41+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42+
Query,
43+
Body,
44+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45+
ResBody,
46+
RequestHeaders,
47+
ResponseHeaders,
48+
> {
49+
query?: Query;
50+
params?: Params;
51+
body?: Body;
52+
resBody: ResBody;
53+
headers?: RequestHeaders;
54+
resHeaders?: ResponseHeaders;
55+
}
56+
export type ApiSpec<
4057
ParamKeys extends string = string,
4158
Params extends Record<ParamKeys, string | number> = Record<
4259
ParamKeys,
@@ -52,16 +69,9 @@ export interface ApiSpec<
5269
>,
5370
RequestHeaders extends Record<string, string> = Record<string, string>,
5471
ResponseHeaders extends Record<string, string> = Record<string, string>,
55-
> {
56-
query?: Query;
57-
params?: Params;
58-
body?: Body;
59-
resBody: ResBody;
60-
headers?: RequestHeaders;
61-
resHeaders?: ResponseHeaders;
62-
}
72+
> = BaseApiSpec<Params, Query, Body, ResBody, RequestHeaders, ResponseHeaders>;
6373
// eslint-disable-next-line @typescript-eslint/no-explicit-any
64-
export type AnyApiSpec = ApiSpec<string, any, any, any, any, any, any>;
74+
export type AnyApiSpec = BaseApiSpec<any, any, any, any, any, any>;
6575

6676
type JsonHeader = {
6777
"Content-Type": "application/json";
@@ -89,6 +99,21 @@ export type ApiP<
8999
: never
90100
: never;
91101

102+
export type ApiHasP<
103+
E extends ApiEndpoints,
104+
Path extends keyof E & string,
105+
M extends Method,
106+
> = E[Path] extends ApiEndpoint
107+
? E[Path][M] extends ApiSpec<ParseUrlParams<Path>>
108+
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
109+
E[Path][M]["body"] extends Record<string, any>
110+
? true
111+
: E[Path][M]["headers"] extends Record<string, string>
112+
? true
113+
: false
114+
: never
115+
: never;
116+
92117
export type ApiRes<
93118
AResponses extends ApiResponses,
94119
SC extends keyof AResponses & StatusCode,

src/common/validate.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@ import { Result } from "../utils";
22
import { AnyApiEndpoint, AnyApiEndpoints, isMethod } from "./spec";
33
import { ParsedQs } from "qs";
44

5+
export type Validators<
6+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7+
ParamsValidator extends AnyValidator | never,
8+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9+
QueryValidator extends AnyValidator | never,
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
BodyValidator extends AnyValidator | never,
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
HeadersValidator extends AnyValidator | never,
14+
> = {
15+
// FIXME: FilterNeverにしたい
16+
params: ParamsValidator;
17+
query: QueryValidator;
18+
body: BodyValidator;
19+
headers: HeadersValidator;
20+
};
21+
export type AnyValidators = Validators<
22+
AnyValidator | never,
23+
AnyValidator | never,
24+
AnyValidator | never,
25+
AnyValidator | never
26+
>;
27+
28+
export type Validator<Data, Error> = () => Result<Data, Error>;
29+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
30+
export type AnyValidator = Validator<any, any>;
31+
532
export type ValidatorsInput = {
633
path: string;
734
method: string;

src/express/index.test.ts

Lines changed: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { describe, it, expect, vi } from "vitest";
22
import request from "supertest";
33
import express from "express";
4-
import { asAsync, typed, ValidateLocals, validatorMiddleware } from "./index";
4+
import {
5+
asAsync,
6+
typed,
7+
ZodValidateLocals,
8+
validatorMiddleware,
9+
} from "./index";
510
import { ZodApiEndpoints } from "../zod";
6-
import { z } from "zod";
11+
import { z, ZodError } from "zod";
712
import { Request } from "express";
813
import { ParseUrlParams } from "../common";
914

@@ -55,23 +60,29 @@ describe("validatorMiddleware", () => {
5560
middleware(req as Request, res, next);
5661
expect(next).toHaveBeenCalled();
5762
expect(res.locals.validate).toEqual(expect.any(Function));
58-
const locals = res.locals as ValidateLocals<
63+
const locals = res.locals as ZodValidateLocals<
5964
(typeof pathMap)["/"]["get"],
6065
ParseUrlParams<"/">
6166
>;
6267
const validate = locals.validate(req as Request);
6368

64-
const query = validate.query();
65-
expect(query.success).toBe(true);
66-
expect(query.data!.name).toBe("alice");
69+
{
70+
const r = validate.query();
71+
expect(r.error).toBeUndefined();
72+
expect(r.data?.name).toBe("alice");
73+
}
6774

68-
const body = validate.body();
69-
expect(body.success).toBe(true);
70-
expect(body.data!.name).toBe("alice");
75+
{
76+
const r = validate.body();
77+
expect(r.error).toBeUndefined();
78+
expect(r.data?.name).toBe("alice");
79+
}
7180

72-
const headers = validate.headers();
73-
expect(headers.success).toBe(true);
74-
expect(headers.data!["content-type"]).toBe("application/json");
81+
{
82+
const r = validate.headers();
83+
expect(r.error).toBeUndefined();
84+
expect(r.data?.["content-type"]).toBe("application/json");
85+
}
7586
});
7687

7788
it("should fail if request schema is invalid", () => {
@@ -89,21 +100,57 @@ describe("validatorMiddleware", () => {
89100
middleware(req as Request, res, next);
90101
expect(next).toHaveBeenCalled();
91102
expect(res.locals.validate).toEqual(expect.any(Function));
92-
const locals = res.locals as ValidateLocals<
103+
const locals = res.locals as ZodValidateLocals<
93104
(typeof pathMap)["/"]["get"],
94105
ParseUrlParams<"/">
95106
>;
96107
const validate = locals.validate(req as Request);
97108

98-
console.log("validate", validate);
99-
const query = validate.query();
100-
expect(query.success).toBe(false);
109+
{
110+
const r = validate.query();
111+
expect(r.error).toEqual(
112+
new ZodError([
113+
{
114+
code: "invalid_type",
115+
expected: "string",
116+
received: "undefined",
117+
path: ["name"],
118+
message: "Required",
119+
},
120+
]),
121+
);
122+
expect(r.data).toBeUndefined();
123+
}
101124

102-
const body = validate.body();
103-
expect(body.success).toBe(false);
125+
{
126+
const r = validate.body();
127+
expect(r.error).toEqual(
128+
new ZodError([
129+
{
130+
code: "invalid_type",
131+
expected: "string",
132+
received: "undefined",
133+
path: ["name"],
134+
message: "Required",
135+
},
136+
]),
137+
);
138+
expect(r.data).toBeUndefined();
139+
}
104140

105-
const headers = validate.headers();
106-
expect(headers.success).toBe(false);
141+
const r = validate.headers();
142+
expect(r.error).toEqual(
143+
new ZodError([
144+
{
145+
code: "invalid_literal",
146+
expected: "application/json",
147+
received: undefined,
148+
path: ["content-type"],
149+
message: `Invalid literal value, expected "application/json"`,
150+
},
151+
]),
152+
);
153+
expect(r.data).toBeUndefined();
107154
});
108155
});
109156

@@ -123,10 +170,8 @@ describe("validatorMiddleware", () => {
123170
middleware(req as unknown as Request, res, next);
124171
expect(next).toHaveBeenCalled();
125172
expect(res.locals.validate).toEqual(expect.any(Function));
126-
const locals = res.locals as ValidateLocals<
127-
undefined,
128-
ParseUrlParams<"">
129-
>;
173+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
174+
const locals = res.locals as ZodValidateLocals<any, ParseUrlParams<"">>;
130175
const validate = locals.validate(req as Request);
131176

132177
const query = validate.query;
@@ -157,10 +202,8 @@ describe("validatorMiddleware", () => {
157202
middleware(req as unknown as Request, res, next);
158203
expect(next).toHaveBeenCalled();
159204
expect(res.locals.validate).toEqual(expect.any(Function));
160-
const locals = res.locals as ValidateLocals<
161-
undefined,
162-
ParseUrlParams<"">
163-
>;
205+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
206+
const locals = res.locals as ZodValidateLocals<any, ParseUrlParams<"">>;
164207
const validate = locals.validate(req as Request);
165208

166209
const query = validate.query;
@@ -220,19 +263,19 @@ describe("typed", () => {
220263
return res.json([{ id: "1", name: "alice" }]);
221264
});
222265
wApp.post("/users", (req, res) => {
223-
const body = res.locals.validate(req).body();
224-
if (!body.success) {
266+
const { data } = res.locals.validate(req).body();
267+
if (data === undefined) {
225268
return res.status(400).json({ message: "invalid body" });
226269
}
227-
return res.json({ id: "1", name: body.data.name });
270+
return res.json({ id: "1", name: data.name });
228271
});
229272
wApp.get("/users/:id", (req, res) => {
230273
const qResult = res.locals.validate(req).query();
231274
const pResult = res.locals.validate(req).params();
232-
if (!pResult.success) {
275+
if (pResult.data === undefined) {
233276
return res.status(400).json({ message: "invalid query" });
234277
}
235-
if (qResult.success) {
278+
if (qResult.data !== undefined) {
236279
return res.status(200).json({ id: pResult.data.id, name: "alice" });
237280
}
238281
return res.status(200).json({ id: pResult.data.id, name: "alice" });

src/express/index.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { StatusCode } from "../common";
2020
import { ParseUrlParams } from "../common";
2121
import { ParsedQs } from "qs";
22+
import { AnyValidators } from "../common/validate";
2223

2324
/**
2425
* Express Request Handler, but with more strict type information.
@@ -46,7 +47,9 @@ export type ToHandler<
4647
M extends Method,
4748
> = Handler<
4849
ToApiEndpoints<ZodE>[Path][M],
49-
ValidateLocals<ZodE[Path][M], ParseUrlParams<Path>>
50+
ZodE[Path][M] extends ZodApiSpec
51+
? ZodValidateLocals<ZodE[Path][M], ParseUrlParams<Path>>
52+
: Record<string, never>
5053
>;
5154

5255
/**
@@ -75,16 +78,13 @@ export type ExpressResponse<
7578
) => Response<ApiRes<Responses, SC>, LocalsObj, SC>;
7679
};
7780

78-
export type ValidateLocals<
79-
AS extends ZodApiSpec | undefined,
80-
ParamKeys extends string,
81-
> = {
82-
validate: (
83-
req: Request<ParamsDictionary, unknown, unknown, unknown>,
84-
) => AS extends ZodApiSpec
85-
? ZodValidators<AS, ParamKeys>
86-
: Record<string, never>;
81+
export type ValidateLocals<Vs extends AnyValidators | Record<string, never>> = {
82+
validate: (req: Request<ParamsDictionary, unknown, unknown, unknown>) => Vs;
8783
};
84+
export type ZodValidateLocals<
85+
AS extends ZodApiSpec,
86+
ParamKeys extends string,
87+
> = ValidateLocals<ZodValidators<AS, ParamKeys>>;
8888

8989
/**
9090
* Express Router, but with more strict type information.

0 commit comments

Comments
 (0)