diff options
author | Hakim Cassimally <hakim@mysociety.org> | 2014-03-13 16:56:02 +0000 |
---|---|---|
committer | Hakim Cassimally <hakim@mysociety.org> | 2014-10-16 16:56:26 +0000 |
commit | d1fee928f02dbc30d3a38b746155ce5b12be4a1b (patch) | |
tree | 5e8bdccbd69863e69098b9aa900c1e71745f8eb5 /perllib/Open311/Endpoint.pm | |
parent | 592f4c0ba0f822b55bb242cb12768ce771599d09 (diff) |
Open311 Endpoint
Subsystems include
* ::Spark encoding conventions for xml/json
* ::Schema using Rx to validate form of inputs and outputs,
including validation for, e.g., dates and CSV as part of Open311
Handles following paths:
* Open311 attributes for Service Definition
http://wiki.open311.org/GeoReport_v2#GET_Service_Definition
* POST service request
* GET Service Requests
* GET Service Request
Objects:
* ::Service
* ::Service::Request
Diffstat (limited to 'perllib/Open311/Endpoint.pm')
-rw-r--r-- | perllib/Open311/Endpoint.pm | 773 |
1 files changed, 773 insertions, 0 deletions
diff --git a/perllib/Open311/Endpoint.pm b/perllib/Open311/Endpoint.pm new file mode 100644 index 000000000..8a440e4ae --- /dev/null +++ b/perllib/Open311/Endpoint.pm @@ -0,0 +1,773 @@ +package Open311::Endpoint; + +=head1 NAME + +Open311::Endpoint - a generic Open311 endpoint implementation + +=cut + +use Web::Simple; + +use JSON; +use XML::Simple; + +use Open311::Endpoint::Result; +use Open311::Endpoint::Service; +use Open311::Endpoint::Service::Request; +use Open311::Endpoint::Spark; +use Open311::Endpoint::Schema; + +use MooX::HandlesVia; + +use Data::Dumper; +use Scalar::Util 'blessed'; +use List::Util 'first'; +use Types::Standard ':all'; + +use DateTime::Format::W3CDTF; + +=head1 DESCRIPTION + +An implementation of L<http://wiki.open311.org/GeoReport_v2> with a +dispatcher written as a L<Plack> application, designed to be easily +deployed. + +This is a generic wrapper, designed to be a conformant Open311 server. +However, it knows nothing about your business logic! You should subclass it +and provide the necessary methods. + +=head1 SUBCLASSING + +See also t/open311/endpoint/Endpoint1.pm as an example. + +=head2 methods to override + +These are the important methods to override. They are passed a list of +simple arguments, and should generally return objects like +L<Open311::Endpoint::Request>. + + services + service + post_service_request + get_service_requests + get_service_request + requires_jurisdiction_ids + check_jurisdiction_id + +The dispatch framework will take care of actually formatting the output +into conformant XML or JSON. + +TODO document better + +=cut + +sub services { + # this should be overridden in your subclass! + (); +} +sub service { + # this stub implementation is a simple lookup on $self->services, and + # should *probably* be overridden in your subclass! + # (for example, to look up in App DB, with $args->{jurisdiction_id}) + + my ($self, $service_code, $args) = @_; + + return first { $_->service_code eq $service_code } $self->services; +} + +sub post_service_request { + my ($self, $service, $args) = @_; + + die "abstract method post_service_request not overridden"; +} + +sub get_service_requests { + my ($self, $args) = @_; + die "abstract method get_service_requests not overridden"; +} + +sub get_service_request { + my ($self, $service_request_id, $args) = @_; + + die "abstract method get_service_request not overridden"; +} + +sub requires_jurisdiction_ids { + # you may wish to subclass this + return shift->has_multiple_jurisdiction_ids; +} + +sub check_jurisdiction_id { + my ($self, $jurisdiction_id) = @_; + + # you may wish to override this stub implementation which: + # - always succeeds if no jurisdiction_id is set + # - accepts no jurisdiction_id if there is only one set + # - otherwise checks that the id passed is one of those set + # + return 1 unless $self->has_jurisdiction_ids; + + if (! defined $jurisdiction_id) { + return $self->requires_jurisdiction_ids ? 1 : undef; + } + + return first { $jurisdiction_id eq $_ } $self->get_jurisdiction_ids; +} + + + +=head2 Configurable arguments + + * default_service_notice - default for <service_notice> if not + set by the service or an individual request + * jurisdictions - an array of jurisdiction_ids + you may want to subclass the methods: + - requires_jurisdiction_ids + - check_jurisdiction_id + * default_identifier_type + Open311 doesn't mandate what these types look like, but a backend + server may! The module provides an example identifier type which allows + ascii "word" characters .e.g [a-zA-Z0-9_] as an example default. + You can also override these individually using: + + identifier_types => { + api_key => '//str', # + jurisdiction_id => ... + service_code => ... + service_request_id => ... + # etc. + } + +=cut + +has default_identifier_type => ( + is => 'ro', + isa => Str, + default => '/open311/example/identifier', +); + +has identifier_types => ( + is => 'ro', + isa => HashRef[Str], + default => sub { {} }, + handles_via => 'Hash', + handles => { + get_identifier_type => 'get', + }, +); + +around get_identifier_type => sub { + my ($orig, $self, $type) = @_; + return $self->$orig($type) // $self->default_identifier_type; +}; + +has default_service_notice => ( + is => 'ro', + isa => Maybe[Str], + predicate => 1, +); + +has jurisdiction_ids => ( + is => 'ro', + isa => Maybe[ArrayRef], + default => sub { [] }, + handles_via => 'Array', + handles => { + has_jurisdiction_ids => 'count', + get_jurisdiction_ids => 'elements', + } +); + +=head2 Other accessors + +You may additionally wish to replace the following objects. + + * schema - Data::Rx schema for validating Open311 protocol inputs and + outputs + * spark - methods for munging base data-structure for output + * json - JSON output object + * xml - XML::Simple output object + +=cut + +has schema => ( + is => 'lazy', + default => sub { + my $self = shift; + Open311::Endpoint::Schema->new( endpoint => $self ), + }, + handles => { + rx => 'schema', + format_boolean => 'format_boolean', + }, +); + +has spark => ( + is => 'lazy', + default => sub { + Open311::Endpoint::Spark->new(); + }, +); + +has json => ( + is => 'lazy', + default => sub { + JSON->new->pretty->allow_blessed->convert_blessed; + }, +); + +has xml => ( + is => 'lazy', + default => sub { + XML::Simple->new( + NoAttr=> 1, + KeepRoot => 1, + SuppressEmpty => 0, + ); + }, +); + +has w3_dt => ( + is => 'lazy', + default => sub { DateTime::Format::W3CDTF->new }, +); + +=head2 Dispatching + +The method dispatch_request returns a list of all the dispatcher routines +that will be checked in turn by L<Web::Simple>. + +You may extend this in a subclass, or with a role. + +=cut + +sub dispatch_request { + my $self = shift; + + sub (.*) { + my ($self, $ext) = @_; + $self->format_response($ext); + }, + + sub (GET + /services + ?*) { + my ($self, $args) = @_; + $self->call_api( GET_Service_List => $args ); + }, + + sub (GET + /services/* + ?*) { + my ($self, $service_id, $args) = @_; + $self->call_api( GET_Service_Definition => $service_id, $args ); + }, + + sub (POST + /requests + %*) { + my ($self, $args) = @_; + $self->call_api( POST_Service_Request => $args ); + }, + + sub (GET + /tokens/*) { + return Open311::Endpoint::Result->error( 400, 'not implemented' ); + }, + + sub (GET + /requests + ?*) { + my ($self, $args) = @_; + $self->call_api( GET_Service_Requests => $args ); + }, + + sub (GET + /requests/* + ?*) { + my ($self, $service_request_id, $args) = @_; + $self->call_api( GET_Service_Request => $service_request_id, $args ); + }, +} + +sub GET_Service_List_input_schema { + return shift->get_jurisdiction_id_validation; +} + +sub GET_Service_List_output_schema { + return { + type => '//rec', + required => { + services => { + type => '//arr', + contents => '/open311/service', + }, + } + }; +} + +sub GET_Service_List { + my ($self, @args) = @_; + + my @services = map { + my $service = $_; + { + keywords => (join ',' => @{ $service->keywords } ), + metadata => $self->format_boolean( $service->has_attributes ), + map { $_ => $service->$_ } + qw/ service_name service_code description type group /, + } + } $self->services; + return { + services => \@services, + }; +} + +sub GET_Service_Definition_input_schema { + my $self = shift; + return { + type => '//seq', + contents => [ + $self->get_identifier_type('service_code'), + $self->get_jurisdiction_id_validation, + ], + }; +} + +sub GET_Service_Definition_output_schema { + return { + type => '//rec', + required => { + service_definition => { + type => '/open311/service_definition', + }, + } + }; +} + +sub GET_Service_Definition { + my ($self, $service_id, $args) = @_; + + my $service = $self->service($service_id, $args) or return; + my $order = 0; + my $service_definition = { + service_definition => { + service_code => $service_id, + attributes => [ + map { + my $attribute = $_; + { + order => ++$order, + variable => $self->format_boolean( $attribute->variable ), + required => $self->format_boolean( $attribute->required ), + $attribute->has_values ? ( + values => [ + map { + my ($key, $name) = @$_; + +{ + key => $key, + name => $name, + } + } $attribute->values_kv + ]) : (), + map { $_ => $attribute->$_ } + qw/ code datatype datatype_description description /, + } + } $service->get_attributes, + ], + }, + }; + return $service_definition; +} + +sub POST_Service_Request_input_schema { + my ($self, $args) = @_; + + my $service_code = $args->{service_code}; + unless ($service_code && $args->{api_key}) { + # return a simple validator + # to give a nice error message + return { + type => '//rec', + required => { + service_code => $self->get_identifier_type('service_code'), + api_key => $self->get_identifier_type('api_key') }, + rest => '//any', + }; + } + + my $service = $self->service($service_code) + or return; # we can't fetch service, so signal error TODO + + my %attributes; + for my $attribute ($service->get_attributes) { + my $section = $attribute->required ? 'required' : 'optional'; + my $key = sprintf 'attribute[%s]', $attribute->code; + my $def = $attribute->schema_definition; + + $attributes{ $section }{ $key } = $def; + } + + # we have to supply at least one of these, but can supply more + my @address_options = ( + { lat => '//num', long => '//num' }, + { address_string => '//str' }, + { address_id => '//str' }, + ); + + my @address_schemas; + while (my $address_required = shift @address_options) { + push @address_schemas, + { + type => '//rec', + required => { + service_code => $self->get_identifier_type('service_code'), + api_key => $self->get_identifier_type('api_key'), + %{ $attributes{required} }, + %{ $address_required }, + $self->get_jurisdiction_id_required_clause, + }, + optional => { + email => '//str', + device_id => '//str', + account_id => '//str', + first_name => '//str', + last_name => '//str', + phone => '//str', + description => '//str', + media_url => '//str', + %{ $attributes{optional} }, + (map %$_, @address_options), + $self->get_jurisdiction_id_optional_clause, + }, + }; + } + + return { + type => '//any', + of => \@address_schemas, + }; +} + +sub POST_Service_Request_output_schema { + my ($self, $args) = @_; + + my $service_code = $args->{service_code}; + my $service = $self->service($service_code); + + my %return_schema = ( + ($service->type eq 'realtime') ? ( service_request_id => $self->get_identifier_type('service_request_id') ) : (), + ($service->type eq 'batch') ? ( token => '//str' ) : (), + ); + + return { + type => '//rec', + required => { + service_requests => { + type => '//arr', + contents => { + type => '//rec', + required => { + %return_schema, + }, + optional => { + service_notice => '//str', + account_id => '//str', + + }, + }, + }, + }, + }; +} + +sub POST_Service_Request { + my ($self, $args) = @_; + + # TODO pass this through instead of calculating again? + my $service_code = $args->{service_code}; + my $service = $self->service($service_code); + + my @service_requests = $self->post_service_request( $service, $args ); + + return { + service_requests => [ + map { + my $service_notice = + $_->service_notice + || $service->default_service_notice + || $self->default_service_notice; + +{ + ($service->type eq 'realtime') ? ( service_request_id => $_->service_request_id ) : (), + ($service->type eq 'batch') ? ( token => $_->token ) : (), + $service_notice ? ( service_notice => $service_notice ) : (), + $_->has_account_id ? ( account_id => $_->account_id ) : (), + } + } @service_requests, + ], + }; +} + +sub GET_Service_Requests_input_schema { + my $self = shift; + return { + type => '//rec', + required => { + $self->get_jurisdiction_id_required_clause, + }, + optional => { + $self->get_jurisdiction_id_optional_clause,, + service_request_id => { + type => '/open311/comma', + contents => $self->get_identifier_type('service_request_id'), + }, + service_code => { + type => '/open311/comma', + contents => $self->get_identifier_type('service_code'), + }, + start_date => '/open311/datetime', + end_date => '/open311/datetime', + status => { + type => '/open311/comma', + contents => '/open311/status', + }, + }, + }; +} + +sub GET_Service_Requests_output_schema { + my $self = shift; + return { + type => '//rec', + required => { + service_requests => { + type => '//arr', + contents => '/open311/service_request', + }, + }, + }; +} + +sub GET_Service_Requests { + my ($self, $args) = @_; + + my @service_requests = $self->get_service_requests({ + + jurisdiction_id => $args->{jurisdiction_id}, + start_date => $args->{start_date}, + end_date => $args->{end_date}, + + map { + $args->{$_} ? + ( $_ => [ split ',' => $args->{$_} ] ) + : () + } qw/ service_request_id service_code status /, + }); + + $self->format_service_requests(@service_requests); +} + +sub GET_Service_Request_input_schema { + my $self = shift; + return { + type => '//seq', + contents => [ + $self->get_identifier_type('service_request_id'), + { + type => '//rec', + required => { + $self->get_jurisdiction_id_required_clause, + }, + optional => { + $self->get_jurisdiction_id_optional_clause, + } + } + ], + }; +} + +sub GET_Service_Request_output_schema { + my $self = shift; + return { + type => '//rec', + required => { + service_requests => { + type => '//seq', # e.g. a single service_request + contents => [ + '/open311/service_request', + ] + }, + }, + }; +} + +sub GET_Service_Request { + my ($self, $service_request_id, $args) = @_; + + my $service_request = $self->get_service_request($service_request_id, $args); + + $self->format_service_requests($service_request); +} + +sub format_service_requests { + my ($self, @service_requests) = @_; + return { + service_requests => [ + map { + my $request = $_; + +{ + ( + map { + $_ => $request->$_, + } + qw/ + service_request_id + status + service_name + service_code + address + address_id + zipcode + lat + long + media_url + / + ), + ( + map { + $_ => $self->w3_dt->format_datetime( $request->$_ ), + } + qw/ + requested_datetime + updated_datetime + / + ), + ( + map { + my $value = $request->$_; + $value ? ( $_ => $value ) : (), + } + qw/ + description + agency_responsible + service_notice + / + ), + } + } @service_requests, + ], + }; +} + +sub has_multiple_jurisdiction_ids { + return shift->has_jurisdiction_ids > 1; +} + +sub get_jurisdiction_id_validation { + my $self = shift; + + # jurisdiction_id is documented as "Required", but with the note + # 'This is only required if the endpoint serves multiple jurisdictions' + # i.e. it is optional as regards the schema, but the server may choose + # to error if it is not provided. + return { + type => '//rec', + ($self->requires_jurisdiction_ids ? 'required' : 'optional') => { + jurisdiction_id => $self->get_identifier_type('jurisdiction_id'), + }, + }; +} + +sub get_jurisdiction_id_required_clause { + my $self = shift; + $self->requires_jurisdiction_ids ? (jurisdiction_id => $self->get_identifier_type('jurisdiction_id')) : (); +} + +sub get_jurisdiction_id_optional_clause { + my $self = shift; + $self->requires_jurisdiction_ids ? () : (jurisdiction_id => $self->get_identifier_type('jurisdiction_id')); +} + +sub call_api { + my ($self, $api_name, @args) = @_; + + my $api_method = $self->can($api_name) + or die "No such API $api_name!"; + + if (my $input_schema_method = $self->can("${api_name}_input_schema")) { + my $input_schema = $self->$input_schema_method(@args) + or return Open311::Endpoint::Result->error( 400, + 'Bad request' ); + + my $schema = $self->rx->make_schema( $input_schema ); + my $input = (scalar @args == 1) ? $args[0] : [@args]; + eval { + $schema->assert_valid( $input ); + }; + if ($@) { + return Open311::Endpoint::Result->error( 400, + "Error in input for $api_name", + split /\n/, $@, + # map $_->struct, @{ $@->failures }, # bit cheeky, spec suggests it wants strings only + ); + } + } + + my $data = eval { $self->$api_method(@args) } + or return Open311::Endpoint::Result->error( + $@ ? (500 => $@) : (404 => 'Resource not found') + ); + + if (my $output_schema_method = $self->can("${api_name}_output_schema")) { + my $definition = $self->$output_schema_method(@args); + my $schema = $self->rx->make_schema( $definition ); + eval { + $schema->assert_valid( $data ); + }; + if ($@) { + return Open311::Endpoint::Result->error( 500, + "Error in output for $api_name", + split /\n/, $@, + # map $_->struct, @{ $@->failures }, + ); + } + } + + return Open311::Endpoint::Result->success( $data ); +} + +sub format_response { + my ($self, $ext) = @_; + response_filter { + my $response = shift; + return $response unless blessed $response; + my $status = $response->status; + my $data = $response->data; + if ($ext eq 'json') { + return [ + $status, + [ 'Content-Type' => 'application/json' ], + [ $self->json->encode( + $self->spark->process_for_json( $data ) + )] + ]; + } + elsif ($ext eq 'xml') { + return [ + $status, + [ 'Content-Type' => 'text/xml' ], + [ qq(<?xml version="1.0" encoding="utf-8"?>\n), + $self->xml->XMLout( + $self->spark->process_for_xml( $data ) + )], + ]; + } + else { + return [ + 404, + [ 'Content-Type' => 'text/plain' ], + [ 'Bad extension. We support .xml and .json' ], + ] + } + } +} + +=head1 AUTHOR and LICENSE + + hakim@mysociety.org 2014 + +This is released under the same license as FixMyStreet. +see https://github.com/mysociety/fixmystreet/blob/master/LICENSE.txt + +=cut + +__PACKAGE__->run_if_script; |