Humbug OpenAPI client
use OpenAPI::Humbug;
my $humbug = OpenAPI::Humbug->new(
spec => 'openapi.yaml',
executable => '/usr/local/bin/humbug', # optional, otherwise PATH
);
my $result = $humbug->validate(
request => {
method => 'POST',
url => 'https://api.example.test/users',
headers => [
['Content-Type', 'application/json'],
['X-Trace', 'one'],
['X-Trace', 'two'],
],
body => qq|{"age":-1}|,
},
response => {
status => 201,
headers => [['Content-Type', 'application/json']],
body => qq|{"id":42}|,
},
);
if(!$result->{valid}) {
for my $d ($result->{request}{diagnostics}->@*) {
next if defined $d->{suppressed_by};
warn "$d->{code}: $d->{message}\n";
}
}
$humbug->close;This module validates captured HTTP transactions against an OpenAPI 3.1 specification using humbug, a validator that runs as a persistent child process and speaks a line-oriented JSON protocol over its stdin and stdout.
The Perl side only manages the process and carries data faithfully; all OpenAPI semantics live in the sidecar. Each client starts one child, loads one specification into it once, and then validates any number of transactions through it, one at a time.
The humbug executable is a separate program that must be installed on its own; see
"INSTALLING HUMBUG".
my $humbug = OpenAPI::Humbug->new(%options);Starts the sidecar, initializes it with the specification, and waits for it to confirm. Throws an
OpenAPI::Humbug::Error on any failure (for example kind sidecar with code
spec.invalid for a broken specification), after terminating and reaping the child.
Options:
-
spec
Required. The path of a local OpenAPI 3.1 specification file. A relative path is resolved against the current working directory at the time
newis called, and the absolute path is sent to the sidecar, so changing directory later has no effect. The child also inherits the working directory from the caller. -
executable
The
humbugexecutable. If it contains a slash it is used as is; otherwise it is looked up inPATH. Defaults tohumbug. The executable is always run directly, never through a shell. -
args
An array reference of extra command-line arguments for the executable, for example
['-max-body-bytes', 1048576]. Resource limits are set this way, never over the protocol. -
timeout
How long to wait for the reply to any one command (including
initialize), in seconds. Defaults to 60. A timeout terminates the child and makes the client unusable; the command is not retried. -
max_reply_bytes
The longest reply line accepted from the sidecar, in bytes, to bound memory if the child misbehaves. Defaults to 64 MiB.
-
max_stderr_bytes
How much of the sidecar's stderr to keep (the most recent bytes). stderr is always drained so a noisy child cannot block; only this much is retained. Defaults to 64 KiB.
my $hash = $humbug->specification;The specification object from the initialize reply, such as openapi_version and title.
my $hash = $humbug->limits;The resource limits the sidecar reported, such as max_body_bytes, or undef if it reported
none. The set of keys belongs to the sidecar; treat a missing key as unknown.
my $hash = $humbug->implementation;The implementation object from the initialize reply (name, version, libraries), or
undef. It is for logs and bug reports; do not branch on it.
my $text = $humbug->stderr;The most recent bytes (up to max_stderr_bytes) the sidecar wrote to stderr. A healthy sidecar
normally writes nothing.
The constructor options, with defaults applied. spec is the absolute path sent to the sidecar
and executable the path that was run. Changing them after construction has no effect on the
running child, except that timeout applies to later commands.
my $result = $humbug->validate(
request => \%request,
response => \%response, # optional
validate => { request => 1, response => 1 }, # optional
);
# Mojolicious messages, in place of either hash
my $result = $humbug->validate(request => $tx->req, response => $tx->res);
# a Mojolicious transaction, in place of both
my $result = $humbug->validate($tx);
my $result = $humbug->validate($tx, validate => { response => 0 });
# HTTP::Request and HTTP::Response, in place of either hash
my $result = $humbug->validate(request => $http_request, response => $http_response);
# an HTTP::Response with its associated request ($http_response->request)
my $result = $humbug->validate(response => $http_response);Validates one HTTP transaction and returns the sidecar's complete result object as a hash
reference. A transaction that violates the specification, or that could not be fully checked, is
still a result; it is never an exception.
request and response may each be a hash reference, as described below, a
Mojo::Message::Request or Mojo::Message::Response, or an HTTP::Request or
HTTP::Response; a Mojo::Transaction::HTTP may be passed first in place of both. An
HTTP::Response given without a request brings its own. See "MOJOLICIOUS" and
"HTTP::MESSAGE" for how those are converted.
The request is a hash reference with these keys:
-
method
Required. The HTTP method as captured. Case is not normalized.
-
url
Required. The effective absolute URL as captured (scheme, host, path and query; no fragment). It is not normalized.
-
headers
Optional. The header lines as an array reference of
[$name, $value]pairs; see "HEADERS". -
body
Optional. The decoded body as a byte string; see "BODIES".
The response, if given, has the same headers and body keys, plus:
-
status
Required. The integer status code, 100 to 599.
validate selects which sides are checked: request defaults to true, and response defaults
to true if and only if a response was given. Values are interpreted as Perl booleans. Asking for the
response without a response, or turning both off, is refused by the sidecar.
Unknown keys, references where strings are expected, and similar mistakes throw an exception of
kind argument before anything is sent.
$humbug->close;
Sends shutdown if the child is still usable, closes the pipes, and reaps the child, terminating
it if it does not exit promptly (after the smaller of timeout and 5 seconds, then SIGTERM, then
SIGKILL). Returns true if the child exited cleanly with status 0. Calling it again is harmless.
It does not throw.
Dropping the last reference to the client does the same on a best-effort basis, but an explicit
close is recommended.
Mojolicious is optional. It is never loaded by this module; when a Mojo::Message::Request, Mojo::Message::Response or Mojo::Transaction::HTTP is passed to "validate", it is converted to the request and response hashes described there, and then validated exactly like them. The Mojo objects are only read, never changed. Mojolicious 9.0 or later is supported.
-
Requests
The method is sent as is. The URL is Mojo's effective absolute URL (
$req->url->to_abs) without its fragment or credentials. A relative request URL is resolved against the base URL Mojo keeps for it: on the server, from the connection and theHostheader; for a request parsed from raw text, fromHostwith schemehttp. If Mojo has no scheme and host to resolve it against,validatethrows anargumenterror rather than guess. Mojo stores the path and query as received, but percent-encodes characters that are not allowed in a URL. -
Responses
The status code is required: a response Mojo has not received or generated yet is an
argumenterror. -
Headers
Every header line is sent, and repeated lines (
Set-Cookiein particular) stay separate lines in their original order. Two things are lost inside Mojo before this module sees the message: the relative order of lines with different names (Mojo groups them by name and sorts the names), and the original casing of common names (Mojo uses its canonical casing, such asContent-Type, and otherwise the casing of the first line with that name). A folded (multi-line) value is joined with a space. Header values are the bytes Mojo holds. -
Bodies
The body is the bytes Mojo holds for the message, never re-serialized. Mojo has already removed chunked transfer encoding, and, where it decompressed a body (its user agent does so for
gzipresponses), it also removed theContent-Encodingheader; a body Mojo did not decompress is sent as is, still encoded. Some bodies have no faithful bytes, and are anargumenterror: a body that is still being received, a streamed (dynamic) body, and amultipartbody, which Mojo keeps only as parsed parts.Mojo does not record whether a message had no body or an empty one, so the HTTP rules decide (RFC 9112 section 6.3). A request has a body if it has data, a
Content-Lengthor aTransfer-Encoding; otherwise it has none. A response toHEAD(when the request is known), and a1xx,204or304response, has no body; any other response has one, possibly empty. -
Transactions
validate($tx)validates$tx->req, and$tx->resonly if the response is complete: it has a status code and Mojo has finished it, that is received it in full (client side) or sent it in full (server side). Otherwise the response is left out, and only the request is checked; asking for it withvalidate => { response => 1 }is then anargumenterror. On the server, before the response has been sent (in anafter_dispatchhook, for example), passrequest => $tx->req, response => $tx->resinstead. Onlyvalidatemay follow the transaction.
OpenAPI::Humbug::Adapter::Mojo does the conversion; it is internal and not a supported interface.
HTTP::Message is optional too. It is never loaded by this module; when an HTTP::Request or
HTTP::Response is passed to "validate" as request or response, it is converted to the
request and response hashes described there, and then validated exactly like them. The objects are
only read, never changed. HTTP::Message 6.00 or later is supported. An HTTP::Response must be
passed by name; validate($http_response) is an argument error.
-
The request
With
response => $http_responseand norequest, the request is the response's associated request,$http_response->request(which LWP::UserAgent sets to the request that produced the response, the last one after redirects). It is used even when only the response is checked (validate => { request => 0 }), because the sidecar needs it to find the operation. A response with no associated request is anargumenterror. Arequestpassed explicitly, as an object or a hash, is always used instead, even when it differs. A response hash on its own is still an error, as before. -
Requests
The method is sent as is. The URL is the request's URI without its fragment or credentials. It must be absolute, with a scheme and a host: HTTP::Request has no base URL to resolve a relative URI against, and a
Hostheader gives no scheme, so a relative URI is anargumenterror rather than a guess. -
Headers
Every header line is sent, and repeated lines (
Set-Cookiein particular) stay separate lines in their original order. HTTP::Headers does not keep the rest of the wire form: it groups lines by name, orders the names its own way (general, request, response and entity headers, then the others alphabetically), and normalizes the casing of names (content-typebecomesContent-Type). A folded (multi-line) value is joined with a space, as RFC 9112 section 5.2 asks of a recipient. Values are the strings HTTP::Headers holds; an object, such as a URI, is stringified. -
Bodies
The body is the raw
content, neverdecoded_content: no character orContent-Encodingdecoding, and no re-serialization. As with any body, agzipbody must be decoded before it is validated (see "BODIES"). Two bodies have no bytes to send and are anargumenterror: content produced by a code reference, and a body held as parts (built withpartsoradd_partand not yet generated, which would change the message).HTTP::Message does not record whether a message had no body or an empty one, so the same HTTP rules as for Mojolicious decide (RFC 9112 section 6.3). A request has a body if it has content, a
Content-Lengthor aTransfer-Encoding. A response toHEAD, and a1xx,204or304response, has no body; any other response has one, possibly empty.
OpenAPI::Humbug::Adapter::HTTPMessage does the conversion; it is internal and not a supported interface.
Headers are an ordered array reference of [$name, $value] pairs, never a hash, because HTTP
headers are ordered lines that may repeat:
headers => [
['Content-Type', 'application/json'],
['Set-Cookie', 'a=1'],
['Set-Cookie', 'b=2'],
['X-Empty', ''],
],Order, repeats, name casing, empty values and whitespace are sent exactly as given. Lines are never
combined (in particular Set-Cookie), split, trimmed or merged. Every pair must have
exactly two elements.
Header values are byte strings. Every value is sent base64-encoded, which the protocol allows for
any value, so values that are not valid UTF-8 travel unchanged. Names are sent as text and never
encoded; a header name is ASCII in practice, and the sidecar rejects one that is not an HTTP token
with a validator.unsupported_input error.
body is the HTTP body as a byte string, after any transfer and content decoding (gzip and so
on): exactly the bytes the application saw. It is not a Perl data structure; pass
encode_json($data) yourself if that is what you have, and remember that the sidecar judges the
bytes it receives. References are rejected rather than serialized.
- A missing
bodykey, orbody => undef, means the message has no body. body => ''means the message has a body of zero bytes. The two are not the same: for an operation that requires a body, the first is reported asbody.requiredand the second asbody.decode.
The body is never parsed, re-serialized, compressed or decompressed. A Content-Encoding header
is passed through but ignored by the sidecar, so send decoded bytes. Diagnostic spans are byte
offsets into the body you sent.
Header values and bodies are bytes. If you have a Perl character string (for example JSON text
from a decoder), encode it first with Encode::encode('UTF-8', $text). A string containing a
character above 0xFF cannot be bytes and is rejected with an argument error rather than
guessed at. A string with Perl's internal UTF-8 flag on but only characters up to 0xFF is
treated as the bytes those characters denote, as Perl's own I/O would.
method, url and header names are text. They are encoded as UTF-8 in the JSON sent to the
sidecar. An effective URL is ASCII (percent-encoded) as it appeared on the wire, so send it that
way.
validate returns the sidecar's result object unchanged, with JSON booleans as Perl's
true and false. Unknown fields and diagnostic codes are preserved. The important fields:
-
valid,complete,has_violationsFor the transaction (over the sides that were checked).
validis true only if the checks completed and found no violations. Decide onvalid, not onhas_violationsalone. -
request,responseEach side has a
status(valid,invalid,incompleteorskipped), its ownvalid,completeandhas_violations, and an ordereddiagnosticsarray. A side that was not checked isskippedand nevervalid. -
diagnostics
Each has
id,code,severity(error,warningorinfo),message, and where relevantinstance,schema,detail,operationand the relationshipsparent,children,caused_byandsuppressed_by. Suppressed diagnostics are kept; hide them in a display if you like. Warnings and info do not make a transaction invalid. Use the machine-readable fields, nevermessage.
See the sidecar's diagnostics documentation for the codes. The findings for a given input may change between sidecar releases.
There are three different outcomes, and they must not be confused:
-
Invalid
validatereturns a result withvalidfalse andhas_violationstrue: the transaction violates the specification. -
Incomplete
validatereturns a result withcompletefalse: something could not be checked (a body over the size limit, an unsupported media type, an unknown operation, ...). Such a result is not valid, but not necessarily a violation either.validation.incompletediagnostics say why. -
Failure
validatethrows an OpenAPI::Humbug::Error: there is no verdict at all. Itskindsays whether the arguments were bad (argument), the sidecar refused the command (sidecar, with the sidecar'scode), or the connection or protocol failed (transport,protocol,timeout). A failure is never reported as a valid or invalid result.
After an argument or sidecar error the client can be used again. After a transport,
protocol or timeout failure the child is terminated and the client is unusable: further calls
throw closed. Nothing is restarted or retried automatically; construct a new client if you want
to continue.
newstarts one child and initializes it with one specification. The specification cannot be changed afterwards; create another client for another specification.validatemay be called any number of times. Commands are sent one at a time.closeshuts the child down and reaps it. Destroying the object does the same, best effort.- A client must not be shared between threads, or used in a child process after
fork; create a new client in each process. A forked copy of the object never touches the parent's child.
The humbug executable comes from go-humbug and is a
separate, runtime-only prerequisite; this distribution never builds or downloads it, and Go is not
needed at runtime. With Go installed:
go install github.com/uperl/go-humbug/cmd/humbug@latest
or build it from a checkout with make build and pass the path as executable.
The unit tests use a fake sidecar and need neither Go nor humbug. The integration tests run
against a real humbug if OPENAPI_HUMBUG_EXECUTABLE names one, and are skipped otherwise:
OPENAPI_HUMBUG_EXECUTABLE=/path/to/humbug prove -lv t/integration.t
Cpanel::JSON::XS 4.38 or later is used if it is installed, and JSON::PP otherwise. Either way results are the same. Cpanel::JSON::XS is optional, but recommended for large bodies or many transactions.
Linux and macOS. Windows is not supported yet.
Graham Ollis plicease@cpan.org
This software is copyright (c) 2026 by Graham Ollis.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.