Skip to content

fix: preserve operationId acronym casing#3809

Open
thomasvanlankveld wants to merge 10 commits intohey-api:mainfrom
thomasvanlankveld:fix/operation-id-preserve-casing
Open

fix: preserve operationId acronym casing#3809
thomasvanlankveld wants to merge 10 commits intohey-api:mainfrom
thomasvanlankveld:fix/operation-id-preserve-casing

Conversation

@thomasvanlankveld
Copy link
Copy Markdown

@thomasvanlankveld thomasvanlankveld commented Apr 22, 2026

Problem

When output.case: 'preserve' is set and an operationId contains acronyms (e.g. RequestOperations_describeHTTPRequest), operation-derived type and schema names lose the acronym casing during IR normalization. HTTPRequest becomes HttpRequest in generated *Data/*Response types and in Valibot/Zod validators.

Fixes #3797

Root Cause

operationToId in the parser pre-normalizes the raw operationId via toCase(sanitizeNamespaceIdentifier(id), targetCase) with targetCase = output.case ?? 'camelCase'. That's correct for disambiguation and name uniqueness, but downstream consumers that derived names from operation.id lost the acronym information the user asked case: 'preserve' to keep.

Fix

Add an operationBaseName(operation) helper in @hey-api/shared. It returns operation.operationId verbatim when its canonical form (strip non-alphanumerics + lowercase) matches the canonical form of operation.id, and falls back to operation.id when they differ — that difference is the signal that the IR appended a disambiguation suffix, which must be kept. The canonical comparison is case- and separator-insensitive so it works across all output.case values.

Route operation-name derivation through the helper:

  • TypeScript plugin: *Data / *Response / *Error types and webhook equivalents
  • Valibot and Zod plugins: operation schemas, webhook equivalents, and v3 / v4 / mini api entry points
  • OperationPath.id() strategy (consistency + regression guard)

Collision-disambiguation behavior is unchanged — verified against the existing internal-name-conflict scenario, which still produces Create2Data / Create3Data for its three-way create / create_ / create__ operationId collision.

Consumer impact

Under case: 'preserve', operation-derived type and schema names (request/response/error types, zod/valibot schemas) that previously had acronym segments lowercased on re-concatenation (describeHttpRequest) now retain the original casing (describeHTTPRequest). Unaffected under other case values. Downstream code importing these names directly will need to update — the changeset calls this out as well.

This fix targets case: 'preserve' only. Under non-preserve casings, downstream toCase still squashes acronyms — the helper rescues them only where the final casing step is a no-op.

Test Coverage

Added snapshot tests across the main/valibot/zod suites using a new spec specs/3.1.x/acronym-operationid-preserve.yaml (operationId: RequestOperations_describeHTTPRequest). Under case: 'preserve', generated names now keep the acronym casing (RequestOperations_describeHTTPRequestData, vRequestOperations_describeHTTPRequestPath, etc.).

Unit tests for operationBaseName in @hey-api/shared cover the three branches (normal path, missing operationId, disambiguation) and all output.case values (camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, preserve).

Known limitations / out of scope

Two operation-name sinks are intentionally left alone:

  1. SDK method names (sdk.gen.ts). Flat-surface consumers want camelCase-joined with acronyms preserved (requestOperationsDescribeHTTPRequest); no methodName.casing value delivers that. 'preserve' keeps the raw operationId including separators (RequestOperations_describeHTTPRequest); 'camelCase' runs toCase, which treats all-caps runs as word boundaries and squashes them (requestOperationsDescribeHttpRequest). The nested shape (nesting: 'operationId' + custom nestingDelimiters) recovers the acronym by splitting into segments, but flips the call site from client.requestOperationsDescribeHTTPRequest(...) to client.RequestOperations.describeHTTPRequest(...) — a migration for any consumer on the flat shape. The real follow-up is a casing mode (or smarter toCase) that treats all-caps runs as a single word — a @hey-api/shared naming-semantics change, larger than this PR's helper.
  2. Fastify / NestJS route name. These plugins emit operation.id directly as a schema property. A naive swap to operationBaseName would change behavior under default output.case too (operation.id is camelCased today; operationBaseName returns the raw operationId). The correct fix is a companion toCase(operationBaseName(operation), output.case ?? 'camelCase') in each plugin — deferrable.

Discussion

Two alternative approaches that were considered:

  1. Stop pre-normalizing operation.id in operationToId. Fix the root: emit the sanitized-but-uncased operationId into the IR, and let each consumer's applyNaming / pathToName step handle casing (same way schemas work, derived from the raw $ref via pathToName). One change, every surface fixed. Rejected for this PR because it changes operation.id's shape for user hooks / custom plugins, and because fastify / nestjs emit operation.id as a literal property name with no casing step after — they'd need companion casing to avoid a regression. (Although operationToId is marked @deprecated)
  2. Split concerns on the IR: add operation.baseName alongside operation.id. id stays the cased lookup key; baseName is the display base. Cleanest semantically, but a new public IR surface, bigger PR, and solves a design problem beyond the reported bug.

Instead, this PR introduces the operationBaseName helper. This should localize the fix, so that it would be relatively easy to clean up if you later want to go for one of the other two approaches.

When `case: 'preserve'` is set on the typescript plugin, operation-derived
types (`*Data`, `*Response`, `*Error`) should keep the spec's `operationId`
verbatim. Add a minimal spec with operationId `RequestOperations_describeHTTPRequest`
and a snapshot asserting the acronyms and underscore survive.

Test fails on main: the IR pre-normalizes `operationId` to camelCase before
the plugin's casing runs, so `preserve` preserves the already-flattened
`requestOperationsDescribeHttpRequest*` form.
…ed types

Add `operationBaseName(operation)` helper in the shared parser utils and
route the `@hey-api/typescript` plugin's operation and webhook `buildSymbolIn`
calls through it. The helper returns `operation.operationId` verbatim when
`operation.id` is the plain camelCase normalization of it, and falls back
to `operation.id` when disambiguation (a numeric suffix) was appended — so
existing conflict-resolution behavior for duplicate operationIds is preserved.

Makes the failing typescript snapshot pass.
Align the `OperationPath.id()` strategy with the helper so `operation.id`-based
custom strategies benefit from operationId preservation. For all built-in SDK
configurations this is a no-op: with `nesting: 'id'` the IR is path-derived
(helper falls back to `operation.id`), and with the default `nesting: 'operationId'`
the `fromOperationId` strategy is used instead.

Also add a snapshot scenario that runs `@hey-api/sdk` + `@hey-api/typescript`
against the acronym spec as an integration regression guard — the SDK method
name and referenced types both preserve the raw `operationId` under preserve
casing.
Mirror the typescript scenario for the valibot plugin: generate schemas
against the acronym spec with `case: 'preserve'` and assert the operation
schema names keep the raw operationId (`vRequestOperations_describeHTTPRequest*`).

Test fails on main because valibot's `namingAnchor` is `operation.id`,
which is pre-normalized to camelCase.
Swap `namingAnchor: operation.id` to `operationBaseName(operation)` across
the valibot plugin's request body/headers/path/query, response, webhook,
and composite request-schema entry points. Makes the failing valibot
snapshot pass.
Mirror the valibot scenario for the zod plugin across v3, v4, and mini.
The zod test harness iterates over all three compatibility versions from
a single scenario list, so a single addition per test file covers all
three zod outputs.

Tests fail on main because zod's `namingAnchor` is `operation.id`, which
is pre-normalized to camelCase.
Swap `namingAnchor: operation.id` to `operationBaseName(operation)` across
the zod plugin's request/response/webhook entry points and the v3, v4,
and mini composite request-schema entry points. Makes the failing zod
snapshots pass.
@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 22, 2026

@thomasvanlankveld is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 22, 2026

🦋 Changeset detected

Latest commit: bc74632

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hey-api/openapi-ts Patch
@hey-api/shared Patch
@hey-api/openapi-python Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 22, 2026

TL;DR — When case: 'preserve' is configured, operation-derived names (types, SDK methods, validator schemas) now use the raw operationId from the spec instead of the normalized IR id, preserving acronym casing like HTTP instead of lowercasing it to Http. Fixes #3797.

Key changes

  • Add operationBaseName helper to @hey-api/shared — returns the raw operationId when available and not disambiguated, falling back to the normalized IR id otherwise
  • Replace operation.id with operationBaseName(operation) across all plugins — typescript, zod (v3/v4/mini), and valibot (v1) plugins now use the helper for naming operations and webhooks
  • Update OperationPath.id to use operationBaseName — ensures SDK method paths also benefit from preserved casing
  • Add test spec and snapshot coverage — new acronym-operationid-preserve.yaml spec with RequestOperations_describeHTTPRequest exercises the fix across all plugins
  • Refine changeset to per-plugin scopes — changeset now lists each affected plugin separately (@hey-api/typescript, zod, valibot) and documents the consumer-visible output change

Summary | 45 files | 10 commits | base: mainfix/operation-id-preserve-casing


Preserve acronym casing in operation-derived names

Before: an operationId of RequestOperations_describeHTTPRequest was normalized to requestOperationsDescribeHttpRequest by the IR, and case: 'preserve' would produce names like RequestOperationsDescribeHttpRequestData — losing the HTTP acronym casing.
After: operationBaseName returns the raw operationId string (RequestOperations_describeHTTPRequest) so case: 'preserve' produces RequestOperations_describeHTTPRequestData, keeping the original casing intact.

The core logic lives in a new operationBaseName function. It strips non-alphanumerics and lowercases both operation.id and operation.operationId before comparing — this makes the check insensitive to casing and separators, which is necessary because operation.id is built via toCase(sanitized, output.case) and the target casing is not available at the call site. If the canonical forms match, the raw operationId is returned; otherwise the function falls back to operation.id.

When does the fallback to operation.id kick in?

Two cases: (1) when no operationId is set in the spec (the IR synthesizes one from method + path), and (2) when the sanitized operationId collided with another operation and was disambiguated with a numeric suffix — the suffix only exists on operation.id and must be kept.

Every plugin that derives names from operations — @hey-api/typescript (types + webhooks), zod (v3, v4, mini), and valibot (v1) — was updated to call operationBaseName(operation) instead of reading operation.id directly. Unit tests cover all five supported casings, the missing-operationId fallback, and the disambiguation-suffix fallback.

The changeset documents the consumer-visible impact: under case: 'preserve', generated names that previously lowercased acronym segments (e.g. describeHttpRequest) now retain the original casing (describeHTTPRequest). Downstream code importing these names directly will need to update.

operation.ts (shared util) · operation.ts (locations) · operation.ts (typescript) · operation.test.ts · acronym-operationid-preserve.yaml

Pullfrog  | View workflow run | via Pullfrog𝕏

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 22, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 39.87%. Comparing base (6cf6b1a) to head (bc74632).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3809      +/-   ##
==========================================
+ Coverage   39.80%   39.87%   +0.06%     
==========================================
  Files         530      530              
  Lines       19467    19483      +16     
  Branches     5791     5806      +15     
==========================================
+ Hits         7748     7768      +20     
+ Misses       9487     9484       -3     
+ Partials     2232     2231       -1     
Flag Coverage Δ
unittests 39.87% <100.00%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 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.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 22, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3809

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3809

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3809

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3809

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3809

@hey-api/spec-types

npm i https://pkg.pr.new/@hey-api/spec-types@3809

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3809

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3809

commit: bc74632

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The core operationBaseName approach is sound and the test coverage via snapshots looks thorough for the case: 'preserve' + default output.case scenario. Two items to consider before merge.

Missed call sites outside the diff: packages/openapi-ts/src/plugins/nestjs/plugin.ts:91 and packages/openapi-ts/src/plugins/fastify/plugin.ts:120 still use name: operation.id directly. If those plugins support case: 'preserve' for operation-derived names, they should also use operationBaseName(operation) for consistency.

Task list (5/5 completed)
  • Read the diff — identify major areas of change
  • Read the core logic changes (packages/shared, plugins)
  • Read the test spec and test config
  • Verify correctness of the approach and edge cases
  • Self-critique drafted comments and submit review

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run𝕏

Comment thread packages/shared/src/openApi/shared/utils/operation.ts Outdated
Comment thread packages/shared/src/openApi/shared/utils/operation.ts
The old equality check hardcoded `'camelCase'` while `operation.id` is
built from `output.case`. Under `output.case: 'PascalCase'` (or any
non-camelCase), the comparison never matched and the helper silently
fell back to `operation.id`, re-introducing the acronym-squash bug.

Canonicalize both sides (strip non-alphanumerics, lowercase) so the
match works across all `output.case` values. The canonicalization
still catches disambiguation because appended numeric suffixes change
the canonical form.

Add unit tests covering all `output.case` values, missing operationId,
and the disambiguation path.
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 22, 2026

Verified the canonical comparison fix in operationBaseName — it correctly handles all output.case values by stripping non-alphanumerics and lowercasing both sides. Confirmed the fix, replied to the thread, and resolved it.

Task list (4/4 completed)
  • Checkout PR branch and review the changes
  • Verify the canonical comparison fix is correct
  • Reply to the review comment and resolve if addressed
  • Report progress

Pullfrog  | View workflow run | via Pullfrog𝕏

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 22, 2026

Verified the author's fixes in 49b52fb — both the canonical comparison (casing-agnostic equality check) and the unit tests look correct. Replied to both threads and resolved them.

Task list (4/4 completed)
  • Checkout PR and verify the author's fixes in commit 49b52fb
  • Reply to review threads acknowledging the fixes
  • Resolve addressed threads
  • Report progress

Pullfrog  | View workflow run | via Pullfrog𝕏

@thomasvanlankveld
Copy link
Copy Markdown
Author

thomasvanlankveld commented Apr 22, 2026

Re: the nestjs/fastify call sites flagged in the review — considered, deferred. A direct operation.id → operationBaseName swap changes behavior under default output.case too (operation.id is camelCased today; operationBaseName returns the raw operationId), so those plugins would need a companion toCase(name, output.case ?? 'camelCase') step to avoid breaking existing users on defaults. Flagged in the new "Known limitations" section of the description.

@thomasvanlankveld thomasvanlankveld marked this pull request as ready for review April 22, 2026 13:37
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 22, 2026

Review submitted. The operationBaseName helper and its application across TypeScript/Valibot/Zod plugins are sound — the canonical comparison correctly detects disambiguation suffixes while being case- and separator-insensitive. Two minor non-blocking comments posted: (1) update the OperationPath.id() JSDoc to reflect the new semantics, and (2) note the subtle public-API behavior change in the changeset for external consumers.

Task list (5/5 completed)
  • Read the diff to identify major areas of change
  • Review core logic changes in packages/shared and packages/openapi-ts
  • Review test spec and test changes
  • Review snapshot files for correctness
  • Self-critique and submit review

Pullfrog  | View workflow run | via Pullfrog𝕏

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Apr 22, 2026
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-structured PR with clear problem analysis, thorough test coverage across all plugin surfaces, and a sound disambiguation strategy. Two minor items below — neither blocks merge.

Task list (5/5 completed)
  • Read the diff to identify major areas of change
  • Review core logic changes in packages/shared and packages/openapi-ts
  • Review test spec and test changes
  • Review snapshot files for correctness
  • Self-critique and submit review

Pullfrog  | View workflow run𝕏

* // Result: ['getUserById']
*/
id: (): OperationPathStrategy => (operation) => [operation.id],
id: (): OperationPathStrategy => (operation) => [operationBaseName(operation)],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc for OperationPath.id() (lines 197–203) still says "Uses operation.id as a single path segment" and the example shows operation.id: 'getUserById'['getUserById']. After this change the return value is operationBaseName(operation), which may be operation.operationId rather than operation.id.

Since OperationPath is part of the public API of @hey-api/shared, the doc should reflect the new semantics — something like "Uses the operation's base name (raw operationId when available, falling back to operation.id)." The example could show both cases.

* // Result: ['getUserById']
*/
id: (): OperationPathStrategy => (operation) => [operation.id],
id: (): OperationPathStrategy => (operation) => [operationBaseName(operation)],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a subtle public-API behavior change: anyone calling OperationPath.id() in a custom OperationStructureStrategy and not running the result through applyNaming/toCase will now get the raw operationId instead of the pre-cased operation.id. Built-in plugins are fine (they all apply naming downstream), but external consumers might not. Worth calling out in the changeset or release notes.

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — the two inline comments from the prior review are minor and non-blocking.

Task list (5/5 completed)
  • Read the diff to identify major areas of change
  • Review core logic changes in packages/shared and packages/openapi-ts
  • Review test spec and test changes
  • Review snapshot files for correctness
  • Self-critique and submit review

Pullfrog  | View workflow run𝕏

Replace the `parser` scope (no parser code changed) with three
`plugin(name)` scopes matching where the user-visible changes land:
@hey-api/typescript, zod, and valibot. Add a note describing the
observable shift in operation-derived names for `case: 'preserve'`
consumers.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

responses.case: "preserve" and requests.case: "preserve" do not preserve acronym casing for operation-derived types

1 participant