File Coverage

blib/lib/Config/Structured.pm
Criterion Covered Total %
statement 138 142 97.1
branch 34 44 77.2
condition 12 17 70.5
subroutine 21 21 100.0
pod 1 1 100.0
total 206 225 91.5


line stmt bran cond sub pod time code
1             package Config::Structured 3.01;
2 13     13   2845621 use v5.26;
  13         48  
3 13     13   71 use warnings;
  13         26  
  13         1670  
4              
5             # ABSTRACT: Provides generalized and structured configuration value access
6              
7             =encoding UTF-8
8              
9             =head1 NAME
10              
11             Config::Structured - provides generalized and structured configuration value access
12              
13             =head1 SYNOPSIS
14              
15             Basic usage:
16              
17             use Config::Structured;
18              
19             my $conf = Config::Structured->new(
20             structure => {
21             db => {
22             dsn => {
23             isa => 'Str',
24             default => '',
25             description => 'Data Source Name for connecting to the database',
26             url => "https://en.wikipedia.org/wiki/Data_source_name",
27             examples => ["dbi:SQLite:dbname=:memory:", "dbi:mysql:host=localhost;port=3306;database=prod_myapp"]
28             },
29             username => {
30             isa => 'Str',
31             default => 'dbuser',
32             description => "the database user's username",
33             },
34             password => {
35             isa => 'Str',
36             description => "the database user's password",
37             sensitive => 1,
38             notes => "Often ref'd via file or ENV for security"
39             },
40             }
41             },
42             config => {
43             db => {
44             username => 'appuser',
45             host => {
46             source => 'env',
47             ref => 'DB_HOSTNAME',
48             },
49             password => {
50             source => 'file',
51             ref => '/run/secrets/db_password',
52             },
53             }
54             }
55             );
56              
57             say $conf->db->username(); # appuser
58             # assuming that the hostname value has been set in the DB_HOSTNAME env var
59             say $conf->db->host; # prod_db_1.mydomain.com
60             # assuming that the password value has been stored in /run/secrets/db_password
61             say $conf->db->password(1); # *mD9ua&ZSVzEeWkm93bmQzG
62              
63             Hooks example showing how to ensure config directories exist prior to first
64             use:
65              
66             use File::Path qw(make_path);
67              
68             my $conf = Config::Structured->new(
69             ...
70             hooks => {
71             '/paths/*' => {
72             on_load => sub($node,$value) {
73             make_path($value)
74             }
75             }
76             }
77             )
78              
79             =head1 DESCRIPTION
80              
81             L<Config::Structured> is a configuration value manager and accessor. Its design
82             is based on the premise of predefining a structure (which is essentially a schema
83             plus some metadata) to which the configuration must adhere. This has the effect
84             of ensuring that when the application accesses its configuration, it has confidence
85             that the values are of appopriate types, defaults are declared in a consistent
86             manner, and new configuration nodes cannot be added ad hoc (i.e., without being
87             declared within the structure).
88              
89             A configuration structure is a hierarchical system of nodes. Nodes may be
90             branches (containing only other nodes) or leaves (identified by their C<isa>
91             key). Any keys are allowed within a leaf node, for custom tracking of arbitrary
92             metadata, but the following are handled specially by C<Config::Structured>:
93              
94             =over
95              
96             =item C<isa>
97              
98             Required
99              
100             Type constraint against which the configured value for the given key will be
101             checked. See L<Moose::Util::TypeConstraints>. Can be set to C<Any> to opt out of
102             type checking. If a typecheck fails, the L<on_typecheck_error> handler is
103             invoked.
104              
105             =item C<default>
106              
107             Optional
108              
109             This key's value is the default configuration value if a data source or value is
110             not provided by the configuation.
111              
112             =item C<sensitive>
113              
114             Optional
115              
116             Set to true to mark this key's value as sensitive (e.g., password data).
117             Sensitive values will be returned as a string of asterisks unless a truth-y
118             value is passed to the accessor
119              
120             use builtin qw(true);
121              
122             conf->db->pass # ************
123             conf->db->pass(true) # uAjH9PmjH9^knCy4$z3TM4
124              
125             This behavior is mimicked in L</to_hash> and L</get_node>.
126              
127             =item C<description>
128              
129             Optional
130              
131             A human-readable description of the configuration option.
132              
133             =item C<notes>
134              
135             Optional
136              
137             Human-readable implementation notes of the configuration node.
138              
139             =item C<examples>
140              
141             Optional
142              
143             One or more example values for the given configuration node.
144              
145             =item C<url>
146              
147             Optional
148              
149             A web URL to additional information about the configuration node or resource
150              
151             =back
152              
153             =head1 CONSTRUCTORS
154              
155             =head2 Config::Structured->new( %params )
156              
157             Returns a C<Config::Structured> node (a dynamically-generated subclass of
158             C<Config::Structured::Node>). Nodes implement all methods in the L<METHODS>
159             section, plus those corresponding to the configuration keys defined in their
160             structure definition.
161              
162             Parameters:
163              
164             =head4 structure
165              
166             Required
167              
168             Either a string or a HashRef. If a string is passed, it is handed off to
169             L<Data::Structure::Deserialize::Auto>, which attempts to parse a
170             YAML, JSON, TOML, or perl string value or filename of an existing, readable file
171             containing data in one of those formats, into its corresponding perl data
172             structure. The format of such a structure is detailed in the L</DESCRIPTION>
173             section.
174              
175             =head4 config
176              
177             Required
178              
179             Either a string or a HashRef. If a string is passed, it is handed off to
180             L<Data::Structure::Deserialize::Auto>, which attempts to parse a
181             YAML, JSON, TOML, or perl string value or filename of an existing, readable file
182             containing data in one of those formats, into its corresponding perl data
183             structure. Its format should mirror that of its C<structure> except that its
184             leaf nodes should contain the configured value for that key.
185              
186             =head5 Referenced Value
187              
188             In some cases, however, it is inconvenient or insecure to store the configuation
189             value here (such as with passwords). In that case, the actual configuration
190             value may be stored in a separate file or an environment variable, and a
191             reference may be used in C<config> to point to it. To invoke this behavior,
192             the node's L</isa> must be a string type (such as C<Str> or C<Str|Undef>). Then,
193             set the config value to a HashRef containing two keys:
194              
195             =over
196              
197             =item * source - C<"file"> or C<"env">
198              
199             =item * ref - the filesystem path (relative or absolute) or the name of the environment variable holding the value
200              
201             =back
202              
203             If the value is pulled from a file, it will be L<chomp|https://perldoc.perl.org/functions/chomp>ed.
204              
205             =head4 hooks
206              
207             Optional
208              
209             A HashRef whose keys are config paths. A config path is a slash-separated string
210             of config node keys, beginning with a root slash. Asterisks are valid placeholders
211             for full or partial path components. E.g.:
212              
213             /db/user
214             /db/*
215             /email/recipients/admin_*
216             /*/password
217              
218             The values corresponding to these keys are HashRefs whose keys are supported
219             hook types. Two types of hooks are supported:
220              
221             =over
222              
223             =item * on_load - these hooks are run once, when the applicable config node is constructed
224              
225             =item * on_access - these hooks are run each time the applicable config node is invoked
226              
227             =back
228              
229             The values corresponding to those keys are CodeRefs (or ArrayRefs of CodeRefs) to
230             run when the appropriate events occur on the specified config paths.
231              
232             The hook function is passed two arguments: the configuration node path, and the
233             configuration value (which is not obscured, even for sensitive data nodes)
234              
235             =head4 on_typecheck_error
236              
237             Optional.
238              
239             Controls the behavior occurring when a value type constraint check fails.
240              
241             =over
242              
243             =item * fail - die with an error message about the constraint failure
244              
245             =item * warn (default) - emit a warning and set the value to undef
246              
247             =item * undef (or any other value) - do nothing and set the value to undef
248              
249             =back
250              
251             =head1 METHODS
252              
253             =pod
254              
255             =head2 to_hash( $reveal_sensitive = 0 )
256              
257             Returns the entire configuration tree as hashref. Sensitive values are obscured
258             unless C<$reveal_sensitive> is true.
259              
260             =head2 get_node( $child = undef, $reveal_sensitive = 0 )
261              
262             Get all data and metadata for a given node. If given, C<$child> is the name
263             of a direct child node to get the data for, otherwise data for the called
264             object is returned. For leaf nodes, sensitive values are obscured unless
265             C<$reveal_sensitive> is true.
266              
267             Returns a HashRef which always contains the following keys:
268              
269             =over
270              
271             =item * C<path> - the full configuration path of the node
272              
273             =item * C<depth> - how many levels deep this node is in the config (1-based)
274              
275             =item * C<branches> - ArrayRef of the names of all branch children of this node
276              
277             =item * C<leaves> - ArrayRef of the names of all leaf children of this node
278              
279             =back
280              
281             Additionally, for leaf nodes:
282              
283             =over
284              
285             =item * C<value> - the value of the configuration node (possibly obscured)
286              
287             =item * C<overridden> - boolean value that reflects whether the configuration value for this node is the default (0) or from C<config> (1)
288              
289             =item * C<reference> - present only if the node uses a L</Referenced Value>, in which case it is a HashRef containing the C<source> and C<ref> keys and values
290              
291             =item * {structure keys} - all keys and values from the node's structure are present as well (e.g., L</isa>, L</description>, etc., as well as any custom data)
292              
293             =back
294              
295             =cut
296              
297 13     13   10692 use Class::Prototyped;
  13         156798  
  13         110  
298 13     13   7757 use Data::Structure::Deserialize::Auto qw(deserialize);
  13         1136777  
  13         1432  
299 13     13   120 use IO::All;
  13         25  
  13         152  
300 13     13   9528 use Moose::Util::TypeConstraints;
  13         5348237  
  13         222  
301 13     13   29854 use Scalar::Util qw(looks_like_number);
  13         35  
  13         992  
302 13     13   110 use Syntax::Keyword::Try;
  13         30  
  13         190  
303 13     13   1422 use Readonly;
  13         29  
  13         871  
304              
305 13     13   76 use experimental qw(signatures);
  13         26  
  13         146  
306              
307             Readonly::Array my @RESERVED => qw(clone clonePackage destroy DESTROY import new newCore newPackage reflect to_hash get_node);
308              
309             Readonly::Scalar my $PERL_IDENTIFIER => qr/^ (?[ ( \p{Word} & \p{XID_Start} ) + [_] ])
310             (?[ ( \p{Word} & \p{XID_Continue} ) ]) * $/x;
311              
312             Readonly::Hash my %TCF_HANDLERS => (
313             warn => sub($msg) {warn($msg)},
314             fail => sub($msg) {die($msg)}
315             );
316              
317             Readonly::Hash my %REF_HANDLERS => (
318             env => sub($ref) {$ENV{$ref}},
319             file => sub($ref) {
320             try {join($/, io($ref)->chomp->slurp)} catch ($e) {
321             die("Can't read referenced file at '$ref': $e")
322             }
323             },
324             );
325              
326             my $base_class = Class::Prototyped->newPackage(__PACKAGE__ . '::Node');
327              
328             ###
329             # PRIVATE FUNCTIONS
330             ###
331 65     65   108 my sub join_path(@path_components) {
  65         138  
  65         86  
332 65         263 join(q{/}, q{}, @path_components) #insert an empty string so that the result starts with a slash
333             }
334              
335 56     56   76 my sub is_branch($node) {
  56         83  
  56         69  
336             try {$node->isa('Class::Prototyped')}
337 56         104 catch ($e) {0} # throws if $node is not blessed
338             }
339              
340             my sub check_value_type ($isa, $value) {
341             my $tc = Moose::Util::TypeConstraints::find_or_parse_type_constraint($isa);
342             die("invalid typeconstraint '$isa'") unless (defined($tc));
343             return $tc->check($value);
344             }
345              
346 31     31   54 my sub is_ref_value($def, $cfg) {
  31         52  
  31         56  
  31         44  
347 31 100       194 return 0 if ($def->{isa} !~ /^str/i);
348 26 100       116 return 0 if (ref($cfg) ne 'HASH');
349 3   33     34 return exists($cfg->{ref}) && exists($cfg->{source});
350             }
351              
352             my sub resolve_ref_value($def, $cfg) {
353             my ($ref, $src) = @{$cfg}{qw(ref source)};
354             my $h = $REF_HANDLERS{$src};
355             die("invalid reference source type: '$src'") unless (defined($h));
356             return $h->($ref);
357             }
358              
359             my sub stringify_value($v) {
360             return 'undef' unless (defined($v));
361             return encode_json($v) if (ref($v));
362             return qq{"$v"} if (!looks_like_number($v));
363             return $v;
364             }
365              
366 36     36   65 my sub resolve_value($k, $def, $cfg) {
  36         59  
  36         76  
  36         59  
  36         74  
367 36         58 my $v;
368 36 100       200 if (!exists($cfg->{$k})) {
    100          
369 5         13 $v = $def->{default};
370             } elsif (is_ref_value($def, $cfg->{$k})) { # indirect value
371 3         33 $v = resolve_ref_value($def, $cfg->{$k});
372             } else {
373 28         62 $v = $cfg->{$k};
374             }
375 36 100       32923 return $v if (check_value_type($def->{isa}, $v));
376 1         68 die("value " . stringify_value($v) . " does not conform to type '" . $def->{isa} . "'");
377 0         0 return ();
378             }
379              
380 90     90   142 my sub get_hooks($hooks, $path, $type) {
  90         177  
  90         151  
  90         153  
  90         162  
381 90         159 my @h;
382 90         224 foreach my $p (keys($hooks->%*)) {
383 26         108 my $pat = $p =~ s|[*]|[^/]*|gr; # path wildcard to regex
384 26 100       505 if ($path =~ /$p/) {
385 14         71 my $t = $hooks->{$p}->{$type};
386 14 50       90 push(@h, grep {ref($_) eq 'CODE'} (ref($t) eq 'ARRAY' ? $t->@* : ($t)));
  14         59  
387             }
388             }
389 90         291 return @h;
390             }
391              
392 12     12   588 my sub valid_children(@list) {
  12         32  
  12         19  
393 12         19 my @v;
394 12         24 foreach my $i (@list) {
395 58 100       353 next if ($i =~ /[*]$/); # skip parent slots (ending in *)
396 46 100       156 next if (grep {$_ eq $i} @RESERVED); # skip reserved words
  506         3032  
397 22         192 push(@v, $i);
398             }
399 12         50 return @v;
400             }
401              
402             ###
403             # CONSTRUCTOR
404             ###
405 40     40 1 3573463 sub new($class, %args) {
  40         99  
  40         208  
  40         91  
406 40 100       198 die("structure is a required parameter") unless (defined($args{structure}));
407 39 100       308 die("config is a required parameter") unless (defined($args{config}));
408              
409             # process %args
410 38 100       253 my $config = ref($args{config}) ? $args{config} : deserialize($args{config});
411 38 100       46606 my $structure = ref($args{structure}) ? $args{structure} : deserialize($args{structure});
412 38   100     4933 my $hooks = $args{hooks} // {};
413 38   100     161 my $path = $args{path} // [];
414 38 50       379 my $tc_fail = $TCF_HANDLERS{exists($args{on_typecheck_error}) ? $args{on_typecheck_error} : 'warn'};
415              
416 4         7 my $obj = Class::Prototyped->new(
417             '*' => $base_class,
418 4     4   12 to_hash => sub($self, $reveal = 0) {
  4         7  
  4         9  
419             return {
420 5         16 (map {$_ => $self->$_($reveal)} $self->get_node->{leaves}->@*),
421 4         11 (map {$_ => $self->$_->to_hash($reveal)} $self->get_node->{branches}->@*),
  3         11  
422             };
423             },
424 12     12   22 get_node => sub($self, $name = undef, $reveal = 0) {
  12         20  
  12         16  
  12         20  
  12         20  
425 12         45 my @children = valid_children($self->reflect->slotNames);
426 12 50       47 my $node = defined($name) ? $self->$name($reveal) : $self;
427 12         22 my $details = {};
428 12 50       36 unless (is_branch($node)) {
429 0         0 $details = {$structure->{$name}->%*};
430 0 0       0 $details->{overridden} = (exists($config->{$name}) ? 1 : 0), $details->{value} = $node;
431 0 0       0 $details->{reference} = $config->{$name} if (is_ref_value($structure->{$name}, $config->{$name}));
432             }
433 12         26 $details->{branches} = [grep {is_branch($self->$_)} @children];
  22         69  
434 12         69 $details->{leaves} = [grep {!is_branch($self->$_)} @children];
  22         54  
435 12         30 $details->{path} = join_path(grep {defined} ($path->@*, $name));
  22         50  
436 12 50       75 $details->{depth} = defined($name) ? scalar($path->@*) + 1 : scalar($path->@*);
437 12         144 return $details;
438             },
439 38         885 );
440              
441 38         16395 foreach my $k (keys($structure->%*)) {
442             # Ensure key does not conflict with a method
443             warn("Reserved token '$k' found in structure definition. Skipping...") and next
444 56 100 50     3177 if ($k !~ $PERL_IDENTIFIER || grep {$_ eq $k} @RESERVED);
  605   100     3471  
445 53         566 my $npath = join_path($path->@*, $k);
446 53 100       196 if (exists($structure->{$k}->{isa})) { # leaf node
447 36         65 my $v;
448             try {
449             # actually finding the value is complicated: it can come from default, config, env, or a file, so abstract it away in resolve_value
450             $v = resolve_value($k, $structure->{$k}, $config);
451 36         96 } catch ($e) {
452             # Catch any errors in value resolutuion and call preferred handler
453             $e =~ /(.*) (at .* line .*)$/;
454             $tc_fail->(__PACKAGE__ . " $1 for cfg path $npath\n") if (defined($tc_fail));
455             }
456             # ON_LOAD HANDLER
457 36         3358 $_->($npath, $v) foreach (get_hooks($hooks, $npath, 'on_load'));
458             # sub that's run on access to leaf node
459 54         106 $obj->reflect->addSlot(
460 54     54   1015 $k => sub($self, $reveal_sensitive = 0) {
  54         97  
  54         79  
461             # ON_ACCESS HANDLER
462 54         135 $_->($npath, $v) foreach (get_hooks($hooks, $npath, 'on_access'));
463             # Return the value, unless it's sensitive in which case obscure it
464 54 50 33     512 return ($structure->{$k}->{sensitive} && !$reveal_sensitive && defined($v)) ? '*' x 12 : $v;
465             }
466 36         317 );
467             } else { # branch node - recursively call constructor for the next-level down node
468             # important! recursively create node outside of sub so that we frontend all node value resolution
469             # otherwise, on_load handlers wouldnt't get called until the parent node was accessed
470             my $branch = __PACKAGE__->new(
471             config => $config->{$k} // {},
472 17   100     188 structure => $structure->{$k},
473             path => [$path->@*, $k],
474             hooks => $hooks,
475             );
476             # sub that's run on access to branch node (use same signature to be consistent with leaves)
477 17     39   102 $obj->reflect->addSlot($k => sub($self, $reveal_sensitive = 0) {$branch});
  39         109  
  39         21697  
  39         65  
  39         58  
  39         55  
478             }
479             }
480              
481 38         5072 return $obj;
482             }
483              
484             =pod
485              
486             =head1 CAVEATS
487              
488             Some tokens are unavailable to be used as configuration node keys. The following
489             keys, as well as any key that is not a
490             L<valid perl identifier|https://perldoc.pl/perldata#Identifier-parsing>, are
491             disallowed - if used in a structure file, a warning will be emitted and the
492             applicable node will be discarded.
493              
494             =over
495              
496             =item * C<clone>
497              
498             =item * C<clonePackage>
499              
500             =item * C<destroy>
501              
502             =item * C<DESTROY>
503              
504             =item * C<import>
505              
506             =item * C<new>
507              
508             =item * C<newCore>
509              
510             =item * C<newPackage>
511              
512             =item * C<reflect>
513              
514             =item * C<to_hash>
515              
516             =item * C<get_node>
517              
518             =back
519              
520             =head1 AUTHOR
521              
522             Mark Tyrrell C<< <mark@tyrrminal.dev> >>
523              
524             =head1 LICENSE
525              
526             Copyright (c) 2024 Mark Tyrrell
527              
528             Permission is hereby granted, free of charge, to any person obtaining a copy
529             of this software and associated documentation files (the "Software"), to deal
530             in the Software without restriction, including without limitation the rights
531             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
532             copies of the Software, and to permit persons to whom the Software is
533             furnished to do so, subject to the following conditions:
534              
535             The above copyright notice and this permission notice shall be included in all
536             copies or substantial portions of the Software.
537              
538             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
539             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
540             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
541             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
542             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
543             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
544             SOFTWARE.
545              
546             =cut
547              
548             1;
549              
550             __END__