Skip to content

REST API OpenAPI Standards

Ed Mozley edited this page Jul 4, 2026 · 3 revisions

βœ… REST API: how the OpenAPI document is kept correct

This page describes how the OpenAPI document is generated, validated and verified. It is written for developers evaluating whether the specification can be trusted as a contract.


It is generated, not hand-written

The document is produced on each request by a generator that reads three sources already maintained as part of the API:

  • the route table β€” the authoritative list of paths, methods, permissions and handler functions the front controller dispatches;
  • the documentation catalogue (api/v1/spec.json) β€” endpoint summaries, descriptions, parameters, request examples and endpoint-specific errors;
  • the typed schemas β€” reusable component schemas describing every response object.

Because it is derived from the live route table, the specification describes the API the install is actually running. There is no separate file to fall out of date.

The catalogue is a single source of truth: the same spec.json drives both this document and the interactive docs page, so adding or changing an endpoint is one edit that updates both. A dev tool (api/v1/dev/openapi_stub.php) scaffolds a starter entry for any undocumented route, and the self-check (below) fails if a route has no catalogue entry β€” so the two cannot silently diverge as the API grows.


It conforms to the OpenAPI standard

The document validates against the official OpenAPI 3.0 meta-schema with zero errors. Conformance is verified two ways:

  • a bundled validator checks the generated document against a local copy of the official meta-schema (api/v1/dev/jsonschema4.php), so it can be re-run offline at any time;
  • the same document loads in Swagger UI, Redoc, Postman and Insomnia, and drives the client generators (openapi-generator, swagger-codegen).

The validator is proven to reject malformed documents β€” a document with a missing openapi version, a missing responses object, or an out-of-range version string all fail it β€” so a clean pass is a meaningful result rather than a validator that passes everything.

For teams that run linters in CI, the document passes Spectral's recommended ruleset and swagger-cli validate in a Node environment.


The response schemas match reality

Every response schema is validated against a live response from a running install. A verification tool (api/v1/dev/openapi_verify.php) fetches each GET endpoint, resolves any id parameters from a sibling collection, and checks the returned data against its schema field-by-field β€” including type and nullability, and flagging any response field the schema fails to document. The schemas were derived from the serializers and then reconciled against live data until this reported zero mismatches.

The types are read directly from the serializers, which cast every value explicitly ((int), (bool), dates through a shared ISO-8601 helper, nested objects, arrays), so the declared types reflect what the code emits rather than an inference from a single sample.


The specification cannot silently omit or invent an endpoint

A self-check (api/v1/lib/openapi_check.php) verifies a set of invariants and exits non-zero on any failure, so it can gate CI:

  • drift β€” every route in the table has a catalogue entry and every catalogue entry maps to a real route (a clean one-to-one match);
  • references β€” every $ref resolves to a defined component schema;
  • operationIds β€” present and unique across all operations;
  • responses β€” every operation declares at least one response;
  • shape β€” no object-typed field is emitted as an empty array;
  • nullable β€” no schema uses nullable without a type (a strict-linter rule the meta-schema does not enforce);
  • catalogue β€” every spec.json entry is well-formed (valid method, path, summary, permission and parameters) and every examples/errors key matches a real endpoint.

Scope and format choices

  • Version 3.0.3. Targeted for the widest tooling compatibility. It imports into current and older Swagger UI, Redoc, Postman, Insomnia and the mainstream client generators.
  • Two formats, one document. JSON is canonical; YAML is generated from the same structure with conservative quoting, so scalars are never coerced to the wrong type.
  • Dynamic payloads are typed as open objects. Workflow conditions and actions, form field definitions, and workflow-execution payloads and step logs vary in shape by design; they are described as objects rather than given a fixed field list that would misrepresent them.
  • Request bodies are documented with worked examples. The API validates request fields at call time; response bodies additionally carry full typed schemas. A small number of endpoints that require a specific nested id are covered by their derived schema but are not part of the automated live-verification sweep.

Re-running the checks

The tooling needs only PHP β€” no Node, Python or Composer:

# invariants (drift, refs, operationIds, responses, shape)
php api/v1/lib/openapi_check.php

# conformance to the official 3.0 meta-schema
curl -s http://localhost/freeitsm-app/api/v1/openapi.json > /tmp/o.json
php api/v1/dev/jsonschema4.php /tmp/o.json api/v1/dev/oas-3.0-schema.json

# response schemas vs live responses (needs a read key)
php api/v1/dev/openapi_verify.php <read_key>

See api/v1/dev/README.md for the full workflow, including how to bring the typed schemas back in line after changing a module.


Engineering notes: the pitfalls, and how the tooling was hardened

The document and its checks were built against a running install, and a few problems only surfaced through validation β€” first the built-in checks, then an external linter. They are recorded here because each one changed the tooling, and the same traps catch most hand-built OpenAPI documents.

The core lesson: meta-schema conformance is necessary, not sufficient

An OpenAPI document is defined by a meta-schema (itself a JSON Schema), and validating against it is the formal definition of "conforms to the standard". But the meta-schema is deliberately permissive β€” it allows constructs the specification's prose forbids and that linters reject. So "validates against the meta-schema with zero errors" is a true and meaningful statement, but it is not the same as "no tool will complain". The tooling therefore does both: meta-schema validation and a self-check that encodes the linter-style rules the meta-schema leaves out. When an external validator later flags something, the first question is which of the two it is β€” a genuine meta-schema violation, or a stricter linter opinion β€” and the fix in the second case is to add that rule to the self-check so it cannot recur.

Pitfall 1 β€” nullable without a type

In OpenAPI 3.0, nullable only has meaning beside a type; a schema that is nullable with no type is meaningless, and most linters reject it. The meta-schema permits it, so it passed meta-schema validation and the first version of the self-check missed it entirely. It arose two different ways, each fixed at the source:

  • Fields that were null in the sampled data. The schema fixer infers a field's type from a live value; when the only value it saw was null, it wrote nullable: true with no type. Two changes fixed this: the fixer now upgrades a typeless node to a concrete type the moment any non-null value appears (so a list with many rows reveals most types), and the fields that are null across all sampled data were typed from their serializers β€” ids to integer, money to number, tri-state flags to boolean, lookup sub-objects to object, dates and text to string, and genuinely polymorphic values (a CMDB property value, a form answer) to an untyped "any".

  • The nullable-reference idiom. A field that references another schema but can also be null is commonly written {allOf: [{$ref: …}], nullable: true}, because in 3.0 a $ref ignores its siblings so {$ref, nullable} cannot work. But allOf is not a type either, so a strict linter still rejects it. The fix is the documented one: give it the composed type, {type: object, allOf: [{$ref: …}], nullable: true}.

Pitfall 2 β€” the guard has to match the linter exactly

The self-check's first nullable rule skipped any node that carried allOf/anyOf/oneOf, on the assumption that a composition keyword supplied the type. It does not β€” which is exactly why the nullable-reference idiom above slipped past. The rule now flags nullable without type unconditionally, matching how a linter reads it. The general lesson: a lint-style guard that is even slightly looser than the real linter produces false all-clears, so each guard is confirmed with an independent recount using the linter's own predicate rather than trusting the guard that is being written.

Pitfall 3 β€” an object field serialising as []

An "object" with no properties is an empty array in PHP, and json_encode turns an empty PHP array into [], not {}. Anywhere the specification requires an object β€” a schema's properties, an operation's responses, a media type's content β€” an accidental [] is invalid. This appeared when the fixer inferred a schema for a field whose sampled value was an empty list, leaving items: []. A first check for it used a spaced grep and missed the compact JSON; the self-check now inspects the encoded document and fails on any object-typed field emitted as [], and empty items/properties are stripped at the source.

Pitfall 4 β€” a single sample is not enough to type a field

Verifying a schema against one response has two failure modes the tooling had to handle. A field that is null in the sampled record looks untyped even though the code clearly types it β€” resolved by sampling across every row of a list and by falling back to the serializer for always-null fields. And a genuinely polymorphic field looks wrongly concrete: a CMDB object's property value is a string on a text CI and an integer on a numeric one, so a single text sample typed it string until a numeric CI's response contradicted it. Live verification caught the contradiction, and the field is now typed as an untyped "any" with a description, which is the honest representation.

How the dev tools evolved

Tool Role, and how it changed
openapi_verify.php Fetches every GET endpoint and checks the live data against its schema. Gained OpenAPI-aware handling of $ref, allOf and nullable, resolution of {id} parameters from sibling collections, and detection of response fields the schema fails to document.
openapi_fix.php Auto-patches the schemas from live responses. Its original null handling was the source of Pitfall 1; it now upgrades typeless nodes to concrete types on the first real value, and strips the empty items/properties behind Pitfall 3.
jsonschema4.php A dependency-free draft-04 validator for checking the document against the official meta-schema without Node or Python. Kept honest by first proving it rejects deliberately broken documents (missing openapi, missing responses, an out-of-range version), so a clean pass is meaningful.
openapi_check.php The permanent self-check. Grew from five invariants to six as the nullable rule was added, and the sixth rule itself was tightened once (Pitfall 2) to stop excluding composition keywords.

Every one of these traps is now caught by openapi_check.php or the meta-schema validator, so re-running the checks after a change surfaces a regression rather than shipping it.


See also: REST API: OpenAPI specification (getting and using it) Β· REST API (how the API works).

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally