API-Docker
view release on metacpan or search on metacpan
lib/API/Docker/Role/Filters.pm view on Meta::CPAN
package API::Docker::Role::Filters;
# ABSTRACT: The filters query parameter, normalised into the one shape the engine reads
our $VERSION = '0.004';
use Moo::Role;
use Carp qw( croak );
use namespace::clean;
# The boolean classes JSON::MaybeXS hands back across its backends. Named
# rather than duck-typed: a blessed object that merely overloads bool is not
# a claim of being a JSON boolean.
my %BOOLEAN_CLASS = map { $_ => 1 } qw(
JSON::PP::Boolean
Types::Serialiser::Boolean
);
sub _normalise_filters {
my ($self, $filters) = @_;
croak __PACKAGE__ . '->_normalise_filters filters must be a HashRef of '
. 'filter name to value, e.g. { dangling => [\'true\'] }'
unless ref $filters eq 'HASH';
my %normalised;
for my $name (sort keys %$filters) {
croak __PACKAGE__ . '->_normalise_filters filter name must not be empty'
unless length $name;
my $value = $filters->{$name};
# A bare value is one value, not a mistake worth refusing -- the engine
# is the one that insists on the list.
my @values = ref $value eq 'ARRAY' ? @$value : ($value);
$normalised{$name} = [ map { $self->_normalise_filter_value($name, $_) } @values ];
}
return \%normalised;
}
sub _normalise_filter_value {
my ($self, $name, $value) = @_;
my $where = __PACKAGE__ . '->_normalise_filters filter \'' . $name . '\' ';
croak $where . 'has an undefined value; the engine reads a JSON null into '
. 'a string as the empty string and rejects it there'
unless defined $value;
my $ref = ref $value;
return $value ? 'true' : 'false' if $BOOLEAN_CLASS{$ref};
if ($ref eq 'SCALAR') {
croak $where . 'is a ScalarRef to something other than 1 or 0; \\1 and '
. '\\0 are read as the booleans \'true\' and \'false\''
unless $$value eq '1' || $$value eq '0';
return $$value ? 'true' : 'false';
}
croak $where . 'has a ' . $ref . ' reference as a value; filter values are '
. 'strings, or an ArrayRef of them' if $ref;
croak $where . 'has an empty value; the engine rejects it. A Perl boolean '
. 'stringifies to \'\' when false -- the engine wants the string '
. '\'false\''
unless length $value;
# Stringify a copy: a scalar carrying a number would otherwise be
# JSON-encoded as one, and the engine's filter values are strings.
return "$value";
}
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
API::Docker::Role::Filters - The filters query parameter, normalised into the one shape the engine reads
=head1 VERSION
version 0.004
=head1 SYNOPSIS
package API::Docker::API::Whatever;
use Moo;
with 'API::Docker::Role::Filters';
sub list {
my ($self, %opts) = @_;
my %params;
$params{filters} = $self->_normalise_filters($opts{filters})
if defined $opts{filters};
return $self->client->get('/whatever', params => \%params);
}
=head1 DESCRIPTION
Every C<list> and C<prune> endpoint of the Engine API takes a C<filters>
query parameter, and every one of them wants the same thing: a JSON B<map of
string to array of string>.
filters => { dangling => ['true'] } correct
filters => { dangling => 'true' } wrong -- not an array
filters => { dangling => 1 } wrong -- not an array
filters => { dangling => [1] } wrong -- a number, not a string
filters => { dangling => [\1] } wrong -- a JSON boolean
The transport JSON-encodes a HashRef C<params> value on its own, so the
I<encoding> was never the problem. The I<shape> is, and it is the thing
clients get wrong, because Perl has no notion of "array of string" and a
HashRef literal will happily hold whatever the caller typed.
This role normalises that shape in one place, so the twelve methods that
accept C<filters> agree on it and document it by pointing here.
=head2 What it does
=over
=item * A value that is not an ArrayRef is wrapped into a one-element one, so
C<< { dangling => 'true' } >> means what it looks like it means.
=item * Each element is stringified, so C<< { stars => [3] } >> reaches the
wire as C<"3"> rather than as the number C<3>.
=item * A JSON boolean object (C<< JSON->true >>, C<< JSON->false >>) and the
ScalarRef form this distribution uses for JSON request bodies (C<\1>, C<\0>)
become the strings C<'true'> and C<'false'>.
=item * Anything else -- another ref, C<undef>, an empty string -- croaks.
=back
The result is a fresh HashRef; the caller's is never modified.
=head2 Why the boolean rewrite is bound to the type and not to the value
A helper that rewrote every true-ish value to C<'true'> would be wrong more
often than it was right. C<< { exited => [0] } >> asks for containers that
exited with status 0, C<< { stars => [0] } >> for images with no stars, and
C<< { label => [1] } >> for a label whose value is C<1> -- rewriting any of
those to C<'false'>/C<'true'> would silently ask a different question.
Binding it to the filter I<name> instead would need a table of which names
are boolean, per endpoint, kept in step with the daemon -- see
L</"What it deliberately does not do">.
So the rewrite is bound to the value's B<type>: a plain Perl C<1> carries no
claim of being a boolean and becomes the string C<"1">, while
C<< JSON->true >> and C<\1> carry exactly that claim, and are also the two
forms C<encode_json> would otherwise turn into a JSON C<true> -- which the
daemon rejects outright.
That leaves one form this role cannot recognise: perl 5.36's core booleans,
where C<< !!1 >> and C<< $x == $y >> produce a boolean the JSON encoder also
writes as C<true>. Stringified, those are C<"1"> and C<""> -- and C<"1"> is a
value the daemon reads as true, so only the false one needs help. It is the
reason an empty string croaks here rather than travelling on.
=head2 What it deliberately does not do
It does not check filter B<names>. Doing so would need one accepted-name
table per endpoint, and the daemon already has them: measured against Podman
5.x (API 1.41), an unknown name is refused with HTTP 500 by
C</containers/json> (C<bogusname is an invalid filter>), C</images/json>
(C<invalid image filter "danglin">), C</volumes>, C</networks> and
C</events>, and Docker validates C</plugins> the same way -- which is how the
Engine API reference's documented C<enable> turns out to be a hard error
where the daemon wants C<enabled> (see
L<API::Docker::API::Plugins/list>). A client-side table would duplicate that
check, and the first time it lagged the daemon it would refuse a filter the
daemon accepts. That is a worse failure than the one it prevents.
It also does not check that a value makes sense for its filter. C<'yes'> for
C<dangling> is a well-formed filter that the daemon rejects
(C<strconv.ParseBool: parsing "yes">), and C<< { label => ['nope'] } >> is a
well-formed filter that simply matches nothing. Both are the caller's
question to get right.
=head2 The daemon's side of each rule
Every rule above is a measured response, not a reading of the reference.
Against Podman on API 1.41, C<< GET /images/json >>:
{"dangling":["true"]} 200, the dangling images
{"dangling":"true"} 500 json: cannot unmarshal string into Go value
of type []string
{"dangling":true} 500 json: cannot unmarshal bool into Go value of
type []string
{"dangling":[true]} 500 json: cannot unmarshal bool into Go value of
type string
{"dangling":[1]} 500 json: cannot unmarshal number into Go value
of type string
{"dangling":[null]} 500 non-boolean value for filter:
strconv.ParseBool: parsing ""
{"dangling":[""]} 500 the same -- Go reads a JSON null into a
string as ""
{"dangling":["1"]} 200, and so do "0", "true" and "false"
So a wrong shape is not silent on this engine -- it is a 500 carrying a Go
type error, one round trip later, naming neither the option nor the key the
caller got wrong. What this role changes is where that is said: at the call,
in terms of the argument, and for the recoverable shapes not at all, because
they are repaired instead.
=head1 METHODS
C<_normalise_filters($filters)> is private and composed into the resource
classes. It takes what the caller passed as C<filters> and returns the
HashRef to hand to the transport as a query parameter; it croaks rather than
sending a shape the daemon will refuse.
=head1 SEE ALSO
=over
=item * L<API::Docker::API::Containers> - C<list>, C<prune>
=item * L<API::Docker::API::Images> - C<list>, C<search>, C<prune>,
C<build_prune>
=item * L<API::Docker::API::Networks> - C<list>, C<prune>
=item * L<API::Docker::API::Volumes> - C<list>, C<prune>
=item * L<API::Docker::API::System> - C<events>
=item * L<API::Docker::API::Secrets> - C<list>
=item * L<API::Docker::API::Configs> - C<list>
=item * L<API::Docker::API::Plugins> - C<list>, whose filter names the daemon
validates
=back
=head1 SUPPORT
=head2 Issues
Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/p5-api-docker/issues>.
=head1 CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
=head1 AUTHOR
Torsten Raudssus <getty@cpan.org>
=head1 COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> L<https://raudssus.de/>.
This is free software; you can redistribute it and/or modify it under
( run in 0.855 second using v1.01-cache-2.11-cpan-364913b4093 )