From 040f3d5c8a0a1e3ed9c3e0a53beb66843271ecb8 Mon Sep 17 00:00:00 2001 From: samzong Date: Wed, 1 Jul 2026 11:57:18 -0400 Subject: [PATCH] feat(codegen): emit body schema for graphql complex inputs Replace fail-closed codegen for required complex GraphQL input fields with request body schema metadata so agents can supply values via --set or --file. Propagate schema Required through OpenAPI/Swagger backends, normalization, rendering, and runtime catalog (schema version bump). Signed-off-by: samzong --- README.md | 5 +- docs/cli-usage.md | 6 +- internal/codegen/backends/graphql/backend.go | 77 +++++++++++++++---- .../codegen/backends/graphql/backend_test.go | 34 ++++++-- internal/codegen/backends/openapi3/backend.go | 4 + internal/codegen/backends/swagger/backend.go | 4 + internal/codegen/normalize/normalize.go | 3 + internal/codegen/rawir/types.go | 1 + internal/codegen/render/render.go | 7 ++ internal/codegen/render/render_test.go | 10 +++ pkg/runtime/catalog.go | 2 +- pkg/runtime/catalog_test.go | 17 +++- pkg/runtime/spec.go | 3 +- 13 files changed, 139 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index ef2c966..03fa64c 100644 --- a/README.md +++ b/README.md @@ -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..nodes` or `.edges`, `pageInfo.endCursor`, `pageInfo.hasNextPage`, and an `after` argument get `--all`; subsequent pages write the cursor back under `variables.after`. diff --git a/docs/cli-usage.md b/docs/cli-usage.md index d189faf..9823b14 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -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..nodes` or `.edges`, `pageInfo.endCursor`, `pageInfo.hasNextPage`, and an `after` argument get `--all`; subsequent pages write the cursor back under `variables.after`. diff --git a/internal/codegen/backends/graphql/backend.go b/internal/codegen/backends/graphql/backend.go index 59e1c8e..ec9e373 100644 --- a/internal/codegen/backends/graphql/backend.go +++ b/internal/codegen/backends/graphql/backend.go @@ -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 } @@ -141,6 +152,7 @@ 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", }, @@ -148,7 +160,7 @@ func (g *generator) operation(opType string, field *ast.FieldDefinition) (rawir. }, 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{{ @@ -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 { @@ -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 { diff --git a/internal/codegen/backends/graphql/backend_test.go b/internal/codegen/backends/graphql/backend_test.go index 6639621..3fb9a27 100644 --- a/internal/codegen/backends/graphql/backend_test.go +++ b/internal/codegen/backends/graphql/backend_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" @@ -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) } } diff --git a/internal/codegen/backends/openapi3/backend.go b/internal/codegen/backends/openapi3/backend.go index f5270e4..328f8c1 100644 --- a/internal/codegen/backends/openapi3/backend.go +++ b/internal/codegen/backends/openapi3/backend.go @@ -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"` } @@ -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) } diff --git a/internal/codegen/backends/swagger/backend.go b/internal/codegen/backends/swagger/backend.go index 2966a71..5ad47dc 100644 --- a/internal/codegen/backends/swagger/backend.go +++ b/internal/codegen/backends/swagger/backend.go @@ -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"` } @@ -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) } diff --git a/internal/codegen/normalize/normalize.go b/internal/codegen/normalize/normalize.go index 58900dc..66f0cb6 100644 --- a/internal/codegen/normalize/normalize.go +++ b/internal/codegen/normalize/normalize.go @@ -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) } diff --git a/internal/codegen/rawir/types.go b/internal/codegen/rawir/types.go index ba2fc93..2cb4bd1 100644 --- a/internal/codegen/rawir/types.go +++ b/internal/codegen/rawir/types.go @@ -69,6 +69,7 @@ type RawSchema struct { Ref string Type string Properties map[string]*RawSchema + Required []string `json:",omitempty"` Items *RawSchema } diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index d2dd761..23ce9b6 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -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) diff --git a/internal/codegen/render/render_test.go b/internal/codegen/render/render_test.go index 2736a45..e90f83f 100644 --- a/internal/codegen/render/render_test.go +++ b/internal/codegen/render/render_test.go @@ -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", }, @@ -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) diff --git a/pkg/runtime/catalog.go b/pkg/runtime/catalog.go index 000e6ab..b5b4dae 100644 --- a/pkg/runtime/catalog.go +++ b/pkg/runtime/catalog.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -const CatalogSchemaVersion = 8 +const CatalogSchemaVersion = 9 const DefaultSearchLimit = 20 const catalogCommandAnnotation = "lathe.catalog.command" diff --git a/pkg/runtime/catalog_test.go b/pkg/runtime/catalog_test.go index aafb021..507b445 100644 --- a/pkg/runtime/catalog_test.go +++ b/pkg/runtime/catalog_test.go @@ -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"}) @@ -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) } @@ -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) { diff --git a/pkg/runtime/spec.go b/pkg/runtime/spec.go index 81c198a..0ef3369 100644 --- a/pkg/runtime/spec.go +++ b/pkg/runtime/spec.go @@ -2,7 +2,7 @@ package runtime import "encoding/json" -const SchemaVersion = 7 +const SchemaVersion = 8 type CommandSpec struct { Group string @@ -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"` }