Adding a simpler interface for the HTTP request library. (#8862)

This commit is contained in:
Amim Knabben 2022-09-05 08:02:36 -03:00 committed by GitHub
parent 79a311d3be
commit 12c9f00931
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 954 additions and 102 deletions

View file

@ -0,0 +1 @@
This module is based in the deprecated library `github.com/gavv/httpexpect`, and contains slightly adaptations.

View file

@ -0,0 +1,36 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
// Array provides methods to inspect attached []interface{} object
// (Go representation of JSON array).
type Array struct {
chain chain
value []interface{}
}
// Iter returns a new slice of Values attached to array elements.
func (a *Array) Iter() []Value {
if a.chain.failed() {
return []Value{}
}
ret := []Value{}
for n := range a.value {
ret = append(ret, Value{a.chain, a.value[n]})
}
return ret
}

View file

@ -0,0 +1,54 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
type chain struct {
reporter Reporter
failbit bool
}
func makeChain(reporter Reporter) chain {
return chain{reporter, false}
}
func (c *chain) failed() bool {
return c.failbit
}
func (c *chain) fail(message string, args ...interface{}) {
if c.failbit {
return
}
c.failbit = true
c.reporter.Errorf(message, args...)
}
func (c *chain) reset() {
c.failbit = false
}
func (c *chain) assertFailed(r Reporter) {
if !c.failbit {
r.Errorf("expected chain is failed, but it's ok")
}
}
func (c *chain) assertOK(r Reporter) {
if c.failbit {
r.Errorf("expected chain is ok, but it's failed")
}
}

View file

@ -0,0 +1,29 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
import "net/http"
// Cookie provides methods to inspect attached http.Cookie value.
type Cookie struct {
chain chain
value *http.Cookie
}
func (c *Cookie) Raw() *http.Cookie {
return c.value
}

View file

@ -0,0 +1,37 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
// Match provides methods to inspect attached regexp match results.
type Match struct {
chain chain
submatches []string
names map[string]int
}
func makeMatch(chain chain, submatches []string, names []string) *Match {
if submatches == nil {
submatches = []string{}
}
namemap := map[string]int{}
for n, name := range names {
if name != "" {
namemap[name] = n
}
}
return &Match{chain, submatches, namemap}
}

View file

@ -0,0 +1,111 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
import (
"reflect"
"github.com/yudai/gojsondiff"
"github.com/yudai/gojsondiff/formatter"
)
// Object provides methods to inspect attached map[string]interface{} object
// (Go representation of JSON object).
type Object struct {
chain chain
value map[string]interface{}
}
func (o *Object) ValueEqual(key string, value interface{}) *Object {
if !o.containsKey(key) {
o.chain.fail("\nexpected object containing key '%s', but got:\n%s",
key, dumpValue(o.value))
return o
}
expected, ok := canonValue(&o.chain, value)
if !ok {
return o
}
if !reflect.DeepEqual(expected, o.value[key]) {
o.chain.fail("\nexpected value for key '%s' equal to:\n%s\n\nbut got:\n%s\n\ndiff:\n%s",
key,
dumpValue(expected),
dumpValue(o.value[key]),
diffValues(expected, o.value[key]))
}
return o
}
func (o *Object) ContainsKey(key string) *Object {
if !o.containsKey(key) {
o.chain.fail("\nexpected object containing key '%s', but got:\n%s",
key,
dumpValue(o.value))
}
return o
}
func (o *Object) NotContainsKey(key string) *Object {
if o.containsKey(key) {
o.chain.fail("\nexpected object not containing key '%s', but got:\n%s",
key, dumpValue(o.value))
}
return o
}
func (o *Object) containsKey(key string) bool {
for k := range o.value {
if k == key {
return true
}
}
return false
}
func diffValues(expected, actual interface{}) string {
differ := gojsondiff.New()
var diff gojsondiff.Diff
if ve, ok := expected.(map[string]interface{}); ok {
if va, ok := actual.(map[string]interface{}); ok {
diff = differ.CompareObjects(ve, va)
} else {
return " (unavailable)"
}
} else if ve, ok := expected.([]interface{}); ok {
if va, ok := actual.([]interface{}); ok {
diff = differ.CompareArrays(ve, va)
} else {
return " (unavailable)"
}
} else {
return " (unavailable)"
}
config := formatter.AsciiFormatterConfig{
ShowArrayIndex: true,
}
f := formatter.NewAsciiFormatter(expected, config)
str, err := f.Format(diff)
if err != nil {
return " (unavailable)"
}
return "--- expected\n+++ actual\n" + str
}

View file

@ -0,0 +1,48 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
import (
"fmt"
"github.com/onsi/ginkgo/v2"
"github.com/stretchr/testify/assert"
)
// Reporter is used to report failures.
// testing.TB, AssertReporter, and RequireReporter implement this interface.
type Reporter interface {
// Errorf reports failure.
// Allowed to return normally or terminate test using t.FailNow().
Errorf(message string, args ...interface{})
}
// AssertReporter implements Reporter interface using `testify/assert'
// package. Failures are non-fatal with this reporter.
type AssertReporter struct {
backend *assert.Assertions
}
// NewAssertReporter returns a new AssertReporter object.
func NewAssertReporter() *AssertReporter {
return &AssertReporter{assert.New(ginkgo.GinkgoT())}
}
// Errorf implements Reporter.Errorf.
func (r *AssertReporter) Errorf(message string, args ...interface{}) {
r.backend.Fail(fmt.Sprintf(message, args...))
}

View file

@ -0,0 +1,176 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
import (
"fmt"
"io"
"net/http"
"net/url"
"path"
)
type HTTPRequest struct {
chain chain
reporter Reporter
baseURL string
client *http.Client
query url.Values
Request *http.Request
HTTPResponse *HTTPResponse
}
// NewRequest returns an HTTPRequest object.
func NewRequest(baseURL string, client *http.Client, reporter Reporter) *HTTPRequest {
response := NewResponse(reporter)
return &HTTPRequest{
baseURL: baseURL,
client: client,
reporter: reporter,
chain: makeChain(reporter),
HTTPResponse: response,
}
}
// GET creates a new HTTP request with GET method.
func (h *HTTPRequest) GET(rpath string) *HTTPRequest {
if h.chain.failed() {
return h
}
return h.DoRequest("GET", rpath)
}
// DoRequest creates a new HTTP request object.
func (h *HTTPRequest) DoRequest(method, rpath string) *HTTPRequest {
uri, err := url.Parse(h.baseURL)
if err != nil {
h.chain.fail(err.Error())
}
var request *http.Request
uri.Path = path.Join(uri.Path, rpath)
if request, err = http.NewRequest(method, uri.String(), nil); err != nil {
h.chain.fail(err.Error())
}
h.Request = request
return h
}
// Expect executes the request and returns an HTTP response.
func (h *HTTPRequest) Expect() *HTTPResponse {
if h.query != nil {
h.Request.URL.RawQuery = h.query.Encode()
}
response, err := h.client.Do(h.Request)
if err != nil {
h.chain.fail(err.Error())
}
h.HTTPResponse.Response = response // set the HTTP response
var content []byte
if content, err = getContent(response); err != nil {
h.chain.fail(err.Error())
}
// set content and cookies from HTTPResponse
h.HTTPResponse.content = content
h.HTTPResponse.cookies = h.HTTPResponse.Response.Cookies()
return h.HTTPResponse
}
// WithURL sets the request URL appending paths when already exist.
func (h *HTTPRequest) WithURL(urlStr string) *HTTPRequest {
if h.chain.failed() {
return h
}
if u, err := url.Parse(urlStr); err != nil {
h.chain.fail(err.Error())
} else {
u.Path = path.Join(h.Request.URL.Path, u.Path)
h.Request.URL = u
}
return h
}
// WithHeader adds given header to request.
func (h *HTTPRequest) WithHeader(key, value string) *HTTPRequest {
if h.chain.failed() {
return h
}
switch http.CanonicalHeaderKey(key) {
case "Host":
h.Request.Host = value
default:
h.Request.Header.Add(key, value)
}
return h
}
// WithCookies adds given cookies to request.
func (h *HTTPRequest) WithCookies(cookies map[string]string) *HTTPRequest {
if h.chain.failed() {
return h
}
for k, v := range cookies {
h.WithCookie(k, v)
}
return h
}
// WithCookie adds given single cookie to request.
func (h *HTTPRequest) WithCookie(k, v string) *HTTPRequest {
if h.chain.failed() {
return h
}
h.Request.AddCookie(&http.Cookie{Name: k, Value: v})
return h
}
// WithBasicAuth sets the request's Authorization header to use HTTP
// Basic Authentication with the provided username and password.
//
// With HTTP Basic Authentication the provided username and password
// are not encrypted.
func (h *HTTPRequest) WithBasicAuth(username, password string) *HTTPRequest {
if h.chain.failed() {
return h
}
h.Request.SetBasicAuth(username, password)
return h
}
// WithQuery adds query parameter to request URL.
func (h *HTTPRequest) WithQuery(key string, value interface{}) *HTTPRequest {
if h.chain.failed() {
return h
}
if h.query == nil {
h.query = make(url.Values)
}
h.query.Add(key, fmt.Sprint(value))
return h
}
// getContent returns the content from the body response.
func getContent(resp *http.Response) ([]byte, error) {
if resp.Body == nil {
return []byte{}, nil
}
return io.ReadAll(resp.Body)
}

View file

@ -0,0 +1,273 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
import (
"encoding/json"
"fmt"
"mime"
"net/http"
"reflect"
"strconv"
"strings"
)
// StatusRange is enum for response status ranges.
type StatusRange int
const (
// Status1xx defines "Informational" status codes.
Status1xx StatusRange = 100
// Status2xx defines "Success" status codes.
Status2xx StatusRange = 200
// Status3xx defines "Redirection" status codes.
Status3xx StatusRange = 300
// Status4xx defines "Client Error" status codes.
Status4xx StatusRange = 400
// Status5xx defines "Server Error" status codes.
Status5xx StatusRange = 500
)
type HTTPResponse struct {
chain chain
content []byte
cookies []*http.Cookie
Response *http.Response
}
// NewResponse returns an empty HTTPResponse object.
func NewResponse(reporter Reporter) *HTTPResponse {
return &HTTPResponse{
chain: makeChain(reporter),
}
}
// Body returns the body of the response.
func (r *HTTPResponse) Body() *String {
return &String{value: string(r.content)}
}
// Raw returns the raw http response.
func (r *HTTPResponse) Raw() *http.Response {
return r.Response
}
// Status compare the actual http response with the expected one raising and error
// if they don't match.
func (r *HTTPResponse) Status(status int) *HTTPResponse {
if r.chain.failed() {
return r
}
r.checkEqual("status", statusCodeText(status), statusCodeText(r.Response.StatusCode))
return r
}
// ContentEncoding succeeds if response has exactly given Content-Encoding
func (r *HTTPResponse) ContentEncoding(encoding ...string) *HTTPResponse {
if r.chain.failed() {
return r
}
r.checkEqual("\"Content-Encoding\" header", encoding, r.Response.Header["Content-Encoding"])
return r
}
// ContentType succeeds if response contains Content-Type header with given
// media type and charset.
func (r *HTTPResponse) ContentType(mediaType string, charset ...string) *HTTPResponse {
r.checkContentType(mediaType, charset...)
return r
}
// Cookies returns a new Array object with all cookie names set by this response.
// Returned Array contains a String value for every cookie name.
func (r *HTTPResponse) Cookies() *Array {
if r.chain.failed() {
return &Array{r.chain, nil}
}
names := []interface{}{}
for _, c := range r.cookies {
names = append(names, c.Name)
}
return &Array{r.chain, names}
}
// Cookie returns a new Cookie object that may be used to inspect given cookie
// set by this response.
func (r *HTTPResponse) Cookie(name string) *Cookie {
if r.chain.failed() {
return &Cookie{r.chain, nil}
}
names := []string{}
for _, c := range r.cookies {
if c.Name == name {
return &Cookie{r.chain, c}
}
names = append(names, c.Name)
}
r.chain.fail("\nexpected response with cookie:\n %q\n\nbut got only cookies:\n%s", name, dumpValue(names))
return &Cookie{r.chain, nil}
}
// Headers returns a new Object that may be used to inspect header map.
func (r *HTTPResponse) Headers() *Object {
var value map[string]interface{}
if !r.chain.failed() {
value, _ = canonMap(&r.chain, r.Response.Header)
}
return &Object{r.chain, value}
}
// Header returns a new String object that may be used to inspect given header.
func (r *HTTPResponse) Header(header string) *String {
return &String{chain: r.chain, value: r.Response.Header.Get(header)}
}
func canonMap(chain *chain, in interface{}) (map[string]interface{}, bool) {
var out map[string]interface{}
data, ok := canonValue(chain, in)
if ok {
out, ok = data.(map[string]interface{})
if !ok {
chain.fail("expected map, got %v", out)
}
}
return out, ok
}
func canonValue(chain *chain, in interface{}) (interface{}, bool) {
b, err := json.Marshal(in)
if err != nil {
chain.fail(err.Error())
return nil, false
}
var out interface{}
if err := json.Unmarshal(b, &out); err != nil {
chain.fail(err.Error())
return nil, false
}
return out, true
}
// StatusRange succeeds if response status belongs to given range.
func (r *HTTPResponse) StatusRange(rn StatusRange) *HTTPResponse {
if r.chain.failed() {
return r
}
status := statusCodeText(r.Response.StatusCode)
actual := statusRangeText(r.Response.StatusCode)
expected := statusRangeText(int(rn))
if actual == "" || actual != expected {
if actual == "" {
r.chain.fail("\nexpected status from range:\n %q\n\nbut got:\n %q",
expected, status)
} else {
r.chain.fail("\nexpected status from range:\n %q\n\nbut got:\n %q (%q)",
expected, actual, status)
}
}
return r
}
func statusCodeText(code int) string {
if s := http.StatusText(code); s != "" {
return strconv.Itoa(code) + " " + s
}
return strconv.Itoa(code)
}
func statusRangeText(code int) string {
switch {
case code >= 100 && code < 200:
return "1xx Informational"
case code >= 200 && code < 300:
return "2xx Success"
case code >= 300 && code < 400:
return "3xx Redirection"
case code >= 400 && code < 500:
return "4xx Client Error"
case code >= 500 && code < 600:
return "5xx Server Error"
default:
return ""
}
}
func (r *HTTPResponse) checkContentType(expectedType string, expectedCharset ...string) bool {
if r.chain.failed() {
return false
}
contentType := r.Response.Header.Get("Content-Type")
if expectedType == "" && len(expectedCharset) == 0 {
if contentType == "" {
return true
}
}
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
r.chain.fail("\ngot invalid \"Content-Type\" header %q", contentType)
return false
}
if mediaType != expectedType {
r.chain.fail("\nexpected \"Content-Type\" header with %q media type,"+
"\nbut got %q", expectedType, mediaType)
return false
}
charset := params["charset"]
if len(expectedCharset) == 0 {
if charset != "" && !strings.EqualFold(charset, "utf-8") {
r.chain.fail("\nexpected \"Content-Type\" header with \"utf-8\" or empty charset,"+
"\nbut got %q", charset)
return false
}
} else {
if !strings.EqualFold(charset, expectedCharset[0]) {
r.chain.fail("\nexpected \"Content-Type\" header with %q charset,"+
"\nbut got %q", expectedCharset[0], charset)
return false
}
}
return true
}
func (r *HTTPResponse) checkEqual(what string, expected, actual interface{}) {
if !reflect.DeepEqual(expected, actual) {
r.chain.fail("\nexpected %s equal to:\n%s\n\nbut got:\n%s",
what, dumpValue(expected), dumpValue(actual))
}
}
func dumpValue(value interface{}) string {
b, err := json.MarshalIndent(value, " ", " ")
if err != nil {
return " " + fmt.Sprintf("%#v", value)
}
return " " + string(b)
}

View file

@ -0,0 +1,120 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
import (
"regexp"
"strings"
)
// String provides methods to inspect attached string value
// (Go representation of JSON string).
type String struct {
chain chain
value string
}
// Raw returns underlying value attached to String.
// This is the value originally passed to NewString.
func (s *String) Raw() string {
return s.value
}
// Empty succeeds if string is empty.
func (s *String) Empty() *String {
return s.Equal("")
}
// NotEmpty succeeds if string is non-empty.
func (s *String) NotEmpty() *String {
return s.NotEqual("")
}
// Equal succeeds if string is equal to given Go string.
func (s *String) Equal(value string) *String {
if !(s.value == value) {
s.chain.fail("\nexpected string equal to:\n %q\n\nbut got:\n %q", value, s.value)
}
return s
}
// NotEqual succeeds if string is not equal to given Go string.
func (s *String) NotEqual(value string) *String {
if !(s.value != value) {
s.chain.fail("\nexpected string not equal to:\n %q", value)
}
return s
}
// Contains succeeds if string contains given Go string as a substring.
func (s *String) Contains(value string) *String {
if !strings.Contains(s.value, value) {
s.chain.fail(
"\nexpected string containing substring:\n %q\n\nbut got:\n %q",
value, s.value)
}
return s
}
// NotContains succeeds if string doesn't contain Go string as a substring.
func (s *String) NotContains(value string) *String {
if strings.Contains(s.value, value) {
s.chain.fail("\nexpected string not containing substring:\n %q\n\nbut got:\n %q", value, s.value)
}
return s
}
// ContainsFold succeeds if string contains given Go string as a substring after
// applying Unicode case-folding (so it's a case-insensitive match).
func (s *String) ContainsFold(value string) *String {
if !strings.Contains(strings.ToLower(s.value), strings.ToLower(value)) {
s.chain.fail("\nexpected string containing substring (case-insensitive):\n %q"+"\n\nbut got:\n %q", value, s.value)
}
return s
}
// NotContainsFold succeeds if string doesn't contain given Go string as a substring
// after applying Unicode case-folding (so it's a case-insensitive match).
//
// Example:
//
// str := NewString(t, "Hello")
// str.NotContainsFold("BYE")
func (s *String) NotContainsFold(value string) *String {
if strings.Contains(strings.ToLower(s.value), strings.ToLower(value)) {
s.chain.fail("\nexpected string not containing substring (case-insensitive):\n %q"+"\n\nbut got:\n %q", value, s.value)
}
return s
}
// Match matches the string with given regexp and returns a new Match object
// with found submatches.
func (s *String) Match(re string) *Match {
r, err := regexp.Compile(re)
if err != nil {
s.chain.fail(err.Error())
return makeMatch(s.chain, nil, nil)
}
m := r.FindStringSubmatch(s.value)
if m == nil {
s.chain.fail("\nexpected string matching regexp:\n `%s`\n\nbut got:\n %q", re, s.value)
return makeMatch(s.chain, nil, nil)
}
return makeMatch(s.chain, m, r.SubexpNames())
}

View file

@ -0,0 +1,33 @@
/*
Copyright 2022 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package httpexpect
// Value provides methods to inspect attached interface{} object
// (Go representation of arbitrary JSON value) and cast it to
// concrete type.
type Value struct {
chain chain
value interface{}
}
func (v *Value) String() *String {
data, ok := v.value.(string)
if !ok {
v.chain.fail("\nexpected string value, but got:\n%s", dumpValue(v.value))
}
return &String{v.chain, data}
}