effect 3.11.5 → 3.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/JSONSchema.ts CHANGED
@@ -85,6 +85,14 @@ export interface JsonSchema7Ref extends JsonSchemaAnnotations {
85
85
  $ref: string
86
86
  }
87
87
 
88
+ /**
89
+ * @category model
90
+ * @since 3.11.7
91
+ */
92
+ export interface JsonSchema7Null extends JsonSchemaAnnotations {
93
+ type: "null"
94
+ }
95
+
88
96
  /**
89
97
  * @category model
90
98
  * @since 3.10.0
@@ -163,7 +171,8 @@ export interface JsonSchema7Array extends JsonSchemaAnnotations {
163
171
  * @since 3.10.0
164
172
  */
165
173
  export interface JsonSchema7Enum extends JsonSchemaAnnotations {
166
- enum: Array<AST.LiteralValue>
174
+ type?: "string" | "number" | "boolean"
175
+ enum: Array<string | number | boolean>
167
176
  }
168
177
 
169
178
  /**
@@ -173,6 +182,7 @@ export interface JsonSchema7Enum extends JsonSchemaAnnotations {
173
182
  export interface JsonSchema7Enums extends JsonSchemaAnnotations {
174
183
  $comment: "/schemas/enums"
175
184
  anyOf: Array<{
185
+ type: "string" | "number"
176
186
  title: string
177
187
  enum: [string | number]
178
188
  }>
@@ -211,6 +221,7 @@ export type JsonSchema7 =
211
221
  | JsonSchema7object
212
222
  | JsonSchema7empty
213
223
  | JsonSchema7Ref
224
+ | JsonSchema7Null
214
225
  | JsonSchema7String
215
226
  | JsonSchema7Number
216
227
  | JsonSchema7Integer
@@ -240,11 +251,17 @@ export const make = <A, I, R>(schema: Schema.Schema<A, I, R>): JsonSchema7Root =
240
251
  // Special case top level `parseJson` transformations
241
252
  ? schema.ast.to
242
253
  : schema.ast
243
- const out: JsonSchema7Root = fromAST(ast, {
254
+ const jsonSchema = fromAST(ast, {
244
255
  definitions
245
256
  })
246
- out.$schema = $schema
247
- if (!Record.isEmptyRecord(definitions)) {
257
+ const out: JsonSchema7Root = {
258
+ $schema,
259
+ $defs: {},
260
+ ...jsonSchema
261
+ }
262
+ if (Record.isEmptyRecord(definitions)) {
263
+ delete out.$defs
264
+ } else {
248
265
  out.$defs = definitions
249
266
  }
250
267
  return out
@@ -405,10 +422,31 @@ const isOverrideAnnotation = (jsonSchema: JsonSchema7): boolean => {
405
422
  ("enum" in jsonSchema) || ("$ref" in jsonSchema)
406
423
  }
407
424
 
408
- // Returns true if the schema is an enum with no other properties.
409
- // This is used to merge enums together.
410
- const isEnumOnly = (schema: JsonSchema7): schema is JsonSchema7Enum =>
411
- "enum" in schema && Object.keys(schema).length === 1
425
+ // Returns true if the schema is an enum with no other properties other than the
426
+ // optional "type". This is used to merge enums together.
427
+ const isMergeableEnum = (jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Enum => {
428
+ const len = Object.keys(jsonSchema).length
429
+ return "enum" in jsonSchema && (len === 1 || ("type" in jsonSchema && len === 2))
430
+ }
431
+
432
+ // Some validators do not support enums without a type keyword. This function
433
+ // adds a type keyword to the schema if it is missing and the enum values are
434
+ // homogeneous.
435
+ const addEnumType = (jsonSchema: JsonSchema7): JsonSchema7 => {
436
+ if ("enum" in jsonSchema && !("type" in jsonSchema)) {
437
+ const type: "string" | "number" | "boolean" | undefined = jsonSchema.enum.every(Predicate.isString) ?
438
+ "string" :
439
+ jsonSchema.enum.every(Predicate.isNumber) ?
440
+ "number" :
441
+ jsonSchema.enum.every(Predicate.isBoolean) ?
442
+ "boolean" :
443
+ undefined
444
+ if (type !== undefined) {
445
+ return { type, ...jsonSchema }
446
+ }
447
+ }
448
+ return jsonSchema
449
+ }
412
450
 
413
451
  const mergeRefinements = (from: any, jsonSchema: any, annotations: any): any => {
414
452
  const out: any = { ...from, ...annotations, ...jsonSchema }
@@ -438,15 +476,54 @@ const mergeRefinements = (from: any, jsonSchema: any, annotations: any): any =>
438
476
  return out
439
477
  }
440
478
 
479
+ type Options = {
480
+ readonly getRef: (id: string) => string
481
+ readonly target: Target
482
+ }
483
+
484
+ type Path = ReadonlyArray<PropertyKey>
485
+
486
+ const isContentSchemaSupported = (options: Options) => options.target !== "jsonSchema7"
487
+
488
+ const isNullTypeKeywordSupported = (options: Options) => options.target !== "openApi3.1"
489
+
490
+ // https://swagger.io/docs/specification/v3_0/data-models/data-types/#null
491
+ const isNullableKeywordSupported = (options: Options) => options.target === "openApi3.1"
492
+
493
+ const isNeverJSONSchema = (jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Never =>
494
+ "$id" in jsonSchema && jsonSchema.$id === "/schemas/never"
495
+
496
+ const isAnyJSONSchema = (jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Any =>
497
+ "$id" in jsonSchema && jsonSchema.$id === "/schemas/any"
498
+
499
+ const isUnknownJSONSchema = (jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Unknown =>
500
+ "$id" in jsonSchema && jsonSchema.$id === "/schemas/unknown"
501
+
502
+ const isVoidJSONSchema = (jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Void =>
503
+ "$id" in jsonSchema && jsonSchema.$id === "/schemas/void"
504
+
505
+ const shrink = (members: Array<JsonSchema7>): Array<JsonSchema7> => {
506
+ let i = members.findIndex(isAnyJSONSchema)
507
+ if (i !== -1) {
508
+ members = [members[i]]
509
+ }
510
+ i = members.findIndex(isUnknownJSONSchema)
511
+ if (i !== -1) {
512
+ members = [members[i]]
513
+ }
514
+ i = members.findIndex(isVoidJSONSchema)
515
+ if (i !== -1) {
516
+ members = [members[i]]
517
+ }
518
+ return members
519
+ }
520
+
441
521
  const go = (
442
522
  ast: AST.AST,
443
523
  $defs: Record<string, JsonSchema7>,
444
524
  handleIdentifier: boolean,
445
- path: ReadonlyArray<PropertyKey>,
446
- options: {
447
- readonly getRef: (id: string) => string
448
- readonly target: Target
449
- }
525
+ path: Path,
526
+ options: Options
450
527
  ): JsonSchema7 => {
451
528
  if (handleIdentifier) {
452
529
  const identifier = AST.getJSONIdentifier(ast)
@@ -487,9 +564,25 @@ const go = (
487
564
  case "Literal": {
488
565
  const literal = ast.literal
489
566
  if (literal === null) {
490
- return { enum: [null], ...getJsonSchemaAnnotations(ast) }
491
- } else if (Predicate.isString(literal) || Predicate.isNumber(literal) || Predicate.isBoolean(literal)) {
492
- return { enum: [literal], ...getJsonSchemaAnnotations(ast) }
567
+ if (isNullTypeKeywordSupported(options)) {
568
+ // https://json-schema.org/draft-07/draft-handrews-json-schema-validation-00.pdf
569
+ // Section 6.1.1
570
+ return { type: "null", ...getJsonSchemaAnnotations(ast) }
571
+ } else {
572
+ // OpenAPI 3.1 does not support the "null" type keyword
573
+ // https://swagger.io/docs/specification/v3_0/data-models/data-types/#null
574
+ return {
575
+ // @ts-expect-error
576
+ enum: [null],
577
+ ...getJsonSchemaAnnotations(ast)
578
+ }
579
+ }
580
+ } else if (Predicate.isString(literal)) {
581
+ return { type: "string", enum: [literal], ...getJsonSchemaAnnotations(ast) }
582
+ } else if (Predicate.isNumber(literal)) {
583
+ return { type: "number", enum: [literal], ...getJsonSchemaAnnotations(ast) }
584
+ } else if (Predicate.isBoolean(literal)) {
585
+ return { type: "boolean", enum: [literal], ...getJsonSchemaAnnotations(ast) }
493
586
  }
494
587
  throw new Error(errors_.getJSONSchemaMissingAnnotationErrorMessage(path, ast))
495
588
  }
@@ -636,38 +729,90 @@ const go = (
636
729
  return { ...output, ...getJsonSchemaAnnotations(ast) }
637
730
  }
638
731
  case "Union": {
639
- const anyOf: Array<JsonSchema7> = []
732
+ const members: Array<JsonSchema7> = []
640
733
  for (const type of ast.types) {
641
- const schema = go(type, $defs, true, path, options)
642
- if ("enum" in schema) {
643
- if (Object.keys(schema).length > 1) {
644
- anyOf.push(schema)
734
+ const jsonSchema = go(type, $defs, true, path, options)
735
+ if (!isNeverJSONSchema(jsonSchema)) {
736
+ const last = members[members.length - 1]
737
+ if (isMergeableEnum(jsonSchema) && last !== undefined && isMergeableEnum(last)) {
738
+ members[members.length - 1] = { enum: last.enum.concat(jsonSchema.enum) }
645
739
  } else {
646
- const last = anyOf[anyOf.length - 1]
647
- if (last !== undefined && isEnumOnly(last)) {
648
- for (const e of schema.enum) {
649
- last.enum.push(e)
650
- }
651
- } else {
652
- anyOf.push(schema)
740
+ members.push(jsonSchema)
741
+ }
742
+ }
743
+ }
744
+
745
+ const anyOf = shrink(members)
746
+
747
+ const finalize = (anyOf: Array<JsonSchema7>) => {
748
+ switch (anyOf.length) {
749
+ case 0:
750
+ return {
751
+ ...constNever,
752
+ ...getJsonSchemaAnnotations(ast)
753
+ }
754
+ case 1: {
755
+ return {
756
+ ...addEnumType(anyOf[0]),
757
+ ...getJsonSchemaAnnotations(ast)
653
758
  }
654
759
  }
655
- } else {
656
- anyOf.push(schema)
760
+ default:
761
+ return {
762
+ anyOf: anyOf.map(addEnumType),
763
+ ...getJsonSchemaAnnotations(ast)
764
+ }
657
765
  }
658
766
  }
659
- if (anyOf.length === 1 && isEnumOnly(anyOf[0])) {
660
- return { enum: anyOf[0].enum, ...getJsonSchemaAnnotations(ast) }
661
- } else {
662
- return { anyOf, ...getJsonSchemaAnnotations(ast) }
767
+
768
+ if (isNullableKeywordSupported(options)) {
769
+ let nullable = false
770
+ const nonNullables: Array<JsonSchema7> = []
771
+ for (const s of anyOf) {
772
+ if ("nullable" in s) {
773
+ nullable = true
774
+ const nn = { ...s }
775
+ delete nn.nullable
776
+ nonNullables.push(nn)
777
+ } else if (isMergeableEnum(s)) {
778
+ const nnes = s.enum.filter((e) => e !== null)
779
+ if (nnes.length < s.enum.length) {
780
+ nullable = true
781
+ if (nnes.length === 0) {
782
+ continue
783
+ }
784
+ const nn = { ...s }
785
+ nn.enum = nnes
786
+ nonNullables.push(nn)
787
+ }
788
+ } else {
789
+ nonNullables.push(s)
790
+ }
791
+ }
792
+ if (nullable) {
793
+ const out = finalize(nonNullables)
794
+ if (!isAnyJSONSchema(out) && !isUnknownJSONSchema(out)) {
795
+ // @ts-expect-error
796
+ out.nullable = nullable
797
+ }
798
+ return out
799
+ }
663
800
  }
801
+
802
+ return finalize(anyOf)
664
803
  }
665
804
  case "Enums": {
666
- return {
667
- $comment: "/schemas/enums",
668
- anyOf: ast.enums.map((e) => ({ title: e[0], enum: [e[1]] })),
669
- ...getJsonSchemaAnnotations(ast)
670
- }
805
+ const anyOf = ast.enums.map((e) => addEnumType({ title: e[0], enum: [e[1]] }))
806
+ return anyOf.length >= 1 ?
807
+ {
808
+ $comment: "/schemas/enums",
809
+ anyOf,
810
+ ...getJsonSchemaAnnotations(ast)
811
+ } :
812
+ {
813
+ ...constNever,
814
+ ...getJsonSchemaAnnotations(ast)
815
+ }
671
816
  }
672
817
  case "Refinement": {
673
818
  // The jsonSchema annotation is required only if the refinement does not have a transformation
@@ -699,7 +844,7 @@ const go = (
699
844
  "type": "string",
700
845
  "contentMediaType": "application/json"
701
846
  }
702
- if (options.target !== "jsonSchema7") {
847
+ if (isContentSchemaSupported(options)) {
703
848
  out["contentSchema"] = go(ast.to, $defs, handleIdentifier, path, options)
704
849
  }
705
850
  return out