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

RobinTail / express-zod-api / 10058278859

23 Jul 2024 11:45AM CUT coverage: 100.0%. Remained the same
10058278859

Pull #1933

github

web-flow
Merge 05eb330d4 into 8fa92dec4
Pull Request #1933: Bump ansis from 3.3.1 to 3.3.2

744 of 781 branches covered (95.26%)

1180 of 1180 relevant lines covered (100.0%)

437.3 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
  ExamplesObject,
4
  MediaTypeObject,
5
  OAuthFlowObject,
6
  ParameterLocation,
7
  ParameterObject,
8
  ReferenceObject,
9
  RequestBodyObject,
10
  ResponseObject,
11
  SchemaObject,
12
  SchemaObjectType,
13
  SecurityRequirementObject,
14
  SecuritySchemeObject,
15
  TagObject,
16
  isReferenceObject,
17
  isSchemaObject,
18
} from "openapi3-ts/oas31";
19
import {
20
  both,
21
  complement,
22
  concat,
23
  type as detectType,
24
  filter,
25
  fromPairs,
26
  has,
27
  isNil,
28
  map,
29
  mergeAll,
30
  mergeDeepRight,
31
  mergeDeepWith,
32
  objOf,
33
  omit,
34
  pipe,
35
  pluck,
36
  range,
37
  reject,
38
  toLower,
39
  union,
40
  when,
41
  xprod,
42
  zip,
43
} from "ramda";
44
import { z } from "zod";
45
import {
46
  FlatObject,
47
  combinations,
48
  getExamples,
49
  hasCoercion,
50
  isCustomHeader,
51
  isObject,
52
  makeCleanId,
53
  tryToTransform,
54
  ucFirst,
55
} from "./common-helpers";
56
import { InputSource, TagsConfig } from "./config-type";
57
import { DateInSchema, ezDateInBrand } from "./date-in-schema";
58
import { DateOutSchema, ezDateOutBrand } from "./date-out-schema";
59
import { DocumentationError } from "./errors";
60
import { FileSchema, ezFileBrand } from "./file-schema";
61
import { IOSchema } from "./io-schema";
62
import {
63
  LogicalContainer,
64
  andToOr,
65
  mapLogicalContainer,
66
} from "./logical-container";
67
import { metaSymbol } from "./metadata";
68
import { Method } from "./method";
69
import { ProprietaryBrand } from "./proprietary-schemas";
70
import { RawSchema, ezRawBrand } from "./raw-schema";
71
import { HandlingRules, SchemaHandler, walkSchema } from "./schema-walker";
72
import { Security } from "./security";
73
import { UploadSchema, ezUploadBrand } from "./upload-schema";
74

75
export interface OpenAPIContext extends FlatObject {
76
  isResponse: boolean;
77
  serializer: (schema: z.ZodTypeAny) => string;
78
  getRef: (name: string) => ReferenceObject | undefined;
79
  makeRef: (
80
    name: string,
81
    schema: SchemaObject | ReferenceObject,
82
  ) => ReferenceObject;
83
  path: string;
84
  method: Method;
85
}
86

87
export type Depicter = SchemaHandler<
88
  SchemaObject | ReferenceObject,
89
  OpenAPIContext
90
>;
91

92
interface ReqResHandlingProps<S extends z.ZodTypeAny>
93
  extends Pick<
94
    OpenAPIContext,
95
    "serializer" | "getRef" | "makeRef" | "path" | "method"
96
  > {
97
  schema: S;
98
  composition: "inline" | "components";
99
  description?: string;
100
  brandHandling?: HandlingRules<SchemaObject | ReferenceObject, OpenAPIContext>;
101
}
102

103
const shortDescriptionLimit = 50;
156✔
104
const isoDateDocumentationUrl =
105
  "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString";
156✔
106

107
const samples = {
156✔
108
  integer: 0,
109
  number: 0,
110
  string: "",
111
  boolean: false,
112
  object: {},
113
  null: null,
114
  array: [],
115
} satisfies Record<Extract<SchemaObjectType, string>, unknown>;
116

117
/** @see https://expressjs.com/en/guide/routing.html */
118
const routePathParamsRegex = /:([A-Za-z0-9_]+)/g;
156✔
119

120
export const getRoutePathParams = (path: string): string[] =>
156✔
121
  path.match(routePathParamsRegex)?.map((param) => param.slice(1)) || [];
510✔
122

123
export const reformatParamsInPath = (path: string) =>
156✔
124
  path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`);
402✔
125

126
export const depictDefault: Depicter = (
156✔
127
  { _def }: z.ZodDefault<z.ZodTypeAny>,
128
  { next },
129
) => ({
24✔
130
  ...next(_def.innerType),
131
  default: _def[metaSymbol]?.defaultLabel || _def.defaultValue(),
42✔
132
});
133

134
export const depictCatch: Depicter = (
156✔
135
  { _def: { innerType } }: z.ZodCatch<z.ZodTypeAny>,
136
  { next },
137
) => next(innerType);
6✔
138

139
export const depictAny: Depicter = () => ({ format: "any" });
156✔
140

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

152
export const depictFile: Depicter = (schema: FileSchema) => {
156✔
153
  const subject = schema.unwrap();
54✔
154
  return {
54✔
155
    type: "string",
156
    format:
157
      subject instanceof z.ZodString
54✔
158
        ? subject._def.checks.find((check) => check.kind === "base64")
6✔
159
          ? "byte"
160
          : "file"
161
        : "binary",
162
  };
163
};
164

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

170
export const depictDiscriminatedUnion: Depicter = (
156✔
171
  {
172
    options,
173
    discriminator,
174
  }: z.ZodDiscriminatedUnion<string, z.ZodDiscriminatedUnionOption<string>[]>,
175
  { next },
176
) => {
177
  return {
18✔
178
    discriminator: { propertyName: discriminator },
179
    oneOf: options.map(next),
180
  };
181
};
182

183
/** @throws AssertionError */
184
const tryFlattenIntersection = (
156✔
185
  children: Array<SchemaObject | ReferenceObject>,
186
) => {
187
  const [left, right] = children
96✔
188
    .filter(isSchemaObject)
189
    .filter(
190
      (entry) =>
191
        entry.type === "object" &&
192✔
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 = (
156✔
224
  { _def: { left, right } }: z.ZodIntersection<z.ZodTypeAny, z.ZodTypeAny>,
225
  { next },
226
) => {
227
  const children = [left, right].map(next);
96✔
228
  try {
96✔
229
    return tryFlattenIntersection(children);
96✔
230
  } catch {}
231
  return { allOf: children };
18✔
232
};
233

234
export const depictOptional: Depicter = (
156✔
235
  schema: z.ZodOptional<z.ZodTypeAny>,
236
  { next },
237
) => next(schema.unwrap());
66✔
238

239
export const depictReadonly: Depicter = (
156✔
240
  schema: z.ZodReadonly<z.ZodTypeAny>,
241
  { next },
242
) => next(schema.unwrap());
12✔
243

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

256
const getSupportedType = (value: unknown): SchemaObjectType | undefined => {
156✔
257
  const detected = toLower(detectType(value)); // toLower is typed well unlike .toLowerCase()
1,008✔
258
  const isSupported =
259
    detected === "number" ||
1,008✔
260
    detected === "string" ||
261
    detected === "boolean" ||
262
    detected === "object" ||
263
    detected === "null" ||
264
    detected === "array";
265
  return typeof value === "bigint"
1,008✔
266
    ? "integer"
267
    : isSupported
1,002✔
268
      ? detected
269
      : undefined;
270
};
271

272
export const depictEnum: Depicter = (
156✔
273
  schema: z.ZodEnum<[string, ...string[]]> | z.ZodNativeEnum<z.EnumLike>,
274
) => ({
24✔
275
  type: getSupportedType(Object.values(schema.enum)[0]),
276
  enum: Object.values(schema.enum),
277
});
278

279
export const depictLiteral: Depicter = ({ value }: z.ZodLiteral<unknown>) => ({
984✔
280
  type: getSupportedType(value), // constructor allows z.Primitive only, but ZodLiteral does not have that constrant
281
  const: value,
282
});
283

284
export const depictObject: Depicter = (
156✔
285
  schema: z.ZodObject<z.ZodRawShape>,
286
  { isResponse, next },
287
) => {
288
  const keys = Object.keys(schema.shape);
2,100✔
289
  const isOptionalProp = (prop: z.ZodTypeAny) =>
2,100✔
290
    isResponse && hasCoercion(prop)
3,216✔
291
      ? prop instanceof z.ZodOptional
292
      : prop.isOptional();
293
  const required = keys.filter((key) => !isOptionalProp(schema.shape[key]));
3,216✔
294
  const result: SchemaObject = { type: "object" };
2,100✔
295
  if (keys.length) {
2,100✔
296
    result.properties = depictObjectProperties(schema, next);
1,920✔
297
  }
298
  if (required.length) {
2,058✔
299
    result.required = required;
1,860✔
300
  }
301
  return result;
2,058✔
302
};
303

304
/**
305
 * @see https://swagger.io/docs/specification/data-models/data-types/
306
 * @since OAS 3.1: using type: "null"
307
 * */
308
export const depictNull: Depicter = () => ({ type: "null" });
156✔
309

310
export const depictDateIn: Depicter = ({}: DateInSchema, ctx) => {
156✔
311
  assert(
36✔
312
    !ctx.isResponse,
313
    new DocumentationError({
314
      message: "Please use ez.dateOut() for output.",
315
      ...ctx,
316
    }),
317
  );
318
  return {
30✔
319
    description: "YYYY-MM-DDTHH:mm:ss.sssZ",
320
    type: "string",
321
    format: "date-time",
322
    pattern: /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$/.source,
323
    externalDocs: {
324
      url: isoDateDocumentationUrl,
325
    },
326
  };
327
};
328

329
export const depictDateOut: Depicter = ({}: DateOutSchema, ctx) => {
156✔
330
  assert(
30✔
331
    ctx.isResponse,
332
    new DocumentationError({
333
      message: "Please use ez.dateIn() for input.",
334
      ...ctx,
335
    }),
336
  );
337
  return {
24✔
338
    description: "YYYY-MM-DDTHH:mm:ss.sssZ",
339
    type: "string",
340
    format: "date-time",
341
    externalDocs: {
342
      url: isoDateDocumentationUrl,
343
    },
344
  };
345
};
346

347
/** @throws DocumentationError */
348
export const depictDate: Depicter = ({}: z.ZodDate, ctx) =>
156✔
349
  assert.fail(
12✔
350
    new DocumentationError({
351
      message: `Using z.date() within ${
352
        ctx.isResponse ? "output" : "input"
12✔
353
      } schema is forbidden. Please use ez.date${
354
        ctx.isResponse ? "Out" : "In"
12✔
355
      }() instead. Check out the documentation for details.`,
356
      ...ctx,
357
    }),
358
  );
359

360
export const depictBoolean: Depicter = () => ({ type: "boolean" });
210✔
361

362
export const depictBigInt: Depicter = () => ({
156✔
363
  type: "integer",
364
  format: "bigint",
365
});
366

367
const areOptionsLiteral = (
156✔
368
  subject: z.ZodTypeAny[],
369
): subject is z.ZodLiteral<unknown>[] =>
370
  subject.every((option) => option instanceof z.ZodLiteral);
24✔
371

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

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

424
/**
425
 * @since OAS 3.1 using prefixItems for depicting tuples
426
 * @since 17.5.0 added rest handling, fixed tuple type
427
 * */
428
export const depictTuple: Depicter = (
156✔
429
  { items, _def: { rest } }: z.AnyZodTuple,
430
  { next },
431
) => ({
42✔
432
  type: "array",
433
  prefixItems: items.map(next),
434
  // does not appear to support items:false, so not:{} is a recommended alias
435
  items: rest === null ? { not: {} } : next(rest),
42✔
436
});
437

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

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

533
export const depictObjectProperties = (
156✔
534
  { shape }: z.ZodObject<z.ZodRawShape>,
535
  next: Parameters<Depicter>[1]["next"],
536
) => map(next, shape);
1,974✔
537

538
const makeSample = (depicted: SchemaObject) => {
156✔
539
  const firstType = (
540
    Array.isArray(depicted.type) ? depicted.type[0] : depicted.type
42!
541
  ) as keyof typeof samples;
542
  return samples?.[firstType];
42✔
543
};
544

545
const makeNullableType = (prev: SchemaObject): SchemaObjectType[] => {
156✔
546
  const current = typeof prev.type === "string" ? [prev.type] : prev.type || [];
138!
547
  if (current.includes("null")) {
138✔
548
    return current;
72✔
549
  }
550
  return current.concat("null");
66✔
551
};
552

553
export const depictEffect: Depicter = (
156✔
554
  schema: z.ZodEffects<z.ZodTypeAny>,
555
  { isResponse, next },
556
) => {
557
  const input = next(schema.innerType());
186✔
558
  const { effect } = schema._def;
186✔
559
  if (isResponse && effect.type === "transform" && isSchemaObject(input)) {
186✔
560
    const outputType = tryToTransform(schema, makeSample(input));
42✔
561
    if (outputType && ["number", "string", "boolean"].includes(outputType)) {
42✔
562
      return { type: outputType as "number" | "string" | "boolean" };
30✔
563
    } else {
564
      return next(z.any());
12✔
565
    }
566
  }
567
  if (!isResponse && effect.type === "preprocess" && isSchemaObject(input)) {
144✔
568
    const { type: inputType, ...rest } = input;
18✔
569
    return {
18✔
570
      ...rest,
571
      format: `${rest.format || inputType} (preprocessed)`,
30✔
572
    };
573
  }
574
  return input;
126✔
575
};
576

577
export const depictPipeline: Depicter = (
156✔
578
  { _def }: z.ZodPipeline<z.ZodTypeAny, z.ZodTypeAny>,
579
  { isResponse, next },
580
) => next(_def[isResponse ? "out" : "in"]);
24✔
581

582
export const depictBranded: Depicter = (
156✔
583
  schema: z.ZodBranded<z.ZodTypeAny, string | number | symbol>,
584
  { next },
585
) => next(schema.unwrap());
6✔
586

587
export const depictLazy: Depicter = (
156✔
588
  { schema }: z.ZodLazy<z.ZodTypeAny>,
589
  { next, serializer: serialize, getRef, makeRef },
590
): ReferenceObject => {
591
  const hash = serialize(schema);
72✔
592
  return (
72✔
593
    getRef(hash) ||
108✔
594
    (() => {
595
      makeRef(hash, {}); // make empty ref first
36✔
596
      return makeRef(hash, next(schema)); // update
36✔
597
    })()
598
  );
599
};
600

601
export const depictRaw: Depicter = (schema: RawSchema, { next }) =>
156✔
602
  next(schema.unwrap().shape.raw);
12✔
603

604
const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined =>
156✔
605
  examples.length
1,386✔
606
    ? fromPairs(
607
        zip(
608
          range(1, examples.length + 1).map((idx) => `example${idx}`),
510✔
609
          map(objOf("value"), examples),
610
        ),
611
      )
612
    : undefined;
613

614
export const depictExamples = (
156✔
615
  schema: z.ZodTypeAny,
616
  isResponse: boolean,
617
  omitProps: string[] = [],
888✔
618
): ExamplesObject | undefined =>
619
  pipe(
1,098✔
620
    getExamples,
621
    map(when(both(isObject, complement(Array.isArray)), omit(omitProps))),
622
    enumerateExamples,
623
  )({ schema, variant: isResponse ? "parsed" : "original", validate: true });
1,098✔
624

625
export const depictParamExamples = (
156✔
626
  schema: z.ZodTypeAny,
627
  param: string,
628
): ExamplesObject | undefined =>
629
  pipe(
288✔
630
    getExamples,
631
    filter<FlatObject>(has(param)),
632
    pluck(param),
633
    enumerateExamples,
634
  )({ schema, variant: "original", validate: true });
635

636
export const extractObjectSchema = (
156✔
637
  subject: IOSchema,
638
): z.ZodObject<z.ZodRawShape> => {
639
  if (subject instanceof z.ZodObject) {
672✔
640
    return subject;
552✔
641
  }
642
  if (subject instanceof z.ZodBranded) {
120✔
643
    return extractObjectSchema(subject.unwrap());
18✔
644
  }
645
  if (
102✔
646
    subject instanceof z.ZodUnion ||
192✔
647
    subject instanceof z.ZodDiscriminatedUnion
648
  ) {
649
    return subject.options
18✔
650
      .map((option) => extractObjectSchema(option))
36✔
651
      .reduce((acc, option) => acc.merge(option.partial()), z.object({}));
36✔
652
  } else if (subject instanceof z.ZodEffects) {
84✔
653
    return extractObjectSchema(subject._def.schema);
24✔
654
  } else if (subject instanceof z.ZodPipeline) {
60✔
655
    return extractObjectSchema(subject._def.in);
6✔
656
  } // intersection left:
657
  return extractObjectSchema(subject._def.left).merge(
54✔
658
    extractObjectSchema(subject._def.right),
659
  );
660
};
661

662
export const depictRequestParams = ({
156✔
663
  path,
664
  method,
665
  schema,
666
  inputSources,
667
  serializer,
668
  getRef,
669
  makeRef,
670
  composition,
671
  brandHandling,
672
  description = `${method.toUpperCase()} ${path} Parameter`,
432✔
673
}: ReqResHandlingProps<IOSchema> & {
674
  inputSources: InputSource[];
675
}) => {
676
  const { shape } = extractObjectSchema(schema);
444✔
677
  const pathParams = getRoutePathParams(path);
444✔
678
  const isQueryEnabled = inputSources.includes("query");
444✔
679
  const areParamsEnabled = inputSources.includes("params");
444✔
680
  const areHeadersEnabled = inputSources.includes("headers");
444✔
681
  const isPathParam = (name: string) =>
444✔
682
    areParamsEnabled && pathParams.includes(name);
720✔
683
  const isHeaderParam = (name: string) =>
444✔
684
    areHeadersEnabled && isCustomHeader(name);
660✔
685

686
  const parameters = Object.keys(shape)
444✔
687
    .map<{ name: string; location?: ParameterLocation }>((name) => ({
720✔
688
      name,
689
      location: isPathParam(name)
720✔
690
        ? "path"
691
        : isHeaderParam(name)
660✔
692
          ? "header"
693
          : isQueryEnabled
636✔
694
            ? "query"
695
            : undefined,
696
    }))
697
    .filter(
698
      (parameter): parameter is Required<typeof parameter> =>
699
        parameter.location !== undefined,
720✔
700
    );
701

702
  return parameters.map<ParameterObject>(({ name, location }) => {
444✔
703
    const depicted = walkSchema(shape[name], {
282✔
704
      rules: { ...brandHandling, ...depicters },
705
      onEach,
706
      onMissing,
707
      ctx: {
708
        isResponse: false,
709
        serializer,
710
        getRef,
711
        makeRef,
712
        path,
713
        method,
714
      },
715
    });
716
    const result =
717
      composition === "components"
282✔
718
        ? makeRef(makeCleanId(description, name), depicted)
719
        : depicted;
720
    return {
282✔
721
      name,
722
      in: location,
723
      required: !shape[name].isOptional(),
724
      description: depicted.description || description,
534✔
725
      schema: result,
726
      examples: depictParamExamples(schema, name),
727
    };
728
  });
729
};
730

731
export const depicters: HandlingRules<
732
  SchemaObject | ReferenceObject,
733
  OpenAPIContext,
734
  z.ZodFirstPartyTypeKind | ProprietaryBrand
735
> = {
156✔
736
  ZodString: depictString,
737
  ZodNumber: depictNumber,
738
  ZodBigInt: depictBigInt,
739
  ZodBoolean: depictBoolean,
740
  ZodNull: depictNull,
741
  ZodArray: depictArray,
742
  ZodTuple: depictTuple,
743
  ZodRecord: depictRecord,
744
  ZodObject: depictObject,
745
  ZodLiteral: depictLiteral,
746
  ZodIntersection: depictIntersection,
747
  ZodUnion: depictUnion,
748
  ZodAny: depictAny,
749
  ZodDefault: depictDefault,
750
  ZodEnum: depictEnum,
751
  ZodNativeEnum: depictEnum,
752
  ZodEffects: depictEffect,
753
  ZodOptional: depictOptional,
754
  ZodNullable: depictNullable,
755
  ZodDiscriminatedUnion: depictDiscriminatedUnion,
756
  ZodBranded: depictBranded,
757
  ZodDate: depictDate,
758
  ZodCatch: depictCatch,
759
  ZodPipeline: depictPipeline,
760
  ZodLazy: depictLazy,
761
  ZodReadonly: depictReadonly,
762
  [ezFileBrand]: depictFile,
763
  [ezUploadBrand]: depictUpload,
764
  [ezDateOutBrand]: depictDateOut,
765
  [ezDateInBrand]: depictDateIn,
766
  [ezRawBrand]: depictRaw,
767
};
768

769
export const onEach: SchemaHandler<
770
  SchemaObject | ReferenceObject,
771
  OpenAPIContext,
772
  "each"
773
> = (schema: z.ZodTypeAny, { isResponse, prev }) => {
156✔
774
  if (isReferenceObject(prev)) {
5,700✔
775
    return {};
54✔
776
  }
777
  const { description } = schema;
5,646✔
778
  const shouldAvoidParsing = schema instanceof z.ZodLazy;
5,646✔
779
  const hasTypePropertyInDepiction = prev.type !== undefined;
5,646✔
780
  const isResponseHavingCoercion = isResponse && hasCoercion(schema);
5,646✔
781
  const isActuallyNullable =
782
    !shouldAvoidParsing &&
5,646✔
783
    hasTypePropertyInDepiction &&
784
    !isResponseHavingCoercion &&
785
    schema.isNullable();
786
  const examples = shouldAvoidParsing
5,646!
787
    ? []
788
    : getExamples({
789
        schema,
790
        variant: isResponse ? "parsed" : "original",
5,646✔
791
        validate: true,
792
      });
793
  const result: SchemaObject = {};
5,646✔
794
  if (description) {
5,646✔
795
    result.description = description;
30✔
796
  }
797
  if (isActuallyNullable) {
5,646✔
798
    result.type = makeNullableType(prev);
78✔
799
  }
800
  if (examples.length) {
5,646✔
801
    result.examples = examples.slice();
564✔
802
  }
803
  return result;
5,646✔
804
};
805

806
export const onMissing: SchemaHandler<
807
  SchemaObject | ReferenceObject,
808
  OpenAPIContext,
809
  "last"
810
> = (schema: z.ZodTypeAny, ctx) =>
156✔
811
  assert.fail(
42✔
812
    new DocumentationError({
813
      message: `Zod type ${schema.constructor.name} is unsupported.`,
814
      ...ctx,
815
    }),
816
  );
817

818
export const excludeParamsFromDepiction = (
156✔
819
  depicted: SchemaObject | ReferenceObject,
820
  names: string[],
821
): SchemaObject | ReferenceObject => {
822
  if (isReferenceObject(depicted)) {
276✔
823
    return depicted;
6✔
824
  }
825
  const copy = { ...depicted };
270✔
826
  if (copy.properties) {
270✔
827
    copy.properties = omit(names, copy.properties);
204✔
828
  }
829
  if (copy.examples) {
270✔
830
    copy.examples = copy.examples.map((entry) => omit(names, entry));
30✔
831
  }
832
  if (copy.required) {
270✔
833
    copy.required = copy.required.filter((name) => !names.includes(name));
462✔
834
  }
835
  if (copy.allOf) {
270✔
836
    copy.allOf = copy.allOf.map((entry) =>
6✔
837
      excludeParamsFromDepiction(entry, names),
12✔
838
    );
839
  }
840
  if (copy.oneOf) {
270✔
841
    copy.oneOf = copy.oneOf.map((entry) =>
18✔
842
      excludeParamsFromDepiction(entry, names),
36✔
843
    );
844
  }
845
  return copy;
270✔
846
};
847

848
export const excludeExamplesFromDepiction = (
156✔
849
  depicted: SchemaObject | ReferenceObject,
850
): SchemaObject | ReferenceObject =>
851
  isReferenceObject(depicted) ? depicted : omit(["examples"], depicted);
1,092!
852

853
export const depictResponse = ({
156✔
854
  method,
855
  path,
856
  schema,
857
  mimeTypes,
858
  variant,
859
  serializer,
860
  getRef,
861
  makeRef,
862
  composition,
863
  hasMultipleStatusCodes,
864
  statusCode,
865
  brandHandling,
866
  description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${
864✔
867
    hasMultipleStatusCodes ? statusCode : ""
864✔
868
  }`.trim(),
869
}: ReqResHandlingProps<z.ZodTypeAny> & {
870
  mimeTypes: ReadonlyArray<string>;
871
  variant: "positive" | "negative";
872
  statusCode: number;
873
  hasMultipleStatusCodes: boolean;
874
}): ResponseObject => {
875
  const depictedSchema = excludeExamplesFromDepiction(
888✔
876
    walkSchema(schema, {
877
      rules: { ...brandHandling, ...depicters },
878
      onEach,
879
      onMissing,
880
      ctx: {
881
        isResponse: true,
882
        serializer,
883
        getRef,
884
        makeRef,
885
        path,
886
        method,
887
      },
888
    }),
889
  );
890
  const media: MediaTypeObject = {
888✔
891
    schema:
892
      composition === "components"
888✔
893
        ? makeRef(makeCleanId(description), depictedSchema)
894
        : depictedSchema,
895
    examples: depictExamples(schema, true),
896
  };
897
  return { description, content: fromPairs(xprod(mimeTypes, [media])) };
888✔
898
};
899

900
type SecurityHelper<K extends Security["type"]> = (
901
  security: Extract<Security, { type: K }>,
902
  inputSources?: InputSource[],
903
) => SecuritySchemeObject;
904

905
const depictBasicSecurity: SecurityHelper<"basic"> = () => ({
156✔
906
  type: "http",
907
  scheme: "basic",
908
});
909
const depictBearerSecurity: SecurityHelper<"bearer"> = ({
156✔
910
  format: bearerFormat,
911
}) => {
912
  const result: SecuritySchemeObject = {
24✔
913
    type: "http",
914
    scheme: "bearer",
915
  };
916
  if (bearerFormat) {
24✔
917
    result.bearerFormat = bearerFormat;
6✔
918
  }
919
  return result;
24✔
920
};
921
const depictInputSecurity: SecurityHelper<"input"> = (
156✔
922
  { name },
923
  inputSources,
924
) => {
925
  const result: SecuritySchemeObject = {
36✔
926
    type: "apiKey",
927
    in: "query",
928
    name,
929
  };
930
  if (inputSources?.includes("body")) {
36✔
931
    if (inputSources?.includes("query")) {
24✔
932
      result["x-in-alternative"] = "body";
6✔
933
      result.description = `${name} CAN also be supplied within the request body`;
6✔
934
    } else {
935
      result["x-in-actual"] = "body";
18✔
936
      result.description = `${name} MUST be supplied within the request body instead of query`;
18✔
937
    }
938
  }
939
  return result;
36✔
940
};
941
const depictHeaderSecurity: SecurityHelper<"header"> = ({ name }) => ({
156✔
942
  type: "apiKey",
943
  in: "header",
944
  name,
945
});
946
const depictCookieSecurity: SecurityHelper<"cookie"> = ({ name }) => ({
156✔
947
  type: "apiKey",
948
  in: "cookie",
949
  name,
950
});
951
const depictOpenIdSecurity: SecurityHelper<"openid"> = ({
156✔
952
  url: openIdConnectUrl,
953
}) => ({
6✔
954
  type: "openIdConnect",
955
  openIdConnectUrl,
956
});
957
const depictOAuth2Security: SecurityHelper<"oauth2"> = ({ flows = {} }) => ({
156✔
958
  type: "oauth2",
959
  flows: map(
960
    (flow): OAuthFlowObject => ({ ...flow, scopes: flow.scopes || {} }),
36✔
961
    reject(isNil, flows) as Required<typeof flows>,
962
  ),
963
});
964

965
export const depictSecurity = (
156✔
966
  container: LogicalContainer<Security>,
967
  inputSources?: InputSource[],
968
): LogicalContainer<SecuritySchemeObject> => {
969
  const methods: { [K in Security["type"]]: SecurityHelper<K> } = {
426✔
970
    basic: depictBasicSecurity,
971
    bearer: depictBearerSecurity,
972
    input: depictInputSecurity,
973
    header: depictHeaderSecurity,
974
    cookie: depictCookieSecurity,
975
    openid: depictOpenIdSecurity,
976
    oauth2: depictOAuth2Security,
977
  };
978
  return mapLogicalContainer(container, (security) =>
426✔
979
    (methods[security.type] as SecurityHelper<typeof security.type>)(
126✔
980
      security,
981
      inputSources,
982
    ),
983
  );
984
};
985

986
export const depictSecurityRefs = (
156✔
987
  container: LogicalContainer<{ name: string; scopes: string[] }>,
988
): SecurityRequirementObject[] => {
989
  if ("or" in container) {
804✔
990
    return container.or.map(
414✔
991
      (entry): SecurityRequirementObject =>
992
        "and" in entry
108✔
993
          ? mergeAll(map(({ name, scopes }) => objOf(name, scopes), entry.and))
90✔
994
          : { [entry.name]: entry.scopes },
995
    );
996
  }
997
  if ("and" in container) {
390✔
998
    return depictSecurityRefs(andToOr(container));
384✔
999
  }
1000
  return depictSecurityRefs({ or: [container] });
6✔
1001
};
1002

1003
export const depictBody = ({
156✔
1004
  method,
1005
  path,
1006
  schema,
1007
  mimeTypes,
1008
  serializer,
1009
  getRef,
1010
  makeRef,
1011
  composition,
1012
  brandHandling,
1013
  paramNames,
1014
  description = `${method.toUpperCase()} ${path} Request body`,
228✔
1015
}: ReqResHandlingProps<IOSchema> & {
1016
  mimeTypes: ReadonlyArray<string>;
1017
  paramNames: string[];
1018
}): RequestBodyObject => {
1019
  const bodyDepiction = excludeExamplesFromDepiction(
240✔
1020
    excludeParamsFromDepiction(
1021
      walkSchema(schema, {
1022
        rules: { ...brandHandling, ...depicters },
1023
        onEach,
1024
        onMissing,
1025
        ctx: {
1026
          isResponse: false,
1027
          serializer,
1028
          getRef,
1029
          makeRef,
1030
          path,
1031
          method,
1032
        },
1033
      }),
1034
      paramNames,
1035
    ),
1036
  );
1037
  const media: MediaTypeObject = {
198✔
1038
    schema:
1039
      composition === "components"
198✔
1040
        ? makeRef(makeCleanId(description), bodyDepiction)
1041
        : bodyDepiction,
1042
    examples: depictExamples(schema, false, paramNames),
1043
  };
1044
  return { description, content: fromPairs(xprod(mimeTypes, [media])) };
198✔
1045
};
1046

1047
export const depictTags = <TAG extends string>(
156✔
1048
  tags: TagsConfig<TAG>,
1049
): TagObject[] =>
1050
  (Object.keys(tags) as TAG[]).map((tag) => {
24✔
1051
    const def = tags[tag];
48✔
1052
    const result: TagObject = {
48✔
1053
      name: tag,
1054
      description: typeof def === "string" ? def : def.description,
48✔
1055
    };
1056
    if (typeof def === "object" && def.url) {
48✔
1057
      result.externalDocs = { url: def.url };
6✔
1058
    }
1059
    return result;
48✔
1060
  });
1061

1062
export const ensureShortDescription = (description: string) =>
156✔
1063
  description.length <= shortDescriptionLimit
126✔
1064
    ? description
1065
    : description.slice(0, shortDescriptionLimit - 1) + "…";
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