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.