← back to Cli Printing Press
fix(cli): route discriminator sync to typed tables (#503)
97180c037ba976b528ebc9e1d9ff7e58ca8b630f · 2026-05-02 10:09:03 -0700 · Trevin Chow
Files touched
M internal/generator/generator_test.goM internal/generator/templates/sync.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/profiler/profiler.goM internal/profiler/profiler_test.goM internal/spec/spec.go
Diff
commit 97180c037ba976b528ebc9e1d9ff7e58ca8b630f
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat May 2 10:09:03 2026 -0700
fix(cli): route discriminator sync to typed tables (#503)
---
internal/generator/generator_test.go | 131 ++++++++++++++++++++-
internal/generator/templates/sync.go.tmpl | 84 +++++++++++++-
internal/openapi/parser.go | 57 +++++++--
internal/openapi/parser_test.go | 70 +++++++++++
internal/profiler/profiler.go | 187 +++++++++++++++++++++++++-----
internal/profiler/profiler_test.go | 46 ++++++++
internal/spec/spec.go | 15 ++-
7 files changed, 542 insertions(+), 48 deletions(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 1f95a704..22528382 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2,6 +2,7 @@ package generator
import (
"encoding/json"
+ "fmt"
"go/parser"
"go/token"
"net/http"
@@ -1709,6 +1710,128 @@ func TestGenerateStoreUpsertBatchDispatchesToTypedTable(t *testing.T) {
runGoCommand(t, outputDir, "test", "./internal/store")
}
+func TestSyncDiscriminatorDispatchRoutesMixedItemsToTypedTables(t *testing.T) {
+ t.Parallel()
+
+ typedListEndpoint := func(path string) spec.Endpoint {
+ return spec.Endpoint{
+ Method: "GET",
+ Path: path,
+ Description: "List typed entities",
+ Response: spec.ResponseDef{Type: "array"},
+ Params: []spec.Param{
+ {Name: "id", Type: "string"},
+ {Name: "type", Type: "string"},
+ {Name: "name", Type: "string"},
+ {Name: "created_at", Type: "string", Format: "date-time"},
+ },
+ }
+ }
+
+ apiSpec := &spec.APISpec{
+ Name: "mixednet",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/mixednet-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "network_entities": {
+ Description: "Mixed network entities",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/network-entities",
+ Description: "List mixed network entities",
+ Response: spec.ResponseDef{Type: "array", Item: "NetworkEntity"},
+ Params: []spec.Param{
+ {Name: "limit", Type: "integer"},
+ {Name: "cursor", Type: "string"},
+ },
+ Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+ },
+ },
+ },
+ "workspaces": {
+ Description: "Workspaces",
+ Endpoints: map[string]spec.Endpoint{"list": typedListEndpoint("/workspaces")},
+ },
+ "collections": {
+ Description: "Collections",
+ Endpoints: map[string]spec.Endpoint{"list": typedListEndpoint("/collections")},
+ },
+ "teams": {
+ Description: "Teams",
+ Endpoints: map[string]spec.Endpoint{"list": typedListEndpoint("/teams")},
+ },
+ },
+ Types: map[string]spec.TypeDef{
+ "NetworkEntity": {
+ Fields: []spec.TypeField{
+ {Name: "type", Type: "string", Enum: []string{"workspace", "collection", "team"}},
+ {Name: "id", Type: "string"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ gen.VisionSet = VisionTemplateSet{Store: true, Sync: true}
+ require.NoError(t, gen.Generate())
+
+ syncSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ src := string(syncSrc)
+ assert.Contains(t, src, `"workspace": "workspaces"`)
+ assert.Contains(t, src, `upsertResourceBatch(db, resource, items)`)
+
+ inlineTest := fmt.Sprintf(`package cli
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "testing"
+
+ "%s/internal/store"
+)
+
+func TestUpsertResourceBatchRoutesDiscriminatorItems(t *testing.T) {
+ s, err := store.Open(filepath.Join(t.TempDir(), "data.db"))
+ if err != nil {
+ t.Fatalf("open: %%v", err)
+ }
+ defer s.Close()
+
+ items := []json.RawMessage{
+ json.RawMessage(`+"`"+`{"type":"workspace","id":"w1","name":"Workspace","created_at":"2026-01-01T00:00:00Z"}`+"`"+`),
+ json.RawMessage(`+"`"+`{"type":"collection","id":"c1","name":"Collection","created_at":"2026-01-01T00:00:00Z"}`+"`"+`),
+ json.RawMessage(`+"`"+`{"type":"team","id":"t1","name":"Team","created_at":"2026-01-01T00:00:00Z"}`+"`"+`),
+ }
+ stored, extractFailures, err := upsertResourceBatch(s, "network_entities", items)
+ if err != nil {
+ t.Fatalf("upsertResourceBatch: %%v", err)
+ }
+ if stored != len(items) || extractFailures != 0 {
+ t.Fatalf("stored/extractFailures = %%d/%%d, want %%d/0", stored, extractFailures, len(items))
+ }
+
+ for _, table := range []string{"workspaces", "collections", "teams"} {
+ var count int
+ if err := s.DB().QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil {
+ t.Fatalf("count %%s: %%v", table, err)
+ }
+ if count != 1 {
+ t.Fatalf("%%s count = %%d, want 1", table, count)
+ }
+ }
+}
+`, naming.CLI(apiSpec.Name))
+ require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "cli", "sync_discriminator_test.go"), []byte(inlineTest), 0o644))
+
+ runGoCommandRequired(t, outputDir, "mod", "tidy")
+ runGoCommandRequired(t, outputDir, "test", "./internal/cli", "./internal/store")
+}
+
func TestGenerateStoreBackfillsIndexedColumnsOnUpgrade(t *testing.T) {
t.Parallel()
@@ -4475,11 +4598,11 @@ func TestGeneratedSyncIDFieldOverridesAndProbes(t *testing.T) {
// all three return values. Without this contract, extractFailures would
// not be observable from sync's per-item warning code.
assert.Contains(t, syncContent,
- `stored, extractFailures, err := db.UpsertBatch(resource, items)`,
- "sync.go syncResource must consume the three-tuple UpsertBatch return")
+ `stored, extractFailures, err := upsertResourceBatch(db, resource, items)`,
+ "sync.go syncResource must consume the three-tuple batch upsert return")
assert.Contains(t, syncContent,
- `stored, extractFailures, err := db.UpsertBatch(dep.Name, items)`,
- "sync.go syncDependentResource must consume the three-tuple UpsertBatch return")
+ `stored, extractFailures, err := upsertResourceBatch(db, dep.Name, items)`,
+ "sync.go syncDependentResource must consume the three-tuple batch upsert return")
// store.go's UpsertBatch declaration matches the new signature.
assert.Contains(t, storeContent,
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index baeac779..e629701e 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -411,7 +411,7 @@ func syncResource(c interface {
// "primary_key_unresolved" the first time any single item
// fails, and the F4b "stored_count_zero_after_extraction"
// probe when extraction succeeded but rows still didn't land.
- stored, extractFailures, err := db.UpsertBatch(resource, items)
+ stored, extractFailures, err := upsertResourceBatch(db, resource, items)
if err != nil {
if !humanFriendly {
fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
@@ -685,6 +685,84 @@ func findCursorInMap(m map[string]json.RawMessage, cursorKeys []string) string {
return ""
}
+type discriminatorDispatch struct {
+ Field string
+ Values map[string]string
+}
+
+var discriminatorDispatchers = map[string]discriminatorDispatch{
+{{- range .SyncableResources}}
+{{- if .Discriminator.Field}}
+ {{printf "%q" .Name}}: {
+ Field: {{printf "%q" .Discriminator.Field}},
+ Values: map[string]string{
+{{- range .Discriminator.Mappings}}
+ {{printf "%q" .Value}}: {{printf "%q" .Resource}},
+{{- end}}
+ },
+ },
+{{- end}}
+{{- end}}
+{{- range .DependentSyncResources}}
+{{- if .Discriminator.Field}}
+ {{printf "%q" .Name}}: {
+ Field: {{printf "%q" .Discriminator.Field}},
+ Values: map[string]string{
+{{- range .Discriminator.Mappings}}
+ {{printf "%q" .Value}}: {{printf "%q" .Resource}},
+{{- end}}
+ },
+ },
+{{- end}}
+{{- end}}
+}
+
+func upsertResourceBatch(db *store.Store, resource string, items []json.RawMessage) (int, int, error) {
+ if _, ok := discriminatorDispatchers[resource]; !ok {
+ return db.UpsertBatch(resource, items)
+ }
+
+ grouped := map[string][]json.RawMessage{}
+ order := []string{}
+ for _, item := range items {
+ target := resource
+ var obj map[string]any
+ if err := json.Unmarshal(item, &obj); err == nil {
+ target = resolveDiscriminatedResource(resource, obj)
+ }
+ if _, ok := grouped[target]; !ok {
+ order = append(order, target)
+ }
+ grouped[target] = append(grouped[target], item)
+ }
+
+ var stored, extractFailures int
+ for _, target := range order {
+ targetStored, targetExtractFailures, err := db.UpsertBatch(target, grouped[target])
+ if err != nil {
+ return stored, extractFailures + targetExtractFailures, err
+ }
+ stored += targetStored
+ extractFailures += targetExtractFailures
+ }
+ return stored, extractFailures, nil
+}
+
+func resolveDiscriminatedResource(resource string, obj map[string]any) string {
+ dispatcher, ok := discriminatorDispatchers[resource]
+ if !ok || dispatcher.Field == "" {
+ return resource
+ }
+ value := store.LookupFieldValue(obj, dispatcher.Field)
+ if value == nil {
+ return resource
+ }
+ if target, ok := dispatcher.Values[fmt.Sprintf("%v", value)]; ok && target != "" {
+ return target
+ }
+ return resource
+}
+
// upsertSingleObject stores a non-array API response as a single record.
func upsertSingleObject(db *store.Store, resource string, data json.RawMessage) error {
var obj map[string]any
@@ -693,6 +771,8 @@ func upsertSingleObject(db *store.Store, resource string, data json.RawMessage)
return db.Upsert(resource, resource, data)
}
+ resource = resolveDiscriminatedResource(resource, obj)
+
id := extractID(resource, obj)
if id == "" {
id = resource
@@ -893,7 +973,7 @@ func syncDependentResource(c interface {
}
}
- stored, extractFailures, err := db.UpsertBatch(dep.Name, items)
+ stored, extractFailures, err := upsertResourceBatch(db, dep.Name, items)
if err != nil {
if humanFriendly {
fmt.Fprintf(os.Stderr, "\n %s: upsert error for parent %s: %v\n", dep.Name, parentID, err)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 79b99196..e22a871e 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1820,7 +1820,7 @@ func collectAllOfProperties(
if prop == nil {
continue
}
- properties[name] = prop
+ properties[naming.ASCIIFold(name)] = prop
}
for _, sub := range schema.AllOf {
if collectAllOfProperties(sub, properties, required, visited) {
@@ -1849,24 +1849,28 @@ func mapResponse(op *openapi3.Operation, fallbackName string) (spec.ResponseDef,
schema := schemaRef.Value
if isObjectSchema(schema) {
if dataRef := schema.Properties["data"]; dataRef != nil && isArraySchema(schemaRefValue(dataRef)) {
+ itemRef := schemaRefValue(dataRef).Items
return spec.ResponseDef{
- Type: "array",
- Item: schemaTypeName(schemaRefValue(dataRef).Items, fallbackName+"Item"),
+ Type: "array",
+ Item: schemaTypeName(itemRef, fallbackName+"Item"),
+ Discriminator: mapResponseDiscriminator(itemRef),
}, "data"
}
}
if isArraySchema(schema) {
return spec.ResponseDef{
- Type: "array",
- Item: schemaTypeName(schema.Items, fallbackName+"Item"),
+ Type: "array",
+ Item: schemaTypeName(schema.Items, fallbackName+"Item"),
+ Discriminator: mapResponseDiscriminator(schema.Items),
}, ""
}
if isObjectSchema(schema) {
return spec.ResponseDef{
- Type: "object",
- Item: schemaTypeName(schemaRef, fallbackName+"Response"),
+ Type: "object",
+ Item: schemaTypeName(schemaRef, fallbackName+"Response"),
+ Discriminator: mapResponseDiscriminator(schemaRef),
}, ""
}
@@ -2130,9 +2134,11 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
continue
}
seenGoNames[goFieldName] = true
+ fieldSchema := schemaRefValue(properties[fieldName])
fields = append(fields, spec.TypeField{
Name: fieldName,
- Type: mapSchemaType(schemaRefValue(properties[fieldName])),
+ Type: mapSchemaType(fieldSchema),
+ Enum: schemaEnum(fieldSchema),
})
}
@@ -2140,6 +2146,39 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
}
}
+func mapResponseDiscriminator(schemaRef *openapi3.SchemaRef) *spec.ResponseDiscriminator {
+ schema := schemaRefValue(schemaRef)
+ if schema == nil || schema.Discriminator == nil || strings.TrimSpace(schema.Discriminator.PropertyName) == "" {
+ return nil
+ }
+
+ out := &spec.ResponseDiscriminator{
+ Field: strings.TrimSpace(schema.Discriminator.PropertyName),
+ Mapping: map[string]string{},
+ }
+ if len(schema.Discriminator.Mapping) == 0 {
+ return out
+ }
+
+ values := make([]string, 0, len(schema.Discriminator.Mapping))
+ for value := range schema.Discriminator.Mapping {
+ values = append(values, value)
+ }
+ sort.Strings(values)
+ for _, value := range values {
+ ref := schema.Discriminator.Mapping[value].Ref
+ target := refComponentName(ref)
+ if target == "" {
+ target = ref
+ }
+ if target == "" {
+ target = value
+ }
+ out.Mapping[value] = toTypeName(target)
+ }
+ return out
+}
+
func collectTypeProperties(schemaRef *openapi3.SchemaRef, properties map[string]*openapi3.SchemaRef, visited map[*openapi3.Schema]struct{}) {
if schemaRef == nil || schemaRef.Value == nil {
return
@@ -2158,7 +2197,7 @@ func collectTypeProperties(schemaRef *openapi3.SchemaRef, properties map[string]
if strings.HasPrefix(name, "_") {
continue
}
- properties[sanitizeTypeName(name)] = prop
+ properties[naming.ASCIIFold(name)] = prop
}
for _, sub := range schema.AllOf {
collectTypeProperties(sub, properties, visited)
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 1a48c376..038604c9 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -50,6 +50,76 @@ func TestParsePetstore(t *testing.T) {
assert.Contains(t, parsed.Types, "Pet")
}
+func TestParsePreservesResponseDiscriminatorAndEnumFields(t *testing.T) {
+ t.Parallel()
+
+ parsed, err := Parse([]byte(`
+openapi: 3.0.3
+info:
+ title: Mixed Network
+ version: 1.0.0
+paths:
+ /network-entities:
+ get:
+ operationId: listNetworkEntities
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: "#/components/schemas/NetworkEntity"
+components:
+ schemas:
+ NetworkEntity:
+ type: object
+ discriminator:
+ propertyName: type
+ mapping:
+ workspace: "#/components/schemas/Workspace"
+ collection: "#/components/schemas/Collection"
+ properties:
+ type:
+ type: string
+ enum: [workspace, collection]
+ id:
+ type: string
+ Workspace:
+ type: object
+ properties:
+ id:
+ type: string
+ Collection:
+ type: object
+ properties:
+ id:
+ type: string
+`))
+ require.NoError(t, err)
+
+ endpoint := parsed.Resources["network-entities"].Endpoints["list"]
+ require.NotNil(t, endpoint.Response.Discriminator)
+ assert.Equal(t, "type", endpoint.Response.Discriminator.Field)
+ assert.Equal(t, map[string]string{
+ "collection": "Collection",
+ "workspace": "Workspace",
+ }, endpoint.Response.Discriminator.Mapping)
+
+ var typeField spec.TypeField
+ for _, field := range parsed.Types["NetworkEntity"].Fields {
+ if field.Name == "type" {
+ typeField = field
+ break
+ }
+ }
+ assert.Equal(t, []string{"workspace", "collection"}, typeField.Enum)
+}
+
func TestParseStytchOpenAPI(t *testing.T) {
t.Parallel()
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 96506bb4..47953626 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -54,6 +54,20 @@ type SearchBodyField struct {
Required bool `json:"required"`
}
+// DiscriminatorMapping routes one discriminator value to the concrete resource
+// whose typed table should receive the item.
+type DiscriminatorMapping struct {
+ Value string
+ Resource string
+}
+
+// DiscriminatorDispatch describes a mixed response payload whose items carry a
+// discriminator field such as type/kind/__typename/objectType.
+type DiscriminatorDispatch struct {
+ Field string
+ Mappings []DiscriminatorMapping
+}
+
// SyncableResource describes a resource that supports list sync (paginated or single-page).
type SyncableResource struct {
Name string
@@ -68,6 +82,11 @@ type SyncableResource struct {
// should fail the whole run even under the new (non-strict) exit-code
// policy. Defaults to false.
Critical bool
+
+ // Discriminator routes heterogeneous response items to concrete typed
+ // resources before storage. Empty when the endpoint returns a homogeneous
+ // resource.
+ Discriminator DiscriminatorDispatch
}
// DependentResource describes a child resource that requires iterating a parent
@@ -91,6 +110,10 @@ type DependentResource struct {
// SyncableResource.Critical so spec authors can mark child paths as
// load-bearing.
Critical bool
+
+ // Discriminator routes heterogeneous dependent-resource response items to
+ // concrete typed resources before storage.
+ Discriminator DiscriminatorDispatch
}
// APIProfile describes the shape of an API and what power-user features it warrants.
@@ -141,7 +164,7 @@ func Profile(s *spec.APISpec) *APIProfile {
SearchableFields: make(map[string][]string),
}
- resourceNames := collectResourceNames(s.Resources)
+ resourceNames, resourceNameIndex := collectResourceNameMetadata(s.Resources)
syncable := make(map[string]syncableMeta) // resource name -> chosen list endpoint metadata
parameterized := make(map[string]syncableMeta) // resource name -> parameterized list endpoint metadata (excluded from flat sync; carries IDField/Critical for dependent-resource emission)
searchable := make(map[string]map[string]struct{})
@@ -277,7 +300,7 @@ func Profile(s *spec.APISpec) *APIProfile {
// like GetFriendList?steamid=REQUIRED that need a parent ID)
if !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint) {
if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
- syncable[resourceName] = metaFromEndpoint(endpoint)
+ syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
}
}
@@ -296,7 +319,7 @@ func Profile(s *spec.APISpec) *APIProfile {
// Enum-expanded paths are more specific than generic resource
// paths, so they always win on name collision. This ensures
// deterministic output regardless of Go map iteration order.
- meta := metaFromEndpoint(endpoint)
+ meta := metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
meta.Path = expandedPath
syncable[expandedName] = meta
}
@@ -307,13 +330,13 @@ func Profile(s *spec.APISpec) *APIProfile {
// annotations on a child path-item flow into the override
// and critical-resource maps.
if _, ok := parameterized[resourceName]; !ok {
- parameterized[resourceName] = metaFromEndpoint(endpoint)
+ parameterized[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
}
} else {
// Paginated endpoints override the path set above — they have
// richer pagination support for full data retrieval.
if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
- syncable[resourceName] = metaFromEndpoint(endpoint)
+ syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
}
}
}
@@ -324,7 +347,7 @@ func Profile(s *spec.APISpec) *APIProfile {
// Only include endpoints whose name suggests a collection (list, all,
// index, etc.) — exclude singular getters like "get" or "show".
if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
- syncable[resourceName] = metaFromEndpoint(endpoint)
+ syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
}
}
@@ -382,7 +405,9 @@ func Profile(s *spec.APISpec) *APIProfile {
p.CRUDResources++
}
- for subName, sub := range r.SubResources {
+ subNames := sortedKeys(r.SubResources)
+ for _, subName := range subNames {
+ sub := r.SubResources[subName]
walk(subName, sub)
}
}
@@ -772,24 +797,29 @@ func matchesResource(name string, resourceNames map[string]struct{}) bool {
return false
}
-func collectResourceNames(resources map[string]spec.Resource) map[string]struct{} {
+func collectResourceNameMetadata(resources map[string]spec.Resource) (map[string]struct{}, map[string]string) {
names := make(map[string]struct{})
+ index := make(map[string]string)
- var walk func(name string, r spec.Resource)
- walk = func(name string, r spec.Resource) {
+ walkResources(resources, func(name string, _ spec.Resource) {
+ resourceName := strings.ToLower(name)
for _, variant := range nameVariants(name) {
names[variant] = struct{}{}
+ if _, ok := index[variant]; !ok {
+ index[variant] = resourceName
+ }
}
- for subName, sub := range r.SubResources {
- walk(subName, sub)
- }
- }
+ })
- for name, resource := range resources {
- walk(name, resource)
- }
+ return names, index
+}
- return names
+func walkResources(resources map[string]spec.Resource, visit func(name string, resource spec.Resource)) {
+ for _, name := range sortedKeys(resources) {
+ resource := resources[name]
+ visit(name, resource)
+ walkResources(resource.SubResources, visit)
+ }
}
func nameVariants(name string) []string {
@@ -931,6 +961,7 @@ func detectDependentResources(parameterized map[string]syncableMeta, syncable ma
Path: path,
IDField: meta.IDField,
Critical: meta.Critical,
+ Discriminator: meta.Discriminator,
})
}
// Sort for deterministic output
@@ -944,21 +975,118 @@ func detectDependentResources(parameterized map[string]syncableMeta, syncable ma
// is still selecting between candidates (e.g., flat vs. paginated). It is
// converted into a SyncableResource at the end of Profile().
type syncableMeta struct {
- Path string
- IDField string
- Critical bool
+ Path string
+ IDField string
+ Critical bool
+ Discriminator DiscriminatorDispatch
}
// metaFromEndpoint extracts the IDField and Critical fields a parser populated
// from path-item-level extensions (or, for IDField, from response-schema
// inference). Keeps the per-endpoint plumbing in one place so future profiler
// fields propagate uniformly.
-func metaFromEndpoint(e spec.Endpoint) syncableMeta {
+func metaFromEndpoint(e spec.Endpoint, types map[string]spec.TypeDef, resourceNameIndex map[string]string) syncableMeta {
return syncableMeta{
- Path: e.Path,
- IDField: e.IDField,
- Critical: e.Critical,
+ Path: e.Path,
+ IDField: e.IDField,
+ Critical: e.Critical,
+ Discriminator: discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
+ }
+}
+
+func discriminatorDispatchForEndpoint(endpoint spec.Endpoint, types map[string]spec.TypeDef, resourceNameIndex map[string]string) DiscriminatorDispatch {
+ if endpoint.Response.Discriminator != nil {
+ dispatch := buildDiscriminatorDispatch(endpoint.Response.Discriminator.Field, endpoint.Response.Discriminator.Mapping, resourceNameIndex)
+ if len(dispatch.Mappings) >= 2 {
+ return dispatch
+ }
+ }
+
+ typeDef, ok := lookupTypeDef(endpoint.Response.Item, types)
+ if !ok {
+ return DiscriminatorDispatch{}
+ }
+ for _, field := range typeDef.Fields {
+ if !isDiscriminatorField(field.Name) || len(field.Enum) < 2 {
+ continue
+ }
+ mapping := make(map[string]string, len(field.Enum))
+ for _, value := range field.Enum {
+ mapping[value] = value
+ }
+ dispatch := buildDiscriminatorDispatch(field.Name, mapping, resourceNameIndex)
+ if len(dispatch.Mappings) >= 2 {
+ return dispatch
+ }
+ }
+ return DiscriminatorDispatch{}
+}
+
+func lookupTypeDef(name string, types map[string]spec.TypeDef) (spec.TypeDef, bool) {
+ if name == "" || len(types) == 0 {
+ return spec.TypeDef{}, false
+ }
+ if typeDef, ok := types[name]; ok {
+ return typeDef, true
+ }
+ normalized := normalizeName(name)
+ for typeName, typeDef := range types {
+ if normalizeName(typeName) == normalized {
+ return typeDef, true
+ }
+ }
+ return spec.TypeDef{}, false
+}
+
+func isDiscriminatorField(name string) bool {
+ switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
+ case "type", "kind", "typename", "objecttype":
+ return true
+ default:
+ return false
+ }
+}
+
+func buildDiscriminatorDispatch(field string, rawMapping map[string]string, resourceNameIndex map[string]string) DiscriminatorDispatch {
+ if strings.TrimSpace(field) == "" || len(rawMapping) == 0 || len(resourceNameIndex) == 0 {
+ return DiscriminatorDispatch{}
+ }
+ values := make([]string, 0, len(rawMapping))
+ for value := range rawMapping {
+ values = append(values, value)
+ }
+ sort.Strings(values)
+
+ seenResources := make(map[string]struct{})
+ dispatch := DiscriminatorDispatch{Field: field}
+ for _, value := range values {
+ target := rawMapping[value]
+ resource, ok := resourceNameForDiscriminatorTarget(target, resourceNameIndex)
+ if !ok {
+ resource, ok = resourceNameForDiscriminatorTarget(value, resourceNameIndex)
+ }
+ if !ok {
+ continue
+ }
+ dispatch.Mappings = append(dispatch.Mappings, DiscriminatorMapping{
+ Value: value,
+ Resource: resource,
+ })
+ seenResources[resource] = struct{}{}
+ }
+ if len(seenResources) < 2 {
+ return DiscriminatorDispatch{}
+ }
+ return dispatch
+}
+
+func resourceNameForDiscriminatorTarget(target string, resourceNameIndex map[string]string) (string, bool) {
+ for _, variant := range nameVariants(target) {
+ if resource, ok := resourceNameIndex[variant]; ok {
+ return resource, true
+ }
}
+ return "", false
}
// sortedSyncableResources converts the per-resource metadata map into a sorted
@@ -973,10 +1101,11 @@ func sortedSyncableResources(m map[string]syncableMeta) []SyncableResource {
for i, name := range names {
meta := m[name]
resources[i] = SyncableResource{
- Name: name,
- Path: meta.Path,
- IDField: meta.IDField,
- Critical: meta.Critical,
+ Name: name,
+ Path: meta.Path,
+ IDField: meta.IDField,
+ Critical: meta.Critical,
+ Discriminator: meta.Discriminator,
}
}
return resources
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index ac002f9e..b10cf0dc 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -134,6 +134,52 @@ func TestProfileEnumExpansion(t *testing.T) {
assert.Equal(t, "/v1/api/team", syncPaths["team"])
}
+func TestProfileDiscriminatorDispatchFromResponseTypeEnum(t *testing.T) {
+ s := &spec.APISpec{
+ Name: "mixed-network",
+ Resources: map[string]spec.Resource{
+ "network_entities": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/network-entities",
+ Response: spec.ResponseDef{Type: "array", Item: "NetworkEntity"},
+ Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+ },
+ },
+ },
+ "workspaces": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/workspaces"}}},
+ "collections": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/collections"}}},
+ "teams": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/teams"}}},
+ },
+ Types: map[string]spec.TypeDef{
+ "NetworkEntity": {
+ Fields: []spec.TypeField{
+ {Name: "type", Type: "string", Enum: []string{"workspace", "collection", "team", "unknown"}},
+ {Name: "id", Type: "string"},
+ },
+ },
+ },
+ }
+
+ profile := Profile(s)
+
+ var mixed SyncableResource
+ for _, resource := range profile.SyncableResources {
+ if resource.Name == "network_entities" {
+ mixed = resource
+ break
+ }
+ }
+ require.Equal(t, "network_entities", mixed.Name)
+ require.Equal(t, "type", mixed.Discriminator.Field)
+ assert.Equal(t, []DiscriminatorMapping{
+ {Value: "collection", Resource: "collections"},
+ {Value: "team", Resource: "teams"},
+ {Value: "workspace", Resource: "workspaces"},
+ }, mixed.Discriminator.Mappings)
+}
+
func TestProfileEnumExpansion_NoExpansionForNonEnum(t *testing.T) {
// Standard API without enum params should not be affected
profile := Profile(petstoreSpec())
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7705323c..62db7e05 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -637,8 +637,14 @@ type Param struct {
}
type ResponseDef struct {
- Type string `yaml:"type" json:"type"` // object, array
- Item string `yaml:"item" json:"item"` // type name
+ Type string `yaml:"type" json:"type"` // object, array
+ Item string `yaml:"item" json:"item"` // type name
+ Discriminator *ResponseDiscriminator `yaml:"discriminator,omitempty" json:"discriminator,omitempty"`
+}
+
+type ResponseDiscriminator struct {
+ Field string `yaml:"field" json:"field"`
+ Mapping map[string]string `yaml:"mapping,omitempty" json:"mapping,omitempty"` // discriminator value -> schema/resource name
}
type Pagination struct {
@@ -654,8 +660,9 @@ type TypeDef struct {
}
type TypeField struct {
- Name string `yaml:"name" json:"name"`
- Type string `yaml:"type" json:"type"`
+ Name string `yaml:"name" json:"name"`
+ Type string `yaml:"type" json:"type"`
+ Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"`
// Selection is an optional GraphQL sub-selection rendered when this field
// is used in a generated GraphQL query. It lets wrapper specs keep the Go
// field simple (for example, totalPriceSet as json.RawMessage) while still
← 263be3b3 fix(cli): strip build/ from publish staging (#502)
·
back to Cli Printing Press
·
fix(cli): honor documented typed exit codes (#504) 31e63be4 →