Preskočiť na obsah

Príklad: DI rozšírenie ​

API používateľov nastavené iba v sekcii api: súboru config/common.neon, cez DI rozšírenie. V services: nie je ApiDecider, ApiPresenter ani error handler.

Od verzie 4.0.0

NetteApiExtension je novinka vo verzii 4.0.0.

PožiadavkaStavPoznámka
GET /api/v1/hello200verejný, authorization: false
GET /api/v1/users bez tokenu401predvolená autorizácia, ako problem detail
GET /api/v1/users s Authorization: Bearer secret-token200zoznam z UserRepository
GET /api/v1/users/detail?id=2200action: detail
GET /api/v1/users/detail400type https://api.example.com/problems/validation-error
POST /api/v1/users name=Carol201, 201, potom 429rateLimit: strict, 2 požiadavky za minútu
OPTIONS /api/v1/users200preflight, token netreba
GET /api/v1/nothing404type https://api.example.com/problems/not-found

Konfigurácia ​

yaml
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

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, endpoints 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

	endpoints:
		# GET /api/v1/hello is public
		- {method: GET, version: 1, package: hello, handler: App\Api\HelloHandler('Hello from neon'), authorization: false}
		# GET /api/v1/users and GET /api/v1/users/detail?id=1 need the token
		- {method: GET, version: 1, package: users, handler: App\Api\UsersListHandler}
		- {method: GET, version: 1, package: users, action: detail, handler: App\Api\UserDetailHandler}
		# POST /api/v1/users is limited to 2 calls per minute
		- {method: POST, version: 1, package: users, handler: App\Api\UserCreateHandler, rateLimit: strict}
  • defaults: authorization: token chráni každý endpoint, authorization: false robí hello verejným.
  • HelloHandler('Hello from neon') je statement, handler dostane argument z konfigurácie.
  • UsersListHandler dostane UserRepository v konštruktore autowiringom.

Handler so závislosťou ​

php
<?php

declare(strict_types=1);

namespace App\Api;

use App\Model\UserRepository;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;

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()]);
    }
}

Obmedzenie požiadaviek ​

php
<?php

declare(strict_types=1);

namespace App;

use Nette\Http\IRequest;
use Tomaj\NetteApi\RateLimit\RateLimitInterface;
use Tomaj\NetteApi\RateLimit\RateLimitResponse;

/**
 * Fixed window: at most $limit requests per client in $window seconds.
 * Counts in files to stay runnable without Redis; use Redis or APCu in production.
 */
final class FileRateLimit implements RateLimitInterface
{
    public function __construct(
        private IRequest $request,
        private string $storageDir,
        private int $limit = 2,
        private int $window = 60,
    ) {
    }

    public function check(): ?RateLimitResponse
    {
        // runs before authorization, so the key cannot be trusted yet; it only picks the bucket
        $client = $this->request->getHeader('X-Api-Key') ?? $this->request->getRemoteAddress() ?? 'unknown';
        $windowStart = intdiv(time(), $this->window) * $this->window;
        $file = $this->storageDir . '/rate-' . sha1($client) . '-' . $windowStart;

        $handle = fopen($file, 'c+');
        flock($handle, LOCK_EX);
        $usedBefore = (int) stream_get_contents($handle);
        if ($usedBefore < $this->limit) {
            ftruncate($handle, 0);
            rewind($handle);
            fwrite($handle, (string) ($usedBefore + 1));
        }
        flock($handle, LOCK_UN);
        fclose($handle);

        $retryAfter = $windowStart + $this->window - time();

        // ApiPresenter answers 429 when remaining is 0, so remaining counts this request too:
        // with limit 2 the first two requests get 2 and 1, the third one gets 0 and a 429 problem
        return new RateLimitResponse(
            $this->limit,
            $this->limit - min($usedBefore, $this->limit),
            $retryAfter,
        );
    }
}

Bootstrap ​

php
<?php

declare(strict_types=1);

namespace App;

use Nette\Bootstrap\Configurator;
use Nette\Utils\FileSystem;

final class Bootstrap
{
    public static function boot(): Configurator
    {
        $configurator = new Configurator();
        FileSystem::createDir(__DIR__ . '/../log');
        $configurator->enableTracy(__DIR__ . '/../log');
        $configurator->setTempDirectory(__DIR__ . '/../temp');
        $configurator->addConfig(__DIR__ . '/../config/common.neon');
        return $configurator;
    }
}

Test ​

bash
#!/usr/bin/env bash
# End-to-end check: the whole API is configured in the "api:" neon section of the DI extension.
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 8097
P='https://api.example.com/problems/'

request "$BASE/api/v1/hello"
expect_status 200 "public endpoint"
expect_body '{"message":"Hello from neon"}' "statement argument"
ok "GET /api/v1/hello is public and the handler got its neon 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 (action: 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()"

Časté otázky ​

Ako ho spustím? ​

composer install a ./e2e.sh v examples/di-extension. Test spustí vstavaný PHP server na porte 8097.

Kde je zaregistrovaný ApiPresenter? ​

Rozšírenie ho zaregistruje ako api.presenter. application: mapping a routa v RouterFactory zostávajú v aplikácii.

Ako pridám endpoint? ​

Napíšte handler do app/Api/ a pridajte jeden riadok do api: endpoints:. Nič iné sa nemení.

Čo sa stane pri preklepe v konfigurácii? ​

Kontajner sa neskompiluje a správa pomenuje endpoint aj kľúč. Skúste authorization: tokne.

Dá sa použiť so službami z iných príkladov? ​

Áno, ApiDecider alebo error handler v services: sa použije namiesto toho z rozšírenia, pozrite Prechod zo services.

Vydané pod licenciou MIT.