Linting OpenAPI Specs with Spectral Custom Rules

Linting OpenAPI Specs with Spectral Custom Rules

API contracts are promises. Your OpenAPI specification says "this endpoint returns a userId field of type string." Every consumer of your API — mobile apps, partner integrations, frontend teams — builds against that promise. When the promise breaks silently, you get production incidents, not compiler errors.

Spectral is a linter for OpenAPI (and AsyncAPI, JSON Schema, and more) that lets you enforce those promises automatically. Beyond the built-in ruleset, you can write custom rules that match your team's API design standards. This post shows you how.

Why Linting Isn't Enough Without Custom Rules

Spectral ships with @stoplight/spectral-openapi, a ruleset that catches obvious mistakes: missing info.version, undocumented response codes, operations without tags. These are table-stakes checks.

But every engineering org has standards that go beyond the defaults:

  • All endpoints must include a correlationId in response headers
  • Error responses must conform to RFC 7807 (Problem Details)
  • Path parameters must use camelCase, not snake_case
  • Every POST endpoint must document a 201 or 202 response

None of these are in Spectral's default ruleset. Custom rules are how you encode your team's decisions.

Setting Up Spectral

Install Spectral CLI:

npm install -g @stoplight/spectral-cli

Create a .spectral.yaml at your repo root:

extends:
  - "@stoplight/spectral-openapi"
rules: {}

Run it against your spec:

spectral lint openapi.yaml

Writing Your First Custom Rule

Custom rules in Spectral have four parts: a description, a message template, a severity, and a given/then pair that selects nodes and applies a function.

Here's a rule that enforces RFC 7807 error responses:

rules:
  problem-details-error-response:
    description: "Error responses must include application/problem+json content type"
    message: "{{path}} error response is missing application/problem+json content type"
    severity: error
    given: "$.paths.*.*.responses[?(@property >= '400' && @property <= '599')]"
    then:
      field: content
      function: schema
      functionOptions:
        schema:
          type: object
          required:
            - "application/problem+json"

The given path uses JSONPath to select all response objects with status codes 400–599. The then clause checks that a content field exists with the required media type.

Custom Functions for Complex Logic

When JSONPath expressions aren't expressive enough, you write a custom function. Functions live in a directory you specify in your config:

extends:
  - "@stoplight/spectral-openapi"
functionsDir: "./spectral-functions"
rules:
  camel-case-path-params:
    description: "Path parameters must use camelCase"
    severity: warn
    given: "$.paths.*.*.parameters[?(@.in == 'path')]"
    then:
      function: camelCase

Create spectral-functions/camelCase.js:

export default (input) => {
  if (typeof input.name !== "string") return;
  const camel = input.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
  if (camel !== input.name) {
    return [
      {
        message: `Path parameter "${input.name}" should be camelCase: "${camel}"`,
      },
    ];
  }
};

Building a Team Ruleset

Once you have more than a handful of rules, package them as a reusable ruleset. Create spectral-ruleset.js:

import { oas } from "@stoplight/spectral-formats";

export default {
  formats: [oas],
  extends: [["@stoplight/spectral-openapi", "recommended"]],
  rules: {
    "require-correlation-id-header": {
      description: "All responses must document a X-Correlation-ID header",
      severity: "warn",
      given: "$.paths.*.*.responses.*",
      then: {
        field: "headers",
        function: "schema",
        functionOptions: {
          schema: {
            type: "object",
            required: ["X-Correlation-ID"],
          },
        },
      },
    },
    "post-must-have-201-or-202": {
      description: "POST operations must document a 201 or 202 response",
      severity: "error",
      given: "$.paths.*.post.responses",
      then: {
        function: "schema",
        functionOptions: {
          schema: {
            type: "object",
            anyOf: [{ required: ["201"] }, { required: ["202"] }],
          },
        },
      },
    },
  },
};

Teams reference this from their .spectral.yaml:

extends:
  - "./spectral-ruleset.js"

Publish the ruleset to npm if multiple repos need it.

Integrating into CI

Add Spectral to your CI pipeline so specs are validated on every pull request:

# .github/workflows/api-lint.yml
name: API Lint
on: [pull_request]
jobs:
  spectral:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm install -g @stoplight/spectral-cli
      - run: spectral lint openapi.yaml --fail-severity error

--fail-severity error exits with a non-zero code only on error-level violations, letting warn violations pass without blocking the PR.

From Linting to Contract Testing

Linting validates the spec itself. Contract testing validates that your running API matches the spec. These are complementary — linting catches issues at design time, contract testing catches drift at runtime.

Tools like Schemathesis and Prism take your linted spec as input. A clean Spectral pass means your spec is well-formed and ready to drive mock servers and property-based tests.

For teams running end-to-end tests, platforms like HelpMeTest let you write API verification tests in plain English, without managing test infrastructure. But Spectral belongs earlier in the pipeline — it's your first line of defense, running before any server is deployed.

Practical Starting Point

Don't try to write 40 rules on day one. Start with three:

  1. Require operationId on every operation — this prevents ambiguous test names downstream
  2. Require example values on all parameters — this enables Prism mock generation
  3. Enforce your error response format — this is the rule most teams care about most

Add rules incrementally, based on real violations you find in code review. Spectral makes those violations catchable automatically, so reviewers can focus on design decisions rather than mechanics.

The spec is your contract. Treat it like code: lint it, version it, enforce it.

Read more