Your generated TypeScript client exports createBranchReposRepoBranchesPost and listBranches2.
The first is a web framework’s default operationId turned into a method. The second is an id
somebody copied, which the generator made unique by appending a digit, without a warning. Both
names ship, and renaming either later breaks every caller.
What you get
You will end up with an audit that fails ids which would make bad method names, a rename pass that fixes them, and a diff of what two generators produce. This is for you if you publish an OpenAPI document that generators read.
Short answer
Give every operation an operationId in snake_case, verb then noun, such as list_branches, and
keep it unique even once case is ignored. Audit the ids in CI, rename with an OpenAPI Overlay and
the openapi-format casing pass, then generate with two generators and diff the method lists.
Generators that name methods from operationId follow the rename. sdkgen and Kiota name calls from
the path, so the path has to read well too.
You will need
Node 22 or later and an OpenAPI 3 document, ideally inside an authoring workflow that already
lints it. Verified 2026-10-15 against Node 22.22.0, openapi-format 1.34.0,
@hey-api/openapi-ts 0.99.0 and @voxgig/apidef 8.25.2. The
OpenAPI 3.0.3 specification requires
each operationId to be unique and case-sensitive, and recommends common programming naming
conventions without naming one.1
Voxgig maintains sdkgen. This page compares how it names methods with OpenAPI Generator, Speakeasy, and Kiota.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Kiota | Deep path hierarchies, and callers who navigate by path segment | Call names come from path segments and HTTP verbs, so an operationId convention buys you nothing here | Your callers expect verb names your team chose |
| OpenAPI Generator | The widest language coverage, and ids you already control | A duplicate inside one tag gets _0 appended with only a log warning | You want naming decided by a model rather than by each id |
| sdkgen | Entity-shaped clients whose paths already name the resource | Reads an operationId only when a path has no segment to name an entity, so the paths carry the naming | Your paths are actions or RPC verbs rather than resources |
| Speakeasy | Ids you control, grouped by tag or x-speakeasy-group | Method names track the operationId unless x-speakeasy-name-override decouples them, so a rename breaks callers | You cannot add vendor extensions to the published document |
OpenAPI Generator and Speakeasy turn each id into a method, so a bad id ships as a bad method and a
renamed id is a breaking change. Speakeasy files each method under a
namespace named after its tag.
Kiota and sdkgen build names from the path, which makes the ids nearly irrelevant to them and the
path segments the thing to get right. One convention serves both
groups: snake_case verb plus noun, on paths that name resources.
Find the ids that would become bad names
The audit asks of each id whether it exists, is unique, is not a framework default, is
snake_case, and starts with a verb. Uniqueness is checked twice: as written, then with case
and punctuation folded away. The framework check compares the id with its own path and method,
so get_branch_head and create_post pass it.
// FastAPI's default is the function name, then the path with every non-word
// character turned into `_`, then the method: `create_branch_repos__repo__branches_post`.
// So the id ends with its own operation's path and method, and a doubled
// underscore usually marks it. An id that merely ends in a method name, such
// as `get_branch_head` or `create_post`, is not one. The comparison is folded,
// so the mark survives a casing pass that rewrote the id.
export function frameworkDefault({ id, method, path }) {
const tail = folded(path)
return id.includes('__') || (tail !== '' && folded(id).endsWith(tail + method.toLowerCase()))
}
/** The problems with one operation's id, given every id in the document. */
export function problems(op, exact, fold) {
const { id, unread } = op
if (unread) return ['in another file, bundle first']
if (id === undefined || id === '') return ['missing']
if (typeof id !== 'string') return ['not a string']
const found = []
if (exact.get(id) > 1) found.push('duplicate')
else if (fold.get(folded(id)) > 1) found.push('collides after re-casing')
if (frameworkDefault(op)) found.push('framework default')
if (!SNAKE.test(id)) found.push('not snake_case')
else if (!VERBS.includes(id.split('_')[0])) found.push('no verb first')
return found
}
Run it on the sample document, a small repository-hosting API with branches and tags:
node ids.mjs api.json
api.json: 6 operations, 6 with a problem
GET /repos/{repo}/branches listBranches duplicate, not snake_case
POST /repos/{repo}/branches create_branch_repos__repo__branches_post framework default, not snake_case
GET /repos/{repo}/branches/{branch} GetBranch not snake_case
PATCH /repos/{repo}/branches/{branch} (none) missing
DELETE /repos/{repo}/branches/{branch} delete-branch not snake_case
GET /repos/{repo}/tags listBranches duplicate, not snake_case
The POST id is FastAPI’s default. Its
generate_unique_id
joins the function name, the path with every non-word character replaced by an underscore, and the
method.2 The audit exits 1 here, so the same command can gate a pull request.
Rename with an overlay, then fix the case
A casing tool can turn GetBranch into get_branch, but it cannot know that the tags endpoint
means list_tags, because that is a change of words rather than of case. Put the word changes in an
OpenAPI Overlay, a reviewed file of targeted
edits, and leave case to a rule.
{
"target": "$.paths['/repos/{repo}/branches'].post",
"description": "FastAPI's default id, function name plus path plus method",
"update": { "operationId": "create_branch" }
},
openapi-format applies
the overlay first and the --casingFile rule after it, in one run. The casing file holds one rule,
"operationId": "snake_case".
npx openapi-format api.json --no-sort --overlayFile rename.overlay.json --casingFile casing.json -o api.clean.json && node ids.mjs api.clean.json
api.clean.json: 6 operations, 0 with a problem
GET /repos/{repo}/branches list_branches ok
POST /repos/{repo}/branches create_branch ok
GET /repos/{repo}/branches/{branch} get_branch ok
PATCH /repos/{repo}/branches/{branch} update_branch ok
DELETE /repos/{repo}/branches/{branch} delete_branch ok
GET /repos/{repo}/tags list_tags ok
Keep the overlay beside the code that emits the document. A framework writes its defaults again on every export, and the overlay re-applies to each one.
Generate twice and diff the method lists
The audit says the ids are clean. The generators decide what that is worth. methods.mjs runs
@hey-api/openapi-ts, which names
functions from operationId, and @voxgig/apidef, the model step inside sdkgen, which names them from
the path. Both run on the document before and after the rename.
node methods.mjs
operationId-driven, @hey-api/openapi-ts 0.99.0, api.json -> api.clean.json
GET /repos/{repo}/branches listBranches -> listBranches
POST /repos/{repo}/branches createBranchReposRepoBranchesPost -> createBranch
GET /repos/{repo}/branches/{branch} getBranch -> getBranch
PATCH /repos/{repo}/branches/{branch} patchReposByRepoBranchesByBranch -> updateBranch
DELETE /repos/{repo}/branches/{branch} deleteBranch -> deleteBranch
GET /repos/{repo}/tags listBranches2 -> listTags
3 of 6 method names changed
path-driven, @voxgig/apidef 8.25.2 (the sdkgen model step), api.json -> api.clean.json
GET /repos/{repo}/branches branch.list -> branch.list
POST /repos/{repo}/branches branch.create -> branch.create
GET /repos/{repo}/branches/{branch} branch.load -> branch.load
PATCH /repos/{repo}/branches/{branch} branch.update -> branch.update
DELETE /repos/{repo}/branches/{branch} branch.remove -> branch.remove
GET /repos/{repo}/tags tag.list -> tag.list
0 of 6 method names changed
The three names that changed are the three the overlay renamed. The casing pass changed no
TypeScript method, because the generator re-cases every id into camelCase itself. The missing id
became patchReposByRepoBranchesByBranch, a name the generator built from the method and path. The
copied id became listBranches2.
The path-driven column did not move. sdkgen read the same six operations as one branch entity
with five operations and one tag entity with one. Paths and methods were all it read. For it, the
rename was a no-op, and a path such as /repos/{repo}/branches is what produced branch.list.
Check it worked
Twelve tests run, covering the audit, the rename pass, and both generators.
node --test ids.test.mjs
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
One test regenerates api.clean.json from the committed overlay and casing file and compares it
with the committed copy, so the rename cannot drift from its record. Another asserts that the
casing pass alone leaves create_branch_repos_repo_branches_post, the duplicate, and the missing
id in place.
When it goes wrong
The specification accepts listBranches beside list_branches, because it treats the value as
case-sensitive. The generator re-cases both to listBranches and names the second listBranches2,
and by default it prints no warning. Its opt-in validator, parser.validate_EXPERIMENTAL, stops on
an exact duplicate but compares ids as written, so it passes this pair; a test pins all three. The
audit folds case before counting for this reason. OpenAPI Generator appends _0 to a duplicate
inside one tag and logs a warning, in its
DefaultCodegen.3
Speakeasy’s linter reports a collision as an error, under its
generator-duplicate-operation-name
rule.
The casing pass invents words. listOAuthApps converts to list_o_auth_apps, because camelCase
hides where an acronym ends; a test pins that too. Write the id in snake_case at the source, where
the word boundaries are explicit, rather than converting it afterwards.
A path with no literal segment, such as GET / or GET /{check}, takes its entity name from the
operationId in sdkgen. The model step in apidef 8.25.2 falls back to the id there, ahead of the
response schema’s title, and the sample’s root.json produces get_service_status.load. That fallback is the one
place the model step uses the id to name anything.
When not to do this
Do not rename the ids behind an SDK you have already published as a minor release. In OpenAPI
Generator and Speakeasy the method name is the id, so the rename is a breaking change. Add
x-speakeasy-name-override or OpenAPI Generator’s
--operation-id-name-mappings instead, and
rename on the next major version.
Do not fill missing ids from a template. The openapi-format generate option named the PATCH
patch_branches in the tests, a method and a plural that describe neither the intent nor the
single branch it changes.
Do not expect a clean operationId convention to fix sdkgen or Kiota output. Both name calls from the path, so a path whose segments name no resource produces a poor client whatever its id says. Fix the path, or accept that the convention serves the id-driven generators only.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, openapi-format 1.34.0, @hey-api/openapi-ts 0.99.0
and @voxgig/apidef 8.25.2. Every output block is what the command preceding it printed. OpenAPI
Generator, Speakeasy, and Kiota were not run; their behavior is taken from the source and
documentation linked on this page. The sdkgen names come from @voxgig/apidef run on its own, the
step of an sdkgen generate that classifies the paths.
Footnotes
-
The 3.0.3 text asks for an id that is unique among all operations and case-sensitive. Taken together, the two rules permit
listBranchesandlist_branchesin one document. It then recommends following common programming naming conventions, a phrase that names no convention, which leaves the choice to whichever generator reads the id next. ↩︎ Back to text -
FastAPI’s guide to generating clients shows the method its own default produces,
createItemItemsPost, and concedes that it does not read cleanly. It also says why the default looks that way: the specification requires unique ids, and a function name plus a path plus a method is unique by construction. Readability arrives further down that guide, as a customgenerate_unique_id_functionthat the reader writes. ↩︎ Back to text -
The loop is in
DefaultCodegen.addOperationToGroup. Its counter starts at zero, so a secondlistBranchesbecomeslistBranches_0and a third becomeslistBranches_1. It compares only the operations already filed under the same tag. Two identical ids under two different tags pass through unchanged, into two different API classes. ↩︎ Back to text