Skip to content

Commit 0053243

Browse files
authored
chunk detection based on response format (#59)
This introduces `AbstractChunkReader` and adds a few chunk readers with different chunk detection strategies. Also includes some auto selection heuristics based on detected response format and ability to specify a chunk reader at `Ctx` and `Client` levels. Chunk readers available: - `LineChunkReader`: Chunks delimited by newline. This was the only available strategy earlier. It is now the default when the response type is detected to be not of `OpenAPI.APIModel` type. - `JSONChunkReader`: Each chunk is a JSON. Whitespaces between JSONs are ignored. This is now the default when the response type is detected to be a `OpenAPI.APIModel`. - `RFC7464ChunkReader`: A reader based on [RFC 7464](https://www.rfc-editor.org/rfc/rfc7464.html). Available for use by overriding through `Client` or `Ctx`. The `Client` and `Ctx` constructors take an additional `chunk_reader_type` keyword parameter. This can be one of `OpenAPI.Clients.LineChunkReader`, `OpenAPI.Clients.JSONChunkReader` or `OpenAPI.Clients.RFC7464ChunkReader`. If not specified, then the type is automatically determined based on the return type of the API call.
1 parent 8928973 commit 0053243

File tree

3 files changed

+271
-5
lines changed

3 files changed

+271
-5
lines changed

src/client.jl

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import Base: convert, show, summary, getproperty, setproperty!, iterate
1414
import ..OpenAPI: APIModel, UnionAPIModel, OneOfAPIModel, AnyOfAPIModel, APIClientImpl, OpenAPIException, InvocationException, to_json, from_json, validate_property, property_type
1515
import ..OpenAPI: str2zoneddatetime, str2datetime, str2date
1616

17+
18+
abstract type AbstractChunkReader end
19+
1720
# collection formats (OpenAPI v2)
1821
# TODO: OpenAPI v3 has style and explode options instead of collection formats, which are yet to be supported
1922
# TODO: Examine whether multi is now supported
@@ -128,6 +131,7 @@ Keyword parameters:
128131
- `pre_request_hook(ctx::Ctx)`: This method is called before every API call. It is passed the context object that will be used for the API call. The function should return the context object to be used for the API call.
129132
- `pre_request_hook(resource_path::AbstractString, body::Any, headers::Dict{String,String})`: This method is called before every API call. It is passed the resource path, request body and request headers that will be used for the API call. The function should return those after making any modifications to them.
130133
- `escape_path_params`: Whether the path parameters should be escaped before being used in the URL. This is useful if the path parameters contain characters that are not allowed in URLs or contain path separators themselves.
134+
- `chunk_reader_type`: The type of chunk reader to be used for streaming responses. This can be one of `LineChunkReader`, `JSONChunkReader` or `RFC7464ChunkReader`. If not specified, then the type is automatically determined based on the return type of the API call.
131135
- `verbose`: Can be set either to a boolean or a function.
132136
- If set to true, then the client will log all HTTP requests and responses.
133137
- If set to a function, then that function will be called with the following parameters:
@@ -144,6 +148,7 @@ struct Client
144148
timeout::Ref{Int}
145149
pre_request_hook::Function # user provided hook to modify the request before it is sent
146150
escape_path_params::Union{Nothing,Bool}
151+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}
147152
long_polling_timeout::Int
148153

149154
function Client(root::String;
@@ -153,6 +158,7 @@ struct Client
153158
timeout::Int=DEFAULT_TIMEOUT_SECS,
154159
pre_request_hook::Function=noop_pre_request_hook,
155160
escape_path_params::Union{Nothing,Bool}=nothing,
161+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing,
156162
verbose::Union{Bool,Function}=false,
157163
)
158164
clntoptions = Dict{Symbol,Any}(:throw=>false)
@@ -167,7 +173,7 @@ struct Client
167173
# disable ALPN to support servers that enable both HTTP/2 and HTTP/1.1 on same port
168174
Downloads.Curl.setopt(easy, LibCURL.CURLOPT_SSL_ENABLE_ALPN, 0)
169175
end
170-
new(root, headers, get_return_type, clntoptions, downloader, Ref{Int}(timeout), pre_request_hook, escape_path_params, long_polling_timeout)
176+
new(root, headers, get_return_type, clntoptions, downloader, Ref{Int}(timeout), pre_request_hook, escape_path_params, chunk_reader_type, long_polling_timeout)
171177
end
172178
end
173179

@@ -237,15 +243,17 @@ struct Ctx
237243
curl_mime_upload::Ref{Any}
238244
pre_request_hook::Function
239245
escape_path_params::Bool
246+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}
240247

241248
function Ctx(client::Client, method::String, return_types::Dict{Regex,Type}, resource::String, auth, body=nothing;
242249
timeout::Int=client.timeout[],
243250
pre_request_hook::Function=client.pre_request_hook,
244251
escape_path_params::Bool=something(client.escape_path_params, true),
252+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=client.chunk_reader_type,
245253
)
246254
resource = client.root * resource
247255
headers = copy(client.headers)
248-
new(client, method, return_types, resource, auth, Dict{String,String}(), Dict{String,String}(), headers, Dict{String,String}(), Dict{String,String}(), body, timeout, Ref{Any}(nothing), pre_request_hook, escape_path_params)
256+
new(client, method, return_types, resource, auth, Dict{String,String}(), Dict{String,String}(), headers, Dict{String,String}(), Dict{String,String}(), body, timeout, Ref{Any}(nothing), pre_request_hook, escape_path_params, chunk_reader_type)
249257
end
250258
end
251259

@@ -414,11 +422,11 @@ response(::Type{T}, data::Dict{String,Any}) where {T} = from_json(T, data)::T
414422
response(::Type{T}, data::Dict{String,Any}) where {T<:Dict} = convert(T, data)
415423
response(::Type{Vector{T}}, data::Vector{V}) where {T,V} = [response(T, v) for v in data]
416424

417-
struct ChunkReader
425+
struct LineChunkReader <: AbstractChunkReader
418426
buffered_input::Base.BufferStream
419427
end
420428

421-
function Base.iterate(iter::ChunkReader, _state=nothing)
429+
function Base.iterate(iter::LineChunkReader, _state=nothing)
422430
if eof(iter.buffered_input)
423431
return nothing
424432
else
@@ -432,6 +440,57 @@ function Base.iterate(iter::ChunkReader, _state=nothing)
432440
end
433441
end
434442

443+
struct JSONChunkReader <: AbstractChunkReader
444+
buffered_input::Base.BufferStream
445+
end
446+
447+
function Base.iterate(iter::JSONChunkReader, _state=nothing)
448+
if eof(iter.buffered_input)
449+
return nothing
450+
else
451+
# read all whitespaces
452+
while !eof(iter.buffered_input)
453+
byte = peek(iter.buffered_input, UInt8)
454+
if isspace(Char(byte))
455+
read(iter.buffered_input, UInt8)
456+
else
457+
break
458+
end
459+
end
460+
eof(iter.buffered_input) && return nothing
461+
valid_json = JSON.parse(iter.buffered_input)
462+
bytes = convert(Vector{UInt8}, codeunits(JSON.json(valid_json)))
463+
return (bytes, iter)
464+
end
465+
end
466+
467+
# Ref: https://www.rfc-editor.org/rfc/rfc7464.html
468+
const RFC7464_RECORD_SEPARATOR = UInt8(0x1E)
469+
struct RFC7464ChunkReader <: AbstractChunkReader
470+
buffered_input::Base.BufferStream
471+
end
472+
473+
function Base.iterate(iter::RFC7464ChunkReader, _state=nothing)
474+
if eof(iter.buffered_input)
475+
return nothing
476+
else
477+
out = IOBuffer()
478+
while !eof(iter.buffered_input)
479+
byte = read(iter.buffered_input, UInt8)
480+
if byte == RFC7464_RECORD_SEPARATOR
481+
bytes = take!(out)
482+
if isnothing(_state) || !isempty(bytes)
483+
return (bytes, iter)
484+
end
485+
else
486+
write(out, byte)
487+
end
488+
end
489+
bytes = take!(out)
490+
return (bytes, iter)
491+
end
492+
end
493+
435494
noop_pre_request_hook(ctx::Ctx) = ctx
436495
noop_pre_request_hook(resource_path::AbstractString, body::Any, headers::Dict{String,String}) = (resource_path, body, headers)
437496

@@ -491,7 +550,13 @@ function do_request(ctx::Ctx, stream::Bool=false; stream_to::Union{Channel,Nothi
491550
end
492551
@async begin
493552
try
494-
for chunk in ChunkReader(output)
553+
if isnothing(ctx.chunk_reader_type)
554+
default_return_type = ctx.client.get_return_type(ctx.return_types, nothing, "")
555+
readerT = default_return_type <: APIModel ? JSONChunkReader : LineChunkReader
556+
else
557+
readerT = ctx.chunk_reader_type
558+
end
559+
for chunk in readerT(output)
495560
return_type = ctx.client.get_return_type(ctx.return_types, nothing, String(copy(chunk)))
496561
data = response(return_type, resp, chunk)
497562
put!(stream_to, data)

test/chunkreader_tests.jl

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
module ChunkReaderTests
2+
using Test
3+
using JSON
4+
using OpenAPI
5+
using OpenAPI.Clients: AbstractChunkReader, JSONChunkReader, LineChunkReader, RFC7464ChunkReader
6+
7+
function linechunk1()
8+
buff = Base.BufferStream()
9+
reader = LineChunkReader(buff)
10+
results = String[]
11+
readertask = @async begin
12+
for line in reader
13+
push!(results, String(line))
14+
end
15+
end
16+
write(buff, "hello\nworld\n")
17+
write(buff, "goodbye\n")
18+
close(buff)
19+
wait(readertask)
20+
@test results == ["hello", "world", "goodbye"]
21+
end
22+
23+
function linechunk2()
24+
buff = Base.BufferStream()
25+
reader = LineChunkReader(buff)
26+
results = String[]
27+
readertask = @async begin
28+
for line in reader
29+
push!(results, String(line))
30+
end
31+
end
32+
write(buff, "\nhello\nworld\n")
33+
write(buff, "goodbye\n")
34+
close(buff)
35+
wait(readertask)
36+
@test results == ["", "hello", "world", "goodbye"]
37+
end
38+
39+
function linechunk3()
40+
buff = Base.BufferStream()
41+
reader = LineChunkReader(buff)
42+
results = String[]
43+
readertask = @async begin
44+
for line in reader
45+
push!(results, String(line))
46+
end
47+
end
48+
write(buff, "hello\nworld\n")
49+
write(buff, "goodbye")
50+
close(buff)
51+
wait(readertask)
52+
@test results == ["hello", "world", "goodbye"]
53+
end
54+
55+
function jsonchunk1()
56+
buff = Base.BufferStream()
57+
reader = JSONChunkReader(buff)
58+
results = String[]
59+
readertask = @async begin
60+
for json in reader
61+
push!(results, String(json))
62+
end
63+
end
64+
65+
write(buff, "{\"hello\": \"world\"}")
66+
write(buff, "{\"hello\": \"world\"}")
67+
close(buff)
68+
wait(readertask)
69+
for result in results
70+
json = JSON.parse(result)
71+
@test json["hello"] == "world"
72+
end
73+
@test length(results) == 2
74+
end
75+
76+
function jsonchunk2()
77+
buff = Base.BufferStream()
78+
reader = JSONChunkReader(buff)
79+
results = String[]
80+
readertask = @async begin
81+
for json in reader
82+
push!(results, String(json))
83+
end
84+
end
85+
86+
write(buff, "{\"hello\": \"world\"}\n")
87+
write(buff, "{\"hello\": \"world\"}\n")
88+
close(buff)
89+
wait(readertask)
90+
for result in results
91+
json = JSON.parse(result)
92+
@test json["hello"] == "world"
93+
end
94+
@test length(results) == 2
95+
end
96+
97+
function jsonchunk3()
98+
buff = Base.BufferStream()
99+
reader = JSONChunkReader(buff)
100+
results = String[]
101+
readertask = @async begin
102+
for json in reader
103+
push!(results, String(json))
104+
end
105+
end
106+
107+
write(buff, "\n\n{\"hello\": \"world\"}\n\n")
108+
write(buff, "{\"hello\": \"world\"}\n")
109+
close(buff)
110+
wait(readertask)
111+
for result in results
112+
json = JSON.parse(result)
113+
@test json["hello"] == "world"
114+
end
115+
@test length(results) == 2
116+
end
117+
118+
function jsonchunk4()
119+
buff = Base.BufferStream()
120+
reader = JSONChunkReader(buff)
121+
results = String[]
122+
readertask = @async begin
123+
for json in reader
124+
push!(results, String(json))
125+
end
126+
end
127+
128+
write(buff, "\n\n{\"hello\": \"world\"}\n\n")
129+
write(buff, "{\"hello\": \"world\"\n")
130+
close(buff)
131+
@test_throws TaskFailedException wait(readertask)
132+
@test length(results) == 1
133+
end
134+
135+
function rfc7464chunk1()
136+
buff = Base.BufferStream()
137+
reader = RFC7464ChunkReader(buff)
138+
results = String[]
139+
readertask = @async begin
140+
for chunk in reader
141+
push!(results, String(chunk))
142+
end
143+
end
144+
145+
write(buff, OpenAPI.Clients.RFC7464_RECORD_SEPARATOR)
146+
write(buff, "{\"hello\": \"world\"}")
147+
write(buff, OpenAPI.Clients.RFC7464_RECORD_SEPARATOR)
148+
write(buff, "{\"hello\": \"world\"}")
149+
close(buff)
150+
wait(readertask)
151+
for result in results
152+
if !isempty(result)
153+
json = JSON.parse(result)
154+
@test json["hello"] == "world"
155+
end
156+
end
157+
@test length(results) == 3
158+
end
159+
160+
function rfc7464chunk2()
161+
buff = Base.BufferStream()
162+
reader = RFC7464ChunkReader(buff)
163+
results = String[]
164+
readertask = @async begin
165+
for chunk in reader
166+
push!(results, String(chunk))
167+
end
168+
end
169+
170+
write(buff, "{\"hello\": \"world\"}")
171+
write(buff, OpenAPI.Clients.RFC7464_RECORD_SEPARATOR)
172+
write(buff, "{\"hello\": \"world\"}")
173+
write(buff, OpenAPI.Clients.RFC7464_RECORD_SEPARATOR)
174+
close(buff)
175+
wait(readertask)
176+
for result in results
177+
if !isempty(result)
178+
json = JSON.parse(result)
179+
@test json["hello"] == "world"
180+
end
181+
end
182+
@test length(results) == 2
183+
end
184+
185+
function runtests()
186+
linechunk1()
187+
linechunk2()
188+
linechunk3()
189+
jsonchunk1()
190+
jsonchunk2()
191+
jsonchunk3()
192+
jsonchunk4()
193+
rfc7464chunk1()
194+
rfc7464chunk2()
195+
end
196+
197+
end # module ChunkReaderTests

test/runtests.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Test, HTTP
22

3+
include("chunkreader_tests.jl")
34
include("testutils.jl")
45
include("modelgen/testmodelgen.jl")
56
include("client/runtests.jl")
@@ -10,6 +11,9 @@ include("forms/forms_client.jl")
1011
@testset "ModelGen" begin
1112
TestModelGen.runtests()
1213
end
14+
@testset "Chunk Readers" begin
15+
ChunkReaderTests.runtests()
16+
end
1317
@testset "Petstore Client" begin
1418
try
1519
if run_tests_with_servers && !openapi_generator_env

0 commit comments

Comments
 (0)