Final template API rework

This commit is contained in:
Matej Bačo
2026-04-19 10:35:57 +02:00
parent 8a1f8c71b2
commit 2a95cfd5a3
15 changed files with 243 additions and 83 deletions
+1 -2
View File
@@ -701,9 +701,8 @@ jobs:
- name: Installing latest version
run: |
rm docker-compose.yml
# TODO: Minify docker-compose; remove development tooling
rm .env
curl https://appwrite.io/install/compose -o docker-compose.yml
curl https://appwrite.io/install/env -o .env
sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
docker compose up -d
+8
View File
@@ -1,3 +1,11 @@
# Version 1.9.2
### Notable changes
### Fixes
### Miscellaneous
# Version 1.9.0
## What's Changed
+3 -3
View File
@@ -72,7 +72,7 @@ docker run -it --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
--entrypoint="install" \
appwrite/appwrite:1.9.1
appwrite/appwrite:1.9.2
```
### Windows
@@ -84,7 +84,7 @@ docker run -it --rm ^
--volume //var/run/docker.sock:/var/run/docker.sock ^
--volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^
--entrypoint="install" ^
appwrite/appwrite:1.9.1
appwrite/appwrite:1.9.2
```
#### PowerShell
@@ -94,7 +94,7 @@ docker run -it --rm `
--volume /var/run/docker.sock:/var/run/docker.sock `
--volume ${pwd}/appwrite:/usr/src/code/appwrite:rw `
--entrypoint="install" `
appwrite/appwrite:1.9.1
appwrite/appwrite:1.9.2
```
运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。
+3 -3
View File
@@ -75,7 +75,7 @@ docker run -it --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
--entrypoint="install" \
appwrite/appwrite:1.9.1
appwrite/appwrite:1.9.2
```
### Windows
@@ -88,7 +88,7 @@ docker run -it --rm ^
--volume //var/run/docker.sock:/var/run/docker.sock ^
--volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^
--entrypoint="install" ^
appwrite/appwrite:1.9.1
appwrite/appwrite:1.9.2
```
#### PowerShell
@@ -99,7 +99,7 @@ docker run -it --rm `
--volume /var/run/docker.sock:/var/run/docker.sock `
--volume ${pwd}/appwrite:/usr/src/code/appwrite:rw `
--entrypoint="install" `
appwrite/appwrite:1.9.1
appwrite/appwrite:1.9.2
```
Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation.
+8
View File
@@ -26,6 +26,7 @@ use Appwrite\Utopia\Request\Filters\V19 as RequestV19;
use Appwrite\Utopia\Request\Filters\V20 as RequestV20;
use Appwrite\Utopia\Request\Filters\V21 as RequestV21;
use Appwrite\Utopia\Request\Filters\V22 as RequestV22;
use Appwrite\Utopia\Request\Filters\V23 as RequestV23;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -34,6 +35,7 @@ use Appwrite\Utopia\Response\Filters\V19 as ResponseV19;
use Appwrite\Utopia\Response\Filters\V20 as ResponseV20;
use Appwrite\Utopia\Response\Filters\V21 as ResponseV21;
use Appwrite\Utopia\Response\Filters\V22 as ResponseV22;
use Appwrite\Utopia\Response\Filters\V23 as ResponseV23;
use Appwrite\Utopia\View;
use Executor\Executor;
use MaxMind\Db\Reader;
@@ -897,6 +899,9 @@ Http::init()
if (version_compare($requestFormat, '1.9.1', '<')) {
$request->addFilter(new RequestV22());
}
if (version_compare($requestFormat, '1.9.2', '<')) {
$request->addFilter(new RequestV23());
}
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -921,6 +926,9 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
if (version_compare($responseFormat, '1.9.2', '<')) {
$response->addFilter(new ResponseV23());
}
if (version_compare($responseFormat, '1.9.1', '<')) {
$response->addFilter(new ResponseV22());
}
+2 -2
View File
@@ -46,8 +46,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours
const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours
const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
const APP_CACHE_BUSTER = 4322;
const APP_VERSION_STABLE = '1.9.1';
const APP_CACHE_BUSTER = 4323;
const APP_VERSION_STABLE = '1.9.2';
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
const APP_DATABASE_ATTRIBUTE_IP = 'ip';
+1
View File
@@ -94,6 +94,7 @@ abstract class Migration
'1.8.1' => 'V23',
'1.9.0' => 'V24',
'1.9.1' => 'V24',
'1.9.2' => 'V24',
];
/**
@@ -33,13 +33,13 @@ class Delete extends Action
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/project/templates/email')
->httpAlias('/v1/projects/:projectId/templates/email')
->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale')
->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale')
->desc('Delete project email template')
->groups(['api', 'project'])
->label('scope', 'templates.write')
->label('event', 'templates.[templateType].delete')
->label('audits.event', 'project.template.delete')
->label('audits.resource', 'project.template/{response.type}')
->label('audits.resource', 'project.template/{response.templateId}')
->label('sdk', new Method(
namespace: 'project',
group: 'templates',
@@ -56,8 +56,8 @@ class Delete extends Action
],
contentType: ContentType::NONE
))
->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes'])
->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
->inject('response')
->inject('queueForEvents')
->inject('dbForPlatform')
@@ -68,7 +68,7 @@ class Delete extends Action
}
public function action(
string $type,
string $templateId,
string $locale,
Response $response,
QueueEvent $queueForEvents,
@@ -77,16 +77,16 @@ class Delete extends Action
Document $project,
Locale $localeObject,
) {
$locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en');
$locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$templates = $project->getAttribute('templates', []);
$template = $templates['email.' . $type . '-' . $locale] ?? null;
$template = $templates['email.' . $templateId . '-' . $locale] ?? null;
if (is_null($template)) {
throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION);
}
unset($templates['email.' . $type . '-' . $locale]);
unset($templates['email.' . $templateId . '-' . $locale]);
$updates = new Document([
'templates' => $templates,
@@ -94,7 +94,7 @@ class Delete extends Action
$project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
$queueForEvents->setParam('templateType', $type);
$queueForEvents->setParam('templateType', $templateId);
$response->noContent();
}
@@ -27,8 +27,8 @@ class Get extends Action
public function __construct()
{
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/project/templates/email/:type')
->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale')
->setHttpPath('/v1/project/templates/email/:templateId')
->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale')
->desc('Get project email template')
->groups(['api', 'project'])
->label('scope', 'templates.read')
@@ -47,8 +47,8 @@ class Get extends Action
)
]
))
->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes'])
->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
->inject('response')
->inject('project')
->inject('locale')
@@ -56,16 +56,16 @@ class Get extends Action
}
public function action(
string $type,
string $templateId,
string $locale,
Response $response,
Document $project,
Locale $localeObject,
) {
$locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en');
$locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$templates = $project->getAttribute('templates', []);
$template = $templates['email.' . $type . '-' . $locale] ?? null;
$template = $templates['email.' . $templateId . '-' . $locale] ?? null;
$localeObj = new Locale($locale);
$localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en'));
@@ -94,7 +94,7 @@ class Get extends Action
];
// fallback to the base template.
$config = $templateConfigs[$type] ?? [
$config = $templateConfigs[$templateId] ?? [
'file' => 'email-inner-base.tpl',
'placeholders' => ['buttonText', 'body', 'footer']
];
@@ -107,21 +107,21 @@ class Get extends Action
// Set type-specific parameters
foreach ($config['placeholders'] as $param) {
$escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']);
$message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml);
$message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml);
}
$message
// common placeholders on all the templates
->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello"))
->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks"))
->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature"));
->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello"))
->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks"))
->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature"));
// `useContent: false` will strip new lines!
$message = $message->render(useContent: true);
$template = [
'message' => $message,
'subject' => $localeObj->getText('emails.' . $type . '.subject'),
'subject' => $localeObj->getText('emails.' . $templateId . '.subject'),
'senderEmail' => '',
'senderName' => '',
'custom' => false,
@@ -130,7 +130,7 @@ class Get extends Action
$template['custom'] = true;
}
$template['type'] = $type;
$template['templateId'] = $templateId;
$template['locale'] = $locale;
$response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE);
@@ -33,13 +33,13 @@ class Update extends Action
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/project/templates/email')
->httpAlias('/v1/projects/:projectId/templates/email')
->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale')
->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale')
->desc('Update project email template')
->groups(['api', 'project'])
->label('scope', 'templates.write')
->label('event', 'templates.[templateType].update')
->label('audits.event', 'project.template.update')
->label('audits.resource', 'project.template/{response.type}')
->label('audits.resource', 'project.template/{response.templateId}')
->label('sdk', new Method(
namespace: 'project',
group: 'templates',
@@ -55,8 +55,8 @@ class Update extends Action
)
]
))
->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes'])
->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? []))
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes'])
->param('subject', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.')
->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.')
->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true)
@@ -72,7 +72,7 @@ class Update extends Action
}
public function action(
string $type,
string $templateId,
string $locale,
string $subject,
string $message,
@@ -86,7 +86,7 @@ class Update extends Action
Document $project,
Locale $localeObject,
) {
$locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en');
$locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$template = [
'senderName' => $senderName,
@@ -97,7 +97,7 @@ class Update extends Action
];
$templates = $project->getAttribute('templates', []);
$templates['email.' . $type . '-' . $locale] = $template;
$templates['email.' . $templateId . '-' . $locale] = $template;
$updates = new Document([
'templates' => $templates,
@@ -105,10 +105,10 @@ class Update extends Action
$project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
$queueForEvents->setParam('templateType', $type);
$queueForEvents->setParam('templateType', $templateId);
$response->dynamic(new Document([
'type' => $type,
'templateId' => $templateId,
'locale' => $locale,
'senderName' => $template['senderName'],
'senderEmail' => $template['senderEmail'],
@@ -0,0 +1,31 @@
<?php
namespace Appwrite\Utopia\Request\Filters;
use Appwrite\Utopia\Request\Filter;
class V23 extends Filter
{
// Convert 1.9.1 params to 1.9.2
protected function parseEmailTemplate(array $content): array
{
if (isset($content['type'])) {
$content['templateId'] = $content['type'];
unset($content['type']);
}
return $content;
}
public function parse(array $content, string $model): array
{
switch ($model) {
case 'project.getEmailTemplate':
case 'project.updateEmailTemplate':
case 'project.deleteEmailTemplate':
$content = $this->parseEmailTemplate($content);
break;
}
return $content;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Appwrite\Utopia\Response\Filters;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filter;
// Convert 1.9.2 Data format to 1.9.1 format
class V23 extends Filter
{
public function parse(array $content, string $model): array
{
return match ($model) {
Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content),
default => $content,
};
}
private function parseEmailTemplate(array $content): array
{
if (isset($content['templateId'])) {
$content['type'] = $content['templateId'];
unset($content['templateId']);
}
return $content;
}
}
@@ -1,32 +0,0 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response\Model;
abstract class Template extends Model
{
public function __construct()
{
$this
->addRule('type', [
'type' => self::TYPE_STRING,
'description' => 'Template type',
'default' => '',
'example' => 'verification',
])
->addRule('locale', [
'type' => self::TYPE_STRING,
'description' => 'Template locale',
'default' => '',
'example' => 'en_us',
])
->addRule('message', [
'type' => self::TYPE_STRING,
'description' => 'Template message',
'default' => '',
'example' => 'Click on the link to verify your account.',
])
;
}
}
@@ -3,13 +3,31 @@
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class TemplateEmail extends Template
class TemplateEmail extends Model
{
public function __construct()
{
parent::__construct();
$this
->addRule('templateId', [
'type' => self::TYPE_STRING,
'description' => 'Template type',
'default' => '',
'example' => 'verification',
])
->addRule('locale', [
'type' => self::TYPE_STRING,
'description' => 'Template locale',
'default' => '',
'example' => 'en_us',
])
->addRule('message', [
'type' => self::TYPE_STRING,
'description' => 'Template message',
'default' => '',
'example' => 'Click on the link to verify your account.',
])
->addRule('senderName', [
'type' => self::TYPE_STRING,
'description' => 'Name of the sender',
+106 -7
View File
@@ -15,7 +15,7 @@ trait TemplatesBase
$template = $this->getEmailTemplate('verification', 'en');
$this->assertSame(200, $template['headers']['status-code']);
$this->assertSame('verification', $template['body']['type']);
$this->assertSame('verification', $template['body']['templateId']);
$this->assertSame('en', $template['body']['locale']);
$this->assertFalse($template['body']['custom']);
$this->assertNotEmpty($template['body']['subject']);
@@ -27,7 +27,7 @@ trait TemplatesBase
$template = $this->getEmailTemplate('verification');
$this->assertSame(200, $template['headers']['status-code']);
$this->assertSame('verification', $template['body']['type']);
$this->assertSame('verification', $template['body']['templateId']);
$this->assertSame('en', $template['body']['locale']);
$this->assertFalse($template['body']['custom']);
}
@@ -40,7 +40,7 @@ trait TemplatesBase
$get = $this->getEmailTemplate('magicSession', 'en');
$this->assertSame(200, $get['headers']['status-code']);
$this->assertSame('magicSession', $get['body']['type']);
$this->assertSame('magicSession', $get['body']['templateId']);
$this->assertSame('en', $get['body']['locale']);
$this->assertTrue($get['body']['custom']);
$this->assertSame('Magic Subject', $get['body']['subject']);
@@ -85,7 +85,7 @@ trait TemplatesBase
);
$this->assertSame(200, $update['headers']['status-code']);
$this->assertSame('verification', $update['body']['type']);
$this->assertSame('verification', $update['body']['templateId']);
$this->assertSame('en', $update['body']['locale']);
$this->assertSame('Please verify your email', $update['body']['subject']);
$this->assertSame('Click here to verify: {{url}}', $update['body']['message']);
@@ -135,7 +135,7 @@ trait TemplatesBase
);
$this->assertSame(200, $update['headers']['status-code']);
$this->assertSame('sessionAlert', $update['body']['type']);
$this->assertSame('sessionAlert', $update['body']['templateId']);
$this->assertSame('en', $update['body']['locale']);
// Cleanup
@@ -281,6 +281,105 @@ trait TemplatesBase
$this->deleteEmailTemplate('recovery', 'en');
}
// =========================================================================
// Legacy response format tests (request + response filters)
// =========================================================================
public function testGetEmailTemplateLegacyResponseFormat(): void
{
$headers = \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders());
$template = $this->client->call(
Client::METHOD_GET,
'/project/templates/email/verification',
$headers,
);
$this->assertSame(200, $template['headers']['status-code']);
// Response filter should rename templateId -> type for < 1.9.2 clients.
$this->assertArrayHasKey('type', $template['body']);
$this->assertArrayNotHasKey('templateId', $template['body']);
$this->assertSame('verification', $template['body']['type']);
$this->assertSame('en', $template['body']['locale']);
}
public function testUpdateEmailTemplateLegacyResponseFormat(): void
{
$headers = \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders());
// Request filter should accept legacy `type` and map it to `templateId`.
$update = $this->client->call(
Client::METHOD_PATCH,
'/project/templates/email',
$headers,
[
'type' => 'magicSession',
'locale' => 'en',
'subject' => 'Legacy Subject',
'message' => 'Legacy Body',
],
);
$this->assertSame(200, $update['headers']['status-code']);
// Response filter should rename templateId -> type for < 1.9.2 clients.
$this->assertArrayHasKey('type', $update['body']);
$this->assertArrayNotHasKey('templateId', $update['body']);
$this->assertSame('magicSession', $update['body']['type']);
$this->assertSame('Legacy Subject', $update['body']['subject']);
$this->assertSame('Legacy Body', $update['body']['message']);
$this->assertTrue($update['body']['custom']);
// Verify persisted, then cleanup via legacy DELETE with `type`.
$get = $this->getEmailTemplate('magicSession', 'en');
$this->assertSame(200, $get['headers']['status-code']);
$this->assertTrue($get['body']['custom']);
$delete = $this->client->call(
Client::METHOD_DELETE,
'/project/templates/email',
$headers,
[
'type' => 'magicSession',
'locale' => 'en',
],
);
$this->assertSame(204, $delete['headers']['status-code']);
$after = $this->getEmailTemplate('magicSession', 'en');
$this->assertFalse($after['body']['custom']);
}
public function testUpdateEmailTemplateLegacyInvalidType(): void
{
$headers = \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.1',
], $this->getHeaders());
$update = $this->client->call(
Client::METHOD_PATCH,
'/project/templates/email',
$headers,
[
'type' => 'notATemplate',
'locale' => 'en',
'subject' => 'Subject',
'message' => 'Message',
],
);
$this->assertSame(400, $update['headers']['status-code']);
}
// =========================================================================
// Helpers
// =========================================================================
@@ -315,7 +414,7 @@ trait TemplatesBase
bool $authenticated = true,
): mixed {
$params = [
'type' => $type,
'templateId' => $type,
];
if ($locale !== null) {
@@ -352,7 +451,7 @@ trait TemplatesBase
protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed
{
$params = [
'type' => $type,
'templateId' => $type,
];
if ($locale !== null) {