Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for query parameter serialization style "deepObject" #78

Merged
merged 10 commits into from
Jul 22, 2024
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
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
tanmaykm marked this conversation as resolved.
Show resolved Hide resolved
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
vdayanand marked this conversation as resolved.
Show resolved Hide resolved
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
Loading