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

RobinTail / express-zod-api / 8348850131

19 Mar 2024 07:14PM CUT coverage: 100.0%. Remained the same
8348850131

push

github

RobinTail
17.4.1-beta1

665 of 698 branches covered (95.27%)

1096 of 1096 relevant lines covered (100.0%)

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

75
/* eslint-disable @typescript-eslint/no-use-before-define */
76

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

89
type Depicter<
90
  T extends z.ZodTypeAny,
91
  Variant extends HandlingVariant = "regular",
92
> = SchemaHandler<T, SchemaObject | ReferenceObject, OpenAPIContext, Variant>;
93

94
interface ReqResDepictHelperCommonProps
95
  extends Pick<
96
    OpenAPIContext,
97
    "serializer" | "getRef" | "makeRef" | "path" | "method"
98
  > {
99
  schema: z.ZodTypeAny;
100
  mimeTypes: string[];
101
  composition: "inline" | "components";
102
  description?: string;
103
}
104

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

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

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

122
export const getRoutePathParams = (path: string): string[] =>
182✔
123
  path.match(routePathParamsRegex)?.map((param) => param.slice(1)) || [];
826✔
124

125
export const reformatParamsInPath = (path: string) =>
182✔
126
  path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`);
434✔
127

128
export const depictDefault: Depicter<z.ZodDefault<z.ZodTypeAny>> = ({
182✔
129
  schema: {
130
    _def: { innerType, defaultValue },
131
  },
132
  next,
133
}) => ({ ...next(innerType), default: defaultValue() });
21✔
134

135
export const depictCatch: Depicter<z.ZodCatch<z.ZodTypeAny>> = ({
182✔
136
  schema: {
137
    _def: { innerType },
138
  },
139
  next,
140
}) => next(innerType);
7✔
141

142
export const depictAny: Depicter<z.ZodAny> = () => ({
182✔
143
  format: "any",
144
});
145

146
export const depictUpload: Depicter<z.ZodType> = (ctx) => {
182✔
147
  assert(
28✔
148
    !ctx.isResponse,
149
    new DocumentationError({
150
      message: "Please use ez.upload() only for input.",
151
      ...ctx,
152
    }),
153
  );
154
  return {
21✔
155
    type: "string",
156
    format: "binary",
157
  };
158
};
159

160
export const depictFile: Depicter<z.ZodType> = ({ schema }) => ({
182✔
161
  type: "string",
162
  format:
163
    schema instanceof z.ZodString
63✔
164
      ? schema._def.checks.find((check) => check.kind === "regex")
7✔
165
        ? "byte"
166
        : "file"
167
      : "binary",
168
});
169

170
export const depictUnion: Depicter<z.ZodUnion<z.ZodUnionOptions>> = ({
182✔
171
  schema: { options },
172
  next,
173
}) => ({ oneOf: options.map(next) });
91✔
174

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

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

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

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

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

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

260
export const depictEnum: Depicter<
261
  z.ZodEnum<[string, ...string[]]> | z.ZodNativeEnum<any> // keeping "any" for ZodNativeEnum as compatibility fix
262
> = ({ schema }) => ({
182✔
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>> = ({
182✔
268
  schema: { value },
269
}) => ({
1,057✔
270
  type: typeof value as "string" | "number" | "boolean",
271
  enum: [value],
272
});
273

274
export const depictObject: Depicter<z.ZodObject<z.ZodRawShape>> = ({
182✔
275
  schema,
276
  isResponse,
277
  ...rest
278
}) => {
279
  const keys = Object.keys(schema.shape);
2,296✔
280
  const isOptionalProp = (prop: z.ZodTypeAny) =>
2,296✔
281
    isResponse && hasCoercion(prop)
3,528✔
282
      ? prop instanceof z.ZodOptional
283
      : prop.isOptional();
284
  const required = keys.filter((key) => !isOptionalProp(schema.shape[key]));
3,528✔
285
  const result: SchemaObject = { type: "object" };
2,296✔
286
  if (keys.length) {
2,296✔
287
    result.properties = depictObjectProperties({ schema, isResponse, ...rest });
2,100✔
288
  }
289
  if (required.length) {
2,247✔
290
    result.required = required;
2,030✔
291
  }
292
  return result;
2,247✔
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" });
182✔
300

301
export const depictDateIn: Depicter<z.ZodType> = (ctx) => {
182✔
302
  assert(
35✔
303
    !ctx.isResponse,
304
    new DocumentationError({
305
      message: "Please use ez.dateOut() for output.",
306
      ...ctx,
307
    }),
308
  );
309
  return {
28✔
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) => {
182✔
321
  assert(
35✔
322
    ctx.isResponse,
323
    new DocumentationError({
324
      message: "Please use ez.dateIn() for input.",
325
      ...ctx,
326
    }),
327
  );
328
  return {
28✔
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) =>
182✔
340
  assert.fail(
14✔
341
    new DocumentationError({
342
      message: `Using z.date() within ${
343
        ctx.isResponse ? "output" : "input"
14✔
344
      } schema is forbidden. Please use ez.date${
345
        ctx.isResponse ? "Out" : "In"
14✔
346
      }() instead. Check out the documentation for details.`,
347
      ...ctx,
348
    }),
349
  );
350

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

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

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

365
export const depictRecord: Depicter<z.ZodRecord<z.ZodTypeAny>> = ({
182✔
366
  schema: { keySchema, valueSchema },
367
  ...rest
368
}) => {
369
  if (keySchema instanceof z.ZodEnum || keySchema instanceof z.ZodNativeEnum) {
126✔
370
    const keys = Object.values(keySchema.enum) as string[];
14✔
371
    const result: SchemaObject = { type: "object" };
14✔
372
    if (keys.length) {
14!
373
      result.properties = depictObjectProperties({
14✔
374
        schema: z.object(fromPairs(xprod(keys, [valueSchema]))),
375
        ...rest,
376
      });
377
      result.required = keys;
14✔
378
    }
379
    return result;
14✔
380
  }
381
  if (keySchema instanceof z.ZodLiteral) {
112✔
382
    return {
28✔
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)) {
84✔
392
    const required = map((opt) => `${opt.value}`, keySchema.options);
28✔
393
    const shape = fromPairs(xprod(required, [valueSchema]));
14✔
394
    return {
14✔
395
      type: "object",
396
      properties: depictObjectProperties({ schema: z.object(shape), ...rest }),
397
      required,
398
    };
399
  }
400
  return { type: "object", additionalProperties: rest.next(valueSchema) };
70✔
401
};
402

403
export const depictArray: Depicter<z.ZodArray<z.ZodTypeAny>> = ({
182✔
404
  schema: { _def: def, element },
405
  next,
406
}) => {
407
  const result: SchemaObject = { type: "array", items: next(element) };
105✔
408
  if (def.minLength) {
105✔
409
    result.minItems = def.minLength.value;
35✔
410
  }
411
  if (def.maxLength) {
105✔
412
    result.maxItems = def.maxLength.value;
7✔
413
  }
414
  return result;
105✔
415
};
416

417
/** @since OAS 3.1 using prefixItems for depicting tuples */
418
export const depictTuple: Depicter<z.ZodTuple> = ({
182✔
419
  schema: { items },
420
  next,
421
}) => ({ type: "array", prefixItems: items.map(next) });
35✔
422

423
export const depictString: Depicter<z.ZodString> = ({
182✔
424
  schema: {
425
    isEmail,
426
    isURL,
427
    minLength,
428
    maxLength,
429
    isUUID,
430
    isCUID,
431
    isCUID2,
432
    isULID,
433
    isIP,
434
    isEmoji,
435
    isDatetime,
436
    _def: { checks },
437
  },
438
}) => {
439
  const regexCheck = checks.find(
1,463✔
440
    (check): check is z.ZodStringCheck & { kind: "regex" } =>
441
      check.kind === "regex",
294✔
442
  );
443
  const datetimeCheck = checks.find(
1,463✔
444
    (check): check is z.ZodStringCheck & { kind: "datetime" } =>
445
      check.kind === "datetime",
301✔
446
  );
447
  const regex = regexCheck
1,463✔
448
    ? regexCheck.regex
449
    : datetimeCheck
1,400✔
450
      ? datetimeCheck.offset
14✔
451
        ? new RegExp(
452
            `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}:\\d{2})|Z)$`,
453
          )
454
        : new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$`)
455
      : undefined;
456
  const result: SchemaObject = { type: "string" };
1,463✔
457
  const formats: Record<NonNullable<SchemaObject["format"]>, boolean> = {
1,463✔
458
    "date-time": isDatetime,
459
    email: isEmail,
460
    url: isURL,
461
    uuid: isUUID,
462
    cuid: isCUID,
463
    cuid2: isCUID2,
464
    ulid: isULID,
465
    ip: isIP,
466
    emoji: isEmoji,
467
  };
468
  for (const format in formats) {
1,463✔
469
    if (formats[format]) {
12,656✔
470
      result.format = format;
105✔
471
      break;
105✔
472
    }
473
  }
474
  if (minLength !== null) {
1,463✔
475
    result.minLength = minLength;
84✔
476
  }
477
  if (maxLength !== null) {
1,463✔
478
    result.maxLength = maxLength;
28✔
479
  }
480
  if (regex) {
1,463✔
481
    result.pattern = regex.source;
77✔
482
  }
483
  return result;
1,463✔
484
};
485

486
/** @since OAS 3.1: exclusive min/max are numbers */
487
export const depictNumber: Depicter<z.ZodNumber> = ({ schema }) => {
182✔
488
  const minCheck = schema._def.checks.find(({ kind }) => kind === "min") as
427✔
489
    | Extract<z.ZodNumberCheck, { kind: "min" }>
490
    | undefined;
491
  const minimum =
492
    schema.minValue === null
427✔
493
      ? schema.isInt
252✔
494
        ? Number.MIN_SAFE_INTEGER
495
        : -Number.MAX_VALUE
496
      : schema.minValue;
497
  const isMinInclusive = minCheck ? minCheck.inclusive : true;
427✔
498
  const maxCheck = schema._def.checks.find(({ kind }) => kind === "max") as
427✔
499
    | Extract<z.ZodNumberCheck, { kind: "max" }>
500
    | undefined;
501
  const maximum =
502
    schema.maxValue === null
427✔
503
      ? schema.isInt
378✔
504
        ? Number.MAX_SAFE_INTEGER
505
        : Number.MAX_VALUE
506
      : schema.maxValue;
507
  const isMaxInclusive = maxCheck ? maxCheck.inclusive : true;
427✔
508
  const result: SchemaObject = {
427✔
509
    type: schema.isInt ? "integer" : "number",
427✔
510
    format: schema.isInt ? "int64" : "double",
427✔
511
  };
512
  if (isMinInclusive) {
427✔
513
    result.minimum = minimum;
336✔
514
  } else {
515
    result.exclusiveMinimum = minimum;
91✔
516
  }
517
  if (isMaxInclusive) {
427✔
518
    result.maximum = maximum;
406✔
519
  } else {
520
    result.exclusiveMaximum = maximum;
21✔
521
  }
522
  return result;
427✔
523
};
524

525
export const depictObjectProperties = ({
182✔
526
  schema: { shape },
527
  next,
528
}: Parameters<Depicter<z.ZodObject<z.ZodRawShape>>>[0]) => map(next, shape);
2,163✔
529

530
const makeSample = (depicted: SchemaObject) => {
182✔
531
  const type = (
532
    Array.isArray(depicted.type) ? depicted.type[0] : depicted.type
49!
533
  ) as keyof typeof samples;
534
  return samples?.[type];
49✔
535
};
536

537
const makeNullableType = (prev: SchemaObject): SchemaObjectType[] => {
182✔
538
  const current = typeof prev.type === "string" ? [prev.type] : prev.type || [];
161!
539
  if (current.includes("null")) {
161✔
540
    return current;
84✔
541
  }
542
  return current.concat("null");
77✔
543
};
544

545
export const depictEffect: Depicter<z.ZodEffects<z.ZodTypeAny>> = ({
182✔
546
  schema,
547
  isResponse,
548
  next,
549
}) => {
550
  const input = next(schema.innerType());
231✔
551
  const { effect } = schema._def;
231✔
552
  if (isResponse && effect.type === "transform" && !isReferenceObject(input)) {
231✔
553
    const outputType = tryToTransform(schema, makeSample(input));
49✔
554
    if (outputType && ["number", "string", "boolean"].includes(outputType)) {
49✔
555
      return { type: outputType as "number" | "string" | "boolean" };
35✔
556
    } else {
557
      return next(z.any());
14✔
558
    }
559
  }
560
  if (
182✔
561
    !isResponse &&
378✔
562
    effect.type === "preprocess" &&
563
    !isReferenceObject(input)
564
  ) {
565
    const { type: inputType, ...rest } = input;
21✔
566
    return {
21✔
567
      ...rest,
568
      format: `${rest.format || inputType} (preprocessed)`,
35✔
569
    };
570
  }
571
  return input;
161✔
572
};
573

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

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

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

600
export const depictRaw: Depicter<RawSchema> = ({ next, schema }) =>
182✔
601
  next(schema.shape.raw);
14✔
602

603
const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined =>
182✔
604
  examples.length
1,470✔
605
    ? zipObj(
606
        range(1, examples.length + 1).map((idx) => `example${idx}`),
560✔
607
        map(objOf("value"), examples),
608
      )
609
    : undefined;
610

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

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

633
export const extractObjectSchema = (
182✔
634
  subject: IOSchema,
635
  tfError: DocumentationError,
636
): z.ZodObject<z.ZodRawShape> => {
637
  if (subject instanceof z.ZodObject) {
693✔
638
    return subject;
595✔
639
  }
640
  if (
98✔
641
    subject instanceof z.ZodUnion ||
182✔
642
    subject instanceof z.ZodDiscriminatedUnion
643
  ) {
644
    return Array.from(subject.options.values())
21✔
645
      .map((option) => extractObjectSchema(option, tfError))
42✔
646
      .reduce((acc, option) => acc.merge(option.partial()), z.object({}));
42✔
647
  } else if (subject instanceof z.ZodEffects) {
77✔
648
    assert(subject._def.effect.type === "refinement", tfError);
14✔
649
    return extractObjectSchema(subject._def.schema, tfError); // object refinement
7✔
650
  } // intersection left
651
  return extractObjectSchema(subject._def.left, tfError).merge(
63✔
652
    extractObjectSchema(subject._def.right, tfError),
653
  );
654
};
655

656
export const depictRequestParams = ({
182✔
657
  path,
658
  method,
659
  schema,
660
  inputSources,
661
  serializer,
662
  getRef,
663
  makeRef,
664
  composition,
665
  description = `${method.toUpperCase()} ${path} Parameter`,
469✔
666
}: Omit<ReqResDepictHelperCommonProps, "mimeTypes"> & {
667
  inputSources: InputSource[];
668
}): ParameterObject[] => {
669
  const { shape } = extractObjectSchema(
483✔
670
    schema,
671
    new DocumentationError({
672
      message: `Using transformations on the top level schema is not allowed.`,
673
      path,
674
      method,
675
      isResponse: false,
676
    }),
677
  );
678
  const pathParams = getRoutePathParams(path);
483✔
679
  const isQueryEnabled = inputSources.includes("query");
483✔
680
  const areParamsEnabled = inputSources.includes("params");
483✔
681
  const areHeadersEnabled = inputSources.includes("headers");
483✔
682
  const isPathParam = (name: string) =>
483✔
683
    areParamsEnabled && pathParams.includes(name);
812✔
684
  const isHeaderParam = (name: string) =>
483✔
685
    areHeadersEnabled && isCustomHeader(name);
203✔
686
  return Object.keys(shape)
483✔
687
    .filter((name) => isQueryEnabled || isPathParam(name))
770✔
688
    .map((name) => {
689
      const depicted = walkSchema({
266✔
690
        schema: shape[name],
691
        isResponse: false,
692
        rules: depicters,
693
        onEach,
694
        onMissing,
695
        serializer,
696
        getRef,
697
        makeRef,
698
        path,
699
        method,
700
      });
701
      const result =
702
        composition === "components"
266✔
703
          ? makeRef(makeCleanId(description, name), depicted)
704
          : depicted;
705
      return {
266✔
706
        name,
707
        in: isPathParam(name)
266✔
708
          ? "path"
709
          : isHeaderParam(name)
203✔
710
            ? "header"
711
            : "query",
712
        required: !shape[name].isOptional(),
713
        description: depicted.description || description,
504✔
714
        schema: result,
715
        examples: depictParamExamples(schema, name),
716
      };
717
    });
718
};
719

720
export const depicters: HandlingRules<
721
  SchemaObject | ReferenceObject,
722
  OpenAPIContext
723
> = {
182✔
724
  ZodString: depictString,
725
  ZodNumber: depictNumber,
726
  ZodBigInt: depictBigInt,
727
  ZodBoolean: depictBoolean,
728
  ZodNull: depictNull,
729
  ZodArray: depictArray,
730
  ZodTuple: depictTuple,
731
  ZodRecord: depictRecord,
732
  ZodObject: depictObject,
733
  ZodLiteral: depictLiteral,
734
  ZodIntersection: depictIntersection,
735
  ZodUnion: depictUnion,
736
  ZodAny: depictAny,
737
  ZodDefault: depictDefault,
738
  ZodEnum: depictEnum,
739
  ZodNativeEnum: depictEnum,
740
  ZodEffects: depictEffect,
741
  ZodOptional: depictOptional,
742
  ZodNullable: depictNullable,
743
  ZodDiscriminatedUnion: depictDiscriminatedUnion,
744
  ZodBranded: depictBranded,
745
  ZodDate: depictDate,
746
  ZodCatch: depictCatch,
747
  ZodPipeline: depictPipeline,
748
  ZodLazy: depictLazy,
749
  ZodReadonly: depictReadonly,
750
  [ezFileKind]: depictFile,
751
  [ezUploadKind]: depictUpload,
752
  [ezDateOutKind]: depictDateOut,
753
  [ezDateInKind]: depictDateIn,
754
  [ezRawKind]: depictRaw,
755
};
756

757
export const onEach: Depicter<z.ZodTypeAny, "each"> = ({
182✔
758
  schema,
759
  isResponse,
760
  prev,
761
}) => {
762
  if (isReferenceObject(prev)) {
6,251✔
763
    return {};
63✔
764
  }
765
  const { description } = schema;
6,188✔
766
  const shouldAvoidParsing = schema instanceof z.ZodLazy;
6,188✔
767
  const hasTypePropertyInDepiction = prev.type !== undefined;
6,188✔
768
  const isResponseHavingCoercion = isResponse && hasCoercion(schema);
6,188✔
769
  const isActuallyNullable =
770
    !shouldAvoidParsing &&
6,188✔
771
    hasTypePropertyInDepiction &&
772
    !isResponseHavingCoercion &&
773
    schema.isNullable();
774
  const examples = shouldAvoidParsing
6,188!
775
    ? []
776
    : getExamples({
777
        schema,
778
        variant: isResponse ? "parsed" : "original",
6,188✔
779
        validate: true,
780
      });
781
  const result: SchemaObject = {};
6,188✔
782
  if (description) {
6,188✔
783
    result.description = description;
35✔
784
  }
785
  if (isActuallyNullable) {
6,188✔
786
    result.type = makeNullableType(prev);
91✔
787
  }
788
  if (examples.length) {
6,188✔
789
    result.examples = Array.from(examples);
623✔
790
  }
791
  return result;
6,188✔
792
};
793

794
export const onMissing: Depicter<z.ZodTypeAny, "last"> = ({ schema, ...ctx }) =>
182✔
795
  assert.fail(
49✔
796
    new DocumentationError({
797
      message: `Zod type ${schema.constructor.name} is unsupported.`,
798
      ...ctx,
799
    }),
800
  );
801

802
export const excludeParamsFromDepiction = (
182✔
803
  depicted: SchemaObject | ReferenceObject,
804
  pathParams: string[],
805
): SchemaObject | ReferenceObject => {
806
  if (isReferenceObject(depicted)) {
308✔
807
    return depicted;
7✔
808
  }
809
  const copy = { ...depicted };
301✔
810
  if (copy.properties) {
301✔
811
    copy.properties = omit(pathParams, copy.properties);
224✔
812
  }
813
  if (copy.examples) {
301✔
814
    copy.examples = copy.examples.map((entry) => omit(pathParams, entry));
35✔
815
  }
816
  if (copy.required) {
301✔
817
    copy.required = copy.required.filter((name) => !pathParams.includes(name));
511✔
818
  }
819
  if (copy.allOf) {
301✔
820
    copy.allOf = copy.allOf.map((entry) =>
7✔
821
      excludeParamsFromDepiction(entry, pathParams),
14✔
822
    );
823
  }
824
  if (copy.oneOf) {
301✔
825
    copy.oneOf = copy.oneOf.map((entry) =>
21✔
826
      excludeParamsFromDepiction(entry, pathParams),
42✔
827
    );
828
  }
829
  return copy;
301✔
830
};
831

832
export const excludeExamplesFromDepiction = (
182✔
833
  depicted: SchemaObject | ReferenceObject,
834
): SchemaObject | ReferenceObject =>
835
  isReferenceObject(depicted) ? depicted : omit(["examples"], depicted);
1,190!
836

837
export const depictResponse = ({
182✔
838
  method,
839
  path,
840
  schema,
841
  mimeTypes,
842
  variant,
843
  serializer,
844
  getRef,
845
  makeRef,
846
  composition,
847
  hasMultipleStatusCodes,
848
  statusCode,
849
  description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${
938✔
850
    hasMultipleStatusCodes ? statusCode : ""
938✔
851
  }`.trim(),
852
}: ReqResDepictHelperCommonProps & {
853
  variant: "positive" | "negative";
854
  statusCode: number;
855
  hasMultipleStatusCodes: boolean;
856
}): ResponseObject => {
857
  const depictedSchema = excludeExamplesFromDepiction(
966✔
858
    walkSchema({
859
      schema,
860
      isResponse: true,
861
      rules: depicters,
862
      onEach,
863
      onMissing,
864
      serializer,
865
      getRef,
866
      makeRef,
867
      path,
868
      method,
869
    }),
870
  );
871
  const media: MediaTypeObject = {
966✔
872
    schema:
873
      composition === "components"
966✔
874
        ? makeRef(makeCleanId(description), depictedSchema)
875
        : depictedSchema,
876
    examples: depictExamples(schema, true),
877
  };
878
  return { description, content: fromPairs(xprod(mimeTypes, [media])) };
966✔
879
};
880

881
type SecurityHelper<K extends Security["type"]> = (
882
  security: Security & { type: K },
883
  inputSources?: InputSource[],
884
) => SecuritySchemeObject;
885

886
const depictBasicSecurity: SecurityHelper<"basic"> = () => ({
182✔
887
  type: "http",
888
  scheme: "basic",
889
});
890
const depictBearerSecurity: SecurityHelper<"bearer"> = ({
182✔
891
  format: bearerFormat,
892
}) => {
893
  const result: SecuritySchemeObject = {
28✔
894
    type: "http",
895
    scheme: "bearer",
896
  };
897
  if (bearerFormat) {
28✔
898
    result.bearerFormat = bearerFormat;
7✔
899
  }
900
  return result;
28✔
901
};
902
const depictInputSecurity: SecurityHelper<"input"> = (
182✔
903
  { name },
904
  inputSources,
905
) => {
906
  const result: SecuritySchemeObject = {
42✔
907
    type: "apiKey",
908
    in: "query",
909
    name,
910
  };
911
  if (inputSources?.includes("body")) {
42✔
912
    if (inputSources?.includes("query")) {
28✔
913
      result["x-in-alternative"] = "body";
7✔
914
      result.description = `${name} CAN also be supplied within the request body`;
7✔
915
    } else {
916
      result["x-in-actual"] = "body";
21✔
917
      result.description = `${name} MUST be supplied within the request body instead of query`;
21✔
918
    }
919
  }
920
  return result;
42✔
921
};
922
const depictHeaderSecurity: SecurityHelper<"header"> = ({ name }) => ({
182✔
923
  type: "apiKey",
924
  in: "header",
925
  name,
926
});
927
const depictCookieSecurity: SecurityHelper<"cookie"> = ({ name }) => ({
182✔
928
  type: "apiKey",
929
  in: "cookie",
930
  name,
931
});
932
const depictOpenIdSecurity: SecurityHelper<"openid"> = ({
182✔
933
  url: openIdConnectUrl,
934
}) => ({
7✔
935
  type: "openIdConnect",
936
  openIdConnectUrl,
937
});
938
const depictOAuth2Security: SecurityHelper<"oauth2"> = ({ flows = {} }) => ({
182✔
939
  type: "oauth2",
940
  flows: map(
941
    (flow): OAuthFlowObject => ({ ...flow, scopes: flow.scopes || {} }),
42✔
942
    reject(isNil, flows) as Required<typeof flows>,
943
  ),
944
});
945

946
export const depictSecurity = (
182✔
947
  container: LogicalContainer<Security>,
948
  inputSources?: InputSource[],
949
): LogicalContainer<SecuritySchemeObject> => {
950
  const methods: { [K in Security["type"]]: SecurityHelper<K> } = {
462✔
951
    basic: depictBasicSecurity,
952
    bearer: depictBearerSecurity,
953
    input: depictInputSecurity,
954
    header: depictHeaderSecurity,
955
    cookie: depictCookieSecurity,
956
    openid: depictOpenIdSecurity,
957
    oauth2: depictOAuth2Security,
958
  };
959
  return mapLogicalContainer(container, (security) =>
462✔
960
    (methods[security.type] as SecurityHelper<typeof security.type>)(
147✔
961
      security,
962
      inputSources,
963
    ),
964
  );
965
};
966

967
export const depictSecurityRefs = (
182✔
968
  container: LogicalContainer<{ name: string; scopes: string[] }>,
969
): SecurityRequirementObject[] => {
970
  if ("or" in container) {
868✔
971
    return container.or.map(
448✔
972
      (entry): SecurityRequirementObject =>
973
        "and" in entry
126✔
974
          ? mergeAll(map(({ name, scopes }) => objOf(name, scopes), entry.and))
105✔
975
          : { [entry.name]: entry.scopes },
976
    );
977
  }
978
  if ("and" in container) {
420✔
979
    return depictSecurityRefs(andToOr(container));
413✔
980
  }
981
  return depictSecurityRefs({ or: [container] });
7✔
982
};
983

984
export const depictRequest = ({
182✔
985
  method,
986
  path,
987
  schema,
988
  mimeTypes,
989
  serializer,
990
  getRef,
991
  makeRef,
992
  composition,
993
  description = `${method.toUpperCase()} ${path} Request body`,
252✔
994
}: ReqResDepictHelperCommonProps): RequestBodyObject => {
995
  const pathParams = getRoutePathParams(path);
266✔
996
  const bodyDepiction = excludeExamplesFromDepiction(
266✔
997
    excludeParamsFromDepiction(
998
      walkSchema({
999
        schema,
1000
        isResponse: false,
1001
        rules: depicters,
1002
        onEach,
1003
        onMissing,
1004
        serializer,
1005
        getRef,
1006
        makeRef,
1007
        path,
1008
        method,
1009
      }),
1010
      pathParams,
1011
    ),
1012
  );
1013
  const media: MediaTypeObject = {
217✔
1014
    schema:
1015
      composition === "components"
217✔
1016
        ? makeRef(makeCleanId(description), bodyDepiction)
1017
        : bodyDepiction,
1018
    examples: depictExamples(schema, false, pathParams),
1019
  };
1020
  return { description, content: fromPairs(xprod(mimeTypes, [media])) };
217✔
1021
};
1022

1023
export const depictTags = <TAG extends string>(
182✔
1024
  tags: TagsConfig<TAG>,
1025
): TagObject[] =>
1026
  (Object.keys(tags) as TAG[]).map((tag) => {
28✔
1027
    const def = tags[tag];
56✔
1028
    const result: TagObject = {
56✔
1029
      name: tag,
1030
      description: typeof def === "string" ? def : def.description,
56✔
1031
    };
1032
    if (typeof def === "object" && def.url) {
56✔
1033
      result.externalDocs = { url: def.url };
7✔
1034
    }
1035
    return result;
56✔
1036
  });
1037

1038
export const ensureShortDescription = (description: string) =>
182✔
1039
  description.length <= shortDescriptionLimit
147✔
1040
    ? description
1041
    : 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