NAME
PONAPI::Manual - An introduction to using PONAPI::Server
VERSION
version 0.001006
DESCRIPTION
The origin of the name PONAPI (pronounced: Po-Na-Pi) is JSONAPI. We jokingly decided to replace the JavaScript reference (JS) with a Perl one (P).
Even though 'Perl Object Notaion' is a made-up thing, we liked how it sounds; so we kept it.
This document describes how to use and set up PONAPI::Server
. The latter parts describe how to set up a repository from scratch, including a set-by-step of the implementation.
The recommended usage is to use PONAPI::Server directly as a PSGI application:
# myapp.psgi
use PONAPI::Server;
PONAPI::Server->new(
'repository.class' => 'Test::PONAPI::DAO::Repository::MockDB',
)->to_app;
And later, on the command line, you can use any PSGI-compatible utility to run the application:
plackup -p 5000 myapp.psgi
Any {json:api} compliant client can then access your application:
$ perl -MPONAPI::Client -MData::Dumper -E 'say Dumper(PONAPI::Client->new->retrieve(type => "people", id => 88))'
$ curl -X GET -H "Content-Type: application/vnd.api+json" 'http://0:5000/people/88
That'll give you the default options, with the default repository; for a real world scenario, you'll have to get your hands dirty.
Because {json:api} extensively uses the PATCH
method, we recommend using Plack::Middleware::MethodOverride or another similar middleware to enable HTTP method overriding; this is to allow clients without PATCH to still use the API:
# myapp.psgi
use PONAPI::Server;
use Plack::Middleware::MethodOverride;
my $app = PONAPI::Server->to_app(
'repository.class' => 'Test::PONAPI::DAO::Repository::MockDB',
)
Plack::Middleware::MethodOverride->wrap($app);
SERVER CONFIGURATION
The server looks for a YAML configuration file in conf/server.yml
, under the current working directory.
# PONAPI server & repository configuration file
# switch options take the positive values: "yes", 1 & "true"
# and negative values: "no", 0 & "false"
server:
spec_version: "1.0" # {json:api} version
sort_allowed: "false" # server-side sorting support
send_version_header: "true" # server will send 'X-PONAPI-Server-Version' header responses
send_document_self_link: "true" # server will add a 'self' link to documents without errors
links_type: "relative" # all links are either "relative" or "full" (inc. request base)
respond_to_updates_with_200: "false" # successful updates will return 200's instead of 202's when true
repository:
class: "Test::PONAPI::DAO::Repository::MockDB"
args: []
Currently, only two sections of server.yml
are relevant to PONAPI::Server: server
, which influences how the server behaves, and repository
, which is the class that controls how the data is acquired. See "CREATING A REPOSITORY" for more on the latter.
- spec_version
-
The {json:api} version that the server is serving. Must be present.
- sort_allowed
-
true/false. If true, server-side sorting is supported. If false, requests including the
sort
parameter will immediately return an error, without ever reaching the repository. - send_version_header
-
true/false. If true, all responses will include the {json:api} spec version set in
spec_version
, through a customX-PONAPI-Server-Version
header. - send_document_self_link
-
true/false. On successful operations, the document will be forced to include a
links
section with aself
key, which will usually point to the request path (but not always -- consider pagination). - links_type
-
Either
"relative"
or"full"
-- full links will include the request base (hostname), while relative requests will not:/articles/1 # Relative link http://localhost:5000/articles/1 # Full link
- respond_to_updates_with_200
-
true/false.
This is false by default, which will cause successful update-like operations (
update
,update_relationships
,create_relationships
, anddelete_relationships
) to immediately return with a 202, instead of a 200.This is often desirable, because the specification mandates that certain update operations returning a 200 do an extra retrieve and pass over that information to the client. Due to several factors (e.g. replication delay) that retrieve might either need to be run on the master, or return unreliable information, and may slow down requests due to the extra fetch.
CREATING A REPOSITORY
The other relevant section of server.yml
is repository
, which may look something like this:
repository:
class: "My::Repository::Yadda"
args: []
Where My::Repository::Yadda
is a class that consumes the PONAPI::Repository
role; args will be passed to the repo when it is instantiated.
To use PONAPI, we'll need a repo to communicate between the server and the data we want to server, so let's create a minimal repo from scratch. Our aim here is to have a repo that will handle something like a minimalistic blog, so we need to figure out what data types we'll handle, and what their relationships are.
For this example, we'll have three types -- articles, comments, and people -- and two relationships: articles have one or more comments, and articles have one author.
It may help to think of relationships as something like foreign keys in SQL.
In any case, the structure we want to have is this: articles -> has comments -> has an author (type people)
And in SQL, the tables might look something like this:
CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title CHAR(64) NOT NULL,
body TEXT NOT NULL,
created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE people (
id INTEGER PRIMARY KEY,
name CHAR(64) NOT NULL DEFAULT "anonymous",
age INTEGER NOT NULL DEFAULT "100",
gender CHAR(10) NOT NULL DEFAULT "unknown"
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL DEFAULT ""
);
And finally, on perl:
# First, some boilerplate...
package My::Repository::Hooray;
use Moose;
with 'PONAPI::Repository';
use PONAPI::Constants;
use PONAPI::Exception;
That is the initial boilerplate for any repo. The PONAPI::Repository role requires us to define several methods, which also require us to have some information regarding the data we are serving:
my %types = (
articles => 1,
comments => 1,
people => 1,
);
# Returns true if the repo handles $type
sub has_type {
my ($self, $type) = @_;
return 1 if $types{$type};
}
my %relationships => (
# Articles has two relationships, one to comments, and
# one to authors -- but authors actually has type 'people'
articles => {
comments => { type => 'comments', one_to_many => 1 },
authors => { type => 'people', one_to_many => 0 },
},
);
# Returns true if $type has a $rel_type relationships
sub has_relationship {
my ( $self, $type, $rel_name ) = @_;
return 1 if exists $relationships{$type}
&& exists $relationships{$type}{$rel_type};
}
# Returns true if $rel has a on
sub has_one_to_many_relationship {
my ( $self, $type, $rel_name ) = @_;
return unless $self->has_relationship($type, $rel_type);
return 1 if $relationships{$type}{$rel_type}{one_to_many};
}
The next method we have to define is type_has_fields
. We'll get a type and an arrayref of fields, and we'll return true if all all elements in the arrayref are attributes of type.
my %columns = (
articles => [qw/ id title body created updated /],
people => [qw/ id name age gender /],
comments => [qw/ id body /],
);
sub type_has_fields {
my ($self, $type, $has_fields) = @_;
return unless $self->has_type($type);
my %type_columns = map +($_=>1), @{ $columns{$type} };
return if grep !exists $type_columns{$_}, @$has_fields;
return 1;
}
With those out of the way, we can get to the meat of the repository:
API method
All of these will receive at least these two arguments: type
, the type of the request, and document
, an instance of PONAPI::Builder::Document.
Note that by the time the arguments reach the API methods, some amount of validation will have already happened, like if the requested relationships exists.
retrieve_all
retrieve_all( type => $type, %optional_arguments )
Our starting point:
sub retrieve_all {
my ($self, %args) = @_;
my $type = $args{type};
my $document = $args{document};
my $dbh = $self->dbh;
my $table = $dbh->quote($type);
my $sth = $dbh->prepare(qq{ SELECT * FROM $table });
return unless $sth->execute;
while ( my $row = $sth->fetchrow_hashref ) {
my $id = delete $row->{id};
# Add a new resource, identified by $type and $id
my $resource = $doc->add_resource( type => $type, id => $id );
# These will show up under the attributes key
$resource->add_attribute( $_ => $row->{$_} ) for keys %{$row};
# Add a links key, with pointer to ourselves
$resource->add_self_link();
}
return;
}
A call to $dao->retrieve_all(type => 'articles', ...)
will then return something like this:
{
data => [
{
type => 'articles',
id => 123456,
attributes => {
title => 'This Or That',
body => '...',
updated => '...',
created => '...',
},
links => {
self => '/articles/123456',
},
},
...
],
}
To be fully API compliant, we should also return the relationships that a given resource has. To do this, we'll need a way of finding what our related resources are. For simplicity, let's assume that our data in SQL is in some junction tables:
-- articles-to-authors, one to one
CREATE TABLE rel_articles_people (
id_articles INTEGER NOT NULL PRIMARY KEY,
id_people INTEGER NOT NULL
)
-- articles-to-comments, one to many
CREATE TABLE IF NOT EXISTS rel_articles_comments (
id_articles INTEGER NOT NULL,
id_comments INTEGER UNIQUE NOT NULL
)
Then, we can change %relationships
to have that same information:
my %relationships => (
articles => {
comments => {
type => 'comments',
one_to_many => 1,
rel_table => 'rel_articles_comments',
id_column => 'id_articles',
rel_id_column => 'id_comments',
},
authors => ...,
},
...
);
sub _fetch_relationships {
my ($self, %args) = @_;
my ($type, $id) = @args{qw/ type id /};
my $type_relationships = $relationships{$type} || {};
return unless %$type_relationships;
my $dbh = $self->dbh;
my %rels;
foreach my $rel_type ( keys %$type_relationships ) {
my $relationship_info = $type_relationships->{$rel_type};
my $table = $relationship_info->{rel_table};
my $id_col = $relationship_info->{id_column};
my $rel_id_col = $relationship_info->{rel_id_column};
my $sth = $dbh->prepare(
"SELECT $rel_id_col FROM $table WHERE $id_col = ?"
);
return unless $sth->execute($id);
while ( my $row = $sth->fetchrow_hashref ) {
push @{ $rels{$rel_type} ||= []}, $row->{$rel_id_col};
}
}
return \%rels;
}
sub _add_relationships {
my ($self, %args) = @_;
my $resource = $args{resource};
my $all_relationships = $self->_fetch_relationships(%args);
foreach my $rel_type ( keys %$all_relationships ) {
my $relationships = $all_relationships->{$rel_type} || [];
next unless @$relationships;
my $one_to_many = $self->has_one_to_many_relationship($type, $rel_type);
foreach my $rel_id ( @$relationships ) {
$resource->add_relationship( $rel_type, $rel_id, $one_to_many )
->add_self_link
->add_related_link;
}
}
}
sub retrieve_all {
...
# Inside the while loop:
$self->_add_relationships(
%args,
resource => $resource,
);
...
}
With this as our starting base, let's go over all the potential arguments we can receive, and see how we can implement them.
- include
-
Spec.
This will contain an arrayref of relationship names, like one of these:
[qw/ authors /] [qw/ authors comments /]
If present, our response needs to include a top-level
included
key with has full resources for every relationship of the types in the arrayref. For instance, if we getinclude => [qw/ comments /]
, and the requested resources have two comments, then ourinclude
will have those two, with the same information as if the user had manually calledretrieve
on each of them.The responses should look something like this:
{ data => [... same as before ...], included => [ { type => 'comments' id => 44, attributes => { body => 'I make terrible comments', }, links => { self => '/comments/44' }, } { type => 'comments' id => 45, attributes => { body => 'I make even worse comments!', }, links => { self => '/comments/45' }, } ], }
In our repo:
sub _add_included { my ( $self, $type, $ids, %args ) = @_; my ( $doc ) = @args{qw< document >}; my $placeholders = join ", ", ('?') x @$ids; my $sql = "SELECT * FROM $type WHERE id IN ( $placeholders )"; my $sth = $self->dbh->prepare($sql); $sql->execute(@$ids); while ( my $inc = $sth->fetchrow_hashref() ) { my $id = delete $inc->{id}; $doc->add_included( type => $type, id => $id ) ->add_attributes( %{$inc} ) ->add_self_link; } } sub _add_relationships { ... # same as before # Inside the main for loop $self->_add_include( $rel_type, $relationships, %args, ); ... }
- fields
-
Spec.
This will arrive as a hashref looking something like this:
type => 'articles', fields => { articles => [qw/ title /], }
Here, the meaning is quite simple: only return the 'title' attribute of the resource:
{ type => 'articles', id => 2, attributes => { title => "The great title!", }, # No relationships requested! relationships => {}, }
However, not only can we get attributes in
fields
, but also relationships. Consider a request with this:type => 'articles', fields => { articles => [qw/ title authors /], }
In this case, we'll want to return this:
{ type => 'articles', id => 2, attributes => { title => "The great title!", }, # No relationships requested! relationships => { authors => { type => 'people', id => 44 }, }, }
Moreso,
fields
can be combined withinclude
:type => 'articles', include => [qw/ authors /], fields => { articles => [], # no attributes or relationships people => [qw/ name /], },
And the response:
data => [ { type => "articles", id => 2, }, { type => "articles", id => 3, }, ], include => [ { type => 'people', id => 44, attributes => { name => "Foo" }, }, { type => 'people', id => 46, attributes => { name => "Bar" }, } ]
Sadly for the SQL example, this means we'll have to filter our relationships out of
fields
manually. On the bright side, PONAPI::DAO will have already validated that all of the requested fields are valid for the given type, so we can avoid those steps.Let's tackle fetching only soem attributes first. So far, we have two SELECT statements, one in retrieve_all, and one in _add_included; since both will need to handle
fields
, let's add a function to abstract that complexity away:sub _columns_for_select { my ($self, %args) = @_; my $type = $args{type}; my $columns = $columns{$type}; if ( my $fields = $args{fields}{$type} ) { my $type_relationships = $relationships{$type} || {}; $columns = [ 'id', grep !exists $type_relationships->{$_}, @$fields ]; } return $columns; }
Now, we could do something like this in retrieve_all and _add_included:
... my $dbh = $self->dbh; my $table = $dbh->quote($type); my $columns = $self->_columns_for_select(%args); $columns = join ', ', map $dbh->quote($_), @$columns; my $sth = $dbh->prepare(qq{ SELECT $columns FROM $table }); ...
(At this point, you should be seriously looking into alternatives to composing your own SQL. In the repository used for testing PONAPI::Server, we used SQL::Composer. The manual will continue rolling it's own SQL, but please don't do that. For your sanity.)
We'll also want to modify our _fetch_relationships to handle
fields
requesting only certain relationships:... foreach my $rel_type ( keys %$type_relationships ) { if ( $args{fields} && $args{fields}{$type} ) { next unless exists $args{fields}{$type}{$rel_type}; } ...
- page
-
Spec.
{json:api} doesn't specify much for
page
. The repository will get a hash, but the contents and format are entirely implementation dependent. For simplicity, let's take an offset-based approach, where page should look like this:page => { offset => 15000, limit => 5000, }
One options is to implement this in terms of a LIMIT:
... # in retrieve_all # Make sure to validate $args{page} first! The DAO won't do it # for you! my %page = %{ $args{page} || {} }; $page{offset} //= 0; my ($limit, $offset) = @page{qw/limit offset/}; $sql .= "\nLIMIT $offset, $limit" if $limit; ...
So far so good, but we may want to provide pagination links for the user to fetch the next batch of data.
my ($self, %args) = @_; my ($page, $rows_fetched, $document) = @args{qw/page rows document/}; my ($offset, $limit) = @{$page}{qw/offset limit/}; my %current = %$page; my %first = ( %current, offset => 0, ); my (%previous, %next); if ( ($offset - $limit) >= 0 ) { %previous = %current; $previous{offset} -= $current{limit}; } if ( $rows_fetched >= $limit ) { %next = %current; $next{offset} += $limit; } $document->add_pagination_links( first => \%first, self => \%current, prev => \%previous, next => \%next, );
- sort
-
Spec.
Will arrive as an arrayref of attributes, relationship names, and relationship attributes:
type => 'articles', # Just attributes sort => [qw/ created title /], type => 'articles', # attributes + relationship names sort => [qw/ created comments /], type => 'articles', # relationship attributes sort => [qw/ authors.name /],
Fields may start with a minus (
-
), which means they must be sorted in descending order:type => 'articles', # created DESC, title ASC sort => [qw/ -created title /],
For our SQL example, we'll only support sorting by first-level attributes, to keep the code simple:
... my $sort = $args{sort} || []; my $sort_sql = join ", ", map { my ($desc, $col) = /\A(-?)(.+)\z/; $col = exists $columns{$type}{$col} ? $dbh->quote($col) : undef; $col ? ( $desc ? "$col DESC" : "$col ASC" ) : (); } @$sort; $sql .= "\n ORDER BY $sort_sql" if $sort_sql; ...
Implementation note for SQL repositories: While not required, we recommend implementing sort so that sorting by
id
automagically uses whatever the underlaying column name is. This is to avoid clients needing to know specifics of the repo implementation to sort by the primary key:# Bad, client needs to know that id => id_articles $client->retrieve_all( type => 'articles', sort => [qw/article_id/], ); # Good, client just sorts by id, repo handles changing that to id_articles $client->retrieve_all( type => 'articles', sort => [qw/id/], );
- filter
-
Spec.
This is almost entirely up to the repo. You will receive a hashref, but the contents and how to handle them are entirely up to you.
To make implementing
retrieve
simpler, the example will implement a filter that allows for this:filter => { id => [ 1, 2, 55 ], # id IN (1, 2, 55) age => 44, # age = 44 },
And in the code:
... my $filter = $args{filter} || {}; my (@bind, @where); foreach my $col ( grep { exists $columns{$type}{$_} } keys %$filer ) { my $filter_values = $filter{$col}; my $quoted = $dbh->quote($col); my $where; if ( ref $filter_values ) { my $placeholder_csv = join ', ', ('?') x @$filter_values; $where = "$quoted IN ( $placeholder_csv )"; } else { $filter_values = [ $filter_values ]; $where = "$quoted = ?"; } push @where, "($where)"; push @bind, @$filter_values; } my $where = join " AND ", @where; ... $sql .= "\n$where" if $where; ... $sth->execute(@bind); ...
Note that the spec does not define any way to express complex filters, like
<=
or even a simpleOR
; this may change in future versions of the spec, or extended in future versions ofPONAPI
.
retrieve
retrieve( type => $type, id => $id, %optional_arguments )
Spec.
Similar to retrieve_all
, but instead of returning multiple resources, it returns just one. Takes all the same arguments as retrieve_all
, but page
and sort
are only relevant for include
d resources.
Since our <retrieve_all> example has a working filter
, we can implement retrieve
based on top of that:
sub retrieve {
my ($self, %args) = @_;
my $type = $args{type};
my $id = delete $args{id};
$args{filter}{$type} = $id;
# This is missing page+sort of the included arguments
return $self->retrieve_all(%args);
}
retrieve_relationships
retrieve_relationships( type => $type, id => $id, rel_type => $rel_type, %optional_arguments )
Spec.
For one-to-one relationships, this returns a single resource identifier:
$dao->retrieve_relationships( type => 'articles, id => 1, rel_type => 'authors' );
# returns
data => { type => 'people', id => 6 }
For one-to-many, it returns a collection of resource identifiers:
$dao->retrieve_relationships( type => 'articles, id => 1, rel_type => 'comments' );
# returns
data => [
{ type => 'comments', id => 45 },
{ type => 'comments', id => 46 },
]
retrieve_relationships
may get a subset of the optional arguments for retrieve_all
: filter
, page
, and sort
.
For the client, retrieve_relationships
serves primarily a shortcut to this:
my $response = $client->retrieve(
type => 'articles',
id => 2,
rel_type => 'comments',
fields => {
articles => [qw/comments/],
comments => [qw/id/],
},
)->{data}{relationships}{comments};
With the added functionality that they may page
the results.
retrieve_by_relationship
retrieve_by_relationship( type => $type, id => $id, rel_type => $rel_type, %optional_arguments )
Similar to retrieve_relationships
, but returns full resources, rather than just resource identifiers.
delete
delete(type => $type, id => $id)
Spec.
Removes a single resource.
sub delete {
my ($self, %args) = @_;
my $dbh = $self->dbh;
my $table = $dbh->quote($type);
my $sth = $dbh->prepare(qq{ DELETE FROM $table WHERE id = ? });
$sth->execute($args{id});
}
It's up to the repo if it also wants to remove any relationships what the resource may have.
create
create(type => $type, data => $data, %optinal_arguments)
Spec.
Create a new resource of type $type
, using the data provided. The only optional argument is id
-- repos can choose to use client-provided IDs, if desired.
$data
will be in this format:
$data => {
type => $type,
attributes => { ... },
relationships => {
foo => { ... }, # one-to-one
bar => [ { ... }, ... ], # one-to-many
},
}
Note that repos must add a resource to the document passed in, where the id
is the id of the created resource.
Implementation example -- we won't handle a user-provided id
.
sub create {
my ($self, %args) = @_;
my ($doc, $type, $data, $id) = @args{qw/document type data id/};
if ( $id ) {
PONAPI::Exception->throw(
message => "User-provided ids are not supported",
bad_request_data => 1,
);
}
my $attributes = $data->{attributes} || {};
my $relationships = $data->{relationships} || {};
my $dbh = $self->dbh;
my $table = $dbh->quote($type);
my $sql = "INSERT INTO $table ";
my @values;
if ( %$attributes ) {
my @quoted_columns = map $dbh->quote($_), keys %$attributes;
@values = values %$attributes;
$sql .= '(' . join(',', @quoted_columns) . ')'
. 'VALUES (' . join(',', '?' x @quoted_columns) . ')';
}
else {
$sql .= 'DEFAULT VALUES'; # assuming sqlite, will not work elsewhere
}
my $sth = $dbh->prepare($sql);
$sth->execute(@values);
# This is critical! We need to let the user know the inserted
# id
my $new_id = $self->dbh->last_insert_id("","","","");
$doc->add_resource( type => $type, id => $new_id );
# Finally, we defer creating relationships to another method
foreach my $rel_type ( keys %$relationships ) {
my $rel_data = $relationships->{$rel_type};
$self->update_relationships(
%args,
id => $new_id,
rel_type => $rel_type,
data => $rel_data,
);
}
}
update
update( type => $type, id => $id, data => { ... } )
Spec.
Updates the resource. data
may have one or both of attributes
and relationships
:
Change the title, leave everything else alone: data => { type => 'articles', id => 2, attributes => { title => 'New title!', }, }
Update the one-to-one author relationship, leave everything else alone: data => { type => 'articles', id => 2, relationships => { authors => { type => 'people', id => 3 }, } }
Update the one-to-many comments relationship, clear the one-to-one author relationship, change the body of the article:
data => {
type => 'articles',
id => 2,
attributes => { body => "New and improved", },
relationships => {
authors => undef,
comments => [
{ type => 'comments', id => 99 },
{ type => 'comments', id => 100 },
],
}
}
Clear the one-to-many comments relationship:
data => {
type => 'articles',
id => 2,
relationships => { comments => [] }
}
update
has a strict return value! It MUST return one of three constants imported by PONAPI::Constants:
- PONAPI_UPDATED_NOTHING
-
The requested update did nothing. Amongst other reasons, this may be because the resource doesn't exist, or the changes were no-ops.
Spec-wise, this is used to distinguish between returning a 200/202 and returning a 204 or 404; see http://jsonapi.org/format/#crud-updating-relationship-responses-204 and http://jsonapi.org/format/#crud-updating-responses-404
Depending on the underlaying implementation, this may be hard to figure out, so implementations may chose to just return
PONAPI_UPDATED_NORMAL
instead.(in
DBD::mysql
, you'll need to either connect to the database withmysql_client_found_rows
, or parse$dbh->{mysql_info}
forChanged
immediately after the UPDATE) - PONAPI_UPDATED_EXTENDED
-
The request asked us to update 2 rows, but for whatever reason, we updated more. As an example, this may be due to automatically adding the
updated_at
column on every update.Depending on the server configuration, returning
PONAPI_UPDATED_EXTENDED
may trigger an extraretrieve
on the updated resource, to ensure that the client has the most up to date data. - PONAPI_UPDATED_NORMAL
-
Everything went fine and we updated normally.
Code example:
sub update {
my ($self, %args) = @_;
my ($type, $id, $data) = @args{qw/type id data/};
my ($attributes, $relationships) = map $_||{}, @{$data}{qw/attributes relationships/};
my $return_value = PONAPI_UPDATED_NORMAL;
if ( %$attributes ) {
my $dbh = $self->dbh;
my ( @update, @values_for_update);
while ( my ($column, $new_value) = each %$attributes ) {
push @update, $dbh->quote($_) . " = ?";
push @values_for_update, $new_value;
}
my $sql = "UPDATE $table SET "
. join( ', ', @update )
. 'VALUES ('
. join( ',', ('?') x @values_for_update )
. ')';
my $sth = $dbh->prepare($sql);
$sth->execute(@values_for_update);
if ( !$sth->rows ) {
# Woah there. Either the resource doesn't exist, or
# the update did nothing
return PONAPI_UPDATED_NOTHING;
}
}
foreach my $rel_type ( keys %$relationships ) {
# We'll get to this later
$self->update_relationships(
%args,
rel_type => $rel_type,
data => $relationships->{$rel_type},
);
}
}
Since we're not adding any extra columns, we can ignore PONAPI_UPDATED_EXTENDED
.
delete_relationships
delete_relationships( type => $type, id => $id, rel_type => $rel_type, data => [ { ... }, ... ] )
Spec.
Removes the resource(s) in data
as from the one-to-many relationship pointed by $type and $rel_type.
Like update
, delete_relationships
has a strict return value.
sub delete_relationships {
my ( $self, %args ) = @_;
my ( $type, $id, $rel_type, $data ) = @args{qw< type id rel_type data >};
my $relationship_info = $type_relationships->{$rel_type};
my $table = $relationship_info->{rel_table};
my $id_column = $relationship_info->{id_column};
my $rel_id_column = $relationship_info->{rel_id_column};
my $key_type = $relationship_info->{type};
my @all_values;
foreach my $resource ( @$data ) {
my $data_type = $resource->{type};
# This is one of the few cases when we need to manually validate
# that the data is correct -- this catches cases like this:
# {
# rel_type => 'comments',
# data => [
# { type => comments => id => 5 }, # Good
# { type => people => id => 19 }, # Bad, why people?
# ],
# }
if ( $data_type ne $key_type ) {
PONAPI::Exception->throw(
message => "Data has type `$data_type`, but we were expecting `$key_type`",
bad_request_data => 1,
);
}
push @all_values, $resource->{id};
}
my $rows_modified = 0;
my $sql = "DELETE FROM $table WHERE $id_column = ? AND $rel_id_column = ?";
my $sth = $self->dbh->prepare($sql);
foreach my $rel_id ( @all_values ) {
$sth->execute($id, $rel_id);
$rows_modified += $sth->rows;
}
return !$rows_modified
? PONAPI_UPDATED_NOTHING
: PONAPI_UPDATED_NORMAL;
}
create_relationships
create_relationships( type => $type, id => $id, rel_type => $rel_type, data => [ { ... }, ... ] )
Spec.
Adds the resource(s) in data
as new members to the one-to-many relationship pointed by $type and $rel_type.
Like update
, create_relationships
has a strict return value.
Sample implementation:
sub create_relationships {
my ( $self, %args ) = @_;
my ( $type, $id, $rel_type, $data ) = @args{qw<type id rel_type data>};
my $dbh = $self->dbh;
my $table = $dbh->quote($type);
my $relationship_info = $type_relationships->{$rel_type};
my $rel_table = $relationship_info->{rel_table};
my $key_type = $relationship_info->{type};
my $id_column = $relationship_info->{id_column};
my $rel_id_column = $relationship_info->{rel_id_column};
my $sql = 'INSERT INTO ' . $dbh->quote($rel_table) . ' '
. '('
. join(',', map $dbh->quote($_), $id_column, $rel_id_column)
. ') VALUES (?, ?)';
my @all_values;
foreach my $relationship ( @$data ) {
my $data_type = $relationship->{type};
if ( $data_type ne $key_type ) {
PONAPI::Exception->throw(
message => "Data has type `$data_type`, but we were expecting `$key_type`",
bad_request_data => 1,
);
}
my $insert = [ $id, $relationship->{id} ];
push @all_values, $insert;
}
my $sth = $dbh->prepare($sql);
foreach my $values ( @all_values ) {
$sth->execute(@$values);
}
return PONAPI_UPDATED_NORMAL;
}
=head3 update_relationships
update_relationships( type => $type, id => $id, rel_type => $rel_type, data => ... )
Spec.
Unlike the previous two methods, update_relationships
handles both one-to-many and one-to-one relationships. For one-to-one, data
will be either a hashref, or undef
; for a one-to-many, it'll be an arrayref.
data => undef, # clear the one-to-one
data => { type => 'people', id => 781 }, # update the one-to-one
data => [], # clear the one-to-many,
data => [ # update the one-to-many
{ type => 'comments', id => 415 },
{ type => 'comments', id => 416 },
]
Like update
, update_relationships
has a strict return value.
sub update_relationships {
my ($self, %args) = @_;
my ( $type, $id, $rel_type, $data ) = @args{qw< type id rel_type data >};
my $relationship_info = $type_relationships->{$rel_type};
my $rel_table = $relationship_info->{rel_table};
my $id_column = $relationship_info->{id_column};
my $rel_id_column = $relationship_info->{rel_id_column};
# Let's have an arrayref
$data = $data
? ref($data) eq 'HASH' ? [ keys(%$data) ? $data : () ] : $data
: [];
# Let's start by clearing all relationships; this way
# we can implement the SQL below without adding special cases
# for ON DUPLICATE KEY UPDATE and sosuch.
my $sql = "DELETE FROM $rel_table WHERE $id_column = ?";
my $delete_sth = $self->dbh->prepare($sql);
$delete_sth->execute($id);
# And now, update the relationship by inserting into it
my $sql = "INSERT INTO $rel_table ($id_column, $id_rel_column) VALUES (?, ?)";
my $sth->prepare($sql);
foreach my $insert ( @$data ) {
$sth->execute( $id, $insert{id} );
}
return PONAPI_UPDATED_NORMAL;
}
What should the repository validate?
While the DAO takes care of most validations, some things fall squarely on repositories:
- Valid member names
-
The spec specifies certain restriction to member names.
PONAPI::Server
doesn't implement these, but instead, provides a module that allows implementing them at the repository level, PONAPI::Utils::Names. - Type validation in (create|update|delete)_relationships
-
This is one of the few cases when we need to manually validate that the data is correct, in order to catch cases like this:
{ rel_type => 'comments', data => [ { type => comments => id => 5 }, # Good { type => people => id => 19 }, # Bad, why people? ], }
The implementation examples above include these checks, and how to throw exceptions for them.
Throwing exceptions
It is recommended that you throw exceptions in the repo by using PONAPI::Exception:
PONAPI::Exception->throw(
message => "Something has gone horribly wrong"
);
See the documentation of PONAPI::Exception for more examples, including the different of more specific exceptions you can throw.
Responding with a particular status
Usually this is not needed, but it's possible to manually set the status of most responses by using $document->set_status($n)
in any API method.
Repos without relationships
This boils down to:
sub has_relationship {}
sub has_one_to_many_relationship {}
# These should never be reached if the two has_* methods
# above return false
sub retrieve_relationships {}
sub retrieve_by_relationship {}
sub create_relationships {}
sub delete_relationships {}
sub update_relationships {}
What about bulk inserts?
{json:api}
itself has no way to do bulk inserts or updates, but opens the possibility of these being implemented through extensions.
However, PONAPI::Server
doesn't support extensions yet.
AUTHORS
Mickey Nasriachi <mickey@cpan.org>
Stevan Little <stevan@cpan.org>
Brian Fraser <hugmeir@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2016 by Mickey Nasriachi, Stevan Little, Brian Fraser.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.