Skip to content

Commit

Permalink
Fix #818 Support multiple cookies with API Gateway v2
Browse files Browse the repository at this point in the history
  • Loading branch information
mnapoli committed Jan 18, 2021
1 parent 175aa3d commit f3ba590
Show file tree
Hide file tree
Showing 10 changed files with 240 additions and 36 deletions.
1 change: 1 addition & 0 deletions demo/psr7.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
{
return new Response(200, [
'Content-Type' => ['text/html'],
'Set-Cookie' => ['foo', 'bar'],
], 'Hello world!');
}
};
1 change: 1 addition & 0 deletions serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ functions:
- ${bref:layer.php-74}
events:
- http: 'ANY /psr7'
- httpApi: 'GET /psr7'
environment:
BREF_LOOP_MAX: 100

Expand Down
19 changes: 4 additions & 15 deletions src/Event/Http/FpmHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,19 +126,17 @@ public function handleRequest(HttpRequestEvent $event, Context $context): HttpRe
throw new FastCgiCommunicationFailed;
}

$responseHeaders = $this->getResponseHeaders($response, $event->hasMultiHeader());
$responseHeaders = $this->getResponseHeaders($response);

// Extract the status code
if (isset($responseHeaders['status'])) {
$status = (int) (is_array($responseHeaders['status']) ? $responseHeaders['status'][0] : $responseHeaders['status']);
unset($responseHeaders['status']);
}

$response = new HttpResponse($response->getBody(), $responseHeaders, $status ?? 200);

$this->ensureStillRunning();

return $response;
return new HttpResponse($response->getBody(), $responseHeaders, $status ?? 200);
}

/**
Expand Down Expand Up @@ -280,17 +278,8 @@ private function waitUntilStopped(int $pid): void
/**
* Return an array of the response headers.
*/
private function getResponseHeaders(ProvidesResponseData $response, bool $isMultiHeader): array
private function getResponseHeaders(ProvidesResponseData $response): array
{
$responseHeaders = $response->getHeaders();
if (! $isMultiHeader) {
// If we are not in "multi-header" mode, we must keep the last value only
// and cast it to string
foreach ($responseHeaders as $key => $value) {
$responseHeaders[$key] = end($value);
}
}

return array_change_key_case($responseHeaders, CASE_LOWER);
return array_change_key_case($response->getHeaders(), CASE_LOWER);
}
}
4 changes: 4 additions & 0 deletions src/Event/Http/HttpHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public function handle($event, Context $context): array

$response = $this->handleRequest($httpEvent, $context);

if ($httpEvent->isFormatV2()) {
return $response->toApiGatewayFormatV2();
}

return $response->toApiGatewayFormat($httpEvent->hasMultiHeader());
}
}
43 changes: 27 additions & 16 deletions src/Event/Http/HttpRequestEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public function getHeaders(): array

public function hasMultiHeader(): bool
{
if ($this->payloadVersion === 2.0) {
if ($this->isFormatV2()) {
return false;
}

Expand Down Expand Up @@ -109,7 +109,7 @@ public function getServerName(): string

public function getPath(): string
{
if ($this->payloadVersion >= 2) {
if ($this->isFormatV2()) {
return $this->event['rawPath'] ?? '/';
}

Expand Down Expand Up @@ -159,21 +159,24 @@ public function getRequestContext(): array

public function getCookies(): array
{
if ($this->payloadVersion === 2.0) {
if (! isset($this->event['cookies'])) {
return [];
if ($this->isFormatV2()) {
$cookieParts = $this->event['cookies'] ?? [];
$cookies = [];
foreach ($cookieParts as $cookiePart) {
[$cookieName, $cookieValue] = explode('=', $cookiePart, 2);
$cookies[$cookieName] = urldecode($cookieValue);
}
$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);
return $cookies;
}

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) {
[$cookieName, $cookieValue] = explode('=', $cookiePart, 2);
Expand All @@ -192,7 +195,7 @@ public function getPathParameters(): array

private function rebuildQueryString(): string
{
if ($this->payloadVersion === 2.0) {
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)
Expand Down Expand Up @@ -302,11 +305,19 @@ private function extractHeaders(): array

// Cookies are separated from headers in payload v2, we re-add them in there
// so that we have the full original HTTP request
if ($this->payloadVersion === 2.0 && ! empty($this->event['cookies'])) {
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;
}
}
53 changes: 48 additions & 5 deletions src/Event/Http/HttpResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ final class HttpResponse
/** @var string */
private $body;

/**
* @param array<string|string[]> $headers
*/
public function __construct(string $body, array $headers = [], int $statusCode = 200)
{
$this->body = $body;
Expand All @@ -29,11 +32,7 @@ public function toApiGatewayFormat(bool $multiHeaders = false): array

$headers = [];
foreach ($this->headers as $name => $values) {
// Capitalize header keys
// See https://github.com/zendframework/zend-diactoros/blob/754a2ceb7ab753aafe6e3a70a1fb0370bde8995c/src/Response/SapiEmitterTrait.php#L96
$name = str_replace('-', ' ', $name);
$name = ucwords($name);
$name = str_replace(' ', '-', $name);
$name = $this->capitalizeHeaderName($name);

if ($multiHeaders) {
// Make sure the values are always arrays
Expand All @@ -60,4 +59,48 @@ public function toApiGatewayFormat(bool $multiHeaders = false): array
'body' => $base64Encoding ? base64_encode($this->body) : $this->body,
];
}

/**
* See https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response
*/
public function toApiGatewayFormatV2(): array
{
$base64Encoding = (bool) getenv('BREF_BINARY_RESPONSES');

$headers = [];
$cookies = [];
foreach ($this->headers as $name => $values) {
$name = $this->capitalizeHeaderName($name);

if ($name === 'Set-Cookie') {
$cookies = is_array($values) ? $values : [$values];
} else {
// Make sure the values are never arrays
// because API Gateway v2 does not support multi-value headers
$headers[$name] = is_array($values) ? end($values) : $values;
}
}

// The headers must be a JSON object. If the PHP array is empty it is
// serialized to `[]` (we want `{}`) so we force it to an empty object.
$headers = empty($headers) ? new \stdClass : $headers;

return [
'cookies' => $cookies,
'isBase64Encoded' => $base64Encoding,
'statusCode' => $this->statusCode,
'headers' => $headers,
'body' => $base64Encoding ? base64_encode($this->body) : $this->body,
];
}

/**
* See https://github.com/zendframework/zend-diactoros/blob/754a2ceb7ab753aafe6e3a70a1fb0370bde8995c/src/Response/SapiEmitterTrait.php#L96
*/
private function capitalizeHeaderName(string $name): string
{
$name = str_replace('-', ' ', $name);
$name = ucwords($name);
return str_replace(' ', '-', $name);
}
}
96 changes: 96 additions & 0 deletions tests/Event/Http/HttpResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Bref\Event\Http\HttpResponse;
use PHPUnit\Framework\TestCase;
use stdClass;

class HttpResponseTest extends TestCase
{
Expand All @@ -23,6 +24,16 @@ public function test conversion to API Gateway format()
],
'body' => '<p>Hello world!</p>',
], $response->toApiGatewayFormat());

self::assertSame([
'cookies' => [],
'isBase64Encoded' => false,
'statusCode' => 200,
'headers' => [
'Content-Type' => 'text/html; charset=utf-8',
],
'body' => '<p>Hello world!</p>',
], $response->toApiGatewayFormatV2());
}

public function test headers are capitalized()
Expand All @@ -37,6 +48,14 @@ public function test headers are capitalized()
'headers' => ['X-Foo-Bar' => 'baz'],
'body' => '',
], $response->toApiGatewayFormat());

self::assertEquals([
'cookies' => [],
'isBase64Encoded' => false,
'statusCode' => 200,
'headers' => ['X-Foo-Bar' => 'baz'],
'body' => '',
], $response->toApiGatewayFormatV2());
}

public function test nested arrays in headers are flattened()
Expand All @@ -52,6 +71,15 @@ public function test nested arrays in headers are flattened()
'headers' => ['Foo' => 'baz'],
'body' => '',
], $response->toApiGatewayFormat());

self::assertEquals([
'cookies' => [],
'isBase64Encoded' => false,
'statusCode' => 200,
// The last value is kept (when multiheaders are not enabled)
'headers' => ['Foo' => 'baz'],
'body' => '',
], $response->toApiGatewayFormatV2());
}

public function test empty headers are considered objects()
Expand All @@ -60,6 +88,7 @@ public function test empty headers are considered objects()

// Make sure that the headers are `"headers":{}` (object) and not `"headers":[]` (array)
self::assertEquals('{"isBase64Encoded":false,"statusCode":200,"headers":{},"body":""}', json_encode($response->toApiGatewayFormat()));
self::assertEquals('{"cookies":[],"isBase64Encoded":false,"statusCode":200,"headers":{},"body":""}', json_encode($response->toApiGatewayFormatV2()));
}

/**
Expand All @@ -79,4 +108,71 @@ public function test header values are forced as arrays for multiheaders
'body' => '',
], $response->toApiGatewayFormat(true));
}

public function test response with single cookie()
{
$response = new HttpResponse('', [
'set-cookie' => 'foo',
]);

self::assertEquals([
'isBase64Encoded' => false,
'statusCode' => 200,
'headers' => [
'Set-Cookie' => 'foo',
],
'body' => '',
], $response->toApiGatewayFormat());

self::assertEquals([
'isBase64Encoded' => false,
'statusCode' => 200,
'multiValueHeaders' => [
'Set-Cookie' => ['foo'],
],
'body' => '',
], $response->toApiGatewayFormat(true));

self::assertEquals([
'cookies' => ['foo'],
'isBase64Encoded' => false,
'statusCode' => 200,
'headers' => new stdClass,
'body' => '',
], $response->toApiGatewayFormatV2());
}

public function test response with multiple cookies()
{
$response = new HttpResponse('', [
'set-cookie' => ['foo', 'bar'],
]);

self::assertEquals([
'isBase64Encoded' => false,
'statusCode' => 200,
'headers' => [
// Keep only the last value in v1 without multi-headers
'Set-Cookie' => 'bar',
],
'body' => '',
], $response->toApiGatewayFormat());

self::assertEquals([
'isBase64Encoded' => false,
'statusCode' => 200,
'multiValueHeaders' => [
'Set-Cookie' => ['foo', 'bar'],
],
'body' => '',
], $response->toApiGatewayFormat(true));

self::assertEquals([
'cookies' => ['foo', 'bar'],
'isBase64Encoded' => false,
'statusCode' => 200,
'headers' => new stdClass,
'body' => '',
], $response->toApiGatewayFormatV2());
}
}
33 changes: 33 additions & 0 deletions tests/Functional/HttpApiTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php declare(strict_types=1);

namespace Bref\Test\Functional;

use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use GuzzleHttp\Client;
use PHPUnit\Framework\TestCase;

class HttpApiTest extends TestCase
{
use ArraySubsetAsserts;

/** @var Client */
private $http;

public function setUp(): void
{
parent::setUp();

$this->http = new Client([
'base_uri' => 'https://3ipdsvypt1.execute-api.eu-west-1.amazonaws.com/',
'http_errors' => false,
]);
}

public function test supports multiple cookies with API Gateway format v2()
{
$response = $this->http->request('GET');

self::assertSame(200, $response->getStatusCode());
self::assertEquals(['foo', 'bar'], $response->getHeader('Set-Cookie'));
}
}
Loading

0 comments on commit f3ba590

Please sign in to comment.