diff --git a/client.go b/client.go index 9a2fde6..41846b3 100644 --- a/client.go +++ b/client.go @@ -50,23 +50,36 @@ func WrapClient(c *http.Client, spec io.Reader, opts ...Option) (*ValidatingClie }, nil } +// WithClient returns a new client using the same validator, but a new client. This can be useful to change transport +// or authorization settings, while still contributing to the same spec validation. +func (v *ValidatingClient) WithClient(c *http.Client) (*ValidatingClient, error) { + if v == nil { + return nil, fmt.Errorf("cannot switch client on nil validator") + } + + return &ValidatingClient{ + c: c, + Verifier: v.Verifier, + }, nil +} + // Do takes any http.Request, sends it to the server it and then records the result. -func (v ValidatingClient) Do(r *http.Request) (*http.Response, error) { +func (v *ValidatingClient) Do(r *http.Request) (*http.Response, error) { return v.recordResponse(v.c.Do(r)) } // Head is a convenience method for recording responses for HTTP HEAD requests -func (v ValidatingClient) Head(url string) (resp *http.Response, err error) { +func (v *ValidatingClient) Head(url string) (resp *http.Response, err error) { return v.recordResponse(v.c.Head(url)) } // Get is a convenience method for recording responses for HTTP GET requests -func (v ValidatingClient) Get(url string) (resp *http.Response, err error) { +func (v *ValidatingClient) Get(url string) (resp *http.Response, err error) { return v.recordResponse(v.c.Get(url)) } // Put is a convenience method for recording responses for HTTP PUT requests -func (v ValidatingClient) Put(url string, contentType string, body io.Reader) (resp *http.Response, err error) { +func (v *ValidatingClient) Put(url string, contentType string, body io.Reader) (resp *http.Response, err error) { req, err := http.NewRequest(http.MethodPut, url, body) req.Header.Set("Content-Type", contentType) if err != nil { @@ -76,12 +89,12 @@ func (v ValidatingClient) Put(url string, contentType string, body io.Reader) (r } // Post is a convenience method for recording responses for HTTP POST requests -func (v ValidatingClient) Post(url string, contentType string, body io.Reader) (resp *http.Response, err error) { +func (v *ValidatingClient) Post(url string, contentType string, body io.Reader) (resp *http.Response, err error) { return v.recordResponse(v.c.Post(url, contentType, body)) } // Delete records response for HTTP DELETE requests -func (v ValidatingClient) Delete(url string) (resp *http.Response, err error) { +func (v *ValidatingClient) Delete(url string) (resp *http.Response, err error) { req, err := http.NewRequest(http.MethodDelete, url, nil) if err != nil { return nil, err @@ -89,7 +102,7 @@ func (v ValidatingClient) Delete(url string) (resp *http.Response, err error) { return v.recordResponse(v.c.Do(req)) } -func (v ValidatingClient) recordResponse(resp *http.Response, err error) (*http.Response, error) { +func (v *ValidatingClient) recordResponse(resp *http.Response, err error) (*http.Response, error) { if err == nil { v.Record(resp) } diff --git a/client_test.go b/client_test.go index 8c826f2..d83447b 100644 --- a/client_test.go +++ b/client_test.go @@ -1,7 +1,14 @@ package copper import ( + "bytes" + "fmt" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" ) @@ -26,3 +33,130 @@ func TestWithBasePath(t *testing.T) { }) } } + +func TestWithClient(t *testing.T) { + f, err := os.Open("testdata/thing-spec.yaml") + require.NoError(t, err) + defer f.Close() + + s := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if r.URL.Path == "/ping" { + _, _ = w.Write([]byte(`{"message":"pong!"}`)) + } else { + _, _ = w.Write([]byte(`{"thing": "yes"}`)) + } + }), + ) + defer s.Close() + + c, err := WrapClient(http.DefaultClient, f) + require.NoError(t, err) + + _, err = c.Get(s.URL + "/ping") + assert.NoError(t, err) + + other, err := c.WithClient(&http.Client{}) + require.NoError(t, err) + + _, err = other.Get(s.URL + "/other") + assert.NoError(t, err) + + c.Verify(t) +} + +type numberHandler struct { + contentType string + path string + number string +} + +func (n *numberHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", n.contentType) + w.WriteHeader(http.StatusOK) + if r.URL.Path == n.path { + _, _ = w.Write([]byte(fmt.Sprintf("{\"number\": %v}", n.number))) + } +} + +func TestValidationErrors(t *testing.T) { + f, err := os.ReadFile("testdata/minimal-spec.yaml") + require.NoError(t, err) + + tt := []struct { + name string + contentType string + number string + requestPath string + }{ + {"wrong path", "application/json", "2", "/wrong"}, + {"not a number", "application/json", "two", "/mini"}, + {"base path", "application/json", "2", "/"}, + {"no content type", "", "2", "/mini"}, + {"wrong content type", "text/plain", "2", "/mini"}, + {"empty number", "application/json", "", "/mini"}, + } + + handler := &numberHandler{} + s := httptest.NewServer(handler) + defer s.Close() + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + handler.path, handler.contentType, handler.number = tc.requestPath, tc.contentType, tc.number + + r := bytes.NewReader(f) + c, err := WrapClient(http.DefaultClient, r) + require.NoError(t, err) + + assert.Error(t, c.Error()) + }) + } +} + +func TestRequestBodyValidation(t *testing.T) { + f, err := os.ReadFile("testdata/request-body-spec.yaml") + require.NoError(t, err) + + s := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ) + defer s.Close() + + tt := []struct { + name string + contentType string + body string + shouldError bool + }{ + {"according to spec", "application/json", `{"input":"pem"}`, false}, + {"wrong content type", "text/plain", `{"input":"pem"}`, true}, + {"wrong input field type", "application/json", `{"input":5}`, true}, + {"missing input field", "application/json", `{"message":"stuff"}`, true}, + {"extra fields in body", "application/json", `{"input": "yes", "message":"stuff"}`, false}, + {"empty content type", "", `{"input":"pem"}`, true}, + {"empty body", "application/json", "", true}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + r := bytes.NewReader(f) + c, err := WrapClient(http.DefaultClient, r) + require.NoError(t, err) + + res, err := c.Post(s.URL+"/req", tc.contentType, strings.NewReader(tc.body)) + require.Equal(t, 204, res.StatusCode) + + assert.NoError(t, err) + if tc.shouldError { + assert.ErrorIs(t, c.Error(), ErrRequestInvalid) + } else { + assert.NoError(t, c.Error()) + } + }) + } +} diff --git a/go.mod b/go.mod index 82f480a..82fcf9c 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,20 @@ module github.com/callebjorkell/copper go 1.21.0 require ( - github.com/getkin/kin-openapi v0.120.0 + github.com/getkin/kin-openapi v0.122.0 github.com/stretchr/testify v1.8.4 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect - github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/swag v0.22.7 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 44d71ae..f9148a3 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,11 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/getkin/kin-openapi v0.120.0 h1:MqJcNJFrMDFNc07iwE8iFC5eT2k/NPUFDIpNeiZv8Jg= -github.com/getkin/kin-openapi v0.120.0/go.mod h1:PCWw/lfBrJY4HcdqE3jj+QFkaFK8ABoqo7PvqVhXXqw= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/getkin/kin-openapi v0.122.0 h1:WB9Jbl0Hp/T79/JF9xlSW5Kl9uYdk/AWD0yAd9HOM10= +github.com/getkin/kin-openapi v0.122.0/go.mod h1:PCWw/lfBrJY4HcdqE3jj+QFkaFK8ABoqo7PvqVhXXqw= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/swag v0.22.7 h1:JWrc1uc/P9cSomxfnsFSVWoE1FW6bNbrVPmpQYpCcR8= +github.com/go-openapi/swag v0.22.7/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= @@ -17,10 +14,8 @@ github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -31,12 +26,8 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= @@ -44,7 +35,6 @@ github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/testdata/minimal-spec.yaml b/testdata/minimal-spec.yaml new file mode 100644 index 0000000..c384214 --- /dev/null +++ b/testdata/minimal-spec.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.1 +info: + title: ping test + version: '1.0' +servers: + - url: 'http://localhost:8000/' +paths: + /mini: + get: + responses: + "201": + content: + "application/json": + schema: + type: object + properties: + number: + type: integer + required: + - number + description: A number! diff --git a/testdata/request-body-spec.yaml b/testdata/request-body-spec.yaml new file mode 100644 index 0000000..3a34e99 --- /dev/null +++ b/testdata/request-body-spec.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.1 +info: + title: ping test + version: '1.0' +servers: + - url: 'http://localhost:8000/' +paths: + /req: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + input: + type: string + required: + - input + responses: + "204": + description: "Just an empty response" diff --git a/testdata/thing-spec.yaml b/testdata/thing-spec.yaml new file mode 100644 index 0000000..b302a9b --- /dev/null +++ b/testdata/thing-spec.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.1 +info: + title: ping test + version: '1.0' +servers: + - url: 'http://localhost:8000/' +paths: + /ping: + get: + responses: + "200": + content: + "application/json": + schema: + type: object + properties: + message: + type: string + required: + - message + description: The pongness + /other: + get: + responses: + "200": + content: + "application/json": + schema: + type: object + properties: + thing: + type: string + required: + - thing + description: The other one! diff --git a/verifier.go b/verifier.go index 5f3f3e2..9200b19 100644 --- a/verifier.go +++ b/verifier.go @@ -3,6 +3,7 @@ package copper import ( "bytes" "context" + "errors" "fmt" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" @@ -16,6 +17,13 @@ import ( "testing" ) +var ( + ErrNotChecked = errors.New("endpoint not checked") + ErrNotPartOfSpec = errors.New("response is not part of spec") + ErrResponseInvalid = errors.New("response invalid") + ErrRequestInvalid = errors.New("request invalid") +) + var supportedMethods = []string{ http.MethodGet, http.MethodHead, @@ -61,7 +69,7 @@ func NewVerifier(specBytes []byte, basePath string) (*Verifier, error) { spec: spec, } - for path, item := range spec.Paths { + for path, item := range spec.Paths.Map() { c.loadPath(path, item) } @@ -70,6 +78,12 @@ func NewVerifier(specBytes []byte, basePath string) (*Verifier, error) { func (v *Verifier) Record(res *http.Response) { req := res.Request + + // The body has already been read, so try to reset the body since kin-openapi expects this to be readable + if req.GetBody != nil { + req.Body, _ = req.GetBody() + } + v.mu.Lock() defer v.mu.Unlock() for i := range v.endpoints { @@ -92,7 +106,10 @@ func (v *Verifier) Record(res *http.Response) { } if err := openapi3filter.ValidateRequest(context.Background(), reqInput); err != nil { - v.errors = append(v.errors, fmt.Errorf("request invalid: %w", err)) + v.errors = append( + v.errors, + errors.Join(ErrRequestInvalid, fmt.Errorf("%s %s: %w", req.Method, req.URL.Path, err)), + ) } bodyBytes := bytes.Buffer{} @@ -105,7 +122,10 @@ func (v *Verifier) Record(res *http.Response) { Options: reqInput.Options, }) if err != nil { - v.errors = append(v.errors, fmt.Errorf("response invalid: %w", err)) + v.errors = append( + v.errors, + errors.Join(ErrResponseInvalid, fmt.Errorf("%s %s: %d: %w", req.Method, req.URL.Path, res.StatusCode, err)), + ) } if bodyBytes.Len() > 0 { res.Body = io.NopCloser(&bodyBytes) @@ -116,21 +136,36 @@ func (v *Verifier) Record(res *http.Response) { } } - v.errors = append(v.errors, - fmt.Errorf("%v %v with response %v is not part of the spec", req.Method, req.URL.Path, res.StatusCode)) + v.errors = append( + v.errors, + errors.Join(ErrNotPartOfSpec, fmt.Errorf("%v %v: %v", req.Method, req.URL.Path, res.StatusCode)), + ) } -func (v *Verifier) Verify(t *testing.T) { +// Error return the current collection of errors in the verifier. +func (v *Verifier) Error() error { v.mu.Lock() defer v.mu.Unlock() - for _, e := range v.errors { - t.Error(e) - } + + var errs []error for i := range v.endpoints { if !v.endpoints[i].checked { - t.Errorf("%v %v was not checked for response %v", v.endpoints[i].method, v.endpoints[i].path, v.endpoints[i].response) + errs = append( + errs, + errors.Join(ErrNotChecked, fmt.Errorf("%s %s: %s", v.endpoints[i].method, v.endpoints[i].path, v.endpoints[i].response)), + ) } } + + return errors.Join(append(v.errors, errs...)...) +} + +// Verify will cause the given test context to fail with an error if Error returns a non-nil error. +func (v *Verifier) Verify(t *testing.T) { + err := v.Error() + if err != nil { + t.Error(err) + } } func (v *Verifier) loadPath(path string, i *openapi3.PathItem) { @@ -144,7 +179,7 @@ func (v *Verifier) loadPath(path string, i *openapi3.PathItem) { continue } - for responseCode := range op.Responses { + for responseCode := range op.Responses.Map() { e := endpoint{ checked: false, method: method,