How to Write Custom Spectral Rules

The built-in spectral:oas and spectral:asyncapi rulesets enforce general hygiene, but they cannot know your organization’s naming conventions, required headers, or versioning policy. Custom rules close that gap. This guide is part of Spec Linting & API Governance with Spectral within OpenAPI & AsyncAPI Schema Authoring, and it walks through authoring rules with given JSONPath, then core functions, severities, and a custom JavaScript function — the moment you move from “linting works” to “linting enforces our style guide.”

Problem & Context

Your team agreed on a style guide: every path uses kebab-case nouns, every operation declares an operationId in camelCase, error responses reuse a shared Problem schema, and every collection endpoint advertises rate-limit headers. None of that is in the built-in rulesets. Without enforcement, the conventions live in a wiki page nobody reads, and the spec drifts. The fix is to encode each convention as a Spectral rule so a violation fails the pull request automatically. A rule has three required parts: given (which nodes to inspect), then (what must be true), and severity (how much a violation matters).

The mental model is a pipeline per rule: given runs a JSONPath query over the document and yields a list of nodes; for each node, Spectral runs the function in then; any failure becomes a result tagged with the rule’s severity. Everything else — description, message, formats, resolved — refines that core flow. Once you internalize “select nodes, assert on each,” writing a new rule becomes a matter of finding the right JSONPath and picking the right core function, and only rarely reaching for JavaScript.

Step-by-Step Solution

1. Select target nodes with a given JSONPath

given is a JSONPath expression that returns the set of nodes the rule evaluates. Target all write operations across every path:

rules:
  operation-id-required:
    given: "$.paths[*][get,put,post,delete,patch]"
    severity: error
    then:
      field: operationId
      function: truthy

$.paths[*] walks every path item; [get,put,post,delete,patch] narrows to HTTP methods. Each matched node is an operation object.

2. Declare the assertion with a core function in then

then states what must hold. Use field to drill into a property of the matched node, then a function. Enforce camelCase operation IDs with the casing function:

  operation-id-camel-case:
    given: "$.paths[*][get,put,post,delete,patch]"
    severity: error
    then:
      field: operationId
      function: casing
      functionOptions:
        type: camel        # camel | pascal | kebab | snake | macro | flat

For regex checks use pattern; for presence use truthy. To validate an object key rather than a value — for example a path string — set field: "@key":

  path-kebab-case:
    given: "$.paths"
    severity: error
    then:
      field: "@key"
      function: pattern
      functionOptions:
        match: "^(\\/[a-z0-9-{}]+)+$"   # lowercase, digits, dashes, path params

3. Set severity

severity is error, warn, info, or hint. Only results at or above --fail-severity (default error) make the CLI exit non-zero, so use error for hard rules and warn for advisory ones you intend to tighten later.

4. Test the rule against a fixture

Create a deliberately broken spec and run the linter. Given a path /getUsers and an operation without an operationId:

npx spectral lint bad-fixture.yaml --ruleset .spectral.yaml --fail-severity error

Expected output:

bad-fixture.yaml
 6:3   error  path-kebab-case        "/getUsers" does not match the expected pattern.  paths./getUsers
 7:5   error  operation-id-required  operationId is missing.                           paths./getUsers.get

✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints)
echo $?
# 1   -> the gate correctly fails the build

5. Add a message and description for clear feedback

Polish each rule so contributors understand why it fired. {{path}}, {{value}}, and {{property}} interpolate into the message:

  operation-id-camel-case:
    description: "operationId must be camelCase so generated SDK methods are idiomatic."
    message: "{{path}}: operationId \"{{value}}\" is not camelCase."
    given: "$.paths[*][get,put,post,delete,patch]"
    severity: error
    then:
      field: operationId
      function: casing
      functionOptions:
        type: camel

6. Scope the rule to a spec version with formats (when needed)

Some rules only make sense for one spec version. A rule banning the legacy nullable keyword belongs to OpenAPI 3.1 only; running it against a 3.0 document would be wrong. Add a formats array so Spectral skips the rule for non-matching documents:

  no-nullable-31:
    description: "OpenAPI 3.1 replaces nullable with type arrays."
    formats: [oas3_1]      # evaluated only for 3.1 documents
    given: "$..properties[*]"
    severity: error
    then:
      field: nullable
      function: undefined

This keeps a single shared ruleset usable across a fleet of specs at different versions without false positives.

Complete Working Example

A self-contained .spectral.yaml combining several custom rules plus a custom JavaScript function that asserts collection GET responses expose a X-RateLimit-Limit header. First the ruleset:

# .spectral.yaml
extends: ["spectral:oas"]

functions: [hasRateLimitHeader]   # names a function file in functionsDir
functionsDir: "./functions"       # ./functions/hasRateLimitHeader.js

rules:
  path-kebab-case:
    description: "Path segments must be lowercase kebab-case nouns."
    message: "{{property}} is not kebab-case."
    given: "$.paths"
    severity: error
    then:
      field: "@key"
      function: pattern
      functionOptions:
        match: "^(\\/[a-z0-9-{}]+)+$"

  operation-id-required:
    description: "Every operation needs an operationId for SDK generation."
    message: "{{path}} is missing an operationId."
    given: "$.paths[*][get,put,post,delete,patch]"
    severity: error
    then:
      field: operationId
      function: truthy

  operation-id-camel-case:
    description: "operationId must be camelCase."
    message: "operationId \"{{value}}\" is not camelCase."
    given: "$.paths[*][get,put,post,delete,patch].operationId"
    severity: error
    then:
      function: casing
      functionOptions:
        type: camel

  error-uses-problem-schema:
    description: "4xx/5xx responses must reuse the shared Problem schema."
    message: "Error response should $ref components.schemas.Problem."
    given: "$.paths[*][*].responses[?(@property.match(/^(4|5)\\d\\d$/))].content.application/json.schema"
    severity: warn
    then:
      field: "$ref"
      function: pattern
      functionOptions:
        match: "Problem$"

  list-endpoint-rate-limit:
    description: "Collection GET responses must expose rate-limit headers."
    given: "$.paths[*].get.responses.200.headers"
    severity: warn
    then:
      function: hasRateLimitHeader   # custom function below

The custom function. Spectral functions export a default function (input, options, context) and push to the returned results array for each violation. Place this at ./functions/hasRateLimitHeader.js:

// functions/hasRateLimitHeader.js
export default function hasRateLimitHeader(input, _options, context) {
  // input is the 'headers' object selected by `given`
  if (input && Object.prototype.hasOwnProperty.call(input, "X-RateLimit-Limit")) {
    return; // returning nothing (or []) means the rule passes
  }
  return [
    {
      message: "200 response is missing the X-RateLimit-Limit header.",
      path: [...context.path], // anchors the result to the matched node
    },
  ];
}

Run it against a spec to confirm both core-function and custom-function rules fire:

npx spectral lint specs/openapi.yaml --ruleset .spectral.yaml --fail-severity error

This ruleset drops straight into the CI workflow described in Spec Linting & API Governance with Spectral, so the same rules that pass locally also gate every pull request. Rules that reference shared schemas like Problem assume those components exist — see defining JSON Schema components for how to structure them.

Gotchas & Edge Cases

JSONPath filter expressions need the right escaping in YAML. The filter [?(@property.match(/^(4|5)\\d\\d$/))] uses a regex literal; backslashes must be doubled inside double-quoted YAML, and the whole given is best wrapped in quotes. Test the expression with --verbose and a low severity before trusting it — a silently empty match looks identical to a passing rule.

@key versus field on the value. field: "@key" validates the object’s key (the path string, the property name); omitting field validates the whole matched node; field: someProp validates a child value. Mixing these up is the most common reason a casing or pattern rule appears to do nothing.

Custom functions must return the right shape. Return undefined or an empty array to signal a pass, and an array of { message, path } objects to signal failures. Returning a bare string or a truthy non-array throws at runtime with Cannot read properties of undefined. Always spread context.path so the result points at the offending node rather than the document root.

FAQ

Why does my Spectral rule match nothing?

The given JSONPath is not selecting any nodes, often because it expects a resolved document. Bundle multi-file specs with @redocly/cli first and re-test, and lower the rule severity to info with --verbose to inspect what Spectral evaluated.

Can one rule apply more than one check?

Yes. The then field accepts an array of function objects, and Spectral applies every entry to each node selected by given. Each entry can target a different field and use a different core function.

Do I need a custom JavaScript function for most rules?

No. The core functions truthy, pattern, casing, enumeration, length, and schema cover the large majority of governance rules. Reach for a custom function only when a check needs logic the core functions cannot express.