Skip to content

Commit

Permalink
Merge pull request #10 from xp-forge/feature/streaming
Browse files Browse the repository at this point in the history
Implement HTTP response streaming
  • Loading branch information
thekid committed Nov 20, 2023
2 parents 7822c42 + e0c12a0 commit 3b8bc0d
Show file tree
Hide file tree
Showing 12 changed files with 617 additions and 344 deletions.
35 changes: 19 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ Example
Put this code in a file called *Greet.class.php*:

```php
use com\amazon\aws\lambda\HttpApi;
use com\amazon\aws\lambda\HttpIntegration;

class Greet extends HttpApi {
class Greet extends HttpIntegration {

/**
* Returns routes
Expand Down Expand Up @@ -65,30 +65,33 @@ By adding `-m develop`, these can be run in the development webserver.
Setup and deployment
--------------------
Follow the steps shown on the [xp-forge/lambda README](https://github.com/xp-forge/lambda) to create the runtime layer, the service role and the lambda function itself. Next, create the API gateway as follows:
Follow the steps shown on the [xp-forge/lambda README](https://github.com/xp-forge/lambda) to create the runtime layer, the service role and the lambda function itself. Next, create the function URL as follows:
```bash
$ aws apigatewayv2 create-api \
--name hello-api \
--protocol-type HTTP \
--target "arn:aws:lambda:eu-central-1:XXXXXXXXXXXX:function:hello"
$ aws lambda create-function-url-config \
--function-name greet \
--auth-type NONE \
--invoke-mode RESPONSE_STREAM
```
The API's HTTP endpoint will be returned by this command.
The URL will be returned by this command.
Invocation
----------
You can either open the HTTP endpoint in your browser or by using *curl*:
```bash
$ curl -i https://XXXXXXXXXX.execute-api.eu-central-1.amazonaws.com/hello?name=$USER
HTTP/2 200
date: Sat, 28 Aug 2021 21:26:13 GMT
content-type: text/plain
content-length: 60
apigw-requestid: Ey9-Xg_UliAEPKQ=
Hello timmf from PHP 8.0.10 on stage $default @ eu-central-1
$ curl -i https://XXXXXXXXXX.lambda-url.eu-central-1.on.aws/?name=$USER
Date: Sun, 18 Jun 2023 20:00:55 GMT
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: keep-alive
x-amzn-RequestId: 3505bbff-e39e-42d3-98d7-9827fb3eb093
x-amzn-Remapped-content-length: 59
Set-Cookie: visited=1687118455; SameSite=Lax; HttpOnly
X-Amzn-Trace-Id: root=1-648f6276-672c96fe6230795d23453441;sampled=0;lineage=83e616e2:0

Hello timmf from PHP 8.2.7 on stage $default @ eu-central-1
```
Deploying changes
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"keywords": ["module", "xp"],
"require" : {
"xp-framework/core": "^11.0 | ^10.0 | ^9.0 | ^8.0 | ^7.0",
"xp-forge/lambda": "^5.0 | ^4.0 | ^3.0 | ^2.0 | ^1.0",
"xp-forge/lambda": "^5.0",
"xp-forge/web": "^3.0 | ^2.13",
"php": ">=7.0.0"
},
Expand Down
45 changes: 9 additions & 36 deletions src/main/php/com/amazon/aws/lambda/HttpApi.class.php
Original file line number Diff line number Diff line change
@@ -1,63 +1,36 @@
<?php namespace com\amazon\aws\lambda;

use Throwable;
use web\{Application, Environment, Error, InternalServerError, Logging, Request, Response, Routing};
use web\{Error, InternalServerError, Request, Response};

/**
* AWS Lambda with Amazon HTTP API Gateway
* AWS Lambda with Amazon HTTP API Gateway. Uses buffering as streamed responses
* are not supported by API Gateway's LAMBDA_PROXY integration
*
* @test com.amazon.aws.lambda.unittest.HttpApiTest
* @see https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html
*/
abstract class HttpApi extends Handler {
abstract class HttpApi extends HttpIntegration {

/**
* Returns routes. Overwrite this in subclasses!
*
* @param web.Environment $environment
* @return web.Application|web.Routing|[:var]
*/
public abstract function routes($environment);

/** @return com.amazon.aws.lambda.Lambda|callable */
/** @return callable|com.amazon.aws.lambda.Lambda|com.amazon.aws.lambda.Streaming */
public function target() {
$logging= Logging::of(function($request, $response, $error= null) {
$query= $request->uri()->query();
$this->environment->trace(sprintf(
'TRACE [%s] %d %s %s %s',
$request->value('context')->traceId,
$response->status(),
$request->method(),
$request->uri()->path().($query ? '?'.$query : ''),
$error ? $error->toString() : ''
));
});

// Determine routing
$routing= Routing::cast($this->routes(new Environment(
getenv('PROFILE') ?: 'prod',
$this->environment->root,
$this->environment->path('static'),
[$this->environment->properties],
[],
$logging
)));
$routing= $this->routing();

// Return event handler
return function($event, $context) use($routing, $logging) {
return function($event, $context) use($routing) {
$in= new FromApiGateway($event);
$req= new Request($in);
$res= new Response(new ResponseDocument());

try {
foreach ($routing->service($req->pass('context', $context)->pass('request', $in->context()), $res) ?? [] as $_) { }
$logging->log($req, $res);
$this->tracing->log($req, $res);
$res->end();

return $res->output()->document;
} catch (Throwable $t) {
$e= $t instanceof Error ? $t : new InternalServerError($t);
$logging->log($req, $res, $e);
$this->tracing->log($req, $res, $e);
return $res->output()->error($e);
}
};
Expand Down
41 changes: 41 additions & 0 deletions src/main/php/com/amazon/aws/lambda/HttpIntegration.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php namespace com\amazon\aws\lambda;

use web\{Routing, Environment as WebEnv};

/**
* Base class for HTTP APIs with the following implementations:
*
* - `HttpStreaming` for Lambda function URLs with streaming support
* - `HttpApi` for API Gateway and function URLs with buffering
*
* @see https://docs.aws.amazon.com/lambda/latest/dg/configuration-response-streaming.html
*/
abstract class HttpIntegration extends Handler {
protected $tracing;

/** Creates a new handler with a given lambda environment */
public function __construct(Environment $environment) {
parent::__construct($environment);
$this->tracing= new Tracing($environment);
}

/**
* Returns routes. Overwrite this in subclasses!
*
* @param web.Environment $environment
* @return web.Application|web.Routing|[:var]
*/
public abstract function routes($environment);

/** @return web.Routing */
protected final function routing() {
return Routing::cast($this->routes(new WebEnv(
$this->environment->variable('PROFILE') ?? 'prod',
$this->environment->root,
$this->environment->path('static'),
[$this->environment->properties],
[],
$this->tracing
)));
}
}
40 changes: 40 additions & 0 deletions src/main/php/com/amazon/aws/lambda/HttpStreaming.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php namespace com\amazon\aws\lambda;

use Throwable;
use web\{Error, InternalServerError, Request, Response};

/**
* AWS Lambda with AWS function URLs. Uses streaming as this has lower
* TTFB and memory consumption.
*
* @test com.amazon.aws.lambda.unittest.HttpIntegrationTest
* @see https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-features.html#gettingstarted-features-urls
*/
abstract class HttpStreaming extends HttpIntegration {

/** @return callable|com.amazon.aws.lambda.Lambda|com.amazon.aws.lambda.Streaming */
public function target() {
$routing= $this->routing();

// Return event handler
return function($event, $stream, $context) use($routing) {
$in= new FromApiGateway($event);
$req= new Request($in);
$res= new Response(new StreamingTo($stream));

try {
foreach ($routing->service($req->pass('context', $context)->pass('request', $in->context()), $res) ?? [] as $_) { }
$this->tracing->log($req, $res);

$res->end();
} catch (Throwable $t) {
$e= $t instanceof Error ? $t : new InternalServerError($t);
$this->tracing->log($req, $res, $e);

$res->answer($e->status(), $e->getMessage());
$res->header('x-amzn-ErrorType', nameof($e));
$res->send($e->compoundMessage(), 'text/plain');
}
};
}
}
11 changes: 8 additions & 3 deletions src/main/php/com/amazon/aws/lambda/ResponseDocument.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,17 @@ public function begin($status, $message, $headers) {
* @return [:var]
*/
public function error($e) {
$message= $e->compoundMessage();
return [
'statusCode' => $e->status(),
'statusDescription' => $e->getMessage(),
'isBase64Encoded' => false,
'headers' => ['Content-Type' => 'text/plain', 'x-amzn-ErrorType' => nameof($e)],
'body' => $e->compoundMessage(),
'headers' => [
'Content-Type' => 'text/plain',
'Content-Length' => strlen($message),
'x-amzn-ErrorType' => nameof($e)
],
'body' => $message,
];
}

Expand All @@ -104,7 +109,7 @@ public function finish() {
}

// Report unencoded length in headers
$this->document['headers']['Content-Length']= (string)strlen($this->document['body']);
$this->document['headers']['Content-Length']= strlen($this->document['body']);
if ($this->document['isBase64Encoded']) {
$this->document['body']= base64_encode($this->document['body']);
}
Expand Down
68 changes: 68 additions & 0 deletions src/main/php/com/amazon/aws/lambda/StreamingTo.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php namespace com\amazon\aws\lambda;

use text\json\{Json, StreamOutput};
use web\io\Output;

/**
* Response streaming with HTTP integration
*
* @test com.amazon.aws.lambda.unittest.HttpApiTest
* @see https://github.com/xp-forge/lambda/pull/23#issuecomment-1595720377
*/
class StreamingTo extends Output {
const DELIMITER= "\0\0\0\0\0\0\0\0";
const MIME_TYPE= 'application/vnd.awslambda.http-integration-response';
const USESTREAM= 'Must use RESPONSE_STREAM';

private $stream;

/** Creates a new streaming response */
public function __construct(Stream $stream) {
$this->stream= $stream;
$this->stream->use(self::MIME_TYPE);
}

/** @return web.io.Output */
public function stream() { return $this; }

/**
* Begins a request
*
* @param int $status
* @param string $message
* @param [:string[]] $headers
*/
public function begin($status, $message, $headers) {
$meta= [
'statusCode' => $status,
'statusDescription' => $message,
'headers' => [],
'body' => self::USESTREAM,
];
foreach ($headers as $name => $values) {
if ('Set-Cookie' === $name) {
$meta['cookies']= $values;
} else {
$meta['headers'][$name]= current($values);
}
}

$this->stream->write(json_encode($meta));
$this->stream->write(self::DELIMITER);
}

/**
* Writes the bytes
*
* @param string $bytes
* @return void
*/
public function write($bytes) {
$this->stream->write($bytes);
}

/** @return void */
public function finish() {
$this->stream->end();
}
}
21 changes: 21 additions & 0 deletions src/main/php/com/amazon/aws/lambda/Tracing.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php namespace com\amazon\aws\lambda;

use web\Logging;
use web\logging\ToFunction;

class Tracing extends Logging {

public function __construct(Environment $environment) {
parent::__construct(new ToFunction(function($request, $response, $error= null) use($environment) {
$query= $request->uri()->query();
$environment->trace(sprintf(
'TRACE [%s] %d %s %s %s',
$request->value('context')->traceId,
$response->status(),
$request->method(),
$request->uri()->path().($query ? '?'.$query : ''),
$error ? $error->toString() : ''
));
}));
}
}

0 comments on commit 3b8bc0d

Please sign in to comment.