From 8ae06bc93401e5f9573bd2aef11e2817790b5dee Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Fri, 22 May 2026 23:01:22 -0600 Subject: [PATCH 1/2] feat(generation): add Agentif-focused schema generation --- Project.toml | 2 +- README.md | 22 +++- src/JSONSchema.jl | 3 +- src/generation.jl | 301 +++++++++++++++++++++++++++++++++++++++++++++ src/schema.jl | 15 +++ test/generation.jl | 221 +++++++++++++++++++++++++++++++++ test/runtests.jl | 3 + 7 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 src/generation.jl create mode 100644 test/generation.jl diff --git a/Project.toml b/Project.toml index 63b7b46..70d5945 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "JSONSchema" uuid = "7d188eb4-7ad8-530c-ae41-71a32a6d4692" -version = "1.5.0" +version = "1.6.0" [deps] Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" diff --git a/README.md b/README.md index 8e118f7..9d2bc90 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ valid document. This package has been tested with the [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) -for draft v4 and v6. +for draft v4, v6, and v7. ## API @@ -80,3 +80,23 @@ As a short-hand for `validate(schema, x) === nothing`, use Note that if `x` is a `String` in JSON format, you must use `JSON.parse(x)` before passing to `validate`, that is, JSONSchema operates on the parsed representation, not on the underlying `String` representation of the JSON data. + +Generate a `Schema` object from a Julia type by calling `schema`: +```julia +julia> params = schema( + @NamedTuple{query::String, limit::Union{Nothing,Int}}; + additionalProperties = false, + ) +A JSONSchema + +julia> params.spec["required"] +1-element Vector{String}: + "query" +``` + +The initial generator is intended for simple typed API parameters. It supports +`NamedTuple`s, concrete structs, JSON scalar types, vectors, dictionaries, +tuples, and nullable unions such as `Union{Nothing,String}`. Generated schemas +are returned as ordinary `Schema` objects; the underlying dictionary is +available as both `.data` and `.spec`, and `JSON.json(params)` serializes the +generated schema. diff --git a/src/JSONSchema.jl b/src/JSONSchema.jl index 1fec843..30b72f3 100644 --- a/src/JSONSchema.jl +++ b/src/JSONSchema.jl @@ -9,9 +9,10 @@ import Downloads import JSON import URIs -export Schema, validate +export Schema, schema, validate include("schema.jl") +include("generation.jl") include("validation.jl") export diagnose diff --git a/src/generation.jl b/src/generation.jl new file mode 100644 index 0000000..379de87 --- /dev/null +++ b/src/generation.jl @@ -0,0 +1,301 @@ +# Copyright (c) 2018: fredo-dedup and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +const DEFAULT_GENERATED_DRAFT = "https://json-schema.org/draft-07/schema#" + +""" + schema(::Type{T}; kwargs...) where {T} + +Generate a small JSON Schema for Julia data type `T`. + +This generator intentionally covers the type shapes used by agent tool +parameters: `NamedTuple`s, concrete structs, scalar JSON primitives, vectors, +dicts, tuples, and nullable unions such as `Union{Nothing, String}`. + +Keyword arguments: +- `draft`: URI to place in the generated `"\$schema"` field. +- `all_fields_required`: mark every object field as required, including fields + whose type admits `nothing` or `missing`. +- `additionalProperties`: when set to a boolean, apply that value to generated + object schemas that have fixed properties. + +`refs` is accepted for provider compatibility; generated schemas are inlined +in this first pass. +""" +function schema( + ::Type{T}; + draft::AbstractString = DEFAULT_GENERATED_DRAFT, + refs = false, + all_fields_required::Bool = false, + additionalProperties::Union{Nothing,Bool} = nothing, +) where {T} + generated = _schema_for_type(T; all_fields_required, draft) + generated["\$schema"] = String(draft) + if additionalProperties !== nothing + _set_additional_properties!(generated, additionalProperties) + end + return Schema(generated) +end + +function _schema_for_type( + ::Type{T}; + all_fields_required::Bool, + draft::AbstractString, +) where {T} + if T === Any + return Dict{String,Any}() + elseif T === Nothing || T === Missing + return Dict{String,Any}("type" => "null") + elseif _is_union_type(T) + return _union_schema(T; all_fields_required, draft) + elseif T <: AbstractString || T <: Symbol + return Dict{String,Any}("type" => "string") + elseif T <: Bool + return Dict{String,Any}("type" => "boolean") + elseif T <: Integer + return Dict{String,Any}("type" => "integer") + elseif T <: Real + return Dict{String,Any}("type" => "number") + elseif T <: NamedTuple + return _object_schema( + fieldnames(T), + fieldtypes(T); + all_fields_required, + draft, + ) + elseif T <: AbstractVector + return _array_schema(eltype(T); all_fields_required, draft) + elseif T <: Tuple + return _tuple_schema(T; all_fields_required, draft) + elseif T <: AbstractDict + return _dict_schema(T; all_fields_required, draft) + elseif isconcretetype(T) && isstructtype(T) + return _object_schema( + fieldnames(T), + fieldtypes(T); + all_fields_required, + draft, + ) + else + return Dict{String,Any}() + end +end + +_is_union_type(::Type{T}) where {T} = T isa Union + +function _union_schema( + ::Type{T}; + all_fields_required::Bool, + draft::AbstractString, +) where {T} + union_types = Base.uniontypes(T) + nullable = any(t -> t === Nothing || t === Missing, union_types) + non_null_types = filter(t -> t !== Nothing && t !== Missing, union_types) + + if isempty(non_null_types) + return Dict{String,Any}("type" => "null") + end + + if length(non_null_types) == 1 + schema = _schema_for_type( + first(non_null_types); + all_fields_required, + draft, + ) + nullable && return _nullable_schema(schema) + return schema + end + + alternatives = Any[ + _schema_for_type(t; all_fields_required, draft) for t in non_null_types + ] + if nullable + push!(alternatives, Dict{String,Any}("type" => "null")) + end + return Dict{String,Any}("anyOf" => alternatives) +end + +function _nullable_schema(schema::AbstractDict) + schema = deepcopy(schema) + typ = get(schema, "type", nothing) + if typ isa AbstractString + schema["type"] = Any[typ, "null"] + elseif typ isa AbstractVector + values = Any[typ...] + "null" in values || push!(values, "null") + schema["type"] = values + else + schema = Dict{String,Any}( + "anyOf" => Any[schema, Dict{String,Any}("type" => "null")], + ) + end + return schema +end + +function _array_schema( + ::Type{T}; + all_fields_required::Bool, + draft::AbstractString, +) where {T} + return Dict{String,Any}( + "type" => "array", + "items" => _schema_for_type(T; all_fields_required, draft), + ) +end + +function _tuple_schema( + ::Type{T}; + all_fields_required::Bool, + draft::AbstractString, +) where {T} + parameters = collect(T.parameters) + if _is_unbounded_vararg_tuple(parameters) + fixed_parameters = parameters[1:end-1] + vararg_type = getfield(parameters[end], :T) + if isempty(fixed_parameters) + return _array_schema(vararg_type; all_fields_required, draft) + end + + fixed_schemas = Any[ + _schema_for_type(t; all_fields_required, draft) for t in fixed_parameters + ] + generated = Dict{String,Any}( + "type" => "array", + "minItems" => length(fixed_parameters), + ) + if _uses_prefix_items(draft) + generated["prefixItems"] = fixed_schemas + generated["items"] = _schema_for_type( + vararg_type; + all_fields_required, + draft, + ) + else + generated["items"] = fixed_schemas + generated["additionalItems"] = _schema_for_type( + vararg_type; + all_fields_required, + draft, + ) + end + return generated + end + + tuple_types = fieldtypes(T) + if isempty(tuple_types) + return Dict{String,Any}( + "type" => "array", + "maxItems" => 0, + ) + end + + tuple_schemas = Any[ + _schema_for_type(t; all_fields_required, draft) for t in tuple_types + ] + generated = Dict{String,Any}( + "type" => "array", + "minItems" => length(tuple_types), + "maxItems" => length(tuple_types), + ) + if _uses_prefix_items(draft) + generated["prefixItems"] = tuple_schemas + else + generated["items"] = tuple_schemas + generated["additionalItems"] = false + end + return generated +end + +function _is_unbounded_vararg_tuple(parameters) + return !isempty(parameters) && + parameters[end] isa Core.TypeofVararg && + !isdefined(parameters[end], :N) +end + +function _uses_prefix_items(draft::AbstractString) + return occursin("2019-09", draft) || occursin("2020-12", draft) +end + +function _dict_schema( + ::Type{T}; + all_fields_required::Bool, + draft::AbstractString, +) where {T} + value_schema = _schema_for_type(valtype(T); all_fields_required, draft) + return Dict{String,Any}( + "type" => "object", + "additionalProperties" => value_schema, + ) +end + +function _object_schema( + names, + types::Tuple; + all_fields_required::Bool, + draft::AbstractString, +) + properties = Dict{String,Any}() + required = String[] + for (name, type) in zip(names, types) + field_name = string(name) + properties[field_name] = _schema_for_type( + type; + all_fields_required, + draft, + ) + if all_fields_required || _is_required_type(type) + push!(required, field_name) + end + end + generated = Dict{String,Any}( + "type" => "object", + "properties" => properties, + ) + if !isempty(required) + generated["required"] = required + end + return generated +end + +function _is_required_type(::Type{T}) where {T} + return !(Nothing <: T) && !(Missing <: T) +end + +function _set_additional_properties!(schema, value::Bool) + return schema +end + +function _set_additional_properties!(schema::AbstractVector, value::Bool) + for item in schema + _set_additional_properties!(item, value) + end + return schema +end + +function _set_additional_properties!(schema::AbstractDict, value::Bool) + if haskey(schema, "properties") + schema["additionalProperties"] = value + elseif get(schema, "type", nothing) == "object" && + !haskey(schema, "additionalProperties") + schema["additionalProperties"] = value + end + for key in ("properties", "\$defs", "definitions") + children = get(schema, key, nothing) + if children isa AbstractDict + for child in values(children) + _set_additional_properties!(child, value) + end + end + end + for key in ("items", "additionalProperties") + child = get(schema, key, nothing) + _set_additional_properties!(child, value) + end + for key in ("prefixItems", "anyOf", "oneOf", "allOf") + children = get(schema, key, nothing) + _set_additional_properties!(children, value) + end + return schema +end diff --git a/src/schema.jl b/src/schema.jl index 2e36033..a58c3ad 100644 --- a/src/schema.jl +++ b/src/schema.jl @@ -294,4 +294,19 @@ my_schema = Schema( """ Schema(schema::String; kwargs...) = Schema(JSON.parse(schema); kwargs...) +function Base.getproperty(schema::Schema, name::Symbol) + name === :spec && return getfield(schema, :data) + return getfield(schema, name) +end + +function Base.propertynames(::Schema; private::Bool = false) + return private ? (:data, :spec) : (:data, :spec) +end + +Base.getindex(schema::Schema, key) = schema.data[key] +Base.haskey(schema::Schema, key) = haskey(schema.data, key) +Base.get(schema::Schema, key, default) = get(schema.data, key, default) +Base.keys(schema::Schema) = keys(schema.data) +JSON.lower(schema::Schema) = schema.data + Base.show(io::IO, ::Schema) = print(io, "A JSONSchema") diff --git a/test/generation.jl b/test/generation.jl new file mode 100644 index 0000000..b9a6ad3 --- /dev/null +++ b/test/generation.jl @@ -0,0 +1,221 @@ +# Copyright (c) 2018: fredo-dedup and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +@testset "Schema generation" begin + @testset "Agentif-style tool parameters" begin + T = @NamedTuple{ + path::String, + recursive::Bool, + limit::Union{Nothing,Int}, + tags::Vector{String}, + metadata::Dict{String,Any}, + } + + generated = JSONSchema.schema( + T; + all_fields_required = false, + additionalProperties = false, + ) + + @test typeof(generated) == JSONSchema.Schema + @test generated.spec === generated.data + @test generated.spec["\$schema"] == + JSONSchema.DEFAULT_GENERATED_DRAFT + @test generated.spec["type"] == "object" + @test generated.spec["additionalProperties"] == false + + properties = generated.spec["properties"] + @test properties["path"]["type"] == "string" + @test properties["recursive"]["type"] == "boolean" + @test properties["limit"]["type"] == Any["integer", "null"] + @test properties["tags"]["type"] == "array" + @test properties["tags"]["items"]["type"] == "string" + @test properties["metadata"]["type"] == "object" + @test properties["metadata"]["additionalProperties"] == Dict{String,Any}() + + @test Set(generated.spec["required"]) == + Set(["path", "recursive", "tags", "metadata"]) + @test isvalid( + generated, + Dict( + "path" => "README.md", + "recursive" => false, + "tags" => ["docs"], + "metadata" => Dict("source" => "agentif"), + ), + ) + @test isvalid( + generated, + Dict( + "path" => "README.md", + "recursive" => true, + "limit" => nothing, + "tags" => String[], + "metadata" => Dict{String,Any}(), + ), + ) + @test !isvalid( + generated, + Dict( + "path" => "README.md", + "recursive" => false, + "limit" => "ten", + "tags" => ["docs"], + "metadata" => Dict{String,Any}(), + ), + ) + @test !isvalid( + generated, + Dict( + "path" => "README.md", + "recursive" => false, + "tags" => ["docs"], + "metadata" => Dict{String,Any}(), + "extra" => true, + ), + ) + end + + @testset "Provider required-field override" begin + T = @NamedTuple{required::String, optional::Union{Nothing,String}} + + generated = JSONSchema.schema( + T; + all_fields_required = true, + additionalProperties = false, + ) + + @test Set(generated.spec["required"]) == Set(["required", "optional"]) + + non_nullable = String[] + for (name, type) in zip(fieldnames(T), fieldtypes(T)) + if !(Nothing <: type) + push!(non_nullable, string(name)) + end + end + generated.spec["required"] = non_nullable + + @test Set(generated.spec["required"]) == Set(["required"]) + end + + @testset "Union fields" begin + T = @NamedTuple{ + only_null::Union{Nothing,Missing}, + scalar::Union{String,Int}, + nullable_scalar::Union{Nothing,String,Int}, + } + generated = JSONSchema.schema(T) + properties = generated.spec["properties"] + + @test properties["only_null"]["type"] == "null" + @test Set(s["type"] for s in properties["scalar"]["anyOf"]) == + Set(["integer", "string"]) + @test Set(s["type"] for s in properties["nullable_scalar"]["anyOf"]) == + Set(["integer", "null", "string"]) + end + + @testset "Anthropic-style draft override" begin + generated = JSONSchema.schema( + @NamedTuple{x::String}; + draft = "https://json-schema.org/draft/2020-12/schema", + refs = :defs, + ) + + @test generated.spec["\$schema"] == + "https://json-schema.org/draft/2020-12/schema" + @test !haskey(generated.spec, "\$defs") + end + + @testset "Google nullable serialization path" begin + T = @NamedTuple{optional::Union{Nothing,String}} + generated = JSONSchema.schema( + T; + additionalProperties = false, + ) + parsed = JSON.parse(JSON.json(generated)) + optional = parsed["properties"]["optional"] + + @test optional["type"] == Any["string", "null"] + end + + @testset "Generated Schema conveniences" begin + generated = JSONSchema.schema(@NamedTuple{x::String}) + + @test generated["type"] == "object" + @test haskey(generated, "properties") + @test get(generated, "missing", 42) == 42 + @test "required" in collect(keys(generated)) + @test JSON.lower(generated) === generated.data + @test JSON.parse(JSON.json(generated))["type"] == "object" + end + + @testset "Struct parameters" begin + struct SearchOptions + query::String + max_results::Union{Nothing,Int} + end + + generated = JSONSchema.schema(SearchOptions; additionalProperties = false) + + @test typeof(generated) == JSONSchema.Schema + @test generated.spec["properties"]["query"]["type"] == "string" + @test generated.spec["properties"]["max_results"]["type"] == + Any["integer", "null"] + @test Set(generated.spec["required"]) == Set(["query"]) + end + + @testset "Nested additionalProperties" begin + T = @NamedTuple{ + entries::Dict{String,@NamedTuple{enabled::Bool}}, + } + + generated = JSONSchema.schema(T; additionalProperties = false) + entries = generated.spec["properties"]["entries"] + entry = entries["additionalProperties"] + + @test generated.spec["additionalProperties"] == false + @test entries["type"] == "object" + @test entry["type"] == "object" + @test entry["properties"]["enabled"]["type"] == "boolean" + @test entry["additionalProperties"] == false + end + + @testset "Tuple parameters" begin + repeated = JSONSchema.schema(Tuple{Vararg{String}}) + prefixed = JSONSchema.schema(Tuple{String,Vararg{Int}}) + fixed = JSONSchema.schema(Tuple{String,Int}) + unbounded = JSONSchema.schema(Tuple) + + @test repeated.spec["type"] == "array" + @test repeated.spec["items"]["type"] == "string" + @test prefixed.spec["items"][1]["type"] == "string" + @test prefixed.spec["additionalItems"]["type"] == "integer" + @test fixed.spec["items"][1]["type"] == "string" + @test fixed.spec["items"][2]["type"] == "integer" + @test fixed.spec["additionalItems"] == false + @test unbounded.spec["items"] == Dict{String,Any}() + end + + @testset "Tuple parameters in draft 2020-12" begin + draft = "https://json-schema.org/draft/2020-12/schema" + fixed = JSONSchema.schema(Tuple{String,Int}; draft) + prefixed = JSONSchema.schema(Tuple{String,Vararg{Int}}; draft) + nested = JSONSchema.schema(@NamedTuple{coords::Tuple{String,Int}}; draft) + + @test fixed.spec["\$schema"] == draft + @test fixed.spec["prefixItems"][1]["type"] == "string" + @test fixed.spec["prefixItems"][2]["type"] == "integer" + @test !haskey(fixed.spec, "additionalItems") + @test !haskey(fixed.spec, "items") + + @test prefixed.spec["prefixItems"][1]["type"] == "string" + @test prefixed.spec["items"]["type"] == "integer" + @test !haskey(prefixed.spec, "additionalItems") + + coords = nested.spec["properties"]["coords"] + @test coords["prefixItems"][1]["type"] == "string" + @test coords["prefixItems"][2]["type"] == "integer" + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 68f2e62..c58aa71 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -338,6 +338,9 @@ end @testset "exports" begin @test Schema === JSONSchema.Schema + @test schema === JSONSchema.schema @test validate === JSONSchema.validate @test diagnose === JSONSchema.diagnose end + +include("generation.jl") From 0418c0eb1b5dc4bcb0aa8e0fa215cc5f21763de9 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Tue, 26 May 2026 13:26:35 -0600 Subject: [PATCH 2/2] Refine schema generation API for 1.6 --- LICENSE.md | 2 +- README.md | 29 +++++++---- ext/JSONSchemaJSON3Ext.jl | 2 +- src/JSONSchema.jl | 4 +- src/generation.jl | 96 +++++++++++++--------------------- src/schema.jl | 14 +++-- src/validation.jl | 2 +- test/generation.jl | 105 +++++++++++++++++++++----------------- test/runtests.jl | 6 ++- 9 files changed, 128 insertions(+), 132 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index 8a9e1c8..ead75ef 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The JSONSchema.jl package is licensed under the MIT "Expat" License: -> Copyright (c) 2018: fredo. +> Copyright (c) 2026: fredo-dedup, quinnj, and contributors. > > Permission is hereby granted, free of charge, to any person obtaining a copy > of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9d2bc90..b64de2e 100644 --- a/README.md +++ b/README.md @@ -81,22 +81,33 @@ Note that if `x` is a `String` in JSON format, you must use `JSON.parse(x)` before passing to `validate`, that is, JSONSchema operates on the parsed representation, not on the underlying `String` representation of the JSON data. -Generate a `Schema` object from a Julia type by calling `schema`: +Generate a `Schema` object from a Julia type by calling `JSONSchema.schema`: ```julia -julia> params = schema( +julia> params = JSONSchema.schema( @NamedTuple{query::String, limit::Union{Nothing,Int}}; additionalProperties = false, ) A JSONSchema -julia> params.spec["required"] +julia> JSONSchema.spec(params)["required"] 1-element Vector{String}: "query" ``` -The initial generator is intended for simple typed API parameters. It supports -`NamedTuple`s, concrete structs, JSON scalar types, vectors, dictionaries, -tuples, and nullable unions such as `Union{Nothing,String}`. Generated schemas -are returned as ordinary `Schema` objects; the underlying dictionary is -available as both `.data` and `.spec`, and `JSON.json(params)` serializes the -generated schema. +`JSONSchema.schema` is intentionally not exported, to avoid clashing with common +names in user code. The initial generator is intended for simple typed API +parameters. It supports `NamedTuple`s, concrete structs, JSON scalar types, +vectors, dictionaries, tuples, and nullable unions such as +`Union{Nothing,String}`. + +The generator emits an inline subset of JSON Schema that is valid for draft v7 +by default: `type`, `properties`, `required`, `additionalProperties`, `items`, +`additionalItems`, `anyOf`, and nullable primitive type arrays. Passing `draft` +sets the generated `"$schema"` URI; draft 2019-09 and 2020-12 also use +`prefixItems` for tuple schemas. The generator does not infer validation +constraints such as string patterns, numeric ranges, formats, enums, recursive +references, or schema definitions. + +Generated schemas are returned as ordinary `Schema` objects; the underlying +dictionary is available as `.data` or through `JSONSchema.spec(params)`, and +`JSON.json(params)` serializes the generated schema. diff --git a/ext/JSONSchemaJSON3Ext.jl b/ext/JSONSchemaJSON3Ext.jl index 07cbb3b..c09c0bd 100644 --- a/ext/JSONSchemaJSON3Ext.jl +++ b/ext/JSONSchemaJSON3Ext.jl @@ -1,4 +1,4 @@ -# Copyright (c) 2018: fredo-dedup and contributors +# Copyright (c) 2026: fredo-dedup, quinnj, and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. diff --git a/src/JSONSchema.jl b/src/JSONSchema.jl index 30b72f3..d3b2d79 100644 --- a/src/JSONSchema.jl +++ b/src/JSONSchema.jl @@ -1,4 +1,4 @@ -# Copyright (c) 2018: fredo-dedup and contributors +# Copyright (c) 2026: fredo-dedup, quinnj, and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. @@ -9,7 +9,7 @@ import Downloads import JSON import URIs -export Schema, schema, validate +export Schema, validate include("schema.jl") include("generation.jl") diff --git a/src/generation.jl b/src/generation.jl index 379de87..27a86c9 100644 --- a/src/generation.jl +++ b/src/generation.jl @@ -1,4 +1,4 @@ -# Copyright (c) 2018: fredo-dedup and contributors +# Copyright (c) 2026: fredo-dedup, quinnj, and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. @@ -6,7 +6,7 @@ const DEFAULT_GENERATED_DRAFT = "https://json-schema.org/draft-07/schema#" """ - schema(::Type{T}; kwargs...) where {T} + JSONSchema.schema(T::Type; kwargs...) Generate a small JSON Schema for Julia data type `T`. @@ -25,12 +25,12 @@ Keyword arguments: in this first pass. """ function schema( - ::Type{T}; + @nospecialize(T::Type); draft::AbstractString = DEFAULT_GENERATED_DRAFT, refs = false, all_fields_required::Bool = false, additionalProperties::Union{Nothing,Bool} = nothing, -) where {T} +) generated = _schema_for_type(T; all_fields_required, draft) generated["\$schema"] = String(draft) if additionalProperties !== nothing @@ -40,10 +40,10 @@ function schema( end function _schema_for_type( - ::Type{T}; + @nospecialize(T::Type); all_fields_required::Bool, draft::AbstractString, -) where {T} +) if T === Any return Dict{String,Any}() elseif T === Nothing || T === Missing @@ -59,12 +59,7 @@ function _schema_for_type( elseif T <: Real return Dict{String,Any}("type" => "number") elseif T <: NamedTuple - return _object_schema( - fieldnames(T), - fieldtypes(T); - all_fields_required, - draft, - ) + return _object_schema(T; all_fields_required, draft) elseif T <: AbstractVector return _array_schema(eltype(T); all_fields_required, draft) elseif T <: Tuple @@ -72,24 +67,21 @@ function _schema_for_type( elseif T <: AbstractDict return _dict_schema(T; all_fields_required, draft) elseif isconcretetype(T) && isstructtype(T) - return _object_schema( - fieldnames(T), - fieldtypes(T); - all_fields_required, - draft, - ) + return _object_schema(T; all_fields_required, draft) else return Dict{String,Any}() end end -_is_union_type(::Type{T}) where {T} = T isa Union +function _is_union_type(@nospecialize(T::Type)) + return T isa Union +end function _union_schema( - ::Type{T}; + @nospecialize(T::Type); all_fields_required::Bool, draft::AbstractString, -) where {T} +) union_types = Base.uniontypes(T) nullable = any(t -> t === Nothing || t === Missing, union_types) non_null_types = filter(t -> t !== Nothing && t !== Missing, union_types) @@ -99,11 +91,8 @@ function _union_schema( end if length(non_null_types) == 1 - schema = _schema_for_type( - first(non_null_types); - all_fields_required, - draft, - ) + schema = + _schema_for_type(first(non_null_types); all_fields_required, draft) nullable && return _nullable_schema(schema) return schema end @@ -135,10 +124,10 @@ function _nullable_schema(schema::AbstractDict) end function _array_schema( - ::Type{T}; + @nospecialize(T::Type); all_fields_required::Bool, draft::AbstractString, -) where {T} +) return Dict{String,Any}( "type" => "array", "items" => _schema_for_type(T; all_fields_required, draft), @@ -146,20 +135,21 @@ function _array_schema( end function _tuple_schema( - ::Type{T}; + @nospecialize(T::Type); all_fields_required::Bool, draft::AbstractString, -) where {T} +) parameters = collect(T.parameters) if _is_unbounded_vararg_tuple(parameters) - fixed_parameters = parameters[1:end-1] + fixed_parameters = parameters[1:(end-1)] vararg_type = getfield(parameters[end], :T) if isempty(fixed_parameters) return _array_schema(vararg_type; all_fields_required, draft) end fixed_schemas = Any[ - _schema_for_type(t; all_fields_required, draft) for t in fixed_parameters + _schema_for_type(t; all_fields_required, draft) for + t in fixed_parameters ] generated = Dict{String,Any}( "type" => "array", @@ -167,28 +157,19 @@ function _tuple_schema( ) if _uses_prefix_items(draft) generated["prefixItems"] = fixed_schemas - generated["items"] = _schema_for_type( - vararg_type; - all_fields_required, - draft, - ) + generated["items"] = + _schema_for_type(vararg_type; all_fields_required, draft) else generated["items"] = fixed_schemas - generated["additionalItems"] = _schema_for_type( - vararg_type; - all_fields_required, - draft, - ) + generated["additionalItems"] = + _schema_for_type(vararg_type; all_fields_required, draft) end return generated end tuple_types = fieldtypes(T) if isempty(tuple_types) - return Dict{String,Any}( - "type" => "array", - "maxItems" => 0, - ) + return Dict{String,Any}("type" => "array", "maxItems" => 0) end tuple_schemas = Any[ @@ -219,10 +200,10 @@ function _uses_prefix_items(draft::AbstractString) end function _dict_schema( - ::Type{T}; + @nospecialize(T::Type); all_fields_required::Bool, draft::AbstractString, -) where {T} +) value_schema = _schema_for_type(valtype(T); all_fields_required, draft) return Dict{String,Any}( "type" => "object", @@ -231,35 +212,28 @@ function _dict_schema( end function _object_schema( - names, - types::Tuple; + @nospecialize(T::Type); all_fields_required::Bool, draft::AbstractString, ) properties = Dict{String,Any}() required = String[] - for (name, type) in zip(names, types) + for (name, type) in zip(fieldnames(T), fieldtypes(T)) field_name = string(name) - properties[field_name] = _schema_for_type( - type; - all_fields_required, - draft, - ) + properties[field_name] = + _schema_for_type(type; all_fields_required, draft) if all_fields_required || _is_required_type(type) push!(required, field_name) end end - generated = Dict{String,Any}( - "type" => "object", - "properties" => properties, - ) + generated = Dict{String,Any}("type" => "object", "properties" => properties) if !isempty(required) generated["required"] = required end return generated end -function _is_required_type(::Type{T}) where {T} +function _is_required_type(@nospecialize(T::Type)) return !(Nothing <: T) && !(Missing <: T) end diff --git a/src/schema.jl b/src/schema.jl index a58c3ad..f5e2252 100644 --- a/src/schema.jl +++ b/src/schema.jl @@ -1,4 +1,4 @@ -# Copyright (c) 2018: fredo-dedup and contributors +# Copyright (c) 2026: fredo-dedup, quinnj, and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. @@ -294,14 +294,12 @@ my_schema = Schema( """ Schema(schema::String; kwargs...) = Schema(JSON.parse(schema); kwargs...) -function Base.getproperty(schema::Schema, name::Symbol) - name === :spec && return getfield(schema, :data) - return getfield(schema, name) -end +""" + spec(schema::Schema) -function Base.propertynames(::Schema; private::Bool = false) - return private ? (:data, :spec) : (:data, :spec) -end +Return the parsed dictionary or boolean backing `schema`. +""" +spec(schema::Schema) = schema.data Base.getindex(schema::Schema, key) = schema.data[key] Base.haskey(schema::Schema, key) = haskey(schema.data, key) diff --git a/src/validation.jl b/src/validation.jl index ad5d6b1..a4466f4 100644 --- a/src/validation.jl +++ b/src/validation.jl @@ -1,4 +1,4 @@ -# Copyright (c) 2018: fredo-dedup and contributors +# Copyright (c) 2026: fredo-dedup, quinnj, and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. diff --git a/test/generation.jl b/test/generation.jl index b9a6ad3..0504075 100644 --- a/test/generation.jl +++ b/test/generation.jl @@ -1,4 +1,4 @@ -# Copyright (c) 2018: fredo-dedup and contributors +# Copyright (c) 2026: fredo-dedup, quinnj, and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. @@ -20,22 +20,23 @@ ) @test typeof(generated) == JSONSchema.Schema - @test generated.spec === generated.data - @test generated.spec["\$schema"] == - JSONSchema.DEFAULT_GENERATED_DRAFT - @test generated.spec["type"] == "object" - @test generated.spec["additionalProperties"] == false + data = JSONSchema.spec(generated) + @test data === generated.data + @test data["\$schema"] == JSONSchema.DEFAULT_GENERATED_DRAFT + @test data["type"] == "object" + @test data["additionalProperties"] == false - properties = generated.spec["properties"] + properties = data["properties"] @test properties["path"]["type"] == "string" @test properties["recursive"]["type"] == "boolean" @test properties["limit"]["type"] == Any["integer", "null"] @test properties["tags"]["type"] == "array" @test properties["tags"]["items"]["type"] == "string" @test properties["metadata"]["type"] == "object" - @test properties["metadata"]["additionalProperties"] == Dict{String,Any}() + @test properties["metadata"]["additionalProperties"] == + Dict{String,Any}() - @test Set(generated.spec["required"]) == + @test Set(data["required"]) == Set(["path", "recursive", "tags", "metadata"]) @test isvalid( generated, @@ -87,7 +88,8 @@ additionalProperties = false, ) - @test Set(generated.spec["required"]) == Set(["required", "optional"]) + data = JSONSchema.spec(generated) + @test Set(data["required"]) == Set(["required", "optional"]) non_nullable = String[] for (name, type) in zip(fieldnames(T), fieldtypes(T)) @@ -95,9 +97,9 @@ push!(non_nullable, string(name)) end end - generated.spec["required"] = non_nullable + data["required"] = non_nullable - @test Set(generated.spec["required"]) == Set(["required"]) + @test Set(data["required"]) == Set(["required"]) end @testset "Union fields" begin @@ -107,7 +109,7 @@ nullable_scalar::Union{Nothing,String,Int}, } generated = JSONSchema.schema(T) - properties = generated.spec["properties"] + properties = JSONSchema.spec(generated)["properties"] @test properties["only_null"]["type"] == "null" @test Set(s["type"] for s in properties["scalar"]["anyOf"]) == @@ -123,17 +125,14 @@ refs = :defs, ) - @test generated.spec["\$schema"] == + @test JSONSchema.spec(generated)["\$schema"] == "https://json-schema.org/draft/2020-12/schema" - @test !haskey(generated.spec, "\$defs") + @test !haskey(JSONSchema.spec(generated), "\$defs") end @testset "Google nullable serialization path" begin T = @NamedTuple{optional::Union{Nothing,String}} - generated = JSONSchema.schema( - T; - additionalProperties = false, - ) + generated = JSONSchema.schema(T; additionalProperties = false) parsed = JSON.parse(JSON.json(generated)) optional = parsed["properties"]["optional"] @@ -147,7 +146,8 @@ @test haskey(generated, "properties") @test get(generated, "missing", 42) == 42 @test "required" in collect(keys(generated)) - @test JSON.lower(generated) === generated.data + @test JSONSchema.spec(generated) === generated.data + @test JSON.lower(generated) === JSONSchema.spec(generated) @test JSON.parse(JSON.json(generated))["type"] == "object" end @@ -157,25 +157,26 @@ max_results::Union{Nothing,Int} end - generated = JSONSchema.schema(SearchOptions; additionalProperties = false) + generated = + JSONSchema.schema(SearchOptions; additionalProperties = false) @test typeof(generated) == JSONSchema.Schema - @test generated.spec["properties"]["query"]["type"] == "string" - @test generated.spec["properties"]["max_results"]["type"] == + data = JSONSchema.spec(generated) + @test data["properties"]["query"]["type"] == "string" + @test data["properties"]["max_results"]["type"] == Any["integer", "null"] - @test Set(generated.spec["required"]) == Set(["query"]) + @test Set(data["required"]) == Set(["query"]) end @testset "Nested additionalProperties" begin - T = @NamedTuple{ - entries::Dict{String,@NamedTuple{enabled::Bool}}, - } + T = @NamedTuple{entries::Dict{String,@NamedTuple{enabled::Bool}}} generated = JSONSchema.schema(T; additionalProperties = false) - entries = generated.spec["properties"]["entries"] + data = JSONSchema.spec(generated) + entries = data["properties"]["entries"] entry = entries["additionalProperties"] - @test generated.spec["additionalProperties"] == false + @test data["additionalProperties"] == false @test entries["type"] == "object" @test entry["type"] == "object" @test entry["properties"]["enabled"]["type"] == "boolean" @@ -188,33 +189,43 @@ fixed = JSONSchema.schema(Tuple{String,Int}) unbounded = JSONSchema.schema(Tuple) - @test repeated.spec["type"] == "array" - @test repeated.spec["items"]["type"] == "string" - @test prefixed.spec["items"][1]["type"] == "string" - @test prefixed.spec["additionalItems"]["type"] == "integer" - @test fixed.spec["items"][1]["type"] == "string" - @test fixed.spec["items"][2]["type"] == "integer" - @test fixed.spec["additionalItems"] == false - @test unbounded.spec["items"] == Dict{String,Any}() + repeated_data = JSONSchema.spec(repeated) + prefixed_data = JSONSchema.spec(prefixed) + fixed_data = JSONSchema.spec(fixed) + unbounded_data = JSONSchema.spec(unbounded) + + @test repeated_data["type"] == "array" + @test repeated_data["items"]["type"] == "string" + @test prefixed_data["items"][1]["type"] == "string" + @test prefixed_data["additionalItems"]["type"] == "integer" + @test fixed_data["items"][1]["type"] == "string" + @test fixed_data["items"][2]["type"] == "integer" + @test fixed_data["additionalItems"] == false + @test unbounded_data["items"] == Dict{String,Any}() end @testset "Tuple parameters in draft 2020-12" begin draft = "https://json-schema.org/draft/2020-12/schema" fixed = JSONSchema.schema(Tuple{String,Int}; draft) prefixed = JSONSchema.schema(Tuple{String,Vararg{Int}}; draft) - nested = JSONSchema.schema(@NamedTuple{coords::Tuple{String,Int}}; draft) + nested = + JSONSchema.schema(@NamedTuple{coords::Tuple{String,Int}}; draft) + + fixed_data = JSONSchema.spec(fixed) + prefixed_data = JSONSchema.spec(prefixed) + nested_data = JSONSchema.spec(nested) - @test fixed.spec["\$schema"] == draft - @test fixed.spec["prefixItems"][1]["type"] == "string" - @test fixed.spec["prefixItems"][2]["type"] == "integer" - @test !haskey(fixed.spec, "additionalItems") - @test !haskey(fixed.spec, "items") + @test fixed_data["\$schema"] == draft + @test fixed_data["prefixItems"][1]["type"] == "string" + @test fixed_data["prefixItems"][2]["type"] == "integer" + @test !haskey(fixed_data, "additionalItems") + @test !haskey(fixed_data, "items") - @test prefixed.spec["prefixItems"][1]["type"] == "string" - @test prefixed.spec["items"]["type"] == "integer" - @test !haskey(prefixed.spec, "additionalItems") + @test prefixed_data["prefixItems"][1]["type"] == "string" + @test prefixed_data["items"]["type"] == "integer" + @test !haskey(prefixed_data, "additionalItems") - coords = nested.spec["properties"]["coords"] + coords = nested_data["properties"]["coords"] @test coords["prefixItems"][1]["type"] == "string" @test coords["prefixItems"][2]["type"] == "integer" end diff --git a/test/runtests.jl b/test/runtests.jl index c58aa71..41ae5d1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,4 +1,4 @@ -# Copyright (c) 2018: fredo-dedup and contributors +# Copyright (c) 2026: fredo-dedup, quinnj, and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. @@ -338,9 +338,11 @@ end @testset "exports" begin @test Schema === JSONSchema.Schema - @test schema === JSONSchema.schema @test validate === JSONSchema.validate @test diagnose === JSONSchema.diagnose + @test !(:schema in names(JSONSchema)) + @test !(:spec in names(JSONSchema)) + @test JSONSchema.schema(@NamedTuple{x::String}) isa JSONSchema.Schema end include("generation.jl")