Skip to content

Commit 8ce0218

Browse files
krynjuclaudetanmaykm
authored
Support HTTP.jl 2.0 alongside 1.x (#96)
* Support HTTP.jl 2.0 alongside 1.x Add compatibility for HTTP.jl 2.0's reworked public API while keeping 1.x working. A load-time feature flag (_HTTP_V2) and small shims in the HTTP.jl client backend bridge the differences: - statustext: HTTP.Messages removed -> fall back to Response.reason - header lookup: HTTP.Header / HTTP.header(::Vector) removed -> plain case-insensitive lookup over the request headers - content_type returns a String on 2.0 (was a Pair on 1.x) - TimeoutError.readtimeout -> .timeout_ns; ConnectError.error -> .cause - timeout milliseconds forced to Integer so the reason string still matches the `\d+ milliseconds` regex in is_longpoll_timeout - read(io, n) removed -> readbytes!; drop the verbose kwarg unsupported by 2.0 HTTP.open; map readtimeout -> read_idle_timeout on 2.0 server.jl: HTTP.Forms.Multipart -> HTTP.Multipart (same type on 1.x). Tests/fixtures updated for both versions, and the timeout server fixture uses HTTP.listen! on 2.0 (serve!(...; stream=true) on 1.x). CI now tests both HTTP majors via an http matrix dimension; a step pins the HTTP compat per cell. Julia 1.6 + HTTP 2 is excluded (HTTP 2.0 requires Julia >= 1.10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix CI HTTP pin: scope sed to [compat] section The unscoped sed matched the HTTP UUID line in [deps] too, rewriting it to a bare version and breaking resolution (Malformed UUID string). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Detect HTTP major via pkgversion instead of submodule sniffing Use pkgversion(HTTP) >= v"2" (guarded for Julia < 1.9, where only HTTP 1.x can be installed) rather than checking for the internal HTTP.Messages submodule. Explicit about intent and not coupled to an internal name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Normalize HTTP 2.x DNSError reason to match 1.x HTTP.jl 2.x introduced a dedicated HTTP.DNSError type for name-resolution failures. It stringifies as "HTTP.DNSError(...)", whereas 1.x wrapped a Sockets.DNSError in a ConnectError that stringified as "DNSError: ...". This broke the client's reason-string contract (tests assert the reason starts with "DNSError"). Add a version-guarded _http_error_message specialization that normalizes the 2.x DNSError into "DNSError: could not resolve host "<host>"". Elided under 1.x via @static if _HTTP_V2, so 1.x behavior is unchanged. * Fix generated server code for HTTP 2: HTTP.headers(req) removed HTTP.jl 2.x removed the 1-arg HTTP.headers(req) form that returns all headers; only the 2-arg lookup headers(req, key) remains. Generated server read-handlers used Dict{String,String}(HTTP.headers(req)) to collect header params, so any endpoint with a header param (e.g. delete_pet's api_key) threw a MethodError -> HTTP 500 under HTTP 2. Use req.headers, which works on both 1.x and 2.x. NOTE: this is generated code. The matching change belongs upstream in the openapi-generator Julia server template; these checked-in fixtures are hand-patched to match until that lands. * Make allany verbose tests HTTP 2 compatible Two HTTP.jl 2.x behavior changes broke test_debug: 1. verbose=true output: 1.x (and the curl backend) write the raw exchange to stderr; 2.x writes a '[http] ... via h1' format to stdout. The test captured stderr and asserted 'HTTP/1.1 200 OK', so under 2.x the pipe got no data and readavailable() blocked forever (hung the whole suite). Capture the right stream per version, assert the matching format, and close the pipe write-end before reading so an empty stream yields EOF instead of hanging. 2. response body framing: 1.x servers chunk the body ('27\r\n{json}\r\n0...'), 2.x sends it unframed with Content-Length. The custom-verbose test parsed the JSON via split(str,'\n')[2] (assuming a chunk-size line) -> BoundsError under 2.x. Extract the JSON object by brace span instead, framing-agnostic. * Resolve server header params case-insensitively for HTTP.jl 2.x HTTP.jl 2.x canonicalizes incoming request header field names (e.g. "api_key" arrives as "Api_key"), whereas 1.x preserves the sent case. The server's get_param did a case-sensitive Dict lookup, so header params silently resolved to nothing under HTTP 2.x — a required header param would 400, an optional one would be dropped. Add a case-insensitive fallback in get_param (src/server.jl) that fires only when the exact lookup misses. Header field names are case- insensitive per RFC, so this is spec-correct and version-agnostic; it also fixes already-generated servers without regeneration. Query/path param dicts are not canonicalized, so exact match always wins for them. Add regression tests in test/param_deserialize.jl covering canonicalized key resolution, exact-match precedence, missing/required params, and the to_param end-to-end path. * Raise wait_server timeout to 90s for slow CI server startup Server processes launched via run_server use --pkgimages=no, forcing a full recompile on each launch. Under HTTP.jl 2.x (slower precompile) on cold CI runners this regularly exceeds the 20s wait, so the server tests time out and get skipped, and timed-out-but-still-launching processes orphan the port for the next testset. Bump the wait to 90s. * bump patch version --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: tan <tanmaykm@gmail.com>
1 parent e0af53f commit 8ce0218

15 files changed

Lines changed: 681 additions & 41 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
pull_request:
77
jobs:
88
test:
9-
name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }}
9+
name: Julia ${{ matrix.version }} - HTTP ${{ matrix.http }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }}
1010
runs-on: ${{ matrix.os }}
1111
strategy:
1212
fail-fast: false
@@ -15,10 +15,17 @@ jobs:
1515
- '1.6'
1616
- '1' # automatically expands to the latest stable 1.x release of Julia
1717
- 'nightly'
18+
http:
19+
- '1'
20+
- '2'
1821
os:
1922
- ubuntu-latest
2023
arch:
2124
- x64
25+
exclude:
26+
# HTTP.jl 2.0 requires Julia >= 1.10
27+
- version: '1.6'
28+
http: '2'
2229
steps:
2330
- uses: actions/checkout@v4
2431
- uses: julia-actions/setup-julia@v1
@@ -35,6 +42,8 @@ jobs:
3542
${{ runner.os }}-test-${{ env.cache-name }}-
3643
${{ runner.os }}-test-
3744
${{ runner.os }}-
45+
- name: Constrain HTTP.jl to v${{ matrix.http }}
46+
run: sed -i -E '/^\[compat\]/,/^\[/{s/^HTTP = .*/HTTP = "${{ matrix.http }}"/}' Project.toml
3847
- uses: julia-actions/julia-buildpkg@v1
3948
- uses: julia-actions/julia-runtest@v1
4049
- uses: julia-actions/julia-processcoverage@v1

Project.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ keywords = ["Swagger", "OpenAPI", "REST"]
44
license = "MIT"
55
desc = "OpenAPI server and client helper for Julia"
66
authors = ["JuliaHub Inc."]
7-
version = "0.2.2"
7+
version = "0.2.3"
88

99
[deps]
1010
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
@@ -21,7 +21,7 @@ p7zip_jll = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0"
2121

2222
[compat]
2323
Downloads = "1"
24-
HTTP = "1"
24+
HTTP = "1, 2"
2525
JSON = "0.20, 0.21, 1"
2626
LibCURL = "0.6, 1"
2727
MIMEs = "0.1, 1"

src/client/httplibs/juliaweb_http.jl

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,60 @@
2222
# - HTTPRequestError <: AbstractHTTPLibError
2323
# =============================================================================
2424

25+
# HTTP.jl 2.0 reworked much of the public API; we support both 1.x and 2.x and
26+
# branch on the major version at load time. `pkgversion` exists from Julia 1.9;
27+
# on older Julia only HTTP 1.x can be installed (HTTP 2.0 needs Julia >= 1.10).
28+
const _HTTP_V2 = isdefined(Base, :pkgversion) && something(pkgversion(HTTP), v"1") >= v"2"
29+
30+
# Status reason text: `HTTP.Messages.statustext` on 1.x, `Response.reason` on 2.x.
31+
function _http_statustext(raw::HTTP.Response)
32+
if isdefined(HTTP, :Messages)
33+
return HTTP.Messages.statustext(raw.status)
34+
elseif hasproperty(raw, :reason) && !isempty(raw.reason)
35+
return raw.reason
36+
else
37+
return string(raw.status)
38+
end
39+
end
40+
41+
# Case-insensitive header lookup over the request header collection (a Dict).
42+
# Avoids `HTTP.Header`/`HTTP.header(::Vector, ...)`, both removed in 2.0.
43+
function _header_value_ci(headers, key::AbstractString)
44+
lk = lowercase(key)
45+
for (k, v) in headers
46+
lowercase(String(k)) == lk && return String(v)
47+
end
48+
return nothing
49+
end
50+
51+
# Form content type: `HTTP.content_type` returns a `Pair` on 1.x, a `String` on 2.x.
52+
_http_form_content_type(body) = (ct = HTTP.content_type(body); ct isa Pair ? ct[2] : ct)
53+
54+
# Timeout budget in ms: `TimeoutError.readtimeout` (s) on 1.x, `.timeout_ns` on 2.x.
55+
# Keep this an integer — the reason string is later matched against `\d+ milliseconds`.
56+
_http_timeout_ms(e::HTTP.TimeoutError) =
57+
hasproperty(e, :readtimeout) ? e.readtimeout * 1000 : e.timeout_ns ÷ 1_000_000
58+
59+
# Underlying cause of a connect error: `.error` on 1.x, `.cause` on 2.x.
60+
_http_connect_cause(e::HTTP.ConnectError) = hasproperty(e, :cause) ? e.cause : e.error
61+
62+
# Message for a generic HTTP error. HTTP 2.x introduces a dedicated `HTTP.DNSError`
63+
# (a subtype of HTTP.HTTPError) for name-resolution failures, where 1.x instead wraps a
64+
# `Sockets.DNSError` inside a ConnectError. The two stringify differently
65+
# (`"HTTP.DNSError(...)"` vs `"DNSError: ..."`); normalize the 2.x form so the surfaced
66+
# reason starts with "DNSError" on both, keeping the message stable across versions.
67+
_http_error_message(error::HTTP.HTTPError) = string(error)
68+
@static if _HTTP_V2
69+
_http_error_message(error::HTTP.DNSError) = "DNSError: could not resolve host \"$(error.hostname)\""
70+
end
71+
72+
# Inactivity-timeout keyword: 1.x calls it `readtimeout`; 2.0 renamed it to
73+
# `read_idle_timeout` (`readtimeout` still works but emits a deprecation warning).
74+
_http_read_timeout_kw(timeout) = _HTTP_V2 ? (; read_idle_timeout=timeout) : (; readtimeout=timeout)
75+
2576
function get_response_property(raw::HTTP.Response, name::Symbol)
2677
if name === :message
27-
return HTTP.Messages.statustext(raw.status)
78+
return _http_statustext(raw)
2879
else
2980
return getproperty(raw, name)
3081
end
@@ -40,26 +91,27 @@ struct HTTPRequestError <: AbstractHTTPLibError
4091
response::Union{Nothing,HTTP.Response}
4192

4293
function HTTPRequestError(error::HTTP.TimeoutError, bytesread::Int, response::Union{Nothing,HTTP.Response})
43-
message = "Operation timed out after $(error.readtimeout*1000) milliseconds with $(bytesread) bytes received"
94+
message = "Operation timed out after $(_http_timeout_ms(error)) milliseconds with $(bytesread) bytes received"
4495
new(message, error, response)
4596
end
4697

4798
function HTTPRequestError(error::HTTP.TimeoutError, response::Union{Nothing,HTTP.Response})
48-
message = "Operation timed out after $(error.readtimeout*1000) milliseconds"
99+
message = "Operation timed out after $(_http_timeout_ms(error)) milliseconds"
49100
new(message, error, response)
50101
end
51102

52103
function HTTPRequestError(error::HTTP.ConnectError)
53-
message = if isa(error.error, CapturedException)
54-
string(error.error.ex)
104+
cause = _http_connect_cause(error)
105+
message = if isa(cause, CapturedException)
106+
string(cause.ex)
55107
else
56-
string(error.error)
108+
string(cause)
57109
end
58110
new(message, error, nothing)
59111
end
60112

61113
function HTTPRequestError(error::HTTP.HTTPError)
62-
message = string(error)
114+
message = _http_error_message(error)
63115
new(message, error, nothing)
64116
end
65117
end
@@ -99,8 +151,7 @@ function prep_args(::Val{:http}, ctx::Ctx)
99151
headers = ctx.header
100152
body = nothing
101153

102-
header_pairs = [convert(HTTP.Header, p) for p in headers]
103-
content_type_set = HTTP.header(header_pairs, "Content-Type", nothing)
154+
content_type_set = _header_value_ci(headers, "Content-Type")
104155
if !isnothing(content_type_set)
105156
content_type_set = lowercase(content_type_set)
106157
end
@@ -146,7 +197,7 @@ function prep_args(::Val{:http}, ctx::Ctx)
146197
body_dict[_k] = _v
147198
end
148199
body = HTTP.Form(body_dict)
149-
headers["Content-Type"] = content_type_set = HTTP.content_type(body)[2]
200+
headers["Content-Type"] = content_type_set = _http_form_content_type(body)
150201
end
151202

152203
if ctx.body !== nothing
@@ -213,7 +264,7 @@ end
213264

214265
function _http_request(ctx, method, url, headers, body, timeout, bytesread, captured_response, output)
215266
captured_response[] = http_response = HTTP.request(method, url, headers, body;
216-
readtimeout=timeout,
267+
_http_read_timeout_kw(timeout)...,
217268
connect_timeout=timeout ÷ 2,
218269
retry=false,
219270
redirect=true,
@@ -229,22 +280,30 @@ end
229280
function _http_streaming_request(ctx, method, url, headers, body, timeout, bytesread, captured_response, output, stream_to)
230281
http_response = nothing
231282

283+
# HTTP.jl 2.0's `HTTP.open` does not accept a `verbose` keyword; only pass it on 1.x.
284+
open_kwargs = merge(_http_read_timeout_kw(timeout),
285+
(; connect_timeout=timeout ÷ 2,
286+
retry=false,
287+
redirect=true,
288+
status_exception=false))
289+
if !_HTTP_V2
290+
open_kwargs = merge(open_kwargs, (; verbose=get(ctx.client.clntoptions, :verbose, false)))
291+
end
292+
232293
@sync begin
233294
@async begin
234295
try
235-
HTTP.open(method, url, headers;
236-
readtimeout=timeout,
237-
connect_timeout=timeout ÷ 2,
238-
retry=false,
239-
redirect=true,
240-
status_exception=false,
241-
verbose=get(ctx.client.clntoptions, :verbose, false)) do io
296+
HTTP.open(method, url, headers; open_kwargs...) do io
242297
write(io, body)
243298
captured_response[] = http_response = startread(io)
244299
try
300+
# `read(io, n)` is unavailable on 2.0 streams; read into a reusable
301+
# buffer with `readbytes!`, which works on both 1.x and 2.x.
302+
buf = Vector{UInt8}(undef, 8192) # 8KB chunks
245303
while !eof(io)
246-
data = read(io, 8192) # Read 8KB chunks
247-
bytesread[] += write(output, data)
304+
n = readbytes!(io, buf)
305+
n == 0 && break
306+
bytesread[] += write(output, view(buf, 1:n))
248307
end
249308
finally
250309
close(output)

src/server.jl

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,27 @@ end
6161

6262
function get_param(source::Dict, name::String, required::Bool)
6363
val = get(source, name, nothing)
64+
if isnothing(val)
65+
# HTTP header field names are case-insensitive, and some HTTP.jl versions
66+
# canonicalize incoming request header names (e.g. "api_key" -> "Api_key").
67+
# Fall back to a case-insensitive match so header params resolve regardless
68+
# of the HTTP.jl version. The exact lookup above wins first, so query/path
69+
# params (whose dicts are not canonicalized) are unaffected.
70+
lname = lowercase(name)
71+
for (k, v) in source
72+
if lowercase(k) == lname
73+
val = v
74+
break
75+
end
76+
end
77+
end
6478
if required && isnothing(val)
6579
throw(ValidationException("required parameter \"$name\" missing"))
6680
end
6781
return val
6882
end
6983

70-
function get_param(source::Vector{HTTP.Forms.Multipart}, name::String, required::Bool)
84+
function get_param(source::Vector{HTTP.Multipart}, name::String, required::Bool)
7185
ind = findfirst(x -> x.name == name, source)
7286
if required && isnothing(ind)
7387
throw(ValidationException("required parameter \"$name\" missing"))
@@ -140,7 +154,7 @@ function to_param(T, source::Dict, name::String; required::Bool=false, collectio
140154
end
141155
end
142156

143-
function to_param(T, source::Vector{HTTP.Forms.Multipart}, name::String; required::Bool=false, collection_format::Union{String,Nothing}=",", multipart::Bool=false, isfile::Bool=false)
157+
function to_param(T, source::Vector{HTTP.Multipart}, name::String; required::Bool=false, collection_format::Union{String,Nothing}=",", multipart::Bool=false, isfile::Bool=false)
144158
param = get_param(source, name, required)
145159
if param === nothing
146160
return nothing

test/client/allany/runtests.jl

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,26 @@ function test_debug(httplib::Symbol)
8686
)
8787
api = M.DefaultApi(client)
8888

89+
# HTTP.jl 2.x routes `verbose=true` output to stdout in a "[http] ... via h1"
90+
# format, whereas 1.x (and the Downloads/curl backend) write the raw exchange to
91+
# stderr. Capture the right stream and assert on the matching format per version.
92+
use_stdout = httplib === :http && OpenAPI.Clients._HTTP_V2
8993
pipe = Pipe()
90-
redirect_stderr(pipe) do
94+
redirect = use_stdout ? redirect_stdout : redirect_stderr
95+
redirect(pipe) do
9196
pet = M.AnyOfMappedPets(mapped_cat)
9297
api_return, http_resp = echo_anyof_mapped_pets_post(api, pet)
9398
@test pet_equals(api_return, pet)
9499
end
95-
out_str = String(readavailable(pipe))
96-
@test occursin("HTTP/1.1 200 OK", out_str)
100+
# Close the write end so the read sees EOF; without this, `readavailable` blocks
101+
# forever when nothing was written to the captured stream.
102+
close(pipe.in)
103+
out_str = read(pipe, String)
104+
if use_stdout
105+
@test occursin("[http]", out_str) && occursin("200", out_str)
106+
else
107+
@test occursin("HTTP/1.1 200 OK", out_str)
108+
end
97109
end
98110

99111
if httplib === :downloads
@@ -150,7 +162,10 @@ function test_debug(httplib::Symbol)
150162
write(iob, message)
151163
end
152164
data_in_str = String(take!(iob))
153-
data_in_str = strip(split(data_in_str, "\n")[2])
165+
# The curl backend reports the raw response body. HTTP.jl 1.x servers send it
166+
# chunk-framed ("27\r\n{json}\r\n0\r\n\r\n"); 2.x sends an unframed body with a
167+
# Content-Length. Extract the JSON object itself so the parse works either way.
168+
data_in_str = data_in_str[findfirst('{', data_in_str):findlast('}', data_in_str)]
154169
data_in_json = JSON.parse(data_in_str)
155170
@test data_in_json["pet_type"] == "cat"
156171
@test data_in_json["hunts"] == true

test/client/utilstests.jl

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,20 @@ end
6868
function test_longpoll_exception_check()
6969
resp = OpenAPI.Clients.Downloads.Response("http", "http://localhost", 200, "no error", [])
7070

71+
# HTTP.jl 1.x and 2.0 have different `TimeoutError`/`ConnectError` constructors.
72+
mk_timeout_err() = OpenAPI.Clients._HTTP_V2 ? HTTP.TimeoutError("read_idle", 20_000_000) : HTTP.TimeoutError(20)
73+
mk_connect_err() = HTTP.ConnectError("http://localhost", ErrorException("dns error"))
74+
7175
not_longpoll_timeouts = [
7276
OpenAPI.Clients.Downloads.RequestError("http://localhost", 500, "not timeout error", resp),
73-
OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), 20, nothing),
74-
OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), nothing),
75-
OpenAPI.Clients.HTTPRequestError(HTTP.ConnectError("http://localhost", "dns error")),
77+
OpenAPI.Clients.HTTPRequestError(mk_timeout_err(), 20, nothing),
78+
OpenAPI.Clients.HTTPRequestError(mk_timeout_err(), nothing),
79+
OpenAPI.Clients.HTTPRequestError(mk_connect_err()),
7680
]
7781

7882
longpoll_timeouts = [
7983
OpenAPI.Clients.Downloads.RequestError("http://localhost", 200, "Operation timed out after 300 milliseconds with 0 bytes received", resp), # timeout error
80-
OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), 20, HTTP.Response(200, "hello")),
84+
OpenAPI.Clients.HTTPRequestError(mk_timeout_err(), 20, HTTP.Response(200, "hello")),
8185
]
8286

8387
@test OpenAPI.Clients.is_longpoll_timeout("not an exception") == false

test/deep_object/deep_server.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ using .DeepServer: register, FindPetsByStatus200Response
66

77
const server = Ref{Any}(nothing)
88

9-
function find_pets_by_status(::HTTP.Messages.Request, param::DeepServer.FindPetsByStatusStatusParameter)
9+
function find_pets_by_status(::HTTP.Request, param::DeepServer.FindPetsByStatusStatusParameter)
1010
return FindPetsByStatus200Response(param)
1111
end
1212

0 commit comments

Comments
 (0)