Skip to content

OpenAPI Schema Generation

Whity Core automatically generates OpenAPI 3.0 schemas from discovered plugins. This enables type-safe TypeScript client generation and API documentation.

OpenAPI (formerly Swagger) is a specification for describing HTTP APIs. It enables:

  • Automated client code generation
  • Interactive API documentation
  • API testing tools integration
  • Schema validation

Learn more: https://spec.openapis.org/oas/v3.0.3

Terminal window
php public/index.php generate:openapi

This generates public/openapi.json containing the complete API specification.

The generated openapi.json includes:

  • Paths: All routes registered with the Router the generator is given. The generate:openapi command currently registers PLUGIN routes only; the core admin resources join the spec with #167 (the mechanism — Router::register with a schema argument — is already in place for them)
  • Methods: HTTP method for each endpoint (GET, POST, PATCH, DELETE, etc.)
  • Security: Bearer token authentication configuration
  • Typed bodies (WC-166): routes that declare a schema get a requestBody and per-status responses referencing named components.schemas via $ref
  • Responses: the declared per-status success shape (or a 200 default) PLUS the standard error surface injected into every operation (see below)
  • Tags: declared, or derived from the path

Generation is deterministic (paths, methods, and component schemas are sorted — regenerating over the same routes is byte-identical) and self-validating: the command refuses to write a spec with dangling $refs or response-less operations (exit 1 with the errors listed).

Declaring typed request/response bodies (WC-166)

Section titled “Declaring typed request/response bodies (WC-166)”

Any route — core (Router::register(..., schema:)) or plugin (the optional 'schema' key in the route array, SDK ≥ 1.1.1) — can declare its contract:

'schema' => [
'summary' => 'Create a widget',
'tags' => ['widgets'],
'request' => 'WidgetCreate', // component name => $ref, or inline JSON-Schema array
'responses' => [
201 => 'Widget', // component name => $ref'd application/json body
400 => ['description' => 'Validation failed'], // raw response object
],
'components' => [ // schemas this route contributes to components.schemas
'WidgetCreate' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string']]],
'Widget' => ['type' => 'object', 'properties' => ['id' => ['type' => 'integer'], 'name' => ['type' => 'string']]],
],
]

Identical component contributions from multiple routes are idempotent; a CONFLICTING redefinition keeps the first definition and logs a warning. The shipped plugins/HelloWorld declares its /api/hello response (Greeting) as a working reference.

When you create a new plugin, the schema generator automatically includes it:

  1. Plugin implements PluginInterface
  2. Run php public/index.php generate:openapi
  3. New endpoint appears in public/openapi.json
<?php
namespace Whity\Plugins;
use Whity\Sdk\PluginInterface;
use Whity\Core\Request;
use Whity\Core\Response;
class UserList implements PluginInterface
{
public function getRoute(): string { return '/api/users'; }
public function getMethod(): string { return 'GET'; }
public function getRequiredRole(): ?string { return null; }
public function handle(Request $request): Response
{
// ... implementation
}
}

Routes extracted from PluginInterface::getRoute() support:

  • Simple paths: /api/users
  • Parameterized paths: /api/users/{id}

All standard HTTP methods supported:

  • GET — Retrieve resource
  • POST — Create resource
  • PATCH — Update resource
  • DELETE — Delete resource
  • PUT — Replace resource

Endpoints with getRequiredRole() returning non-null are marked as requiring Bearer token authentication.

Endpoints with getRequiredRole() === null are public (no auth required).

The generator injects the application’s uniform error envelope into every operation’s responses, so clients and MCP see the full error surface. Each injected response references the shared Error component (#/components/schemas/Error{ "error": string, "details"?: object }, the body produced by Response::error()).

Injected codes:

  • Always (transport-level): 404 Not found, 405 Method not allowed, 500 Internal server error.
  • Only when the operation requires authentication (it carries security: [{bearerAuth: []}]): 401 Unauthorized.
  • Only when the operation is role/permission gated: 403 Forbidden.
  • Only when the operation declares a request body: 400 Invalid request body.

422/429 are not blanket-injected — they are owned by explicit route declarations.

Injection is merge-not-clobber: an explicitly declared response for a status code always wins (a route declaring a richer 403, a 422, etc. keeps its own object). Public routes therefore no longer falsely advertise 401/403. Response keys are emitted in ascending status-code order for byte-stable regeneration.

The schema generator is in src/OpenAPI/:

  • SchemaGenerator.php — Main generator class
  • SchemaBuilder.php — OpenAPI spec builder helper

Tests are in tests/OpenAPI/ and tests/Console/.

The spec is the generation input for the frontend’s typed API client:

  • cd web && npm run generate:api regenerates web/lib/api/schema.d.ts from public/openapi.json via openapi-typescript. The committed file must match a fresh generation — CI fails otherwise (web job drift check).
  • web/lib/api/client.ts wraps the schema with openapi-fetch and preserves the platform auth behavior as middleware: credentials: 'include', the X-Requested-With CSRF header (WC-160), and the 401 → silent refresh → single retry flow. The retry bypasses the middleware, so refresh loops are structurally impossible. web/__tests__/typed-api-client.test.ts pins this contract.
  • Screens import the singleton: import { api } from '@/lib/api/client' and call api.GET('/api/users'), api.POST('/api/delegations', { body }), api.PATCH('/api/users/{id}', { params: { path: { id } }, body }) — request bodies, path params, query params and responses are all typed from the spec.
  • Feature types.ts files derive their shapes from the schema (components['schemas']['Delegation']) instead of hand-mirroring the API.

Changing an endpoint therefore means: update the handler + CoreApiSchemas, run php public/index.php generate:openapi, then npm run generate:api, and commit all three artifacts together.