File Coverage

blib/lib/AWS/Lambda/PSGI.pm
Criterion Covered Total %
statement 212 243 87.2
branch 45 68 66.1
condition 29 42 69.0
subroutine 29 35 82.8
pod 0 8 0.0
total 315 396 79.5


line stmt bran cond sub pod time code
1             package AWS::Lambda::PSGI;
2 3     3   2563 use 5.026000;
  3         12  
3 3     3   16 use utf8;
  3         5  
  3         19  
4 3     3   99 use strict;
  3         7  
  3         69  
5 3     3   14 use warnings;
  3         6  
  3         88  
6 3     3   484 use URI::Escape;
  3         1420  
  3         229  
7 3     3   1457 use Plack::Util;
  3         9602  
  3         83  
8 3     3   20 use bytes ();
  3         5  
  3         49  
9 3     3   978 use MIME::Base64;
  3         1193  
  3         172  
10 3     3   515 use JSON::Types;
  3         280  
  3         170  
11 3     3   571 use Encode;
  3         13936  
  3         191  
12 3     3   1031 use Try::Tiny;
  3         3996  
  3         208  
13 3     3   1376 use Plack::Middleware::ReverseProxy;
  3         9016  
  3         96  
14 3     3   2159 use AWS::Lambda;
  3         60  
  3         114  
15 3     3   20 use Scalar::Util qw(reftype);
  3         5  
  3         160  
16 3     3   18 use JSON::XS qw(encode_json);
  3         6  
  3         7818  
17              
18             sub new {
19 2     2 0 189 my $proto = shift;
20 2   33     15 my $class = ref $proto || $proto;
21            
22 2         5 my $self;
23 2 50 33     12 if (@_ == 1 && ref $_[0] eq 'HASH') {
24 0         0 $self = bless {%{$_[0]}}, $class;
  0         0  
25             } else {
26 2         7 $self = bless {@_}, $class;
27             }
28              
29 2 50       13 if (!defined $self->{invoke_mode}) {
30             my $mode = $ENV{PERL5_LAMBDA_PSGI_INVOKE_MODE}
31 2   33     16 || $ENV{AWS_LWA_INVOKE_MODE}; # for compatibility with https://github.com/awslabs/aws-lambda-web-adapter
32 2         290 $self->{invoke_mode} = uc $mode;
33             }
34            
35 2         16 return $self;
36             }
37              
38 0     0 0 0 sub prepare_app { return }
39              
40             sub app {
41 0 0   0 0 0 return $_[0]->{app} if scalar(@_) == 1;
42 0 0       0 return $_[0]->{app} = scalar(@_) == 2 ? $_[1] : [ @_[1..$#_ ]];
43             }
44              
45             sub to_app {
46 0     0 0 0 my $self = shift;
47 0         0 $self->prepare_app;
48 0     0   0 return sub { $self->call(@_) };
  0         0  
49             }
50              
51             sub wrap {
52 0     0 0 0 my($self, $app, @args) = @_;
53              
54             # Lambda function runs as reverse proxy backend.
55             # So, we always enable ReverseProxy middleware.
56 0         0 $app = Plack::Middleware::ReverseProxy->wrap($app);
57              
58 0 0       0 if (ref $self) {
59 0         0 $self->{app} = $app;
60             } else {
61 0         0 $self = $self->new({ app => $app, @args });
62             }
63 0         0 return $self->to_app;
64             }
65              
66             sub call {
67 0     0 0 0 my($self, $env, $ctx) = @_;
68              
69             # $ctx is added by #26
70             # fall back to $AWS::Lambda::context because of backward compatibility.
71 0   0     0 $ctx ||= $AWS::Lambda::context;
72              
73 0 0       0 if ($self->{invoke_mode} eq "RESPONSE_STREAM") {
74 0         0 my $input = $self->_format_input_v2($env, $ctx);
75 0         0 $input->{'psgi.streaming'} = Plack::Util::TRUE;
76 0         0 my $res = $self->app->($input);
77 0         0 return $self->_handle_response_stream($res);
78             } else {
79 0         0 my $input = $self->format_input($env, $ctx);
80 0         0 my $res = $self->app->($input);
81 0         0 return $self->format_output($res);
82             }
83             }
84              
85             sub format_input {
86 14     14 0 89650 my ($self, $payload, $ctx) = @_;
87 14 50       47 if (my $context = $payload->{requestContext}) {
88 14 100       36 if ($context->{elb}) {
89             # Application Load Balancer https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html
90 3         10 return $self->_format_input_v1($payload, $ctx);
91             }
92             }
93 11 100       29 if (my $version = $payload->{version}) {
94 6 50       19 if ($version =~ /^1[.]/) {
95             # API Gateway for REST https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
96 0         0 return $self->_format_input_v1($payload, $ctx);
97             }
98 6 50       26 if ($version =~ /^2[.]/) {
99             # API Gateway for HTTP https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
100 6         18 return $self->_format_input_v2($payload, $ctx);
101             }
102             }
103 5         18 return $self->_format_input_v1($payload, $ctx);
104             }
105              
106             sub _format_input_v1 {
107 8     8   15 my ($self, $payload, $ctx) = @_;
108 8         12 my $env = {};
109              
110             # merge queryStringParameters and multiValueQueryStringParameters
111             my $query = {
112 8   100     34 %{$payload->{queryStringParameters} // {}},
113 8   100     15 %{$payload->{multiValueQueryStringParameters} // {}},
  8         32  
114             };
115 8         18 my @params;
116 8         37 while (my ($key, $value) = each %$query) {
117 4 50       13 if (ref($value) eq 'ARRAY') {
118 4         9 for my $v (@$value) {
119 7         29 push @params, "$key=$v";
120             }
121             } else {
122 0         0 push @params, "$key=$value";
123             }
124             }
125 8         27 $env->{QUERY_STRING} = join '&', @params;
126              
127             # merge headers and multiValueHeaders
128             my $headers = {
129 8   100     34 %{$payload->{headers} // {}},
130 8   100     13 %{$payload->{multiValueHeaders} // {}},
  8         59  
131             };
132 8         40 while (my ($key, $value) = each %$headers) {
133 66         172 $key =~ s/-/_/g;
134 66         111 $key = uc $key;
135 66 100       144 if ($key !~ /^(?:CONTENT_LENGTH|CONTENT_TYPE)$/) {
136 60         106 $key = "HTTP_$key";
137             }
138 66 100       159 if (ref $value eq "ARRAY") {
139 48         93 $value = join ", ", @$value;
140             }
141 66         231 $env->{$key} = $value;
142             }
143              
144 8         20 $env->{'psgi.version'} = [1, 1];
145 8         28 $env->{'psgi.errors'} = *STDERR;
146 8         17 $env->{'psgi.run_once'} = Plack::Util::FALSE;
147 8         17 $env->{'psgi.multithread'} = Plack::Util::FALSE;
148 8         14 $env->{'psgi.multiprocess'} = Plack::Util::FALSE;
149 8         20 $env->{'psgi.streaming'} = Plack::Util::FALSE;
150 8         21 $env->{'psgi.nonblocking'} = Plack::Util::FALSE;
151 8         14 $env->{'psgix.harakiri'} = Plack::Util::TRUE;
152 8         16 $env->{'psgix.input.buffered'} = Plack::Util::TRUE;
153              
154             # inject the request id that compatible with Plack::Middleware::RequestId
155 8 100       22 if ($ctx) {
156 1         10 $env->{'psgix.request_id'} = $ctx->aws_request_id;
157 1         4 $env->{'HTTP_X_REQUEST_ID'} = $ctx->aws_request_id;
158             }
159              
160 8   100     51 my $body = encode_utf8($payload->{body} // '');
161 8 100       72 if ($payload->{isBase64Encoded}) {
162 3         36 $body = decode_base64 $body;
163             }
164 8     1   140 open my $input, "<", \$body;
  1         7  
  1         2  
  1         10  
165 8         863 $env->{REQUEST_METHOD} = $payload->{httpMethod};
166 8         18 $env->{'psgi.input'} = $input;
167 8   100     33 $env->{CONTENT_LENGTH} //= bytes::length($body);
168 8         952 $env->{REQUEST_URI} = $payload->{path};
169 8 100       20 if ($env->{QUERY_STRING}) {
170 4         17 $env->{REQUEST_URI} .= '?' . $env->{QUERY_STRING};
171             }
172 8         30 $env->{PATH_INFO} = URI::Escape::uri_unescape($payload->{path});
173              
174 8         113 $env->{SCRIPT_NAME} = '';
175 8         13 my $requestContext = $payload->{requestContext};
176 8 50       19 if ($requestContext) {
177 8         14 my $path = $requestContext->{path};
178 8         15 my $stage = $requestContext->{stage};
179 8 50 66     36 if ($stage && $path && $path ne $payload->{path}) {
      66        
180 5         17 $env->{SCRIPT_NAME} = "/$stage";
181             }
182             }
183              
184 8         45 return $env;
185             }
186              
187             sub _format_input_v2 {
188 6     6   14 my ($self, $payload, $ctx) = @_;
189 6         11 my $env = {};
190              
191 6         16 $env->{QUERY_STRING} = $payload->{rawQueryString};
192              
193 6   50     17 my $headers = $payload->{headers} // {};
194 6         26 while (my ($key, $value) = each %$headers) {
195 53         130 $key =~ s/-/_/g;
196 53         90 $key = uc $key;
197 53 100       105 if ($key !~ /^(?:CONTENT_LENGTH|CONTENT_TYPE)$/) {
198 46         80 $key = "HTTP_$key";
199             }
200 53         180 $env->{$key} = $value;
201             }
202              
203 6         15 $env->{'psgi.version'} = [1, 1];
204 6         20 $env->{'psgi.errors'} = *STDERR;
205 6         13 $env->{'psgi.run_once'} = Plack::Util::FALSE;
206 6         11 $env->{'psgi.multithread'} = Plack::Util::FALSE;
207 6         16 $env->{'psgi.multiprocess'} = Plack::Util::FALSE;
208 6         14 $env->{'psgi.streaming'} = Plack::Util::FALSE;
209 6         13 $env->{'psgi.nonblocking'} = Plack::Util::FALSE;
210 6         10 $env->{'psgix.harakiri'} = Plack::Util::TRUE;
211 6         11 $env->{'psgix.input.buffered'} = Plack::Util::TRUE;
212              
213             # inject the request id that compatible with Plack::Middleware::RequestId
214 6 50       30 if ($ctx) {
215 0         0 $env->{'psgix.request_id'} = $ctx->aws_request_id;
216 0         0 $env->{'HTTP_X_REQUEST_ID'} = $ctx->aws_request_id;
217             }
218              
219 6   100     30 my $body = encode_utf8($payload->{body} // '');
220 6 100       33 if ($payload->{isBase64Encoded}) {
221 2         23 $body = decode_base64 $body;
222             }
223 6         91 open my $input, "<", \$body;
224 6         15 $env->{'psgi.input'} = $input;
225 6   100     22 $env->{CONTENT_LENGTH} //= bytes::length($body);
226 6         19 my $requestContext = $payload->{requestContext};
227 6         13 $env->{REQUEST_METHOD} = $requestContext->{http}{method};
228 6         12 $env->{REQUEST_URI} = $payload->{rawPath};
229 6 100       45 if ($env->{QUERY_STRING}) {
230 2         10 $env->{REQUEST_URI} .= '?' . $env->{QUERY_STRING};
231             }
232 6         13 $env->{PATH_INFO} = $requestContext->{http}{path};
233 6         10 $env->{SCRIPT_NAME} = '';
234 6         25 return $env;
235             }
236              
237             sub format_output {
238 4     4 0 42680 my ($self, $response) = @_;
239 4         8 my ($status, $headers, $body) = @$response;
240              
241 4         7 my $singleValueHeaders = {};
242 4         6 my $multiValueHeaders = {};
243             Plack::Util::header_iter($headers, sub {
244 6     6   55 my ($k, $v) = @_;
245 6         15 $singleValueHeaders->{lc $k} = string $v;
246 6   100     39 push @{$multiValueHeaders->{lc $k} //= []}, string $v;
  6         31  
247 4         26 });
248              
249 4         42 my $content = '';
250 4 100       21 if (reftype($body) eq 'ARRAY') {
251 3         12 $content = join '', grep defined, @$body;
252             } else {
253 1         5 local $/ = \4096;
254 1         37 while (defined(my $buf = $body->getline)) {
255 1         64 $content .= $buf;
256             }
257 1         30 $body->close;
258             }
259              
260 4   50     21 my $type = $singleValueHeaders->{'content-type'} // 'application/octet-stream';
261 4         22 my $isBase64Encoded = $type !~ m(^text/.*|application/(:?json|javascript|xml))i;
262 4 100       10 if ($isBase64Encoded) {
263 1         9 $content = encode_base64 $content, '';
264             } else {
265             $content = try {
266             # is valid utf-8 string? try to decode as utf-8.
267 3     3   382 decode_utf8($content, Encode::FB_CROAK | Encode::LEAVE_SRC);
268             } catch {
269             # it looks not utf-8 encoding. fallback to base64 encoding.
270 1     1   37 $isBase64Encoded = 1;
271 1         15 encode_base64 $content, '';
272 3         20 };
273             }
274              
275             return +{
276 4         71 isBase64Encoded => bool $isBase64Encoded,
277             headers => $singleValueHeaders,
278             multiValueHeaders => $multiValueHeaders,
279             statusCode => number $status,
280             body => string $content,
281             }
282             }
283              
284             sub _handle_response_stream {
285 3     3   28003 my ($self, $response) = @_;
286 3 100       15 if (reftype($response) ne "CODE") {
287 2         3 my $orig = $response;
288             $response = sub {
289 2     2   5 my $responder = shift;
290 2         3 $responder->($orig);
291 2         11 };
292             }
293              
294             return sub {
295 3     3   22 my $lambda_responder = shift;
296             my $psgi_responder = sub {
297 3         15 my $response = shift;
298 3         10 my ($status, $headers, $body) = @$response;
299              
300             # write the prelude.
301 3         6 my $writer = $lambda_responder->("application/vnd.awslambda.http-integration-response");
302 3         891 my $prelude = encode_json($self->_format_response_stream($status, $headers));
303 3         51 $prelude .= "\x00\x00\x00\x00\x00\x00\x00\x00";
304 3 50       15 $writer->write($prelude) or die "failed to write prelude: $!";
305              
306             # write the body.
307 3 100       70 if (!defined $body) {
308             # the caller will write the body.
309 1         4 return $writer;
310             }
311 2 100       10 if (reftype($body) eq 'ARRAY') {
312             # array-ref
313 1         2 for my $chunk (@$body) {
314 1 50       3 $writer->write($chunk) or die "failed to write chunk: $!";
315             }
316             } else {
317             # IO::Handle-like object
318 1         4 local $/ = \4096;
319 1         41 while (defined(my $chunk = $body->getline)) {
320 1 50       41 $writer->write($chunk) or die "failed to write chunk: $!";
321             }
322             }
323 2 50       77 $writer->close or die "failed to close writer: $!";
324 2         44 return;
325 3         14 };
326 3         8 $response->($psgi_responder);
327 3         12 };
328             }
329              
330             sub _format_response_stream {
331 3     3   11 my ($self, $status, $headers) = @_;
332 3         5 my $headers_hash = {};
333 3         6 my $cookies = [];
334              
335             Plack::Util::header_iter($headers, sub {
336 3     3   42 my ($k, $v) = @_;
337 3         9 $k = lc $k;
338 3 50       10 if ($k eq 'set-cookie') {
    50          
339 0         0 push @$cookies, string $v;
340             } elsif (exists $headers_hash->{$k}) {
341 0         0 $headers_hash->{$k} = ", $v";
342             } else {
343 3         9 $headers_hash->{$k} = string $v;
344             }
345 3         22 });
346              
347             return +{
348 3         86 statusCode => number $status,
349             headers => $headers_hash,
350             cookies => $cookies,
351             };
352             }
353              
354             1;
355             __END__