[object Object]

← 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

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 →