# Custom Lint Rules

> **Scope of this page**
>
> This page documents the Zuplo **gateway** runtime (API, AI, and MCP gateways), not the Developer Portal.
>
> It applies to every Zuplo hosting option: Managed Edge, Managed Dedicated, and Self-Hosted. See [Hosting options](https://zuplo.com/docs/articles/hosting-options).

When the built-in rules don't cover a convention your team follows, add your own
in `zuplo.lint.jsonc`. Custom rules use the
[Spectral](https://docs.stoplight.io/docs/spectral/e5b9616d6d50c-rulesets)
ruleset format, so a rule written for Spectral or vacuum works here unchanged.

## Write a rule

A rule selects parts of your OpenAPI document with `given` and checks each one
with a function in `then`:

```jsonc title="zuplo.lint.jsonc"
{
  "$schema": "https://cdn.zuplo.com/schemas/zuplo-lint.json",
  "version": 2,
  "extends": ["zuplo:recommended"],
  "rules": {
    "operation-summary-length": {
      "description": "Summaries must fit in the developer portal sidebar",
      "message": "Keep summaries to 60 characters or fewer",
      "severity": "warn",
      "given": "$.paths[*][*].summary",
      "then": { "function": "length", "functionOptions": { "max": 60 } },
    },
  },
}
```

```txt
  config/routes.oas.json
    45:20      warn      Keep summaries to 60 characters or fewer: 'List every todo item that belongs to the current user, sorted by the date it was created' must not be longer/greater than '60'  operation-summary-length  $.paths['/v1/todos'].get.summary
```

The key, `operation-summary-length`, is the rule ID. It appears in results, and
you use it to change the rule's severity in
[`overrides`](./api-lint-configuration.mdx#overrides). IDs starting with
`zuplo/` are reserved for Zuplo's rules.

| Field          | Description                                                                                                                            |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `given`        | A [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expression, or a list of them, selecting the values to check.              |
| `then`         | The function to run on each selected value, or a list of functions. Each has `function`, and optionally `field` and `functionOptions`. |
| `severity`     | `"error"`, `"warn"`, `"info"`, `"hint"`, or `"off"`. Defaults to `"warn"`.                                                             |
| `message`      | The text each result starts with. Defaults to `description`, then to the rule ID.                                                      |
| `description`  | What the rule checks. Used as the message when `message` isn't set.                                                                    |
| `paths`        | Report the rule only on these API paths, such as `["/v1/**"]`.                                                                         |
| `excludePaths` | Don't report the rule on these API paths.                                                                                              |
| `formats`      | Document formats the rule applies to, such as `["oas3"]`.                                                                              |

`then.field` checks a property of each selected value instead of the value
itself, so this rule reports every operation without a `summary`:

```jsonc
{
  "version": 2,
  "rules": {
    "operation-summary": {
      "description": "Operations need a summary",
      "given": "$.paths[*][*]",
      "then": { "field": "summary", "function": "truthy" },
    },
  },
}
```

## Functions

These are the general-purpose functions, the same as Spectral's core functions:

| Function       | Reports when the value...                                               | Options                                                               |
| -------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `truthy`       | is missing, empty, `false`, or `0`                                      |                                                                       |
| `falsy`        | is set to a truthy value                                                |                                                                       |
| `defined`      | is missing                                                              |                                                                       |
| `undefined`    | is set                                                                  |                                                                       |
| `pattern`      | doesn't match `match`, or matches `notMatch`                            | `match`, `notMatch` (regular expressions)                             |
| `casing`       | isn't in the given case                                                 | `type`: `camel`, `pascal`, `kebab`, `snake`, `cobol`, `flat`, `macro` |
| `length`       | is shorter than `min` or longer than `max` (characters, items, or keys) | `min`, `max`                                                          |
| `enumeration`  | isn't one of `values`                                                   | `values`                                                              |
| `alphabetical` | isn't sorted                                                            | `keyedBy` for lists of objects                                        |
| `schema`       | doesn't match a JSON schema                                             | `schema`                                                              |
| `xor`          | has more or fewer than one of `properties`                              | `properties`                                                          |

A Zuplo route has settings, such as its policies, that the OpenAPI document
alone can't explain, because policies are defined in `config/policies.json`.
These Zuplo functions read both files:

| Function                       | Reports a route that...                                  | Options                                                         |
| ------------------------------ | -------------------------------------------------------- | --------------------------------------------------------------- |
| `zuplo:requirePolicy`          | doesn't have a policy of one of the given types          | `policyTypes` (required), `direction` (`inbound` or `outbound`) |
| `zuplo:noUndefinedPolicies`    | references a policy that isn't in `config/policies.json` |                                                                 |
| `zuplo:noEmptyInboundPolicies` | has an empty `policies.inbound` list                     |                                                                 |
| `zuplo:requireRouteLabel`      | has no `label` in `x-zuplo-route`                        |                                                                 |
| `zuplo:requireCorsPolicy`      | has no `corsPolicy` in `x-zuplo-route`                   |                                                                 |

A Zuplo function checks every route itself, so set `"given": "$"` and choose the
routes with `paths` and `excludePaths`. Only `zuplo:requirePolicy` uses your
`message`; the other Zuplo functions write their own.

Run `npx zuplo lint --list-rules` to see every function and rule available.

## Examples

### Require a policy on some routes

[`requiredPolicies`](./api-lint-configuration.mdx#requiredpolicies) applies to
every route. A custom rule with `zuplo:requirePolicy` applies to the routes you
choose:

```jsonc
{
  "version": 2,
  "rules": {
    "require-api-key-v1": {
      "message": "v1 routes need an API key",
      "severity": "error",
      "paths": ["/v1/**"],
      "given": "$",
      "then": {
        "function": "zuplo:requirePolicy",
        "functionOptions": { "policyTypes": ["api-key"] },
      },
    },
  },
}
```

```txt
    16:14      error     v1 routes need an API key: get /v1/admin  require-api-key-v1  paths./v1/admin.get.x-zuplo-route.policies.inbound
```

`policyTypes` matches the same way as in `requiredPolicies`: `"api-key"` matches
the `api-key-inbound` policy type. Add one rule per requirement, each with its
own ID and `paths`.

### Enforce a naming convention

```jsonc
{
  "version": 2,
  "rules": {
    "operation-id-camel-case": {
      "description": "operationIds use camelCase",
      "severity": "error",
      "given": "$.paths[*][*].operationId",
      "then": { "function": "casing", "functionOptions": { "type": "camel" } },
    },
  },
}
```

### Keep internal headers out of the public API

```jsonc
{
  "version": 2,
  "rules": {
    "no-x-internal-headers": {
      "description": "Don't expose X-Internal-* headers",
      "severity": "warn",
      "given": "$.paths[*][*].parameters[?(@.in == 'header')].name",
      "then": {
        "function": "pattern",
        "functionOptions": { "notMatch": "^X-Internal-" },
      },
    },
  },
}
```

## Share rules with a ruleset file

To use the same rules in several projects, put them in a ruleset file and extend
it. A ruleset file is a `.json` or `.jsonc` file in the Spectral ruleset format:

```jsonc title="lint/team-ruleset.jsonc"
{
  // Rules shared by every API team
  "rules": {
    "operation-id-camel-case": {
      "description": "operationIds use camelCase",
      "severity": "error",
      "given": "$.paths[*][*].operationId",
      "then": { "function": "casing", "functionOptions": { "type": "camel" } },
    },
  },
}
```

```jsonc title="zuplo.lint.jsonc"
{
  "$schema": "https://cdn.zuplo.com/schemas/zuplo-lint.json",
  "version": 2,
  "extends": ["zuplo:recommended", "./lint/team-ruleset.jsonc"],
}
```

The path is relative to `zuplo.lint.jsonc`. A ruleset file can extend the
built-in rulesets (`vacuum:oas`, `spectral:oas`, `vacuum:owasp`,
`spectral:owasp`) but not other files. Rulesets must be local files: the linter
doesn't download rulesets from a URL, so copy a shared ruleset into your
repository, or add it as a package, and reference that path. YAML rulesets
aren't supported; convert them to JSON.

`zuplo.lint.jsonc` can still change the severity or scope of a rule from a
ruleset file, the same as any other rule.

## Next steps

- [Lint configuration](./api-lint-configuration.mdx) — rulesets, severities, and
  overrides.
- [Lint rules reference](./api-lint-rules.mdx) — the rules in the Zuplo presets.
