File Coverage

blib/lib/CatalystX/QueryModel.pm
Criterion Covered Total %
statement 73 86 84.8
branch 14 24 58.3
condition 4 8 50.0
subroutine 36 45 80.0
pod 0 4 0.0
total 127 167 76.0


line stmt bran cond sub pod time code
1             package CatalystX::QueryModel;
2              
3 6     6   85402 use Class::Method::Modifiers;
  6         50  
  6         456  
4 6     6   52 use Scalar::Util;
  6         12  
  6         203  
5 6     6   34 use Moo::_Utils;
  6         15  
  6         268  
6 6     6   37 use Module::Pluggable::Object;
  6         15  
  6         148  
7 6     6   30 use Module::Runtime ();
  6         16  
  6         116  
8 6     6   61 use CatalystX::RequestModel::Utils::InvalidContentType;
  6         14  
  6         1892  
9              
10             require Moo::Role;
11             require Sub::Util;
12              
13             our @DEFAULT_ROLES = (qw(CatalystX::QueryModel::DoesQueryModel));
14             our @DEFAULT_EXPORTS = (qw(property properties namespace content_type));
15             our %Meta_Data = ();
16              
17 24     24 0 91 sub default_roles { return @DEFAULT_ROLES }
18 48     48 0 164 sub default_exports { return @DEFAULT_EXPORTS }
19 0     0 0 0 sub request_model_metadata { return %Meta_Data }
20 0     0 0 0 sub request_model_metadata_for { return $Meta_Data{shift} }
21              
22             sub import {
23 24     24   245133 my $class = shift;
24 24         96 my $target = caller;
25              
26 24 50       591 unless (Moo::Role->is_role($target)) {
27 24         1323 my $orig = $target->can('with');
28             Moo::_Utils::_install_tracked($target, 'with', sub {
29 0 0   0   0 unless ($target->can('request_metadata')) {
        0      
        0      
        0      
        0      
30 0         0 $Meta_Data{$target}{'request'} = \my @data;
31 0     0   0 my $method = Sub::Util::set_subname "${target}::request_metadata" => sub { @data };
  0         0  
32 6     6   62 no strict 'refs';
  6         35  
  6         4301  
33 0         0 *{"${target}::request_metadata"} = $method;
  0         0  
34             }
35 0         0 &$orig;
36 24         218 });
37             }
38              
39 24         1514 foreach my $default_role ($class->default_roles) {
40 24 50       97 next if Role::Tiny::does_role($target, $default_role);
41 24         530 Moo::Role->apply_roles_to_package($target, $default_role);
42 24         18460 foreach my $export ($class->default_exports) {
43 96         2957 Moo::_Utils::_install_tracked($target, "__${export}_for_exporter", \&{"${target}::${export}"});
  96         367  
44             }
45             }
46              
47             my %cb = map {
48 24         927 $_ => $target->can("__${_}_for_exporter");
  96         436  
49             } $class->default_exports;
50              
51 24         126 foreach my $exported_method (keys %cb) {
52             my $sub = sub {
53 102 100   102   10778 if(Scalar::Util::blessed($_[0])) {
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
        102      
54 36         154 return $cb{$exported_method}->(@_);
55             } else {
56 66         247 return $cb{$exported_method}->($target, @_);
57             }
58 96         3049 };
59 96         252 Moo::_Utils::_install_tracked($target, $exported_method, $sub);
60             }
61              
62             Class::Method::Modifiers::install_modifier $target, 'around', 'has', sub {
63 60     60   41740 my $orig = shift;
64 60         259 my ($attr, %opts) = @_;
65              
66 60         114 my $predicate;
67 60 100       208 unless($opts{required}) {
68 48 50       146 if(exists $opts{predicate}) {
69 0         0 $predicate = $opts{predicate};
70             } else {
71 48         167 $predicate = "__cx_q_model_has_${attr}";
72 48         114 $opts{predicate} = $predicate;
73             }
74             }
75              
76 60 50       199 if(my $info = delete $opts{property}) {
77 60 50 50     379 $info = +{ name=>$attr } unless (ref($info)||'') eq 'HASH';
78 60 100       205 $info->{attr_predicate} = $predicate if defined($predicate);
79 60 50       187 $info->{omit_empty} = 1 unless exists($info->{omit_empty});
80 60         118 my $method = \&{"${target}::property"};
  60         319  
81 60         169 $method->($attr, $info, \%opts);
82             }
83              
84 60         256 return $orig->($attr, %opts);
85 24 50       1222 } if $target->can('has');
86             }
87              
88             sub _add_metadata {
89 66     66   165 my ($target, $type, @add) = @_;
90 66   66     252 my $store = $Meta_Data{$target}{$type} ||= do {
91 30         51 my @data;
92 30 50 33     123 if (Moo::Role->is_role($target) or $target->can("${type}_metadata")) {
93             $target->can('around')->("${type}_metadata", sub {
94 0     0   0 my ($orig, $self) = (shift, shift);
95 0         0 ($self->$orig(@_), @data);
96 0         0 });
97             } else {
98 30         1492 require Sub::Util;
99 30     26   281 my $method = Sub::Util::set_subname "${target}::${type}_metadata" => sub { @data };
  26     26   128  
        26      
        26      
        26      
        26      
100 6     6   66 no strict 'refs';
  6         14  
  6         943  
101 30         85 *{"${target}::${type}_metadata"} = $method;
  30         145  
102             }
103 30         117 \@data;
104             };
105              
106 66         154 push @$store, @add;
107 66         211 return;
108             }
109              
110             1;
111              
112             =head1 NAME
113              
114             CatalystX::QueryModel - Inflate Models from a Request Content Body or from URL Query Parameters
115              
116             =head1 SYNOPSIS
117              
118             An example Catalyst Request Model:
119              
120             package Example::Model::PagingQuery;
121              
122             use Moose;
123             use CatalystX::QueryModel;
124              
125             extends 'Catalyst::Model';
126              
127             namespace 'user';
128              
129             has status => (is=>'ro', property=>1);
130             has page => (is=>'ro', property=>1);
131              
132             __PACKAGE__->meta->make_immutable();
133              
134             Using it in a controller:
135              
136             package Example::Controller::User;
137              
138             use Moose;
139             use MooseX::MethodAttributes;
140              
141             extends 'Catalyst::Controller';
142              
143             sub root :Chained(/root) PathPart('user') CaptureArgs(0) { }
144              
145             sub list :GET Chained('root') PathPart('') Args(0) Does(QueryModel) QueryModel(PagingQuery) {
146             my ($self, $c, $query_model) = @_;
147             }
148              
149             __PACKAGE__->meta->make_immutable;
150              
151             Now if the incoming GET looks like this:
152              
153             [debug] Query Parameters are:
154             .-------------------------------------+--------------------------------------.
155             | Parameter | Value |
156             +-------------------------------------+--------------------------------------+
157             | user.page | 2 |
158             | user.status | active |
159             '-------------------------------------+--------------------------------------'
160              
161             The object instance C<$query_model> would look like:
162              
163             say $query_model->page; # 2
164             say $query_model->status; # 'active'
165              
166             And C<$query_model> has additional helper public methods to query attributes marked as request
167             fields (via the C<property> attribute field) which you can read about below.
168              
169             =head1 DESCRIPTION
170              
171             This is very similiar to <CatalystX::RequestModel> but for query parameters that are part of the request
172             URL. Basically we are mapping the query params hash to a object which makes it more robust to access
173             and gives you a place to do any sort of query parameter logic. Can neaten up your controllers and give
174             you more reusable code.
175              
176             =head2 Query options
177              
178             When you include "use CatalystX::QueryModel" we apply the role L<CatalystX::QueryModel::DoesQueryModel>
179             to you model, which gives you some useful methods as well as the ability to store the meta data needed
180             to properly mapped parsed query parameters to your model. You also get some imported subroutines and a
181             new field on your attribute declarations:
182              
183             C<namespace>: This is an optional imported subroutine which allows you to declare the namespace under which
184             we expect to find the attribute mappings. This can be useful if your fields are not top level in your
185             request content body (as in the example given above). This is optional and if you leave it off we just
186             assume all fields are in the top level of the parsed data hash that you content parser builds based on whatever
187             is in the content body.
188              
189             If you declare a namespace in a query model by default we don't throw an error if the namespace is missing
190             (unlike in request models) because I think for query parameters this is the common case where the query
191             is not required (for example in a paged list screen when you default to page 1 when a page is not given).
192             If you want the namespace required you can declare it so like this
193              
194             namespace paging => (required=>1);
195              
196             C<content_type>: This is the request content type which this model is designed to handle. For now you can
197             only declare one content type per model (if your endpoint can handle more than one content type you'll need
198             for now to define a request model for each one; I'm open to changing this to allow one than one content type
199             per request model, but I need to see your use cases for this before I paint myself into a corner codewise).
200              
201             This is also an optional check for query parameters.
202              
203             C<property>: This is a new field allowed on your attribute declarations. Setting its value to C<1> (as in
204             the example above) just means to use all the default settings for the declared content_type but you can declare
205             this as a hashref instead if you have special handling needs. For example:
206              
207             has notes => (is=>'ro', property=>+{ expand=>'JSON' });
208              
209             Here's the current list of property settings and what they do. You can also request the test cases for more
210             examples:
211              
212             =over 4
213              
214             =item name
215              
216             The name of the field in the request body we are mapping to the request model. The default is to just use
217             the name of the attribute.
218              
219             =item omit_empty
220              
221             Defaults to true. If there's no matching field in the request body we leave the request model attribute
222             empty (we don't stick an undef in there). If for some reason you don't want that, setting this to false
223             will put an undef into a scalar fields, and an empty array into an indexed one. If has no effect on
224             attributes that map to a submodel since I have no idea what that should be (your use cases welcomed).
225              
226             =item flatten
227              
228             If the value associated with a field is an array, flatten it to a single value. The default is based on
229             the body content parser. Its really a hack to deal with HTML form POST and Query parameters since the
230             way those formats work you can't be sure if a value is flat or an array. This isn't a problem with
231             JSON encoded request bodies. You'll need to check the docs for the Content Body Parser you are using to
232             see what this does.
233              
234             =item always_array
235              
236             Similar to C<flatten> but opposite, it forces a value into an array even if there's just one value. Again
237             mostly useful to deal with ideosyncracies of HTML form post.
238              
239             B<NOTE>: The attribute property settings C<flatten> and C<always_array> are currently exclusive (only one of
240             the two will apply if you supply both. The C<always_array> property always takes precedence. At some point
241             in the future supplying both might generate an exception so its best not to do that. I'm only leaving it
242             allowed for now since I'm not sure there's a use case for both.
243              
244             =item boolean
245              
246             Defaults to false. If true will convert value to the common Perl convention 0 is false, 1 is true. The way
247             this is converted is partly dependent on your content body parser.
248              
249             =item expand
250              
251             Example the value into a data structure by parsing it. Right now there's only one value this will take,
252             which is C<JSON> and will then parse the value into a structure using a JSON parser. Again this is mostly
253             useful for HTML form posting and coping with some limitations you have in classic HTML form input types.
254              
255             =back
256              
257             =head2 Setting a required attribute
258              
259             Generally it's best to not mark attributes which map to request properties as required and to handled anything
260             like thia via your validation layer so that you can provide more useful feedback to your application users.
261             If you do need to mark something required in order for your request model to be valid, please note that we
262             capture the exception created by Moo/se and throw L<CatalystX::RequestModel::Utils::BadRequest>. If you are
263             using L<CatalystX::Errors> this will get rendered as a HTTP 400 Bad Request; otherwise you just get the
264             generic L<Catalyst> HTTP 500 Server Error or as you might have written in your custom error handling code.
265              
266             =head2 Nested and Indexed attributes
267              
268             These work the same as in L<CatalystX::RequestModel>
269              
270             =head1 METHODS
271              
272             Please see L<CatalystX::QueryModel::DoesQueryModel> for the public API details.
273              
274             =head1 EXCEPTIONS
275              
276             This class can throw the following exceptions. Please note all exceptions are compatible with
277             L<CatalystX::Errors> to make it easy and consistent to convert errors to actual error responses.
278              
279             =head2 Bad Request
280              
281             If your request generates an exception when trying to instantiate your model (basically when calling ->new
282             on it) we capture that error, log the error and throw a L<CatalystX::RequestModel::Utils::BadRequest>
283              
284             =head2 Invalid Request Content Type
285              
286             If the incoming content body doesn't have a content type header that matches one of the available
287             content body parsers then we throw an L<CatalystX::RequestModel::Utils::InvalidContentType>. This
288             will get interpretated as an HTTP 415 status client error if you are using L<CatalystX::Errors>.
289              
290             =head1 AUTHOR
291              
292             John Napiorkowski <jjnapiork@cpan.org>
293              
294             =head1 COPYRIGHT
295            
296             2022
297              
298             =head1 LICENSE
299              
300             This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
301            
302             =cut
303