File Coverage

blib/lib/Net/Duo/Object.pm
Criterion Covered Total %
statement 125 133 93.9
branch 39 50 78.0
condition 4 5 80.0
subroutine 16 17 94.1
pod 5 5 100.0
total 189 210 90.0


line stmt bran cond sub pod time code
1             # Helper base class for Duo objects.
2             #
3             # The Duo API contains a variety of objects, represented as JSON objects with
4             # multiple fields. This objects often embed other objects inside them. To
5             # provide a nice Perl API with getters, setters, and commit and delete methods
6             # on individual objects, we want to wrap these Duo REST API objects in Perl
7             # classes.
8             #
9             # This module serves as a base class for such objects and does the dirty work
10             # of constructing an object from decoded JSON data and building the accessors
11             # automatically from a field specification.
12             #
13             # SPDX-License-Identifier: MIT
14              
15             package Net::Duo::Object 1.02;
16              
17 6     6   2961 use 5.014;
  6         22  
18 6     6   30 use strict;
  6         11  
  6         111  
19 6     6   37 use warnings;
  6         12  
  6         219  
20              
21 6     6   38 use Carp qw(croak);
  6         12  
  6         287  
22 6     6   47 use JSON ();
  6         10  
  6         133  
23 6     6   2585 use Sub::Install;
  6         10452  
  6         25  
24              
25             # Helper function to parse the data for a particular field specification.
26             #
27             # $spec - The field specification (a value in the hash from _fields)
28             #
29             # Returns: The type in scalar context
30             # The type and then a reference to a hash of flags in array context
31             sub _field_type {
32 581     581   990 my ($spec) = @_;
33 581         755 my ($type, @flags);
34              
35             # If the specification is a reference, it's an array, with the first value
36             # as type and the rest as flags. Otherwise, it's a simple type.
37 581 100       1099 if (ref($spec) eq 'ARRAY') {
38 255         317 ($type, @flags) = @{$spec};
  255         533  
39             } else {
40 326         440 $type = $spec;
41             }
42              
43             # Return the appropriate value or values.
44 581 100       1387 return wantarray ? ($type, { map { $_ => 1 } @flags }) : $type;
  188         683  
45             }
46              
47             # Helper function to do the data translation from the results of JSON parsing
48             # to our internal representation. This mostly consists of converting nested
49             # objects into proper objects, but it also makes a deep copy of the data.
50             #
51             # This is broken into a separate function so that it can be used by both new()
52             # and commit().
53             #
54             # $self - Class of object we're creating or an object of the right type
55             # $data_ref - Reference to parsed data from JSON
56             # $duo - Net::Duo object to use for subobjects
57             #
58             # Returns: Reference to hash suitable for blessing as an object
59             sub _convert_data {
60 20     20   48 my ($self, $data_ref, $duo) = @_;
61              
62             # Retrieve the field specification for this object.
63 20         63 my $fields = $self->_fields;
64              
65             # Make a deep copy of the data following the field specification.
66 20         34 my %result;
67             FIELD:
68 20         33 for my $field (keys %{$fields}) {
  20         73  
69 215 100       397 next FIELD if (!exists($data_ref->{$field}));
70 155         234 my $type = _field_type($fields->{$field});
71 155         234 my $value = $data_ref->{$field};
72 155 100       249 if ($type eq 'simple') {
    100          
    50          
73 138         276 $result{$field} = $value;
74             } elsif ($type eq 'array') {
75 11         17 $result{$field} = [@{$value}];
  11         32  
76             } elsif (defined($value)) {
77 6         10 my @objects;
78 6         8 for my $object (@{$value}) {
  6         10  
79 9         46 push(@objects, $type->new($duo, $object));
80             }
81 6         16 $result{$field} = \@objects;
82             }
83             }
84              
85             # Return the new data structure.
86 20         91 return \%result;
87             }
88              
89             # Create a new Net::Duo object. This constructor can be inherited by all
90             # object classes. It takes the decoded JSON and uses the field specification
91             # for the object to construct an object via deep copying.
92             #
93             # The child class must provide a static method fields() that returns a field
94             # specification. See the documentation for more details.
95             #
96             # $class - Class of object to create
97             # $duo - Net::Duo object to use for further API calls on this object
98             # $data_ref - Object data as a reference to a hash (usually decoded from JSON)
99             #
100             # Returns: Newly-created object
101             sub new {
102 16     16 1 36 my ($class, $duo, $data_ref) = @_;
103 16         53 my $self = $class->_convert_data($data_ref, $duo);
104 16         27 $self->{_duo} = $duo;
105 16         31 bless($self, $class);
106 16         67 return $self;
107             }
108              
109             # Create a new object in Duo. This constructor must be overridden by
110             # subclasses to pass in the additional URI parameter for the Duo API endpoint.
111             # It takes a reference to a hash representing the object values and returns
112             # the new object as an appropriately-blessed object. Currently, no local data
113             # checking is performed on the provided data.
114             #
115             # The child class must provide a static method fields() that returns a field
116             # specification. See the documentation for more details.
117             #
118             # $class - Class of object to create
119             # $duo - Net::Duo object to use to create the object
120             # $uri - Duo endpoint to use for creation
121             # $data_ref - Data for new object as a reference to a hash
122             #
123             # Returns: Newly-created object
124             # Throws: Net::Duo::Exception on any problem creating the object
125             sub create {
126 5     5 1 24 my ($class, $duo, $uri, $data_ref) = @_;
127              
128             # Retrieve the field specification for this object.
129 5         21 my $fields = $class->_fields;
130              
131             # Make a copy of the data and convert all boolean values.
132 5         11 my %data = %{$data_ref};
  5         28  
133             FIELD:
134 5         23 for my $field (keys %data) {
135 22         59 my ($type, $flags) = _field_type($fields->{$field});
136 22 100       80 if ($flags->{boolean}) {
    100          
137 3 100       9 $data{$field} = $data{$field} ? 'true' : 'false';
138             } elsif ($flags->{zero_or_one}) {
139 3 100       11 $data{$field} = $data{$field} ? 1 : 0;
140             }
141             }
142              
143             # Create the object in Duo.
144 5         42 my $self = $duo->call_json('POST', $uri, \%data);
145              
146             # Add the Net::Duo object.
147 5         18 $self->{_duo} = $duo;
148              
149             # Bless and return the new object.
150 5         25 bless($self, $class);
151 5         67 return $self;
152             }
153              
154             # Commit changes to the object to Duo. This method must be overridden by
155             # subclasses to pass in the additional URI parameter for the Duo API endpoint.
156             # It sends all of the fields that have been modified by setters, and then
157             # clears the flags that track modifications if the commit was successful.
158             #
159             # The child class must provide a static method fields() that returns a field
160             # specification. See the documentation for more details.
161             #
162             # $self - Subclass of Net::Duo::Object
163             # $uri - Duo endpoint to use for updates
164             #
165             # Returns: undef
166             # Throws: Net::Duo::Exception on any problem modifying the object in Duo
167             sub commit {
168 4     4 1 13 my ($self, $uri) = @_;
169              
170             # Retrieve the field specification for this object.
171 4         13 my $fields = $self->_fields;
172              
173             # Iterate through the changed fields to build the data for Duo. Remap
174             # boolean fields to true or false here.
175 4         9 my %data;
176 4         7 for my $field (keys %{ $self->{_changed} }) {
  4         20  
177 14         31 my ($type, $flags) = _field_type($fields->{$field});
178 14 50       41 if ($flags->{boolean}) {
    50          
179 0 0       0 $data{$field} = $self->{$field} ? 'true' : 'false';
180             } elsif ($flags->{zero_or_one}) {
181 0 0       0 $data{$field} = $self->{$field} ? 1 : 0;
182             } else {
183 14         40 $data{$field} = $self->{$field};
184             }
185             }
186              
187             # Modify the object in Duo. Duo will return the resulting new object,
188             # which we want to convert back to our internal representation.
189 4         22 my $new_data_ref = $self->{_duo}->call_json('POST', $uri, \%data);
190 4         20 $new_data_ref = $self->_convert_data($new_data_ref, $self->{_duo});
191              
192             # Duo may have changed or canonicalized the data, or someone else may have
193             # changed other parts of the object, so replace all of our data with what
194             # Duo now says the object looks like. Save our private fields. This is
195             # more extensible than having a whitelist of private fields.
196 4         13 delete $self->{_changed};
197 4         7 for my $field (keys %{$self}) {
  4         15  
198 38 100       77 next if ($field !~ m{ \A _ }xms);
199 4         10 $new_data_ref->{$field} = $self->{$field};
200             }
201 4         8 %{$self} = %{$new_data_ref};
  4         24  
  4         15  
202 4         30 return;
203             }
204              
205             # Create all the accessor methods for the object fields. This method is
206             # normally called via code outside of any method in the object class so that
207             # it is run when the class is first imported.
208             #
209             # The child class must provide a static method fields() that returns a field
210             # specification. See the documentation for more details.
211             #
212             # $class - Class whose accessors we're initializing
213             #
214             # Returns: undef
215             sub install_accessors {
216 30     30 1 94 my ($class) = @_;
217              
218             # Retrieve the field specification for this object.
219 30         94 my $fields = $class->_fields;
220              
221             # Create an accessor for each one.
222 30         62 for my $field (keys %{$fields}) {
  30         150  
223 318         3256 my ($type, $flags) = _field_type($fields->{$field});
224              
225             # For fields containing arrays, return a copy of the array instead
226             # of the reference to the internal data structure in the object,
227             # preventing client manipulation of our internals.
228 318         479 my $code;
229 318 100       576 if ($type eq 'simple') {
230 270     224   730 $code = sub { my $self = shift; return $self->{$field} };
  224         104601  
  224         1059  
231             } else {
232             $code = sub {
233 35     35   9288 my $self = shift;
234 35 100       193 return if !$self->{$field};
235 21         84 return @{ $self->{$field} };
  21         147  
236 48         147 };
237             }
238              
239             # Create and install the accessor.
240 318         840 my $spec = { code => $code, into => $class, as => $field };
241 318         772 Sub::Install::install_sub($spec);
242              
243             # If the "set" flag is set, also generate a setter.
244 318 100       13996 if ($flags->{set}) {
245 72 50       141 if ($type eq 'simple') {
246             $code = sub {
247 14     14   6388 my ($self, $value) = @_;
248 14         32 $self->{$field} = $value;
249 14         35 $self->{_changed}{$field} = 1;
250 14         27 return;
251 72         258 };
252             } else {
253             $code = sub {
254 0     0   0 my ($self, @values) = @_;
255 0         0 $self->{$field} = [@values];
256 0         0 $self->{_changed}{$field} = 1;
257 0         0 return;
258 0         0 };
259             }
260 72         248 $spec = { code => $code, into => $class, as => "set_$field" };
261 72         162 Sub::Install::install_sub($spec);
262             }
263             }
264 30         450 return;
265             }
266              
267             # Returns the current contents of the object as JSON. The json() method of
268             # nested objects is called to convert them in turn.
269             #
270             # $self - The object to convert to JSON
271             #
272             # Returns: JSON representation of the object using the Duo data model
273             sub json {
274 11     11 1 1331 my ($self) = @_;
275              
276             # Create a JSON encoder and decoder.
277 11         90 my $json = JSON->new->utf8(1);
278              
279             # Retrieve the field specification for this object.
280 11         85 my $fields = $self->_fields;
281              
282             # Iterate through the fields to build the data structure we'll convert to
283             # JSON. We have to do some data mapping and call the json() method on any
284             # embedded objects. This is unnecessarily inefficient since it converts
285             # the children to JSON and then back again, purely for coding convenience.
286 11         20 my %data;
287             FIELD:
288 11         20 for my $field (keys %{$self}) {
  11         79  
289 83 100       236 next FIELD if ($field =~ m{ \A _ }xms);
290 72         134 my ($type, $flags) = _field_type($fields->{$field});
291 72 100 100     198 if ($type eq 'simple' || $type eq 'array') {
292 69 100       149 if ($flags->{boolean}) {
    50          
293 3 100       48 $data{$field} = $self->{$field} ? JSON::true : JSON::false;
294             } elsif ($flags->{zero_or_one}) {
295 0 0       0 $data{$field} = $self->{$field} ? 1 : 0;
296             } else {
297 66         213 $data{$field} = $self->{$field};
298             }
299             } else {
300 3   50     5 my @children = map { $_->json } @{ $self->{$field} // [] };
  6         27  
  3         8  
301 3         8 $data{$field} = [map { $json->decode($_) } @children];
  6         40  
302             }
303             }
304              
305             # Convert the result to JSON and return it.
306 11         211 return $json->encode(\%data);
307             }
308              
309             1;
310             __END__