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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions cli/src/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ var (
tableColumns []string
dumpHeaders string
fail bool
validateSchema string
rawOutput bool
compact bool
)
Expand Down Expand Up @@ -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)")

Expand Down Expand Up @@ -302,6 +304,7 @@ func snapshotConfig() config.Config {
TableColumns: tableColumns,
DumpHeaders: dumpHeaders,
Fail: fail,
ValidateSchema: validateSchema,
RawOutput: rawOutput,
Compact: compact,
}
Expand Down
1 change: 1 addition & 0 deletions cli/src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Config struct {
TableColumns []string
DumpHeaders string
Fail bool
ValidateSchema string
RawOutput bool
Compact bool
}
Expand Down
9 changes: 9 additions & 0 deletions cli/src/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
114 changes: 114 additions & 0 deletions cli/src/internal/service/validate_schema.go
Original file line number Diff line number Diff line change
@@ -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
}
127 changes: 127 additions & 0 deletions cli/src/internal/service/validate_schema_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
3 changes: 3 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"jmespath",
"jongio",
"jsonl",
"jsonschema",
"keyvault",
"kusto",
"ldflags",
Expand All @@ -51,6 +52,7 @@
"pflag",
"pwsh",
"rest",
"santhosh",
"scloud",
"securego",
"servicebus",
Expand All @@ -60,6 +62,7 @@
"synapse",
"tcpdump",
"telemetry",
"tekuri",
"traceparent",
"tracestate",
"usgovcloudapi",
Expand Down
5 changes: 3 additions & 2 deletions web/src/pages/reference.astro
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ const base = import.meta.env.BASE_URL;
<tr><td><code>--repeat</code></td><td>int</td><td>1</td><td>Send the request N times and print latency stats (min/mean/p50/p95/p99/max) to stderr; only the last response body goes to stdout</td></tr>
<tr><td><code>--allow-host</code></td><td>string[]</td><td>[]</td><td>Restrict requests to hosts matching a pattern (repeatable; leading <code>*.</code> matches subdomains). Env: <code>AZD_REST_ALLOWED_HOSTS</code> (comma separated)</td></tr>
<tr><td><code>--fail</code></td><td>bool</td><td>false</td><td>Exit with code <code>22</code> when the response status is 400 or higher. The response body is still printed, so error details stay visible.</td></tr>
<tr><td><code>--validate-schema</code></td><td>string</td><td>""</td><td>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.</td></tr>
</tbody>
</table>
</div>
Expand Down Expand Up @@ -308,8 +309,8 @@ azd rest get https://api.example.com/resource --retry 0`} language="bash" />
</thead>
<tbody>
<tr><td><code>0</code></td><td>Success</td></tr>
<tr><td><code>1</code></td><td>Request failed (network error, invalid response)</td></tr>
<tr><td><code>2</code></td><td>Invalid arguments or configuration</td></tr>
<tr><td><code>1</code></td><td>Request failed (network error, invalid response), or a <code>--validate-schema</code> check found a non-conforming response</td></tr>
<tr><td><code>2</code></td><td>Invalid arguments or configuration (includes a missing or invalid <code>--validate-schema</code> file)</td></tr>
<tr><td><code>22</code></td><td>Response status was 400 or higher and <code>--fail</code> was set</td></tr>
</tbody>
</table>
Expand Down
Loading