Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions examples/express/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 35 additions & 10 deletions src/common/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,24 @@ type AsJsonApiEndpoint<AE extends ApiEndpoint> = {
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<ParamKeys, string | number> = Record<
ParamKeys,
Expand All @@ -52,16 +69,9 @@ export interface ApiSpec<
>,
RequestHeaders extends Record<string, string> = Record<string, string>,
ResponseHeaders extends Record<string, string> = Record<string, string>,
> {
query?: Query;
params?: Params;
body?: Body;
resBody: ResBody;
headers?: RequestHeaders;
resHeaders?: ResponseHeaders;
}
> = BaseApiSpec<Params, Query, Body, ResBody, RequestHeaders, ResponseHeaders>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyApiSpec = ApiSpec<string, any, any, any, any, any, any>;
export type AnyApiSpec = BaseApiSpec<any, any, any, any, any, any>;

type JsonHeader = {
"Content-Type": "application/json";
Expand Down Expand Up @@ -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<ParseUrlParams<Path>>
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
E[Path][M]["body"] extends Record<string, any>
? true
: E[Path][M]["headers"] extends Record<string, string>
? true
: false
: never
: never;

export type ApiRes<
AResponses extends ApiResponses,
SC extends keyof AResponses & StatusCode,
Expand Down
27 changes: 27 additions & 0 deletions src/common/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data, Error> = () => Result<Data, Error>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyValidator = Validator<any, any>;

export type ValidatorsInput = {
path: string;
method: string;
Expand Down
109 changes: 76 additions & 33 deletions src/express/index.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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", () => {
Expand All @@ -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();
});
});

Expand All @@ -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<any, ParseUrlParams<"">>;
const validate = locals.validate(req as Request);

const query = validate.query;
Expand Down Expand Up @@ -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<any, ParseUrlParams<"">>;
const validate = locals.validate(req as Request);

const query = validate.query;
Expand Down Expand Up @@ -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" });
Expand Down
20 changes: 10 additions & 10 deletions src/express/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -46,7 +47,9 @@ export type ToHandler<
M extends Method,
> = Handler<
ToApiEndpoints<ZodE>[Path][M],
ValidateLocals<ZodE[Path][M], ParseUrlParams<Path>>
ZodE[Path][M] extends ZodApiSpec
? ZodValidateLocals<ZodE[Path][M], ParseUrlParams<Path>>
: Record<string, never>
>;

/**
Expand Down Expand Up @@ -75,16 +78,13 @@ export type ExpressResponse<
) => Response<ApiRes<Responses, SC>, LocalsObj, SC>;
};

export type ValidateLocals<
AS extends ZodApiSpec | undefined,
ParamKeys extends string,
> = {
validate: (
req: Request<ParamsDictionary, unknown, unknown, unknown>,
) => AS extends ZodApiSpec
? ZodValidators<AS, ParamKeys>
: Record<string, never>;
export type ValidateLocals<Vs extends AnyValidators | Record<string, never>> = {
validate: (req: Request<ParamsDictionary, unknown, unknown, unknown>) => Vs;
};
export type ZodValidateLocals<
AS extends ZodApiSpec,
ParamKeys extends string,
> = ValidateLocals<ZodValidators<AS, ParamKeys>>;

/**
* Express Router, but with more strict type information.
Expand Down
Loading