965 lines
29 KiB
TypeScript
965 lines
29 KiB
TypeScript
/**
|
|
* @since 3.10.0
|
|
*/
|
|
|
|
import * as Arr from "./Array.js"
|
|
import * as errors_ from "./internal/schema/errors.js"
|
|
import * as schemaId_ from "./internal/schema/schemaId.js"
|
|
import * as Option from "./Option.js"
|
|
import * as ParseResult from "./ParseResult.js"
|
|
import * as Predicate from "./Predicate.js"
|
|
import * as Record from "./Record.js"
|
|
import type * as Schema from "./Schema.js"
|
|
import * as AST from "./SchemaAST.js"
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchemaAnnotations {
|
|
title?: string
|
|
description?: string
|
|
default?: unknown
|
|
examples?: Array<unknown>
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.11.5
|
|
*/
|
|
export interface JsonSchema7Never extends JsonSchemaAnnotations {
|
|
$id: "/schemas/never"
|
|
not: {}
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Any extends JsonSchemaAnnotations {
|
|
$id: "/schemas/any"
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Unknown extends JsonSchemaAnnotations {
|
|
$id: "/schemas/unknown"
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Void extends JsonSchemaAnnotations {
|
|
$id: "/schemas/void"
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7object extends JsonSchemaAnnotations {
|
|
$id: "/schemas/object"
|
|
anyOf: [
|
|
{ type: "object" },
|
|
{ type: "array" }
|
|
]
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7empty extends JsonSchemaAnnotations {
|
|
$id: "/schemas/%7B%7D"
|
|
anyOf: [
|
|
{ type: "object" },
|
|
{ type: "array" }
|
|
]
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Ref extends JsonSchemaAnnotations {
|
|
$ref: string
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.11.7
|
|
*/
|
|
export interface JsonSchema7Null extends JsonSchemaAnnotations {
|
|
type: "null"
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7String extends JsonSchemaAnnotations {
|
|
type: "string"
|
|
minLength?: number
|
|
maxLength?: number
|
|
pattern?: string
|
|
format?: string
|
|
contentMediaType?: string
|
|
allOf?: Array<{
|
|
minLength?: number
|
|
maxLength?: number
|
|
pattern?: string
|
|
}>
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Numeric extends JsonSchemaAnnotations {
|
|
minimum?: number
|
|
exclusiveMinimum?: number
|
|
maximum?: number
|
|
exclusiveMaximum?: number
|
|
multipleOf?: number
|
|
allOf?: Array<{
|
|
minimum?: number
|
|
exclusiveMinimum?: number
|
|
maximum?: number
|
|
exclusiveMaximum?: number
|
|
multipleOf?: number
|
|
}>
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Number extends JsonSchema7Numeric {
|
|
type: "number"
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Integer extends JsonSchema7Numeric {
|
|
type: "integer"
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Boolean extends JsonSchemaAnnotations {
|
|
type: "boolean"
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Array extends JsonSchemaAnnotations {
|
|
type: "array"
|
|
items?: JsonSchema7 | Array<JsonSchema7>
|
|
minItems?: number
|
|
maxItems?: number
|
|
additionalItems?: JsonSchema7 | boolean
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Enum extends JsonSchemaAnnotations {
|
|
type?: "string" | "number" | "boolean"
|
|
enum: Array<string | number | boolean>
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Enums extends JsonSchemaAnnotations {
|
|
$comment: "/schemas/enums"
|
|
anyOf: Array<{
|
|
type: "string" | "number"
|
|
title: string
|
|
enum: [string | number]
|
|
}>
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7AnyOf extends JsonSchemaAnnotations {
|
|
anyOf: Array<JsonSchema7>
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export interface JsonSchema7Object extends JsonSchemaAnnotations {
|
|
type: "object"
|
|
required: Array<string>
|
|
properties: Record<string, JsonSchema7>
|
|
additionalProperties?: boolean | JsonSchema7
|
|
patternProperties?: Record<string, JsonSchema7>
|
|
propertyNames?: JsonSchema7
|
|
}
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export type JsonSchema7 =
|
|
| JsonSchema7Never
|
|
| JsonSchema7Any
|
|
| JsonSchema7Unknown
|
|
| JsonSchema7Void
|
|
| JsonSchema7object
|
|
| JsonSchema7empty
|
|
| JsonSchema7Ref
|
|
| JsonSchema7Null
|
|
| JsonSchema7String
|
|
| JsonSchema7Number
|
|
| JsonSchema7Integer
|
|
| JsonSchema7Boolean
|
|
| JsonSchema7Array
|
|
| JsonSchema7Enum
|
|
| JsonSchema7Enums
|
|
| JsonSchema7AnyOf
|
|
| JsonSchema7Object
|
|
|
|
/**
|
|
* @category model
|
|
* @since 3.10.0
|
|
*/
|
|
export type JsonSchema7Root = JsonSchema7 & {
|
|
$schema?: string
|
|
$defs?: Record<string, JsonSchema7>
|
|
}
|
|
|
|
/**
|
|
* @category encoding
|
|
* @since 3.10.0
|
|
*/
|
|
export const make = <A, I, R>(schema: Schema.Schema<A, I, R>): JsonSchema7Root => {
|
|
const definitions: Record<string, any> = {}
|
|
const ast = AST.isTransformation(schema.ast) && isParseJsonTransformation(schema.ast.from)
|
|
// Special case top level `parseJson` transformations
|
|
? schema.ast.to
|
|
: schema.ast
|
|
const jsonSchema = fromAST(ast, {
|
|
definitions
|
|
})
|
|
const out: JsonSchema7Root = {
|
|
$schema,
|
|
$defs: {},
|
|
...jsonSchema
|
|
}
|
|
if (Record.isEmptyRecord(definitions)) {
|
|
delete out.$defs
|
|
} else {
|
|
out.$defs = definitions
|
|
}
|
|
return out
|
|
}
|
|
|
|
type Target = "jsonSchema7" | "jsonSchema2019-09" | "openApi3.1"
|
|
|
|
type TopLevelReferenceStrategy = "skip" | "keep"
|
|
|
|
type AdditionalPropertiesStrategy = "allow" | "strict"
|
|
|
|
/**
|
|
* Returns a JSON Schema with additional options and definitions.
|
|
*
|
|
* **Warning**
|
|
*
|
|
* This function is experimental and subject to change.
|
|
*
|
|
* **Options**
|
|
*
|
|
* - `definitions`: A record of definitions that are included in the schema.
|
|
* - `definitionPath`: The path to the definitions within the schema (defaults
|
|
* to "#/$defs/").
|
|
* - `target`: Which spec to target. Possible values are:
|
|
* - `'jsonSchema7'`: JSON Schema draft-07 (default behavior).
|
|
* - `'jsonSchema2019-09'`: JSON Schema draft-2019-09.
|
|
* - `'openApi3.1'`: OpenAPI 3.1.
|
|
* - `topLevelReferenceStrategy`: Controls the handling of the top-level
|
|
* reference. Possible values are:
|
|
* - `"keep"`: Keep the top-level reference (default behavior).
|
|
* - `"skip"`: Skip the top-level reference.
|
|
* - `additionalPropertiesStrategy`: Controls the handling of additional properties. Possible values are:
|
|
* - `"strict"`: Disallow additional properties (default behavior).
|
|
* - `"allow"`: Allow additional properties.
|
|
*
|
|
* @category encoding
|
|
* @since 3.11.5
|
|
* @experimental
|
|
*/
|
|
export const fromAST = (ast: AST.AST, options: {
|
|
readonly definitions: Record<string, JsonSchema7>
|
|
readonly definitionPath?: string | undefined
|
|
readonly target?: Target | undefined
|
|
readonly topLevelReferenceStrategy?: TopLevelReferenceStrategy | undefined
|
|
readonly additionalPropertiesStrategy?: AdditionalPropertiesStrategy | undefined
|
|
}): JsonSchema7 => {
|
|
const definitionPath = options.definitionPath ?? "#/$defs/"
|
|
const getRef = (id: string) => definitionPath + id
|
|
const target = options.target ?? "jsonSchema7"
|
|
const topLevelReferenceStrategy = options.topLevelReferenceStrategy ?? "keep"
|
|
const additionalPropertiesStrategy = options.additionalPropertiesStrategy ?? "strict"
|
|
return go(
|
|
ast,
|
|
options.definitions,
|
|
"handle-identifier",
|
|
[],
|
|
{
|
|
getRef,
|
|
target,
|
|
topLevelReferenceStrategy,
|
|
additionalPropertiesStrategy
|
|
},
|
|
"handle-annotation",
|
|
"handle-errors"
|
|
)
|
|
}
|
|
|
|
const constNever: JsonSchema7Never = {
|
|
$id: "/schemas/never",
|
|
not: {}
|
|
}
|
|
|
|
const constAny: JsonSchema7Any = {
|
|
$id: "/schemas/any"
|
|
}
|
|
|
|
const constUnknown: JsonSchema7Unknown = {
|
|
$id: "/schemas/unknown"
|
|
}
|
|
|
|
const constVoid: JsonSchema7Void = {
|
|
$id: "/schemas/void"
|
|
}
|
|
|
|
const constObject: JsonSchema7object = {
|
|
$id: "/schemas/object",
|
|
"anyOf": [
|
|
{ "type": "object" },
|
|
{ "type": "array" }
|
|
]
|
|
}
|
|
|
|
const constEmptyStruct: JsonSchema7empty = {
|
|
$id: "/schemas/%7B%7D",
|
|
"anyOf": [
|
|
{ "type": "object" },
|
|
{ "type": "array" }
|
|
]
|
|
}
|
|
|
|
const $schema = "http://json-schema.org/draft-07/schema#"
|
|
|
|
function getRawDescription(annotated: AST.Annotated | undefined): string | undefined {
|
|
if (annotated !== undefined) return Option.getOrUndefined(AST.getDescriptionAnnotation(annotated))
|
|
}
|
|
|
|
function getRawTitle(annotated: AST.Annotated | undefined): string | undefined {
|
|
if (annotated !== undefined) return Option.getOrUndefined(AST.getTitleAnnotation(annotated))
|
|
}
|
|
|
|
function getRawDefault(annotated: AST.Annotated | undefined): Option.Option<unknown> {
|
|
if (annotated !== undefined) return AST.getDefaultAnnotation(annotated)
|
|
return Option.none()
|
|
}
|
|
|
|
function encodeDefault(ast: AST.AST, def: unknown): Option.Option<unknown> {
|
|
const getOption = ParseResult.getOption(ast, false)
|
|
return getOption(def)
|
|
}
|
|
|
|
function getRawExamples(annotated: AST.Annotated | undefined): ReadonlyArray<unknown> | undefined {
|
|
if (annotated !== undefined) return Option.getOrUndefined(AST.getExamplesAnnotation(annotated))
|
|
}
|
|
|
|
function encodeExamples(ast: AST.AST, examples: ReadonlyArray<unknown>): Array<unknown> | undefined {
|
|
const getOption = ParseResult.getOption(ast, false)
|
|
const out = Arr.filterMap(examples, (e) => getOption(e))
|
|
return out.length > 0 ? out : undefined
|
|
}
|
|
|
|
function filterBuiltIn(ast: AST.AST, annotation: string | undefined, key: symbol): string | undefined {
|
|
if (annotation !== undefined) {
|
|
switch (ast._tag) {
|
|
case "StringKeyword":
|
|
return annotation !== AST.stringKeyword.annotations[key] ? annotation : undefined
|
|
case "NumberKeyword":
|
|
return annotation !== AST.numberKeyword.annotations[key] ? annotation : undefined
|
|
case "BooleanKeyword":
|
|
return annotation !== AST.booleanKeyword.annotations[key] ? annotation : undefined
|
|
}
|
|
}
|
|
return annotation
|
|
}
|
|
|
|
function pruneJsonSchemaAnnotations(
|
|
ast: AST.AST,
|
|
description: string | undefined,
|
|
title: string | undefined,
|
|
def: Option.Option<unknown>,
|
|
examples: ReadonlyArray<unknown> | undefined
|
|
): JsonSchemaAnnotations | undefined {
|
|
const out: JsonSchemaAnnotations = {}
|
|
if (description !== undefined) out.description = description
|
|
if (title !== undefined) out.title = title
|
|
if (Option.isSome(def)) {
|
|
const o = encodeDefault(ast, def.value)
|
|
if (Option.isSome(o)) {
|
|
out.default = o.value
|
|
}
|
|
}
|
|
if (examples !== undefined) {
|
|
const encodedExamples = encodeExamples(ast, examples)
|
|
if (encodedExamples !== undefined) {
|
|
out.examples = encodedExamples
|
|
}
|
|
}
|
|
if (Object.keys(out).length === 0) {
|
|
return undefined
|
|
}
|
|
return out
|
|
}
|
|
|
|
function getContextJsonSchemaAnnotations(ast: AST.AST, annotated: AST.Annotated): JsonSchemaAnnotations | undefined {
|
|
return pruneJsonSchemaAnnotations(
|
|
ast,
|
|
getRawDescription(annotated),
|
|
getRawTitle(annotated),
|
|
getRawDefault(annotated),
|
|
getRawExamples(annotated)
|
|
)
|
|
}
|
|
|
|
function getJsonSchemaAnnotations(ast: AST.AST): JsonSchemaAnnotations | undefined {
|
|
return pruneJsonSchemaAnnotations(
|
|
ast,
|
|
filterBuiltIn(ast, getRawDescription(ast), AST.DescriptionAnnotationId),
|
|
filterBuiltIn(ast, getRawTitle(ast), AST.TitleAnnotationId),
|
|
getRawDefault(ast),
|
|
getRawExamples(ast)
|
|
)
|
|
}
|
|
|
|
function mergeJsonSchemaAnnotations(
|
|
jsonSchema: JsonSchema7,
|
|
jsonSchemaAnnotations: JsonSchemaAnnotations | undefined
|
|
): JsonSchema7 {
|
|
if (jsonSchemaAnnotations) {
|
|
if ("$ref" in jsonSchema) {
|
|
return { allOf: [jsonSchema], ...jsonSchemaAnnotations } as any
|
|
}
|
|
return { ...jsonSchema, ...jsonSchemaAnnotations }
|
|
}
|
|
return jsonSchema
|
|
}
|
|
|
|
const pruneUndefined = (ast: AST.AST): AST.AST | undefined => {
|
|
if (Option.isNone(AST.getJSONSchemaAnnotation(ast))) {
|
|
return AST.pruneUndefined(ast, pruneUndefined, (ast) => pruneUndefined(ast.from))
|
|
}
|
|
}
|
|
|
|
const isParseJsonTransformation = (ast: AST.AST): boolean =>
|
|
ast.annotations[AST.SchemaIdAnnotationId] === AST.ParseJsonSchemaId
|
|
|
|
const isOverrideAnnotation = (ast: AST.AST, jsonSchema: JsonSchema7): boolean => {
|
|
if (AST.isRefinement(ast)) {
|
|
const schemaId = ast.annotations[AST.SchemaIdAnnotationId]
|
|
if (schemaId === schemaId_.IntSchemaId) {
|
|
return "type" in jsonSchema && jsonSchema.type !== "integer"
|
|
}
|
|
}
|
|
return ("type" in jsonSchema) || ("oneOf" in jsonSchema) || ("anyOf" in jsonSchema) || ("$ref" in jsonSchema)
|
|
}
|
|
|
|
const mergeRefinements = (from: any, jsonSchema: any, ast: AST.AST): any => {
|
|
const out: any = { ...from, ...getJsonSchemaAnnotations(ast), ...jsonSchema }
|
|
out.allOf ??= []
|
|
|
|
const handle = (name: string, filter: (i: any) => boolean) => {
|
|
if (name in jsonSchema && name in from) {
|
|
out.allOf.unshift({ [name]: from[name] })
|
|
out.allOf = out.allOf.filter(filter)
|
|
}
|
|
}
|
|
|
|
handle("minLength", (i) => i.minLength > jsonSchema.minLength)
|
|
handle("maxLength", (i) => i.maxLength < jsonSchema.maxLength)
|
|
handle("pattern", (i) => i.pattern !== jsonSchema.pattern)
|
|
handle("minItems", (i) => i.minItems > jsonSchema.minItems)
|
|
handle("maxItems", (i) => i.maxItems < jsonSchema.maxItems)
|
|
handle("minimum", (i) => i.minimum > jsonSchema.minimum)
|
|
handle("maximum", (i) => i.maximum < jsonSchema.maximum)
|
|
handle("exclusiveMinimum", (i) => i.exclusiveMinimum > jsonSchema.exclusiveMinimum)
|
|
handle("exclusiveMaximum", (i) => i.exclusiveMaximum < jsonSchema.exclusiveMaximum)
|
|
handle("multipleOf", (i) => i.multipleOf !== jsonSchema.multipleOf)
|
|
|
|
if (out.allOf.length === 0) {
|
|
delete out.allOf
|
|
}
|
|
return out
|
|
}
|
|
|
|
type GoOptions = {
|
|
readonly getRef: (id: string) => string
|
|
readonly target: Target
|
|
readonly topLevelReferenceStrategy: TopLevelReferenceStrategy
|
|
readonly additionalPropertiesStrategy: AdditionalPropertiesStrategy
|
|
}
|
|
|
|
function isContentSchemaSupported(options: GoOptions): boolean {
|
|
switch (options.target) {
|
|
case "jsonSchema7":
|
|
return false
|
|
case "jsonSchema2019-09":
|
|
case "openApi3.1":
|
|
return true
|
|
}
|
|
}
|
|
|
|
function getAdditionalProperties(options: GoOptions): boolean {
|
|
switch (options.additionalPropertiesStrategy) {
|
|
case "allow":
|
|
return true
|
|
case "strict":
|
|
return false
|
|
}
|
|
}
|
|
|
|
function addASTAnnotations(jsonSchema: JsonSchema7, ast: AST.AST): JsonSchema7 {
|
|
return addAnnotations(jsonSchema, getJsonSchemaAnnotations(ast))
|
|
}
|
|
|
|
function addAnnotations(jsonSchema: JsonSchema7, annotations: JsonSchemaAnnotations | undefined): JsonSchema7 {
|
|
if (annotations === undefined || Object.keys(annotations).length === 0) {
|
|
return jsonSchema
|
|
}
|
|
if ("$ref" in jsonSchema) {
|
|
return { allOf: [jsonSchema], ...annotations } as any
|
|
}
|
|
return { ...jsonSchema, ...annotations }
|
|
}
|
|
|
|
function getIdentifierAnnotation(ast: AST.AST): string | undefined {
|
|
const identifier = Option.getOrUndefined(AST.getJSONIdentifier(ast))
|
|
if (identifier === undefined) {
|
|
if (AST.isSuspend(ast)) {
|
|
return getIdentifierAnnotation(ast.f())
|
|
}
|
|
if (AST.isTransformation(ast) && AST.isTypeLiteral(ast.from) && AST.isDeclaration(ast.to)) {
|
|
const to = ast.to
|
|
const surrogate = AST.getSurrogateAnnotation(to)
|
|
if (Option.isSome(surrogate)) {
|
|
return getIdentifierAnnotation(to)
|
|
}
|
|
}
|
|
}
|
|
return identifier
|
|
}
|
|
|
|
function go(
|
|
ast: AST.AST,
|
|
$defs: Record<string, JsonSchema7>,
|
|
identifier: "handle-identifier" | "ignore-identifier",
|
|
path: ReadonlyArray<PropertyKey>,
|
|
options: GoOptions,
|
|
annotation: "handle-annotation" | "ignore-annotation",
|
|
errors: "handle-errors" | "ignore-errors"
|
|
): JsonSchema7 {
|
|
if (
|
|
identifier === "handle-identifier" &&
|
|
(options.topLevelReferenceStrategy !== "skip" || AST.isSuspend(ast))
|
|
) {
|
|
const id = getIdentifierAnnotation(ast)
|
|
if (id !== undefined) {
|
|
const escapedId = id.replace(/~/ig, "~0").replace(/\//ig, "~1")
|
|
const out = { $ref: options.getRef(escapedId) }
|
|
if (!Record.has($defs, id)) {
|
|
$defs[id] = out
|
|
$defs[id] = go(ast, $defs, "ignore-identifier", path, options, "handle-annotation", errors)
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
if (annotation === "handle-annotation") {
|
|
const hook = AST.getJSONSchemaAnnotation(ast)
|
|
if (Option.isSome(hook)) {
|
|
const handler = hook.value as JsonSchema7
|
|
if (isOverrideAnnotation(ast, handler)) {
|
|
switch (ast._tag) {
|
|
case "Declaration":
|
|
return addASTAnnotations(handler, ast)
|
|
default:
|
|
return handler
|
|
}
|
|
} else {
|
|
switch (ast._tag) {
|
|
case "Refinement": {
|
|
const t = AST.getTransformationFrom(ast)
|
|
if (t === undefined) {
|
|
return mergeRefinements(
|
|
go(ast.from, $defs, identifier, path, options, "handle-annotation", errors),
|
|
handler,
|
|
ast
|
|
)
|
|
} else {
|
|
return go(t, $defs, identifier, path, options, "handle-annotation", errors)
|
|
}
|
|
}
|
|
default:
|
|
return {
|
|
...go(ast, $defs, identifier, path, options, "ignore-annotation", errors),
|
|
...handler
|
|
} as any
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const surrogate = AST.getSurrogateAnnotation(ast)
|
|
if (Option.isSome(surrogate)) {
|
|
return go(surrogate.value, $defs, identifier, path, options, "handle-annotation", errors)
|
|
}
|
|
switch (ast._tag) {
|
|
// Unsupported
|
|
case "Declaration":
|
|
case "UndefinedKeyword":
|
|
case "BigIntKeyword":
|
|
case "UniqueSymbol":
|
|
case "SymbolKeyword": {
|
|
if (errors === "ignore-errors") return addASTAnnotations(constAny, ast)
|
|
throw new Error(errors_.getJSONSchemaMissingAnnotationErrorMessage(path, ast))
|
|
}
|
|
case "Suspend": {
|
|
if (identifier === "handle-identifier") {
|
|
if (errors === "ignore-errors") return addASTAnnotations(constAny, ast)
|
|
throw new Error(errors_.getJSONSchemaMissingIdentifierAnnotationErrorMessage(path, ast))
|
|
}
|
|
return go(ast.f(), $defs, "ignore-identifier", path, options, "handle-annotation", errors)
|
|
}
|
|
// Primitives
|
|
case "NeverKeyword":
|
|
return addASTAnnotations(constNever, ast)
|
|
case "VoidKeyword":
|
|
return addASTAnnotations(constVoid, ast)
|
|
case "UnknownKeyword":
|
|
return addASTAnnotations(constUnknown, ast)
|
|
case "AnyKeyword":
|
|
return addASTAnnotations(constAny, ast)
|
|
case "ObjectKeyword":
|
|
return addASTAnnotations(constObject, ast)
|
|
case "StringKeyword":
|
|
return addASTAnnotations({ type: "string" }, ast)
|
|
case "NumberKeyword":
|
|
return addASTAnnotations({ type: "number" }, ast)
|
|
case "BooleanKeyword":
|
|
return addASTAnnotations({ type: "boolean" }, ast)
|
|
case "Literal": {
|
|
const literal = ast.literal
|
|
if (literal === null) {
|
|
return addASTAnnotations({ type: "null" }, ast)
|
|
} else if (Predicate.isString(literal)) {
|
|
return addASTAnnotations({ type: "string", enum: [literal] }, ast)
|
|
} else if (Predicate.isNumber(literal)) {
|
|
return addASTAnnotations({ type: "number", enum: [literal] }, ast)
|
|
} else if (Predicate.isBoolean(literal)) {
|
|
return addASTAnnotations({ type: "boolean", enum: [literal] }, ast)
|
|
}
|
|
if (errors === "ignore-errors") return addASTAnnotations(constAny, ast)
|
|
throw new Error(errors_.getJSONSchemaMissingAnnotationErrorMessage(path, ast))
|
|
}
|
|
case "Enums": {
|
|
const anyOf = ast.enums.map((e) => {
|
|
const type: "string" | "number" = Predicate.isNumber(e[1]) ? "number" : "string"
|
|
return { type, title: e[0], enum: [e[1]] }
|
|
})
|
|
return anyOf.length >= 1 ?
|
|
addASTAnnotations({
|
|
$comment: "/schemas/enums",
|
|
anyOf
|
|
}, ast) :
|
|
addASTAnnotations(constNever, ast)
|
|
}
|
|
case "TupleType": {
|
|
const elements = ast.elements.map((e, i) =>
|
|
mergeJsonSchemaAnnotations(
|
|
go(e.type, $defs, "handle-identifier", path.concat(i), options, "handle-annotation", errors),
|
|
getContextJsonSchemaAnnotations(e.type, e)
|
|
)
|
|
)
|
|
const rest = ast.rest.map((type) =>
|
|
mergeJsonSchemaAnnotations(
|
|
go(type.type, $defs, "handle-identifier", path, options, "handle-annotation", errors),
|
|
getContextJsonSchemaAnnotations(type.type, type)
|
|
)
|
|
)
|
|
const output: JsonSchema7Array = { type: "array" }
|
|
// ---------------------------------------------
|
|
// handle elements
|
|
// ---------------------------------------------
|
|
const len = ast.elements.length
|
|
if (len > 0) {
|
|
output.minItems = len - ast.elements.filter((element) => element.isOptional).length
|
|
output.items = elements
|
|
}
|
|
// ---------------------------------------------
|
|
// handle rest element
|
|
// ---------------------------------------------
|
|
const restLength = rest.length
|
|
if (restLength > 0) {
|
|
const head = rest[0]
|
|
const isHomogeneous = restLength === 1 && ast.elements.every((e) => e.type === ast.rest[0].type)
|
|
if (isHomogeneous) {
|
|
output.items = head
|
|
} else {
|
|
output.additionalItems = head
|
|
}
|
|
|
|
// ---------------------------------------------
|
|
// handle post rest elements
|
|
// ---------------------------------------------
|
|
if (restLength > 1) {
|
|
if (errors === "ignore-errors") return addASTAnnotations(constAny, ast)
|
|
throw new Error(errors_.getJSONSchemaUnsupportedPostRestElementsErrorMessage(path))
|
|
}
|
|
} else {
|
|
if (len > 0) {
|
|
output.additionalItems = false
|
|
} else {
|
|
output.maxItems = 0
|
|
}
|
|
}
|
|
|
|
return addASTAnnotations(output, ast)
|
|
}
|
|
case "TypeLiteral": {
|
|
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) {
|
|
return addASTAnnotations(constEmptyStruct, ast)
|
|
}
|
|
const output: JsonSchema7Object = {
|
|
type: "object",
|
|
required: [],
|
|
properties: {},
|
|
additionalProperties: getAdditionalProperties(options)
|
|
}
|
|
let patternProperties: JsonSchema7 | undefined = undefined
|
|
let propertyNames: JsonSchema7 | undefined = undefined
|
|
for (const is of ast.indexSignatures) {
|
|
const pruned = pruneUndefined(is.type) ?? is.type
|
|
const parameter = is.parameter
|
|
switch (parameter._tag) {
|
|
case "StringKeyword": {
|
|
output.additionalProperties = go(
|
|
pruned,
|
|
$defs,
|
|
"handle-identifier",
|
|
path,
|
|
options,
|
|
"handle-annotation",
|
|
errors
|
|
)
|
|
break
|
|
}
|
|
case "TemplateLiteral": {
|
|
patternProperties = go(pruned, $defs, "handle-identifier", path, options, "handle-annotation", errors)
|
|
propertyNames = {
|
|
type: "string",
|
|
pattern: AST.getTemplateLiteralRegExp(parameter).source
|
|
}
|
|
break
|
|
}
|
|
case "Refinement": {
|
|
patternProperties = go(pruned, $defs, "handle-identifier", path, options, "handle-annotation", errors)
|
|
propertyNames = go(parameter, $defs, "handle-identifier", path, options, "handle-annotation", errors)
|
|
break
|
|
}
|
|
case "SymbolKeyword": {
|
|
const indexSignaturePath = path.concat("[symbol]")
|
|
output.additionalProperties = go(
|
|
pruned,
|
|
$defs,
|
|
"handle-identifier",
|
|
indexSignaturePath,
|
|
options,
|
|
"handle-annotation",
|
|
errors
|
|
)
|
|
propertyNames = go(
|
|
parameter,
|
|
$defs,
|
|
"handle-identifier",
|
|
indexSignaturePath,
|
|
options,
|
|
"handle-annotation",
|
|
errors
|
|
)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// ---------------------------------------------
|
|
// handle property signatures
|
|
// ---------------------------------------------
|
|
for (let i = 0; i < ast.propertySignatures.length; i++) {
|
|
const ps = ast.propertySignatures[i]
|
|
const name = ps.name
|
|
if (Predicate.isString(name)) {
|
|
const pruned = pruneUndefined(ps.type)
|
|
const type = pruned ?? ps.type
|
|
output.properties[name] = mergeJsonSchemaAnnotations(
|
|
go(type, $defs, "handle-identifier", path.concat(ps.name), options, "handle-annotation", errors),
|
|
getContextJsonSchemaAnnotations(type, ps)
|
|
)
|
|
// ---------------------------------------------
|
|
// handle optional property signatures
|
|
// ---------------------------------------------
|
|
if (!ps.isOptional && pruned === undefined) {
|
|
output.required.push(name)
|
|
}
|
|
} else {
|
|
if (errors === "ignore-errors") return addASTAnnotations(constAny, ast)
|
|
throw new Error(errors_.getJSONSchemaUnsupportedKeyErrorMessage(name, path))
|
|
}
|
|
}
|
|
// ---------------------------------------------
|
|
// handle index signatures
|
|
// ---------------------------------------------
|
|
if (patternProperties !== undefined) {
|
|
delete output.additionalProperties
|
|
output.patternProperties = { "": patternProperties }
|
|
}
|
|
if (propertyNames !== undefined) {
|
|
output.propertyNames = propertyNames
|
|
}
|
|
|
|
return addASTAnnotations(output, ast)
|
|
}
|
|
case "Union": {
|
|
const members: Array<JsonSchema7> = ast.types.map((t) =>
|
|
go(t, $defs, "handle-identifier", path, options, "handle-annotation", errors)
|
|
)
|
|
const anyOf = compactUnion(members)
|
|
switch (anyOf.length) {
|
|
case 0:
|
|
return constNever
|
|
case 1:
|
|
return addASTAnnotations(anyOf[0], ast)
|
|
default:
|
|
return addASTAnnotations({ anyOf }, ast)
|
|
}
|
|
}
|
|
case "Refinement":
|
|
return go(ast.from, $defs, identifier, path, options, "handle-annotation", errors)
|
|
case "TemplateLiteral": {
|
|
const regex = AST.getTemplateLiteralRegExp(ast)
|
|
return addASTAnnotations({
|
|
type: "string",
|
|
title: String(ast),
|
|
description: "a template literal",
|
|
pattern: regex.source
|
|
}, ast)
|
|
}
|
|
case "Transformation": {
|
|
if (isParseJsonTransformation(ast.from)) {
|
|
const out: JsonSchema7String & { contentSchema?: JsonSchema7 } = {
|
|
"type": "string",
|
|
"contentMediaType": "application/json"
|
|
}
|
|
if (isContentSchemaSupported(options)) {
|
|
out["contentSchema"] = go(ast.to, $defs, identifier, path, options, "handle-annotation", errors)
|
|
}
|
|
return out
|
|
}
|
|
const from = go(ast.from, $defs, identifier, path, options, "handle-annotation", errors)
|
|
if (
|
|
ast.transformation._tag === "TypeLiteralTransformation" &&
|
|
isJsonSchema7Object(from)
|
|
) {
|
|
const to = go(ast.to, {}, "ignore-identifier", path, options, "handle-annotation", "ignore-errors")
|
|
if (isJsonSchema7Object(to)) {
|
|
for (const t of ast.transformation.propertySignatureTransformations) {
|
|
const toKey = t.to
|
|
const fromKey = t.from
|
|
if (Predicate.isString(toKey) && Predicate.isString(fromKey)) {
|
|
const toProperty = to.properties[toKey]
|
|
if (Predicate.isRecord(toProperty)) {
|
|
const fromProperty = from.properties[fromKey]
|
|
if (Predicate.isRecord(fromProperty)) {
|
|
const annotations: JsonSchemaAnnotations = {}
|
|
if (Predicate.isString(toProperty.title)) annotations.title = toProperty.title
|
|
if (Predicate.isString(toProperty.description)) annotations.description = toProperty.description
|
|
if (Array.isArray(toProperty.examples)) annotations.examples = toProperty.examples
|
|
if (Object.hasOwn(toProperty, "default")) annotations.default = toProperty.default
|
|
from.properties[fromKey] = addAnnotations(fromProperty, annotations)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return addASTAnnotations(from, ast)
|
|
}
|
|
}
|
|
}
|
|
|
|
function isJsonSchema7Object(jsonSchema: unknown): jsonSchema is JsonSchema7Object {
|
|
return Predicate.isRecord(jsonSchema) && jsonSchema.type === "object" && Predicate.isRecord(jsonSchema.properties)
|
|
}
|
|
|
|
function isNeverWithoutCustomAnnotations(jsonSchema: JsonSchema7): boolean {
|
|
return jsonSchema === constNever || (Predicate.hasProperty(jsonSchema, "$id") && jsonSchema.$id === constNever.$id &&
|
|
Object.keys(jsonSchema).length === 3 && jsonSchema.title === AST.neverKeyword.annotations[AST.TitleAnnotationId])
|
|
}
|
|
|
|
function isAny(jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Any {
|
|
return "$id" in jsonSchema && jsonSchema.$id === constAny.$id
|
|
}
|
|
|
|
function isUnknown(jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Unknown {
|
|
return "$id" in jsonSchema && jsonSchema.$id === constUnknown.$id
|
|
}
|
|
|
|
function isVoid(jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Void {
|
|
return "$id" in jsonSchema && jsonSchema.$id === constVoid.$id
|
|
}
|
|
|
|
function isCompactableLiteral(jsonSchema: JsonSchema7 | undefined): jsonSchema is JsonSchema7Enum {
|
|
return Predicate.hasProperty(jsonSchema, "enum") && "type" in jsonSchema && Object.keys(jsonSchema).length === 2
|
|
}
|
|
|
|
function compactUnion(members: Array<JsonSchema7>): Array<JsonSchema7> {
|
|
const out: Array<JsonSchema7> = []
|
|
for (const m of members) {
|
|
if (isNeverWithoutCustomAnnotations(m)) continue
|
|
if (isAny(m) || isUnknown(m) || isVoid(m)) return [m]
|
|
if (isCompactableLiteral(m) && out.length > 0) {
|
|
const last = out[out.length - 1]
|
|
if (isCompactableLiteral(last) && last.type === m.type) {
|
|
out[out.length - 1] = {
|
|
type: last.type,
|
|
enum: [...last.enum, ...m.enum]
|
|
} as JsonSchema7Enum
|
|
continue
|
|
}
|
|
}
|
|
out.push(m)
|
|
}
|
|
return out
|
|
}
|