← back to Cli Printing Press
fix(cli): refresh stale catalog entries and reject non-spec bodies (#286)
ee6bd881eca4e1eb1563968e54cd082bd725fecb · 2026-04-25 12:17:43 -0700 · Trevin Chow
Files touched
M catalog/asana.yamlD catalog/sendgrid.yamlD catalog/square.yamlM internal/cli/root.goA internal/cli/spec_content_check.goA internal/cli/spec_content_check_test.go
Diff
commit ee6bd881eca4e1eb1563968e54cd082bd725fecb
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat Apr 25 12:17:43 2026 -0700
fix(cli): refresh stale catalog entries and reject non-spec bodies (#286)
---
catalog/asana.yaml | 2 +-
catalog/sendgrid.yaml | 11 ------
catalog/square.yaml | 11 ------
internal/cli/root.go | 15 ++++++--
internal/cli/spec_content_check.go | 45 ++++++++++++++++++++++
internal/cli/spec_content_check_test.go | 68 +++++++++++++++++++++++++++++++++
6 files changed, 126 insertions(+), 26 deletions(-)
diff --git a/catalog/asana.yaml b/catalog/asana.yaml
index 119ae888..ec373e0e 100644
--- a/catalog/asana.yaml
+++ b/catalog/asana.yaml
@@ -2,7 +2,7 @@ name: asana
display_name: Asana
description: Work management and project tracking API
category: project-management
-spec_url: https://raw.githubusercontent.com/Asana/openapi/main/defs/asana_oas.yaml
+spec_url: https://raw.githubusercontent.com/Asana/openapi/master/defs/asana_oas.yaml
spec_format: yaml
openapi_version: "3.0"
tier: official
diff --git a/catalog/sendgrid.yaml b/catalog/sendgrid.yaml
deleted file mode 100644
index e215b5a6..00000000
--- a/catalog/sendgrid.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-name: sendgrid
-display_name: SendGrid
-description: Email delivery and marketing API
-category: marketing
-spec_url: https://raw.githubusercontent.com/twilio/sendgrid-oai/main/spec/json/sendgrid_oai.json
-spec_format: json
-openapi_version: "3.0"
-tier: official
-verified_date: "2026-03-23"
-homepage: https://docs.sendgrid.com/api-reference
-notes: None.
diff --git a/catalog/square.yaml b/catalog/square.yaml
deleted file mode 100644
index a09fdb5b..00000000
--- a/catalog/square.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-name: square
-display_name: Square
-description: Payment processing and commerce API
-category: payments
-spec_url: https://raw.githubusercontent.com/square/square-openapi/master/openapi.json
-spec_format: json
-openapi_version: "3.0"
-tier: official
-verified_date: "2026-03-23"
-homepage: https://developer.squareup.com/reference/square
-notes: None.
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 4a942e7b..319cb88f 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -641,11 +641,20 @@ func inferTrafficAnalysisPath(specFiles []string, specSource string) string {
}
func readSpec(specFile string, refresh bool, skipCache bool) ([]byte, error) {
+ var data []byte
+ var err error
if strings.HasPrefix(specFile, "http://") || strings.HasPrefix(specFile, "https://") {
- return fetchOrCacheSpec(specFile, refresh, skipCache)
+ data, err = fetchOrCacheSpec(specFile, refresh, skipCache)
+ } else {
+ data, err = os.ReadFile(specFile)
}
-
- return os.ReadFile(specFile)
+ if err != nil {
+ return nil, err
+ }
+ if rejectErr := rejectIfNotSpec(data); rejectErr != nil {
+ return nil, rejectErr
+ }
+ return data, nil
}
func mergeSpecs(specs []*spec.APISpec, name string) *spec.APISpec {
diff --git a/internal/cli/spec_content_check.go b/internal/cli/spec_content_check.go
new file mode 100644
index 00000000..0d103a4a
--- /dev/null
+++ b/internal/cli/spec_content_check.go
@@ -0,0 +1,45 @@
+package cli
+
+import (
+ "bytes"
+ "fmt"
+ "regexp"
+)
+
+// httpErrorBodyPattern matches the raw-content 404 / 5xx body GitHub serves
+// when a path is missing ("404: Not Found", "500: Internal Server Error").
+// Catches stale spec_urls whose download silently writes the error body to
+// disk and feeds it to the parser, where it surfaces several layers down as
+// a misleading "validation: name is required" or similar error.
+var httpErrorBodyPattern = regexp.MustCompile(`^[0-9]{3}: `)
+
+// rejectIfNotSpec returns a descriptive error when data plainly looks like an
+// HTTP error response or an HTML page rather than an OpenAPI / GraphQL / spec
+// document. Heuristic by design: only catches signatures that no legitimate
+// spec would produce. A real spec failing the parser is allowed through to
+// produce the parser's own error message; this gate fires only when the
+// content was never a spec in the first place.
+func rejectIfNotSpec(data []byte) error {
+ trimmed := bytes.TrimSpace(data)
+ if httpErrorBodyPattern.Match(trimmed) {
+ return fmt.Errorf("content looks like an HTTP error response, not a spec: %q", firstLineSnippet(trimmed))
+ }
+ lower := bytes.ToLower(trimmed)
+ if bytes.HasPrefix(lower, []byte("<html")) || bytes.HasPrefix(lower, []byte("<!doctype html")) {
+ return fmt.Errorf("content looks like an HTML page, not a spec")
+ }
+ return nil
+}
+
+// firstLineSnippet returns the first line of data, capped at 80 bytes, for
+// inclusion in error messages. Avoids dumping a full HTML page back at the
+// user when surfacing the rejection.
+func firstLineSnippet(data []byte) string {
+ if i := bytes.IndexByte(data, '\n'); i >= 0 {
+ data = data[:i]
+ }
+ if len(data) > 80 {
+ data = data[:80]
+ }
+ return string(data)
+}
diff --git a/internal/cli/spec_content_check_test.go b/internal/cli/spec_content_check_test.go
new file mode 100644
index 00000000..3ac03564
--- /dev/null
+++ b/internal/cli/spec_content_check_test.go
@@ -0,0 +1,68 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestRejectIfNotSpec covers issue #275 F-5's content-validity heuristic.
+// A stale catalog spec_url returns "404: Not Found" or an HTML error page;
+// the existing fetchOrCacheSpec status check catches HTTP-level errors, but
+// any path that hands a downloaded body to readSpec without that gate (a
+// user feeding a curl-saved 404 dump as --spec, a stale cache entry, an
+// upstream proxy that masks status codes) bypasses it. rejectIfNotSpec
+// surfaces a clear message at the boundary instead of letting the parser
+// produce "validation: name is required" two layers down.
+func TestRejectIfNotSpec(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ data []byte
+ wantErr bool
+ errSubstring string
+ }{
+ {"github raw 404 body", []byte("404: Not Found"), true, "HTTP error response"},
+ {"github raw 5xx body", []byte("500: Internal Server Error\n"), true, "HTTP error response"},
+ {"html error page", []byte("<html><body>Error</body></html>"), true, "HTML page"},
+ {"html5 doctype", []byte("<!DOCTYPE html>\n<html>..."), true, "HTML page"},
+ {"openapi yaml", []byte("openapi: 3.0.0\ninfo:\n title: x\n"), false, ""},
+ {"openapi json", []byte(`{"openapi":"3.0.0","info":{"title":"x"}}`), false, ""},
+ {"graphql sdl", []byte("type Query { hello: String }\n"), false, ""},
+ {"plain string with digits in middle", []byte("hello 404: world"), false, ""},
+ {"empty bytes", []byte(""), false, ""},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ err := rejectIfNotSpec(tc.data)
+ if tc.wantErr {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.errSubstring)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+// TestReadSpecRejectsLocal404Dump exercises the readSpec entry point with a
+// file containing a GitHub-raw 404 body. Captures the actual bug the F-5
+// reporter hit: their smoke methodology curled spec URLs and saved the
+// response body before invoking printing-press; stale URLs produced 14-byte
+// "404: Not Found" files that previously fed straight into the parser.
+func TestReadSpecRejectsLocal404Dump(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ stale := filepath.Join(dir, "stale.spec")
+ require.NoError(t, os.WriteFile(stale, []byte("404: Not Found"), 0o644))
+
+ _, err := readSpec(stale, false, true)
+ require.Error(t, err, "readSpec must reject a 404-shaped body")
+ assert.Contains(t, err.Error(), "HTTP error response")
+}
← 6361826d fix(cli): normalize object-shaped description fields before
·
back to Cli Printing Press
·
fix(cli): extend flag-identifier dedup to request body field 15529383 →