Skip to content

Commit c70d49a

Browse files
authored
Add HTTP QUERY method support (#1319)
1 parent 12d98f9 commit c70d49a

14 files changed

Lines changed: 284 additions & 19 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ r = HTTP.post(
7171
returned = JSON.parse(String(r.body))
7272
```
7373

74+
For safe, idempotent requests with content, HTTP.jl supports the RFC 10008
75+
`QUERY` method through `HTTP.query`:
76+
77+
```julia
78+
r = HTTP.query(
79+
"https://api.example.com/search";
80+
headers = ["Content-Type" => "application/json"],
81+
body = JSON.json(Dict("select" => ["name", "email"])),
82+
)
83+
```
84+
7485
Stream directly into an `IO` sink with `response_stream`, or use `HTTP.open` when
7586
you want pull-based control over the response stream. The `do`-block form of
7687
`HTTP.open` returns the final `HTTP.Response`, not the value returned by the

docs/src/api/client.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ HTTP.roundtrip!
2222
HTTP.request
2323
HTTP.get
2424
HTTP.head
25+
HTTP.query
2526
HTTP.post
2627
HTTP.put
2728
HTTP.patch

docs/src/guides/client.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ HTTP.forceclose(server)
4444

4545
Useful top-level request helpers:
4646

47-
- `HTTP.get`, `HTTP.head`, `HTTP.post`, `HTTP.put`, `HTTP.patch`, `HTTP.delete`, `HTTP.options`
47+
- `HTTP.get`, `HTTP.head`, `HTTP.query`, `HTTP.post`, `HTTP.put`, `HTTP.patch`, `HTTP.delete`, `HTTP.options`
4848
- `HTTP.request` for the fully general call shape
4949
- `HTTP.open` when you want streaming control instead of an eagerly consumed body
5050

@@ -317,6 +317,30 @@ form = HTTP.Form(Dict("file" => open("upload.bin", "r"), "kind" => "binary"))
317317
HTTP.post("http://example.com/upload", [], form)
318318
```
319319

320+
### Sending QUERY requests
321+
322+
RFC 10008 defines `QUERY` for safe, idempotent requests with content. Use
323+
`HTTP.query` when a request needs a body but has GET-like semantics:
324+
325+
```julia
326+
HTTP.query(
327+
"https://api.example.com/search";
328+
headers = ["Content-Type" => "application/json"],
329+
body = """{"select":["name","email"],"limit":10}""",
330+
)
331+
```
332+
333+
`Dict` and `NamedTuple` bodies are encoded the same way as `HTTP.post` form
334+
bodies, with `Content-Type: application/x-www-form-urlencoded` set
335+
automatically:
336+
337+
```julia
338+
HTTP.query("https://api.example.com/search"; body = (select = "name", limit = 10))
339+
```
340+
341+
Servers that support `QUERY` can advertise accepted query content media types
342+
with the `Accept-Query` response header.
343+
320344
### Query parameters
321345

322346
The `query` keyword URL-encodes a `Dict` or vector of pairs and appends them

src/HTTP.jl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ while `Reseau` provides the transport, resolver, and TLS substrate.
1010
1111
Common entrypoints:
1212
13-
- `request`, `get`, `post`, `put`, `patch`, `delete`, `head`, and `options`
13+
- `request`, `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, and `query`
1414
for high-level client requests.
1515
- `open` for client-side request/response streaming.
1616
- `serve!` / `serve` for `Request -> Response` servers.
@@ -80,10 +80,10 @@ include("http_websockets.jl")
8080
:close_idle_connections!, :defaultheader!, :delete, :do!, :expired, :fileserver,
8181
:forceclose, :get, :get!, :get_request_context, :hasheader, :head, :header,
8282
:headercontains, :headers, :idle_connection_count, :isaborted, :isrecoverable,
83-
:listen, :listen!, :mkheaders, :nobody, :open, :options, :patch, :peeraddr, :port, :post, :put,
84-
:read_request, :removeheader, :request, :retry_attempts, :roundtrip!, :serve, :serve!,
85-
:servecontent, :servefile, :set_deadline!, :setheader, :setstatus, :sse_stream,
86-
:startwrite, :streamhandler, :trailers, :write_request!, :write_response!,
83+
:listen, :listen!, :mkheaders, :nobody, :open, :options, :patch, :peeraddr, :port, :post,
84+
:put, :query, :read_request, :removeheader, :request, :retry_attempts, :roundtrip!,
85+
:serve, :serve!, :servecontent, :servefile, :set_deadline!, :setheader, :setstatus,
86+
:sse_stream, :startwrite, :streamhandler, :trailers, :write_request!, :write_response!,
8787
))
8888
end
8989

src/http_client.jl

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2027,11 +2027,11 @@ Keyword arguments:
20272027
fallback when neither is provided
20282028
- `retry`: overall toggle for high-level request retries; lower-level reused-connection transport retries still happen independently
20292029
- `retries`: maximum number of retry attempts after the initial request attempt
2030-
- `retry_non_idempotent`: allow automatic retries for methods like `POST`/`PATCH`; `PUT` and `DELETE` are already treated as idempotent
2030+
- `retry_non_idempotent`: allow automatic retries for methods like `POST`/`PATCH`; `QUERY`, `PUT`, and `DELETE` are already treated as idempotent
20312031
- `retry_if`: optional callback `(attempt, err, req, resp) -> Bool | nothing`; request-path failures are passed as `RequestRetryError` so implementations can inspect `err.err`, while response-based retry checks pass `err = nothing` and `resp = response`; `true` forces a retry when the request body is replayable, `false` suppresses retry, and `nothing` defers to built-in retry rules
20322032
- `respect_retry_after`: honor server `Retry-After` on retryable `429`/`503` responses
20332033
- `retry_bucket`: `true` uses the request transport's default `RetryBucket`, `false` disables bucket coordination, and a custom `RetryBucket` overrides the transport default
2034-
- automatic retries only occur for replayable request bodies; built-in policy retries idempotent methods (`GET`, `HEAD`, `OPTIONS`, `TRACE`, `PUT`, `DELETE`) plus requests carrying `Idempotency-Key`/`X-Idempotency-Key`
2034+
- automatic retries only occur for replayable request bodies; built-in policy retries idempotent methods (`GET`, `HEAD`, `OPTIONS`, `TRACE`, `QUERY`, `PUT`, `DELETE`) plus requests carrying `Idempotency-Key`/`X-Idempotency-Key`
20352035
- `status_exception`: throw `StatusError` for non-success responses
20362036
- `redirect`: follow redirects through `do!`
20372037
- `redirect_limit`: maximum number of redirects to follow for this call;
@@ -2232,6 +2232,7 @@ const _REQUEST_HELPER_DOC = """
22322232
request(method, url, headers=Pair{String,String}[], body=nothing; kwargs...)
22332233
get(url, headers=Pair{String,String}[]; kwargs...)
22342234
head(url, headers=Pair{String,String}[]; kwargs...)
2235+
query(url, [headers], [body]; kwargs...)
22352236
post(url, [headers], [body]; kwargs...)
22362237
put(url, [headers], [body]; kwargs...)
22372238
patch(url, [headers], [body]; kwargs...)
@@ -2259,6 +2260,12 @@ function head(url::Union{AbstractString,URI}, headers=Pair{String,String}[]; kwa
22592260
return request("HEAD", url, headers, nothing; kwargs...)
22602261
end
22612262

2263+
@doc _REQUEST_HELPER_DOC
2264+
function query(url::Union{AbstractString,URI}, args...; kwargs...)
2265+
headers, body = _split_headers_body_args(args)
2266+
return request("QUERY", url, headers, body; kwargs...)
2267+
end
2268+
22622269
@doc _REQUEST_HELPER_DOC
22632270
function post(url::Union{AbstractString,URI}, args...; kwargs...)
22642271
headers, body = _split_headers_body_args(args)
@@ -2302,6 +2309,11 @@ get(client::Client, url::Union{AbstractString,URI}, headers=Pair{String,String}[
23022309
head(client::Client, url::Union{AbstractString,URI}, headers=Pair{String,String}[]; kwargs...) =
23032310
request("HEAD", url, headers, nothing; client=client, kwargs...)
23042311

2312+
function query(client::Client, url::Union{AbstractString,URI}, args...; kwargs...)
2313+
headers, body = _split_headers_body_args(args)
2314+
return request("QUERY", url, headers, body; client=client, kwargs...)
2315+
end
2316+
23052317
function post(client::Client, url::Union{AbstractString,URI}, args...; kwargs...)
23062318
headers, body = _split_headers_body_args(args)
23072319
return request("POST", url, headers, body; client=client, kwargs...)
@@ -2367,6 +2379,12 @@ macro client(request_middleware, stream_middleware=:(()))
23672379
return request("HEAD", url, headers, nothing; kwargs...)
23682380
end
23692381

2382+
function query(url, args...; kwargs...)
2383+
$__source__
2384+
headers, body = HTTP._split_headers_body_args(args)
2385+
return request("QUERY", url, headers, body; kwargs...)
2386+
end
2387+
23702388
function post(url, args...; kwargs...)
23712389
$__source__
23722390
headers, body = HTTP._split_headers_body_args(args)

src/http_client_redirect.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function _normalize_redirect_method_override(redirect_method)::Tuple{Union{Nothi
2020
redirect_method == :same && return nothing, true
2121
redirect_method isa AbstractString || redirect_method isa Symbol || throw(ArgumentError("redirect_method must be nothing, :same, or an HTTP method String/Symbol"))
2222
method = uppercase(String(redirect_method))
23-
method in ("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH") || throw(ArgumentError("redirect_method must be one of GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH, or :same"))
23+
method in ("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH", "QUERY") || throw(ArgumentError("redirect_method must be one of GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH, QUERY, or :same"))
2424
return method, false
2525
end
2626

@@ -242,7 +242,7 @@ function _rewrite_method_for_redirect(method::String, status::Int, policy::_Redi
242242
if policy.redirect_method !== nothing
243243
return policy.redirect_method::String
244244
end
245-
method == "HEAD" && return method
245+
(method == "HEAD" || method == "QUERY") && return method
246246
return "GET"
247247
end
248248

src/http_client_retry.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ end
2525
end
2626

2727
@inline function _retryable_request_method(method::String)::Bool
28-
return method == "GET" || method == "HEAD" || method == "OPTIONS" || method == "TRACE" || method == "PUT" || method == "DELETE"
28+
return method == "GET" || method == "HEAD" || method == "OPTIONS" || method == "TRACE" || method == "PUT" || method == "DELETE" || method == "QUERY"
2929
end
3030

3131
@inline function _retryable_request_headers(request::Request)::Bool

src/http_core.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,7 @@ const _COMMON_CANONICAL_HEADER_KEYS = Dict(
517517
"Accept-Charset" => "Accept-Charset",
518518
"Accept-Encoding" => "Accept-Encoding",
519519
"Accept-Language" => "Accept-Language",
520+
"Accept-Query" => "Accept-Query",
520521
"Accept-Ranges" => "Accept-Ranges",
521522
"Cache-Control" => "Cache-Control",
522523
"Connection" => "Connection",

src/http_transport.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1445,7 +1445,7 @@ end
14451445
end
14461446

14471447
@inline function _retryable_method(method::String)::Bool
1448-
return method == "GET" || method == "HEAD" || method == "OPTIONS" || method == "TRACE"
1448+
return method == "GET" || method == "HEAD" || method == "OPTIONS" || method == "TRACE" || method == "QUERY"
14491449
end
14501450

14511451
@inline function _retryable_request(request::Request)::Bool

src/precompile.jl

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ function _run_precompile_workload_inner!()::Nothing
124124
payload = _precompile_body_string(req.body)
125125
return _precompile_text_response("echo:" * getparam(req, "name") * ":" * payload)
126126
end))
127+
register!(request_router, "QUERY", "/search/{name}", Handlers.handlertimeout(0.5)(req -> begin
128+
content_type = header(req, "Content-Type", "")
129+
payload = _precompile_body_string(req.body)
130+
response_headers = ["Accept-Query" => "application/x-www-form-urlencoded"]
131+
req.method == "QUERY" || return Response(405, EmptyBody(); content_length=0)
132+
occursin("application/x-www-form-urlencoded", content_type) || return Response(415, EmptyBody(); content_length=0)
133+
return _precompile_text_response("query:" * getparam(req, "name") * ":" * payload, 200, response_headers)
134+
end))
135+
register!(request_router, "QUERY", "/search-redirect", req ->
136+
Response(
137+
307,
138+
EmptyBody();
139+
headers=["Location" => "/search/redirected"],
140+
content_length=0,
141+
)
142+
)
127143
register!(request_router, "GET", "/redirect", req ->
128144
Response(
129145
302,
@@ -248,6 +264,19 @@ function _run_precompile_workload_inner!()::Nothing
248264
@assert echo.status == 200
249265
@assert String(echo.body) == "echo:jane:ping"
250266

267+
_precompile_trace("request query")
268+
query_headers = ["Content-Type" => "application/x-www-form-urlencoded"]
269+
query_resp = request("QUERY", "http://$(request_address)/search/jane", query_headers, (term="ping",); client=client, stream_timeouts...)
270+
@assert query_resp.status == 200
271+
@assert header(query_resp, "Accept-Query", nothing) == "application/x-www-form-urlencoded"
272+
@assert String(query_resp.body) == "query:jane:term=ping"
273+
274+
_precompile_trace("request query redirect")
275+
query_redirect = request("QUERY", "http://$(request_address)/search-redirect", query_headers, "term=redirect"; client=client, stream_timeouts...)
276+
@assert query_redirect.status == 200
277+
@assert header(query_redirect, "Accept-Query", nothing) == "application/x-www-form-urlencoded"
278+
@assert String(query_redirect.body) == "query:redirected:term=redirect"
279+
251280
_precompile_trace("request redirect")
252281
redirected = get("http://$(request_address)/redirect"; client=client, request_timeouts...)
253282
@assert redirected.status == 200

0 commit comments

Comments
 (0)