DRY up ZodError → FieldError mapping

Five journal API route handlers had the same inline lambda:

    parsed.error.issues.map((i) => ({
      field: i.path.join("."),
      message: i.message,
    }))

Pull it into `zodIssuesToFieldErrors(error)` next to FieldError in
`@trails-cool/api/errors.ts` and re-export it so all five handlers
share the same implementation. The path-flattening rule now lives in
one place, and new routes get it for free.

Public error-response shape is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 11:45:09 +02:00
parent 079c3de90c
commit edd2cabf12
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 23 additions and 8 deletions

View file

@ -5,6 +5,7 @@ import {
PaginationQuerySchema,
CreateActivityRequestSchema,
ERROR_CODES,
zodIssuesToFieldErrors,
} from "@trails-cool/api";
/** GET /api/v1/activities — paginated activity list */
@ -59,7 +60,7 @@ export async function action({ request }: Route.ActionArgs) {
const parsed = CreateActivityRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed",
parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })));
zodIssuesToFieldErrors(parsed.error));
}
const id = await createActivity(user.id, {

View file

@ -1,7 +1,7 @@
import type { Route } from "./+types/api.v1.routes.$id";
import { requireApiUser, apiError } from "~/lib/api-guard.server";
import { getRouteWithVersions, updateRoute, deleteRoute } from "~/lib/routes.server";
import { UpdateRouteRequestSchema, ERROR_CODES } from "@trails-cool/api";
import { UpdateRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api";
/** GET /api/v1/routes/:id — full route detail */
export async function loader({ request, params }: Route.LoaderArgs) {
@ -45,7 +45,7 @@ export async function action({ request, params }: Route.ActionArgs) {
const parsed = UpdateRouteRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed",
parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })));
zodIssuesToFieldErrors(parsed.error));
}
await updateRoute(params.id, user.id, parsed.data);

View file

@ -5,6 +5,7 @@ import {
PaginationQuerySchema,
CreateRouteRequestSchema,
ERROR_CODES,
zodIssuesToFieldErrors,
} from "@trails-cool/api";
/** GET /api/v1/routes — paginated route list */
@ -58,7 +59,7 @@ export async function action({ request }: Route.ActionArgs) {
const parsed = CreateRouteRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed",
parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })));
zodIssuesToFieldErrors(parsed.error));
}
const id = await createRoute(user.id, parsed.data);

View file

@ -1,6 +1,6 @@
import type { Route } from "./+types/api.v1.routes.compute";
import { requireApiUser, apiError } from "~/lib/api-guard.server";
import { ComputeRouteRequestSchema, ERROR_CODES } from "@trails-cool/api";
import { ComputeRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api";
const PLANNER_URL = process.env.PLANNER_URL ?? "http://localhost:3001";
@ -13,7 +13,7 @@ export async function action({ request }: Route.ActionArgs) {
const parsed = ComputeRouteRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed",
parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })));
zodIssuesToFieldErrors(parsed.error));
}
try {

View file

@ -1,6 +1,6 @@
import type { Route } from "./+types/api.v1.uploads";
import { requireApiUser, apiError } from "~/lib/api-guard.server";
import { PresignedUploadRequestSchema, ERROR_CODES } from "@trails-cool/api";
import { PresignedUploadRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api";
import { randomUUID } from "node:crypto";
const S3_ENDPOINT = process.env.S3_ENDPOINT ?? "http://localhost:3902";
@ -16,7 +16,7 @@ export async function action({ request }: Route.ActionArgs) {
const parsed = PresignedUploadRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed",
parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })));
zodIssuesToFieldErrors(parsed.error));
}
const { filename, resourceType, resourceId } = parsed.data;

View file

@ -14,6 +14,18 @@ export const ApiErrorResponseSchema = z.object({
export type FieldError = z.infer<typeof FieldErrorSchema>;
export type ApiErrorResponse = z.infer<typeof ApiErrorResponseSchema>;
/**
* Map a ZodError's issues to the FieldError[] shape used in API
* validation responses. Keeps the path-flattening rule in one place so
* every route returns validation errors in the same shape.
*/
export function zodIssuesToFieldErrors(error: z.ZodError): FieldError[] {
return error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
}));
}
/** Standard error codes */
export const ERROR_CODES = {
VALIDATION_ERROR: "VALIDATION_ERROR",

View file

@ -4,6 +4,7 @@ export { ENDPOINTS } from "./endpoints.ts";
// Error schemas
export {
ApiErrorResponseSchema, FieldErrorSchema, ERROR_CODES,
zodIssuesToFieldErrors,
type ApiErrorResponse, type FieldError,
} from "./errors.ts";