Structuring OpenAPI Paths & Resources
Path structure is the decision that ripples furthest through an API program: it shapes generated SDK method names, the developer-portal sidebar, gateway routing rules, and every consumer’s mental model of your service. Get it wrong and you ship /getUserData, four-level nesting, and silent breaking renames that consumers debug as 404s. This guide is part of OpenAPI & AsyncAPI Schema Authoring and covers resource-oriented design, parameter and versioning strategy, automated path linting in CI, and aggregating many service specs into one portal.
Scope: this page covers naming conventions, path/query parameter placement, versioning, deprecation signaling, and multi-service aggregation for the paths object in OpenAPI 3.1. It does not cover payload schema design — see Defining JSON Schema Components — or per-operation auth, covered in Security Schemes & OAuth Flows. For service-boundary specifics, see the long-form guide on structuring OpenAPI paths for microservices.
Apply these naming rules consistently; they are what the linting rules below enforce:
| Rule | Good example | Avoid |
|---|---|---|
| Plural nouns for collections | /users, /orders |
/user, /getUsers |
| HTTP method carries the action | POST /orders |
/createOrder |
| Two-level nesting maximum | /orders/{orderId}/items |
/orders/{id}/items/{id}/variants/{id} |
| Kebab-case for multi-word segments | /order-items |
/orderItems, /order_items |
| Path params identify, query params filter | /users/{userId}?status=active |
/users/active/{userId} |
Prerequisites & Environment Setup
Install the pinned toolchain locally and in CI so everyone lints against identical rules:
npm install --save-dev @stoplight/spectral-cli@6
npm install --save-dev @redocly/cli@2 # v2.x ships the `join` command
node --version # expect v20.x
npx spectral --version # expect 6.x
npx redocly --version # expect 2.x
You need a valid OpenAPI 3.1 document with a populated paths object and a .spectral.yaml ruleset at the repo root. If your spec is split across files with $ref, run npx redocly bundle openapi.yaml -o bundled.yaml before linting so the linter sees fully resolved paths.
Core Configuration
Define each path with explicit parameter typing and validation. The parameters block at path level applies to every operation under it; operation-level parameters add to or override it. Annotate the non-obvious keys inline:
# openapi.yaml
openapi: 3.1.0
info:
title: Example API
version: "1.0.0"
servers:
- url: https://api.example.com/v1 # base path/version lives here, not in path keys
description: Production
- url: https://staging-api.example.com/v1
description: Staging
paths:
/users/{userId}:
parameters:
- name: userId
in: path
required: true # path params are always required
schema:
type: string
format: uuid # advisory; pattern below does the real validation
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
get:
summary: Retrieve a user by ID
operationId: getUserById # drives the generated SDK method name
tags: [Users] # groups this op in the portal sidebar
parameters:
- name: include
in: query # filters/expansions go in query, never the path
required: false
schema: { type: string, enum: [orders, profile] }
responses:
'200':
description: User found
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'404': { description: User not found }
For versioning, prefer URL path versioning (/v1) declared in the servers array for public APIs and portals — it is explicit in logs, history, and bookmarks. Reserve header-based versioning for internal services behind a gateway. Mark a sunset endpoint with deprecated: true at the operation level and document the removal date in its description, then emit a Sunset response header from the gateway so clients see the timeline programmatically.
Integration Pattern
Run path linting on every pull request and fail the build on convention violations so drift never merges. This workflow bundles $refs first, lints, and verifies a clean multi-service join:
# .github/workflows/validate-openapi.yml
name: Validate OpenAPI Paths
on:
pull_request:
paths: ['**/*.yaml', '.spectral.yaml']
jobs:
validate-paths:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Bundle multi-file spec
run: npx redocly bundle openapi.yaml -o bundled.yaml
- name: Lint path conventions
run: npx spectral lint bundled.yaml --ruleset .spectral.yaml --fail-severity error
- name: Detect path collisions across services
run: npx redocly join services/*.yaml -o /tmp/combined.yaml
The ruleset enforces kebab-case segments, a nesting ceiling, and no trailing slash:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
path-naming-convention:
description: Paths use kebab-case segments, max three segments.
severity: error
given: $.paths[*]~ # the ~ targets the path KEY, not its value
then:
function: pattern
functionOptions:
match: "^/([a-z0-9-]+)(/[a-z0-9-]+|/\\{[a-zA-Z][a-zA-Z0-9]*\\}){0,3}$"
no-trailing-slash:
description: Paths must not end with a trailing slash.
severity: error
given: $.paths[*]~
then:
function: pattern
functionOptions: { notMatch: "/$" }
operation-id-required:
description: Every operation needs an operationId for SDK generation.
severity: error
given: $.paths[*][get,post,put,patch,delete]
then: { field: operationId, function: truthy }
A failing run exits with code 1 and prints the offending path with a line number, blocking the merge until it is fixed.
Advanced Options
Tag-driven portal navigation. Declare top-level tags with descriptions and assign every operation a tag. Portal generators turn tags into sidebar groups, so a disciplined tag taxonomy is your information architecture:
tags:
- name: Users
description: User account management
- name: Orders
description: Order creation and fulfillment
Server-relative base paths. Keep the version and host out of path keys entirely and put them in servers. This makes the same paths block valid across production, staging, and local without edits, and lets the portal offer a server dropdown in its “Try It” panel.
Collision-safe aggregation. When merging service specs, give each service a distinct resource prefix and wire redocly join into CI. It refuses to merge duplicate path keys, turning a silent portal overwrite into a loud build failure you can fix before release.
Verification & Testing
Confirm both structural validity and convention compliance before merging:
npx redocly lint openapi.yaml # structural + best-practice checks
npx spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error
echo $? # 0 means all path rules passed
Build the portal and inspect the sidebar: every operation should appear under its declared tag, paths should read as plural nouns, and the server dropdown should list each servers entry. For multi-service portals, run redocly join and confirm it completes without a duplicate-key error before deploying.
Troubleshooting
Duplicate operationId during SDK generation — Two operations share an operationId, so the generator cannot produce distinct method names. Make every operationId unique and descriptive (listOrders, getOrderById); the operation-id-required rule plus a uniqueness check catches this early.
Spectral reports no matches for a path rule — The JSONPath targets the path value instead of the key. Use the trailing ~ ($.paths[*]~) to match the path string itself; without it, the regex runs against the path-item object and never fires.
Over-nested paths produce unwieldy SDK names — A path like /tenants/{id}/users/{id}/posts/{id}/comments generates deeply chained method names and brittle docs. Flatten with query parameters: GET /comments?userId={id}&postId={id}. Enforce the limit with the nesting regex above.
Silent breaking change from a renamed path — Removing or renaming a path without deprecation forces consumers to debug 404s. Keep the old path with deprecated: true, document the replacement in its description, and retain it for at least one major version while emitting a Sunset header.
FAQ
Should I use URL versioning or header-based versioning?
URL versioning such as /v1/orders is recommended for public APIs and developer portals because it is visible in browser history, logs, and bookmarks and is trivial to route at a gateway. Header-based versioning suits internal microservices where a gateway enforces and abstracts the version negotiation.
How do I prevent path collisions when merging multiple specs?
Give each service a distinct path prefix such as /v1/orders and /v1/users, then run a collision-detection step before portal generation. The redocly join command merges specs and errors on duplicate path keys, so wiring it into CI catches clashes before they reach the portal.
What is the maximum recommended path nesting depth?
Two levels, for example /users/{userId}/posts. Flatten anything deeper using query parameters or independent top-level resources so SDK method names and portal navigation stay readable.
Can I enforce path conventions automatically in CI?
Yes. Spectral custom rules with regex patterns enforce kebab-case segments, parameter formats, and nesting limits on every pull request. Fail the build on error-severity findings so non-conforming paths cannot merge.
Related
- OpenAPI & AsyncAPI Schema Authoring — parent overview
- How to structure OpenAPI paths for microservices
- Defining JSON Schema Components — payload schemas referenced by your paths
- Security Schemes & OAuth Flows — per-operation auth requirements
- Webhook & Callback Definitions — paths that trigger outbound events