Skip to content

Commit e5d20e8

Browse files
committed
feat: Add Unix Domain Socket support for HTTP requests
- Implement HTTP.UnixSocket module for UDS communication - Add unix_socket option to HTTP.fetch for specifying socket path - Support all HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD) - Handle chunked transfer encoding and Content-Length - Include comprehensive test suite with 14 passing tests - Add test helper (HTTP.Test.UnixSocketServer) for UDS testing - Update documentation with Docker daemon examples - Compatible with existing HTTP.Response API Supports use cases like: - Docker daemon communication (/var/run/docker.sock) - systemd service APIs - Custom Unix socket-based HTTP services
1 parent 4e9883e commit e5d20e8

File tree

7 files changed

+1171
-6
lines changed

7 files changed

+1171
-6
lines changed

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A modern HTTP client library for Elixir that provides a fetch API similar to web
1313
- **Browser-like API**: Familiar fetch interface with promises and async/await patterns
1414
- **Full HTTP support**: GET, POST, PUT, DELETE, PATCH, HEAD methods
1515
- **Complete httpc integration**: Support for all :httpc.request options
16+
- **Unix Domain Sockets**: HTTP over Unix sockets for Docker daemon, systemd, and other local services
1617
- **Form data support**: HTTP.FormData for multipart/form-data and file uploads
1718
- **Streaming file uploads**: Efficient large file uploads using streams
1819
- **Type-safe configuration**: HTTP.FetchOptions for structured request configuration
@@ -43,13 +44,23 @@ response =
4344
binary_data = response.body
4445

4546
# POST request with JSON
46-
{:ok, response} =
47+
{:ok, response} =
4748
HTTP.fetch("https://jsonplaceholder.typicode.com/posts", [
4849
method: "POST",
4950
headers: %{"Content-Type" => "application/json"},
5051
body: JSON.encode\!(%{title: "Hello", body: "World"})
5152
])
5253
|> HTTP.Promise.await()
54+
55+
# Unix Domain Socket request (Docker daemon example)
56+
{:ok, response} =
57+
HTTP.fetch("http://localhost/version",
58+
unix_socket: "/var/run/docker.sock")
59+
|> HTTP.Promise.await()
60+
61+
# Parse Docker version info
62+
{:ok, docker_info} = HTTP.Response.json(response)
63+
IO.puts("Docker Version: #{docker_info["Version"]}")
5364
```
5465

5566
# Form data with file upload
@@ -80,7 +91,8 @@ promise = HTTP.fetch(url, [
8091
body: "request body",
8192
content_type: "application/json",
8293
options: [timeout: 10_000],
83-
signal: abort_controller
94+
signal: abort_controller,
95+
unix_socket: "/var/run/docker.sock" # Optional: use Unix Domain Socket
8496
])
8597
```
8698

lib/http.ex

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule HTTP do
1212
- **Automatic streaming**: Responses >5MB or with unknown Content-Length automatically stream
1313
- **Request cancellation**: Via `HTTP.AbortController` for aborting in-flight requests
1414
- **Promise chaining**: JavaScript-like promise interface with `then/3` support
15+
- **Unix Domain Sockets**: Support for HTTP over Unix sockets (Docker daemon, systemd, etc.)
1516
- **Telemetry integration**: Comprehensive event emission for monitoring and observability
1617
- **Zero external dependencies**: Uses only Erlang/OTP built-in modules (except telemetry)
1718
@@ -34,6 +35,12 @@ defmodule HTTP do
3435
])
3536
|> HTTP.Promise.await()
3637
38+
# Unix Domain Socket request (e.g., Docker daemon)
39+
{:ok, response} =
40+
HTTP.fetch("http://localhost/version",
41+
unix_socket: "/var/run/docker.sock")
42+
|> HTTP.Promise.await()
43+
3744
## Architecture
3845
3946
The library is structured around these core modules:
@@ -97,6 +104,8 @@ defmodule HTTP do
97104
(e.g., `sync: false`, `body_format: :binary`). Overrides `Request` defaults.
98105
- `:signal`: An `HTTP.AbortController` PID. If provided, the request can be aborted
99106
via this controller.
107+
- `:unix_socket`: Path to a Unix Domain Socket file (e.g., "/var/run/docker.sock").
108+
When provided, the request is sent over the Unix socket instead of TCP/IP.
100109
101110
Returns:
102111
- `%HTTP.Promise{}`: A Promise struct. The caller should `HTTP.Promise.await(promise_struct)` to get the final
@@ -227,6 +236,21 @@ defmodule HTTP do
227236
IO.puts "Request was likely aborted. Reason: \#{inspect(reason)}"
228237
end
229238
end
239+
240+
# Unix Domain Socket request to Docker daemon
241+
docker_promise = HTTP.fetch("http://localhost/version", unix_socket: "/var/run/docker.sock")
242+
case HTTP.Promise.await(docker_promise) do
243+
%HTTP.Response{status: 200} = response ->
244+
case HTTP.Response.json(response) do
245+
{:ok, json} ->
246+
IO.puts "Docker Version: \#{json["Version"]}"
247+
IO.puts "API Version: \#{json["ApiVersion"]}"
248+
{:error, reason} ->
249+
IO.inspect reason, label: "JSON Parse Error"
250+
end
251+
{:error, reason} ->
252+
IO.inspect reason, label: "Docker Request Error"
253+
end
230254
"""
231255
@spec fetch(String.t() | URI.t(), Keyword.t() | map()) :: %HTTP.Promise{}
232256
def fetch(url, init \\ []) do
@@ -245,8 +269,9 @@ defmodule HTTP do
245269
options: Keyword.merge(Request.__struct__().options, options.opts)
246270
}
247271

248-
# Extract AbortController PID from FetchOptions
272+
# Extract AbortController PID and unix_socket from FetchOptions
249273
abort_controller_pid = options.signal
274+
unix_socket_path = options.unix_socket
250275

251276
# Emit telemetry event for request start
252277
HTTP.Telemetry.request_start(request.method, request.url, request.headers)
@@ -257,7 +282,7 @@ defmodule HTTP do
257282
:http_fetch_task_supervisor,
258283
HTTP,
259284
:handle_async_request,
260-
[request, self(), abort_controller_pid]
285+
[request, self(), abort_controller_pid, unix_socket_path]
261286
)
262287

263288
# Wrap the task in our new Promise struct
@@ -385,11 +410,50 @@ defmodule HTTP do
385410
@spec handle_async_request(
386411
Request.t(),
387412
pid(),
388-
pid() | nil
413+
pid() | nil,
414+
String.t() | nil
389415
) :: Response.t() | {:error, term()}
390-
def handle_async_request(request, _calling_pid, abort_controller_pid) do
416+
def handle_async_request(request, _calling_pid, abort_controller_pid, unix_socket_path \\ nil) do
391417
start_time = System.monotonic_time(:microsecond)
392418

419+
# If unix_socket_path is provided, use Unix socket transport
420+
if unix_socket_path do
421+
handle_unix_socket_request(request, unix_socket_path, start_time)
422+
else
423+
handle_httpc_request(request, abort_controller_pid, start_time)
424+
end
425+
end
426+
427+
# Handle Unix Domain Socket requests
428+
defp handle_unix_socket_request(request, socket_path, start_time) do
429+
try do
430+
# Get timeout from request options
431+
timeout = Keyword.get(request.http_options, :timeout, 30_000)
432+
433+
case HTTP.UnixSocket.request(socket_path, request, timeout) do
434+
{:ok, response} ->
435+
# Emit telemetry event for request completion
436+
duration = System.monotonic_time(:microsecond) - start_time
437+
body_size = if is_binary(response.body), do: byte_size(response.body), else: 0
438+
HTTP.Telemetry.request_stop(response.status, request.url, body_size, duration)
439+
response
440+
441+
{:error, reason} ->
442+
duration = System.monotonic_time(:microsecond) - start_time
443+
HTTP.Telemetry.request_exception(request.url, reason, duration)
444+
throw(reason)
445+
end
446+
catch
447+
reason ->
448+
duration = System.monotonic_time(:microsecond) - start_time
449+
HTTP.Telemetry.request_exception(request.url, reason, duration)
450+
{:error, reason}
451+
end
452+
end
453+
454+
# Handle regular HTTP/HTTPS requests via :httpc
455+
defp handle_httpc_request(request, abort_controller_pid, start_time) do
456+
393457
# Use a try/catch block to convert `throw` from handle_httpc_response into an {:error, reason} tuple
394458
try do
395459
case Request.to_httpc_args(request) do

lib/http/fetch_options.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ defmodule HTTP.FetchOptions do
8989
options: [],
9090
opts: [sync: false],
9191
signal: nil,
92+
unix_socket: nil,
9293
timeout: nil,
9394
connect_timeout: nil,
9495
ssl: nil,
@@ -112,6 +113,7 @@ defmodule HTTP.FetchOptions do
112113
options: keyword(),
113114
opts: keyword(),
114115
signal: any() | nil,
116+
unix_socket: String.t() | nil,
115117
timeout: integer() | nil,
116118
connect_timeout: integer() | nil,
117119
ssl: list() | nil,
@@ -230,6 +232,9 @@ defmodule HTTP.FetchOptions do
230232
{:signal, signal}, acc ->
231233
%{acc | signal: signal}
232234

235+
{:unix_socket, unix_socket}, acc ->
236+
%{acc | unix_socket: unix_socket}
237+
233238
{:timeout, timeout}, acc ->
234239
%{acc | timeout: timeout}
235240

0 commit comments

Comments
 (0)