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
58 changes: 58 additions & 0 deletions examples/simple/withValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { newZodValidator, ZodApiEndpoints } from "../../src";
import { ValidateError, withValidation } from "../../src/fetch/validation";
import { z, ZodError } from "zod";

const GITHUB_API_ORIGIN = "https://api.github.com";

// See https://docs.github.com/ja/rest/repos/repos?apiVersion=2022-11-28#get-all-repository-topics
const spec = {
"/repos/:owner/:repo/topics": {
get: {
responses: { 200: { body: z.object({ names: z.string().array() }) } },
},
},
} satisfies ZodApiEndpoints;
// type Spec = ToApiEndpoints<typeof spec>;
const spec2 = {
"/repos/:owner/:repo/topics": {
get: {
responses: { 200: { body: z.object({ noexist: z.string() }) } },
},
},
} satisfies ZodApiEndpoints;

const main = async () => {
{
// const fetchT = fetch as FetchT<typeof GITHUB_API_ORIGIN, Spec>;
const { req: reqValidator, res: resValidator } = newZodValidator(spec);
const fetchWithV = withValidation(fetch, spec, reqValidator, resValidator);
const response = await fetchWithV(
`${GITHUB_API_ORIGIN}/repos/mpppk/typed-api-spec/topics?page=1`,
{ headers: { Accept: "application/vnd.github+json" } },
);
if (!response.ok) {
const { message } = await response.json();
return console.error(message);
}
const { names } = await response.json();
console.log(names);
Comment on lines +33 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid parsing response body twice

The response body is being parsed twice: once for error checking and once for data extraction. This can lead to errors as the body stream can only be consumed once.

-    if (!response.ok) {
-      const { message } = await response.json();
-      return console.error(message);
-    }
-    const { names } = await response.json();
+    const data = await response.json();
+    if (!response.ok) {
+      return console.error(data.message);
+    }
+    const { names } = data;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!response.ok) {
const { message } = await response.json();
return console.error(message);
}
const { names } = await response.json();
console.log(names);
const data = await response.json();
if (!response.ok) {
return console.error(data.message);
}
const { names } = data;
console.log(names);

}

{
// const fetchT = fetch as FetchT<typeof GITHUB_API_ORIGIN, Spec>;
const { req: reqValidator, res: resValidator } = newZodValidator(spec2);
const fetchWithV = withValidation(fetch, spec2, reqValidator, resValidator);
try {
await fetchWithV(
`${GITHUB_API_ORIGIN}/repos/mpppk/typed-api-spec/topics?page=1`,
{ headers: { Accept: "application/vnd.github+json" } },
);
} catch (e: unknown) {
if (e instanceof ValidateError) {
console.log("error thrown", (e.error as ZodError).format());
}
}
Comment on lines +50 to +54
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance error handling

The error handling could be more specific and informative:

  1. Add handling for network errors
  2. Add handling for JSON parsing errors
  3. Log the full error stack in development
     } catch (e: unknown) {
       if (e instanceof ValidateError) {
         console.log("error thrown", (e.error as ZodError).format());
+      } else if (e instanceof TypeError) {
+        console.error("Network error:", e.message);
+      } else {
+        console.error("Unexpected error:", e);
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (e: unknown) {
if (e instanceof ValidateError) {
console.log("error thrown", (e.error as ZodError).format());
}
}
} catch (e: unknown) {
if (e instanceof ValidateError) {
console.log("error thrown", (e.error as ZodError).format());
} else if (e instanceof TypeError) {
console.error("Network error:", e.message);
} else {
console.error("Unexpected error:", e);
}
}

}
};

main();
32 changes: 27 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
"example:express-zod": "tsx examples/express/zod/express.ts",
"example:express-zod-fetch": "tsx examples/express/zod/fetch.ts",
"example:fasitify-zod": "tsx examples/fastify/zod/fastify.ts",
"example:fasitify-zod-fetch": "tsx examples/fastify/zod/fetch.ts"
"example:fasitify-zod-fetch": "tsx examples/fastify/zod/fetch.ts",
"example:withValidation": "tsx examples/simple/withValidation.ts"
},
"author": "mpppk",
"license": "ISC",
"devDependencies": {
"@types/path-to-regexp": "^1.7.0",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

⚠️ Potential issue

Update @types/path-to-regexp to match path-to-regexp version

The type definitions package @types/path-to-regexp@1.7.0 is indeed outdated and doesn't match the runtime dependency path-to-regexp@8.2.0. However, after verification:

  • Latest path-to-regexp version is 8.2.0
  • Latest @types/path-to-regexp version is 1.7.0
  • No newer versions of @types/path-to-regexp are available

Since path-to-regexp v8.x.x now includes built-in TypeScript type definitions, the separate @types package is no longer needed. You should remove the @types/path-to-regexp dependency entirely.

🔗 Analysis chain

Fix version mismatch between @types/path-to-regexp and path-to-regexp

The type definitions version (@types/path-to-regexp@^1.7.0) doesn't match the runtime dependency version (path-to-regexp@^8.2.0). This mismatch could lead to type definition inconsistencies.

Let's verify the latest compatible versions:

Consider updating to matching major versions to ensure type safety.

Also applies to: 123-123

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check latest versions of path-to-regexp and its type definitions
echo "Latest path-to-regexp version:"
npm view path-to-regexp version

echo "\nLatest @types/path-to-regexp version:"
npm view @types/path-to-regexp version

echo "\nVerifying version compatibility..."
npm view @types/path-to-regexp@* version

Length of output: 380

"@types/qs": "^6.9.15",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.0.0",
Expand Down Expand Up @@ -116,5 +118,8 @@
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
"types": "./dist/index.d.ts",
"dependencies": {
"path-to-regexp": "^8.2.0"
}
}
11 changes: 11 additions & 0 deletions src/core/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ type AsJsonApiEndpoint<AE extends ApiEndpoint> = {
export type ApiEndpoints = { [Path in string]: ApiEndpoint };
export type AnyApiEndpoints = { [Path in string]: AnyApiEndpoint };

export type UnknownApiEndpoints = {
[Path in string]: Partial<Record<Method, UnknownApiSpec>>;
};

export interface BaseApiSpec<
Params,
Query,
Expand Down Expand Up @@ -66,6 +70,13 @@ export type ApiSpec<
> = BaseApiSpec<Params, Query, Body, RequestHeaders, Responses>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyApiSpec = BaseApiSpec<any, any, any, any, any>;
export type UnknownApiSpec = BaseApiSpec<
unknown,
unknown,
unknown,
unknown,
DefineApiResponses<DefineResponse<unknown, unknown>>
>;

type JsonHeader = {
"Content-Type": "application/json";
Expand Down
80 changes: 78 additions & 2 deletions src/core/validate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Result } from "../utils";
import { AnyApiEndpoint, AnyApiEndpoints, Method } from "./spec";
import { AnyApiEndpoint, AnyApiEndpoints, isMethod, Method } from "./spec";
import { ParsedQs } from "qs";

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

export const runValidators = (validators: AnyValidators, error: unknown) => {
const newD = () => Result.data(undefined);
return {
preCheck: error,
params: validators.params?.() ?? newD(),
query: validators.query?.() ?? newD(),
body: validators.body?.() ?? newD(),
headers: validators.headers?.() ?? newD(),
};
};

export type ResponseValidators<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
BodyValidator extends AnyValidator | undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
HeadersValidator extends AnyValidator | undefined,
> = {
body: BodyValidator;
headers: HeadersValidator;
};
export type AnyResponseValidators = Partial<
ResponseValidators<AnyValidator, AnyValidator>
>;
export const runResponseValidators = (validators: {
validator: AnyResponseValidators;
error: unknown;
}) => {
const newD = () => Result.data(undefined);
return {
// TODO: スキーマが間違っていても、bodyのvalidatorがなぜか定義されていない
preCheck: validators.error,
body: validators.validator.body?.() ?? newD(),
headers: validators.validator.headers?.() ?? newD(),
};
};

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;
params?: Record<string, string>;
params: Record<string, string | string[]>;
query?: ParsedQs;
body?: Record<string, string>;
headers: Record<string, string | string[] | undefined>;
};
export type ResponseValidatorsInput = {
path: string;
method: string;
statusCode: number;
body?: unknown;
headers: Headers;
};

type ValidationError = {
actual: string;
Expand Down Expand Up @@ -100,3 +143,36 @@ export const getApiSpec = <
const r = validatePathAndMethod(endpoints, maybePath, maybeMethod);
return Result.map(r, (d) => endpoints[d.path][d.method]);
};

export const preCheck = <E extends AnyApiEndpoints>(
endpoints: E,
path: string,
maybeMethod: string,
) => {
const method = maybeMethod?.toLowerCase();
if (!isMethod(method)) {
return Result.error(newValidatorMethodNotFoundError(method));
}
return getApiSpec(endpoints, path, method);
};
Comment on lines +152 to +157
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Handle potential undefined value for method in preCheck.

If maybeMethod is undefined, the method variable becomes undefined, and passing it to isMethod or newValidatorMethodNotFoundError may cause unintended behavior or runtime errors. Ensure that method is properly defined before proceeding with validation.

Consider updating the code to handle undefined values for method:

 const method = maybeMethod?.toLowerCase();
+ if (!method) {
+   return Result.error(newValidatorMethodNotFoundError('undefined'));
+ }
 if (!isMethod(method)) {
   return Result.error(newValidatorMethodNotFoundError(method));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const method = maybeMethod?.toLowerCase();
if (!isMethod(method)) {
return Result.error(newValidatorMethodNotFoundError(method));
}
return getApiSpec(endpoints, path, method);
};
const method = maybeMethod?.toLowerCase();
if (!method) {
return Result.error(newValidatorMethodNotFoundError('undefined'));
}
if (!isMethod(method)) {
return Result.error(newValidatorMethodNotFoundError(method));
}
return getApiSpec(endpoints, path, method);
};


export type ValidatorError =
| ValidatorMethodNotFoundError
| ValidatorPathNotFoundError;

export const newValidatorMethodNotFoundError = (method: string) => ({
target: "method",
actual: method,
message: `method does not exist in endpoint`,
});
type ValidatorMethodNotFoundError = ReturnType<
typeof newValidatorMethodNotFoundError
>;
export const newValidatorPathNotFoundError = (path: string) => ({
target: "path",
actual: path,
message: `path does not exist in endpoints`,
});
type ValidatorPathNotFoundError = ReturnType<
typeof newValidatorPathNotFoundError
>;
8 changes: 6 additions & 2 deletions src/express/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,24 @@ export type RouterT<
};

export const validatorMiddleware = <
V extends (input: ValidatorsInput) => AnyValidators,
V extends (input: ValidatorsInput) => {
validator: AnyValidators;
error: unknown;
},
Comment on lines +95 to +98
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Handle the error returned by the validator function.

The validator function now returns an object containing both validator and error. Currently, the error is not being used or handled, which may lead to unhandled exceptions or missed error reporting.

Consider handling the error appropriately, such as logging it or passing it to the next function to trigger Express's error handling middleware.

>(
validator: V,
) => {
return (_req: Request, res: Response, next: NextFunction) => {
res.locals.validate = (req: Request) => {
return validator({
const { validator: v2 } = validator({
path: req.route?.path?.toString(),
method: req.method,
headers: req.headers,
params: req.params,
query: req.query,
body: req.body,
});
return v2;
};
next();
};
Expand Down
3 changes: 2 additions & 1 deletion src/express/valibot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ describe("valibot", () => {
},
},
} satisfies ValibotApiEndpoints;
const middleware = validatorMiddleware(newValibotValidator(pathMap));
const { req: reqValidator } = newValibotValidator(pathMap);
const middleware = validatorMiddleware(reqValidator);
const next = vi.fn();

describe("request to endpoint which is defined in ApiSpec", () => {
Expand Down
3 changes: 2 additions & 1 deletion src/express/valibot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const typed = <const Endpoints extends ValibotApiEndpoints>(
pathMap: Endpoints,
router: Router,
): RouterT<ToApiEndpoints<Endpoints>, ToValidatorsMap<Endpoints>> => {
router.use(validatorMiddleware(newValibotValidator(pathMap)));
const { req: reqValidator } = newValibotValidator(pathMap);
router.use(validatorMiddleware(reqValidator));
return router;
};
4 changes: 3 additions & 1 deletion src/express/zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ describe("validatorMiddleware", () => {
},
},
} satisfies ZodApiEndpoints;
const middleware = validatorMiddleware(newZodValidator(pathMap));
const { req: reqValidator } = newZodValidator(pathMap);
const middleware = validatorMiddleware(reqValidator);
const next = vi.fn();

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

{
const res = await request(app).post("/users").send({ name: "alice" });
console.log(res.body);
expect(res.status).toBe(200);
expect(res.body).toEqual({ id: "1", name: "alice" });
}
Expand Down
3 changes: 2 additions & 1 deletion src/express/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const typed = <const Endpoints extends ZodApiEndpoints>(
pathMap: Endpoints,
router: Router,
): RouterT<ToApiEndpoints<Endpoints>, ToValidatorsMap<Endpoints>> => {
router.use(validatorMiddleware(newZodValidator(pathMap)));
const { req: reqValidator } = newZodValidator(pathMap);
router.use(validatorMiddleware(reqValidator));
return router;
};
Loading