From f6adb598d2b459e2a96e0a6ba7aee1edf47f761e Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:17:02 -0700 Subject: [PATCH] Add --validate-schema to check a response against a JSON Schema --validate-schema validates the JSON response against a JSON Schema and exits non-zero when it does not conform, printing each validation error to stderr so contract checks in CI can fail the build. The response body is still printed first, so this composes with normal output. Exit codes follow the existing contract: a conforming response exits 0, a non-conforming response exits 1, and a missing or invalid schema file (or a non-JSON response) exits 2 with a clear message. Validation uses the santhosh-tekuri/jsonschema/v6 library, promoted from an indirect to a direct dependency. Closes #267 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 + cli/go.mod | 2 +- cli/src/internal/cmd/root.go | 3 + cli/src/internal/config/config.go | 1 + cli/src/internal/service/service.go | 9 ++ cli/src/internal/service/validate_schema.go | 114 ++++++++++++++++ .../internal/service/validate_schema_test.go | 127 ++++++++++++++++++ cspell.json | 3 + web/src/pages/reference.astro | 5 +- 9 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 cli/src/internal/service/validate_schema.go create mode 100644 cli/src/internal/service/validate_schema_test.go diff --git a/README.md b/README.md index e3157eb..d98d812 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,9 @@ azd rest doctor # Exit non-zero (code 22) on an HTTP error so scripts and CI stop on failure azd rest get https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}?api-version=2021-04-01 --fail + +# Validate the response against a JSON Schema and exit non-zero when it does not conform +azd rest get https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}?api-version=2021-04-01 --validate-schema schema.json ``` For the complete command and flag reference, see the [CLI Reference](https://jongio.github.io/azd-rest/reference/cli/) on the website. diff --git a/cli/go.mod b/cli/go.mod index 00ce9d8..2ed89b7 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -11,6 +11,7 @@ require ( github.com/jongio/azd-core v0.5.7 github.com/magefile/mage v1.17.2 github.com/mark3labs/mcp-go v0.56.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -86,7 +87,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.3 // indirect diff --git a/cli/src/internal/cmd/root.go b/cli/src/internal/cmd/root.go index 35e9e2a..c5b161c 100644 --- a/cli/src/internal/cmd/root.go +++ b/cli/src/internal/cmd/root.go @@ -62,6 +62,7 @@ var ( tableColumns []string dumpHeaders string fail bool + validateSchema string rawOutput bool compact bool ) @@ -227,6 +228,7 @@ Examples: rootCmd.PersistentFlags().StringSliceVar(&tableColumns, "table-columns", nil, "Comma-separated columns to show, in order, for --format table (ignored for other formats)") rootCmd.PersistentFlags().StringVar(&dumpHeaders, "dump-headers", "", "Write response status line and headers to a file (use - for stderr)") rootCmd.PersistentFlags().BoolVar(&fail, "fail", false, "Exit with code 22 when the response status is 400 or higher (the response body is still printed)") + rootCmd.PersistentFlags().StringVar(&validateSchema, "validate-schema", "", "Validate the JSON response against a JSON Schema file and exit non-zero when it does not conform, printing each validation error to stderr") rootCmd.PersistentFlags().BoolVarP(&rawOutput, "raw-output", "r", false, "With --query, print a string result unquoted and an array of strings one per line (like jq -r)") rootCmd.PersistentFlags().BoolVarP(&compact, "compact", "c", false, "Minify JSON output to a single line (applies to auto and json formats and --query results)") @@ -302,6 +304,7 @@ func snapshotConfig() config.Config { TableColumns: tableColumns, DumpHeaders: dumpHeaders, Fail: fail, + ValidateSchema: validateSchema, RawOutput: rawOutput, Compact: compact, } diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index 0e8015c..da0eb07 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -48,6 +48,7 @@ type Config struct { TableColumns []string DumpHeaders string Fail bool + ValidateSchema string RawOutput bool Compact bool } diff --git a/cli/src/internal/service/service.go b/cli/src/internal/service/service.go index c12646c..6d71c69 100644 --- a/cli/src/internal/service/service.go +++ b/cli/src/internal/service/service.go @@ -460,6 +460,15 @@ func (s *RequestService) Execute(ctx context.Context, cfg config.Config, method, fmt.Fprint(os.Stderr, ExpandWriteOut(cfg.WriteOut, opts.Method, opts.URL, resp)) } + // --validate-schema (#267): after the body has been written, check the + // response against a JSON Schema and return non-zero when it does not + // conform so contract checks in CI can fail the build. + if cfg.ValidateSchema != "" { + if err := validateResponseSchema(os.Stderr, resp.Body, cfg.ValidateSchema); err != nil { + return err + } + } + // --fail (#233): after the body and metadata have been written, return a // non-zero exit for an error status so scripts and CI can detect failures. if cfg.Fail && resp.StatusCode >= 400 { diff --git a/cli/src/internal/service/validate_schema.go b/cli/src/internal/service/validate_schema.go new file mode 100644 index 0000000..3739e6c --- /dev/null +++ b/cli/src/internal/service/validate_schema.go @@ -0,0 +1,114 @@ +package service + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + + "github.com/jongio/azd-rest/src/internal/client" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// schemaResourceName is the in-memory identifier the compiler uses for the +// user-supplied schema. It never touches the filesystem or network. +const schemaResourceName = "schema.json" + +// validateSchemaUsageError signals invalid --validate-schema usage: a missing +// or unreadable schema file, a schema that is not valid JSON or not a valid +// JSON Schema, or a non-JSON response. It reports exit code 2 so main can tell +// it apart from a conformance failure, which is a plain error (exit 1). +type validateSchemaUsageError struct{ msg string } + +func (e *validateSchemaUsageError) Error() string { return e.msg } + +// ExitCode returns 2 for invalid --validate-schema usage. +func (e *validateSchemaUsageError) ExitCode() int { return 2 } + +// validateResponseSchema validates the JSON response body against the JSON +// Schema in schemaPath. It returns nil when the response conforms. When the +// response does not conform it writes each validation error to errOut and +// returns a plain error so the command exits non-zero. A missing or invalid +// schema file, or a non-JSON response, returns a validateSchemaUsageError +// (exit 2). +func validateResponseSchema(errOut io.Writer, body []byte, schemaPath string) error { + schemaRaw, err := os.ReadFile(schemaPath) // #nosec G304 -- User-specified schema path via --validate-schema flag is intentional. + if err != nil { + return &validateSchemaUsageError{msg: fmt.Sprintf("failed to read --validate-schema file %q: %v", schemaPath, err)} + } + + schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaRaw)) + if err != nil { + return &validateSchemaUsageError{msg: fmt.Sprintf("--validate-schema file %q is not valid JSON: %v", schemaPath, err)} + } + + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource(schemaResourceName, schemaDoc); err != nil { + return &validateSchemaUsageError{msg: fmt.Sprintf("invalid JSON Schema in %q: %v", schemaPath, err)} + } + schema, err := compiler.Compile(schemaResourceName) + if err != nil { + return &validateSchemaUsageError{msg: fmt.Sprintf("invalid JSON Schema in %q: %v", schemaPath, err)} + } + + if !client.IsJSON(body) { + return &validateSchemaUsageError{msg: "--validate-schema requires a JSON response"} + } + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(body)) + if err != nil { + return &validateSchemaUsageError{msg: fmt.Sprintf("failed to parse JSON response for --validate-schema: %v", err)} + } + + err = schema.Validate(instance) + if err == nil { + return nil + } + + var ve *jsonschema.ValidationError + if !errors.As(err, &ve) { + return &validateSchemaUsageError{msg: fmt.Sprintf("--validate-schema failed: %v", err)} + } + + messages := flattenSchemaErrors(ve.BasicOutput()) + for _, m := range messages { + fmt.Fprintln(errOut, m) + } + + noun := "errors" + if len(messages) == 1 { + noun = "error" + } + return fmt.Errorf("response does not conform to JSON Schema %q (%d %s)", schemaPath, len(messages), noun) +} + +// flattenSchemaErrors walks the basic-output tree and returns one line per leaf +// failure in the form "instanceLocation: message", so callers can print a flat +// list of concrete problems instead of a nested structure. +func flattenSchemaErrors(unit *jsonschema.OutputUnit) []string { + var messages []string + var walk func(u *jsonschema.OutputUnit) + walk = func(u *jsonschema.OutputUnit) { + if u == nil { + return + } + if u.Error != nil { + loc := u.InstanceLocation + if loc == "" { + loc = "/" + } + messages = append(messages, fmt.Sprintf("%s: %s", loc, u.Error.String())) + } + for i := range u.Errors { + walk(&u.Errors[i]) + } + } + walk(unit) + + if len(messages) == 0 { + // The tree carried no leaf error text (unusual); fall back to a single + // generic line so the user still sees a failure was reported. + messages = append(messages, "response does not conform to the schema") + } + return messages +} diff --git a/cli/src/internal/service/validate_schema_test.go b/cli/src/internal/service/validate_schema_test.go new file mode 100644 index 0000000..65f79f2 --- /dev/null +++ b/cli/src/internal/service/validate_schema_test.go @@ -0,0 +1,127 @@ +package service + +import ( + "bytes" + "context" + "errors" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testSchema = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["name", "age"], + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + } +}` + +// writeSchema writes content to a temp file and returns its path. +func writeSchema(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "schema.json") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestValidateResponseSchema_ConformingReturnsNil(t *testing.T) { + schema := writeSchema(t, testSchema) + + var errOut bytes.Buffer + err := validateResponseSchema(&errOut, []byte(`{"name":"rg1","age":3}`), schema) + require.NoError(t, err) + assert.Empty(t, errOut.String()) +} + +func TestValidateResponseSchema_NonConformingPrintsErrorsAndReturnsError(t *testing.T) { + schema := writeSchema(t, testSchema) + + // Missing "age" and wrong type for "name". + var errOut bytes.Buffer + err := validateResponseSchema(&errOut, []byte(`{"name":123}`), schema) + require.Error(t, err) + + // A conformance failure is a plain error (exit 1), not a usage error. + var coder exitCoder + assert.False(t, errors.As(err, &coder), "conformance failure should not carry a usage exit code") + assert.Contains(t, err.Error(), "does not conform") + + printed := errOut.String() + assert.NotEmpty(t, printed, "each validation error should be printed") +} + +func TestValidateResponseSchema_MissingSchemaReturnsUsageError(t *testing.T) { + var errOut bytes.Buffer + err := validateResponseSchema(&errOut, []byte(`{"name":"rg1","age":3}`), filepath.Join(t.TempDir(), "nope.json")) + require.Error(t, err) + + var coder exitCoder + require.True(t, errors.As(err, &coder)) + assert.Equal(t, 2, coder.ExitCode()) + assert.Empty(t, errOut.String()) +} + +func TestValidateResponseSchema_InvalidSchemaJSONReturnsUsageError(t *testing.T) { + schema := writeSchema(t, "this is not json") + + var errOut bytes.Buffer + err := validateResponseSchema(&errOut, []byte(`{"name":"rg1","age":3}`), schema) + require.Error(t, err) + + var coder exitCoder + require.True(t, errors.As(err, &coder)) + assert.Equal(t, 2, coder.ExitCode()) + assert.Contains(t, err.Error(), "not valid JSON") +} + +func TestValidateResponseSchema_NonJSONResponseReturnsUsageError(t *testing.T) { + schema := writeSchema(t, testSchema) + + var errOut bytes.Buffer + err := validateResponseSchema(&errOut, []byte("plain text body"), schema) + require.Error(t, err) + + var coder exitCoder + require.True(t, errors.As(err, &coder)) + assert.Equal(t, 2, coder.ExitCode()) + assert.Contains(t, err.Error(), "requires a JSON response") +} + +func TestExecute_ValidateSchema_ConformingReturnsNilAndWritesBody(t *testing.T) { + srv := failTestServer(t, http.StatusOK, `{"name":"rg1","age":3}`) + schema := writeSchema(t, testSchema) + + cfg := baseTestConfig(t) + cfg.ValidateSchema = schema + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/data") + require.NoError(t, err) + + out, readErr := os.ReadFile(cfg.OutputFile) + require.NoError(t, readErr) + assert.Contains(t, string(out), "rg1") +} + +func TestExecute_ValidateSchema_NonConformingReturnsErrorAndWritesBody(t *testing.T) { + srv := failTestServer(t, http.StatusOK, `{"name":"rg1"}`) + schema := writeSchema(t, testSchema) + + cfg := baseTestConfig(t) + cfg.ValidateSchema = schema + + err := newTestService().Execute(context.Background(), cfg, "GET", srv.URL+"/data") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not conform") + + // The response body is still written before the failure is returned. + out, readErr := os.ReadFile(cfg.OutputFile) + require.NoError(t, readErr) + assert.Contains(t, string(out), "rg1") +} diff --git a/cspell.json b/cspell.json index 3e461fa..8ab2a5b 100644 --- a/cspell.json +++ b/cspell.json @@ -41,6 +41,7 @@ "jmespath", "jongio", "jsonl", + "jsonschema", "keyvault", "kusto", "ldflags", @@ -51,6 +52,7 @@ "pflag", "pwsh", "rest", + "santhosh", "scloud", "securego", "servicebus", @@ -60,6 +62,7 @@ "synapse", "tcpdump", "telemetry", + "tekuri", "traceparent", "tracestate", "usgovcloudapi", diff --git a/web/src/pages/reference.astro b/web/src/pages/reference.astro index 446eed5..4ae7b9b 100644 --- a/web/src/pages/reference.astro +++ b/web/src/pages/reference.astro @@ -126,6 +126,7 @@ const base = import.meta.env.BASE_URL; --repeatint1Send the request N times and print latency stats (min/mean/p50/p95/p99/max) to stderr; only the last response body goes to stdout --allow-hoststring[][]Restrict requests to hosts matching a pattern (repeatable; leading *. matches subdomains). Env: AZD_REST_ALLOWED_HOSTS (comma separated) --failboolfalseExit with code 22 when the response status is 400 or higher. The response body is still printed, so error details stay visible. + --validate-schemastring""Validate the JSON response against a JSON Schema file and exit non-zero when it does not conform. Each validation error is printed to stderr. The response body is still printed. Use it for contract checks in CI. @@ -308,8 +309,8 @@ azd rest get https://api.example.com/resource --retry 0`} language="bash" /> 0Success - 1Request failed (network error, invalid response) - 2Invalid arguments or configuration + 1Request failed (network error, invalid response), or a --validate-schema check found a non-conforming response + 2Invalid arguments or configuration (includes a missing or invalid --validate-schema file) 22Response status was 400 or higher and --fail was set