Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,17 @@ Top-level non-resource commands:
- `-o table|json|yaml` (default `auto`: table on TTY, JSON when piped).
- Errors are emitted as JSON on stderr with shape `{error, message, status, response, request_id}`. Stdout stays clean for piping.

### Request validation

JSON payloads supplied with `--data`, `--file`, or `--file -` are checked
against the embedded OpenAPI request schema before the API call (including
`--all` and `--dry-run`). Invalid JSON, missing required fields, wrong types,
and enum/constraint mismatches return usage exit code 2 without sending.
Unknown fields in structured objects produce warnings on stderr but are
sent unchanged to tolerate schema drift. Explicitly open and typed maps
retain their schema semantics. Payload values are not printed in validation
errors or warnings. Use `--describe` to inspect the expected schema.

### Exit codes

| Code | Meaning |
Expand Down
9 changes: 7 additions & 2 deletions cmd/twoctl/cli/extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,13 @@ func TestRunOperationWithRequestBody(t *testing.T) {
}))
defer srv.Close()

stdout, _, err := runRoot(t, "--url", srv.URL, "--api-key", "secret_test_x",
"order", "create", "--data", `{"foo":"bar"}`)
flagURL, flagAPIKey = srv.URL, "secret_test_x"
cmd := buildOperationCommand("test", "/test", "POST", payloadTestOperation(t, `{"type":"object","properties":{"foo":{"type":"string"}}}`))
var output strings.Builder
cmd.SetOut(&output)
cmd.SetArgs([]string{"--data", `{"foo":"bar"}`})
err := cmd.Execute()
stdout := output.String()
if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/twoctl/cli/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ func runOperation(c *cobra.Command, method, path string, op *openapi3.Operation,
if err != nil {
return err
}
body, err := readRequestBody(b, headers)
body, err := readValidatedRequestBody(c.ErrOrStderr(), op, b, headers)
if err != nil {
return err
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/twoctl/cli/paginate.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ func runPaginated(c *cobra.Command, method, path string, op *openapi3.Operation,
}
}

if err := validatePayload(c.ErrOrStderr(), op, bodyBytes); err != nil {
return err
}

combined, err = fetchAllPages(c, client, resolved.BaseURL, method, path, op, b, bodyBytes, cursorPtr)
if err != nil {
return err
Expand Down
187 changes: 187 additions & 0 deletions cmd/twoctl/cli/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package cli

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"

"github.com/getkin/kin-openapi/openapi3"
)

// validatePayload checks a private payload projection. Shared embedded schemas
// and the original bytes sent to the API are never modified.
func validatePayload(w io.Writer, op *openapi3.Operation, raw []byte) error {
if len(raw) == 0 {
if op.RequestBody != nil && op.RequestBody.Value != nil && op.RequestBody.Value.Required {
return payloadError("request body is required")
}
return nil
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return payloadError("request body must contain one valid JSON value")
}
if op.RequestBody == nil || op.RequestBody.Value == nil {
return nil
}
mt := op.RequestBody.Value.Content.Get("application/json")
if mt == nil || mt.Schema == nil || mt.Schema.Value == nil {
return nil
}
schema := mt.Schema.Value
if err := schema.VisitJSON(pruneUnknownPayload(schema, value), openapi3.VisitAsRequest()); err != nil {
// Do not render SchemaError.Error(): it includes the submitted payload.
var schemaErr *openapi3.SchemaError
if errors.As(err, &schemaErr) {
return payloadError(fmt.Sprintf("request body violates %s at %q; use --describe to inspect the schema",
schemaErr.SchemaField, "/"+strings.Join(schemaErr.JSONPointer(), "/")))
}
return payloadError("request body does not match the operation schema; use --describe")
}
warnings := map[string]bool{}
unknownPayloadFields(schema, value, "", warnings)
paths := make([]string, 0, len(warnings))
for path := range warnings {
paths = append(paths, path)
}
sort.Strings(paths)
for _, path := range paths {
fmt.Fprintf(w, "warning: unknown request property at %q; sending unchanged (the API schema may have drifted)\n", path)
}
return nil
}

func payloadError(message string) error {
return &apiError{code: "invalid_request_body", message: message, exit: ExitUsage}
}

func unknownPayloadFields(s *openapi3.Schema, value any, path string, warnings map[string]bool) {
schemas := allPayloadSchemas(s, map[*openapi3.Schema]bool{})
switch v := value.(type) {
case map[string]any:
unknownObjectFields(schemas, v, path, warnings)
case []any:
items := payloadItemSchema(schemas)
for i, item := range v {
unknownPayloadFields(items, item, fmt.Sprintf("%s/%d", path, i), warnings)
}
}
}

func unknownObjectFields(schemas []*openapi3.Schema, value map[string]any, path string, warnings map[string]bool) {
shape := payloadObjectShape(schemas)
for key, item := range value {
childPath := path + "/" + strings.ReplaceAll(strings.ReplaceAll(key, "~", "~0"), "/", "~1")
if ref := shape.props[key]; ref != nil {
unknownPayloadFields(ref.Value, item, childPath, warnings)
} else if shape.open {
unknownPayloadFields(shape.extra, item, childPath, warnings)
} else if shape.declared {
warnings[childPath] = true
}
}
}

func readValidatedRequestBody(w io.Writer, op *openapi3.Operation, b *opBindings, headers http.Header) (io.Reader, error) {
if b.bodyFile == nil || b.bodyData == nil {
return nil, nil
}
raw, err := readBody(*b.bodyFile, *b.bodyData)
if err != nil {
return nil, err
}
if err := validatePayload(w, op, raw); err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, nil
}
headers.Set("Content-Type", "application/json")
return bytes.NewReader(raw), nil
}

func allPayloadSchemas(s *openapi3.Schema, seen map[*openapi3.Schema]bool) []*openapi3.Schema {
if s == nil || seen[s] {
return nil
}
seen[s] = true
out := []*openapi3.Schema{s}
refs := append(append(append(openapi3.SchemaRefs{}, s.AllOf...), s.AnyOf...), s.OneOf...)
for _, ref := range refs {
if ref != nil {
out = append(out, allPayloadSchemas(ref.Value, seen)...)
}
}
return out
}

func pruneUnknownPayload(s *openapi3.Schema, value any) any {
schemas := allPayloadSchemas(s, map[*openapi3.Schema]bool{})
switch v := value.(type) {
case map[string]any:
return pruneUnknownObject(schemas, v)
case []any:
items := payloadItemSchema(schemas)
out := make([]any, len(v))
for i, item := range v {
out[i] = pruneUnknownPayload(items, item)
}
return out
default:
return value
}
}

type payloadShape struct {
props openapi3.Schemas
extra *openapi3.Schema
open, declared bool
}

func payloadObjectShape(schemas []*openapi3.Schema) payloadShape {
shape := payloadShape{props: openapi3.Schemas{}, extra: &openapi3.Schema{}}
for _, schema := range schemas {
if schema.AdditionalProperties.Schema != nil {
shape.extra.AllOf = append(shape.extra.AllOf, schema.AdditionalProperties.Schema)
}
shape.open = shape.open || schema.AdditionalProperties.Schema != nil ||
(schema.AdditionalProperties.Has != nil && *schema.AdditionalProperties.Has)
shape.declared = shape.declared || len(schema.Properties) > 0 ||
(schema.AdditionalProperties.Has != nil && !*schema.AdditionalProperties.Has)
for key, ref := range schema.Properties {
if shape.props[key] == nil {
shape.props[key] = &openapi3.SchemaRef{Value: &openapi3.Schema{}}
}
shape.props[key].Value.AllOf = append(shape.props[key].Value.AllOf, ref)
}
}
return shape
}

func pruneUnknownObject(schemas []*openapi3.Schema, value map[string]any) map[string]any {
shape := payloadObjectShape(schemas)
out := map[string]any{}
for key, item := range value {
if ref := shape.props[key]; ref != nil {
out[key] = pruneUnknownPayload(ref.Value, item)
} else if shape.open || !shape.declared {
out[key] = pruneUnknownPayload(shape.extra, item)
}
}
return out
}

func payloadItemSchema(schemas []*openapi3.Schema) *openapi3.Schema {
items := &openapi3.Schema{}
for _, schema := range schemas {
if schema.Items != nil {
items.AllOf = append(items.AllOf, schema.Items)
}
}
return items
}
Loading
Loading