Enforce an API style guide in CI with Spectral: custom rules that stop bad OpenAPI before it merges
Every API program starts with a style guide document, and most end with it unread in a wiki while specs quietly grow duplicate operationIds, 200 responses with no body, GET requests that take secrets in the query string, and a dozen subtly different error shapes. Linting turns the guide into something that actually runs: a machine-readable ruleset that fails the pull request. Spectral is the standard OpenAPI linter for exactly this job, and the value is not its built-in rules, it is encoding your team's conventions once and enforcing them forever.
Run it locally and in CI
Spectral takes a ruleset file (YAML or JS) and lints one or more documents:
npm install --save-dev @stoplight/spectral-cli
npx spectral lint openapi/openapi.yaml --ruleset openapi/.spectral.yamlA minimal CI step fails the build on errors while letting warnings pass:
# .github/workflows/openapi-lint.yml (excerpt)
- name: Lint OpenAPI
run: npx spectral lint openapi/openapi.yaml --ruleset openapi/.spectral.yaml --fail-severity=error--fail-severity=error is the key split: conventions you are still rolling out are warn and do not block, while hard contracts are error and do. Add the same command to a pre-commit hook so authors get feedback before they open the PR.
A practical ruleset
Start by extending the OpenAPI recommended rules, then turn the style guide into explicit rules:
# openapi/.spectral.yaml
extends:
- spectral:oas
rules:
# ---- Documentation completeness (warnings during rollout) ----
operation-operationId: error
operation-summary: warn
operation-description: warn
info-contact: error
info-description: error
# ---- operationId conventions ----
operation-id-camel-case:
message: operationId must be lowerCamelCase and verb-first.
severity: error
given: $.paths[*][*]
then:
field: operationId
function: pattern
functionOptions:
match: '^(list|get|create|update|patch|delete|archive|cancel|approve|export)[A-Z][A-Za-z0-9]*$'
operation-id-unique:
message: operationId must be unique across the document.
severity: error
given: $.paths[*][*]
then:
field: operationId
function: enumeration
# uniqueness is enforced by the built-in operation-operationId-unique;
# this entry is illustrative of naming policy.
# ---- Tag hygiene ----
tags-declared-at-root:
message: Every tag used by an operation must be declared in the root tags array.
severity: error
given: $.paths[*][*].tags[*]
then:
function: in
functionOptions:
values:
- Users
- Orders
- Billing
- Webhooks
# ---- Error and response contracts ----
error-response-shape:
message: Every 4xx/5xx response must reference the ProblemDetail schema.
severity: error
given: $.paths[*][*].responses[?@property.match(/^(4|5)/)]
then:
field: content.application/json.schema.$ref
function: truthy
no-200-without-body:
message: A 200 response must define a response body or be explicitly 204.
severity: warn
given: $.paths[*][*].responses.200
then:
field: content
function: truthy
# ---- Security ----
no-secrets-in-query:
message: Never pass tokens or passwords as query parameters; use a security scheme.
severity: error
given: $.paths[*][*].parameters[*]
then:
field: name
function: pattern
functionOptions:
notMatch: '(?i)(token|password|secret|apikey|api_key)'
# ---- Consistency ----
array-responses-wrapped:
message: Prefer an object envelope for top-level arrays (pagination consistency).
severity: warn
given: $.paths[*][get].responses.200.content.application/json.schema
then:
field: type
function: pattern
functionOptions:
notMatch: '^array$'The exact JSONPath in a couple of these is illustrative; Spectral's given uses JSONPath-plus, and for cross-field logic you write a custom function rather than overloading the built-in ones. The pattern to internalize is: a rule has a given selector for where it applies, a then assertion (built-in function or your own), and a severity.
Custom functions for cross-field rules
Many real conventions cannot be expressed as "this field matches a regex," for example "every write operation that returns 202 must also document a Location header," or "every enum used in a response must have an unknown-value policy note." Custom functions live in a functions/ directory next to the ruleset:
// openapi/functions/hasLocationHeaderFor202.js
module.exports = (response, _options, context) => {
if (context.path[context.path.length - 1] !== "202") return;
const headers = response.headers || {};
if (!headers.Location) {
return [
{
message: "A 202 Accepted response must document a Location header.",
path: [...context.path, "headers"],
},
];
}
};Register it in the ruleset and attach it:
functions: [hasLocationHeaderFor202]
rules:
async-202-needs-location:
severity: error
given: $.paths[*][*].responses
then:
function: hasLocationHeaderFor202Custom functions are plain JavaScript receiving the matched node and a context; return an array of results (empty means the rule passed). This is where you encode the genuinely domain-specific contracts from your architecture reviews, such as "money fields use amount_minor plus currency" or "every paginated list has cursor parameters."
Errors, warnings, and the rollout path
Turning on twenty errors at once on a legacy spec produces thousands of violations and the team disables the linter. Roll out in stages:
- Run with everything as
warnand capture the baseline count. - Freeze new violations by failing CI only on changed files (or by generating an allowlist snapshot that only shrinks).
- Promote rules to
erroras the affected areas are cleaned. - Reserve
errorfor things that cause real breakage: duplicate ids, invalid references, missing error bodies, secrets in the URL, security-scheme gaps.
Spectral supports exceptions per path via the except map or document-level overrides, which is preferable to deleting a rule for one legacy endpoint; the exception documents the debt and keeps the rule active everywhere else.
Lint more than the spec
The same ruleset should run on specs generated from code and specs authored by hand, so a reverse-engineered document is held to the same bar as a hand-written one. Two adjacent checks belong in the same pipeline:
- Structural validity and resolution: every
$refresolves, the document passes the OpenAPI schema (Spectral covers much of this). - Breaking-change detection: a separate diff step compares the spec against the released version and blocks unannounced removals. Linting governs internal style; the diff governs external compatibility. They are complementary and both belong in CI.
You can also lint examples against their schemas and validate that operationIds referenced by mocks, tests, and agent tool configs still exist, which catches a renamed id that would otherwise break generated clients silently.
What this buys codegen and AI callers
Lint rules are what make the conventions in the rest of this series non-optional: unique verb-first operationIds produce usable SDK method names; declared tags produce stable namespaces; consistent error bodies mean generated error handling works the same way on every endpoint; documented security schemes mean an AI agent authenticates correctly instead of inventing a header. An agent or code generator is only as consistent as the spec it reads, and a linter is how you guarantee that consistency at scale.
Checklist
- Add Spectral with a committed ruleset; run it locally, in pre-commit, and in CI with
--fail-severity=error. - Extend the recommended OpenAPI rules and encode your style guide as explicit rules with messages that say how to fix the issue.
- Cover operationId uniqueness and casing, declared tags, response bodies, error shape, and no secrets in query parameters.
- Write custom functions for cross-field contracts (202 needs Location, money needs currency, pagination needs cursors).
- Roll out legacy areas as warnings with a shrinking allowlist; promote real-breakage rules to errors.
- Use path-level exceptions for known debt instead of disabling rules.
- Run the same ruleset on hand-written and code-generated specs, and pair linting with a breaking-change diff.
Get these in place and your API style stops being a document people agree with and ignore, and becomes a gate the build enforces on every change.
You can author the ruleset, lint and fix a spec, and generate a conformant client in one local-first workspace, right in your browser. For the external-compatibility gate that pairs with internal linting, see detecting breaking API changes with OpenAPI diffs in CI.