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.

From per-service paths to a unified portal Three service specs with prefixed resource paths merge into one aggregated portal grouped by tags. users-service /v1/users/{userId} orders-service /v1/orders/{orderId} billing-service /v1/invoices/{id} redocly join collision check Unified portal grouped by tags

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.