File Coverage

blib/lib/PONAPI/Server.pm
Criterion Covered Total %
statement 168 170 98.8
branch 92 112 82.1
condition 70 96 72.9
subroutine 22 22 100.0
pod 2 2 100.0
total 354 402 88.0


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