diff --git a/cpanfile b/cpanfile index 1354e62..89de67c 100644 --- a/cpanfile +++ b/cpanfile @@ -32,3 +32,7 @@ feature 'postgres', 'PostgreSQL Support' => sub { requires 'Test::PostgreSQL'; }; +feature 'codepoint-open', 'Code Point Open manipulation' => sub { + requires 'Geo::UK::Postcode::CodePointOpen'; +}; + diff --git a/lib/Pear/LocalLoop.pm b/lib/Pear/LocalLoop.pm index 044b322..8bdb811 100644 --- a/lib/Pear/LocalLoop.pm +++ b/lib/Pear/LocalLoop.pm @@ -148,6 +148,10 @@ sub startup { my $api_v1 = $api->under('/v1'); + my $api_v1_supplier = $api_v1->under('/supplier'); + + $api_v1_supplier->post('/location')->to('api-v1-supplier-location#index'); + my $api_v1_org = $api_v1->under('/organisation')->to('api-v1-organisation#auth'); $api_v1_org->post('/graphs')->to('api-v1-organisation-graphs#index'); diff --git a/lib/Pear/LocalLoop/Command/codepoint_open.pm b/lib/Pear/LocalLoop/Command/codepoint_open.pm index fbc9bb0..bfdedae 100644 --- a/lib/Pear/LocalLoop/Command/codepoint_open.pm +++ b/lib/Pear/LocalLoop/Command/codepoint_open.pm @@ -36,7 +36,6 @@ sub run { split_postcode => 1, ); - use Devel::Dwarn; my $pc_rs = $self->app->schema->resultset('GbPostcode'); while ( my $pc = $iter->() ) { $pc_rs->find_or_create( diff --git a/lib/Pear/LocalLoop/Controller/Api/V1/Supplier/Location.pm b/lib/Pear/LocalLoop/Controller/Api/V1/Supplier/Location.pm new file mode 100644 index 0000000..accd3af --- /dev/null +++ b/lib/Pear/LocalLoop/Controller/Api/V1/Supplier/Location.pm @@ -0,0 +1,109 @@ +package Pear::LocalLoop::Controller::Api::V1::Supplier::Location; +use Mojo::Base 'Mojolicious::Controller'; + +has validation_data => sub { + my $children_errors = { + latitude => { + validation => [ + { required => {} }, + { number => { error_prefix => 'not_number' } }, + { in_range => { args => [ -90, 90 ], error_prefix => 'outside_range' } }, + ], + }, + longitude => { + validation => [ + { required => {} }, + { number => { error_prefix => 'not_number' } }, + { in_range => { args => [ -180, 180 ], error_prefix => 'outside_range' } }, + ], + }, + }; + + return { + index => { + north_east => { + validation => [ + { required => {} }, + { is_object => { error_prefix => 'not_object' } }, + ], + children => $children_errors, + }, + south_west => { + validation => [ + { required => {} }, + { is_object => { error_prefix => 'not_object' } }, + ], + children => $children_errors, + }, + } + } +}; + +sub index { + my $c = shift; + + return if $c->validation_error('index'); + + my $json = $c->stash->{api_json}; + + # Extra custom error, because its funny + if ( $json->{north_east}->{latitude} < $json->{south_west}->{latitude} ) { + return $c->render( + json => { + success => Mojo::JSON->false, + errors => [ 'upside_down' ], + }, + status => 400, + ); + } + + my $entity = $c->stash->{api_user}->entity; + my $entity_type_object = $entity->type_object; + + # need: organisations only, with name, latitude, and longitude + my $org_rs = $entity->purchases->search_related('seller', + { + 'seller.type' => 'organisation', + 'organisation.latitude' => { -between => [ + $json->{south_west}->{latitude}, + $json->{north_east}->{latitude}, + ] }, + 'organisation.longitude' => { -between => [ + $json->{south_west}->{longitude}, + $json->{north_east}->{longitude}, + ] }, + }, + { + join => [ qw/ organisation / ], + columns => [ + 'organisation.name', + 'organisation.latitude', + 'organisation.longitude', + ], + group_by => [ qw/ organisation.id / ], + }, + ); + + $org_rs->result_class('DBIx::Class::ResultClass::HashRefInflator'); + + my $suppliers = [ map { + { + latitude => $_->{organisation}->{latitude} * 1, + longitude => $_->{organisation}->{longitude} * 1, + name => $_->{organisation}->{name}, + } + } $org_rs->all ]; + + $c->render( + json => { + success => Mojo::JSON->true, + suppliers => $suppliers, + self => { + latitude => $entity_type_object->latitude, + longitude => $entity_type_object->longitude, + } + }, + ); +} + +1; diff --git a/lib/Pear/LocalLoop/Plugin/Validators.pm b/lib/Pear/LocalLoop/Plugin/Validators.pm index bab1863..c1fa9f6 100644 --- a/lib/Pear/LocalLoop/Plugin/Validators.pm +++ b/lib/Pear/LocalLoop/Plugin/Validators.pm @@ -64,6 +64,115 @@ sub register { $value = $app->parse_iso_datetime( $value ); return defined $value ? undef : 1; }); + + $app->validator->add_check( is_object => sub { + my ( $validation, $name, $value ) = @_; + return ref ( $value ) eq 'HASH' ? undef : 1; + }); + + $app->validator->add_check( in_range => sub { + my ( $validation, $name, $value, $low, $high ) = @_; + return $low < $value && $value < $high ? undef : 1; + }); + + $app->helper( validation_error => sub { _validation_error(@_) } ); +} + +=head2 validation_error + +Returns undef if there is no validation error, returns true otherwise - having +set the errors up as required. Renders out the errors as an array, with status +400 + +=cut + +sub _validation_error { + my ( $c, $sub_name ) = @_; + + my $val_data = $c->validation_data->{ $sub_name }; + return unless defined $val_data; + my $data = $c->stash->{api_json}; + + my @errors = _validate_set( $c, $val_data, $data ); + + if ( scalar @errors ) { + my @sorted_errors = sort @errors; + $c->render( + json => { + success => Mojo::JSON->false, + errors => \@sorted_errors, + }, + status => 400, + ); + return \@errors; + } + + return; +} + +sub _validate_set { + my ( $c, $val_data, $data, $parent_name ) = @_; + + my @errors; + + # MUST get a raw validation object + my $validation = $c->app->validator->validation; + $validation->input( $data ); + + for my $val_data_key ( keys %$val_data ) { + + $validation->topic( $val_data_key ); + + my $val_set = $val_data->{$val_data_key}; + + my $custom_check_prefix = {}; + + for my $val_error ( @{$val_set->{validation}} ) { + my ( $val_validator ) = keys %$val_error; + + unless ( + $validation->validator->checks->{$val_validator} + || $val_validator =~ /required|optional/ + ) { + $c->app->log->warn( 'Unknown Validator [' . $val_validator . ']' ); + next; + } + + if ( my $custom_prefix = $val_error->{ $val_validator }->{ error_prefix } ) { + $custom_check_prefix->{ $val_validator } = $custom_prefix; + } + my $val_args = $val_error->{ $val_validator }->{ args }; + + $validation->$val_validator( + ( $val_validator =~ /required|optional/ ? $val_data_key : () ), + ( defined $val_args ? @$val_args : () ) + ); + + # stop bothering checking if failed, validation stops after first failure + last if $validation->has_error( $val_data_key ); + } + + if ( $validation->has_error( $val_data_key ) ) { + my ( $check ) = @{ $validation->error( $val_data_key ) }; + my $error_prefix = defined $custom_check_prefix->{ $check } + ? $custom_check_prefix->{ $check } + : $check; + my $error_string = join ('_', + $error_prefix, + ( defined $parent_name ? $parent_name : () ), + $val_data_key, + ); + push @errors, $error_string; + } elsif ( defined $val_set->{ children } ) { + push @errors, _validate_set( + $c, + $val_set->{ children }, + $data->{ $val_data_key }, + $val_data_key ); + } + } + + return @errors; } 1; diff --git a/lib/Pear/LocalLoop/Schema/Result/Entity.pm b/lib/Pear/LocalLoop/Schema/Result/Entity.pm index 0321049..aa3df1b 100644 --- a/lib/Pear/LocalLoop/Schema/Result/Entity.pm +++ b/lib/Pear/LocalLoop/Schema/Result/Entity.pm @@ -63,4 +63,16 @@ sub name { } } +sub type_object { + my $self = shift; + + if ( $self->type eq 'customer' ) { + return $self->customer; + } elsif ( $self->type eq 'organisation' ) { + return $self->organisation; + } else { + return; + } +} + 1; diff --git a/t/api/v1/supplier/location.t b/t/api/v1/supplier/location.t new file mode 100644 index 0000000..7b7794a --- /dev/null +++ b/t/api/v1/supplier/location.t @@ -0,0 +1,59 @@ +use Mojo::Base -strict; + +use FindBin qw/ $Bin /; + +use Test::More; +use Mojo::JSON; +use Test::Pear::LocalLoop; +use DateTime; + +my $framework = Test::Pear::LocalLoop->new( + etc_dir => "$Bin/../../../etc", +); +$framework->install_fixtures('full'); + +my $t = $framework->framework; +my $schema = $t->app->schema; + +my $session_key = $framework->login({ + email => 'org1@example.com', + password => 'abc123', +}); + +$t->post_ok('/api/upload' => json => { + transaction_value => 10, + transaction_type => 1, + purchase_time => "2017-08-14T11:29:07.965+01:00", + organisation_id => 2, + session_key => $session_key, + }) + ->status_is(200) + ->json_is('/success', Mojo::JSON->true); + +# Rough area around Lancaster +$t->post_ok('/api/v1/supplier/location' => json => { + session_key => $session_key, + north_east => { + latitude => 54.077665, + longitude => -2.761860, + }, + south_west => { + latitude => 54.013361, + longitude => -2.857647, + }, + }) + ->status_is(200)->or($framework->dump_error) + ->json_is('/success', Mojo::JSON->true) + ->json_is('/suppliers', [ + { + name => 'Test Org 2', + latitude => 54.04679, + longitude => -2.7963, + }, + ]) + ->json_is('/self', { + latitude => 54.04725, + longitude => -2.79611, + }); + +done_testing; diff --git a/t/api/v1/supplier/location_errors.t b/t/api/v1/supplier/location_errors.t new file mode 100644 index 0000000..8313727 --- /dev/null +++ b/t/api/v1/supplier/location_errors.t @@ -0,0 +1,117 @@ +use Mojo::Base -strict; + +use FindBin qw/ $Bin /; + +use Test::More; +use Mojo::JSON; +use Test::Pear::LocalLoop; +use DateTime; + +my $framework = Test::Pear::LocalLoop->new( + etc_dir => "$Bin/../../../etc", +); +$framework->install_fixtures('full'); + +my $t = $framework->framework; +my $schema = $t->app->schema; + +my $session_key = $framework->login({ + email => 'org1@example.com', + password => 'abc123', +}); + +$t->post_ok('/api/v1/supplier/location' => json => { + session_key => $session_key, + }) + ->status_is(400)->or($framework->dump_error) + ->json_is('/success', Mojo::JSON->false) + ->json_is('/errors', [ + 'required_north_east', + 'required_south_west', + ]); + +$t->post_ok('/api/v1/supplier/location' => json => { + session_key => $session_key, + north_east => 'banana', + south_west => 'apple', + }) + ->status_is(400)->or($framework->dump_error) + ->json_is('/success', Mojo::JSON->false) + ->json_is('/errors', [ + 'not_object_north_east', + 'not_object_south_west', + ]); + +$t->post_ok('/api/v1/supplier/location' => json => { + session_key => $session_key, + north_east => {}, + south_west => {}, + }) + ->status_is(400)->or($framework->dump_error) + ->json_is('/success', Mojo::JSON->false) + ->json_is('/errors', [ + 'required_north_east_latitude', + 'required_north_east_longitude', + 'required_south_west_latitude', + 'required_south_west_longitude', + ]); + +$t->post_ok('/api/v1/supplier/location' => json => { + session_key => $session_key, + north_east => { + latitude => 'banana', + longitude => 'apple', + }, + south_west => { + latitude => 'grapefruit', + longitude => 'orange', + }, + }) + ->status_is(400)->or($framework->dump_error) + ->json_is('/success', Mojo::JSON->false) + ->json_is('/errors', [ + 'not_number_north_east_latitude', + 'not_number_north_east_longitude', + 'not_number_south_west_latitude', + 'not_number_south_west_longitude', + ]); + +$t->post_ok('/api/v1/supplier/location' => json => { + session_key => $session_key, + north_east => { + latitude => 90.00001, + longitude => 180.00001, + }, + south_west => { + latitude => -90.00001, + longitude => -180.00001, + }, + }) + ->status_is(400)->or($framework->dump_error) + ->json_is('/success', Mojo::JSON->false) + ->json_is('/errors', [ + 'outside_range_north_east_latitude', + 'outside_range_north_east_longitude', + 'outside_range_south_west_latitude', + 'outside_range_south_west_longitude', + ]); + +# upside down when NeLat < SwLat +$t->post_ok('/api/v1/supplier/location' => json => { + session_key => $session_key, + north_east => { + latitude => -89, + longitude => 170, + }, + south_west => { + latitude => 89, + longitude => -170, + }, + }) + ->status_is(400)->or($framework->dump_error) + ->json_is('/success', Mojo::JSON->false) + ->json_is('/errors', [ + 'upside_down', + ])->or($framework->dump_error); + +done_testing;