Skip to content

fix(openapi): use exact-match dedup in default_operation_id_creator - #4923

Open
chuenchen309 wants to merge 1 commit into
litestar-org:mainfrom
chuenchen309:fix/operation-id-substring-collision
Open

fix(openapi): use exact-match dedup in default_operation_id_creator#4923
chuenchen309 wants to merge 1 commit into
litestar-org:mainfrom
chuenchen309:fix/operation-id-substring-collision

Conversation

@chuenchen309

@chuenchen309 chuenchen309 commented Jul 14, 2026

Copy link
Copy Markdown

What

default_operation_id_creator can generate identical operationIds for two structurally different routes, violating the uniqueness OpenAPI requires of operationId (and which this codebase's own test_routes_with_different_paths_should_generate_unique_operation_ids already asserts for the cases it covers).

Root cause

components_namespace = ""
for component in (...):
    if component.title() not in components_namespace:
        components_namespace += component.title()

The dedup check is a substring check against the concatenated components_namespace string, not an exact-match check against the individual components seen so far. So if a later component's title happens to be a substring of the already-built string, it gets silently dropped — even though it's a distinct path component.

Example: path components ["users"] vs ["users", "user"].

  • ["users"]components_namespace = "Users".
  • ["users", "user"]"Users" added, then "User" is checked: "User" in "Users"True (substring!) → the "user" component is dropped. Result stays "Users".

Both routes now produce the operationId UsersGetHandler, even though they represent different paths.

Fix

Track the set of components already seen (by exact value) instead of doing substring containment against the concatenated string:

components_namespace = ""
seen_components: set[str] = set()
for component in (...):
    title = component.title()
    if title not in seen_components:
        seen_components.add(title)
        components_namespace += title

This preserves the original intent — skip an exact-duplicate component (e.g. a router prefix literally repeating a path segment name) — without dropping components that merely share a substring with something already accumulated.

How I verified this

  • Reproduced the collision directly against default_operation_id_creator: ["users"] and ["users", "user"] both produced "UsersGetHandler".
  • Added a regression test, test_default_operation_id_creator_does_not_collide_on_component_substring, in tests/unit/test_openapi/test_path_item.py; confirmed it fails on the current code (AssertionError: 'UsersGetHandler' != 'UsersGetHandler').
  • Applied the fix, confirmed the new test and all 23 tests in test_path_item.py pass.
  • Ran the full tests/unit/test_openapi/ suite: 262 passed, no regressions.
  • mypy litestar/_openapi/utils.py — clean.
  • pre-commit run on changed files — clean (ruff check/format, unasyncd, typos).

Disclosure

I used AI assistance (Claude) to help investigate and draft this fix, but I personally reproduced the collision, wrote/reviewed the regression test, ran the full suite myself, and reviewed the final diff before submitting. Happy to answer any questions.


📚 Documentation preview 📚: https://litestar-org.github.io/litestar-docs-preview/4923

`default_operation_id_creator` builds an operationId by concatenating
title-cased path components, skipping a component if it's already
present via `component.title() not in components_namespace` — a
substring check against the concatenated string, not an exact-match
check against individual components.

This causes false collisions: for path components `["users", "user"]`,
"User" is a substring of the already-accumulated "Users", so the
"user" component is silently dropped, producing the same operationId
("UsersGetHandler") as a route whose only component is `["users"]`.
Two structurally different routes can end up with identical
operationIds, which OpenAPI requires to be unique
(test_routes_with_different_paths_should_generate_unique_operation_ids
already encodes this invariant, just not for this substring case).

Fix: track seen component titles in a set and check membership there
instead of doing a substring check against the concatenated namespace
string. This preserves the original intent (skip an exact-duplicate
component, e.g. when a router prefix repeats a literal path segment)
without falsely dropping components that merely share a substring.

How verified: reproduced the collision directly against
default_operation_id_creator with ["users"] vs ["users", "user"])
producing the same id; added a regression test
(test_default_operation_id_creator_does_not_collide_on_component_substring)
confirmed failing before the fix (AssertionError: 'UsersGetHandler' !=
'UsersGetHandler'); applied the fix; confirmed all 23 tests in
test_path_item.py and the full 262-test tests/unit/test_openapi/ suite
pass. mypy clean, pre-commit clean (ruff, unasyncd, typos).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chuenchen309
chuenchen309 requested review from a team as code owners July 14, 2026 09:49
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 67.30%. Comparing base (bd18fd4) to head (fe874de).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
litestar/_openapi/utils.py 80.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4923   +/-   ##
=======================================
  Coverage   67.29%   67.30%           
=======================================
  Files         293      293           
  Lines       15228    15231    +3     
  Branches     1728     1728           
=======================================
+ Hits        10248    10251    +3     
  Misses       4833     4833           
  Partials      147      147           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant