NAME

API::Docker::Role::HTTP - HTTP transport role for Docker Engine API

VERSION

version 0.003

SYNOPSIS

package MyDockerClient;
use Moo;

has host => (is => 'ro', required => 1);
has api_version => (is => 'ro');

with 'API::Docker::Role::HTTP';

# Now use get, post, put, delete_request methods
my $data = $self->get('/containers/json');

DESCRIPTION

This role provides HTTP transport for the Docker Engine API. It implements HTTP/1.1 communication over Unix sockets and TCP sockets without depending on heavy HTTP client libraries like LWP.

Features:

  • Unix socket transport (unix://...)

  • TCP socket transport (tcp://host:port)

  • HTTP/1.1 chunked transfer encoding

  • Automatic JSON encoding/decoding

  • Newline-delimited JSON event streams (ndjson => 1), including the failures the engine reports inside an HTTP 200 body

  • Demultiplexing of the Docker stream format ("stream_frames")

  • Request/response logging via Log::Any

  • Automatic connection management

Consuming classes must provide host and api_version attributes.

Both transports are plaintext. There is no TLS here: a tcp:// host is spoken to in the clear, whatever "tls" in API::Docker and "cert_path" in API::Docker are set to. tls => 1 croaks at construction rather than pretending otherwise -- put a TLS terminator in front of the daemon and point host at that.

get

my $data = $client->get($path, %opts);

Perform HTTP GET request. Returns decoded JSON or raw response body.

Options:

  • params - HashRef of query parameters; a HashRef value is JSON-encoded

  • headers - HashRef of extra HTTP headers, e.g. { 'X-Registry-Auth' => $b64 }

  • ndjson - Parse the body as newline-delimited JSON and always return an ArrayRef of events, even for a stream carrying a single object. Named for the format rather than stream, which is already a query parameter of /events and /containers/{id}/stats. An errorDetail event in such a stream croaks; see "Failure inside a 200 response"

  • croak_on_error - Default true, and only consulted with ndjson => 1. Set it false for a stream whose objects are engine data rather than the outcome of one operation -- /events is the only such endpoint here

  • raw - Never decode the body; return the response bytes verbatim

  • headers names are validated, not sanitised; see "Header names are rejected, header values are stripped"

Failure inside a 200 response

/build, /images/create (pull) and /images/{name}/push report a failed operation as an errorDetail object inside a stream the daemon already answered with HTTP 200. The status line is committed before the operation is attempted, so the >= 400 check above cannot see it, and a client that trusts the status hands a broken build back as a success.

So an ndjson => 1 request scans the decoded events and croaks with an API::Docker::Error::Stream the moment one carries errorDetail. That object stringifies to the reason plus Carp's usual location suffix, so eval-and-inspect-$@ code cannot tell it from the plain croak it replaces; $err->events carries the complete event list, so the progress output that led up to the failure is not lost with the return value.

The trigger is the errorDetail key alone. The flat error key the engine sends beside it holds the same text and is used only as a fallback message, never as the trigger on its own.

croak_on_error => 0 turns the scan off for a stream that is a feed rather than an operation. The check is on by default, and opting out is per endpoint, because the set of operation-shaped streaming endpoints is open-ended while the feed-shaped ones are /events and nothing else: a new endpoint added without a thought about this gets the loud behaviour, not the silent one.

Header names are rejected, header values are stripped

A CR or LF in a header value is stripped and the value is flattened onto its own line. A header name that is not an RFC 9110 token is refused with a croak instead.

The asymmetry is deliberate. A value can pick up a stray newline honestly -- MIME::Base64::encode_base64 wraps its output by default, and a token pasted out of a file brings its line ending along -- and flattening it preserves what the caller meant. A name is a literal the programmer wrote; there is no benign way for one to contain CR, LF, a space or a colon, and quietly rewriting "X-Foo\r\nX-Bar" into X-FooX-Bar would put a header on the wire under a name nobody asked for. Validating against the token grammar also catches the separators that would corrupt the request without injecting anything.

post

my $data = $client->post($path, $body, %opts);

Perform HTTP POST request. $body is automatically JSON-encoded if provided.

Options: params, headers, ndjson, croak_on_error and raw as for "get", plus raw_body and content_type for sending a non-JSON payload such as a build context tarball.

put

my $data = $client->put($path, $body, %opts);

Perform HTTP PUT request. $body is automatically JSON-encoded if provided.

Options: params (hashref of query parameters).

delete_request

my $data = $client->delete_request($path, %opts);

Perform HTTP DELETE request.

Options: params (hashref of query parameters).

stream_frames

my $frames = $client->stream_frames('GET', "/containers/$id/logs", %opts);

Perform a request against one of the engine's framed endpoints (/containers/{id}/logs, /exec/{id}/start) and return an ArrayRef of frames:

[ { stream => 'stdout', data => "OUT\n" },
  { stream => 'stderr', data => "ERR\n" } ]

stream is stdout, stderr or stdin for a multiplexed stream, and raw for an unframed one. It is always a plain string, so callers never need a defined-check. Joining the payloads gives the plain text:

my $text = join '', map { $_->{data} } @$frames;

The response body is never JSON-decoded, so a container printing JSON lines is returned verbatim.

Options are those of _request (params, body, headers), plus:

  • tty - Skip demultiplexing and return the body as a single raw frame. Set it when the container or exec instance was created with a TTY and its output is binary; see "Detecting a framed stream" for why.

Detecting a framed stream

A container created without a TTY produces the Docker stream format -- an 8-byte header per frame (byte 0 the stream type, bytes 4-7 a big-endian uint32 payload length) followed by that many payload bytes. With a TTY there is no header and the payload is raw pty output.

The engine is supposed to distinguish the two with the response Content-Type (application/vnd.docker.multiplexed-stream against application/vnd.docker.raw-stream), but that signal is not dependable. Measured against Podman 5.4.2 (API 1.41): GET /containers/{id}/logs sends no Content-Type at all, for either kind of container, and POST /exec/{id}/start sends application/vnd.docker.raw-stream for both -- including the non-TTY exec whose body is in fact multiplexed. Trusting the header would therefore hand frame headers to the caller on that engine.

The framing is decided from the bytes instead. The body is walked as frames: each header must have a stream type of 0, 1 or 2, three zero bytes after it, and a payload length that leaves at least that many bytes in the buffer. The body is treated as framed only when the walk consumes it exactly and yields at least one frame; anything else is returned as a single raw frame.

This can be fooled in one direction only. Raw TTY output is misread as framed if it begins with a byte no greater than 0x02, followed by three NUL bytes and a length that happens to chain exactly to the end of the body. Text output cannot do that -- a printable character is 0x20 or above -- so it takes binary output from a TTY-allocated container. Pass tty => 1 for that case. The reverse mistake cannot happen silently: a genuine frame stream is only ever reported as raw when its final frame is truncated, which needs the daemon to close the connection mid-frame.

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-api-docker/issues.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.