Example: attribute endpoints
The users API from the DI extension example, but the endpoints are written on the handler classes with attributes. The api: section has no endpoints: list, only discovery:.
Since 4.0.0
The attributes and api: discovery: are new in 4.0.0.
| Request | Status | Attribute |
|---|---|---|
GET /api/v1/hello | 200 | #[Get(1, 'hello')], #[Authorization(false)] |
GET /api/v1/users without a token | 401 | #[Get(1, 'users')], the default authorization |
GET /api/v1/users with Authorization: Bearer secret-token | 200 | #[Get(1, 'users')] |
GET /api/v1/users/detail?id=2 | 200 | #[Get(1, 'users', 'detail')] |
GET /api/v1/users/detail | 400 | missing id, a problem detail |
POST /api/v1/users name=Carol | 201, 201, then 429 | #[Post(1, 'users')], #[RateLimit('strict')] |
OPTIONS /api/v1/users | 200 | CORS preflight, no token needed |
GET /api/v1/nothing | 404 | no attribute for it |
The configuration
extensions:
api: Tomaj\NetteApi\DI\NetteApiExtension
parameters:
apiTokens:
secret-token: '*'
services:
router: App\RouterFactory::create
# a normal application service, handlers get it through autowiring
- App\Model\UserRepository
# a handler that needs a scalar argument is a normal service, its attributes are read as well
- App\Api\HelloHandler('Hello from attributes')
api:
# error details are on unless APP_ENV=production
outputConfigurator: Tomaj\NetteApi\Output\Configurator\EnvConfigurator('APP_ENV', 'production')
errors:
format: problemDetails
typeBaseUri: https://api.example.com/problems/
# without cors the extension sends no CORS headers
cors:
allowedOrigins: [https://app.example.com]
preflight: true
logging:
maskHeaders: true
# named services, #[Authorization] and #[RateLimit] use them by name
authorizations:
token: Tomaj\NetteApi\Authorization\BearerTokenAuthorization(Tomaj\NetteApi\Misc\StaticTokenRepository(%apiTokens%))
rateLimits:
strict: App\FileRateLimit(storageDir: %tempDir%, limit: 2)
# every endpoint needs the token unless it says otherwise
defaults:
authorization: token
# every class in app/Api with #[Get], #[Post], ... becomes an endpoint
discovery:
paths: [%appDir%/Api]
namespace: App\Apidiscovery: pathsandnamespacetell the extension to read the classes inapp/Api.HelloHandler('Hello from attributes')is registered inservices:because it needs a string. Its attributes are read from the service, no second handler is created.authorizations: tokenandrateLimits: strictare the names the attributes use.
A public endpoint
<?php
declare(strict_types=1);
namespace App\Api;
use Tomaj\NetteApi\Attribute\Authorization;
use Tomaj\NetteApi\Attribute\Get;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;
// public: GET /api/v1/hello
#[Get(1, 'hello')]
#[Authorization(false)]
final class HelloHandler extends BaseHandler
{
// the message comes from the service in config/common.neon
public function __construct(private readonly string $message)
{
parent::__construct();
}
public function handle(array $params): ResponseInterface
{
return new JsonApiResponse(200, ['message' => $this->message]);
}
}A rate-limited endpoint
<?php
declare(strict_types=1);
namespace App\Api;
use Tomaj\NetteApi\Attribute\Post;
use Tomaj\NetteApi\Attribute\RateLimit;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Params\PostInputParam;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;
// POST /api/v1/users, at most 2 calls per minute
#[Post(1, 'users')]
#[RateLimit('strict')]
final class UserCreateHandler extends BaseHandler
{
public function params(): array
{
return [
(new PostInputParam('name'))->setRequired(),
];
}
public function handle(array $params): ResponseInterface
{
return new JsonApiResponse(201, ['id' => 3, 'name' => $params['name']]);
}
}A handler with a dependency
<?php
declare(strict_types=1);
namespace App\Api;
use App\Model\UserRepository;
use Tomaj\NetteApi\Attribute\Get;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;
// GET /api/v1/users, protected by api.defaults.authorization
#[Get(1, 'users')]
final class UsersListHandler extends BaseHandler
{
// UserRepository is autowired, the handler is a normal service
public function __construct(private readonly UserRepository $users)
{
parent::__construct();
}
public function handle(array $params): ResponseInterface
{
return new JsonApiResponse(200, ['users' => $this->users->all()]);
}
}An endpoint with an action
<?php
declare(strict_types=1);
namespace App\Api;
use App\Model\UserRepository;
use Tomaj\NetteApi\Attribute\Get;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Params\GetInputParam;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;
// GET /api/v1/users/detail?id=1
#[Get(1, 'users', 'detail')]
final class UserDetailHandler extends BaseHandler
{
public function __construct(private readonly UserRepository $users)
{
parent::__construct();
}
public function params(): array
{
return [
(new GetInputParam('id'))->setRequired(),
];
}
public function handle(array $params): ResponseInterface
{
$user = $this->users->find((int) $params['id']);
if ($user === null) {
return new JsonApiResponse(404, ['status' => 'error', 'message' => 'User not found']);
}
return new JsonApiResponse(200, $user);
}
}The test
#!/usr/bin/env bash
# End-to-end check: endpoints come from attributes on the handler classes.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f vendor/autoload.php ]] || { echo "fail: vendor/ is missing, run composer install first" >&2; exit 1; }
source ../ci/lib.sh
rm -rf temp/cache temp/rate-* log
serve 8098
P='https://api.example.com/problems/'
request "$BASE/api/v1/hello"
expect_status 200 "public endpoint"
expect_body '{"message":"Hello from attributes"}' "service argument"
ok "GET /api/v1/hello is public and the handler service got its argument"
request "$BASE/api/v1/users"
expect_status 401 "default authorization"
expect_header 'Content-Type: application/problem+json' "errors.format"
expect_body "\"type\":\"${P}unauthorized\"" "errors.typeBaseUri"
ok "defaults.authorization protects GET /api/v1/users, the error is a problem detail"
request -H 'Authorization: Bearer secret-token' "$BASE/api/v1/users"
expect_status 200 "token"
expect_body '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}' "autowired repository"
ok "with the token the handler answers with users from an autowired service"
request -H 'Authorization: Bearer secret-token' "$BASE/api/v1/users/detail?id=2"
expect_status 200 "action"
expect_body '{"id":2,"name":"Bob"}' "detail"
ok "GET /api/v1/users/detail is a separate endpoint (#[Get(1, 'users', 'detail')])"
request -H 'Authorization: Bearer secret-token' "$BASE/api/v1/users/detail"
expect_status 400 "params"
expect_body "\"type\":\"${P}validation-error\"" "validation problem"
ok "a missing param answers 400 problem"
request -H 'Origin: https://app.example.com' "$BASE/api/v1/hello"
expect_header 'Access-Control-Allow-Origin: https://app.example.com' "allowed origin"
request -H 'Origin: https://evil.example.com' "$BASE/api/v1/hello"
no_header 'Access-Control-Allow-Origin' "other origin"
ok "cors.allowedOrigins answers only the listed origin"
request -X OPTIONS -H 'Origin: https://app.example.com' "$BASE/api/v1/users"
expect_status 200 "preflight"
expect_header 'Access-Control-Allow-Methods' "preflight methods"
ok "cors.preflight answers OPTIONS for every endpoint without a token"
for i in 1 2; do
request -X POST -H 'Authorization: Bearer secret-token' -d 'name=Carol' "$BASE/api/v1/users"
expect_status 201 "create $i"
done
expect_body '{"id":3,"name":"Carol"}' "created"
request -X POST -H 'Authorization: Bearer secret-token' -d 'name=Carol' "$BASE/api/v1/users"
expect_status 429 "rate limit"
expect_body "\"type\":\"${P}rate-limited\"" "429 problem"
ok "#[RateLimit('strict')] lets 2 POST requests through, the third one gets 429"
request "$BASE/api/v1/nothing"
expect_status 404 "unknown"
expect_body "\"type\":\"${P}not-found\"" "404 problem"
ok "an unknown endpoint answers 404 problem"
# the compiled container only calls addApi(), the handlers are created when they are used
grep -q "addApi" temp/cache/nette.configurator/*.php || { echo "fail: no addApi() in the compiled container" >&2; exit 1; }
ok "the compiled container registers the endpoints with addApi()"FAQ
How do I run it?
composer install and ./e2e.sh in examples/attributes. The test starts the PHP built-in server on port 8098.
How do I add an endpoint?
Write a handler in app/Api/ with #[Get(1, 'something')]. The neon config does not change; in debug mode the container rebuilds on its own.
How does it differ from the DI extension example?
Only in where the endpoints are written. The handlers, the error format, CORS and the rate limit are the same, and so is the compiled container.
What happens with a typo in an attribute?
The container does not compile. Try #[Authorization('tokne')]: the message names the endpoint and the class with the attribute.
Can I protect only some endpoints of one handler?
No, #[Authorization] is for the whole class. Write two handlers, like UsersListHandler and UserCreateHandler here.