File Coverage

blib/lib/PONAPI/Server.pm
Criterion Covered Total %
statement 179 181 98.9
branch 93 114 81.5
condition 70 96 72.9
subroutine 25 25 100.0
pod 2 2 100.0
total 369 418 88.2


line stmt bran cond sub pod time code
1             # ABSTRACT: PONAPI - Perl implementation of {JSON:API} (http://jsonapi.org/) v1.0
2             package PONAPI::Server;
3              
4 3     3   264749 use strict;
  3         9  
  3         76  
5 3     3   16 use warnings;
  3         6  
  3         182  
6              
7             our $VERSION = '0.002006';
8              
9 3     3   1664 use Plack::Request;
  3         108391  
  3         153  
10 3     3   2175 use Plack::Response;
  3         11622  
  3         86  
11 3     3   2402 use HTTP::Headers::ActionPack;
  3         10540  
  3         85  
12 3     3   19 use Module::Runtime ();
  3         6  
  3         51  
13 3     3   2097 use Return::MultiLevel ();
  3         9589  
  3         64  
14 3     3   18 use JSON::XS ();
  3         5  
  3         66  
15 3     3   16 use URI::Escape qw( uri_unescape );
  3         4  
  3         164  
16              
17 3     3   1809 use PONAPI::Server::ConfigReader;
  3         11  
  3         117  
18 3     3   2182 use PONAPI::Utils::Names qw( check_name );
  3         13  
  3         267  
19              
20 3     3   32 use parent 'Plack::Component';
  3         7  
  3         24  
21              
22             use constant {
23 3         6699 ERR_MISSING_CONTENT_TYPE => +{ __error__ => +[ 415, "{JSON:API} Missing Content-Type header" ] },
24             ERR_WRONG_CONTENT_TYPE => +{ __error__ => +[ 415, "{JSON:API} Invalid Content-Type header" ] },
25             ERR_WRONG_HEADER_ACCEPT => +{ __error__ => +[ 406, "{JSON:API} Invalid Accept header" ] },
26             ERR_BAD_REQ => +{ __error__ => +[ 400, "{JSON:API} Bad request" ] },
27             ERR_BAD_REQ_INVALID_NAME => +{ __error__ => +[ 400, "{JSON:API} Bad request (invalid member-name)" ] },
28             ERR_BAD_REQ_PARAMS => +{ __error__ => +[ 400, "{JSON:API} Bad request (unsupported parameters)" ] },
29             ERR_SORT_NOT_ALLOWED => +{ __error__ => +[ 400, "{JSON:API} Server-side sorting not allowed" ] },
30             ERR_NO_MATCHING_ROUTE => +{ __error__ => +[ 404, "{JSON:API} No matching route" ] },
31 3     3   9871 };
  3         8  
32              
33             my $qr_member_name_prefix = qr/^[a-zA-Z0-9]/;
34              
35             sub prepare_app {
36 9     9 1 8627 my $self = shift;
37              
38 9         19 my %conf;
39 9         17 local $@;
40 9         22 eval {
41 9         474 %conf = PONAPI::Server::ConfigReader->new( dir => 'conf' )->read_config;
42             };
43 9   100     387 $self->{$_} //= $conf{$_} for keys %conf;
44              
45             # Some defaults
46 9         181 my $default_media_type = 'application/vnd.api+json';
47 9   50     32 $self->{'ponapi.spec_version'} //= '1.0';
48 9   33     29 $self->{'ponapi.mediatype'} //= $default_media_type;
49              
50 9         37 $self->_load_dao();
51             }
52              
53             sub call {
54 50     50 1 249181 my ( $self, $env ) = @_;
55 50         366 my $req = Plack::Request->new($env);
56              
57             my $ponapi_params = Return::MultiLevel::with_return {
58 50     50   1910 $self->_ponapi_params( shift, $req )
59 50         746 };
60              
61             return $self->_error_response( $ponapi_params->{__error__} )
62 50 100       1462 if $ponapi_params->{__error__};
63              
64 25         69 my $action = delete $ponapi_params->{action};
65 25         222 my ( $status, $headers, $res ) = $self->{'ponapi.DAO'}->$action($ponapi_params);
66 25         352 return $self->_response( $status, $headers, $res );
67             }
68              
69              
70             ### ...
71              
72             sub _load_dao {
73 9     9   16 my $self = shift;
74              
75             my $repository =
76 9   50     55 Module::Runtime::use_module( $self->{'repository.class'} )->new( @{ $self->{'repository.args'} } )
77             || die "[PONAPI Server] failed to create a repository object\n";
78              
79             $self->{'ponapi.DAO'} = PONAPI::DAO->new(
80             repository => $repository,
81 8         637 version => $self->{'ponapi.spec_version'},
82             );
83             }
84              
85             sub _ponapi_params {
86 50     50   109 my ( $self, $wr, $req ) = @_;
87              
88             # THE HEADERS
89 50         176 $self->_ponapi_check_headers($wr, $req);
90              
91             # THE PATH --> route matching
92 47         1620 my @ponapi_route_params = $self->_ponapi_route_match($wr, $req);
93              
94             # THE QUERY
95 41         163 my @ponapi_query_params = $self->_ponapi_query_params($wr, $req);
96              
97             # THE BODY CONTENT
98 33         123 my @ponapi_data = $self->_ponapi_data($wr, $req);
99              
100             # misc.
101 25 50       284 my $req_base = $self->{'ponapi.relative_links'} eq 'full' ? "".$req->base : '/';
102 25 50       144 my $req_path = $self->{'ponapi.relative_links'} eq 'full' ? "".$req->uri : $req->path_info;
103 25         180 my $update_200 = !!$self->{'ponapi.respond_to_updates_with_200'};
104 25 100       98 my $doc_self_link = ($req->method eq 'GET') ? !!$self->{'ponapi.doc_auto_self_link'} : 0;
105              
106 25         432 my %params = (
107             @ponapi_route_params,
108             @ponapi_query_params,
109             @ponapi_data,
110             req_base => $req_base,
111             req_path => $req_path,
112             respond_to_updates_with_200 => $update_200,
113             send_doc_self_link => $doc_self_link,
114             );
115              
116 25         226 return \%params;
117             }
118              
119             sub _ponapi_route_match {
120 47     47   102 my ( $self, $wr, $req ) = @_;
121 47         173 my $method = $req->method;
122              
123 47 50       326 $wr->(ERR_BAD_REQ) unless grep { $_ eq $method } qw< GET POST PATCH DELETE >;
  188         368  
124              
125 47         202 my ( $type, $id, $relationships, $rel_type ) = split '/' => substr($req->path_info,1);
126              
127             # validate `type`
128 47 100 100     847 $wr->(ERR_BAD_REQ) unless defined $type and $type =~ /$qr_member_name_prefix/ ;
129              
130             # validate `rel_type`
131 44 100       177 if ( defined $rel_type ) {
    100          
132 7 50       31 $wr->(ERR_BAD_REQ) if $relationships ne 'relationships';
133             }
134             elsif ( $relationships ) {
135 1         2 $rel_type = $relationships;
136 1         3 undef $relationships;
137             }
138              
139 44         98 my $def_rel_type = defined $rel_type;
140              
141 44 100 100     198 $wr->(ERR_BAD_REQ) if $def_rel_type and $rel_type !~ /$qr_member_name_prefix/;
142              
143             # set `action`
144 43         70 my $action;
145 43 100       116 if ( defined $id ) {
146 32 100 100     140 $action = 'create_relationships' if $method eq 'POST' and $relationships and $def_rel_type;
      66        
147 32 100 100     252 $action = 'retrieve' if $method eq 'GET' and !$relationships and !$def_rel_type;
      100        
148 32 100 100     236 $action = 'retrieve_by_relationship' if $method eq 'GET' and !$relationships and $def_rel_type;
      100        
149 32 100 100     204 $action = 'retrieve_relationships' if $method eq 'GET' and $relationships and $def_rel_type;
      66        
150 32 50 66     127 $action = 'update' if $method eq 'PATCH' and !$relationships and !$def_rel_type;
      33        
151 32 50 66     112 $action = 'update_relationships' if $method eq 'PATCH' and $relationships and $def_rel_type;
      66        
152 32 100 100     141 $action = 'delete' if $method eq 'DELETE' and !$relationships and !$def_rel_type;
      66        
153 32 100 100     173 $action = 'delete_relationships' if $method eq 'DELETE' and $relationships and $def_rel_type;
      66        
154             }
155             else {
156 11 100       38 $action = 'retrieve_all' if $method eq 'GET';
157 11 100       42 $action = 'create' if $method eq 'POST';
158             }
159              
160 43 100       133 $wr->(ERR_NO_MATCHING_ROUTE) unless $action;
161              
162             # return ( action, type, id?, rel_type? )
163 41         139 my @ret = ( action => $action, type => $type );
164 41 100       196 defined $id and push @ret => id => $id;
165 41 100       121 $def_rel_type and push @ret => rel_type => $rel_type;
166 41         235 return @ret;
167             }
168              
169             sub _ponapi_check_headers {
170 50     50   100 my ( $self, $wr, $req ) = @_;
171              
172 50         393 my $pack = HTTP::Headers::ActionPack->new;
173 50         2712 my $mt = $self->{'ponapi.mediatype'};
174              
175             # check Content-Type
176 50         222 my $content_type = $req->headers->header('Content-Type');
177 50 100       8652 $wr->(ERR_MISSING_CONTENT_TYPE) unless $content_type;
178 49 100       156 $wr->(ERR_WRONG_CONTENT_TYPE) unless $content_type eq $mt;
179              
180             # check Accept
181 48 100       165 if ( my $accept = $req->headers->header('Accept') ) {
182             my @jsonapi_accept =
183 2 50       65 map { ( $_->[1]->type eq $mt ) ? $_->[1] : () }
  2         537158  
184             $pack->create_header( 'Accept' => $accept )->iterable;
185              
186             $wr->(ERR_WRONG_HEADER_ACCEPT)
187 2 100 66     44 if @jsonapi_accept and !( grep { $_->params_are_empty } @jsonapi_accept );
  2         11  
188             }
189             }
190              
191             sub _ponapi_query_params {
192 41     41   86 my ( $self, $wr, $req ) = @_;
193              
194 41         87 my %params;
195 41         165 my $query_params = $req->query_parameters;
196              
197 41         2824 my $unesacpe_values = !!$req->headers->header('X-PONAPI-Escaped-Values');
198              
199             # loop over query parameters (unique keys)
200 41         1220 for my $k ( keys %{ $query_params } ) {
  41         151  
201 18         126 my ( $p, $f ) = $k =~ /^ (\w+?) (?:\[(\w+)\])? $/x;
202              
203             # valid parameter names
204             $wr->(ERR_BAD_REQ_PARAMS)
205 18 50       41 unless grep { $p eq $_ } qw< fields filter page include sort >;
  90         155  
206              
207             # "complex" parameters have the correct structre
208             $wr->(ERR_BAD_REQ)
209 18 100 100     66 if !defined $f and grep { $p eq $_ } qw< page fields filter >;
  42         107  
210              
211             # 'sort' requested but not supported
212             $wr->(ERR_SORT_NOT_ALLOWED)
213 14 50 33     48 if $p eq 'sort' and !$self->{'ponapi.sort_allowed'};
214              
215             # values can be passed as CSV
216 11 50       47 my @values = map { $unesacpe_values ? uri_unescape($_) : $_ }
217 14         65 map { split /,/ } $query_params->get_all($k);
  14         195  
218              
219             # check we have values for a given key
220             # (for 'fields' an empty list is valid)
221             $wr->(ERR_BAD_REQ)
222 14 100 66     118 if $p ne 'fields' and exists $query_params->{$k} and !@values;
      66        
223              
224             # values passed on in array-ref
225 20         74 grep { $p eq $_ } qw< fields filter >
226 10 100       19 and $params{$p}{$f} = \@values;
227              
228             # page info has one value per request
229 10 50       32 $p eq 'page' and $params{$p}{$f} = $values[0];
230              
231             # values passed on in hash-ref
232 10 100       35 $p eq 'include' and $params{include} = \@values;
233              
234             # sort values: indicate direction
235             # Not doing any processing here to allow repos to support
236             # complex sorting, if they want to.
237 10 50       40 $p eq 'sort' and $params{'sort'} = \@values;
238             }
239              
240 33         129 return %params;
241             }
242              
243             sub _ponapi_data {
244 33     33   70 my ( $self, $wr, $req ) = @_;
245              
246 33 100       138 return unless $req->content_length > 0;
247              
248 12 50       105 $wr->(ERR_BAD_REQ) if $req->method eq 'GET';
249              
250 12         86 my $body;
251 12         21 eval { $body = JSON::XS::decode_json( $req->content ); 1 };
  12         46  
  11         11220  
252              
253 12 50 66     1382 $wr->(ERR_BAD_REQ) unless $body and ref $body eq 'HASH' and exists $body->{data};
      66        
254              
255 11         25 my $data = $body->{data};
256              
257 11 100 66     110 $wr->(ERR_BAD_REQ) unless !defined $data or ref($data) =~ /^(?:ARRAY|HASH)$/;
258              
259 10 50       56 $self->_validate_data_members( $wr, $data ) if defined $data;
260              
261 4         21 return ( data => $data );
262             }
263              
264             sub _validate_data_members {
265 10     10   22 my ( $self, $wr, $data ) = @_;
266              
267 10 100       40 my @recs = ref $data eq 'ARRAY' ? @{$data} : $data;
  2         7  
268              
269 10         25 for my $r ( @recs ) {
270 10 50       23 return unless keys %{$r};
  10         38  
271              
272             # `type`
273 10 50       35 $wr->(ERR_BAD_REQ) unless $r->{type};
274 10 100       61 $wr->(ERR_BAD_REQ_INVALID_NAME) unless check_name( $r->{type} );
275              
276             # `attributes`
277 9 100       1140 if ( $r->{attributes} ) {
278 3 100       15 $wr->(ERR_BAD_REQ) unless ref( $r->{attributes} ) eq 'HASH';
279             $wr->(ERR_BAD_REQ_INVALID_NAME)
280 2 100       5 if grep { !check_name($_) } keys %{ $r->{attributes} };
  3         11  
  2         8  
281             }
282              
283             # `relationships`
284 7 100       36 if ( $r->{relationships} ) {
285 3 100       14 $wr->(ERR_BAD_REQ) unless ref( $r->{relationships} ) eq 'HASH';
286              
287 2         3 for my $k ( keys %{ $r->{relationships} } ) {
  2         10  
288 2 100       7 $wr->(ERR_BAD_REQ_INVALID_NAME) unless check_name($k);
289              
290 1         3 for ( keys %{ $r->{relationships}{$k} } ) {
  1         6  
291             $wr->(ERR_BAD_REQ)
292             if !ref( $r->{relationships}{$k}{$_} ) eq 'HASH'
293 1 50 33     13 or !exists $r->{relationships}{$k}{$_}{type};
294              
295             $wr->(ERR_BAD_REQ_INVALID_NAME)
296             if !check_name( $r->{relationships}{$k}{$_}{type} )
297 1 50 33     5 or grep { !check_name($_) } keys %{ $r->{relationships}{$k}{$_} };
  0         0  
  0         0  
298             }
299             }
300             }
301             }
302             }
303              
304             sub _response {
305 50     50   118 my ( $self, $status, $headers, $content ) = @_;
306 50   50     526 my $res = Plack::Response->new( $status || 200 );
307              
308 50         1061 $res->headers($headers);
309 50         1251 $res->content_type( $self->{'ponapi.mediatype'} );
310             $res->header( 'X-PONAPI-Server-Version' => $self->{'ponapi.spec_version'} )
311 50 50       1260 if $self->{'ponapi.send_version_header'};
312 50 50       2123 if ( ref $content ) {
313 50         629 my $enc_content = JSON::XS::encode_json $content;
314 50         272 $res->content($enc_content);
315 50         560 $res->content_length( length($enc_content) );
316             }
317 50         1610 $res->finalize;
318             }
319              
320             sub _error_response {
321 25     25   47 my ( $self, $args ) = @_;
322              
323             return $self->_response( $args->[0], [], +{
324 25         260 jsonapi => { version => $self->{'ponapi.spec_version'} },
325             errors => [ { detail => $args->[1], status => $args->[0] } ],
326             });
327             }
328              
329             1;
330              
331             __END__
332              
333             =pod
334              
335             =encoding UTF-8
336              
337             =head1 NAME
338              
339             PONAPI::Server - PONAPI - Perl implementation of {JSON:API} (http://jsonapi.org/) v1.0
340              
341             =head1 VERSION
342              
343             version 0.002006
344              
345             =head1 SYNOPSIS
346              
347             # Run the server
348             $ plackup -MPONAPI::Server -e 'PONAPI::Server->new("repository.class" => "Test::PONAPI::Repository::MockDB")->to_app'
349              
350             $ perl -MPONAPI::Client -E 'say Dumper(PONAPI::Client->new->retrieve(type => "people", id => 88))'
351              
352             # Or with cURL:
353             $ curl -X GET -H "Content-Type: application/vnd.api+json" 'http://0:5000/people/88'
354              
355             =head1 DESCRIPTION
356              
357             C<PONAPI::Server> is a small plack server that implements the
358             L<{json:api}|http://jsonapi.org/> specification.
359              
360             You'll have to set up a repository (to provide access to the data
361             you want to server) and tweak some server configurations, so
362             hop over to L<PONAPI::Manual> for the next steps!
363              
364             =head1 BUGS, CONTACT AND SUPPORT
365              
366             For reporting bugs or submitting patches, please use the github
367             bug tracker at L<https://github.com/mickeyn/PONAPI>.
368              
369             =head1 AUTHORS
370              
371             =over 4
372              
373             =item *
374              
375             Mickey Nasriachi <mickey@cpan.org>
376              
377             =item *
378              
379             Stevan Little <stevan@cpan.org>
380              
381             =item *
382              
383             Brian Fraser <hugmeir@cpan.org>
384              
385             =back
386              
387             =head1 COPYRIGHT AND LICENSE
388              
389             This software is copyright (c) 2016 by Mickey Nasriachi, Stevan Little, Brian Fraser.
390              
391             This is free software; you can redistribute it and/or modify it under
392             the same terms as the Perl 5 programming language system itself.
393              
394             =cut