← back to Cli Printing Press
fix(cli): sync skips resources with unresolved {key} placeholders (#1009)
5fabad63d2a15ddfb8fcf752082070437156ffb2 · 2026-05-10 23:45:53 -0700 · Justin Fu
* fix(cli): sync skips resources with unresolved {key} placeholders
Hierarchical APIs (Yahoo Fantasy, Reddit pre-2024, YouTube Data v3,
MLB Stats) declare resource paths like /league/{league_key}/players
that the flat-list sync cannot fill from context. Today the unresolved
placeholder is sent verbatim to the API, gets HTTP 500, and aborts the
entire sync run on the first failed resource.
After this change, syncResource() scans the resolved path for unresolved
{key} placeholders before fetching. If any remain, emit a structured
sync_warning event naming the missing keys and continue to the next
resource. Sync exits 0 if any resource synced successfully (existing
behavior); the warn counter increments by one per skipped resource.
Flat APIs are unaffected — they have no unresolved placeholders after
syncResourcePath() resolution, so the new branch is a no-op at runtime.
Verified via TestGenerateSyncFlatAPIUnaffected and the unchanged golden
fixtures for non-hierarchical generation cases.
Two golden fixtures (generate-golden-api, generate-tier-routing-api)
were regenerated to capture the new var declaration and skip block in
their emitted internal/cli/sync.go.
Closes #1006.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): expand sync skip regex to camelCase placeholders
Addresses review feedback on #1009. The unresolved-path-key regex
matched only lowercase/underscore identifiers, so camelCase placeholders
preserved verbatim from OpenAPI specs — YouTube Data v3's {channelId},
{playlistId}, etc. — slipped past the skip check and reproduced the
original bug. Broaden the character class to [a-zA-Z_][a-zA-Z0-9_]*.
Also switch the sync_warning event emission from a hand-built JSON
string to json.Marshal on a typed struct so resource/path are properly
escaped, and remove the now-misleading inline comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Justin <>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Files touched
A internal/generator/sync_unresolved_path_key_test.goM internal/generator/templates/sync.go.tmplM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
Diff
commit 5fabad63d2a15ddfb8fcf752082070437156ffb2
Author: Justin Fu <justinwfu@gmail.com>
Date: Sun May 10 23:45:53 2026 -0700
fix(cli): sync skips resources with unresolved {key} placeholders (#1009)
* fix(cli): sync skips resources with unresolved {key} placeholders
Hierarchical APIs (Yahoo Fantasy, Reddit pre-2024, YouTube Data v3,
MLB Stats) declare resource paths like /league/{league_key}/players
that the flat-list sync cannot fill from context. Today the unresolved
placeholder is sent verbatim to the API, gets HTTP 500, and aborts the
entire sync run on the first failed resource.
After this change, syncResource() scans the resolved path for unresolved
{key} placeholders before fetching. If any remain, emit a structured
sync_warning event naming the missing keys and continue to the next
resource. Sync exits 0 if any resource synced successfully (existing
behavior); the warn counter increments by one per skipped resource.
Flat APIs are unaffected — they have no unresolved placeholders after
syncResourcePath() resolution, so the new branch is a no-op at runtime.
Verified via TestGenerateSyncFlatAPIUnaffected and the unchanged golden
fixtures for non-hierarchical generation cases.
Two golden fixtures (generate-golden-api, generate-tier-routing-api)
were regenerated to capture the new var declaration and skip block in
their emitted internal/cli/sync.go.
Closes #1006.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): expand sync skip regex to camelCase placeholders
Addresses review feedback on #1009. The unresolved-path-key regex
matched only lowercase/underscore identifiers, so camelCase placeholders
preserved verbatim from OpenAPI specs — YouTube Data v3's {channelId},
{playlistId}, etc. — slipped past the skip check and reproduced the
original bug. Broaden the character class to [a-zA-Z_][a-zA-Z0-9_]*.
Also switch the sync_warning event emission from a hand-built JSON
string to json.Marshal on a typed struct so resource/path are properly
escaped, and remove the now-misleading inline comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Justin <>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
---
.../generator/sync_unresolved_path_key_test.go | 160 +++++++++++++++++++++
internal/generator/templates/sync.go.tmpl | 46 ++++++
.../printing-press-golden/internal/cli/sync.go | 46 ++++++
.../tier-routing-golden/internal/cli/sync.go | 46 ++++++
4 files changed, 298 insertions(+)
diff --git a/internal/generator/sync_unresolved_path_key_test.go b/internal/generator/sync_unresolved_path_key_test.go
new file mode 100644
index 00000000..279913a5
--- /dev/null
+++ b/internal/generator/sync_unresolved_path_key_test.go
@@ -0,0 +1,160 @@
+// Copyright 2026 Anthropic, PBC. Licensed under Apache-2.0.
+
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/naming"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestGenerateSyncSkipsUnresolvedPathKeys verifies that the sync template
+// includes the unresolved-{key} skip block. Hierarchical-API specs (Yahoo
+// Fantasy, Reddit pre-2024, YouTube Data v3) have resource paths whose
+// {key} placeholders cannot be filled from flat-list context. Sync should
+// emit a sync_warning and skip the resource instead of aborting the run.
+//
+// Structural test — asserts the generated sync.go contains the skip wiring.
+// A runtime assertion would require spinning up a mock HTTP server and
+// invoking the generated binary; we cover that path via the integration
+// suite. This test guards the template against template-level regression.
+//
+// Refs #1006.
+func TestGenerateSyncSkipsUnresolvedPathKeys(t *testing.T) {
+ t.Parallel()
+
+ // One resource whose path contains an unresolved {key}. The placeholder
+ // `{external_team_uuid}` is intentionally non-derivable from any other
+ // resource name in this spec, so the dependent-resource auto-detector
+ // won't claim it — the resource lands in the flat sync path where the
+ // new skip block fires.
+ apiSpec := &spec.APISpec{
+ Name: "hierarchical-sample",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.test/v1",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{"HIERARCHICAL_SAMPLE_API_KEY"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/hierarchical-sample-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "items": {
+ Description: "Items scoped by an external parent key",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/parent/{external_team_uuid}/items",
+ Description: "List items under an external parent",
+ Response: spec.ResponseDef{Type: "array"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ syncContent := string(syncGo)
+
+ // Structural assertion 1: the regex var declaration is present.
+ assert.Contains(t, syncContent, "unresolvedPathKeyRE",
+ "generated sync.go should declare unresolvedPathKeyRE var")
+ assert.Contains(t, syncContent, "regexp.MustCompile(`\\{[a-zA-Z_][a-zA-Z0-9_]*\\}`)",
+ "unresolvedPathKeyRE should match {key} placeholders including camelCase (YouTube Data v3, etc.)")
+
+ // Structural assertion 2: the FindAllString check is wired in syncResource.
+ assert.Contains(t, syncContent, "unresolvedPathKeyRE.FindAllString(path, -1)",
+ "syncResource should scan the resolved path for unresolved {key}s")
+
+ // Structural assertion 3: the sync_warning event payload is correct.
+ // Payload is marshalled from a typed struct (so resource/path get proper
+ // JSON escaping); we assert on the struct field literals rather than the
+ // hand-built JSON string that previous versions emitted.
+ assert.Contains(t, syncContent, `Event: "sync_warning"`,
+ "skip branch should emit a sync_warning event")
+ assert.Contains(t, syncContent, `Reason: "unfilled_path_key"`,
+ "skip branch should use reason=unfilled_path_key")
+
+ // Structural assertion 4: the skip branch returns a Warn (non-fatal),
+ // not an Err. The orchestrator's exit policy depends on this distinction.
+ assert.Contains(t, syncContent, `Warn: fmt.Errorf("skipped %s: unresolved path keys`,
+ "unresolved-key skip should populate syncResult.Warn, not Err")
+
+ // Sanity: the generated code should still compile. `go build` for the
+ // full generated project is exercised by TestGenerateDependentSyncCompiles
+ // and friends; here we just verify the template renders without producing
+ // a syntax error by checking that the regex literal is well-formed.
+ assert.NotContains(t, syncContent, "regexp.MustCompile(``)",
+ "unresolvedPathKeyRE pattern should not render as empty literal")
+}
+
+// TestGenerateSyncFlatAPIUnaffected verifies the skip block is a no-op for
+// flat APIs whose paths contain no {key} placeholders. The generated code
+// is identical except for the new constant and check, and the runtime
+// branch never fires.
+func TestGenerateSyncFlatAPIUnaffected(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "flat-sample",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.test/v1",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{"FLAT_SAMPLE_API_KEY"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/flat-sample-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "users": {
+ Description: "Manage users",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/users",
+ Description: "List users",
+ Response: spec.ResponseDef{Type: "array"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ syncContent := string(syncGo)
+
+ // The skip-block template is unconditionally rendered (it's a runtime
+ // check, not a template conditional), so the var declaration and check
+ // are present even for flat APIs. The runtime branch is a no-op when
+ // no path contains an unresolved {key}.
+ assert.Contains(t, syncContent, "unresolvedPathKeyRE",
+ "unresolvedPathKeyRE should be present even on flat-API CLIs (no-op at runtime)")
+
+ // The flat resource's sync path map entry should NOT contain a {key},
+ // so we expect no runtime trigger.
+ assert.Contains(t, syncContent, `"users": "/users"`,
+ "flat users resource should have a clean path with no placeholders")
+}
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 935bbbea..5f657914 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -22,6 +22,15 @@ import (
"github.com/spf13/cobra"
)
+// unresolvedPathKeyRE matches `{key}` placeholders left in a sync path
+// after syncResourcePath() resolution. Hierarchical APIs (Yahoo Fantasy,
+// Reddit pre-2024, YouTube Data v3, MLB Stats, etc.) declare paths like
+// "/league/{league_key}/players" that can only be filled from parent
+// context — flat-list sync cannot fill them. Resources with unresolved
+// keys emit sync_warning and are skipped without aborting the run, so
+// sync still completes for resources that DO have resolvable paths.
+var unresolvedPathKeyRE = regexp.MustCompile(`\{[a-zA-Z_][a-zA-Z0-9_]*\}`)
+
// syncResult holds the outcome of syncing a single resource.
type syncResult struct {
Resource string
@@ -322,6 +331,43 @@ func syncResource(c interface {
if err != nil {
return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
}
+
+ // Skip resources whose path template still contains unresolved `{key}`
+ // placeholders after syncResourcePath() resolution. These paths require
+ // parent context (league_key, team_key, channel_id, etc.) that flat-list
+ // sync cannot fill. Emit a sync_warning describing the missing keys and
+ // continue — sync exits 0 if any resource succeeded, so this keeps
+ // hierarchical-API CLIs functional for the resources they CAN sync flat.
+ if missingKeys := unresolvedPathKeyRE.FindAllString(path, -1); len(missingKeys) > 0 {
+ if !humanFriendly {
+ payload := struct {
+ Event string `json:"event"`
+ Resource string `json:"resource"`
+ Reason string `json:"reason"`
+ Keys []string `json:"keys"`
+ Path string `json:"path"`
+ Message string `json:"message"`
+ }{
+ Event: "sync_warning",
+ Resource: resource,
+ Reason: "unfilled_path_key",
+ Keys: missingKeys,
+ Path: path,
+ Message: fmt.Sprintf("path %s requires parent context (%s); resource skipped", path, strings.Join(missingKeys, ", ")),
+ }
+ payloadJSON, _ := json.Marshal(payload)
+ fmt.Fprintf(os.Stdout, "%s\n", payloadJSON)
+ } else {
+ fmt.Fprintf(os.Stderr, " %s skipped (requires parent context: %s)\n",
+ resource, strings.Join(missingKeys, ", "))
+ }
+ return syncResult{
+ Resource: resource,
+ Warn: fmt.Errorf("skipped %s: unresolved path keys %v", resource, missingKeys),
+ Duration: time.Since(started),
+ }
+ }
+
var totalCount int
// Resume cursor from sync_state (unless --full cleared it)
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 c53c0291..ca5c71d6 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
@@ -18,6 +18,15 @@ import (
"github.com/spf13/cobra"
)
+// unresolvedPathKeyRE matches `{key}` placeholders left in a sync path
+// after syncResourcePath() resolution. Hierarchical APIs (Yahoo Fantasy,
+// Reddit pre-2024, YouTube Data v3, MLB Stats, etc.) declare paths like
+// "/league/{league_key}/players" that can only be filled from parent
+// context — flat-list sync cannot fill them. Resources with unresolved
+// keys emit sync_warning and are skipped without aborting the run, so
+// sync still completes for resources that DO have resolvable paths.
+var unresolvedPathKeyRE = regexp.MustCompile(`\{[a-zA-Z_][a-zA-Z0-9_]*\}`)
+
// syncResult holds the outcome of syncing a single resource.
type syncResult struct {
Resource string
@@ -300,6 +309,43 @@ func syncResource(c interface {
if err != nil {
return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
}
+
+ // Skip resources whose path template still contains unresolved `{key}`
+ // placeholders after syncResourcePath() resolution. These paths require
+ // parent context (league_key, team_key, channel_id, etc.) that flat-list
+ // sync cannot fill. Emit a sync_warning describing the missing keys and
+ // continue — sync exits 0 if any resource succeeded, so this keeps
+ // hierarchical-API CLIs functional for the resources they CAN sync flat.
+ if missingKeys := unresolvedPathKeyRE.FindAllString(path, -1); len(missingKeys) > 0 {
+ if !humanFriendly {
+ payload := struct {
+ Event string `json:"event"`
+ Resource string `json:"resource"`
+ Reason string `json:"reason"`
+ Keys []string `json:"keys"`
+ Path string `json:"path"`
+ Message string `json:"message"`
+ }{
+ Event: "sync_warning",
+ Resource: resource,
+ Reason: "unfilled_path_key",
+ Keys: missingKeys,
+ Path: path,
+ Message: fmt.Sprintf("path %s requires parent context (%s); resource skipped", path, strings.Join(missingKeys, ", ")),
+ }
+ payloadJSON, _ := json.Marshal(payload)
+ fmt.Fprintf(os.Stdout, "%s\n", payloadJSON)
+ } else {
+ fmt.Fprintf(os.Stderr, " %s skipped (requires parent context: %s)\n",
+ resource, strings.Join(missingKeys, ", "))
+ }
+ return syncResult{
+ Resource: resource,
+ Warn: fmt.Errorf("skipped %s: unresolved path keys %v", resource, missingKeys),
+ Duration: time.Since(started),
+ }
+ }
+
var totalCount int
// Resume cursor from sync_state (unless --full cleared it)
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 2cdd2396..541b3867 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
@@ -19,6 +19,15 @@ import (
"github.com/spf13/cobra"
)
+// unresolvedPathKeyRE matches `{key}` placeholders left in a sync path
+// after syncResourcePath() resolution. Hierarchical APIs (Yahoo Fantasy,
+// Reddit pre-2024, YouTube Data v3, MLB Stats, etc.) declare paths like
+// "/league/{league_key}/players" that can only be filled from parent
+// context — flat-list sync cannot fill them. Resources with unresolved
+// keys emit sync_warning and are skipped without aborting the run, so
+// sync still completes for resources that DO have resolvable paths.
+var unresolvedPathKeyRE = regexp.MustCompile(`\{[a-zA-Z_][a-zA-Z0-9_]*\}`)
+
// syncResult holds the outcome of syncing a single resource.
type syncResult struct {
Resource string
@@ -277,6 +286,43 @@ func syncResource(c interface {
if err != nil {
return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
}
+
+ // Skip resources whose path template still contains unresolved `{key}`
+ // placeholders after syncResourcePath() resolution. These paths require
+ // parent context (league_key, team_key, channel_id, etc.) that flat-list
+ // sync cannot fill. Emit a sync_warning describing the missing keys and
+ // continue — sync exits 0 if any resource succeeded, so this keeps
+ // hierarchical-API CLIs functional for the resources they CAN sync flat.
+ if missingKeys := unresolvedPathKeyRE.FindAllString(path, -1); len(missingKeys) > 0 {
+ if !humanFriendly {
+ payload := struct {
+ Event string `json:"event"`
+ Resource string `json:"resource"`
+ Reason string `json:"reason"`
+ Keys []string `json:"keys"`
+ Path string `json:"path"`
+ Message string `json:"message"`
+ }{
+ Event: "sync_warning",
+ Resource: resource,
+ Reason: "unfilled_path_key",
+ Keys: missingKeys,
+ Path: path,
+ Message: fmt.Sprintf("path %s requires parent context (%s); resource skipped", path, strings.Join(missingKeys, ", ")),
+ }
+ payloadJSON, _ := json.Marshal(payload)
+ fmt.Fprintf(os.Stdout, "%s\n", payloadJSON)
+ } else {
+ fmt.Fprintf(os.Stderr, " %s skipped (requires parent context: %s)\n",
+ resource, strings.Join(missingKeys, ", "))
+ }
+ return syncResult{
+ Resource: resource,
+ Warn: fmt.Errorf("skipped %s: unresolved path keys %v", resource, missingKeys),
+ Duration: time.Since(started),
+ }
+ }
+
var totalCount int
// Resume cursor from sync_state (unless --full cleared it)
← a03a7b81 fix(generator): rename trailing '_test' stems to avoid Go te
·
back to Cli Printing Press
·
fix(generator): preserve multi-spec server prefixes (#861) 3e56bedf →