OpenAPI with Both Libraries
better-route exports OpenAPI 3.1 documents from router/resource contracts. better-data derives JSON Schemas from DTOs. Together they produce a single document where every endpoint references typed DTO components.
The pattern
use BetterData\Route\BetterRouteBridge;
use BetterRoute\BetterRoute;
add_action('rest_api_init', function (): void {
$router = BetterRoute::router('shop', 'v1');
BetterRouteBridge::post(
$router,
'/products',
CreateProductDto::class,
fn (CreateProductDto $dto) => ProductDto::fromPost($dto->saveAsPost()),
[
'operationId' => 'productsCreate',
'tags' => ['Products'],
'requestSchema' => '#/components/schemas/CreateProduct',
'responseSchema' => '#/components/schemas/Product',
],
);
BetterRouteBridge::get(
$router,
'/products/{id}',
ProductDto::class,
fn (ProductDto $dto) => $dto,
[
'operationId' => 'productsGet',
'tags' => ['Products'],
'routeFields' => ['id'],
'responseSchema' => '#/components/schemas/Product',
],
);
$router->register();
\BetterRoute\OpenApi\OpenApiRouteRegistrar::register(
restNamespace: 'shop/v1',
contractsProvider: static fn (): array => $router->contracts(openApiOnly: true),
options: [
'title' => 'Shop API',
'version' => 'v1.0.0',
'serverUrl' => '/wp-json',
'components' => BetterRouteBridge::openApiComponents([
'Product' => ProductDto::class,
'CreateProduct' => CreateProductDto::class,
]),
'permissionCallback' => fn () => true, // override v0.3.0 admin-only default
],
);
});
Endpoint: GET /wp-json/shop/v1/openapi.json returns the merged document.
Component generation
BetterRouteBridge::openApiComponents() calls MetaKeyRegistry::toJsonSchema() for each DTO and assembles a ['schemas' => [name => schema]] map.
The argument can be:
-
List of FQNs — names auto-derived (
Dtosuffix stripped):BetterRouteBridge::openApiComponents([
ProductDto::class, // → 'Product'
CreateProductDto::class, // → 'CreateProduct'
OrderDto::class, // → 'Order'
]); -
Map customName => FQN — explicit naming:
BetterRouteBridge::openApiComponents([
'ProductRead' => ProductDto::class,
'ProductCreate' => CreateProductDto::class,
'ProductUpdate' => UpdateProductDto::class,
]);
Mix both styles in one call — the array is iterated; numeric keys use auto-derivation, string keys use the literal name.
Schema refs in route options
The bridge auto-generates requestSchema and responseSchema refs based on the handler signature when not specified:
BetterRouteBridge::post(
$router,
'/products',
CreateProductDto::class,
fn (CreateProductDto $dto) => ProductDto::fromPost($dto->saveAsPost()),
);
// requestSchema → '#/components/schemas/CreateProduct'
// responseSchema → '#/components/schemas/Product' (inferred from handler return type)
When the handler return type isn't reflectable (closure with mixed return), the bridge omits responseSchema. Specify it manually with responseSchema or responseSchemaName:
['responseSchema' => '#/components/schemas/Product']
Mixing better-route resources and better-data DTOs
A single OpenAPI document can include:
- Routes from
Router(better-route's regular$router->get/post/...) - Routes from
ResourceDSL (CPT / table — better-route'sResource::make(...)->register()) - Routes wired through
BetterRouteBridge(DTO-driven) - WooCommerce routes from
WooRouteRegistrar
Merge their components into one document:
$components = array_merge_recursive(
BetterRoute::wooOpenApiComponents(),
BetterRouteBridge::openApiComponents([
ProductDto::class,
OrderDto::class,
]),
);
OpenApiRouteRegistrar::register(
restNamespace: 'shop/v1',
contractsProvider: static fn (): array => $router->contracts(openApiOnly: true),
options: [
'title' => 'Shop API',
'version' => 'v1.0.0',
'components' => $components,
],
);
array_merge_recursive preserves entries from both sides; if a key collides (e.g., both libs define a Product schema), the better-data one wins because it's later in the merge order. Use unique component names to avoid this.
strictSchemas
OpenApiExporter's strictSchemas: true mode (since better-route v0.3.0) throws InvalidArgumentException if any $ref points to a missing schema. Combined with the bridge's auto-generated refs, this catches the case where you forgot to add a DTO to openApiComponents():
$openapi = BetterRoute::openApiExporter()->export(
$contracts,
[
'strictSchemas' => true,
'components' => BetterRouteBridge::openApiComponents([
ProductDto::class,
// CreateProductDto missing → exporter throws on the route that refs it
]),
],
);
In CI, run a smoke that exports the doc with strictSchemas: true. The throw catches DTO-vs-route drift early.
Securing the OpenAPI endpoint
Since better-route v0.3.0, OpenApiRouteRegistrar::register() defaults to current_user_can('manage_options') — admin-only. Most public APIs want the doc accessible to API consumers; pass permissionCallback to override:
options: [
// ...
'permissionCallback' => fn () => true, // public
// OR
'permissionCallback' => fn () => current_user_can('read'), // any logged-in user
]
See better-route OpenAPI endpoint for the full options.
Common mistakes
- Forgetting to add a DTO to
openApiComponents()— the route references#/components/schemas/Foobut the schema isn't defined; clients get a permissiveadditionalProperties: trueplaceholder. UsestrictSchemas: truein CI to catch this. - Using auto-derived names that collide (two DTOs both deriving
'Product') — name them explicitly via the map form. - Mixing
array_merge_recursivewith WC components — works, but be aware that schema-key collisions silently merge values; pick unique names per layer. - Expecting the bridge's
responseSchemaauto-detection to work for closures that don't declare a return type — declare: ProductDtoor passresponseSchemaexplicitly.