Tutorial: your first action
In this tutorial, you add a report of recently changed pages to an extension. You write one class and one scope, and you get two HTTP endpoints, validation, error responses, access control and API documentation. We tested the code and the responses on TYPO3 14.3.10.
With a scope, administrators can give access to your actions
separately. It appears in the app record and on the consent screen.
If your action only reads content, use
\TYPO3\CMS\Backend\Scope\ContentReadScope and skip this step.
namespace MyVendor\MyExtension\Scope;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use TYPO3\CMS\Core\Scope\ScopeInterface;
use TYPO3\CMS\Core\Scope\ScopeUser;
#[AsTaggedItem(index: 'my-extension:report:read')]
final class PageReportScope implements ScopeInterface
{
public function getName(): string
{
return 'Read page reports';
}
public function getDescription(): string
{
return 'Read reports about recently changed pages.';
}
public function getIcon(): string
{
return 'actions-document-info';
}
public function allowedForUser(ScopeUser $user): bool
{
return true;
}
}Each public method with #[AsAction] becomes an action. The PHP types
and the PHPDoc return type become the JSON Schema. With the default
Configuration/Services.yaml of an extension, you register
nothing.
namespace MyVendor\MyExtension\Action;
use Doctrine\DBAL\ParameterType;
use MyVendor\MyExtension\Scope\PageReportScope;
use TYPO3\CMS\Core\Action\ActionContext;
use TYPO3\CMS\Core\Action\ActionType;
use TYPO3\CMS\Core\Action\Error\NotFoundError;
use TYPO3\CMS\Core\Attribute\AsAction;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
final readonly class PageReportAction
{
public function __construct(
private ConnectionPool $connectionPool,
) {}
/**
* @param int $limit Maximum number of pages
* @return list<array{
* uid: int,
* title: string,
* changedAt: int,
* }>
*/
#[AsAction(
route: 'my-extension/pages/recent',
type: ActionType::fetch,
summary: 'List the recently changed pages',
scopes: [PageReportScope::class],
)]
public function recent(
ActionContext $context,
int $limit = 5,
): array {
$rows = $this->pagesVisibleTo($context)
->orderBy('tstamp', 'DESC')
->setMaxResults(max(1, min($limit, 50)))
->executeQuery()
->fetchAllAssociative();
return array_map($this->toReport(...), $rows);
}
/**
* @return array{uid: int, title: string, changedAt: int}
* @throws NotFoundError The page is not available to the user
*/
#[AsAction(
route: 'my-extension/pages/{uid}',
type: ActionType::fetch,
summary: 'Fetch the report of one page',
scopes: [PageReportScope::class],
)]
public function show(ActionContext $context, int $uid): array
{
$query = $this->pagesVisibleTo($context);
$row = $query
->andWhere($query->expr()->eq(
'uid',
$query->createNamedParameter(
$uid,
ParameterType::INTEGER,
),
))
->executeQuery()
->fetchAssociative();
if ($row === false) {
throw new NotFoundError('Page not found', 1794000001);
}
return $this->toReport($row);
}
private function pagesVisibleTo(
ActionContext $context,
): QueryBuilder {
$user = $context->principal;
assert($user instanceof BackendUserAuthentication);
$visible = $user->getPagePermsClause(
Permission::PAGE_SHOW,
);
$query = $this->connectionPool
->getQueryBuilderForTable('pages');
return $query
->select('uid', 'title', 'tstamp')
->from('pages')
->where(
QueryHelper::stripLogicalOperatorPrefix($visible),
);
}
/**
* @param array<string, mixed> $row
* @return array{uid: int, title: string, changedAt: int}
*/
private function toReport(array $row): array
{
return [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
'changedAt' => (int)$row['tstamp'],
];
}
}TYPO3 checks the scopes, but an action decides itself what its
query returns. A direct database query has to apply the
permissions of the user, as pagesVisibleTo() does here. APIs such
as the DataHandler do this for you.
TYPO3 compiles actions with the dependency injection container.
vendor/bin/typo3 cache:flushOpen the app in Administration > Integrations > API Hub and select Read page reports.
curl -G -H "Authorization: Bearer $TYPO3_TOKEN" \
"$TYPO3_URL/typo3/api/v1/my-extension/pages/recent" \
-d limit=2[
{
"uid": 1,
"title": "Alle sprog, alle tegn",
"changedAt": 1793970942
},
{
"uid": 2,
"title": "Jede Sprache, jedes Zeichen",
"changedAt": 1793970942
}
]With a scope, administrators can give access to your actions
separately. It appears in the app record and on the consent screen.
If your action only reads content, use
\TYPO3\CMS\Backend\Scope\ContentReadScope and skip this step.
namespace MyVendor\MyExtension\Scope;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use TYPO3\CMS\Core\Scope\ScopeInterface;
use TYPO3\CMS\Core\Scope\ScopeUser;
#[AsTaggedItem(index: 'my-extension:report:read')]
final class PageReportScope implements ScopeInterface
{
public function getName(): string
{
return 'Read page reports';
}
public function getDescription(): string
{
return 'Read reports about recently changed pages.';
}
public function getIcon(): string
{
return 'actions-document-info';
}
public function allowedForUser(ScopeUser $user): bool
{
return true;
}
}Define the scope
With a scope, administrators can give access to your actions
separately. It appears in the app record and on the consent screen.
If your action only reads content, use
\TYPO3\CMS\Backend\Scope\ContentReadScope and skip this step.
namespace MyVendor\MyExtension\Scope;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use TYPO3\CMS\Core\Scope\ScopeInterface;
use TYPO3\CMS\Core\Scope\ScopeUser;
#[AsTaggedItem(index: 'my-extension:report:read')]
final class PageReportScope implements ScopeInterface
{
public function getName(): string
{
return 'Read page reports';
}
public function getDescription(): string
{
return 'Read reports about recently changed pages.';
}
public function getIcon(): string
{
return 'actions-document-info';
}
public function allowedForUser(ScopeUser $user): bool
{
return true;
}
}namespace MyVendor\MyExtension\Scope;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use TYPO3\CMS\Core\Scope\ScopeInterface;
use TYPO3\CMS\Core\Scope\ScopeUser;
#[AsTaggedItem(index: 'my-extension:report:read')]
final class PageReportScope implements ScopeInterface
{
public function getName(): string
{
return 'Read page reports';
}
public function getDescription(): string
{
return 'Read reports about recently changed pages.';
}
public function getIcon(): string
{
return 'actions-document-info';
}
public function allowedForUser(ScopeUser $user): bool
{
return true;
}
}
Each public method with #[AsAction] becomes an action. The PHP types
and the PHPDoc return type become the JSON Schema. With the default
Configuration/Services.yaml of an extension, you register
nothing.
namespace MyVendor\MyExtension\Action;
use Doctrine\DBAL\ParameterType;
use MyVendor\MyExtension\Scope\PageReportScope;
use TYPO3\CMS\Core\Action\ActionContext;
use TYPO3\CMS\Core\Action\ActionType;
use TYPO3\CMS\Core\Action\Error\NotFoundError;
use TYPO3\CMS\Core\Attribute\AsAction;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
final readonly class PageReportAction
{
public function __construct(
private ConnectionPool $connectionPool,
) {}
/**
* @param int $limit Maximum number of pages
* @return list<array{
* uid: int,
* title: string,
* changedAt: int,
* }>
*/
#[AsAction(
route: 'my-extension/pages/recent',
type: ActionType::fetch,
summary: 'List the recently changed pages',
scopes: [PageReportScope::class],
)]
public function recent(
ActionContext $context,
int $limit = 5,
): array {
$rows = $this->pagesVisibleTo($context)
->orderBy('tstamp', 'DESC')
->setMaxResults(max(1, min($limit, 50)))
->executeQuery()
->fetchAllAssociative();
return array_map($this->toReport(...), $rows);
}
/**
* @return array{uid: int, title: string, changedAt: int}
* @throws NotFoundError The page is not available to the user
*/
#[AsAction(
route: 'my-extension/pages/{uid}',
type: ActionType::fetch,
summary: 'Fetch the report of one page',
scopes: [PageReportScope::class],
)]
public function show(ActionContext $context, int $uid): array
{
$query = $this->pagesVisibleTo($context);
$row = $query
->andWhere($query->expr()->eq(
'uid',
$query->createNamedParameter(
$uid,
ParameterType::INTEGER,
),
))
->executeQuery()
->fetchAssociative();
if ($row === false) {
throw new NotFoundError('Page not found', 1794000001);
}
return $this->toReport($row);
}
private function pagesVisibleTo(
ActionContext $context,
): QueryBuilder {
$user = $context->principal;
assert($user instanceof BackendUserAuthentication);
$visible = $user->getPagePermsClause(
Permission::PAGE_SHOW,
);
$query = $this->connectionPool
->getQueryBuilderForTable('pages');
return $query
->select('uid', 'title', 'tstamp')
->from('pages')
->where(
QueryHelper::stripLogicalOperatorPrefix($visible),
);
}
/**
* @param array<string, mixed> $row
* @return array{uid: int, title: string, changedAt: int}
*/
private function toReport(array $row): array
{
return [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
'changedAt' => (int)$row['tstamp'],
];
}
}TYPO3 checks the scopes, but an action decides itself what its
query returns. A direct database query has to apply the
permissions of the user, as pagesVisibleTo() does here. APIs such
as the DataHandler do this for you.
Write the action
Each public method with #[AsAction] becomes an action. The PHP types
and the PHPDoc return type become the JSON Schema. With the default
Configuration/Services.yaml of an extension, you register
nothing.
namespace MyVendor\MyExtension\Action;
use Doctrine\DBAL\ParameterType;
use MyVendor\MyExtension\Scope\PageReportScope;
use TYPO3\CMS\Core\Action\ActionContext;
use TYPO3\CMS\Core\Action\ActionType;
use TYPO3\CMS\Core\Action\Error\NotFoundError;
use TYPO3\CMS\Core\Attribute\AsAction;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
final readonly class PageReportAction
{
public function __construct(
private ConnectionPool $connectionPool,
) {}
/**
* @param int $limit Maximum number of pages
* @return list<array{
* uid: int,
* title: string,
* changedAt: int,
* }>
*/
#[AsAction(
route: 'my-extension/pages/recent',
type: ActionType::fetch,
summary: 'List the recently changed pages',
scopes: [PageReportScope::class],
)]
public function recent(
ActionContext $context,
int $limit = 5,
): array {
$rows = $this->pagesVisibleTo($context)
->orderBy('tstamp', 'DESC')
->setMaxResults(max(1, min($limit, 50)))
->executeQuery()
->fetchAllAssociative();
return array_map($this->toReport(...), $rows);
}
/**
* @return array{uid: int, title: string, changedAt: int}
* @throws NotFoundError The page is not available to the user
*/
#[AsAction(
route: 'my-extension/pages/{uid}',
type: ActionType::fetch,
summary: 'Fetch the report of one page',
scopes: [PageReportScope::class],
)]
public function show(ActionContext $context, int $uid): array
{
$query = $this->pagesVisibleTo($context);
$row = $query
->andWhere($query->expr()->eq(
'uid',
$query->createNamedParameter(
$uid,
ParameterType::INTEGER,
),
))
->executeQuery()
->fetchAssociative();
if ($row === false) {
throw new NotFoundError('Page not found', 1794000001);
}
return $this->toReport($row);
}
private function pagesVisibleTo(
ActionContext $context,
): QueryBuilder {
$user = $context->principal;
assert($user instanceof BackendUserAuthentication);
$visible = $user->getPagePermsClause(
Permission::PAGE_SHOW,
);
$query = $this->connectionPool
->getQueryBuilderForTable('pages');
return $query
->select('uid', 'title', 'tstamp')
->from('pages')
->where(
QueryHelper::stripLogicalOperatorPrefix($visible),
);
}
/**
* @param array<string, mixed> $row
* @return array{uid: int, title: string, changedAt: int}
*/
private function toReport(array $row): array
{
return [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
'changedAt' => (int)$row['tstamp'],
];
}
}namespace MyVendor\MyExtension\Action;
use Doctrine\DBAL\ParameterType;
use MyVendor\MyExtension\Scope\PageReportScope;
use TYPO3\CMS\Core\Action\ActionContext;
use TYPO3\CMS\Core\Action\ActionType;
use TYPO3\CMS\Core\Action\Error\NotFoundError;
use TYPO3\CMS\Core\Attribute\AsAction;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
final readonly class PageReportAction
{
public function __construct(
private ConnectionPool $connectionPool,
) {}
/**
* @param int $limit Maximum number of pages
* @return list<array{
* uid: int,
* title: string,
* changedAt: int,
* }>
*/
#[AsAction(
route: 'my-extension/pages/recent',
type: ActionType::fetch,
summary: 'List the recently changed pages',
scopes: [PageReportScope::class],
)]
public function recent(
ActionContext $context,
int $limit = 5,
): array {
$rows = $this->pagesVisibleTo($context)
->orderBy('tstamp', 'DESC')
->setMaxResults(max(1, min($limit, 50)))
->executeQuery()
->fetchAllAssociative();
return array_map($this->toReport(...), $rows);
}
/**
* @return array{uid: int, title: string, changedAt: int}
* @throws NotFoundError The page is not available to the user
*/
#[AsAction(
route: 'my-extension/pages/{uid}',
type: ActionType::fetch,
summary: 'Fetch the report of one page',
scopes: [PageReportScope::class],
)]
public function show(ActionContext $context, int $uid): array
{
$query = $this->pagesVisibleTo($context);
$row = $query
->andWhere($query->expr()->eq(
'uid',
$query->createNamedParameter(
$uid,
ParameterType::INTEGER,
),
))
->executeQuery()
->fetchAssociative();
if ($row === false) {
throw new NotFoundError('Page not found', 1794000001);
}
return $this->toReport($row);
}
private function pagesVisibleTo(
ActionContext $context,
): QueryBuilder {
$user = $context->principal;
assert($user instanceof BackendUserAuthentication);
$visible = $user->getPagePermsClause(
Permission::PAGE_SHOW,
);
$query = $this->connectionPool
->getQueryBuilderForTable('pages');
return $query
->select('uid', 'title', 'tstamp')
->from('pages')
->where(
QueryHelper::stripLogicalOperatorPrefix($visible),
);
}
/**
* @param array<string, mixed> $row
* @return array{uid: int, title: string, changedAt: int}
*/
private function toReport(array $row): array
{
return [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
'changedAt' => (int)$row['tstamp'],
];
}
}
TYPO3 checks the scopes, but an action decides itself what its
query returns. A direct database query has to apply the
permissions of the user, as pagesVisibleTo() does here. APIs such
as the DataHandler do this for you.
TYPO3 checks the scopes, but an action decides itself what its
query returns. A direct database query has to apply the
permissions of the user, as pagesVisibleTo() does here. APIs such
as the DataHandler do this for you.
TYPO3 compiles actions with the dependency injection container.
vendor/bin/typo3 cache:flushClear the caches
TYPO3 compiles actions with the dependency injection container.
vendor/bin/typo3 cache:flushvendor/bin/typo3 cache:flush
Open the app in Administration > Integrations > API Hub and select Read page reports.
Grant the scope to an app
Open the app in Administration > Integrations > API Hub and select Read page reports.
curl -G -H "Authorization: Bearer $TYPO3_TOKEN" \
"$TYPO3_URL/typo3/api/v1/my-extension/pages/recent" \
-d limit=2[
{
"uid": 1,
"title": "Alle sprog, alle tegn",
"changedAt": 1793970942
},
{
"uid": 2,
"title": "Jede Sprache, jedes Zeichen",
"changedAt": 1793970942
}
]Call the action
curl -G -H "Authorization: Bearer $TYPO3_TOKEN" \
"$TYPO3_URL/typo3/api/v1/my-extension/pages/recent" \
-d limit=2curl -G -H "Authorization: Bearer $TYPO3_TOKEN" \
"$TYPO3_URL/typo3/api/v1/my-extension/pages/recent" \
-d limit=2
[
{
"uid": 1,
"title": "Alle sprog, alle tegn",
"changedAt": 1793970942
},
{
"uid": 2,
"title": "Jede Sprache, jedes Zeichen",
"changedAt": 1793970942
}
][
{
"uid": 1,
"title": "Alle sprog, alle tegn",
"changedAt": 1793970942
},
{
"uid": 2,
"title": "Jede Sprache, jedes Zeichen",
"changedAt": 1793970942
}
]
Results without more code#
- Validation
-
TYPO3 rejects a value of the wrong type before your method starts.
{ "type": "https://typo3.org/go/exception/CMS/1793870606", "title": "Bad Request", "status": 400, "detail": "The request contains invalid parameters", "errors": [ { "parameter": "limit", "detail": "String value found, but an integer is required" } ] }GET …/my-extension/pages/recent?limit=abc, status 400json{ "type": "https://typo3.org/go/exception/CMS/1793870606", "title": "Bad Request", "status": 400, "detail": "The request contains invalid parameters", "errors": [ { "parameter": "limit", "detail": "String value found, but an integer is required" } ] } - Error responses
-
The exception code becomes the
typeof the response, so it is part of your API. Do not change it later.{ "type": "https://typo3.org/go/exception/CMS/1794000001", "title": "Not Found", "status": 404, "detail": "Page not found" }GET …/my-extension/pages/999999, status 404json{ "type": "https://typo3.org/go/exception/CMS/1794000001", "title": "Not Found", "status": 404, "detail": "Page not found" } - Access control
-
Without the scope, TYPO3 does not call the method.
{ "type": "about:blank", "title": "Forbidden", "status": 403, "detail": "Missing scope: my-extension:report:read" }Status 403, after removing the scope from the appjson{ "type": "about:blank", "title": "Forbidden", "status": 403, "detail": "Missing scope: my-extension:report:read" } - Documentation
- The OpenAPI document now contains both operations, for example
fetch_my_extension_pages_by_uidwith the responses200,404anddefault. The new scope is listed with the OAuth scopes. - Backend JavaScript
-
The same action is available in the backend, see Calling the API from backend JavaScript:
import { action } from '@typo3/core/action/request.js'; const request = action('/v1/my-extension/pages/{uid}', { uid: '3' }); const response = await request.get(); const report = await response.resolve();javascriptimport { action } from '@typo3/core/action/request.js'; const request = action('/v1/my-extension/pages/{uid}', { uid: '3' }); const response = await request.get(); const report = await response.resolve();
Next steps#
- Limit the channels of an action with targets.
- Accept data from the path, query or body, see Parameter sources.
- Add a new version without breaking clients, see Actions.
- Declare further errors, see Errors.