diff --git a/lib/NGCP/Panel/Controller/API/Contacts.pm b/lib/NGCP/Panel/Controller/API/Contacts.pm index 788cf95872..a2832bb718 100644 --- a/lib/NGCP/Panel/Controller/API/Contacts.pm +++ b/lib/NGCP/Panel/Controller/API/Contacts.pm @@ -10,17 +10,8 @@ use DateTime::Format::RFC3339 qw(); use Digest::SHA3 qw(sha3_256_base64); use HTTP::Headers qw(); use HTTP::Headers::Util qw(split_header_words); -use HTTP::Status qw( - HTTP_BAD_REQUEST - HTTP_CREATED - HTTP_NOT_MODIFIED - HTTP_OK - HTTP_UNPROCESSABLE_ENTITY - HTTP_UNSUPPORTED_MEDIA_TYPE - HTTP_INTERNAL_SERVER_ERROR -); +use HTTP::Status qw(:constants); #use JE qw(); -use JSON qw(from_json); use MooseX::ClassAttribute qw(class_has); use NGCP::Panel::Form::Contact::Admin qw(); use NGCP::Panel::Form::Contact::Reseller qw(); @@ -37,6 +28,8 @@ require Catalyst::ActionRole::QueryParameter; require Catalyst::ActionRole::RequireSSL; require URI::QueryParam; +with 'NGCP::Panel::Role::API'; + class_has('dispatch_path', is => 'ro', default => '/api/contacts/'); class_has('relation', is => 'ro', default => 'http://purl.org/sipwise/ngcp-api/#rel-contacts'); has('last_modified', is => 'rw', isa => InstanceOf['DateTime']); @@ -125,52 +118,27 @@ sub OPTIONS : Allow { sub POST : Allow { my ($self, $c) = @_; - my $media_type = 'application/json'; + { - last unless $self->forbid_link_header($c); - last unless $self->valid_media_type($c, $media_type); - last unless $self->require_body($c); - my $json = do { local $/; $c->request->body->getline }; # slurp - last unless $self->require_wellformed_json($c, $media_type, $json); - #last unless $self->valid_entity($c, $json); + my $resource = $self->get_valid_post_data( + c => $c, + media_type => 'application/json', + ); + last unless $resource; my $contact_form; - my $resource = from_json($json); - $resource->{reseller}{id} = delete $resource->{reseller_id}; if($c->user->roles eq "api_admin") { - $c->log->debug("+++++++++++++++ using NGCP::Panel::Form::Contact::Admin for validation"); $contact_form = NGCP::Panel::Form::Contact::Admin->new; } else { $contact_form = NGCP::Panel::Form::Contact::Reseller->new; - $resource->{reseller}{id} = $c->user->reseller_id; - } - my %fields = map { $_->name => undef } $contact_form->fields; - for my $k (keys %{ $resource }) { - unless(exists $fields{$k}) { - $c->log->debug("+++++++++++++ deleting unknown key '$k'"); - delete $resource->{$k}; - } - $resource->{$k} = DateTime::Format::RFC3339->format_datetime($resource->{$k}) - if $resource->{$k}->$_isa('DateTime'); - } - - my $result = $contact_form->run(params => $resource); - if ($result->error_results->size) { - $c->response->status(HTTP_UNPROCESSABLE_ENTITY); - $c->response->header('Content-Language' => 'en'); - # TODO: return error in json! - $c->response->content_type('application/xhtml+xml'); - my $e = $result->error_results->map(sub { - sprintf 'field: \'%s\', input: \'%s\', errors: %s', $_->name, $_->input // '', $_->errors->join(q()) - })->join("\n"); - $c->stash( - template => 'api/unprocessable_entity.tt', - error_message => "Validation failed: $e", - ); - last; + $resource->{reseller_id} = $c->user->reseller_id; } + last unless $self->validate_form( + c => $c, + resource => $resource, + form => $contact_form, + ); - $resource->{reseller_id} = $resource->{reseller}{id}; delete $resource->{reseller}; my $now = DateTime->now; $resource->{create_timestamp} = $now; $resource->{modify_timestamp} = $now; @@ -178,13 +146,8 @@ sub POST : Allow { try { $contact = $c->model('DB')->resultset('contacts')->create($resource); } catch($e) { - $c->log->error("failed to create contact: $e"); # TODO: log user, input etc - $c->response->status(HTTP_INTERNAL_SERVER_ERROR); - # TODO: that one is not rendered, rather than our "normal" 500 template! - $c->stash( - template => 'api/internal_server_error.tt', - error_message => "DB query faild: $e", - ); + $c->log->error("failed to create contact: $e"); # TODO: user, message, trace, ... + $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "failed to create contact"); last; } @@ -244,16 +207,6 @@ sub expires : Private { return DateTime->now->clone->add(years => 1); # XXX insert product end-of-life } -sub forbid_link_header : Private { - my ($self, $c) = @_; - return 1 unless $c->request->header('Link'); - $c->response->status(HTTP_BAD_REQUEST); - $c->response->header('Content-Language' => 'en'); - $c->response->content_type('application/xhtml+xml'); - $c->stash(template => 'api/forbid_link_header.tt'); - return; -} - sub hal_from_contact : Private { my ($self, $contact) = @_; # XXX invalid 00-00-00 dates @@ -291,30 +244,6 @@ sub hal_from_contact : Private { return $hal; } -sub require_body : Private { - my ($self, $c) = @_; - return 1 if $c->request->body; - $c->response->status(HTTP_BAD_REQUEST); - $c->response->header('Content-Language' => 'en'); - $c->response->content_type('application/xhtml+xml'); - $c->stash(template => 'api/require_body.tt'); - return; -} - -sub require_wellformed_json : Private { - my ($self, $c, $media_type, $patch) = @_; - try { - NGCP::Panel::ValidateJSON->new($patch); - } catch($e) { - $c->response->status(HTTP_BAD_REQUEST); - $c->response->header('Content-Language' => 'en'); - $c->response->content_type('application/xhtml+xml'); - $c->stash(template => 'api/valid_entity.tt', media_type => $media_type, error_message => $e); - return; - }; - return 1; -} - sub valid_id : Private { my ($self, $c, $id) = @_; return 1 if $id->is_integer; @@ -325,73 +254,16 @@ sub valid_id : Private { return; } -sub valid_media_type : Private { - my ($self, $c, $media_type) = @_; - return 1 if $c->request->header('Content-Type') && 0 == index $c->request->header('Content-Type'), $media_type; - $c->response->status(HTTP_UNSUPPORTED_MEDIA_TYPE); - $c->response->header('Content-Language' => 'en'); - $c->response->content_type('application/xhtml+xml'); - $c->stash(template => 'api/valid_media_type.tt', media_type => $media_type); - return; -} -=pod -sub valid_entity : Private { - my ($self, $c, $entity) = @_; - my $js - = path($c->path_to(qw(share static js api tv4.js)))->slurp - . "\nvar schema = " - . path($c->path_to(qw(share static js api properties contacts-item.json)))->slurp - . ";\nvar data = " - . $entity - . ";\ntv4.validate(data, schema);"; - my $je = JE->new; - unless ($je->eval($js)) { - die "generic JavaScript error: $@" if $@; - $c->response->status(HTTP_UNPROCESSABLE_ENTITY); - $c->response->header('Content-Language' => 'en'); - $c->response->content_type('application/xhtml+xml'); - $c->stash( - template => 'api/unprocessable_entity.tt', - error_message => JSON::to_json( - { map { $_ => $je->{tv4}{error}{$_}->value } qw(dataPath message schemaPath) }, - { canonical => 1, pretty => 1, } - ) - ); - return; - } - return 1; -} -=cut - sub end : Private { my ($self, $c) = @_; $c->forward(qw(Controller::Root render)); $c->response->content_type('') if $c->response->content_type =~ qr'text/html'; # stupid RenderView getting in the way -use Carp qw(longmess); use DateTime::Format::RFC3339 qw(); use Data::Dumper qw(Dumper); use Convert::Ascii85 qw(); if (@{ $c->error }) { - my $incident = DateTime->from_epoch(epoch => Time::HiRes::time); - my $incident_id = sprintf '%X', $incident->strftime('%s%N'); - my $incident_timestamp = DateTime::Format::RFC3339->new->format_datetime($incident); - local $Data::Dumper::Indent = 1; - local $Data::Dumper::Useqq = 1; - local $Data::Dumper::Deparse = 1; - local $Data::Dumper::Quotekeys = 0; - local $Data::Dumper::Sortkeys = 1; - my $crash_state = join "\n", @{ $c->error }, longmess, Dumper($c), Dumper($c->config); - $c->log->error( - "Exception id $incident_id at $incident_timestamp crash_state:" . - ($crash_state ? ("\n" . $crash_state) : ' disabled') - ); + my $msg = join ', ', @{ $c->error }; + $c->log->error($msg); + $self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error"); $c->clear_errors; - $c->stash( - exception_incident => $incident_id, - exception_timestamp => $incident_timestamp, - template => 'api/internal_server_error.tt' - ); - $c->response->status(500); - $c->response->content_type('application/xhtml+xml'); - $c->detach($c->view); } } diff --git a/lib/NGCP/Panel/Role/API.pm b/lib/NGCP/Panel/Role/API.pm new file mode 100644 index 0000000000..351ffc8a27 --- /dev/null +++ b/lib/NGCP/Panel/Role/API.pm @@ -0,0 +1,122 @@ +package NGCP::Panel::Role::API; +use Moose::Role; +use Sipwise::Base; + +use JSON qw(); +use HTTP::Status qw(:constants); +use Safe::Isa qw($_isa); +use Try::Tiny; + +sub get_valid_post_data { + my ($self, %params) = @_; + + my $c = $params{c}; + my $media_type = $params{media_type}; + + return unless $self->forbid_link_header($c); + return unless $self->valid_media_type($c, $media_type); + return unless $self->require_body($c); + my $json = do { local $/; $c->request->body->getline }; # slurp + return unless $self->require_wellformed_json($c, $media_type, $json); + + return JSON::from_json($json); +} + +sub validate_form { + my ($self, %params) = @_; + + my $c = $params{c}; + my $resource = $params{resource}; + my $form = $params{form}; + + my @normalized = (); + + # move {xxx_id} into {xxx}{id} for FormHandler + foreach my $key(keys %{ $resource } ) { + if($key =~ /^([a-z]+)_id$/) { + push @normalized, $1; + $resource->{$1}{id} = delete $resource->{$key}; + } + } + + use Data::Printer; p $resource; + + # remove unknown keys + my %fields = map { $_->name => undef } $form->fields; + for my $k (keys %{ $resource }) { + unless(exists $fields{$k}) { + $c->log->info("deleting unknown key '$k' from message"); # TODO: user, message trace, ... + delete $resource->{$k}; + } + $resource->{$k} = DateTime::Format::RFC3339->format_datetime($resource->{$k}) + if $resource->{$k}->$_isa('DateTime'); + } + + # check keys/vals + my $result = $form->run(params => $resource); + if ($result->error_results->size) { + my $e = $result->error_results->map(sub { + sprintf 'field=\'%s\', input=\'%s\', errors=\'%s\'', $_->name, $_->input // '', $_->errors->join(q()) + })->join("; "); + $self->error($c, HTTP_UNPROCESSABLE_ENTITY, "Validation failed. $e"); + return; + } + + # move {xxx}{id} back into {xxx_id} for DB + foreach my $key(@normalized) { + $resource->{$key . '_id'} = $resource->{$key}{id}; + delete $resource->{$key}; + } + + return 1; +} + +# private + +sub error { + my ($self, $c, $code, $message) = @_; + + $c->log->error("error $code - $message"); # TODO: user, trace etc + + $c->response->content_type('application/json'); + $c->response->status($code); + $c->response->body(JSON::to_json({ code => $code, message => $message })."\n"); +} + +sub forbid_link_header { + my ($self, $c) = @_; + return 1 unless $c->request->header('Link'); + $self->error($c, HTTP_BAD_REQUEST, "The request must not contain 'Link' headers. Instead assert relationships in the entity body."); + return; +} + +sub valid_media_type { + my ($self, $c, $media_type) = @_; + return 1 if($c->request->header('Content-Type') && + index($c->request->header('Content-Type'), $media_type) == 0); + $self->error($c, HTTP_UNSUPPORTED_MEDIA_TYPE, "Unsupported media type, accepting '$media_type' only."); + return; +} + +sub require_body { + my ($self, $c) = @_; + return 1 if $c->request->body; + $self->error($c, HTTP_BAD_REQUEST, "This request is missing a message body."); + return; +} + +sub require_wellformed_json { + my ($self, $c, $media_type, $patch) = @_; + try { + NGCP::Panel::ValidateJSON->new($patch); + } catch { + $self->error($c, HTTP_BAD_REQUEST, "The entity is not a well-formed '$media_type' document. $_"); + return; + }; + return 1; +} + + + +1; +# vim: set tabstop=4 expandtab: diff --git a/share/templates/api/forbid_link_header.tt b/share/templates/api/forbid_link_header.tt deleted file mode 100644 index b4e7e38980..0000000000 --- a/share/templates/api/forbid_link_header.tt +++ /dev/null @@ -1,9 +0,0 @@ - - - - Bad request - - -

The request must not contain Link headers. Instead assert relationships in the entity body.

- - diff --git a/share/templates/api/internal_sever_error.tt b/share/templates/api/internal_sever_error.tt deleted file mode 100644 index d6169988a2..0000000000 --- a/share/templates/api/internal_sever_error.tt +++ /dev/null @@ -1,10 +0,0 @@ - - - - Internal Server Error - - -

An error occured during processing the request.

-

[% error_message | html %]

- - diff --git a/share/templates/api/unprocessable_entity.tt b/share/templates/api/unprocessable_entity.tt deleted file mode 100644 index 7b440f86dd..0000000000 --- a/share/templates/api/unprocessable_entity.tt +++ /dev/null @@ -1,10 +0,0 @@ - - - - Unprocessable entity - - -

The entity could not be processed.

-

[% error_message | html %]

- - diff --git a/share/templates/api/valid_entity.tt b/share/templates/api/valid_entity.tt deleted file mode 100644 index a7f1f647f5..0000000000 --- a/share/templates/api/valid_entity.tt +++ /dev/null @@ -1,10 +0,0 @@ - - - - Bad request - - -

The entity is not a well-formed [% media_type | html %] document.

-

[% error_message | html %]

- - diff --git a/share/templates/api/valid_media_type.tt b/share/templates/api/valid_media_type.tt deleted file mode 100644 index 3eb36783d8..0000000000 --- a/share/templates/api/valid_media_type.tt +++ /dev/null @@ -1,9 +0,0 @@ - - - - Unsupported media type - - -

Unsupported media type, accepting entities of type [% media_type | html %] only.

- -