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

RobinTail / express-zod-api / 10928178564

18 Sep 2024 06:39PM CUT coverage: 100.0%. Remained the same
10928178564

Pull #2046

github

web-flow
Merge 27ad23f36 into 9e965c751
Pull Request #2046: Bump the typescript-eslint group with 2 updates

755 of 792 branches covered (95.33%)

1200 of 1200 relevant lines covered (100.0%)

434.22 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
  concat,
21
  type as detectType,
22
  filter,
23
  fromPairs,
24
  has,
25
  isNil,
26
  map,
27
  mergeAll,
28
  mergeDeepRight,
29
  mergeDeepWith,
30
  objOf,
31
  omit,
32
  pipe,
33
  pluck,
34
  range,
35
  reject,
36
  toLower,
37
  union,
38
  when,
39
  xprod,
40
  zip,
41
} from "ramda";
42
import { z } from "zod";
43
import {
44
  FlatObject,
45
  combinations,
46
  getExamples,
47
  hasCoercion,
48
  isCustomHeader,
49
  makeCleanId,
50
  tryToTransform,
51
  ucFirst,
52
} from "./common-helpers";
53
import { InputSource, TagsConfig } from "./config-type";
54
import { DateInSchema, ezDateInBrand } from "./date-in-schema";
55
import { DateOutSchema, ezDateOutBrand } from "./date-out-schema";
56
import { DocumentationError } from "./errors";
57
import { FileSchema, ezFileBrand } from "./file-schema";
58
import { IOSchema } from "./io-schema";
59
import {
60
  LogicalContainer,
61
  andToOr,
62
  mapLogicalContainer,
63
} from "./logical-container";
64
import { metaSymbol } from "./metadata";
65
import { Method } from "./method";
66
import { ProprietaryBrand } from "./proprietary-schemas";
67
import { RawSchema, ezRawBrand } from "./raw-schema";
68
import { HandlingRules, SchemaHandler, walkSchema } from "./schema-walker";
69
import { Security } from "./security";
70
import { UploadSchema, ezUploadBrand } from "./upload-schema";
71

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

84
export type Depicter = SchemaHandler<
85
  SchemaObject | ReferenceObject,
86
  OpenAPIContext
87
>;
88

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

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

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

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

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

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

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

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

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

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

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

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

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

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

220
export const depictIntersection: Depicter = (
156✔
221
  { _def: { left, right } }: z.ZodIntersection<z.ZodTypeAny, z.ZodTypeAny>,
222
  { next },
223
) => {
224
  const children = [left, right].map(next);
96✔
225
  try {
96✔
226
    return tryFlattenIntersection(children);
96✔
227
  } catch {}
228
  return { allOf: children };
18✔
229
};
230

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

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

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

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

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

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

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

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

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

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

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

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

359
export const depictBigInt: Depicter = () => ({
156✔
360
  type: "integer",
361
  format: "bigint",
362
});
363

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

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

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

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

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

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

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

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

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

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

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

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

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

598
export const depictRaw: Depicter = (schema: RawSchema, { next }) =>
156✔
599
  next(schema.unwrap().shape.raw);
12✔
600

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

611
export const depictExamples = (
156✔
612
  schema: z.ZodTypeAny,
613
  isResponse: boolean,
614
  omitProps: string[] = [],
888✔
615
): ExamplesObject | undefined =>
616
  pipe(
1,098✔
617
    getExamples,
618
    map(when((subj) => detectType(subj) === "Object", omit(omitProps))),
480✔
619
    enumerateExamples,
620
  )({ schema, variant: isResponse ? "parsed" : "original", validate: true });
1,098✔
621

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1059
export const ensureShortDescription = (description: string) =>
156✔
1060
  description.length <= shortDescriptionLimit
126✔
1061
    ? description
1062
    : 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