Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ r = HTTP.post(
returned = JSON.parse(String(r.body))
```

For safe, idempotent requests with content, HTTP.jl supports the RFC 10008
`QUERY` method through `HTTP.query`:

```julia
r = HTTP.query(
"https://api.example.com/search";
headers = ["Content-Type" => "application/json"],
body = JSON.json(Dict("select" => ["name", "email"])),
)
```

Stream directly into an `IO` sink with `response_stream`, or use `HTTP.open` when
you want pull-based control over the response stream. The `do`-block form of
`HTTP.open` returns the final `HTTP.Response`, not the value returned by the
Expand Down
1 change: 1 addition & 0 deletions docs/src/api/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ HTTP.roundtrip!
HTTP.request
HTTP.get
HTTP.head
HTTP.query
HTTP.post
HTTP.put
HTTP.patch
Expand Down
26 changes: 25 additions & 1 deletion docs/src/guides/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ HTTP.forceclose(server)

Useful top-level request helpers:

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

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

### Sending QUERY requests

RFC 10008 defines `QUERY` for safe, idempotent requests with content. Use
`HTTP.query` when a request needs a body but has GET-like semantics:

```julia
HTTP.query(
"https://api.example.com/search";
headers = ["Content-Type" => "application/json"],
body = """{"select":["name","email"],"limit":10}""",
)
```

`Dict` and `NamedTuple` bodies are encoded the same way as `HTTP.post` form
bodies, with `Content-Type: application/x-www-form-urlencoded` set
automatically:

```julia
HTTP.query("https://api.example.com/search"; body = (select = "name", limit = 10))
```

Servers that support `QUERY` can advertise accepted query content media types
with the `Accept-Query` response header.

### Query parameters

The `query` keyword URL-encodes a `Dict` or vector of pairs and appends them
Expand Down
10 changes: 5 additions & 5 deletions src/HTTP.jl
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ while `Reseau` provides the transport, resolver, and TLS substrate.

Common entrypoints:

- `request`, `get`, `post`, `put`, `patch`, `delete`, `head`, and `options`
- `request`, `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, and `query`
for high-level client requests.
- `open` for client-side request/response streaming.
- `serve!` / `serve` for `Request -> Response` servers.
Expand Down Expand Up @@ -80,10 +80,10 @@ include("http_websockets.jl")
:close_idle_connections!, :defaultheader!, :delete, :do!, :expired, :fileserver,
:forceclose, :get, :get!, :get_request_context, :hasheader, :head, :header,
:headercontains, :headers, :idle_connection_count, :isaborted, :isrecoverable,
:listen, :listen!, :mkheaders, :nobody, :open, :options, :patch, :peeraddr, :port, :post, :put,
:read_request, :removeheader, :request, :retry_attempts, :roundtrip!, :serve, :serve!,
:servecontent, :servefile, :set_deadline!, :setheader, :setstatus, :sse_stream,
:startwrite, :streamhandler, :trailers, :write_request!, :write_response!,
:listen, :listen!, :mkheaders, :nobody, :open, :options, :patch, :peeraddr, :port, :post,
:put, :query, :read_request, :removeheader, :request, :retry_attempts, :roundtrip!,
:serve, :serve!, :servecontent, :servefile, :set_deadline!, :setheader, :setstatus,
:sse_stream, :startwrite, :streamhandler, :trailers, :write_request!, :write_response!,
))
end

Expand Down
22 changes: 20 additions & 2 deletions src/http_client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2027,11 +2027,11 @@ Keyword arguments:
fallback when neither is provided
- `retry`: overall toggle for high-level request retries; lower-level reused-connection transport retries still happen independently
- `retries`: maximum number of retry attempts after the initial request attempt
- `retry_non_idempotent`: allow automatic retries for methods like `POST`/`PATCH`; `PUT` and `DELETE` are already treated as idempotent
- `retry_non_idempotent`: allow automatic retries for methods like `POST`/`PATCH`; `QUERY`, `PUT`, and `DELETE` are already treated as idempotent
- `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
- `respect_retry_after`: honor server `Retry-After` on retryable `429`/`503` responses
- `retry_bucket`: `true` uses the request transport's default `RetryBucket`, `false` disables bucket coordination, and a custom `RetryBucket` overrides the transport default
- 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`
- 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`
- `status_exception`: throw `StatusError` for non-success responses
- `redirect`: follow redirects through `do!`
- `redirect_limit`: maximum number of redirects to follow for this call;
Expand Down Expand Up @@ -2232,6 +2232,7 @@ const _REQUEST_HELPER_DOC = """
request(method, url, headers=Pair{String,String}[], body=nothing; kwargs...)
get(url, headers=Pair{String,String}[]; kwargs...)
head(url, headers=Pair{String,String}[]; kwargs...)
query(url, [headers], [body]; kwargs...)
post(url, [headers], [body]; kwargs...)
put(url, [headers], [body]; kwargs...)
patch(url, [headers], [body]; kwargs...)
Expand Down Expand Up @@ -2259,6 +2260,12 @@ function head(url::Union{AbstractString,URI}, headers=Pair{String,String}[]; kwa
return request("HEAD", url, headers, nothing; kwargs...)
end

@doc _REQUEST_HELPER_DOC
function query(url::Union{AbstractString,URI}, args...; kwargs...)
headers, body = _split_headers_body_args(args)
return request("QUERY", url, headers, body; kwargs...)
end

@doc _REQUEST_HELPER_DOC
function post(url::Union{AbstractString,URI}, args...; kwargs...)
headers, body = _split_headers_body_args(args)
Expand Down Expand Up @@ -2302,6 +2309,11 @@ get(client::Client, url::Union{AbstractString,URI}, headers=Pair{String,String}[
head(client::Client, url::Union{AbstractString,URI}, headers=Pair{String,String}[]; kwargs...) =
request("HEAD", url, headers, nothing; client=client, kwargs...)

function query(client::Client, url::Union{AbstractString,URI}, args...; kwargs...)
headers, body = _split_headers_body_args(args)
return request("QUERY", url, headers, body; client=client, kwargs...)
end

function post(client::Client, url::Union{AbstractString,URI}, args...; kwargs...)
headers, body = _split_headers_body_args(args)
return request("POST", url, headers, body; client=client, kwargs...)
Expand Down Expand Up @@ -2367,6 +2379,12 @@ macro client(request_middleware, stream_middleware=:(()))
return request("HEAD", url, headers, nothing; kwargs...)
end

function query(url, args...; kwargs...)
$__source__
headers, body = HTTP._split_headers_body_args(args)
return request("QUERY", url, headers, body; kwargs...)
end

function post(url, args...; kwargs...)
$__source__
headers, body = HTTP._split_headers_body_args(args)
Expand Down
4 changes: 2 additions & 2 deletions src/http_client_redirect.jl
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function _normalize_redirect_method_override(redirect_method)::Tuple{Union{Nothi
redirect_method == :same && return nothing, true
redirect_method isa AbstractString || redirect_method isa Symbol || throw(ArgumentError("redirect_method must be nothing, :same, or an HTTP method String/Symbol"))
method = uppercase(String(redirect_method))
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"))
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"))
return method, false
end

Expand Down Expand Up @@ -227,7 +227,7 @@ function _rewrite_method_for_redirect(method::String, status::Int, policy::_Redi
if policy.redirect_method !== nothing
return policy.redirect_method::String
end
method == "HEAD" && return method
(method == "HEAD" || method == "QUERY") && return method
return "GET"
end

Expand Down
2 changes: 1 addition & 1 deletion src/http_client_retry.jl
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ end
end

@inline function _retryable_request_method(method::String)::Bool
return method == "GET" || method == "HEAD" || method == "OPTIONS" || method == "TRACE" || method == "PUT" || method == "DELETE"
return method == "GET" || method == "HEAD" || method == "OPTIONS" || method == "TRACE" || method == "PUT" || method == "DELETE" || method == "QUERY"
end

@inline function _retryable_request_headers(request::Request)::Bool
Expand Down
1 change: 1 addition & 0 deletions src/http_core.jl
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ const _COMMON_CANONICAL_HEADER_KEYS = Dict(
"Accept-Charset" => "Accept-Charset",
"Accept-Encoding" => "Accept-Encoding",
"Accept-Language" => "Accept-Language",
"Accept-Query" => "Accept-Query",
"Accept-Ranges" => "Accept-Ranges",
"Cache-Control" => "Cache-Control",
"Connection" => "Connection",
Expand Down
2 changes: 1 addition & 1 deletion src/http_transport.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1445,7 +1445,7 @@ end
end

@inline function _retryable_method(method::String)::Bool
return method == "GET" || method == "HEAD" || method == "OPTIONS" || method == "TRACE"
return method == "GET" || method == "HEAD" || method == "OPTIONS" || method == "TRACE" || method == "QUERY"
end

@inline function _retryable_request(request::Request)::Bool
Expand Down
29 changes: 29 additions & 0 deletions src/precompile.jl
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ function _run_precompile_workload_inner!()::Nothing
payload = _precompile_body_string(req.body)
return _precompile_text_response("echo:" * getparam(req, "name") * ":" * payload)
end))
register!(request_router, "QUERY", "/search/{name}", Handlers.handlertimeout(0.5)(req -> begin
content_type = header(req, "Content-Type", "")
payload = _precompile_body_string(req.body)
response_headers = ["Accept-Query" => "application/x-www-form-urlencoded"]
req.method == "QUERY" || return Response(405, EmptyBody(); content_length=0)
occursin("application/x-www-form-urlencoded", content_type) || return Response(415, EmptyBody(); content_length=0)
return _precompile_text_response("query:" * getparam(req, "name") * ":" * payload, 200, response_headers)
end))
register!(request_router, "QUERY", "/search-redirect", req ->
Response(
307,
EmptyBody();
headers=["Location" => "/search/redirected"],
content_length=0,
)
)
register!(request_router, "GET", "/redirect", req ->
Response(
302,
Expand Down Expand Up @@ -248,6 +264,19 @@ function _run_precompile_workload_inner!()::Nothing
@assert echo.status == 200
@assert String(echo.body) == "echo:jane:ping"

_precompile_trace("request query")
query_headers = ["Content-Type" => "application/x-www-form-urlencoded"]
query_resp = request("QUERY", "http://$(request_address)/search/jane", query_headers, (term="ping",); client=client, stream_timeouts...)
@assert query_resp.status == 200
@assert header(query_resp, "Accept-Query", nothing) == "application/x-www-form-urlencoded"
@assert String(query_resp.body) == "query:jane:term=ping"

_precompile_trace("request query redirect")
query_redirect = request("QUERY", "http://$(request_address)/search-redirect", query_headers, "term=redirect"; client=client, stream_timeouts...)
@assert query_redirect.status == 200
@assert header(query_redirect, "Accept-Query", nothing) == "application/x-www-form-urlencoded"
@assert String(query_redirect.body) == "query:redirected:term=redirect"

_precompile_trace("request redirect")
redirected = get("http://$(request_address)/redirect"; client=client, request_timeouts...)
@assert redirected.status == 200
Expand Down
Loading
Loading