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
3 changes: 2 additions & 1 deletion examples/express/express.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import express from "express";
import { asAsync, ToHandlers, typed } from "../../src/express";
import { asAsync } from "../../src/express";
import { pathMap } from "./spec";
import { ToHandlers, typed } from "../../src/express/zod";

const emptyMiddleware = (
req: express.Request,
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@
"import": "./dist/express/index.mjs",
"types": "./dist/express/index.d.ts"
},
"./express/zod": {
"require": "./dist/express/zod.js",
"import": "./dist/express/zod.mjs",
"types": "./dist/express/zod.d.ts"
},
"./fetch": {
"require": "./dist/fetch/index.js",
"import": "./dist/fetch/index.mjs",
Expand Down
12 changes: 6 additions & 6 deletions src/common/validate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Result } from "../utils";
import { AnyApiEndpoint, AnyApiEndpoints, isMethod } from "./spec";
import { AnyApiEndpoint, AnyApiEndpoints, isMethod, Method } from "./spec";
import { ParsedQs } from "qs";

export type Validators<
Expand All @@ -18,12 +18,12 @@ export type Validators<
body: BodyValidator;
headers: HeadersValidator;
};
export type AnyValidators = Validators<
AnyValidator | undefined,
AnyValidator | undefined,
AnyValidator | undefined,
AnyValidator | undefined
export type AnyValidators = Partial<
Validators<AnyValidator, AnyValidator, AnyValidator, AnyValidator>
>;
export type ValidatorsMap = {
[Path in string]: Partial<Record<Method, AnyValidators>>;
};

export type Validator<Data, Error> = () => Result<Data, Error>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
55 changes: 13 additions & 42 deletions src/express/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
import express from "express";
import { asAsync, ValidateLocals, validatorMiddleware } from "./index";
import {
asAsync,
typed,
ZodValidateLocals,
validatorMiddleware,
ToHandlers,
} from "./index";
import { ZodApiEndpoints } from "../zod";
newZodValidator,
ZodApiEndpoints,
ZodApiSpec,
ZodValidators,
} from "../zod";
import { z, ZodError } from "zod";
import { Request } from "express";
import { ParseUrlParams } from "../common";
import { ToHandlers, typed } from "./zod";

type ZodValidateLocals<
AS extends ZodApiSpec,
ParamKeys extends string,
> = ValidateLocals<ZodValidators<AS, ParamKeys>>;

describe("validatorMiddleware", () => {
const pathMap = {
Expand Down Expand Up @@ -42,7 +47,7 @@ describe("validatorMiddleware", () => {
},
},
} satisfies ZodApiEndpoints;
const middleware = validatorMiddleware(pathMap);
const middleware = validatorMiddleware(newZodValidator(pathMap));
const next = vi.fn();

describe("request to endpoint which is defined in ApiSpec", () => {
Expand Down Expand Up @@ -411,38 +416,4 @@ describe("Handler", () => {
return res.json([{ id: "1", name: body.name }]);
};
});
it("packages/list", async () => {
const pathMap = {
"/users": {
get: {
body: z.object({
state1: z.string(),
}),
query: z.object({
state2: z.union([z.literal("reviewing"), z.literal("accepted")]),
}),
resBody: {
200: z.object({ state1: z.string(), state2: z.string() }),
400: z.object({ message: z.string() }),
},
},
},
} satisfies ZodApiEndpoints;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const getHandler: ToHandlers<typeof pathMap>["/users"]["get"] = (
req,
res,
) => {
const { data: query, error: queryErr } = res.locals.validate(req).query();
if (queryErr) {
return res.status(400).json({ message: "invalid query" });
}
const { data: body, error: bodyError } = res.locals.validate(req).body();
if (bodyError) {
return res.status(400).json({ message: "invalid params" });
}
return res.json({ state1: body.state1, state2: query.state2 });
};
});
});
100 changes: 27 additions & 73 deletions src/express/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import { IRouter, RequestHandler, Router } from "express";
import {
ZodApiEndpoints,
ZodApiSpec,
Method,
ApiResponses,
ApiRes,
ToApiEndpoints,
ApiSpec,
newZodValidator,
} from "../index";
import { ZodValidators } from "../zod";
import { IRouter, RequestHandler } from "express";
import { Method, ApiResponses, ApiRes, ApiSpec, ApiEndpoints } from "../index";
import {
NextFunction,
ParamsDictionary,
Expand All @@ -18,7 +8,11 @@ import {
} from "express-serve-static-core";
import { StatusCode } from "../common";
import { ParsedQs } from "qs";
import { AnyValidators } from "../common/validate";
import {
AnyValidators,
ValidatorsInput,
ValidatorsMap,
} from "../common/validate";

/**
* Express Request Handler, but with more strict type information.
Expand All @@ -37,34 +31,19 @@ export type Handler<
next: NextFunction,
) => void;

/**
* Convert ZodApiSpec to Express Request Handler type.
*/
export type ToHandler<
ZodE extends ZodApiEndpoints,
Path extends keyof ZodE & string,
M extends Method,
Spec extends ApiSpec | undefined,
Validators extends AnyValidators | undefined,
> = Handler<
ToApiEndpoints<ZodE>[Path][M],
ZodE[Path][M] extends ZodApiSpec
? ZodValidateLocals<
ZodE[Path][M],
// FIXME
// ParseUrlParams<Path> extends never ? string : ParseUrlParams<Path>
string
>
: Record<string, never>
Spec,
ValidateLocals<
Validators extends AnyValidators ? Validators : Record<string, never>
>
>;

/**
* Convert ZodApiEndpoints to Express Request Handler type map.
*/
export type ToHandlers<
ZodE extends ZodApiEndpoints,
E extends ToApiEndpoints<ZodE> = ToApiEndpoints<ZodE>,
> = {
export type ToHandlers<E extends ApiEndpoints, V extends ValidatorsMap> = {
[Path in keyof E & string]: {
[M in Method]: ToHandler<ZodE, Path, M>;
[M in Method]: ToHandler<E[Path][M], V[Path][M]>;
};
};

Expand All @@ -85,31 +64,31 @@ export type ExpressResponse<
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.
*/
export type RouterT<
ZodE extends ZodApiEndpoints,
E extends ApiEndpoints,
V extends ValidatorsMap,
SC extends StatusCode = StatusCode,
> = Omit<IRouter, Method> & {
[M in Method]: <Path extends string & keyof ZodE>(
[M in Method]: <Path extends string & keyof E>(
path: Path,
...handlers: [
// Middlewareは複数のエンドポイントで実装を使い回されることがあるので、型チェックはゆるくする
...Array<RequestHandler>,
// Handlerは厳密に型チェックする
ToHandler<ZodE, Path, M>,
ToHandler<E[Path][M], V[Path][M]>,
]
) => RouterT<ZodE, SC>;
) => RouterT<E, V, SC>;
};

export const validatorMiddleware = (pathMap: ZodApiEndpoints) => {
const validator = newZodValidator(pathMap);
export const validatorMiddleware = <
V extends (input: ValidatorsInput) => AnyValidators,
>(
validator: V,
) => {
return (_req: Request, res: Response, next: NextFunction) => {
res.locals.validate = (req: Request) => {
return validator({
Expand All @@ -125,32 +104,6 @@ export const validatorMiddleware = (pathMap: ZodApiEndpoints) => {
};
};

/**
* Set validator and add more strict type information to router.
*
* @param pathMap API endpoints
* @param router Express Router
*
* @example
* ```
* const router = typed(pathMap, express.Router())
* router.get('/path', (req, res) => {
* const r = res.locals.validate(req).query()
* if (!r.success) {
* return res.status(400).json({ message: 'Invalid query' })
* }
* return res.status(200).json({ message: 'success', value: r.data.value })
* })
* ```
*/
export const typed = <const Endpoints extends ZodApiEndpoints>(
pathMap: Endpoints,
router: Router,
): RouterT<Endpoints> => {
router.use(validatorMiddleware(pathMap));
return router;
};

export type AsyncRequestHandler<Handler extends RequestHandler> = (
req: Parameters<NoInfer<Handler>>[0],
res: Parameters<NoInfer<Handler>>[1],
Expand Down Expand Up @@ -194,7 +147,8 @@ export const wrap = <Handler extends RequestHandler>(
* ```
* @param router Express.Router to be wrapped
*/
export const asAsync = <Router extends IRouter | RouterT<never>>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const asAsync = <Router extends IRouter | RouterT<any, any>>(
router: Router,
): Router => {
return new Proxy(router, {
Expand Down
68 changes: 68 additions & 0 deletions src/express/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
newZodValidator,
ToApiEndpoints,
ZodApiEndpoints,
ZodApiSpec,
ZodValidators,
} from "../zod";
import { Method } from "../common";
import {
RouterT,
ToHandler as ToPureHandler,
ToHandlers as ToPureHandlers,
validatorMiddleware,
} from "./index";
import { Router } from "express";

/**
* Convert ZodApiSpec to Express Request Handler type.
*/
export type ToHandler<
ZodE extends ZodApiEndpoints,
Path extends keyof ZodE & string,
M extends Method,
> = ToPureHandler<ToApiEndpoints<ZodE>[Path][M], ToValidators<ZodE[Path][M]>>;

export type ToValidators<Spec extends ZodApiSpec | undefined> =
Spec extends ZodApiSpec ? ZodValidators<Spec, string> : Record<string, never>;

/**
* Convert ZodApiEndpoints to Express Request Handler type map.
*/
export type ToHandlers<
ZodE extends ZodApiEndpoints,
E extends ToApiEndpoints<ZodE> = ToApiEndpoints<ZodE>,
V extends ToValidatorsMap<ZodE> = ToValidatorsMap<ZodE>,
> = ToPureHandlers<E, V>;

export type ToValidatorsMap<ZodE extends ZodApiEndpoints> = {
[Path in keyof ZodE & string]: {
[M in Method]: ToValidators<ZodE[Path][M]>;
};
};

/**
* Set validator and add more strict type information to router.
*
* @param pathMap API endpoints
* @param router Express Router
*
* @example
* ```
* const router = typed(pathMap, express.Router())
* router.get('/path', (req, res) => {
* const r = res.locals.validate(req).query()
* if (!r.success) {
* return res.status(400).json({ message: 'Invalid query' })
* }
* return res.status(200).json({ message: 'success', value: r.data.value })
* })
* ```
*/
export const typed = <const Endpoints extends ZodApiEndpoints>(
pathMap: Endpoints,
router: Router,
): RouterT<ToApiEndpoints<Endpoints>, ToValidatorsMap<Endpoints>> => {
router.use(validatorMiddleware(newZodValidator(pathMap)));
return router;
};
10 changes: 6 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ export {
asAsync as expressAsAsync,
wrap as expressWrap,
AsyncRequestHandler as ExpressAsyncRequestHandler,
typed as expressTyped,
ExpressResponse,
ZodValidateLocals as ExpressValidateLocals,
RouterT as ExpressRouterT,
Handler as ExpressHandler,
ToHandlers as ExpressToHandlers,
ToHandler as ExpressToHandler,
} from "./express";

export {
ToHandlers as ExpressToZodHandlers,
ToHandler as ExpressToZodHandler,
typed as expressZodTyped,
} from "./express/zod";

import FetchT, { RequestInitT } from "./fetch";
export { FetchT, RequestInitT };

Expand Down