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

RobinTail / express-zod-api / 7661667876

25 Jan 2024 11:17PM CUT coverage: 100.0%. Remained the same
7661667876

Pull #1505

github

web-flow
Merge f8ab02efa into 79a631c16
Pull Request #1505: Bump @typescript-eslint/parser from 6.19.0 to 6.19.1

678 of 715 branches covered (0.0%)

1155 of 1155 relevant lines covered (100.0%)

502.58 hits per line

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

100.0
/src/documentation-helpers.ts
1
import assert from "node:assert/strict";
2
import {
3
  ContentObject,
4
  ExampleObject,
5
  ExamplesObject,
6
  OAuthFlowObject,
7
  OAuthFlowsObject,
8
  ParameterObject,
9
  ReferenceObject,
10
  RequestBodyObject,
11
  ResponseObject,
12
  SchemaObject,
13
  SchemaObjectType,
14
  SecurityRequirementObject,
15
  SecuritySchemeObject,
16
  TagObject,
17
  isReferenceObject,
18
} from "openapi3-ts/oas31";
19
import {
20
  concat,
21
  fromPairs,
22
  map,
23
  mergeDeepRight,
24
  mergeDeepWith,
25
  omit,
26
  union,
27
  xprod,
28
} from "ramda";
29
import { z } from "zod";
30
import {
31
  FlatObject,
32
  combinations,
33
  getExamples,
34
  hasCoercion,
35
  isCustomHeader,
36
  makeCleanId,
37
  tryToTransform,
38
  ucFirst,
39
} from "./common-helpers";
40
import { InputSource, TagsConfig } from "./config-type";
41
import { ezDateInKind } from "./date-in-schema";
42
import { ezDateOutKind } from "./date-out-schema";
43
import { DocumentationError } from "./errors";
44
import { ezFileKind } from "./file-schema";
45
import { IOSchema } from "./io-schema";
46
import {
47
  LogicalContainer,
48
  andToOr,
49
  mapLogicalContainer,
50
} from "./logical-container";
51
import { Method } from "./method";
52
import { RawSchema, ezRawKind } from "./raw-schema";
53
import { isoDateRegex } from "./schema-helpers";
54
import {
55
  HandlingRules,
56
  HandlingVariant,
57
  SchemaHandler,
58
  walkSchema,
59
} from "./schema-walker";
60
import { Security } from "./security";
61
import { ezUploadKind } from "./upload-schema";
62

63
/* eslint-disable @typescript-eslint/no-use-before-define */
64

65
export interface OpenAPIContext extends FlatObject {
66
  isResponse: boolean;
67
  serializer: (schema: z.ZodTypeAny) => string;
68
  getRef: (name: string) => ReferenceObject | undefined;
69
  makeRef: (
70
    name: string,
71
    schema: SchemaObject | ReferenceObject,
72
  ) => ReferenceObject;
73
  path: string;
74
  method: Method;
75
}
76

77
type Depicter<
78
  T extends z.ZodTypeAny,
79
  Variant extends HandlingVariant = "regular",
80
> = SchemaHandler<T, SchemaObject | ReferenceObject, OpenAPIContext, Variant>;
81

82
interface ReqResDepictHelperCommonProps
83
  extends Pick<
84
    OpenAPIContext,
85
    "serializer" | "getRef" | "makeRef" | "path" | "method"
86
  > {
87
  schema: z.ZodTypeAny;
88
  mimeTypes: string[];
89
  composition: "inline" | "components";
90
  description?: string;
91
}
92

93
const shortDescriptionLimit = 50;
150✔
94
const isoDateDocumentationUrl =
95
  "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString";
150✔
96

97
const samples = {
150✔
98
  integer: 0,
99
  number: 0,
100
  string: "",
101
  boolean: false,
102
  object: {},
103
  null: null,
104
  array: [],
105
} satisfies Record<Extract<SchemaObjectType, string>, unknown>;
106

107
/** @see https://expressjs.com/en/guide/routing.html */
108
const routePathParamsRegex = /:([A-Za-z0-9_]+)/g;
150✔
109

110
export const getRoutePathParams = (path: string): string[] => {
150✔
111
  const match = path.match(routePathParamsRegex);
708✔
112
  if (!match) {
708✔
113
    return [];
582✔
114
  }
115
  return match.map((param) => param.slice(1));
156✔
116
};
117

118
export const reformatParamsInPath = (path: string) =>
150✔
119
  path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`);
372✔
120

121
export const depictDefault: Depicter<z.ZodDefault<z.ZodTypeAny>> = ({
150✔
122
  schema: {
123
    _def: { innerType, defaultValue },
124
  },
125
  next,
126
}) => ({
18✔
127
  ...next({ schema: innerType }),
128
  default: defaultValue(),
129
});
130

131
export const depictCatch: Depicter<z.ZodCatch<z.ZodTypeAny>> = ({
150✔
132
  schema: {
133
    _def: { innerType },
134
  },
135
  next,
136
}) => next({ schema: innerType });
6✔
137

138
export const depictAny: Depicter<z.ZodAny> = () => ({
150✔
139
  format: "any",
140
});
141

142
export const depictUpload: Depicter<z.ZodType> = (ctx) => {
150✔
143
  assert(
24✔
144
    !ctx.isResponse,
145
    new DocumentationError({
146
      message: "Please use ez.upload() only for input.",
147
      ...ctx,
148
    }),
149
  );
150
  return {
18✔
151
    type: "string",
152
    format: "binary",
153
  };
154
};
155

156
export const depictFile: Depicter<z.ZodType> = ({ schema }) => ({
150✔
157
  type: "string",
158
  format:
159
    schema instanceof z.ZodString
54✔
160
      ? schema._def.checks.find((check) => check.kind === "regex")
6✔
161
        ? "byte"
162
        : "file"
163
      : "binary",
164
});
165

166
export const depictUnion: Depicter<z.ZodUnion<z.ZodUnionOptions>> = ({
150✔
167
  schema: { options },
168
  next,
169
}) => ({
78✔
170
  oneOf: options.map((option) => next({ schema: option })),
156✔
171
});
172

173
export const depictDiscriminatedUnion: Depicter<
174
  z.ZodDiscriminatedUnion<string, z.ZodDiscriminatedUnionOption<string>[]>
175
> = ({ schema: { options, discriminator }, next }) => {
150✔
176
  return {
18✔
177
    discriminator: { propertyName: discriminator },
178
    oneOf: Array.from(options.values()).map((option) =>
179
      next({ schema: option }),
36✔
180
    ),
181
  };
182
};
183

184
/** @throws AssertionError */
185
const tryFlattenIntersection = (
150✔
186
  children: Array<SchemaObject | ReferenceObject>,
187
) => {
188
  const [left, right] = children.filter(
96✔
189
    (entry): entry is SchemaObject =>
190
      !isReferenceObject(entry) &&
192✔
191
      entry.type === "object" &&
192
      Object.keys(entry).every((key) =>
193
        ["type", "properties", "required", "examples"].includes(key),
552✔
194
      ),
195
  );
196
  assert(left && right, "Can not flatten objects");
96✔
197
  const flat: SchemaObject = { type: "object" };
78✔
198
  if (left.properties || right.properties) {
78✔
199
    flat.properties = mergeDeepWith(
66✔
200
      (a, b) =>
201
        Array.isArray(a) && Array.isArray(b)
18✔
202
          ? concat(a, b)
203
          : a === b
12!
204
            ? b
205
            : assert.fail("Can not flatten properties"),
206
      left.properties || {},
66!
207
      right.properties || {},
66!
208
    );
209
  }
210
  if (left.required || right.required) {
78✔
211
    flat.required = union(left.required || [], right.required || []);
66!
212
  }
213
  if (left.examples || right.examples) {
78✔
214
    flat.examples = combinations(
36✔
215
      left.examples || [],
36!
216
      right.examples || [],
36!
217
      ([a, b]) => mergeDeepRight(a, b),
36✔
218
    );
219
  }
220
  return flat;
78✔
221
};
222

223
export const depictIntersection: Depicter<
224
  z.ZodIntersection<z.ZodTypeAny, z.ZodTypeAny>
225
> = ({
150✔
226
  schema: {
227
    _def: { left, right },
228
  },
229
  next,
230
}) => {
231
  const children = [left, right].map((entry) => next({ schema: entry }));
192✔
232
  try {
96✔
233
    return tryFlattenIntersection(children);
96✔
234
  } catch {}
235
  return { allOf: children };
18✔
236
};
237

238
export const depictOptional: Depicter<z.ZodOptional<z.ZodTypeAny>> = ({
150✔
239
  schema,
240
  next,
241
}) => next({ schema: schema.unwrap() });
66✔
242

243
export const depictReadonly: Depicter<z.ZodReadonly<z.ZodTypeAny>> = ({
150✔
244
  schema,
245
  next,
246
}) => next({ schema: schema._def.innerType });
12✔
247

248
/** @since OAS 3.1 nullable replaced with type array having null */
249
export const depictNullable: Depicter<z.ZodNullable<z.ZodTypeAny>> = ({
150✔
250
  schema,
251
  next,
252
}) => {
253
  const nested = next({ schema: schema.unwrap() });
60✔
254
  if (!isReferenceObject(nested)) {
60!
255
    nested.type = makeNullableType(nested);
60✔
256
  }
257
  return nested;
60✔
258
};
259

260
export const depictEnum: Depicter<
261
  z.ZodEnum<[string, ...string[]]> | z.ZodNativeEnum<any> // keeping "any" for ZodNativeEnum as compatibility fix
262
> = ({ schema }) => ({
150✔
263
  type: typeof Object.values(schema.enum)[0] as "string" | "number",
264
  enum: Object.values(schema.enum),
265
});
266

267
export const depictLiteral: Depicter<z.ZodLiteral<unknown>> = ({
150✔
268
  schema: { value },
269
}) => ({
906✔
270
  type: typeof value as "string" | "number" | "boolean",
271
  enum: [value],
272
});
273

274
export const depictObject: Depicter<z.ZodObject<z.ZodRawShape>> = ({
150✔
275
  schema,
276
  isResponse,
277
  ...rest
278
}) => {
279
  const keys = Object.keys(schema.shape);
1,968✔
280
  const isOptionalProp = (prop: z.ZodTypeAny) =>
1,968✔
281
    isResponse && hasCoercion(prop)
3,024✔
282
      ? prop instanceof z.ZodOptional
283
      : prop.isOptional();
284
  const required = keys.filter((key) => !isOptionalProp(schema.shape[key]));
3,024✔
285
  const result: SchemaObject = { type: "object" };
1,968✔
286
  if (keys.length) {
1,968✔
287
    result.properties = depictObjectProperties({ schema, isResponse, ...rest });
1,800✔
288
  }
289
  if (required.length) {
1,926✔
290
    result.required = required;
1,740✔
291
  }
292
  return result;
1,926✔
293
};
294

295
/**
296
 * @see https://swagger.io/docs/specification/data-models/data-types/
297
 * @since OAS 3.1: using type: "null"
298
 * */
299
export const depictNull: Depicter<z.ZodNull> = () => ({ type: "null" });
150✔
300

301
export const depictDateIn: Depicter<z.ZodType> = (ctx) => {
150✔
302
  assert(
30✔
303
    !ctx.isResponse,
304
    new DocumentationError({
305
      message: "Please use ez.dateOut() for output.",
306
      ...ctx,
307
    }),
308
  );
309
  return {
24✔
310
    description: "YYYY-MM-DDTHH:mm:ss.sssZ",
311
    type: "string",
312
    format: "date-time",
313
    pattern: isoDateRegex.source,
314
    externalDocs: {
315
      url: isoDateDocumentationUrl,
316
    },
317
  };
318
};
319

320
export const depictDateOut: Depicter<z.ZodType> = (ctx) => {
150✔
321
  assert(
30✔
322
    ctx.isResponse,
323
    new DocumentationError({
324
      message: "Please use ez.dateIn() for input.",
325
      ...ctx,
326
    }),
327
  );
328
  return {
24✔
329
    description: "YYYY-MM-DDTHH:mm:ss.sssZ",
330
    type: "string",
331
    format: "date-time",
332
    externalDocs: {
333
      url: isoDateDocumentationUrl,
334
    },
335
  };
336
};
337

338
/** @throws DocumentationError */
339
export const depictDate: Depicter<z.ZodDate> = (ctx) =>
150✔
340
  assert.fail(
12✔
341
    new DocumentationError({
342
      message: `Using z.date() within ${
343
        ctx.isResponse ? "output" : "input"
12✔
344
      } schema is forbidden. Please use ez.date${
345
        ctx.isResponse ? "Out" : "In"
12✔
346
      }() instead. Check out the documentation for details.`,
347
      ...ctx,
348
    }),
349
  );
350

351
export const depictBoolean: Depicter<z.ZodBoolean> = () => ({
198✔
352
  type: "boolean",
353
});
354

355
export const depictBigInt: Depicter<z.ZodBigInt> = () => ({
150✔
356
  type: "integer",
357
  format: "bigint",
358
});
359

360
const areOptionsLiteral = (
150✔
361
  subject: z.ZodTypeAny[],
362
): subject is z.ZodLiteral<unknown>[] =>
363
  subject.every((option) => option instanceof z.ZodLiteral);
24✔
364

365
export const depictRecord: Depicter<z.ZodRecord<z.ZodTypeAny>> = ({
150✔
366
  schema: { keySchema, valueSchema },
367
  ...rest
368
}) => {
369
  if (keySchema instanceof z.ZodEnum || keySchema instanceof z.ZodNativeEnum) {
108✔
370
    const keys = Object.values(keySchema.enum) as string[];
12✔
371
    const result: SchemaObject = { type: "object" };
12✔
372
    if (keys.length) {
12!
373
      result.properties = depictObjectProperties({
12✔
374
        schema: z.object(fromPairs(xprod(keys, [valueSchema]))),
375
        ...rest,
376
      });
377
      result.required = keys;
12✔
378
    }
379
    return result;
12✔
380
  }
381
  if (keySchema instanceof z.ZodLiteral) {
96✔
382
    return {
24✔
383
      type: "object",
384
      properties: depictObjectProperties({
385
        schema: z.object({ [keySchema.value]: valueSchema }),
386
        ...rest,
387
      }),
388
      required: [keySchema.value],
389
    };
390
  }
391
  if (keySchema instanceof z.ZodUnion && areOptionsLiteral(keySchema.options)) {
72✔
392
    const required = map((opt) => `${opt.value}`, keySchema.options);
24✔
393
    const shape = fromPairs(xprod(required, [valueSchema]));
12✔
394
    return {
12✔
395
      type: "object",
396
      properties: depictObjectProperties({ schema: z.object(shape), ...rest }),
397
      required,
398
    };
399
  }
400
  return {
60✔
401
    type: "object",
402
    additionalProperties: rest.next({ schema: valueSchema }),
403
  };
404
};
405

406
export const depictArray: Depicter<z.ZodArray<z.ZodTypeAny>> = ({
150✔
407
  schema: { _def: def, element },
408
  next,
409
}) => {
410
  const result: SchemaObject = {
90✔
411
    type: "array",
412
    items: next({ schema: element }),
413
  };
414
  if (def.minLength) {
90✔
415
    result.minItems = def.minLength.value;
30✔
416
  }
417
  if (def.maxLength) {
90✔
418
    result.maxItems = def.maxLength.value;
6✔
419
  }
420
  return result;
90✔
421
};
422

423
/** @since OAS 3.1 using prefixItems for depicting tuples */
424
export const depictTuple: Depicter<z.ZodTuple> = ({
150✔
425
  schema: { items },
426
  next,
427
}) => {
428
  const types = items.map((item) => next({ schema: item }));
54✔
429
  return {
30✔
430
    type: "array",
431
    prefixItems: types,
432
  };
433
};
434

435
export const depictString: Depicter<z.ZodString> = ({
150✔
436
  schema: {
437
    isEmail,
438
    isURL,
439
    minLength,
440
    maxLength,
441
    isUUID,
442
    isCUID,
443
    isCUID2,
444
    isULID,
445
    isIP,
446
    isEmoji,
447
    isDatetime,
448
    _def: { checks },
449
  },
450
}) => {
451
  const regexCheck = checks.find(
1,254✔
452
    (check): check is z.ZodStringCheck & { kind: "regex" } =>
453
      check.kind === "regex",
252✔
454
  );
455
  const datetimeCheck = checks.find(
1,254✔
456
    (check): check is z.ZodStringCheck & { kind: "datetime" } =>
457
      check.kind === "datetime",
258✔
458
  );
459
  const regex = regexCheck
1,254✔
460
    ? regexCheck.regex
461
    : datetimeCheck
1,200✔
462
      ? datetimeCheck.offset
12✔
463
        ? new RegExp(
464
            `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}:\\d{2})|Z)$`,
465
          )
466
        : new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$`)
467
      : undefined;
468
  const result: SchemaObject = { type: "string" };
1,254✔
469
  const formats: Record<NonNullable<SchemaObject["format"]>, boolean> = {
1,254✔
470
    "date-time": isDatetime,
471
    email: isEmail,
472
    url: isURL,
473
    uuid: isUUID,
474
    cuid: isCUID,
475
    cuid2: isCUID2,
476
    ulid: isULID,
477
    ip: isIP,
478
    emoji: isEmoji,
479
  };
480
  for (const format in formats) {
1,254✔
481
    if (formats[format]) {
10,848✔
482
      result.format = format;
90✔
483
      break;
90✔
484
    }
485
  }
486
  if (minLength !== null) {
1,254✔
487
    result.minLength = minLength;
72✔
488
  }
489
  if (maxLength !== null) {
1,254✔
490
    result.maxLength = maxLength;
24✔
491
  }
492
  if (regex) {
1,254✔
493
    result.pattern = regex.source;
66✔
494
  }
495
  return result;
1,254✔
496
};
497

498
/** @since OAS 3.1: exclusive min/max are numbers */
499
export const depictNumber: Depicter<z.ZodNumber> = ({ schema }) => {
150✔
500
  const minCheck = schema._def.checks.find(({ kind }) => kind === "min") as
366✔
501
    | Extract<z.ZodNumberCheck, { kind: "min" }>
502
    | undefined;
503
  const minimum =
504
    schema.minValue === null
366✔
505
      ? schema.isInt
216✔
506
        ? Number.MIN_SAFE_INTEGER
507
        : Number.MIN_VALUE
508
      : schema.minValue;
509
  const isMinInclusive = minCheck ? minCheck.inclusive : true;
366✔
510
  const maxCheck = schema._def.checks.find(({ kind }) => kind === "max") as
366✔
511
    | Extract<z.ZodNumberCheck, { kind: "max" }>
512
    | undefined;
513
  const maximum =
514
    schema.maxValue === null
366✔
515
      ? schema.isInt
324✔
516
        ? Number.MAX_SAFE_INTEGER
517
        : Number.MAX_VALUE
518
      : schema.maxValue;
519
  const isMaxInclusive = maxCheck ? maxCheck.inclusive : true;
366✔
520
  const result: SchemaObject = {
366✔
521
    type: schema.isInt ? "integer" : "number",
366✔
522
    format: schema.isInt ? "int64" : "double",
366✔
523
  };
524
  if (isMinInclusive) {
366✔
525
    result.minimum = minimum;
288✔
526
  } else {
527
    result.exclusiveMinimum = minimum;
78✔
528
  }
529
  if (isMaxInclusive) {
366✔
530
    result.maximum = maximum;
348✔
531
  } else {
532
    result.exclusiveMaximum = maximum;
18✔
533
  }
534
  return result;
366✔
535
};
536

537
export const depictObjectProperties = ({
150✔
538
  schema: { shape },
539
  next,
540
}: Parameters<Depicter<z.ZodObject<z.ZodRawShape>>>[0]) =>
541
  Object.keys(shape).reduce<Record<string, SchemaObject | ReferenceObject>>(
1,854✔
542
    (carry, key) => ({
3,108✔
543
      ...carry,
544
      [key]: next({ schema: shape[key] }),
545
    }),
546
    {},
547
  );
548

549
const makeSample = (depicted: SchemaObject) => {
150✔
550
  const type = (
551
    Array.isArray(depicted.type) ? depicted.type[0] : depicted.type
42!
552
  ) as keyof typeof samples;
553
  return samples?.[type];
42✔
554
};
555

556
const makeNullableType = (prev: SchemaObject): SchemaObjectType[] => {
150✔
557
  const current = typeof prev.type === "string" ? [prev.type] : prev.type || [];
138!
558
  if (current.includes("null")) {
138✔
559
    return current;
72✔
560
  }
561
  return current.concat("null");
66✔
562
};
563

564
export const depictEffect: Depicter<z.ZodEffects<z.ZodTypeAny>> = ({
150✔
565
  schema,
566
  isResponse,
567
  next,
568
}) => {
569
  const input = next({ schema: schema.innerType() });
198✔
570
  const { effect } = schema._def;
198✔
571
  if (isResponse && effect.type === "transform" && !isReferenceObject(input)) {
198✔
572
    const outputType = tryToTransform(schema, makeSample(input));
42✔
573
    if (outputType && ["number", "string", "boolean"].includes(outputType)) {
42✔
574
      return { type: outputType as "number" | "string" | "boolean" };
30✔
575
    } else {
576
      return next({ schema: z.any() });
12✔
577
    }
578
  }
579
  if (
156✔
580
    !isResponse &&
324✔
581
    effect.type === "preprocess" &&
582
    !isReferenceObject(input)
583
  ) {
584
    const { type: inputType, ...rest } = input;
18✔
585
    return {
18✔
586
      ...rest,
587
      format: `${rest.format || inputType} (preprocessed)`,
30✔
588
    };
589
  }
590
  return input;
138✔
591
};
592

593
export const depictPipeline: Depicter<
594
  z.ZodPipeline<z.ZodTypeAny, z.ZodTypeAny>
595
> = ({ schema, isResponse, next }) =>
150✔
596
  next({ schema: schema._def[isResponse ? "out" : "in"] });
12✔
597

598
export const depictBranded: Depicter<
599
  z.ZodBranded<z.ZodTypeAny, string | number | symbol>
600
> = ({ schema, next }) => next({ schema: schema.unwrap() });
150✔
601

602
export const depictLazy: Depicter<z.ZodLazy<z.ZodTypeAny>> = ({
150✔
603
  next,
604
  schema: lazy,
605
  serializer: serialize,
606
  getRef,
607
  makeRef,
608
}): ReferenceObject => {
609
  const hash = serialize(lazy.schema);
72✔
610
  return (
72✔
611
    getRef(hash) ||
108✔
612
    (() => {
613
      makeRef(hash, {}); // make empty ref first
36✔
614
      return makeRef(hash, next({ schema: lazy.schema })); // update
36✔
615
    })()
616
  );
617
};
618

619
export const depictRaw: Depicter<RawSchema> = ({ next, schema }) =>
150✔
620
  next({ schema: schema.shape.raw });
12✔
621

622
export const depictExamples = (
150✔
623
  schema: z.ZodTypeAny,
624
  isResponse: boolean,
625
  omitProps: string[] = [],
828✔
626
): ExamplesObject | undefined => {
627
  const examples = getExamples({
1,026✔
628
    schema,
629
    variant: isResponse ? "parsed" : "original",
1,026✔
630
    validate: true,
631
  });
632
  if (examples.length === 0) {
1,026✔
633
    return undefined;
594✔
634
  }
635
  return examples.reduce<ExamplesObject>(
432✔
636
    (carry, example, index) => ({
450✔
637
      ...carry,
638
      [`example${index + 1}`]: {
639
        value:
640
          typeof example === "object" && !Array.isArray(example)
1,338✔
641
            ? omit(omitProps, example)
642
            : example,
643
      } satisfies ExampleObject,
644
    }),
645
    {},
646
  );
647
};
648

649
export const depictParamExamples = (
150✔
650
  schema: z.ZodTypeAny,
651
  isResponse: boolean,
652
  param: string,
653
): ExamplesObject | undefined => {
654
  const examples = getExamples({
240✔
655
    schema,
656
    variant: isResponse ? "parsed" : "original",
240✔
657
    validate: true,
658
  });
659
  if (examples.length === 0) {
240✔
660
    return undefined;
210✔
661
  }
662
  return examples.reduce<ExamplesObject>(
30✔
663
    (carry, example, index) =>
664
      param in example
42!
665
        ? {
666
            ...carry,
667
            [`example${index + 1}`]: {
668
              value: example[param],
669
            } satisfies ExampleObject,
670
          }
671
        : carry,
672
    {},
673
  );
674
};
675

676
export const extractObjectSchema = (
150✔
677
  subject: IOSchema,
678
  tfError: DocumentationError,
679
): z.ZodObject<z.ZodRawShape> => {
680
  if (subject instanceof z.ZodObject) {
594✔
681
    return subject;
510✔
682
  }
683
  if (
84✔
684
    subject instanceof z.ZodUnion ||
156✔
685
    subject instanceof z.ZodDiscriminatedUnion
686
  ) {
687
    return Array.from(subject.options.values())
18✔
688
      .map((option) => extractObjectSchema(option, tfError))
36✔
689
      .reduce((acc, option) => acc.merge(option.partial()), z.object({}));
36✔
690
  } else if (subject instanceof z.ZodEffects) {
66✔
691
    assert(subject._def.effect.type === "refinement", tfError);
12✔
692
    return extractObjectSchema(subject._def.schema, tfError); // object refinement
6✔
693
  } // intersection left
694
  return extractObjectSchema(subject._def.left, tfError).merge(
54✔
695
    extractObjectSchema(subject._def.right, tfError),
696
  );
697
};
698

699
export const depictRequestParams = ({
150✔
700
  path,
701
  method,
702
  schema,
703
  inputSources,
704
  serializer,
705
  getRef,
706
  makeRef,
707
  composition,
708
  description = `${method.toUpperCase()} ${path} Parameter`,
402✔
709
}: Omit<ReqResDepictHelperCommonProps, "mimeTypes"> & {
710
  inputSources: InputSource[];
711
}): ParameterObject[] => {
712
  const { shape } = extractObjectSchema(
414✔
713
    schema,
714
    new DocumentationError({
715
      message: `Using transformations on the top level schema is not allowed.`,
716
      path,
717
      method,
718
      isResponse: false,
719
    }),
720
  );
721
  const pathParams = getRoutePathParams(path);
414✔
722
  const isQueryEnabled = inputSources.includes("query");
414✔
723
  const areParamsEnabled = inputSources.includes("params");
414✔
724
  const areHeadersEnabled = inputSources.includes("headers");
414✔
725
  const isPathParam = (name: string) =>
414✔
726
    areParamsEnabled && pathParams.includes(name);
696✔
727
  const isHeaderParam = (name: string) =>
414✔
728
    areHeadersEnabled && isCustomHeader(name);
174✔
729
  return Object.keys(shape)
414✔
730
    .filter((name) => isQueryEnabled || isPathParam(name))
660✔
731
    .map((name) => {
732
      const depicted = walkSchema({
228✔
733
        schema: shape[name],
734
        isResponse: false,
735
        rules: depicters,
736
        onEach,
737
        onMissing,
738
        serializer,
739
        getRef,
740
        makeRef,
741
        path,
742
        method,
743
      });
744
      const result =
745
        composition === "components"
228✔
746
          ? makeRef(makeCleanId(description, name), depicted)
747
          : depicted;
748
      return {
228✔
749
        name,
750
        in: isPathParam(name)
228✔
751
          ? "path"
752
          : isHeaderParam(name)
174✔
753
            ? "header"
754
            : "query",
755
        required: !shape[name].isOptional(),
756
        description: depicted.description || description,
432✔
757
        schema: result,
758
        examples: depictParamExamples(schema, false, name),
759
      };
760
    });
761
};
762

763
export const depicters: HandlingRules<
764
  SchemaObject | ReferenceObject,
765
  OpenAPIContext
766
> = {
150✔
767
  ZodString: depictString,
768
  ZodNumber: depictNumber,
769
  ZodBigInt: depictBigInt,
770
  ZodBoolean: depictBoolean,
771
  ZodNull: depictNull,
772
  ZodArray: depictArray,
773
  ZodTuple: depictTuple,
774
  ZodRecord: depictRecord,
775
  ZodObject: depictObject,
776
  ZodLiteral: depictLiteral,
777
  ZodIntersection: depictIntersection,
778
  ZodUnion: depictUnion,
779
  ZodAny: depictAny,
780
  ZodDefault: depictDefault,
781
  ZodEnum: depictEnum,
782
  ZodNativeEnum: depictEnum,
783
  ZodEffects: depictEffect,
784
  ZodOptional: depictOptional,
785
  ZodNullable: depictNullable,
786
  ZodDiscriminatedUnion: depictDiscriminatedUnion,
787
  ZodBranded: depictBranded,
788
  ZodDate: depictDate,
789
  ZodCatch: depictCatch,
790
  ZodPipeline: depictPipeline,
791
  ZodLazy: depictLazy,
792
  ZodReadonly: depictReadonly,
793
  [ezFileKind]: depictFile,
794
  [ezUploadKind]: depictUpload,
795
  [ezDateOutKind]: depictDateOut,
796
  [ezDateInKind]: depictDateIn,
797
  [ezRawKind]: depictRaw,
798
};
799

800
export const onEach: Depicter<z.ZodTypeAny, "each"> = ({
150✔
801
  schema,
802
  isResponse,
803
  prev,
804
}) => {
805
  if (isReferenceObject(prev)) {
5,358✔
806
    return {};
54✔
807
  }
808
  const { description } = schema;
5,304✔
809
  const shouldAvoidParsing = schema instanceof z.ZodLazy;
5,304✔
810
  const hasTypePropertyInDepiction = prev.type !== undefined;
5,304✔
811
  const isResponseHavingCoercion = isResponse && hasCoercion(schema);
5,304✔
812
  const isActuallyNullable =
813
    !shouldAvoidParsing &&
5,304✔
814
    hasTypePropertyInDepiction &&
815
    !isResponseHavingCoercion &&
816
    schema.isNullable();
817
  const examples = shouldAvoidParsing
5,304!
818
    ? []
819
    : getExamples({
820
        schema,
821
        variant: isResponse ? "parsed" : "original",
5,304✔
822
        validate: true,
823
      });
824
  const result: SchemaObject = {};
5,304✔
825
  if (description) {
5,304✔
826
    result.description = description;
30✔
827
  }
828
  if (isActuallyNullable) {
5,304✔
829
    result.type = makeNullableType(prev);
78✔
830
  }
831
  if (examples.length) {
5,304✔
832
    result.examples = Array.from(examples);
534✔
833
  }
834
  return result;
5,304✔
835
};
836

837
export const onMissing: Depicter<z.ZodTypeAny, "last"> = ({ schema, ...ctx }) =>
150✔
838
  assert.fail(
42✔
839
    new DocumentationError({
840
      message: `Zod type ${schema.constructor.name} is unsupported.`,
841
      ...ctx,
842
    }),
843
  );
844

845
export const excludeParamsFromDepiction = (
150✔
846
  depicted: SchemaObject | ReferenceObject,
847
  pathParams: string[],
848
): SchemaObject | ReferenceObject => {
849
  if (isReferenceObject(depicted)) {
264✔
850
    return depicted;
6✔
851
  }
852
  const properties = depicted.properties
258✔
853
    ? omit(pathParams, depicted.properties)
854
    : undefined;
855
  const examples = depicted.examples
258✔
856
    ? depicted.examples.map((entry) => omit(pathParams, entry))
30✔
857
    : undefined;
858
  const required = depicted.required
258✔
859
    ? depicted.required.filter((name) => !pathParams.includes(name))
438✔
860
    : undefined;
861
  const allOf = depicted.allOf
258✔
862
    ? (depicted.allOf as SchemaObject[]).map((entry) =>
863
        excludeParamsFromDepiction(entry, pathParams),
12✔
864
      )
865
    : undefined;
866
  const oneOf = depicted.oneOf
258✔
867
    ? (depicted.oneOf as SchemaObject[]).map((entry) =>
868
        excludeParamsFromDepiction(entry, pathParams),
36✔
869
      )
870
    : undefined;
871

872
  return omit(
258✔
873
    Object.entries({ properties, required, examples, allOf, oneOf })
874
      .filter(([{}, value]) => value === undefined)
1,290✔
875
      .map(([key]) => key),
852✔
876
    {
877
      ...depicted,
878
      properties,
879
      required,
880
      examples,
881
      allOf,
882
      oneOf,
883
    },
884
  );
885
};
886

887
export const excludeExamplesFromDepiction = (
150✔
888
  depicted: SchemaObject | ReferenceObject,
889
): SchemaObject | ReferenceObject =>
890
  isReferenceObject(depicted) ? depicted : omit(["examples"], depicted);
1,020!
891

892
export const depictResponse = ({
150✔
893
  method,
894
  path,
895
  schema,
896
  mimeTypes,
897
  variant,
898
  serializer,
899
  getRef,
900
  makeRef,
901
  composition,
902
  hasMultipleStatusCodes,
903
  statusCode,
904
  description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${
804✔
905
    hasMultipleStatusCodes ? statusCode : ""
804✔
906
  }`.trim(),
907
}: ReqResDepictHelperCommonProps & {
908
  variant: "positive" | "negative";
909
  statusCode: number;
910
  hasMultipleStatusCodes: boolean;
911
}): ResponseObject => {
912
  const depictedSchema = excludeExamplesFromDepiction(
828✔
913
    walkSchema({
914
      schema,
915
      isResponse: true,
916
      rules: depicters,
917
      onEach,
918
      onMissing,
919
      serializer,
920
      getRef,
921
      makeRef,
922
      path,
923
      method,
924
    }),
925
  );
926
  const examples = depictExamples(schema, true);
828✔
927
  const result =
928
    composition === "components"
828✔
929
      ? makeRef(makeCleanId(description), depictedSchema)
930
      : depictedSchema;
931

932
  return {
828✔
933
    description,
934
    content: mimeTypes.reduce<ContentObject>(
935
      (carry, mimeType) => ({
834✔
936
        ...carry,
937
        [mimeType]: { schema: result, examples },
938
      }),
939
      {},
940
    ),
941
  };
942
};
943

944
type SecurityHelper<K extends Security["type"]> = (
945
  security: Security & { type: K },
946
  inputSources?: InputSource[],
947
) => SecuritySchemeObject;
948

949
const depictBasicSecurity: SecurityHelper<"basic"> = () => ({
150✔
950
  type: "http",
951
  scheme: "basic",
952
});
953
const depictBearerSecurity: SecurityHelper<"bearer"> = ({
150✔
954
  format: bearerFormat,
955
}) => {
956
  const result: SecuritySchemeObject = {
24✔
957
    type: "http",
958
    scheme: "bearer",
959
  };
960
  if (bearerFormat) {
24✔
961
    result.bearerFormat = bearerFormat;
6✔
962
  }
963
  return result;
24✔
964
};
965
const depictInputSecurity: SecurityHelper<"input"> = (
150✔
966
  { name },
967
  inputSources,
968
) => {
969
  const result: SecuritySchemeObject = {
36✔
970
    type: "apiKey",
971
    in: "query",
972
    name,
973
  };
974
  if (inputSources?.includes("body")) {
36✔
975
    if (inputSources?.includes("query")) {
24✔
976
      result["x-in-alternative"] = "body";
6✔
977
      result.description = `${name} CAN also be supplied within the request body`;
6✔
978
    } else {
979
      result["x-in-actual"] = "body";
18✔
980
      result.description = `${name} MUST be supplied within the request body instead of query`;
18✔
981
    }
982
  }
983
  return result;
36✔
984
};
985
const depictHeaderSecurity: SecurityHelper<"header"> = ({ name }) => ({
150✔
986
  type: "apiKey",
987
  in: "header",
988
  name,
989
});
990
const depictCookieSecurity: SecurityHelper<"cookie"> = ({ name }) => ({
150✔
991
  type: "apiKey",
992
  in: "cookie",
993
  name,
994
});
995
const depictOpenIdSecurity: SecurityHelper<"openid"> = ({
150✔
996
  url: openIdConnectUrl,
997
}) => ({
6✔
998
  type: "openIdConnect",
999
  openIdConnectUrl,
1000
});
1001
const depictOAuth2Security: SecurityHelper<"oauth2"> = ({ flows = {} }) => ({
150✔
1002
  type: "oauth2",
1003
  flows: (
1004
    Object.keys(flows) as (keyof typeof flows)[]
1005
  ).reduce<OAuthFlowsObject>((acc, key) => {
1006
    const flow = flows[key];
42✔
1007
    if (!flow) {
42✔
1008
      return acc;
12✔
1009
    }
1010
    const { scopes = {}, ...rest } = flow;
30!
1011
    return { ...acc, [key]: { ...rest, scopes } satisfies OAuthFlowObject };
30✔
1012
  }, {}),
1013
});
1014

1015
export const depictSecurity = (
150✔
1016
  container: LogicalContainer<Security>,
1017
  inputSources?: InputSource[],
1018
): LogicalContainer<SecuritySchemeObject> => {
1019
  const methods: { [K in Security["type"]]: SecurityHelper<K> } = {
390✔
1020
    basic: depictBasicSecurity,
1021
    bearer: depictBearerSecurity,
1022
    input: depictInputSecurity,
1023
    header: depictHeaderSecurity,
1024
    cookie: depictCookieSecurity,
1025
    openid: depictOpenIdSecurity,
1026
    oauth2: depictOAuth2Security,
1027
  };
1028
  return mapLogicalContainer(container, (security) =>
390✔
1029
    (methods[security.type] as SecurityHelper<typeof security.type>)(
120✔
1030
      security,
1031
      inputSources,
1032
    ),
1033
  );
1034
};
1035

1036
export const depictSecurityRefs = (
150✔
1037
  container: LogicalContainer<{ name: string; scopes: string[] }>,
1038
): SecurityRequirementObject[] => {
1039
  if (typeof container === "object") {
744!
1040
    if ("or" in container) {
744✔
1041
      return container.or.map((entry) =>
384✔
1042
        ("and" in entry
108✔
1043
          ? entry.and
1044
          : [entry]
1045
        ).reduce<SecurityRequirementObject>(
1046
          (agg, { name, scopes }) => ({
156✔
1047
            ...agg,
1048
            [name]: scopes,
1049
          }),
1050
          {},
1051
        ),
1052
      );
1053
    }
1054
    if ("and" in container) {
360✔
1055
      return depictSecurityRefs(andToOr(container));
354✔
1056
    }
1057
  }
1058
  return depictSecurityRefs({ or: [container] });
6✔
1059
};
1060

1061
export const depictRequest = ({
150✔
1062
  method,
1063
  path,
1064
  schema,
1065
  mimeTypes,
1066
  serializer,
1067
  getRef,
1068
  makeRef,
1069
  composition,
1070
  description = `${method.toUpperCase()} ${path} Request body`,
216✔
1071
}: ReqResDepictHelperCommonProps): RequestBodyObject => {
1072
  const pathParams = getRoutePathParams(path);
228✔
1073
  const bodyDepiction = excludeExamplesFromDepiction(
228✔
1074
    excludeParamsFromDepiction(
1075
      walkSchema({
1076
        schema,
1077
        isResponse: false,
1078
        rules: depicters,
1079
        onEach,
1080
        onMissing,
1081
        serializer,
1082
        getRef,
1083
        makeRef,
1084
        path,
1085
        method,
1086
      }),
1087
      pathParams,
1088
    ),
1089
  );
1090
  const bodyExamples = depictExamples(schema, false, pathParams);
186✔
1091
  const result =
1092
    composition === "components"
186✔
1093
      ? makeRef(makeCleanId(description), bodyDepiction)
1094
      : bodyDepiction;
1095

1096
  return {
186✔
1097
    description,
1098
    content: mimeTypes.reduce<ContentObject>(
1099
      (carry, mimeType) => ({
186✔
1100
        ...carry,
1101
        [mimeType]: { schema: result, examples: bodyExamples },
1102
      }),
1103
      {},
1104
    ),
1105
  };
1106
};
1107

1108
export const depictTags = <TAG extends string>(
150✔
1109
  tags: TagsConfig<TAG>,
1110
): TagObject[] =>
1111
  (Object.keys(tags) as TAG[]).map((tag) => {
24✔
1112
    const def = tags[tag];
48✔
1113
    const result: TagObject = {
48✔
1114
      name: tag,
1115
      description: typeof def === "string" ? def : def.description,
48✔
1116
    };
1117
    if (typeof def === "object" && def.url) {
48✔
1118
      result.externalDocs = { url: def.url };
6✔
1119
    }
1120
    return result;
48✔
1121
  });
1122

1123
export const ensureShortDescription = (description: string) => {
150✔
1124
  if (description.length <= shortDescriptionLimit) {
120✔
1125
    return description;
102✔
1126
  }
1127
  return description.slice(0, shortDescriptionLimit - 1) + "…";
18✔
1128
};
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