OpenAPI Documents
OpenAPI Documents
Hazaar can generate a standards-compliant OpenAPI 3.2 document from your application's routes, so you can publish a machine-readable API description and serve interactive docs with any off-the-shelf Swagger UI or Redoc — without hand-maintaining a separate spec file.
Everything about this feature is opt-in: the openapi config section, and every published route, are deliberately something you set up yourself rather than something that turns on and guesses at your app's shape.
Note
The generator statically enumerates every route your app can dispatch, for the file, json, and attribute router types (each has a real declaration — route.php, a JSON file, or #[Route] attributes — to reflect on). basic/advanced routes are also supported, but since those two router types have no declaration surface at all (routes are resolved purely by directory/class naming convention, per request), their paths are a best-effort reconstruction from controller class and method names rather than a byte-for-byte guarantee.
Enabling it
Add an openapi section to your app config and set enabled to true. Everything else in this section — info, servers, securitySchemes — is hand-authored, not derived from anything else in your app:
// configs/app.php
return [
'production' => [
'openapi' => [
'enabled' => true,
'info' => [
'title' => 'My API',
'version' => '1.4.0',
'description' => 'The public API for My App.',
],
'servers' => [
['url' => 'https://api.example.com', 'description' => 'Production'],
],
'securitySchemes' => [
'bearerAuth' => ['type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'JWT'],
],
],
],
];Tips
info.version here is your API's contract version, bumped as your API changes shape — unrelated to the openapi field the generator writes itself (fixed at 3.2.0), and unrelated to any URL-based API versioning (/v1/users) you might also be doing.
securitySchemes is a named catalog in OpenAPI's own Security Scheme Object shape. It's purely descriptive metadata for spec consumers (Swagger UI's "Authorize" button, client codegen) — it does not change how auth is actually enforced. That stays exactly as you've already wired it up via route/middleware ->auth()/->session() calls, entirely independent of this config.
Publishing routes
Nothing is included in the generated document unless its target controller method carries #[Publish]. These attributes live on the method itself, so they work the same way regardless of which router type actually dispatches to it:
namespace App\Controllers;
use Hazaar\Controller\Action;
use Hazaar\OpenApi\Attribute\Deprecated;
use Hazaar\OpenApi\Attribute\Publish;
use Hazaar\OpenApi\Attribute\RequestBody;
use Hazaar\OpenApi\Attribute\Response;
use Hazaar\OpenApi\Attribute\Security;
use Hazaar\OpenApi\Attribute\Summary;
use Hazaar\OpenApi\Attribute\Tag;
class User extends Action
{
/**
* @return array<UserModel>
*/
#[Publish]
#[Summary('List users')]
#[Tag('Users')]
#[Security('bearerAuth')]
public function index(): array
{
return $this->users->toArray();
}
#[Publish]
#[Summary('Create a user')]
#[Tag('Users')]
#[Security('bearerAuth')]
#[RequestBody(CreateUserRequest::class)]
#[Response(201, 'The created user')]
#[Response(422, 'Validation failed')]
public function create(): UserModel
{
// ...
}
#[Deprecated]
#[Publish]
#[Summary('Old lookup endpoint, use /user instead')]
public function legacyLookup(): mixed
{
// ...
}
}Neither index() nor create() had to repeat model: UserModel::class — see Inferring response schemas below. create()'s #[Response(201, ...)] is still needed to say the success status is 201 rather than the default 200, and its #[Response(422, ...)] for the error case, since neither of those can be inferred from a return type.
Available attributes (all in Hazaar\OpenApi\Attribute):
#[Publish]— required for a route to appear in the document at all.#[Summary('...')]/#[Description('...')]— short/long text for the operation.#[Tag('Group', ...)]— groups operations in Swagger UI/Redoc's sidebar.#[Response(status, description, model: ?, contentType: 'application/json', isArray: false)]— repeatable, one per documented status code.modelis aHazaar\Model\Struct/Schemaclass name; its schema is generated and$ref-linked intocomponents.schemas.modelcan be left out for a 2xx status — see Inferring response schemas.#[RequestBody(model, required: true, contentType: 'application/json')]— describes the request body.#[Security('schemeName', ...scopes)]— repeatable;schemeNamemust match a key in your config'ssecuritySchemescatalog, or generation fails with a clear error rather than silently omitting it.#[Deprecated]— marks the operationdeprecated: true.
Path and query parameters are inferred automatically from the target method's reflected parameters — anything that appears as a {placeholder} in the route's path becomes a path parameter, everything else becomes a query parameter.
Inferring response schemas
#[Response] is never required just to wire up a success schema that's already reflectable off the action's own return type. Two shapes are inferred automatically:
// A Struct/Schema-typed return -> the 200 response schema is that model.
#[Publish]
public function view(int $id): UserModel { ... }
// `array` with an `@return array<ModelClass>` docblock -> an array-of-model 200 schema.
/**
* @return array<UserModel>
*/
#[Publish]
public function index(): array { ... }#[Publish] alone is enough for both of these — no #[Response] needed at all. You only need to add one when you want to:
- change the success status —
#[Response(201, 'The created user')]still infers the schema from the return type (any 2xx status works, not just200), it's just the status/description that need saying explicitly; - document additional responses — errors, alternates, anything beyond the one inferred success case, each as its own
#[Response(...)]; - override what would otherwise be inferred — pass
model:(and optionallyisArray: true) explicitly and it's used as-is, no reflection involved.
Nothing is inferred for a scalar/mixed/union return type, a Response subclass, or array with no (or an unrecognised) @return docblock — those still need an explicit #[Response] with a model to get a documented schema, or are left as a bare {description} entry if you don't provide one.
Documenting request/response bodies
Any Hazaar\Model\Struct or Schema class used as a #[RequestBody]/#[Response] model is reflected into a JSON Schema (2020-12) fragment: native property types, @var array<Type> docblock element types, nested model references, and backed/pure enums are all handled automatically. Two attributes exist purely to add documentation text that the existing validation-rule attributes don't carry:
use Hazaar\Model\Attribute\Description;
use Hazaar\Model\Attribute\Example;
use Hazaar\Model\Attribute\Required;
use Hazaar\Model\Schema;
class CreateUserRequest extends Schema
{
#[Required]
#[Description('The user\'s email address, used for login and notifications.')]
#[Example('[email protected]')]
protected string $email = '';
}Required, MaxLength/MinLength, and Max/Min/Range are also picked up and mapped onto the generated schema's required, maxLength/minLength, and maximum/minimum respectively. Other rule attributes (Format, Pad, Trim, Truncate, Only, Hide, Filter, Currency) are serialization-time transforms with no direct JSON Schema equivalent, so they aren't reflected into the schema.
Generating the document
bin/hazaar openapi compile --path /path/to/app --format json openapi.json
bin/hazaar openapi compile --path /path/to/app --format yaml openapi.yamlThe command refuses to run with a clear error if openapi.enabled isn't true in the resolved app config.
Serving it live
There's no vendored viewer — point any standard Swagger UI/Redoc installation at wherever you serve the generated file. A minimal route to serve it directly from a running app:
use Hazaar\Application\Router;
use Hazaar\OpenApi\Generator;
Router::get('/openapi.json', function () use ($app) {
return (new Generator($app->config, $app->router))->generate()->toArray();
});