diff --git a/cmd/openapi/commands/openapi/README.md b/cmd/openapi/commands/openapi/README.md index 6393f13..98fe8d6 100644 --- a/cmd/openapi/commands/openapi/README.md +++ b/cmd/openapi/commands/openapi/README.md @@ -1260,7 +1260,7 @@ openapi spec query --format json 'schemas | where(isComponent) | take(5)' ./spec | Flag | Short | Description | | ---------- | ----- | --------------------------------------------------------------- | -| `--format` | | Output format: `table` (default), `json`, `markdown`, or `toon` | +| `--format` | | Output format: `table` (default), `json`, `markdown`, `toon`, or `gcf` | | `--file` | `-f` | Read query from file instead of argument | For the full query language reference, run `openapi spec query-reference`. diff --git a/cmd/openapi/commands/openapi/query.go b/cmd/openapi/commands/openapi/query.go index a3434aa..9c47a30 100644 --- a/cmd/openapi/commands/openapi/query.go +++ b/cmd/openapi/commands/openapi/query.go @@ -36,7 +36,7 @@ Pipeline stages: group-by(field), group-by(field, name_field), length Variables: let $var = expr Functions: def name: body; def name($p): body; include "file.oq"; - Output: to-yaml, format(table|json|markdown|toon) + Output: to-yaml, format(table|json|markdown|toon|gcf) Meta: explain, fields Operators: ==, !=, >, <, >=, <=, and, or, not, // (or default), has(), @@ -60,7 +60,7 @@ var queryOutputFormat string var queryFromFile string func init() { - queryCmd.Flags().StringVar(&queryOutputFormat, "format", "table", "output format: table, json, markdown, or toon") + queryCmd.Flags().StringVar(&queryOutputFormat, "format", "table", "output format: table, json, markdown, toon, or gcf") queryCmd.Flags().StringVarP(&queryFromFile, "file", "f", "", "read query from file instead of argument") // Custom help template: Usage + Flags together, then Examples last @@ -167,6 +167,8 @@ func queryOpenAPI(ctx context.Context, processor *OpenAPIProcessor, queryStr str output = oq.FormatMarkdown(result, g) case "toon": output = oq.FormatToon(result, g) + case "gcf": + output = oq.FormatGCF(result, g) default: output = oq.FormatTable(result, g) } diff --git a/go.mod b/go.mod index 8085aac..4cd3820 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/speakeasy-api/openapi go 1.25.0 require ( + github.com/blackwell-systems/gcf-go v1.2.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/speakeasy-api/jsonpath v0.6.3 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 1690795..b2513f7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/blackwell-systems/gcf-go v1.2.2 h1:oPc9VbC5DDMPek/RMWO3zVcImVr3QTOcCIgBOLcjNRk= +github.com/blackwell-systems/gcf-go v1.2.2/go.mod h1:E4fW1kxdrIoWxlI4iwZL8mh7BvdLTkE88NyijtGGcZc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/oq/README.md b/oq/README.md index 95a0518..d6886e3 100644 --- a/oq/README.md +++ b/oq/README.md @@ -113,7 +113,7 @@ Navigate into the internal structure of operations. These stages produce new row |-------|-------------| | `explain` | Print query plan | | `fields` | List available fields | -| `format(fmt)` | Set output format (table/json/markdown/toon) | +| `format(fmt)` | Set output format (table/json/markdown/toon/gcf) | | `to-yaml` | Output raw YAML nodes from underlying spec objects | The `to-yaml` stage uses `path` (JSON pointer) as the wrapper key for each emitted node, giving full attribution to the source location in the spec. @@ -349,6 +349,7 @@ openapi spec query 'schemas | take(5) | format(markdown)' spec.yaml | `json` | JSON array | | `markdown` | Markdown table | | `toon` | [TOON](https://github.com/toon-format/toon) tabular format | +| `gcf` | [GCF](https://gcformat.com) pipe-delimited format with inline schemas | ## Examples diff --git a/oq/format.go b/oq/format.go index 056bce4..ebbd592 100644 --- a/oq/format.go +++ b/oq/format.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + gcf "github.com/blackwell-systems/gcf-go" "github.com/speakeasy-api/openapi/graph" "github.com/speakeasy-api/openapi/oq/expr" "gopkg.in/yaml.v3" @@ -221,6 +222,53 @@ func FormatToon(result *Result, g *graph.SchemaGraph) string { return sb.String() } +// FormatGCF formats a result using GCF (Graph Compact Format). +// GCF uses positional pipe-delimited rows with inline schemas. +// See https://gcformat.com +func FormatGCF(result *Result, g *graph.SchemaGraph) string { + if result.Explain != "" { + return result.Explain + } + + if result.IsCount { + return gcf.EncodeGeneric(map[string]interface{}{"count": result.Count}) + } + + syncGroupsFromRows(result) + + if len(result.Rows) == 0 { + return gcf.EncodeGeneric([]interface{}{}) + } + + fields := result.Fields + if len(fields) == 0 { + fields = resolveDefaultFields(result.Rows) + } + + rows := make([]interface{}, 0, len(result.Rows)) + for _, row := range result.Rows { + m := make(map[string]interface{}, len(fields)) + for _, f := range fields { + v := fieldValue(row, f, g) + switch v.Kind { + case expr.KindString: + m[f] = v.Str + case expr.KindInt: + m[f] = v.Int + case expr.KindBool: + m[f] = v.Bool + case expr.KindArray: + m[f] = v.Arr + default: + m[f] = nil + } + } + rows = append(rows, m) + } + + return gcf.EncodeGeneric(rows) +} + // FormatYAML formats results as raw YAML from the underlying schema/operation objects. // For multiple results, outputs a YAML stream with --- separators. // This enables piping into yq for content-level queries. diff --git a/oq/oq.go b/oq/oq.go index 307f5cc..90a2715 100644 --- a/oq/oq.go +++ b/oq/oq.go @@ -85,7 +85,7 @@ type Result struct { Count int Groups []GroupResult Explain string // human-readable pipeline explanation - FormatHint string // format preference from format stage (table, json, markdown, toon) + FormatHint string // format preference from format stage (table, json, markdown, toon, gcf) EmitYAML bool // emit raw YAML nodes instead of formatted output } diff --git a/oq/oq_test.go b/oq/oq_test.go index 85d0543..bd4b7ad 100644 --- a/oq/oq_test.go +++ b/oq/oq_test.go @@ -1412,6 +1412,98 @@ func TestFormatToon_Explain(t *testing.T) { assert.Contains(t, toon, "Source: schemas", "toon should render explain output") } +func TestFormatGCF_Success(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + + result, err := oq.Execute("schemas | where(isComponent) | take(3) | select name, type", g) + require.NoError(t, err) + + out := oq.FormatGCF(result, g) + assert.Contains(t, out, "GCF profile=generic", "gcf should have profile header") + assert.Contains(t, out, "{name,type}", "gcf should declare field names") + assert.Contains(t, out, "object", "gcf should include object type value") +} + +func TestFormatGCF_Count_Success(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + + result, err := oq.Execute("schemas | length", g) + require.NoError(t, err) + + out := oq.FormatGCF(result, g) + assert.Contains(t, out, "count=", "gcf count should use key=value format") +} + +func TestFormatGCF_Groups_Success(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + + result, err := oq.Execute("schemas | where(isComponent) | group-by(type)", g) + require.NoError(t, err) + + out := oq.FormatGCF(result, g) + assert.Contains(t, out, "GCF profile=generic", "gcf should have profile header") + assert.Contains(t, out, "{count,key,names}", "gcf should declare group fields") +} + +func TestFormatGCF_Empty_Success(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + + result, err := oq.Execute(`schemas | where(isComponent) | where(name == "NonExistent")`, g) + require.NoError(t, err) + + out := oq.FormatGCF(result, g) + assert.Contains(t, out, "GCF profile=generic", "empty gcf should still have profile header") +} + +func TestFormatGCF_BoolAndIntFields(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + + result, err := oq.Execute("schemas | where(isComponent) | take(1) | select name, depth, isComponent", g) + require.NoError(t, err) + + out := oq.FormatGCF(result, g) + assert.NotEmpty(t, out, "gcf output should not be empty") + assert.Contains(t, out, "GCF profile=generic", "gcf should have profile header") +} + +func TestFormatGCF_SpecialChars(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + + result, err := oq.Execute("schemas | where(isComponent) | take(1) | select name, hash, location", g) + require.NoError(t, err) + + out := oq.FormatGCF(result, g) + assert.Contains(t, out, "GCF profile=generic", "gcf should have profile header") + assert.Contains(t, out, "{hash,location,name}", "gcf should declare fields with special-char values") +} + +func TestFormatGCF_Explain(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + + result, err := oq.Execute("schemas | where(depth > 0) | explain", g) + require.NoError(t, err) + + out := oq.FormatGCF(result, g) + assert.Contains(t, out, "Source: schemas", "gcf should render explain output as-is") +} + +func TestFormatGCF_InlinePipeline(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + + result, err := oq.Execute("schemas | where(isComponent) | take(3) | format(gcf)", g) + require.NoError(t, err) + + assert.Equal(t, "gcf", result.FormatHint, "format(gcf) should set FormatHint") +} + func TestFormatMarkdown_Explain(t *testing.T) { t.Parallel() g := loadTestGraph(t) diff --git a/oq/parse.go b/oq/parse.go index 66a29bb..666c81f 100644 --- a/oq/parse.go +++ b/oq/parse.go @@ -322,8 +322,8 @@ func parseStage(s string) (Stage, error) { case "format": f := strings.TrimSpace(args) - if f != "table" && f != "json" && f != "markdown" && f != "toon" { - return Stage{}, fmt.Errorf("format must be table, json, markdown, or toon, got %q", f) + if f != "table" && f != "json" && f != "markdown" && f != "toon" && f != "gcf" { + return Stage{}, fmt.Errorf("format must be table, json, markdown, toon, or gcf, got %q", f) } return Stage{Kind: StageFormat, Format: f}, nil