HTTP API Client: New request 69/181669/7
authorMaciej Wereski <m.wereski@partner.samsung.com>
Thu, 9 Nov 2017 11:28:40 +0000 (12:28 +0100)
committerMaciej Wereski <m.wereski@partner.samsung.com>
Wed, 4 Jul 2018 14:21:51 +0000 (16:21 +0200)
Change-Id: Id974791f22f96b1408d9bcd0514291c6672cb6ed
Signed-off-by: Maciej Wereski <m.wereski@partner.samsung.com>
http/client/client.go
http/client/client_test.go

index 7706220..f0dbed8 100644 (file)
 package client
 
 import (
+       "bytes"
+       "encoding/json"
+       "errors"
+       "io"
+       "io/ioutil"
+       "net/http"
+       "reflect"
        "time"
 
        "git.tizen.org/tools/boruta"
@@ -35,9 +42,13 @@ type BorutaClient struct {
        boruta.Workers
 }
 
-// apiPrefix is part of URL that is common in all uses and contains API
-// version.
-const apiPrefix = "/api/v1/"
+const (
+       // contentType denotes format in which we talk with Boruta server.
+       contentType = "application/json"
+       // apiPrefix is part of URL that is common in all uses and contains API
+       // version.
+       apiPrefix = "/api/v1/"
+)
 
 // NewBorutaClient provides BorutaClient ready to communicate with specified
 // Boruta server.
@@ -49,12 +60,112 @@ func NewBorutaClient(url string) *BorutaClient {
        }
 }
 
+// readBody is simple wrapper function that reads body of http request into byte
+// slice and closes the body.
+func readBody(body io.ReadCloser) ([]byte, error) {
+       defer body.Close()
+       content, err := ioutil.ReadAll(body)
+       if err != nil {
+               err = errors.New("unable to read server response: " + err.Error())
+       }
+       return content, err
+}
+
+// bodyJSONUnmarshal is a wrapper that unmarshals server response into an
+// appropriate structure.
+func bodyJSONUnmarshal(body io.ReadCloser, val interface{}) error {
+       content, err := readBody(body)
+       if err != nil {
+               return err
+       }
+       err = json.Unmarshal(content, val)
+       if err != nil {
+               return errors.New("unmarshalling JSON response failed: " + err.Error())
+       }
+       return nil
+}
+
+// getServerError parses Boruta server response that contains serverError and
+// returns an error.
+func getServerError(resp *http.Response) error {
+       if resp.StatusCode < http.StatusBadRequest {
+               return nil
+       }
+       srvErr := new(util.ServerError)
+       switch resp.Header.Get("Content-Type") {
+       case contentType:
+               if err := bodyJSONUnmarshal(resp.Body, srvErr); err != nil {
+                       return err
+               }
+       default:
+               msg, err := readBody(resp.Body)
+               if err != nil {
+                       return err
+               }
+               srvErr.Err = string(msg)
+       }
+       srvErr.Status = resp.StatusCode
+       return srvErr
+}
+
+// processResponse is helper function that parses Boruta server response and sets
+// returned value or returns serverError. val must be a pointer. In case the body
+// was empty (or server returned an error) it will be zeroed - if the val is a
+// pointer to ReqInfo then ReqInfo members will be zeroed; to nil a pointer pass
+// pointer to pointer to ReqInfo. Function may panic when passed value isn't a pointer.
+func processResponse(resp *http.Response, val interface{}) error {
+       var v reflect.Value
+
+       if val != nil {
+               if reflect.TypeOf(val).Kind() != reflect.Ptr {
+                       panic("can't set val, please pass appropriate pointer")
+               }
+
+               v = reflect.ValueOf(val).Elem()
+       }
+
+       setNil := func() {
+               if val != nil && !reflect.ValueOf(val).IsNil() {
+                       v.Set(reflect.Zero(v.Type()))
+               }
+       }
+
+       switch {
+       case resp.StatusCode == http.StatusNoContent:
+               setNil()
+               return nil
+       case resp.StatusCode >= http.StatusBadRequest:
+               setNil()
+               return getServerError(resp)
+       default:
+               return bodyJSONUnmarshal(resp.Body, val)
+       }
+}
+
 // NewRequest creates new Boruta request.
 func (client *BorutaClient) NewRequest(caps boruta.Capabilities,
        priority boruta.Priority, owner boruta.UserInfo, validAfter time.Time,
        deadline time.Time) (boruta.ReqID, error) {
+       req, err := json.Marshal(&boruta.ReqInfo{
+               Priority:   priority,
+               Owner:      owner,
+               Deadline:   deadline,
+               ValidAfter: validAfter,
+               Caps:       caps,
+       })
+       if err != nil {
+               return 0, err
+       }
 
-       return boruta.ReqID(0), util.ErrNotImplemented
+       resp, err := http.Post(client.url+"reqs/", contentType, bytes.NewReader(req))
+       if err != nil {
+               return 0, err
+       }
+       var reqID util.ReqIDPack
+       if err = processResponse(resp, &reqID); err != nil {
+               return 0, err
+       }
+       return reqID.ReqID, nil
 }
 
 // CloseRequest closes or cancels Boruta request.
index 4687b4d..7cb48db 100644 (file)
 package client
 
 import (
+       "encoding/json"
+       "errors"
+       "io/ioutil"
+       "net/http"
+       "net/http/httptest"
+       "strings"
        "testing"
        "time"
 
        . "git.tizen.org/tools/boruta"
        util "git.tizen.org/tools/boruta/http"
+       "git.tizen.org/tools/boruta/requests"
        "github.com/stretchr/testify/assert"
 )
 
-const url = "http://localhost:8080"
+type testCase struct {
+       // name of testcase - must be the same as in server tests
+       name string
+       // path without server address and apiPrefix (e.g. reqs/5/prolong)
+       path string
+       // json that will be sent by client in HTTP request
+       json string
+       // expected content type
+       contentType string
+       // expected status
+       status int
+       // expected headers
+       header http.Header
+}
+
+type dummyReadCloser int
+
+func (r dummyReadCloser) Close() error {
+       return errors.New("close failed")
+}
 
-func initTest(t *testing.T) (*assert.Assertions, *BorutaClient) {
+func (r dummyReadCloser) Read(p []byte) (n int, err error) {
+       err = errors.New("read failed")
+       return
+}
+
+const (
+       contentJSON = "application/json"
+       dateLayout  = "2006-01-02T15:04:05Z07:00"
+)
+
+// req is valid request that may be used in tests directly or as a template.
+var req ReqInfo
+var errRead = errors.New("unable to read server response: read failed")
+
+func init() {
+       deadline, err := time.Parse(dateLayout, "2200-12-31T01:02:03Z")
+       if err != nil {
+               panic(err)
+       }
+       validAfter, err := time.Parse(dateLayout, "2100-01-01T04:05:06Z")
+       if err != nil {
+               panic(err)
+       }
+       req = ReqInfo{
+               Priority:   Priority(8),
+               Deadline:   deadline,
+               ValidAfter: validAfter,
+               Caps: map[string]string{
+                       "architecture": "armv7l",
+                       "monitor":      "yes",
+               },
+       }
+}
+
+func initTest(t *testing.T, url string) (*assert.Assertions, *BorutaClient) {
        return assert.New(t), NewBorutaClient(url)
 }
 
+// from http/server/api/v1/api.go
+func jsonMustMarshal(data interface{}) []byte {
+       res, err := json.Marshal(data)
+       if err != nil {
+               panic("unable to marshal JSON:" + err.Error())
+       }
+       return res
+}
+
+// generateTestMap returns map where key is path to which client HTTP request
+// will be done. Slices of testCase pointers is used as value.
+func generateTestMap(tests []*testCase) map[string][]*testCase {
+       ret := make(map[string][]*testCase)
+       for _, test := range tests {
+               ret[test.path] = append(ret[test.path], test)
+       }
+       return ret
+}
+
+func prepareServer(method string, tests []*testCase) *httptest.Server {
+       mux := http.NewServeMux()
+       tcasesMap := generateTestMap(tests)
+
+       // Some test cases are identified only by path (e.g. GET/HEAD).
+       validateTestCase := func(test []*testCase) {
+               if len(test) != 1 {
+                       panic("len != 1")
+               }
+       }
+
+       // GET or HEAD doesn't have body (and are idempotent), so path must be unique.
+       if method != http.MethodPost {
+               for _, v := range tcasesMap {
+                       validateTestCase(v)
+               }
+       }
+
+       handler := func(w http.ResponseWriter, r *http.Request) {
+               var test *testCase
+               var data []byte
+               // Take test fixtures from server tests.
+               fpath := "../server/api/v1/testdata/"
+               body, err := ioutil.ReadAll(r.Body)
+               r.Body.Close()
+               // Making operation like "req/id/close" - path must be unique.
+               if method == http.MethodPost && len(body) == 0 {
+                       validateTestCase(tcasesMap[r.URL.Path])
+               }
+               tlen := len(tcasesMap[r.URL.Path])
+               if tlen == 0 {
+                       panic("No test cases for path: " + r.URL.Path)
+               }
+               for i, tcase := range tcasesMap[r.URL.Path] {
+                       // There may be many POST requests per one path.
+                       // Differentiate by body.
+                       if method == http.MethodPost {
+                               if len(body) == 0 || string(body) == tcase.json {
+                                       test = tcase
+                                       break
+                               }
+                               if i == tlen-1 {
+                                       panic("matching testcase not found")
+                               }
+                       } else {
+                               test = tcase
+                               break
+                       }
+               }
+               if test.status != http.StatusNoContent {
+                       // Find appropriate file with reply.
+                       fpath += test.name + "-" + r.Method
+                       switch test.contentType {
+                       case contentJSON:
+                               fpath += ".json"
+                       default:
+                               fpath += ".txt"
+                       }
+                       data, err = ioutil.ReadFile(fpath)
+                       if err != nil {
+                               panic(err)
+                       }
+                       w.Header().Set("Content-Type", test.contentType)
+               }
+               w.WriteHeader(test.status)
+               if test.status != http.StatusNoContent {
+                       w.Write(data)
+               }
+       }
+
+       mux.Handle(apiPrefix, http.HandlerFunc(handler))
+       return httptest.NewServer(mux)
+}
+
 func TestNewBorutaClient(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
 
        assert.NotNil(client)
-       assert.Equal(url+"/api/v1/", client.url)
+       assert.Equal("/api/v1/", client.url)
+}
+
+func TestReadBody(t *testing.T) {
+       assert := assert.New(t)
+       msg := `
+       W malinowym chruśniaku, przed ciekawych wzrokiem
+       Zapodziani po głowy, przez długie godziny
+       Zrywaliśmy przybyłe tej nocy maliny.
+       Palce miałaś na oślep skrwawione ich sokiem.
+       `
+       reader := ioutil.NopCloser(strings.NewReader(msg))
+
+       body, err := readBody(reader)
+       assert.Nil(err)
+       assert.Equal([]byte(msg), body)
+
+       body, err = readBody(dummyReadCloser(0))
+       assert.Equal(errRead, err)
+       assert.Empty(body)
+}
+
+func TestBodyJSONUnmarshal(t *testing.T) {
+       assert := assert.New(t)
+       var reqinfo *ReqInfo
+       reqJSON := jsonMustMarshal(&req)
+       msg := `
+       Bąk złośnik huczał basem, jakby straszył kwiaty,
+       Rdzawe guzy na słońcu wygrzewał liść chory,
+       Złachmaniałych pajęczyn skrzyły się wisiory,
+       I szedł tyłem na grzbiecie jakiś żuk kosmaty.
+       `
+
+       reader := ioutil.NopCloser(strings.NewReader(string(reqJSON)))
+       assert.Nil(bodyJSONUnmarshal(reader, &reqinfo))
+       assert.Equal(&req, reqinfo)
+
+       assert.Equal(errRead, bodyJSONUnmarshal(dummyReadCloser(0), reqinfo))
+
+       errJSON := errors.New("unmarshalling JSON response failed: invalid character 'B' looking for beginning of value")
+       reader = ioutil.NopCloser(strings.NewReader(msg))
+       assert.Equal(errJSON, bodyJSONUnmarshal(reader, reqinfo))
+}
+
+func TestGetServerError(t *testing.T) {
+       assert := assert.New(t)
+       var resp http.Response
+       resp.Header = make(http.Header)
+       msg := `
+       Duszno było od malin, któreś, szepcząc, rwała,
+       A szept nasz tylko wówczas nacichał w ich woni,
+       Gdym wargami wygarniał z podanej mi dłoni
+       Owoce, przepojone wonią twego ciała.
+       `
+
+       resp.StatusCode = http.StatusOK
+       assert.Nil(getServerError(&resp))
+
+       missing := `
+       {
+                 "error": "Request not found"
+       }
+       `
+       resp.Body = ioutil.NopCloser(strings.NewReader(missing))
+       resp.StatusCode = http.StatusNotFound
+       resp.Header.Set("Content-Type", contentJSON)
+       notfound := util.NewServerError(NotFoundError("Request"))
+       assert.Equal(notfound, getServerError(&resp))
+
+       resp.Body = ioutil.NopCloser(strings.NewReader(msg))
+       errJSON := errors.New("unmarshalling JSON response failed: invalid character 'D' looking for beginning of value")
+       assert.Equal(errJSON, getServerError(&resp))
+
+       internal := "internal server error: test"
+       resp.Body = ioutil.NopCloser(strings.NewReader(internal))
+       resp.StatusCode = http.StatusInternalServerError
+       resp.Header.Set("Content-Type", "text/plain")
+       internalErr := util.NewServerError(util.ErrInternalServerError, "test")
+       assert.Equal(internalErr, getServerError(&resp))
+
+       resp.Body = dummyReadCloser(0)
+       assert.Equal(errRead, getServerError(&resp))
+}
+
+func TestProcessResponse(t *testing.T) {
+       assert := assert.New(t)
+       var resp http.Response
+       var reqinfo *ReqInfo
+       var srvErr *util.ServerError
+       missing := `
+       {
+               "error": "Request not found"
+       }
+       `
+
+       resp.StatusCode = http.StatusNoContent
+       assert.Nil(processResponse(&resp, &reqinfo))
+       assert.Nil(reqinfo)
+
+       reqinfo = new(ReqInfo)
+       assert.Nil(processResponse(&resp, &reqinfo))
+       assert.Nil(reqinfo)
+
+       resp.Header = make(http.Header)
+       resp.Header.Set("Content-Type", contentJSON)
+
+       resp.StatusCode = http.StatusOK
+       resp.Body = ioutil.NopCloser(strings.NewReader(string(jsonMustMarshal(&req))))
+       assert.Nil(processResponse(&resp, &reqinfo))
+       assert.Equal(&req, reqinfo)
+
+       notfound := util.NewServerError(NotFoundError("Request"))
+       resp.StatusCode = http.StatusNotFound
+       resp.Body = ioutil.NopCloser(strings.NewReader(missing))
+       srvErr = processResponse(&resp, &reqinfo).(*util.ServerError)
+       assert.Equal(notfound, srvErr)
+
+       badType := "can't set val, please pass appropriate pointer"
+       var foo int
+       assert.PanicsWithValue(badType, func() { processResponse(&resp, foo) })
 }
 
 func TestNewRequest(t *testing.T) {
-       assert, client := initTest(t)
-       assert.NotNil(client)
+       prefix := "new-req-"
+       path := "/api/v1/reqs/"
 
-       reqID, err := client.NewRequest(make(Capabilities), Priority(0),
-               UserInfo{}, time.Now(), time.Now())
+       badPrio := req
+       badPrio.Priority = Priority(32)
+       tests := []*testCase{
+               &testCase{
+                       // valid request
+                       name:        prefix + "valid",
+                       path:        path,
+                       json:        string(jsonMustMarshal(&req)),
+                       contentType: contentJSON,
+                       status:      http.StatusCreated,
+               },
+               &testCase{
+                       // bad request - priority out of bounds
+                       name:        prefix + "bad-prio",
+                       path:        path,
+                       json:        string(jsonMustMarshal(&badPrio)),
+                       contentType: contentJSON,
+                       status:      http.StatusBadRequest,
+               },
+       }
+
+       srv := prepareServer(http.MethodPost, tests)
+       defer srv.Close()
+       assert, client := initTest(t, srv.URL)
+
+       // valid request
+       reqID, err := client.NewRequest(req.Caps, req.Priority,
+               req.Owner, req.ValidAfter, req.Deadline)
+       assert.Equal(ReqID(1), reqID)
+       assert.Nil(err)
+
+       // bad request - priority out of bounds
+       expectedErr := util.NewServerError(requests.ErrPriority)
+       reqID, err = client.NewRequest(badPrio.Caps, badPrio.Priority,
+               badPrio.Owner, badPrio.ValidAfter, badPrio.Deadline)
        assert.Zero(reqID)
-       assert.Equal(util.ErrNotImplemented, err)
+       assert.Equal(expectedErr, err)
+
+       // http.Post failure
+       client.url = "http://nosuchaddress.fail"
+       reqID, err = client.NewRequest(req.Caps, req.Priority,
+               req.Owner, req.ValidAfter, req.Deadline)
+       assert.Zero(reqID)
+       assert.NotNil(err)
 }
 
 func TestCloseRequest(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        err := client.CloseRequest(ReqID(0))
@@ -57,7 +369,7 @@ func TestCloseRequest(t *testing.T) {
 }
 
 func TestUpdateRequest(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        err := client.UpdateRequest(nil)
@@ -65,7 +377,7 @@ func TestUpdateRequest(t *testing.T) {
 }
 
 func TestGetRequestInfo(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        reqInfo, err := client.GetRequestInfo(ReqID(0))
@@ -74,7 +386,7 @@ func TestGetRequestInfo(t *testing.T) {
 }
 
 func TestListRequests(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        reqInfo, err := client.ListRequests(nil)
@@ -83,7 +395,7 @@ func TestListRequests(t *testing.T) {
 }
 
 func TestAcquireWorker(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        accessInfo, err := client.AcquireWorker(ReqID(0))
@@ -92,7 +404,7 @@ func TestAcquireWorker(t *testing.T) {
 }
 
 func TestProlongAccess(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        err := client.ProlongAccess(ReqID(0))
@@ -100,7 +412,7 @@ func TestProlongAccess(t *testing.T) {
 }
 
 func TestListWorkers(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        list, err := client.ListWorkers(nil, nil)
@@ -109,7 +421,7 @@ func TestListWorkers(t *testing.T) {
 }
 
 func TestGetWorkerInfo(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        info, err := client.GetWorkerInfo(WorkerUUID(""))
@@ -118,7 +430,7 @@ func TestGetWorkerInfo(t *testing.T) {
 }
 
 func TestSetState(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        err := client.SetState(WorkerUUID(""), FAIL)
@@ -126,7 +438,7 @@ func TestSetState(t *testing.T) {
 }
 
 func TestSetGroups(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        err := client.SetGroups(WorkerUUID(""), nil)
@@ -134,7 +446,7 @@ func TestSetGroups(t *testing.T) {
 }
 
 func TestDeregister(t *testing.T) {
-       assert, client := initTest(t)
+       assert, client := initTest(t, "")
        assert.NotNil(client)
 
        err := client.Deregister(WorkerUUID(""))