From: nperez Date: Thu, 4 Feb 2010 14:15:31 +0000 (-0600) Subject: initial commit with working tests, docs, and conversion to dzil+podweaver X-Git-Tag: 2.001002~9 X-Git-Url: http://git.shadowcat.co.uk/gitweb/gitweb.cgi?p=catagits%2FCatalyst-Controller-DBIC-API.git;a=commitdiff_plain;h=d273984026646e5b57c052deef3fcb9121122060 initial commit with working tests, docs, and conversion to dzil+podweaver --- d273984026646e5b57c052deef3fcb9121122060 diff --git a/Changes b/Changes new file mode 100644 index 0000000..9ef6816 --- /dev/null +++ b/Changes @@ -0,0 +1,97 @@ +Revision history for Catalyst-Controller-DBIC-API: {{ $dist->version }} + +{{ $NEXT }} +- Merge create and update into update_or_create +- object is much advanced now: + + Identifier can be omitted, and data_root in the request is interpreted +- Because of the above one object or several is now possible for update or create +- Create and Update object validation now happens iteratively +- Creates and Updates can be mixed inside a single bulk request +- All modifying actions on the database occur within an all-or-nothing transaction +- Much of the DBIC search parameter munging is properly moved to the RequestArguments + Role in the form of a trigger on 'search' to populate 'search_parameters' and + 'search_attributes' which correspond directly to ->search($parameters, $attributes); +- Error handling is now much more consistent, using Try::Tiny everywhere possible +- Tests are now modernized and use JSON::Any +- Extending is now explicitly done via Moose method modifiers +- The only portion of the stash in use is to allow runtime definition of create/update_allows +- list is now broken down into several steps: + + list_munge_parameters + + list_perform_search + + list_format_output + + row_format_output (which is just a passthrough per row) + +1.004002 +- Implement 'as' as a complement to 'select' +- CGI::Expand'ed search parameters are now also JSON decoded + test +- Fixed pod for parameters using a json string which shouldn't be surrounded + by single quotes +- Use next instead of NEXT in RPC +- Moved sub object from RPC/REST to Base to DRY + This will break your code if you subclass from REST + and had relied on the action name 'object' +- Check for defined objects before returning them for create/update + +1.004001 +- Allow for more complex prefetch_allows (multiple keys in hash) +- Skip non-existant parameters in deserialization +- Fixed whitespace to use spaces instead of tabs +- Fixed pod to not use the config attributes from before 1.004 +- Fixed prefetch_allows check to properly handle nested attrs + test + +1.004000 +- Moosify +- Move validation for *_exposes/*_allows to Data::DPath::Validator +- Reorganize internals to use Moose and roles +- Allow maximum configuration for what request parameters are named +- Properly handle JSON boolean values +- Earlier and more consistent validation of configuration and request parameters + +1.003004 +- Database errors are also handled for searches + tests +- Totalcount isn't included in the response if a db error occurs while fetching data +- Converted no_plan tests to done_testing (required Test::More 0.88) + +1.003003 +- Database errors are properly handled + test +- Fixed isa redefined warnings +- Fixed bug preventing compat with future Catalyst::Action::Deserialize versions + +1.003002 +- Added totalcount to paged list responses +- Fixed some tests weren't run in t/rpc/list.t +- Fixed wrong setup_dbic_args_method error message + +1.003001 +- Minor fix to prevent failing test + +1.003000 +- Added prefetch support +- Refactored to ensure all request params accept JSON, CGI::Expand or standard params +- Doc improvements + +1.002000 +- Better error handing when unable to parse search arg +- Added setup_dbic_args_method config option +- Added list_search_exposes config option +- Removed duplicate tests in t/rpc/list.t and t/rest/list.t +- Fixed searches on columns which have a rel with the same name + and vice versa +- Added search by json +- Added pagination support + +1.001000 +- Added setup_list_method configuration flag (jshirley) +- Added support for setting config params in stash +- Added list_grouped_by, list_count and list_ordered_by config attributes +- Fixed bug with behaviour of list_returns + +1.000002 +- Fixed lack of deserialization under RPC + +1.000001 +- Improved docs + +1.000000 +- Released + diff --git a/dist.ini b/dist.ini new file mode 100644 index 0000000..17ce2d0 --- /dev/null +++ b/dist.ini @@ -0,0 +1,30 @@ +name = Catalyst-Controller-DBIC-API +version = 2.001000 +author = Nicholas Perez +author = Luke Saunders +author = Alexander Hartmaier +license = Perl_5 +copyright_holder = Luke Saunders, Nicholas Perez, et al. + +[@Classic] + +[PodWeaver] +[BumpVersion] +[PkgVersion] +[PodVersion] +[NextRelease] +[MetaResources] +repository = git://git.shadowcat.co.uk/catagits/Catalyst-Controller-DBIC-API + +[Prereq] +DBIx::Class = 0.08103 +Catalyst::Runtime = 5.7010 +Catalyst::Action::REST = 0.60 +CGI::Expand = 2.02 +JSON::Any = 1.19 +Test::Deep = 0.104 +Data::DPath::Validator = 0.093411 +Test::More = 0.88 +Catalyst::Model::DBIC::Schema = 0.20 +Test::WWW::Mechanize = 0.20 +Test::WWW::Mechanize::Catalyst = 0.37 diff --git a/lib/Catalyst/Controller/DBIC/API.pm b/lib/Catalyst/Controller/DBIC/API.pm new file mode 100644 index 0000000..b992e96 --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API.pm @@ -0,0 +1,949 @@ +package Catalyst::Controller::DBIC::API; + +#ABSTRACT: Provides a DBIx::Class web service automagically +use Moose; +BEGIN { extends 'Catalyst::Controller'; } + +use CGI::Expand (); +use DBIx::Class::ResultClass::HashRefInflator; +use JSON::Any; +use Test::Deep::NoTest('eq_deeply'); +use MooseX::Types::Moose(':all'); +use Moose::Util; +use Scalar::Util('blessed', 'reftype'); +use Try::Tiny; +use Catalyst::Controller::DBIC::API::Request; +use namespace::autoclean; + +with 'Catalyst::Controller::DBIC::API::StoredResultSource'; +with 'Catalyst::Controller::DBIC::API::StaticArguments'; +with 'Catalyst::Controller::DBIC::API::RequestArguments' => { static => 1 }; + +__PACKAGE__->config(); + +=head1 SYNOPSIS + + package MyApp::Controller::API::RPC::Artist; + use Moose; + BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' } + + __PACKAGE__->config + ( action => { setup => { PathPart => 'artist', Chained => '/api/rpc/rpc_base' } }, # define parent chain action and partpath + class => 'MyAppDB::Artist', # DBIC schema class + create_requires => ['name', 'age'], # columns required to create + create_allows => ['nickname'], # additional non-required columns that create allows + update_allows => ['name', 'age', 'nickname'], # columns that update allows + update_allows => ['name', 'age', 'nickname'], # columns that update allows + select => [qw/name age/], # columns that data returns + prefetch => ['cds'], # relationships that are prefetched when no prefetch param is passed + prefetch_allows => [ # every possible prefetch param allowed + 'cds', + qw/ cds /, + { cds => 'tracks' }, + { cds => [qw/ tracks /] } + ], + ordered_by => [qw/age/], # order of generated list + search_exposes => [qw/age nickname/, { cds => [qw/title year/] }], # columns that can be searched on via list + data_root => 'data' # defaults to "list" for backwards compatibility + use_json_boolean => 1, # use JSON::Any::true|false in the response instead of strings + return_object => 1, # makes create and update actions return the object + ); + + # Provides the following functional endpoints: + # /api/rpc/artist/create + # /api/rpc/artist/list + # /api/rpc/artist/id/[id]/delete + # /api/rpc/artist/id/[id]/update +=cut + +=method_protected begin + + :Private + +A begin method is provided to apply the L role to $c->request, and perform deserialization and validation of request parameters + +=cut + +sub begin :Private +{ + $DB::single = 1; + my ($self, $c) = @_; + + Catalyst::Controller::DBIC::API::Request->meta->apply($c->req) + unless Moose::Util::does_role($c->req, 'Catalyst::Controller::DBIC::API::Request'); + $c->forward('deserialize'); +} + +=method_protected setup + + :Chained('specify.in.subclass.config') :CaptureArgs(0) :PathPart('specify.in.subclass.config') + +This action is the chain root of the controller. It must either be overridden or configured to provide a base pathpart to the action and also a parent action. For example, for class MyAppDB::Track you might have + + package MyApp::Controller::API::RPC::Track; + use base qw/Catalyst::Controller::DBIC::API::RPC/; + + __PACKAGE__->config + ( action => { setup => { PathPart => 'track', Chained => '/api/rpc/rpc_base' } }, + ... + ); + + # or + + sub setup :Chained('/api/rpc_base') :CaptureArgs(0) :PathPart('track') { + my ($self, $c) = @_; + + $self->next::method($c); + } + +This action will populate $c->req->current_result_set with $self->stored_result_source->resultset for other actions in the chain to use. + +=cut + +sub setup :Chained('specify.in.subclass.config') :CaptureArgs(0) :PathPart('specify.in.subclass.config') +{ + $DB::single = 1; + my ($self, $c) = @_; + + $c->req->_set_current_result_set($self->stored_result_source->resultset); +} + +=method_protected object + + :Chained('setup') :CaptureArgs(1) :PathPart('') + +This action is the chain root for all object level actions (such as delete and update). If an identifier is passed it will be used to find that particular object and add it to the request's store of objects. Otherwise, the data stored at the data_root of the request_data will be interpreted as an array of objects on which to operate. If the hashes are missing an 'id' key, they will be considered a new object to be created, otherwise, the values in the hash will be used to perform an update. Please see L for more details on the stored objects. + +=cut + +sub object :Chained('setup') :CaptureArgs(1) :PathPart('') +{ + my ($self, $c, $id) = @_; + + my $vals = $c->req->request_data->{$self->data_root}; + unless(defined($vals)) + { + # no data root, assume the request_data itself is the payload + $vals = [$c->req->request_data || {}]; + } + elsif(reftype($vals) eq 'HASH') + { + $vals = [ $vals ]; + } + + if(defined($id)) + { + try + { + # there can be only one set of data + $c->req->add_object([$self->object_lookup($c, $id), $vals->[0]]); + } + catch + { + $c->log->error($_); + $self->push_error($c, { message => $_ }); + $c->detach(); + } + } + else + { + unless(reftype($vals) eq 'ARRAY') + { + $c->log->error('Invalid request data'); + $self->push_error($c, { message => 'Invalid request data' }); + $c->detach(); + } + + foreach my $val (@$vals) + { + unless(exists($val->{id})) + { + $c->req->add_object([$c->req->current_result_set->new_result({}), $val]); + next; + } + + try + { + $c->req->add_object([$self->object_lookup($c, $val->{id}), $val]); + } + catch + { + $c->log->error($_); + $self->push_error($c, { message => $_ }); + $c->detach(); + } + } + } +} + +=method_protected object_lookup + +This method provides the look up functionality for an object based on 'id'. It is passed the current $c and the $id to be used to perform the lookup. Dies if there is no provided $id or if no object was found. + +=cut + +sub object_lookup +{ + my ($self, $c, $id) = @_; + + die 'No valid ID provided for look up' unless defined $id and length $id; + my $object = $c->req->current_result_set->find($id); + die "No object found for id '$id'" unless defined $object; + return $object; +} + +=method_protected deserialize + +deserialize absorbs the request data and transforms it into useful bits by using CGI::Expand->expand_hash and a smattering of JSON::Any->from_json for a handful of arguments. Current only the following arguments are capable of being expressed as JSON: + + search_arg + count_arg + page_arg + ordered_by_arg + grouped_by_arg + prefetch_arg + +It should be noted that arguments can used mixed modes in with some caveats. Each top level arg can be expressed as CGI::Expand with their immediate child keys expressed as JSON. + +=cut + +sub deserialize :ActionClass('Deserialize') +{ + $DB::single = 1; + my ($self, $c) = @_; + my $req_params; + + if ($c->req->data && scalar(keys %{$c->req->data})) + { + $req_params = $c->req->data; + } + else + { + $req_params = CGI::Expand->expand_hash($c->req->params); + + foreach my $param (@{[$self->search_arg, $self->count_arg, $self->page_arg, $self->ordered_by_arg, $self->grouped_by_arg, $self->prefetch_arg]}) + { + # these params can also be composed of JSON + # but skip if the parameter is not provided + next if not exists $req_params->{$param}; + # find out if CGI::Expand was involved + if (ref $req_params->{$param} eq 'HASH') + { + for my $key ( keys %{$req_params->{$param}} ) + { + try + { + my $deserialized = JSON::Any->from_json($req_params->{$param}->{$key}); + $req_params->{$param}->{$key} = $deserialized; + } + catch + { + $c->log->debug("Param '$param.$key' did not deserialize appropriately: $_") + if $c->debug; + } + } + } + else + { + try + { + my $deserialized = JSON::Any->from_json($req_params->{$param}); + $req_params->{$param} = $deserialized; + } + catch + { + $c->log->debug("Param '$param' did not deserialize appropriately: $_") + if $c->debug; + } + } + } + } + + $self->inflate_request($c, $req_params); +} + +=method_protected inflate_request + +inflate_request is called at the end of deserialize to populate key portions of the request with the useful bits + +=cut + +sub inflate_request +{ + $DB::single = 1; + my ($self, $c, $params) = @_; + + try + { + # set static arguments + $c->req->_set_controller($self); + + # set request arguments + $c->req->_set_request_data($params); + + } + catch + { + $c->log->error($_); + $self->push_error($c, { message => $_ }); + $c->detach(); + } + +} + +=method_protected list + + :Private + +List level action chained from L. List's steps are broken up into three distinct methods: L, L, and L. + +The goal of this method is to call ->search() on the current_result_set, HashRefInflator the result, and return it in $c->stash->{response}->{$self->data_root}. Pleasee see the individual methods for more details on what actual processing takes place. + +If the L config param is defined then the hashes will contain only those columns, otherwise all columns in the object will be returned. L of course supports the function/procedure calling semantics that L. In order to have proper column names in the result, provide arguments in L (which also follows L semantics. Similarly L, L, L and L affect the maximum number of rows returned as well as the ordering and grouping. Note that if select, count, ordered_by or grouped_by request parameters are present then these will override the values set on the class with select becoming bound by the select_exposes attribute. + +If not all objects in the resultset are required then it's possible to pass conditions to the method as request parameters. You can use a JSON string as the 'search' parameter for maximum flexibility or use L syntax. In the second case the request parameters are expanded into a structure and then used as the search condition. + +For example, these request parameters: + + ?search.name=fred&search.cd.artist=luke + OR + ?search={"name":"fred","cd": {"artist":"luke"}} + +Would result in this search (where 'name' is a column of the schema class, 'cd' is a relation of the schema class and 'artist' is a column of the related class): + + $rs->search({ name => 'fred', 'cd.artist' => 'luke' }, { join => ['cd'] }) + +It is also possible to use a JSON string for expandeded parameters: + + ?search.datetime={"-between":["2010-01-06 19:28:00","2010-01-07 19:28:00"]} + +Note that if pagination is needed, this can be achieved using a combination of the L and L parameters. For example: + + ?page=2&count=20 + +Would result in this search: + + $rs->search({}, { page => 2, rows => 20 }) + +=cut + +sub list :Private +{ + $DB::single = 1; + my ($self, $c) = @_; + + $self->list_munge_parameters($c); + $self->list_perform_search($c); + $self->list_format_output($c); +} + +=method_protected list_munge_parameters + +list_munge_parameters is a noop by default. All arguments will be passed through without any manipulation. In order to successfully manipulate the parameters before the search is performed, simply access $c->req->search_parameters|search_attributes (ArrayRef and HashRef respectively), which correspond directly to ->search($parameters, $attributes). Parameter keys will be in already-aliased form. + +=cut + +sub list_munge_parameters { } # noop by default + +=method_protected list_perform_search + +list_perform_search executes the actual search. current_result_set is updated to contain the result returned from ->search. If paging was requested, search_total_entries will be set as well. + +=cut + +sub list_perform_search +{ + $DB::single = 1; + my ($self, $c) = @_; + + try + { + my $req = $c->req; + + my $rs = $req->current_result_set->search + ( + $req->search_parameters, + $req->search_attributes + ); + + $req->_set_current_result_set($rs); + + $req->_set_search_total_entries($req->current_result_set->pager->total_entries) + if $req->has_search_attributes && $req->search_attributes->{page}; + } + catch + { + $c->log->error($_); + $self->push_error($c, { message => 'a database error has occured.' }); + $c->detach(); + } +} + +=method_protected list_format_output + +list_format_output prepares the response for transmission across the wire. A copy of the current_result_set is taken and its result_class is set to L. Each row in the resultset is then iterated and passed to L with the result of that call added to the output. + +=cut + +sub list_format_output +{ + $DB::single = 1; + my ($self, $c) = @_; + + my $rs = $c->req->current_result_set->search; + $rs->result_class('DBIx::Class::ResultClass::HashRefInflator'); + + try + { + my $output = {}; + my $formatted = []; + + foreach my $row ($rs->all) + { + push(@$formatted, $self->row_format_output($row)); + } + + $output->{$self->data_root} = $formatted; + + if ($c->req->has_search_total_entries) + { + $output->{$self->total_entries_arg} = $c->req->search_total_entries + 0; + } + + $c->stash->{response} = $output; + } + catch + { + $c->log->error($_); + $self->push_error($c, { message => 'a database error has occured.' }); + $c->detach(); + } +} + +=method_protected row_format_output + +row_format_output is called each row of the inflated output generated from the search. It receives only one argument, the hashref that represents the row. By default, this method is merely a passthrough. + +=cut + +sub row_format_output { shift; shift; } # passthrough by default + +=method_protected update_or_create + + :Private + +update_or_create is responsible for iterating any stored objects and performing updates or creates. Each object is first validated to ensure it meets the criteria specified in the L and L (or L) parameters of the controller config. The objects are then committed within a transaction via L. + +=cut + +sub update_or_create :Private +{ + $DB::single = 1; + my ($self, $c) = @_; + + if($c->req->has_objects) + { + $self->validate_objects($c); + $self->transact_objects($c, \&save_objects); + } + else + { + $c->log->error($_); + $self->push_error($c, { message => 'No objects on which to operate' }); + $c->detach(); + } +} + +=method_protected transact_objects + +transact_objects performs the actual commit to the database via $schema->txn_do. This method accepts two arguments, the context and a coderef to be used within the transaction. All of the stored objects are passed as an arrayref for the only argument to the coderef. + +=cut + +sub transact_objects +{ + $DB::single = 1; + my ($self, $c, $coderef) = @_; + + try + { + $self->stored_result_source->schema->txn_do + ( + $coderef, + $c->req->objects + ); + } + catch + { + $c->log->error($_); + $self->push_error($c, { message => 'a database error has occured.' }); + $c->detach(); + } +} + +=method_protected validate_objects + +This is a shortcut method for performing validation on all of the stored objects in the request. Each object's provided values (for create or update) are updated to the allowed values permitted by the various config parameters. + +=cut + +sub validate_objects +{ + $DB::single = 1; + my ($self, $c) = @_; + + try + { + foreach my $obj ($c->req->all_objects) + { + $obj->[1] = $self->validate_object($c, $obj); + } + } + catch + { + my $err = $_; + $c->log->error($err); + $err =~ s/\s+at\s+\/.+\n$//g; + $self->push_error($c, { message => $err }); + $c->detach(); + } +} + +=method_protected validate_object + +validate_object takes the context and the object as an argument. It then filters the passed values in slot two of the tuple through the create|update_allows configured. It then returns those filtered values. Values that are not allowed are silently ignored. If there are no values for a particular key, no valid values at all, or multiple of the same key, this method will die. + +=cut + +sub validate_object +{ + $DB::single = 1; + my ($self, $c, $obj) = @_; + my ($object, $params) = @$obj; + + my %values; + my %requires_map = map + { + $_ => 1 + } + @{ + ($object->in_storage) + ? [] + : $c->stash->{create_requires} || $self->create_requires + }; + + my %allows_map = map + { + (ref $_) ? %{$_} : ($_ => 1) + } + ( + keys %requires_map, + @{ + ($object->in_storage) + ? ($c->stash->{update_allows} || $self->update_allows) + : ($c->stash->{create_allows} || $self->create_allows) + } + ); + + foreach my $key (keys %allows_map) + { + # check value defined if key required + my $allowed_fields = $allows_map{$key}; + + if (ref $allowed_fields) + { + my $related_source = $object->result_source->related_source($key); + my $related_params = $params->{$key}; + my %allowed_related_map = map { $_ => 1 } @$allowed_fields; + my $allowed_related_cols = ($allowed_related_map{'*'}) ? [$related_source->columns] : $allowed_fields; + + foreach my $related_col (@{$allowed_related_cols}) + { + if (my $related_col_value = $related_params->{$related_col}) { + $values{$key}{$related_col} = $related_col_value; + } + } + } + else + { + my $value = $params->{$key}; + + if ($requires_map{$key}) + { + unless (defined($value)) + { + # if not defined look for default + $value = $object->result_source->column_info($key)->{default_value}; + unless (defined $value) + { + die "No value supplied for ${key} and no default"; + } + } + } + + # check for multiple values + if (ref($value) && !($value == JSON::Any::true || $value == JSON::Any::false)) + { + require Data::Dumper; + die "Multiple values for '${key}': ${\Data::Dumper::Dumper($value)}"; + } + + # check exists so we don't just end up with hash of undefs + # check defined to account for default values being used + $values{$key} = $value if exists $params->{$key} || defined $value; + } + } + + unless (keys %values || !$object->in_storage) + { + die 'No valid keys passed'; + } + + return \%values; +} + +=method_protected delete + + :Private + +delete operates on the stored objects in the request. It first transacts the objects, deleting them in the database, and then clears the request store of objects. + +=cut + +sub delete :Private +{ + $DB::single = 1; + my ($self, $c) = @_; + + if($c->req->has_objects) + { + $self->transact_objects($c, \&delete_objects); + $c->req->clear_objects; + } + else + { + $c->log->error($_); + $self->push_error($c, { message => 'No objects on which to operate' }); + $c->detach(); + } +} + +=head1 HELPER FUNCTIONS + +This functions are only helper functions and should have a void invocant. If they are called as methods, they will die. The only reason they are stored in the class is to allow for customization without rewriting the methods that make use of these helper functions. + +=head2 save_objects + +This helper function is used by update_or_create to perform the actual database manipulations. + +=head2 delete_objects + +This helper function is used by delete to perform the actual database delete of objects. + +=cut + +# NOT A METHOD +sub save_objects +{ + my ($objects) = @_; + die 'save_objects coderef had an invocant and shouldn\'t have had one' if blessed($objects); + + foreach my $obj (@$objects) + { + my ($object, $params) = @$obj; + + if ($object->in_storage) { + foreach my $key (keys %{$params}) { + my $value = $params->{$key}; + if (ref($value) && !($value == JSON::Any::true || $value == JSON::Any::false)) { + my $related_params = delete $params->{$key}; + my $row = $object->find_related($key, {} , {}); + $row->update($related_params); + } + } + $object->update($params); + } else { + $object->set_columns($params); + $object->insert; + } + } +} + +# NOT A METHOD +sub delete_objects +{ + my ($objects) = @_; + die 'delete_objects coderef had an invocant and shouldn\'t have had one' if blessed($objects); + + map { $_->[0]->delete } @$objects; +} + +=method_protected end + + :Private + +end performs the final manipulation of the response before it is serialized. This includes setting the success of the request both at the HTTP layer and JSON layer. If configured with return_object true, and there are stored objects as the result of create or update, those will be inflated according to the schema and get_inflated_columns + +=cut + +sub end :Private +{ + $DB::single = 1; + my ($self, $c) = @_; + + # check for errors + my $default_status; + + # Check for errors caught elsewhere + if ( $c->res->status and $c->res->status != 200 ) { + $default_status = $c->res->status; + $c->stash->{response}->{success} = $self->use_json_boolean ? JSON::Any::false : 'false'; + } elsif ($self->get_errors($c)) { + $c->stash->{response}->{messages} = $self->get_errors($c); + $c->stash->{response}->{success} = $self->use_json_boolean ? JSON::Any::false : 'false'; + $default_status = 400; + } else { + $c->stash->{response}->{success} = $self->use_json_boolean ? JSON::Any::true : 'true'; + $default_status = 200; + } + + unless ($default_status == 200) + { + delete $c->stash->{response}->{$self->data_root}; + } + elsif($self->return_object && $c->req->has_objects) + { + $DB::single = 1; + my $returned_objects = []; + map {my %inflated = $_->[0]->get_inflated_columns; push(@$returned_objects, \%inflated) } $c->req->all_objects; + $c->stash->{response}->{$self->data_root} = scalar(@$returned_objects) > 1 ? $returned_objects : $returned_objects->[0]; + } + + $c->res->status( $default_status || 200 ); + $c->forward('serialize'); +} + +# from Catalyst::Action::Serialize +sub serialize :ActionClass('Serialize') { + my ($self, $c) = @_; + +} + +=method_protected push_error + +push_error stores an error message into the stash to be later retrieved by L. Accepts a Dict[message => Str] parameter that defines the error message. + +=cut + +sub push_error +{ + my ( $self, $c, $params ) = @_; + push( @{$c->stash->{_dbic_crud_errors}}, $params->{message} || 'unknown error' ); +} + +=method_protected get_errors + +get_errors returns all of the errors stored in the stash + +=cut + +sub get_errors +{ + my ( $self, $c ) = @_; + return $c->stash->{_dbic_crud_errors}; +} + +=head1 DESCRIPTION + +Easily provide common API endpoints based on your L schema classes. Module provides both RPC and REST interfaces to base functionality. Uses L and L to serialise response and/or deserialise request. + +=head1 OVERVIEW + +This document describes base functionlity such as list, create, delete, update and the setting of config attributes. L and L describe details of provided endpoints to those base methods. + +You will need to create a controller for each schema class you require API endpoints for. For example if your schema has Artist and Track, and you want to provide a RESTful interface to these, you should create MyApp::Controller::API::REST::Artist and MyApp::Controller::API::REST::Track which both subclass L. Similarly if you wanted to provide an RPC style interface then subclass L. You then configure these individually as specified in L. + +Also note that the test suite of this module has an example application used to run tests against. It maybe helpful to look at that until a better tutorial is written. + +=head2 CONFIGURATION + +Each of your controller classes needs to be configured to point at the relevant schema class, specify what can be updated and so on, as shown in the L. + +The class, create_requires, create_allows and update_requires parameters can also be set in the stash like so: + + sub setup :Chained('/api/rpc/rpc_base') :CaptureArgs(1) :PathPart('any') { + my ($self, $c, $object_type) = @_; + + if ($object_type eq 'artist') { + $c->stash->{class} = 'MyAppDB::Artist'; + $c->stash->{create_requires} = [qw/name/]; + $c->stash->{update_allows} = [qw/name/]; + } else { + $self->push_error($c, { message => "invalid object_type" }); + return; + } + + $self->next::method($c); + } + +Generally it's better to have one controller for each DBIC source with the config hardcoded, but in some cases this isn't possible. + +Note that the Chained, CaptureArgs and PathPart are just standard Catalyst configuration parameters and that then endpoint specified in Chained - in this case '/api/rpc/rpc_base' - must actually exist elsewhere in your application. See L for more details. + +Below are explanations for various configuration parameters. Please see L for more details. + +=head3 class + +Whatever you would pass to $c->model to get a resultset for this class. MyAppDB::Track for example. + +=head3 data_root + +By default, the response data is serialized into $c->stash->{response}->{$self->data_root} and data_root defaults to 'list' to preserve backwards compatibility. This is now configuable to meet the needs of the consuming client. + +=head3 use_json_boolean + +By default, the response success status is set to a string value of "true" or "false". If this attribute is true, JSON::Any's true() and false() will be used instead. Note, this does not effect other internal processing of boolean values. + +=head3 count_arg, page_arg, select_arg, search_arg, grouped_by_arg, ordered_by_arg, prefetch_arg, as_arg, total_entries_arg + +These attributes allow customization of the component to understand requests made by clients where these argument names are not flexible and cannot conform to this components defaults. + +=head3 create_requires + +Arrayref listing columns required to be passed to create in order for the request to be valid. + +=head3 create_allows + +Arrayref listing columns additional to those specified in create_requires that are not required to create but which create does allow. Columns passed to create that are not listed in create_allows or create_requires will be ignored. + +=head3 update_allows + +Arrayref listing columns that update will allow. Columns passed to update that are not listed here will be ignored. + +=head3 select + +Arguments to pass to L when performing search for L. + +=head3 as + +Complements arguments passed to L when performing a search. This allows you to specify column names in the result for RDBMS functions, etc. + +=head3 select_exposes + +Columns and related columns that are okay to return in the resultset since clients can request more or less information specified than the above select argument. + +=head3 prefetch + +Arguments to pass to L when performing search for L. + +=head3 prefetch_allows + +Arrayref listing relationships that are allowed to be prefetched. +This is necessary to avoid denial of service attacks in form of +queries which would return a large number of data +and unwanted disclosure of data. + +=head3 grouped_by + +Arguments to pass to L when performing search for L. + +=head3 ordered_by + +Arguments to pass to L when performing search for L. + +=head3 search_exposes + +Columns and related columns that are okay to search on. For example if only the position column and all cd columns were to be allowed + + search_exposes => [qw/position/, { cd => ['*'] }] + +You can also use this to allow custom columns should you wish to allow them through in order to be caught by a custom resultset. For example: + + package RestTest::Controller::API::RPC::TrackExposed; + + ... + + __PACKAGE__->config + ( ..., + search_exposes => [qw/position title custom_column/], + ); + +and then in your custom resultset: + + package RestTest::Schema::ResultSet::Track; + + use base 'RestTest::Schema::ResultSet'; + + sub search { + my $self = shift; + my ($clause, $params) = @_; + + # test custom attrs + if (my $pretend = delete $clause->{custom_column}) { + $clause->{'cd.year'} = $pretend; + } + my $rs = $self->SUPER::search(@_); + } + +=head3 count + +Arguments to pass to L when performing search for L. + +=head3 page + +Arguments to pass to L when performing search for L. + +=head1 EXTENDING + +By default the create, delete and update actions will not return anything apart from the success parameter set in L, often this is not ideal but the required behaviour varies from application to application. So normally it's sensible to write an intermediate class which your main controller classes subclass from. + +For example if you wanted create to return the JSON for the newly created object you might have something like: + + package MyApp::ControllerBase::DBIC::API::RPC; + ... + use Moose; + BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' }; + ... + sub create :Chained('setup') :Args(0) :PathPart('create') { + my ($self, $c) = @_; + + # $c->req->all_objects will contain all of the created + $self->next::method($c); + + if ($c->req->has_objects) { + # $c->stash->{response} will be serialized in the end action + $c->stash->{response}->{$self->data_root} = [ map { { $_->get_inflated_columns } } ($c->req->all_objects) ] ; + } + } + + + package MyApp::Controller::API::RPC::Track; + ... + use Moose; + BEGIN { extends 'MyApp::ControllerBase::DBIC::API::RPC' }; + ... + +It should be noted that the L attribute will produce the above result for you, free of charge. + +For REST the only difference besides the class names would be that create should be :Private rather than an endpoint. + +Similarly you might want create, update and delete to all forward to the list action once they are done so you can refresh your view. This should also be simple enough. + +If more extensive customization is required, it is recommened to peer into the roles that comprise the system and make use + +=head1 NOTES + +It should be noted that version 1.004 and above makes a rapid depature from the status quo. The internals were revamped to use more modern tools such as Moose and its role system to refactor functionality out into self-contained roles. + +To this end, internally, this module now understands JSON boolean values (as represented by JSON::Any) and will Do The Right Thing in handling those values. This means you can have ColumnInflators installed that can covert between JSON::Any booleans and whatever your database wants for boolean values. + +Validation for various *_allows or *_exposes is now accomplished via Data::DPath::Validator with a lightly simplified, via subclass, Data::DPath::Validator::Visitor. The rough jist of the process goes as follows: Arguments provided to those attributes are fed into the Validator and Data::DPaths are generated. Then, incoming requests are validated against these paths generated. The validator is set in "loose" mode meaning only one path is required to match. For more information, please see L and more specifically L. + +Since 2.00100: +Transactions are used. The stash is put aside in favor of roles applied to the request object with additional accessors. +Error handling is now much more consistent with most errors immediately detaching. +The internals are much easier to read and understand with lots more documentation. + +=cut + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/JoinBuilder.pm b/lib/Catalyst/Controller/DBIC/API/JoinBuilder.pm new file mode 100644 index 0000000..884aaea --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/JoinBuilder.pm @@ -0,0 +1,115 @@ +package Catalyst::Controller::DBIC::API::JoinBuilder; + +#ABSTRACT: Provides a helper class to automatically keep track of joins in complex searches +use Moose; +use MooseX::Types::Moose(':all'); +use Catalyst::Controller::DBIC::API::Types(':all'); +use namespace::autoclean; + +=attribute_public parent is: ro, isa: 'Catalyst::Controller::DBIC::API::JoinBuilder' + +parent stores the direct ascendant in the datastructure that represents the join + +=cut + +has parent => +( + is => 'ro', + isa => JoinBuilder, + predicate => 'has_parent', + weak_ref => 1, + trigger => sub { my ($self, $new) = @_; $new->add_child($self); }, +); + +=attribute_public children is: ro, isa: ArrayRef['Catalyst::Controller::DBIC::API::JoinBuilder'], traits => ['Array'] + +children stores the immediate descendants in the datastructure that represents the join. + +Handles the following methods: + + all_children => 'elements' + has_children => 'count' + add_child => 'push' + +=cut + +has children => +( + is => 'ro', + isa => ArrayRef[JoinBuilder], + traits => ['Array'], + default => sub { [] }, + handles => + { + all_children => 'elements', + has_children => 'count', + add_child => 'push', + } +); + +=attribute_public joins is: ro, isa: HashRef, lazy_build: true + +joins holds the cached generated join datastructure. + +=cut + +has joins => +( + is => 'ro', + isa => HashRef, + lazy_build => 1, +); + +=attribute_public name is: ro, isa: Str, required: 1 + +Sets the key for this level in the generated hash + +=cut + +has name => +( + is => 'ro', + isa => Str, + required => 1, +); + +=method_private _build_joins + +_build_joins finds the top parent in the structure and then recursively iterates the children building out the join datastructure + +=cut + +sub _build_joins +{ + my ($self) = @_; + + my $parent; + while(my $found = $self->parent) + { + if($found->has_parent) + { + $self = $found; + next; + } + $parent = $found; + } + + my $builder; + $builder = sub + { + my ($node) = @_; + my $foo = {}; + map { $foo->{$_->name} = $builder->($_) } $node->all_children; + return $foo; + }; + + return $builder->($parent || $self); +} + +=head1 DESCRIPTION + +JoinBuilder is used to keep track of joins automgically for complex searches. It accomplishes this by building a simple tree of parents and children and then recursively drilling into the tree to produce a useable join attribute for ->search. + +=cut + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/REST.pm b/lib/Catalyst/Controller/DBIC/API/REST.pm new file mode 100644 index 0000000..a497052 --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/REST.pm @@ -0,0 +1,88 @@ +package Catalyst::Controller::DBIC::API::REST; + +#ABSTRACT: Provides a REST interface to DBIx::Class +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API'; } + +__PACKAGE__->config( + 'default' => 'application/json', + 'stash_key' => 'response', + 'map' => { + 'application/x-www-form-urlencoded' => 'JSON', + 'application/json' => 'JSON', + }); + +=head1 DESCRIPTION + +Provides a REST style API interface to the functionality described in L. + +By default provides the following endpoints: + + $base (accepts PUT and GET) + $base/[identifier] (accepts POST and DELETE) + +Where $base is the URI described by L, the chain root of the controller, and the request type will determine the L method to forward. + +=method_protected setup + +Chained: override +PathPart: override +CaptureArgs: 0 + +As described in L, this action is the chain root of the controller but has no pathpart or chain parent defined by default, so these must be defined in order for the controller to function. The neatest way is normally to define these using the controller's config. + + __PACKAGE__->config + ( action => { setup => { PathPart => 'track', Chained => '/api/rest/rest_base' } }, + ... + ); + +=method_protected base + +Chained: L +PathPart: none +CaptureArgs: 0 + +Forwards to list level methods described in L as follows: + +DELETE: forwards to L then L +POST/PUT: forwards to L then L +GET: forwards to L + +=cut + +sub base : Chained('setup') PathPart('') ActionClass('REST') Args { + my ( $self, $c ) = @_; + +} + +sub base_PUT { + my ( $self, $c ) = @_; + + $c->forward('object'); + return if $self->get_errors($c); + $c->forward('update_or_create'); +} + +sub base_POST { + my ( $self, $c ) = @_; + + $c->forward('object'); + return if $self->get_errors($c); + $c->forward('update_or_create'); +} + +sub base_DELETE { + my ( $self, $c ) = @_; + $DB::single =1; + $c->forward('object'); + return if $self->get_errors($c); + $c->forward('delete'); +} + +sub base_GET { + my ( $self, $c ) = @_; + + $c->forward('list'); +} + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/RPC.pm b/lib/Catalyst/Controller/DBIC/API/RPC.pm new file mode 100644 index 0000000..4fc764a --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/RPC.pm @@ -0,0 +1,126 @@ +package Catalyst::Controller::DBIC::API::RPC; +#ABSTRACT: Provides an RPC interface to DBIx::Class + +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API'; } + +__PACKAGE__->config( + 'action' => { object => { PathPart => 'id' } }, + 'default' => 'application/json', + 'stash_key' => 'response', + 'map' => { + 'application/x-www-form-urlencoded' => 'JSON', + 'application/json' => 'JSON', + }, +); + +=head1 DESCRIPTION + +Provides an RPC API interface to the functionality described in L. + +By default provides the following endpoints: + + $base/create + $base/list + $base/id/[identifier]/delete + $base/id/[identifier]/update + +Where $base is the URI described by L, the chain root of the controller. + +=method_protected setup + +Chained: override +PathPart: override +CaptureArgs: 0 + +As described in L, this action is the chain root of the controller but has no pathpart or chain parent defined by default, so these must be defined in order for the controller to function. The neatest way is normally to define these using the controller's config. + + __PACKAGE__->config + ( action => { setup => { PathPart => 'track', Chained => '/api/rpc/rpc_base' } }, + ... + ); + +=method_protected object + +Chained: L +PathPart: object +CaptureArgs: 1 + +Provides an chain point to the functionality described in L. All object level endpoints should use this as their chain root. + +=cut + +sub index : Chained('setup') PathPart('') Args(0) { + my ( $self, $c ) = @_; + + $self->push_error($c, { message => 'Not implemented' }); + $c->res->status( '404' ); +} + +=method_protected create + +Chained: L +PathPart: create +CaptureArgs: 0 + +Provides an endpoint to the functionality described in L. + +=cut + +sub create :Chained('setup') :PathPart('create') :Args(0) +{ + my ($self, $c) = @_; + $c->forward('object'); + return if $self->get_errors($c); + $c->forward('update_or_create'); +} + +=method_protected list + +Chained: L +PathPart: list +CaptureArgs: 0 + +Provides an endpoint to the functionality described in L. + +=cut + +sub list :Chained('setup') :PathPart('list') :Args(0) { + my ($self, $c) = @_; + + $self->next::method($c); +} + +=method_protected update + +Chained: L +PathPart: update +CaptureArgs: 0 + +Provides an endpoint to the functionality described in L. + +=cut + +sub update :Chained('object') :PathPart('update') :Args(0) { + my ($self, $c) = @_; + + $c->forward('update_or_create'); +} + +=method_protected delete + +Chained: L +PathPart: delete +CaptureArgs: 0 + +Provides an endpoint to the functionality described in L. + +=cut + +sub delete :Chained('object') :PathPart('delete') :Args(0) { + my ($self, $c) = @_; + + $self->next::method($c); +} + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/Request.pm b/lib/Catalyst/Controller/DBIC/API/Request.pm new file mode 100644 index 0000000..c98e74f --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/Request.pm @@ -0,0 +1,53 @@ +package Catalyst::Controller::DBIC::API::Request; + +#ABSTRACT: Provides a role to be applied to the Request object +use Moose::Role; +use MooseX::Aliases; +use MooseX::Types::Moose(':all'); +use namespace::autoclean; + +#XXX HACK +sub _application {} +sub _controller {} + +=attribute_private _application is: ro, isa: Object, handles: Catalyst::Controller::DBIC::API::StoredResultSource + +This attribute helps bridge between the request guts and the application guts; allows request argument validation against the schema. This is set during L + +=cut + +has '_application' => +( + is => 'ro', + writer => '_set_application', + isa => Object|ClassName, +); + +has '_controller' => +( + is => 'ro', + writer => '_set_controller', + isa => Object, + trigger => sub + { + my ($self, $new) = @_; + + $self->_set_class($new->class) if defined($new->class); + $self->_set_application($new->_application); + $self->_set_prefetch_allows($new->prefetch_allows); + $self->_set_search_exposes($new->search_exposes); + $self->_set_select_exposes($new->select_exposes); + } +); + +with 'Catalyst::Controller::DBIC::API::StoredResultSource'; +with 'Catalyst::Controller::DBIC::API::RequestArguments'; +with 'Catalyst::Controller::DBIC::API::Request::Context'; + +=head1 DESCRIPTION + +Please see L and L for the details of this class, as both of those roles are consumed in this role. + +=cut + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/Request/Context.pm b/lib/Catalyst/Controller/DBIC/API/Request/Context.pm new file mode 100644 index 0000000..5653031 --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/Request/Context.pm @@ -0,0 +1,51 @@ +package Catalyst::Controller::DBIC::API::Request::Context; + +#ABSTRACT: Provides additional context to the Request +use Moose::Role; +use MooseX::Types::Moose(':all'); +use MooseX::Types::Structured('Tuple'); +use Catalyst::Controller::DBIC::API::Types(':all'); +use namespace::autoclean; + +=attribute_public objects is: ro, isa ArrayRef[Tuple[Object,Maybe[HashRef]]], traits: ['Array'] + +This attribute stores the objects found/created at the object action. It handles the following methods: + + all_objects => 'elements' + add_object => 'push' + count_objects => 'count' + has_objects => 'count' + clear_objects => 'clear' + +=cut + +has objects => +( + is => 'ro', + isa => ArrayRef[ Tuple[ Object, Maybe[HashRef] ] ], + traits => [ 'Array' ], + default => sub { [] }, + handles => + { + all_objects => 'elements', + add_object => 'push', + count_objects => 'count', + has_objects => 'count', + clear_objects => 'clear', + }, +); + +=attribute_public current_result_set is: ro, isa: L + +Stores the current ResultSet derived from the initial L. + +=cut + +has current_result_set => +( + is => 'ro', + isa => ResultSet, + writer => '_set_current_result_set', +); + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/RequestArguments.pm b/lib/Catalyst/Controller/DBIC/API/RequestArguments.pm new file mode 100644 index 0000000..e9e2139 --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/RequestArguments.pm @@ -0,0 +1,610 @@ +package Catalyst::Controller::DBIC::API::RequestArguments; + +#ABSTRACT: Provides Request argument validation +use MooseX::Role::Parameterized; +use Catalyst::Controller::DBIC::API::Types(':all'); +use MooseX::Types::Moose(':all'); +use Scalar::Util('reftype'); +use Data::Dumper; +use namespace::autoclean; + +use Catalyst::Controller::DBIC::API::JoinBuilder; + + +=attribute_private search_validator + +A Catalyst::Controller::DBIC::API::Validator instance used solely to validate search parameters + +=cut + +with 'MooseX::Role::BuildInstanceOf' => +{ + 'target' => 'Catalyst::Controller::DBIC::API::Validator', + 'prefix' => 'search_validator', +}; + +=attribute_private select_validator + +A Catalyst::Controller::DBIC::API::Validator instance used solely to validate select parameters + +=cut + +with 'MooseX::Role::BuildInstanceOf' => +{ + 'target' => 'Catalyst::Controller::DBIC::API::Validator', + 'prefix' => 'select_validator', +}; + +=attribute_private prefetch_validator + +A Catalyst::Controller::DBIC::API::Validator instance used solely to validate prefetch parameters + +=cut + +with 'MooseX::Role::BuildInstanceOf' => +{ + 'target' => 'Catalyst::Controller::DBIC::API::Validator', + 'prefix' => 'prefetch_validator', +}; + +parameter static => ( isa => Bool, default => 0 ); + +role { + + my $p = shift; + + if($p->static) + { + requires qw/check_has_relation check_column_relation/; + } + else + { + requires qw/_controller check_has_relation check_column_relation/; + } + +=attribute_public count is: ro, isa: Int + +count is the number of rows to be returned during paging + +=cut + + has 'count' => + ( + is => 'ro', + writer => '_set_count', + isa => Int, + predicate => 'has_count', + ); + +=attribute_public page is: ro, isa: Int + +page is what page to return while paging + +=cut + + has 'page' => + ( + is => 'ro', + writer => '_set_page', + isa => Int, + predicate => 'has_page', + ); + +=attribute_public ordered_by is: ro, isa: L + +ordered_by is passed to ->search to determine sorting + +=cut + + has 'ordered_by' => + ( + is => 'ro', + writer => '_set_ordered_by', + isa => OrderedBy, + predicate => 'has_ordered_by', + coerce => 1, + default => sub { $p->static ? [] : undef }, + ); + +=attribute_public groupd_by is: ro, isa: L + +grouped_by is passed to ->search to determine aggregate results + +=cut + + has 'grouped_by' => + ( + is => 'ro', + writer => '_set_grouped_by', + isa => GroupedBy, + predicate => 'has_grouped_by', + coerce => 1, + default => sub { $p->static ? [] : undef }, + ); + +=attribute_public prefetch is: ro, isa: L + +prefetch is passed to ->search to optimize the number of database fetches for joins + +=cut + + has prefetch => + ( + is => 'ro', + writer => '_set_prefetch', + isa => Prefetch, + default => sub { $p->static ? [] : undef }, + coerce => 1, + trigger => sub + { + my ($self, $new) = @_; + if($self->has_prefetch_allows and @{$self->prefetch_allows}) + { + foreach my $pf (@$new) + { + if(HashRef->check($pf)) + { + die qq|'${\Dumper($pf)}' is not an allowed prefetch in: ${\join("\n", @{$self->prefetch_validator->templates})}| + unless $self->prefetch_validator->validate($pf)->[0]; + } + else + { + die qq|'$pf' is not an allowed prefetch in: ${\join("\n", @{$self->prefetch_validator->templates})}| + unless $self->prefetch_validator->validate({$pf => 1})->[0]; + } + } + } + else + { + return if not defined($new); + die 'Prefetching is not allowed' if @$new; + } + }, + ); + +=attribute_public prefetch_allows is: ro, isa: ArrayRef[ArrayRef|Str|HashRef] + +prefetch_allows limits what relations may be prefetched when executing searches with joins. This is necessary to avoid denial of service attacks in form of queries which would return a large number of data and unwanted disclosure of data. + +Like the synopsis in DBIC::API shows, you can declare a "template" of what is allowed (by using an '*'). Each element passed in, will be converted into a Data::DPath and added to the validator. + + prefetch_allows => [ 'cds', { cds => tracks }, { cds => producers } ] # to be explicit + prefetch_allows => [ 'cds', { cds => '*' } ] # wildcard means the same thing + +=cut + + has prefetch_allows => + ( + is => 'ro', + writer => '_set_prefetch_allows', + isa => ArrayRef[ArrayRef|Str|HashRef], + default => sub { [ ] }, + predicate => 'has_prefetch_allows', + trigger => sub + { + my ($self, $new) = @_; + + sub check_rel { + my ($self, $rel, $static) = @_; + if(ArrayRef->check($rel)) + { + foreach my $rel_sub (@$rel) + { + $self->check_rel($rel_sub, $static); + } + } + elsif(HashRef->check($rel)) + { + while(my($k,$v) = each %$rel) + { + $self->check_has_relation($k, $v, undef, $static); + } + $self->prefetch_validator->load($rel); + } + else + { + $self->check_has_relation($rel, undef, undef, $static); + $self->prefetch_validator->load($rel); + } + } + + foreach my $rel (@$new) + { + $self->check_rel($rel, $p->static); + } + }, + ); + +=attribute_public search_exposes is: ro, isa: ArrayRef[Str|HashRef] + +search_exposes limits what can actually be searched. If a certain column isn't indexed or perhaps a BLOB, you can explicitly say which columns can be search and exclude that one. + +Like the synopsis in DBIC::API shows, you can declare a "template" of what is allowed (by using an '*'). Each element passed in, will be converted into a Data::DPath and added to the validator. + +=cut + + has 'search_exposes' => + ( + is => 'ro', + writer => '_set_search_exposes', + isa => ArrayRef[Str|HashRef], + predicate => 'has_search_exposes', + default => sub { [ ] }, + trigger => sub + { + my ($self, $new) = @_; + $self->search_validator->load($_) for @$new; + }, + ); + +=attribute_public search is: ro, isa: HashRef + +search contains the raw search parameters. Upon setting, a trigger will fire to format them, set search_parameters, and set search_attributes. + +Please see L for details on how the format works. + +=cut + + has 'search' => + ( + is => 'ro', + writer => '_set_search', + isa => SearchParameters, + predicate => 'has_search', + coerce => 1, + trigger => sub + { + my ($self, $new) = @_; + + if($self->has_search_exposes and @{$self->search_exposes}) + { + foreach my $foo (@$new) + { + while( my ($k, $v) = each %$foo) + { + local $Data::Dumper::Terse = 1; + die qq|{ $k => ${\Dumper($v)} } is not an allowed search term in: ${\join("\n", @{$self->search_validator->templates})}| + unless $self->search_validator->validate({$k=>$v})->[0]; + } + } + } + else + { + foreach my $foo (@$new) + { + while( my ($k, $v) = each %$foo) + { + $self->check_column_relation({$k => $v}); + } + } + } + + my ($search_parameters, $search_attributes) = $self->generate_parameters_attributes($new); + $self->_set_search_parameters($search_parameters); + $self->_set_search_attributes($search_attributes); + + }, + ); + +=attribute_public search_parameters is:ro, isa: L + +search_parameters stores the formatted search parameters that will be passed to ->search + +=cut + + has search_parameters => + ( + is => 'ro', + isa => SearchParameters, + writer => '_set_search_parameters', + predicate => 'has_search_parameters', + coerce => 1, + default => sub { [{}] }, + ); + +=attribute_public search_attributes is:ro, isa: HashRef + +search_attributes stores the formatted search attributes that will be passed to ->search + +=cut + + has search_attributes => + ( + is => 'ro', + isa => HashRef, + writer => '_set_search_attributes', + predicate => 'has_search_attributes', + lazy_build => 1, + ); + +=attribute_public search_total_entries is: ro, isa: Int + +search_total_entries stores the total number of entries in a paged search result + +=cut + + has search_total_entries => + ( + is => 'ro', + isa => Int, + writer => '_set_search_total_entries', + predicate => 'has_search_total_entries', + ); + +=attribute_public select_exposes is: ro, isa: ArrayRef[Str|HashRef] + +select_exposes limits what can actually be selected. Use this to whitelist database functions (such as COUNT). + +Like the synopsis in DBIC::API shows, you can declare a "template" of what is allowed (by using an '*'). Each element passed in, will be converted into a Data::DPath and added to the validator. + +=cut + + has 'select_exposes' => + ( + is => 'ro', + writer => '_set_select_exposes', + isa => ArrayRef[Str|HashRef], + predicate => 'has_select_exposes', + default => sub { [ ] }, + trigger => sub + { + my ($self, $new) = @_; + $self->select_validator->load($_) for @$new; + }, + ); + +=attribute_public select is: ro, isa: L + +select is the search attribute that allows you to both limit what is returned in the result set, and also make use of database functions like COUNT. + +Please see L for more details. + +=cut + + has select => + ( + is => 'ro', + writer => '_set_select', + isa => SelectColumns, + predicate => 'has_select', + default => sub { $p->static ? [] : undef }, + coerce => 1, + trigger => sub + { + my ($self, $new) = @_; + if($self->has_select_exposes) + { + foreach my $val (@$new) + { + die "'$val' is not allowed in a select" + unless $self->select_validator->validate($val); + } + } + else + { + $self->check_column_relation($_, $p->static) for @$new; + } + }, + ); + +=attribute_public as is: ro, isa: L + +as is the search attribute compliment to L that allows you to label columns for object inflaction and actually reference database functions like COUNT. + +Please see L for more details. + +=cut + + has as => + ( + is => 'ro', + writer => '_set_as', + isa => AsAliases, + default => sub { $p->static ? [] : undef }, + trigger => sub + { + my ($self, $new) = @_; + if($self->has_select) + { + die "'as' argument count (${\scalar(@$new)}) must match 'select' argument count (${\scalar(@{$self->select || []})})" + unless @$new == @{$self->select || []}; + } + elsif(defined $new) + { + die "'as' is only valid if 'select is also provided'"; + } + } + ); + +=attribute_public joins is: ro, isa L + +joins holds the top level JoinBuilder object used to keep track of joins automagically while formatting complex search parameters. + +Provides a single handle which returns the 'join' attribute for search_attributes: + + build_joins => 'joins' + +=cut + + has joins => + ( + is => 'ro', + isa => JoinBuilder, + lazy_build => 1, + handles => + { + build_joins => 'joins', + } + ); + +=attribute_public request_data is: ro, isa: HashRef + +request_data holds the raw (but deserialized) data for ths request + +=cut + + has 'request_data' => + ( + is => 'ro', + isa => HashRef, + writer => '_set_request_data', + trigger => sub + { + my ($self, $new) = @_; + my $controller = $self->_controller; + $self->_set_prefetch($new->{$controller->prefetch_arg}) if exists $new->{$controller->prefetch_arg}; + $self->_set_select($new->{$controller->select_arg}) if exists $new->{$controller->select_arg}; + $self->_set_as($new->{$controller->as_arg}) if exists $new->{$controller->as_arg}; + $self->_set_grouped_by($new->{$controller->grouped_by_arg}) if exists $new->{$controller->grouped_by_arg}; + $self->_set_ordered_by($new->{$controller->ordered_by_arg}) if exists $new->{$controller->ordered_by_arg}; + $self->_set_search($new->{$controller->search_arg}) if exists $new->{$controller->search_arg}; + $self->_set_count($new->{$controller->count_arg}) if exists $new->{$controller->count_arg}; + $self->_set_page($new->{$controller->page_arg}) if exists $new->{$controller->page_arg}; + } + ); + + method _build_joins => sub { return Catalyst::Controller::DBIC::API::JoinBuilder->new(name => 'TOP') }; + +=method_protected format_search_parameters + +format_search_parameters iterates through the provided params ArrayRef, calling generate_column_parameters on each one + +=cut + + method format_search_parameters => sub + { + $DB::single = 1; + my ($self, $params) = @_; + + my $genparams = []; + + foreach my $param (@$params) + { + push(@$genparams, $self->generate_column_parameters($self->stored_result_source, $param, $self->joins)); + } + + return $genparams; + }; + +=method_protected generate_column_parameters + +generate_column_parameters recursively generates properly aliased parameters for search, building a new JoinBuilder each layer of recursion + +=cut + + method generate_column_parameters => sub + { + $DB::single = 1; + my ($self, $source, $param, $join, $base) = @_; + $base ||= 'me'; + my $search_params; + + # build up condition + foreach my $column (keys %$param) + { + if($source->has_relationship($column)) + { + unless (ref($param->{$column}) && reftype($param->{$column}) eq 'HASH') + { + $search_params->{join('.', $base, $column)} = $param->{$column}; + next; + } + + %$search_params = + %{ + $self->generate_column_parameters + ( + $source->related_source($column), + $param->{$column}, + Catalyst::Controller::DBIC::API::JoinBuilder->new(parent => $join, name => $column), + $column + ) + }; + } + else + { + $search_params->{join('.', $base, $column)} = $param->{$column}; + } + } + + return $search_params; + }; + +=method_protected generate_parameters_attributes + +generate_parameters_attributes takes the raw search arguments and formats the parameters by calling format_search_parameters. Then builds the related attributes, preferring request-provided arguments for things like grouped_by over statically configured options. Finally tacking on the appropriate joins. Returns both formatted search parameters and the search attributes. + +=cut + + method generate_parameters_attributes => sub + { + $DB::single = 1; + my ($self, $args) = @_; + + return ( $self->format_search_parameters($args), $self->search_attributes ); + }; + +=method_protected _build_search_attributes + +This builder method generates the search attributes + +=cut + + method _build_search_attributes => sub + { + my ($self, $args) = @_; + my $static = $self->_controller; + my $search_attributes = + { + group_by => $self->grouped_by || ((scalar(@{$static->grouped_by})) ? $static->grouped_by : undef), + order_by => $self->ordered_by || ((scalar(@{$static->ordered_by})) ? $static->ordered_by : undef), + select => $self->select || ((scalar(@{$static->select})) ? $static->select : undef), + as => $self->as || ((scalar(@{$static->as})) ? $static->as : undef), + prefetch => $self->prefetch || $static->prefetch || undef, + rows => $self->count || $static->count, + page => $self->page, + join => $self->build_joins, + }; + + $search_attributes = + { + map { @$_ } + grep + { + defined($_->[1]) + ? + (ref($_->[1]) && reftype($_->[1]) eq 'HASH' && keys %{$_->[1]}) + || (ref($_->[1]) && reftype($_->[1]) eq 'ARRAY' && @{$_->[1]}) + || length($_->[1]) + : + undef + } + map { [$_, $search_attributes->{$_}] } + keys %$search_attributes + }; + + + if ($search_attributes->{page} && !$search_attributes->{rows}) { + die 'list_page can only be used with list_count'; + } + + if ($search_attributes->{select}) { + # make sure all columns have an alias to avoid ambiguous issues + # but allow non strings (eg. hashrefs for db procs like 'count') + # to pass through unmolested + $search_attributes->{select} = [map { (Str->check($_) && $_ !~ m/\./) ? "me.$_" : $_ } (ref $search_attributes->{select}) ? @{$search_attributes->{select}} : $search_attributes->{select}]; + } + + return $search_attributes; + + }; + +}; +=head1 DESCRIPTION + +RequestArguments embodies those arguments that are provided as part of a request or effect validation on request arguments. This Role can be consumed in one of two ways. As this is a parameterized Role, it accepts a single argument at composition time: 'static'. This indicates that those parameters should be stored statically and used as a fallback when the current request doesn't provide them. + +=cut + + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/StaticArguments.pm b/lib/Catalyst/Controller/DBIC/API/StaticArguments.pm new file mode 100644 index 0000000..862dc21 --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/StaticArguments.pm @@ -0,0 +1,155 @@ +package Catalyst::Controller::DBIC::API::StaticArguments; + +#ABSTRACT: Provides controller level configuration arguments +use Moose::Role; +use MooseX::Types::Moose(':all'); +use namespace::autoclean; + +requires 'check_column_relation'; + +=attribute_public create_requires create_allows update_requires update_allows + +These attributes control requirements and limits to columns when creating or updating objects. + +Each provides a number of handles: + + "get_${var}_column" => 'get' + "set_${var}_column" => 'set' + "delete_${var}_column" => 'delete' + "insert_${var}_column" => 'insert' + "count_${var}_column" => 'count' + "all_${var}_columns" => 'elements' + +=cut + +foreach my $var (qw/create_requires create_allows update_requires update_allows/) +{ + has $var => + ( + is => 'ro', + isa => ArrayRef[Str|HashRef], + traits => ['Array'], + default => sub { [] }, + trigger => sub + { + my ($self, $new) = @_; + $self->check_column_relation($_, 1) for @$new; + }, + handles => + { + "get_${var}_column" => 'get', + "set_${var}_column" => 'set', + "delete_${var}_column" => 'delete', + "insert_${var}_column" => 'insert', + "count_${var}_column" => 'count', + "all_${var}_columns" => 'elements', + } + ); + + before "set_${var}_column" => sub { $_[0]->check_column_relation($_[2], 1) }; #" + before "insert_${var}_column" => sub { $_[0]->check_column_relation($_[2], 1) }; #" +} + +=attribute_public count_arg is: ro, isa: Str, default: 'list_count' + +count_arg controls how to reference 'count' in the the request_data + +=cut + +has 'count_arg' => ( is => 'ro', isa => Str, default => 'list_count' ); + +=attribute_public page_arg is: ro, isa: Str, default: 'list_page' + +page_arg controls how to reference 'page' in the the request_data + +=cut + +has 'page_arg' => ( is => 'ro', isa => Str, default => 'list_page' ); + +=attribute_public select_arg is: ro, isa: Str, default: 'list_returns' + +select_arg controls how to reference 'select' in the the request_data + +=cut + +has 'select_arg' => ( is => 'ro', isa => Str, default => 'list_returns' ); + +=attribute_public as_arg is: ro, isa: Str, default: 'as' + +as_arg controls how to reference 'as' in the the request_data + +=cut + +has 'as_arg' => ( is => 'ro', isa => Str, default => 'as' ); + +=attribute_public search_arg is: ro, isa: Str, default: 'search' + +search_arg controls how to reference 'search' in the the request_data + +=cut + +has 'search_arg' => ( is => 'ro', isa => Str, default => 'search' ); + +=attribute_public grouped_by_arg is: ro, isa: Str, default: 'list_grouped_by' + +grouped_by_arg controls how to reference 'grouped_by' in the the request_data + +=cut + +has 'grouped_by_arg' => ( is => 'ro', isa => Str, default => 'list_grouped_by' ); + +=attribute_public ordered_by_arg is: ro, isa: Str, default: 'list_ordered_by' + +ordered_by_arg controls how to reference 'ordered_by' in the the request_data + +=cut + +has 'ordered_by_arg' => ( is => 'ro', isa => Str, default => 'list_ordered_by' ); + +=attribute_public prefetch_arg is: ro, isa: Str, default: 'list_prefetch' + +prefetch_arg controls how to reference 'prefetch' in the the request_data + +=cut + +has 'prefetch_arg' => ( is => 'ro', isa => Str, default => 'list_prefetch' ); + +=attribute_public data_root is: ro, isa: Str, default: 'listt' + +data_root controls how to reference where the data is in the the request_data + +=cut + +has 'data_root' => ( is => 'ro', isa => Str, default => 'list'); + +=attribute_public total_entries_arg is: ro, isa: Str, default: 'totalcount' + +total_entries_arg controls how to reference 'total_entries' in the the request_data + +=cut + +has 'total_entries_arg' => ( is => 'ro', isa => Str, default => 'totalcount' ); + +=attribute_public use_json_boolean is: ro, isa: Bool, default: 0 + +use_json_boolean controls whether JSON::Any boolean types are used in the success parameter of the response or if raw strings are used + +=cut + +has 'use_json_boolean' => ( is => 'ro', isa => Bool, default => 0 ); + +=attribute_public return_object is: ro, isa: Bool, default: 0 + +return_object controls whether the results of create/update are serialized and returned in the response + +=cut + +has 'return_object' => ( is => 'ro', isa => Bool, default => 0 ); + +=head1 DESCRIPTION + +StaticArguments is a Role that is composed by the controller to provide configuration parameters such as how where in the request data to find specific elements, and if to use JSON boolean types. + +=cut + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/StoredResultSource.pm b/lib/Catalyst/Controller/DBIC/API/StoredResultSource.pm new file mode 100644 index 0000000..3fc3cf4 --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/StoredResultSource.pm @@ -0,0 +1,134 @@ +package Catalyst::Controller::DBIC::API::StoredResultSource; +#ABSTRACT: Provides acessors for static resources + +use Moose::Role; +use MooseX::Types::Moose(':all'); +use Catalyst::Controller::DBIC::API::Types(':all'); +use Try::Tiny; +use namespace::autoclean; + +requires '_application'; + +=attribute_public class is: ro, isa: Str + +class is the name of the class that is the model for this controller + +=cut + +has 'class' => ( is => 'ro', isa => Str, writer => '_set_class' ); + +=attribute_public stored_result_source is: ro, isa: L + +This is the result source for the controller + +=cut + +has 'stored_result_source' => +( + is => 'ro', + isa => ResultSource, + lazy_build => 1, +); + +=attribute_public stored_model is: ro, isa: L + +This is the model for the controller + +=cut + +has 'stored_model' => +( + is => 'ro', + isa => Model, + lazy_build => 1, +); + +sub _build_stored_model +{ + return $_[0]->_application->model($_[0]->class); +} + +sub _build_stored_result_source +{ + return shift->stored_model->result_source(); +} + +=method_public check_has_column + +Convenience method for checking if the column exists in the result source + +=cut + +sub check_has_column +{ + my ($self, $col) = @_; + die "Column '$col' does not exist in ResultSet '${\$self->class}'" + unless $self->stored_result_source->has_column($col); +} + +=method_public check_has_relation + +check_has_relation meticulously delves into the result sources relationships to determine if the provided relation is valid. Accepts a relation name, and optional HashRef indicating a nested relationship. Iterates, and recurses through provided arguments until exhausted. Dies if at any time the relationship or column does not exist. + +=cut + +sub check_has_relation +{ + my ($self, $rel, $other, $nest, $static) = @_; + + $nest ||= $self->stored_result_source; + + if(HashRef->check($other)) + { + my $rel_src = $nest->related_source($rel); + die "Relation '$rel_src' does not exist" if not defined($rel_src); + + while(my($k,$v) = each %$other) + { + $self->check_has_relation($k, $v, $rel_src, $static); + } + } + else + { + return 1 if $static && ArrayRef->check($other) && $other->[0] eq '*'; + die "Relation '$rel' does not exist in ${\$nest->from}" + unless $nest->has_relationship($rel) || $nest->has_column($rel); + return 1; + } +} + +=method_public check_column_relation + +Convenience method to first check if the provided argument is a valid relation (if it is a HashRef) or column. + +=cut + +sub check_column_relation +{ + my ($self, $col_rel, $static) = @_; + + if(HashRef->check($col_rel)) + { + try + { + while(my($k,$v) = each %$col_rel) + { + $self->check_has_relation($k, $v, undef, $static); + } + } + catch + { + # not a relation but a column with a predicate + while(my($k, undef) = each %$col_rel) + { + $self->check_has_column($k); + } + } + } + else + { + $self->check_has_column($col_rel); + } +} + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/Types.pm b/lib/Catalyst/Controller/DBIC/API/Types.pm new file mode 100644 index 0000000..3632e83 --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/Types.pm @@ -0,0 +1,105 @@ +package Catalyst::Controller::DBIC::API::Types; + +#ABSTRACT: Provides shortcut types and coercions for DBIC::API +use warnings; +use strict; + +use MooseX::Types -declare => [qw/OrderedBy GroupedBy Prefetch SelectColumns AsAliases ResultSource ResultSet Model SearchParameters JoinBuilder/]; +use MooseX::Types::Moose(':all'); + +=type Prefetch as Maybe[ArrayRef[Str|HashRef]] + +Represents the structure of the prefetch argument. + +Coerces Str and HashRef. + +=cut + +subtype Prefetch, as Maybe[ArrayRef[Str|HashRef]]; +coerce Prefetch, from Str, via { [$_] }, from HashRef, via { [$_] }; + +=type GroupedBy as Maybe[ArrayRef[Str]] + +Represents the structure of the grouped_by argument. + +Coerces Str. + +=cut + +subtype GroupedBy, as Maybe[ArrayRef[Str]]; +coerce GroupedBy, from Str, via { [$_] }; + +=type OrderedBy as Maybe[ArrayRef[Str|HashRef|ScalarRef]] + +Represents the structure of the ordered_by argument + +Coerces Str. + +=cut + +subtype OrderedBy, as Maybe[ArrayRef[Str|HashRef|ScalarRef]]; +coerce OrderedBy, from Str, via { [$_] }; + +=type SelectColumns as Maybe[ArrayRef[Str|HashRef]] + +Represents the structure of the select argument + +Coerces Str. + +=cut + +subtype SelectColumns, as Maybe[ArrayRef[Str|HashRef]]; +coerce SelectColumns, from Str, via { [$_] }; + +=type SearchParameters as Maybe[ArrayRef[HashRef]] + +Represents the structure of the search argument + +Coerces HashRef. + +=cut + +subtype SearchParameters, as Maybe[ArrayRef[HashRef]]; +coerce SearchParameters, from HashRef, via { [$_] }; + +=type AsAliases as Maybe[ArrayRef[Str]] + +Represents the structure of the as argument + +=cut + +subtype AsAliases, as Maybe[ArrayRef[Str]]; + +=type ResultSet as class_type('DBIx::Class::ResultSet') + +Shortcut for DBIx::Class::ResultSet + +=cut + +subtype ResultSet, as class_type('DBIx::Class::ResultSet'); + +=type ResultSource as class_type('DBIx::Class::ResultSource') + +Shortcut for DBIx::Class::ResultSource + +=cut + +subtype ResultSource, as class_type('DBIx::Class::ResultSource'); + +=type JoinBuilder as class_type('Catalyst::Controller::DBIC::API::JoinBuilder') + +Shortcut for Catalyst::Controller::DBIC::API::JoinBuilder + +=cut + +subtype JoinBuilder, as class_type('Catalyst::Controller::DBIC::API::JoinBuilder'); + +=type Model as class_type('DBIx::Class') + +Shortcut for model objects + +=cut + +subtype Model, as class_type('DBIx::Class'); + +1; diff --git a/lib/Catalyst/Controller/DBIC/API/Validator.pm b/lib/Catalyst/Controller/DBIC/API/Validator.pm new file mode 100644 index 0000000..7817644 --- /dev/null +++ b/lib/Catalyst/Controller/DBIC/API/Validator.pm @@ -0,0 +1,112 @@ +package Catalyst::Controller::DBIC::API::Validator; +#ABSTRACT: Provides validation services for inbound requests against whitelisted parameters +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Data::DPath::Validator'; } + +has '+visitor' => ( 'builder' => '_build_custom_visitor' ); + +sub _build_custom_visitor +{ + return Catalyst::Controller::DBIC::API::Visitor->new(); +} + +Catalyst::Controller::DBIC::API::Validator->meta->make_immutable; + +############################################################################### +package Catalyst::Controller::DBIC::API::Visitor; + +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Data::DPath::Validator::Visitor'; } + +use constant DEBUG => $ENV{DATA_DPATH_VALIDATOR_DEBUG} || 0; + +around visit_array => sub +{ + my ($orig, $self, $array) = @_; + $self->dive(); + warn 'ARRAY: '. $self->current_template if DEBUG; + if(@$array == 1 && $array->[0] eq '*') + { + $self->append_text('[reftype eq "HASH" ]'); + $self->add_template($self->current_template); + } + else + { + if($self->current_template =~ /\/$/) + { + my $temp = $self->current_template; + $self->reset_template(); + $temp =~ s/\/$//; + $self->append_text($temp); + } + $self->$orig($array); + } + $self->rise(); +}; + +sub visit_array_entry +{ + my ($self, $elem, $index, $array) = @_; + $self->dive(); + warn 'ARRAYENTRY: '. $self->current_template if DEBUG; + if(!ref($elem)) + { + $self->append_text($elem . '/*'); + $self->add_template($self->current_template); + } + elsif(ref($elem) eq 'HASH') + { + $self->visit($elem); + } + $self->rise(); + $self->value_type('NONE'); +}; + +around visit_hash => sub +{ + my ($orig, $self, $hash) = @_; + $self->dive(); + if($self->current_template =~ /\/$/) + { + my $temp = $self->current_template; + $self->reset_template(); + $temp =~ s/\/$//; + $self->append_text($temp); + } + warn 'HASH: '. $self->current_template if DEBUG; + $self->$orig($hash); + $self->rise(); +}; + +around visit_value => sub +{ + my ($orig, $self, $val) = @_; + + if($self->value_type eq 'NONE') + { + $self->dive(); + $self->append_text($val . '/*'); + $self->add_template($self->current_template); + warn 'VALUE: ' . $self->current_template if DEBUG; + $self->rise(); + } + elsif($self->value_type eq 'HashKey') + { + $self->append_text($val); + warn 'VALUE: ' . $self->current_template if DEBUG; + } + else + { + $self->$orig($val); + } + +}; + + +Catalyst::Controller::DBIC::API::Visitor->meta->make_immutable; + +1; diff --git a/t/lib/DBICTest.pm b/t/lib/DBICTest.pm new file mode 100644 index 0000000..697a6f6 --- /dev/null +++ b/t/lib/DBICTest.pm @@ -0,0 +1,199 @@ +package # hide from PAUSE + DBICTest; + +use strict; +use warnings; +use RestTest::Schema; + +=head1 NAME + +DBICTest - Library to be used by DBIx::Class test scripts. + +=head1 SYNOPSIS + + use lib qw(t/lib); + use DBICTest; + use Test::More; + + my $schema = DBICTest->init_schema(); + +=head1 DESCRIPTION + +This module provides the basic utilities to write tests against +DBIx::Class. + +=head1 METHODS + +=head2 init_schema + + my $schema = DBICTest->init_schema( + no_deploy=>1, + no_populate=>1, + ); + +This method removes the test SQLite database in t/var/DBIxClass.db +and then creates a new, empty database. + +This method will call deploy_schema() by default, unless the +no_deploy flag is set. + +Also, by default, this method will call populate_schema() by +default, unless the no_deploy or no_populate flags are set. + +=cut + +sub init_schema { + my $self = shift; + my %args = @_; + + my $db_file = "t/var/DBIxClass.db"; + + unlink($db_file) if -e $db_file; + unlink($db_file . "-journal") if -e $db_file . "-journal"; + mkdir("t/var") unless -d "t/var"; + + my $dsn = $args{"dsn"} || "dbi:SQLite:${db_file}"; + my $dbuser = $args{"user"} || ''; + my $dbpass = $args{"pass"} || ''; + + my $schema; + + my @connect_info = ($dsn, $dbuser, $dbpass, { AutoCommit => 1 }); + + if ($args{compose_connection}) { + $schema = RestTest::Schema->compose_connection( + 'DBICTest', @connect_info + ); + } else { + $schema = RestTest::Schema->compose_namespace('DBICTest') + ->connect(@connect_info); + } + + if ( !$args{no_deploy} ) { + __PACKAGE__->deploy_schema( $schema ); + __PACKAGE__->populate_schema( $schema ) if( !$args{no_populate} ); + } + return $schema; +} + + +sub get_ddl_file { + my $self = shift; + my $schema = shift; + + return 't/lib/' . lc($schema->storage->dbh->{Driver}->{Name}) . '.sql'; +} + +=head2 deploy_schema + + DBICTest->deploy_schema( $schema ); + +=cut + +sub deploy_schema { + my $self = shift; + my $schema = shift; + + my $file = shift || $self->get_ddl_file($schema); + open IN, $file; + my $sql; + { local $/ = undef; $sql = ; } + close IN; + ($schema->storage->dbh->do($_) || print "Error on SQL: $_\n") for split(/;\n/, $sql); +} + + +=head2 clear_schema + + DBICTest->clear_schema( $schema ); + +=cut + +sub clear_schema { + my $self = shift; + my $schema = shift; + + foreach my $class ($schema->sources) { + $schema->resultset($class)->delete; + } +} + + +=head2 populate_schema + + DBICTest->populate_schema( $schema ); + +After you deploy your schema you can use this method to populate +the tables with test data. + +=cut + +sub populate_schema { + my $self = shift; + my $schema = shift; + + $schema->populate('Artist', [ + [ qw/artistid name/ ], + [ 1, 'Caterwauler McCrae' ], + [ 2, 'Random Boy Band' ], + [ 3, 'We Are Goth' ], + ]); + + $schema->populate('CD', [ + [ qw/cdid artist title year/ ], + [ 1, 1, "Spoonful of bees", 1999 ], + [ 2, 1, "Forkful of bees", 2001 ], + [ 3, 1, "Caterwaulin' Blues", 1997 ], + [ 4, 2, "Generic Manufactured Singles", 2001 ], + [ 5, 2, "We like girls and stuff", 2003 ], + [ 6, 3, "Come Be Depressed With Us", 1998 ], + ]); + + $schema->populate('Tag', [ + [ qw/tagid cd tag/ ], + [ 1, 1, "Blue" ], + [ 2, 2, "Blue" ], + [ 3, 3, "Blue" ], + [ 4, 5, "Blue" ], + [ 5, 2, "Cheesy" ], + [ 6, 4, "Cheesy" ], + [ 7, 5, "Cheesy" ], + [ 8, 2, "Shiny" ], + [ 9, 4, "Shiny" ], + ]); + + $schema->populate('Producer', [ + [ qw/producerid name/ ], + [ 1, 'Matt S Trout' ], + [ 2, 'Bob The Builder' ], + [ 3, 'Fred The Phenotype' ], + ]); + + $schema->populate('CD_to_Producer', [ + [ qw/cd producer/ ], + [ 1, 1 ], + [ 3, 2 ], + [ 2, 3 ], + ]); + + $schema->populate('Track', [ + [ qw/trackid cd position title last_updated_on/ ], + [ 4, 2, 1, "Stung with Success"], + [ 5, 2, 2, "Stripy"], + [ 6, 2, 3, "Sticky Honey"], + [ 7, 3, 1, "Yowlin"], + [ 8, 3, 2, "Howlin"], + [ 9, 3, 3, "Fowlin", '2007-10-20 00:00:00'], + [ 10, 4, 1, "Boring Name"], + [ 11, 4, 2, "Boring Song"], + [ 12, 4, 3, "No More Ideas"], + [ 13, 5, 1, "Sad"], + [ 14, 5, 2, "Under The Weather"], + [ 15, 5, 3, "Suicidal"], + [ 16, 1, 1, "The Bees Knees"], + [ 17, 1, 2, "Apiary"], + [ 18, 1, 3, "Beehind You"], + ]); +} + +1; diff --git a/t/lib/RestTest.pm b/t/lib/RestTest.pm new file mode 100644 index 0000000..59ebdf9 --- /dev/null +++ b/t/lib/RestTest.pm @@ -0,0 +1,62 @@ +package RestTest; + +use strict; +use warnings; + +use Catalyst::Runtime '5.70'; + +# Set flags and add plugins for the application +# +# -Debug: activates the debug mode for very useful log messages +# ConfigLoader: will load the configuration from a YAML file in the +# application's home directory +# Static::Simple: will serve static files from the application's root +# directory + +use Catalyst; + +our $VERSION = '0.01'; + +# Configure the application. +# +# Note that settings in RestTest.yml (or other external +# configuration file that you set up manually) take precedence +# over this when using ConfigLoader. Thus configuration +# details given here can function as a default configuration, +# with a external configuration file acting as an override for +# local deployment. + +__PACKAGE__->config( name => 'RestTest' ); + +# Start the application +__PACKAGE__->setup; + + +=head1 NAME + +RestTest - Catalyst based application + +=head1 SYNOPSIS + + script/resttest_server.pl + +=head1 DESCRIPTION + +[enter your description here] + +=head1 SEE ALSO + +L, L + +=head1 AUTHOR + +luke saunders + +=head1 LICENSE + +This library is free software, you can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +1; diff --git a/t/lib/RestTest/Controller/API.pm b/t/lib/RestTest/Controller/API.pm new file mode 100644 index 0000000..ae0b673 --- /dev/null +++ b/t/lib/RestTest/Controller/API.pm @@ -0,0 +1,12 @@ +package RestTest::Controller::API; + +use strict; +use warnings; +use base qw/Catalyst::Controller/; + +sub api_base : Chained('/') PathPart('api') CaptureArgs(0) { + my ( $self, $c ) = @_; + +} + +1; diff --git a/t/lib/RestTest/Controller/API/REST.pm b/t/lib/RestTest/Controller/API/REST.pm new file mode 100644 index 0000000..58f75c8 --- /dev/null +++ b/t/lib/RestTest/Controller/API/REST.pm @@ -0,0 +1,17 @@ +package RestTest::Controller::API::REST; + +use strict; +use warnings; +use base qw/Catalyst::Controller/; + +sub rest_base : Chained('/api/api_base') PathPart('rest') CaptureArgs(0) { + my ( $self, $c ) = @_; + +} + +sub end :Private { + my ( $self, $c ) = @_; + +} + +1; diff --git a/t/lib/RestTest/Controller/API/REST/Artist.pm b/t/lib/RestTest/Controller/API/REST/Artist.pm new file mode 100644 index 0000000..3dad378 --- /dev/null +++ b/t/lib/RestTest/Controller/API/REST/Artist.pm @@ -0,0 +1,15 @@ +package RestTest::Controller::API::REST::Artist; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::REST' } +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'artist', Chained => '/api/rest/rest_base' } }, + class => 'RestTestDB::Artist', + create_requires => ['name'], + create_allows => ['name'], + update_allows => ['name'], + prefetch_allows => [[qw/ cds /],{ 'cds' => 'tracks'}], + ); + +1; diff --git a/t/lib/RestTest/Controller/API/REST/BoundArtist.pm b/t/lib/RestTest/Controller/API/REST/BoundArtist.pm new file mode 100644 index 0000000..1e689bd --- /dev/null +++ b/t/lib/RestTest/Controller/API/REST/BoundArtist.pm @@ -0,0 +1,23 @@ +package RestTest::Controller::API::REST::BoundArtist; +use Moose; +BEGIN { extends 'RestTest::Controller::API::REST::Artist'; } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'bound_artist', Chained => '/api/rest/rest_base' } }, + class => 'RestTestDB::Artist', + create_requires => ['name'], + create_allows => ['name'], + update_allows => ['name'] + ); + +# Arbitrary limit +override list_munge_parameters => sub +{ + my ( $self, $c) = @_; + # Return the first one, regardless of arguments + $c->req->search_parameters->[0]->{'me.artistid'} = 1; +}; + +1; diff --git a/t/lib/RestTest/Controller/API/REST/CD.pm b/t/lib/RestTest/Controller/API/REST/CD.pm new file mode 100644 index 0000000..dc556fc --- /dev/null +++ b/t/lib/RestTest/Controller/API/REST/CD.pm @@ -0,0 +1,15 @@ +package RestTest::Controller::API::REST::CD; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::REST' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'cd', Chained => '/api/rest/rest_base' } }, + class => 'RestTestDB::CD', + create_requires => ['artist', 'title', 'year' ], + update_allows => ['title', 'year'], + prefetch_allows => [['artist', ['tracks'], { cd_to_producer => ['producer'], tags => 'cd' }]], + ); + +1; diff --git a/t/lib/RestTest/Controller/API/REST/Producer.pm b/t/lib/RestTest/Controller/API/REST/Producer.pm new file mode 100644 index 0000000..a87c212 --- /dev/null +++ b/t/lib/RestTest/Controller/API/REST/Producer.pm @@ -0,0 +1,16 @@ +package RestTest::Controller::API::REST::Producer; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::REST' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'producer', Chained => '/api/rest/rest_base' } }, + class => 'RestTestDB::Producer', + create_requires => ['name'], + update_allows => ['name'], + select => ['name'], + return_object => 1, + ); + +1; diff --git a/t/lib/RestTest/Controller/API/REST/Track.pm b/t/lib/RestTest/Controller/API/REST/Track.pm new file mode 100644 index 0000000..6a7bcf0 --- /dev/null +++ b/t/lib/RestTest/Controller/API/REST/Track.pm @@ -0,0 +1,15 @@ +package RestTest::Controller::API::REST::Track; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::REST' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'track', Chained => '/api/rest/rest_base' } }, + class => 'RestTestDB::Track', + create_requires => ['cd', 'title' ], + create_allows => ['cd', 'title', 'position' ], + update_allows => ['title', 'position'] + ); + +1; diff --git a/t/lib/RestTest/Controller/API/RPC.pm b/t/lib/RestTest/Controller/API/RPC.pm new file mode 100644 index 0000000..10ea3e8 --- /dev/null +++ b/t/lib/RestTest/Controller/API/RPC.pm @@ -0,0 +1,17 @@ +package RestTest::Controller::API::RPC; + +use strict; +use warnings; +use base qw/Catalyst::Controller/; + +sub rpc_base : Chained('/api/api_base') PathPart('rpc') CaptureArgs(0) { + my ( $self, $c ) = @_; + +} + +sub end :Private { + my ( $self, $c ) = @_; + +} + +1; diff --git a/t/lib/RestTest/Controller/API/RPC/Any.pm b/t/lib/RestTest/Controller/API/RPC/Any.pm new file mode 100644 index 0000000..97fe287 --- /dev/null +++ b/t/lib/RestTest/Controller/API/RPC/Any.pm @@ -0,0 +1,29 @@ +package RestTest::Controller::API::RPC::Any; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' } + +use namespace::autoclean; + +sub setup :Chained('/api/rpc/rpc_base') :CaptureArgs(1) :PathPart('any') { + my ($self, $c, $object_type) = @_; + + my $config = {}; + if ($object_type eq 'artist') { + $config->{class} = 'RestTestDB::Artist'; + $config->{create_requires} = [qw/name/]; + $config->{update_allows} = [qw/name/]; + } elsif ($object_type eq 'track') { + $config->{class} = 'RestTestDB::Track'; + $config->{update_allows} = [qw/title position/]; + } else { + $self->push_error($c, { message => "invalid object_type" }); + return; + } + + $c->req->_set_class($config->{class}); + $self->_set_class($config->{class}); + $c->req->_set_current_result_set($self->stored_result_source->resultset); + $c->stash->{$_} = $config->{$_} for keys %{$config}; +} + +1; diff --git a/t/lib/RestTest/Controller/API/RPC/Artist.pm b/t/lib/RestTest/Controller/API/RPC/Artist.pm new file mode 100644 index 0000000..2d0367d --- /dev/null +++ b/t/lib/RestTest/Controller/API/RPC/Artist.pm @@ -0,0 +1,16 @@ +package RestTest::Controller::API::RPC::Artist; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'artist', Chained => '/api/rpc/rpc_base' } }, + class => 'RestTestDB::Artist', + create_requires => ['name'], + create_allows => ['name'], + update_allows => ['name'], + prefetch_allows => [[qw/ cds /],{ 'cds' => 'tracks'}], + ); + +1; diff --git a/t/lib/RestTest/Controller/API/RPC/CD.pm b/t/lib/RestTest/Controller/API/RPC/CD.pm new file mode 100644 index 0000000..8d1cbaf --- /dev/null +++ b/t/lib/RestTest/Controller/API/RPC/CD.pm @@ -0,0 +1,15 @@ +package RestTest::Controller::API::RPC::CD; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'cd', Chained => '/api/rpc/rpc_base' } }, + class => 'RestTestDB::CD', + create_requires => ['artist', 'title', 'year' ], + update_allows => ['title', 'year'], + prefetch_allows => [[qw/ tracks /]], + ); + +1; diff --git a/t/lib/RestTest/Controller/API/RPC/Producer.pm b/t/lib/RestTest/Controller/API/RPC/Producer.pm new file mode 100644 index 0000000..cf3a8ef --- /dev/null +++ b/t/lib/RestTest/Controller/API/RPC/Producer.pm @@ -0,0 +1,17 @@ +package RestTest::Controller::API::RPC::Producer; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'producer', Chained => '/api/rpc/rpc_base' } }, + class => 'RestTestDB::Producer', + create_requires => ['name'], + create_allows => ['producerid'], + update_allows => ['name'], + select => ['name'], + return_object => 1, + ); + +1; diff --git a/t/lib/RestTest/Controller/API/RPC/Track.pm b/t/lib/RestTest/Controller/API/RPC/Track.pm new file mode 100644 index 0000000..ef91089 --- /dev/null +++ b/t/lib/RestTest/Controller/API/RPC/Track.pm @@ -0,0 +1,19 @@ +package RestTest::Controller::API::RPC::Track; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'track', Chained => '/api/rpc/rpc_base' } }, + class => 'RestTestDB::Track', + create_requires => ['cd', 'title' ], + create_allows => ['cd', 'title', 'position' ], + update_allows => ['title', 'position', { cd => ['*'] }], + grouped_by => ['position'], + select => ['position'], + ordered_by => ['position'], + search_exposes => ['title'] + ); + +1; diff --git a/t/lib/RestTest/Controller/API/RPC/TrackExposed.pm b/t/lib/RestTest/Controller/API/RPC/TrackExposed.pm new file mode 100644 index 0000000..3872235 --- /dev/null +++ b/t/lib/RestTest/Controller/API/RPC/TrackExposed.pm @@ -0,0 +1,15 @@ +package RestTest::Controller::API::RPC::TrackExposed; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'track_exposed', Chained => '/api/rpc/rpc_base' } }, + class => 'RestTestDB::Track', + select => [qw/position title/], + ordered_by => [qw/position/], + search_exposes => [qw/position/, { cd => [qw/title year pretend/, { 'artist' => ['*'] } ]}], + ); + +1; diff --git a/t/lib/RestTest/Controller/API/RPC/TrackSetupDBICArgs.pm b/t/lib/RestTest/Controller/API/RPC/TrackSetupDBICArgs.pm new file mode 100644 index 0000000..d988f50 --- /dev/null +++ b/t/lib/RestTest/Controller/API/RPC/TrackSetupDBICArgs.pm @@ -0,0 +1,21 @@ +package RestTest::Controller::API::RPC::TrackSetupDBICArgs; +use Moose; +BEGIN { extends 'Catalyst::Controller::DBIC::API::RPC' } + +use namespace::autoclean; + +__PACKAGE__->config + ( action => { setup => { PathPart => 'track_setup_dbic_args', Chained => '/api/rpc/rpc_base' } }, + class => 'RestTestDB::Track', + select => [qw/position title/], + ordered_by => [qw/position/], + ); + +override list_munge_parameters => sub +{ + my ($self, $c) = @_; + + $c->req->search_parameters->[0]->{'me.position'} = { '!=' => '1' }; +}; + +1; diff --git a/t/lib/RestTest/Controller/Root.pm b/t/lib/RestTest/Controller/Root.pm new file mode 100644 index 0000000..6dbb124 --- /dev/null +++ b/t/lib/RestTest/Controller/Root.pm @@ -0,0 +1,55 @@ +package RestTest::Controller::Root; + +use strict; +use warnings; +use base 'Catalyst::Controller'; + +# +# Sets the actions in this controller to be registered with no prefix +# so they function identically to actions created in MyApp.pm +# +__PACKAGE__->config->{namespace} = ''; + +=head1 NAME + +RestTest::Controller::Root - Root Controller for RestTest + +=head1 DESCRIPTION + +[enter your description here] + +=head1 METHODS + +=cut + +=head2 default + +=cut + +sub default : Private { + my ( $self, $c ) = @_; + + # Hello World + $c->response->body( $c->welcome_message ); +} + +=head2 end + +Attempt to render a view, if needed. + +=cut + +sub end : Private {} + +=head1 AUTHOR + +luke saunders + +=head1 LICENSE + +This library is free software, you can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +1; diff --git a/t/lib/RestTest/Model/RestTestDB.pm b/t/lib/RestTest/Model/RestTestDB.pm new file mode 100644 index 0000000..7d89f5c --- /dev/null +++ b/t/lib/RestTest/Model/RestTestDB.pm @@ -0,0 +1,19 @@ +package RestTest::Model::RestTestDB; + +use strict; +use warnings; +use base 'Catalyst::Model::DBIC::Schema'; + +use Catalyst::Utils; + +__PACKAGE__->config( + schema_class => 'RestTest::Schema', + connect_info => [ + "DBI:SQLite:t/var/DBIxClass.db", + "", + "", + {AutoCommit => 1} + ] +); + +1; diff --git a/t/lib/RestTest/Schema.pm b/t/lib/RestTest/Schema.pm new file mode 100644 index 0000000..14d1adf --- /dev/null +++ b/t/lib/RestTest/Schema.pm @@ -0,0 +1,10 @@ +package # hide from PAUSE + RestTest::Schema; + +use base qw/DBIx::Class::Schema/; + +no warnings qw/qw/; + +__PACKAGE__->load_namespaces; + +1; diff --git a/t/lib/RestTest/Schema/Result/Artist.pm b/t/lib/RestTest/Schema/Result/Artist.pm new file mode 100644 index 0000000..912b378 --- /dev/null +++ b/t/lib/RestTest/Schema/Result/Artist.pm @@ -0,0 +1,26 @@ +package # hide from PAUSE + RestTest::Schema::Result::Artist; + +use base 'DBIx::Class'; + +__PACKAGE__->load_components('Core'); +__PACKAGE__->table('artist'); +__PACKAGE__->add_columns( + 'artistid' => { + data_type => 'integer', + is_auto_increment => 1, + }, + 'name' => { + data_type => 'varchar', + size => 100, + is_nullable => 1, + }, +); +__PACKAGE__->set_primary_key('artistid'); + +__PACKAGE__->has_many( + cds => 'RestTest::Schema::Result::CD', undef, + { order_by => 'year' }, +); + +1; diff --git a/t/lib/RestTest/Schema/Result/CD.pm b/t/lib/RestTest/Schema/Result/CD.pm new file mode 100644 index 0000000..3377178 --- /dev/null +++ b/t/lib/RestTest/Schema/Result/CD.pm @@ -0,0 +1,44 @@ +package # hide from PAUSE + RestTest::Schema::Result::CD; + +use base 'DBIx::Class::Core'; + +__PACKAGE__->table('cd'); +__PACKAGE__->add_columns( + 'cdid' => { + data_type => 'integer', + is_auto_increment => 1, + }, + 'artist' => { + data_type => 'integer', + }, + 'title' => { + data_type => 'varchar', + size => 100, + }, + 'year' => { + data_type => 'varchar', + size => 100, + }, +); +__PACKAGE__->set_primary_key('cdid'); +__PACKAGE__->add_unique_constraint([ qw/artist title/ ]); + +__PACKAGE__->belongs_to( artist => 'RestTest::Schema::Result::Artist' ); + +__PACKAGE__->has_many( tracks => 'RestTest::Schema::Result::Track' ); +__PACKAGE__->has_many( + tags => 'RestTest::Schema::Result::Tag', undef, + { order_by => 'tag' }, +); +__PACKAGE__->has_many( + cd_to_producer => 'RestTest::Schema::Result::CD_to_Producer' => 'cd' +); + +__PACKAGE__->many_to_many( producers => cd_to_producer => 'producer' ); +__PACKAGE__->many_to_many( + producers_sorted => cd_to_producer => 'producer', + { order_by => 'producer.name' }, +); + +1; diff --git a/t/lib/RestTest/Schema/Result/CD_to_Producer.pm b/t/lib/RestTest/Schema/Result/CD_to_Producer.pm new file mode 100644 index 0000000..07b6ee8 --- /dev/null +++ b/t/lib/RestTest/Schema/Result/CD_to_Producer.pm @@ -0,0 +1,23 @@ +package # hide from PAUSE + RestTest::Schema::Result::CD_to_Producer; + +use base 'DBIx::Class::Core'; + +__PACKAGE__->table('cd_to_producer'); +__PACKAGE__->add_columns( + cd => { data_type => 'integer' }, + producer => { data_type => 'integer' }, +); +__PACKAGE__->set_primary_key(qw/cd producer/); + +__PACKAGE__->belongs_to( + 'cd', 'RestTest::Schema::Result::CD', + { 'foreign.cdid' => 'self.cd' } +); + +__PACKAGE__->belongs_to( + 'producer', 'RestTest::Schema::Result::Producer', + { 'foreign.producerid' => 'self.producer' } +); + +1; diff --git a/t/lib/RestTest/Schema/Result/Producer.pm b/t/lib/RestTest/Schema/Result/Producer.pm new file mode 100644 index 0000000..c80937f --- /dev/null +++ b/t/lib/RestTest/Schema/Result/Producer.pm @@ -0,0 +1,21 @@ +package # hide from PAUSE + RestTest::Schema::Result::Producer; + +use base 'DBIx::Class::Core'; + +__PACKAGE__->table('producer'); +__PACKAGE__->add_columns( + 'producerid' => { + data_type => 'integer', + is_auto_increment => 1 + }, + 'name' => { + data_type => 'varchar', + size => 100, + default_value => 'fred' + }, +); +__PACKAGE__->set_primary_key('producerid'); +__PACKAGE__->add_unique_constraint(prod_name => [ qw/name/ ]); + +1; diff --git a/t/lib/RestTest/Schema/Result/Tag.pm b/t/lib/RestTest/Schema/Result/Tag.pm new file mode 100644 index 0000000..4ab5df9 --- /dev/null +++ b/t/lib/RestTest/Schema/Result/Tag.pm @@ -0,0 +1,24 @@ +package # hide from PAUSE + RestTest::Schema::Result::Tag; + +use base qw/DBIx::Class::Core/; + +__PACKAGE__->table('tags'); +__PACKAGE__->add_columns( + 'tagid' => { + data_type => 'integer', + is_auto_increment => 1, + }, + 'cd' => { + data_type => 'integer', + }, + 'tag' => { + data_type => 'varchar', + size => 100, + }, +); +__PACKAGE__->set_primary_key('tagid'); + +__PACKAGE__->belongs_to( cd => 'RestTest::Schema::Result::CD' ); + +1; diff --git a/t/lib/RestTest/Schema/Result/Track.pm b/t/lib/RestTest/Schema/Result/Track.pm new file mode 100644 index 0000000..8ce5d4f --- /dev/null +++ b/t/lib/RestTest/Schema/Result/Track.pm @@ -0,0 +1,36 @@ +package # hide from PAUSE + RestTest::Schema::Result::Track; + +use base 'DBIx::Class::Core'; +__PACKAGE__->table('track'); +__PACKAGE__->add_columns( + 'trackid' => { + data_type => 'integer', + is_auto_increment => 1, + }, + 'cd' => { + data_type => 'integer', + }, + 'position' => { + data_type => 'integer', + accessor => 'pos', + default_value => 0 + }, + 'title' => { + data_type => 'varchar', + size => 100, + }, + last_updated_on => { + data_type => 'datetime', + accessor => 'updated_date', + is_nullable => 1 + }, +); +__PACKAGE__->set_primary_key('trackid'); + +__PACKAGE__->add_unique_constraint([ qw/cd position/ ]); +__PACKAGE__->add_unique_constraint([ qw/cd title/ ]); + +__PACKAGE__->belongs_to( cd => 'RestTest::Schema::Result::CD'); + +1; diff --git a/t/lib/RestTest/Schema/ResultSet.pm b/t/lib/RestTest/Schema/ResultSet.pm new file mode 100644 index 0000000..4cf9ddb --- /dev/null +++ b/t/lib/RestTest/Schema/ResultSet.pm @@ -0,0 +1,6 @@ +package # hide from PAUSE + RestTest::Schema::ResultSet; + +use base 'DBIx::Class::ResultSet'; + +1; diff --git a/t/lib/RestTest/Schema/ResultSet/Track.pm b/t/lib/RestTest/Schema/ResultSet/Track.pm new file mode 100644 index 0000000..c8d24c6 --- /dev/null +++ b/t/lib/RestTest/Schema/ResultSet/Track.pm @@ -0,0 +1,19 @@ +package # hide from PAUSE + RestTest::Schema::ResultSet::Track; + +use base 'RestTest::Schema::ResultSet'; + +sub search { + my $self = shift; + my ($clause, $params) = @_; + + if (ref $clause eq 'ARRAY') { + # test custom attrs + if (my $pretend = delete $clause->[0]->{'cd.pretend'}) { + $clause->[0]->{'cd.year'} = $pretend; + } + } + my $rs = $self->SUPER::search(@_); +} + +1; diff --git a/t/lib/sqlite.sql b/t/lib/sqlite.sql new file mode 100644 index 0000000..75342b5 --- /dev/null +++ b/t/lib/sqlite.sql @@ -0,0 +1,63 @@ +-- +-- Created by SQL::Translator::Producer::SQLite +-- Created on Tue Aug 8 01:53:20 2006 +-- +BEGIN TRANSACTION; + +-- +-- Table: cd_to_producer +-- +CREATE TABLE cd_to_producer ( + cd integer NOT NULL, + producer integer NOT NULL, + PRIMARY KEY (cd, producer) +); + +-- +-- Table: artist +-- +CREATE TABLE artist ( + artistid INTEGER PRIMARY KEY NOT NULL, + name varchar(100) +); + + +-- +-- Table: cd +-- +CREATE TABLE cd ( + cdid INTEGER PRIMARY KEY NOT NULL, + artist integer NOT NULL, + title varchar(100) NOT NULL, + year varchar(100) NOT NULL +); + +-- +-- Table: track +-- +CREATE TABLE track ( + trackid INTEGER PRIMARY KEY NOT NULL, + cd integer NOT NULL, + position integer NOT NULL, + title varchar(100) NULL, + last_updated_on datetime NULL +); + +-- +-- Table: tags +-- +CREATE TABLE tags ( + tagid INTEGER PRIMARY KEY NOT NULL, + cd integer NOT NULL, + tag varchar(100) NOT NULL +); + +-- +-- Table: producer +-- +CREATE TABLE producer ( + producerid INTEGER PRIMARY KEY NOT NULL, + name varchar(100) NOT NULL +); + +COMMIT; diff --git a/t/rest/create.t b/t/rest/create.t new file mode 100644 index 0000000..4fdbfb5 --- /dev/null +++ b/t/rest/create.t @@ -0,0 +1,90 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; + +use RestTest; +use DBICTest; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $artist_create_url = "$base/api/rest/artist"; +my $producer_create_url = "$base/api/rest/producer"; + +# test validation when no params sent +{ + my $test_data = JSON::Any->Dump({ wrong_param => 'value' }); + my $req = PUT( $artist_create_url ); + $req->content_type('text/x-json'); + $req->content_length( + do { use bytes; length( $test_data ) } + ); + $req->content( $test_data ); + $mech->request($req); + + cmp_ok( $mech->status, '==', 400, 'attempt without required params caught' ); + my $response = JSON::Any->Load( $mech->content); + like($response->{messages}->[0], qr/No value supplied for name and no default/, 'correct message returned' ); +} + +# test default value used if default value exists +{ + my $test_data = JSON::Any->Dump({}); + my $req = PUT( $producer_create_url ); + $req->content_type('text/x-json'); + $req->content_length( + do { use bytes; length( $test_data ) } + ); + $req->content( $test_data ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'default value used when not supplied' ); + ok($schema->resultset('Producer')->find({ name => 'fred' }), 'record created with default name'); +} + +# test create works as expected when passing required value +{ + my $test_data = JSON::Any->Dump({ name => 'king luke' }); + my $req = PUT( $producer_create_url ); + $req->content_type('text/x-json'); + $req->content_length( + do { use bytes; length( $test_data ) } + ); + $req->content( $test_data ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'request with valid content okay' ); + my $new_obj = $schema->resultset('Producer')->find({ name => 'king luke' }); + ok($new_obj, 'record created with specified name'); + + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response->{list}, { $new_obj->get_columns }, 'json for new producer returned' ); +} + +# test bulk create +{ + my $test_data = JSON::Any->Dump({ list => [{ name => 'king nperez' }, { name => 'queen perla'}] }); + my $req = PUT( $producer_create_url ); + $req->content_type('text/x-json'); + $req->content_length( + do { use bytes; length( $test_data ) } + ); + $req->content( $test_data ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'request with valid content okay' ); + my $rs = $schema->resultset('Producer')->search([ { name => 'king nperez' }, { name => 'queen perla' } ]); + ok($rs, 'record created with specified name'); + + my $response = JSON::Any->Load( $mech->content); + my $expected = [ map { my %foo = $_->get_inflated_columns; \%foo; } $rs->all ]; + is_deeply( $response->{list}, $expected, 'json for bulk create returned' ); +} + +done_testing(); diff --git a/t/rest/delete.t b/t/rest/delete.t new file mode 100644 index 0000000..0021ca5 --- /dev/null +++ b/t/rest/delete.t @@ -0,0 +1,40 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; +my $content_type = [ 'Content-Type', 'application/x-www-form-urlencoded' ]; + +use RestTest; +use DBICTest; +use Test::More tests => 4; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $track = $schema->resultset('Track')->first; +my %original_cols = $track->get_columns; + +my $track_delete_url = "$base/api/rest/track/" . $track->id; + +{ + my $req = HTTP::Request->new( DELETE => $track_delete_url ); + $req->content_type('text/x-json'); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'Attempt to delete track ok' ); + + my $deleted_track = $schema->resultset('Track')->find($track->id); + is($deleted_track, undef, 'track deleted'); +} + +{ + my $req = HTTP::Request->new( DELETE => $track_delete_url ); + $req->content_type('text/x-json'); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'Attempt to delete again caught' ); +} diff --git a/t/rest/list.t b/t/rest/list.t new file mode 100644 index 0000000..c7683f8 --- /dev/null +++ b/t/rest/list.t @@ -0,0 +1,108 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; + +use RestTest; +use DBICTest; +use URI; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $artist_list_url = "$base/api/rest/artist"; +my $filtered_artist_list_url = "$base/api/rest/bound_artist"; +my $producer_list_url = "$base/api/rest/producer"; + +# test open request +{ + my $req = GET( $artist_list_url, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'open attempt okay' ); + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct message returned' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.artistid' => 1 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'attempt with basic search okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ artistid => 1 })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.name.LIKE' => '%waul%' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'attempt with basic search okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ name => { LIKE => '%waul%' }})->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for complex query' ); +} + +{ + $DB::single = 1; + my $uri = URI->new( $producer_list_url ); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'open producer request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Producer')->search({}, { select => ['name'] })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for class with list_returns specified' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.cds.title' => 'Forkful of bees' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search related request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ 'cds.title' => 'Forkful of bees' }, { join => 'cds' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for class with select specified' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.cds.title' => 'Forkful of bees', 'list_returns.0.count' => '*', 'as.0' => 'count'}); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search related request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ 'cds.title' => 'Forkful of bees' }, { select => [ {count => '*'} ], as => [ 'count' ], join => 'cds' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for count' ); +} + +{ + my $uri = URI->new( $filtered_artist_list_url ); + $uri->query_form({ 'search.artistid' => '2' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search related request okay' ); + my $response = JSON::Any->Load( $mech->content); + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ 'artistid' => '1' })->all; + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for class with setup_list_method specified' ); +} + +done_testing(); diff --git a/t/rest/update.t b/t/rest/update.t new file mode 100644 index 0000000..ee5837b --- /dev/null +++ b/t/rest/update.t @@ -0,0 +1,83 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; +my $content_type = [ 'Content-Type', 'application/x-www-form-urlencoded' ]; + +use RestTest; +use DBICTest; +use Test::More tests => 15; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $track = $schema->resultset('Track')->first; +my %original_cols = $track->get_columns; + +my $track_update_url = "$base/api/rest/track/" . $track->id; + +# test invalid track id caught +{ + foreach my $wrong_id ('sdsdsdsd', 3434234) { + my $incorrect_url = "$base/api/rest/track/" . $wrong_id; + my $test_data = JSON::Any->Dump({ title => 'value' }); + my $req = POST( $incorrect_url, Content => $test_data ); + $req->content_type('text/x-json'); + $mech->request($req); + + cmp_ok( $mech->status, '==', 400, 'Attempt with invalid track id caught' ); + + my $response = JSON::Any->Load( $mech->content); + like( $response->{messages}->[0], qr/No object found for id/, 'correct message returned' ); + + $track->discard_changes; + is_deeply({ $track->get_columns }, \%original_cols, 'no update occurred'); + } +} + +# validation when no params sent +{ + my $test_data = JSON::Any->Dump({ wrong_param => 'value' }); + my $req = POST( $track_update_url, Content => $test_data ); + $req->content_type('text/x-json'); + $mech->request($req); + + cmp_ok( $mech->status, '==', 400, 'Update with no keys causes error' ); + + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response->{messages}, ['No valid keys passed'], 'correct message returned' ); + + $track->discard_changes; + is_deeply({ $track->get_columns }, \%original_cols, 'no update occurred'); +} + +{ + my $test_data = JSON::Any->Dump({ title => undef }); + my $req = POST( $track_update_url, Content => $test_data ); + $req->content_type('text/x-json'); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'Update with key with no value okay' ); + + $track->discard_changes; + isnt($track->title, $original_cols{title}, 'Title changed'); + is($track->title, undef, 'Title changed to undef'); +} + +{ + my $test_data = JSON::Any->Dump({ title => 'monkey monkey' }); + my $req = POST( $track_update_url, Content => $test_data ); + $req->content_type('text/x-json'); + $mech->request($req); + + cmp_ok( $mech->status, '==', 200, 'Update with key with value okay' ); + + $track->discard_changes; + is($track->title, 'monkey monkey', 'Title changed to "monkey monkey"'); +} diff --git a/t/rpc/create.t b/t/rpc/create.t new file mode 100644 index 0000000..4e0913f --- /dev/null +++ b/t/rpc/create.t @@ -0,0 +1,89 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; +my $content_type = [ 'Content-Type', 'application/x-www-form-urlencoded' ]; + +use RestTest; +use DBICTest; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $artist_create_url = "$base/api/rpc/artist/create"; +my $any_artist_create_url = "$base/api/rpc/any/artist/create"; +my $producer_create_url = "$base/api/rpc/producer/create"; + +# test validation when no params sent +{ + my $req = POST( $artist_create_url, { + wrong_param => 'value' + }, 'Accept' => 'text/json' ); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 400, 'attempt without required params caught' ); + my $response = JSON::Any->Load( $mech->content); + like( $response->{messages}->[0], qr/No value supplied for name and no default/, 'correct message returned' ); +} + +# test default value used if default value exists +{ + my $req = POST( $producer_create_url, { + + }, 'Accept' => 'text/json' ); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'default value used when not supplied' ); + ok($schema->resultset('Producer')->find({ name => 'fred' }), 'record created with default name'); +} + +# test create works as expected when passing required value +{ + my $req = POST( $producer_create_url, { + name => 'king luke' + }, 'Accept' => 'text/json' ); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'param value used when supplied' ); + + my $new_obj = $schema->resultset('Producer')->find({ name => 'king luke' }); + ok($new_obj, 'record created with specified name'); + + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response->{list}, { $new_obj->get_columns }, 'json for new producer returned' ); +} + +# test stash config handling +{ + $DB::single = 1; + my $req = POST( $any_artist_create_url, { + name => 'queen monkey' + }, 'Accept' => 'text/json' ); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'stashed config okay' ); + + my $new_obj = $schema->resultset('Artist')->find({ name => 'queen monkey' }); + ok($new_obj, 'record created with specified name'); + + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { success => 'true' }, 'json for new artist returned' ); +} + +# test create returns an error as expected when passing invalid value +{ + my $long_string = '-' x 1024; + + my $req = POST( $producer_create_url, { + producerid => $long_string, + name => $long_string, + }, 'Accept' => 'text/json' ); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 400, 'invalid param value produces error' ); +} + +done_testing(); diff --git a/t/rpc/delete.t b/t/rpc/delete.t new file mode 100644 index 0000000..f780741 --- /dev/null +++ b/t/rpc/delete.t @@ -0,0 +1,44 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; +my $content_type = [ 'Content-Type', 'application/x-www-form-urlencoded' ]; + +use RestTest; +use DBICTest; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $track = $schema->resultset('Track')->first; +my %original_cols = $track->get_columns; + +my $track_delete_url = "$base/api/rpc/track/id/" . $track->id . "/delete"; + +{ + my $req = POST( $track_delete_url, { + + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'Attempt to delete track ok' ); + + my $deleted_track = $schema->resultset('Track')->find($track->id); + is($deleted_track, undef, 'track deleted'); +} + +{ + my $req = POST( $track_delete_url, { + + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 400, 'Attempt to delete again caught' ); +} + +done_testing(); diff --git a/t/rpc/list.t b/t/rpc/list.t new file mode 100644 index 0000000..6560c44 --- /dev/null +++ b/t/rpc/list.t @@ -0,0 +1,240 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; + +use RestTest; +use DBICTest; +use URI; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $artist_list_url = "$base/api/rpc/artist/list"; +my $producer_list_url = "$base/api/rpc/producer/list"; +my $track_list_url = "$base/api/rpc/track/list"; +my $cd_list_url = "$base/api/rpc/cd/list"; + +# test open request +{ + my $req = GET( $artist_list_url, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'open attempt okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct message returned' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.artistid' => 1 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'attempt with basic search okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ artistid => 1 })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.name.LIKE' => '%waul%' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'attempt with basic search okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ name => { LIKE => '%waul%' }})->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for complex query' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.name.LIKE' => '%waul%', 'list_returns.0.count' => '*', 'as.0' => 'count'}); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'attempt with basic count' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ name => { LIKE => '%waul%' }}, { select => [ {count => '*'} ], as => ['count'] } )->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for count' ); +} + +{ + my $uri = URI->new( $producer_list_url ); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'open producer request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Producer')->search({}, { select => ['name'] })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for class with list_returns specified' ); +} + + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.cds.title' => 'Forkful of bees' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search related request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ 'cds.title' => 'Forkful of bees' }, { join => 'cds' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for class with list_returns specified' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'list_ordered_by' => 'position' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search related request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Track')->search({}, { group_by => 'position', order_by => 'position ASC', select => 'position' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for class with everything specified in class' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'list_ordered_by' => 'cd', 'list_returns' => 'cd', 'list_grouped_by' => 'cd' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search related request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Track')->search({}, { group_by => 'cd', order_by => 'cd ASC', select => 'cd' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned when everything overridden in query' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'list_ordered_by' => 'cd', 'list_count' => 2 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'list count request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Track')->search({}, { group_by => 'position', order_by => 'position ASC', select => 'position', rows => 2 })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'list_ordered_by' => 'cd', 'list_count' => 2, 'list_page' => 'fgdg' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'non numeric list_page request not okay' ); + my $response = JSON::Any->Load( $mech->content); + is($response->{success}, 'false', 'correct data returned'); + like($response->{messages}->[0], qr/Attribute \(page\) does not pass the type constraint because: Validation failed for 'Int' failed with value fgdg/, 'correct data returned'); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'list_ordered_by' => 'cd', 'list_count' => 'sdsdf', 'list_page' => 2 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'non numeric list_count request not okay' ); + my $response = JSON::Any->Load( $mech->content); + is($response->{success}, 'false', 'correct data returned'); + like($response->{messages}->[0], qr/Attribute \(count\) does not pass the type constraint because: Validation failed for 'Int' failed with value sdsdf/, 'correct data returned'); + +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'list_ordered_by' => 'cd', 'list_count' => 2, 'list_page' => 2 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'list count with page request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Track')->search({}, { group_by => 'position', order_by => 'position ASC', select => 'position', rows => 2, page => 2 })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true', totalcount => 3 }, 'correct data returned' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'list_ordered_by' => 'cd', 'list_page' => 2 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'list page without count returns error' ); + my $response = JSON::Any->Load( $mech->content); + like( $response->{messages}->[0], qr/a database error has occured/, 'correct data returned' ); +} + +{ + my $uri = URI->new( $cd_list_url ); + $uri->query_form({ 'search.artist.name' => 'Caterwauler McCrae' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + if (cmp_ok( $mech->status, '==', 200, 'search on rel with same name column request okay' )) { + my @expected_response = map { { $_->get_columns } } $schema->resultset('CD')->search({'me.artist' => 1})->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for search on rel with same name column' ); + } +} + +{ + my $uri = URI->new( $cd_list_url ); + $uri->query_form({ 'search.artist' => 1 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search on column with same name rel request okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('CD')->search({'me.artist' => 1})->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for search on column with same name rel' ); +} + +{ + my $uri = URI->new( $cd_list_url ); + $uri->query_form({ 'search.title' => 'Spoonful of bees', 'search.tracks.position' => 1 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + if (cmp_ok( $mech->status, '==', 200, 'search on col which exists for me and related table okay' )) { + my @expected_response = map { { $_->get_columns } } $schema->resultset('CD')->search({'me.title' => 'Spoonful of bees', 'tracks.position' => 1}, { join => 'tracks' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct data returned for search on col which exists for me and related table' ); + } +} + +{ + my $uri = URI->new( $cd_list_url ); + $uri->query_form({ 'list_ordered_by' => 'invalid_column' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + if (cmp_ok( $mech->status, '==', 400, 'order_by on non-existing col returns error' )) { + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { messages => ['a database error has occured.'], success => 'false' }, + 'error returned for order_by on non-existing col' ); + } +} + +{ + my $uri = URI->new( $cd_list_url ); + $uri->query_form({ 'list_ordered_by' => 'invalid_column', 'list_count' => 2, 'list_page' => 1 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + if (cmp_ok( $mech->status, '==', 400, 'order_by on invalid col with paging returns error' )) { + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { messages => ['a database error has occured.'], success => 'false' }, + 'error returned for order_by on non-existing col with paging' ); + } +} + +done_testing(); diff --git a/t/rpc/list_json_search.t b/t/rpc/list_json_search.t new file mode 100644 index 0000000..089f5b7 --- /dev/null +++ b/t/rpc/list_json_search.t @@ -0,0 +1,70 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; + +use RestTest; +use DBICTest; +use URI; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $artist_list_url = "$base/api/rpc/artist/list"; +my $base_rs = $schema->resultset('Track')->search({}, { select => [qw/me.title me.position/], order_by => 'position' }); + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search' => '{"gibberish}' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'attempt with gibberish json not okay' ); + my $response = JSON::Any->Load( $mech->content); + is($response->{success}, 'false', 'correct data returned for gibberish in search' ); + like($response->{messages}->[0], qr/Attribute \(search\) does not pass the type constraint because/, 'correct data returned for gibberish in search' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search' => '{"name":{"LIKE":"%waul%"}}' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'attempt with basic search okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ name => { LIKE => '%waul%' }})->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( { list => \@expected_response, success => 'true' }, $response, 'correct data returned for complex query' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search' => '{ "cds": { "title": "Spoonful of bees" }}' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'attempt with related search okay' ); + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ 'cds.title' => 'Spoonful of bees' }, { join => 'cds' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( { list => \@expected_response, success => 'true' }, $response, 'correct data returned for complex query' ); +} + +{ + my $uri = URI->new( $artist_list_url ); + $uri->query_form({ 'search.name' => '{"LIKE":"%waul%"}' }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'attempt with mixed CGI::Expand + JSON search okay' ); + + my @expected_response = map { { $_->get_columns } } $schema->resultset('Artist')->search({ name => { LIKE => '%waul%' }})->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( { list => \@expected_response, success => 'true' }, $response, 'correct data returned for complex query' ); +} + +done_testing(); diff --git a/t/rpc/list_prefetch.t b/t/rpc/list_prefetch.t new file mode 100644 index 0000000..bb82da7 --- /dev/null +++ b/t/rpc/list_prefetch.t @@ -0,0 +1,78 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; + +use RestTest; +use DBICTest; +use URI; +use Test::More tests => 17; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $artist_list_url = "$base/api/rpc/artist/list"; +my $cd_list_url = "$base/api/rpc/cd/list"; + +foreach my $req_params ({ 'list_prefetch' => '["cds"]' }, { 'list_prefetch' => 'cds' }) { + my $uri = URI->new( $artist_list_url ); + $uri->query_form($req_params); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search with simple prefetch request okay' ); + my $rs = $schema->resultset('Artist')->search(undef, { prefetch => ['cds'] }); + $rs->result_class('DBIx::Class::ResultClass::HashRefInflator'); + my @rows = $rs->all; + my $expected_response = { list => \@rows, success => 'true' }; + my $response = JSON::Any->Load( $mech->content); + #use Data::Dumper; warn Dumper($response, $expected_response); + is_deeply( $expected_response, $response, 'correct data returned for search with simple prefetch specified as param' ); +} + +foreach my $req_params ({ 'list_prefetch' => '{"cds":"tracks"}' }, { 'list_prefetch.cds' => 'tracks' }) { + my $uri = URI->new( $artist_list_url ); + $uri->query_form($req_params); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search with multi-level prefetch request okay' ); + my $rs = $schema->resultset('Artist')->search(undef, { prefetch => {'cds' => 'tracks'} }); + $rs->result_class('DBIx::Class::ResultClass::HashRefInflator'); + my @rows = $rs->all; + my $expected_response = { list => \@rows, success => 'true' }; + my $response = JSON::Any->Load( $mech->content); + #use Data::Dumper; warn Dumper($response, $expected_response); + is_deeply( $expected_response, $response, 'correct data returned for search with multi-level prefetch specified as param' ); +} + +foreach my $req_params ({ 'list_prefetch' => '["artist"]' }, { 'list_prefetch' => 'artist' }) { + my $uri = URI->new( $cd_list_url ); + $uri->query_form($req_params); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'prefetch of artist not okay' ); + + my $expected_response = map { { $_->get_columns } } $schema->resultset('CD')->all; + my $response = JSON::Any->Load( $mech->content); + #use Data::Dumper; warn Dumper($response, $expected_response); + is($response->{success}, 'false', 'correct message returned' ); + like($response->{messages}->[0], qr/not an allowed prefetch in:/, 'correct message returned' ); +} + +{ + my $uri = URI->new( $cd_list_url ); + $uri->query_form({ 'list_prefetch' => 'tracks', 'list_ordered_by' => 'title', 'list_count' => 2, 'list_page' => 1 }); + my $req = GET( $uri, 'Accept' => 'text/x-json' ); + $mech->request($req); + if (cmp_ok( $mech->status, '==', 400, 'order_by on non-unique col with paging returns error' )) { + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { messages => ['a database error has occured.'], success => 'false' }, + 'error returned for order_by on non-existing col with paging' ); + } +} diff --git a/t/rpc/list_search_allows.t b/t/rpc/list_search_allows.t new file mode 100644 index 0000000..c623fab --- /dev/null +++ b/t/rpc/list_search_allows.t @@ -0,0 +1,132 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; + +use RestTest; +use DBICTest; +use URI; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $track_list_url = "$base/api/rpc/track_exposed/list"; +my $base_rs = $schema->resultset('Track')->search({}, { select => [qw/me.title me.position/], order_by => 'position' }); + +# test open request +{ + my $req = GET( $track_list_url, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'open attempt okay' ); + + my @expected_response = map { { $_->get_columns } } $base_rs->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct message returned' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'search.position' => 1 }); + my $req = GET( $uri, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search on position okay' ); + my @expected_response = map { { $_->get_columns } } $base_rs->search({ position => 1 })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response, { list => \@expected_response, success => 'true' }, 'correct message returned' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'search.title' => 'Stripy' }); + my $req = GET( $uri, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'search on title not okay' ); + + my @expected_response = map { { $_->get_columns } } $base_rs->search({ position => 1 })->all; + my $response = JSON::Any->Load( $mech->content); + is($response->{success}, 'false', 'correct message returned'); + like($response->{messages}->[0], qr/is not an allowed search term/, 'correct message returned'); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'search.title' => 'Stripy' }); + my $req = GET( $uri, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'search on title not okay' ); + + my $expected_response = map { { $_->get_columns } } $base_rs->search({ position => 1 })->all; + my $response = JSON::Any->Load( $mech->content); + is($response->{success}, 'false', 'correct message returned'); + like($response->{messages}->[0], qr/is not an allowed search term/, 'correct message returned'); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'search.cd.artist' => '1' }); + my $req = GET( $uri, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 400, 'search on various cd fields not okay' ); + my $response = JSON::Any->Load( $mech->content); + is($response->{success}, 'false', 'correct message returned'); + like($response->{messages}->[0], qr/is not an allowed search term/, 'correct message returned'); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'search.cd.title' => 'Spoonful of bees', 'search.cd.year' => '1999' }); + my $req = GET( $uri, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search on various cd fields okay' ); + my @expected_response = map { { $_->get_columns } } $base_rs->search({ 'cd.year' => '1999', 'cd.title' => 'Spoonful of bees' }, { join => 'cd' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply($response, { list => \@expected_response, success => 'true' }, 'correct message returned' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'search.cd.title' => 'Spoonful of bees', 'search.cd.pretend' => '1999' }); + my $req = GET( $uri, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search with custom col okay' ); + my @expected_response = map { { $_->get_columns } } $base_rs->search({ 'cd.year' => '1999', 'cd.title' => 'Spoonful of bees' }, { join => 'cd' })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply($response, { list => \@expected_response, success => 'true' }, 'correct message returned' ); +} + +{ + my $uri = URI->new( $track_list_url ); + $uri->query_form({ 'search.cd.artist.name' => 'Random Boy Band' }); + my $req = GET( $uri, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'search on artist field okay due to wildcard' ); + my @expected_response = map { { $_->get_columns } } $base_rs->search({ 'artist.name' => 'Random Boy Band' }, { join => { cd => 'artist' } })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply($response, { list => \@expected_response, success => 'true' }, 'correct message returned' ); +} + +done_testing(); diff --git a/t/rpc/setup_dbic_args.t b/t/rpc/setup_dbic_args.t new file mode 100644 index 0000000..faa28dd --- /dev/null +++ b/t/rpc/setup_dbic_args.t @@ -0,0 +1,37 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; + +use RestTest; +use DBICTest; +use URI; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $track_list_url = "$base/api/rpc/track_setup_dbic_args/list"; +my $base_rs = $schema->resultset('Track')->search({}, { select => [qw/me.title me.position/], order_by => 'position' }); + +# test open request +{ + my $req = GET( $track_list_url, { + + }, 'Accept' => 'text/x-json' ); + $mech->request($req); + cmp_ok( $mech->status, '==', 200, 'open attempt okay' ); + + my @expected_response = map { { $_->get_columns } } $base_rs->search({ position => { '!=' => '1' } })->all; + my $response = JSON::Any->Load( $mech->content); + is_deeply( { list => \@expected_response, success => 'true' }, $response, 'correct message returned' ); +} + +done_testing(); diff --git a/t/rpc/update.t b/t/rpc/update.t new file mode 100644 index 0000000..dc68881 --- /dev/null +++ b/t/rpc/update.t @@ -0,0 +1,133 @@ +use 5.6.0; + +use strict; +use warnings; + +use lib 't/lib'; + +my $base = 'http://localhost'; +my $content_type = [ 'Content-Type', 'application/x-www-form-urlencoded' ]; + +use RestTest; +use DBICTest; +use Test::More; +use Test::WWW::Mechanize::Catalyst 'RestTest'; +use HTTP::Request::Common; +use JSON::Any; + +my $mech = Test::WWW::Mechanize::Catalyst->new; +ok(my $schema = DBICTest->init_schema(), 'got schema'); + +my $track = $schema->resultset('Track')->first; +my %original_cols = $track->get_columns; + +my $track_update_url = "$base/api/rpc/track/id/" . $track->id . "/update"; +my $any_track_update_url = "$base/api/rpc/any/track/id/" . $track->id . "/update"; + +# test invalid track id caught +{ + foreach my $wrong_id ('sdsdsdsd', 3434234) { + my $incorrect_url = "$base/api/rpc/track/id/" . $wrong_id . "/update"; + my $req = POST( $incorrect_url, { + title => 'value' + }); + + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 400, 'Attempt with invalid track id caught' ); + + my $response = JSON::Any->Load( $mech->content); + like( $response->{messages}->[0], qr/No object found for id/, 'correct message returned' ); + + $track->discard_changes; + is_deeply({ $track->get_columns }, \%original_cols, 'no update occurred'); + } +} + +# validation when no params sent +{ + my $req = POST( $track_update_url, { + wrong_param => 'value' + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 400, 'Update with no keys causes error' ); + + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response->{messages}, ['No valid keys passed'], 'correct message returned' ); + + $track->discard_changes; + is_deeply({ $track->get_columns }, \%original_cols, 'no update occurred'); +} + +{ + my $req = POST( $track_update_url, { + wrong_param => 'value' + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 400, 'Update with no keys causes error' ); + + my $response = JSON::Any->Load( $mech->content); + is_deeply( $response->{messages}, ['No valid keys passed'], 'correct message returned' ); + + $track->discard_changes; + is_deeply({ $track->get_columns }, \%original_cols, 'no update occurred'); +} + +{ + my $req = POST( $track_update_url, { + title => undef + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'Update with key with no value okay' ); + + $track->discard_changes; + isnt($track->title, $original_cols{title}, 'Title changed'); + is($track->title, '', 'Title changed to undef'); +} + +{ + my $req = POST( $track_update_url, { + title => 'monkey monkey' + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'Update with key with value okay' ); + + $track->discard_changes; + is($track->title, 'monkey monkey', 'Title changed to "monkey monkey"'); +} + +{ + my $req = POST( $track_update_url, { + title => 'sheep sheep', + 'cd.year' => '2009' + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'Update with key with value and related key okay' ); + + $track->discard_changes; + is($track->title, 'sheep sheep', 'Title changed'); + is($track->cd->year, '2009', 'Related field changed"'); +} + +{ + my $req = POST( $any_track_update_url, { + title => 'baa' + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'Stash update okay' ); + + $track->discard_changes; + is($track->title, 'baa', 'Title changed'); +} + +{ + my $req = POST( $any_track_update_url, { + position => '14' + }); + $mech->request($req, $content_type); + cmp_ok( $mech->status, '==', 200, 'Position update okay' ); + + $track->discard_changes; + is($track->get_column('position'), '14', 'Position changed'); +} + +done_testing(); diff --git a/t/var/DBIxClass.db b/t/var/DBIxClass.db new file mode 100644 index 0000000..a2e3024 Binary files /dev/null and b/t/var/DBIxClass.db differ diff --git a/weaver.ini b/weaver.ini new file mode 100644 index 0000000..9cb783a --- /dev/null +++ b/weaver.ini @@ -0,0 +1,30 @@ +[@CorePrep] + +[Name] +[Version] + +[Generic / SYNOPSIS] +[Generic / DESCRIPTION] +[Generic / OVERVIEW] + +[Collect / PUBLIC_ATTRIBUTES] +command = attribute_public +[Collect / PROTECTED_ATTRIBUTES] +command = attribute_protected +[Collect / PRIVATE_ATTRIBUTES] +command = attribute_private + +[Collect / PUBLIC_METHODS] +command = method_public +[Collect / PROTECTED_METHODS] +command = method_protected +[Collect / PRIVATE_METHODS] +command = method_private + +[Collect / TYPES] +command = type + +[Leftovers] + +[Authors] +[Legal]