Move schedule endpoints to projects group (/v1/projects/:projectId/schedules)

This commit is contained in:
Prem Palanisamy
2026-02-18 06:23:19 +00:00
committed by premtsd-code
parent 3fde05e024
commit 63d2efdaac
6 changed files with 228 additions and 145 deletions
@@ -32,18 +32,18 @@ class Create extends Action
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/schedules')
->setHttpPath('/v1/projects/:projectId/schedules')
->desc('Create schedule')
->groups(['api', 'schedules'])
->groups(['api', 'projects'])
->label('scope', 'schedules.write')
->label('audits.event', 'schedule.create')
->label('audits.resource', 'schedule/{response.$id}')
->label('sdk', new Method(
namespace: 'schedules',
namespace: 'projects',
group: 'schedules',
name: 'create',
name: 'createSchedule',
description: '/docs/references/schedules/create.md',
auth: [AuthType::ADMIN, AuthType::KEY],
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
@@ -51,29 +51,37 @@ class Create extends Action
)
],
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('resourceType', '', new WhiteList([SCHEDULE_RESOURCE_TYPE_FUNCTION, SCHEDULE_RESOURCE_TYPE_EXECUTION, SCHEDULE_RESOURCE_TYPE_MESSAGE], true), 'The resource type for the schedule. Possible values: ' . implode(', ', [SCHEDULE_RESOURCE_TYPE_FUNCTION, SCHEDULE_RESOURCE_TYPE_EXECUTION, SCHEDULE_RESOURCE_TYPE_MESSAGE]) . '.')
->param('resourceId', '', new UID(), 'The resource ID to associate with this schedule.')
->param('schedule', '', new Cron(), 'Schedule CRON expression.')
->param('active', false, new Boolean(), 'Whether the schedule is active.', true)
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('dbForPlatform')
->inject('getProjectDB')
->inject('authorization')
->callback($this->action(...));
}
public function action(
string $projectId,
string $resourceType,
string $resourceId,
string $schedule,
bool $active,
Response $response,
Document $project,
Database $dbForProject,
Database $dbForPlatform,
callable $getProjectDB,
Authorization $authorization,
): void {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$dbForProject = $getProjectDB($project);
$collection = match ($resourceType) {
SCHEDULE_RESOURCE_TYPE_FUNCTION => 'functions',
SCHEDULE_RESOURCE_TYPE_EXECUTION => 'executions',
@@ -8,7 +8,6 @@ use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
@@ -27,16 +26,16 @@ class Get extends Action
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/schedules/:scheduleId')
->setHttpPath('/v1/projects/:projectId/schedules/:scheduleId')
->desc('Get schedule')
->groups(['api', 'schedules'])
->groups(['api', 'projects'])
->label('scope', 'schedules.read')
->label('sdk', new Method(
namespace: 'schedules',
namespace: 'projects',
group: 'schedules',
name: 'get',
name: 'getSchedule',
description: '/docs/references/schedules/get.md',
auth: [AuthType::ADMIN, AuthType::KEY],
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
@@ -44,21 +43,27 @@ class Get extends Action
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('scheduleId', '', new UID(), 'Schedule ID.')
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('authorization')
->callback($this->action(...));
}
public function action(
string $projectId,
string $scheduleId,
Response $response,
Document $project,
Database $dbForPlatform,
Authorization $authorization,
): void {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$schedule = $authorization->skip(
fn () => $dbForPlatform->getDocument('schedules', $scheduleId)
);
@@ -15,6 +15,7 @@ use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
@@ -32,16 +33,16 @@ class XList extends Action
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/schedules')
->setHttpPath('/v1/projects/:projectId/schedules')
->desc('List schedules')
->groups(['api', 'schedules'])
->groups(['api', 'projects'])
->label('scope', 'schedules.read')
->label('sdk', new Method(
namespace: 'schedules',
namespace: 'projects',
group: 'schedules',
name: 'list',
name: 'listSchedules',
description: '/docs/references/schedules/list.md',
auth: [AuthType::ADMIN, AuthType::KEY],
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
@@ -49,30 +50,35 @@ class XList extends Action
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('queries', [], new Schedules(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Schedules::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('project')
->inject('dbForPlatform')
->inject('authorization')
->callback($this->action(...));
}
public function action(
string $projectId,
array $queries,
bool $includeTotal,
Response $response,
Document $project,
Database $dbForPlatform,
Authorization $authorization,
): void {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
// Always scope to the current project
$queries[] = Query::equal('projectId', [$project->getId()]);
$cursor = Query::getCursorQueries($queries, false);
-2
View File
@@ -109,8 +109,6 @@ trait ProjectCustom
'subscribers.read',
'migrations.write',
'migrations.read',
'schedules.read',
'schedules.write',
'tokens.read',
'tokens.write',
],
+39 -26
View File
@@ -4,44 +4,57 @@ namespace Tests\E2E\Services\Schedules;
use Tests\E2E\Client;
use Utopia\Database\Helpers\ID;
use Utopia\System\System;
trait SchedulesBase
{
protected function createSchedule(array $params = []): array
public function testCreateProject(): array
{
return $this->client->call(Client::METHOD_POST, '/schedules', array_merge([
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
}
], $this->getHeaders()), [
'teamId' => ID::unique(),
'name' => 'Schedule Test Team',
]);
protected function getSchedule(string $scheduleId): array
{
return $this->client->call(Client::METHOD_GET, '/schedules/' . $scheduleId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
}
$this->assertEquals(201, $team['headers']['status-code']);
protected function listSchedules(array $params = []): array
{
return $this->client->call(Client::METHOD_GET, '/schedules', array_merge([
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), $params);
}
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Schedule Test Project',
'teamId' => $team['body']['$id'],
'region' => System::getEnv('_APP_REGION', 'default'),
]);
protected function createFunction(array $params = []): array
{
return $this->client->call(Client::METHOD_POST, '/functions', array_merge([
$this->assertEquals(201, $project['headers']['status-code']);
$projectId = $project['body']['$id'];
$key = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), array_merge([
'functionId' => ID::unique(),
'name' => 'Test Schedule Function',
'runtime' => 'node-22',
'entrypoint' => 'index.js',
'execute' => ['any'],
], $params));
], $this->getHeaders()), [
'keyId' => ID::unique(),
'name' => 'Schedule Test Key',
'scopes' => [
'functions.read',
'functions.write',
'execution.read',
'execution.write',
'messages.read',
'messages.write',
],
]);
$this->assertEquals(201, $key['headers']['status-code']);
return [
'projectId' => $projectId,
'apiKey' => $key['body']['secret'],
];
}
}
@@ -2,28 +2,50 @@
namespace Tests\E2E\Services\Schedules;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectConsole;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
use Tests\E2E\Scopes\SideClient;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\System\System;
class SchedulesCustomServerTest extends Scope
{
use SchedulesBase;
use ProjectCustom;
use SideServer;
use ProjectConsole;
use SideClient;
public function testCreateSchedule(): array
/**
* @depends testCreateProject
*/
public function testCreateSchedule($data): array
{
$id = $data['projectId'] ?? '';
$apiKey = $data['apiKey'] ?? '';
/**
* Test for SUCCESS
*/
$function = $this->createFunction();
$function = $this->client->call(Client::METHOD_POST, '/functions', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $apiKey,
], [
'functionId' => ID::unique(),
'name' => 'Test Schedule Function',
'runtime' => 'node-22',
'entrypoint' => 'index.js',
'execute' => ['any'],
]);
$this->assertEquals(201, $function['headers']['status-code']);
$functionId = $function['body']['$id'];
$response = $this->createSchedule([
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'resourceType' => 'function',
'resourceId' => $functionId,
'schedule' => '0 0 * * *',
@@ -42,13 +64,20 @@ class SchedulesCustomServerTest extends Scope
$this->assertTrue($response['body']['active']);
$this->assertNotEmpty($response['body']['region']);
return ['scheduleId' => $response['body']['$id'], 'functionId' => $functionId];
}
$data = array_merge($data, [
'scheduleId' => $response['body']['$id'],
'functionId' => $functionId,
]);
public function testCreateScheduleResourceNotFound(): void
{
// Function not found
$response = $this->createSchedule([
/**
* Test for FAILURE
*/
// Resource not found
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'resourceType' => 'function',
'resourceId' => ID::unique(),
'schedule' => '0 0 * * *',
@@ -56,51 +85,35 @@ class SchedulesCustomServerTest extends Scope
$this->assertEquals(404, $response['headers']['status-code']);
// Execution not found
$response = $this->createSchedule([
'resourceType' => 'execution',
'resourceId' => ID::unique(),
'schedule' => '*/10 * * * *',
]);
$this->assertEquals(404, $response['headers']['status-code']);
// Message not found
$response = $this->createSchedule([
'resourceType' => 'message',
'resourceId' => ID::unique(),
'schedule' => '0 9 * * 1',
]);
$this->assertEquals(404, $response['headers']['status-code']);
}
public function testCreateScheduleInvalidResourceType(): void
{
$response = $this->createSchedule([
// Invalid resource type
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'resourceType' => 'invalid',
'resourceId' => ID::unique(),
'schedule' => '0 0 * * *',
]);
$this->assertEquals(400, $response['headers']['status-code']);
}
public function testCreateScheduleInvalidCron(): void
{
$response = $this->createSchedule([
// Invalid cron
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'resourceType' => 'function',
'resourceId' => ID::unique(),
'schedule' => 'not-a-cron',
]);
$this->assertEquals(400, $response['headers']['status-code']);
}
public function testCreateScheduleMissingRequired(): void
{
// Missing resourceType
$response = $this->createSchedule([
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'resourceId' => ID::unique(),
'schedule' => '0 0 * * *',
]);
@@ -108,7 +121,10 @@ class SchedulesCustomServerTest extends Scope
$this->assertEquals(400, $response['headers']['status-code']);
// Missing resourceId
$response = $this->createSchedule([
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'resourceType' => 'function',
'schedule' => '0 0 * * *',
]);
@@ -116,49 +132,69 @@ class SchedulesCustomServerTest extends Scope
$this->assertEquals(400, $response['headers']['status-code']);
// Missing schedule
$response = $this->createSchedule([
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'resourceType' => 'function',
'resourceId' => ID::unique(),
]);
$this->assertEquals(400, $response['headers']['status-code']);
return $data;
}
/**
* @depends testCreateSchedule
*/
public function testGetSchedule(array $data): void
public function testGetSchedule($data): array
{
$scheduleId = $data['scheduleId'];
$id = $data['projectId'] ?? '';
$scheduleId = $data['scheduleId'] ?? '';
/**
* Test for SUCCESS
*/
$response = $this->getSchedule($scheduleId);
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules/' . $scheduleId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertEquals($scheduleId, $response['body']['$id']);
$this->assertEquals('function', $response['body']['resourceType']);
$this->assertEquals('0 0 * * *', $response['body']['schedule']);
$this->assertTrue($response['body']['active']);
}
public function testGetScheduleNotFound(): void
{
$response = $this->getSchedule('nonexistent');
/**
* Test for FAILURE
*/
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules/error', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(404, $response['headers']['status-code']);
return $data;
}
/**
* @depends testCreateSchedule
*/
public function testListSchedules(array $data): void
public function testListSchedules($data): array
{
$id = $data['projectId'] ?? '';
/**
* Test for SUCCESS
*/
$response = $this->listSchedules();
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsArray($response['body']['schedules']);
@@ -176,15 +212,12 @@ class SchedulesCustomServerTest extends Scope
$this->assertArrayHasKey('schedule', $schedule);
$this->assertArrayHasKey('active', $schedule);
$this->assertArrayHasKey('region', $schedule);
}
/**
* @depends testCreateSchedule
*/
public function testListSchedulesWithQuery(array $data): void
{
// Filter by resourceType
$response = $this->listSchedules([
/** Filter by resourceType */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [Query::equal('resourceType', ['function'])->toString()],
]);
@@ -195,8 +228,11 @@ class SchedulesCustomServerTest extends Scope
$this->assertEquals('function', $schedule['resourceType']);
}
// Filter by active status
$response = $this->listSchedules([
/** Filter by active status */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [Query::equal('active', [true])->toString()],
]);
@@ -205,61 +241,78 @@ class SchedulesCustomServerTest extends Scope
foreach ($response['body']['schedules'] as $schedule) {
$this->assertTrue($schedule['active']);
}
}
public function testListSchedulesWithTotalDisabled(): void
{
$response = $this->listSchedules([
/** List with total disabled */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'total' => false,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(0, $response['body']['total']);
$this->assertIsArray($response['body']['schedules']);
}
public function testListSchedulesInvalidQuery(): void
{
$response = $this->listSchedules([
/**
* Test for FAILURE
*/
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'queries' => [Query::equal('nonexistent', ['value'])->toString()],
]);
$this->assertEquals(400, $response['headers']['status-code']);
return $data;
}
public function testScheduleProjectIsolation(): void
/**
* @depends testCreateSchedule
*/
public function testScheduleProjectIsolation($data): void
{
// Create a function and schedule in the current project
$function = $this->createFunction();
$this->assertEquals(201, $function['headers']['status-code']);
$scheduleId = $data['scheduleId'] ?? '';
$response = $this->createSchedule([
'resourceType' => 'function',
'resourceId' => $function['body']['$id'],
'schedule' => '0 12 * * *',
// Create a second project
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'teamId' => ID::unique(),
'name' => 'Isolation Test Team',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$scheduleId = $response['body']['$id'];
$this->assertEquals(201, $team['headers']['status-code']);
// Create a fresh project with its own key
$freshProject = $this->getProject(true);
$otherProject = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Isolation Test Project',
'teamId' => $team['body']['$id'],
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(201, $otherProject['headers']['status-code']);
$otherProjectId = $otherProject['body']['$id'];
// Try to get the schedule from the other project
$response = $this->client->call(\Tests\E2E\Client::METHOD_GET, '/schedules/' . $scheduleId, [
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $otherProjectId . '/schedules/' . $scheduleId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $freshProject['$id'],
'x-appwrite-key' => $freshProject['apiKey'],
]);
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(404, $response['headers']['status-code']);
// List should not include schedules from other projects
$response = $this->client->call(\Tests\E2E\Client::METHOD_GET, '/schedules', [
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $otherProjectId . '/schedules', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $freshProject['$id'],
'x-appwrite-key' => $freshProject['apiKey'],
]);
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), []);
$this->assertEquals(200, $response['headers']['status-code']);