← back to Cli Printing Press
fix(cli): pick gid/sid/uid before name in PK heuristic (#1465)
01c816daf99dacab6a69333c263a003ccf49c956 · 2026-05-15 13:01:56 -0700 · Trevin Chow
* fix(cli): pick gid/sid/uid before name in resolveIDFieldFromResponseSchema
Asana and Twilio key every resource on a vendor-specific identifier
(gid for Asana, sid for Twilio). The PK heuristic in
resolveIDFieldFromResponseSchema previously checked for id (tier 2),
then resource-prefixed keys (tier 3), then `name` (tier 4) — so APIs
without bare `id` got `name` written into resourceIDFieldOverrides.
Generated CLIs then upserted on display names and downstream paths
like /workspaces/<workspace>/users got a name where the API expected
a gid, causing HTTP 400.
Insert a new tier 3.5 between the resource-prefixed lookup and the
`name` fallback that scans for gid, sid, uid, uuid, guid. Also reorder
the runtime genericIDFieldFallbacks list (in sync.go.tmpl, store.go.tmpl,
and store_upsert_batch_test.go.tmpl) so the vendor names take
precedence over `name` for resources that never received a templated
override.
Golden fixtures updated for the three sites that emit the fallback
list (generate-golden-api, generate-sync-walker-api,
generate-tier-routing-api).
Closes #1394
* docs(cli): update PK fallback doc comment and add guid test case
Greptile noted two small gaps in the previous commit:
- resolveIDFieldFromResponseSchema's function-level doc comment still
described the pre-3.5 chain without mentioning the new vendor-identifier
tier. Update to list gid/sid/uid/uuid/guid alongside the existing tiers.
- The table-driven test had no dedicated `guid` case, leaving the fifth
entry of the tier 3.5 loop without coverage. Add it.
Refs #1394
Files touched
M internal/generator/generator_test.goM internal/generator/templates/store.go.tmplM internal/generator/templates/store_upsert_batch_test.go.tmplM internal/generator/templates/sync.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goM testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.goM testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
Diff
commit 01c816daf99dacab6a69333c263a003ccf49c956
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 15 13:01:56 2026 -0700
fix(cli): pick gid/sid/uid before name in PK heuristic (#1465)
* fix(cli): pick gid/sid/uid before name in resolveIDFieldFromResponseSchema
Asana and Twilio key every resource on a vendor-specific identifier
(gid for Asana, sid for Twilio). The PK heuristic in
resolveIDFieldFromResponseSchema previously checked for id (tier 2),
then resource-prefixed keys (tier 3), then `name` (tier 4) — so APIs
without bare `id` got `name` written into resourceIDFieldOverrides.
Generated CLIs then upserted on display names and downstream paths
like /workspaces/<workspace>/users got a name where the API expected
a gid, causing HTTP 400.
Insert a new tier 3.5 between the resource-prefixed lookup and the
`name` fallback that scans for gid, sid, uid, uuid, guid. Also reorder
the runtime genericIDFieldFallbacks list (in sync.go.tmpl, store.go.tmpl,
and store_upsert_batch_test.go.tmpl) so the vendor names take
precedence over `name` for resources that never received a templated
override.
Golden fixtures updated for the three sites that emit the fallback
list (generate-golden-api, generate-sync-walker-api,
generate-tier-routing-api).
Closes #1394
* docs(cli): update PK fallback doc comment and add guid test case
Greptile noted two small gaps in the previous commit:
- resolveIDFieldFromResponseSchema's function-level doc comment still
described the pre-3.5 chain without mentioning the new vendor-identifier
tier. Update to list gid/sid/uid/uuid/guid alongside the existing tiers.
- The table-driven test had no dedicated `guid` case, leaving the fifth
entry of the tier 3.5 loop without coverage. Add it.
Refs #1394
---
internal/generator/generator_test.go | 6 ++-
internal/generator/templates/store.go.tmpl | 7 ++-
.../templates/store_upsert_batch_test.go.tmpl | 2 +-
internal/generator/templates/sync.go.tmpl | 7 ++-
internal/openapi/parser.go | 15 +++++-
internal/openapi/parser_test.go | 57 ++++++++++++++++++++++
.../printing-press-golden/internal/cli/sync.go | 7 ++-
.../sync-walker-golden/internal/cli/sync.go | 7 ++-
.../sync-walker-golden/internal/store/store.go | 7 ++-
.../tier-routing-golden/internal/cli/sync.go | 7 ++-
10 files changed, 106 insertions(+), 16 deletions(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index c5c933eb..44a82104 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -8115,9 +8115,11 @@ func TestGeneratedSyncIDFieldOverridesAndProbes(t *testing.T) {
// (b) Generic fallback list reduced — kalshi-specific names dropped.
// The user owns the kalshi CLI and will regenerate with x-resource-id
// annotations; no other public-library CLIs depend on these names.
+ // Vendor identifiers (gid, sid, uid, uuid, guid) precede `name` so
+ // APIs like Asana don't fall through to a display field — see #1394.
assert.Contains(t, storeContent,
- `var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}`,
- "store.go genericIDFieldFallbacks must be the reduced WU-2 U3 list")
+ `var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}`,
+ "store.go genericIDFieldFallbacks must include vendor identifiers before name")
// Negative: kalshi-specific names must not be in the fallback list.
// We assert a robust shape: no occurrence of "ticker" inside the fallback
// declaration. The generic check below also pins the absence at a
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index d2dde88a..08fd8954 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -884,8 +884,11 @@ var resourceIDFieldOverrides = map[string]string{
// genericIDFieldFallbacks is the runtime safety net for resources that did
// NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
// UpsertBatch inserts or replaces multiple records in a single transaction
// and returns (stored, extractFailures, err). stored counts rows landed in
diff --git a/internal/generator/templates/store_upsert_batch_test.go.tmpl b/internal/generator/templates/store_upsert_batch_test.go.tmpl
index a0c7dae8..9ce60459 100644
--- a/internal/generator/templates/store_upsert_batch_test.go.tmpl
+++ b/internal/generator/templates/store_upsert_batch_test.go.tmpl
@@ -184,7 +184,7 @@ func TestUpsertBatch_GenericFallbackList(t *testing.T) {
}
defer s.Close()
- for _, key := range []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"} {
+ for _, key := range []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"} {
t.Run(key, func(t *testing.T) {
rt := "fallback_" + key
items := []json.RawMessage{
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 8848f4b9..1ca1c054 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -1428,8 +1428,11 @@ var resourceIDFieldOverrides = map[string]string{
// genericIDFieldFallbacks is the runtime safety net for resources that did
// NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
// pageItemKeys is scanned in priority order; lowercase REST-convention keys
// come first, PascalCase .NET variants second. Without the PascalCase row,
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index bd6a7ad0..1f9a084e 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -3320,7 +3320,8 @@ func readWalkerExtension(extensions map[string]any, context string) *spec.Walker
// resolveIDFieldFromResponseSchema implements tiers 2-5 of the IDField fallback
// chain: prefer "id", then a resource-prefixed key (`<singular>_id` /
-// `_uuid` / `_guid`), then "name", then the first scalar field listed in the
+// `_uuid` / `_guid`), then a vendor identifier (`gid` / `sid` / `uid` /
+// `uuid` / `guid`), then "name", then the first scalar field listed in the
// response schema's `required:` array (walking properties in their schema order).
// Returns "" when no field qualifies; templates fall through to runtime list
// scanning. Tier 1 (`x-resource-id` extension) is handled separately by the
@@ -3362,6 +3363,18 @@ func resolveIDFieldFromResponseSchema(op *openapi3.Operation, resourceName strin
return id
}
+ // Tier 3.5: vendor-specific identifier names. Asana keys every resource
+ // on `gid`, Twilio on `sid`, others on `uid`/`uuid`/`guid`. These are
+ // scalar primary keys by convention; without this tier the heuristic
+ // falls through to Tier 4 and picks `name` (a display field), so the
+ // generated CLI upserts on names and sync paths like
+ // `/workspaces/<workspace>/users` get a name where the API expects a gid.
+ for _, key := range []string{"gid", "sid", "uid", "uuid", "guid"} {
+ if _, ok := itemSchema.Properties[key]; ok {
+ return key
+ }
+ }
+
// Tier 4: explicit `name`
if _, ok := itemSchema.Properties["name"]; ok {
return "name"
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 082d4200..2feabd17 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -4298,6 +4298,63 @@ func TestParseIDFieldFallbackChain(t *testing.T) {
properties:
id: {type: string}
label: {type: string}
+`,
+ wantID: "id",
+ },
+ {
+ name: "tier 3.5: gid wins over name (Asana shape)",
+ schemaYAML: ` type: object
+ properties:
+ gid: {type: string}
+ name: {type: string}
+ resource_type: {type: string}
+`,
+ wantID: "gid",
+ },
+ {
+ name: "tier 3.5: sid wins over name (Twilio shape)",
+ schemaYAML: ` type: object
+ properties:
+ sid: {type: string}
+ name: {type: string}
+ friendly_name: {type: string}
+`,
+ wantID: "sid",
+ },
+ {
+ name: "tier 3.5: uid wins over name",
+ schemaYAML: ` type: object
+ properties:
+ uid: {type: string}
+ name: {type: string}
+`,
+ wantID: "uid",
+ },
+ {
+ name: "tier 3.5: uuid wins over name",
+ schemaYAML: ` type: object
+ properties:
+ uuid: {type: string}
+ name: {type: string}
+`,
+ wantID: "uuid",
+ },
+ {
+ name: "tier 3.5: guid wins over name",
+ schemaYAML: ` type: object
+ properties:
+ guid: {type: string}
+ name: {type: string}
+`,
+ wantID: "guid",
+ },
+ {
+ name: "tier 3.5: id wins over gid (tier 2 takes precedence)",
+ schemaYAML: ` type: object
+ properties:
+ id: {type: string}
+ gid: {type: string}
+ name: {type: string}
`,
wantID: "id",
},
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
index ce6ad48a..486fe79e 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -1286,8 +1286,11 @@ var resourceIDFieldOverrides = map[string]string{
// genericIDFieldFallbacks is the runtime safety net for resources that did
// NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
// pageItemKeys is scanned in priority order; lowercase REST-convention keys
// come first, PascalCase .NET variants second. Without the PascalCase row,
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
index 0d874121..84011d0b 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
@@ -1277,8 +1277,11 @@ var resourceIDFieldOverrides = map[string]string{
// genericIDFieldFallbacks is the runtime safety net for resources that did
// NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
// pageItemKeys is scanned in priority order; lowercase REST-convention keys
// come first, PascalCase .NET variants second. Without the PascalCase row,
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
index 69d7bf22..d7c92347 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
@@ -809,8 +809,11 @@ var resourceIDFieldOverrides = map[string]string{
// genericIDFieldFallbacks is the runtime safety net for resources that did
// NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
// UpsertBatch inserts or replaces multiple records in a single transaction
// and returns (stored, extractFailures, err). stored counts rows landed in
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index 0c907a09..cfa2ca75 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -995,8 +995,11 @@ var resourceIDFieldOverrides = map[string]string{}
// genericIDFieldFallbacks is the runtime safety net for resources that did
// NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
// pageItemKeys is scanned in priority order; lowercase REST-convention keys
// come first, PascalCase .NET variants second. Without the PascalCase row,
← 72adfefd fix(cli): drop unused client import from param-less endpoint
·
back to Cli Printing Press
·
fix(cli): preserve OpenAPI param casing in detected paginati 272d8e83 →