Skip to content

Commit ecb65e6

Browse files
committed
Refactor Client to support multiple HTTP libraries
This introduces a way to choose between different HTTP libraries while instantiating an OpenAPI client. Maintains OpenAPI API and models compatibility, though the HTTP library specific return values (raw response, errors and such) would be as per the chosen library. - New HTTP client library abstraction: Created an interface contract for HTTP client libraries (`httplibs.jl`, `julialang_downloads.jl`, `juliaweb_http.jl`) - Modularized client code: Moved client types and chunk readers into dedicated modules (`clienttypes.jl`, `chunk_readers.jl`) - Client libraries support: - Default: Downloads.jl - Optional: HTTP.jl via new `httplib` parameter Enables future backend additions without major refactoring. Maintains backward compatibility (Downloads.jl is default). Example: ```julia client = Client("http://api.example.com") client = Client("http://api.example.com"; httplib=OpenAPI.HTTPLib.HTTP) api = MyGeneratedApi(client) result, http_resp = get_resource(api, 123) @info "Status: $(http_resp.status)" @info "Headers: $(http_resp.headers)" raw_response = http_resp.raw # HTTP.Response or Downloads.Response ```
1 parent 0c95ebb commit ecb65e6

37 files changed

+1366
-673
lines changed

docs/src/userguide.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ Client(root::String;
127127
escape_path_params::Union{Nothing,Bool}=nothing,
128128
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing,
129129
verbose::Union{Bool,Function}=false,
130+
httplib::Symbol=OpenAPI.HTTPLib.Downloads,
130131
)
131132
```
132133

@@ -140,7 +141,8 @@ Where:
140141
- `pre_request_hook`: user provided hook to modify the request before it is sent
141142
- `escape_path_params`: Whether the path parameters should be escaped before being used in the URL (true by default). This is useful if the path parameters contain characters that are not allowed in URLs or contain path separators themselves.
142143
- `chunk_reader_type`: The type of chunk reader to be used for streaming responses.
143-
- `verbose`: whether to enable verbose logging
144+
- `verbose`: whether to enable verbose logging (behavior depends on chosen HTTP backend)
145+
- `httplib`: The HTTP client library to use for making requests. Can be `OpenAPI.HTTPLib.Downloads` (default) for Downloads.jl or `OpenAPI.HTTPLib.HTTP` for HTTP.jl.
144146

145147
The `pre_request_hook` must provide the following two implementations:
146148
- `pre_request_hook(ctx::OpenAPI.Clients.Ctx) -> ctx`
@@ -150,9 +152,10 @@ The `chunk_reader_type` can be one of `LineChunkReader`, `JSONChunkReader` or `R
150152

151153
The `verbose` option can be one of:
152154
- `false`: the default, no verbose logging
153-
- `true`: enables curl verbose logging to stderr
154-
- a function that accepts two arguments - type and message (available on Julia version >= 1.7)
155+
- `true`: enables verbose logging to stderr
156+
- a function that accepts two arguments - type and message **(only supported with Downloads.jl backend; available on Julia version >= 1.7)**
155157
- a default implementation of this that uses `@info` to log the arguments is provided as `OpenAPI.Clients.default_debug_hook`
158+
- **Note:** This option is not supported when using the HTTP.jl backend. With HTTP.jl, use `verbose=true` for boolean verbose logging only.
156159

157160
In case of any errors an instance of `ApiException` is thrown. It has the following fields:
158161

src/client.jl

Lines changed: 16 additions & 470 deletions
Large diffs are not rendered by default.

src/client/chunk_readers.jl

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
struct LineChunkReader <: AbstractChunkReader
2+
buffered_input::Base.BufferStream
3+
end
4+
5+
function Base.iterate(iter::LineChunkReader, _state=nothing)
6+
if eof(iter.buffered_input)
7+
return nothing
8+
else
9+
out = IOBuffer()
10+
while !eof(iter.buffered_input)
11+
byte = read(iter.buffered_input, UInt8)
12+
(byte == codepoint('\n')) && break
13+
write(out, byte)
14+
end
15+
return (take!(out), iter)
16+
end
17+
end
18+
19+
struct JSONChunkReader <: AbstractChunkReader
20+
buffered_input::Base.BufferStream
21+
end
22+
23+
function Base.iterate(iter::JSONChunkReader, _state=nothing)
24+
if eof(iter.buffered_input)
25+
return nothing
26+
else
27+
# read all whitespaces
28+
while !eof(iter.buffered_input)
29+
byte = peek(iter.buffered_input, UInt8)
30+
if isspace(Char(byte))
31+
read(iter.buffered_input, UInt8)
32+
else
33+
break
34+
end
35+
end
36+
eof(iter.buffered_input) && return nothing
37+
valid_json = JSON.parse(iter.buffered_input)
38+
bytes = convert(Vector{UInt8}, codeunits(JSON.json(valid_json)))
39+
return (bytes, iter)
40+
end
41+
end
42+
43+
# Ref: https://www.rfc-editor.org/rfc/rfc7464.html
44+
const RFC7464_RECORD_SEPARATOR = UInt8(0x1E)
45+
struct RFC7464ChunkReader <: AbstractChunkReader
46+
buffered_input::Base.BufferStream
47+
end
48+
49+
function Base.iterate(iter::RFC7464ChunkReader, _state=nothing)
50+
if eof(iter.buffered_input)
51+
return nothing
52+
else
53+
out = IOBuffer()
54+
while !eof(iter.buffered_input)
55+
byte = read(iter.buffered_input, UInt8)
56+
if byte == RFC7464_RECORD_SEPARATOR
57+
bytes = take!(out)
58+
if isnothing(_state) || !isempty(bytes)
59+
return (bytes, iter)
60+
end
61+
else
62+
write(out, byte)
63+
end
64+
end
65+
bytes = take!(out)
66+
return (bytes, iter)
67+
end
68+
end

src/client/clienttypes.jl

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
abstract type AbstractChunkReader end
2+
abstract type AbstractHTTPLibError end
3+
const HTTPLibResponse = Union{HTTP.Response, Downloads.Response}
4+
const HTTPLibError = Union{Downloads.RequestError, AbstractHTTPLibError}
5+
6+
# methods to get exception messages out of errors which could be surfaced either as request or response errors
7+
get_message(::HTTPLibError) = ""
8+
get_message(::HTTPLibResponse) = ""
9+
get_response(::HTTPLibError) = nothing
10+
get_status(::HTTPLibError) = 0
11+
12+
# collection formats (OpenAPI v2)
13+
# TODO: OpenAPI v3 has style and explode options instead of collection formats, which are yet to be supported
14+
# TODO: Examine whether multi is now supported
15+
const COLL_MULTI = "multi" # (legacy) aliased to CSV, as multi is not supported by Requests.jl (https://github.com/JuliaWeb/Requests.jl/issues/140)
16+
const COLL_PIPES = "pipes"
17+
const COLL_SSV = "ssv"
18+
const COLL_TSV = "tsv"
19+
const COLL_CSV = "csv"
20+
const COLL_DLM = Dict{String,String}([COLL_PIPES=>"|", COLL_SSV=>" ", COLL_TSV=>"\t", COLL_CSV=>",", COLL_MULTI=>","])
21+
22+
const DEFAULT_TIMEOUT_SECS = 5*60
23+
const DEFAULT_LONGPOLL_TIMEOUT_SECS = 15*60
24+
25+
const HTTPLib = (
26+
HTTP = :http,
27+
Downloads = :downloads
28+
)
29+
30+
struct ApiException <: Exception
31+
status::Int
32+
reason::String
33+
resp::Union{Nothing, HTTPLibResponse}
34+
error::Union{Nothing, HTTPLibError}
35+
36+
function ApiException(error::HTTPLibError; reason::String="")
37+
isempty(reason) && (reason = get_message(error))
38+
resp = get_response(error)
39+
status = get_status(error)
40+
new(status, reason, resp, error)
41+
end
42+
end
43+
44+
"""
45+
ApiResponse
46+
47+
Represents the HTTP API response from the server. This is returned as the second return value from all API calls.
48+
49+
Properties available:
50+
- `status`: the HTTP status code
51+
- `message`: the HTTP status message
52+
- `headers`: the HTTP headers
53+
- `raw`: the raw response from the HTTP library used
54+
"""
55+
struct ApiResponse
56+
raw::HTTPLibResponse
57+
end
58+
59+
get_response_property(raw::HTTPLibResponse, name::Symbol) = getproperty(raw, name)
60+
function Base.getproperty(resp::ApiResponse, name::Symbol)
61+
raw = getfield(resp, :raw)
62+
if name in (:status, :message, :headers)
63+
return get_response_property(raw, name)
64+
else
65+
return getfield(resp, name)
66+
end
67+
end
68+
69+
70+
function get_api_return_type(return_types::Dict{Regex,Type}, ::Nothing, response_data::String)
71+
# this is the async case, where we do not have the response code yet
72+
# in such cases we look for the 200 response code
73+
return get_api_return_type(return_types, 200, response_data)
74+
end
75+
function get_api_return_type(return_types::Dict{Regex,Type}, response_code::Integer, response_data::String)
76+
default_response_code = 0
77+
for code in string.([response_code, default_response_code])
78+
for (re, rt) in return_types
79+
if match(re, code) !== nothing
80+
return rt
81+
end
82+
end
83+
end
84+
# if no specific return type was defined, we assume that:
85+
# - if response code is 2xx, then we make the method call return nothing
86+
# - otherwise we make it throw an ApiException
87+
return (200 <= response_code <=206) ? Nothing : nothing # first(return_types)[2]
88+
end
89+
90+
function default_debug_hook(type, message)
91+
@info("OpenAPI HTTP transport", type, message)
92+
end
93+
94+
"""
95+
Client(root::String;
96+
headers::Dict{String,String}=Dict{String,String}(),
97+
get_return_type::Function=get_api_return_type,
98+
long_polling_timeout::Int=DEFAULT_LONGPOLL_TIMEOUT_SECS,
99+
timeout::Int=DEFAULT_TIMEOUT_SECS,
100+
pre_request_hook::Function=noop_pre_request_hook,
101+
escape_path_params::Union{Nothing,Bool}=nothing,
102+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing,
103+
verbose::Union{Bool,Function}=false,
104+
httplib::Symbol=HTTPLib.Downloads,
105+
)
106+
107+
Create a new OpenAPI client context.
108+
109+
A client context holds common information to be used across APIs. It also holds a connection to the server and uses that across API calls.
110+
The client context needs to be passed as the first parameter of all API calls.
111+
112+
Parameters:
113+
- `root`: The root URL of the server. This is the base URL that will be used for all API calls.
114+
115+
Keyword parameters:
116+
- `headers`: A dictionary of HTTP headers to be sent with all API calls.
117+
- `get_return_type`: A function that is called to determine the return type of an API call. This function is called with the following parameters:
118+
- `return_types`: A dictionary of regular expressions and their corresponding return types. The regular expressions are matched against the HTTP status code of the response.
119+
- `response_code`: The HTTP status code of the response.
120+
- `response_data`: The response data as a string.
121+
The function should return the return type to be used for the API call.
122+
- `long_polling_timeout`: The timeout in seconds for long polling requests. This is the time after which the request will be aborted if no data is received from the server.
123+
- `timeout`: The timeout in seconds for all other requests. This is the time after which the request will be aborted if no data is received from the server.
124+
- `pre_request_hook`: A function that is called before every API call. This function must provide two methods:
125+
- `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.
126+
- `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.
127+
- `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.
128+
- `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.
129+
- `verbose`: Can be set either to a boolean or a function (function support depends on the HTTP library).
130+
- If set to true, then the client will log all HTTP requests and responses.
131+
- If set to a function (only supported with Downloads.jl backend), then that function will be called with the following parameters:
132+
- `type`: The type of message.
133+
- `message`: The message to be logged.
134+
- Note: When using HTTP.jl backend (`httplib=OpenAPI.HTTPLib.HTTP`), the `verbose` parameter must be a boolean.
135+
- `httplib`: The HTTP client library to use for making requests. Can be `OpenAPI.HTTPLib.Downloads` (default) for Downloads.jl or `OpenAPI.HTTPLib.HTTP` for HTTP.jl.
136+
137+
"""
138+
struct Client
139+
root::String
140+
headers::Dict{String,String}
141+
get_return_type::Function # user provided hook to get return type from response data
142+
clntoptions::Dict{Symbol,Any}
143+
downloader::Union{Nothing,Downloader}
144+
timeout::Ref{Int}
145+
pre_request_hook::Function # user provided hook to modify the request before it is sent
146+
escape_path_params::Union{Nothing,Bool}
147+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}
148+
long_polling_timeout::Int
149+
request_interrupt_supported::Bool
150+
httplib::Symbol # which http implementation to use
151+
152+
function Client(root::String;
153+
headers::Dict{String,String}=Dict{String,String}(),
154+
get_return_type::Function=get_api_return_type,
155+
long_polling_timeout::Int=DEFAULT_LONGPOLL_TIMEOUT_SECS,
156+
timeout::Int=DEFAULT_TIMEOUT_SECS,
157+
pre_request_hook::Function=noop_pre_request_hook,
158+
escape_path_params::Union{Nothing,Bool}=nothing,
159+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=nothing,
160+
verbose::Union{Bool,Function}=false,
161+
httplib::Symbol=:http,
162+
)
163+
# Validate library choice
164+
if httplib values(HTTPLib)
165+
throw(ArgumentError("Invalid httplib: $httplib"))
166+
end
167+
168+
clntoptions = Dict{Symbol,Any}(:throw=>false)
169+
if isa(verbose, Bool)
170+
clntoptions[:verbose] = verbose
171+
elseif isa(verbose, Function)
172+
if httplib === HTTPLib.HTTP
173+
throw(ArgumentError("With HTTP.jl, `verbose` can only be a boolean"))
174+
end
175+
clntoptions[:debug] = verbose
176+
end
177+
178+
if httplib === HTTPLib.HTTP
179+
downloader = nothing
180+
interruptable = false
181+
else
182+
downloader = Downloads.Downloader()
183+
downloader.easy_hook = (easy, opts) -> begin
184+
Downloads.Curl.setopt(easy, LibCURL.CURLOPT_LOW_SPEED_TIME, long_polling_timeout)
185+
# disable ALPN to support servers that enable both HTTP/2 and HTTP/1.1 on same port
186+
Downloads.Curl.setopt(easy, LibCURL.CURLOPT_SSL_ENABLE_ALPN, 0)
187+
end
188+
189+
interruptable = request_supports_interrupt()
190+
end
191+
new(root, headers, get_return_type, clntoptions, downloader, Ref{Int}(timeout), pre_request_hook, escape_path_params, chunk_reader_type, long_polling_timeout, interruptable, httplib)
192+
end
193+
end
194+
195+
struct Ctx
196+
client::Client
197+
method::String
198+
return_types::Dict{Regex,Type}
199+
resource::String
200+
auth::Vector{String}
201+
202+
path::Dict{String,String}
203+
query::Dict{String,String}
204+
header::Dict{String,String}
205+
form::Dict{String,String}
206+
file::Dict{String,String}
207+
body::Any
208+
timeout::Int
209+
curl_mime_upload::Ref{Any}
210+
pre_request_hook::Function
211+
escape_path_params::Bool
212+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}
213+
214+
function Ctx(client::Client, method::String, return_types::Dict{Regex,Type}, resource::String, auth, body=nothing;
215+
timeout::Int=client.timeout[],
216+
pre_request_hook::Function=client.pre_request_hook,
217+
escape_path_params::Bool=something(client.escape_path_params, true),
218+
chunk_reader_type::Union{Nothing,Type{<:AbstractChunkReader}}=client.chunk_reader_type,
219+
)
220+
resource = client.root * resource
221+
headers = copy(client.headers)
222+
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)
223+
end
224+
end

src/client/httplibs/httplibs.jl

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# =============================================================================
2+
# HTTP Backend Interface Contract
3+
# =============================================================================
4+
#
5+
# Each HTTP backend implementation must provide the following functions:
6+
#
7+
# 1. Request Preparation (via Val dispatch)
8+
# prep_args(::Val{:backend_symbol}, ctx::Ctx) -> (body, kwargs)
9+
#
10+
# Prepares request body and HTTP library-specific options from the context.
11+
# - Handles content-type detection and setting
12+
# - Processes form data and file uploads
13+
# - Converts body to appropriate format (JSON, form-encoded, etc.)
14+
# - Returns tuple of (body, kwargs) for the HTTP library
15+
#
16+
# 2. Request Execution (via Val dispatch)
17+
# do_request(::Val{:backend_symbol}, ctx::Ctx, resource_path::String,
18+
# body, output, kwargs, stream::Bool; stream_to::Union{Channel,Nothing})
19+
# -> (response, output)
20+
#
21+
# Executes the HTTP request using the backend library.
22+
# - Performs synchronous or streaming request based on `stream` flag
23+
# - Handles task management for streaming responses
24+
# - Returns tuple of (response, output) or (error, output) on failure
25+
#
26+
# 3. Response Header Access (via Type dispatch)
27+
# get_response_header(resp::BackendResponse, name::AbstractString,
28+
# defaultval::AbstractString) -> String
29+
#
30+
# Retrieves a header value from the backend-specific response object.
31+
# Case-insensitive header name matching required.
32+
#
33+
# 4. Error Information Extraction (via Type dispatch)
34+
# get_message(error::BackendError) -> String
35+
# get_response(error::BackendError) -> Union{Nothing, BackendResponse}
36+
# get_status(error::BackendError) -> Int
37+
#
38+
# Extracts error information from backend-specific error objects.
39+
# - get_message: Human-readable error description
40+
# - get_response: Associated response object (if available)
41+
# - get_status: HTTP status code (0 if no response available)
42+
#
43+
# 5. Response Property Access (via Type dispatch, optional)
44+
# get_response_property(raw::BackendResponse, name::Symbol) -> Any
45+
#
46+
# Provides access to backend-specific response properties.
47+
# Only needed if backend response type doesn't directly support
48+
# required properties (status, message, headers).
49+
#
50+
# =============================================================================
51+
# Available Backend Implementations
52+
# =============================================================================
53+
#
54+
# :downloads (OpenAPI.HTTPLib.Downloads) - Uses Downloads.jl from Julia stdlib
55+
# :http (OpenAPI.HTTPLib.HTTP) - Uses HTTP.jl from JuliaWeb ecosystem
56+
#
57+
# =============================================================================
58+
59+
include("juliaweb_http.jl")
60+
include("julialang_downloads.jl")

0 commit comments

Comments
 (0)