Príklad: Problem Details
Všetky chyby API objednávok ako RFC 9457 problem details, pri vývoji, v produkcii a len pre klientov, ktorí o ne žiadajú.
Od verzie 4.0.0
ProblemDetailsErrorHandler, NegotiatingErrorHandler a ProblemDetailsResponse sú nové vo verzii 4.0.0.
| Požiadavka | Stav | type | Poznámka |
|---|---|---|---|
POST /api/v1/orders product=book&quantity=2 | 201 | application/json | |
POST /api/v1/orders quantity=9 | 400 | validation-error | errors pre product a quantity, aj v produkcii |
POST /api/v1/orders product=sold-out&quantity=2 | 409 | out-of-stock | vráti ho handler, s product a available |
POST /api/v1/orders product=crash&quantity=1 | 500 | internal-error | text len pri vývoji, vždy v log/exception.log |
GET /api/v1/me bez tokenu | 401 | unauthorized | |
GET /api/v1/limited | 429 | rate-limited | limit, retryAfter a Retry-After: 30 |
GET /api/v1/nothing | 404 | not-found | |
GET /api/v1/openapi | 200 | schéma ProblemDetails pre chyby |
S ERRORS=negotiate majú chyby formát 3.x, kým klient nepošle Accept: application/problem+json.
Služby
application:
mapping:
Api: Tomaj\NetteApi\Presenters\*Presenter
parameters:
apiTokens:
secret-token: '*'
# problem "type" is this base + a short name, e.g. https://api.example.com/problems/validation-error
problemTypeBaseUri: https://api.example.com/problems/
services:
router: App\RouterFactory::create
apiPresenter:
factory: Tomaj\NetteApi\Presenters\ApiPresenter
# error details (exception messages) are on unless APP_ENV=production
- Tomaj\NetteApi\Output\Configurator\EnvConfigurator('APP_ENV', 'production')
- Tomaj\NetteApi\Misc\IpDetector
# every error (400, 401, 404, 429, 500) is sent as application/problem+json
errorHandler: Tomaj\NetteApi\Error\ProblemDetailsErrorHandler(typeBaseUri: %problemTypeBaseUri%)
# the OpenAPI spec describes the error responses with the ProblemDetails schema
- Tomaj\NetteApi\Handlers\OpenApiHandler(
initData: [info: [title: 'Orders API', description: 'Errors are RFC 9457 problem details']]
specBuilder: Tomaj\NetteApi\OpenApi\SpecBuilder(
Tomaj\NetteApi\OpenApi\PathBuilder(responseBuilder: Tomaj\NetteApi\OpenApi\ResponseBuilder(problemDetails: true))
)
)
- Tomaj\NetteApi\Link\ApiLink
apiDecider:
factory: Tomaj\NetteApi\ApiDecider
setup:
# POST /api/v1/orders: 400 with "errors", 409 from the handler, 500 from an exception
- addApi(Tomaj\NetteApi\EndpointIdentifier('POST', 1, 'orders'), App\Api\OrderCreateHandler(%problemTypeBaseUri%), Tomaj\NetteApi\Authorization\NoAuthorization())
# GET /api/v1/me: 401 without a valid bearer token
- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'me'), App\Api\MeHandler(), Tomaj\NetteApi\Authorization\BearerTokenAuthorization(Tomaj\NetteApi\Misc\StaticTokenRepository(%apiTokens%)))
# GET /api/v1/limited: always over the limit, 429
- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'limited'), App\Api\MeHandler(), Tomaj\NetteApi\Authorization\NoAuthorization(), App\ExhaustedRateLimit())
- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'openapi'), Tomaj\NetteApi\Handlers\OpenApiHandler, Tomaj\NetteApi\Authorization\NoAuthorization())Výber podľa Accept
services:
# replaces the errorHandler service from common.neon:
# Accept: application/problem+json gets problem details, every other client the 3.x format
errorHandler: Tomaj\NetteApi\Error\NegotiatingErrorHandler(
Tomaj\NetteApi\Error\DefaultErrorHandler()
Tomaj\NetteApi\Error\ProblemDetailsErrorHandler(typeBaseUri: %problemTypeBaseUri%)
)<?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();
// Tracy writes the exceptions logged by ProblemDetailsErrorHandler to log/, which has to exist
FileSystem::createDir(__DIR__ . '/../log');
$configurator->enableTracy(__DIR__ . '/../log');
$configurator->setTempDirectory(__DIR__ . '/../temp');
$configurator->addConfig(__DIR__ . '/../config/common.neon');
// ERRORS=negotiate: problem details only for clients that send Accept: application/problem+json
if (getenv('ERRORS') === 'negotiate') {
$configurator->addConfig(__DIR__ . '/../config/negotiate.neon');
}
return $configurator;
}
}Handler
<?php
declare(strict_types=1);
namespace App\Api;
use Nette\Utils\Json;
use RuntimeException;
use Tomaj\NetteApi\Error\ProblemDetails;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\OpenApi\ResponseBuilder;
use Tomaj\NetteApi\Output\JsonOutput;
use Tomaj\NetteApi\Params\PostInputParam;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ProblemDetailsResponse;
use Tomaj\NetteApi\Response\ResponseInterface;
final class OrderCreateHandler extends BaseHandler
{
private string $problemTypeBaseUri;
public function __construct(string $problemTypeBaseUri)
{
parent::__construct();
// the same base as ProblemDetailsErrorHandler, which adds a missing trailing slash too
$this->problemTypeBaseUri = rtrim($problemTypeBaseUri, '/') . '/';
}
public function summary(): string
{
return 'Create an order';
}
public function params(): array
{
return [
(new PostInputParam('product'))->setRequired(),
(new PostInputParam('quantity'))->setRequired()->setAvailableValues(['1', '2', '3']),
];
}
public function handle(array $params): ResponseInterface
{
if ($params['product'] === 'crash') {
// ProblemDetailsErrorHandler::handle() answers 500, the message only with error details on
throw new RuntimeException('SQLSTATE[08006] connection to orders-db:5432 refused');
}
if ($params['product'] === 'sold-out') {
// a business error: the handler builds the problem itself, with an own type and extension members
return new ProblemDetailsResponse(new ProblemDetails(
status: 409,
title: 'Out of stock',
detail: 'Product sold-out has 0 pieces left, 2 requested.',
type: $this->problemTypeBaseUri . 'out-of-stock',
instance: '/api/v1/orders',
extensions: ['product' => 'sold-out', 'available' => 0],
));
}
return new JsonApiResponse(201, ['product' => $params['product'], 'quantity' => (int) $params['quantity']]);
}
public function outputs(): array
{
return [
new JsonOutput(201, '{"type":"object","required":["product","quantity"]}', 'The new order'),
// outputs are validated in development: a problem the handler returns must be declared too,
// otherwise ApiPresenter replaces it with a 500 from handleSchema()
new JsonOutput(409, Json::encode(ResponseBuilder::problemDetailsSchema()), 'Out of stock'),
];
}
}Obmedzenie požiadaviek
<?php
declare(strict_types=1);
namespace App;
use Tomaj\NetteApi\RateLimit\RateLimitInterface;
use Tomaj\NetteApi\RateLimit\RateLimitResponse;
/**
* No requests left, retry in 30 seconds. Without an own error response ApiPresenter asks
* the error handler for the 429, see examples/rate-limit for a real limit.
*/
final class ExhaustedRateLimit implements RateLimitInterface
{
public function check(): ?RateLimitResponse
{
return new RateLimitResponse(limit: 10, remaining: 0, retryAfter: 30);
}
}Test
#!/usr/bin/env bash
# End-to-end check: every framework error is application/problem+json, then content negotiation.
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 log
# --- development: problem details for every error, exception messages in "detail"
serve 8091
P='https://api.example.com/problems/'
request -X POST -d 'product=book&quantity=2' "$BASE/api/v1/orders"
expect_status 201 "valid order"
expect_header 'Content-Type: application/json' "success stays application/json"
ok "POST /api/v1/orders with valid input answers 201 JSON"
request -X POST -d 'quantity=9' "$BASE/api/v1/orders"
expect_status 400 "invalid input"
expect_header 'Content-Type: application/problem+json' "400 content type"
expect_body "\"type\":\"${P}validation-error\",\"status\":400,\"title\":\"Bad Request\",\"detail\":\"Request parameters are not valid.\"" "400 members"
expect_body '"errors":[{"parameter":"product","detail":"Field is required"},{"parameter":"quantity"' "400 errors list"
ok "invalid input answers 400 problem with an errors list"
request -X POST -d 'product=sold-out&quantity=2' "$BASE/api/v1/orders"
expect_status 409 "business error"
expect_header 'Content-Type: application/problem+json' "409 content type"
expect_body "{\"type\":\"${P}out-of-stock\",\"status\":409,\"title\":\"Out of stock\",\"detail\":\"Product sold-out has 0 pieces left, 2 requested.\",\"instance\":\"/api/v1/orders\",\"product\":\"sold-out\",\"available\":0}" "409 body"
ok "a handler returns its own 409 problem"
request -X POST -d 'product=crash&quantity=1' "$BASE/api/v1/orders"
expect_status 500 "exception"
expect_header 'Content-Type: application/problem+json' "500 content type"
expect_body "{\"type\":\"${P}internal-error\",\"status\":500,\"title\":\"Internal Server Error\",\"detail\":\"SQLSTATE[08006] connection to orders-db:5432 refused\"}" "500 debug"
grep -q "orders-db:5432 refused" log/exception.log || { echo "fail: the exception is not in log/exception.log" >&2; exit 1; }
ok "an exception answers 500 problem with the message in development and is logged"
request "$BASE/api/v1/me"
expect_status 401 "no token"
expect_body "{\"type\":\"${P}unauthorized\",\"status\":401,\"title\":\"Unauthorized\",\"detail\":\"Authorization header HTTP_Authorization is not set\"}" "401 body"
ok "a missing token answers 401 problem"
request -H 'Authorization: Bearer secret-token' "$BASE/api/v1/me"
expect_status 200 "token"
ok "a valid token answers 200"
request "$BASE/api/v1/limited"
expect_status 429 "rate limit"
expect_header 'Content-Type: application/problem+json' "429 content type"
expect_header 'Retry-After: 30' "429 Retry-After"
expect_body "{\"type\":\"${P}rate-limited\",\"status\":429,\"title\":\"Too Many Requests\",\"detail\":\"Too many requests. Retry after 30 seconds.\",\"limit\":10,\"retryAfter\":30}" "429 body"
ok "an exhausted rate limit answers 429 problem"
request "$BASE/api/v1/nothing"
expect_status 404 "unknown endpoint"
expect_header 'Content-Type: application/problem+json' "404 content type"
expect_body "{\"type\":\"${P}not-found\",\"status\":404,\"title\":\"Not Found\",\"detail\":\"Unknown api endpoint\"}" "404 body"
ok "an unknown endpoint answers 404 problem"
request "$BASE/api/v1/openapi"
expect_status 200 "openapi"
expect_body '"ProblemDetails":{"type":"object","description":"RFC 9457 problem details"' "ProblemDetails schema"
expect_body '"application/problem+json; charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}' "error responses reference it"
ok "the OpenAPI spec documents errors with the ProblemDetails schema"
# --- production: no exception message
APP_ENV=production serve 8092
request -X POST -d 'product=crash&quantity=1' "$BASE/api/v1/orders"
expect_status 500 "production exception"
expect_body "{\"type\":\"${P}internal-error\",\"status\":500,\"title\":\"Internal Server Error\"}" "500 production"
if [[ "$BODY" == *SQLSTATE* ]]; then echo "fail: production leaks the exception message" >&2; exit 1; fi
ok "production hides the exception message"
request -X POST -d 'quantity=9' "$BASE/api/v1/orders"
expect_body '"errors":[{"parameter":"product","detail":"Field is required"}' "400 errors in production"
ok "validation errors are sent in production too"
# --- negotiation: problem details only when the client asks for them
rm -rf temp/cache
ERRORS=negotiate serve 8093
request "$BASE/api/v1/nothing"
expect_status 404 "negotiate default"
expect_header 'Content-Type: application/json' "legacy content type"
expect_body '{"status":"error","message":"Unknown api endpoint"}' "legacy body"
expect_header 'Vary: Accept' "legacy Vary"
ok "without Accept the 3.x error format is sent, with Vary: Accept"
request -H 'Accept: application/problem+json, application/json;q=0.5' "$BASE/api/v1/nothing"
expect_status 404 "negotiate problem"
expect_header 'Content-Type: application/problem+json' "problem content type"
expect_body "\"type\":\"${P}not-found\"" "problem body"
expect_header 'Vary: Accept' "problem Vary"
ok "Accept: application/problem+json gets a problem, with Vary: Accept"
request -H 'Accept: application/*' "$BASE/api/v1/nothing"
expect_header 'Content-Type: application/json' "wildcard content type"
ok "Accept: application/* gets the 3.x error format"Časté otázky
Ako ho spustím?
composer install a ./e2e.sh v examples/problem-details. Test spustí vstavaný PHP server na portoch 8091, 8092 a 8093.
Prečo 409 potrebuje výstup?
Pri vývoji sa výstupy validujú a odpoveď s kódom, ktorý nie je v outputs(), sa zmení na 500 z handleSchema(). Handler uvádza 409 so schémou ResponseBuilder::problemDetailsSchema().
Prečo je type URL, ktorá neexistuje?
api.example.com je len zástupná adresa. Nasmerujte problemTypeBaseUri na stránky, ktoré vaše problémy opisujú.
Ako ponechám starý formát pre starých klientov?
Použite config/negotiate.neon: NegotiatingErrorHandler pošle problem details len klientom s Accept: application/problem+json.