Skip to content

Commit 784335f

Browse files
authored
Client validation (#139)
* Implement withValidation * Refine
1 parent 8955cb6 commit 784335f

14 files changed

+508
-49
lines changed

examples/simple/withValidation.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { newZodValidator, ZodApiEndpoints } from "../../src";
2+
import { ValidateError, withValidation } from "../../src/fetch/validation";
3+
import { z, ZodError } from "zod";
4+
5+
const GITHUB_API_ORIGIN = "https://api.github.com";
6+
7+
// See https://docs.github.com/ja/rest/repos/repos?apiVersion=2022-11-28#get-all-repository-topics
8+
const spec = {
9+
"/repos/:owner/:repo/topics": {
10+
get: {
11+
responses: { 200: { body: z.object({ names: z.string().array() }) } },
12+
},
13+
},
14+
} satisfies ZodApiEndpoints;
15+
// type Spec = ToApiEndpoints<typeof spec>;
16+
const spec2 = {
17+
"/repos/:owner/:repo/topics": {
18+
get: {
19+
responses: { 200: { body: z.object({ noexist: z.string() }) } },
20+
},
21+
},
22+
} satisfies ZodApiEndpoints;
23+
24+
const main = async () => {
25+
{
26+
// const fetchT = fetch as FetchT<typeof GITHUB_API_ORIGIN, Spec>;
27+
const { req: reqValidator, res: resValidator } = newZodValidator(spec);
28+
const fetchWithV = withValidation(fetch, spec, reqValidator, resValidator);
29+
const response = await fetchWithV(
30+
`${GITHUB_API_ORIGIN}/repos/mpppk/typed-api-spec/topics?page=1`,
31+
{ headers: { Accept: "application/vnd.github+json" } },
32+
);
33+
if (!response.ok) {
34+
const { message } = await response.json();
35+
return console.error(message);
36+
}
37+
const { names } = await response.json();
38+
console.log(names);
39+
}
40+
41+
{
42+
// const fetchT = fetch as FetchT<typeof GITHUB_API_ORIGIN, Spec>;
43+
const { req: reqValidator, res: resValidator } = newZodValidator(spec2);
44+
const fetchWithV = withValidation(fetch, spec2, reqValidator, resValidator);
45+
try {
46+
await fetchWithV(
47+
`${GITHUB_API_ORIGIN}/repos/mpppk/typed-api-spec/topics?page=1`,
48+
{ headers: { Accept: "application/vnd.github+json" } },
49+
);
50+
} catch (e: unknown) {
51+
if (e instanceof ValidateError) {
52+
console.log("error thrown", (e.error as ZodError).format());
53+
}
54+
}
55+
}
56+
};
57+
58+
main();

package-lock.json

+27-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+7-2
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
"example:express-zod": "tsx examples/express/zod/express.ts",
1515
"example:express-zod-fetch": "tsx examples/express/zod/fetch.ts",
1616
"example:fasitify-zod": "tsx examples/fastify/zod/fastify.ts",
17-
"example:fasitify-zod-fetch": "tsx examples/fastify/zod/fetch.ts"
17+
"example:fasitify-zod-fetch": "tsx examples/fastify/zod/fetch.ts",
18+
"example:withValidation": "tsx examples/simple/withValidation.ts"
1819
},
1920
"author": "mpppk",
2021
"license": "ISC",
2122
"devDependencies": {
23+
"@types/path-to-regexp": "^1.7.0",
2224
"@types/qs": "^6.9.15",
2325
"@types/supertest": "^6.0.2",
2426
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -116,5 +118,8 @@
116118
},
117119
"main": "./dist/index.js",
118120
"module": "./dist/index.mjs",
119-
"types": "./dist/index.d.ts"
121+
"types": "./dist/index.d.ts",
122+
"dependencies": {
123+
"path-to-regexp": "^8.2.0"
124+
}
120125
}

src/core/spec.ts

+11
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ type AsJsonApiEndpoint<AE extends ApiEndpoint> = {
3838
export type ApiEndpoints = { [Path in string]: ApiEndpoint };
3939
export type AnyApiEndpoints = { [Path in string]: AnyApiEndpoint };
4040

41+
export type UnknownApiEndpoints = {
42+
[Path in string]: Partial<Record<Method, UnknownApiSpec>>;
43+
};
44+
4145
export interface BaseApiSpec<
4246
Params,
4347
Query,
@@ -66,6 +70,13 @@ export type ApiSpec<
6670
> = BaseApiSpec<Params, Query, Body, RequestHeaders, Responses>;
6771
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6872
export type AnyApiSpec = BaseApiSpec<any, any, any, any, any>;
73+
export type UnknownApiSpec = BaseApiSpec<
74+
unknown,
75+
unknown,
76+
unknown,
77+
unknown,
78+
DefineApiResponses<DefineResponse<unknown, unknown>>
79+
>;
6980

7081
type JsonHeader = {
7182
"Content-Type": "application/json";

src/core/validate.ts

+78-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Result } from "../utils";
2-
import { AnyApiEndpoint, AnyApiEndpoints, Method } from "./spec";
2+
import { AnyApiEndpoint, AnyApiEndpoints, isMethod, Method } from "./spec";
33
import { ParsedQs } from "qs";
44

55
export type Validators<
@@ -25,18 +25,61 @@ export type ValidatorsMap = {
2525
[Path in string]: Partial<Record<Method, AnyValidators>>;
2626
};
2727

28+
export const runValidators = (validators: AnyValidators, error: unknown) => {
29+
const newD = () => Result.data(undefined);
30+
return {
31+
preCheck: error,
32+
params: validators.params?.() ?? newD(),
33+
query: validators.query?.() ?? newD(),
34+
body: validators.body?.() ?? newD(),
35+
headers: validators.headers?.() ?? newD(),
36+
};
37+
};
38+
39+
export type ResponseValidators<
40+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
41+
BodyValidator extends AnyValidator | undefined,
42+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
43+
HeadersValidator extends AnyValidator | undefined,
44+
> = {
45+
body: BodyValidator;
46+
headers: HeadersValidator;
47+
};
48+
export type AnyResponseValidators = Partial<
49+
ResponseValidators<AnyValidator, AnyValidator>
50+
>;
51+
export const runResponseValidators = (validators: {
52+
validator: AnyResponseValidators;
53+
error: unknown;
54+
}) => {
55+
const newD = () => Result.data(undefined);
56+
return {
57+
// TODO: スキーマが間違っていても、bodyのvalidatorがなぜか定義されていない
58+
preCheck: validators.error,
59+
body: validators.validator.body?.() ?? newD(),
60+
headers: validators.validator.headers?.() ?? newD(),
61+
};
62+
};
63+
2864
export type Validator<Data, Error> = () => Result<Data, Error>;
2965
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3066
export type AnyValidator = Validator<any, any>;
3167

3268
export type ValidatorsInput = {
3369
path: string;
3470
method: string;
35-
params?: Record<string, string>;
71+
params: Record<string, string | string[]>;
3672
query?: ParsedQs;
3773
body?: Record<string, string>;
3874
headers: Record<string, string | string[] | undefined>;
3975
};
76+
export type ResponseValidatorsInput = {
77+
path: string;
78+
method: string;
79+
statusCode: number;
80+
body?: unknown;
81+
headers: Headers;
82+
};
4083

4184
type ValidationError = {
4285
actual: string;
@@ -100,3 +143,36 @@ export const getApiSpec = <
100143
const r = validatePathAndMethod(endpoints, maybePath, maybeMethod);
101144
return Result.map(r, (d) => endpoints[d.path][d.method]);
102145
};
146+
147+
export const preCheck = <E extends AnyApiEndpoints>(
148+
endpoints: E,
149+
path: string,
150+
maybeMethod: string,
151+
) => {
152+
const method = maybeMethod?.toLowerCase();
153+
if (!isMethod(method)) {
154+
return Result.error(newValidatorMethodNotFoundError(method));
155+
}
156+
return getApiSpec(endpoints, path, method);
157+
};
158+
159+
export type ValidatorError =
160+
| ValidatorMethodNotFoundError
161+
| ValidatorPathNotFoundError;
162+
163+
export const newValidatorMethodNotFoundError = (method: string) => ({
164+
target: "method",
165+
actual: method,
166+
message: `method does not exist in endpoint`,
167+
});
168+
type ValidatorMethodNotFoundError = ReturnType<
169+
typeof newValidatorMethodNotFoundError
170+
>;
171+
export const newValidatorPathNotFoundError = (path: string) => ({
172+
target: "path",
173+
actual: path,
174+
message: `path does not exist in endpoints`,
175+
});
176+
type ValidatorPathNotFoundError = ReturnType<
177+
typeof newValidatorPathNotFoundError
178+
>;

src/express/index.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -92,20 +92,24 @@ export type RouterT<
9292
};
9393

9494
export const validatorMiddleware = <
95-
V extends (input: ValidatorsInput) => AnyValidators,
95+
V extends (input: ValidatorsInput) => {
96+
validator: AnyValidators;
97+
error: unknown;
98+
},
9699
>(
97100
validator: V,
98101
) => {
99102
return (_req: Request, res: Response, next: NextFunction) => {
100103
res.locals.validate = (req: Request) => {
101-
return validator({
104+
const { validator: v2 } = validator({
102105
path: req.route?.path?.toString(),
103106
method: req.method,
104107
headers: req.headers,
105108
params: req.params,
106109
query: req.query,
107110
body: req.body,
108111
});
112+
return v2;
109113
};
110114
next();
111115
};

src/express/valibot.test.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ describe("valibot", () => {
5858
},
5959
},
6060
} satisfies ValibotApiEndpoints;
61-
const middleware = validatorMiddleware(newValibotValidator(pathMap));
61+
const { req: reqValidator } = newValibotValidator(pathMap);
62+
const middleware = validatorMiddleware(reqValidator);
6263
const next = vi.fn();
6364

6465
describe("request to endpoint which is defined in ApiSpec", () => {

src/express/valibot.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const typed = <const Endpoints extends ValibotApiEndpoints>(
6565
pathMap: Endpoints,
6666
router: Router,
6767
): RouterT<ToApiEndpoints<Endpoints>, ToValidatorsMap<Endpoints>> => {
68-
router.use(validatorMiddleware(newValibotValidator(pathMap)));
68+
const { req: reqValidator } = newValibotValidator(pathMap);
69+
router.use(validatorMiddleware(reqValidator));
6970
return router;
7071
};

src/express/zod.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ describe("validatorMiddleware", () => {
5151
},
5252
},
5353
} satisfies ZodApiEndpoints;
54-
const middleware = validatorMiddleware(newZodValidator(pathMap));
54+
const { req: reqValidator } = newZodValidator(pathMap);
55+
const middleware = validatorMiddleware(reqValidator);
5556
const next = vi.fn();
5657

5758
describe("request to endpoint which is defined in ApiSpec", () => {
@@ -302,6 +303,7 @@ describe("typed", () => {
302303

303304
{
304305
const res = await request(app).post("/users").send({ name: "alice" });
306+
console.log(res.body);
305307
expect(res.status).toBe(200);
306308
expect(res.body).toEqual({ id: "1", name: "alice" });
307309
}

src/express/zod.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const typed = <const Endpoints extends ZodApiEndpoints>(
6363
pathMap: Endpoints,
6464
router: Router,
6565
): RouterT<ToApiEndpoints<Endpoints>, ToValidatorsMap<Endpoints>> => {
66-
router.use(validatorMiddleware(newZodValidator(pathMap)));
66+
const { req: reqValidator } = newZodValidator(pathMap);
67+
router.use(validatorMiddleware(reqValidator));
6768
return router;
6869
};

0 commit comments

Comments
 (0)