diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17d7e3c..09f331b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: test: - name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + name: Julia ${{ matrix.version }} - HTTP ${{ matrix.http }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -15,10 +15,17 @@ jobs: - '1.6' - '1' # automatically expands to the latest stable 1.x release of Julia - 'nightly' + http: + - '1' + - '2' os: - ubuntu-latest arch: - x64 + exclude: + # HTTP.jl 2.0 requires Julia >= 1.10 + - version: '1.6' + http: '2' steps: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v1 @@ -35,6 +42,8 @@ jobs: ${{ runner.os }}-test-${{ env.cache-name }}- ${{ runner.os }}-test- ${{ runner.os }}- + - name: Constrain HTTP.jl to v${{ matrix.http }} + run: sed -i -E '/^\[compat\]/,/^\[/{s/^HTTP = .*/HTTP = "${{ matrix.http }}"/}' Project.toml - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - uses: julia-actions/julia-processcoverage@v1 diff --git a/Project.toml b/Project.toml index 2d7e47b..91767da 100644 --- a/Project.toml +++ b/Project.toml @@ -4,7 +4,7 @@ keywords = ["Swagger", "OpenAPI", "REST"] license = "MIT" desc = "OpenAPI server and client helper for Julia" authors = ["JuliaHub Inc."] -version = "0.2.2" +version = "0.3.0" [deps] Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" @@ -21,7 +21,7 @@ p7zip_jll = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" [compat] Downloads = "1" -HTTP = "1" +HTTP = "1, 2" JSON = "0.20, 0.21, 1" LibCURL = "0.6, 1" MIMEs = "0.1, 1" diff --git a/src/client/httplibs/juliaweb_http.jl b/src/client/httplibs/juliaweb_http.jl index a856b75..57a5759 100644 --- a/src/client/httplibs/juliaweb_http.jl +++ b/src/client/httplibs/juliaweb_http.jl @@ -22,9 +22,60 @@ # - HTTPRequestError <: AbstractHTTPLibError # ============================================================================= +# HTTP.jl 2.0 reworked much of the public API; we support both 1.x and 2.x and +# branch on the major version at load time. `pkgversion` exists from Julia 1.9; +# on older Julia only HTTP 1.x can be installed (HTTP 2.0 needs Julia >= 1.10). +const _HTTP_V2 = isdefined(Base, :pkgversion) && something(pkgversion(HTTP), v"1") >= v"2" + +# Status reason text: `HTTP.Messages.statustext` on 1.x, `Response.reason` on 2.x. +function _http_statustext(raw::HTTP.Response) + if isdefined(HTTP, :Messages) + return HTTP.Messages.statustext(raw.status) + elseif hasproperty(raw, :reason) && !isempty(raw.reason) + return raw.reason + else + return string(raw.status) + end +end + +# Case-insensitive header lookup over the request header collection (a Dict). +# Avoids `HTTP.Header`/`HTTP.header(::Vector, ...)`, both removed in 2.0. +function _header_value_ci(headers, key::AbstractString) + lk = lowercase(key) + for (k, v) in headers + lowercase(String(k)) == lk && return String(v) + end + return nothing +end + +# Form content type: `HTTP.content_type` returns a `Pair` on 1.x, a `String` on 2.x. +_http_form_content_type(body) = (ct = HTTP.content_type(body); ct isa Pair ? ct[2] : ct) + +# Timeout budget in ms: `TimeoutError.readtimeout` (s) on 1.x, `.timeout_ns` on 2.x. +# Keep this an integer — the reason string is later matched against `\d+ milliseconds`. +_http_timeout_ms(e::HTTP.TimeoutError) = + hasproperty(e, :readtimeout) ? e.readtimeout * 1000 : e.timeout_ns ÷ 1_000_000 + +# Underlying cause of a connect error: `.error` on 1.x, `.cause` on 2.x. +_http_connect_cause(e::HTTP.ConnectError) = hasproperty(e, :cause) ? e.cause : e.error + +# Message for a generic HTTP error. HTTP 2.x introduces a dedicated `HTTP.DNSError` +# (a subtype of HTTP.HTTPError) for name-resolution failures, where 1.x instead wraps a +# `Sockets.DNSError` inside a ConnectError. The two stringify differently +# (`"HTTP.DNSError(...)"` vs `"DNSError: ..."`); normalize the 2.x form so the surfaced +# reason starts with "DNSError" on both, keeping the message stable across versions. +_http_error_message(error::HTTP.HTTPError) = string(error) +@static if _HTTP_V2 + _http_error_message(error::HTTP.DNSError) = "DNSError: could not resolve host \"$(error.hostname)\"" +end + +# Inactivity-timeout keyword: 1.x calls it `readtimeout`; 2.0 renamed it to +# `read_idle_timeout` (`readtimeout` still works but emits a deprecation warning). +_http_read_timeout_kw(timeout) = _HTTP_V2 ? (; read_idle_timeout=timeout) : (; readtimeout=timeout) + function get_response_property(raw::HTTP.Response, name::Symbol) if name === :message - return HTTP.Messages.statustext(raw.status) + return _http_statustext(raw) else return getproperty(raw, name) end @@ -40,26 +91,27 @@ struct HTTPRequestError <: AbstractHTTPLibError response::Union{Nothing,HTTP.Response} function HTTPRequestError(error::HTTP.TimeoutError, bytesread::Int, response::Union{Nothing,HTTP.Response}) - message = "Operation timed out after $(error.readtimeout*1000) milliseconds with $(bytesread) bytes received" + message = "Operation timed out after $(_http_timeout_ms(error)) milliseconds with $(bytesread) bytes received" new(message, error, response) end function HTTPRequestError(error::HTTP.TimeoutError, response::Union{Nothing,HTTP.Response}) - message = "Operation timed out after $(error.readtimeout*1000) milliseconds" + message = "Operation timed out after $(_http_timeout_ms(error)) milliseconds" new(message, error, response) end function HTTPRequestError(error::HTTP.ConnectError) - message = if isa(error.error, CapturedException) - string(error.error.ex) + cause = _http_connect_cause(error) + message = if isa(cause, CapturedException) + string(cause.ex) else - string(error.error) + string(cause) end new(message, error, nothing) end function HTTPRequestError(error::HTTP.HTTPError) - message = string(error) + message = _http_error_message(error) new(message, error, nothing) end end @@ -99,8 +151,7 @@ function prep_args(::Val{:http}, ctx::Ctx) headers = ctx.header body = nothing - header_pairs = [convert(HTTP.Header, p) for p in headers] - content_type_set = HTTP.header(header_pairs, "Content-Type", nothing) + content_type_set = _header_value_ci(headers, "Content-Type") if !isnothing(content_type_set) content_type_set = lowercase(content_type_set) end @@ -146,7 +197,7 @@ function prep_args(::Val{:http}, ctx::Ctx) body_dict[_k] = _v end body = HTTP.Form(body_dict) - headers["Content-Type"] = content_type_set = HTTP.content_type(body)[2] + headers["Content-Type"] = content_type_set = _http_form_content_type(body) end if ctx.body !== nothing @@ -213,7 +264,7 @@ end function _http_request(ctx, method, url, headers, body, timeout, bytesread, captured_response, output) captured_response[] = http_response = HTTP.request(method, url, headers, body; - readtimeout=timeout, + _http_read_timeout_kw(timeout)..., connect_timeout=timeout ÷ 2, retry=false, redirect=true, @@ -229,22 +280,30 @@ end function _http_streaming_request(ctx, method, url, headers, body, timeout, bytesread, captured_response, output, stream_to) http_response = nothing + # HTTP.jl 2.0's `HTTP.open` does not accept a `verbose` keyword; only pass it on 1.x. + open_kwargs = merge(_http_read_timeout_kw(timeout), + (; connect_timeout=timeout ÷ 2, + retry=false, + redirect=true, + status_exception=false)) + if !_HTTP_V2 + open_kwargs = merge(open_kwargs, (; verbose=get(ctx.client.clntoptions, :verbose, false))) + end + @sync begin @async begin try - HTTP.open(method, url, headers; - readtimeout=timeout, - connect_timeout=timeout ÷ 2, - retry=false, - redirect=true, - status_exception=false, - verbose=get(ctx.client.clntoptions, :verbose, false)) do io + HTTP.open(method, url, headers; open_kwargs...) do io write(io, body) captured_response[] = http_response = startread(io) try + # `read(io, n)` is unavailable on 2.0 streams; read into a reusable + # buffer with `readbytes!`, which works on both 1.x and 2.x. + buf = Vector{UInt8}(undef, 8192) # 8KB chunks while !eof(io) - data = read(io, 8192) # Read 8KB chunks - bytesread[] += write(output, data) + n = readbytes!(io, buf) + n == 0 && break + bytesread[] += write(output, view(buf, 1:n)) end finally close(output) diff --git a/src/server.jl b/src/server.jl index d7d1a74..4029555 100644 --- a/src/server.jl +++ b/src/server.jl @@ -61,13 +61,27 @@ end function get_param(source::Dict, name::String, required::Bool) val = get(source, name, nothing) + if isnothing(val) + # HTTP header field names are case-insensitive, and some HTTP.jl versions + # canonicalize incoming request header names (e.g. "api_key" -> "Api_key"). + # Fall back to a case-insensitive match so header params resolve regardless + # of the HTTP.jl version. The exact lookup above wins first, so query/path + # params (whose dicts are not canonicalized) are unaffected. + lname = lowercase(name) + for (k, v) in source + if lowercase(k) == lname + val = v + break + end + end + end if required && isnothing(val) throw(ValidationException("required parameter \"$name\" missing")) end return val end -function get_param(source::Vector{HTTP.Forms.Multipart}, name::String, required::Bool) +function get_param(source::Vector{HTTP.Multipart}, name::String, required::Bool) ind = findfirst(x -> x.name == name, source) if required && isnothing(ind) throw(ValidationException("required parameter \"$name\" missing")) @@ -140,7 +154,7 @@ function to_param(T, source::Dict, name::String; required::Bool=false, collectio end end -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) +function to_param(T, source::Vector{HTTP.Multipart}, name::String; required::Bool=false, collection_format::Union{String,Nothing}=",", multipart::Bool=false, isfile::Bool=false) param = get_param(source, name, required) if param === nothing return nothing diff --git a/test/client/allany/runtests.jl b/test/client/allany/runtests.jl index 374d257..bd8cd88 100644 --- a/test/client/allany/runtests.jl +++ b/test/client/allany/runtests.jl @@ -86,14 +86,26 @@ function test_debug(httplib::Symbol) ) api = M.DefaultApi(client) + # HTTP.jl 2.x routes `verbose=true` output to stdout in a "[http] ... via h1" + # format, whereas 1.x (and the Downloads/curl backend) write the raw exchange to + # stderr. Capture the right stream and assert on the matching format per version. + use_stdout = httplib === :http && OpenAPI.Clients._HTTP_V2 pipe = Pipe() - redirect_stderr(pipe) do + redirect = use_stdout ? redirect_stdout : redirect_stderr + redirect(pipe) do pet = M.AnyOfMappedPets(mapped_cat) api_return, http_resp = echo_anyof_mapped_pets_post(api, pet) @test pet_equals(api_return, pet) end - out_str = String(readavailable(pipe)) - @test occursin("HTTP/1.1 200 OK", out_str) + # Close the write end so the read sees EOF; without this, `readavailable` blocks + # forever when nothing was written to the captured stream. + close(pipe.in) + out_str = read(pipe, String) + if use_stdout + @test occursin("[http]", out_str) && occursin("200", out_str) + else + @test occursin("HTTP/1.1 200 OK", out_str) + end end if httplib === :downloads @@ -150,7 +162,10 @@ function test_debug(httplib::Symbol) write(iob, message) end data_in_str = String(take!(iob)) - data_in_str = strip(split(data_in_str, "\n")[2]) + # The curl backend reports the raw response body. HTTP.jl 1.x servers send it + # chunk-framed ("27\r\n{json}\r\n0\r\n\r\n"); 2.x sends an unframed body with a + # Content-Length. Extract the JSON object itself so the parse works either way. + data_in_str = data_in_str[findfirst('{', data_in_str):findlast('}', data_in_str)] data_in_json = JSON.parse(data_in_str) @test data_in_json["pet_type"] == "cat" @test data_in_json["hunts"] == true diff --git a/test/client/utilstests.jl b/test/client/utilstests.jl index af8f108..3c065ae 100644 --- a/test/client/utilstests.jl +++ b/test/client/utilstests.jl @@ -68,16 +68,20 @@ end function test_longpoll_exception_check() resp = OpenAPI.Clients.Downloads.Response("http", "http://localhost", 200, "no error", []) + # HTTP.jl 1.x and 2.0 have different `TimeoutError`/`ConnectError` constructors. + mk_timeout_err() = OpenAPI.Clients._HTTP_V2 ? HTTP.TimeoutError("read_idle", 20_000_000) : HTTP.TimeoutError(20) + mk_connect_err() = HTTP.ConnectError("http://localhost", ErrorException("dns error")) + not_longpoll_timeouts = [ OpenAPI.Clients.Downloads.RequestError("http://localhost", 500, "not timeout error", resp), - OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), 20, nothing), - OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), nothing), - OpenAPI.Clients.HTTPRequestError(HTTP.ConnectError("http://localhost", "dns error")), + OpenAPI.Clients.HTTPRequestError(mk_timeout_err(), 20, nothing), + OpenAPI.Clients.HTTPRequestError(mk_timeout_err(), nothing), + OpenAPI.Clients.HTTPRequestError(mk_connect_err()), ] longpoll_timeouts = [ OpenAPI.Clients.Downloads.RequestError("http://localhost", 200, "Operation timed out after 300 milliseconds with 0 bytes received", resp), # timeout error - OpenAPI.Clients.HTTPRequestError(HTTP.TimeoutError(20), 20, HTTP.Response(200, "hello")), + OpenAPI.Clients.HTTPRequestError(mk_timeout_err(), 20, HTTP.Response(200, "hello")), ] @test OpenAPI.Clients.is_longpoll_timeout("not an exception") == false diff --git a/test/deep_object/deep_server.jl b/test/deep_object/deep_server.jl index 8d56254..2c02e8d 100644 --- a/test/deep_object/deep_server.jl +++ b/test/deep_object/deep_server.jl @@ -6,7 +6,7 @@ using .DeepServer: register, FindPetsByStatus200Response const server = Ref{Any}(nothing) -function find_pets_by_status(::HTTP.Messages.Request, param::DeepServer.FindPetsByStatusStatusParameter) +function find_pets_by_status(::HTTP.Request, param::DeepServer.FindPetsByStatusStatusParameter) return FindPetsByStatus200Response(param) end diff --git a/test/opa/OPAServer/src/apis/api_DataAPIApi.jl b/test/opa/OPAServer/src/apis/api_DataAPIApi.jl new file mode 100644 index 0000000..0eb8aa3 --- /dev/null +++ b/test/opa/OPAServer/src/apis/api_DataAPIApi.jl @@ -0,0 +1,205 @@ +# This file was generated by the Julia OpenAPI Code Generator +# Do not modify this file directly. Modify the OpenAPI specification instead. + + +function delete_document_read(handler) + function delete_document_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["path"] = OpenAPI.Servers.to_param(String, path_params, "path", required=true, ) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function delete_document_validate(handler) + function delete_document_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + return handler(req) + end +end + +function delete_document_invoke(impl; post_invoke=nothing) + function delete_document_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.delete_document(req::HTTP.Request, openapi_params["path"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function get_document_read(handler) + function get_document_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["path"] = OpenAPI.Servers.to_param(String, path_params, "path", required=true, ) + query_params = HTTP.queryparams(URIs.URI(req.target)) + openapi_params["input"] = OpenAPI.Servers.to_param(Dict{String, Any}, query_params, "input", ) + openapi_params["pretty"] = OpenAPI.Servers.to_param(Bool, query_params, "pretty", ) + openapi_params["provenance"] = OpenAPI.Servers.to_param(Bool, query_params, "provenance", ) + openapi_params["explain"] = OpenAPI.Servers.to_param(String, query_params, "explain", ) + openapi_params["metrics"] = OpenAPI.Servers.to_param(Bool, query_params, "metrics", ) + openapi_params["instrument"] = OpenAPI.Servers.to_param(Bool, query_params, "instrument", ) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function get_document_validate(handler) + function get_document_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + return handler(req) + end +end + +function get_document_invoke(impl; post_invoke=nothing) + function get_document_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.get_document(req::HTTP.Request, openapi_params["path"]; input=get(openapi_params, "input", nothing), pretty=get(openapi_params, "pretty", nothing), provenance=get(openapi_params, "provenance", nothing), explain=get(openapi_params, "explain", nothing), metrics=get(openapi_params, "metrics", nothing), instrument=get(openapi_params, "instrument", nothing),) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function get_document_with_path_read(handler) + function get_document_with_path_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["path"] = OpenAPI.Servers.to_param(String, path_params, "path", required=true, ) + query_params = HTTP.queryparams(URIs.URI(req.target)) + openapi_params["pretty"] = OpenAPI.Servers.to_param(Bool, query_params, "pretty", ) + openapi_params["provenance"] = OpenAPI.Servers.to_param(Bool, query_params, "provenance", ) + openapi_params["explain"] = OpenAPI.Servers.to_param(String, query_params, "explain", ) + openapi_params["metrics"] = OpenAPI.Servers.to_param(Bool, query_params, "metrics", ) + openapi_params["instrument"] = OpenAPI.Servers.to_param(Bool, query_params, "instrument", ) + openapi_params["request_body"] = OpenAPI.Servers.to_param_type(Dict{String, Any}, String(req.body)) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function get_document_with_path_validate(handler) + function get_document_with_path_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + return handler(req) + end +end + +function get_document_with_path_invoke(impl; post_invoke=nothing) + function get_document_with_path_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.get_document_with_path(req::HTTP.Request, openapi_params["path"], openapi_params["request_body"]; pretty=get(openapi_params, "pretty", nothing), provenance=get(openapi_params, "provenance", nothing), explain=get(openapi_params, "explain", nothing), metrics=get(openapi_params, "metrics", nothing), instrument=get(openapi_params, "instrument", nothing),) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function get_document_with_web_hook_read(handler) + function get_document_with_web_hook_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["path"] = OpenAPI.Servers.to_param(String, path_params, "path", required=true, ) + query_params = HTTP.queryparams(URIs.URI(req.target)) + openapi_params["pretty"] = OpenAPI.Servers.to_param(Bool, query_params, "pretty", ) + openapi_params["request_body"] = OpenAPI.Servers.to_param_type(Dict{String, Any}, String(req.body)) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function get_document_with_web_hook_validate(handler) + function get_document_with_web_hook_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + return handler(req) + end +end + +function get_document_with_web_hook_invoke(impl; post_invoke=nothing) + function get_document_with_web_hook_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.get_document_with_web_hook(req::HTTP.Request, openapi_params["path"], openapi_params["request_body"]; pretty=get(openapi_params, "pretty", nothing),) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function patch_document_read(handler) + function patch_document_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["path"] = OpenAPI.Servers.to_param(String, path_params, "path", required=true, ) + openapi_params["PatchesSchemaInner"] = OpenAPI.Servers.to_param_type(Vector{PatchesSchemaInner}, String(req.body)) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function patch_document_validate(handler) + function patch_document_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + OpenAPI.validate_param("PatchesSchemaInner", "patch_document", :minItems, openapi_params["PatchesSchemaInner"], 1) + + return handler(req) + end +end + +function patch_document_invoke(impl; post_invoke=nothing) + function patch_document_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.patch_document(req::HTTP.Request, openapi_params["path"], openapi_params["PatchesSchemaInner"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function put_document_read(handler) + function put_document_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["path"] = OpenAPI.Servers.to_param(String, path_params, "path", required=true, ) + headers = Dict{String,String}(req.headers) + openapi_params["If-None-Match"] = OpenAPI.Servers.to_param(String, headers, "If-None-Match", ) + openapi_params["body"] = OpenAPI.Servers.to_param_type(Any, String(req.body)) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function put_document_validate(handler) + function put_document_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + return handler(req) + end +end + +function put_document_invoke(impl; post_invoke=nothing) + function put_document_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.put_document(req::HTTP.Request, openapi_params["path"], openapi_params["body"]; if_none_match=get(openapi_params, "If-None-Match", nothing),) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + + +function registerDataAPIApi(router::HTTP.Router, impl; path_prefix::String="", optional_middlewares...) + HTTP.register!(router, "DELETE", path_prefix * "/v1/data/{path}", OpenAPI.Servers.middleware(impl, delete_document_read, delete_document_validate, delete_document_invoke; optional_middlewares...)) + HTTP.register!(router, "GET", path_prefix * "/v1/data/{path}", OpenAPI.Servers.middleware(impl, get_document_read, get_document_validate, get_document_invoke; optional_middlewares...)) + HTTP.register!(router, "POST", path_prefix * "/v1/data/{path}", OpenAPI.Servers.middleware(impl, get_document_with_path_read, get_document_with_path_validate, get_document_with_path_invoke; optional_middlewares...)) + HTTP.register!(router, "POST", path_prefix * "/v0/data/{path}", OpenAPI.Servers.middleware(impl, get_document_with_web_hook_read, get_document_with_web_hook_validate, get_document_with_web_hook_invoke; optional_middlewares...)) + HTTP.register!(router, "PATCH", path_prefix * "/v1/data/{path}", OpenAPI.Servers.middleware(impl, patch_document_read, patch_document_validate, patch_document_invoke; optional_middlewares...)) + HTTP.register!(router, "PUT", path_prefix * "/v1/data/{path}", OpenAPI.Servers.middleware(impl, put_document_read, put_document_validate, put_document_invoke; optional_middlewares...)) + return router +end diff --git a/test/param_deserialize.jl b/test/param_deserialize.jl index 1881c71..2a9b7a6 100644 --- a/test/param_deserialize.jl +++ b/test/param_deserialize.jl @@ -1,7 +1,38 @@ using Test -using OpenAPI.Servers: deep_dict_repr -using OpenAPI: deep_object_to_array +using OpenAPI.Servers: deep_dict_repr, get_param, to_param +using OpenAPI: deep_object_to_array, ValidationException + +@testset "Case-insensitive header param lookup" begin + # HTTP.jl 2.x canonicalizes incoming request header names (e.g. the wire header + # "api_key" arrives as "Api_key"), while 1.x preserves the sent case. Header field + # names are case-insensitive per RFC, so server param lookup must resolve them + # regardless of the HTTP.jl version. See get_param in src/server.jl. + canonicalized = Dict{String,String}("Api_key" => "secret", "Uuid_parameter" => "abc") + + @testset "get_param resolves canonicalized keys" begin + @test get_param(canonicalized, "api_key", false) == "secret" + @test get_param(canonicalized, "uuid_parameter", false) == "abc" + # required param present only under its canonicalized key must not throw + @test get_param(canonicalized, "api_key", true) == "secret" + end + + @testset "exact match still wins" begin + # an exact key takes precedence over any case-insensitive fallback + mixed = Dict{String,String}("api_key" => "exact", "Api_key" => "canon") + @test get_param(mixed, "api_key", false) == "exact" + end + + @testset "genuinely missing param" begin + @test get_param(canonicalized, "missing", false) === nothing + @test_throws ValidationException get_param(canonicalized, "missing", true) + end + + @testset "to_param end-to-end (as generated code calls it)" begin + @test to_param(String, canonicalized, "api_key") == "secret" + @test to_param(String, canonicalized, "uuid_parameter"; required=true) == "abc" + end +end @testset "Test deep_dict_repr" begin @testset "Single level object" begin query_string = Dict("key1" => "value1", "key2" => "value2") diff --git a/test/server/juliahub/specs/juliahubrunner/src/apis/api_JobRunnerApi.jl b/test/server/juliahub/specs/juliahubrunner/src/apis/api_JobRunnerApi.jl new file mode 100644 index 0000000..b28304d --- /dev/null +++ b/test/server/juliahub/specs/juliahubrunner/src/apis/api_JobRunnerApi.jl @@ -0,0 +1,298 @@ +# This file was generated by the Julia OpenAPI Code Generator +# Do not modify this file directly. Modify the OpenAPI specification instead. + + +function file_upload_finalize_read(handler) + function file_upload_finalize_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["job_id"] = OpenAPI.Servers.to_param(String, path_params, "job_id", required=true, ) + openapi_params["UploadFileDetails"] = OpenAPI.Servers.to_param_type(UploadFileDetails, String(req.body)) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function file_upload_finalize_validate(handler) + function file_upload_finalize_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + OpenAPI.validate_param("job_id", "file_upload_finalize", :maxLength, openapi_params["job_id"], 13) + OpenAPI.validate_param("job_id", "file_upload_finalize", :minLength, openapi_params["job_id"], 10) + + return handler(req) + end +end + +function file_upload_finalize_invoke(impl; post_invoke=nothing) + function file_upload_finalize_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.file_upload_finalize(req::HTTP.Request, openapi_params["job_id"], openapi_params["UploadFileDetails"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function file_upload_init_read(handler) + function file_upload_init_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["job_id"] = OpenAPI.Servers.to_param(String, path_params, "job_id", required=true, ) + openapi_params["UploadFileDetails"] = OpenAPI.Servers.to_param_type(UploadFileDetails, String(req.body)) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function file_upload_init_validate(handler) + function file_upload_init_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + OpenAPI.validate_param("job_id", "file_upload_init", :maxLength, openapi_params["job_id"], 13) + OpenAPI.validate_param("job_id", "file_upload_init", :minLength, openapi_params["job_id"], 10) + + return handler(req) + end +end + +function file_upload_init_invoke(impl; post_invoke=nothing) + function file_upload_init_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.file_upload_init(req::HTTP.Request, openapi_params["job_id"], openapi_params["UploadFileDetails"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function get_clusterinfo_read(handler) + function get_clusterinfo_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["job_id"] = OpenAPI.Servers.to_param(String, path_params, "job_id", required=true, ) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function get_clusterinfo_validate(handler) + function get_clusterinfo_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + OpenAPI.validate_param("job_id", "get_clusterinfo", :maxLength, openapi_params["job_id"], 13) + OpenAPI.validate_param("job_id", "get_clusterinfo", :minLength, openapi_params["job_id"], 10) + + return handler(req) + end +end + +function get_clusterinfo_invoke(impl; post_invoke=nothing) + function get_clusterinfo_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.get_clusterinfo(req::HTTP.Request, openapi_params["job_id"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function get_dataset_credentials_read(handler) + function get_dataset_credentials_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["dataset_id"] = OpenAPI.Servers.to_param(String, path_params, "dataset_id", required=true, ) + headers = Dict{String,String}(req.headers) + openapi_params["X-JuliaHub-JobId"] = OpenAPI.Servers.to_param(String, headers, "X-JuliaHub-JobId", required=true, ) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function get_dataset_credentials_validate(handler) + function get_dataset_credentials_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + OpenAPI.validate_param("dataset_id", "get_dataset_credentials", :maxLength, openapi_params["dataset_id"], 36) + OpenAPI.validate_param("dataset_id", "get_dataset_credentials", :minLength, openapi_params["dataset_id"], 36) + + OpenAPI.validate_param("X-JuliaHub-JobId", "get_dataset_credentials", :maxLength, openapi_params["X-JuliaHub-JobId"], 13) + OpenAPI.validate_param("X-JuliaHub-JobId", "get_dataset_credentials", :minLength, openapi_params["X-JuliaHub-JobId"], 10) + + return handler(req) + end +end + +function get_dataset_credentials_invoke(impl; post_invoke=nothing) + function get_dataset_credentials_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.get_dataset_credentials(req::HTTP.Request, openapi_params["dataset_id"], openapi_params["X-JuliaHub-JobId"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function get_datasets_read(handler) + function get_datasets_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function get_datasets_validate(handler) + function get_datasets_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + return handler(req) + end +end + +function get_datasets_invoke(impl; post_invoke=nothing) + function get_datasets_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.get_datasets(req::HTTP.Request;) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function get_job_input_read(handler) + function get_job_input_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["job_id"] = OpenAPI.Servers.to_param(String, path_params, "job_id", required=true, ) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function get_job_input_validate(handler) + function get_job_input_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + OpenAPI.validate_param("job_id", "get_job_input", :maxLength, openapi_params["job_id"], 13) + OpenAPI.validate_param("job_id", "get_job_input", :minLength, openapi_params["job_id"], 10) + + return handler(req) + end +end + +function get_job_input_invoke(impl; post_invoke=nothing) + function get_job_input_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.get_job_input(req::HTTP.Request, openapi_params["job_id"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function get_user_datasets_read(handler) + function get_user_datasets_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + query_params = HTTP.queryparams(URIs.URI(req.target)) + openapi_params["name"] = OpenAPI.Servers.to_param(String, query_params, "name", style="form", is_explode=true) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function get_user_datasets_validate(handler) + function get_user_datasets_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + + return handler(req) + end +end + +function get_user_datasets_invoke(impl; post_invoke=nothing) + function get_user_datasets_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.get_user_datasets(req::HTTP.Request; name=get(openapi_params, "name", nothing),) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function update_job_distributed_status_read(handler) + function update_job_distributed_status_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["job_id"] = OpenAPI.Servers.to_param(String, path_params, "job_id", required=true, ) + openapi_params["JobDistributedStatus"] = OpenAPI.Servers.to_param_type(JobDistributedStatus, String(req.body)) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function update_job_distributed_status_validate(handler) + function update_job_distributed_status_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + OpenAPI.validate_param("job_id", "update_job_distributed_status", :maxLength, openapi_params["job_id"], 13) + OpenAPI.validate_param("job_id", "update_job_distributed_status", :minLength, openapi_params["job_id"], 10) + + return handler(req) + end +end + +function update_job_distributed_status_invoke(impl; post_invoke=nothing) + function update_job_distributed_status_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.update_job_distributed_status(req::HTTP.Request, openapi_params["job_id"], openapi_params["JobDistributedStatus"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + +function update_job_status_read(handler) + function update_job_status_read_handler(req::HTTP.Request) + openapi_params = Dict{String,Any}() + path_params = HTTP.getparams(req) + openapi_params["job_id"] = OpenAPI.Servers.to_param(String, path_params, "job_id", required=true, ) + openapi_params["JobStatus"] = OpenAPI.Servers.to_param_type(JobStatus, String(req.body)) + req.context[:openapi_params] = openapi_params + + return handler(req) + end +end + +function update_job_status_validate(handler) + function update_job_status_validate_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + + OpenAPI.validate_param("job_id", "update_job_status", :maxLength, openapi_params["job_id"], 13) + OpenAPI.validate_param("job_id", "update_job_status", :minLength, openapi_params["job_id"], 10) + + return handler(req) + end +end + +function update_job_status_invoke(impl; post_invoke=nothing) + function update_job_status_invoke_handler(req::HTTP.Request) + openapi_params = req.context[:openapi_params] + ret = impl.update_job_status(req::HTTP.Request, openapi_params["job_id"], openapi_params["JobStatus"];) + resp = OpenAPI.Servers.server_response(ret) + return (post_invoke === nothing) ? resp : post_invoke(req, resp) + end +end + + +function registerJobRunnerApi(router::HTTP.Router, impl; path_prefix::String="", optional_middlewares...) + HTTP.register!(router, "POST", path_prefix * "/jobs/{job_id}/file_uploads", OpenAPI.Servers.middleware(impl, file_upload_finalize_read, file_upload_finalize_validate, file_upload_finalize_invoke; optional_middlewares...)) + HTTP.register!(router, "PUT", path_prefix * "/jobs/{job_id}/file_uploads", OpenAPI.Servers.middleware(impl, file_upload_init_read, file_upload_init_validate, file_upload_init_invoke; optional_middlewares...)) + HTTP.register!(router, "GET", path_prefix * "/jobs/{job_id}/clusterinfo", OpenAPI.Servers.middleware(impl, get_clusterinfo_read, get_clusterinfo_validate, get_clusterinfo_invoke; optional_middlewares...)) + HTTP.register!(router, "GET", path_prefix * "/datasets/{dataset_id}/credentials", OpenAPI.Servers.middleware(impl, get_dataset_credentials_read, get_dataset_credentials_validate, get_dataset_credentials_invoke; optional_middlewares...)) + HTTP.register!(router, "GET", path_prefix * "/datasets", OpenAPI.Servers.middleware(impl, get_datasets_read, get_datasets_validate, get_datasets_invoke; optional_middlewares...)) + HTTP.register!(router, "GET", path_prefix * "/jobs/{job_id}/input", OpenAPI.Servers.middleware(impl, get_job_input_read, get_job_input_validate, get_job_input_invoke; optional_middlewares...)) + HTTP.register!(router, "GET", path_prefix * "/user/datasets", OpenAPI.Servers.middleware(impl, get_user_datasets_read, get_user_datasets_validate, get_user_datasets_invoke; optional_middlewares...)) + HTTP.register!(router, "POST", path_prefix * "/jobs/{job_id}/distributed_status", OpenAPI.Servers.middleware(impl, update_job_distributed_status_read, update_job_distributed_status_validate, update_job_distributed_status_invoke; optional_middlewares...)) + HTTP.register!(router, "POST", path_prefix * "/jobs/{job_id}/status", OpenAPI.Servers.middleware(impl, update_job_status_read, update_job_status_validate, update_job_status_invoke; optional_middlewares...)) + return router +end diff --git a/test/server/openapigenerator_petstore_v3/petstore/src/apis/api_PetApi.jl b/test/server/openapigenerator_petstore_v3/petstore/src/apis/api_PetApi.jl index 861be59..2701f42 100644 --- a/test/server/openapigenerator_petstore_v3/petstore/src/apis/api_PetApi.jl +++ b/test/server/openapigenerator_petstore_v3/petstore/src/apis/api_PetApi.jl @@ -47,7 +47,7 @@ function delete_pet_read(handler) openapi_params = Dict{String,Any}() path_params = HTTP.getparams(req) openapi_params["petId"] = OpenAPI.Servers.to_param(Int64, path_params, "petId", required=true, ) - headers = Dict{String,String}(HTTP.headers(req)) + headers = Dict{String,String}(req.headers) openapi_params["api_key"] = OpenAPI.Servers.to_param(String, headers, "api_key", ) req.context[:openapi_params] = openapi_params diff --git a/test/server/petstore_v2/petstore/src/apis/api_PetApi.jl b/test/server/petstore_v2/petstore/src/apis/api_PetApi.jl index 8a8eec7..9bccaf3 100644 --- a/test/server/petstore_v2/petstore/src/apis/api_PetApi.jl +++ b/test/server/petstore_v2/petstore/src/apis/api_PetApi.jl @@ -47,7 +47,7 @@ function delete_pet_read(handler) openapi_params = Dict{String,Any}() path_params = HTTP.getparams(req) openapi_params["petId"] = OpenAPI.Servers.to_param(Int64, path_params, "petId", required=true, ) - headers = Dict{String,String}(HTTP.headers(req)) + headers = Dict{String,String}(req.headers) openapi_params["api_key"] = OpenAPI.Servers.to_param(String, headers, "api_key", ) req.context[:openapi_params] = openapi_params diff --git a/test/server/petstore_v3/petstore/src/apis/api_PetApi.jl b/test/server/petstore_v3/petstore/src/apis/api_PetApi.jl index 8f42cf3..f3e3346 100644 --- a/test/server/petstore_v3/petstore/src/apis/api_PetApi.jl +++ b/test/server/petstore_v3/petstore/src/apis/api_PetApi.jl @@ -47,7 +47,7 @@ function delete_pet_read(handler) openapi_params = Dict{String,Any}() path_params = HTTP.getparams(req) openapi_params["petId"] = OpenAPI.Servers.to_param(Int64, path_params, "petId", required=true, ) - headers = Dict{String,String}(HTTP.headers(req)) + headers = Dict{String,String}(req.headers) openapi_params["api_key"] = OpenAPI.Servers.to_param(String, headers, "api_key", ) req.context[:openapi_params] = openapi_params diff --git a/test/server/timeouttest/timeouttest_server.jl b/test/server/timeouttest/timeouttest_server.jl index df9702b..2ac8dd9 100644 --- a/test/server/timeouttest/timeouttest_server.jl +++ b/test/server/timeouttest/timeouttest_server.jl @@ -51,7 +51,12 @@ function run_server(port=8081) HTTP.register!(router, "/longpollstream", longpollstream) HTTP.register!(router, "/stop", HTTP.streamhandler(stop)) HTTP.register!(router, "/ping", HTTP.streamhandler(ping)) - server[] = HTTP.serve!(router, port; stream=true) + # HTTP.jl 1.x serves stream handlers via `serve!(...; stream=true)`; + # 2.0 uses `listen!`, which always runs the handler in streaming mode. + http_v2 = isdefined(Base, :pkgversion) && something(pkgversion(HTTP), v"1") >= v"2" + server[] = http_v2 ? + HTTP.listen!(router, "127.0.0.1", port) : + HTTP.serve!(router, port; stream=true) wait(server[]) catch ex @error("Server error", exception=(ex, catch_backtrace())) diff --git a/test/testutils.jl b/test/testutils.jl index f3c1993..161d790 100644 --- a/test/testutils.jl +++ b/test/testutils.jl @@ -27,7 +27,7 @@ end function wait_server(port) @info("Waiting for server", port) - is_ok = timedwait(20.0; pollint=2.0) do + is_ok = timedwait(90.0; pollint=2.0) do try resp = HTTP.request("GET", "http://127.0.0.1:$port/ping") return resp.status == 200