diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad24377 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# OpenAPI Command Bundle + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/stixx/openapi-command-bundle.svg?style=flat-square)](https://packagist.org/packages/stixx/openapi-command-bundle) +[![Total Downloads](https://img.shields.io/packagist/dt/stixx/openapi-command-bundle.svg?style=flat-square)](https://packagist.org/packages/stixx/openapi-command-bundle) +[![License](https://img.shields.io/packagist/l/stixx/openapi-command-bundle.svg?style=flat-square)](https://packagist.org/packages/stixx/openapi-command-bundle) + +The **OpenAPI Command Bundle** is a Symfony bundle that allows you to build HTTP APIs around Command Bus messages (DTOs) without the need for manual controller creation or Symfony `#[Route]` attributes on your commands. + +By using standard OpenAPI operation attributes (from `zircote/swagger-php`) directly on your command DTOs, this bundle automatically generates Symfony routes and handles the entire request-to-command lifecycle: deserialization, validation, dispatching to the messenger bus, and responding. + +## Key Features + +- **OpenAPI-Driven Routing**: Define your API endpoints directly on your command DTOs using `#[OA\Post]`, `#[OA\Get]`, `#[OA\Put]`, etc. +- **No Manual Controllers**: A single `CommandController` handles all generated routes by default. +- **Automatic Deserialization**: Automatically maps JSON request bodies, route parameters, and query parameters to your command DTOs. +- **Built-in Validation**: Integrates with Symfony Validator to ensure your commands are valid before they reach your handlers. +- **Messenger Integration**: Dispatches your commands directly to the Symfony Messenger bus. +- **Auto-Generated Documentation**: Seamlessly integrates with `NelmioApiDocBundle` to include your command-based routes in your OpenAPI/Swagger documentation. +- **Problem Details Support**: Returns RFC 7807 compliant error responses for validation and mapping errors. + +## Installation + +### 1. Install via Composer + +```bash +composer require stixx/openapi-command-bundle +``` + +### 2. Enable the Bundle + +If you are using Symfony Flex, the bundle will be automatically enabled. Otherwise, add it to your `config/bundles.php`: + +```php +return [ + // ... + Stixx\OpenApiCommandBundle\StixxOpenApiCommandBundle::class => ['all' => true], +]; +``` + +## Usage + +### 1. Create a Command DTO + +Annotate your command with OpenAPI attributes. No Symfony `#[Route]` is needed. + +```php +namespace App\Command; + +use OpenApi\Attributes as OA; +use Symfony\Component\Validator\Constraints as Assert; + +#[OA\Post( + path: '/api/projects', + operationId: 'create_project', + summary: 'Create a new project' +)] +final class CreateProjectCommand +{ + public function __construct( + #[Assert\NotBlank] + #[Assert\Length(min: 3, max: 50)] + public string $name, + + #[Assert\Length(max: 255)] + public ?string $description = null, + ) {} +} +``` + +### 2. Create a Message Handler + +Implement a standard Symfony Messenger handler for your command. + +```php +namespace App\Handler; + +use App\Command\CreateProjectCommand; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +#[AsMessageHandler] +final class CreateProjectHandler +{ + public function __invoke(CreateProjectCommand $command): array + { + // Your business logic here (e.g., persist to database) + + return [ + 'id' => '123', + 'name' => $command->name, + ]; + } +} +``` + +### 3. Call the API + +The bundle automatically registers the route `/api/projects` (POST). + +```bash +curl -X POST http://localhost:3000/api/projects \ + -H "Content-Type: application/json" \ + -d '{"name": "New Project", "description": "This is a project description"}' +``` + +The bundle will: +1. Detect the route and map it to `CreateProjectCommand`. +2. Deserialize the JSON body into the command object. +3. Validate the command using Symfony Validator. +4. Dispatch the command to the Messenger bus. +5. Return the handler's result as a JSON response with an appropriate status code (e.g., `201 Created`). + +## Configuration + +You can customize the bundle's behavior in `config/packages/stixx_openapi_command.yaml`: + +```yaml +stixx_openapi_command: + validation: + enabled: true + groups: ['Default'] + openapi: + problem_details: true # Enable RFC 7807 problem details for errors +``` + +## Documentation + +For more detailed information, please refer to the following documentation: + +- [Command Routing & Request Handling](docs/command-routing.md) +- [Validation & Error Handling](docs/validation.md) +- [OpenAPI Integration](docs/openapi.md) + +## Requirements + +- PHP 8.4 or higher +- Symfony 7.3 or higher + +## License + +This bundle is released under the [MIT License](LICENSE). diff --git a/docs/command-routing.md b/docs/command-routing.md new file mode 100644 index 0000000..36b673b --- /dev/null +++ b/docs/command-routing.md @@ -0,0 +1,294 @@ +# Command routing (OpenAPI-driven, no Symfony #[Route] needed on commands) + +This bundle lets you build HTTP APIs around Command Bus messages (command DTOs) without writing controllers for each endpoint and without using Symfony’s #[Route] on the command classes. Instead, you declare OpenAPI operation attributes (from swagger-php) directly on your command DTOs and the bundle generates Symfony routes at compile time. A single CommandController handles the request lifecycle (deserialization, validation, dispatch, response) by default, with optional per-route overrides. + +Declaring routes on commands is supported via: + +- OpenAPI operation attributes on your command classes (non-controllers), such as #[OA\Post], #[OA\Get], #[OA\Put], #[OA\Patch], #[OA\Delete], etc. The path and HTTP method(s) are taken from these attributes, and the optional operationId becomes the route name. + +This coexists with classic Symfony route configuration (YAML/PHP/XML). Choose what fits your project. + + +## Prerequisites + +- Symfony 7.3+ +- This bundle installed and enabled +- Your command classes are registered as services (typical with autowire/autoconfigure) + +Important: Commands must NOT be controllers +- Do not extend Symfony\Bundle\FrameworkBundle\Controller\AbstractController in your command classes. +- Do not use #[Symfony\Bundle\FrameworkBundle\Controller\Attribute\AsController] on your command classes. +- The bundle excludes real controllers determined by: inheritance from AbstractController, presence of #[AsController], or having method‑level #[Route] attributes (controllers typically define #[Route] on methods). Command DTOs use only OpenAPI attributes at class level. + + +## Request lifecycle + +The following sequence diagram illustrates the lifecycle of a request handled by the **OpenAPI Command Bundle**, from the initial HTTP request to the final response. + +```mermaid +sequenceDiagram + participant Client + participant Symfony as Symfony Kernel + participant Validator as RequestValidatorSubscriber + participant Resolver as CommandValueResolver + participant Controller as CommandController + participant Messenger as MessageBusInterface + participant Status as StatusResolverInterface + participant Responder as ResponderChain + + Client->>Symfony: HTTP Request (JSON) + Symfony->>Validator: kernel.request event + Validator->>Validator: Run tagged validators (RequestValidatorChain) + Note over Validator: Throws BadRequestHttpException if validation fails + + Symfony->>Resolver: Resolve command DTO + Resolver->>Resolver: Decode JSON body + Resolver->>Resolver: Merge route and query parameters + Resolver->>Resolver: Denormalize into Command DTO + Note over Resolver: Throws BadRequestHttpException if mapping fails + + Symfony->>Controller: Invoke CommandController(Request, Command) + + Controller->>Controller: Validate Command DTO (Symfony Validator) + Note over Controller: Throws ApiProblemException if validation fails + + Controller->>Messenger: Dispatch Command + Messenger->>Messenger: Execute Message Handler + Messenger-->>Controller: Return Handler Result + Note over Controller: Catch HandlerFailedException and rethrow actual cause + + Controller->>Status: Resolve HTTP Status Code + Status-->>Controller: status code (e.g., 201) + + Controller->>Responder: respond(result, status) + Responder->>Responder: Find supporting Responder (e.g., JsonResponder) + Responder-->>Controller: Response object + + Controller-->>Symfony: Return Response + Symfony-->>Client: HTTP Response (JSON) + + Note over Symfony, Client: ApiExceptionSubscriber handles exceptions and returns Problem Details (RFC 7807) +``` + +## No extra routes configuration required + +Starting with this version, you do not need to add any custom route import for command DTOs. + +How it works +- The bundle decorates Symfony’s `AttributeDirectoryLoader` (the same mechanism used to load controller routes from attributes). +- During the normal route-building process, we automatically scan your project’s `%kernel.project_dir%/src` directory and add routes for command classes that meet the criteria: have class-level OpenAPI operation attributes (e.g., `#[OA\Post]`, `#[OA\Get]`, …) and are not controllers. +- This happens once per router build and coexists with your existing controller routes and any manually configured routes. + + +Notes +- No additional routing import is necessary. The bundle augments the standard attribute route loading automatically. +- The scan is recursive and limited to `%kernel.project_dir%/src`. +- Only classes that are annotated with OpenAPI operation attributes (e.g., `#[OA\Post]`) at class level and are not recognized controllers (`AbstractController`, `#[AsController]`, or having method-level `#[Route]`) will produce routes. + - Because of this, ensure your commands are plain DTOs and do not extend `AbstractController`, do not use `#[AsController]`, and do not declare method-level `#[Route]` attributes. + + +## Use OpenAPI attributes on command classes (no Symfony #[Route]) + +Place OpenAPI operation attributes on your command DTOs. They do not become controllers; the bundle still routes to CommandController by default. You can optionally override the controller per operation using an OpenAPI vendor extension or by annotating the command class with #[CommandObject(controller: ...)]. + +```php +use OpenApi\Attributes as OA; +use Stixx\OpenApiCommandBundle\Attribute\CommandObject; + +#[CommandObject] // optional – you can also set a custom controller here +#[OA\Post(path: '/api/employees', operationId: 'add_employee', summary: 'Add employee')] +final class AddEmployeeCommand +{ + public function __construct( + public string $firstName, + public string $lastName, + public string $email, + ) {} +} + +#[OA\Delete(path: '/api/employees/{uuid}', operationId: 'remove_employee', summary: 'Remove employee')] +final class RemoveEmployeeCommand +{ + public function __construct(public string $uuid) {} +} +``` + +Override the controller via OpenAPI vendor extension: + +```php +#[OA\Post(path: '/api/import', operationId: 'import_data', x: ['controller' => App\\Controller\\CustomCommandController::class])] +final class ImportDataCommand {} +``` + +Or via a class-level CommandObject attribute (not visible in the generated OpenAPI): + +```php +#[CommandObject(controller: App\\Controller\\CustomCommandController::class)] +#[OA\Post(path: '/api/import', operationId: 'import_data')] +final class ImportDataCommand {} +``` + +Notes +- Real controllers are not affected. The bundle uses a precompiled list of controller classes based on: AbstractController inheritance, #[AsController], or method-level #[Route]. +- Only classes that declare class-level OpenAPI operations are considered for routing discovery. + + +## Coexistence with classic route config + +You can keep writing routes manually in YAML/PHP. The bundle’s attribute-based routes will happily coexist. If the same route name is declared multiple times, Symfony will apply its usual conflict rules; the bundle also ensures uniqueness when it autogenerates names. + + +## Handling the HTTP request + +All generated routes point to a single controller: Stixx\OpenApiCommandBundle\Controller\CommandController. + +Request-to-command mapping +- Body: If the request contains a non-empty body, it must be JSON (`application/json` or `+json`). The body is denormalized into your command DTO via Symfony Serializer. +- No body / Merging: The `CommandValueResolver` collects scalar values from route placeholders and query parameters and merges them with the body data (if any) to build the command. If nothing mappable is found, a 400 Bad Request might be thrown depending on the command's requirements. + +Validation +- By default, the bundle validates the deserialized command via Symfony Validator before dispatching it. +- Configure validation via bundle options (validation groups, toggle HTTP validation). + +Dispatch and response +- The command is dispatched on the Messenger bus. The response status code is resolved by `Stixx\OpenApiCommandBundle\Response\StatusResolverInterface` based on request/command; the response is handled by a chain of responders implementing `Stixx\OpenApiCommandBundle\Responder\ResponderInterface`. + +--- + +## Customizing Responses (Responders) + +By default, the bundle includes responders for JSON serialization, but you can extend this by adding your own custom responders. This is useful if you need to return different formats (e.g., CSV, XML) or handle specific return types from your message handlers. + +### 1. Implement `ResponderInterface` + +Create a class that implements `Stixx\OpenApiCommandBundle\Responder\ResponderInterface`: + +```php +namespace App\Responder; + +use Stixx\OpenApiCommandBundle\Responder\ResponderInterface; +use Symfony\Component\HttpFoundation\Response; + +final class CsvResponder implements ResponderInterface +{ + public function supports(mixed $result): bool + { + // Return true if this responder can handle the result + return is_array($result) && isset($result['format']) && $result['format'] === 'csv'; + } + + public function respond(mixed $result, int $status): Response + { + $csvData = $this->convertToCsv($result['data']); + + return new Response($csvData, $status, [ + 'Content-Type' => 'text/csv', + ]); + } + + private function convertToCsv(array $data): string + { + // CSV conversion logic... + return "column1,column2\nvalue1,value2"; + } +} +``` + +### 2. Tag your Service + +Register your responder as a service and tag it with `stixx_openapi_command.response.responder`. With autoconfiguration enabled, the bundle automatically detects services implementing `ResponderInterface`. + +```yaml +# config/services.yaml +services: + App\Responder\CsvResponder: + tags: + - { name: 'stixx_openapi_command.response.responder', priority: 10 } +``` + +### How it works + +The `ResponderChain` iterates through all registered responders and calls `supports($result)` on each. The first responder that returns `true` will be used to generate the `Response`. You can use the `priority` attribute in the tag to control the order of the responders. + +--- + +## End-to-end example + +Command +```php +use OpenApi\Attributes as OA; +use Symfony\Component\Validator\Constraints as Assert; + +#[OA\Post(path: '/api/projects', operationId: 'project_create', summary: 'Create project')] +final class CreateProjectCommand +{ + public function __construct( + #[Assert\NotBlank] + public string $name, + + #[Assert\Length(max: 200)] + public ?string $description = null, + ) {} +} +``` + +Handler +```php +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +#[AsMessageHandler] +final class CreateProjectHandler +{ + public function __invoke(CreateProjectCommand $command): array + { + // ... create and persist + return ['id' => '123', 'name' => $command->name]; + } +} +``` + +Call +``` +POST /api/projects +Content-Type: application/json + +{"name":"Acme CMS","description":"Headless"} +``` + +Response +``` +HTTP/1.1 201 Created +Content-Type: application/json + +{"id":"123","name":"Acme CMS"} +``` + + +## OpenAPI / NelmioApiDoc + +The bundle provides a `CommandRouteDescriber` so your routes appear in Nelmio ApiDoc automatically. If you use Nelmio areas, the bundle also exposes a helper (`NelmioAreaRoutesChecker`) that can recognize whether a request targets a documented API route. Routes generated from OpenAPI attributes are compiled into Symfony’s router, so they are visible in `bin/console debug:router`. + + +## Migration note + +The previously introduced AsCommandRoute attribute and the temporary Symfony #[Route]-on-command approach are no longer supported to avoid confusion. Use OpenAPI operation attributes on the command class instead. You may override the controller via an OpenAPI vendor extension (x: { controller: FQCN }) or via a class-level #[CommandObject(controller: ...)]. + +Note about CommandRouteTaggedPass +- Earlier versions compiled route metadata via a DI compiler pass (`CommandRouteTaggedPass`) into a container parameter consumed by a loader. The bundle now uses Symfony-style filesystem scanning with attribute loaders (`AttributeDirectoryLoaderDecorator` + `CommandRouteClassLoader`). `CommandRouteTaggedPass` is no longer registered or used at runtime; it remains in the codebase only for backward-compatibility reference and may be removed in a future major release. + + +## Troubleshooting + +- My routes don’t show up + - Verify your command classes are registered as services (autowire/autoconfigure setups usually cover this). + - Ensure the command class declares a class-level OpenAPI operation attribute (e.g., #[OA\Post(path: ...)]). + - Ensure the class is NOT detected as a real controller by the bundle’s rules: it must not extend `AbstractController`, must not use `#[AsController]`, and must not declare method-level `#[Route]` attributes. + +- Symfony auto-tagged my command with controller.service_arguments + - This is less likely now since commands no longer use `#[Route]`. If you still observe it due to your own service config, it won’t exclude the class unless it matches the refined rules (`AbstractController`, `#[AsController]`, or method-level `#[Route]`). + +- 400 Bad Request: Unsupported Content-Type + - When sending a body, set `Content-Type: application/json` (or a `+json` media type). + +- Validation errors + - The bundle validates command DTOs prior to dispatch; you’ll get a 400 with violation details when constraints fail. diff --git a/docs/openapi.md b/docs/openapi.md new file mode 100644 index 0000000..2e4586c --- /dev/null +++ b/docs/openapi.md @@ -0,0 +1,109 @@ +# OpenAPI Integration + +The **OpenAPI Command Bundle** is designed to bridge the gap between your OpenAPI specification and your PHP Command Bus. It leverages standard OpenAPI attributes to drive routing and integrates seamlessly with `NelmioApiDocBundle` to generate your API documentation. + +## Defining Endpoints with OpenAPI Attributes + +Instead of using Symfony's `#[Route]` attribute, you define your endpoints directly on your command DTOs using attributes from the `zircote/swagger-php` library. + +### Supported Attributes + +The bundle recognizes the following class-level attributes: + +- `#[OA\Get]` +- `#[OA\Post]` +- `#[OA\Put]` +- `#[OA\Patch]` +- `#[OA\Delete]` +- `#[OA\Options]` +- `#[OA\Head]` + +### Example + +```php +namespace App\Command; + +use OpenApi\Attributes as OA; + +#[OA\Post( + path: '/api/items', + operationId: 'create_item', + summary: 'Create a new item', + description: 'This endpoint creates a new item in the system.' +)] +final class CreateItemCommand +{ + public function __construct( + public string $name, + ) {} +} +``` + +The `path` property defines the URL, and the `operationId` is used as the Symfony route name. + +--- + +## NelmioApiDoc Integration + +The bundle includes a `CommandRouteDescriber` that automatically extracts the OpenAPI information from your command classes and includes it in the documentation generated by `NelmioApiDocBundle`. + +### How it Works + +When `NelmioApiDocBundle` generates the documentation: +1. It identifies the routes handled by `CommandController`. +2. The `CommandRouteDescriber` finds the associated command class for each route. +3. It reads the OpenAPI attributes from the command class. +4. It merges this information into the final OpenAPI specification. + +This means that your `summary`, `description`, `parameters`, and even `responses` defined via attributes on the command class will appear in your Swagger UI. + +--- + +## Advanced Customization + +### Customizing the Controller + +By default, all routes are handled by `Stixx\OpenApiCommandBundle\Controller\CommandController`. If you need a custom controller for a specific operation, you can specify it using an OpenAPI vendor extension or the `#[CommandObject]` attribute. + +**Using Vendor Extension:** + +```php +#[OA\Post( + path: '/api/special', + x: ['controller' => App\Controller\SpecialController::class] +)] +final class SpecialCommand {} +``` + +**Using `#[CommandObject]`:** + +```php +use Stixx\OpenApiCommandBundle\Attribute\CommandObject; + +#[CommandObject(controller: App\Controller\SpecialController::class)] +#[OA\Post(path: '/api/special')] +final class SpecialCommand {} +``` + +### Problem Details Models + +If `problem_details` is enabled in the configuration (which it is by default), the bundle automatically registers the following models with `NelmioApiDocBundle`: + +- `ProblemDetails` +- `Violation` +- `ProblemDetailsInvalidRequestBody` + +These models will be used to describe error responses (400, 404, etc.) in your generated documentation, providing a clear schema for client developers. + +--- + +## Route Discovery + +The bundle automatically scans your `%kernel.project_dir%/src` directory for classes with OpenAPI attributes. + +- Only classes with **class-level** OpenAPI operation attributes are scanned. +- Classes that are already recognized as Symfony controllers (e.g., they extend `AbstractController` or use `#[AsController]`) are ignored to avoid conflicts. +- You can see the generated routes by running: + ```bash + php bin/console debug:router + ``` diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..389d4a4 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,159 @@ +# Validation & Error Handling + +The **OpenAPI Command Bundle** integrates with the Symfony Validator component to ensure your command DTOs are valid before they reach your message handlers. It also provides a robust error-handling mechanism that returns RFC 7807-compliant Problem Details responses. + +## Command Validation + +By default, the bundle automatically validates every command deserialized from a request. This happens within the `CommandController` before the command is dispatched to the Messenger bus. + +### Basic Usage + +Simply use standard Symfony Validator constraints on your command properties: + +```php +namespace App\Command; + +use OpenApi\Attributes as OA; +use Symfony\Component\Validator\Constraints as Assert; + +#[OA\Post(path: '/api/users', operationId: 'create_user')] +final class CreateUserCommand +{ + public function __construct( + #[Assert\NotBlank] + #[Assert\Email] + public string $email, + + #[Assert\NotBlank] + #[Assert\Length(min: 8)] + public string $password, + ) {} +} +``` + +If a request fails validation, the bundle will interrupt the flow and return a `400 Bad Request` response with violation details. + +### Configuration + +You can customize the validation behavior in `config/packages/stixx_openapi_command.yaml`: + +```yaml +stixx_openapi_command: + validation: + enabled: true # Enable or disable automatic validation (default: true) + groups: ['Default'] # Specify validation groups to use (default: ['Default']) +``` + +### Validation Groups + +If you need to use specific validation groups, you can configure them globally in the bundle configuration as shown above. + +> **Note**: Currently, validation groups are applied globally to all commands handled by the bundle. + +--- + +## Error Handling & Problem Details + +When an error occurs (validation fail, malformed JSON, mapping error, etc.), the bundle returns a response using the **RFC 7807 (Problem Details for HTTP APIs)** standard. + +### Content-Type + +Problem responses use the `application/problem+json` media type. + +### Example Validation Error Response + +```json +{ + "type": "about:blank", + "title": "Validation failed", + "status": 400, + "detail": "Validation failed", + "violations": [ + { + "propertyPath": "email", + "title": "This value is not a valid email address.", + "parameters": { + "{{ value }}": "\"invalid-email\"" + }, + "type": "urn:uuid:bd79c0ab-ddb3-4675-903c-8b6141c2f08b" + } + ] +} +``` + +### Mapping Errors + +If the request body cannot be mapped to your command DTO (e.g., missing required properties in the JSON, type mismatch), the bundle throws a `BadRequestHttpException` which is transformed into a Problem Details response: + +```json +{ + "type": "about:blank", + "title": "Unable to map request to command", + "status": 400, + "detail": "Unable to map request to command: Required parameter \"name\" is missing" +} +``` + +--- + +## Disabling Problem Details + +If you prefer to handle errors yourself or don't want to use the Problem Details format, you can disable it: + +```yaml +stixx_openapi_command: + openapi: + problem_details: false +``` + +When disabled, the bundle will not prepend Problem Details models to your NelmioApiDoc configuration, but it will still throw exceptions that you can catch in your own event listeners. + +--- + +## Extending Request Validation + +In addition to standard DTO validation, you can extend the validation of the incoming HTTP request by implementing custom request validators. This is useful for cross-field validation, checking headers, or performing security checks before the command is even deserialized. + +### 1. Implement `ValidatorInterface` + +Create a class that implements `Stixx\OpenApiCommandBundle\Validator\ValidatorInterface`: + +```php +namespace App\Validator; + +use Stixx\OpenApiCommandBundle\Validator\ValidatorInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; + +final class CustomHeaderValidator implements ValidatorInterface +{ + public function validate(Request $request): void + { + if (!$request->headers->has('X-Custom-Header')) { + throw new BadRequestHttpException('Missing X-Custom-Header'); + } + } +} +``` + +### 2. Tag your Service + +Register your validator as a service and tag it with `stixx_openapi_command.request.validator`. If you have autoconfiguration enabled, the bundle will automatically detect and register your validator if it implements the interface. + +```yaml +# config/services.yaml +services: + App\Validator\CustomHeaderValidator: + tags: + - { name: 'stixx_openapi_command.request.validator' } +``` + +### How it works + +All tagged validators are executed in a chain during the `kernel.request` event, but only for routes that are managed by this bundle (detected via `NelmioAreaRoutesChecker`). If any validator throws an exception, the request cycle is interrupted. + +--- + +## Customizing Error Responses + +The bundle uses an `ExceptionToApiProblemTransformer` to convert internal exceptions into `ApiProblemException`. You can decorate or override this service if you need to customize how specific exceptions are mapped to problem details.