Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion ext/JSONSchemaJSON3Ext.jl
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/JSONSchema.jl
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -12,6 +12,7 @@ import URIs
export Schema, validate

include("schema.jl")
include("generation.jl")
include("validation.jl")

export diagnose
Expand Down
275 changes: 275 additions & 0 deletions src/generation.jl
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion src/schema.jl
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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")
2 changes: 1 addition & 1 deletion src/validation.jl
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading