HTTP API: Add agregation package
authorMaciej Wereski <m.wereski@partner.samsung.com>
Thu, 23 Nov 2017 15:12:27 +0000 (16:12 +0100)
committerMaciej Wereski <m.wereski@partner.samsung.com>
Tue, 5 Jun 2018 10:48:28 +0000 (12:48 +0200)
Its purpose is to create and aggregate all Boruta HTTP API version. It
also provides handler for panics and redirect ambiguous request to a
default API version.

Change-Id: Ida94aed412951744557db6c30dd658d36ff3e47e
Signed-off-by: Maciej Wereski <m.wereski@partner.samsung.com>
http/server/api/api.go [new file with mode: 0644]
http/server/api/api_test.go [new file with mode: 0644]
http/server/api/testdata/panic-other-error.txt [moved from http/server/api/v1/testdata/panic-other-error.txt with 100% similarity]
http/server/api/testdata/panic-server-error.txt [moved from http/server/api/v1/testdata/panic-server-error.txt with 100% similarity]
http/server/api/v1/api.go
http/server/api/v1/api_test.go
http/server/api/v1/handlers_test.go

diff --git a/http/server/api/api.go b/http/server/api/api.go
new file mode 100644 (file)
index 0000000..cddbca0
--- /dev/null
@@ -0,0 +1,107 @@
+/*
+ *  Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved
+ *
+ *  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 api aggregates all availabe Boruta HTTP API versions.
+package api
+
+import (
+       "fmt"
+       "net/http"
+
+       . "git.tizen.org/tools/boruta"
+       util "git.tizen.org/tools/boruta/http"
+       "git.tizen.org/tools/boruta/http/server/api/v1"
+       "github.com/dimfeld/httptreemux"
+)
+
+// defaultAPI contains information which version of the API is treated as default.
+// It should always be latest stable version.
+const defaultAPI = v1.Version
+
+// API provides HTTP API handlers.
+type API struct {
+       r       *httptreemux.TreeMux
+       reqs    Requests
+       workers Workers
+}
+
+// panicHandler is desired as httptreemux PanicHandler function. It sends
+// InternalServerError with details to client whose request caused panic.
+func panicHandler(w http.ResponseWriter, r *http.Request, err interface{}) {
+       var reason interface{}
+       var status = http.StatusInternalServerError
+       switch srvErr := err.(type) {
+       case *util.ServerError:
+               reason = srvErr.Err
+               status = srvErr.Status
+       default:
+               reason = srvErr
+       }
+       // Because marshalling JSON may fail, data is sent in plaintext.
+       w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+       w.WriteHeader(status)
+       w.Write([]byte(fmt.Sprintf("Internal Server Error:\n%s", reason)))
+}
+
+// redirectToDefault redirects requests which lack API version information to
+// default API. For example, if "v1" is the default API version, then request
+// with path "/api/reqs/list" will be redirected to "/api/v1/reqs/list".
+func redirectToDefault(w http.ResponseWriter, r *http.Request,
+       p map[string]string) {
+       u := *r.URL
+       u.Path = "/api/" + defaultAPI + "/" + p["path"]
+       http.Redirect(w, r, u.String(), http.StatusPermanentRedirect)
+}
+
+// setDefaultAPI register handler for API calls that lack API version in path.
+func setDefaultAPIRedirect(prefix *httptreemux.Group) {
+       for _, method := range [...]string{
+               http.MethodGet,
+               http.MethodHead,
+               http.MethodPost,
+               http.MethodPut,
+               http.MethodPatch,
+               http.MethodDelete,
+               http.MethodConnect,
+               http.MethodOptions,
+               http.MethodTrace,
+       } {
+               prefix.Handle(method, "/*path", redirectToDefault)
+       }
+}
+
+// NewAPI registers all available Boruta HTTP APIs on provided router. It also
+// sets panicHandler for all panics that may occur in any API. Finally it sets
+// default API version to which requests that miss API version are redirected.
+func NewAPI(router *httptreemux.TreeMux, requestsAPI Requests,
+       workersAPI Workers) (api *API) {
+       api = new(API)
+
+       api.reqs = requestsAPI
+       api.workers = workersAPI
+
+       api.r = router
+       api.r.PanicHandler = panicHandler
+       api.r.RedirectBehavior = httptreemux.Redirect308
+
+       all := api.r.NewGroup("/api")
+       v1group := all.NewGroup("/" + v1.Version)
+
+       _ = v1.NewAPI(v1group, api.reqs, api.workers)
+       setDefaultAPIRedirect(all)
+
+       return
+}
diff --git a/http/server/api/api_test.go b/http/server/api/api_test.go
new file mode 100644 (file)
index 0000000..94b89e2
--- /dev/null
@@ -0,0 +1,146 @@
+/*
+ *  Copyright (c) 2017-2018 Samsung Electronics Co., Ltd All Rights Reserved
+ *
+ *  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 api
+
+import (
+       "errors"
+       "flag"
+       "io/ioutil"
+       "net/http"
+       "net/http/httptest"
+       "os"
+       "path/filepath"
+       "testing"
+
+       util "git.tizen.org/tools/boruta/http"
+       "git.tizen.org/tools/boruta/matcher"
+       "git.tizen.org/tools/boruta/requests"
+       "git.tizen.org/tools/boruta/workers"
+       "github.com/dimfeld/httptreemux"
+       "github.com/stretchr/testify/assert"
+)
+
+var update bool
+
+func TestMain(m *testing.M) {
+       flag.BoolVar(&update, "update", false, "update testdata")
+       flag.Parse()
+       os.Exit(m.Run())
+}
+
+func initTest(t *testing.T) (*assert.Assertions, *API) {
+       wm := workers.NewWorkerList()
+       return assert.New(t), NewAPI(httptreemux.New(),
+               requests.NewRequestQueue(wm, matcher.NewJobsManager(wm)), wm)
+}
+
+func TestPanicHandler(t *testing.T) {
+       assert, api := initTest(t)
+       msg := "Test PanicHandler: server panic"
+       otherErr := "some error"
+       tests := [...]struct {
+               name string
+               path string
+               err  interface{}
+       }{
+               {
+                       name: "panic-server-error",
+                       path: "/priv/api/panic/srvErr/",
+                       err: &util.ServerError{
+                               Err:    msg,
+                               Status: http.StatusInternalServerError,
+                       },
+               },
+               {
+                       name: "panic-other-error",
+                       path: "/priv/api/panic/otherErr/",
+                       err:  otherErr,
+               },
+       }
+       contentType := "text/plain; charset=utf-8"
+       grp := api.r.NewGroup("/")
+
+       newHandler := func(err interface{}) httptreemux.HandlerFunc {
+               return func(_ http.ResponseWriter, _ *http.Request, _ map[string]string) {
+                       panic(err)
+               }
+       }
+       for _, test := range tests {
+               grp.GET(test.path, newHandler(test.err))
+       }
+       srv := httptest.NewServer(api.r)
+       assert.NotNil(srv)
+       defer srv.Close()
+       for _, test := range tests {
+               resp, err := http.Get(srv.URL + test.path)
+               assert.Nil(err)
+               tdata := filepath.Join("testdata", test.name+".txt")
+               body, err := ioutil.ReadAll(resp.Body)
+               assert.Nil(err)
+               if update {
+                       ioutil.WriteFile(tdata, body, 0644)
+               }
+               expected, err := ioutil.ReadFile(tdata)
+               assert.Nil(err)
+               assert.Equal(http.StatusInternalServerError, resp.StatusCode)
+               assert.Equal(contentType, resp.Header.Get("Content-Type"))
+               assert.Equal(expected, body)
+       }
+}
+
+func TestNewAPI(t *testing.T) {
+       assert, api := initTest(t)
+       assert.NotNil(api)
+}
+
+func TestRedirectToDefault(t *testing.T) {
+       assert, api := initTest(t)
+       srv := httptest.NewServer(api.r)
+       defer srv.Close()
+
+       reqPath := "/api/reqs"
+       redirPath := "/api/" + defaultAPI + "/reqs/"
+       var i int
+       redirCheck := func(req *http.Request, via []*http.Request) error {
+               switch {
+               case i == 0:
+                       // Check if proper status code was set.
+                       assert.Equal(http.StatusPermanentRedirect, req.Response.StatusCode)
+                       // Check if method hasn't changed.
+                       assert.Equal(via[0].Method, req.Method, "first redirection")
+               case i == 1:
+                       // Check if proper URL was set.
+                       assert.Equal(srv.URL+redirPath, req.URL.String())
+                       // Check if method hasn't changed.
+                       assert.Equal(via[0].Method, req.Method, "second redirection")
+                       // It isn't our business if default API does more redirects, but
+                       // return error when there's more than 10 redirects (as Go does).
+               case i > 9:
+                       return errors.New("too many redirects")
+               }
+               i++
+               return nil
+       }
+
+       client := http.Client{
+               CheckRedirect: redirCheck,
+       }
+
+       // Response is checked in redirCheck.
+       _, err := client.Post(srv.URL+reqPath, "text/plain", nil)
+       assert.Nil(err)
+}
index 61be0d5..bc897e1 100644 (file)
@@ -21,7 +21,6 @@ package v1
 
 import (
        "encoding/json"
-       "fmt"
        "net/http"
        "regexp"
        "strconv"
@@ -38,9 +37,12 @@ type responseData interface{}
 // reqHandler denotes function that parses HTTP request and returns responseData.
 type reqHandler func(*http.Request, map[string]string) responseData
 
+// Version contains version string of the API.
+const Version = "v1"
+
 // API provides HTTP API handlers.
 type API struct {
-       r       *httptreemux.TreeMux
+       r       *httptreemux.Group
        reqs    Requests
        workers Workers
 }
@@ -59,24 +61,6 @@ func jsonMustMarshal(data responseData) []byte {
        return res
 }
 
-// panicHandler is intended as a httptreemux PanicHandler function. It sends
-// InternalServerError with details to client whose request caused panic.
-func panicHandler(w http.ResponseWriter, r *http.Request, err interface{}) {
-       var reason interface{}
-       var status = http.StatusInternalServerError
-       switch srvErr := err.(type) {
-       case *util.ServerError:
-               reason = srvErr.Err
-               status = srvErr.Status
-       default:
-               reason = srvErr
-       }
-       // Because marshalling JSON may fail, data is sent in plaintext.
-       w.Header().Set("Content-Type", "text/plain; charset=utf-8")
-       w.WriteHeader(status)
-       w.Write([]byte(fmt.Sprintf("Internal Server Error:\n%s", reason)))
-}
-
 // routerSetHandler wraps fn by adding HTTP headers, handling error and
 // marshalling. Such wrapped function is then registered in the API router as a
 // handler for given path, provided methods and HTTP success status that should
@@ -106,9 +90,9 @@ func routerSetHandler(grp *httptreemux.Group, path string, fn reqHandler,
        }
 }
 
-// NewAPI takes router and registers HTTP API in it. httptreemux.PanicHandler
+// NewAPI takes router and registers HTTP API in it. htttreemux.PanicHandler
 // function is set. Also other setting of the router may be modified.
-func NewAPI(router *httptreemux.TreeMux, requestsAPI Requests,
+func NewAPI(router *httptreemux.Group, requestsAPI Requests,
        workersAPI Workers) (api *API) {
        api = new(API)
 
@@ -116,11 +100,9 @@ func NewAPI(router *httptreemux.TreeMux, requestsAPI Requests,
        api.workers = workersAPI
 
        api.r = router
-       api.r.PanicHandler = panicHandler
 
-       root := api.r.NewGroup("/api/v1")
-       reqs := root.NewGroup("/reqs")
-       workers := root.NewGroup("/workers")
+       reqs := api.r.NewGroup("/reqs")
+       workers := api.r.NewGroup("/workers")
 
        // Requests API
        routerSetHandler(reqs, "/", api.listRequestsHandler, http.StatusOK,
index a1da367..e53dbd6 100644 (file)
@@ -27,7 +27,6 @@ import (
        "testing"
 
        . "git.tizen.org/tools/boruta"
-       util "git.tizen.org/tools/boruta/http"
        "git.tizen.org/tools/boruta/mocks"
        "github.com/dimfeld/httptreemux"
        "github.com/golang/mock/gomock"
@@ -120,14 +119,16 @@ func TestMain(m *testing.M) {
        os.Exit(m.Run())
 }
 
-func initTest(t *testing.T) (*assert.Assertions, *allMocks, *API) {
+func initTest(t *testing.T) (*assert.Assertions, *allMocks, *httptreemux.TreeMux) {
+       r := httptreemux.New()
        ctrl := gomock.NewController(t)
        m := &allMocks{
                ctrl: ctrl,
                rq:   mocks.NewMockRequests(ctrl),
                wm:   mocks.NewMockWorkers(ctrl),
        }
-       return assert.New(t), m, NewAPI(httptreemux.New(), m.rq, m.wm)
+       _ = NewAPI(r.NewGroup("/api/"+Version), m.rq, m.wm)
+       return assert.New(t), m, r
 }
 
 func (m *allMocks) finish() {
@@ -161,8 +162,8 @@ func newWorker(uuid string, state WorkerState, groups Groups, caps Capabilities)
        return
 }
 
-func runTests(assert *assert.Assertions, api *API, tests []requestTest) {
-       srv := httptest.NewServer(api.r)
+func runTests(assert *assert.Assertions, r *httptreemux.TreeMux, tests []requestTest) {
+       srv := httptest.NewServer(r)
        defer srv.Close()
        var req *http.Request
        var err error
@@ -231,63 +232,6 @@ func TestJsonMustMarshal(t *testing.T) {
        assert.Panics(func() { jsonMustMarshal(make(chan bool)) })
 }
 
-func TestPanicHandler(t *testing.T) {
-       assert, m, api := initTest(t)
-       defer m.finish()
-       msg := "Test PanicHandler: server panic"
-       otherErr := "some error"
-       tests := [...]struct {
-               name string
-               path string
-               err  interface{}
-       }{
-               {
-                       name: "panic-server-error",
-                       path: "/priv/api/panic/srvErr/",
-                       err: &util.ServerError{
-                               Err:    msg,
-                               Status: http.StatusInternalServerError,
-                       },
-               },
-               {
-                       name: "panic-other-error",
-                       path: "/priv/api/panic/otherErr/",
-                       err:  otherErr,
-               },
-       }
-       contentType := "text/plain; charset=utf-8"
-
-       newHandler := func(err interface{}) reqHandler {
-               return func(r *http.Request, ps map[string]string) responseData {
-                       panic(err)
-               }
-       }
-       for _, test := range tests {
-               routerSetHandler(api.r.NewGroup("/"), test.path, newHandler(test.err),
-                       http.StatusOK, http.MethodGet)
-       }
-       var tcaseErrStr string
-       srv := httptest.NewServer(api.r)
-       assert.NotNil(srv)
-       defer srv.Close()
-       for _, test := range tests {
-               tcaseErrStr = test.name + ": FAILED"
-               resp, err := http.Get(srv.URL + test.path)
-               assert.Nil(err)
-               tdata := filepath.Join("testdata", test.name+".txt")
-               body, err := ioutil.ReadAll(resp.Body)
-               assert.Nil(err)
-               if update {
-                       ioutil.WriteFile(tdata, body, 0644)
-               }
-               expected, err := ioutil.ReadFile(tdata)
-               assert.Nil(err)
-               assert.Equal(http.StatusInternalServerError, resp.StatusCode, tcaseErrStr)
-               assert.Equal(contentType, resp.Header.Get("Content-Type"), tcaseErrStr)
-               assert.Equal(expected, body, tcaseErrStr)
-       }
-}
-
 func TestParseReqID(t *testing.T) {
        assert := assert.New(t)
        reqid, err := parseReqID("1")
index fe238fc..29a2d4e 100644 (file)
@@ -32,7 +32,7 @@ import (
 )
 
 func TestNewRequestHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        prefix := "new-req-"
@@ -72,11 +72,11 @@ func TestNewRequestHandler(t *testing.T) {
                malformedJSONTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestCloseRequestHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        methods := []string{http.MethodPost}
@@ -104,11 +104,11 @@ func TestCloseRequestHandler(t *testing.T) {
                notFoundTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestUpdateRequestHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        methods := []string{http.MethodPost}
@@ -180,11 +180,11 @@ func TestUpdateRequestHandler(t *testing.T) {
                notFoundTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestGetRequestInfoHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        methods := []string{http.MethodGet, http.MethodHead}
@@ -216,11 +216,11 @@ func TestGetRequestInfoHandler(t *testing.T) {
                invalidIDTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestListRequestsHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        deadline, err := time.Parse(dateLayout, future)
@@ -324,11 +324,11 @@ func TestListRequestsHandler(t *testing.T) {
                malformedJSONTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestAcquireWorkerHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        methods := []string{http.MethodPost}
@@ -367,11 +367,11 @@ func TestAcquireWorkerHandler(t *testing.T) {
                invalidIDTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestProlongAccessHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        methods := []string{http.MethodPost}
@@ -396,11 +396,11 @@ func TestProlongAccessHandler(t *testing.T) {
                invalidIDTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestListWorkersHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        armCaps := make(Capabilities)
@@ -517,11 +517,11 @@ func TestListWorkersHandler(t *testing.T) {
                malformedJSONTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestGetWorkerInfoHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        prefix := "worker-info-"
@@ -554,11 +554,11 @@ func TestGetWorkerInfoHandler(t *testing.T) {
                notFoundTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestSetWorkerStateHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        prefix := "worker-set-state-"
@@ -594,11 +594,11 @@ func TestSetWorkerStateHandler(t *testing.T) {
                malformedJSONTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestSetWorkerGroupsHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        path := "/api/v1/workers/%s/setgroups"
@@ -636,11 +636,11 @@ func TestSetWorkerGroupsHandler(t *testing.T) {
                malformedJSONTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }
 
 func TestDeregisterWorkerHandler(t *testing.T) {
-       assert, m, api := initTest(t)
+       assert, m, r := initTest(t)
        defer m.finish()
 
        prefix := "worker-deregister-"
@@ -672,5 +672,5 @@ func TestDeregisterWorkerHandler(t *testing.T) {
                notFoundTest,
        }
 
-       runTests(assert, api, tests)
+       runTests(assert, r, tests)
 }