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;
--repeat--allow-host*. matches subdomains). Env: AZD_REST_ALLOWED_HOSTS (comma separated)--fail22 when the response status is 400 or higher. The response body is still printed, so error details stay visible.--validate-schema0121--validate-schema check found a non-conforming response2--validate-schema file)22--fail was set