Skip to content

Commit 53df2b3

Browse files
authored
feat(parse): add unknown_fields keyword (#451)
* feat(parse): add unknown_fields keyword Add an keyword to typed JSON parsing and parse! that preserves the current ignore behavior by default while allowing callers to reject unmatched members with . This updates the JSON read style plumbing to use StructUtils 2.8's unknownfield hook, adds regression tests for nested and in-place parsing, and documents the new option. * refactor(parse): simplify unknown field policy state
1 parent d7e93eb commit 53df2b3

4 files changed

Lines changed: 74 additions & 22 deletions

File tree

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Arrow = "2.8.0"
2222
ArrowTypes = "2.2"
2323
Parsers = "1, 2"
2424
PrecompileTools = "1"
25-
StructUtils = "2.3"
25+
StructUtils = "2.8"
2626
julia = "1.9"
2727

2828
[extras]

docs/src/reading.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ With this approach, JSON.jl automatically:
164164
- Converts values to the appropriate field types
165165
- Constructs the struct with the parsed values
166166

167+
By default, any extra JSON keys that don't match fields in the target type are
168+
ignored. If you want those to error instead, pass `unknown_fields=:error`:
169+
170+
```julia
171+
JSON.parse("""{"name": "Alice", "age": 30, "admin": true}""", Person; unknown_fields=:error)
172+
# ERROR: ArgumentError: encountered unknown JSON member "admin" while parsing `Person`
173+
```
174+
167175
This works for nested structs too:
168176

169177
```julia
@@ -505,4 +513,4 @@ Let's walk through some notable features of the example above:
505513
* The `percentages` field is a dictionary with keys of type `Percent`, which is a custom type. The `liftkey` function is defined to convert the JSON string keys to `Percent` types (parses the Float64 manually)
506514
* The `json_properties` field has a type of `JSONText`, which means the raw JSON will be preserved as a String of the `JSONText` type.
507515
* The `matrix` field is a `Matrix{Float64}`, so the JSON input array-of-arrays are materialized as such.
508-
* The `extra_key` field is not defined in the `FrankenStruct` type, so it is ignored and skipped over.
516+
* The `extra_key` field is not defined in the `FrankenStruct` type, so it is ignored and skipped over by default. Pass `unknown_fields=:error` if you want unknown keys to throw instead.

src/parse.jl

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Currently supported keyword arguments include:
1818
* `isroot`: whether this is the root LazyValue encompassing the entire json buffer. If `false` parses only the first JSON value and ignores trailing characters. (default: `true`)
1919
* `dicttype`: a custom `AbstractDict` type to use instead of `$DEFAULT_OBJECT_TYPE` as the default type for JSON object materialization
2020
* `null`: a custom value to use for JSON null values (default: `nothing`)
21+
* `unknown_fields`: controls how unmatched JSON object keys or positional values are handled when parsing into a target type or existing object; supported values are `:ignore` (default) and `:error`
2122
* `style`: a custom `StructUtils.StructStyle` subtype instance to be used in calls to `StructUtils.make` and `StructUtils.lift`. This allows overriding
2223
default behaviors for non-owned types.
2324
@@ -37,7 +38,7 @@ of type `T` will be attempted utilizing machinery and interfaces provided by the
3738
* If `T` was defined with the `@noarg` macro, an empty instance will be constructed, and field values set as JSON keys match field names
3839
* If `T` had default field values defined using the `@defaults` or `@kwarg` macros (from StructUtils.jl package), those will be set in the value of `T` unless different values are parsed from the JSON
3940
* If `T` was defined with the `@nonstruct` macro, the struct will be treated as a primitive type and constructed using the `lift` function rather than from field values
40-
* JSON keys that don't match field names in `T` will be ignored (skipped over)
41+
* JSON keys that don't match field names in `T` will be ignored (skipped over) by default; pass `unknown_fields=:error` to reject them
4142
* If a field in `T` has a `name` fieldtag, the `name` value will be used to match JSON keys instead
4243
* If `T` or any recursive field type of `T` is abstract, an appropriate `JSON.@choosetype T x -> ...` definition should exist for "choosing" a concrete type at runtime; default type choosing exists for `Union{T, Missing}` and `Union{T, Nothing}` where the JSON value is checked if `null`. If the `Any` type is encountered, the default materialization types will be used (`JSON.Object`, `Vector{Any}`, etc.)
4344
* For any non-JSON-standard non-aggregate (i.e. non-object, non-array) field type of `T`, a `JSON.lift(::Type{T}, x) = ...` definition can be defined for how to "lift" the default JSON value (String, Number, Bool, `nothing`) to the type `T`; a default lift definition exists, for example, for `JSON.lift(::Type{Missing}, x) = missing` where the standard JSON value for `null` is `nothing` and it can be "lifted" to `missing`
@@ -150,12 +151,16 @@ import StructUtils: StructStyle
150151

151152
abstract type JSONStyle <: StructStyle end
152153

153-
# defining a custom style allows us to pass a non-default dicttype `O` through JSON.parse
154-
struct JSONReadStyle{O,T} <: JSONStyle
154+
# defining a custom style allows us to pass a non-default dicttype `O` through JSON.parse,
155+
# while still delegating custom behavior to an inner StructStyle if one was provided
156+
struct JSONReadStyle{O,T,S} <: JSONStyle
155157
null::T
158+
style::S
159+
ignore_unknown_fields::Bool
156160
end
157161

158-
JSONReadStyle{O}(null::T) where {O,T} = JSONReadStyle{O,T}(null)
162+
JSONReadStyle{O}(null::T, style::S=StructUtils.DefaultStyle(), ignore_unknown_fields::Bool=true) where {O,T,S} =
163+
JSONReadStyle{O,T,S}(null, style, ignore_unknown_fields)
159164

160165
objecttype(::StructStyle) = DEFAULT_OBJECT_TYPE
161166
objecttype(::JSONReadStyle{OT}) where {OT} = OT
@@ -164,6 +169,26 @@ nullvalue(st::JSONReadStyle) = st.null
164169

165170
# this allows struct fields to specify tags under the json key specifically to override JSON behavior
166171
StructUtils.fieldtagkey(::JSONStyle) = :json
172+
StructUtils.defaultstate(st::JSONReadStyle) = StructUtils.defaultstate(st.style)
173+
174+
function jsonreadstyle(::Type{T}, ::Type{O}, null, style::StructStyle, unknown_fields::Symbol) where {T,O}
175+
ignore_unknown_fields =
176+
unknown_fields === :ignore ? true :
177+
unknown_fields === :error ? false :
178+
throw(ArgumentError("`unknown_fields` must be `:ignore` or `:error`, got `$(repr(unknown_fields))`"))
179+
if T === Any && !ignore_unknown_fields
180+
throw(ArgumentError("`unknown_fields` is only supported when parsing into a target type or existing object"))
181+
end
182+
return JSONReadStyle{O}(null, style, ignore_unknown_fields)
183+
end
184+
185+
@noinline unknownfielderror(::Type{T}, key) where {T} =
186+
ArgumentError("encountered unknown JSON member $(repr(key)) while parsing `$T`")
187+
188+
function StructUtils.unknownfield(st::JSONReadStyle, ::Type{T}, key, value) where {T}
189+
st.ignore_unknown_fields || throw(unknownfielderror(T, key))
190+
return StructUtils.unknownfield(st.style, T, key, value)
191+
end
167192

168193
"See [`parse`](@ref)."
169194
function parsefile end
@@ -180,15 +205,19 @@ parse(io::Union{IO,Base.AbstractCmd}, ::Type{T}=Any; kw...) where {T} = parse(Ba
180205
parse!(io::Union{IO,Base.AbstractCmd}, x::T; kw...) where {T} = parse!(Base.read(io), x; kw...)
181206

182207
parse(buf::Union{AbstractVector{UInt8},AbstractString}, ::Type{T}=Any;
183-
dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing,
184-
style::StructStyle=JSONReadStyle{dicttype}(null), kw...) where {T,O} =
185-
@inline parse(lazy(buf; kw...), T; dicttype, null, style)
208+
dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=StructUtils.DefaultStyle(),
209+
unknown_fields::Symbol=:ignore, kw...) where {T,O} =
210+
@inline parse(lazy(buf; kw...), T; dicttype, null, style, unknown_fields)
186211

187-
parse!(buf::Union{AbstractVector{UInt8},AbstractString}, x::T; dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=JSONReadStyle{dicttype}(null), kw...) where {T,O} =
188-
@inline parse!(lazy(buf; kw...), x; dicttype, null, style)
212+
parse!(buf::Union{AbstractVector{UInt8},AbstractString}, x::T;
213+
dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=StructUtils.DefaultStyle(),
214+
unknown_fields::Symbol=:ignore, kw...) where {T,O} =
215+
@inline parse!(lazy(buf; kw...), x; dicttype, null, style, unknown_fields)
189216

190-
parse(x::LazyValue, ::Type{T}=Any; dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=JSONReadStyle{dicttype}(null)) where {T,O} =
191-
@inline _parse(x, T, dicttype, null, style)
217+
parse(x::LazyValue, ::Type{T}=Any;
218+
dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=StructUtils.DefaultStyle(),
219+
unknown_fields::Symbol=:ignore) where {T,O} =
220+
@inline _parse(x, T, dicttype, null, jsonreadstyle(T, O, null, style, unknown_fields))
192221

193222
function _parse(x::LazyValue, ::Type{T}, dicttype::Type{O}, null, style::StructStyle) where {T,O}
194223
y, pos = StructUtils.make(style, T, x)
@@ -210,7 +239,10 @@ function _parse(x::LazyValue, ::Type{Any}, ::Type{DEFAULT_OBJECT_TYPE}, null, ::
210239
return out.value
211240
end
212241

213-
parse!(x::LazyValue, obj::T; dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=JSONReadStyle{dicttype}(null)) where {T,O} = StructUtils.make!(style, obj, x)
242+
parse!(x::LazyValue, obj::T;
243+
dicttype::Type{O}=DEFAULT_OBJECT_TYPE, null=nothing, style::StructStyle=StructUtils.DefaultStyle(),
244+
unknown_fields::Symbol=:ignore) where {T,O} =
245+
StructUtils.make!(jsonreadstyle(T, O, null, style, unknown_fields), obj, x)
214246

215247
# for LazyValue, if x started at the beginning of the JSON input,
216248
# then we want to ensure that the entire input was consumed
@@ -332,25 +364,28 @@ function StructUtils.make(st::StructStyle, ::Type{Any}, x::LazyValues)
332364
end
333365

334366
# catch PtrString via lift or make! so we can ensure it never "escapes" to user-level
335-
StructUtils.liftkey(st::StructStyle, ::Type{T}, x::PtrString) where {T} =
367+
StructUtils.liftkey(st::JSONReadStyle, ::Type{T}, x::PtrString) where {T} =
336368
StructUtils.liftkey(st, T, convert(String, x))
337-
StructUtils.lift(st::StructStyle, ::Type{T}, x::PtrString, tags) where {T} =
369+
StructUtils.lift(st::JSONReadStyle, ::Type{T}, x::PtrString, tags) where {T} =
338370
StructUtils.lift(st, T, convert(String, x), tags)
339-
StructUtils.lift(st::StructStyle, ::Type{T}, x::PtrString) where {T} =
371+
StructUtils.lift(st::JSONReadStyle, ::Type{T}, x::PtrString) where {T} =
340372
StructUtils.lift(st, T, convert(String, x))
341373

342374
# liftkey for numeric dict key types to enable round-tripping Dict{Int,V}, Dict{Float64,V}, etc.
343375
# these correspond to the lowerkey definitions in write.jl that convert numeric keys to strings
344-
StructUtils.liftkey(::JSONStyle, ::Type{T}, x::AbstractString) where {T<:Integer} = Base.parse(T, x)
345-
StructUtils.liftkey(::JSONStyle, ::Type{T}, x::AbstractString) where {T<:AbstractFloat} = Base.parse(T, x)
376+
StructUtils.liftkey(::JSONReadStyle, ::Type{T}, x::AbstractString) where {T<:Integer} = Base.parse(T, x)
377+
StructUtils.liftkey(::JSONReadStyle, ::Type{T}, x::AbstractString) where {T<:AbstractFloat} = Base.parse(T, x)
378+
379+
StructUtils.lift(style::JSONReadStyle, ::Type{T}, x, tags) where {T} = StructUtils.lift(style.style, T, x, tags)
380+
StructUtils.lift(style::JSONReadStyle, ::Type{T}, x) where {T} = StructUtils.lift(style.style, T, x)
346381

347-
function StructUtils.lift(style::StructStyle, ::Type{T}, x::LazyValues) where {T<:AbstractArray{E,0}} where {E}
382+
function StructUtils.lift(style::JSONReadStyle, ::Type{T}, x::LazyValues) where {T<:AbstractArray{E,0}} where {E}
348383
m = T(undef)
349384
m[1], pos = StructUtils.lift(style, E, x)
350385
return m, pos
351386
end
352387

353-
function StructUtils.lift(style::StructStyle, ::Type{T}, x::LazyValues, tags=(;)) where {T}
388+
function StructUtils.lift(style::JSONReadStyle, ::Type{T}, x::LazyValues, tags=(;)) where {T}
354389
type = gettype(x)
355390
buf = getbuf(x)
356391
if type == JSONTypes.STRING
@@ -525,4 +560,4 @@ end
525560
invalid(error, buf, pos, "tuple")
526561
end
527562
return ex
528-
end
563+
end

test/parse.jl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,7 @@ end
501501
@testset "JSON.parse with types" begin
502502
obj = JSON.parse("""{ "a": 1,"b": 2,"c": 3,"d": 4}""", A)
503503
@test obj == A(1, 2, 3, 4)
504+
@test JSON.parse("""{ "a": 1,"b": 2,"c": 3,"d": 4, "e": 5}""", A) == A(1, 2, 3, 4)
504505
# test order doesn't matter
505506
obj2 = JSON.parse("""{ "d": 1,"b": 2,"c": 3,"a": 4}""", A)
506507
@test obj2 == A(4, 2, 3, 1)
@@ -552,6 +553,13 @@ end
552553
@test obj.id == 1 && !isdefined(obj, :name)
553554
obj = JSON.parse("""{ "id": 1, "a": {"a": 1, "b": 2, "c": 3, "d": 4}}""", E)
554555
@test obj == E(1, A(1, 2, 3, 4))
556+
@test_throws ArgumentError JSON.parse("""{ "a": 1,"b": 2,"c": 3,"d": 4, "e": 5}""", A; unknown_fields=:error)
557+
@test_throws ArgumentError JSON.parse("""{ "id": 1, "a": {"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}}""", E; unknown_fields=:error)
558+
obj = B()
559+
@test_throws ArgumentError JSON.parse!("""{ "a": 1,"b": 2,"c": 3,"d": 4, "e": 5}""", obj; unknown_fields=:error)
560+
@test_throws ArgumentError JSON.parse("""[1, 2, 3, 4, 5]""", A; unknown_fields=:error)
561+
@test_throws ArgumentError JSON.parse("""{ "a": 1,"b": 2,"c": 3,"d": 4}""", A; unknown_fields=:boom)
562+
@test_throws ArgumentError JSON.parse("""{ "a": 1}"""; unknown_fields=:error)
555563
obj = JSON.parse("""{ "id": 1, "rate": 2.0, "name": "3"}""", F)
556564
@test obj == F(1, 2.0, "3")
557565
obj = JSON.parse("""{ "id": 1, "rate": 2.0, "name": "3", "f": {"id": 1, "rate": 2.0, "name": "3"}}""", G)
@@ -756,6 +764,7 @@ end
756764
# test custom JSONStyle overload
757765
JSON.lift(::CustomJSONStyle, ::Type{Rational}, x) = Rational(x.num[], x.den[])
758766
@test JSON.parse("{\"num\": 1,\"den\":3}", Rational; style=CustomJSONStyle()) == 1//3
767+
@test JSON.parse("{\"num\": 1,\"den\":3}", Rational; style=CustomJSONStyle(), unknown_fields=:error) == 1//3
759768
@test isequal(JSON.parse("{\"num\": 1,\"den\":null}", @NamedTuple{num::Int, den::Union{Int, Missing}}; null=missing, style=StructUtils.DefaultStyle()), (num=1, den=missing))
760769
# choosetype field tag on Any struct field
761770
@test JSON.parse("{\"id\":1,\"any\":{\"type\":\"int\",\"value\":10}}", Q) == Q(1, (type="int", value=10))

0 commit comments

Comments
 (0)