You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
385 lines
13 KiB
385 lines
13 KiB
package NGCP::Panel::Role::API;
|
|
use Moose::Role;
|
|
use Sipwise::Base;
|
|
|
|
use JSON qw();
|
|
use JSON::Pointer;
|
|
use HTTP::Status qw(:constants);
|
|
use Safe::Isa qw($_isa);
|
|
use Try::Tiny;
|
|
use DateTime::Format::HTTP qw();
|
|
use DateTime::Format::RFC3339 qw();
|
|
use Types::Standard qw(InstanceOf);
|
|
use Regexp::Common qw(delimited); # $RE{delimited}
|
|
use HTTP::Headers::Util qw(split_header_words);
|
|
use NGCP::Panel::Utils::ValidateJSON qw();
|
|
|
|
has('last_modified', is => 'rw', isa => InstanceOf['DateTime']);
|
|
|
|
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 = $c->stash->{body};
|
|
return unless $self->require_wellformed_json($c, $media_type, $json);
|
|
|
|
return JSON::from_json($json);
|
|
}
|
|
|
|
sub get_valid_put_data {
|
|
my ($self, %params) = @_;
|
|
|
|
my $c = $params{c};
|
|
my $media_type = $params{media_type};
|
|
my $id = $params{id};
|
|
|
|
return unless $self->valid_id($c, $id);
|
|
return unless $self->forbid_link_header($c);
|
|
return unless $self->valid_media_type($c, $media_type);
|
|
return unless $self->require_precondition($c, 'If-Match');
|
|
return unless $self->require_body($c);
|
|
my $json = $c->stash->{body};
|
|
return unless $self->require_wellformed_json($c, $media_type, $json);
|
|
|
|
return JSON::from_json($json);
|
|
}
|
|
|
|
sub get_valid_patch_data {
|
|
my ($self, %params) = @_;
|
|
|
|
my $c = $params{c};
|
|
my $media_type = $params{media_type};
|
|
my $id = $params{id};
|
|
|
|
return unless $self->valid_id($c, $id);
|
|
return unless $self->forbid_link_header($c);
|
|
return unless $self->valid_media_type($c, $media_type);
|
|
return unless $self->require_body($c);
|
|
my $json = $c->stash->{body};
|
|
return unless $self->require_wellformed_json($c, $media_type, $json);
|
|
return unless $self->require_valid_patch($c, $json);
|
|
|
|
return $json;
|
|
}
|
|
|
|
sub validate_form {
|
|
my ($self, %params) = @_;
|
|
|
|
my $c = $params{c};
|
|
my $resource = $params{resource};
|
|
my $form = $params{form};
|
|
my $run = $params{run} // 1;
|
|
|
|
my @normalized = ();
|
|
|
|
# move {xxx_id} into {xxx}{id} for FormHandler
|
|
foreach my $key(keys %{ $resource } ) {
|
|
if($key =~ /^([a-z]+)_id$/) {
|
|
$c->log->debug("++++++++++++ moving key $key with value " . ($resource->{$key} // '<null>') . " to $1/id");
|
|
push @normalized, $1;
|
|
$resource->{$1}{id} = delete $resource->{$key};
|
|
}
|
|
}
|
|
|
|
# remove unknown keys
|
|
my %fields = map { $_->name => undef } $form->fields;
|
|
for my $k (keys %{ $resource }) {
|
|
unless(exists $fields{$k}) {
|
|
$c->log->debug("+++++++++ 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');
|
|
$resource->{$k} = $resource->{$k} + 0
|
|
if($form->field($k)->$_isa('HTML::FormHandler::Field::Integer') ||
|
|
$form->field($k)->$_isa('HTML::FormHandler::Field::Float'));
|
|
}
|
|
|
|
if($run) {
|
|
# check keys/vals
|
|
my $result = $form->run(params => $resource);
|
|
if ($result->error_results->size) {
|
|
my $f = $result->error_results->[0];
|
|
my $e = $result->error_results->map(sub {
|
|
sprintf 'field=\'%s\', input=\'%s\', errors=\'%s\'', ($_->parent->$_isa('HTML::FormHandler::Field::Result') ? $_->parent->name . '_' : '') . $_->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) {
|
|
next unless(exists $resource->{$key});
|
|
$resource->{$key . '_id'} = defined($resource->{$key}{id}) ?
|
|
int($resource->{$key}{id}) :
|
|
$resource->{$key}{id};
|
|
delete $resource->{$key};
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
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 length $c->stash->{body};
|
|
$self->error($c, HTTP_BAD_REQUEST, "This request is missing a message body.");
|
|
return;
|
|
}
|
|
|
|
sub require_precondition {
|
|
my ($self, $c, $header_name) = @_;
|
|
return 1 if $c->request->header($header_name);
|
|
$self->error($c, HTTP_PRECONDITION_REQUIRED, "This request is required to be conditional, use the '$header_name' header.");
|
|
return;
|
|
}
|
|
|
|
sub valid_precondition {
|
|
my ($self, $c, $etag, $entity_name) = @_;
|
|
my $if_match = $c->request->header('If-Match');
|
|
return 1 if '*' eq $if_match || grep {$etag eq $_} Data::Record->new({
|
|
split => qr/\s*,\s*/, unless => $RE{delimited}{-delim => q(")},
|
|
})->records($if_match);
|
|
$self->error($c, HTTP_PRECONDITION_FAILED, "This '$entity_name' entity cannot be found, it is either expired or does not exist. Fetch a fresh one.");
|
|
return;
|
|
}
|
|
|
|
sub require_preference {
|
|
my ($self, $c) = @_;
|
|
my @preference = grep { 'return' eq $_->[0] } split_header_words($c->request->header('Prefer'));
|
|
return $preference[0][1]
|
|
if 1 == @preference && ('minimal' eq $preference[0][1] || 'representation' eq $preference[0][1]);
|
|
$self->error($c, HTTP_BAD_REQUEST, "This request is required to express an expectation about the response. Use the 'Prefer' header with either 'return=representation' or 'return='minimal' preference.");
|
|
return;
|
|
}
|
|
|
|
sub require_wellformed_json {
|
|
my ($self, $c, $media_type, $patch) = @_;
|
|
try {
|
|
NGCP::Panel::Utils::ValidateJSON->new($patch);
|
|
} catch {
|
|
$self->error($c, HTTP_BAD_REQUEST, "The entity is not a well-formed '$media_type' document. $_");
|
|
return;
|
|
};
|
|
return 1;
|
|
}
|
|
|
|
=pod
|
|
# don't use caching for now, keep it as simple as possible
|
|
sub cached {
|
|
my ($self, $c) = @_;
|
|
my $response = $c->cache->get($c->request->uri->canonical->as_string);
|
|
unless ($response) {
|
|
$c->log->info('not cached');
|
|
return;
|
|
}
|
|
my $matched_tag = $c->request->header('If-None-Match') && ('*' eq $c->request->header('If-None-Match'))
|
|
|| (grep {$response->header('ETag') eq $_} Data::Record->new({
|
|
split => qr/\s*,\s*/, unless => $RE{delimited}{-delim => q(")},
|
|
})->records($c->request->header('If-None-Match')));
|
|
my $not_modified = $c->request->header('If-Modified-Since')
|
|
&& !($self->last_modified < DateTime::Format::HTTP->parse_datetime($c->request->header('If-Modified-Since')));
|
|
if (
|
|
$matched_tag && $not_modified
|
|
|| $matched_tag
|
|
|| $not_modified
|
|
) {
|
|
$c->response->status(HTTP_NOT_MODIFIED);
|
|
$c->response->headers($response->headers);
|
|
$c->log->info('cached');
|
|
return 1;
|
|
}
|
|
$c->log->info('stale');
|
|
return;
|
|
}
|
|
|
|
sub etag {
|
|
my ($self, $octets) = @_;
|
|
return sprintf '"ni:/sha3-256;%s"', sha3_256_base64($octets);
|
|
}
|
|
|
|
sub expires {
|
|
my ($self) = @_;
|
|
return DateTime->now->clone->add(years => 1); # XXX insert product end-of-life
|
|
}
|
|
=cut
|
|
|
|
sub allowed_methods {
|
|
my ($self) = @_;
|
|
my $meta = $self->meta;
|
|
my @allow;
|
|
for my $method ($meta->get_method_list) {
|
|
push @allow, $meta->get_method($method)->name
|
|
if $meta->get_method($method)->can('attributes') && 'Allow' ~~ $meta->get_method($method)->attributes;
|
|
}
|
|
return [sort @allow];
|
|
}
|
|
|
|
sub valid_id {
|
|
my ($self, $c, $id) = @_;
|
|
return 1 if $id->is_integer;
|
|
$self->error($c, HTTP_BAD_REQUEST, "Invalid id in request URI");
|
|
return;
|
|
}
|
|
|
|
sub require_valid_patch {
|
|
my ($self, $c, $json) = @_;
|
|
|
|
my $valid_ops = {
|
|
'test' => { 'path' => 1, 'value' => 1 },
|
|
'remove' => { 'path' => 1 },
|
|
'add' => { 'path' => 1, 'value' => 1 },
|
|
'replace' => { 'path' => 1, 'value' => 1 },
|
|
'move' => { 'from' => 1, 'path' => 1 },
|
|
'copy' => { 'from' => 1, 'path' => 1 },
|
|
};
|
|
|
|
my $patch = JSON::from_json($json);
|
|
unless(ref $patch eq "ARRAY") {
|
|
$self->error($c, HTTP_BAD_REQUEST, "Body for PATCH must be an array.");
|
|
return;
|
|
}
|
|
foreach my $elem(@{ $patch }) {
|
|
unless(ref $elem eq "HASH") {
|
|
$self->error($c, HTTP_BAD_REQUEST, "Array in body of PATCH must only contain hashes.");
|
|
return;
|
|
}
|
|
unless(exists $elem->{op}) {
|
|
$self->error($c, HTTP_BAD_REQUEST, "PATCH element must have an 'op' field.");
|
|
return;
|
|
}
|
|
unless(exists $valid_ops->{$elem->{op}}) {
|
|
$self->error($c, HTTP_BAD_REQUEST, "Invalid PATCH op '$elem->{op}', must be one of " . (join(', ', map { "'".$_."'" } keys %{ $valid_ops }) ));
|
|
return;
|
|
}
|
|
my $tmpelem = { %{ $elem } };
|
|
my $tmpops = { %{ $valid_ops } };
|
|
my $op = delete $tmpelem->{op};
|
|
foreach my $k(keys %{ $tmpelem }) {
|
|
unless(exists $tmpops->{$op}->{$k}) {
|
|
$self->error($c, HTTP_BAD_REQUEST, "Invalid PATCH key '$k' for op '$op', must be one of " . (join(', ', map { "'".$_."'" } keys %{ $valid_ops->{$op} }) ));
|
|
return;
|
|
}
|
|
delete $tmpops->{$op}->{$k};
|
|
}
|
|
if(keys %{ $tmpops->{$op} }) {
|
|
$self->error($c, HTTP_BAD_REQUEST, "Missing PATCH keys ". (join(', ', map { "'".$_."'" } keys %{ $tmpops->{$op} }) ) . " for op '$op'");
|
|
return;
|
|
}
|
|
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
sub resource_exists {
|
|
my ($self, $c, $entity_name, $resource) = @_;
|
|
return 1 if $resource;
|
|
$self->error($c, HTTP_NOT_FOUND, "Entity '$entity_name' not found.");
|
|
return;
|
|
}
|
|
|
|
sub apply_patch {
|
|
my ($self, $c, $entity, $json) = @_;
|
|
my $patch = JSON::decode_json($json);
|
|
for my $op (@{ $patch }) {
|
|
my $coderef = JSON::Pointer->can($op->{op});
|
|
die "invalid op '".$op->{op}."' despite schema validation" unless $coderef;
|
|
try {
|
|
for ($op->{op}) {
|
|
if ('add' eq $_ or 'replace' eq $_) {
|
|
$entity = $coderef->('JSON::Pointer', $entity, $op->{path}, $op->{value});
|
|
} elsif ('remove' eq $_) {
|
|
$entity = $coderef->('JSON::Pointer', $entity, $op->{path});
|
|
} elsif ('move' eq $_ or 'copy' eq $_) {
|
|
$entity = $coderef->('JSON::Pointer', $entity, $op->{from}, $op->{path});
|
|
} elsif ('test' eq $_) {
|
|
die "test failed - path: $op->{path} value: $op->{value}\n"
|
|
unless $coderef->('JSON::Pointer', $entity, $op->{path}, $op->{value});
|
|
}
|
|
}
|
|
} catch {
|
|
$self->error($c, HTTP_UNPROCESSABLE_ENTITY, "The entity could not be processed: $_");
|
|
return;
|
|
};
|
|
}
|
|
return $entity;
|
|
}
|
|
|
|
sub set_body {
|
|
my ($self, $c) = @_;
|
|
$c->stash->{body} = $c->request->body ? (do { local $/; $c->request->body->getline }) : '';
|
|
}
|
|
|
|
sub log_request {
|
|
my ($self, $c) = @_;
|
|
|
|
my $params = join(', ', map { "'".$_."'='".($c->request->query_params->{$_} // '')."'" }
|
|
keys %{ $c->request->query_params }
|
|
);
|
|
my $user;
|
|
if($c->user->roles eq "api_admin" || $c->user->roles eq "api_reseller") {
|
|
$user = $c->user->login;
|
|
} else {
|
|
$user = $c->user->username . '@' . $c->user->domain;
|
|
}
|
|
|
|
$c->log->info("API function '".$c->request->path."' called by '" . $user .
|
|
"' ('" . $c->user->roles . "') from host '".$c->request->address."' with params " .
|
|
(length $params ? $params : "''") .
|
|
" and body '" . $c->stash->{body} . "'");
|
|
}
|
|
|
|
sub log_response {
|
|
my ($self, $c) = @_;
|
|
|
|
# TODO: should be put a UUID to stash in log_request and use it here to correlate
|
|
# req/res lines?
|
|
$c->forward(qw(Controller::Root render));
|
|
$c->response->content_type('')
|
|
if $c->response->content_type =~ qr'text/html'; # stupid RenderView getting in the way
|
|
if (@{ $c->error }) {
|
|
my $msg = join ', ', @{ $c->error };
|
|
$c->log->error($msg);
|
|
$self->error($c, HTTP_INTERNAL_SERVER_ERROR, "Internal Server Error");
|
|
$c->clear_errors;
|
|
}
|
|
$c->log->info("API function '".$c->request->path."' generated response with code '" .
|
|
$c->response->code . "' and body '" .
|
|
$c->response->body . "'");
|
|
}
|
|
|
|
1;
|
|
# vim: set tabstop=4 expandtab:
|