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
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,8 @@ Grouping rules:
GraphQL commands execute `POST /graphql` with a baked `{query, variables}`
request envelope. Scalar and enum arguments become typed flags that merge under
`variables`. Input-object arguments expand scalar and enum leaf fields into
dotted variable flags such as `--input-name`; required fields that cannot be
represented as flags fail codegen instead of publishing an incomplete command.
Optional complex fields can still be supplied with `--set` or `--file`.
dotted variable flags such as `--input-name`; complex fields remain in the
request body schema and are supplied with `--set` or `--file`.
Relay-style outputs with `list_path: data.<operation>.nodes` or `.edges`,
`pageInfo.endCursor`, `pageInfo.hasNextPage`, and an `after` argument get
`--all`; subsequent pages write the cursor back under `variables.after`.
Expand Down
6 changes: 3 additions & 3 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,9 @@ Optional GraphQL policy blocks tune the generated CLI contract:
GraphQL commands execute `POST /graphql` with a baked JSON body template:
`{"query": "...", "variables": {}}`. Scalar and enum arguments become typed CLI
flags and merge under `variables`. Input-object arguments expand scalar and enum
leaf fields into dotted variable flags such as `--input-name`; required fields
that cannot be faithfully represented as flags fail codegen. Optional complex
fields remain query-declared and can be supplied with `--set` or `--file`.
leaf fields into dotted variable flags such as `--input-name`; complex fields
remain query-declared in the body schema and can be supplied with `--set` or
`--file`.
Relay-style outputs with `list_path: data.<operation>.nodes` or `.edges`,
`pageInfo.endCursor`, `pageInfo.hasNextPage`, and an `after` argument get
`--all`; subsequent pages write the cursor back under `variables.after`.
Expand Down
77 changes: 60 additions & 17 deletions internal/codegen/backends/graphql/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,21 @@ func (g *generator) operation(opType string, field *ast.FieldDefinition) (rawir.
var varDefs, argList []string
var params []rawir.RawParameter
variableDefaults := map[string]any{}
variablesSchema := &rawir.RawSchema{Type: "object", Properties: map[string]*rawir.RawSchema{}}
for _, arg := range field.Arguments {
varDefs = append(varDefs, "$"+arg.Name+": "+arg.Type.String())
argList = append(argList, arg.Name+": $"+arg.Name)
argParams, err := g.variableParamsForArg(opType, field.Name, arg, variableDefaults)
argSchema, err := g.variableSchema(arg.Type, map[string]bool{})
if err != nil {
return rawir.RawOperation{}, fmt.Errorf("%s %q: argument %q schema: %w", opType, field.Name, arg.Name, err)
}
if argSchema != nil {
variablesSchema.Properties[arg.Name] = argSchema
if arg.Type.NonNull && arg.DefaultValue == nil {
variablesSchema.Required = append(variablesSchema.Required, arg.Name)
}
}
argParams, err := g.variableParamsForArg(arg, variableDefaults)
if err != nil {
return rawir.RawOperation{}, err
}
Expand Down Expand Up @@ -141,14 +152,15 @@ func (g *generator) operation(opType string, field *ast.FieldDefinition) (rawir.
RequestBody: &rawir.RawRequestBody{
Required: true,
MediaType: "application/json",
Schema: variablesSchema,
Template: string(template),
MergePath: "variables",
},
Output: output,
}, nil
}

func (g *generator) variableParamsForArg(opType string, operationName string, arg *ast.ArgumentDefinition, defaults map[string]any) ([]rawir.RawParameter, error) {
func (g *generator) variableParamsForArg(arg *ast.ArgumentDefinition, defaults map[string]any) ([]rawir.RawParameter, error) {
def := g.schema.Types[arg.Type.Name()]
if def != nil && def.IsLeafType() {
return []rawir.RawParameter{{
Expand All @@ -161,26 +173,17 @@ func (g *generator) variableParamsForArg(opType string, operationName string, ar
}}, nil
}
if def != nil && def.Kind == ast.InputObject && arg.Type.Elem == nil {
return g.inputObjectParams(opType, operationName, arg.Name, arg.Type, arg.Type.NonNull && arg.DefaultValue == nil, defaults, map[string]bool{})
}
if arg.Type.NonNull && arg.DefaultValue == nil {
return nil, fmt.Errorf("%s %q: required argument %q of type %q is not a scalar and cannot be expressed as a CLI flag", opType, operationName, arg.Name, arg.Type.String())
return g.inputObjectParams(arg.Name, arg.Type, arg.Type.NonNull && arg.DefaultValue == nil, defaults, map[string]bool{})
}
return nil, nil
}

func (g *generator) inputObjectParams(opType string, operationName string, prefix string, typ *ast.Type, required bool, defaults map[string]any, onPath map[string]bool) ([]rawir.RawParameter, error) {
func (g *generator) inputObjectParams(prefix string, typ *ast.Type, required bool, defaults map[string]any, onPath map[string]bool) ([]rawir.RawParameter, error) {
def := g.schema.Types[typ.Name()]
if def == nil || def.Kind != ast.InputObject || typ.Elem != nil {
if required {
return nil, fmt.Errorf("%s %q: required input field %q of type %q cannot be expressed as CLI flags", opType, operationName, prefix, typ.String())
}
return nil, nil
}
if onPath[def.Name] {
if required {
return nil, fmt.Errorf("%s %q: required input field %q creates an input object cycle through %q", opType, operationName, prefix, def.Name)
}
return nil, nil
}
if required {
Expand All @@ -206,20 +209,60 @@ func (g *generator) inputObjectParams(opType string, operationName string, prefi
continue
}
if fieldDef != nil && fieldDef.Kind == ast.InputObject && field.Type.Elem == nil {
child, err := g.inputObjectParams(opType, operationName, name, field.Type, fieldRequired, defaults, next)
child, err := g.inputObjectParams(name, field.Type, fieldRequired, defaults, next)
if err != nil {
return nil, err
}
params = append(params, child...)
continue
}
if fieldRequired {
return nil, fmt.Errorf("%s %q: required input field %q of type %q cannot be expressed as CLI flags", opType, operationName, name, field.Type.String())
}
}
return params, nil
}

func (g *generator) variableSchema(typ *ast.Type, onPath map[string]bool) (*rawir.RawSchema, error) {
if typ == nil {
return nil, nil
}
if typ.Elem != nil {
item, err := g.variableSchema(typ.Elem, onPath)
if err != nil {
return nil, err
}
return &rawir.RawSchema{Type: "array", Items: item}, nil
}
def := g.schema.Types[typ.Name()]
if def == nil {
return &rawir.RawSchema{Type: "string"}, nil
}
if def.IsLeafType() {
return &rawir.RawSchema{Type: scalarType(typ)}, nil
}
if def.Kind != ast.InputObject {
return &rawir.RawSchema{Type: "object"}, nil
}
if onPath[def.Name] {
return &rawir.RawSchema{Type: "object"}, nil
}

next := clonePath(onPath)
next[def.Name] = true
schema := &rawir.RawSchema{Type: "object", Properties: map[string]*rawir.RawSchema{}}
for _, field := range def.Fields {
fieldSchema, err := g.variableSchema(field.Type, next)
if err != nil {
return nil, err
}
if fieldSchema != nil {
schema.Properties[field.Name] = fieldSchema
}
if field.Type.NonNull && field.DefaultValue == nil {
schema.Required = append(schema.Required, field.Name)
}
}
return schema, nil
}

func (g *generator) selectionSet(typeName string, depth int, onPath map[string]bool) (string, error) {
def := g.schema.Types[typeName]
if def == nil {
Expand Down
34 changes: 27 additions & 7 deletions internal/codegen/backends/graphql/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -501,20 +502,39 @@ type App { id: ID! }
}
}

func TestParse_FailsClosedOnRequiredUnsupportedInputField(t *testing.T) {
func TestParse_RequiredComplexInputFieldsUseBodySchema(t *testing.T) {
const sdl = `
input MemberInput { email: String! }
input CreateAppInput { members: [MemberInput!]! }
input CreateAppInput { name: String!, members: [MemberInput!]! }
type Query { ping: String }
type Mutation { createApp(input: CreateAppInput!): App! }
type App { id: ID! }
`
_, err := parseSDL(t, sdl, nil, []string{"createApp"})
if err == nil {
t.Fatal("expected fail-closed error for a required unsupported input field")
mod, err := parseSDL(t, sdl, nil, []string{"createApp"})
if err != nil {
t.Fatalf("required complex input field should use body schema: %v", err)
}
create := byID(mod.Operations)["console_createApp"]
if len(create.Parameters) != 1 {
t.Fatalf("params = %+v, want only scalar leaf flag", create.Parameters)
}
if p := create.Parameters[0]; p.Name != "input.name" || !p.Required {
t.Fatalf("input.name param = %+v", p)
}
schema := create.RequestBody.Schema
if schema == nil || schema.Properties["input"] == nil {
t.Fatalf("body schema = %+v, want input object", schema)
}
input := schema.Properties["input"]
if !reflect.DeepEqual(input.Required, []string{"name", "members"}) {
t.Fatalf("input required = %#v", input.Required)
}
members := input.Properties["members"]
if members == nil || members.Type != "array" || members.Items == nil {
t.Fatalf("members schema = %+v, want array item schema", members)
}
if !strings.Contains(err.Error(), "createApp") || !strings.Contains(err.Error(), "input.members") {
t.Errorf("error = %v, want to name the operation and field", err)
if !reflect.DeepEqual(members.Items.Required, []string{"email"}) {
t.Fatalf("member required = %#v", members.Items.Required)
}
}

Expand Down
4 changes: 4 additions & 0 deletions internal/codegen/backends/openapi3/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ type schemaNode struct {
Default any `json:"default,omitempty" yaml:"default,omitempty"`
Enum []any `json:"enum,omitempty" yaml:"enum,omitempty"`
Properties map[string]*schemaNode `json:"properties,omitempty" yaml:"properties,omitempty"`
Required []string `json:"required,omitempty" yaml:"required,omitempty"`
Items *schemaNode `json:"items,omitempty" yaml:"items,omitempty"`
}

Expand Down Expand Up @@ -401,6 +402,9 @@ func convertSchema(s *schemaNode) *rawir.RawSchema {
out.Properties[k] = convertSchema(v)
}
}
if len(s.Required) > 0 {
out.Required = append([]string(nil), s.Required...)
}
if s.Items != nil {
out.Items = convertSchema(s.Items)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/codegen/backends/swagger/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type schemaNode struct {
Ref string `json:"$ref,omitempty"`
Type string `json:"type,omitempty"`
Properties map[string]*schemaNode `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
Items *schemaNode `json:"items,omitempty"`
}

Expand Down Expand Up @@ -226,6 +227,9 @@ func convertSchema(s *schemaNode) *rawir.RawSchema {
out.Properties[k] = convertSchema(v)
}
}
if len(s.Required) > 0 {
out.Required = append([]string(nil), s.Required...)
}
if s.Items != nil {
out.Items = convertSchema(s.Items)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/codegen/normalize/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ func runtimeSchema(s *rawir.RawSchema, defs map[string]*rawir.RawSchema, visited
out.Properties[k] = runtimeSchema(v, defs, visited)
}
}
if len(s.Required) > 0 {
out.Required = append([]string(nil), s.Required...)
}
if s.Items != nil {
out.Items = runtimeSchema(s.Items, defs, visited)
}
Expand Down
1 change: 1 addition & 0 deletions internal/codegen/rawir/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type RawSchema struct {
Ref string
Type string
Properties map[string]*RawSchema
Required []string `json:",omitempty"`
Items *RawSchema
}

Expand Down
7 changes: 7 additions & 0 deletions internal/codegen/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,13 @@ func writeSchemaLiteral(b *strings.Builder, s *runtime.SchemaSpec) {
}
b.WriteString("},")
}
if len(s.Required) > 0 {
b.WriteString("Required: []string{")
for _, name := range s.Required {
fmt.Fprintf(b, "%q,", name)
}
b.WriteString("},")
}
if s.Items != nil {
b.WriteString("Items: ")
writeSchemaLiteral(b, s.Items)
Expand Down
10 changes: 10 additions & 0 deletions internal/codegen/render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ func TestRenderModule_EmitsRequestBodyEnvelope(t *testing.T) {
RequestBody: &runtime.RequestBody{
Required: true,
MediaType: "application/json",
Schema: &runtime.SchemaSpec{
Type: "object",
Properties: map[string]*runtime.SchemaSpec{
"input": {Type: "object", Required: []string{"name"}},
},
Required: []string{"input"},
},
Template: `{"query":"mutation CreateApp($name:String!){createApp(name:$name){id}}","variables":{}}`,
MergePath: "variables",
},
Expand All @@ -139,6 +146,9 @@ func TestRenderModule_EmitsRequestBodyEnvelope(t *testing.T) {
`Template:`,
`createApp(name:$name)`,
`MergePath: "variables"`,
`Required: []string{`,
`"input"`,
`"name"`,
} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q", want)
Expand Down
2 changes: 1 addition & 1 deletion pkg/runtime/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
)

const CatalogSchemaVersion = 8
const CatalogSchemaVersion = 9
const DefaultSearchLimit = 20

const catalogCommandAnnotation = "lathe.catalog.command"
Expand Down
17 changes: 15 additions & 2 deletions pkg/runtime/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,17 @@ func TestBuildCatalog_RequestBodyEnvelope(t *testing.T) {
OperationID: "Apps_CreateApp",
Method: "POST",
PathTpl: "/graphql",
RequestBody: &RequestBody{Required: true, MediaType: "application/json", Template: tmpl, MergePath: "variables"},
RequestBody: &RequestBody{
Required: true,
MediaType: "application/json",
Schema: &SchemaSpec{
Type: "object",
Properties: map[string]*SchemaSpec{"name": {Type: "string"}},
Required: []string{"name"},
},
Template: tmpl,
MergePath: "variables",
},
}})

catalog := BuildCatalog(root, CatalogOptions{CLIName: "myctl"})
Expand All @@ -186,7 +196,7 @@ func TestBuildCatalog_RequestBodyEnvelope(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"template":`, `"merge_path":"variables"`, `createApp(name:$name)`} {
for _, want := range []string{`"template":`, `"merge_path":"variables"`, `"required":["name"]`, `createApp(name:$name)`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("catalog JSON missing %q:\n%s", want, raw)
}
Expand All @@ -199,6 +209,9 @@ func TestBuildCatalog_RequestBodyEnvelope(t *testing.T) {
if rt == nil || rt.Template != tmpl || rt.MergePath != "variables" {
t.Fatalf("round-trip body envelope = %+v", rt)
}
if rt.Schema == nil || !reflect.DeepEqual(rt.Schema.Required, []string{"name"}) {
t.Fatalf("round-trip body schema = %+v", rt.Schema)
}
}

func TestBuildCatalog_SensitiveFlagInputModes(t *testing.T) {
Expand Down
3 changes: 2 additions & 1 deletion pkg/runtime/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package runtime

import "encoding/json"

const SchemaVersion = 7
const SchemaVersion = 8

type CommandSpec struct {
Group string
Expand Down Expand Up @@ -80,6 +80,7 @@ type SchemaSpec struct {
Ref string `json:"ref,omitempty"`
Type string `json:"type,omitempty"`
Properties map[string]*SchemaSpec `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
Items *SchemaSpec `json:"items,omitempty"`
}

Expand Down