← back to Cli Printing Press
fix(cli): derive Google Discovery resources from operationIds (#680)
39cbc18971a10aba41bbc56a68e1710a03bb063d · 2026-05-07 13:28:50 -0500 · Cathryn Lavery
* feat(cli): add Google Cloud Run generation
* fix(cli): prefer catalog specs for print
* chore(cli): satisfy current lint rules
* fix(cli): enrich generated specs by catalog URL
Files touched
A catalog/google-cloud-run.yamlM docs/SPEC-EXTENSIONS.mdM internal/cli/generate_test.goM internal/cli/root.goM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM internal/pipeline/discover.goM internal/pipeline/discover_test.goM internal/pipeline/regenmerge/apply.goM testdata/golden/expected/catalog-list/stdout.txtA testdata/openapi/google-discovery-run.yaml
Diff
commit 39cbc18971a10aba41bbc56a68e1710a03bb063d
Author: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com>
Date: Thu May 7 13:28:50 2026 -0500
fix(cli): derive Google Discovery resources from operationIds (#680)
* feat(cli): add Google Cloud Run generation
* fix(cli): prefer catalog specs for print
* chore(cli): satisfy current lint rules
* fix(cli): enrich generated specs by catalog URL
---
catalog/google-cloud-run.yaml | 21 ++
docs/SPEC-EXTENSIONS.md | 15 ++
internal/cli/generate_test.go | 15 ++
internal/cli/root.go | 49 +++-
internal/openapi/parser.go | 309 ++++++++++++++++++-----
internal/openapi/parser_test.go | 157 ++++++++++++
internal/pipeline/climanifest.go | 21 +-
internal/pipeline/climanifest_test.go | 22 ++
internal/pipeline/discover.go | 22 +-
internal/pipeline/discover_test.go | 7 +
internal/pipeline/regenmerge/apply.go | 4 +-
testdata/golden/expected/catalog-list/stdout.txt | 1 +
testdata/openapi/google-discovery-run.yaml | 212 ++++++++++++++++
13 files changed, 778 insertions(+), 77 deletions(-)
diff --git a/catalog/google-cloud-run.yaml b/catalog/google-cloud-run.yaml
new file mode 100644
index 00000000..65093178
--- /dev/null
+++ b/catalog/google-cloud-run.yaml
@@ -0,0 +1,21 @@
+name: google-cloud-run
+display_name: Google Cloud Run
+description: Cloud Run Admin API for deploying and managing serverless container services, revisions, jobs, and executions
+category: cloud
+spec_url: https://api.apis.guru/v2/specs/googleapis.com/run/v2/openapi.yaml
+spec_format: yaml
+openapi_version: "3.0"
+tier: community
+spec_source: community
+verified_date: "2026-05-06"
+homepage: https://cloud.google.com/run/docs/reference/rest
+notes: |
+ OpenAPI 3.0 spec is community-maintained on apis.guru, converted from Google's
+ Discovery document. Auth is OAuth2 with the
+ https://www.googleapis.com/auth/cloud-platform scope; the simplest token source
+ is `gcloud auth print-access-token`.
+
+ Spec quirk: paths use the Google convention `/v2/{name}` with the resource
+ hierarchy encoded in operationIds (e.g. run.projects.locations.services.revisions.delete)
+ rather than in the path. The OpenAPI parser derives resources from those
+ Google Discovery operationIds when path segments are generic.
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 597df884..a5a3c1e5 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -15,6 +15,8 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
| `x-display-name` | `info` | `APISpec.DisplayName` | No |
| `x-website` | `info` | `APISpec.WebsiteURL` | No |
| `x-proxy-routes` | `info` | `APISpec.ProxyRoutes` | No |
+| `x-origin` | `info` | Google Discovery resource fallback | No |
+| `x-providerName` | `info` | Google Discovery resource fallback | No |
| `x-tier-routing` | root or `info` | `APISpec.TierRouting` | No |
| `x-auth-type` | `components.securitySchemes.<name>` | `APISpec.Auth.Type` | No |
| `x-auth-format` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
@@ -131,6 +133,19 @@ info:
/v1/publish: publishing
```
+### `x-origin` / `x-providerName`
+
+Recognized on Google Discovery specs converted by apis.guru. These extensions do
+not populate an `APISpec` field; they gate the parser's operationId-based
+resource fallback for paths such as `/v2/{name}` and `/{resource}:getIamPolicy`.
+
+Rules:
+- Optional.
+- `x-providerName: googleapis.com` enables Google Discovery resource fallback.
+- `x-origin` enables the fallback when any entry has `format: google` or a
+ Discovery URL under `googleapis.com/$discovery`.
+- Ignored for non-Google specs.
+
### `x-tier-routing`
Declares opt-in free/paid credential routing for APIs where some endpoints work
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 2c6ee633..72e6483d 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -787,6 +787,21 @@ func TestEnrichSpecFromCatalogCopiesGenerationMetadata(t *testing.T) {
assert.Equal(t, "hidden", apiSpec.MCP.EndpointTools)
}
+func TestEnrichSpecFromCatalogMatchesSpecURLWhenSlugDiffers(t *testing.T) {
+ apiSpec := &spec.APISpec{
+ Name: "cloud-run-admin",
+ DisplayName: "Cloud Run Admin",
+ DisplayNameDerivedFromTitle: true,
+ }
+
+ enrichSpecFromCatalog(apiSpec, "https://api.apis.guru/v2/specs/googleapis.com/run/v2/openapi.yaml")
+
+ assert.Equal(t, "Google Cloud Run", apiSpec.DisplayName)
+ assert.False(t, apiSpec.DisplayNameDerivedFromTitle)
+ assert.Equal(t, "cloud", apiSpec.Category)
+ assert.Equal(t, "https://cloud.google.com/run/docs/reference/rest", apiSpec.WebsiteURL)
+}
+
func TestEnrichSpecFromCatalogReplacesTitleDerivedDisplayName(t *testing.T) {
apiSpec := &spec.APISpec{
Name: "trigger-dev",
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 4e38d79b..5f463654 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -303,7 +303,7 @@ func newGenerateCmd() *cobra.Command {
return printDryRun(apiSpec, absOut, specFiles)
}
- novelFeatures, polished, err := runGenerateProject(apiSpec, absOut, generateProjectOptions{validate: validate, polish: polish, researchDir: researchDir, trafficAnalysisPath: trafficAnalysisPath, specFiles: specFiles, rejectUnshippablePageContextTraffic: true})
+ novelFeatures, polished, err := runGenerateProject(apiSpec, absOut, generateProjectOptions{validate: validate, polish: polish, researchDir: researchDir, trafficAnalysisPath: trafficAnalysisPath, specFiles: specFiles, specURL: specURL, rejectUnshippablePageContextTraffic: true})
if err != nil {
return err
}
@@ -428,11 +428,12 @@ type generateProjectOptions struct {
researchDir string
trafficAnalysisPath string
specFiles []string
+ specURL string
rejectUnshippablePageContextTraffic bool
}
func runGenerateProject(apiSpec *spec.APISpec, absOut string, opts generateProjectOptions) ([]pipeline.NovelFeatureManifest, bool, error) {
- enrichSpecFromCatalog(apiSpec)
+ enrichSpecFromCatalog(apiSpec, catalogSpecLookupRefs(opts.specFiles, opts.specURL)...)
gen := generator.New(apiSpec, absOut)
novelFeatures := loadResearchSources(gen, opts.researchDir)
trafficAnalysis, err := loadTrafficAnalysisForGenerate(opts.trafficAnalysisPath, opts.specFiles, apiSpec.SpecSource)
@@ -1093,19 +1094,53 @@ func translateNarrative(n *pipeline.ReadmeNarrative) *generator.ReadmeNarrative
}
// enrichSpecFromCatalog looks up the API in the embedded catalog and copies
-// ProxyRoutes into the spec if present. This allows catalog entries to declare
-// service routing for proxy-envelope APIs without requiring CLI flags.
-func enrichSpecFromCatalog(apiSpec *spec.APISpec) {
+// generation metadata into the spec if present.
+func enrichSpecFromCatalog(apiSpec *spec.APISpec, specRefs ...string) {
if apiSpec == nil || apiSpec.Name == "" {
return
}
- entry, err := catalog.LookupFS(catalogfs.FS, apiSpec.Name)
- if err != nil {
+ entry := lookupCatalogEntryForGenerateSpec(apiSpec.Name, specRefs)
+ if entry == nil {
return
}
enrichSpecFromCatalogEntry(apiSpec, entry)
}
+func catalogSpecLookupRefs(specFiles []string, specURL string) []string {
+ refs := make([]string, 0, len(specFiles)+1)
+ if specURL != "" {
+ refs = append(refs, specURL)
+ }
+ refs = append(refs, specFiles...)
+ return refs
+}
+
+func lookupCatalogEntryForGenerateSpec(apiName string, specRefs []string) *catalog.Entry {
+ if entry, err := catalog.LookupFS(catalogfs.FS, apiName); err == nil {
+ return entry
+ }
+ specURLs := make(map[string]struct{}, len(specRefs))
+ for _, ref := range specRefs {
+ ref = strings.TrimSpace(ref)
+ if strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "http://") {
+ specURLs[ref] = struct{}{}
+ }
+ }
+ if len(specURLs) == 0 {
+ return nil
+ }
+ entries, err := catalog.ParseFS(catalogfs.FS)
+ if err != nil {
+ return nil
+ }
+ for i := range entries {
+ if _, ok := specURLs[entries[i].SpecURL]; ok {
+ return &entries[i]
+ }
+ }
+ return nil
+}
+
func enrichSpecFromCatalogEntry(apiSpec *spec.APISpec, entry *catalog.Entry) {
if apiSpec == nil || entry == nil {
return
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index ffabd4f0..934eb236 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"regexp"
+ "slices"
"sort"
"strconv"
"strings"
@@ -41,6 +42,8 @@ const (
extensionDisplayName = "x-display-name"
extensionWebsite = "x-website"
extensionProxyRoutes = "x-proxy-routes"
+ extensionOrigin = "x-origin"
+ extensionProviderName = "x-providerName"
)
// SetMaxResources overrides the default resource limit. When not called,
@@ -1390,6 +1393,8 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
return pathKeys[i] < pathKeys[j]
})
commonPrefix := detectCommonPrefix(pathKeys, basePath)
+ frameworkRenames := map[string]string{}
+ googleDiscovery := isGoogleDiscoverySpec(doc)
// Auto-calibrate endpoint limit unless the user explicitly set it.
// Pre-scans the spec to find the largest resource or sub-resource,
@@ -1400,13 +1405,16 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
type resourceKey struct{ primary, sub string }
endpointCounts := map[resourceKey]int{}
for _, path := range pathKeys {
- primaryName, subName := resourceAndSubFromPath(path, basePath, commonPrefix)
- if primaryName == "" {
+ pathItem := doc.Paths.Value(path)
+ if pathItem == nil {
continue
}
- pathItem := doc.Paths.Value(path)
- if pathItem != nil {
- endpointCounts[resourceKey{primaryName, subName}] += len(pathItem.Operations())
+ for _, op := range pathItem.Operations() {
+ primaryName, subName := resourceAndSubForOperation(path, basePath, commonPrefix, op, googleDiscovery)
+ if primaryName == "" {
+ continue
+ }
+ endpointCounts[resourceKey{primaryName, subName}]++
}
}
for _, count := range endpointCounts {
@@ -1437,53 +1445,6 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
pathCritical := readPathItemCritical(pathItem, path)
pathTier := readTierExtension(pathItem.Extensions, fmt.Sprintf("path %q", path))
- primaryName, subName := resourceAndSubFromPath(path, basePath, commonPrefix)
- if primaryName == "" {
- warnf("skipping path %q: could not derive resource name", path)
- continue
- }
-
- // Framework cobra collision check — only for top-level resources
- // (subName == ""). Sub-resources register under a parent prefix and
- // don't shadow framework commands. If primaryName matches a reserved
- // cobra Use literal, rename to <api-slug>-<resource> and warn so
- // the operator sees it. Self-collision (the renamed name already
- // exists in out.Resources) bumps a numeric suffix until unique.
- if subName == "" {
- if _, reserved := spec.ReservedCobraUseNames[primaryName]; reserved {
- primaryName = renameForFrameworkCollision(out, primaryName, path)
- }
- }
-
- resource, ok := out.Resources[primaryName]
- if !ok {
- if len(out.Resources) >= maxResources {
- warnf("skipping path %q: resource limit (%d) reached", path, maxResources)
- continue
- }
- resource = spec.Resource{
- Description: tagDescriptions[primaryName],
- Endpoints: map[string]spec.Endpoint{},
- SubResources: map[string]spec.Resource{},
- }
- }
-
- // Determine the target: direct resource endpoints or sub-resource endpoints
- var targetEndpoints map[string]spec.Endpoint
- targetResourceName := primaryName
- if subName != "" {
- if _, ok := resource.SubResources[subName]; !ok {
- resource.SubResources[subName] = spec.Resource{
- Description: tagDescriptions[subName],
- Endpoints: map[string]spec.Endpoint{},
- }
- }
- targetEndpoints = resource.SubResources[subName].Endpoints
- targetResourceName = subName
- } else {
- targetEndpoints = resource.Endpoints
- }
-
methods := make([]string, 0, len(operations))
for method := range operations {
methods = append(methods, method)
@@ -1497,12 +1458,63 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
continue
}
+ primaryName, subName := resourceAndSubForOperation(path, basePath, commonPrefix, op, googleDiscovery)
+ if primaryName == "" {
+ warnf("skipping %s %q: could not derive resource name", method, path)
+ continue
+ }
+ endpointResourceName := primaryName
+ if subName != "" {
+ endpointResourceName = subName
+ }
+
+ // Framework cobra collision check. Sub-resource names do not shadow
+ // framework commands, but every primary resource becomes a top-level
+ // Cobra command, even when the current endpoint lands under a
+ // sub-resource.
+ if renamed, ok := frameworkRenames[primaryName]; ok {
+ primaryName = renamed
+ } else if _, reserved := spec.ReservedCobraUseNames[primaryName]; reserved {
+ originalName := primaryName
+ primaryName = renameForFrameworkCollision(out, primaryName, path)
+ frameworkRenames[originalName] = primaryName
+ }
+
+ resource, ok := out.Resources[primaryName]
+ if !ok {
+ if len(out.Resources) >= maxResources {
+ warnf("skipping path %q: resource limit (%d) reached", path, maxResources)
+ continue
+ }
+ resource = spec.Resource{
+ Description: tagDescriptions[primaryName],
+ Endpoints: map[string]spec.Endpoint{},
+ SubResources: map[string]spec.Resource{},
+ }
+ }
+
+ // Determine the target: direct resource endpoints or sub-resource endpoints
+ var targetEndpoints map[string]spec.Endpoint
+ targetResourceName := primaryName
+ if subName != "" {
+ if _, ok := resource.SubResources[subName]; !ok {
+ resource.SubResources[subName] = spec.Resource{
+ Description: tagDescriptions[subName],
+ Endpoints: map[string]spec.Endpoint{},
+ }
+ }
+ targetEndpoints = resource.SubResources[subName].Endpoints
+ targetResourceName = subName
+ } else {
+ targetEndpoints = resource.Endpoints
+ }
+
if len(targetEndpoints) >= maxEndpointsPerResource {
warnf("skipping %s %q: endpoint limit (%d) reached for resource %q.%s", method, path, maxEndpointsPerResource, primaryName, targetResourceName)
continue
}
- endpointName := resolveEndpointName(method, path, op, targetEndpoints, targetResourceName, basePath, commonPrefix)
+ endpointName := resolveEndpointName(method, path, op, targetEndpoints, endpointResourceName, basePath, commonPrefix)
summary := strings.TrimSpace(op.Summary)
desc := strings.TrimSpace(op.Description)
description := selectDescription(summary, desc)
@@ -1559,20 +1571,20 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
endpoint.Critical = pathCritical
targetEndpoints[endpointName] = endpoint
- }
- // Update descriptions
- if subName != "" {
- sub := resource.SubResources[subName]
- if sub.Description == "" {
- sub.Description = humanizeResourceName(subName)
+ // Update descriptions
+ if subName != "" {
+ sub := resource.SubResources[subName]
+ if sub.Description == "" {
+ sub.Description = humanizeResourceName(subName)
+ }
+ resource.SubResources[subName] = sub
}
- resource.SubResources[subName] = sub
- }
- if resource.Description == "" {
- resource.Description = humanizeResourceName(primaryName)
+ if resource.Description == "" {
+ resource.Description = humanizeResourceName(primaryName)
+ }
+ out.Resources[primaryName] = resource
}
- out.Resources[primaryName] = resource
}
assignEndpointAliases(out.Resources)
@@ -3049,6 +3061,26 @@ func resourceAndSubFromPath(path, basePath string, commonPrefix []string) (strin
return resourceAndSubFromSegments(segments)
}
+func resourceAndSubForOperation(path, basePath string, commonPrefix []string, op *openapi3.Operation, googleDiscovery bool) (string, string) {
+ primary, sub := resourceAndSubFromPath(path, basePath, commonPrefix)
+ if primary != "" && !pathStartsWithCustomVerbParam(path, basePath, commonPrefix) {
+ return primary, sub
+ }
+ if googleDiscovery {
+ opPrimary, opSub := resourceAndSubFromGoogleOperationID(operationID(op), true)
+ if opPrimary != "" {
+ return opPrimary, opSub
+ }
+ if pathPrimary := resourceFromGooglePathAfterLeadingParams(path, basePath, commonPrefix); pathPrimary != "" {
+ return pathPrimary, ""
+ }
+ }
+ if opPrimary, opSub := resourceAndSubFromGoogleOperationID(operationID(op), false); opPrimary != "" {
+ return opPrimary, opSub
+ }
+ return primary, sub
+}
+
func resourceAndSubFromSegments(segments []string) (string, string) {
if len(segments) == 0 {
return "", ""
@@ -3078,6 +3110,34 @@ func resourceAndSubFromSegments(segments []string) (string, string) {
return primary, sub
}
+func pathStartsWithCustomVerbParam(path, basePath string, commonPrefix []string) bool {
+ segments := pathSegmentsAfterBase(path, basePath)
+ if len(commonPrefix) > 0 && hasSegmentPrefix(segments, commonPrefix) {
+ segments = segments[len(commonPrefix):]
+ }
+ return len(segments) > 0 && isPathParamCustomVerbSegment(segments[0])
+}
+
+func resourceFromGooglePathAfterLeadingParams(path, basePath string, commonPrefix []string) string {
+ segments := pathSegmentsAfterBase(path, basePath)
+ if len(commonPrefix) > 0 && hasSegmentPrefix(segments, commonPrefix) {
+ segments = segments[len(commonPrefix):]
+ }
+ if len(segments) == 0 || !isPathParamSegment(segments[0]) {
+ return ""
+ }
+ for _, segment := range segments {
+ if isPathParamSegment(segment) {
+ continue
+ }
+ if isPathParamCustomVerbSegment(segment) {
+ continue
+ }
+ return sanitizeResourceName(strings.ReplaceAll(toSnakeCase(segment), "_", "-"))
+ }
+ return ""
+}
+
func detectCommonPrefix(paths []string, basePath string) []string {
segmentLists := make([][]string, 0, len(paths))
for _, path := range paths {
@@ -3262,8 +3322,7 @@ func isVersionSegment(segment string) bool {
return false
}
if segment[0] == 'v' && len(segment) >= 2 {
- _, err := strconv.Atoi(segment[1:])
- return err == nil
+ return versionSegmentPattern.MatchString(segment)
}
_, err := strconv.Atoi(segment)
return err == nil
@@ -3273,6 +3332,11 @@ func isPathParamSegment(segment string) bool {
return strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}")
}
+func isPathParamCustomVerbSegment(segment string) bool {
+ close := strings.Index(segment, "}:")
+ return strings.HasPrefix(segment, "{") && close > 0 && close+2 < len(segment)
+}
+
func baseURLPath(baseURL string) string {
if baseURL == "" {
return ""
@@ -3288,6 +3352,9 @@ func operationIDToName(operationID, resourceName string, commonPrefix []string)
if strings.TrimSpace(operationID) == "" {
return ""
}
+ if name := googleOperationIDEndpointName(operationID, resourceName); name != "" {
+ return name
+ }
original := toSnakeCase(operationID)
if original == "" {
return ""
@@ -3319,6 +3386,114 @@ func operationIDToName(operationID, resourceName string, commonPrefix []string)
return strings.ReplaceAll(name, "_", "-")
}
+func googleOperationIDEndpointName(operationID, resourceName string) string {
+ chain, verb, ok := googleOperationIDResourceChain(operationID, true, true)
+ if !ok {
+ return ""
+ }
+ resource := sanitizeResourceName(strings.ReplaceAll(toSnakeCase(resourceName), "_", "-"))
+ if resource == "" {
+ return ""
+ }
+ if slices.Contains(chain, resource) {
+ return strings.ReplaceAll(toSnakeCase(verb), "_", "-")
+ }
+ return ""
+}
+
+func resourceAndSubFromGoogleOperationID(operationID string, allowUnscoped bool) (string, string) {
+ chain, _, ok := googleOperationIDResourceChain(operationID, allowUnscoped, false)
+ if !ok {
+ return "", ""
+ }
+ switch len(chain) {
+ case 0:
+ return "", ""
+ case 1:
+ return chain[0], ""
+ case 2:
+ return chain[0], chain[1]
+ default:
+ return chain[len(chain)-2], chain[len(chain)-1]
+ }
+}
+
+func googleOperationIDResourceChain(operationID string, allowUnscoped, fallbackWhenScopedEmpty bool) ([]string, string, bool) {
+ parts := strings.Split(strings.TrimSpace(operationID), ".")
+ if len(parts) < 3 {
+ return nil, "", false
+ }
+ start, ok := googleOperationIDScopeStart(parts)
+ if !ok && allowUnscoped {
+ start = 1
+ ok = true
+ }
+ if ok && allowUnscoped && fallbackWhenScopedEmpty && len(parts[start:]) < 2 {
+ start = 1
+ }
+ if !ok || len(parts[start:]) < 2 {
+ return nil, "", false
+ }
+
+ verb := parts[len(parts)-1]
+ rawChain := parts[start : len(parts)-1]
+ chain := make([]string, 0, len(rawChain))
+ for _, segment := range rawChain {
+ resource := sanitizeResourceName(strings.ReplaceAll(toSnakeCase(segment), "_", "-"))
+ if resource == "" {
+ return nil, "", false
+ }
+ chain = append(chain, resource)
+ }
+ return chain, verb, true
+}
+
+func googleOperationIDScopeStart(parts []string) (int, bool) {
+ for i := range len(parts) {
+ switch parts[i] {
+ case "projects", "organizations", "folders":
+ if i+1 < len(parts) && parts[i+1] == "locations" {
+ return i + 2, true
+ }
+ case "billingAccounts":
+ if i+1 < len(parts) && parts[i+1] == "locations" {
+ return i + 2, true
+ }
+ return i + 1, true
+ }
+ }
+ return 0, false
+}
+
+func isGoogleDiscoverySpec(doc *openapi3.T) bool {
+ if raw, ok := lookupOpenAPIInfoExtension(doc, extensionProviderName); ok {
+ if provider, ok := raw.(string); ok && strings.EqualFold(strings.TrimSpace(provider), "googleapis.com") {
+ return true
+ }
+ }
+ raw, ok := lookupOpenAPIInfoExtension(doc, extensionOrigin)
+ if !ok {
+ return false
+ }
+ origins, ok := raw.([]any)
+ if !ok {
+ return false
+ }
+ for _, origin := range origins {
+ entry, ok := origin.(map[string]any)
+ if !ok {
+ continue
+ }
+ if format, ok := entry["format"].(string); ok && strings.EqualFold(strings.TrimSpace(format), "google") {
+ return true
+ }
+ if originURL, ok := entry["url"].(string); ok && strings.Contains(originURL, "googleapis.com/$discovery") {
+ return true
+ }
+ }
+ return false
+}
+
func operationIDResourceVariants(resourceName string) []string {
resource := toSnakeCase(strings.TrimSpace(resourceName))
if resource == "" {
@@ -3373,6 +3548,8 @@ func stripOperationIDVersionPrefix(name string) string {
// These appear in specs like Cal.com: BookingsController_2024-08-13_getBooking → 2024_08_13
var operationIDDatePattern = regexp.MustCompile(`_\d{4}_\d{2}_\d{2}_?`)
+var versionSegmentPattern = regexp.MustCompile(`^v[0-9]+((alpha|beta|p[0-9]+)[0-9]*)*$`)
+
// stripOperationIDControllerAndDate removes controller class names and embedded
// version dates from operationIDs. APIs auto-generated from NestJS, FastAPI, or
// similar frameworks include these patterns (e.g., BookingsController_2024-08-13_getBooking).
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index e351ec82..654bbc15 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -722,6 +722,9 @@ func TestPathSegmentsStripsGenericAPIPrefix(t *testing.T) {
{"strips api then version", "/api/v2/pokemon", "", "pokemon"},
{"strips version then api then version", "/v2/api/v1/pokemon", "", "pokemon"},
{"strips api then numeric version", "/api/0/organizations", "", "organizations"},
+ {"strips beta version", "/v1beta2/{parent}/repositories", "", "{parent}"},
+ {"strips alpha version", "/v1alpha1/{parent}/services", "", "{parent}"},
+ {"strips p beta version", "/v1p1beta1/{parent}/sessions", "", "{parent}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -766,6 +769,8 @@ func TestOperationIDToName(t *testing.T) {
{operationID: "OrganizationsController_getOrg", resourceName: "organizations", want: "get-org"},
// No controller/version pattern — should be unchanged
{operationID: "getBookingByUid", resourceName: "bookings", want: "get-by-uid"},
+ {operationID: "run.projects.locations.services.revisions.delete", resourceName: "revisions", want: "delete"},
+ {operationID: "run.projects.locations.services.getIamPolicy", resourceName: "services", want: "get-iam-policy"},
}
for _, tt := range tests {
@@ -776,6 +781,158 @@ func TestOperationIDToName(t *testing.T) {
}
}
+func TestResourceAndSubFromGoogleOperationID(t *testing.T) {
+ tests := []struct {
+ name string
+ operationID string
+ wantPrimary string
+ wantSub string
+ wantName string
+ nameResource string
+ }{
+ {
+ name: "project location resource",
+ operationID: "run.projects.locations.services.create",
+ wantPrimary: "services",
+ nameResource: "services",
+ wantName: "create",
+ },
+ {
+ name: "nested subresource keeps owning parent",
+ operationID: "run.projects.locations.services.revisions.delete",
+ wantPrimary: "services",
+ wantSub: "revisions",
+ nameResource: "revisions",
+ wantName: "delete",
+ },
+ {
+ name: "deep chain uses immediate parent",
+ operationID: "run.projects.locations.jobs.executions.tasks.list",
+ wantPrimary: "executions",
+ wantSub: "tasks",
+ nameResource: "tasks",
+ wantName: "list",
+ },
+ {
+ name: "organization location scope",
+ operationID: "example.organizations.locations.widgets.get",
+ wantPrimary: "widgets",
+ nameResource: "widgets",
+ wantName: "get",
+ },
+ {
+ name: "billing account scope",
+ operationID: "example.billingAccounts.invoices.list",
+ wantPrimary: "invoices",
+ nameResource: "invoices",
+ wantName: "list",
+ },
+ {
+ name: "non Google scope ignored",
+ operationID: "gmail.users.messages.list",
+ },
+ {
+ name: "unscoped Google Discovery operation",
+ operationID: "bigquery.tables.getIamPolicy",
+ wantPrimary: "tables",
+ nameResource: "tables",
+ wantName: "get-iam-policy",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotPrimary, gotSub := resourceAndSubFromGoogleOperationID(tt.operationID, false)
+ if tt.name == "unscoped Google Discovery operation" {
+ gotPrimary, gotSub = resourceAndSubFromGoogleOperationID(tt.operationID, true)
+ }
+ assert.Equal(t, tt.wantPrimary, gotPrimary)
+ assert.Equal(t, tt.wantSub, gotSub)
+ assert.Equal(t, tt.wantName, googleOperationIDEndpointName(tt.operationID, tt.nameResource))
+ })
+ }
+}
+
+func TestParseGoogleDiscoveryResourceFallback(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "google-discovery-run.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ assert.NotContains(t, parsed.Resources, "name-run")
+ assert.NotContains(t, parsed.Resources, "name-wait")
+ assert.NotContains(t, parsed.Resources, "resource-get-iam-policy")
+ assert.NotContains(t, parsed.Resources, "resource-set-iam-policy")
+ assert.NotContains(t, parsed.Resources, "resource-test-iam-permissions")
+
+ services := parsed.Resources["services"]
+ require.Contains(t, services.Endpoints, "create")
+ require.Contains(t, services.Endpoints, "patch")
+ require.Contains(t, services.Endpoints, "list")
+ require.Contains(t, services.Endpoints, "get-iam-policy")
+ require.Contains(t, services.Endpoints, "set-iam-policy")
+ require.Contains(t, services.Endpoints, "test-iam-permissions")
+ require.Contains(t, services.SubResources, "revisions")
+ assert.Contains(t, services.SubResources["revisions"].Endpoints, "delete")
+ assert.Contains(t, services.SubResources["revisions"].Endpoints, "get")
+ assert.Contains(t, services.SubResources["revisions"].Endpoints, "list")
+
+ assert.NotContains(t, parsed.Resources, "jobs")
+ jobs := parsed.Resources["google-cloud-run-jobs"]
+ require.Contains(t, jobs.Endpoints, "create")
+ require.Contains(t, jobs.Endpoints, "list")
+ require.Contains(t, jobs.Endpoints, "run")
+ require.Contains(t, jobs.SubResources, "executions")
+ assert.Contains(t, jobs.SubResources["executions"].Endpoints, "list")
+
+ executions := parsed.Resources["executions"]
+ require.Contains(t, executions.SubResources, "tasks")
+ assert.Contains(t, executions.SubResources["tasks"].Endpoints, "list")
+
+ operations := parsed.Resources["operations"]
+ assert.Contains(t, operations.Endpoints, "list")
+ assert.Contains(t, operations.Endpoints, "wait")
+
+ locations := parsed.Resources["locations"]
+ assert.Contains(t, locations.Endpoints, "list")
+}
+
+func TestParseGoogleDiscoveryUnscopedOperationIDFallbackRequiresGoogleOrigin(t *testing.T) {
+ t.Parallel()
+
+ base := `openapi: 3.0.3
+info:
+ title: BigQuery API
+ version: v2
+%s
+paths:
+ /{resource}:getIamPolicy:
+ get:
+ operationId: bigquery.tables.getIamPolicy
+ parameters:
+ - name: resource
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ "200": {description: ok}
+`
+
+ parsed, err := Parse(fmt.Appendf(nil, base, " x-providerName: googleapis.com\n"))
+ require.NoError(t, err)
+ require.Contains(t, parsed.Resources, "tables")
+ assert.Contains(t, parsed.Resources["tables"].Endpoints, "get-iam-policy")
+ assert.NotContains(t, parsed.Resources, "resource-get-iam-policy")
+
+ parsed, err = Parse(fmt.Appendf(nil, base, ""))
+ require.NoError(t, err)
+ assert.NotContains(t, parsed.Resources, "tables")
+ assert.Contains(t, parsed.Resources, "resource-get-iam-policy")
+}
+
func TestReclassifyPathParamModifiers(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 56a0970c..8741bff0 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -459,7 +459,7 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
}
// Look up catalog entry for category/description/display-name enrichment.
- if entry, err := catalogpkg.LookupFS(catalog.FS, p.APIName); err == nil {
+ if entry := lookupCatalogEntryForGenerate(p.APIName, m.SpecURL); entry != nil {
m.CatalogEntry = entry.Name
m.Category = entry.Category
m.Description = entry.Description
@@ -491,6 +491,25 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
return WriteMCPBManifestFromStruct(p.OutputDir, m)
}
+func lookupCatalogEntryForGenerate(apiName, specURL string) *catalogpkg.Entry {
+ if entry, err := catalogpkg.LookupFS(catalog.FS, apiName); err == nil {
+ return entry
+ }
+ if specURL == "" {
+ return nil
+ }
+ entries, err := catalogpkg.ParseFS(catalog.FS)
+ if err != nil {
+ return nil
+ }
+ for i := range entries {
+ if entries[i].SpecURL == specURL {
+ return &entries[i]
+ }
+ }
+ return nil
+}
+
// detectSpecFormat examines the raw spec bytes and returns a format
// string: "openapi3", "graphql", or "internal".
func detectSpecFormat(data []byte) string {
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index d062b738..dabe29a4 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -452,6 +452,28 @@ func TestWriteManifestForGenerateKeepsCatalogDisplayNameOverTitleFallback(t *tes
assert.Equal(t, "Product Hunt", got.DisplayName)
}
+func TestWriteManifestForGenerateMatchesCatalogBySpecURLWhenSlugDiffers(t *testing.T) {
+ dir := t.TempDir()
+
+ err := WriteManifestForGenerate(GenerateManifestParams{
+ APIName: "cloud-run-admin",
+ SpecURL: "https://api.apis.guru/v2/specs/googleapis.com/run/v2/openapi.yaml",
+ OutputDir: dir,
+ Spec: &spec.APISpec{
+ Name: "cloud-run-admin",
+ DisplayName: "Cloud Run Admin",
+ DisplayNameDerivedFromTitle: true,
+ Auth: spec.AuthConfig{Type: "bearer_token"},
+ },
+ })
+ require.NoError(t, err)
+
+ got := readPublishedManifest(t, dir)
+ assert.Equal(t, "google-cloud-run", got.CatalogEntry)
+ assert.Equal(t, "Google Cloud Run", got.DisplayName)
+ assert.Equal(t, "cloud", got.Category)
+}
+
func TestWriteManifestForGenerateWithDocsURL(t *testing.T) {
dir := t.TempDir()
diff --git a/internal/pipeline/discover.go b/internal/pipeline/discover.go
index 24955b44..66608494 100644
--- a/internal/pipeline/discover.go
+++ b/internal/pipeline/discover.go
@@ -3,6 +3,9 @@ package pipeline
import (
"fmt"
"strings"
+
+ catalogfs "github.com/mvanhorn/cli-printing-press/v4/catalog"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
)
// KnownSpec holds metadata about a known API spec.
@@ -133,7 +136,14 @@ func ApisGuruPattern(provider, version string) string {
func DiscoverSpec(apiName string) (string, string, error) {
normalized := strings.ToLower(strings.TrimSpace(apiName))
- // Check known specs first
+ if entry, err := catalog.LookupFS(catalogfs.FS, normalized); err == nil {
+ if entry.SpecURL == "" {
+ return "", "", fmt.Errorf("catalog entry %q does not define spec_url - try providing a URL with --spec", apiName)
+ }
+ return entry.SpecURL, catalogSpecSource(entry), nil
+ }
+
+ // Check known specs for aliases that do not have catalog entries.
if spec, ok := KnownSpecs[normalized]; ok {
return spec.URL, "known-specs registry", nil
}
@@ -149,6 +159,16 @@ func DiscoverSpec(apiName string) (string, string, error) {
return "", "", fmt.Errorf("could not find OpenAPI spec for %q - try providing a URL with --spec", apiName)
}
+func catalogSpecSource(entry *catalog.Entry) string {
+ if source := strings.TrimSpace(entry.SpecSource); source != "" {
+ return "catalog entry (" + source + ")"
+ }
+ if tier := strings.TrimSpace(entry.Tier); tier != "" {
+ return "catalog entry (" + tier + ")"
+ }
+ return "catalog entry"
+}
+
// IsSandboxSafe returns true if the API is known to have a safe test/sandbox environment.
func IsSandboxSafe(apiName string) bool {
normalized := strings.ToLower(strings.TrimSpace(apiName))
diff --git a/internal/pipeline/discover_test.go b/internal/pipeline/discover_test.go
index 6155f792..357af0e6 100644
--- a/internal/pipeline/discover_test.go
+++ b/internal/pipeline/discover_test.go
@@ -14,6 +14,13 @@ func TestDiscoverSpec_KnownAPI(t *testing.T) {
assert.Contains(t, url, "petstore")
}
+func TestDiscoverSpec_CatalogEntryWins(t *testing.T) {
+ url, source, err := DiscoverSpec("google-cloud-run")
+ require.NoError(t, err)
+ assert.Equal(t, "https://api.apis.guru/v2/specs/googleapis.com/run/v2/openapi.yaml", url)
+ assert.Equal(t, "catalog entry (community)", source)
+}
+
func TestDiscoverSpec_UnknownAPI(t *testing.T) {
// Unknown APIs still return a url via apis-guru fallback, so check that it works
url, source, err := DiscoverSpec("zzz-nonexistent-api-zzz")
diff --git a/internal/pipeline/regenmerge/apply.go b/internal/pipeline/regenmerge/apply.go
index 97fbcf25..210d724c 100644
--- a/internal/pipeline/regenmerge/apply.go
+++ b/internal/pipeline/regenmerge/apply.go
@@ -294,8 +294,8 @@ func injectAddCommands(hostPath string, calls []string) error {
// Insert before the last `return` statement, or at the end if no
// trailing return.
insertAt := len(fn.Body.List)
- for i := len(fn.Body.List) - 1; i >= 0; i-- {
- if _, isRet := fn.Body.List[i].(*dst.ReturnStmt); isRet {
+ for i, stmt := range slices.Backward(fn.Body.List) {
+ if _, isRet := stmt.(*dst.ReturnStmt); isRet {
insertAt = i
break
}
diff --git a/testdata/golden/expected/catalog-list/stdout.txt b/testdata/golden/expected/catalog-list/stdout.txt
index d7e798e2..0cf40b8c 100644
--- a/testdata/golden/expected/catalog-list/stdout.txt
+++ b/testdata/golden/expected/catalog-list/stdout.txt
@@ -3,6 +3,7 @@ auth:
cloud:
digitalocean Cloud infrastructure and developer platform API
+ google-cloud-run Cloud Run Admin API for deploying and managing serverless container services, revisions, jobs, and executions
developer-tools:
github Software development platform API
diff --git a/testdata/openapi/google-discovery-run.yaml b/testdata/openapi/google-discovery-run.yaml
new file mode 100644
index 00000000..18024fb4
--- /dev/null
+++ b/testdata/openapi/google-discovery-run.yaml
@@ -0,0 +1,212 @@
+openapi: 3.0.3
+info:
+ title: Google Cloud Run API
+ version: v2
+ x-origin:
+ - format: google
+ url: https://run.googleapis.com/$discovery/rest?version=v2
+ version: v2
+ x-providerName: googleapis.com
+servers:
+ - url: https://run.googleapis.com/
+paths:
+ /v2/{name}:
+ delete:
+ operationId: run.projects.locations.services.revisions.delete
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ get:
+ operationId: run.projects.locations.services.revisions.get
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ patch:
+ operationId: run.projects.locations.services.patch
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{name}/operations:
+ get:
+ operationId: run.projects.locations.operations.list
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{name}/locations:
+ get:
+ operationId: run.projects.locations.list
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{name}:run:
+ post:
+ operationId: run.projects.locations.jobs.run
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{name}:wait:
+ post:
+ operationId: run.projects.locations.operations.wait
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{parent}/executions:
+ get:
+ operationId: run.projects.locations.jobs.executions.list
+ parameters:
+ - name: parent
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{parent}/jobs:
+ get:
+ operationId: run.projects.locations.jobs.list
+ parameters:
+ - name: parent
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ post:
+ operationId: run.projects.locations.jobs.create
+ parameters:
+ - name: parent
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{parent}/revisions:
+ get:
+ operationId: run.projects.locations.services.revisions.list
+ parameters:
+ - name: parent
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{parent}/services:
+ get:
+ operationId: run.projects.locations.services.list
+ parameters:
+ - name: parent
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ post:
+ operationId: run.projects.locations.services.create
+ parameters:
+ - name: parent
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{parent}/tasks:
+ get:
+ operationId: run.projects.locations.jobs.executions.tasks.list
+ parameters:
+ - name: parent
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{resource}:getIamPolicy:
+ get:
+ operationId: run.projects.locations.services.getIamPolicy
+ parameters:
+ - name: resource
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{resource}:setIamPolicy:
+ post:
+ operationId: run.projects.locations.services.setIamPolicy
+ parameters:
+ - name: resource
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
+ /v2/{resource}:testIamPermissions:
+ post:
+ operationId: run.projects.locations.services.testIamPermissions
+ parameters:
+ - name: resource
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: ok
← da078d0e chore(main): release 4.0.2 (#684)
·
back to Cli Printing Press
·
chore(main): release 4.0.3 (#686) 2c538be1 →