← back to Cli Printing Press
fix(cli): infer resource-prefixed IDField from item-schema properties (#938)
6cd57cc2bb3ec0b728cff7584aa98acfec24f783 · 2026-05-10 12:05:55 -0700 · Trevin Chow
* fix(cli): infer resource-prefixed IDField from item-schema properties
Adds a new tier between the bare-id and name fallbacks in
resolveIDFieldFromResponseSchema that matches `<singular_resource>_id`
(also `_uuid` and `_guid`) against item-schema properties. APIs whose
list responses key off `category_id`/`session_uuid` instead of a bare
`id` no longer leave Endpoint.IDField empty, so generated sync stops
silently dropping rows when no `x-resource-id` annotation is present.
Property names are compared after toSnakeCase normalization so camelCase
(`categoryId`) and kebab-cased path resources (`/auth-tokens`) hit the
same heuristic without spec-side changes. The bare `id` tier still wins
to honor the REST convention; explicit `x-resource-id` continues to
override every tier.
Refs #915
* fix(cli): hoist propNames sort and harden ie-stem singular table
Addresses Greptile review on #938:
1. Build and sort propNames once before the suffix loop in
resourcePrefixedIDField; the prior version rebuilt and re-sorted up
to three times per call.
2. Add `movies`, `series`, `matrices`, `indices`, `vertices` to the
irregulars table so resource paths whose stems end in `ie`/`ix`
singularize correctly. Without these, `/movies` would resolve to
`movy` and miss any `movie_id` property.
Adds a regression test covering the `/movies` → `movie_id` case.
Files touched
M internal/openapi/parser.goM internal/openapi/parser_test.go
Diff
commit 6cd57cc2bb3ec0b728cff7584aa98acfec24f783
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 12:05:55 2026 -0700
fix(cli): infer resource-prefixed IDField from item-schema properties (#938)
* fix(cli): infer resource-prefixed IDField from item-schema properties
Adds a new tier between the bare-id and name fallbacks in
resolveIDFieldFromResponseSchema that matches `<singular_resource>_id`
(also `_uuid` and `_guid`) against item-schema properties. APIs whose
list responses key off `category_id`/`session_uuid` instead of a bare
`id` no longer leave Endpoint.IDField empty, so generated sync stops
silently dropping rows when no `x-resource-id` annotation is present.
Property names are compared after toSnakeCase normalization so camelCase
(`categoryId`) and kebab-cased path resources (`/auth-tokens`) hit the
same heuristic without spec-side changes. The bare `id` tier still wins
to honor the REST convention; explicit `x-resource-id` continues to
override every tier.
Refs #915
* fix(cli): hoist propNames sort and harden ie-stem singular table
Addresses Greptile review on #938:
1. Build and sort propNames once before the suffix loop in
resourcePrefixedIDField; the prior version rebuilt and re-sorted up
to three times per call.
2. Add `movies`, `series`, `matrices`, `indices`, `vertices` to the
irregulars table so resource paths whose stems end in `ie`/`ix`
singularize correctly. Without these, `/movies` would resolve to
`movy` and miss any `movie_id` property.
Adds a regression test covering the `/movies` → `movie_id` case.
---
internal/openapi/parser.go | 99 +++++++++++++++++++++++--
internal/openapi/parser_test.go | 160 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 253 insertions(+), 6 deletions(-)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 87759170..1f5151dc 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1699,7 +1699,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
if pathResourceIDOverride != "" {
endpoint.IDField = pathResourceIDOverride
} else {
- endpoint.IDField = resolveIDFieldFromResponseSchema(op)
+ endpoint.IDField = resolveIDFieldFromResponseSchema(op, targetResourceName)
}
endpoint.Critical = pathCritical
@@ -2787,13 +2787,18 @@ func readTierExtension(extensions map[string]any, context string) string {
return strings.TrimSpace(tier)
}
-// resolveIDFieldFromResponseSchema implements tiers 2-4 of the IDField fallback
-// chain: prefer "id", then "name", then the first scalar field listed in the
+// 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
// 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
// caller — it overrides every tier here.
-func resolveIDFieldFromResponseSchema(op *openapi3.Operation) string {
+//
+// resourceName is the parser's targetResourceName for this operation; it drives
+// the resource-prefixed heuristic and may be empty for synthetic endpoints,
+// in which case that tier is skipped.
+func resolveIDFieldFromResponseSchema(op *openapi3.Operation, resourceName string) string {
if op == nil || op.Responses == nil {
return ""
}
@@ -2816,12 +2821,22 @@ func resolveIDFieldFromResponseSchema(op *openapi3.Operation) string {
if _, ok := itemSchema.Properties["id"]; ok {
return "id"
}
- // Tier 3: explicit `name`
+
+ // Tier 3: resource-prefixed key. Catches APIs whose item schemas key off
+ // `<singular>_id` instead of a bare `id` (e.g. podscan's Category with
+ // `category_id`/`category_name`). Both the resource name and each property
+ // name are normalized to snake_case, so `categoryId`/`category_id` and
+ // `auth-tokens`/`auth_token_id` match through the same comparison.
+ if id := resourcePrefixedIDField(itemSchema, resourceName); id != "" {
+ return id
+ }
+
+ // Tier 4: explicit `name`
if _, ok := itemSchema.Properties["name"]; ok {
return "name"
}
- // Tier 4: first scalar field appearing in the schema's required[] array,
+ // Tier 5: first scalar field appearing in the schema's required[] array,
// matched against properties in their schema-declared order. kin-openapi
// preserves YAML/JSON property order in MapKeys/Extensions but not via
// range over Properties (it's a Go map). Fall back to iterating the
@@ -2840,6 +2855,78 @@ func resolveIDFieldFromResponseSchema(op *openapi3.Operation) string {
return ""
}
+// resourcePrefixedIDField returns the first property whose snake-cased name
+// matches `<singular_resource>_id`, then `_uuid`, then `_guid`. Returns "" when
+// the resource name is empty or no property matches. Property names are
+// returned verbatim so callers preserve the spec's original casing (e.g.
+// `categoryId` rather than `category_id`).
+func resourcePrefixedIDField(schema *openapi3.Schema, resourceName string) string {
+ singular := singularizeIdentifier(toSnakeCase(resourceName))
+ if singular == "" {
+ return ""
+ }
+ // Sort property names so behavior is deterministic across Go map
+ // iteration order; this only matters when a schema declares multiple
+ // keys that snake-case to the same target, which is unusual.
+ propNames := make([]string, 0, len(schema.Properties))
+ for name := range schema.Properties {
+ propNames = append(propNames, name)
+ }
+ sort.Strings(propNames)
+ for _, suffix := range []string{"_id", "_uuid", "_guid"} {
+ target := singular + suffix
+ for _, propName := range propNames {
+ if toSnakeCase(propName) == target {
+ return propName
+ }
+ }
+ }
+ return ""
+}
+
+// singularizeIdentifier returns a simple singular form of a snake-cased
+// identifier. Mirrors spec.singularize for parser-local use without exporting
+// the spec helper. Only the trailing token is singularized so multi-word
+// identifiers like `auth_tokens` become `auth_token`.
+func singularizeIdentifier(s string) string {
+ if s == "" {
+ return ""
+ }
+ idx := strings.LastIndex(s, "_")
+ prefix, last := "", s
+ if idx >= 0 {
+ prefix, last = s[:idx+1], s[idx+1:]
+ }
+ irregulars := map[string]string{
+ "properties": "property",
+ "companies": "company",
+ "categories": "category",
+ "entries": "entry",
+ "statuses": "status",
+ "addresses": "address",
+ "analyses": "analysis",
+ // `<stem>+s` plurals on `ie`/`ix`-ending stems that the generic
+ // `ies → y` rule would otherwise mangle (`movies → movy`).
+ "movies": "movie",
+ "series": "series",
+ "matrices": "matrix",
+ "indices": "index",
+ "vertices": "vertex",
+ }
+ if singular, ok := irregulars[last]; ok {
+ return prefix + singular
+ }
+ switch {
+ case strings.HasSuffix(last, "ies") && len(last) > 3:
+ return prefix + last[:len(last)-3] + "y"
+ case strings.HasSuffix(last, "ses"), strings.HasSuffix(last, "xes"), strings.HasSuffix(last, "zes"):
+ return prefix + last[:len(last)-2]
+ case strings.HasSuffix(last, "s") && !strings.HasSuffix(last, "ss") && len(last) > 1:
+ return prefix + last[:len(last)-1]
+ }
+ return prefix + last
+}
+
// unwrapItemSchema returns the schema of items inside a list response. Handles
// four shapes: bare array (return Items.Value), {data: [...]} wrapper (fast
// path; mirrors mapResponse's handling), single-named-array envelope where the
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 1fe4eaba..e99256e6 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -3661,6 +3661,166 @@ paths:
}
}
+// TestParseIDFieldResourcePrefixedHeuristic covers list responses whose item
+// schemas key off `<singular_resource>_id` (or `_uuid`/`_guid`) instead of a
+// bare `id`. Without this heuristic, APIs like podscan whose Category items
+// only carry `category_id` would fall through every fallback tier and leave
+// IDField empty, causing sync to silently drop every row.
+func TestParseIDFieldResourcePrefixedHeuristic(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ path string
+ schemaYAML string
+ wantID string
+ }{
+ {
+ name: "plural resource picks <singular>_id",
+ path: "/categories",
+ schemaYAML: ` type: object
+ properties:
+ category_id: {type: string}
+ category_name: {type: string}
+ category_display_name: {type: string}
+`,
+ wantID: "category_id",
+ },
+ {
+ name: "singular resource picks <name>_id",
+ path: "/user",
+ schemaYAML: ` type: object
+ properties:
+ user_id: {type: string}
+ user_name: {type: string}
+`,
+ wantID: "user_id",
+ },
+ {
+ name: "id wins over <singular>_id (REST convention)",
+ path: "/categories",
+ schemaYAML: ` type: object
+ properties:
+ id: {type: string}
+ category_id: {type: string}
+`,
+ wantID: "id",
+ },
+ {
+ name: "<singular>_id wins over name",
+ path: "/categories",
+ schemaYAML: ` type: object
+ properties:
+ name: {type: string}
+ category_id: {type: string}
+`,
+ wantID: "category_id",
+ },
+ {
+ name: "_uuid suffix is recognized when _id is absent",
+ path: "/sessions",
+ schemaYAML: ` type: object
+ properties:
+ session_uuid: {type: string}
+ started_at: {type: string}
+`,
+ wantID: "session_uuid",
+ },
+ {
+ name: "_guid suffix is recognized when _id and _uuid are absent",
+ path: "/devices",
+ schemaYAML: ` type: object
+ properties:
+ device_guid: {type: string}
+ last_seen: {type: string}
+`,
+ wantID: "device_guid",
+ },
+ {
+ name: "camelCase property name normalizes to snake match",
+ path: "/categories",
+ schemaYAML: ` type: object
+ properties:
+ categoryId: {type: string}
+ categoryName: {type: string}
+`,
+ wantID: "categoryId",
+ },
+ {
+ name: "kebab-case path resource singularizes correctly",
+ path: "/auth-tokens",
+ schemaYAML: ` type: object
+ properties:
+ auth_token_id: {type: string}
+ issued_at: {type: string}
+`,
+ wantID: "auth_token_id",
+ },
+ {
+ name: "_id precedence: prefers _id over _uuid",
+ path: "/categories",
+ schemaYAML: ` type: object
+ properties:
+ category_id: {type: string}
+ category_uuid: {type: string}
+`,
+ wantID: "category_id",
+ },
+ {
+ name: "no <singular>_id falls through to remaining tiers",
+ path: "/things",
+ schemaYAML: ` type: object
+ properties:
+ name: {type: string}
+ other_id: {type: string}
+`,
+ wantID: "name",
+ },
+ {
+ // Without the irregulars override, `movies` would singularize
+ // via the `ies → y` rule to `movy`, missing `movie_id`.
+ name: "ie-ending stem keeps singular form (movies → movie)",
+ path: "/movies",
+ schemaYAML: ` type: object
+ properties:
+ movie_id: {type: string}
+ title: {type: string}
+`,
+ wantID: "movie_id",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ yamlSpec := []byte(`openapi: "3.0.3"
+info:
+ title: Test
+ version: "1.0"
+servers:
+ - url: https://api.example.com
+paths:
+ ` + tt.path + `:
+ get:
+ operationId: list
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+` + tt.schemaYAML)
+ parsed, err := Parse(yamlSpec)
+ require.NoError(t, err)
+
+ ep := findEndpoint(t, parsed, tt.path)
+ assert.Equal(t, tt.wantID, ep.IDField)
+ })
+ }
+}
+
// TestParseXResourceIDAppliesToEveryOperationOnPath exercises the "extensions
// live on the path item" rule — both GET and POST operations under /widgets
// inherit the x-resource-id and x-critical values, even though x-critical is
← d7be66f0 docs(cli): require claim and unclaim on issue ownership (#93
·
back to Cli Printing Press
·
fix(cli): emit form-encoded request bodies (#947) 48cc2a31 →