Skip to main content

Writing Custom Spectral Rules for API Governance

Inconsistent API style and governance violations — missing error responses, version strings that are not semver, property names that mix snake_case with camelCase — routinely slip past manual code review because reviewers are humans checking prose, not parsers checking structure. The failure mode is quiet: the spec merges, a code generator produces wrong types, or a client that expects a structured error body receives a plain string at 3 a.m. This guide is part of the Breaking Change Detection cluster and shows how to encode your team’s API style guide as executable Spectral 6.11 rules that fail the pull request gate automatically.

Symptom

Review passes, spec merges, and one of these surfaces downstream:

  • A generated SDK contains error: any because the OpenAPI spec never declares a 4xx response schema.
  • A client branches on info.version to choose a feature set and silently picks the wrong branch because the version string is "2" instead of "2.0.0".
  • A TypeScript consumer uses user.first_name (from the OpenAPI spec) while another consumer uses user.firstName (from a different endpoint), and the backend returns whichever casing the developer happened to type that day.

Running spectral lint openapi.yaml with only the default spectral:oas ruleset outputs zero errors because none of these constraints live in the OAS standard — they are team governance rules.

Root Cause

Spectral 6.11’s built-in OAS ruleset enforces the OpenAPI specification itself: required fields, valid $ref targets, correct response object shape. It does not enforce your team’s conventions. Governance rules — “every 4xx operation must have a structured error schema”, “version must be semver”, “all property names must be camelCase” — are out of scope for a spec-compliance linter. They need explicit custom rules.

The gap is intentional: Spectral is a framework, not an opinionated style guide. The framework provides JSONPath-based given selectors, built-in assertion functions (truthy, falsy, pattern, enumeration, schema, length, alphabetical), and a plugin interface for custom JavaScript functions. The team supplies the policy; Spectral supplies the evaluation engine.

Step-by-Step Fix

Step 1: Install Spectral 6.11 and Scaffold the Ruleset

Pin the exact version so CI and developer machines produce identical results. Using a floating latest install is a common source of “passes on my machine” failures.

# Install as a project dev dependency — pin to 6.11.0
npm install --save-dev @stoplight/spectral-cli@6.11.0

# Verify
npx spectral --version
# → @stoplight/spectral-cli/6.11.0 ...

Create .spectral.yaml at the repository root. Extending spectral:oas gives you all built-in OpenAPI compliance rules as a baseline so you never accidentally ship a spec with invalid $ref references.

# .spectral.yaml — Spectral 6.11 governance ruleset
extends:
  - "spectral:oas"

# Custom rules are declared here.
# Severity levels: error | warn | info | hint | off
rules: {}

Why this works: extends: ["spectral:oas"] merges the built-in ruleset with your custom rules. Built-in rules you want to override can be re-declared with a different severity or set to off.

Step 2: Enforce Required Error Response Schemas

Every operation that can return a client error must document a 4xx response with a structured schema — not a bare {} or an absent entry. Operations without one produce any-typed error handling in generated clients, which is untestable.

# .spectral.yaml — add under rules:
rules:

  # Rule 1: every operation must declare at least one 4xx response
  require-4xx-response:
    description: >
      Every operation must document at least one 4xx error response
      so generated clients have typed error handling.
    message: "Operation '{{path}}' is missing a 4xx error response declaration."
    severity: error
    given:
      - "$.paths[*].get"
      - "$.paths[*].post"
      - "$.paths[*].put"
      - "$.paths[*].patch"
      - "$.paths[*].delete"
    then:
      field: responses
      function: schema
      functionOptions:
        schema:
          type: object
          # At least one key must start with 4
          patternProperties:
            "^4[0-9]{2}$":
              type: object
          minProperties: 1   # responses object must exist and be non-empty

The given selectors use JSONPath to target each HTTP method separately because a single path item can declare multiple operations. The schema function validates the responses object structure against an inline JSON Schema.

A cleaner alternative when you only need existence, not structure validation, is to use a custom function (Step 3). For now, add a companion rule that rejects a bare {} schema on the error response:

  # Rule 2: 4xx responses must reference or inline a non-empty schema
  require-4xx-schema-content:
    description: >
      4xx responses must include a content body with a schema reference,
      not an empty object.
    message: "Response '{{path}}' declares no content type or schema."
    severity: error
    given: "$.paths[*][*].responses[?(/^4[0-9]{2}$/.test(@property))].content"
    then:
      function: truthy

Why this works: truthy fails if content is absent, null, false, 0, or an empty string. An absent content key on a response object is the exact shape that causes code generators to emit void or any for the error type.

Step 3: Enforce Semver Version Strings

info.version defaults to a developer typing whatever string they feel like. Enforce the semver format so automated tools that parse version numbers for feature detection and changelogs get a reliable value.

  # Rule 3: info.version must follow semver (MAJOR.MINOR.PATCH)
  info-version-semver:
    description: >
      The info.version string must follow semantic versioning (e.g. 1.4.2).
      Pre-release labels (1.4.2-beta.1) and build metadata (1.4.2+sha.abc) are allowed.
    message: "'info.version' value '{{value}}' is not a valid semver string."
    severity: error
    given: "$.info.version"
    then:
      function: pattern
      functionOptions:
        # Semver core with optional pre-release and build-metadata
        match: >-
          ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)
          (?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)
          (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?
          (?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$

Use a compact single-line pattern if YAML multiline is causing anchor issues:

      functionOptions:
        match: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?$"

Why this works: pattern applies a regular expression to the scalar value at the matched JSONPath node. When info.version is "2" or "v2.0" the match fails and Spectral emits an error with the offending value interpolated into the message via {{value}}.

Step 4: Enforce camelCase Property Names With a Custom Function

The built-in pattern function matches against a single scalar. Checking every property name inside every schema object requires iterating over the keys of the matched node — which is outside what a single pattern call can do. This is the threshold at which you write a custom JavaScript function.

Create the functions directory and the function file:

mkdir -p .spectral/functions
touch .spectral/functions/camelCase.js
// .spectral/functions/camelCase.js
// Spectral 6.11 custom function — validates that all keys in a
// schema's properties object use strict lowerCamelCase.

'use strict';

// Matches lowerCamelCase: starts with lowercase letter,
// no underscores, no hyphens, no ALL_CAPS segments.
const CAMEL_CASE_RE = /^[a-z][a-zA-Z0-9]*$/;

/**
 * @param {Record<string, unknown>} targetVal - the matched node (a properties object)
 * @param {object} _options  - functionOptions from the rule (unused here)
 * @param {{ path: string[] }} context
 * @returns {Array<{ message: string, path: string[] }> | void}
 */
export default function camelCase(targetVal, _options, context) {
  if (typeof targetVal !== 'object' || targetVal === null) return;

  const violations = [];

  for (const key of Object.keys(targetVal)) {
    if (!CAMEL_CASE_RE.test(key)) {
      violations.push({
        message: `Property name "${key}" is not lowerCamelCase. Rename it or add an x-field-name alias.`,
        path: [...context.path, key],
      });
    }
  }

  return violations.length > 0 ? violations : undefined;
}

Register the functions directory in .spectral.yaml and add the rule:

# .spectral.yaml — complete file with all four rules
extends:
  - "spectral:oas"

functionsDir: ".spectral/functions"

rules:

  require-4xx-response:
    description: Every operation must document at least one 4xx error response.
    message: "Operation '{{path}}' is missing a 4xx error response declaration."
    severity: error
    given:
      - "$.paths[*].get"
      - "$.paths[*].post"
      - "$.paths[*].put"
      - "$.paths[*].patch"
      - "$.paths[*].delete"
    then:
      field: responses
      function: truthy

  require-4xx-schema-content:
    description: 4xx responses must include a content body with a schema reference.
    message: "Response '{{path}}' declares no content type or schema."
    severity: error
    given: "$.paths[*][*].responses['400','401','403','404','409','422','429','500'].content"
    then:
      function: truthy

  info-version-semver:
    description: info.version must follow semantic versioning (MAJOR.MINOR.PATCH).
    message: "'info.version' value '{{value}}' is not a valid semver string."
    severity: error
    given: "$.info.version"
    then:
      function: pattern
      functionOptions:
        match: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?$"

  property-names-camel-case:
    description: >
      All schema property names must use lowerCamelCase.
      Mixes of snake_case and camelCase break generated SDK types
      and client serialization.
    message: "{{error}}"
    severity: error
    given:
      - "$.components.schemas[*].properties"
      - "$.paths[*][*].requestBody.content[*].schema.properties"
      - "$.paths[*][*].responses[*].content[*].schema.properties"
    then:
      function: camelCase

Why this works: the camelCase function receives the properties object as targetVal. Iterating Object.keys(targetVal) checks every property name in one pass. Returning an array of {message, path} objects lets Spectral report each violation at the precise JSON path of the offending key — so the error message names the exact property name and the team knows exactly what to rename.

Before / After Comparison

Before — a spec that passes the built-in OAS ruleset but fails the custom governance ruleset:

# openapi.yaml — BEFORE: governance violations
openapi: "3.1.0"
info:
  title: "Payments API"
  version: "2"            # ← not semver
paths:
  /transactions:
    post:
      summary: "Create a transaction"
      responses:
        "201":
          description: "Created"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Transaction"
        # ← no 4xx response declared at all
components:
  schemas:
    Transaction:
      type: object
      properties:
        transaction_id:   # ← snake_case
          type: string
        created_at:       # ← snake_case
          type: string
          format: date-time

After — the same spec with violations resolved:

# openapi.yaml — AFTER: governance-compliant
openapi: "3.1.0"
info:
  title: "Payments API"
  version: "2.0.0"        # ✓ semver
paths:
  /transactions:
    post:
      summary: "Create a transaction"
      responses:
        "201":
          description: "Created"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Transaction"
        "400":             # ✓ structured 4xx declared
          description: "Validation error"
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "422":
          description: "Unprocessable entity"
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
components:
  schemas:
    Transaction:
      type: object
      properties:
        transactionId:    # ✓ camelCase
          type: string
        createdAt:        # ✓ camelCase
          type: string
          format: date-time
    ProblemDetails:
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer
        detail:
          type: string

Verification

Run Spectral against the before and after specs to confirm the gate behaves correctly:

# Should produce 4 errors on the BEFORE spec
npx @stoplight/spectral-cli@6.11.0 lint openapi-before.yaml \
  --ruleset .spectral.yaml \
  --fail-severity=error

# Expected output:
# openapi-before.yaml
#  3:12  error  info-version-semver        'info.version' value '2' is not a valid semver string.
#  7:9   error  require-4xx-response       Operation '$.paths./transactions.post' is missing a 4xx error response declaration.
# 17:9   error  property-names-camel-case  Property name "transaction_id" is not lowerCamelCase.
# 20:9   error  property-names-camel-case  Property name "created_at" is not lowerCamelCase.
#
# ✖ 4 problems (4 errors, 0 warnings, 0 infos, 0 hints)
# exit code 1

# Should produce 0 errors on the AFTER spec
npx @stoplight/spectral-cli@6.11.0 lint openapi-after.yaml \
  --ruleset .spectral.yaml \
  --fail-severity=error

# Expected output:
# No results with a severity of 'error' or higher.
# exit code 0

Wire this into CI so the gate runs on every pull request that touches the spec:

# .github/workflows/contract-gate.yml (lint step only)
- name: Governance lint
  run: |
    npx @stoplight/spectral-cli@6.11.0 lint openapi.yaml \
      --ruleset .spectral.yaml \
      --fail-severity=error \
      --format github-actions

The --format github-actions flag emits annotations directly into the GitHub PR diff view so reviewers see the violation inline at the exact line, without opening CI logs. For teams running the full detection pipeline that includes breaking change diffing alongside governance linting, the detecting breaking changes with openapi-diff in CI guide shows how to compose both steps in one workflow, and catching breaking changes with Optic covers an alternative that unifies linting and diffing in one tool.

Edge Cases and Caveats

Deeply nested allOf / oneOf composition: the given path $.paths[*][*].requestBody.content[*].schema.properties only reaches the top-level properties of an inlined schema. Properties inside allOf[*].properties or oneOf[*].properties require additional given entries such as $.components.schemas[*].allOf[*].properties. Enumerate all the nesting patterns your spec uses or the camelCase rule will silently ignore composed schemas.

Vendor extensions (x-* keys): the camelCase.js function as written flags any key that does not match the pattern, including x-internal or x-deprecated. Add an early-exit guard: if (key.startsWith('x-')) continue; inside the loop. Without it, every vendor extension in every properties object triggers a false positive.

Spectral’s given matching against the OpenAPI $ref resolution boundary: Spectral 6.11 resolves $ref values before evaluating rules by default, so a rule targeting $.components.schemas[*].properties reaches schemas regardless of whether they are used inline or by reference. However, if your spec is split into multiple files and you pass only the root file to the CLI, unresolved external $ref targets are skipped. Bundle first with redocly bundle or pass --resolve-local-refs to ensure the full document graph is present before linting.

Frequently Asked Questions

Can Spectral enforce rules that compare two versions of a spec?

No. Spectral lints a single document in isolation. For version-to-version breaking change detection use oasdiff or openapi-diff; Spectral handles single-document governance rules such as naming conventions, required fields, and deprecation policies.

What is the difference between given/then/field and given/then/function in a Spectral rule?

field checks whether a specific property exists on the target node and optionally applies a built-in function to its value. function applies a custom or built-in function to the entire target node when you need to inspect its structure, iterate over its keys, or run logic that a single field check cannot express.

How do I apply a Spectral rule only to specific HTTP methods or status codes?

Use a targeted JSONPath in the given clause, for example $.paths[*].post to scope a rule to POST operations only, or $.paths[*][*].responses['200'] to target 200 response objects. Combine multiple given patterns under a single rule when the same constraint applies to several methods.

Can a custom Spectral function access the full document, not just the matched node?

Yes. Every custom function receives a context argument with context.document, which exposes the full parsed document. This allows cross-referencing — for example, verifying that a referenced schema exists in components/schemas before flagging it.

How do I suppress a Spectral rule for a single operation without disabling it globally?

Add an x-spectral-disable-next-line vendor extension comment in YAML immediately above the offending key, or add a per-file override in .spectral.yaml using the overrides key with a glob pattern that matches only the file and path you want to exempt.