Skip to content

Commit 38abd67

Browse files
authored
filter non-JSON values from schema examples and defaults, closes #5884 (#5888)
1 parent 44e0b04 commit 38abd67

File tree

3 files changed

+61
-6
lines changed

3 files changed

+61
-6
lines changed

.changeset/cool-insects-move.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
filter non-JSON values from schema examples and defaults, closes #5884
6+
7+
Introduce JsonValue type and update JsonSchemaAnnotations to use it for
8+
type safety. Add validation to filter invalid values (BigInt, cyclic refs)
9+
from examples and defaults, preventing infinite recursion on cycles.

packages/effect/src/JSONSchema.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,17 @@ import * as Record from "./Record.js"
1212
import type * as Schema from "./Schema.js"
1313
import * as AST from "./SchemaAST.js"
1414

15+
type JsonValue = string | number | boolean | null | Array<JsonValue> | { [key: string]: JsonValue }
16+
1517
/**
1618
* @category model
1719
* @since 3.10.0
1820
*/
1921
export interface JsonSchemaAnnotations {
2022
title?: string
2123
description?: string
22-
default?: unknown
23-
examples?: Array<unknown>
24+
default?: JsonValue
25+
examples?: Array<JsonValue>
2426
}
2527

2628
/**
@@ -415,9 +417,9 @@ function getRawExamples(annotated: AST.Annotated | undefined): ReadonlyArray<unk
415417
if (annotated !== undefined) return Option.getOrUndefined(AST.getExamplesAnnotation(annotated))
416418
}
417419

418-
function encodeExamples(ast: AST.AST, examples: ReadonlyArray<unknown>): Array<unknown> | undefined {
420+
function encodeExamples(ast: AST.AST, examples: ReadonlyArray<unknown>): Array<JsonValue> | undefined {
419421
const getOption = ParseResult.getOption(ast, false)
420-
const out = Arr.filterMap(examples, (e) => getOption(e))
422+
const out = Arr.filterMap(examples, (e) => getOption(e).pipe(Option.filter(isJsonValue)))
421423
return out.length > 0 ? out : undefined
422424
}
423425

@@ -435,6 +437,38 @@ function filterBuiltIn(ast: AST.AST, annotation: string | undefined, key: symbol
435437
return annotation
436438
}
437439

440+
function isJsonValue(value: unknown, visited: Set<unknown> = new Set()): value is JsonValue {
441+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
442+
return true
443+
}
444+
if (Array.isArray(value) || typeof value === "object") {
445+
// Check for cyclic references
446+
if (visited.has(value)) {
447+
return false
448+
}
449+
visited.add(value)
450+
try {
451+
if (Array.isArray(value)) {
452+
return value.every((item) => isJsonValue(item, visited))
453+
}
454+
// Exclude non-plain objects (Date, RegExp, etc.) by checking constructor
455+
const proto = Object.getPrototypeOf(value)
456+
if (proto !== null && proto !== Object.prototype) {
457+
return false
458+
}
459+
// JSON only allows string keys, so exclude objects with Symbol keys
460+
if (Object.getOwnPropertySymbols(value).length > 0) {
461+
return false
462+
}
463+
// Check all values are JSON values
464+
return Object.values(value).every((v) => isJsonValue(v, visited))
465+
} finally {
466+
visited.delete(value)
467+
}
468+
}
469+
return false
470+
}
471+
438472
function pruneJsonSchemaAnnotations(
439473
ast: AST.AST,
440474
description: string | undefined,
@@ -447,7 +481,7 @@ function pruneJsonSchemaAnnotations(
447481
if (title !== undefined) out.title = title
448482
if (Option.isSome(def)) {
449483
const o = encodeDefault(ast, def.value)
450-
if (Option.isSome(o)) {
484+
if (Option.isSome(o) && isJsonValue(o.value)) {
451485
out.default = o.value
452486
}
453487
}
@@ -949,7 +983,9 @@ function go(
949983
if (Predicate.isString(toProperty.title)) annotations.title = toProperty.title
950984
if (Predicate.isString(toProperty.description)) annotations.description = toProperty.description
951985
if (Array.isArray(toProperty.examples)) annotations.examples = toProperty.examples
952-
if (Object.hasOwn(toProperty, "default")) annotations.default = toProperty.default
986+
if (Object.hasOwn(toProperty, "default") && toProperty.default !== undefined) {
987+
annotations.default = toProperty.default
988+
}
953989
from.properties[fromKey] = addAnnotations(fromProperty, annotations)
954990
}
955991
}

packages/effect/test/Schema/JSONSchema.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,16 @@ describe("fromAST", () => {
670670
})
671671

672672
describe("make", () => {
673+
it("should filter out non-JSON values and cyclic references from default and examples", () => {
674+
const cyclic: any = { value: "test" }
675+
cyclic.self = cyclic
676+
const schema = Schema.String.annotations({ default: 1n as any, examples: ["a", 1n as any, cyclic, "b"] })
677+
expectJSONSchemaAnnotations(schema, {
678+
"type": "string",
679+
"examples": ["a", "b"]
680+
})
681+
})
682+
673683
it("handling of a top level `parseJson` should targeting the \"to\" side", () => {
674684
const schema = Schema.parseJson(Schema.Struct({
675685
a: Schema.parseJson(Schema.NumberFromString)

0 commit comments

Comments
 (0)