Merge pull request #9419 from appwrite/feat-function-download

Feat: Build download endpoint in Functions
This commit is contained in:
Matej Bačo 2025-02-28 12:31:39 +01:00 committed by GitHub
commit f91e8be136
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 257 additions and 2 deletions

View file

@ -206,7 +206,7 @@ services:
appwrite-console:
<<: *x-logging
container_name: appwrite-console
image: appwrite/console:5.3.0-sites-rc.15
image: appwrite/console:5.3.0-sites-rc.16
restart: unless-stopped
networks:
- appwrite

View file

@ -0,0 +1,133 @@
<?php
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Download;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\MethodType;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
use Utopia\Swoole\Request;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getDeploymentBuildDownload';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/build/download')
->desc('Download build')
->groups(['api', 'functions'])
->label('scope', 'functions.read')
->label('sdk', new Method(
namespace: 'functions',
name: 'getDeploymentBuildDownload',
description: <<<EOT
Get a function build content by its unique ID. The endpoint response return with a 'Content-Disposition: attachment' header that tells the browser to start downloading the file to user downloads directory.
EOT,
auth: [AuthType::KEY, AuthType::JWT],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_NONE
)
],
type: MethodType::LOCATION,
contentType: ContentType::ANY,
))
->param('functionId', '', new UID(), 'Function ID.')
->param('deploymentId', '', new UID(), 'Deployment ID.')
->inject('response')
->inject('request')
->inject('dbForProject')
->inject('deviceForBuilds')
->callback([$this, 'action']);
}
public function action(string $functionId, string $deploymentId, Response $response, Request $request, Database $dbForProject, Device $deviceForBuilds)
{
$function = $dbForProject->getDocument('functions', $functionId);
if ($function->isEmpty()) {
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
$deployment = $dbForProject->getDocument('deployments', $deploymentId);
if ($deployment->isEmpty()) {
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
}
if ($deployment->getAttribute('resourceId') !== $function->getId()) {
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
}
$build = $dbForProject->getDocument('builds', $deployment->getAttribute('buildId'));
if ($build->isEmpty()) {
throw new Exception(Exception::BUILD_NOT_FOUND);
}
$path = $build->getAttribute('path', '');
if (!$deviceForBuilds->exists($path)) {
throw new Exception(Exception::BUILD_NOT_FOUND);
}
$response
->setContentType('application/gzip')
->addHeader('Expires', \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)) . ' GMT') // 45 days cache
->addHeader('X-Peak', \memory_get_peak_usage())
->addHeader('Content-Disposition', 'attachment; filename="' . $deploymentId . '.tar.gz"');
$size = $deviceForBuilds->getFileSize($path);
$rangeHeader = $request->getHeader('range');
if (!empty($rangeHeader)) {
$start = $request->getRangeStart();
$end = $request->getRangeEnd();
$unit = $request->getRangeUnit();
if ($end === null) {
$end = min(($start + MAX_OUTPUT_CHUNK_SIZE - 1), ($size - 1));
}
if ($unit !== 'bytes' || $start >= $end || $end >= $size) {
throw new Exception(Exception::STORAGE_INVALID_RANGE);
}
$response
->addHeader('Accept-Ranges', 'bytes')
->addHeader('Content-Range', 'bytes ' . $start . '-' . $end . '/' . $size)
->addHeader('Content-Length', $end - $start + 1)
->setStatusCode(Response::STATUS_CODE_PARTIALCONTENT);
$response->send($deviceForBuilds->read($path, $start, ($end - $start + 1)));
}
if ($size > APP_STORAGE_READ_BUFFER) {
for ($i = 0; $i < ceil($size / MAX_OUTPUT_CHUNK_SIZE); $i++) {
$response->chunk(
$deviceForBuilds->read(
$path,
($i * MAX_OUTPUT_CHUNK_SIZE),
min(MAX_OUTPUT_CHUNK_SIZE, $size - ($i * MAX_OUTPUT_CHUNK_SIZE))
),
(($i + 1) * MAX_OUTPUT_CHUNK_SIZE) >= $size
);
}
} else {
$response->send($deviceForBuilds->read($path));
}
}
}

View file

@ -3,6 +3,7 @@
namespace Appwrite\Platform\Modules\Functions\Services;
use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Create as CreateBuild;
use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Download\Get as DownloadBuild;
use Appwrite\Platform\Modules\Functions\Http\Deployments\Builds\Update as UpdateBuild;
use Appwrite\Platform\Modules\Functions\Http\Deployments\Create as CreateDeployment;
use Appwrite\Platform\Modules\Functions\Http\Deployments\Delete as DeleteDeployment;
@ -64,6 +65,7 @@ class Http extends Service
$this->addAction(DownloadDeployment::getName(), new DownloadDeployment());
$this->addAction(CreateBuild::getName(), new CreateBuild());
$this->addAction(UpdateBuild::getName(), new UpdateBuild());
$this->addAction(DownloadBuild::getName(), new DownloadBuild());
// Executions
$this->addAction(CreateExecution::getName(), new CreateExecution());

View file

@ -312,4 +312,24 @@ trait FunctionsBase
return $domain;
}
protected function getDeploymentDownload(string $functionId, string $deploymentId): mixed
{
$response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId . '/download', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
return $response;
}
protected function getBuildDownload(string $functionId, string $deploymentId): mixed
{
$response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId . '/build/download', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
return $response;
}
}

View file

@ -497,4 +497,44 @@ class FunctionsConsoleClientTest extends Scope
$this->cleanupFunction($functionId);
}
public function testFunctionDownload(): void
{
$functionId = $this->setupFunction([
'functionId' => ID::unique(),
'runtime' => 'node-18.0',
'name' => 'Download Test',
'entrypoint' => 'index.js',
'logging' => false,
'execute' => ['any']
]);
$deploymentId = $this->setupDeployment($functionId, [
'entrypoint' => 'index.js',
'code' => $this->packageFunction('node'),
'activate' => true
]);
$this->assertNotEmpty($deploymentId);
$response = $this->getDeploymentDownload($functionId, $deploymentId);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('application/gzip', $response['headers']['content-type']);
$this->assertGreaterThan(0, $response['headers']['content-length']);
$this->assertGreaterThan(0, \strlen($response['body']));
$deploymentMd5 = \md5($response['body']);
$response = $this->getBuildDownload($functionId, $deploymentId);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('application/gzip', $response['headers']['content-type']);
$this->assertGreaterThan(0, $response['headers']['content-length']);
$this->assertGreaterThan(0, \strlen($response['body']));
$buildMd5 = \md5($response['body']);
$this->assertNotEquals($deploymentMd5, $buildMd5);
$this->cleanupFunction($functionId);
}
}

View file

@ -345,4 +345,24 @@ trait SitesBase
return $domain;
}
protected function getDeploymentDownload(string $siteId, string $deploymentId): mixed
{
$response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId . '/download', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
return $response;
}
protected function getBuildDownload(string $siteId, string $deploymentId): mixed
{
$response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId . '/build/download', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
return $response;
}
}

View file

@ -1634,5 +1634,45 @@ class SitesCustomServerTest extends Scope
$this->cleanupSite($siteId);
}
// TODO: Add tests for deletion of resources when site is deleted
public function testSiteDownload(): void
{
$siteId = $this->setupSite([
'buildRuntime' => 'ssr-22',
'fallbackFile' => null,
'framework' => 'other',
'name' => 'Test Site',
'adapter' => 'static',
'outputDirectory' => './',
'providerBranch' => 'main',
'providerRootDirectory' => './',
'siteId' => ID::unique()
]);
$deploymentId = $this->setupDeployment($siteId, [
'code' => $this->packageSite('static'),
'activate' => true
]);
$this->assertNotEmpty($deploymentId);
$response = $this->getDeploymentDownload($siteId, $deploymentId);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('application/gzip', $response['headers']['content-type']);
$this->assertGreaterThan(0, $response['headers']['content-length']);
$this->assertGreaterThan(0, \strlen($response['body']));
$deploymentMd5 = \md5($response['body']);
$response = $this->getBuildDownload($siteId, $deploymentId);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('application/gzip', $response['headers']['content-type']);
$this->assertGreaterThan(0, $response['headers']['content-length']);
$this->assertGreaterThan(0, \strlen($response['body']));
$buildMd5 = \md5($response['body']);
$this->assertNotEquals($deploymentMd5, $buildMd5);
$this->cleanupSite($siteId);
}
}