-
-
Notifications
You must be signed in to change notification settings - Fork 365
/
HttpRequestEvent.php
355 lines (303 loc) · 13.3 KB
/
HttpRequestEvent.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
<?php declare(strict_types=1);
namespace Bref\Event\Http;
use Bref\Event\InvalidLambdaEvent;
use Bref\Event\LambdaEvent;
use Crwlr\QueryString\Query;
use function str_starts_with;
/**
* Represents a Lambda event that comes from a HTTP request.
*
* The event can come from API Gateway or ALB (Application Load Balancer).
*
* See the following for details on the JSON payloads for HTTP APIs;
* https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format
*/
final class HttpRequestEvent implements LambdaEvent
{
private array $event;
private string $method;
private array $headers;
private string $queryString;
private float $payloadVersion;
public function __construct(mixed $event)
{
// version 1.0 of the HTTP payload
if (isset($event['httpMethod'])) {
$this->method = strtoupper($event['httpMethod']);
} elseif (isset($event['requestContext']['http']['method'])) {
// version 2.0 - https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format
$this->method = strtoupper($event['requestContext']['http']['method']);
} else {
throw new InvalidLambdaEvent('API Gateway or ALB', $event);
}
$this->payloadVersion = (float) ($event['version'] ?? '1.0');
$this->event = $event;
$this->queryString = $this->rebuildQueryString();
$this->headers = $this->extractHeaders();
}
public function toArray(): array
{
return $this->event;
}
public function getBody(): string
{
$requestBody = $this->event['body'] ?? '';
if ($this->event['isBase64Encoded'] ?? false) {
$requestBody = base64_decode($requestBody);
}
return $requestBody;
}
public function getMethod(): string
{
return $this->method;
}
public function getHeaders(): array
{
return $this->headers;
}
public function hasMultiHeader(): bool
{
if ($this->isFormatV2()) {
return false;
}
return isset($this->event['multiValueHeaders']);
}
public function getProtocol(): string
{
return $this->event['requestContext']['protocol'] ?? 'HTTP/1.1';
}
public function getProtocolVersion(): string
{
return ltrim($this->getProtocol(), 'HTTP/');
}
public function getContentType(): ?string
{
return $this->headers['content-type'][0] ?? null;
}
public function getRemotePort(): int
{
return (int) ($this->headers['x-forwarded-port'][0] ?? 80);
}
/**
* @return array{string, string}|array{null, null}
*/
public function getBasicAuthCredentials(): array
{
$authorizationHeader = trim($this->headers['authorization'][0] ?? '');
if (! str_starts_with($authorizationHeader, 'Basic ')) {
return [null, null];
}
$auth = base64_decode(trim(explode(' ', $authorizationHeader)[1]));
if (! $auth || ! strpos($auth, ':')) {
return [null, null];
}
return explode(':', $auth, 2);
}
public function getServerPort(): int
{
return (int) ($this->headers['x-forwarded-port'][0] ?? 80);
}
public function getServerName(): string
{
return $this->headers['host'][0] ?? 'localhost';
}
public function getPath(): string
{
if ($this->isFormatV2()) {
return $this->event['rawPath'] ?? '/';
}
/**
* $event['path'] contains the URL always without the stage prefix.
* $event['requestContext']['path'] contains the URL always with the stage prefix.
* None of the represents the real URL because:
* - the native API Gateway URL has the stage (`/dev`)
* - with a custom domain, the URL doesn't have the stage (`/`)
* - with CloudFront in front of AG, the URL doesn't have the stage (`/`)
* Because it's hard to detect whether CloudFront is used, we will go with the "non-prefixed" URL ($event['path'])
* as it's the one most likely used in production (because in production we use custom domains).
* Since Bref now recommends HTTP APIs (that don't have a stage prefix), this problem will not be common anyway.
* Full history:
* - https://github.com/brefphp/bref/issues/67
* - https://github.com/brefphp/bref/issues/309
* - https://github.com/brefphp/bref/pull/794
*/
return $this->event['path'] ?? '/';
}
public function getUri(): string
{
$queryString = $this->queryString;
$uri = $this->getPath();
if (! empty($queryString)) {
$uri .= '?' . $queryString;
}
return $uri;
}
public function getQueryString(): string
{
return $this->queryString;
}
public function getQueryParameters(): array
{
return self::queryStringToArray($this->queryString);
}
public function getRequestContext(): array
{
return $this->event['requestContext'] ?? [];
}
public function getCookies(): array
{
if ($this->isFormatV2()) {
$cookieParts = $this->event['cookies'] ?? [];
} else {
if (! isset($this->headers['cookie'])) {
return [];
}
// Multiple "Cookie" headers are not authorized
// https://stackoverflow.com/questions/16305814/are-multiple-cookie-headers-allowed-in-an-http-request
$cookieHeader = $this->headers['cookie'][0];
$cookieParts = explode('; ', $cookieHeader);
}
$cookies = [];
foreach ($cookieParts as $cookiePart) {
$explode = explode('=', $cookiePart, 2);
if (count($explode) !== 2) {
continue;
}
[$cookieName, $cookieValue] = $explode;
$cookies[$cookieName] = urldecode($cookieValue);
}
return $cookies;
}
/**
* @return array<string,string>
*/
public function getPathParameters(): array
{
return $this->event['pathParameters'] ?? [];
}
public function getSourceIp(): string
{
if ($this->isFormatV2()) {
return $this->event['requestContext']['http']['sourceIp'] ?? '127.0.0.1';
}
return $this->event['requestContext']['identity']['sourceIp'] ?? '127.0.0.1';
}
private function rebuildQueryString(): string
{
if ($this->isFormatV2()) {
$queryString = $this->event['rawQueryString'] ?? '';
// We re-parse the query string to make sure it is URL-encoded
// Why? To match the format we get when using PHP outside of Lambda (we get the query string URL-encoded)
return http_build_query(self::queryStringToArray($queryString), '', '&', \PHP_QUERY_RFC3986);
}
// It is likely that we do not need to differentiate between API Gateway (Version 1) and ALB. However,
// it would lead to a breaking change since the current implementation for API Gateway does not
// support MultiValue query string. This way, the code is fully backward-compatible while
// offering complete support for multi value query parameters on ALB. Later on there can
// be a feature flag that allows API Gateway users to opt in to complete support as well.
if (isset($this->event['requestContext']['elb'])) {
// AWS differs between ALB with multiValue enabled or not (docs: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers)
$queryParameters = $this->event['multiValueQueryStringParameters'] ?? $this->event['queryStringParameters'] ?? [];
$queryString = '';
// AWS always deliver the list of query parameters as an array. Let's loop through all of the
// query parameters available and parse them to get their original URL decoded values.
foreach ($queryParameters as $key => $values) {
// If multi-value is disabled, $values is a string containing the last parameter sent.
// If multi-value is enabled, $values is *always* an array containing a list of parameters per key.
// Even if we only send 1 parameter (e.g. my_param=1), AWS will still send an array [1] for my_param
// when multi-value is enabled.
// By forcing $values to be an array, we can be consistent with both scenarios by always parsing
// all values available on a given key.
$values = (array) $values;
// Let's go ahead and undo AWS's work and rebuild the original string that formed the
// Query Parameters so that php's native function `parse_str` can automatically
// decode all keys and all values. The result is a PHP array with decoded
// keys and values. See https://github.com/brefphp/bref/pull/693
foreach ($values as $value) {
$queryString .= $key . '=' . $value . '&';
}
}
// queryStringToArray() will automatically `urldecode` any value that needs decoding. This will allow parameters
// like `?my_param[bref][]=first&my_param[bref][]=second` to properly work.
return http_build_query(self::queryStringToArray($queryString), '', '&', \PHP_QUERY_RFC3986);
}
if (isset($this->event['multiValueQueryStringParameters']) && $this->event['multiValueQueryStringParameters']) {
$queryParameterStr = [];
// go through the params and url-encode the values, to build up a complete query-string
foreach ($this->event['multiValueQueryStringParameters'] as $key => $value) {
foreach ($value as $v) {
$queryParameterStr[] = $key . '=' . urlencode($v);
}
}
// re-parse the query-string so it matches the format used when using PHP outside of Lambda
// this is particularly important when using multi-value params - eg. myvar[]=2&myvar=3 ... = [2, 3]
$queryParameters = self::queryStringToArray(implode('&', $queryParameterStr));
return http_build_query($queryParameters, '', '&', \PHP_QUERY_RFC3986);
}
if (empty($this->event['queryStringParameters'])) {
return '';
}
/*
* Watch out: do not use $event['queryStringParameters'] directly!
*
* (that is no longer the case here but it was in the past with Bref 0.2)
*
* queryStringParameters does not handle correctly arrays in parameters
* ?array[key]=value gives ['array[key]' => 'value'] while we want ['array' => ['key' = > 'value']]
* In that case we should recreate the original query string and use parse_str which handles correctly arrays
*/
return http_build_query($this->event['queryStringParameters'], '', '&', \PHP_QUERY_RFC3986);
}
private function extractHeaders(): array
{
// Normalize headers
if (isset($this->event['multiValueHeaders'])) {
$headers = $this->event['multiValueHeaders'];
} else {
$headers = $this->event['headers'] ?? [];
// Turn the headers array into a multi-value array to simplify the code below
$headers = array_map(function ($value): array {
return [$value];
}, $headers);
}
$headers = array_change_key_case($headers, CASE_LOWER);
$hasBody = ! empty($this->event['body']);
// See https://stackoverflow.com/a/5519834/245552
if ($hasBody && ! isset($headers['content-type'])) {
$headers['content-type'] = ['application/x-www-form-urlencoded'];
}
// Auto-add the Content-Length header if it wasn't provided
// See https://github.com/brefphp/bref/issues/162
if ($hasBody && ! isset($headers['content-length'])) {
$headers['content-length'] = [strlen($this->getBody())];
}
// Cookies are separated from headers in payload v2, we re-add them in there
// so that we have the full original HTTP request
if (! empty($this->event['cookies']) && $this->isFormatV2()) {
$cookieHeader = implode('; ', $this->event['cookies']);
$headers['cookie'] = [$cookieHeader];
}
return $headers;
}
/**
* See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format
*/
public function isFormatV2(): bool
{
return $this->payloadVersion === 2.0;
}
/**
* When keys within a URL query string contain dots, PHP's parse_str() method
* converts them to underscores. This method works around this issue so the
* requested query array returns the proper keys with dots.
*
* @return array<string, string>
*
* @see https://github.com/brefphp/bref/issues/756
* @see https://github.com/brefphp/bref/pull/1437
*/
private static function queryStringToArray(string $query): array
{
return Query::fromString($query)->toArray();
}
}