← back to Cli Printing Press
feat(cli): JSON:API support for OpenAPI parser (envelope, page[cursor], x-prefix) (#505)
5584818b5ce40dab67923a0ed5ffb55944bb6757 · 2026-05-02 12:50:39 -0500 · Cathryn Lavery
* feat(cli): flatten JSON:API resource objects in OpenAPI parser
Detect canonical JSON:API Resource Object shape (type + id + attributes
discriminator) in mapTypes() and hoist `attributes.*` to the top of the
generated TypeDef. Without this, every JSON:API spec produces TypeDefs
where `attributes` is one opaque field and the real columns/flags are
unreachable to downstream consumers (store schema, sync, --select flags).
Gated tight: the trio of type (string) + id (string) + attributes (object)
is the discriminator. Vanilla REST specs (Stripe, HubSpot, Twilio) never
co-locate all three, so the change is byte-compat for them. Verified by
the existing golden suite (9/9 PASS, no fixture updates).
Relationships → foreign-key columns is deferred; v1 omits relationships
entirely from the flattened projection.
Adds testdata/openapi/jsonapi-petstore.yaml fixture and three test cases
(discriminator pinning, JSON:API flattening, non-JSON:API regression).
* feat(cli): support JSON:API cursor pagination and auth prefixes
* refactor(cli): simplify JSON:API cursor and x-prefix auth code
- Drop unreachable strings.Index/url.ParseQuery fallback in
nextCursorFromLinks: url.Parse already handles unencoded brackets
(e.g. ?page[cursor]=raw) and the primary path covers it.
- Flatten the 4-level nested x-prefix conditional in mapAuth to 2 levels
by combining the map lookup and string type assertion (nil interface
asserts to ("", false), so behavior is identical).
- Drop the Go 1.21-era tc := tc loop capture in the discriminator test;
unnecessary under per-iteration scoping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M docs/SPEC-EXTENSIONS.mdM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/config.go.tmplM internal/generator/templates/sync.go.tmplM internal/openapi/parser.goA internal/openapi/parser_jsonapi_test.goM internal/profiler/profiler.goA testdata/openapi/jsonapi-petstore.yaml
Diff
commit 5584818b5ce40dab67923a0ed5ffb55944bb6757
Author: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com>
Date: Sat May 2 12:50:39 2026 -0500
feat(cli): JSON:API support for OpenAPI parser (envelope, page[cursor], x-prefix) (#505)
* feat(cli): flatten JSON:API resource objects in OpenAPI parser
Detect canonical JSON:API Resource Object shape (type + id + attributes
discriminator) in mapTypes() and hoist `attributes.*` to the top of the
generated TypeDef. Without this, every JSON:API spec produces TypeDefs
where `attributes` is one opaque field and the real columns/flags are
unreachable to downstream consumers (store schema, sync, --select flags).
Gated tight: the trio of type (string) + id (string) + attributes (object)
is the discriminator. Vanilla REST specs (Stripe, HubSpot, Twilio) never
co-locate all three, so the change is byte-compat for them. Verified by
the existing golden suite (9/9 PASS, no fixture updates).
Relationships → foreign-key columns is deferred; v1 omits relationships
entirely from the flattened projection.
Adds testdata/openapi/jsonapi-petstore.yaml fixture and three test cases
(discriminator pinning, JSON:API flattening, non-JSON:API regression).
* feat(cli): support JSON:API cursor pagination and auth prefixes
* refactor(cli): simplify JSON:API cursor and x-prefix auth code
- Drop unreachable strings.Index/url.ParseQuery fallback in
nextCursorFromLinks: url.Parse already handles unencoded brackets
(e.g. ?page[cursor]=raw) and the primary path covers it.
- Flatten the 4-level nested x-prefix conditional in mapAuth to 2 levels
by combining the map lookup and string type assertion (nil interface
asserts to ("", false), so behavior is identical).
- Drop the Go 1.21-era tc := tc loop capture in the discriminator test;
unnecessary under per-iteration scoping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
docs/SPEC-EXTENSIONS.md | 27 ++++
internal/generator/generator.go | 2 +-
internal/generator/generator_test.go | 90 +++++++++++++
internal/generator/templates/config.go.tmpl | 5 +
internal/generator/templates/sync.go.tmpl | 54 +++++++-
internal/openapi/parser.go | 81 +++++++++++-
internal/openapi/parser_jsonapi_test.go | 190 ++++++++++++++++++++++++++++
internal/profiler/profiler.go | 1 +
testdata/openapi/jsonapi-petstore.yaml | 138 ++++++++++++++++++++
9 files changed, 582 insertions(+), 6 deletions(-)
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 660178c8..5fd029df 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -17,6 +17,7 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
| `x-proxy-routes` | `info` | `APISpec.ProxyRoutes` | No |
| `x-auth-type` | `components.securitySchemes.<name>` | `APISpec.Auth.Type` | No |
| `x-auth-format` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
+| `x-prefix` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
| `x-auth-cookie-domain` | `components.securitySchemes.<name>` | `APISpec.Auth.CookieDomain` | No |
| `x-auth-cookies` | `components.securitySchemes.<name>` | `APISpec.Auth.Cookies` | No |
| `x-resource-id` | path item | `Endpoint.IDField` | No |
@@ -150,6 +151,32 @@ Rules:
- Only read when `x-auth-type: composed`.
- Must be a string.
+### `x-prefix`
+
+Declares a literal token prefix for header API key schemes.
+
+Parsed field: `APISpec.Auth.Format`
+
+Rules:
+- Optional.
+- Only read for OpenAPI `apiKey` security schemes with `in: header`.
+- Must be a string.
+- Leading and trailing whitespace is trimmed.
+- When present, the parser stores `"<prefix> {token}"` in `Auth.Format`.
+- Ignored for query API keys and non-API-key auth schemes.
+
+Example:
+
+```yaml
+components:
+ securitySchemes:
+ apiKey:
+ type: apiKey
+ in: header
+ name: Authorization
+ x-prefix: Klaviyo-API-Key
+```
+
### `x-auth-cookie-domain`
Domain used when extracting named cookies for composed auth.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 13af54da..f9f2dcc2 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2277,7 +2277,7 @@ func isCursorParam(name string) bool {
switch lower {
case "cursor", "min_cursor", "max_cursor", "next_cursor",
"page", "page_token", "next_page_token",
- "min_time", "max_time", "offset":
+ "page[cursor]", "min_time", "max_time", "offset":
return true
}
return false
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 62557981..3c1cad90 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -567,6 +567,59 @@ func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
assert.NotContains(t, content, "bearer-pp-cli auth login\n\nclaude mcp add bearer bearer-pp-mcp")
}
+func TestGenerateAPIKeyAuthFormatSupportsTokenPlaceholder(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "prefix",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ In: "header",
+ Header: "Authorization",
+ Format: "Klaviyo-API-Key {token}",
+ EnvVars: []string{"PREFIX_API_KEY"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/prefix-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "items": {
+ Description: "Manage items",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/items",
+ Description: "List items",
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ const inlineTest = `package config
+
+import "testing"
+
+func TestAPIKeyFormatTokenPlaceholder(t *testing.T) {
+ cfg := &Config{PrefixApiKey: "secret"}
+ if got := cfg.AuthHeader(); got != "Klaviyo-API-Key secret" {
+ t.Fatalf("AuthHeader() = %q, want %q", got, "Klaviyo-API-Key secret")
+ }
+}
+`
+ testPath := filepath.Join(outputDir, "internal", "config", "auth_format_test.go")
+ require.NoError(t, os.WriteFile(testPath, []byte(inlineTest), 0o644))
+
+ runGoCommandRequired(t, outputDir, "test", "./internal/config")
+}
+
func countFiles(t *testing.T, root string) int {
t.Helper()
@@ -1548,6 +1601,8 @@ func TestSyncExtractPaginationNestedCursor(t *testing.T) {
// Tier 1: emission canary. The wrapper-key recursion must ship.
assert.Contains(t, src, "paginationWrapperKeys",
"sync.go must declare paginationWrapperKeys for nested-cursor support")
+ assert.Contains(t, src, "nextCursorFromLinks",
+ "sync.go must parse JSON:API links.next cursors")
assert.Contains(t, src, `"response_metadata"`,
"paginationWrapperKeys must include response_metadata (Slack's envelope)")
assert.Contains(t, src, `"pagination"`,
@@ -1597,6 +1652,41 @@ func TestExtractPageItemsMongoPaginationEnvelope(t *testing.T) {
}
}
+func TestExtractPageItemsJSONAPILinksNext(t *testing.T) {
+ body := []byte(` + "`" + `{
+ "data": [{"id":"pet_1"},{"id":"pet_2"}],
+ "links": {
+ "next": "https://api.example.com/pets?page%5Bcursor%5D=opaque-cursor&page%5Bsize%5D=2"
+ }
+ }` + "`" + `)
+ items, cursor, hasMore := extractPageItems(json.RawMessage(body), "page[cursor]")
+ if len(items) != 2 {
+ t.Fatalf("want 2 items, got %d", len(items))
+ }
+ if cursor != "opaque-cursor" {
+ t.Fatalf("want cursor opaque-cursor, got %q", cursor)
+ }
+ if !hasMore {
+ t.Fatalf("want hasMore=true when links.next cursor is present")
+ }
+}
+
+func TestExtractPageItemsJSONAPILinksNextUnencodedBrackets(t *testing.T) {
+ body := []byte(` + "`" + `{
+ "data": [{"id":"pet_1"}],
+ "links": {
+ "next": "https://api.example.com/pets?page[cursor]=raw-brackets"
+ }
+ }` + "`" + `)
+ _, cursor, hasMore := extractPageItems(json.RawMessage(body), "page[cursor]")
+ if cursor != "raw-brackets" {
+ t.Fatalf("want cursor raw-brackets, got %q", cursor)
+ }
+ if !hasMore {
+ t.Fatalf("want hasMore=true when links.next cursor is present")
+ }
+}
+
// Fast path: top-level cursor with no wrapper present at all. Proves
// the common case (Stripe, GitHub, Linear, Notion) doesn't enter the
// wrapper recursion. Pairs with TopLevelCursorWinsOverWrapper which
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 5101caa5..908a1b1a 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -129,10 +129,15 @@ func (c *Config) AuthHeader() string {
{{- end}}
{{- if .Auth.Format}}
replacements := map[string]string{
+ "token": token,
{{- range .Auth.EnvVars}}
+ {{- if ne (envVarPlaceholder .) "token"}}
"{{envVarPlaceholder .}}": c.{{resolveEnvVarField .}},
+ {{- end}}
+ {{- if ne . "token"}}
"{{.}}": c.{{resolveEnvVarField .}},
{{- end}}
+ {{- end}}
}
return applyAuthFormat("{{.Auth.Format}}", replacements)
{{- else if gt (len .Auth.EnvVars) 0}}
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index e629701e..7cfff962 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -6,6 +6,7 @@ package cli
import (
"encoding/json"
"fmt"
+ "net/url"
"os"
"regexp"
"strconv"
@@ -618,12 +619,16 @@ func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessa
func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {
var hasMore bool
+ nextCursor := nextCursorFromLinks(envelope, cursorParam)
+
// Try common cursor field names
cursorKeys := []string{
"next_cursor", "nextCursor", "cursor", "next_page_token",
"nextPageToken", "page_token", "after", "end_cursor", "endCursor",
}
- nextCursor := findCursorInMap(envelope, cursorKeys)
+ if nextCursor == "" {
+ nextCursor = findCursorInMap(envelope, cursorKeys)
+ }
// If no top-level cursor was found, look one level deeper into well-known
// pagination wrapper objects. Slack returns {"messages":[...],
@@ -667,6 +672,53 @@ func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorPa
return nextCursor, hasMore
}
+// nextCursorFromLinks extracts JSON:API-style pagination cursors from
+// {"links":{"next":"https://example.com/items?page[cursor]=..."}}.
+func nextCursorFromLinks(envelope map[string]json.RawMessage, cursorParam string) string {
+ rawLinks, ok := envelope["links"]
+ if !ok {
+ return ""
+ }
+ var links map[string]json.RawMessage
+ if json.Unmarshal(rawLinks, &links) != nil {
+ return ""
+ }
+ rawNext, ok := links["next"]
+ if !ok {
+ return ""
+ }
+ var nextURL string
+ if json.Unmarshal(rawNext, &nextURL) != nil || nextURL == "" {
+ return ""
+ }
+
+ cursorKeys := []string{cursorParam}
+ if cursorParam != "page[cursor]" {
+ cursorKeys = append(cursorKeys, "page[cursor]")
+ }
+ if cursorParam != "cursor" {
+ cursorKeys = append(cursorKeys, "cursor")
+ }
+ if cursorParam != "after" {
+ cursorKeys = append(cursorKeys, "after")
+ }
+
+ parsed, err := url.Parse(nextURL)
+ if err != nil {
+ return ""
+ }
+ values := parsed.Query()
+ for _, key := range cursorKeys {
+ if key == "" {
+ continue
+ }
+ if cursor := values.Get(key); cursor != "" {
+ return cursor
+ }
+ }
+ return ""
+}
+
// findCursorInMap returns the first non-empty string-typed value in m
// whose key matches one of cursorKeys. Used by extractPaginationFromEnvelope
// to scan both the top-level envelope and well-known wrapper objects with
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index e22a871e..eeff4aad 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -385,8 +385,15 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
break
}
}
+ if auth.Type == "api_key" && strings.EqualFold(auth.In, "header") {
+ if prefix, ok := scheme.Extensions["x-prefix"].(string); ok {
+ if prefix = strings.TrimSpace(prefix); prefix != "" {
+ auth.Format = prefix + " {token}"
+ }
+ }
+ }
// Detect bot token pattern from scheme name (e.g. "BotToken")
- if strings.Contains(strings.ToLower(schemeName), "bot") && strings.EqualFold(auth.Header, "Authorization") {
+ if auth.Format == "" && strings.Contains(strings.ToLower(schemeName), "bot") && strings.EqualFold(auth.Header, "Authorization") {
auth.Format = "Bot {bot_token}"
}
case "oauth2":
@@ -2115,7 +2122,11 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
}
properties := map[string]*openapi3.SchemaRef{}
- collectTypeProperties(schemaRef, properties, map[*openapi3.Schema]struct{}{})
+ if isJSONAPIResourceSchema(schema) {
+ jsonAPIFlattenInto(schema, properties)
+ } else {
+ collectTypeProperties(schemaRef, properties, map[*openapi3.Schema]struct{}{})
+ }
fieldNames := make([]string, 0, len(properties))
for fieldName := range properties {
@@ -2289,6 +2300,68 @@ func isObjectSchema(schema *openapi3.Schema) bool {
return len(schema.Properties) > 0 || len(schema.AllOf) > 0
}
+// isJSONAPIResourceSchema reports whether the schema is a JSON:API
+// (jsonapi.org/format) Resource Object: a string `type` discriminator,
+// a string `id`, and an `attributes` object. The trio is tight enough
+// that vanilla REST specs (Stripe, HubSpot, Twilio) never accidentally
+// match — they don't co-locate type+id+attributes.
+func isJSONAPIResourceSchema(schema *openapi3.Schema) bool {
+ if !isObjectSchema(schema) {
+ return false
+ }
+ typeRef, hasType := schema.Properties["type"]
+ if !hasType {
+ return false
+ }
+ typeVal := schemaRefValue(typeRef)
+ if typeVal == nil || typeVal.Type == nil || !typeVal.Type.Includes(openapi3.TypeString) {
+ return false
+ }
+ idRef, hasID := schema.Properties["id"]
+ if !hasID {
+ return false
+ }
+ idVal := schemaRefValue(idRef)
+ if idVal == nil || idVal.Type == nil || !idVal.Type.Includes(openapi3.TypeString) {
+ return false
+ }
+ attrsRef, hasAttrs := schema.Properties["attributes"]
+ if !hasAttrs {
+ return false
+ }
+ if !isObjectSchema(schemaRefValue(attrsRef)) {
+ return false
+ }
+ return true
+}
+
+// jsonAPIFlattenInto populates properties with the canonical projection
+// of a JSON:API Resource Object: id (preserved), attributes.* (hoisted
+// to top-level), discriminator `type` dropped (constant per resource).
+// Relationships are intentionally omitted in this pass; foreign-key
+// extraction is a follow-up.
+func jsonAPIFlattenInto(schema *openapi3.Schema, properties map[string]*openapi3.SchemaRef) {
+ if schema == nil {
+ return
+ }
+ if idRef, ok := schema.Properties["id"]; ok && idRef != nil {
+ properties["id"] = idRef
+ }
+ attrsRef, ok := schema.Properties["attributes"]
+ if !ok || attrsRef == nil || attrsRef.Value == nil {
+ return
+ }
+ for name, prop := range attrsRef.Value.Properties {
+ if prop == nil {
+ continue
+ }
+ if strings.HasPrefix(name, "_") {
+ continue
+ }
+ properties[name] = prop
+ }
+}
+
func isComplexBodyFieldSchema(schema *openapi3.Schema) bool {
if isObjectSchema(schema) {
return true
@@ -3208,7 +3281,7 @@ func detectPagination(params []spec.Param, op *openapi3.Operation) *spec.Paginat
var pag spec.Pagination
// Detect limit param
- for _, name := range []string{"limit", "maxresults", "pagesize", "page_size", "max_results", "per_page"} {
+ for _, name := range []string{"limit", "maxresults", "pagesize", "page_size", "max_results", "per_page", "page[size]"} {
if _, ok := paramNames[name]; ok {
pag.LimitParam = name
break
@@ -3225,7 +3298,7 @@ func detectPagination(params []spec.Param, op *openapi3.Operation) *spec.Paginat
}
}
if pag.Type == "" {
- for _, name := range []string{"after", "cursor"} {
+ for _, name := range []string{"after", "cursor", "page[cursor]"} {
if _, ok := paramNames[name]; ok {
pag.CursorParam = name
pag.Type = "cursor"
diff --git a/internal/openapi/parser_jsonapi_test.go b/internal/openapi/parser_jsonapi_test.go
new file mode 100644
index 00000000..020c9c3d
--- /dev/null
+++ b/internal/openapi/parser_jsonapi_test.go
@@ -0,0 +1,190 @@
+package openapi
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/getkin/kin-openapi/openapi3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func stringSchema() *openapi3.SchemaRef {
+ return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{openapi3.TypeString}}}
+}
+
+func intSchema() *openapi3.SchemaRef {
+ return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{openapi3.TypeInteger}}}
+}
+
+func objectSchema(props map[string]*openapi3.SchemaRef) *openapi3.Schema {
+ return &openapi3.Schema{
+ Type: &openapi3.Types{openapi3.TypeObject},
+ Properties: props,
+ }
+}
+
+// TestParseJSONAPI_FlattensAttributes verifies the parser hoists the
+// `attributes` sub-object of a JSON:API Resource Object to the top of the
+// generated TypeDef. Without this, every JSON:API spec produces TypeDefs
+// where `attributes` is one opaque field and the real columns/flags
+// (email, name, etc.) are unreachable to downstream consumers.
+//
+// JSON:API Resource Object discriminator: schema has all of
+// `type` (string), `id` (string), and `attributes` (object). Stripe and
+// HubSpot do not have this combination, so the flattening must be a no-op
+// for them — guarded by detection, not applied universally.
+func TestParseJSONAPI_FlattensAttributes(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "jsonapi-petstore.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ require.NotEmpty(t, parsed.Types)
+ require.Contains(t, parsed.Types, "Pet", "Pet TypeDef must be emitted")
+
+ pet := parsed.Types["Pet"]
+ fieldNames := map[string]string{}
+ for _, f := range pet.Fields {
+ fieldNames[f.Name] = f.Type
+ }
+
+ assert.Contains(t, fieldNames, "id", "primary key id must be preserved")
+ assert.Contains(t, fieldNames, "name", "attributes.name must be hoisted to top-level")
+ assert.Contains(t, fieldNames, "species", "attributes.species must be hoisted to top-level")
+ assert.Contains(t, fieldNames, "age", "attributes.age must be hoisted to top-level")
+
+ assert.NotContains(t, fieldNames, "type", "JSON:API discriminator `type` must be dropped")
+ assert.NotContains(t, fieldNames, "attributes", "envelope wrapper `attributes` must not survive flattening")
+}
+
+func TestParseJSONAPI_DetectsBracketedCursorPagination(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "jsonapi-petstore.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ pets := parsed.Resources["pets"]
+ list := pets.Endpoints["list"]
+ require.NotNil(t, list.Pagination, "list endpoint must detect JSON:API page[cursor] pagination")
+ assert.Equal(t, "cursor", list.Pagination.Type)
+ assert.Equal(t, "page[cursor]", list.Pagination.CursorParam)
+ assert.Equal(t, "page[size]", list.Pagination.LimitParam)
+}
+
+func TestParseJSONAPI_MapsAPIKeyPrefixExtension(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "jsonapi-petstore.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ assert.Equal(t, "api_key", parsed.Auth.Type)
+ assert.Equal(t, "Authorization", parsed.Auth.Header)
+ assert.Equal(t, "header", parsed.Auth.In)
+ assert.Equal(t, "Token-Prefix {token}", parsed.Auth.Format)
+}
+
+// TestParseJSONAPI_LeavesNonJSONAPISpecsAlone confirms the flattening is
+// gated. Existing OpenAPI fixtures (Stripe shape, HubSpot shape) must
+// produce byte-identical TypeDefs to before. petstore.yaml uses a flat
+// shape; if its Pet TypeDef changes after this commit, the gate is wrong.
+func TestParseJSONAPI_LeavesNonJSONAPISpecsAlone(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ require.Contains(t, parsed.Types, "Pet")
+
+ pet := parsed.Types["Pet"]
+ fieldNames := map[string]bool{}
+ for _, f := range pet.Fields {
+ fieldNames[f.Name] = true
+ }
+
+ // petstore's Pet has top-level name, status, photoUrls — no JSON:API
+ // envelope. These must remain present and unmoved.
+ assert.True(t, fieldNames["name"], "petstore Pet.name must be present (regression check)")
+ assert.True(t, fieldNames["id"], "petstore Pet.id must be present (regression check)")
+}
+
+// TestIsJSONAPIResource_Discriminator pins the gating logic. A schema is a
+// JSON:API Resource Object only when type+id+attributes are all present
+// AND have the right shape. Adjacent shapes (only id+attributes, only
+// type+id) must NOT match — they show up in vanilla REST specs.
+func TestIsJSONAPIResource_Discriminator(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ schema *openapi3.Schema
+ expected bool
+ }{
+ {
+ name: "canonical JSON:API resource object matches",
+ schema: objectSchema(map[string]*openapi3.SchemaRef{
+ "type": stringSchema(),
+ "id": stringSchema(),
+ "attributes": {Value: objectSchema(map[string]*openapi3.SchemaRef{
+ "foo": stringSchema(),
+ })},
+ }),
+ expected: true,
+ },
+ {
+ name: "missing attributes does not match",
+ schema: objectSchema(map[string]*openapi3.SchemaRef{
+ "type": stringSchema(),
+ "id": stringSchema(),
+ "foo": stringSchema(),
+ }),
+ expected: false,
+ },
+ {
+ name: "non-object attributes does not match",
+ schema: objectSchema(map[string]*openapi3.SchemaRef{
+ "type": stringSchema(),
+ "id": stringSchema(),
+ "attributes": stringSchema(),
+ }),
+ expected: false,
+ },
+ {
+ name: "non-string id does not match",
+ schema: objectSchema(map[string]*openapi3.SchemaRef{
+ "type": stringSchema(),
+ "id": intSchema(),
+ "attributes": {Value: objectSchema(nil)},
+ }),
+ expected: false,
+ },
+ {
+ name: "missing type does not match",
+ schema: objectSchema(map[string]*openapi3.SchemaRef{
+ "id": stringSchema(),
+ "attributes": {Value: objectSchema(map[string]*openapi3.SchemaRef{
+ "foo": stringSchema(),
+ })},
+ }),
+ expected: false,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := isJSONAPIResourceSchema(tc.schema)
+ assert.Equal(t, tc.expected, got)
+ })
+ }
+}
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 47953626..b53af569 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -638,6 +638,7 @@ func hasRequiredScopeParams(endpoint spec.Endpoint) bool {
paginationParams := map[string]bool{
"limit": true, "per_page": true, "page_size": true, "pageSize": true, "first": true, "count": true, "max_results": true,
"after": true, "cursor": true, "page_token": true, "offset": true, "page": true, "before": true, "starting_after": true,
+ "page[cursor]": true, "page[size]": true,
"since": true, "updated_after": true, "modified_since": true, "since_id": true,
"key": true, "format": true, // auth and format params, not scope
}
diff --git a/testdata/openapi/jsonapi-petstore.yaml b/testdata/openapi/jsonapi-petstore.yaml
new file mode 100644
index 00000000..a50cefd3
--- /dev/null
+++ b/testdata/openapi/jsonapi-petstore.yaml
@@ -0,0 +1,138 @@
+openapi: 3.0.0
+info:
+ title: JSON:API Petstore
+ version: 1.0.0
+ description: Minimal JSON:API spec used as a generator-side fixture for envelope flattening, page[cursor] pagination, and x-prefix auth tests.
+servers:
+ - url: https://example.com/api
+paths:
+ /pets:
+ get:
+ summary: List pets
+ operationId: listPets
+ parameters:
+ - name: page[cursor]
+ in: query
+ schema: { type: string }
+ - name: page[size]
+ in: query
+ schema: { type: integer }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/PetList' }
+ post:
+ summary: Create pet
+ operationId: createPet
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/PetCreate' }
+ responses:
+ '201':
+ description: Created
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/PetSingle' }
+ /pets/{id}:
+ get:
+ summary: Get pet
+ operationId: getPet
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/PetSingle' }
+ delete:
+ summary: Delete pet
+ operationId: deletePet
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ '204':
+ description: No content
+components:
+ securitySchemes:
+ apiKey:
+ type: apiKey
+ in: header
+ name: Authorization
+ x-prefix: Token-Prefix
+ description: API key auth via Authorization header with Token-Prefix prefix.
+ schemas:
+ Pet:
+ type: object
+ required: [type, id, attributes]
+ properties:
+ type:
+ type: string
+ enum: [pet]
+ id:
+ type: string
+ attributes:
+ type: object
+ required: [name, species]
+ properties:
+ name:
+ type: string
+ species:
+ type: string
+ age:
+ type: integer
+ relationships:
+ type: object
+ properties:
+ owner:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ type: { type: string }
+ id: { type: string }
+ PetList:
+ type: object
+ properties:
+ data:
+ type: array
+ items: { $ref: '#/components/schemas/Pet' }
+ links:
+ type: object
+ properties:
+ next: { type: string }
+ PetSingle:
+ type: object
+ properties:
+ data: { $ref: '#/components/schemas/Pet' }
+ PetCreate:
+ type: object
+ properties:
+ data:
+ type: object
+ required: [type, attributes]
+ properties:
+ type:
+ type: string
+ enum: [pet]
+ attributes:
+ type: object
+ required: [name, species]
+ properties:
+ name:
+ type: string
+ species:
+ type: string
+security:
+ - apiKey: []
← 6e2562d8 docs(cli): bump README "Go 1.22+" prerequisite to "Go 1.26+"
·
back to Cli Printing Press
·
chore(main): release 3.5.0 (#501) 401fa7c8 →