| 1 | import * as Arr from "../../Array.ts" |
| 2 | import * as InternalRecord from "../../internal/record.ts" |
| 3 | import * as JsonSchema from "../../JsonSchema.ts" |
| 4 | import * as Option from "../../Option.ts" |
| 5 | import * as Predicate from "../../Predicate.ts" |
| 6 | import * as Schema from "../../Schema.ts" |
| 7 | import * as SchemaAST from "../../SchemaAST.ts" |
| 8 | import * as SchemaTransformation from "../../SchemaTransformation.ts" |
| 9 | import * as Tool from "../Tool.ts" |
| 10 | |
| 11 | const REST_PROPERTY_NAME = "__rest__" |
| 12 | const TAIL_PROPERTY_PREFIX = "__tail_" |
| 13 | |
| 14 | const RECORD_DESCRIPTION = |
| 15 | "Object encoded as array of [key, value] pairs. Apply object constraints to the decoded object" |
| 16 | |
| 17 | const TUPLE_DESCRIPTION = |
| 18 | "Tuple encoded as an object with numeric string keys ('0', '1', ...). If present, '__rest__' contains remaining elements" |
| 19 | const TUPLE_TAIL_DESCRIPTION = `${TUPLE_DESCRIPTION}. Post-rest elements use '__tail_0__', '__tail_1__', and so on` |
| 20 | |
| 21 | /** |
| 22 | * @internal |
| 23 | */ |
| 24 | export function resolveReference($ref: string, definitions: JsonSchema.Definitions): JsonSchema.JsonSchema { |
| 25 | const key = JsonSchema.getReferenceKey($ref) |
| 26 | if (key === undefined) { |
| 27 | throw new Error(`Unsupported reference ${JSON.stringify($ref)}`) |
| 28 | } |
| 29 | if (!Object.hasOwn(definitions, key)) { |
| 30 | throw new Error(`Invalid reference ${JSON.stringify($ref)}`) |
| 31 | } |
| 32 | return definitions[key] |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * @internal |
| 37 | */ |
| 38 | export function resolveTopLevelReference( |
| 39 | document: JsonSchema.Document<"draft-2020-12"> |
| 40 | ): JsonSchema.Document<"draft-2020-12"> { |
| 41 | if (typeof document.schema.$ref !== "string") return document |
| 42 | return { |
| 43 | ...document, |
| 44 | schema: resolveReference(document.schema.$ref, document.definitions) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * @internal |
| 50 | */ |
| 51 | export function toCodec<T, E, RD, RE>( |
| 52 | schema: Schema.ConstraintCodec<T, E, RD, RE> |
| 53 | ): Schema.ConstraintCodec<T, unknown, RD, RE> { |
| 54 | const jsonCodec = Schema.toCodecJson(schema) |
| 55 | const encoded = SchemaAST.toEncoded(jsonCodec.ast) |
| 56 | const from = transform(encoded) |
| 57 | if (from === encoded) { |
| 58 | return jsonCodec.ast === schema.ast ? schema : jsonCodec |
| 59 | } |
| 60 | return Schema.make<typeof schema>( |
| 61 | SchemaAST.decodeTo(from, jsonCodec.ast, SchemaTransformation.passthrough()) |
| 62 | ) |
| 63 | } |
| 64 | |
| 65 | function transform(root: SchemaAST.AST): SchemaAST.AST { |
| 66 | let cache: Map<SchemaAST.AST, SchemaAST.AST> | undefined |
| 67 | |
| 68 | function recur(ast: SchemaAST.AST): SchemaAST.AST { |
| 69 | switch (ast._tag) { |
| 70 | case "Union": { |
| 71 | const types = SchemaAST.mapOrSame(ast.types, recur) |
| 72 | const checks = prepareChecks(ast.checks) |
| 73 | const options = ast.options?.mode === "oneOf" ? { ...ast.options, mode: "anyOf" as const } : ast.options |
| 74 | if (types === ast.types && checks === ast.checks && options === ast.options) return ast |
| 75 | return new SchemaAST.Union( |
| 76 | types, |
| 77 | options, |
| 78 | ast.annotations, |
| 79 | checks, |
| 80 | ast.encoding, |
| 81 | ast.context, |
| 82 | ast.encodingChecks |
| 83 | ) |
| 84 | } |
| 85 | case "Arrays": { |
| 86 | if (ast.elements.length > 0 || ast.rest.length > 1) { |
| 87 | return tupleToObject(ast, recur) |
| 88 | } |
| 89 | const rest = SchemaAST.mapOrSame(ast.rest, recur) |
| 90 | const checks = prepareChecks(ast.checks) |
| 91 | if (rest === ast.rest && checks === ast.checks) return ast |
| 92 | return new SchemaAST.Arrays( |
| 93 | ast.isMutable, |
| 94 | ast.elements, |
| 95 | rest, |
| 96 | ast.annotations, |
| 97 | checks, |
| 98 | ast.encoding, |
| 99 | ast.context, |
| 100 | ast.encodingChecks |
| 101 | ) |
| 102 | } |
| 103 | case "Objects": { |
| 104 | if (ast.indexSignatures.length === 1 && ast.propertySignatures.length === 0) { |
| 105 | const indexSignature = ast.indexSignatures[0] |
| 106 | if (Tool.isEmptyParamsRecord(indexSignature)) return ast |
| 107 | } |
| 108 | if (ast.indexSignatures.length > 0) { |
| 109 | return objectToEntries(ast, recur) |
| 110 | } |
| 111 | |
| 112 | const propertySignatures = SchemaAST.mapOrSame(ast.propertySignatures, (propertySignature) => { |
| 113 | let type = recur(propertySignature.type) |
| 114 | if (SchemaAST.isOptional(propertySignature.type)) { |
| 115 | type = optionalToNullable(type) |
| 116 | } |
| 117 | return type === propertySignature.type |
| 118 | ? propertySignature |
| 119 | : new SchemaAST.PropertySignature(propertySignature.name, type) |
| 120 | }) |
| 121 | const checks = prepareChecks(ast.checks) |
| 122 | if ( |
| 123 | propertySignatures === ast.propertySignatures && |
| 124 | checks === ast.checks |
| 125 | ) { |
| 126 | return ast |
| 127 | } |
| 128 | return new SchemaAST.Objects( |
| 129 | propertySignatures, |
| 130 | ast.indexSignatures, |
| 131 | ast.annotations, |
| 132 | checks, |
| 133 | ast.encoding, |
| 134 | ast.context, |
| 135 | ast.encodingChecks |
| 136 | ) |
| 137 | } |
| 138 | case "Suspend": { |
| 139 | const cached = cache?.get(ast) |
| 140 | if (cached !== undefined) return cached |
| 141 | const out = new SchemaAST.Suspend( |
| 142 | () => recur(ast.thunk()), |
| 143 | ast.annotations, |
| 144 | undefined, |
| 145 | ast.encoding, |
| 146 | ast.context |
| 147 | ) |
| 148 | if (cache === undefined) cache = new Map() |
| 149 | cache.set(ast, out) |
| 150 | return out |
| 151 | } |
| 152 | default: { |
| 153 | const checks = prepareChecks(ast.checks) |
| 154 | return checks === ast.checks ? ast : SchemaAST.replaceChecks(ast, checks) |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | return recur(root) |
| 160 | } |
| 161 | |
| 162 | function tupleToObject(ast: SchemaAST.Arrays, recur: (ast: SchemaAST.AST) => SchemaAST.AST): SchemaAST.AST { |
| 163 | const propertySignatures = ast.elements.map((element, index) => |
| 164 | new SchemaAST.PropertySignature(String(index), element) |
| 165 | ) |
| 166 | if (ast.rest.length === 1) { |
| 167 | propertySignatures.push( |
| 168 | new SchemaAST.PropertySignature(REST_PROPERTY_NAME, new SchemaAST.Arrays(false, [], ast.rest)) |
| 169 | ) |
| 170 | } else if (ast.rest.length > 1) { |
| 171 | propertySignatures.push( |
| 172 | new SchemaAST.PropertySignature(REST_PROPERTY_NAME, new SchemaAST.Arrays(false, [], [ast.rest[0]])) |
| 173 | ) |
| 174 | for (let index = 1; index < ast.rest.length; index++) { |
| 175 | propertySignatures.push( |
| 176 | new SchemaAST.PropertySignature(`${TAIL_PROPERTY_PREFIX}${index - 1}__`, ast.rest[index]) |
| 177 | ) |
| 178 | } |
| 179 | } |
| 180 | const from = recur( |
| 181 | new SchemaAST.Objects( |
| 182 | propertySignatures, |
| 183 | [], |
| 184 | structuralAnnotations(ast, ast.rest.length > 1 ? TUPLE_TAIL_DESCRIPTION : TUPLE_DESCRIPTION), |
| 185 | compilerChecks(ast.checks) |
| 186 | ) |
| 187 | ) |
| 188 | return SchemaAST.decodeTo( |
| 189 | from, |
| 190 | ast, |
| 191 | SchemaTransformation.transform({ |
| 192 | decode: (object) => { |
| 193 | const tuple: Array<unknown> = [] |
| 194 | for (let index = 0; index < ast.elements.length; index++) { |
| 195 | const key = String(index) |
| 196 | if (object[key] !== undefined) tuple.push(object[key]) |
| 197 | } |
| 198 | if (REST_PROPERTY_NAME in object) { |
| 199 | const rest = object[REST_PROPERTY_NAME] |
| 200 | for (let index = 0; index < rest.length; index++) { |
| 201 | tuple.push(rest[index]) |
| 202 | } |
| 203 | } |
| 204 | for (let index = 1; index < ast.rest.length; index++) { |
| 205 | tuple.push(object[`${TAIL_PROPERTY_PREFIX}${index - 1}__`]) |
| 206 | } |
| 207 | return tuple |
| 208 | }, |
| 209 | encode: (tuple) => { |
| 210 | const object: Record<string, unknown> = {} |
| 211 | for (let index = 0; index < ast.elements.length; index++) { |
| 212 | if (index < tuple.length) object[String(index)] = tuple[index] |
| 213 | } |
| 214 | if (ast.rest.length >= 1) { |
| 215 | const tailLength = ast.rest.length - 1 |
| 216 | const restEnd = Math.max(ast.elements.length, tuple.length - tailLength) |
| 217 | object[REST_PROPERTY_NAME] = tuple.slice(ast.elements.length, restEnd) |
| 218 | for (let index = 0; index < tailLength; index++) { |
| 219 | object[`${TAIL_PROPERTY_PREFIX}${index}__`] = tuple[restEnd + index] |
| 220 | } |
| 221 | } |
| 222 | return object |
| 223 | } |
| 224 | }) |
| 225 | ) |
| 226 | } |
| 227 | |
| 228 | function objectToEntries( |
| 229 | ast: SchemaAST.Objects, |
| 230 | recur: (ast: SchemaAST.AST) => SchemaAST.AST |
| 231 | ): SchemaAST.AST { |
| 232 | const checks = combineChecks(recordChecks(ast.checks), compilerChecks(ast.checks)) |
| 233 | const key = unionOrSingle([ |
| 234 | ...ast.propertySignatures.map((propertySignature) => new SchemaAST.Literal(propertySignature.name as string)), |
| 235 | ...ast.indexSignatures.map((indexSignature) => indexSignature.parameter) |
| 236 | ]) |
| 237 | const value = unionOrSingle([ |
| 238 | ...ast.propertySignatures.map((propertySignature) => propertySignature.type), |
| 239 | ...ast.indexSignatures.map((indexSignature) => indexSignature.type) |
| 240 | ]) |
| 241 | const from = recur( |
| 242 | new SchemaAST.Arrays( |
| 243 | false, |
| 244 | [], |
| 245 | [new SchemaAST.Arrays(false, [key, value], [])], |
| 246 | structuralAnnotations(ast, RECORD_DESCRIPTION), |
| 247 | checks |
| 248 | ) |
| 249 | ) |
| 250 | return SchemaAST.decodeTo( |
| 251 | from, |
| 252 | ast, |
| 253 | SchemaTransformation.transform({ |
| 254 | decode: Object.fromEntries, |
| 255 | encode: Object.entries |
| 256 | }) |
| 257 | ) |
| 258 | } |
| 259 | |
| 260 | function unionOrSingle(types: ReadonlyArray<SchemaAST.AST>): SchemaAST.AST { |
| 261 | if (types.length === 1) return types[0] |
| 262 | const unique = Array.from(new Set(types)) |
| 263 | return unique.length === 1 ? unique[0] : new SchemaAST.Union(unique) |
| 264 | } |
| 265 | |
| 266 | function combineChecks( |
| 267 | left: SchemaAST.Checks | undefined, |
| 268 | right: SchemaAST.Checks | undefined |
| 269 | ): SchemaAST.Checks | undefined { |
| 270 | if (left === undefined) return right |
| 271 | if (right === undefined) return left |
| 272 | return [...left, ...right] |
| 273 | } |
| 274 | |
| 275 | function compilerChecks(checks: SchemaAST.Checks | undefined): SchemaAST.Checks | undefined { |
| 276 | if (checks === undefined) return undefined |
| 277 | const out = checks.flatMap(compilerCheck) |
| 278 | return Arr.isArrayNonEmpty(out) ? out : undefined |
| 279 | } |
| 280 | |
| 281 | function compilerCheck(check: SchemaAST.Check<any>): Array<SchemaAST.Check<any>> { |
| 282 | // Structural rewrites change the value seen by provider-side checks. A |
| 283 | // no-op proxy lets the generic compiler call `toJsonSchema` with the real |
| 284 | // provider type and dependency schemas without running the original filter |
| 285 | // against the wrong runtime shape. |
| 286 | const annotations = compilerAnnotations(check.annotations) |
| 287 | if (check._tag === "Filter") { |
| 288 | return annotations === undefined ? [] : [new SchemaAST.Filter(() => undefined, annotations)] |
| 289 | } |
| 290 | const checks = check.checks.flatMap(compilerCheck) |
| 291 | if (Arr.isArrayNonEmpty(checks)) { |
| 292 | return [new SchemaAST.FilterGroup(checks, annotations)] |
| 293 | } |
| 294 | return annotations === undefined ? [] : [new SchemaAST.Filter(() => undefined, annotations)] |
| 295 | } |
| 296 | |
| 297 | function compilerAnnotations( |
| 298 | annotations: Schema.Annotations.Filter | undefined |
| 299 | ): Schema.Annotations.Filter | undefined { |
| 300 | if (annotations?.toJsonSchema === undefined) return undefined |
| 301 | return { |
| 302 | representation: annotations.representation, |
| 303 | toJsonSchema: annotations.toJsonSchema |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | function optionalToNullable(type: SchemaAST.AST): SchemaAST.AST { |
| 308 | return SchemaAST.decodeTo( |
| 309 | new SchemaAST.Union([type, SchemaAST.null]), |
| 310 | SchemaAST.optionalKey(type), |
| 311 | SchemaTransformation.transformOptional({ |
| 312 | decode: Option.filter(Predicate.isNotNull), |
| 313 | encode: Option.orElseSome(() => null) |
| 314 | }) |
| 315 | ) |
| 316 | } |
| 317 | |
| 318 | function prepareChecks(checks: SchemaAST.Checks | undefined): SchemaAST.Checks | undefined { |
| 319 | if (checks === undefined) return undefined |
| 320 | const out = SchemaAST.mapOrSame(checks, prepareCheck) |
| 321 | return out as SchemaAST.Checks |
| 322 | } |
| 323 | |
| 324 | function prepareCheck(check: SchemaAST.Check<any>): SchemaAST.Check<any> { |
| 325 | switch (check._tag) { |
| 326 | case "Filter": { |
| 327 | const id = check.annotations?.representation?.id |
| 328 | return id === "effect/schema/isFinite" || id === "effect/schema/isInt" |
| 329 | ? check.annotate({ expected: undefined }) |
| 330 | : check |
| 331 | } |
| 332 | case "FilterGroup": { |
| 333 | const checks = SchemaAST.mapOrSame(check.checks, prepareCheck) |
| 334 | return checks === check.checks |
| 335 | ? check |
| 336 | : new SchemaAST.FilterGroup(checks, check.annotations) |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | function recordChecks(checks: SchemaAST.Checks | undefined): SchemaAST.Checks | undefined { |
| 342 | if (checks === undefined) return undefined |
| 343 | const out = checks.flatMap(recordCheck) |
| 344 | return Arr.isArrayNonEmpty(out) ? out : undefined |
| 345 | } |
| 346 | |
| 347 | function recordCheck(check: SchemaAST.Check<any>): Array<SchemaAST.Check<any>> { |
| 348 | if (check._tag === "FilterGroup") return check.checks.flatMap(recordCheck) |
| 349 | const representation = check.annotations?.representation |
| 350 | const payload = representation?.payload |
| 351 | if (!isJsonObject(payload)) return [] |
| 352 | // A decoded record with at least N properties necessarily came from at least |
| 353 | // N entries. Upper bounds are not transported because duplicate provider |
| 354 | // keys can collapse during `Object.fromEntries`, so `maxItems` could reject |
| 355 | // an input that the returned codec accepts. |
| 356 | switch (representation?.id) { |
| 357 | case "effect/schema/isMinProperties": |
| 358 | return typeof payload.minProperties === "number" |
| 359 | ? [withoutDescription(Schema.isMinLength(payload.minProperties))] |
| 360 | : [] |
| 361 | case "effect/schema/isBetweenProperties": |
| 362 | return typeof payload.minimum === "number" |
| 363 | ? [withoutDescription(Schema.isMinLength(payload.minimum))] |
| 364 | : [] |
| 365 | default: |
| 366 | return [] |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | function isJsonObject(input: Schema.Json | undefined): input is Schema.JsonObject { |
| 371 | return typeof input === "object" && input !== null && !Array.isArray(input) |
| 372 | } |
| 373 | |
| 374 | function withoutDescription(check: SchemaAST.Filter<any>): SchemaAST.Filter<any> { |
| 375 | return check.annotate({ |
| 376 | description: undefined, |
| 377 | expected: undefined, |
| 378 | title: undefined |
| 379 | }) |
| 380 | } |
| 381 | |
| 382 | function structuralAnnotations( |
| 383 | ast: SchemaAST.AST, |
| 384 | structuralDescription: string |
| 385 | ): Schema.Annotations.Annotations { |
| 386 | const descriptions: Array<string> = [] |
| 387 | appendAnnotationDescription(descriptions, ast.annotations) |
| 388 | if (ast.checks !== undefined) { |
| 389 | for (const check of ast.checks) appendCheckDescriptions(descriptions, check) |
| 390 | } |
| 391 | return { |
| 392 | description: descriptions.length === 0 |
| 393 | ? structuralDescription |
| 394 | : `${structuralDescription}; ${descriptions.join(" and ")}` |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | function appendCheckDescriptions(descriptions: Array<string>, check: SchemaAST.Check<any>): void { |
| 399 | appendAnnotationDescription(descriptions, check.annotations) |
| 400 | if (check._tag === "FilterGroup") { |
| 401 | for (const child of check.checks) appendCheckDescriptions(descriptions, child) |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | function appendAnnotationDescription( |
| 406 | descriptions: Array<string>, |
| 407 | annotations: Schema.Annotations.Annotations | undefined |
| 408 | ): void { |
| 409 | const description = annotations?.description ?? annotations?.expected |
| 410 | if (typeof description === "string" && !descriptions.includes(description)) { |
| 411 | descriptions.push(description) |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | type JsonSchemaVisitor = (schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema |
| 416 | |
| 417 | /** |
| 418 | * @internal |
| 419 | */ |
| 420 | export function walkJsonSchema( |
| 421 | schema: JsonSchema.JsonSchema, |
| 422 | visitor: JsonSchemaVisitor |
| 423 | ): JsonSchema.JsonSchema { |
| 424 | const out: JsonSchema.JsonSchema = {} |
| 425 | for (const [key, value] of Object.entries(schema)) { |
| 426 | switch (key) { |
| 427 | case "properties": |
| 428 | case "patternProperties": { |
| 429 | if (isJsonSchema(value)) { |
| 430 | const properties: Record<string, unknown> = {} |
| 431 | for (const [name, property] of Object.entries(value)) { |
| 432 | InternalRecord.assignProperty( |
| 433 | properties, |
| 434 | name, |
| 435 | isJsonSchema(property) ? walkJsonSchema(property, visitor) : property |
| 436 | ) |
| 437 | } |
| 438 | InternalRecord.assignProperty(out, key, properties) |
| 439 | } else { |
| 440 | InternalRecord.assignProperty(out, key, value) |
| 441 | } |
| 442 | break |
| 443 | } |
| 444 | case "additionalProperties": |
| 445 | case "items": |
| 446 | case "propertyNames": { |
| 447 | InternalRecord.assignProperty(out, key, isJsonSchema(value) ? walkJsonSchema(value, visitor) : value) |
| 448 | break |
| 449 | } |
| 450 | case "prefixItems": |
| 451 | case "allOf": |
| 452 | case "anyOf": |
| 453 | case "oneOf": { |
| 454 | InternalRecord.assignProperty( |
| 455 | out, |
| 456 | key, |
| 457 | Array.isArray(value) |
| 458 | ? value.map((member) => isJsonSchema(member) ? walkJsonSchema(member, visitor) : member) |
| 459 | : value |
| 460 | ) |
| 461 | break |
| 462 | } |
| 463 | default: |
| 464 | InternalRecord.assignProperty(out, key, value) |
| 465 | break |
| 466 | } |
| 467 | } |
| 468 | return visitor(out) |
| 469 | } |
| 470 | |
| 471 | /** |
| 472 | * @internal |
| 473 | */ |
| 474 | export function appendDescription(schema: JsonSchema.JsonSchema, description: string): void { |
| 475 | if (typeof schema.description === "string") { |
| 476 | const descriptions = schema.description.split(" and ") |
| 477 | if (!descriptions.includes(description)) schema.description += ` and ${description}` |
| 478 | } else { |
| 479 | schema.description = description |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | /** |
| 484 | * @internal |
| 485 | */ |
| 486 | export function isJsonSchema(input: unknown): input is JsonSchema.JsonSchema { |
| 487 | return typeof input === "object" && input !== null && !Array.isArray(input) |
| 488 | } |