ldieckow/rest
Lars Dieckow 12 years ago
parent f6e01fdbd6
commit e5a42d9306

@ -20,22 +20,32 @@ my $builder = Local::Module::Build->new(
'Capture::Tiny' => 0,
'Catalyst::Action::RenderView' => 0,
'Catalyst::ActionRole::ACL' => 0,
'Catalyst::ActionRole::CheckTrailingSlash' => 0,
'Catalyst::ActionRole::QueryParameter' => 0,
'Catalyst::ActionRole::RequireSSL' => 0,
'Catalyst::Model::DBIC::Schema' => 0,
'Catalyst::Plugin::ConfigLoader' => 0,
'Catalyst::Plugin::Static::Simple' => 0,
'Catalyst::Plugin::Authentication' => 0,
'Catalyst::Plugin::Authorization::Roles' => 0,
'Catalyst::Plugin::ConfigLoader' => 0,
'Catalyst::Plugin::EnableMiddleware' => 0,
'Catalyst::Plugin::Session' => 0,
'Catalyst::Plugin::Session::Store::FastMmap' => 0,
'Catalyst::Plugin::Session::State::Cookie' => 0,
'Catalyst::Plugin::Static::Simple' => 0,
'Catalyst::Runtime' => '5.90040',
'Catalyst::View::JSON' => 0,
'Catalyst::View::TT' => 0,
'CHI' => 0,
'Config::General' => 0,
'Data::HAL' => 0,
'Data::Record' => 0,
'Data::Validate::IP' => 0,
'DateTime' => 0,
'DateTime::Format::HTTP' => 0,
'DateTime::Format::ISO8601' => 0,
'DateTime::Format::RFC3339' => 0,
'DBIx::Class::ResultSet::RecursiveUpdate' => '0.30',
'Digest::SHA3' => 0,
'Email::Valid' => 0,
'File::ShareDir' => 0,
'File::Type' => 0,
@ -51,6 +61,9 @@ my $builder = Local::Module::Build->new(
'HTTP::Headers' => 0,
'HTTP::Status' => 0,
'IPC::System::Simple' => 0,
'JE' => 0,
'JSON::Pointer' => 0,
'JSON::Tiny::Subclassable' => 0,
'Log::Log4perl::Catalyst' => 0,
'Module::Runtime' => 0,
'Moose' => 2,
@ -61,11 +74,15 @@ my $builder = Local::Module::Build->new(
'Net::HTTP' => 0,
'Net::Telnet' => 0,
'NGCP::Schema' => '2.003',
'Plack::Middleware::Deflater' => 0,
'Regexp::Parser' => 0,
'Scalar::Util' => 0,
'Sereal::Decoder' => 0,
'Sereal::Encoder' => 0,
'strict' => 0,
'Template' => 0,
'Text::CSV_XS' => 0,
'Types::Path::Tiny' => 0,
'URI::Encode' => 0,
'URI::Escape' => 0,
'UUID' => 0,

@ -20,12 +20,17 @@ use Catalyst qw/
Static::Simple
Authentication
Authorization::Roles
EnableMiddleware
Session
Session::Store::FastMmap
Session::State::Cookie
/;
use CHI qw();
require CHI::Driver::FastMmap;
use Log::Log4perl::Catalyst qw();
use NGCP::Panel::Cache::Serializer qw();
use NGCP::Panel::Middleware::HSTS qw();
use NGCP::Panel::Middleware::TEgzip qw();
extends 'Catalyst';
our $VERSION = '0.01';
@ -148,12 +153,26 @@ __PACKAGE__->config(
store_user_class => 'NGCP::Panel::AuthenticationStore::RoleFromRealm',
}
}
}
},
'Plugin::EnableMiddleware' => [
NGCP::Panel::Middleware::TEgzip->new,
NGCP::Panel::Middleware::HSTS->new,
],
);
__PACKAGE__->config( default_view => 'HTML' );
__PACKAGE__->log(Log::Log4perl::Catalyst->new($logger_config));
has('cache', is => 'ro', default => sub {
my ($self) = @_;
return CHI->new(
cache_size => '30m',
driver => 'FastMmap',
root_dir => $self->config->{cache_root},
serializer => NGCP::Panel::Cache::Serializer->new,
);
});
# Start the application
__PACKAGE__->setup();

@ -0,0 +1,14 @@
package NGCP::Panel::Cache::Serializer;
use Sipwise::Base;
use Sereal::Decoder qw();
use Sereal::Encoder qw();
sub serialize {
my ($self, $data) = @_;
return Sereal::Encoder::encode_sereal($data);
}
sub deserialize {
my ($self, $sereal) = @_;
return Sereal::Decoder::decode_sereal($sereal);
}

@ -0,0 +1,392 @@
package NGCP::Panel::Controller::API::Contacts;
use Sipwise::Base;
use namespace::sweep;
use boolean qw(true);
use Data::HAL qw();
use Data::HAL::Link qw();
use Data::Record qw();
use DateTime::Format::HTTP qw();
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
);
use JE qw();
use JSON qw();
use MooseX::ClassAttribute qw(class_has);
use NGCP::Panel::Form::Contact::Reseller qw();
use NGCP::Panel::ValidateJSON qw();
use Path::Tiny qw(path);
use Regexp::Common qw(delimited); # $RE{delimited}
use Safe::Isa qw($_isa);
use Types::Standard qw(InstanceOf);
BEGIN { extends 'Catalyst::Controller::ActionRole'; }
require Catalyst::ActionRole::ACL;
require Catalyst::ActionRole::CheckTrailingSlash;
require Catalyst::ActionRole::HTTPMethods;
require Catalyst::ActionRole::QueryParameter;
require Catalyst::ActionRole::RequireSSL;
require URI::QueryParam;
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']);
__PACKAGE__->config(
action => {
map { $_ => {
ACLDetachTo => '/api/root/invalid_user',
AllowedRole => 'api_admin',
Args => 0,
Does => [qw(ACL CheckTrailingSlash RequireSSL)],
Method => $_,
Path => __PACKAGE__->dispatch_path,
QueryParam => '!id',
} } @{ __PACKAGE__->allowed_methods }
},
action_roles => [qw(HTTPMethods QueryParameter)],
);
sub GET : Allow {
my ($self, $c) = @_;
{
last if $self->cached($c);
my $contacts = $c->model('DB')->resultset('contacts');
$self->last_modified($contacts->get_column('modify_timestamp')->max_rs->single->modify_timestamp);
my (@embedded, @links);
for my $contact ($contacts->search({}, {order_by => {-asc => 'me.id'}, prefetch => ['reseller']})->all) {
push @embedded, $self->hal_from_contact($contact);
push @links, Data::HAL::Link->new(
relation => 'ngcp:contacts',
href => sprintf('/api/contacts/?id=%d', $contact->id),
);
}
my $hal = Data::HAL->new(
embedded => [@embedded],
links => [
Data::HAL::Link->new(
relation => 'curies',
href => 'http://purl.org/sipwise/ngcp-api/#rel-{rel}',
name => 'ngcp',
templated => true,
),
Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'),
Data::HAL::Link->new(relation => 'self', href => '/api/contacts/'),
@links,
]
);
my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new(
(map { # XXX Data::HAL must be able to generate links with multiple relations
s|rel="(http://purl.org/sipwise/ngcp-api/#rel-contacts)"|rel="item $1"|;
s/rel=self/rel="collection self"/;
$_
} $hal->http_headers),
Cache_Control => 'no-cache, private',
ETag => $self->etag($hal->as_json),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
), $hal->as_json);
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
$c->response->headers($response->headers);
$c->response->body($response->content);
return;
}
return;
}
sub HEAD : Allow {
my ($self, $c) = @_;
$c->forward(qw(GET));
$c->response->body(q());
return;
}
sub OPTIONS : Allow {
my ($self, $c) = @_;
my $allowed_methods = $self->allowed_methods->join(q(, ));
$c->response->headers(HTTP::Headers->new(
Allow => $allowed_methods,
Accept_Post => 'application/hal+json; profile=http://purl.org/sipwise/ngcp-api/#rel-contacts',
Content_Language => 'en',
));
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/allowed_methods.tt', allowed_methods => $allowed_methods);
return;
}
sub POST : Allow {
my ($self, $c) = @_;
my $media_type = 'application/hal+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 $hal = Data::HAL->from_json($json);
my $r_id;
{
my $reseller_link = ($hal->links // [])->grep(sub {
$_->relation->eq('http://purl.org/sipwise/ngcp-api/#rel-resellers')
});
if ($reseller_link->size) {
my $reseller_uri = URI->new_abs($reseller_link->at(0)->href->as_string, $c->req->uri)->canonical;
my $resellers_uri = URI->new_abs('/api/resellers/', $c->req->uri)->canonical;
if (0 != index $reseller_uri, $resellers_uri) {
$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 => "The link $reseller_uri cannot express a reseller relationship.",
);
last;
}
$r_id = $reseller_uri->rel($resellers_uri)->query_param('id');
last unless $self->valid_id($c, $r_id);
}
}
my $resource = $hal->resource;
my $contact_form = NGCP::Panel::Form::Contact::Reseller->new;
my %fields = map { $_->name => undef } grep { 'Text' eq $_->type || 'Email' eq $_->type } $contact_form->fields;
for my $k (keys %{ $resource }) {
delete $resource->{$k} unless exists $fields{$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');
$c->response->content_type('application/xhtml+xml');
my $e = $result->error_results->map(sub {
sprintf '%s: %s - %s', $_->name, $_->input, $_->errors->join(q())
})->join("\n");
$c->stash(
template => 'api/unprocessable_entity.tt',
error_message => "Validation failed: $e",
);
last;
}
$resource->{reseller_id} = $r_id;
my $now = DateTime->now;
$resource->{create_timestamp} = $now;
$resource->{modify_timestamp} = $now;
my $contact = $c->model('DB')->resultset('contacts')->create($resource);
$c->cache->remove($c->request->uri->canonical->as_string);
$c->response->status(HTTP_CREATED);
$c->response->header(Location => sprintf('/api/contacts/?id=%d', $contact->id));
$c->response->body(q());
}
return;
}
sub allowed_methods : Private {
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 cached : Private {
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 : Private {
my ($self, $octets) = @_;
return sprintf '"ni:/sha3-256;%s"', sha3_256_base64($octets);
}
sub expires : Private {
my ($self) = @_;
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
my %resource = $contact->get_inflated_columns;
my $id = delete $resource{id};
my $hal = Data::HAL->new(
links => [
Data::HAL::Link->new(
relation => 'curies',
href => 'http://purl.org/sipwise/ngcp-api/#rel-{rel}',
name => 'ngcp',
templated => true,
),
Data::HAL::Link->new(relation => 'collection', href => '/api/contacts/'),
Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'),
Data::HAL::Link->new(relation => 'self', href => "/api/contacts/?id=$id"),
$contact->reseller
? Data::HAL::Link->new(
relation => 'ngcp:resellers',
href => sprintf('/api/resellers/?id=%d', $contact->reseller_id),
) : (),
],
relation => 'ngcp:contacts',
);
my %fields = map { $_->name => undef } grep { 'Text' eq $_->type || 'Email' eq $_->type }
NGCP::Panel::Form::Contact::Reseller->new->fields;
for my $k (keys %resource) {
delete $resource{$k} unless exists $fields{$k};
$resource{$k} = DateTime::Format::RFC3339->format_datetime($resource{$k}) if $resource{$k}->$_isa('DateTime');
}
$hal->resource({%resource});
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;
$c->response->status(HTTP_BAD_REQUEST);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/invalid_query_parameter.tt', key => 'id');
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;
}
sub valid_entity : Private {
my ($self, $c, $entity) = @_;
my $js
= path($c->path_to(qw(share static js tv4.js)))->slurp
. "\nvar schema = "
. path($c->path_to(qw(share static js 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;
}
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')
);
$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);
}
}

@ -0,0 +1,664 @@
package NGCP::Panel::Controller::API::ContactsItem;
use Sipwise::Base;
use namespace::sweep;
use boolean qw(true);
use Data::HAL qw();
use Data::HAL::Link qw();
use Data::Record qw();
use DateTime::Format::HTTP qw();
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_NO_CONTENT
HTTP_NOT_FOUND
HTTP_NOT_MODIFIED
HTTP_OK
HTTP_PRECONDITION_FAILED
HTTP_PRECONDITION_REQUIRED
HTTP_UNPROCESSABLE_ENTITY
HTTP_UNSUPPORTED_MEDIA_TYPE
);
use JE qw();
use JSON qw();
use JSON::Pointer qw();
use MooseX::ClassAttribute qw(class_has);
use NGCP::Panel::Form::Contact::Reseller qw();
use NGCP::Panel::ValidateJSON qw();
use Path::Tiny qw(path);
use Regexp::Common qw(delimited); # $RE{delimited}
use Safe::Isa qw($_isa);
use Types::Standard qw(InstanceOf);
BEGIN { extends 'Catalyst::Controller::ActionRole'; }
require Catalyst::ActionRole::ACL;
require Catalyst::ActionRole::CheckTrailingSlash;
require Catalyst::ActionRole::HTTPMethods;
require Catalyst::ActionRole::QueryParameter;
require Catalyst::ActionRole::RequireSSL;
require URI::QueryParam;
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']);
__PACKAGE__->config(
action => {
map { $_ => {
ACLDetachTo => '/api/root/invalid_user',
AllowedRole => 'api_admin',
Args => 0,
Does => [qw(ACL CheckTrailingSlash RequireSSL)],
Method => $_,
Path => __PACKAGE__->dispatch_path,
QueryParam => 'id',
} } @{ __PACKAGE__->allowed_methods }
},
action_roles => [qw(HTTPMethods QueryParameter)],
);
sub GET : Allow {
my ($self, $c) = @_;
{
my $id = delete $c->request->query_parameters->{id};
last unless $self->valid_id($c, $id);
last if $self->cached($c);
my $contact = $self->contact_by_id($c, $id);
last unless $self->resource_exists($c, contact => $contact);
my $hal = $self->hal_from_contact($contact);
my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new(
(map { # XXX Data::HAL must be able to generate links with multiple relations
s|rel="(http://purl.org/sipwise/ngcp-api/#rel-resellers)"|rel="item $1"|;
s/rel=self/rel="item self"/;
$_
} $hal->http_headers),
Cache_Control => 'no-cache, private',
ETag => $self->etag($hal->as_json),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
), $hal->as_json);
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
$c->response->headers($response->headers);
$c->response->body($response->content);
return;
}
return;
}
sub HEAD : Allow {
my ($self, $c) = @_;
$c->forward(qw(GET));
$c->response->body(q());
return;
}
sub OPTIONS : Allow {
my ($self, $c) = @_;
my $allowed_methods = $self->allowed_methods->join(q(, ));
$c->response->headers(HTTP::Headers->new(
Allow => $allowed_methods,
Accept_Patch => 'application/json-patch+json',
Content_Language => 'en',
));
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/allowed_methods.tt', allowed_methods => $allowed_methods);
return;
}
sub PATCH : Allow {
my ($self, $c) = @_;
my $media_type = 'application/json-patch+json';
my $guard = $c->model('DB')->txn_scope_guard;
{
my $id = delete $c->request->query_parameters->{id};
last unless $self->valid_id($c, $id);
last unless $self->forbid_link_header($c);
last unless $self->valid_media_type($c, $media_type);
last unless $self->require_precondition($c, 'If-Match');
my $preference = $self->require_preference($c);
last unless $preference;
my $cached = $c->cache->get($c->request->uri->canonical->as_string);
my ($contact, $entity);
if ($cached) {
try {
die 'not a response object' unless $cached->$_isa('HTTP::Response');
} catch($e) {
die "cache poisoned: $e";
};
last unless $self->valid_precondition($c, $cached->header('ETag'), 'contact');
try {
NGCP::Panel::ValidateJSON->new($cached->content);
$entity = JSON::decode_json($cached->content);
} catch($e) {
die "cache poisoned: $e";
};
} else {
if ('*' eq $c->request->header('If-Match')) {
$contact = $self->contact_by_id($c, $id);
last unless $self->resource_exists($c, contact => $contact);
$entity = JSON::decode_json($self->hal_from_contact($contact)->as_json);
} else {
$c->response->status(HTTP_PRECONDITION_FAILED);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/precondition_failed.tt', entity_name => 'contact');
last;
}
}
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->require_valid_patch($c, $json);
$entity = $self->apply_patch($c, $entity, $json);
last unless $entity;
last unless $self->valid_entity($c, $entity);
my $hal = Data::HAL->from_json(
JSON::to_json($entity, { canonical => 1, convert_blessed => 1, pretty => 1, utf8 => 1 })
);
my $r_id;
{
my $reseller_link = ($hal->links // [])->grep(sub {
$_->relation->eq('http://purl.org/sipwise/ngcp-api/#rel-resellers')
});
if ($reseller_link->size) {
my $reseller_uri = URI->new_abs($reseller_link->at(0)->href->as_string, $c->req->uri)->canonical;
my $resellers_uri = URI->new_abs('/api/resellers/', $c->req->uri)->canonical;
if (0 != index $reseller_uri, $resellers_uri) {
$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 => "The link $reseller_uri cannot express a reseller relationship.",
);
last;
}
$r_id = $reseller_uri->rel($resellers_uri)->query_param('id');
last unless $self->valid_id($c, $r_id);
}
}
my $resource = $hal->resource;
my $contact_form = NGCP::Panel::Form::Contact::Reseller->new;
my %fields = map { $_->name => undef } grep { 'Text' eq $_->type || 'Email' eq $_->type } $contact_form->fields;
for my $k (keys %{ $resource }) {
delete $resource->{$k} unless exists $fields{$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');
$c->response->content_type('application/xhtml+xml');
my $e = $result->error_results->map(sub {
sprintf '%s: %s - %s', $_->name, $_->input, $_->errors->join(q())
})->join("\n");
$c->stash(
template => 'api/unprocessable_entity.tt',
error_message => "Validation failed: $e",
);
last;
}
$resource->{reseller_id} = $r_id;
$resource->{modify_timestamp} = DateTime->now;
$contact = $self->contact_by_id($c, $id) unless $contact;
$contact->update($resource);
$guard->commit;
if ('minimal' eq $preference) {
$c->cache->remove($c->request->uri->canonical->as_string);
$c->response->status(HTTP_NO_CONTENT);
$c->response->header(Preference_Applied => 'return=minimal');
$c->response->body(q());
} else {
$hal = $self->hal_from_contact($contact);
my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new(
$hal->http_headers,
Cache_Control => 'no-cache, private',
ETag => $self->etag($hal->as_json),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
), $hal->as_json);
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
$c->response->headers($response->headers);
$c->response->header(Preference_Applied => 'return=representation'); # don't cache this
$c->response->body($response->content);
}
}
return;
}
sub PUT : Allow {
my ($self, $c) = @_;
my $media_type = 'application/hal+json';
my $guard = $c->model('DB')->txn_scope_guard;
{
my $id = delete $c->request->query_parameters->{id};
last unless $self->valid_id($c, $id);
last unless $self->forbid_link_header($c);
last unless $self->valid_media_type($c, $media_type);
last unless $self->require_precondition($c, 'If-Match');
my $preference = $self->require_preference($c);
last unless $preference;
my $cached = $c->cache->get($c->request->uri->canonical->as_string);
my ($contact, $entity);
if ($cached) {
try {
die 'not a response object' unless $cached->$_isa('HTTP::Response');
} catch($e) {
die "cache poisoned: $e";
};
last unless $self->valid_precondition($c, $cached->header('ETag'), 'contact');
try {
NGCP::Panel::ValidateJSON->new($cached->content);
$entity = JSON::decode_json($cached->content);
} catch($e) {
die "cache poisoned: $e";
};
} else {
if ('*' eq $c->request->header('If-Match')) {
$contact = $self->contact_by_id($c, $id);
last unless $self->resource_exists($c, contact => $contact);
$entity = JSON::decode_json($self->hal_from_contact($contact)->as_json);
} else {
$c->response->status(HTTP_PRECONDITION_FAILED);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/precondition_failed.tt', entity_name => 'contact');
last;
}
}
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);
$entity = JSON::decode_json($json);
last unless $self->valid_entity($c, $entity);
my $hal = Data::HAL->from_json($json);
my $r_id;
{
my $reseller_link = ($hal->links // [])->grep(sub {
$_->relation->eq('http://purl.org/sipwise/ngcp-api/#rel-resellers')
});
if ($reseller_link->size) {
my $reseller_uri = URI->new_abs($reseller_link->at(0)->href->as_string, $c->req->uri)->canonical;
my $resellers_uri = URI->new_abs('/api/resellers/', $c->req->uri)->canonical;
if (0 != index $reseller_uri, $resellers_uri) {
$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 => "The link $reseller_uri cannot express a reseller relationship.",
);
last;
}
$r_id = $reseller_uri->rel($resellers_uri)->query_param('id');
last unless $self->valid_id($c, $r_id);
}
}
my $resource = $hal->resource;
my $contact_form = NGCP::Panel::Form::Contact::Reseller->new;
my %fields = map { $_->name => undef } grep { 'Text' eq $_->type || 'Email' eq $_->type } $contact_form->fields;
for my $k (keys %{ $resource }) {
delete $resource->{$k} unless exists $fields{$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');
$c->response->content_type('application/xhtml+xml');
my $e = $result->error_results->map(sub {
sprintf '%s: %s - %s', $_->name, $_->input, $_->errors->join(q())
})->join("\n");
$c->stash(
template => 'api/unprocessable_entity.tt',
error_message => "Validation failed: $e",
);
last;
}
$resource->{reseller_id} = $r_id;
$resource->{modify_timestamp} = DateTime->now;
$contact = $self->contact_by_id($c, $id) unless $contact;
$contact->update($resource);
$guard->commit;
if ('minimal' eq $preference) {
$c->cache->remove($c->request->uri->canonical->as_string);
$c->response->status(HTTP_NO_CONTENT);
$c->response->header(Preference_Applied => 'return=minimal');
$c->response->body(q());
} else {
$hal = $self->hal_from_contact($contact);
my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new(
$hal->http_headers,
Cache_Control => 'no-cache, private',
ETag => $self->etag($hal->as_json),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
), $hal->as_json);
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
$c->response->headers($response->headers);
$c->response->header(Preference_Applied => 'return=representation'); # don't cache this
$c->response->body($response->content);
}
}
return;
}
sub allowed_methods : Private {
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 apply_patch : Private {
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 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($e) {
$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 => $e);
return;
};
}
return $entity;
}
sub cached : Private {
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 contact_by_id : Private {
my ($self, $c, $id) = @_;
return $c->model('DB')->resultset('contacts')->find({'me.id' => $id}, {prefetch => ['reseller']});
}
sub etag : Private {
my ($self, $octets) = @_;
return sprintf '"ni:/sha3-256;%s"', sha3_256_base64($octets);
}
sub expires : Private {
my ($self) = @_;
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
my %resource = $contact->get_inflated_columns;
my $id = delete $resource{id};
$self->last_modified(delete $resource{modify_timestamp});
my $hal = Data::HAL->new(
links => [
Data::HAL::Link->new(
relation => 'curies',
href => 'http://purl.org/sipwise/ngcp-api/#rel-{rel}',
name => 'ngcp',
templated => true,
),
Data::HAL::Link->new(relation => 'collection', href => '/api/contacts/'),
Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'),
Data::HAL::Link->new(relation => 'self', href => "/api/contacts/?id=$id"),
$contact->reseller
? Data::HAL::Link->new(
relation => 'ngcp:resellers',
href => sprintf('/api/resellers/?id=%d', $contact->reseller_id),
) : (),
],
relation => 'ngcp:contacts',
);
my %fields = map { $_->name => undef } grep { 'Text' eq $_->type || 'Email' eq $_->type }
NGCP::Panel::Form::Contact::Reseller->new->fields;
for my $k (keys %resource) {
delete $resource{$k} unless exists $fields{$k};
$resource{$k} = DateTime::Format::RFC3339->format_datetime($resource{$k}) if $resource{$k}->$_isa('DateTime');
}
$hal->resource({%resource});
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_precondition : Private {
my ($self, $c, $header_name) = @_;
return 1 if $c->request->header($header_name);
$c->response->status(HTTP_PRECONDITION_REQUIRED);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/require_precondition.tt', header_name => $header_name);
return;
}
sub require_preference : Private {
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]);
$c->response->status(HTTP_BAD_REQUEST);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/require_preference.tt');
return;
}
sub require_valid_patch : Private {
my ($self, $c, $json) = @_;
my $js
= path($c->path_to(qw(share static js tv4.js)))->slurp
. "\nvar schema = "
. path($c->path_to(qw(share static js json-patch.json)))->slurp
. ";\nvar data = "
. $json # code injection prevented by asserting well-formedness
. ";\ntv4.validate(data, schema);";
my $je = JE->new;
unless ($je->eval($js)) {
die "generic JavaScript error: $@" if $@;
$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 => 'application/json-patch+json',
error_message => JSON::to_json(
{ map { $_ => $je->{tv4}{error}{$_}->value } qw(dataPath message schemaPath) },
{ canonical => 1, pretty => 1, }
)
);
return;
};
return 1;
}
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 resource_exists : Private {
my ($self, $c, $entity_name, $resource) = @_;
return 1 if $resource;
$c->response->status(HTTP_NOT_FOUND);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/not_found.tt', entity_name => $entity_name);
return;
}
sub valid_entity : Private {
my ($self, $c, $entity) = @_;
my $js
= path($c->path_to(qw(share static js tv4.js)))->slurp
. "\nvar schema = "
. path($c->path_to(qw(share static js contacts-item.json)))->slurp
. ";\nvar data = "
. JSON::to_json($entity, { canonical => 1, pretty => 1, utf8 => 1, })
. ";\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;
}
sub valid_id : Private {
my ($self, $c, $id) = @_;
return 1 if $id->is_integer;
$c->response->status(HTTP_BAD_REQUEST);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/invalid_query_parameter.tt', key => 'id');
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;
}
sub valid_precondition : Private {
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);
$c->response->status(HTTP_PRECONDITION_FAILED);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/precondition_failed.tt', entity_name => $entity_name);
return;
}
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')
);
$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);
}
}

@ -0,0 +1,377 @@
package NGCP::Panel::Controller::API::Contracts;
use Sipwise::Base;
use namespace::sweep;
use boolean qw(true);
use Data::HAL qw();
use Data::HAL::Link qw();
use Data::Record qw();
use DateTime::Format::HTTP qw();
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
);
use JE qw();
use JSON qw();
use MooseX::ClassAttribute qw(class_has);
use NGCP::Panel::Form::Contact::Reseller qw();
use NGCP::Panel::ValidateJSON qw();
use Path::Tiny qw(path);
use Regexp::Common qw(delimited); # $RE{delimited}
use Safe::Isa qw($_isa);
use Types::Standard qw(InstanceOf);
BEGIN { extends 'Catalyst::Controller::ActionRole'; }
require Catalyst::ActionRole::ACL;
require Catalyst::ActionRole::CheckTrailingSlash;
require Catalyst::ActionRole::HTTPMethods;
require Catalyst::ActionRole::QueryParameter;
require Catalyst::ActionRole::RequireSSL;
require URI::QueryParam;
class_has('dispatch_path', is => 'ro', default => '/api/contracts/');
class_has('relation', is => 'ro', default => 'http://purl.org/sipwise/ngcp-api/#rel-contracts');
has('last_modified', is => 'rw', isa => InstanceOf['DateTime']);
__PACKAGE__->config(
action => {
map { $_ => {
ACLDetachTo => '/api/root/invalid_user',
AllowedRole => 'api_admin',
Args => 0,
Does => [qw(ACL CheckTrailingSlash RequireSSL)],
Method => $_,
Path => __PACKAGE__->dispatch_path,
QueryParam => '!id',
} } @{ __PACKAGE__->allowed_methods }
},
action_roles => [qw(HTTPMethods QueryParameter)],
);
sub GET : Allow {
my ($self, $c) = @_;
{
last if $self->cached($c);
my $contracts = $c->model('DB')->resultset('contracts');
$self->last_modified($contracts->get_column('modify_timestamp')->max_rs->single->modify_timestamp);
my (@embedded, @links);
for my $contract ($contracts->search({}, {order_by => {-asc => 'me.id'}, prefetch => ['contact']})->all) {
push @embedded, $self->hal_from_contract($contract);
push @links, Data::HAL::Link->new(
relation => 'ngcp:contracts',
href => sprintf('/api/contracts/?id=%d', $contract->id),
);
}
my $hal = Data::HAL->new(
embedded => [@embedded],
links => [
Data::HAL::Link->new(
relation => 'curies',
href => 'http://purl.org/sipwise/ngcp-api/#rel-{rel}',
name => 'ngcp',
templated => true,
),
Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'),
Data::HAL::Link->new(relation => 'self', href => '/api/contracts/'),
@links,
]
);
my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new(
(map { # XXX Data::HAL must be able to generate links with multiple relations
s|rel="(http://purl.org/sipwise/ngcp-api/#rel-contracts)"|rel="item $1"|;
s/rel=self/rel="collection self"/;
$_
} $hal->http_headers),
$hal->http_headers,
Cache_Control => 'no-cache, private',
ETag => $self->etag($hal->as_json),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
), $hal->as_json);
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
$c->response->headers($response->headers);
$c->response->body($response->content);
return;
}
return;
}
sub HEAD : Allow {
my ($self, $c) = @_;
$c->forward(qw(GET));
$c->response->body(q());
return;
}
sub OPTIONS : Allow {
my ($self, $c) = @_;
my $allowed_methods = $self->allowed_methods->join(q(, ));
$c->response->headers(HTTP::Headers->new(
Allow => $allowed_methods,
Accept_Post => 'application/hal+json; profile=http://purl.org/sipwise/ngcp-api/#rel-contracts',
Content_Language => 'en',
));
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/allowed_methods.tt', allowed_methods => $allowed_methods);
return;
}
sub POST : Allow {
my ($self, $c) = @_;
my $media_type = 'application/hal+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 $hal = Data::HAL->from_json($json);
my $contact_id;
{
my $contact_link = ($hal->links // [])->grep(sub {
$_->relation->eq('http://purl.org/sipwise/ngcp-api/#rel-contacts')
});
if ($contact_link->size) {
my $contact_uri = URI->new_abs($contact_link->at(0)->href->as_string, $c->req->uri)->canonical;
my $contacts_uri = URI->new_abs('/api/contacts/', $c->req->uri)->canonical;
if (0 != index $contact_uri, $contacts_uri) {
$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 => "The link $contact_uri cannot express a contact relationship.",
);
last;
}
$contact_id = $contact_uri->rel($contacts_uri)->query_param('id');
last unless $self->valid_id($c, $contact_id);
}
}
my $resource = $hal->resource;
my %fields = map { $_ => undef } qw(external_id status);
for my $k (keys %{ $resource }) {
delete $resource->{$k} unless exists $fields{$k};
$resource->{$k} = DateTime::Format::RFC3339->format_datetime($resource->{$k})
if $resource->{$k}->$_isa('DateTime');
}
$resource->{contact_id} = $contact_id;
my $now = DateTime->now;
$resource->{create_timestamp} = $now;
$resource->{modify_timestamp} = $now;
my $contract = $c->model('DB')->resultset('contracts')->create($resource);
$c->cache->remove($c->request->uri->canonical->as_string);
$c->response->status(HTTP_CREATED);
$c->response->header(Location => sprintf('/api/contracts/?id=%d', $contract->id));
$c->response->body(q());
}
return;
}
sub allowed_methods : Private {
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 cached : Private {
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 : Private {
my ($self, $octets) = @_;
return sprintf '"ni:/sha3-256;%s"', sha3_256_base64($octets);
}
sub expires : Private {
my ($self) = @_;
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_contract : Private {
my ($self, $contract) = @_;
# XXX invalid 00-00-00 dates
my %resource = $contract->get_inflated_columns;
my $id = delete $resource{id};
my $hal = Data::HAL->new(
links => [
Data::HAL::Link->new(
relation => 'curies',
href => 'http://purl.org/sipwise/ngcp-api/#rel-{rel}',
name => 'ngcp',
templated => true,
),
Data::HAL::Link->new(relation => 'collection', href => '/api/contracts/'),
Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'),
Data::HAL::Link->new(relation => 'self', href => "/api/contracts/?id=$id"),
$contract->contact
? Data::HAL::Link->new(
relation => 'ngcp:contacts',
href => sprintf('/api/contacts/?id=%d', $contract->contact_id),
) : (),
],
relation => 'ngcp:contracts',
);
my %fields = map { $_ => undef } qw(external_id status);
for my $k (keys %resource) {
delete $resource{$k} unless exists $fields{$k};
$resource{$k} = DateTime::Format::RFC3339->format_datetime($resource{$k}) if $resource{$k}->$_isa('DateTime');
}
$hal->resource({%resource});
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;
$c->response->status(HTTP_BAD_REQUEST);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/invalid_query_parameter.tt', key => 'id');
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;
}
sub valid_entity : Private {
my ($self, $c, $entity) = @_;
my $js
= path($c->path_to(qw(share static js tv4.js)))->slurp
. "\nvar schema = "
. path($c->path_to(qw(share static js contracts-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;
}
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')
);
$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);
}
}

@ -0,0 +1,632 @@
package NGCP::Panel::Controller::API::ContractsItem;
use Sipwise::Base;
use namespace::sweep;
use boolean qw(true);
use Data::HAL qw();
use Data::HAL::Link qw();
use Data::Record qw();
use DateTime::Format::HTTP qw();
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_NO_CONTENT
HTTP_NOT_FOUND
HTTP_NOT_MODIFIED
HTTP_OK
HTTP_PRECONDITION_FAILED
HTTP_PRECONDITION_REQUIRED
HTTP_UNPROCESSABLE_ENTITY
HTTP_UNSUPPORTED_MEDIA_TYPE
);
use JE qw();
use JSON qw();
use JSON::Pointer qw();
use MooseX::ClassAttribute qw(class_has);
use NGCP::Panel::ValidateJSON qw();
use Path::Tiny qw(path);
use Regexp::Common qw(delimited); # $RE{delimited}
use Safe::Isa qw($_isa);
use Types::Standard qw(InstanceOf);
BEGIN { extends 'Catalyst::Controller::ActionRole'; }
require Catalyst::ActionRole::ACL;
require Catalyst::ActionRole::CheckTrailingSlash;
require Catalyst::ActionRole::HTTPMethods;
require Catalyst::ActionRole::QueryParameter;
require Catalyst::ActionRole::RequireSSL;
require URI::QueryParam;
class_has('dispatch_path', is => 'ro', default => '/api/contracts/');
class_has('relation', is => 'ro', default => 'http://purl.org/sipwise/ngcp-api/#rel-contracts');
has('last_modified', is => 'rw', isa => InstanceOf['DateTime']);
__PACKAGE__->config(
action => {
map { $_ => {
ACLDetachTo => '/api/root/invalid_user',
AllowedRole => 'api_admin',
Args => 0,
Does => [qw(ACL CheckTrailingSlash RequireSSL)],
Method => $_,
Path => __PACKAGE__->dispatch_path,
QueryParam => 'id',
} } @{ __PACKAGE__->allowed_methods }
},
action_roles => [qw(HTTPMethods QueryParameter)],
);
sub GET : Allow {
my ($self, $c) = @_;
{
my $id = delete $c->request->query_parameters->{id};
last unless $self->valid_id($c, $id);
last if $self->cached($c);
my $contract = $self->contract_by_id($c, $id);
last unless $self->resource_exists($c, contract => $contract);
my $hal = $self->hal_from_contract($contract);
my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new(
(map { # XXX Data::HAL must be able to generate links with multiple relations
s|rel="(http://purl.org/sipwise/ngcp-api/#rel-contacts)"|rel="item $1"|;
s/rel=self/rel="item self"/;
$_
} $hal->http_headers),
Cache_Control => 'no-cache, private',
ETag => $self->etag($hal->as_json),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
), $hal->as_json);
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
$c->response->headers($response->headers);
$c->response->body($response->content);
return;
}
return;
}
sub HEAD : Allow {
my ($self, $c) = @_;
$c->forward(qw(GET));
$c->response->body(q());
return;
}
sub OPTIONS : Allow {
my ($self, $c) = @_;
my $allowed_methods = $self->allowed_methods->join(q(, ));
$c->response->headers(HTTP::Headers->new(
Allow => $allowed_methods,
Accept_Patch => 'application/json-patch+json',
Content_Language => 'en',
));
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/allowed_methods.tt', allowed_methods => $allowed_methods);
return;
}
sub PATCH : Allow {
my ($self, $c) = @_;
my $media_type = 'application/json-patch+json';
my $guard = $c->model('DB')->txn_scope_guard;
{
my $id = delete $c->request->query_parameters->{id};
last unless $self->valid_id($c, $id);
last unless $self->forbid_link_header($c);
last unless $self->valid_media_type($c, $media_type);
last unless $self->require_precondition($c, 'If-Match');
my $preference = $self->require_preference($c);
last unless $preference;
my $cached = $c->cache->get($c->request->uri->canonical->as_string);
my ($contract, $entity);
if ($cached) {
try {
die 'not a response object' unless $cached->$_isa('HTTP::Response');
} catch($e) {
die "cache poisoned: $e";
};
last unless $self->valid_precondition($c, $cached->header('ETag'), 'contract');
try {
NGCP::Panel::ValidateJSON->new($cached->content);
$entity = JSON::decode_json($cached->content);
} catch($e) {
die "cache poisoned: $e";
};
} else {
if ('*' eq $c->request->header('If-Match')) {
$contract = $self->contract_by_id($c, $id);
last unless $self->resource_exists($c, contract => $contract);
$entity = JSON::decode_json($self->hal_from_contract($contract)->as_json);
} else {
$c->response->status(HTTP_PRECONDITION_FAILED);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/precondition_failed.tt', entity_name => 'contract');
last;
}
}
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->require_valid_patch($c, $json);
$entity = $self->apply_patch($c, $entity, $json);
last unless $entity;
last unless $self->valid_entity($c, $entity);
my $hal = Data::HAL->from_json(
JSON::to_json($entity, { canonical => 1, convert_blessed => 1, pretty => 1, utf8 => 1 })
);
my $contact_id;
{
my $contact_link = ($hal->links // [])->grep(sub {
$_->relation->eq('http://purl.org/sipwise/ngcp-api/#rel-contacts')
});
if ($contact_link->size) {
my $contact_uri = URI->new_abs($contact_link->at(0)->href->as_string, $c->req->uri)->canonical;
my $contacts_uri = URI->new_abs('/api/contacts/', $c->req->uri)->canonical;
if (0 != index $contact_uri, $contacts_uri) {
$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 => "The link $contact_uri cannot express a contact relationship.",
);
last;
}
$contact_id = $contact_uri->rel($contacts_uri)->query_param('id');
last unless $self->valid_id($c, $contact_id);
}
}
my $resource = $hal->resource;
my %fields = map { $_ => undef } qw(external_id status);
for my $k (keys %{ $resource }) {
delete $resource->{$k} unless exists $fields{$k};
$resource->{$k} = DateTime::Format::RFC3339->format_datetime($resource->{$k})
if $resource->{$k}->$_isa('DateTime');
}
$resource->{contact_id} = $contact_id;
$resource->{modify_timestamp} = DateTime->now;
$contract = $self->contract_by_id($c, $id) unless $contract;
$contract->update($resource);
$guard->commit;
if ('minimal' eq $preference) {
$c->cache->remove($c->request->uri->canonical->as_string);
$c->response->status(HTTP_NO_CONTENT);
$c->response->header(Preference_Applied => 'return=minimal');
$c->response->body(q());
} else {
$hal = $self->hal_from_contract($contract);
my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new(
$hal->http_headers,
Cache_Control => 'no-cache, private',
ETag => $self->etag($hal->as_json),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
), $hal->as_json);
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
$c->response->headers($response->headers);
$c->response->header(Preference_Applied => 'return=representation'); # don't cache this
$c->response->body($response->content);
}
}
return;
}
sub PUT : Allow {
my ($self, $c) = @_;
my $media_type = 'application/hal+json';
my $guard = $c->model('DB')->txn_scope_guard;
{
my $id = delete $c->request->query_parameters->{id};
last unless $self->valid_id($c, $id);
last unless $self->forbid_link_header($c);
last unless $self->valid_media_type($c, $media_type);
last unless $self->require_precondition($c, 'If-Match');
my $preference = $self->require_preference($c);
last unless $preference;
my $cached = $c->cache->get($c->request->uri->canonical->as_string);
my ($contract, $entity);
if ($cached) {
try {
die 'not a response object' unless $cached->$_isa('HTTP::Response');
} catch($e) {
die "cache poisoned: $e";
};
last unless $self->valid_precondition($c, $cached->header('ETag'), 'contract');
try {
NGCP::Panel::ValidateJSON->new($cached->content);
$entity = JSON::decode_json($cached->content);
} catch($e) {
die "cache poisoned: $e";
};
} else {
if ('*' eq $c->request->header('If-Match')) {
$contract = $self->contract_by_id($c, $id);
last unless $self->resource_exists($c, contract => $contract);
$entity = JSON::decode_json($self->hal_from_contract($contract)->as_json);
} else {
$c->response->status(HTTP_PRECONDITION_FAILED);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/precondition_failed.tt', entity_name => 'contract');
last;
}
}
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);
$entity = JSON::decode_json($json);
last unless $self->valid_entity($c, $entity);
my $hal = Data::HAL->from_json($json);
my $contact_id;
{
my $contact_link = ($hal->links // [])->grep(sub {
$_->relation->eq('http://purl.org/sipwise/ngcp-api/#rel-contacts')
});
if ($contact_link->size) {
my $contact_uri = URI->new_abs($contact_link->at(0)->href->as_string, $c->req->uri)->canonical;
my $contacts_uri = URI->new_abs('/api/contacts/', $c->req->uri)->canonical;
if (0 != index $contact_uri, $contacts_uri) {
$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 => "The link $contact_uri cannot express a contact relationship.",
);
last;
}
$contact_id = $contact_uri->rel($contacts_uri)->query_param('id');
last unless $self->valid_id($c, $contact_id);
}
}
my $resource = $hal->resource;
my %fields = map { $_ => undef } qw(external_id status);
for my $k (keys %{ $resource }) {
delete $resource->{$k} unless exists $fields{$k};
$resource->{$k} = DateTime::Format::RFC3339->format_datetime($resource->{$k})
if $resource->{$k}->$_isa('DateTime');
}
$resource->{contact_id} = $contact_id;
$resource->{modify_timestamp} = DateTime->now;
$contract = $self->contract_by_id($c, $id) unless $contract;
$contract->update($resource);
$guard->commit;
if ('minimal' eq $preference) {
$c->cache->remove($c->request->uri->canonical->as_string);
$c->response->status(HTTP_NO_CONTENT);
$c->response->header(Preference_Applied => 'return=minimal');
$c->response->body(q());
} else {
$hal = $self->hal_from_contract($contract);
my $response = HTTP::Response->new(HTTP_OK, undef, HTTP::Headers->new(
$hal->http_headers,
Cache_Control => 'no-cache, private',
ETag => $self->etag($hal->as_json),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
), $hal->as_json);
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
$c->response->headers($response->headers);
$c->response->header(Preference_Applied => 'return=representation'); # don't cache this
$c->response->body($response->content);
}
}
return;
}
sub allowed_methods : Private {
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 apply_patch : Private {
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 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($e) {
$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 => $e);
return;
};
}
return $entity;
}
sub cached : Private {
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 contract_by_id : Private {
my ($self, $c, $id) = @_;
return $c->model('DB')->resultset('contracts')->find({'me.id' => $id}, {prefetch => ['contact']});
}
sub etag : Private {
my ($self, $octets) = @_;
return sprintf '"ni:/sha3-256;%s"', sha3_256_base64($octets);
}
sub expires : Private {
my ($self) = @_;
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_contract : Private {
my ($self, $contract) = @_;
# XXX invalid 00-00-00 dates
my %resource = $contract->get_inflated_columns;
my $id = delete $resource{id};
$self->last_modified(delete $resource{modify_timestamp});
my $hal = Data::HAL->new(
links => [
Data::HAL::Link->new(
relation => 'curies',
href => 'http://purl.org/sipwise/ngcp-api/#rel-{rel}',
name => 'ngcp',
templated => true,
),
Data::HAL::Link->new(relation => 'collection', href => '/api/contracts/'),
Data::HAL::Link->new(relation => 'profile', href => 'http://purl.org/sipwise/ngcp-api/'),
Data::HAL::Link->new(relation => 'self', href => "/api/contracts/?id=$id"),
$contract->contact
? Data::HAL::Link->new(
relation => 'ngcp:contacts',
href => sprintf('/api/contacts/?id=%d', $contract->contact_id),
) : (),
],
relation => 'ngcp:contracts',
);
my %fields = map { $_ => undef } qw(external_id status);
for my $k (keys %resource) {
delete $resource{$k} unless exists $fields{$k};
$resource{$k} = DateTime::Format::RFC3339->format_datetime($resource{$k}) if $resource{$k}->$_isa('DateTime');
}
$hal->resource({%resource});
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_precondition : Private {
my ($self, $c, $header_name) = @_;
return 1 if $c->request->header($header_name);
$c->response->status(HTTP_PRECONDITION_REQUIRED);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/require_precondition.tt', header_name => $header_name);
return;
}
sub require_preference : Private {
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]);
$c->response->status(HTTP_BAD_REQUEST);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/require_preference.tt');
return;
}
sub require_valid_patch : Private {
my ($self, $c, $json) = @_;
my $js
= path($c->path_to(qw(share static js tv4.js)))->slurp
. "\nvar schema = "
. path($c->path_to(qw(share static js json-patch.json)))->slurp
. ";\nvar data = "
. $json # code injection prevented by asserting well-formedness
. ";\ntv4.validate(data, schema);";
my $je = JE->new;
unless ($je->eval($js)) {
die "generic JavaScript error: $@" if $@;
$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 => 'application/json-patch+json',
error_message => JSON::to_json(
{ map { $_ => $je->{tv4}{error}{$_}->value } qw(dataPath message schemaPath) },
{ canonical => 1, pretty => 1, }
)
);
return;
};
return 1;
}
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 resource_exists : Private {
my ($self, $c, $entity_name, $resource) = @_;
return 1 if $resource;
$c->response->status(HTTP_NOT_FOUND);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/not_found.tt', entity_name => $entity_name);
return;
}
sub valid_entity : Private {
my ($self, $c, $entity) = @_;
my $js
= path($c->path_to(qw(share static js tv4.js)))->slurp
. "\nvar schema = "
. path($c->path_to(qw(share static js contracts-item.json)))->slurp
. ";\nvar data = "
. JSON::to_json($entity, { canonical => 1, pretty => 1, utf8 => 1, })
. ";\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;
}
sub valid_id : Private {
my ($self, $c, $id) = @_;
return 1 if $id->is_integer;
$c->response->status(HTTP_BAD_REQUEST);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/invalid_query_parameter.tt', key => 'id');
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;
}
sub valid_precondition : Private {
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);
$c->response->status(HTTP_PRECONDITION_FAILED);
$c->response->header('Content-Language' => 'en');
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/precondition_failed.tt', entity_name => $entity_name);
return;
}
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')
);
$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);
}
}

@ -0,0 +1,76 @@
package NGCP::Panel::Controller::API::ResellersItem;
use Sipwise::Base;
use namespace::sweep;
use DateTime qw();
use DateTime::Format::HTTP qw();
use HTTP::Headers qw();
use HTTP::Status qw(
HTTP_NOT_IMPLEMENTED
);
use MooseX::ClassAttribute qw(class_has);
BEGIN { extends 'Catalyst::Controller::ActionRole'; }
require Catalyst::ActionRole::ACL;
require Catalyst::ActionRole::CheckTrailingSlash;
require Catalyst::ActionRole::HTTPMethods;
require Catalyst::ActionRole::QueryParameter;
require Catalyst::ActionRole::RequireSSL;
require URI::QueryParam;
class_has('dispatch_path', is => 'ro', default => '/api/resellers/');
__PACKAGE__->config(
action => {
map { $_ => {
ACLDetachTo => '/api/root/invalid_user',
AllowedRole => 'api_admin',
Args => 0,
Does => [qw(ACL CheckTrailingSlash RequireSSL)],
Method => $_,
Path => __PACKAGE__->dispatch_path,
QueryParam => 'id',
} } @{ __PACKAGE__->allowed_methods }
},
action_roles => [qw(HTTPMethods QueryParameter)],
);
sub GET : Allow {
my ($self, $c) = @_;
$c->response->status(HTTP_NOT_IMPLEMENTED);
$c->response->headers(HTTP::Headers->new(
Content_Language => 'en',
Retry_After => DateTime::Format::HTTP->format_datetime(DateTime->new(year => 2014, month => 1, day => 1)), # XXX
));
$c->stash(template => 'api/not_implemented.tt', entity_name => 'resellers');
return;
}
sub HEAD : Allow {
my ($self, $c) = @_;
$c->forward(qw(GET));
$c->response->body(q());
return;
}
sub OPTIONS : Allow {
my ($self, $c) = @_;
my $allowed_methods = $self->allowed_methods->join(q(, ));
$c->response->headers(HTTP::Headers->new(
Allow => $allowed_methods,
Content_Language => 'en',
));
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/allowed_methods.tt', allowed_methods => $allowed_methods);
return;
}
sub allowed_methods : Private {
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];
}

@ -0,0 +1,141 @@
package NGCP::Panel::Controller::API::Root;
use Sipwise::Base;
use namespace::sweep;
use Data::Record qw();
use DateTime::Format::HTTP qw();
use Digest::SHA3 qw(sha3_256_base64);
use Encode qw(encode);
use HTTP::Headers qw();
use HTTP::Response qw();
use HTTP::Status qw(HTTP_NOT_MODIFIED HTTP_OK);
use MooseX::ClassAttribute qw(class_has);
use Regexp::Common qw(delimited); # $RE{delimited}{-delim=>'"'}
BEGIN { extends 'Catalyst::Controller'; }
require Catalyst::ActionRole::ACL;
require Catalyst::ActionRole::CheckTrailingSlash;
require Catalyst::ActionRole::HTTPMethods;
require Catalyst::ActionRole::RequireSSL;
class_has('dispatch_path', is => 'ro', default => '/api/');
__PACKAGE__->config(
action => {
map { $_ => {
ACLDetachTo => [qw(API::Root invalid_user)],
AllowedRole => 'api_admin',
Args => 0,
Does => [qw(ACL CheckTrailingSlash RequireSSL)],
Method => $_,
Path => __PACKAGE__->dispatch_path,
} } @{ __PACKAGE__->allowed_methods }
},
action_roles => [qw(HTTPMethods)],
);
sub GET : Allow {
my ($self, $c) = @_;
my $response = $self->cached($c);
unless ($response) {
$c->stash(template => 'api/root.tt');
$c->forward($c->view);
$c->response->headers(HTTP::Headers->new(
Cache_Control => 'no-cache, public',
Content_Language => 'en',
Content_Type => 'application/xhtml+xml',
ETag => $self->etag($c->response->body),
Expires => DateTime::Format::HTTP->format_datetime($self->expires),
Last_Modified => DateTime::Format::HTTP->format_datetime($self->last_modified),
$self->collections_link_headers,
));
$c->cache->set($c->request->uri->canonical->as_string, $response, { expires_at => $self->expires->epoch });
}
return;
}
sub HEAD : Allow {
my ($self, $c) = @_;
$c->forward(qw(GET));
$c->response->body(q());
return;
}
sub OPTIONS : Allow {
my ($self, $c) = @_;
my $allowed_methods = $self->allowed_methods->join(q(, ));
$c->response->headers(HTTP::Headers->new(
Allow => $allowed_methods,
Content_Language => 'en',
$self->collections_link_headers,
));
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/allowed_methods.tt', allowed_methods => $allowed_methods);
return;
}
sub allowed_methods : Private {
my $meta = __PACKAGE__->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 cached : Private {
my ($self, $c) = @_;
my $response = $c->cache->get($c->request->uri->canonical->as_string);
return unless $response;
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{quoted}, })
->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
) {
$response->code(HTTP_NOT_MODIFIED);
$response->content(undef);
return $response;
}
return;
}
sub collections_link_headers : Private {
my ($self) = @_;
return (
# XXX introspect class attribute API/*.pm->dispatch_path
Link => '</api/contacts/>; rel="collection http://purl.org/sipwise/ngcp-api/#rel-contacts"',
Link => '</api/contracts/>; rel="collection http://purl.org/sipwise/ngcp-api/#rel-contracts"',
# Link => '</api/resellers/>; rel=collection', # XXX does not exist yet
);
}
sub etag : Private {
my ($self, $octets) = @_;
return sprintf '"ni:/sha3-256;%s"', sha3_256_base64($octets);
}
sub expires : Private {
my ($self) = @_;
return DateTime->now->clone->add(years => 1); # XXX insert install timestamp + 1000 days/ product end-of-life
}
sub invalid_user : Private {
my ($self, $c, $ssl_client_m_serial) = @_;
$c->response->status(403);
$c->response->content_type('application/xhtml+xml');
$c->stash(template => 'api/invalid_user.tt', ssl_client_m_serial => $ssl_client_m_serial);
return;
}
sub last_modified : Private {
my ($self, $octets) = @_;
return DateTime->new(year => 2013, month => 11, day => 11); # XXX insert release timestamp
}

@ -117,7 +117,13 @@ sub default :Path {
$c->detach( '/error_page' );
}
sub end : ActionClass('RenderView') {}
sub render :ActionClass('RenderView') { }
sub end : Private {
my ($self, $c) = @_;
$c->forward('render');
return;
}
sub _prune_row {
my ($columns, %row) = @_;

@ -0,0 +1,14 @@
package NGCP::Panel::Middleware::HSTS;
use Sipwise::Base;
use Plack::Util qw();
extends 'Plack::Middleware';
sub call {
my ($self, $env) = @_;
my $res = $self->app->($env);
$self->response_cb($res, sub {
my $res = shift;
my $h = Plack::Util::headers($res->[1]);
$h->set('Strict-Transport-Security' => 'max-age=86400000');
});
}

@ -0,0 +1,50 @@
package NGCP::Panel::Middleware::TEgzip;
use Sipwise::Base;
use HTTP::Headers::Util qw(split_header_words);
use Plack::Middleware::Deflater qw();
# for internal package Plack::Middleware::Deflater::Encoder
use Plack::Util qw();
extends 'Plack::Middleware';
sub call {
my ($self, $env) = @_;
my $res = $self->app->($env);
if (
defined $env->{HTTP_TE}
&& grep {
my %coding = @{$_};
exists $coding{gzip} && !exists $coding{'q'}
|| exists $coding{gzip} && exists $coding{'q'} && $coding{'q'}->is_positive
} split_header_words $env->{HTTP_TE}
) {
$self->response_cb($res, sub {
my $res = shift;
my $h = Plack::Util::headers($res->[1]);
if (
$env->{'SERVER_PROTOCOL'} ne 'HTTP/1.0'
&& !Plack::Util::status_with_no_entity_body($res->[0])
&& $env->{'REQUEST_METHOD'} ne 'HEAD'
&& !$h->exists('Transfer-Encoding')
) {
$h->set('Transfer-Encoding' => 'gzip');
my $encoder = Plack::Middleware::Deflater::Encoder->new('gzip');
# normal response
if ($res->[2] && ref $res->[2] && ref $res->[2] eq ref []) {
my $buf = '';
foreach (@{ $res->[2] }) {
$buf .= $encoder->print($_) if defined $_;
}
$buf .= $encoder->close;
$res->[2] = [$buf];
return;
}
# delayed or stream
return sub {
$encoder->print(shift);
};
}
});
} else {
return $res;
}
}

@ -56,13 +56,13 @@ sub COMPONENT {
sub make_ca {
my ($self) = @_;
my $command = sprintf 'certtool -p --bits 3248 --outfile %s 1>&- 2>&-', $self->prefix->child('ca-key.pem');
$self->log->debug($command);
warn "$command\n";
system $command;
my $ca_selfsign_template = Path::Tiny->tempfile;
$ca_selfsign_template->spew_utf8($self->ca_selfsign_template);
$command = sprintf 'certtool -s --load-privkey %s --outfile %s --template %s 1>&- 2>&-',
$self->prefix->child('ca-key.pem'), $self->prefix->child('ca-cert.pem'), $ca_selfsign_template->stringify;
$self->log->debug($command);
warn "$command\n";
system $command;
return;
}
@ -70,21 +70,21 @@ sub make_ca {
sub make_server {
my ($self) = @_;
my $command = sprintf 'certtool -p --bits 3248 --outfile %s 1>&- 2>&-', $self->prefix->child('server-key.pem');
$self->log->debug($command);
warn "$command\n";
system $command;
my $server_signingrequest_template = Path::Tiny->tempfile;
$server_signingrequest_template->spew($self->server_signingrequest_template);
$command = sprintf 'certtool -q --load-privkey %s --outfile %s --template %s 1>&- 2>&-',
$self->prefix->child('server-key.pem'), $self->prefix->child('server-csr.pem'),
$server_signingrequest_template->stringify;
$self->log->debug($command);
warn "$command\n";
system $command;
my $server_signing_template = Path::Tiny->tempfile;
$server_signing_template->spew($self->server_signing_template);
$command = sprintf 'certtool -c --load-request %s --outfile %s --load-ca-certificate %s --load-ca-privkey %s ' .
'--template %s 1>&- 2>&-', $self->prefix->child('server-csr.pem'), $self->prefix->child('server-cert.pem'),
$self->prefix->child('ca-cert.pem'), $self->prefix->child('ca-key.pem'), $server_signing_template->stringify;
$self->log->debug($command);
warn "$command\n";
system $command;
return;
}

@ -0,0 +1,28 @@
package NGCP::Panel::ValidateJSON;
use Sipwise::Base;
extends 'JSON::Tiny::Subclassable';
my $WHITESPACE_RE = qr/[\x20\x09\x0a\x0d]*/;
sub new {
my ($self, $json) = @_;
$self = $self->next::method;
$self->decode($json);
die $self->error . "\n" if $self->error;
}
sub _decode_object {
my $self = shift;
my $hash = $self->_new_hash;
until (m/\G$WHITESPACE_RE\}/gc) {
m/\G$WHITESPACE_RE"/gc or $self->_exception('Expected string while parsing object');
my $key = $self->_decode_string;
$self->_exception("Unexpected duplicate object member name $key") if exists $hash->{$key};
m/\G$WHITESPACE_RE:/gc or $self->_exception('Expected colon while parsing object');
$hash->{$key} = $self->_decode_value;
redo if m/\G$WHITESPACE_RE,/gc;
last if m/\G$WHITESPACE_RE\}/gc;
$self->_exception('Expected comma or right curly bracket while parsing object');
}
return $hash;
}

@ -70,3 +70,5 @@ log4perl.appender.Default.layout.ConversionPattern=%d{ISO8601} [%p] [%F +%L] %m{
sbc 127.0.0.1:5080
app 127.0.0.1:5070
</callflow>
cache_root /run/shm

@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "contact information for a contract",
"required": ["city", "company", "country", "email", "firstname", "lastname", "phonenumber", "postcode", "street"],
"properties": {
"city": { "type": ["null", "string"], "description": "The contact's city." },
"company": { "type": ["null", "string"], "description": "The contact's company." },
"country": { "type": ["null", "string"], "description": "The contact's country (ISO-3166, e.g. \"AT\" for Austria, \"DE\" for Germany)." },
"email": { "type": "string", "format": "email", "description": "The contact's e-mail address." },
"firstname": { "type": "string", "description": "The contact's given name." },
"lastname": { "type": "string", "description": "The contact's family name." },
"phonenumber": { "type": ["null", "string"], "description": "E.164 number. The contact's officephone number." },
"postcode": { "type": ["null", "string"], "description": "The contact's postal routing code." },
"street": { "type": ["null", "string"], "description": "The contact's street address." }
},
"title": "contact",
"type": "object"
}

@ -0,0 +1,10 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"required": ["external_id", "status"],
"properties": {
"external_id": { "type": ["null", "string"], "description": "XXX" },
"status": { "enum": [null, "active", "locked", "pending", "terminated"], "description": "XXX" }
},
"title": "contract",
"type": "object"
}

@ -0,0 +1,74 @@
{
"title": "JSON Patch",
"description": "A JSON Schema describing a JSON Patch",
"$schema": "http://json-schema.org/draft-04/schema#",
"notes": [
"Only required members are accounted for, other members are ignored"
],
"type": "array",
"items": {
"description": "one JSON Patch operation",
"allOf": [
{
"description": "Members common to all operations",
"type": "object",
"required": [ "op", "path" ],
"properties": {
"path": { "$ref": "#/definitions/jsonPointer" }
}
},
{ "$ref": "#/definitions/oneOperation" }
]
},
"definitions": {
"jsonPointer": {
"type": "string",
"pattern": "^(/[^/~]*(~[01][^/~]*)*)*$"
},
"add": {
"description": "add operation. Value can be any JSON value.",
"properties": { "op": { "enum": [ "add" ] } },
"required": [ "value" ]
},
"remove": {
"description": "remove operation. Only a path is specified.",
"properties": { "op": { "enum": [ "remove" ] } }
},
"replace": {
"description": "replace operation. Value can be any JSON value.",
"properties": { "op": { "enum": [ "replace" ] } },
"required": [ "value" ]
},
"move": {
"description": "move operation. \"from\" is a JSON Pointer.",
"properties": {
"op": { "enum": [ "move" ] },
"from": { "$ref": "#/definitions/jsonPointer" }
},
"required": [ "from" ]
},
"copy": {
"description": "copy operation. \"from\" is a JSON Pointer.",
"properties": {
"op": { "enum": [ "copy" ] },
"from": { "$ref": "#/definitions/jsonPointer" }
},
"required": [ "from" ]
},
"test": {
"description": "test operation. Value can be any JSON value.",
"properties": { "op": { "enum": [ "test" ] } },
"required": [ "value" ]
},
"oneOperation": {
"oneOf": [
{ "$ref": "#/definitions/add" },
{ "$ref": "#/definitions/remove" },
{ "$ref": "#/definitions/replace" },
{ "$ref": "#/definitions/move" },
{ "$ref": "#/definitions/copy" },
{ "$ref": "#/definitions/test" }
]
}
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Allowed methods</title>
</head>
<body>
<p>Allowed methods: [% allowed_methods | html %]</p>
</body>
</html>

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Bad request</title>
</head>
<body>
<p>The request must not contain <tt>Link</tt> headers. Instead assert relationships in the entity body.</p>
</body>
</html>

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Internal server error</title>
</head>
<body>
<p>An exceptional error has occured.</p>
<dl>
<dt>incident number</dt>
<dd>[% exception_incident | html %]</dd>
<dt>time of incident</dt>
<dd>[% exception_timestamp | html %]</dd>
</dl>
<p>Details have been logged on the server.</p>
</body>
</html>

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Invalid query parameter</title>
</head>
<body>
<p>Invalid query parameter value for key <var>[% key | html %]</var></p>
</body>
</html>

@ -4,6 +4,6 @@
<title>Invalid certificate serial number</title>
</head>
<body>
<p>Invalid certificate serial number <var>[% ssl_client_m_serial %]</var></p>
<p>Invalid certificate serial number <var>[% ssl_client_m_serial | html %]</var></p>
</body>
</html>

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Not found</title>
</head>
<body>
<p>Entity <tt>[% entity_name | html %]</tt> not found</p>
</body>
</html>

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Not implemented</title>
</head>
<body>
<p>The functionality for the entity <tt>[% entity_name | html %]</tt> is not yet implemented.</p>
</body>
</html>

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Precondition failed</title>
</head>
<body>
<p>This <tt>[% entity_name | html %]</tt> entity cannot be found, it is either expired or does not exist.
Fetch a fresh one.</p>
</body>
</html>

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Bad request</title>
</head>
<body>
<p>This request is missing a message body.</p>
</body>
</html>

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Precondition required</title>
</head>
<body>
<p>This request is required to be conditional, use the <tt>[% header_name | html %]</tt> header.</p>
</body>
</html>

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Preference required</title>
</head>
<body>
<p>This request is required to express an expectation about the response. Use the <tt>Prefer</tt> header with
either a <tt>return=minimal</tt> or a <tt>return=representation</tt> preference.</p>
</body>
</html>

@ -0,0 +1,375 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Sipwise NGCP HTTP API documentation</title>
<script src="https://code.jquery.com/jquery-2.0.3.min.js"></script>
<style>
h1 { color: white; background-color: #54893B; height: 4em; padding: 1.4em; }
nav ol { counter-reset: section; list-style: none; }
nav li:before { counter-increment: section; content: counters(section, ".") ". "; }
a[rel="collection"] { font-family: monospace; border: solid 0.2em #080; }
a[href^="#"] { text-decoration: none; border-bottom: 0.15em dotted; }
code { white-space: pre-wrap; }
table, th, td { border: 2px outset; }
th, td { width: 50%; }
td { vertical-align: top; }
span { font-family: monospace; }
</style>
</head>
<body>
<h1>Sipwise NGCP HTTP API documentation</h1>
<h2 id="toc">Table of contents</h2>
<nav>
<ol>
<li><a href="#toc">Table of contents</a></li>
<li><a href="#introduction">Introduction</a></li>
<li><a href="#relations">Link relations</a>
<ol>
<li>standard link relations</li>
<li>custom link relations
<ol>
<li><a href="#rel-contacts">contacts</a></li>
<li><a href="#rel-contracts">contracts</a></li>
</ol>
</li>
</ol>
</li>
<li><a href="#examples">Examples</a>
<ol>
<li>request information about the communication options available on the URI</li>
<li>retrieve information that is identified by the URI</li>
<li>create a new subordinate of the resource identified by the URI</li>
<li>store entity under the URI</li>
</ol>
</li>
<li><a href="#hints">Implementation hints</a></li>
<li><a href="#bugs">Bugs and incompatibilities</a></li>
<li><a href="#future">Future considerations</a>
<ol>
<li><a href="#versioning">Versioning</a></li>
<li><a href="#feed-paging">Feed paging</a></li>
<li><a href="#search">Search</a></li>
<li><a href="#prefetch">Link prefetching</a></li>
<li><a href="#home-document">Home document</a></li>
</ol>
</li>
<li><a href="#definitions">Definitions</a></li>
<li><a href="#definitions">Definitions</a></li>
</ol>
</nav>
<h2 id="introduction">Introduction</h2>
<p>This documentation is also a machine-readable service document. The resource processing model is implied by
the <a href="#draft-kelly-json-hal">JSON Hypertext Application Language</a>, draft version 06. This document
specifies a <a href="#rfc6906">profile</a> by defining <a href="#relations">link relations</a> and their
implied restrictions on the media type.</p>
<h2 id="relations">Link relations</h2>
<h3><a href="#iana-relations">standard link relations</a></h3>
<ul>
<li id="self">self</li>
<li id="collection">collection</li>
<li id="item">item</li>
</ul>
<h3>custom link relations</h3>
<p>The entity for each relation is expressed by a HAL document whose resource object validates against the
provided schemas.</p>
<h4 id="rel-contacts"><a href="contacts/" rel="collection">contacts</a></h4>
<code>
{
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "contact information for a contract",
"required": ["city", "company", "country", "email", "firstname", "lastname", "phonenumber", "postcode", "street"],
"properties": {
"city": { "type": ["null", "string"], "description": "The contact's city." },
"company": { "type": ["null", "string"], "description": "The contact's company." },
"country": { "type": ["null", "string"], "description": "The contact's country (ISO-3166, e.g. \"AT\" for Austria, \"DE\" for Germany)." },
"email": { "type": "string", "format": "email", "description": "The contact's e-mail address." },
"firstname": { "type": "string", "description": "The contact's given name." },
"lastname": { "type": "string", "description": "The contact's family name." },
"phonenumber": { "type": ["null", "string"], "description": "E.164 number. The contact's officephone number." },
"postcode": { "type": ["null", "string"], "description": "The contact's postal routing code." },
"street": { "type": ["null", "string"], "description": "The contact's street address." }
},
"title": "contact",
"type": "object"
}
</code>
<h4 id="rel-contracts"><a href="contracts/" rel="collection">contracts</a></h4>
<code>
{
"$schema": "http://json-schema.org/draft-04/schema#",
"required": ["external_id", "status"],
"properties": {
"external_id": { "type": ["null", "string"], "description": "XXX" },
"status": { "enum": [null, "active", "locked", "pending", "terminated"], "description": "XXX" }
},
"title": "contract",
"type": "object"
}
</code>
<h2 id="examples">Examples</h2>
<h3>request information about the communication options available on the URI</h3>
<table>
<tr><th>request</th><th>response</th></tr>
<tr><td>
<code>
OPTIONS /api/contacts/ HTTP/1.1
</code>
</td><td>
<code>
HTTP/1.1 200 OK
Allow: GET, HEAD, OPTIONS, POST
<a href="#draft-wilde-accept-post">Accept-Post</a>: application/hal+json; profile=http://example.com/#rel-contacts
</code>
</td></tr>
</table>
<h3>retrieve information that is identified by the URI</h3>
<table>
<tr><th>request</th><th>response</th></tr>
<tr><td>
<code>
GET /api/contacts/ HTTP/1.1
</code>
</td><td>
<code>
HTTP/1.1 200 OK
Content-Type: application/hal+json; profile="http://example.com/#rel-contacts"
ETag: "ni:/sha3-256;JH7uV8gwV5paX1NL1WK0YPo8KgAMUl+UwqW4c1tm9DA"
Last-Modified: Thu, 21 Nov 2013 16:34:07 GMT
Link: &lt;http://example.com/>; <a href="#rfc6906">rel=profile</a>
<a href="#rfc5988">Link</a>: &lt;/api/contacts/>; rel=self
Link: &lt;/api/resellers/?id=1>; rel="http://example.com/#rel-contacts"
Link: &lt;/api/resellers/?id=2>; rel="http://example.com/#rel-contacts"
{
"_embedded" : {
"ngcp:contacts" : [
{
"_links" : {
"collection" : {
"href" : "/api/contacts/"
},
"curies" : {
"href" : "http://example.com/#rel-{rel}",
"name" : "ngcp",
"templated" : true
},
"ngcp:resellers" : {
"href" : "/api/resellers/?id=1"
},
"profile" : {
"href" : "http://example.com/"
},
"self" : {
"href" : "/api/contacts/?id=1"
}
},
"city" : null,
"company" : null,
"country" : null,
"email" : "default-customer@default.invalid",
"firstname" : "Default",
"lastname" : "Contact",
"phonenumber" : null,
"postcode" : null,
"street" : null
},
{
"_links" : {
"collection" : {
"href" : "/api/contacts/"
},
"curies" : {
"href" : "http://example.com/#rel-{rel}",
"name" : "ngcp",
"templated" : true
},
"profile" : {
"href" : "http://example.com/"
},
"self" : {
"href" : "/api/contacts/?id=2"
}
},
"city" : "McMurdo",
"company" : "Internet Widgets",
"country" : "AQ",
"email" : "a.customr@mail.example.net",
"firstname" : "Any",
"lastname" : "Customr",
"phonenumber" : null,
"postcode" : "000045",
"street" : null
}
]
},
"_links" : {
"curies" : {
"href" : "http://example.com/#rel-{rel}",
"name" : "ngcp",
"templated" : true
},
"ngcp:contacts" : [
{
"href" : "/api/resellers/?id=1"
},
{
"href" : "/api/resellers/?id=2"
}
],
"profile" : {
"href" : "http://example.com/"
},
"self" : {
"href" : "/api/contacts/"
}
}
}
</code>
</td></tr>
</table>
<h3>create a new subordinate of the resource identified by the URI</h3>
<table>
<tr><th>request</th><th>response</th></tr>
<tr><td>
<code>
POST /api/contacts/ HTTP/1.1
Content-Type: application/hal+json
{ "city" : null, "company" : null, "country" : null, "email" : "someid@internal.example.net", "firstname" : "F", "lastname" : "L", "phonenumber" : null, "postcode" : null, "street" : null }
</code>
</td><td>
<code>
HTTP/1.1 201 Created
Location: /api/contacts/?id=9
</code>
</td></tr>
</table>
<h3>store entity under the URI</h3>
<table>
<tr><th>request</th><th>response</th></tr>
<tr><td>
<code>
PUT /api/contacts/?id=9 HTTP/1.1
Content-Type: application/hal+json
If-Match: "ni:/sha3-256;J4dSzVbt1ZFupOVeb0srYPj3gUhzP98IodP1hrniN5w"
<a href="#draft-snell-http-prefer">Prefer</a>: return=minimal
{ "city" : "C", "company" : null, "country" : "ZZ", "email" : "someid@internal.example.net", "firstname" : "F", "lastname" : "L", "phonenumber" : null, "postcode" : null, "street" : "S" }
</code>
</td><td>
<code>
HTTP/1.1 204 No Content
Preference-Applied: return=minimal
</code>
</td></tr>
</table>
<h3>apply set of changes to the resource identified by the URI</h3>
<table>
<tr><th>request</th><th>response</th></tr>
<tr><td>
<code>
<a href="#rfc5789">PATCH</a> /api/contacts/?id=9 HTTP/1.1
Content-Type: <a href="#rfc6902">application/json-patch+json</a>
If-Match: "ni:/sha3-256;u2ICdb8IG8m7E+9TX8qg9O5DTrcILrQ51e1jzjDIsRr"
Prefer: return=representation
[{ "op": "replace", "path": "/email", "value": "fnord@example.net" }]
</code>
</td><td>
<code>
HTTP/1.1 200 OK
Content-Type: application/hal+json; profile=http://example.com/
ETag: "ni:/sha3-256;stgh2IJ+B38gLTjOKYz8z7p0n5lb6Gwi7124bmCfqYN"
Last-Modified: Wed, 27 Nov 2013 11:22:55 GMT
Link: &lt;/api/contacts/>; rel=collection
Link: &lt;http://example.com/>; rel=profile
Link: &lt;/api/contacts/?id=9>; rel=self
Preference-Applied: return=representation
{
"_links" : {
"collection" : {
"href" : "/api/contacts/"
},
"curies" : {
"href" : "http://example.com/#rel-{rel}",
"name" : "ngcp",
"templated" : true
},
"profile" : {
"href" : "http://example.com/"
},
"self" : {
"href" : "/api/contacts/?id=9"
}
},
"city" : "C",
"company" : null,
"country" : "ZZ",
"email" : "fnord@example.net",
"firstname" : "F",
"lastname" : "L",
"phonenumber" : null,
"postcode" : null,
"street" : "S"
}
</code>
</td></tr>
</table>
<h2 id="hints">Implementation hints</h2>
<p><a href="#rfc2616">HTTP</a> is a rich application protocol. A client <a href="#rfc2119">MUST</a> handle
errors, request retries and caching as indicated by the normative status codes and protocol headers. In order
to avoid unnecessary load, it is <a href="#rfc2119">RECOMMENDED</a> that an implementation uses a high-quality
programmable HTTP library that supports persistent connections and retry back-off.</p>
<p id="hints-tight-coupling">Avoid tight coupling to URIs, discover resources by following hyperlinks with the
<a href="#collection">collection</a> relation. (They are highlighted with a green border in this document.) If
an implementation disregards this, it risks breaking with future releases.</p>
<p id="hints-link-header">Resource relations are also replicated as <a href="#rfc5988">Link</a> headers. Use
<span>HEAD</span> requests to traverse relations without transferring entity bodies.</p>
<h2 id="bugs">Bugs and incompatibilities</h2>
<p id="bug-te-gzip">When sending the request header <span>TE: gzip</span>, the application erroneously returns
the header <span>Transfer-Encoding: chunked</span>, whereas <span>Transfer-Encoding: gzip, chunked</span> would
be correct. If you are prepared to change the client code once the bug is fixed, you <a href="#rfc2119">MAY</a>
work around this incompatibility by detecting the magic signature octets <span>0x1f 0x8b</span> and then assume
the correct header. (Until the bug is fixed, it is guaranteed that the application returns either gzip transfer
coding, or uncompressed, and nought else. The chunked coding is applied in any case.)</p>
<h2 id="future">Future considerations</h2>
<h3 id="versioning">Versioning</h3>
<p id="versioning-incrementally">According to hypermedia best practices, there are no numbered versions in the
mechanism itself; functionality is added or subtracted incrementally. This document will keep a log of changes.
Incompatible changes require minting new relations and deprecations of existing ones.</p>
<h3 id="feed-paging">Feed paging</h3>
<p>Add the link relations <span>first</span>, <span>last</span>, <span>next</span>, <span>prev</span>. When a
resource has a link with the <span>next</span> or <span>prev</span> relations, it indicates that the resource
represents an paginated, incomplete feed. Traverse the links to assemble the complete logical feed.</p>
<h3 id="search">Search</h3>
<p>using URI templates</p>
<h3 id="prefetch">Link prefetching</h3>
<p>using the link relation <span>prefetch</span> is an opportunity for optimising closely related requests</p>
<h3 id="home-document">Home document</h3>
<p>a list of resource hints, useful for generating code</p>
<h2 id="definitions">Definitions</h2>
<dl>
<dt id="draft-wilde-accept-post">Accept-Post header</dt>
<dd><a href="http://tools.ietf.org/html/draft-wilde-accept-post">draft-wilde-accept-post</a></dd>
<dt id="rfc6902">JSON Patch</dt>
<dd><a href="http://tools.ietf.org/html/rfc6902">RFC 6902</a></dd>
<dt id="draft-kelly-json-hal">HAL</dt>
<dd><a href="http://tools.ietf.org/html/draft-kelly-json-hal">draft-kelly-json-hal</a></dd>
<dt id="rfc2616">HTTP</dt>
<dd><a href="http://tools.ietf.org/html/rfc2616">RFC 2616</a></dd>
<dt id="rfc5988">Link headers</dt>
<dd><a href="http://tools.ietf.org/html/rfc5988">RFC 5988</a></dd>
<dt id="rfc2119">MAY, MUST, RECOMMENDED</dt>
<dd><a href="http://tools.ietf.org/html/rfc2119">RFC 2119</a></dd>
<dt id="rfc5789">PATCH method</dt>
<dd><a href="http://tools.ietf.org/html/rfc5789">RFC 5789</a></dd>
<dt id="draft-snell-http-prefer">Prefer header</dt>
<dd><a href="http://tools.ietf.org/html/draft-snell-http-prefer">draft-snell-http-prefer</a></dd>
<dt id="rfc6906">profile link relation</dt>
<dd><a href="http://tools.ietf.org/html/rfc6906">RFC 6906</a></dd>
<dt id="iana-relations">standard link relations</dt>
<dd><a href="https://iana.org/assignments/link-relations">IANA link relations registry</a></dd>
</dl>
</body>
</html>

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Unprocessable entity</title>
</head>
<body>
<p>The entity could not be processed.</p>
<p><samp>[% error_message | html %]</samp></p>
</body>
</html>

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Bad request</title>
</head>
<body>
<p>The entity is not a well-formed <tt>[% media_type | html %]</tt> document.</p>
<p><samp>[% error_message | html %]</samp></p>
</body>
</html>

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<title>Unsupported media type</title>
</head>
<body>
<p>Unsupported media type, accepting entities of type <tt>[% media_type | html %]</tt> only.</p>
</body>
</html>
Loading…
Cancel
Save