File Coverage

blib/lib/IO/K8s/AutoGen.pm
Criterion Covered Total %
statement 119 137 86.8
branch 55 74 74.3
condition 14 31 45.1
subroutine 18 19 94.7
pod 6 6 100.0
total 212 267 79.4


line stmt bran cond sub pod time code
1             package IO::K8s::AutoGen;
2             # ABSTRACT: Dynamically generate IO::K8s classes from OpenAPI schema
3             our $VERSION = '1.008';
4 2     2   2212 use v5.10;
  2         8  
5 2     2   10 use strict;
  2         4  
  2         48  
6 2     2   9 use warnings;
  2         4  
  2         140  
7 2     2   10 use Carp qw(croak);
  2         4  
  2         146  
8 2     2   11 use Package::Stash;
  2         6  
  2         892  
9              
10             # Cache of generated classes
11             my %_generated;
12              
13             # Default namespace for auto-generated classes
14             our $DEFAULT_NAMESPACE = 'IO::K8s::_AUTOGEN';
15              
16             # Convert OpenAPI definition name to Perl class name
17             # With namespace 'MyProject::K8s':
18             # helm.cattle.io.v1.HelmChart -> MyProject::K8s::helm::cattle::io::v1::HelmChart
19             sub def_to_class {
20 7     7 1 261439 my ($def_name, $namespace) = @_;
21 7   66     23 $namespace //= $DEFAULT_NAMESPACE;
22 7         13 my $class = $def_name;
23 7         44 $class =~ s/\./::/g;
24 7         28 return "${namespace}::$class";
25             }
26              
27             # Convert Perl class name back to OpenAPI definition name
28             sub class_to_def {
29 1     1 1 3 my ($class) = @_;
30             # Strip any _AUTOGEN namespace prefix
31 1         3 $class =~ s/^IO::K8s::_AUTOGEN_[^:]+:://;
32 1         3 $class =~ s/^IO::K8s::_AUTOGEN:://;
33 1         4 $class =~ s/::/./g;
34 1         3 return $class;
35             }
36              
37             # Check if a class was auto-generated
38             sub is_autogen {
39 3     3 1 8 my ($class) = @_;
40 3         16 return $class =~ /^IO::K8s::_AUTOGEN/;
41             }
42              
43             # Get or generate a class from schema
44             # Options hash can include:
45             # api_version => 'stable.example.com/v1'
46             # kind => 'StaticWebSite'
47             # resource_plural => 'staticwebsites'
48             # is_namespaced => 1
49             sub get_or_generate {
50 5     5 1 133 my ($def_name, $schema, $all_defs, $namespace, %opts) = @_;
51              
52 5         21 my $class = def_to_class($def_name, $namespace);
53 5 50       35 return $class if $_generated{$class};
54              
55 5         24 _generate_class($class, $def_name, $schema, $all_defs, $namespace, %opts);
56 5         31 return $class;
57             }
58              
59             # Generate a class from OpenAPI schema using IO::K8s::Resource
60             sub _generate_class {
61 5     5   16 my ($class, $def_name, $schema, $all_defs, $namespace, %opts) = @_;
62              
63 5 50       19 return if $_generated{$class};
64 5         13 $_generated{$class} = 1; # Mark early to prevent recursion
65              
66             # Ensure parent packages exist
67 5         23 _ensure_package_exists($class);
68              
69             # Set up the class using IO::K8s::Resource's shared setup method
70             {
71 2     2   14 no strict 'refs';
  2         4  
  2         2475  
  5         10  
72 5         11 @{"${class}::ISA"} = ();
  5         92  
73             }
74              
75 5         655 require IO::K8s::Resource;
76 5         51 IO::K8s::Resource->_setup_class($class);
77              
78             # Get the k8s function for this class
79 5 50       55 my $k8s = $class->can('k8s')
80             or croak "Failed to set up k8s DSL for $class";
81              
82 5   50     19 my $properties = $schema->{properties} // {};
83              
84             # Generate attributes using k8s DSL
85             # Property names with special characters ($ref, x-kubernetes-*) are
86             # automatically sanitized to valid Perl identifiers by _k8s(), with
87             # init_arg mapping so constructors still accept the original JSON keys.
88 5         35 for my $prop (sort keys %$properties) {
89 25         25296 my $prop_schema = $properties->{$prop};
90 25         108 my $type_spec = _schema_to_type_spec($prop_schema, $all_defs, $namespace, $prop);
91 25 100       74 next unless defined $type_spec; # Skip unsupported types
92              
93 24         69 $k8s->($prop, $type_spec);
94             }
95              
96             # Determine api_version/kind from schema or explicit options
97 5         6754 my ($api_ver, $kind_val, $res_plural, $is_namespaced);
98              
99 5 100       26 if (my $gvk = $schema->{'x-kubernetes-group-version-kind'}) {
100 1 50       8 my $entry = ref($gvk) eq 'ARRAY' ? $gvk->[0] : $gvk;
101 1   50     6 my $group = $entry->{group} // '';
102 1   50     4 my $version = $entry->{version} // '';
103 1   50     5 $kind_val = $entry->{kind} // '';
104 1 50       6 $api_ver = $group ? "$group/$version" : $version;
105             }
106              
107             # Explicit options override schema-derived values
108 5 100       26 $api_ver = $opts{api_version} if exists $opts{api_version};
109 5 100       18 $kind_val = $opts{kind} if exists $opts{kind};
110 5 100       19 $res_plural = $opts{resource_plural} if exists $opts{resource_plural};
111 5 100       20 $is_namespaced = $opts{is_namespaced} if exists $opts{is_namespaced};
112              
113             # Install class methods if we have api_version/kind
114 5 100 66     45 if (defined $api_ver && defined $kind_val) {
115 2         34 my $stash = Package::Stash->new($class);
116              
117 2     7   45 $stash->add_symbol('&api_version', sub { $api_ver });
  7         13243  
118 2     10   19 $stash->add_symbol('&kind', sub { $kind_val });
  10         5574  
119 2     2   14 $stash->add_symbol('&resource_plural', sub { $res_plural });
  2         12  
120              
121             # Apply Role::APIObject for metadata, to_yaml, save, etc.
122 2         19 require Moo::Role;
123 2         11 require IO::K8s::Role::APIObject;
124 2         17 Moo::Role->apply_roles_to_package($class, 'IO::K8s::Role::APIObject');
125              
126             # Register metadata attribute via k8s DSL so _inflate_struct knows the type
127             # (same as IO::K8s::APIObject::import does for hand-written classes)
128 2         3361 $k8s->('metadata', 'Meta::V1::ObjectMeta');
129              
130             # Apply Namespaced role if requested or schema suggests it
131 2 100       11 if ($is_namespaced) {
132 1         8 require IO::K8s::Role::Namespaced;
133 1         6 Moo::Role->apply_roles_to_package($class, 'IO::K8s::Role::Namespaced');
134             }
135             }
136              
137 5         1394 return $class;
138             }
139              
140             # Opaque type definitions that should be HashRef, not object references
141             my %OPAQUE_TYPES = map { $_ => 1 } qw(
142             io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1
143             io.k8s.apimachinery.pkg.runtime.RawExtension
144             );
145              
146             # Convert OpenAPI schema to k8s() type spec
147             sub _schema_to_type_spec {
148 25     25   70 my ($schema, $all_defs, $namespace, $field_name) = @_;
149              
150             # Handle $ref
151 25 100       92 if (my $ref = $schema->{'$ref'}) {
152 5         33 $ref =~ s{^#/definitions/}{};
153              
154             # Special apimachinery types - not object references
155 5 100       28 if ($ref =~ /intstr\.IntOrString$/) {
156 1         4 return 'IntOrStr';
157             }
158 4 100       48 if ($ref =~ /resource\.Quantity$/) {
159 1         6 return 'Quantity';
160             }
161 3 100       22 if ($ref =~ /meta\.v1\.(Micro)?Time$/) {
162 2         8 return 'Time';
163             }
164              
165             # Opaque types should be HashRef, not object references
166 1 50       5 if ($OPAQUE_TYPES{$ref}) {
167 0         0 return { Str => 1 }; # HashRef
168             }
169              
170             # Generate referenced class if needed
171 1 50 33     8 if ($all_defs && $all_defs->{$ref}) {
172 0         0 my $ref_class = get_or_generate($ref, $all_defs->{$ref}, $all_defs, $namespace);
173 0         0 return "+$ref_class"; # + prefix for full class name
174             }
175 1         4 return undef; # Can't resolve reference
176             }
177              
178 20   50     63 my $type = $schema->{type} // '';
179              
180 20 100       97 if ($type eq 'string') {
    100          
    50          
    100          
    100          
    50          
181 11   100     50 my $format = $schema->{format} // '';
182 11 100       42 return 'IntOrStr' if $format eq 'int-or-string';
183 10 100       51 return 'Time' if $format eq 'date-time';
184 9         26 return 'Str';
185             }
186             elsif ($type eq 'integer') {
187 3         11 return 'Int';
188             }
189             elsif ($type eq 'number') {
190 0         0 return 'Str'; # Treat numbers as strings for now
191             }
192             elsif ($type eq 'boolean') {
193 1         2 return 'Bool';
194             }
195             elsif ($type eq 'array') {
196 1   50     4 my $items = $schema->{items} // {};
197 1 50       4 if (my $ref = $items->{'$ref'}) {
198 0         0 $ref =~ s{^#/definitions/}{};
199 0 0 0     0 if ($all_defs && $all_defs->{$ref}) {
200 0         0 my $ref_class = get_or_generate($ref, $all_defs->{$ref}, $all_defs, $namespace);
201 0         0 return ["+$ref_class"];
202             }
203 0         0 return undef;
204             }
205 1   50     3 my $item_type = $items->{type} // 'string';
206 1 50       6 return ['Str'] if $item_type eq 'string';
207 0 0       0 return ['Int'] if $item_type eq 'integer';
208 0         0 return ['Str']; # Default
209             }
210             elsif ($type eq 'object') {
211 4 100       16 if (my $addl = $schema->{additionalProperties}) {
212 1 50       3 if (my $ref = $addl->{'$ref'}) {
213 0         0 $ref =~ s{^#/definitions/}{};
214 0 0 0     0 if ($all_defs && $all_defs->{$ref}) {
215 0         0 my $ref_class = get_or_generate($ref, $all_defs->{$ref}, $all_defs, $namespace);
216 0         0 return { "+$ref_class" => 1 };
217             }
218 0         0 return undef;
219             }
220 1         3 return { Str => 1 }; # Hash of strings
221             }
222 3         16 return { Str => 1 }; # Generic object -> hash of strings
223             }
224              
225             # Unknown type
226 0         0 return 'Str';
227             }
228              
229             # Ensure parent packages exist
230             sub _ensure_package_exists {
231 5     5   10 my ($class) = @_;
232 5         29 my @parts = split /::/, $class;
233 5         13 pop @parts; # Remove the final class name
234              
235 5         12 my $current = '';
236 5         13 for my $part (@parts) {
237 32 100       70 $current .= '::' if $current;
238 32         48 $current .= $part;
239 2     2   16 no strict 'refs';
  2         8  
  2         426  
240 32 100       41 unless (%{"${current}::"}) {
  32         186  
241             # Create empty package
242 22 50       1464 eval "package $current; 1;" or warn "Could not create package $current: $@";
243             }
244             }
245             }
246              
247             # Clear generated class cache (mainly for testing)
248             sub clear_cache {
249 5     5 1 40608 %_generated = ();
250             }
251              
252             # List all generated classes
253             sub generated_classes {
254 0     0 1   return keys %_generated;
255             }
256              
257             1;
258              
259             __END__