From: Maciej Wereski Date: Thu, 23 Nov 2017 15:12:27 +0000 (+0100) Subject: HTTP API: Add agregation package X-Git-Url: http://review.tizen.org/git/?a=commitdiff_plain;h=e6d1a52b93dd1890964807b6cb4e7af57a1a5d10;p=tools%2Fboruta.git HTTP API: Add agregation package 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 --- diff --git a/http/server/api/api.go b/http/server/api/api.go new file mode 100644 index 0000000..cddbca0 --- /dev/null +++ b/http/server/api/api.go @@ -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 index 0000000..94b89e2 --- /dev/null +++ b/http/server/api/api_test.go @@ -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) +} diff --git a/http/server/api/v1/testdata/panic-other-error.txt b/http/server/api/testdata/panic-other-error.txt similarity index 100% rename from http/server/api/v1/testdata/panic-other-error.txt rename to http/server/api/testdata/panic-other-error.txt diff --git a/http/server/api/v1/testdata/panic-server-error.txt b/http/server/api/testdata/panic-server-error.txt similarity index 100% rename from http/server/api/v1/testdata/panic-server-error.txt rename to http/server/api/testdata/panic-server-error.txt diff --git a/http/server/api/v1/api.go b/http/server/api/v1/api.go index 61be0d5..bc897e1 100644 --- a/http/server/api/v1/api.go +++ b/http/server/api/v1/api.go @@ -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, diff --git a/http/server/api/v1/api_test.go b/http/server/api/v1/api_test.go index a1da367..e53dbd6 100644 --- a/http/server/api/v1/api_test.go +++ b/http/server/api/v1/api_test.go @@ -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") diff --git a/http/server/api/v1/handlers_test.go b/http/server/api/v1/handlers_test.go index fe238fc..29a2d4e 100644 --- a/http/server/api/v1/handlers_test.go +++ b/http/server/api/v1/handlers_test.go @@ -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) }