API-Docker
view release on metacpan or search on metacpan
lib/API/Docker/Type.pm view on Meta::CPAN
sub import {
my ($class) = @_;
$class->_setup_class(scalar caller);
return;
}
# Per-class state that has to outlive the class's own compilation, because a
# generated class ends its body with `use namespace::clean`. That pragma takes
# a snapshot of the package's subs when it is reached and strips every one of
# them at the end of the class's compilation -- the imported Moo and type
# sugar, the two DSL keywords, AND anything a role composed at that point had
# already installed. Two consequences shape the setup below.
#
# has / extends -- The runtime `docker ...;` and `docker_extends ...;` lines
# fire after that cleanup, so by then `has` and `extends` are gone from the
# class and `$target->can('has')` returns undef. The `docker` keyword and
# the `Str`/`Int`/... it is handed survive the same cleanup only because
# Perl bound their CV into the call site at compile time; a name the DSL
# looks up by string at runtime has no such binding, so the two it resolves
# that way are captured here while they are still imported.
#
# the role -- API::Docker::Role::Type is NOT composed here. Composed at
# import (a BEGIN action) its methods and its two attributes would sit in
# namespace::clean's snapshot and be stripped with the sugar. So it is
# composed on the first `docker`/`docker_extends` call instead -- at
# runtime, after the cleanup, exactly as a `with 'Role'` line in a Moo
# class body would run. Every generated class issues at least one such
# call, and the composition lands before any object of the class is built.
my %CLASS_SUGAR;
sub _setup_class {
my ($class, $target) = @_;
Moo->import::into($target);
Types::Standard->import::into($target, qw( Any Bool Int Num Str ));
$CLASS_SUGAR{$target} = {
has => $target->can('has'),
extends => $target->can('extends'),
};
my $stash = Package::Stash->new($target);
$stash->add_symbol('&docker' => sub { $class->_docker($target, @_) });
$stash->add_symbol('&docker_extends' => sub { $class->_docker_extends($target, @_) });
return;
}
# Compose API::Docker::Role::Type once, on the first DSL call the class makes.
# Runtime, so it outlasts the class's `use namespace::clean`; idempotent, so
# the second and later DSL calls are a cheap flag check.
sub _ensure_role {
my ($target) = @_;
return if $CLASS_SUGAR{$target}{role_composed};
$CLASS_SUGAR{$target}{role_composed} = 1;
Moo::Role->apply_roles_to_package($target, 'API::Docker::Role::Type');
return;
}
# The one place a short class name becomes a full one. 'Mount' is
# API::Docker::Type::Mount; a name that already starts with the prefix is
# left alone; a leading + means "this is the full name, take it as it is".
# Docker's definitions are flat -- there are no groups to map, which is why
# there is no prefix table here and only this one rule.
sub _expand_class {
my ($short) = @_;
return substr($short, 1) if $short =~ /\A\+/;
return $short if $short =~ /\AAPI::Docker::Type::/;
return 'API::Docker::Type::' . $short;
}
# PortBindings <- port_bindings. One direction only; see the POD above.
sub _wire_from_perl {
my ($name) = @_;
return join '', map { ucfirst } split /_/, $name;
}
sub _is_type_tiny { return blessed($_[0]) && $_[0]->isa('Type::Tiny') }
# A type spec becomes a descriptor, recursively:
# { kind => 'scalar', scalar => 'Str' }
# { kind => 'object', class => 'API::Docker::Type::PortBinding' }
# { kind => 'array', inner => <descriptor> }
# { kind => 'hash', inner => <descriptor> } keys are caller data
# { kind => 'any' }
# One recursive shape rather than a flag per combination, so { Str,
# ['PortBinding'] } and [[Str]] need no cases of their own anywhere.
sub _parse_type {
my ($spec, $where) = @_;
if (_is_type_tiny($spec)) {
my $name = $spec->name;
return { kind => 'any' } if $name eq 'Any';
return { kind => 'scalar', scalar => $name } if $SCALAR_TYPE{$name};
croak __PACKAGE__ . ": $where has unsupported type " . $name;
}
if (ref $spec eq 'ARRAY') {
croak __PACKAGE__ . ": $where is an array type with "
. scalar(@$spec) . ' element types, it needs exactly one'
unless @$spec == 1;
return { kind => 'array', inner => _parse_type($spec->[0], $where) };
}
if (ref $spec eq 'HASH') {
my @keys = keys %$spec;
croak __PACKAGE__ . ": $where is a hash type; write it as { Str, \$value_type }"
unless @keys == 1 && $keys[0] eq 'Str';
return { kind => 'hash', inner => _parse_type($spec->{Str}, $where) };
}
if (!ref $spec) {
return { kind => 'any' } if $spec eq 'Any';
return { kind => 'scalar', scalar => $spec } if $SCALAR_TYPE{$spec};
return { kind => 'object', class => _expand_class($spec) };
}
croak __PACKAGE__ . ": $where has an unreadable type spec (" . ref($spec) . ')';
}
sub describe_type {
my ($d) = @_;
my $kind = $d->{kind};
return lc $d->{scalar} if $kind eq 'scalar';
return 'object<' . $d->{class} . '>' if $kind eq 'object';
return 'array<' . describe_type($d->{inner}) . '>' if $kind eq 'array';
return 'hash<' . describe_type($d->{inner}) . '>' if $kind eq 'hash';
return 'any';
}
# Nothing is required, so every attribute is Maybe[...]. Hash values and
# array elements are Maybe[...] too: the daemon really does answer
# "2377/tcp": null inside a PortMap, and croaking while inflating a response
# we could otherwise use is not an improvement.
sub _isa_for {
my ($d) = @_;
my $kind = $d->{kind};
return undef if $kind eq 'any';
return $SCALAR_TYPE{ $d->{scalar} } if $kind eq 'scalar';
return InstanceOf[ $d->{class} ] if $kind eq 'object';
my $inner = _isa_for($d->{inner});
return $kind eq 'array' ? ArrayRef : HashRef unless $inner;
return $kind eq 'array' ? ArrayRef[ Maybe[$inner] ] : HashRef[ Maybe[$inner] ];
}
# The boolean normalisation, borrowed from IO::K8s::Resource (../io-k8s-p5),
# which documents the two traps: every reference is true in Perl, so \0 and a
# JSON::PP::Boolean have to be dereferenced rather than tested; and 'false'
# is a non-empty string and therefore true, so the strings are spelled out.
#
# undef stays undef rather than becoming 0. Docker tells an absent flag apart
# from a false one, TO_JSON omits undef, and "no value" must not turn into an
# explicit false on the wire.
sub _normalize_bool {
my ($value) = @_;
if (ref $value) {
my $reftype = Scalar::Util::reftype($value);
croak __PACKAGE__ . ': a Bool wants a scalar or a scalar ref, got ' . $reftype
unless $reftype eq 'SCALAR' || $reftype eq 'REF';
$value = $$value;
croak __PACKAGE__ . ': a Bool scalar ref dereferenced to another reference ('
. ref($value) . '), not a boolean' if ref $value;
}
return undef unless defined $value;
return 0 if lc($value) eq 'false';
return $value ? 1 : 0;
}
# A hashref handed to an object-typed field is inflated the way the entry
# point that started the construction reads keys: through from_data while an
# engine response is being inflated, through new otherwise. So a nested
# literal in a request a caller assembled takes both spellings, and a nested
# object in a daemon response resolves wire names only -- the same
# distinction the two entry points draw at the top level, carried one level
# down. The class is loaded on first use rather than at declaration time: the
lib/API/Docker/Type.pm view on Meta::CPAN
unless defined $name && $name =~ /\A[a-z][a-z0-9_]*\z/;
croak __PACKAGE__ . ": '$name' in $target is a name this role already uses"
if $RESERVED{$name};
croak __PACKAGE__ . ": '$name' is declared twice in $target"
if $REGISTRY{$target} && $REGISTRY{$target}{$name};
my $wire = delete $opt{wire} // _wire_from_perl($name);
# The Perl-name guard above has a twin: _docker_wire_index maps a wire name
# to one Perl name, so a second field claiming a wire name would make the
# first unreachable on inflation while TO_JSON wrote both to that one key.
# None of the 201 generated classes does this; the generator could emit it
# the day a hand-picked `wire` collides with a derived one (karr k85).
if (my ($taken) = sort grep { $REGISTRY{$target}{$_}{wire} eq $wire }
keys %{ $REGISTRY{$target} || {} }) {
croak __PACKAGE__ . ": '$name' in $target asks for the wire name "
. "'$wire', which '$taken' already has";
}
my $since = delete $opt{since};
my $enum = delete $opt{enum};
my $required = delete $opt{required} ? 1 : 0;
croak __PACKAGE__ . ": '$name' in $target got unknown option(s): "
. join(', ', sort keys %opt) if %opt;
my $descriptor = _parse_type($type_spec, "$target\::$name");
my $isa = _isa_for($descriptor);
my $coerce = _coerce_for($descriptor);
$REGISTRY{$target}{$name} = {
name => $name,
wire => $wire,
type => $descriptor,
since => $since,
enum => $enum,
required => $required,
# The same two the Moo attribute below is given, kept so the response
# path can ask whether a value fits before handing it to the constructor
# rather than finding out by being croaked at (karr k83).
isa => $isa ? Maybe[$isa] : undef,
coerce => $coerce,
};
{
no strict 'refs';
push @{"${target}::_docker_attr_order"}, $name;
}
API::Docker::Role::Type::_invalidate_docker_cache($target);
my $has = $CLASS_SUGAR{$target}{has} // $target->can('has');
my $info = $REGISTRY{$target}{$name};
$has->($name,
is => 'rw',
($info->{isa} ? (isa => $info->{isa}) : ()),
($info->{coerce} ? (coerce => $info->{coerce}) : ()),
);
return;
}
sub _docker_extends {
my ($class, $target, @parents) = @_;
_ensure_role($target);
croak __PACKAGE__ . ": docker_extends in $target needs at least one class"
unless @parents;
my @full = map { use_module(_expand_class($_)) } @parents;
my $extends = $CLASS_SUGAR{$target}{extends} // $target->can('extends');
$extends->(@full);
API::Docker::Role::Type::_invalidate_docker_cache($target);
return;
}
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
API::Docker::Type - The DSL and attribute registry behind the generated Docker types
=head1 VERSION
version 0.004
=head1 SYNOPSIS
package API::Docker::Type::Mount;
use API::Docker::Type;
docker target => Str;
=attr target
Container path.
=cut
docker bind_options => 'Mount::BindOptions', since => '1.41';
docker labels => { Str, Str };
docker ulimits => [ 'Resources::Ulimit' ];
docker cpu_shares => Int, wire => 'CPUShares';
=head1 DESCRIPTION
C<API::Docker::Type> is imported, never inherited. Importing it pulls
L<Moo>, the type vocabulary and L<API::Docker::Role::Type> into the calling
package and installs two keywords, C<docker> and C<docker_extends>.
Every class under C<API::Docker::Type::*> is a Perl mirror of one entry
under C<definitions:> in Docker's swagger, which is checked into C<spec/>.
The classes are written from that specification and B<not> from a running
daemon; C<maint/spec-drift-check.pl> is what keeps that claim true.
=head2 What C<docker> does
docker $perl_name => $type;
docker $perl_name => $type, wire => 'CPUShares';
docker $perl_name => $type, since => '1.44';
docker $perl_name => $type, required => 1;
It declares a Moo attribute B<and> writes an entry into a package-level
registry. Both halves matter: the attribute is what a caller uses, the
lib/API/Docker/Type.pm view on Meta::CPAN
use API::Docker::Type;
docker_extends 'Resources';
C<allOf> in swagger means composition, and Perl inheritance says exactly
that. Nothing is duplicated: the parent's fields, their POD and the inline
classes declared inside the parent all stay in one place, and the merged
registry in L<API::Docker::Role::Type> presents C<HostConfig> with all ~70
fields. The alternative -- copying the parent's declarations into the child
-- would duplicate 31 attributes and their C<=attr> blocks and force the
inline classes underneath them to be named twice.
An C<allOf> holding a single C<$ref> and nothing else is not composition at
all; it is swagger's way of hanging a description on a C<$ref>
(C<Mount.Type> and C<MountPoint.Type> both do it). Such a field takes the
type of what it references, which for C<MountType> is C<Str>.
=head2 Inline objects become classes
A property whose schema is an object with its own C<properties>, or an array
whose C<items> are such an object, becomes a class named after the
definition that declares it:
Mount.BindOptions -> API::Docker::Type::Mount::BindOptions
Mount.VolumeOptions.DriverConfig
-> API::Docker::Type::Mount::VolumeOptions::DriverConfig
Resources.Ulimits[] -> API::Docker::Type::Resources::Ulimit
The last one is the exception to the mechanical rule: an array of inline
objects is named for one element, and turning C<Ulimits> into C<Ulimit> is a
judgement call, not a derivation. Those names live in
C<maint/spec-drift-exceptions.yaml> so the checker and a generator agree on
them.
=head2 A generated class loads what it references
Each class carries a plain C<use> for every other type class it names, so
loading C<API::Docker::Type::HostConfig> brings its whole subtree with it.
The declaration itself does B<not> load anything: a class named in a
C<docker> line is loaded lazily, on the first hashref that has to be
inflated into it. That is deliberate belt and braces -- v1.51's definitions
happen to have no reference cycles, and if a later version grows one the
C<use> for the back edge is what a generator has to leave out, while the
model keeps working either way.
=head1 THE TYPE VOCABULARY
Str Int Num Bool scalars
Any untyped; passed through as it arrived
[Str] an array of scalars
[[Str]] an array of arrays of scalars
['PortBinding'] an array of typed objects
'PortBinding' a single typed object
'+Some::Other::Class' the same, without the namespace prefix
{ Str, Str } a hash whose KEYS ARE CALLER DATA
{ Str, ['PortBinding'] } the same, with typed values
A bare class name is short: C<'PortBinding'> is
C<API::Docker::Type::PortBinding>, C<'Mount::BindOptions'> is
C<API::Docker::Type::Mount::BindOptions>. The expansion happens in
C<_expand_class> and nowhere else; a leading C<+> escapes it.
=head2 describe_type
API::Docker::Type::describe_type($info->{type}); # 'hash<array<object>>'
A descriptor as one string, for the drift checker's report. Objects render
as C<< object<Class> >>.
=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
the same terms as the Perl 5 programming language system itself.
=cut
( run in 1.040 second using v1.01-cache-2.11-cpan-302cb4679cc )