Skip to content

Commit

Permalink
feat: support for query parameter serialization style "deepObject" (#78)
Browse files Browse the repository at this point in the history
* query param support deepObject

* fix

* remove stray comment

* Update src/client.jl

Co-authored-by: Tanmay Mohapatra <tanmaykm@gmail.com>

* Update src/json.jl

Co-authored-by: Tanmay Mohapatra <tanmaykm@gmail.com>

* Update src/server.jl

Co-authored-by: Tanmay Mohapatra <tanmaykm@gmail.com>

* fix

* fix

* fix location

---------

Co-authored-by: Tanmay Mohapatra <tanmaykm@gmail.com>
  • Loading branch information
vdayanand and tanmaykm authored Jul 22, 2024
1 parent ca35820 commit 4c7260c
Show file tree
Hide file tree
Showing 51 changed files with 1,363 additions and 58 deletions.
45 changes: 40 additions & 5 deletions src/client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ end

function get_api_return_type(return_types::Dict{Regex,Type}, ::Nothing, response_data::String)
# this is the async case, where we do not have the response code yet
# in such cases we look for the 200 response code
# in such cases we look for the 200 response code
return get_api_return_type(return_types, 200, response_data)
end
function get_api_return_type(return_types::Dict{Regex,Type}, response_code::Integer, response_data::String)
Expand Down Expand Up @@ -191,7 +191,7 @@ set_user_agent(client::Client, ua::String) = set_header(client, "User-Agent", ua
Set the Cookie header to be sent with all API calls.
"""
set_cookie(client::Client, ck::String) = set_header(client, "Cookie", ck)

"""
set_header(client::Client, name::String, value::String)
Expand Down Expand Up @@ -291,8 +291,26 @@ function set_header_content_type(ctx::Ctx, ctypes::Vector{String})
return nothing
end

set_param(params::Dict{String,String}, name::String, value::Nothing; collection_format=",") = nothing
function set_param(params::Dict{String,String}, name::String, value; collection_format=",")
set_param(params::Dict{String,String}, name::String, value::Nothing; collection_format=",", style="form", location=:query, is_explode=default_param_explode(style)) = nothing
# Choose the default collection_format based on spec.
# Overriding it may not match the spec and there's no check.
# But we do not prevent it to allow for wiggle room, since there are many interpretations in the wild over the loosely defined spec around this.
# TODO: `default_param_explode` needs to be improved to handle location too (query, header, cookie...)
function default_param_explode(style::String)
if style == "deepObject"
true
elseif style == "form"
true
else
false
end
end
function set_param(params::Dict{String,String}, name::String, value; collection_format=",", style="form", location::Symbol=:query, is_explode=default_param_explode(style))
deep_explode = style == "deepObject" && is_explode
if deep_explode
merge!(params, deep_object_serialize(Dict(name=>value)))
return nothing
end
if isa(value, Dict)
# implements the default serialization (style=form, explode=true, location=queryparams)
# as mentioned in https://swagger.io/docs/specification/serialization/
Expand Down Expand Up @@ -789,7 +807,7 @@ function storefile(api_call::Function;
folder::AbstractString = pwd(),
filename::Union{String,Nothing} = nothing,
)::Tuple{Any,ApiResponse,String}

result, http_response = api_call()

if isnothing(filename)
Expand Down Expand Up @@ -828,4 +846,21 @@ function extract_filename(resp::Downloads.Response)::String
return string("response", extension_from_mime(MIME(content_type_str)))
end

function deep_object_serialize(dict::Dict, parent_key::String = "")
parts = Pair[]
for (key, value) in dict
new_key = parent_key == "" ? key : "$parent_key[$key]"
if isa(value, Dict)
append!(parts, collect(deep_object_serialize(value, new_key)))
elseif isa(value, Vector)
for (i, v) in enumerate(value)
push!(parts, "$new_key[$(i-1)]"=>"$v")
end
else
push!(parts, "$new_key"=>"$value")
end
end
return Dict(parts)
end

end # module Clients
75 changes: 57 additions & 18 deletions src/json.jl
Original file line number Diff line number Diff line change
Expand Up @@ -34,41 +34,78 @@ function lower(o::T) where {T<:UnionAPIModel}
end
end

struct StyleCtx
location::Symbol
name::String
is_explode::Bool
end

is_deep_explode(sctx::StyleCtx) = sctx.name == "deepObject" && sctx.is_explode

function deep_object_to_array(src::Dict)
keys_are_int = all(key -> occursin(r"^\d+$", key), keys(src))
if keys_are_int
sorted_keys = sort(collect(keys(src)), by=x->parse(Int, x))
final = []
for key in sorted_keys
push!(final, src[key])
end
return final
else
src
end
end

to_json(o) = JSON.json(o)

from_json(::Type{Union{Nothing,T}}, json::Dict{String,Any}) where {T} = from_json(T, json)
from_json(::Type{T}, json::Dict{String,Any}) where {T} = from_json(T(), json)
from_json(::Type{T}, json::Dict{String,Any}) where {T <: Dict} = convert(T, json)
from_json(::Type{T}, j::Dict{String,Any}) where {T <: String} = to_json(j)
from_json(::Type{Any}, j::Dict{String,Any}) = j
from_json(::Type{Union{Nothing,T}}, json::Dict{String,Any}; stylectx=nothing) where {T} = from_json(T, json; stylectx)
from_json(::Type{T}, json::Dict{String,Any}; stylectx=nothing) where {T} = from_json(T(), json; stylectx)
from_json(::Type{T}, json::Dict{String,Any}; stylectx=nothing) where {T <: Dict} = convert(T, json)
from_json(::Type{T}, j::Dict{String,Any}; stylectx=nothing) where {T <: String} = to_json(j)
from_json(::Type{Any}, j::Dict{String,Any}; stylectx=nothing) = j
from_json(::Type{Vector{T}}, j::Vector{Any}; stylectx=nothing) where {T} = j

function from_json(::Type{Vector{T}}, json::Dict{String, Any}; stylectx=nothing) where {T}
if !isnothing(stylectx) && is_deep_explode(stylectx)
cvt = deep_object_to_array(json)
if isa(cvt, Vector)
return from_json(Vector{T}, cvt; stylectx)
else
return from_json(T, json; stylectx)
end
else
return from_json(T, json; stylectx)
end
end

function from_json(o::T, json::Dict{String,Any}) where {T <: UnionAPIModel}
return from_json(o, :value, json)
function from_json(o::T, json::Dict{String,Any};stylectx=nothing) where {T <: UnionAPIModel}
return from_json(o, :value, json;stylectx)
end

from_json(::Type{T}, val::Union{String,Real}) where {T <: UnionAPIModel} = T(val)
function from_json(o::T, val::Union{String,Real}) where {T <: UnionAPIModel}
from_json(::Type{T}, val::Union{String,Real};stylectx=nothing) where {T <: UnionAPIModel} = T(val)
function from_json(o::T, val::Union{String,Real};stylectx=nothing) where {T <: UnionAPIModel}
o.value = val
return o
end

function from_json(o::T, json::Dict{String,Any}) where {T <: APIModel}
function from_json(o::T, json::Dict{String,Any};stylectx=nothing) where {T <: APIModel}
jsonkeys = [Symbol(k) for k in keys(json)]
for name in intersect(propertynames(o), jsonkeys)
from_json(o, name, json[String(name)])
from_json(o, name, json[String(name)];stylectx)
end
return o
end

function from_json(o::T, name::Symbol, json::Dict{String,Any}) where {T <: APIModel}
function from_json(o::T, name::Symbol, json::Dict{String,Any};stylectx=nothing) where {T <: APIModel}
ftype = (T <: UnionAPIModel) ? property_type(T, name, json) : property_type(T, name)
fval = from_json(ftype, json)
fval = from_json(ftype, json; stylectx)
setfield!(o, name, convert(ftype, fval))
return o
end

function from_json(o::T, name::Symbol, v) where {T <: APIModel}
function from_json(o::T, name::Symbol, v; stylectx=nothing) where {T <: APIModel}
ftype = (T <: UnionAPIModel) ? property_type(T, name, Dict{String,Any}()) : property_type(T, name)
atype = isa(ftype, Union) ? ((ftype.a === Nothing) ? ftype.b : ftype.a) : ftype
if ftype === Any
setfield!(o, name, v)
elseif ZonedDateTime <: ftype
Expand All @@ -80,13 +117,15 @@ function from_json(o::T, name::Symbol, v) where {T <: APIModel}
elseif String <: ftype && isa(v, Real)
# string numbers can have format specifiers that allow numbers, ensure they are converted to strings
setfield!(o, name, string(v))
elseif atype <: Real && isa(v, AbstractString)
setfield!(o, name, parse(atype, v))
else
setfield!(o, name, convert(ftype, v))
end
return o
end

function from_json(o::T, name::Symbol, v::Vector) where {T <: APIModel}
function from_json(o::T, name::Symbol, v::Vector; stylectx=nothing) where {T <: APIModel}
# in Julia we can not support JSON null unless the element type is explicitly set to support it
ftype = property_type(T, name)

Expand All @@ -111,7 +150,7 @@ function from_json(o::T, name::Symbol, v::Vector) where {T <: APIModel}
if (vtype <: Vector) && (veltype <: OpenAPI.UnionAPIModel)
vec = veltype[]
for vecelem in v
push!(vec, from_json(veltype(), :value, vecelem))
push!(vec, from_json(veltype(), :value, vecelem;stylectx))
end
setfield!(o, name, vec)
elseif (vtype <: Vector) && (veltype <: OpenAPI.APIModel)
Expand All @@ -129,7 +168,7 @@ function from_json(o::T, name::Symbol, v::Vector) where {T <: APIModel}
return o
end

function from_json(o::T, name::Symbol, ::Nothing) where {T <: APIModel}
function from_json(o::T, name::Symbol, ::Nothing;stylectx=nothing) where {T <: APIModel}
setfield!(o, name, nothing)
return o
end
end
85 changes: 66 additions & 19 deletions src/server.jl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module Servers
using JSON
using HTTP

import ..OpenAPI: APIModel, ValidationException, from_json, to_json
import ..OpenAPI: APIModel, ValidationException, from_json, to_json, deep_object_to_array, StyleCtx, is_deep_explode

function middleware(impl, read, validate, invoke;
init=nothing,
Expand All @@ -29,6 +29,36 @@ end
##############################
# server parameter conversions
##############################
struct Param
keylist::Vector{String}
value::String
end

function parse_query_dict(query_dict::Dict{String, String})::Vector{Param}
params = Vector{Param}()
for (key, value) in query_dict
keylist = replace.(split(key, "["), "]"=>"")
push!(params, Param(keylist, value))
end

return params
end

function deep_dict_repr(qp::Dict)
params = parse_query_dict(qp)
deserialized_dict = Dict{String, Any}()
for param in params
current = deserialized_dict
for part in param.keylist[1:end-1]
current = get!(current, part) do
return Dict{String, Any}()
end
end
current[param.keylist[end]] = param.value
end
return deserialized_dict
end

function get_param(source::Dict, name::String, required::Bool)
val = get(source, name, nothing)
if required && isnothing(val)
Expand All @@ -48,36 +78,50 @@ function get_param(source::Vector{HTTP.Forms.Multipart}, name::String, required:
end
end


function to_param_type(::Type{T}, strval::String) where {T <: Number}
function to_param_type(::Type{T}, strval::String; stylectx=nothing) where {T <: Number}
parse(T, strval)
end

to_param_type(::Type{T}, val::T) where {T} = val
to_param_type(::Type{T}, ::Nothing) where {T} = nothing
to_param_type(::Type{String}, val::Vector{UInt8}) = String(copy(val))
to_param_type(::Type{Vector{UInt8}}, val::String) = convert(Vector{UInt8}, copy(codeunits(val)))
to_param_type(::Type{Vector{T}}, val::Vector{T}, _collection_format::Union{String,Nothing}) where {T} = val
to_param_type(::Type{T}, val::T; stylectx=nothing) where {T} = val
to_param_type(::Type{T}, ::Nothing; stylectx=nothing) where {T} = nothing
to_param_type(::Type{String}, val::Vector{UInt8}; stylectx=nothing) = String(copy(val))
to_param_type(::Type{Vector{UInt8}}, val::String; stylectx=nothing) = convert(Vector{UInt8}, copy(codeunits(val)))
to_param_type(::Type{Vector{T}}, val::Vector{T}, _collection_format::Union{String,Nothing}; stylectx=nothing) where {T} = val
to_param_type(::Type{Vector{T}}, json::Vector{Any}; stylectx=nothing) where {T} = [to_param_type(T, x; stylectx) for x in json]

function to_param_type(::Type{Vector{T}}, json::Dict{String, Any}; stylectx=nothing) where {T}
if !isnothing(stylectx) && is_deep_explode(stylectx)
cvt = deep_object_to_array(json)
if isa(cvt, Vector)
return to_param_type(Vector{T}, cvt; stylectx)
end
end
error("Unable to convert $json to $(Vector{T})")
end

function to_param_type(::Type{T}, strval::String) where {T <: APIModel}
from_json(T, JSON.parse(strval))
function to_param_type(::Type{T}, strval::String; stylectx=nothing) where {T <: APIModel}
from_json(T, JSON.parse(strval); stylectx)
end

function to_param_type(::Type{T}, json::Dict{String,Any}) where {T <: APIModel}
from_json(T, json)
function to_param_type(::Type{T}, json::Dict{String,Any}; stylectx=nothing) where {T <: APIModel}
from_json(T, json; stylectx)
end

function to_param_type(::Type{Vector{T}}, strval::String, delim::String) where {T}
function to_param_type(::Type{Vector{T}}, strval::String, delim::String; stylectx=nothing) where {T}
elems = string.(strip.(split(strval, delim)))
return map(x->to_param_type(T, x), elems)
return map(x->to_param_type(T, x; stylectx), elems)
end

function to_param_type(::Type{Vector{T}}, strval::String) where {T}
function to_param_type(::Type{Vector{T}}, strval::String; stylectx=nothing) where {T}
elems = JSON.parse(strval)
return map(x->to_param_type(T, x), elems)
return map(x->to_param_type(T, x; stylectx), elems)
end

function to_param(T, source::Dict, name::String; required::Bool=false, collection_format::Union{String,Nothing}=",", multipart::Bool=false, isfile::Bool=false)
function to_param(T, source::Dict, name::String; required::Bool=false, collection_format::Union{String,Nothing}=",", multipart::Bool=false, isfile::Bool=false, style::String="form", is_explode::Bool=true, location=:query)
deep_explode = style == "deepObject" && is_explode
if deep_explode
source = deep_dict_repr(source)
end
param = get_param(source, name, required)
if param === nothing
return nothing
Expand All @@ -86,10 +130,13 @@ function to_param(T, source::Dict, name::String; required::Bool=false, collectio
# param is a Multipart
param = isfile ? param.data : String(param.data)
end
if deep_explode
return to_param_type(T, param; stylectx=StyleCtx(location, style, is_explode))
end
if T <: Vector
return to_param_type(T, param, collection_format)
to_param_type(T, param, collection_format)
else
return to_param_type(T, param)
to_param_type(T, param)
end
end

Expand Down
45 changes: 45 additions & 0 deletions test/client/param_serialize.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using OpenAPI.Clients: deep_object_serialize

@testset "Test deep_object_serialize" begin
@testset "Single level object" begin
dict = Dict("key1" => "value1", "key2" => "value2")
expected = Dict("key1" => "value1", "key2" => "value2")
@test deep_object_serialize(dict) == expected
end

@testset "Nested object" begin
dict = Dict("outer" => Dict("inner" => "value"))
expected = Dict("outer[inner]" => "value")
@test deep_object_serialize(dict) == expected
end

@testset "Deeply nested object" begin
dict = Dict("a" => Dict("b" => Dict("c" => Dict("d" => "value"))))
expected = Dict("a[b][c][d]" => "value")
@test deep_object_serialize(dict) == expected
end

@testset "Multiple nested objects" begin
dict = Dict("a" => Dict("b" => "value1", "c" => "value2"))
expected = Dict("a[b]" => "value1", "a[c]" => "value2")
@test deep_object_serialize(dict) == expected
end

@testset "Dictionary represented array" begin
dict = Dict("a" => ["value1", "value2"])
expected = Dict("a[0]" => "value1", "a[1]" => "value2")
@test deep_object_serialize(dict) == expected
end

@testset "Mixed structure" begin
dict = Dict("a" => Dict("b" => "value1", "c" => ["value2", "value3"]))
expected = Dict("a[b]" => "value1", "a[c][0]" => "value2", "a[c][1]" => "value3")
@test deep_object_serialize(dict) == expected
end

@testset "Blank values" begin
dict = Dict("a" => Dict("b" => "", "c" => ""))
expected = Dict("a[b]" => "", "a[c]" => "")
@test deep_object_serialize(dict) == expected
end
end
5 changes: 4 additions & 1 deletion test/client/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ include("openapigenerator_petstore_v3/runtests.jl")

function runtests(; skip_petstore=false, test_file_upload=false)
@testset "Client" begin
@testset "deepObj query param serialization" begin
include("client/param_serialize.jl")
end
@testset "Utils" begin
test_longpoll_exception_check()
test_request_interrupted_exception_check()
Expand Down Expand Up @@ -52,4 +55,4 @@ function run_openapigenerator_tests(; test_file_upload=false)
end
end

end # module OpenAPIClientTests
end # module OpenAPIClientTests
Loading

0 comments on commit 4c7260c

Please sign in to comment.