Writing Custom Spectral Rules for API Governance
This guide sits inside the Contract Linting & Governance reference under API Design Fundamentals & Architecture. It walks the full lifecycle of a custom Spectral rule — from a JSONPath selector to a shared npm package — so your governance policies stop living in a wiki and start blocking bad specs in CI.
When you need a custom rule
The built-in spectral:oas ruleset covers structural correctness, but it knows nothing about your conventions. The moment someone asks “why did an endpoint ship with a snake_case path when our standard is kebab-case?” or “how do we force every error response to use application/problem+json?”, you have crossed from generic linting into governance — and that requires rules you write. If you are still choosing a linter at all, the Spectral vs Redocly rule coverage comparison covers that decision; this page assumes you have landed on Spectral and need to extend it.
A concrete trigger: you adopt a house error format and want linting to guarantee every non-2xx response points at a Problem Details media type, consistent with your RFC 7807 Problem+JSON implementation. No built-in rule expresses that. You write one.
Anatomy of a rule
Every Spectral rule is four things: a given selector, a then action, a severity, and a message. Here is the smallest useful custom rule:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
path-kebab-case:
description: Path segments must be kebab-case.
message: "{{path}} — path segments must be kebab-case."
given: "$.paths[*]~"
severity: error
then:
function: pattern
functionOptions:
match: "^\\/([a-z0-9-]+|\\{[a-zA-Z0-9_]+\\})(\\/([a-z0-9-]+|\\{[a-zA-Z0-9_]+\\}))*$"
Read it top to bottom. given is a JSONPath expression; the trailing ~ selects the key (the path string itself) rather than its value. then names the function to run and its options. severity decides whether a match blocks CI. message is what the author sees — {{path}} and other placeholders interpolate the location. The field sub-key of then (omitted here) narrows the function to one property of the selected node.
The given selector
given is the highest-leverage part of a rule, because a wrong selector either misses violations or floods the report with false positives. A few patterns cover most governance needs:
# Every operation object
given: "$.paths[*][get,post,put,patch,delete]"
# Every response object across all operations
given: "$.paths[*][*].responses[*]"
# Every schema property name (the key, via ~)
given: "$.components.schemas[*].properties[*]~"
# Only 4xx/5xx responses (filter expression)
given: "$.paths[*][*].responses[?(@property.match(/^[45]/))]"
given may also be a list of expressions when one rule applies to several node sets. Keep each selector as narrow as the policy allows; broad selectors are the leading cause of noisy rules that teams then disable.
Built-in functions
Spectral ships a handful of core functions that express most policies without any JavaScript. Learn these first:
| Function | Checks | Typical use |
|---|---|---|
truthy |
Value is present and truthy | Operation has security |
falsy |
Value is absent or falsy | Deprecated flag not set |
pattern |
Value matches / does not match a regex | Path is kebab-case |
enumeration |
Value is one of an allowed set | Media type in an allowlist |
casing |
Value follows a casing style | operationId is camelCase |
schema |
Value validates against a JSON Schema | Response has required fields |
length |
String/array/object within min/max | Summary under N characters |
A composed example — force every non-2xx response to use application/problem+json:
rules:
error-uses-problem-json:
description: Error responses must use application/problem+json.
given: "$.paths[*][*].responses[?(@property.match(/^[45]/))].content"
severity: error
then:
field: "@key"
function: enumeration
functionOptions:
values: ["application/problem+json"]
The schema function is the most powerful built-in: it validates a selected node against an inline JSON Schema 2020-12 document, letting you assert required fields and shapes without code:
rules:
info-has-license:
given: "$.info"
severity: warn
then:
function: schema
functionOptions:
schema:
type: object
required: ["license"]
Authoring a custom JavaScript function
When cross-field logic is required — comparing two properties, checking a value against an external allowlist, or inspecting parent context — drop to a custom function. A Spectral function is a JavaScript module exporting a default function that receives the targeted value and returns an array of result objects:
// functions/response-has-example.js
import { createRulesetFunction } from '@stoplight/spectral-core';
export default createRulesetFunction(
{
input: null,
options: null,
},
function responseHasExample(targetVal, _opts, context) {
// targetVal is a response.content[mediaType] object
const results = [];
const hasExample =
targetVal &&
(targetVal.example !== undefined ||
(targetVal.examples && Object.keys(targetVal.examples).length > 0));
if (!hasExample) {
results.push({
message: 'Response media type must include an example or examples.',
path: [...context.path],
});
}
return results;
},
);
Register it in the ruleset by pointing functions at the containing directory, then reference it by name:
# .spectral.yaml
extends: ["spectral:oas"]
functions: ["response-has-example"]
functionsDir: "./functions"
rules:
response-must-have-example:
description: Every 2xx JSON response must carry an example.
given: "$.paths[*][*].responses[?(@property.match(/^2/))].content[application/json]"
severity: warn
then:
function: response-has-example
The path in each result object is what Spectral uses to point the author at the exact line, so always spread context.path rather than hard-coding a location.
Severities and what they gate
Spectral has four severities: error, warn, info, and hint. Only error returns a non-zero exit code by default, so severity is your gating dial. Reserve error for policies that must never ship — security, SDK-breaking omissions, error-format violations. Use warn for style that should be fixed soon but should not block an urgent release. info and hint are for advisory nudges surfaced in editors. Run CI with an explicit floor so nothing slips:
spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity=error
Testing rules against fixtures
A rule you cannot test is a rule you cannot safely change. Keep two kinds of fixture: a known-good spec that must produce zero findings for your rule, and known-bad specs that must each trigger it. Then assert against the programmatic API:
// test/rules.test.js
import { Spectral } from '@stoplight/spectral-core';
import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
async function lint(specPath) {
const spectral = new Spectral();
const ruleset = await bundleAndLoadRuleset(
fileURLToPath(new URL('../.spectral.yaml', import.meta.url)),
{ fs, fetch },
);
spectral.setRuleset(ruleset);
const doc = fs.readFileSync(specPath, 'utf8');
return spectral.run(doc);
}
test('good fixture is clean for kebab-case rule', async () => {
const results = await lint('test/fixtures/good.yaml');
expect(results.filter((r) => r.code === 'path-kebab-case')).toHaveLength(0);
});
test('bad fixture trips the kebab-case rule', async () => {
const results = await lint('test/fixtures/bad.yaml');
const hits = results.filter((r) => r.code === 'path-kebab-case');
expect(hits.length).toBeGreaterThan(0);
expect(hits[0].severity).toBe(0); // 0 = error
});
The good/bad fixture pair is the same discipline you would apply to any validator: prove it fires when it should and stays silent when it should not. Run these tests in the same CI job that publishes the ruleset.
Packaging a shared ruleset
Governance only works when every service enforces the same rules. Publish the ruleset and its custom functions as an npm package so there is one source of truth:
{
"name": "@acme/spectral-ruleset",
"version": "1.4.0",
"type": "module",
"exports": {
".": "./.spectral.yaml",
"./functions/*": "./functions/*.js"
}
}
Each service then extends by package name:
# service/.spectral.yaml
extends: ["@acme/spectral-ruleset"]
rules:
# Local overrides only where a service has a justified exception
response-must-have-example: warn
Version the package with semver so a rule change becomes a reviewable dependency bump rather than a silent behavior shift across every pipeline at once. Roll out error-severity additions as warn first, give teams a sprint to clean up, then promote — the same staged approach you would use for any breaking governance change.
Common mistakes
| Mistake | Correct approach |
|---|---|
given selector too broad, flooding reports |
Narrow to the exact node set; use filter expressions for 4xx/5xx |
Hard-coding a location instead of context.path |
Always spread context.path so findings point at the right line |
Shipping new rules at error with no grace period |
Introduce as warn, let teams fix, then promote to error |
| No fixtures, so rule changes are untested | Keep known-good and known-bad fixtures and assert findings |
| Copy-pasting the ruleset into every repo | Publish one npm package and extends it everywhere |
FAQ
When should I write a custom JS function instead of using a built-in Spectral function?
Use built-ins whenever the policy is about presence (truthy/falsy), a regex (pattern), an allowed value set (enumeration), a schema (schema), or a casing style (casing). Reach for a custom JavaScript function only when the check needs cross-field logic, access to sibling or parent context via context.path and context.document, or computation the built-ins cannot express — comparing two properties, or validating a value against an external allowlist, for example.
How do I test a custom Spectral rule?
Keep a known-good fixture that must produce zero findings and one or more known-bad fixtures that must each trigger the rule. Run the Spectral programmatic API against both in a test, assert the good fixture returns no results for the rule’s code, and assert each bad fixture returns a result at the expected path and severity. Wire these tests into the CI job that publishes the ruleset so a regression fails the release.
How do I share one custom ruleset across many services?
Publish the ruleset and its custom functions as an npm package, then have every service extend it by package name in .spectral.yaml. Version the package with semver so a rule change is a reviewable dependency bump rather than a silent behavior shift across every pipeline at once. Keep per-service overrides minimal and justified, so the shared package stays the real source of truth.
Related
- Contract Linting & Governance — the reference that frames where custom rules fit in a governance program
- API Design Fundamentals & Architecture — the parent reference covering contract-first design across the API surface
- Spectral vs Redocly Linting Rule Coverage — how Spectral’s rule model compares to Redocly before you commit
- Building a Machine-Readable Error Registry — the error catalog your Problem Details lint rules can validate against