File Coverage

blib/lib/PONAPI/Server.pm
Criterion Covered Total %
statement 194 204 95.1
branch 110 144 76.3
condition 71 97 73.2
subroutine 24 25 96.0
pod 2 2 100.0
total 401 472 84.9


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