Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/openapi/commands/openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 4 additions & 2 deletions cmd/openapi/commands/openapi/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
3 changes: 2 additions & 1 deletion oq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
48 changes: 48 additions & 0 deletions oq/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion oq/oq.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
92 changes: 92 additions & 0 deletions oq/oq_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions oq/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down