github.com/qri-io/qri@v0.10.1-0.20220104210721-c771715036cb/api/spec/spec.go (about)

     1  package spec
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"net"
    10  	"net/http"
    11  	"net/http/httptest"
    12  	"net/url"
    13  	"os"
    14  	"path/filepath"
    15  	"strings"
    16  	"testing"
    17  )
    18  
    19  // AssertHTTPAPISpec runs a test suite of HTTP requests against the given base URL
    20  // to assert it conforms to the qri core API specification. Spec is defined in
    21  // the "open_api_3.yaml" file in the api package
    22  func AssertHTTPAPISpec(t *testing.T, baseURL, specPackagePath string) {
    23  	t.Helper()
    24  
    25  	// Create a mock data server. Can't move this into the testRunner, because we need to
    26  	// ensure only this test is using the server's port "55556".
    27  	s := newMockDataServer(t)
    28  	defer s.Close()
    29  
    30  	base, err := url.Parse(baseURL)
    31  	if err != nil {
    32  		t.Fatalf("invalid base url: %s", err)
    33  	}
    34  
    35  	testFiles := []string{
    36  		"testdata/access.json",
    37  		"testdata/aggregate.json",
    38  		"testdata/automation.json",
    39  		"testdata/dataset.json",
    40  		"testdata/misc.json",
    41  		"testdata/peer.json",
    42  		"testdata/profile.json",
    43  		"testdata/remote.json",
    44  		// sync.json is intentionally left out
    45  		// as it's more a protocol that doesn't belong
    46  		// in the RPC API
    47  		// "testdata/sync.json",
    48  	}
    49  
    50  	sl := mustLoadSkipList(t, filepath.Join(specPackagePath, "testdata/skip.json"))
    51  
    52  	for _, path := range testFiles {
    53  		t.Run(filepath.Base(path), func(t *testing.T) {
    54  			ts := mustLoadTestSuite(t, filepath.Join(specPackagePath, path))
    55  			for i, c := range ts {
    56  				if isInSkipList(sl, c.Endpoint) {
    57  					continue
    58  				}
    59  				if err := c.do(base); err != nil {
    60  					t.Errorf("case %d %s %s:\n%s", i, c.Method, c.Endpoint, err)
    61  				}
    62  			}
    63  		})
    64  	}
    65  }
    66  
    67  func isInSkipList(sl SkipList, endpoint string) bool {
    68  	skip := false
    69  	for _, s := range sl {
    70  		if s == endpoint {
    71  			skip = true
    72  			break
    73  		}
    74  	}
    75  	return skip
    76  }
    77  
    78  func newMockDataServer(t *testing.T) *httptest.Server {
    79  	t.Helper()
    80  
    81  	mockData := []byte(`Parent Identifier,Student Identifier
    82  1001,1002
    83  1010,1020
    84  `)
    85  	mockDataServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    86  		w.Write(mockData)
    87  	}))
    88  	l, err := net.Listen("tcp", ":55556")
    89  	if err != nil {
    90  		t.Fatal(err.Error())
    91  	}
    92  	mockDataServer.Listener = l
    93  	mockDataServer.Start()
    94  	return mockDataServer
    95  }
    96  
    97  // TestCase is a single request-response round trip to the API with parameters
    98  // for constructing the request & expectations for assessing the response.
    99  type TestCase struct {
   100  	Endpoint string            // API endpoint to test
   101  	Method   string            // HTTP request method, defaults to "GET"
   102  	Params   map[string]string // Request query or path parameters
   103  	Headers  map[string]string // Request HTTP headers
   104  	Body     interface{}       // request body
   105  	Expect   *Response         // Assertions about the response
   106  }
   107  
   108  func (c *TestCase) do(u *url.URL) error {
   109  	if c.Method == "" {
   110  		c.Method = http.MethodGet
   111  	}
   112  
   113  	u.Path = c.Endpoint
   114  
   115  	qvars := u.Query()
   116  	for k, v := range c.Params {
   117  		qvars.Set(k, v)
   118  	}
   119  	u.RawQuery = qvars.Encode()
   120  
   121  	body, err := c.reqBodyReader()
   122  	if err != nil {
   123  		return err
   124  	}
   125  
   126  	req, err := http.NewRequest(c.Method, u.String(), body)
   127  	if err != nil {
   128  		return err
   129  	}
   130  	for k, v := range c.Headers {
   131  		req.Header.Set(k, v)
   132  	}
   133  
   134  	res, err := http.DefaultClient.Do(req)
   135  	if err != nil {
   136  		return err
   137  	}
   138  
   139  	if exp := c.Expect; exp != nil {
   140  		if exp.Code != 0 && exp.Code != res.StatusCode {
   141  			return fmt.Errorf("response status code mismatch. want: %d got: %d\nresponse body: %s", exp.Code, res.StatusCode, c.resBodyErrString(res))
   142  		}
   143  
   144  		for key, expVal := range exp.Headers {
   145  			got := res.Header.Get(key)
   146  			if expVal != got {
   147  				return fmt.Errorf("repsonse header %q mismatch.\nwant: %q\ngot:  %q", key, expVal, got)
   148  			}
   149  		}
   150  	}
   151  
   152  	return nil
   153  }
   154  
   155  func (c *TestCase) reqBodyReader() (io.Reader, error) {
   156  	switch b := c.Body.(type) {
   157  	case map[string]interface{}:
   158  		buf := &bytes.Buffer{}
   159  		if err := json.NewEncoder(buf).Encode(b); err != nil {
   160  			return nil, err
   161  		}
   162  		return buf, nil
   163  	case string:
   164  		return strings.NewReader(b), nil
   165  	case nil:
   166  		return nil, nil
   167  	default:
   168  		return nil, fmt.Errorf("unrecognized type for request body %T", c.Body)
   169  	}
   170  }
   171  
   172  func (c *TestCase) decodeResponseBody(res *http.Response) (body interface{}, contentType string, err error) {
   173  	defer res.Body.Close()
   174  	contentType = res.Header.Get("Content-Type")
   175  	switch contentType {
   176  	case "application/json":
   177  		err = json.NewDecoder(res.Body).Decode(&body)
   178  	default:
   179  		body, err = ioutil.ReadAll(res.Body)
   180  	}
   181  	return body, contentType, err
   182  }
   183  
   184  func (c *TestCase) resBodyErrString(res *http.Response) string {
   185  	bd, ct, err := c.decodeResponseBody(res)
   186  	if err != nil {
   187  		return err.Error()
   188  	}
   189  	if ct == "application/json" {
   190  		enc, _ := json.MarshalIndent(bd, "", "  ")
   191  		return string(enc)
   192  	}
   193  
   194  	if data, ok := bd.([]byte); ok {
   195  		return string(data)
   196  	}
   197  
   198  	return fmt.Sprintf("<unexpected response body. Content-Type: %q DataType: %T>", ct, bd)
   199  }
   200  
   201  // Response holds the expected HTTP response
   202  type Response struct {
   203  	Code    int
   204  	Headers map[string]string
   205  }
   206  
   207  func mustLoadTestSuite(t *testing.T, filePath string) []*TestCase {
   208  	f, err := os.Open(filePath)
   209  	if err != nil {
   210  		t.Fatalf("opening test suite file %q: %s", filePath, err)
   211  	}
   212  	defer f.Close()
   213  	suite := []*TestCase{}
   214  	if err := json.NewDecoder(f).Decode(&suite); err != nil {
   215  		t.Fatalf("deserializing test suite file %q: %s", filePath, err)
   216  	}
   217  
   218  	return suite
   219  }
   220  
   221  // SkipList holds a list of endpoints for which to skip testing
   222  type SkipList []string
   223  
   224  func mustLoadSkipList(t *testing.T, filePath string) SkipList {
   225  	f, err := os.Open(filePath)
   226  	if err != nil {
   227  		t.Fatalf("opening test skip list file %q: %s", filePath, err)
   228  	}
   229  	defer f.Close()
   230  	skipList := SkipList{}
   231  	if err := json.NewDecoder(f).Decode(&skipList); err != nil {
   232  		t.Fatalf("deserializing test skip list file %q: %s", filePath, err)
   233  	}
   234  
   235  	return skipList
   236  }