From 72016683a28c48358dfaa25e8f275acab6c87222 Mon Sep 17 00:00:00 2001 From: Peltoche Date: Sat, 13 Oct 2018 14:14:55 +0200 Subject: [PATCH] Add the response body validation --- analyzer.go | 56 ++++++++--- analyzer_test.go | 216 +++++++++++++++++++++++++++++++++++++++--- dataset/petstore.json | 3 + specs.go | 28 +++++- transport.go | 2 +- transport_test.go | 10 +- 6 files changed, 279 insertions(+), 36 deletions(-) diff --git a/analyzer.go b/analyzer.go index 73700b0..b0e0c62 100644 --- a/analyzer.go +++ b/analyzer.go @@ -1,9 +1,11 @@ package oaichecker import ( + "bytes" "encoding/json" "errors" "fmt" + "io/ioutil" "net/http" "strconv" "strings" @@ -60,7 +62,7 @@ func createRouter(analyzer *analysis.Spec) *denco.Router { return r } -func (t *Analyzer) Analyze(req *http.Request) error { +func (t *Analyzer) Analyze(req *http.Request, res *http.Response) error { if req == nil { return errors.New("no request defined") } @@ -95,7 +97,45 @@ func (t *Analyzer) Analyze(req *http.Request) error { } } - return nil + err := t.validateResponse(res, operation.Responses) + + return err +} + +func (t *Analyzer) validateResponse(res *http.Response, resSpec *spec.Responses) error { + for status, response := range resSpec.StatusCodeResponses { + if status == res.StatusCode { + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return err + } + + res.Body = ioutil.NopCloser(bytes.NewReader(body)) + + if response.ResponseProps.Schema == nil { + if len(body) > 0 { + return fmt.Errorf("validation failure list:\nno response body defined inside the specs but have %q", body) + } + return nil + } + + var input interface{} + err = json.Unmarshal(body, &input) + if err != nil { + return fmt.Errorf("failed to parse json body: %s", err) + } + + err = validate.AgainstSchema(response.Schema, input, strfmt.Default) + if err != nil { + return err + } + + return nil + } + } + + return fmt.Errorf("validation failure list:\nresponse status %s not defined inside the specs", res.Status) } func (t *Analyzer) validateBodyParameter(req *http.Request, param *spec.Parameter) error { @@ -110,17 +150,7 @@ func (t *Analyzer) validateBodyParameter(req *http.Request, param *spec.Paramete return err } - paramRef := param.ParamProps.Schema.Ref.String() - - var schema *spec.Schema - for _, def := range t.analyzer.AllDefinitions() { - if paramRef == def.Ref.String() { - schema = def.Schema - break - } - } - - err = validate.AgainstSchema(schema, input, strfmt.Default) + err = validate.AgainstSchema(param.Schema, input, strfmt.Default) if err != nil { return err } diff --git a/analyzer_test.go b/analyzer_test.go index dceccc0..a2510d5 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -2,6 +2,7 @@ package oaichecker import ( "bytes" + "io/ioutil" "mime/multipart" "net/http" "strings" @@ -23,7 +24,7 @@ func Test_Analyzer_Analyze_with_no_request(t *testing.T) { analyzer := NewAnalyzer(specs) - err = analyzer.Analyze(nil) + err = analyzer.Analyze(nil, nil) assert.EqualError(t, err, "no request defined") } @@ -37,7 +38,7 @@ func Test_Analyzer_Analyze_with_request_not_in_specs(t *testing.T) { req, err := http.NewRequest("GET", "invalid/path", nil) require.NoError(t, err) - err = analyzer.Analyze(req) + err = analyzer.Analyze(req, nil) assert.EqualError(t, err, "operation not defined inside the specs") } @@ -54,7 +55,19 @@ func Test_Analyzer_Analyze_with_body_parameters(t *testing.T) { }`)) require.NoError(t, err) - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusCreated), + StatusCode: http.StatusCreated, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.NoError(t, err) } @@ -70,7 +83,19 @@ func Test_Analyzer_Analyze_with_invalid_body_parameters(t *testing.T) { }`)) require.NoError(t, err) - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusCreated), + StatusCode: http.StatusCreated, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.EqualError(t, err, "validation failure list:\n"+ ".photoUrls in body is required") @@ -85,7 +110,19 @@ func Test_Analyzer_Analyze_with_invalid_body_format(t *testing.T) { req, err := http.NewRequest("POST", "/pet", strings.NewReader(`not a json`)) require.NoError(t, err) - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.EqualError(t, err, "invalid character 'o' in literal null (expecting 'u')") } @@ -103,7 +140,21 @@ func Test_Analyzer_Analyze_with_query_parameters(t *testing.T) { q.Set("status", "available") req.URL.RawQuery = q.Encode() - err = analyzer.Analyze(req) + body := `[]` + + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString(body)), + ContentLength: int64(len(body)), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.NoError(t, err) } @@ -121,7 +172,21 @@ func Test_Analyzer_Analyze_with_invalid_query_parameters(t *testing.T) { q.Set("status", "invalid-enum-value") req.URL.RawQuery = q.Encode() - err = analyzer.Analyze(req) + body := `[]` + + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString(body)), + ContentLength: int64(len(body)), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.EqualError(t, err, "validation failure list:\n"+ "status.0 in query should be one of [available pending sold]") @@ -136,7 +201,19 @@ func Test_Analyzer_Analyze_with_unhandled_method(t *testing.T) { req, err := http.NewRequest("OPTION", "/pet/42", nil) require.NoError(t, err) - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.EqualError(t, err, "operation not defined inside the specs") } @@ -151,7 +228,38 @@ func Test_Analyzer_Analyze_with_path_parameters(t *testing.T) { req.Header.Set("userID", "some-id") require.NoError(t, err) - err = analyzer.Analyze(req) + body := `{ + "id": 0, + "category": { + "id": 0, + "name": "string" + }, + "name": "doggie", + "photoUrls": [ + "string" + ], + "tags": [ + { + "id": 0, + "name": "string" + } + ], + "status": "available" + }` + + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString(body)), + ContentLength: int64(len(body)), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.NoError(t, err) } @@ -166,7 +274,19 @@ func Test_Analyzer_Analyze_with_invalid_path_parameters(t *testing.T) { req.Header.Set("userID", "42") require.NoError(t, err) - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.EqualError(t, err, "validation failure list:\n"+ "petId in path must be of type integer: \"string\"") @@ -194,7 +314,25 @@ func Test_Analyzer_Analyze_with_formData_file(t *testing.T) { req.Header.Set("Content-Type", mp.FormDataContentType()) - err = analyzer.Analyze(req) + body := `{ + "code": 0, + "type": "string", + "message": "string" + }` + + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString(body)), + ContentLength: int64(len(body)), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.NoError(t, err) } @@ -217,7 +355,19 @@ func Test_Analyzer_Analyze_with_missing_formData_file(t *testing.T) { req.Header.Set("Content-Type", mp.FormDataContentType()) - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.EqualError(t, err, "validation failure list:\n"+ "file in formData is required") @@ -244,7 +394,19 @@ func Test_Analyzer_Analyze_with_missing_formData_field(t *testing.T) { req.Header.Set("Content-Type", mp.FormDataContentType()) - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.EqualError(t, err, "validation failure list:\n"+ "additionalMetadata in formData is required") @@ -260,7 +422,19 @@ func Test_Analyzer_Analyze_with_header(t *testing.T) { require.NoError(t, err) req.Header.Set("userID", "42") - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusNotFound), + StatusCode: http.StatusNotFound, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.NoError(t, err) } @@ -274,7 +448,19 @@ func Test_Analyzer_Analyze_with_missing_header(t *testing.T) { req, err := http.NewRequest("GET", "/pet/32", nil) require.NoError(t, err) - err = analyzer.Analyze(req) + res := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + ContentLength: int64(0), + Request: req, + Header: make(http.Header, 0), + } + + err = analyzer.Analyze(req, res) assert.EqualError(t, err, "validation failure list:\n"+ "userID in header is required") diff --git a/dataset/petstore.json b/dataset/petstore.json index d3c0d02..5ad7810 100644 --- a/dataset/petstore.json +++ b/dataset/petstore.json @@ -70,6 +70,9 @@ } ], "responses": { + "201": { + "description": "Pet created" + }, "405": { "description": "Invalid input" } diff --git a/specs.go b/specs.go index 748970d..f47922a 100644 --- a/specs.go +++ b/specs.go @@ -2,8 +2,8 @@ package oaichecker import ( "encoding/json" - "io/ioutil" + "github.com/go-openapi/analysis" "github.com/go-openapi/loads" "github.com/go-openapi/strfmt" "github.com/go-openapi/validate" @@ -14,12 +14,25 @@ type Specs struct { } func NewSpecsFromFile(path string) (*Specs, error) { - rawFile, err := ioutil.ReadFile(path) + doc, err := loads.Spec(path) if err != nil { return nil, err } - return NewSpecsFromRaw(rawFile) + err = analysis.Flatten(analysis.FlattenOpts{ + Spec: doc.Analyzer, + BasePath: path, + Expand: true, + }) + if err != nil { + return nil, err + } + + spec := Specs{ + document: doc, + } + + return &spec, nil } func NewSpecsFromRaw(rawSpec []byte) (*Specs, error) { @@ -28,6 +41,15 @@ func NewSpecsFromRaw(rawSpec []byte) (*Specs, error) { return nil, err } + err = analysis.Flatten(analysis.FlattenOpts{ + Spec: document.Analyzer, + BasePath: "", + Expand: true, + }) + if err != nil { + return nil, err + } + spec := Specs{ document: document, } diff --git a/transport.go b/transport.go index 8a8b651..45206d0 100644 --- a/transport.go +++ b/transport.go @@ -52,7 +52,7 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { return nil, err } - err = t.analyzer.Analyze(req) + err = t.analyzer.Analyze(req, res) if err != nil { return nil, err } diff --git a/transport_test.go b/transport_test.go index 25bfe14..70d3a67 100644 --- a/transport_test.go +++ b/transport_test.go @@ -45,7 +45,7 @@ func Test_Transport_implements_RoundTripper(t *testing.T) { func Test_Transport_with_a_valid_request(t *testing.T) { ts := newServer(func(w http.ResponseWriter, r *http.Request) { - _, err := w.Write([]byte("some-response")) + _, err := w.Write([]byte("[]")) require.NoError(t, err) }) defer ts.Close() @@ -60,7 +60,7 @@ func Test_Transport_with_a_valid_request(t *testing.T) { res, err := client.Get(ts.URL + "/pets") assert.NoError(t, err) - assert.Equal(t, "some-response", resBody(t, res)) + assert.JSONEq(t, `[]`, resBody(t, res)) } func Test_Transport_with_a_transport_error(t *testing.T) { @@ -107,7 +107,8 @@ func Test_Transport_with_an_analyzer_error(t *testing.T) { func Test_Transport_with_a_body(t *testing.T) { ts := newServer(func(w http.ResponseWriter, r *http.Request) { - _, err := w.Write([]byte("some-response")) + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte("")) require.NoError(t, err) }) defer ts.Close() @@ -125,5 +126,6 @@ func Test_Transport_with_a_body(t *testing.T) { }`)) assert.NoError(t, err) - assert.Equal(t, "some-response", resBody(t, res)) + assert.Equal(t, http.StatusCreated, res.StatusCode) + assert.Equal(t, "", resBody(t, res)) }