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/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..b64de2e 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,34 @@ 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 `JSONSchema.schema`: +```julia +julia> params = JSONSchema.schema( + @NamedTuple{query::String, limit::Union{Nothing,Int}}; + additionalProperties = false, + ) +A JSONSchema + +julia> JSONSchema.spec(params)["required"] +1-element Vector{String}: + "query" +``` + +`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 1fec843..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. @@ -12,6 +12,7 @@ import URIs export 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..27a86c9 --- /dev/null +++ b/src/generation.jl @@ -0,0 +1,275 @@ +# 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. + +const DEFAULT_GENERATED_DRAFT = "https://json-schema.org/draft-07/schema#" + +""" + JSONSchema.schema(T::Type; kwargs...) + +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( + @nospecialize(T::Type); + draft::AbstractString = DEFAULT_GENERATED_DRAFT, + refs = false, + all_fields_required::Bool = false, + additionalProperties::Union{Nothing,Bool} = nothing, +) + 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( + @nospecialize(T::Type); + all_fields_required::Bool, + draft::AbstractString, +) + 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(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(T; all_fields_required, draft) + else + return Dict{String,Any}() + end +end + +function _is_union_type(@nospecialize(T::Type)) + return T isa Union +end + +function _union_schema( + @nospecialize(T::Type); + all_fields_required::Bool, + draft::AbstractString, +) + 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( + @nospecialize(T::Type); + all_fields_required::Bool, + draft::AbstractString, +) + return Dict{String,Any}( + "type" => "array", + "items" => _schema_for_type(T; all_fields_required, draft), + ) +end + +function _tuple_schema( + @nospecialize(T::Type); + all_fields_required::Bool, + draft::AbstractString, +) + 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( + @nospecialize(T::Type); + all_fields_required::Bool, + draft::AbstractString, +) + value_schema = _schema_for_type(valtype(T); all_fields_required, draft) + return Dict{String,Any}( + "type" => "object", + "additionalProperties" => value_schema, + ) +end + +function _object_schema( + @nospecialize(T::Type); + all_fields_required::Bool, + draft::AbstractString, +) + properties = Dict{String,Any}() + required = String[] + for (name, type) in zip(fieldnames(T), fieldtypes(T)) + 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(@nospecialize(T::Type)) + 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..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,4 +294,17 @@ my_schema = Schema( """ Schema(schema::String; kwargs...) = Schema(JSON.parse(schema); kwargs...) +""" + spec(schema::Schema) + +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) +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/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 new file mode 100644 index 0000000..0504075 --- /dev/null +++ b/test/generation.jl @@ -0,0 +1,232 @@ +# 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. + +@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 + data = JSONSchema.spec(generated) + @test data === generated.data + @test data["\$schema"] == JSONSchema.DEFAULT_GENERATED_DRAFT + @test data["type"] == "object" + @test data["additionalProperties"] == false + + 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 Set(data["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, + ) + + data = JSONSchema.spec(generated) + @test Set(data["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 + data["required"] = non_nullable + + @test Set(data["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 = JSONSchema.spec(generated)["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 JSONSchema.spec(generated)["\$schema"] == + "https://json-schema.org/draft/2020-12/schema" + @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) + 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 JSONSchema.spec(generated) === generated.data + @test JSON.lower(generated) === JSONSchema.spec(generated) + @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 + data = JSONSchema.spec(generated) + @test data["properties"]["query"]["type"] == "string" + @test data["properties"]["max_results"]["type"] == + Any["integer", "null"] + @test Set(data["required"]) == Set(["query"]) + end + + @testset "Nested additionalProperties" begin + T = @NamedTuple{entries::Dict{String,@NamedTuple{enabled::Bool}}} + + generated = JSONSchema.schema(T; additionalProperties = false) + data = JSONSchema.spec(generated) + entries = data["properties"]["entries"] + entry = entries["additionalProperties"] + + @test data["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) + + 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) + + fixed_data = JSONSchema.spec(fixed) + prefixed_data = JSONSchema.spec(prefixed) + nested_data = JSONSchema.spec(nested) + + @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_data["prefixItems"][1]["type"] == "string" + @test prefixed_data["items"]["type"] == "integer" + @test !haskey(prefixed_data, "additionalItems") + + coords = nested_data["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..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. @@ -340,4 +340,9 @@ end @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")