NAME
IO::K8s::Role::SpecBuilder - Role for deep-path spec manipulation on CRD objects
VERSION
version 1.110
SYNOPSIS
package My::App;
use Moo;
with 'IO::K8s::Role::SpecBuilder';
has spec => ( is => 'rw' );
sub BUILD { $_[0]->spec({ routes => [] }) }
package main;
my $app = My::App->new;
$app->spec_push('routes', { match => 'Host(`x`)' });
$app->spec_set('routes.0.match', 'Host(`y`)');
print $app->spec_get('routes.0.match');
DESCRIPTION
This role provides dotted-path get/set/push/merge/delete operations against a consumer class's spec attribute. It exists so callers building arbitrary CRDs (IngressRoute, HTTPRoute, Gateway listeners, ...) can reach deeply-nested fields without writing the indexing by hand each time; since 1.108 the built-in Kinds reach theirs the same way.
Path syntax: dot-separated segments. On a plain hashref/arrayref node each segment is a hash key or an array index; on an object node -- a typed spec, or any nested typed field reached along the way -- a segment is instead the JSON field name mapped to its attribute through the registry, or, for a key the class does not declare, read and written through the object's _unknown_fields bag (D1) exactly as it would be on a plain hash. A purely numeric segment is always an array index: it counts from the end when negative, so -1 addresses the last element, or (on an empty array) the one spec_set/spec_array/spec_push create there; any other index that resolves outside the array croaks rather than autovivifying a hole.
spec_set, spec_array, spec_hash and spec_push vivify missing intermediate structure as they walk: a plain hashref/arrayref on an untyped node, or, on a typed node, whatever the attribute registry declares for that field (an inline struct or referenced class, an array/hash container), with a hashref handed to a typed slot inflated the same way FROM_HASH does. spec_get and spec_delete never vivify.
Every failure the walk's own checks raise, or that vivifying or writing a declared field raises, begins with the spec path and carries no internal file or line -- spec path 'PATH': in front of the reason, or, for a path that is itself empty, spec path 'PATH' is empty on its own -- except inflating a hash into a typed field, which carries no such prefix (see below). A Moo/Type::Tiny failure hit while vivifying or writing a declared field is re-raised with that prefix, such as:
spec path 'PATH': cannot set 'SEG': ORIGINAL MESSAGE
spec path 'PATH': cannot create CLASS for 'SEG': ORIGINAL MESSAGE
spec path 'PATH': cannot clear 'SEG': ORIGINAL MESSAGE
spec path 'PATH': cannot set 'SEG' in 'FIELD': ORIGINAL MESSAGE
spec path 'PATH': cannot push onto 'FIELD': ORIGINAL MESSAGE
The last two are element writes into a typed collection: spec_push, an indexed spec_set, a map key written through spec_set, and spec_merge all run the value through the same coercion and type check the collection's declared field applies to a whole-field assignment, not a looser per-element rule -- ->spec_set('flags.0', 'false') on a field typed [Bool] stores plain 0, exactly as ->flags(['false']) would, and an element the field's type rejects croaks at the write instead of surviving until to_json serializes it. A multi-value spec_push checks and coerces every new value together before appending any of them, so one bad value among several leaves the array unchanged. This applies only to a collection reached through a declared field; an element inside the _unknown_fields bag or an Opaque spec value keeps the free JSON rules of a plain hash or array instead, unchecked.
The walk's own checks use the same spec path 'PATH': prefix for the failures they detect directly, too: an invalid or out-of-range array index, a scalar blocking further descent (whether hit while walking or while storing into it), and spec_array/spec_hash finding a non-array or scalar value already at the path. spec_merge bypasses the path machinery entirely and shallow-merges into the top level only.
A hash handed to a typed field by spec_set or spec_merge is inflated the way FROM_HASH inflates it, and an error that inflation raises -- a value of the wrong shape inside the hash, a missing required field -- comes without the prefix, as "inflate" in IO::K8s words it, but like every other failure here it names the line that called the spec_* method, not a line inside the distribution.
IO::K8s::Role::APIObject composes this role, so every top-level Kind has it: the built-in Kubernetes kinds, CRD classes declared via use IO::K8s::APIObject api_version => ..., ..., and the classes IO::K8s::AutoGen builds at runtime, which compose IO::K8s::Role::APIObject directly. Before 1.108 only CRD classes got it, and composing one of the builder roles (IO::K8s::Role::CertManaged, IO::K8s::Role::Routable, ...) onto a class without it failed at the first spec_* call rather than at composition time; those roles now require the spec_* methods they use, which is only correct because every APIObject has them.
A Kind that declares no spec field at all -- 32 of the shipped ones, carrying data/rules/subjects instead: ConfigMap, Secret, Endpoints, the four RBAC kinds, ... -- still has the methods, and every one of them croaks
IO::K8s::Api::Core::V1::ConfigMap has no spec field: the spec_* methods need one to read or build
naming the class, at the caller's line. Composition does not fail for those Kinds: this role is composed before the class's own k8s spec => ... line runs, so a requires 'spec' would reject every consumer, including the ones that do declare one.
Union fields
A field typed as one of the apiextensions union classes -- V1::JSON (values of a K3s HelmChart/HelmChartConfig, default, example and enum of a schema) or JSONSchemaPropsOrArray, JSONSchemaPropsOrBool, JSONSchemaPropsOrStringArray (items, additionalProperties, additionalItems, dependencies) -- serializes as the bare value it holds, and a spec path walks through it into that value the same way:
$chart->spec_set('values.replicaCount', 3); # values: {"replicaCount": 3}
$chart->spec_get('values.replicaCount'); # 3
$chart->spec_hash('values')->{debug} = 1; # values: {"debug": 1, ...}
$schema->spec_set('items.type', 'string'); # items: {"type": "string"}
A V1::JSON value is free JSON: a hash takes keys, an array takes indexes (-1 included), and a scalar blocks the walk the way a scalar does anywhere else. A JSONSchemaPropsOr* union is walked into the arm it has in use -- the schema, or its array, whose elements are checked against that arm's type (a plain hash is refused where the arm holds schema objects) -- and the boolean arm of JSONSchemaPropsOrBool is a scalar. A union field that is unset, or a union that holds nothing, is built holding what the next segment asks for: a hash (for the Or* unions, the schema arm) for a key, an array for an index. JSONSchemaPropsOrBool has no array to build and croaks with it cannot hold an array, before anything is stored. The union field itself stays an ordinary field: "spec_set" and "spec_delete" on it replace or clear the union object, and "spec_get" returns it. "spec_set" takes any value there that inflation takes, not only a hash -- spec_set('values', [1, 2]) serializes as values: [1,2] -- and so do "spec_push" and an indexed "spec_set" into an array of union objects such as a schema's enum.
spec_get
my $value = $obj->spec_get($path);
Reads a value from the object's spec at the dotted path $path. Each segment is a hash key, an array index (a purely numeric segment, -1 for the last element), or a JSON field name on a typed spec node -- an inline struct, a referenced class, or an array/hash of either. A terminal that is itself typed comes back as that object, not a hashref -- spec_get never serializes what it finds. That includes a union field (see "Union fields"): spec_get('values') on a K3s HelmChart returns the V1::JSON object, while spec_get('values.replicaCount') reads through it into its value. Returns undef if any segment along the way is missing, spec itself is unset, or the terminal value is not defined. Never vivifies.
my $match = $ir->spec_get('routes.0.match');
spec_set
$obj->spec_set($path, $value);
Writes $value into the object's spec at the dotted path $path. Vivifies missing intermediate structure along the way: on a plain hash spec as a hashref (or arrayref, for a numeric segment), and on a typed spec as whatever the attribute registry declares for that field -- an inline struct or referenced class, an array/hash container, or (via the _unknown_fields bag, D1) a plain hash for a field the class does not declare. A hashref handed to a declared object/array/hash-of-objects slot is inflated through the registry the same way FROM_HASH would, and the final write goes through the target's ordinary accessor, so a declared field's own type constraint validates the value -- the wrong type croaks the same way a direct ->attr($value) call would. Returns $self for chaining. undef for a nullable field is a value like any other: the field is then present with an explicit null, which TO_JSON writes -- "spec_delete" is what removes it.
Vivifying a typed intermediate constructs the declared class with no arguments; a class with required attributes cannot be built that way, and the call croaks naming the spec path instead -- build that object yourself and hand it to spec_set as the value.
$ir->spec_set('tls.secretName', 'my-cert');
spec_array
my $arrayref = $obj->spec_array($path);
Vivifies and returns the arrayref at the dotted path $path -- the same intermediate vivification as spec_set, but returning the container itself rather than storing a value into it, so the caller can push, splice or iterate in place. Croaks if the path already holds a defined, non-array value. On a union field (see "Union fields") it returns the array the union holds, building the union holding an empty array when the field is unset or the union holds nothing. Vivifies intermediates the same way spec_set does, including the required-attribute croak described there.
The returned arrayref is the same one the object holds, not a copy: pushing, splicing or otherwise mutating it directly bypasses the declared field's coercion and type check that spec_push/spec_set apply -- use those when a new element needs checking.
push @{ $ir->spec_array('entryPoints') }, 'websecure';
spec_hash
my $hashref = $obj->spec_hash($path);
Vivifies and returns the container at the dotted path $path, so the caller can read or write it directly: a plain hashref on an untyped node or an opaque map field, or the struct/object itself when the declared field is a typed struct or referenced class. On a union field (see "Union fields") it is the container the union holds, never the union object itself: spec_hash('values')->{replicaCount} = 3 on a K3s HelmChart reaches the wire JSON, and an unset values is built holding an empty hash first. Croaks if the path already holds a defined scalar. Vivifies intermediates the same way spec_set does, including the required-attribute croak described there.
The returned container is the same one the object holds, not a copy: writing into it directly does not run the declared field's coercion or type check the way spec_set does -- on a plain hash or an opaque spec value that is nothing new, but on a typed value map ({ Quantity => 1 }) a value written this way skips the per-key validation spec_set would apply.
$ir->spec_hash('tls')->{secretName} = 'my-cert';
spec_push
$obj->spec_push($path, @values);
Appends @values onto the arrayref located at the dotted path $path, vivifying it (and its parent structure, including the required-attribute croak described under spec_set) as needed via spec_array; pushing onto a path that already holds a defined, non-array value croaks rather than replacing it. A hashref value handed to an array-of-objects slot is inflated to the element class, same as spec_set; an already-blessed value is kept as is. Returns $self for chaining.
$ir->spec_push('routes', { match => 'Host(`api.example.com`)' });
spec_merge
$obj->spec_merge(key1 => $value1, key2 => $value2, ...);
Shallow-merges the given key/value pairs into the top-level spec, creating it first if the object has none. On a typed spec each key is written through its declared accessor (with inflation, as spec_set does) when the class declares it, and into the _unknown_fields bag (D1) otherwise. Existing keys are overwritten; keys not mentioned in the merge are left alone. Returns $self for chaining.
$ir->spec_merge(entryPoints => ['web', 'websecure']);
spec_delete
$obj->spec_delete($path);
Removes the value at the dotted path $path. For a hash parent the key is deleted; for a declared field on a typed node there is nothing to remove, so it is cleared to undef through its accessor instead -- which croaks on a required field, because its type constraint is not Maybe-wrapped and rejects undef the same as any other bad value. A nullable field is the exception: undef would leave it present with an explicit null, so its clear_<accessor> removes it instead and it is omitted from TO_JSON again. For an array parent the indexed element is spliced out. If the path does not resolve -- spec is unset, a parent is missing, or the terminal is undefined -- the call is a no-op. Returns $self for chaining.
$ir->spec_delete('tls');
SEE ALSO
IO::K8s::APIObject, IO::K8s::Role::ResourceMap
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/pplu/io-k8s-p5/issues.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHORS
Torsten Raudssus <getty@cpan.org>
Jose Luis Martinez Torres <jlmartin@cpan.org>
COPYRIGHT AND LICENSE
This software is Copyright (c) 2018-2026 by Jose Luis Martinez Torres <jlmartin@cpan.org>.
This is free software, licensed under:
The Apache License, Version 2.0, January 2004