File Coverage

blib/lib/Raisin/Middleware/Formatter.pm
Criterion Covered Total %
statement 111 111 100.0
branch 34 36 94.4
condition 6 10 60.0
subroutine 19 19 100.0
pod 4 5 80.0
total 174 181 96.1


line stmt bran cond sub pod time code
1             #!perl
2             #PODNAME: Raisin::Middleware::Formatter
3             #ABSTRACT: A parser/formatter middleware for L.
4              
5 13     13   428 use strict;
  13         24  
  13         375  
6 13     13   62 use warnings;
  13         22  
  13         1542  
7              
8             package Raisin::Middleware::Formatter;
9             $Raisin::Middleware::Formatter::VERSION = '0.94';
10 13     13   70 use parent 'Plack::Middleware';
  13         22  
  13         68  
11              
12 13     13   16773 use File::Basename qw(fileparse);
  13         49  
  13         1057  
13 13     13   86 use HTTP::Status qw(:constants);
  13         23  
  13         4835  
14 13     13   87 use Scalar::Util qw{ blessed reftype openhandle };
  13         22  
  13         695  
15 13     13   69 use Plack::Request;
  13         23  
  13         332  
16 13     13   445 use Plack::Response;
  13         981  
  13         311  
17 13     13   67 use Plack::Util;
  13         31  
  13         325  
18 13         55 use Plack::Util::Accessor qw(
19             default_format
20             format
21             encoder
22             decoder
23             raisin
24 13     13   66 );
  13         1102  
25              
26             sub call {
27 50     50 1 329513 my ($self, $env) = @_;
28              
29             # Pre-process
30 50         307 my $req = Plack::Request->new($env);
31              
32 50 100       565 if ($req->content) {
33 20         4820 my %media_types_map_flat_hash = $self->decoder->media_types_map_flat_hash;
34              
35 20         69 my ($ctype) = split /;/, $req->content_type, 2;
36 20         170 my $format = $media_types_map_flat_hash{$ctype};
37 20 100       55 unless ($format) {
38 1         2 Raisin::log(info => "unsupported media type: ${ \$req->content_type }");
  1         5  
39 1         373 return Plack::Response->new(HTTP_UNSUPPORTED_MEDIA_TYPE)->finalize;
40             }
41 19         35 $env->{'raisinx.decoder'} = $format;
42              
43 19         46 my $d = Plack::Util::load_class($self->decoder->for($format));
44 19         219 $env->{'raisinx.body_params'} = $d->deserialize($req);
45             }
46              
47 49         34730 my $format = $self->negotiate_format($req);
48 49 100       114 unless ($format) {
49 3         40 return Plack::Response->new(HTTP_NOT_ACCEPTABLE)->finalize;
50             }
51 46         93 $env->{'raisinx.encoder'} = $format;
52              
53 46         159 my $res = $self->app->($env);
54             # Post-process
55             Plack::Util::response_cb($res, sub {
56              
57 46     46   505 my $res = shift;
58 46         165 my $r = Plack::Response->new(@$res);
59              
60             # The application may decide on the fly to return a different
61             # content type than we negotiated above. In that case it becomes
62             # responsible for updating $env appropriately, and also
63             # specifying the encoder to use.
64 46         2942 my $format = $env->{'raisinx.encoder'};
65              
66             # If the body is a data structure of some sort, finalize it now,
67             # BUT NOT if it's a file handle (broadly construed). In that case
68             # treat it as a deferred response.
69 46 50 33     121 if (ref $r->body && !_is_a_handle($r->body)) {
70 46         178 my $s = Plack::Util::load_class($self->encoder->for($format));
71              
72 46 50       615 $r->content_type($s->content_type) unless $r->content_type;
73 46         664 $r->body($s->serialize($r->body));
74             }
75              
76 46         79502 @$res = @{ $r->finalize };
  46         124  
77 46         4165 return;
78 46         3797 });
79             }
80              
81             # Test whether the argument is a "handle," meaning that it's either
82             # a built-in handle or an IO::Handle-like object. It's a file handle
83             # if fileno or Scalar::Util::openhandle think it is, or if it supports
84             # a "getline" and a "close" method.
85             sub _is_a_handle {
86 46     46   409 my ($var) = @_;
87              
88             return
89 46   33     415 ( ( reftype $var // '' ) eq 'GLOB' && ( defined fileno($var) || defined openhandle($var) ) )
90             ||
91             ( blessed $var && $var->can('getline') && $var->can('close') )
92             ;
93             }
94              
95 50   100 50   8065 sub _accept_header_set { length(shift || '') }
96             sub _path_has_extension {
97 71     71   3623 my $path = shift;
98 71         1965 my (undef, undef, $suffix) = fileparse($path, qr/\..[^.]*$/);
99 71         260 $suffix;
100             }
101              
102             sub negotiate_format {
103 62     62 1 350 my ($self, $req) = @_;
104              
105 62         147 my @allowed_formats = $self->allowed_formats_for_requested_route($req);
106              
107             # PRECEDENCE:
108             # - known extension
109             # - headers
110             # - default
111 62         139 my @wanted_formats = do {
112 62         151 my $ext_format = $self->format_from_extension($req->path);
113 62 100       217 if ($ext_format) {
    100          
114 12         28 $ext_format;
115             }
116             elsif (_accept_header_set($req->header('Accept'))) {
117             # In case of wildcard matches, we default to first allowed format
118 24         86 $self->format_from_header($req->header('Accept'), $allowed_formats[0]);
119             }
120             else {
121 26         73 $self->default_format;
122             }
123             };
124              
125             my @matching_formats = grep {
126 62         193 my $format = $_;
  61         86  
127 61 100       85 grep { $format && $format eq $_ } @allowed_formats
  204         576  
128             } @wanted_formats;
129              
130 62         168 shift @matching_formats;
131             }
132              
133             sub format_from_extension {
134 68     68 1 449 my ($self, $path) = @_;
135 68 100       161 return unless $path;
136              
137 67         127 my $ext = _path_has_extension($path);
138 67 100       174 return unless $ext;
139              
140             # Trim leading dot in the extension.
141 18         45 $ext = substr($ext, 1);
142              
143 18         54 my %media_types_map_flat_hash = $self->encoder->media_types_map_flat_hash;
144 18         54 my $format = $media_types_map_flat_hash{$ext};
145 18 100       49 return unless $format;
146              
147 15         54 $format;
148             }
149              
150             sub format_from_header {
151 34     34 1 692 my ($self, $accept, $assumed_wildcard_format) = @_;
152 34 100       99 return unless $accept;
153              
154 33         82 my %media_types_map_flat_hash = $self->encoder->media_types_map_flat_hash;
155             # Add a default format as a `*/*`
156 33         90 $media_types_map_flat_hash{'*/*'} = $assumed_wildcard_format;
157              
158 33         47 my @media_types;
159 33         136 for my $type (split /\s*,\s*/, $accept) {
160 49         117 my ($media, $params) = split /;/, $type, 2;
161             # Cleaning up media type by deleting a Vendor tree
162 49         83 $media =~ s/vnd\.[^+]+\+//g;
163              
164 49 100       120 next unless my $format = $media_types_map_flat_hash{$media};
165              
166 38 100 100     149 my $q = ($params // '') =~ /q=([\d\.]+)/ ? $1 : 1;
167              
168 38         135 push @media_types, { format => $format, q => $q };
169             }
170              
171 33         99 map { $_->{format} } sort { $b->{q} <=> $a->{q} } @media_types;
  38         159  
  11         27  
172             }
173              
174             sub allowed_formats_for_requested_route {
175 62     62 0 118 my ($self, $req) = @_;
176             # Global format has been forced upon entire app
177 62 100       187 return $self->format if $self->format;
178              
179             # Route specific `produces` restrictions
180 56 100       506 if ( $self->raisin ) {
181 33         181 my $route = $self->raisin->routes->find($req->method, $req->path);
182 33 100       102 return @{$route->{produces}} if $route->{produces};
  4         10  
183             }
184              
185             # Prefer Default, allow all others
186 52         124 my @allowed = keys %{ $self->encoder->all };
  52         107  
187 52 100       138 unshift @allowed, $self->default_format if $self->default_format;
188 52         446 return @allowed;
189             }
190              
191             1;
192              
193             __END__