Skip to content

Example: Problem Details ​

Every error of an orders API as RFC 9457 problem details, in development, in production and only for clients that ask for them.

Since 4.0.0

ProblemDetailsErrorHandler, NegotiatingErrorHandler and ProblemDetailsResponse are new in 4.0.0.

RequestStatustypeNote
POST /api/v1/orders product=book&quantity=2201application/json
POST /api/v1/orders quantity=9400validation-errorerrors for product and quantity, in production too
POST /api/v1/orders product=sold-out&quantity=2409out-of-stockreturned by the handler, with product and available
POST /api/v1/orders product=crash&quantity=1500internal-errorthe message only in development, always in log/exception.log
GET /api/v1/me without a token401unauthorized
GET /api/v1/limited429rate-limitedlimit, retryAfter and Retry-After: 30
GET /api/v1/nothing404not-found
GET /api/v1/openapi200ProblemDetails schema for the errors

With ERRORS=negotiate the errors keep the 3.x format unless the client sends Accept: application/problem+json.

The services ​

yaml
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())

Negotiation ​

yaml
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
<?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;
    }
}

The handler ​

php
<?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'),
        ];
    }
}

The rate limit ​

php
<?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);
    }
}

The test ​

bash
#!/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"

FAQ ​

How do I run it? ​

composer install and ./e2e.sh in examples/problem-details. The test starts the PHP built-in server on ports 8091, 8092 and 8093.

Why does the 409 need an output? ​

Outputs are validated in development, and a response whose code is not in outputs() becomes a 500 from handleSchema(). The handler declares 409 with ResponseBuilder::problemDetailsSchema().

Why is the type a URL that does not exist? ​

api.example.com is a placeholder. Point problemTypeBaseUri to pages that document your problems.

How do I keep the old format for old clients? ​

Use config/negotiate.neon: NegotiatingErrorHandler sends problem details only to clients with Accept: application/problem+json.

Released under the MIT License.