CatalystX-RequestModel
view release on metacpan or search on metacpan
lib/CatalystX/RequestModel.pm view on Meta::CPAN
need as well as the type of pre validation work we often perform in a controller. Think of it as a
type of command class pattern subtype. It promotes looser binding between your controller and your
applications models, and it makes for neater, smaller controllers as well as separating out the
types of work we do into smaller, more comprehendible classes. Lastly we encapsulate some of the
more common types of issues into configuration (for example dealing with how HTML form POSTed
parameters can cause you issues when they are sometimes in array form) as well as improve security
by having an explict interface to the model.
Also once we have a model that defines an expected request, we should be able to build upon the meta data
it exposed to do things like auto generate Open API / JSON Schema definition files (TBD but possible).
Basically you convert an unknown hash of values into a well defined object. This should reduce typo
induced errors at the very least.
The main downside here is the time you need to inflate the additional classes as well as some documentation
efforts needed to help new programmers understand this approach.
If you hate this idea but still like the thought of having more structure in mapping your incoming
random parameters you might want to check out L<Catalyst::TraitFor::Request::StructuredParameters>.
B<NOTE> This is work in progress / late beta code. What I mean by that is that I will try to maintain
the public API of this code (as described in the documentation) and only change it if absolutely
needed to move the code forward. However the non public code is subject to change at any time.
So if you are subclassing this and overriding non public methods you need to check carefully at each
new release, but if you are just using the code as described you just need to review the changelog
for any deprecation / breaking changes notices.
=head2 Declaring a model to accept request content bodies
To create a L<Catalyst> model that is ready to accept incoming content body data mapped to its attributes
you just need to use L<CatalystX::RequestModel>:
package Example::Model::RegistrationRequest;
use Moose;
use CatalystX::RequestModel; # <=== The important bit
extends 'Catalyst::Model';
namespace 'person'; # <=== Optional but useful when you have nested form data
content_type 'application/x-www-form-urlencoded'; <=== Required so that we know which content parser to use
has username => (is=>'ro', property=>1);
has first_name => (is=>'ro', property=>1);
has last_name => (is=>'ro', property=>1);
__PACKAGE__->meta->make_immutable();
When you include "use CatalystX::RequestModel" we apply the role L<CatalystX::RequestModel::DoesRequestModel>
to you model, which gives you some useful methods as well as the ability to store the meta data needed
to properly mapped parsed content bodies to your model. You also get two imported subroutines and a
new field on your attribute declarations:
C<namespace>: This is an optional imported subroutine which allows you to declare the namespace under which
we expect to find the attribute mappings. This can be useful if your fields are not top level in your
request content body (as in the example given above). This is optional and if you leave it off we just
assume all fields are in the top level of the parsed data hash that you content parser builds based on whatever
is in the content body.
C<content_type>: This is the request content type which this model is designed to handle. For now you can
only declare one content type per model (if your endpoint can handle more than one content type you'll need
for now to define a request model for each one; I'm open to changing this to allow one than one content type
per request model, but I need to see your use cases for this before I paint myself into a corner codewise).
C<property>: This is a new field allowed on your attribute declarations. Setting its value to C<1> (as in
the example above) just means to use all the default settings for the declared content_type but you can declare
this as a hashref instead if you have special handling needs. For example:
has notes => (is=>'ro', property=>+{ expand=>'JSON' });
Here's the current list of property settings and what they do. You can also request the test cases for more
examples:
=over 4
=item name
The name of the field in the request body we are mapping to the request model. The default is to just use
the name of the attribute.
=item omit_empty
Defaults to true. If there's no matching field in the request body we leave the request model attribute
empty (we don't stick an undef in there). If for some reason you don't want that, setting this to false
will put an undef into a scalar fields, and an empty array into an indexed one. If has no effect on
attributes that map to a submodel since I have no idea what that should be (your use cases welcomed).
=item flatten
If the value associated with a field is an array, flatten it to a single value. The default is based on
the body content parser. Its really a hack to deal with HTML form POST and Query parameters since the
way those formats work you can't be sure if a value is flat or an array. This isn't a problem with
JSON encoded request bodies. You'll need to check the docs for the Content Body Parser you are using to
see what this does.
=item always_array
Similar to C<flatten> but opposite, it forces a value into an array even if there's just one value. Again
mostly useful to deal with ideosyncracies of HTML form post.
B<NOTE>: The attribute property settings C<flatten> and C<always_array> are currently exclusive (only one of
the two will apply if you supply both. The C<always_array> property always takes precedence. At some point
in the future supplying both might generate an exception so its best not to do that. I'm only leaving it
allowed for now since I'm not sure there's a use case for both.
=item boolean
Defaults to false. If true will convert value to the common Perl convention 0 is false, 1 is true. The way
this is converted is partly dependent on your content body parser.
=item expand
Example the value into a data structure by parsing it. Right now there's only one value this will take,
which is C<JSON> and will then parse the value into a structure using a JSON parser. Again this is mostly
useful for HTML form posting and coping with some limitations you have in classic HTML form input types.
=back
=head2 Setting a required attribute
Generally it's best to not mark attributes which map to request properties as required and to handled anything
lib/CatalystX/RequestModel.pm view on Meta::CPAN
has last_name => (is=>'ro', property=>1);
has credit_cards => (is=>'ro', property=>+{ indexed=>1, model=>'AccountRequest::CreditCard' });
__PACKAGE__->meta->make_immutable();
package Example::Model::AccountRequest::CreditCard;
use Moose;
use CatalystX::RequestModel;
extends 'Catalyst::Model';
has id => (is=>'ro', property=>1);
has card_number => (is=>'ro', property=>1);
has expiration => (is=>'ro', property=>1);
Now if your incoming request looks like this it will be parsed into a deep structure by the
correct body parser and mapped to the request object:
.-------------------------------------+--------------------------------------.
| Parameter | Value |
+-------------------------------------+--------------------------------------+
| person.username | jjn |
| person.first_name | John |
| person.last_name | Napiorkowski |
| person.credit_cards[0].card_number | 123123123123123 |
| person.credit_cards[0].expiration | 3000-01-01 |
| person.credit_cards[0].id | 1 |
| person.credit_cards[1].card_number | 4444445555556666 |
| person.credit_cards[1].expiration | 4000-01-01 |
| person.credit_cards[1].id | 2
'-------------------------------------+--------------------------------------'
It would parse and inflate a request model like
my $request_model = $c->model('AccountRequest');
$request_model->username; # jjn
$request_model->first_name; # John
$request_model->last_name; # Napiorkowski
$request_model->credit_cards->[0]->card_number; # 123123123123123
$request_model->credit_cards->[0]->expiration; # 3000-01-01
$request_model->credit_cards->[1]->card_number; # 4444445555556666
$request_model->credit_cards->[1]->expiration; # 4000-01-01
Please note the difference between a request property that is marked as C<indexed> versus
C<always_array>. An C<indexed> property is required to have an array value while C<always_array>
merely coerces a scalar to an array if the value isn't already an array. You cannot use
C<indexed> and C<always_array> in the same request property.
B<NOTE> You can use the C<indexed> attribute property with simple scalar values as well as
deep structured objects. See test cases for more.
Please see L<CatalystX::RequestModel::ContentBodyParser::JSON> for an example JSON request body
with nesting. JSON is actually easier since we don't need a parsing convention to turn the
flat list you get with HTML Form post into a deep structure, nor deal with some of form posting's
idiocracies.
=head2 Endpoints with more than one request model
If an endpoint can handle more than one type of incoming content type you can define that
via the subroutine attribute and the code will pick the right one or throw an exception if none match
(See L</EXCEPTIONS> for more).
sub update :POST Chained('root') PathPart('') Args(0)
Does(RequestModel)
RequestModel(RegistrationRequestForm)
RequestModel(RegistrationRequesJSON)
{
my ($self, $c, $request_model) = @_;
## Do something with the $request_model
}
Also see L<Catalyst::ActionRole::RequestModel>.
=head1 QUERY PARAMETERS
See L<CatalystX::QueryModel>.
=head2 Requests with mixed query and body models
You might have a request that has both query parameters (via the URL) as well as a content body request.
In that case you make the content body request in the same way as you normally do and then add a second
request model that specifies the query parameters. For example you might have a form post with mixed
query and body parameters. You create your models as normal:
package Example::Model::InfoQuery;
use Moose;
use CatalystX::QueryModel;
extends 'Catalyst::Model';
has page => (is=>'ro', required=>1, property=>1);
has offset => (is=>'ro', property=>1);
has search => (is=>'ro', property=>1);
__PACKAGE__->meta->make_immutable();
package Example::Model::LoginRequest;
use Moose;
use CatalystX::RequestModel;
extends 'Catalyst::Model';
content_type 'application/x-www-form-urlencoded';
has username => (is=>'ro', required=>1, property=>1);
has password => (is=>'ro', property=>1);
__PACKAGE__->meta->make_immutable();
And in your action you list the request models:
sub postinfo :Chained(/) Args(0) Does(RequestModel) RequestModel(LoginRequest) QueryModel(InfoQuery) {
my ($self, $c, $login_request, $info_query) = @_;
}
Now if you get a request like this:
[debug] Query Parameters are:
( run in 0.697 second using v1.01-cache-2.11-cpan-39bf76dae61 )