• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

RobinTail / express-zod-api / 14625934837

23 Apr 2025 06:57PM UTC coverage: 99.975%. Remained the same
14625934837

Pull #2573

github

web-flow
Merge 287b126f3 into 83dfadd5f
Pull Request #2573: Improve handling of `ZodError` by `ensureError`

1203 of 1240 branches covered (97.02%)

1 of 1 new or added line in 1 file covered. (100.0%)

4063 of 4064 relevant lines covered (99.98%)

258.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/express-zod-api/src/common-helpers.ts
1
import type { $ZodType } from "@zod/core";
2
import { Request } from "express";
3
import * as R from "ramda";
4✔
4
import { globalRegistry, z } from "zod";
4✔
5
import { CommonConfig, InputSource, InputSources } from "./config-type";
6
import { contentTypes } from "./content-type";
4✔
7
import { OutputValidationError } from "./errors";
4✔
8
import { metaSymbol } from "./metadata";
4✔
9
import { AuxMethod, Method } from "./method";
10

11
/** @desc this type does not allow props assignment, but it works for reading them when merged with another interface */
12
export type EmptyObject = Record<string, never>;
13
export type EmptySchema = z.ZodObject<EmptyObject>;
14
export type FlatObject = Record<string, unknown>;
15

16
/** @link https://stackoverflow.com/a/65492934 */
17
type NoNever<T, F> = [T] extends [never] ? F : T;
18

19
/**
20
 * @desc Using module augmentation approach you can specify tags as the keys of this interface
21
 * @example declare module "express-zod-api" { interface TagOverrides { users: unknown } }
22
 * @link https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation
23
 * */
24
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation
25
export interface TagOverrides {}
26
export type Tag = NoNever<keyof TagOverrides, string>;
27

28
/** @see https://expressjs.com/en/guide/routing.html */
29
export const routePathParamsRegex = /:([A-Za-z0-9_]+)/g;
4✔
30
export const getRoutePathParams = (path: string): string[] =>
4✔
31
  path.match(routePathParamsRegex)?.map((param) => param.slice(1)) || [];
436✔
32

33
const areFilesAvailable = (request: Request): boolean => {
4✔
34
  const contentType = request.header("content-type") || "";
100!
35
  const isUpload = contentType.toLowerCase().startsWith(contentTypes.upload);
100✔
36
  return "files" in request && isUpload;
100✔
37
};
100✔
38

39
export const defaultInputSources: InputSources = {
4✔
40
  get: ["query", "params"],
4✔
41
  post: ["body", "params", "files"],
4✔
42
  put: ["body", "params"],
4✔
43
  patch: ["body", "params"],
4✔
44
  delete: ["query", "params"],
4✔
45
};
4✔
46
const fallbackInputSource: InputSource[] = ["body", "query", "params"];
4✔
47

48
export const getActualMethod = (request: Request) =>
4✔
49
  request.method.toLowerCase() as Method | AuxMethod;
428✔
50

51
export const getInput = (
4✔
52
  req: Request,
268✔
53
  userDefined: CommonConfig["inputSources"] = {},
268✔
54
): FlatObject => {
268✔
55
  const method = getActualMethod(req);
268✔
56
  if (method === "options") return {};
268✔
57
  return (
256✔
58
    userDefined[method] ||
256✔
59
    defaultInputSources[method] ||
204✔
60
    fallbackInputSource
4✔
61
  )
62
    .filter((src) => (src === "files" ? areFilesAvailable(req) : true))
268✔
63
    .reduce<FlatObject>((agg, src) => Object.assign(agg, req[src]), {});
268✔
64
};
268✔
65

66
export const ensureError = (subject: unknown): Error =>
4✔
67
  subject instanceof Error
248✔
68
    ? subject
164✔
69
    : subject instanceof z.ZodError
84✔
70
      ? new Error(getMessageFromError(subject), { cause: subject })
8✔
71
      : new Error(String(subject));
76✔
72

73
export const getMessageFromError = (error: Error): string => {
4✔
74
  if (error instanceof z.ZodError) {
276✔
75
    return error.issues
80✔
76
      .map(({ path, message }) =>
80✔
77
        (path.length ? [path.join("/")] : []).concat(message).join(": "),
76✔
78
      )
80✔
79
      .join("; ");
80✔
80
  }
80✔
81
  if (error instanceof OutputValidationError) {
276✔
82
    const hasFirstField = error.cause.issues[0]?.path.length > 0;
16✔
83
    return `output${hasFirstField ? "/" : ": "}${error.message}`;
16✔
84
  }
16✔
85
  return error.message;
180✔
86
};
180✔
87

88
/** Takes the original unvalidated examples from the properties of ZodObject schema shape */
89
export const pullExampleProps = <T extends z.ZodObject>(subject: T) =>
4✔
90
  Object.entries(subject.shape).reduce<Partial<z.input<T>>[]>(
752✔
91
    (acc, [key, schema]) => {
752✔
92
      const examples =
1,152✔
93
        (schema as z.ZodType).meta()?.[metaSymbol]?.examples || [];
1,152✔
94
      return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({
1,152✔
95
        ...left,
52✔
96
        ...right,
52✔
97
      }));
1,152✔
98
    },
1,152✔
99
    [],
752✔
100
  );
752✔
101

102
export const getExamples = <
4✔
103
  T extends $ZodType,
104
  V extends "original" | "parsed" | undefined,
105
>({
4,276✔
106
  schema,
4,276✔
107
  variant = "original",
4,276✔
108
  validate = variant === "parsed",
4,276✔
109
  pullProps = false,
4,276✔
110
}: {
4,276✔
111
  schema: T;
112
  /**
113
   * @desc examples variant: original or parsed
114
   * @example "parsed" — for the case when possible schema transformations should be applied
115
   * @default "original"
116
   * @override validate: variant "parsed" activates validation as well
117
   * */
118
  variant?: V;
119
  /**
120
   * @desc filters out the examples that do not match the schema
121
   * @default variant === "parsed"
122
   * */
123
  validate?: boolean;
124
  /**
125
   * @desc should pull examples from properties — applicable to ZodObject only
126
   * @default false
127
   * */
128
  pullProps?: boolean;
129
}): ReadonlyArray<V extends "parsed" ? z.output<T> : z.input<T>> => {
4,276✔
130
  let examples = globalRegistry.get(schema)?.[metaSymbol]?.examples || [];
4,276✔
131
  if (!examples.length && pullProps && schema instanceof z.ZodObject)
4,276✔
132
    examples = pullExampleProps(schema);
4,276✔
133
  if (!validate && variant === "original") return examples;
4,276✔
134
  const result: Array<z.input<T> | z.output<T>> = [];
3,880✔
135
  for (const example of examples) {
4,096✔
136
    const parsedExample = z.safeParse(schema, example);
536✔
137
    if (parsedExample.success)
536✔
138
      result.push(variant === "parsed" ? parsedExample.data : example);
536✔
139
  }
536✔
140
  return result;
3,880✔
141
};
3,880✔
142

143
export const combinations = <T>(
4✔
144
  a: T[],
1,372✔
145
  b: T[],
1,372✔
146
  merge: (pair: [T, T]) => T,
1,372✔
147
): T[] => (a.length && b.length ? R.xprod(a, b).map(merge) : a.concat(b));
1,372✔
148

149
export const ucFirst = (subject: string) =>
4✔
150
  subject.charAt(0).toUpperCase() + subject.slice(1).toLowerCase();
2,376✔
151

152
export const makeCleanId = (...args: string[]) => {
4✔
153
  const byAlpha = R.chain((entry) => entry.split(/[^A-Z0-9]/gi), args);
340✔
154
  const byWord = R.chain(
340✔
155
    (entry) =>
340✔
156
      entry.replaceAll(/[A-Z]+/g, (beginning) => `/${beginning}`).split("/"),
1,812✔
157
    byAlpha,
340✔
158
  );
340✔
159
  return byWord.map(ucFirst).join("");
340✔
160
};
340✔
161

162
export const getTransformedType = R.tryCatch(
4✔
163
  <T>(schema: z.ZodTransform<unknown, T>, sample: T) =>
4✔
164
    typeof schema.parse(sample),
40✔
165
  R.always(undefined),
4✔
166
);
4✔
167

168
/** @desc can still be an array, use Array.isArray() or rather R.type() to exclude that case */
169
export const isObject = (subject: unknown) =>
4✔
170
  typeof subject === "object" && subject !== null;
1,480✔
171

172
export const isProduction = R.memoizeWith(
4✔
173
  () => process.env.TSUP_STATIC as string, // eslint-disable-line no-restricted-syntax -- substituted by TSUP
4✔
174
  () => process.env.NODE_ENV === "production", // eslint-disable-line no-restricted-syntax -- memoized
4✔
175
);
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc