diff --git a/supernode/server/api/api.go b/supernode/server/api/api.go new file mode 100644 index 000000000..c235b7f1f --- /dev/null +++ b/supernode/server/api/api.go @@ -0,0 +1,89 @@ +/* + * Copyright The Dragonfly 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 api + +var ( + // V1 is recommended, any new API should be registered in this category. + V1 = newCategory("v1 api", "/api/v1") + + // Extension allows users to register extension APIs into supernode. + // Customized APIs should be registered by using this category. + // It can distinguish between Dragonfly's core APIs and customized APIs. + // And supernode provides `/api/ext` to list all the registered APIs in this + // category. + Extension = newCategory("extension api", "/api/ext") + + // Legacy is deprecated, just for compatibility with the old version, + // please do not use it to add new API. + Legacy = newCategory("legacy api", "") +) + +var ( + apiCategories = make(map[string]*category) +) + +func newCategory(name, prefix string) *category { + if name == "" { + return nil + } + if c, ok := apiCategories[name]; ok && c != nil { + return c + } + + apiCategories[name] = &category{ + name: name, + prefix: prefix, + } + return apiCategories[name] +} + +// category groups the APIs. +type category struct { + name string + prefix string + handlerSpecs []*HandlerSpec +} + +// Register registers an API into this API category. +func (c *category) Register(h *HandlerSpec) *category { + if !validate(h) { + return c + } + c.handlerSpecs = append(c.handlerSpecs, h) + return c +} + +// Name returns the name of this category. +func (c *category) Name() string { + return c.name +} + +// Prefix returns the api prefix of this category. +func (c *category) Prefix() string { + return c.prefix +} + +// Handlers returns all of the APIs registered into this category. +func (c *category) Handlers() []*HandlerSpec { + return c.handlerSpecs +} + +// ----------------------------------------------------------------------------- + +func validate(h *HandlerSpec) bool { + return h != nil && h.HandlerFunc != nil && h.Method != "" +} diff --git a/supernode/server/api/api_test.go b/supernode/server/api/api_test.go new file mode 100644 index 000000000..dec507888 --- /dev/null +++ b/supernode/server/api/api_test.go @@ -0,0 +1,104 @@ +/* + * Copyright The Dragonfly 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 api + +import ( + "context" + "fmt" + "math/rand" + "net/http" + "testing" + + "github.com/stretchr/testify/suite" +) + +func TestSuite(t *testing.T) { + suite.Run(t, new(APISuite)) +} + +type APISuite struct { + suite.Suite + validHandler *HandlerSpec + invalidHandler *HandlerSpec +} + +func (s *APISuite) SetupSuite() { + s.validHandler = &HandlerSpec{ + Method: "GET", + HandlerFunc: func(context.Context, http.ResponseWriter, *http.Request) error { + return nil + }, + } +} + +func (s *APISuite) SetupTest() { + for _, v := range apiCategories { + v.handlerSpecs = nil + } +} + +func (s *APISuite) TestCategory_Register() { + var cases = []struct { + c *category + h *HandlerSpec + }{ + {V1, s.invalidHandler}, + {V1, s.validHandler}, + {Extension, s.validHandler}, + {Legacy, s.validHandler}, + } + + for _, v := range cases { + before := v.c.handlerSpecs + v.c.Register(v.h) + after := v.c.handlerSpecs + if s.invalidHandler == v.h { + s.Equal(before, after) + } else if s.validHandler == v.h { + s.Equal(len(before)+1, len(after)) + s.Equal(after[len(after)-1], v.h) + } + } +} + +func (s *APISuite) TestCategory_others() { + for k, v := range apiCategories { + s.Equal(k, v.name) + s.Equal(v.Name(), v.name) + s.Equal(v.Prefix(), v.prefix) + s.Equal(v.Handlers(), v.handlerSpecs) + } +} + +func (s *APISuite) TestNewCategory() { + // don't create a category with the same name + for k, v := range apiCategories { + c := newCategory(k, "") + s.Equal(c, v) + } + + // don't create a category with empty name + c := newCategory("", "x") + s.Nil(c) + + // create a new category + name := fmt.Sprintf("%v", rand.Float64()) + c = newCategory(name, "/") + defer delete(apiCategories, name) + s.Equal(c.name, name) + s.Equal(c.prefix, "/") +} diff --git a/supernode/server/api/handler.go b/supernode/server/api/handler.go new file mode 100644 index 000000000..f8f33266a --- /dev/null +++ b/supernode/server/api/handler.go @@ -0,0 +1,32 @@ +/* + * Copyright The Dragonfly 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 api + +import ( + "context" + "net/http" +) + +// HandlerSpec describes an HTTP api +type HandlerSpec struct { + Method string + Path string + HandlerFunc HandlerFunc +} + +// HandlerFunc is the http request handler. +type HandlerFunc func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error diff --git a/supernode/server/api/utils.go b/supernode/server/api/utils.go new file mode 100644 index 000000000..d5efb5663 --- /dev/null +++ b/supernode/server/api/utils.go @@ -0,0 +1,103 @@ +/* + * Copyright The Dragonfly 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 api + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/go-openapi/strfmt" + "github.com/sirupsen/logrus" + + "github.com/dragonflyoss/Dragonfly/apis/types" + "github.com/dragonflyoss/Dragonfly/pkg/errortypes" + "github.com/dragonflyoss/Dragonfly/pkg/util" +) + +// ValidateFunc validates the request parameters. +type ValidateFunc func(registry strfmt.Registry) error + +// ParseJSONRequest parses the request JSON parameter to a target object. +func ParseJSONRequest(req io.Reader, target interface{}, validator ValidateFunc) error { + if util.IsNil(target) { + return errortypes.NewHTTPError(http.StatusInternalServerError, "nil target") + } + if err := json.NewDecoder(req).Decode(target); err != nil { + if err == io.EOF { + return errortypes.NewHTTPError(http.StatusBadRequest, "empty request") + } + return errortypes.NewHTTPError(http.StatusBadRequest, err.Error()) + } + if validator != nil { + if err := validator(strfmt.Default); err != nil { + return errortypes.NewHTTPError(http.StatusBadRequest, err.Error()) + } + } + return nil +} + +// EncodeResponse encodes response in json. +// The response body is empty if the data is nil or empty value. +func EncodeResponse(w http.ResponseWriter, code int, data interface{}) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if util.IsNil(data) || data == "" { + return nil + } + return json.NewEncoder(w).Encode(data) +} + +// HandleErrorResponse handles err from server side and constructs response +// for client side. +func HandleErrorResponse(w http.ResponseWriter, err error) { + switch e := err.(type) { + case *errortypes.HTTPError: + _ = EncodeResponse(w, e.Code, errResp(e.Code, e.Msg)) + default: + // By default, server side returns code 500 if error happens. + _ = EncodeResponse(w, http.StatusInternalServerError, + errResp(http.StatusInternalServerError, e.Error())) + } +} + +// WrapHandler converts the 'api.HandlerFunc' into type 'http.HandlerFunc' and +// format the error response if any error happens. +func WrapHandler(handler HandlerFunc) http.HandlerFunc { + pCtx := context.Background() + + return func(w http.ResponseWriter, req *http.Request) { + ctx, cancel := context.WithCancel(pCtx) + defer cancel() + + // Start to handle request. + err := handler(ctx, w, req) + if err != nil { + // Handle error if request handling fails. + HandleErrorResponse(w, err) + } + logrus.Debugf("%s %v err:%v", req.Method, req.URL, err) + } +} + +func errResp(code int, msg string) *types.ErrorResponse { + return &types.ErrorResponse{ + Code: int64(code), + Message: msg, + } +} diff --git a/supernode/server/api/utils_test.go b/supernode/server/api/utils_test.go new file mode 100644 index 000000000..6c4666d0d --- /dev/null +++ b/supernode/server/api/utils_test.go @@ -0,0 +1,207 @@ +/* + * Copyright The Dragonfly 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 api + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/suite" + + "github.com/dragonflyoss/Dragonfly/pkg/errortypes" + "github.com/dragonflyoss/Dragonfly/pkg/util" +) + +func TestUtil(t *testing.T) { + suite.Run(t, new(TestUtilSuite)) +} + +type TestUtilSuite struct { + suite.Suite +} + +func (s *TestUtilSuite) TestParseJSONRequest() { + cases := []struct { + req string + validator ValidateFunc + + // expected + target *testStruct + err string + }{ + {"", nil, nil, "nil target"}, + {"", nil, newT(0), "empty request"}, + {"{x}", nil, newT(0), "invalid character"}, + {"{}", nil, newT(0), ""}, + {"{\"A\":1}", nil, newT(1), ""}, + {"{\"A\":1}", newT(1).validate, newT(1), "invalid"}, + {"{\"A\":2}", newT(2).validate, newT(2), ""}, + } + + for i, c := range cases { + msg := fmt.Sprintf("case %d: %v", i, c) + + var obj *testStruct + buf := bytes.NewBufferString(c.req) + if c.target != nil { + obj = &testStruct{} + } + e := ParseJSONRequest(buf, obj, c.validator) + + if c.err == "" { + s.Nil(e, msg) + s.NotNil(obj, msg) + s.Equal(c.target, obj) + } else { + s.NotNil(e, msg) + s.Contains(e.Error(), c.err, msg) + } + } + +} + +func (s *TestUtilSuite) TestEncodeResponse() { + cases := []struct { + code int + data interface{} + + // expected + err string + }{ + {200, "", ""}, + {200, nil, ""}, + {200, 0, ""}, + {200, newT(1), ""}, + {400, newT(1), ""}, + } + + for i, c := range cases { + msg := fmt.Sprintf("case %d: %v", i, c) + w := httptest.NewRecorder() + e := EncodeResponse(w, c.code, c.data) + if c.err == "" { + s.Nil(e, msg) + s.Equal(c.code, w.Code, msg) + if util.IsNil(c.data) { + s.Equal("", strings.TrimSpace(w.Body.String()), msg) + } else { + s.Equal(fmt.Sprintf("%v", c.data), strings.TrimSpace(w.Body.String()), msg) + } + } else { + s.NotNil(e, msg) + } + } +} + +func (s *TestUtilSuite) TestHandleErrorResponse() { + cases := []struct { + err error + // expected + code int + out string + }{ + { + errortypes.NewHTTPError(400, "user"), + 400, "{\"code\":400,\"message\":\"user\"}\n", + }, + { + fmt.Errorf("hello"), + 500, "{\"code\":500,\"message\":\"hello\"}\n", + }, + } + for i, c := range cases { + msg := fmt.Sprintf("case %d: %v", i, c) + w := httptest.NewRecorder() + HandleErrorResponse(w, c.err) + s.Equal(c.code, w.Code, msg) + s.Equal(c.out, w.Body.String(), msg) + } +} + +func (s *TestUtilSuite) TestWrapHandler() { + var tf HandlerFunc = func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error { + switch req.Method { + case "GET": + return fmt.Errorf("test") + case "POST": + return errortypes.NewHTTPError(400, "test") + } + _ = EncodeResponse(rw, 200, "test") + return nil + } + cases := []struct { + method string + + // expected + code int + out string + }{ + { + "GET", + 500, "{\"code\":500,\"message\":\"test\"}\n", + }, + { + "POST", + 400, "{\"code\":400,\"message\":\"test\"}\n", + }, + { + "PUT", + 200, "\"test\"\n", + }, + } + for i, c := range cases { + msg := fmt.Sprintf("case %d: %v", i, c) + h := WrapHandler(tf) + w := httptest.NewRecorder() + r := httptest.NewRequest(c.method, "http:", nil) + h(w, r) + + s.Equal(c.code, w.Code, msg) + s.Equal(c.out, w.Body.String(), msg) + } +} + +// ----------------------------------------------------------------------------- +// testing helpers + +func newT(a int) *testStruct { + return &testStruct{A: a} +} + +type testStruct struct { + A int +} + +func (t *testStruct) validate(registry strfmt.Registry) error { + if t.A <= 1 { + return fmt.Errorf("invalid") + } + return nil +} + +func (t *testStruct) String() string { + if t == nil { + return "" + } + return fmt.Sprintf("{\"A\":%d}", t.A) +} diff --git a/supernode/server/router.go b/supernode/server/router.go index fc34c272a..18397d6f7 100644 --- a/supernode/server/router.go +++ b/supernode/server/router.go @@ -24,6 +24,7 @@ import ( "github.com/dragonflyoss/Dragonfly/apis/types" "github.com/dragonflyoss/Dragonfly/version" + "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp"