A Laravel package for versioned API routing, OpenAPI 3.0 auto-documentation, and CRUD scaffolding.
Build versioned APIs with automatic OpenAPI documentation generated from PHP docblocks — no YAML/JSON schemas to maintain, no annotation libraries to learn.
- Quick Start
- Features
- Installation
- Architecture
- API Versioning
- Routing & Middleware
- OpenAPI 3.0 Documentation
- CRUD Scaffolding
- Testing
- Configuration
- Error Handling
- IDE support
- Comparison with Alternatives
- API Reference
- License
composer require dskripchenko/laravel-api// 1. Define your API class
class Api extends \Dskripchenko\LaravelApi\Components\BaseApi
{
public static function getMethods(): array
{
return [
'controllers' => [
'user' => [
'controller' => UserController::class,
'actions' => ['list', 'show', 'create'],
],
],
];
}
}
// 2. Define your module
class ApiModule extends \Dskripchenko\LaravelApi\Components\BaseModule
{
public function getApiVersionList(): array
{
return ['v1' => Api::class];
}
}
// 3. Define your ServiceProvider
class ApiServiceProvider extends \Dskripchenko\LaravelApi\Providers\ApiServiceProvider
{
protected function getApiModule() { return new ApiModule(); }
}
// 4. Write a controller with docblocks
class UserController extends \Dskripchenko\LaravelApi\Controllers\ApiController
{
/**
* List users
* @input integer ?$page Page number
* @output integer $id User ID
* @output string $name User name
*/
public function list(Request $request): JsonResponse
{
return $this->success(User::paginate()->toArray());
}
}Result:
GET /api/v1/user/list— API endpointGET /api/doc— Auto-generated API documentation (Scalar)
| Feature | Description |
|---|---|
| Versioned routing | api/{version}/{controller}/{action} with inheritance between versions |
| OpenAPI 3.0 | Auto-generated from @input/@output docblocks — no YAML files |
| CRUD scaffolding | Complete search/create/read/update/delete with filtering, sorting, pagination |
| Middleware cascade | Global → controller → action with fine-grained exclusion |
| Response templates | Reusable $ref schemas in components/schemas |
| Security schemes | @security tag + securitySchemes for Bearer/API key auth |
| Nested parameters | Dot-notation: @input string $address.city → nested JSON schema |
| File uploads | @input file $avatar → auto multipart/form-data |
| Multiple responses | @response 200 {Success} / @response 422 {Error} |
| Header parameters | @header string $Authorization — aggregated from controller + middleware |
| Soft deletes | Built-in restore() and forceDelete() in CrudService |
| Request tracing | RequestIdMiddleware — X-Request-Id propagation + log context |
| Optional output fields | @output string ?$email — marks response fields as optional in OpenAPI schema |
| TypeScript generation | api:generate-types — generates TS interfaces from OpenAPI spec |
| Named routes | Each action registered as a named Laravel route — route('api.v1.user.list') |
| API export | api:export — Postman Collection, HTTP Client (.http), Markdown, cURL |
| Markup linting | api:lint — catches renamed actions, dangling templates, unknown types |
| IDE support | PhpStorm plugin — highlighting, inspections, quick fixes, endpoint list |
| Test helpers | assertApiSuccess(), assertApiError(), assertApiValidationError() |
| Publishable config | config/laravel-api.php — prefix, URI pattern, HTTP methods |
- PHP 8.2+
- Laravel 11.x — 13.x
composer require dskripchenko/laravel-apiphp artisan vendor:publish --tag=laravel-api-configHTTP Request
└─ ApiServiceProvider (registers route: api/{version}/{controller}/{action})
└─ BaseApiRequest (parses version, controller, action from URI)
└─ BaseModule::getApi() (version string → BaseApi subclass)
└─ BaseApi::make()
├─ getMethods() → resolve controller + action
├─ Middleware cascade (global → controller → action)
└─ app()->call(Controller@action)
└─ JsonResponse {success: true, payload: {...}}
Every response is wrapped in a standard envelope:
// Success
{"success": true, "payload": {"id": 1, "name": "John"}}
// Error
{"success": false, "payload": {"errorKey": "not_found", "message": "User not found"}}
// Validation error
{"success": false, "payload": {"errorKey": "validation", "messages": {"email": ["Required"]}}}src/
├── Components/ BaseApi, BaseModule, Meta
├── Console/Commands/ ApiInstall, ApiGenerateTypes, ApiExport, ApiDocClear, ApiLint
├── Controllers/ ApiController, CrudController, ApiDocumentationController
├── Exceptions/ ApiException, ApiErrorHandler, Handler
├── Facades/ ApiRequest, ApiModule, ApiErrorHandler
├── Interfaces/ CrudServiceInterface, ApiInterface
├── Middlewares/ ApiMiddleware, RequestIdMiddleware
├── Providers/ ApiServiceProvider, BaseServiceProvider
├── Requests/ BaseApiRequest, CrudSearchRequest
├── Resources/ BaseJsonResource, BaseJsonResourceCollection
├── Services/ ApiResponseHelper, CrudService, OpenApiTypeScriptGenerator
└── Traits/
├── OpenApiTrait
└── Testing/ MakesHttpApiRequests
API versions use PHP class inheritance — later versions extend earlier ones:
// V1: full API
class ApiV1 extends BaseApi {
public static function getMethods(): array {
return ['controllers' => [
'user' => [
'controller' => UserControllerV1::class,
'actions' => ['list', 'show', 'create', 'update', 'delete'],
],
]];
}
}
// V2: inherits V1, modifies selectively
class ApiV2 extends ApiV1 {
public static function getMethods(): array {
return ['controllers' => [
'user' => [
'controller' => UserControllerV2::class, // upgraded controller
'actions' => [
'delete' => false, // removed in v2
'archive', // new in v2
],
],
]];
}
}V2 automatically inherits list, show, create, update from V1, while overriding the controller and modifying actions.
'actions' => [
'list', // simple: method name = action key
'show' => 'getById', // alias: show → calls getById()
'disabled' => false, // disabled action (404)
'create' => [
'action' => 'store', // explicit method name
'method' => ['post'], // allowed HTTP methods (default: ['post'])
'name' => 'orders.store', // route name: api.{version}.orders.store
'middleware' => [RateLimit::class],
'exclude-middleware' => [LogMiddleware::class],
'exclude-all-middleware' => false,
],
]Global middleware (getMethods root)
└─ Controller middleware
└─ Action middleware
Each level can exclude middleware from parent levels using exclude-middleware (specific) or exclude-all-middleware (all).
Documentation is generated automatically from PHP docblocks. No YAML or JSON files to maintain.
/**
* Create an order
* Detailed description of the endpoint.
*
* @input string $title Order title
* @input string ?$notes Optional notes
* @input integer(int64) $amount Amount in cents
* @input string $status Status [draft,pending,confirmed]
* @input file ?$attachment Optional file
*
* @output integer $id Created order ID
* @output string(date-time) $createdAt Timestamp
* @output string ?$notes Optional notes
*//** @input object $address Address
* @input string $address.city City
* @input string $address.zip ZIP code
* @input array $items Order items
* @input integer $items[].productId Product
* @input integer $items[].quantity Quantity */A list of scalars takes [] with no child: @input string $tags[].
When the fields depend on the route — one generic controller registered under a
key per entity — @input [method] asks a controller method, and the method is
told which operation is being described:
use Dskripchenko\LaravelApi\Services\OpenApi\OperationContext;
/**
* @input integer $id
* @input [entityFields]
*/
public function update(Request $request) { /* ... */ }
public function entityFields(OperationContext $context): array
{
// $context->controllerKey is `users` on /v1/users/update, `posts` on /v1/posts/update
return [
'type' => 'object',
'properties' => ['email' => ['type' => 'string', 'format' => 'email', 'maxLength' => 255]],
'required' => ['email'],
];
}The method may return docblock lines (['string $email Email']) or a JSON
Schema object, which goes into the spec as it is — constraints and all. @output [method] works the same way. See docs/docblock-tags.md.
/**
* @header string $Authorization Bearer token
* @header string ?$X-Request-Id Trace ID
* @security BearerAuth
* @response 200 {OrderResponse}
* @response 422 {ValidationError}
* @deprecated Use createV2 instead
*/Enable reusable schemas via components/schemas:
class Api extends BaseApi {
public static $useResponseTemplates = true;
public static function getOpenApiTemplates(): array {
return [
'OrderResponse' => [
'id' => 'integer!', // required integer
'title' => 'string!', // required string
'total' => 'number', // optional number
'created_at' => 'string(date-time)', // with format
'email' => 'string(email)!', // format + required
'customer' => '@Customer', // $ref to another schema
'items' => '@OrderItem[]', // array of $ref
],
'Customer' => [
'id' => 'integer!',
'name' => 'string!',
],
'OrderItem' => [
'product_id' => 'integer!',
'quantity' => 'integer',
'price' => 'number',
],
];
}
public static function getOpenApiSecurityDefinitions(): array {
return [
'BearerAuth' => ['type' => 'apiKey', 'name' => 'Authorization', 'in' => 'header'],
];
}
}Shorthand syntax reference:
| Syntax | Meaning | Example |
|---|---|---|
type |
Optional field | 'number', 'string', 'object' |
type! |
Required field | 'integer!', 'string!' |
type(format) |
With format | 'string(date-time)', 'string(email)' |
type(format)! |
Format + required | 'string(email)!' |
@Model |
$ref to schema |
'@Customer' |
@Model[] |
Array of $ref |
'@OrderItem[]' |
Array format (['type' => 'string', 'required' => true]) is also supported and can be mixed with shorthand in the same template.
Response envelope. Every response really leaves as {success, payload} (see Response format). public static $responseEnvelope = true; on the Api class makes the spec say so: templates then describe payloads, and every response — @output fields, {Template} refs, error codes — is wrapped. Templates may nest ('client!' => ['email' => 'string(email)'], ['string'], [['id' => 'integer!']]), and a custom envelope is a field map with '{payload}' where the body goes. Details: docblock-tags.md.
The reference page addresses every operation by a hash, and DocLink builds it
without opening a browser — for a ticket, a README, or an IDE:
use Dskripchenko\LaravelApi\Services\OpenApi\DocLink;
DocLink::url('v1', 'order', 'create', 'post');
// https://example.test/api/doc#v1/tag/order/POST/v1/order/create
DocLink::anchor('v1', 'order', 'create', 'post'); // the hash alone, without '#'The first segment is the API version, and it is deliberate: the page is handed an explicit slug per version, so a link survives the docblock summary being reworded. An action declaring several HTTP methods is several operations on the page — one anchor each.
Full tag reference: docs/docblock-tags.md | Linting: docs/linting.md | Cookbook: docs/cookbook.md
Implement CrudService for instant CRUD endpoints:
class ProductService extends CrudService {
public function meta(): Meta {
return (new Meta())
->string('name', 'Name')
->number('price', 'Price')
->select('status', 'Status', ['active', 'draft'])
->crud();
}
public function query(): Builder { return Product::query(); }
public function resource(Model $model): BaseJsonResource { return new BaseJsonResource($model); }
public function collection(Collection $c): BaseJsonResourceCollection { return BaseJsonResource::collection($c); }
}POST /api/v1/product/search
{
"filter": [
{"column": "status", "operator": "=", "value": "active"},
{"column": "price", "operator": "between", "value": [10, 100]},
{"column": "name", "operator": "like", "value": "phone"},
{"column": "description", "operator": "is_not_null"}
],
"order": [{"column": "price", "value": "desc"}],
"page": 1,
"perPage": 20
}Available operators: =, !=, >, <, >=, <=, in, not_in, like, between, is_null, is_not_null
Security: LIKE values are auto-escaped (%, _, \). All write operations are wrapped in DB::transaction(). Filter array is limited to 50 items.
CrudService includes restore($id) and forceDelete($id) for models using SoftDeletes. These methods are not exposed via CrudController by default — add custom actions in your controller:
'restore' => ['action' => 'restore', 'method' => ['post']],
'forceDelete' => ['action' => 'forceDelete', 'method' => ['post']],use Dskripchenko\LaravelApi\Traits\Testing\MakesHttpApiRequests;
class ProductTest extends TestCase
{
use MakesHttpApiRequests;
public function test_list(): void
{
$response = $this->api('v1', 'product', 'search');
$this->assertApiSuccess($response);
}
public function test_not_found(): void
{
$response = $this->api('v1', 'product', 'read', ['id' => 999]);
$this->assertApiError($response, 'not_found');
}
public function test_validation(): void
{
$response = $this->api('v1', 'product', 'create', []);
$this->assertApiValidationError($response, ['name']);
}
}Generate TypeScript interfaces from your OpenAPI spec:
php artisan api:generate-types # All versions → resources/js/shared/api/types.ts
php artisan api:generate-types --api-version=v1 # Specific version
php artisan api:generate-types --output=frontend/src/api/types.ts # Custom pathGiven @output integer $id and @output string ?$email, the generator produces:
export interface UserShowOutput {
id: number;
email?: string;
}Component schemas, operation inputs, and outputs are all generated. See docs/cookbook.md for details.
The markup this package reads is not part of any grammar PhpStorm knows, so it shows as one grey blob of prose — and everything the package fails quietly at fails quietly there too. There is a plugin for that: dskripchenko/laravel-api-idea.
Installing. It is distributed from its own repository rather than from JetBrains Marketplace. Add the plugin repository once — Settings | Plugins | ⚙ | Manage Plugin Repositories… | + —
https://raw.githubusercontent.com/dskripchenko/laravel-api-idea/main/updatePlugins.xml
then install Laravel API from the Marketplace tab as usual; updates arrive the
same way. Paste the address without a trailing space — the IDE encodes it into
the URL, and the resulting 404 is reported as Connection failed. Or take
laravel-api-idea-<version>.zip from any
release and use
Install Plugin from Disk….
PhpStorm, or IntelliJ IDEA Ultimate with the PHP plugin. IDEA Community cannot run it — the plugin hangs off the PHP plugin's PSI, and JetBrains does not publish that for Community.
While you type
- Highlights
@input,@output,@header,@response,@security,@default,@example— types, formats, variables, template references and status codes, so a line the generator has stopped understanding stops looking like the ones it still does. - Reports markup that does not parse, where it is written, instead of letting it vanish from the spec.
- Flags unknown types with the consequence named: the generator will call the
field
string. - Compares the markup against
$request->validate([...]). A validated field the docblock never mentions is reported on the rule itself, with a quick fix that writes the tag from it —emailbecomesstring(email), a rule that is notrequiredbecomes optional,in:a,bbecomes an enumeration. - Marks a
@responsetemplate or a@securityscheme nothing declares, and offers to declare the template — creatinggetOpenApiTemplates()when the class has none. - Turns the silent 404 red: an action pointing at a missing, non-public or static method is an error while typing, with a quick fix that writes the method, shaped like the ones already in the class.
- Completes types, formats, template names, security schemes and status codes.
Getting around
- Ctrl+Click from
{TemplateName},@Model,@security Schemeand@input [method]to their declarations. - Find Usages on a response template — every docblock naming it. A template read only by the generator otherwise looks unused to the IDE.
- Gutter arrows both ways between the route map and the controllers. An action with no arrow points at a method that is not there.
- A line above the controller method opening this endpoint's own page in
/api/doc— see Linking to a single endpoint for how that address is built. - Every endpoint the route map declares, in one searchable tool window, with the version it answers under.
On demand
- Runs
api:lintand makes its findings clickable. - Exports the endpoint under the caret as a request — Bruno, cURL, HTTP Client,
Postman or Markdown — through
api:export --endpoint.
The markup fails quietly: an action whose method was renamed answers 404 — the
same 404 as a wrong URL — and a @response naming a template that does not
exist becomes a $ref into nothing, in a spec that still validates.
php artisan api:lint # report
php artisan api:lint --strict # fail on warnings too, for CI
php artisan api:lint --unrouted # also: public methods no action points atIt reads the route map and the docblocks with the same parser the OpenAPI
generator uses, and also reports an action that validates its input without
declaring any (input.undeclared). Full list of rules:
docs/linting.md.
Export your API spec in multiple formats:
php artisan api:export --format=postman # Postman Collection v2.1
php artisan api:export --format=http # JetBrains/VS Code .http files
php artisan api:export --format=markdown # Standalone documentation
php artisan api:export --format=curl # Bash script with curl commands
php artisan api:export --format=bruno # Bruno collection — a directory of .bru filesOptions: --api-version=v1 (specific version), --output=path (custom file). By default, generates per-version files (v1.json, v1.http, v1.md, v1.sh).
Bruno is a directory rather than a document — a manifest, an environment and one
.bru per request — so --output names a directory and --stdout is refused.
The point of it is that a collection can live in the repository next to the code
that produces it, and a diff of it reads.
A collection of two hundred requests is not what someone wants who is about to try one:
php artisan api:export --endpoint=v1.order.create --format=bruno --stdout
php artisan api:export --endpoint=v1.order.create --format=curl --stdout
php artisan api:export --endpoint=v1.order.create --format=bruno --output=collection/order-create.bruThe endpoint is spelled the way the package names its routes —
version.controller.action — so what is copied out of a log or a route list is
what the command takes. --method=get picks one when the action answers
several. Every format supports this, and none of them has a special case for it:
the spec is narrowed to one path first, and each exporter then does exactly what
it already did.
Publish the configuration file:
php artisan vendor:publish --tag=laravel-api-config// config/laravel-api.php
return [
'prefix' => 'api', // URL prefix
'uri_pattern' => '{version}/{controller}/{action}', // Route pattern
'available_methods' => ['get', 'post', 'put', 'patch', 'delete'],
'openapi_path' => 'public/openapi', // OpenAPI JSON output
'doc_middleware' => [], // Middleware for /api/doc
];throw new ApiException('payment_failed', 'Insufficient funds');
// → {"success": false, "payload": {"errorKey": "payment_failed", "message": "Insufficient funds"}}use Dskripchenko\LaravelApi\Facades\ApiErrorHandler;
use Dskripchenko\LaravelApi\Services\ApiResponseHelper;
use Illuminate\Database\Eloquent\ModelNotFoundException;
ApiErrorHandler::addErrorHandler(
ModelNotFoundException::class,
fn($e) => ApiResponseHelper::sayError(['errorKey' => 'not_found', 'message' => 'Not found'], 404)
);Handlers support inheritance: registering a handler for Exception will also catch RuntimeException via class_parents() traversal.
Add to your middleware stack for request tracing:
// Reads X-Request-Id from request header or generates UUID
// Adds request_id to Log::shareContext()
// Sets X-Request-Id on response header
Dskripchenko\LaravelApi\Middlewares\RequestIdMiddleware::class| Aspect | Classical Laravel | laravel-api |
|---|---|---|
| Route definition | routes/api.php — one route per endpoint, manual versioning |
getMethods() — declarative array, versions via class inheritance |
| Versioning | Manual: route groups, separate controllers, copy-paste | Automatic: V2 extends V1, inherit/override/disable actions |
| Documentation | Separate process: write OpenAPI YAML manually or use annotations | Auto-generated from @input/@output docblocks |
| Response format | Ad-hoc per controller, no standard envelope | Standardized {success, payload} envelope everywhere |
| CRUD boilerplate | Write controller + FormRequest + Resource for each entity | Implement CrudService (4 methods), get 6+ endpoints |
| Middleware per action | Route-level middleware or controller middleware groups | Fine-grained: global → controller → action with exclusion |
| Testing | $this->getJson('/api/v1/users') |
$this->api('v1', 'user', 'list') + assertion helpers |
| Learning curve | Standard Laravel knowledge | Learn getMethods() structure + docblock tags |
| Flexibility | Full control over everything | Constrained to package conventions |
| When to choose | Complex APIs with non-standard routing, GraphQL, event-driven APIs | REST APIs with versioning, standard CRUD, auto-documentation needs |
Advantages of laravel-api:
- Zero-maintenance documentation — docblocks are the single source of truth
- Version inheritance eliminates code duplication between API versions
- Standardized response format across all endpoints
- CRUD scaffolding reduces boilerplate by 60-80%
Disadvantages of laravel-api:
- Fixed URI pattern (
api/{version}/{controller}/{action}) — not RESTful resource routes - Opinionated response format — can't easily switch to JSON:API or HAL
- No native support for resource-style URLs (
/users/{id}vs/user/show?id=1)
| Aspect | L5-Swagger | laravel-api |
|---|---|---|
| Approach | OpenAPI-first: write annotations, generate docs | Code-first: write docblocks, docs + routing together |
| Annotation style | Full OpenAPI annotations (@OA\Get, @OA\Schema, ...) |
Lightweight custom tags (@input, @output, @header) |
| Annotation verbosity | High: 15-30 lines per endpoint for full spec | Low: 3-10 lines per endpoint |
| Routing | None — documentation only, routes defined separately | Integrated — routing + docs from single getMethods() |
| Versioning | Manual — separate annotation groups | Built-in — class inheritance |
| CRUD generation | None | Built-in CrudService + CrudController |
| Response format | Any — you define schemas | Fixed {success, payload} envelope |
| OpenAPI coverage | Full OpenAPI 3.0 spec support | Subset: covers 90% of common use cases |
| IDE support | Plugin support for @OA\* annotations |
Plugin: highlighting, inspections, quick fixes, navigation, endpoint list, api:lint runner |
| Ecosystem | Large community, swagger-php underneath | Smaller, focused package |
| Spec customization | Full control over every OpenAPI field | Limited to supported tags |
| When to choose | API-first design, full OpenAPI compliance needed, existing routes | Rapid development, versioned APIs, integrated routing + docs |
Advantages over L5-Swagger:
- 3-5x less annotation code per endpoint
- Routing and documentation are always in sync (single source)
- Built-in API versioning with inheritance
- CRUD scaffolding included
- No need to learn the full OpenAPI annotation specification
Disadvantages compared to L5-Swagger:
- Less OpenAPI coverage (no callbacks, webhooks, links, discriminator)
- Fixed response format
- Smaller community and ecosystem
- Not suitable for API-first (design-first) workflow
| Aspect | Scramble | laravel-api |
|---|---|---|
| Approach | Zero-config: infers spec from code (types, FormRequest, routes) | Docblock tags: explicit @input/@output annotations |
| Route integration | Uses Laravel's native routes | Custom routing via getMethods() |
| Documentation source | PHP types, FormRequest rules, return types | Docblock annotations |
| Manual annotations | Optional, for edge cases | Required for all endpoints |
| Versioning | None built-in | Built-in class inheritance |
| CRUD | None | Built-in CrudService |
| Setup effort | Minimal — install and it works | Moderate — define module, API class, provider |
| When to choose | Standard Laravel routes, minimal documentation effort | Custom routing, versioning, CRUD needs |
✅ Choose laravel-api when:
- You need versioned APIs with inheritance between versions
- You want integrated routing + documentation from a single source
- You need CRUD scaffolding with filtering, sorting, pagination
- You prefer lightweight docblock tags over verbose annotations
- You want a standardized response format across all endpoints
❌ Choose alternatives when:
- You need RESTful resource-style URLs (
/users/{id}) - You need full OpenAPI 3.0 compliance (callbacks, webhooks, discriminator)
- You follow API-first (design-first) methodology
- You need GraphQL or non-REST APIs
- You want zero-annotation documentation (→ Scramble)
| Class | Methods |
|---|---|
ApiController |
success($payload, $status), error($payload, $status), validationError($messages), created($payload), noContent(), notFound($message) |
CrudController |
meta(), search(CrudSearchRequest), create(Request), read(Request, int), update(Request, int), delete(int) |
ApiDocumentationController |
index() |
| Class | Methods |
|---|---|
CrudService |
meta(), query(), resource(), collection(), search(), create(), read(), update(), delete(), restore(), forceDelete() |
ApiResponseHelper |
say($data, $status), sayError($data, $status) |
| Class | Methods |
|---|---|
BaseApi (all methods are static) |
getMethods(), make(), getOpenApiTemplates(), getOpenApiSecurityDefinitions(), beforeCallAction(), afterCallAction(), getMiddleware() |
BaseModule |
getApi($version), makeApi(), getApiVersionList(), getApiPrefix(), getApiUriPattern(), getAvailableApiMethods(), getDocMiddleware() |
Meta |
string($key, $name), integer($key, $name), number($key, $name), boolean($key, $name), hidden($key, $name), select($key, $name, $items), file($key, $name, $src), action($key, $condition), crud(), getOpenApiInputs(), getColumnKeys() |
| Class | Purpose |
|---|---|
ApiMiddleware |
Abstract base — catches ApiException and generic exceptions |
RequestIdMiddleware |
Generates/propagates X-Request-Id, adds to Log::shareContext() |
| Class | Purpose |
|---|---|
ApiException |
Exception with errorKey string for structured error responses |
ApiErrorHandler |
Registry of exception handlers by class, with parent class traversal |
| Facade | Resolves to |
|---|---|
ApiRequest |
BaseApiRequest — version, controller, action, HTTP method |
ApiModule |
BaseModule — version resolution, route configuration |
ApiErrorHandler |
ApiErrorHandler — exception handler registry |
MIT License. See LICENSE.md for details.