File Coverage

blib/lib/Config/Structured.pm
Criterion Covered Total %
statement 171 194 88.1
branch 36 48 75.0
condition 2 5 40.0
subroutine 39 42 92.8
pod 1 3 33.3
total 249 292 85.2


line stmt bran cond sub pod time code
1             package Config::Structured;
2             $Config::Structured::VERSION = '2.003';
3             # ABSTRACT: Provides generalized and structured configuration value access
4              
5 11     11   800772 use 5.022;
  11         146  
6              
7 11     11   6438 use Moose;
  11         5328917  
  11         73  
8 11     11   86311 use Moose::Util::TypeConstraints;
  11         30  
  11         111  
9 11     11   33658 use Mojo::DynamicMethods -dispatch;
  11         2239726  
  11         93  
10              
11 11     11   6375 use Syntax::Keyword::Junction;
  11         86207  
  11         87  
12 11     11   1014 use Carp;
  11         26  
  11         624  
13 11     11   5371 use IO::All;
  11         125160  
  11         86  
14 11     11   995 use List::Util qw(reduce);
  11         26  
  11         734  
15 11     11   6381 use Data::DPath qw(dpath);
  11         817074  
  11         87  
16 11     11   8323 use Text::Glob qw(match_glob);
  11         9148  
  11         648  
17              
18 11     11   5799 use Readonly;
  11         45218  
  11         595  
19              
20 11     11   5490 use Config::Structured::Deserializer;
  11         143  
  11         474  
21              
22 11     11   6291 use Data::Printer;
  11         362600  
  11         84  
23              
24 11     11   6157 use experimental qw(signatures lexical_subs);
  11         32  
  11         95  
25              
26             # Symbol constants
27             Readonly::Scalar my $EMPTY => q{};
28             Readonly::Scalar my $SLASH => q{/};
29              
30             # Token key constants
31             Readonly::Scalar my $DEF_ISA => q{isa};
32             Readonly::Scalar my $DEF_DEFAULT => q{default};
33             Readonly::Scalar my $CFG_SOURCE => q{source};
34             Readonly::Scalar my $CFG_REF => q{ref};
35              
36             # Token value constants
37             Readonly::Scalar my $CONF_FROM_FILE => q(file);
38             Readonly::Scalar my $CONF_FROM_ENV => q(env);
39              
40             # Method names that are needed by Config::Structured and cannot be overridden by config node names
41             Readonly::Array my @RESERVED =>
42             qw(get meta BUILDCARGS BUILD BUILD_DYNAMIC _config _structure _hooks _base _add_helper __register_default __register_as __get_child_node_names);
43              
44             #
45             # The configuration structure (e.g., $app.conf.def contents)
46             #
47             has '_structure_v' => (
48             is => 'ro',
49             isa => 'Str|HashRef',
50             init_arg => 'structure',
51             required => 1,
52             );
53              
54             has '_structure' => (
55             is => 'ro',
56             isa => 'HashRef',
57             init_arg => undef,
58             lazy => 1,
59             default => sub {Config::Structured::Deserializer->decode(shift->_structure_v)}
60             );
61              
62             has '_hooks' => (
63             is => 'ro',
64             isa => 'HashRef[HashRef[CodeRef]]',
65             init_arg => 'hooks',
66             required => 0,
67             default => sub {{}},
68             );
69              
70             #
71             # The file-based configuration (e.g., $app.conf contents)
72             #
73             has '_config_v' => (
74             is => 'ro',
75             isa => 'Str|HashRef',
76             init_arg => 'config',
77             required => 1,
78             );
79              
80             has '_config' => (
81             is => 'ro',
82             isa => 'HashRef',
83             init_arg => undef,
84             lazy => 1,
85             default => sub {Config::Structured::Deserializer->decode(shift->_config_v)}
86             );
87              
88             #
89             # This instance's base path (e.g., /db)
90             # Recursively constucted through re-instantiation of non-leaf config nodes
91             #
92             has '_base' => (
93             is => 'ro',
94             isa => 'Str',
95             default => $SLASH,
96             );
97              
98             #
99             # Convenience method for adding dynamic methods to an object
100             #
101             sub _add_helper {
102 52     52   213 Mojo::DynamicMethods::register __PACKAGE__, @_;
103             }
104              
105             around BUILDARGS => sub ($orig, $class, @args) {
106             my %args = ref($args[0]) eq 'HASH' ? %{$args[0]} : @args;
107             delete($args{hooks}) unless (defined($args{hooks}));
108             return $class->$orig(%args);
109             };
110              
111             #
112             # Dynamically create methods at instantiation time, corresponding to configuration structure's dpaths
113             # Use lexical subs and closures to avoid polluting namespace unnecessarily (preserving it for config nodes)
114             #
115 37     37 0 76808 sub BUILD ($self, $args) {
  37         76  
  37         65  
  37         59  
116             # lexical subroutines
117              
118 3     3   8 state sub pkg_prefix ($msg) {
  3         5468  
  3         6  
119 3         57 '[' . __PACKAGE__ . "] $msg";
120             }
121              
122 37     37   55 state sub is_hashref ($node) {
  37         106  
  37         117  
123 37         169 return ref($node) eq 'HASH';
124             }
125              
126 98     98   130 state sub is_leaf_node ($node) {
  98         152  
  98         163  
127 98         385 exists($node->{isa});
128             }
129              
130 41     41   53 state sub is_ref_node ($def, $node) {
  41         64  
  41         69  
  41         63  
131 41 100       136 return 0 if ($def->{isa} =~ /hash/i);
132 40 100       172 return 0 unless (ref($node) eq 'HASH');
133 2   33     17 return (exists($node->{$CFG_SOURCE}) && exists($node->{$CFG_REF}));
134             }
135              
136 2     2   3 state sub ref_content_value ($node) {
  2         4  
  2         4  
137 2         5 my $source = $node->{$CFG_SOURCE};
138 2         5 my $ref = $node->{$CFG_REF};
139 2 100       12 if ($source eq $CONF_FROM_FILE) {
    50          
140 1 50       34 if (-f -r $ref) {
141 1         7 chomp(my $contents = io->file($ref)->slurp);
142 1         9457 return $contents;
143             }
144             } elsif ($source eq $CONF_FROM_ENV) {
145 1 50       5 return $ENV{$ref} if (exists($ENV{$ref}));
146             }
147 0         0 return;
148             }
149              
150 42     42   64 state sub node_value ($el, $node) {
  42         5465  
  42         85  
  42         68  
151 42 100       138 if (defined($node)) {
152 41 100       103 my $v = is_ref_node($el, $node) ? ref_content_value($node) : $node;
153 41 50       204 return $v if (defined($v));
154             }
155 1         5 return $el->{$DEF_DEFAULT};
156             }
157              
158             state sub concat_path {
159 52 50   52   494 reduce {local $/ = $SLASH; chomp($a); join(($b =~ m|^$SLASH|) ? $EMPTY : $SLASH, $a, $b)} @_;
  52         226  
  52         125  
  52         529  
160             }
161              
162 38     38   62 state sub typecheck ($isa, $value) {
  38         58  
  38         92  
  38         59  
163 38         152 my $tc = Moose::Util::TypeConstraints::find_or_parse_type_constraint($isa);
164 38 100       10793 if (defined($tc)) {
165 37         132 return $tc->check($value);
166             } else {
167 1         8 carp(pkg_prefix "Invalid typeconstraint '$isa'. Skipping typecheck");
168 1         1019 return 1;
169             }
170             }
171              
172             # Closures
173 42     42   63 my $get_node_value = sub ($el, $path) {
  42         69  
  42         154  
  42         79  
174 42         159 return node_value($el, dpath($path)->matchr($self->_config)->[0]);
175 37         196 };
176              
177 69     69   111 my $get_hooks = sub ($path) {
  69         106  
  69         116  
178 69 100       106 return map {$self->_hooks->{$_}} grep {match_glob($_, $path) ? $_ : ()} keys(%{$self->_hooks});
  21         3347  
  40         2228  
  69         1984  
179 37         141 };
180              
181 38     38   62 my $make_leaf_generator = sub ($el, $path) {
  38         64  
  38         140  
  38         66  
182 38         74 my $isa = $el->{isa};
183 38         95 my $v = $get_node_value->($el, $path);
184              
185 38 50       208 if (defined($v)) {
186 38 100       97 if (typecheck($isa, $v)) {
187 37         2763 my @hooks = grep {defined} map {$_->{on_access}} $get_hooks->($path);
  14         41  
  14         34  
188             return sub {
189             # access hook
190 22         70 foreach (@hooks) {$_->($path, $v)}
  6         106  
191 22         509 return $v;
192             }
193 37         878 } else {
194 1         72 carp(pkg_prefix "Value '" . np($v) . "' does not conform to type '$isa' for node $path");
195             }
196             }
197             return sub {
198 0         0 return;
199             }
200 37         159 };
  1         680  
201              
202 14     14   76 my $make_branch_generator = sub ($path) {
  14         28  
  14         28  
203             return sub {
204 18         634 return __PACKAGE__->new(
205             structure => $self->_structure,
206             config => $self->_config,
207             hooks => $self->_hooks,
208             _base => $path
209             );
210             }
211 37         112 };
  14         93  
212              
213 37         1202 foreach my $el (dpath($self->_base)->match($self->_structure)) {
214 37 50       4042 if (is_hashref($el)) {
215 37         73 foreach my $def (keys(%{$el})) {
  37         148  
216 53 100 50     299 carp(pkg_prefix "Reserved word '$def' used as config node name: ignored") and next if ($def eq any(@RESERVED));
217 52         6121 $self->meta->remove_method($def)
218             ; # if the config node refers to a method already defined on our instance, remove that method
219 52         4765 my $path = concat_path($self->_base, $def); # construct the new directive path by concatenating with our base
220              
221             # Detect whether the resulting node is a branch or leaf node (leaf nodes are required to have an "isa" attribute)
222             # if it's a branch node, return a new Config instance with a new base location, for method chaining (e.g., config->db->pass)
223             $self->_add_helper(
224 52 100       312 $def => (is_leaf_node($el->{$def}) ? $make_leaf_generator->($el->{$def}, $path) : $make_branch_generator->($path)));
225             }
226             }
227             }
228              
229             # Run on_load hooks immediately from root node only since we can't assume that non-root nodes will be created immediately
230 37 100       2162 if ($self->_base eq $SLASH) {
231 33     33   48 sub ($path, $node) {
  33         58  
  33         61  
  33         51  
232 33         53 foreach (keys(%{$node})) {
  33         111  
233 46 100       930 my $p = join($path eq $SLASH ? $EMPTY : $SLASH, $path, $_); #don't duplicate initial slash in path
234 46         91 my $n = $node->{$_};
235 46 100       97 if (is_leaf_node($n)) {
236 32         126 my @hooks = grep {defined} map {$_->{on_load}} $get_hooks->($p);
  7         22  
  7         19  
237 32 100       909 if (@hooks) {
238 4         11 my $v = $get_node_value->($n, $p); #put off resolving the node value until we know we need it
239 4         25 foreach (@hooks) {$_->($p, $v)}
  4         14  
240             }
241             } else {
242 14         74 __SUB__->($p, $n); #recurse on the new branch node
243             }
244             }
245             }
246 19         538 ->($self->_base, $self->_structure); #initially call on root of structure
247             }
248             }
249              
250             #
251             # Handle dynamic method dispatch
252             #
253             sub BUILD_DYNAMIC {
254 28     28 0 935 my ($class, $method, $dyn_methods) = @_;
255             return sub {
256 40     40   4610 my ($self, @args) = @_;
        13      
        13      
        13      
        39      
        42      
        9      
        7      
257 40         153 my $dynamic = $dyn_methods->{$self}{$method};
258 40 50       206 return $self->$dynamic(@args) if ($dynamic);
259 0         0 my $package = ref $self;
260 0         0 croak qq{Can't locate object method "$method" via package "$package"};
261             }
262 28         252 }
263              
264             #
265             # Saved Named/Default Config instances
266             #
267             our $saved_instances = {
268             default => undef,
269             named => {}
270             };
271              
272             #
273             # Instance method
274             # Saves the current instance as the default instance
275             #
276 0     0   0 sub __register_default ($self) {
  0         0  
  0         0  
277 0         0 $saved_instances->{default} = $self;
278 0         0 return $self;
279             }
280              
281             #
282             # Instance method
283             # Saves the current instance by the specified name
284             # Parameters:
285             # Name (Str), required
286             #
287 0     0   0 sub __register_as ($self, $name) {
  0         0  
  0         0  
  0         0  
288 0 0       0 croak 'Registration name is required' unless (defined $name);
289              
290 0         0 $saved_instances->{named}->{$name} = $self;
291 0         0 return $self;
292             }
293              
294             #
295             # Class method
296             # Return a previously saved instance. Returns undef if no instances have been saved. Returns the default instance if no name is provided
297             # Parameters:
298             # Name (Str), optional
299             #
300 0     0 1 0 sub get ($class, $name = undef) {
  0         0  
  0         0  
  0         0  
301 0 0       0 if (defined $name) {
302 0         0 return $saved_instances->{named}->{$name};
303             } else {
304 0         0 return $saved_instances->{default};
305             }
306             }
307              
308             #
309             # Instance method
310             # Get all the node names that are children of the current node in config structure
311             # Returns:
312             # List of strings
313 2     2   16 sub __get_child_node_names ($self) {
  2         4  
  2         4  
314 2         61 my ($node) = dpath($self->_base)->match($self->_structure);
315 2         169 return (keys($node->%*));
316             }
317              
318             1;
319              
320             __END__
321              
322             =pod
323              
324             =encoding UTF-8
325              
326             =head1 NAME
327              
328             Config::Structured - Provides generalized and structured configuration value access
329              
330             =head1 VERSION
331              
332             version 2.003
333              
334             =head1 SYNOPSIS
335              
336             Basic usage:
337              
338             use Config::Structured;
339              
340             my $conf = Config::Structured->new(
341             structure => { ... },
342             config => { ... }
343             );
344              
345             say $conf->some->nested->value();
346              
347             Hooks exammple showing how to ensure config directories exist prior to first
348             use:
349              
350             my $conf = Config::Structured->new(
351             ...
352             hooks => {
353             '/paths/*' => {
354             on_load => sub($node,$value) {
355             Mojo::File->new($value)->make_path
356             }
357             }
358             }
359             )
360              
361             =head1 DESCRIPTION
362              
363             L<Config::Structured> provides a structured method of accessing configuration values
364              
365             This is predicated on the use of a configuration C<structure> (required), This structure
366             provides a hierarchical structure of configuration branches and leaves. Each branch becomes
367             a L<Config::Structured> method which returns a new L<Config::Structured> instance rooted at
368             that node, while each leaf becomes a method which returns the configuration value.
369              
370             The configuration value is normally provided in the C<config> hash. However, a C<config> node
371             for a non-Hash value can be a hash containing the "source" and "ref" keys. This permits sourcing
372             the config value from a file (when source="file") whose filesystem location is given in the "ref"
373             value, or an environment variable (when source="env") whose name is given in the "ref" value.
374              
375             I<Structure Leaf Nodes> are required to include an "isa" key, whose value is a type
376             (see L<Moose::Util::TypeConstraints>). If typechecking is not required, use isa => 'Any'.
377             There are a few other keys that L<Config::Structured> respects in a leaf node:
378              
379             =over
380              
381             =item C<default>
382              
383             This key's value is the default configuration value if a data source or value is not provided by
384             the configuation.
385              
386             =item C<description>
387              
388             =item C<notes>
389              
390             A human-readable description and implementation notes, respectively, of the configuration node.
391             L<Config::Structured> does not do anything with these values at present, but they provides inline
392             documentation of configuration directivess within the structure (particularly useful in the common
393             case where the structure is read from a file)
394              
395             =back
396              
397             Besides C<structure> and C<config>, L<Config::Structured> also accepts a C<hooks> argument at
398             initialization time. This argument must be a HashRef whose keys are patterns matching config
399             node paths, and whose values are HashRefs containing C<on_load> and/or C<on_access> keys. These
400             in turn point to CodeRefs which are run when the config value is initially loaded, or every time
401             it is accessed, respectively.
402              
403             =head1 METHODS
404              
405             =head2 get($name?)
406              
407             Class method.
408              
409             Returns a registered L<Config::Structured> instance. If C<$name> is not provided, returns the default instance.
410             Instances can be registered with C<__register_default> or C<__register_as>. This mechanism is used to provide
411             global access to a configuration, even from code contexts that otherwise cannot share data.
412              
413             =head2 __register_default()
414              
415             Call on a L<Config::Structured> instance to set the instance as the default.
416              
417             =head2 __register_as($name)
418              
419             Call on a L<Config::Structured> instance to register the instance as the provided name.
420              
421             =head2 __get_child_node_names()
422              
423             Returns a list of names (strings) of all immediate child nodes of the current config node
424              
425             =head1 AUTHOR
426              
427             Mark Tyrrell <mtyrrell@concertpharma.com>
428              
429             =head1 COPYRIGHT AND LICENSE
430              
431             This software is copyright (c) 2023 by Concert Pharmaceuticals, Inc.
432              
433             This is free software; you can redistribute it and/or modify it under
434             the same terms as the Perl 5 programming language system itself.
435              
436             =cut