diff --git a/README.md b/README.md index 8507ec9..bd0a7d1 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,13 @@ func main() { server.Checkers.Add( builtin.NewFloat64() ); // 3. bind your implementations - server.HandleFunc(http.MethodGet, func(req api.Request, res *api.Response){ + server.HandleFunc(http.MethodGet, "/path", func(req api.Request, res *api.Response){ // ... process stuff ... res.SetError(api.ErrorSuccess()); }) // 4. launch server - log.Fatal( http.ListenAndServer("localhost:8181", server) ) + log.Fatal( http.ListenAndServe("localhost:8181", server) ) } ``` @@ -210,6 +210,6 @@ In this example we want 3 arguments : - [ ] `[a:b]` - map containing **only** keys of type `a` and values of type `b` (*a or b can be ommited*) - [x] generic controllers implementation (shared objects) - [x] response interface -- [ ] log bound resources when building the aicra server +- [x] log bound resources when building the aicra server - [ ] fail on check for unimplemented resources at server boot. - [ ] fail on check for unavailable types in api.json at server boot. diff --git a/go.mod b/go.mod index c885d02..0adcac2 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module git.xdrm.io/go/aicra -go 1.12 +go 1.14 diff --git a/http.go b/http.go index dc6ad88..44f8acc 100644 --- a/http.go +++ b/http.go @@ -13,100 +13,103 @@ import ( type httpServer Server // ServeHTTP implements http.Handler and has to be called on each request -func (s httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (server httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - // 1. build API request from HTTP request + /* (1) create api.Request from http.Request + ---------------------------------------------------------*/ request, err := api.NewRequest(r) if err != nil { log.Fatal(err) } // 2. find a matching service for this path in the config - serviceDef, pathIndex := s.services.Browse(request.URI) - if serviceDef == nil { + serviceConf, pathIndex := server.config.Browse(request.URI) + if serviceConf == nil { return } + + // 3. extract the service path from request URI servicePath := strings.Join(request.URI[:pathIndex], "/") if !strings.HasPrefix(servicePath, "/") { servicePath = "/" + servicePath } - // 3. check if matching methodDef exists in config */ - var methodDef = serviceDef.Method(r.Method) - if methodDef == nil { - response := api.NewResponse(api.ErrorUnknownMethod()) - response.ServeHTTP(w, r) - logError(response) + // 4. find method configuration from http method */ + var methodConf = serviceConf.Method(r.Method) + if methodConf == nil { + res := api.NewResponse(api.ErrorUnknownMethod()) + res.ServeHTTP(w, r) + logError(res) return } - // 4. parse every input data from the request - store := reqdata.New(request.URI[pathIndex:], r) + // 5. parse data from the request (uri, query, form, json) + data := reqdata.New(request.URI[pathIndex:], r) - /* (4) Check parameters + /* (2) check parameters ---------------------------------------------------------*/ - parameters, paramError := s.extractParameters(store, methodDef.Parameters) + parameters, paramError := server.extractParameters(data, methodConf.Parameters) // Fail if argument check failed if paramError.Code != api.ErrorSuccess().Code { - response := api.NewResponse(paramError) - response.ServeHTTP(w, r) - logError(response) + res := api.NewResponse(paramError) + res.ServeHTTP(w, r) + logError(res) return } request.Param = parameters - /* (5) Search a matching handler + /* (3) search for the handler ---------------------------------------------------------*/ - var serviceHandler *api.Handler - var serviceFound bool + var foundHandler *api.Handler + var found bool - for _, handler := range s.handlers { + for _, handler := range server.handlers { if handler.GetPath() == servicePath { - serviceFound = true + found = true if handler.GetMethod() == r.Method { - serviceHandler = handler + foundHandler = handler } } } // fail if found no handler - if serviceHandler == nil { - if serviceFound { - response := api.NewResponse() - response.SetError(api.ErrorUncallableMethod(), servicePath, r.Method) - response.ServeHTTP(w, r) - logError(response) + if foundHandler == nil { + if found { + res := api.NewResponse() + res.SetError(api.ErrorUncallableMethod(), servicePath, r.Method) + res.ServeHTTP(w, r) + logError(res) return } - response := api.NewResponse() - response.SetError(api.ErrorUncallableService(), servicePath) - response.ServeHTTP(w, r) - logError(response) + res := api.NewResponse() + res.SetError(api.ErrorUncallableService(), servicePath) + res.ServeHTTP(w, r) + logError(res) return } - /* (6) Execute handler and return response + /* (4) execute handler and return response ---------------------------------------------------------*/ // 1. feed request with configuration scope - request.Scope = methodDef.Scope + request.Scope = methodConf.Scope - // 1. execute - response := api.NewResponse() - serviceHandler.Handle(*request, response) + // 2. execute + res := api.NewResponse() + foundHandler.Handle(*request, res) - // 2. apply headers - for key, values := range response.Headers { + // 3. apply headers + for key, values := range res.Headers { for _, value := range values { w.Header().Add(key, value) } } - // 3. write to response - response.ServeHTTP(w, r) + // 4. write to response + res.ServeHTTP(w, r) return } diff --git a/internal/cerr/cerr.go b/internal/cerr/cerr.go index 57f4431..19a8e6d 100644 --- a/internal/cerr/cerr.go +++ b/internal/cerr/cerr.go @@ -16,6 +16,14 @@ func (err Error) Wrap(e error) *WrapError { } } +// WrapString returns a new error which wraps a new error created from a string. +func (err Error) WrapString(e string) *WrapError { + return &WrapError{ + base: err, + wrap: Error(e), + } +} + // WrapError is way to wrap errors recursively. type WrapError struct { base error diff --git a/internal/cerr/cerr_test.go b/internal/cerr/cerr_test.go new file mode 100644 index 0000000..476996c --- /dev/null +++ b/internal/cerr/cerr_test.go @@ -0,0 +1,57 @@ +package cerr + +import ( + "errors" + "fmt" + "testing" +) + +func TestConstError(t *testing.T) { + const cerr1 = Error("some-string") + const cerr2 = Error("some-other-string") + const cerr3 = Error("some-string") // same const value as @cerr1 + + if cerr1.Error() == cerr2.Error() { + t.Errorf("cerr1 should not be equal to cerr2 ('%s', '%s')", cerr1.Error(), cerr2.Error()) + } + if cerr2.Error() == cerr3.Error() { + t.Errorf("cerr2 should not be equal to cerr3 ('%s', '%s')", cerr2.Error(), cerr3.Error()) + } + if cerr1.Error() != cerr3.Error() { + t.Errorf("cerr1 should be equal to cerr3 ('%s', '%s')", cerr1.Error(), cerr3.Error()) + } +} + +func TestWrappedConstError(t *testing.T) { + const parent = Error("file error") + + const readErrorConst = Error("cannot read file") + var wrappedReadError = parent.Wrap(readErrorConst) + + expectedWrappedReadError := fmt.Sprintf("%s: %s", parent.Error(), readErrorConst.Error()) + if wrappedReadError.Error() != expectedWrappedReadError { + t.Errorf("expected '%s' (got '%s')", wrappedReadError.Error(), expectedWrappedReadError) + } +} +func TestWrappedStandardError(t *testing.T) { + const parent = Error("file error") + + var writeErrorStandard error = errors.New("cannot write file") + var wrappedWriteError = parent.Wrap(writeErrorStandard) + + expectedWrappedWriteError := fmt.Sprintf("%s: %s", parent.Error(), writeErrorStandard.Error()) + if wrappedWriteError.Error() != expectedWrappedWriteError { + t.Errorf("expected '%s' (got '%s')", wrappedWriteError.Error(), expectedWrappedWriteError) + } +} +func TestWrappedStringError(t *testing.T) { + const parent = Error("file error") + + var closeErrorString string = "cannot close file" + var wrappedCloseError = parent.WrapString(closeErrorString) + + expectedWrappedCloseError := fmt.Sprintf("%s: %s", parent.Error(), closeErrorString) + if wrappedCloseError.Error() != expectedWrappedCloseError { + t.Errorf("expected '%s' (got '%s')", wrappedCloseError.Error(), expectedWrappedCloseError) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..deb3170 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,636 @@ +package config + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +func TestLegalServiceName(t *testing.T) { + tests := []struct { + Raw string + Error error + }{ + { + `{ + "/": { + "invalid/service-name": { + + } + } + }`, + ErrFormat.Wrap(ErrIllegalServiceName.WrapString("invalid/service-name")), + }, + { + `{ + "/": { + "invalid/service/name": { + + } + } + }`, + ErrFormat.Wrap(ErrIllegalServiceName.WrapString("invalid/service/name")), + }, + { + `{ + "/": { + "invalid-service-name": { + + } + } + }`, + ErrFormat.Wrap(ErrIllegalServiceName.WrapString("invalid-service-name")), + }, + + { + `{ + "/": { + "valid.service_name": { + } + } + }`, + nil, + }, + } + + for i, test := range tests { + + t.Run(fmt.Sprintf("service.%d", i), func(t *testing.T) { + _, err := Parse(strings.NewReader(test.Raw)) + + if err == nil && test.Error != nil { + t.Errorf("expected an error: '%s'", test.Error.Error()) + t.FailNow() + } + if err != nil && test.Error == nil { + t.Errorf("unexpected error: '%s'", err.Error()) + t.FailNow() + } + + if err != nil && test.Error != nil { + if err.Error() != test.Error.Error() { + t.Errorf("expected the error '%s' (got '%s')", test.Error.Error(), err.Error()) + t.FailNow() + } + } + }) + } +} +func TestAvailableMethods(t *testing.T) { + reader := strings.NewReader(`{ + "GET": { "info": "info" }, + "POST": { "info": "info" }, + "PUT": { "info": "info" }, + "DELETE": { "info": "info" } + }`) + srv, err := Parse(reader) + if err != nil { + t.Errorf("unexpected error (got '%s')", err) + t.FailNow() + } + + if srv.Method(http.MethodGet) == nil { + t.Errorf("expected method GET to be available") + t.Fail() + } + if srv.Method(http.MethodPost) == nil { + t.Errorf("expected method POST to be available") + t.Fail() + } + if srv.Method(http.MethodPut) == nil { + t.Errorf("expected method PUT to be available") + t.Fail() + } + if srv.Method(http.MethodDelete) == nil { + t.Errorf("expected method DELETE to be available") + t.Fail() + } + + if srv.Method(http.MethodPatch) != nil { + t.Errorf("expected method PATH to be UNavailable") + t.Fail() + } +} +func TestParseEmpty(t *testing.T) { + reader := strings.NewReader(`{}`) + _, err := Parse(reader) + if err != nil { + t.Errorf("unexpected error (got '%s')", err) + t.FailNow() + } +} +func TestParseJsonError(t *testing.T) { + reader := strings.NewReader(`{ + "GET": { + "info": "info + }, + }`) // trailing ',' is invalid JSON + _, err := Parse(reader) + if err == nil { + t.Errorf("expected error") + t.FailNow() + } +} + +func TestParseMissingMethodDescription(t *testing.T) { + tests := []struct { + Raw string + Error error + }{ + { // missing description + `{ + "GET": { + + } + }`, + ErrFormat.Wrap(ErrMissingMethodDesc.WrapString("GET /")), + }, + { // missing description + `{ + "/": { + "subservice": { + "GET": { + + } + } + } + }`, + ErrFormat.Wrap(ErrMissingMethodDesc.WrapString("GET subservice")), + }, + { // empty description + `{ + "GET": { + "info": "" + } + }`, + ErrFormat.Wrap(ErrMissingMethodDesc.WrapString("GET /")), + }, + { // valid description + `{ + "GET": { + "info": "a" + } + }`, + nil, + }, + { // valid description + `{ + "GET": { + "info": "some description" + } + }`, + nil, + }, + } + + for i, test := range tests { + + t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { + _, err := Parse(strings.NewReader(test.Raw)) + + if err == nil && test.Error != nil { + t.Errorf("expected an error: '%s'", test.Error.Error()) + t.FailNow() + } + if err != nil && test.Error == nil { + t.Errorf("unexpected error: '%s'", err.Error()) + t.FailNow() + } + + if err != nil && test.Error != nil { + if err.Error() != test.Error.Error() { + t.Errorf("expected the error '%s' (got '%s')", test.Error.Error(), err.Error()) + t.FailNow() + } + } + }) + } + +} + +func TestParamEmptyRenameNoRename(t *testing.T) { + reader := strings.NewReader(`{ + "GET": { + "info": "info", + "in": { + "original": { "info": "valid-desc", "type": "valid-type", "name": "" } + } + } + }`) + srv, err := Parse(reader) + if err != nil { + t.Errorf("unexpected error: '%s'", err) + t.FailNow() + } + + method := srv.Method(http.MethodGet) + if method == nil { + t.Errorf("expected GET method not to be nil") + t.FailNow() + } + for _, param := range method.Parameters { + + if param.Rename != "original" { + t.Errorf("expected the parameter 'original' not to be renamed to '%s'", param.Rename) + t.FailNow() + } + } + +} +func TestOptionalParam(t *testing.T) { + reader := strings.NewReader(`{ + "GET": { + "info": "info", + "in": { + "optional": { "info": "valid-desc", "type": "?optional-type" }, + "required": { "info": "valid-desc", "type": "required-type" }, + "required2": { "info": "valid-desc", "type": "a" }, + "optional2": { "info": "valid-desc", "type": "?a" } + } + } + }`) + srv, err := Parse(reader) + if err != nil { + t.Errorf("unexpected error: '%s'", err) + t.FailNow() + } + + method := srv.Method(http.MethodGet) + if method == nil { + t.Errorf("expected GET method not to be nil") + t.FailNow() + } + for pName, param := range method.Parameters { + + if pName == "optional" || pName == "optional2" { + if !param.Optional { + t.Errorf("expected parameter '%s' to be optional", pName) + t.Failed() + } + } + if pName == "required" || pName == "required2" { + if param.Optional { + t.Errorf("expected parameter '%s' to be required", pName) + t.Failed() + } + } + } + +} +func TestParseParameters(t *testing.T) { + tests := []struct { + Raw string + Error error + ErrorAlternative error + }{ + { // invalid param name prefix + `{ + "GET": { + "info": "info", + "in": { + "_param1": {} + } + } + }`, + ErrFormat.Wrap(ErrIllegalParamName.WrapString("GET / {_param1}")), + nil, + }, + { // invalid param name suffix + `{ + "GET": { + "info": "info", + "in": { + "param1_": {} + } + } + }`, + ErrFormat.Wrap(ErrIllegalParamName.WrapString("GET / {param1_}")), + nil, + }, + + { // missing param description + `{ + "GET": { + "info": "info", + "in": { + "param1": {} + } + } + }`, + ErrFormat.Wrap(ErrMissingParamDesc.WrapString("GET / {param1}")), + nil, + }, + { // empty param description + `{ + "GET": { + "info": "info", + "in": { + "param1": { + "info": "" + } + } + } + }`, + ErrFormat.Wrap(ErrMissingParamDesc.WrapString("GET / {param1}")), + nil, + }, + + { // missing param type + `{ + "GET": { + "info": "info", + "in": { + "param1": { + "info": "valid" + } + } + } + }`, + ErrFormat.Wrap(ErrMissingParamType.WrapString("GET / {param1}")), + nil, + }, + { // empty param type + `{ + "GET": { + "info": "info", + "in": { + "param1": { + "info": "valid", + "type": "" + } + } + } + }`, + ErrFormat.Wrap(ErrMissingParamType.WrapString("GET / {param1}")), + nil, + }, + { // invalid type (optional mark only) + `{ + "GET": { + "info": "info", + "in": { + "param1": { + "info": "valid", + "type": "?" + } + } + } + }`, + + ErrFormat.Wrap(ErrMissingParamType.WrapString("GET / {param1}")), + ErrFormat.Wrap(ErrMissingParamType.WrapString("GET / {param1}")), + }, + { // valid description + valid type + `{ + "GET": { + "info": "info", + "in": { + "param1": { + "info": "valid", + "type": "a" + } + } + } + }`, + nil, + nil, + }, + { // valid description + valid OPTIONAL type + `{ + "GET": { + "info": "info", + "in": { + "param1": { + "info": "valid", + "type": "?valid" + } + } + } + }`, + nil, + nil, + }, + + { // name conflict with rename + `{ + "GET": { + "info": "info", + "in": { + "param1": { "info": "valid", "type": "valid" }, + "param2": { "info": "valid", "type": "valid", "name": "param1" } + + } + } + }`, + // 2 possible errors as map order is not deterministic + ErrFormat.Wrap(ErrParamNameConflict.WrapString("GET / {param1}")), + ErrFormat.Wrap(ErrParamNameConflict.WrapString("GET / {param2}")), + }, + { // rename conflict with name + `{ + "GET": { + "info": "info", + "in": { + "param1": { "info": "valid", "type": "valid", "name": "param2" }, + "param2": { "info": "valid", "type": "valid" } + + } + } + }`, + // 2 possible errors as map order is not deterministic + ErrFormat.Wrap(ErrParamNameConflict.WrapString("GET / {param1}")), + ErrFormat.Wrap(ErrParamNameConflict.WrapString("GET / {param2}")), + }, + { // rename conflict with rename + `{ + "GET": { + "info": "info", + "in": { + "param1": { "info": "valid", "type": "valid", "name": "conflict" }, + "param2": { "info": "valid", "type": "valid", "name": "conflict" } + + } + } + }`, + // 2 possible errors as map order is not deterministic + ErrFormat.Wrap(ErrParamNameConflict.WrapString("GET / {param1}")), + ErrFormat.Wrap(ErrParamNameConflict.WrapString("GET / {param2}")), + }, + + { // both renamed with no conflict + `{ + "GET": { + "info": "info", + "in": { + "param1": { "info": "valid", "type": "valid", "name": "freename" }, + "param2": { "info": "valid", "type": "valid", "name": "freename2" } + + } + } + }`, + nil, + nil, + }, + } + + for i, test := range tests { + + t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { + _, err := Parse(strings.NewReader(test.Raw)) + + if err == nil && test.Error != nil { + t.Errorf("expected an error: '%s'", test.Error.Error()) + t.FailNow() + } + if err != nil && test.Error == nil { + t.Errorf("unexpected error: '%s'", err.Error()) + t.FailNow() + } + + if err != nil && test.Error != nil { + if err.Error() != test.Error.Error() && err.Error() != test.ErrorAlternative.Error() { + t.Errorf("got the error: '%s'", err.Error()) + t.Errorf("expected error (alternative 1): '%s'", test.Error.Error()) + t.Errorf("expected error (alternative 2): '%s'", test.ErrorAlternative.Error()) + t.FailNow() + } + } + }) + } + +} + +func TestBrowseSimple(t *testing.T) { + tests := []struct { + Raw string + Path []string + BrowseDepth int + ValidDepth bool + }{ + { // false positive -1 + `{ + "/" : { + "parent": { + "/": { + "subdir": {} + } + } + } + }`, + []string{"parent", "subdir"}, + 1, + false, + }, + { // false positive +1 + `{ + "/" : { + "parent": { + "/": { + "subdir": {} + } + } + } + }`, + []string{"parent", "subdir"}, + 3, + false, + }, + + { + `{ + "/" : { + "parent": { + "/": { + "subdir": {} + } + } + } + }`, + []string{"parent", "subdir"}, + 2, + true, + }, + { // unknown path + `{ + "/" : { + "parent": { + "/": { + "subdir": {} + } + } + } + }`, + []string{"x", "y"}, + 2, + false, + }, + { // unknown path + `{ + "/" : { + "parent": { + "/": { + "subdir": {} + } + } + } + }`, + []string{"parent", "y"}, + 1, + true, + }, + { // Warning: this case is important to understand the precedence of service paths over + // the value of some variables. Here if we send a string parameter in the GET method that + // unfortunately is equal to 'subdir', it will call the sub-service /parent/subdir' instead + // of the service /parent with its parameter set to the value 'subdir'. + `{ + "/" : { + "parent": { + "/": { + "subdir": {} + }, + "GET": { + "info": "valid-desc", + "in": { + "some-value": { + "info": "valid-desc", + "type": "valid-type" + } + } + } + } + } + }`, + []string{"parent", "subdir"}, + 2, + true, + }, + } + + for i, test := range tests { + + t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { + srv, err := Parse(strings.NewReader(test.Raw)) + + if err != nil { + t.Errorf("unexpected error: '%s'", err) + t.FailNow() + } + + _, depth := srv.Browse(test.Path) + if test.ValidDepth { + if depth != test.BrowseDepth { + t.Errorf("expected a depth of %d (got %d)", test.BrowseDepth, depth) + t.FailNow() + } + } else { + if depth == test.BrowseDepth { + t.Errorf("expected a depth NOT %d (got %d)", test.BrowseDepth, depth) + t.FailNow() + } + + } + }) + } + +} diff --git a/internal/config/errors.go b/internal/config/errors.go new file mode 100644 index 0000000..ef413e8 --- /dev/null +++ b/internal/config/errors.go @@ -0,0 +1,27 @@ +package config + +import "git.xdrm.io/go/aicra/internal/cerr" + +// ErrRead - a problem ocurred when trying to read the configuration file +const ErrRead = cerr.Error("cannot read config") + +// ErrFormat - a invalid format has been detected +const ErrFormat = cerr.Error("invalid config format") + +// ErrIllegalServiceName - an illegal character has been found in a service name +const ErrIllegalServiceName = cerr.Error("service must not contain any slash '/' nor '-' symbols") + +// ErrMissingMethodDesc - a method is missing its description +const ErrMissingMethodDesc = cerr.Error("missing method description") + +// ErrMissingParamDesc - a parameter is missing its description +const ErrMissingParamDesc = cerr.Error("missing parameter description") + +// ErrIllegalParamName - a parameter has an illegal name +const ErrIllegalParamName = cerr.Error("illegal parameter name (must not begin/end with '_')") + +// ErrMissingParamType - a parameter has an illegal type +const ErrMissingParamType = cerr.Error("missing parameter type") + +// ErrParamNameConflict - a parameter has a conflict with its name/rename field +const ErrParamNameConflict = cerr.Error("name conflict for parameter") diff --git a/internal/config/method.go b/internal/config/method.go index f570b84..14c3539 100644 --- a/internal/config/method.go +++ b/internal/config/method.go @@ -1,7 +1,6 @@ package config import ( - "fmt" "strings" ) @@ -10,7 +9,7 @@ func (methodDef *Method) checkAndFormat(servicePath string, httpMethod string) e // 1. fail on missing description if len(methodDef.Description) < 1 { - return fmt.Errorf("missing %s.%s description", servicePath, httpMethod) + return ErrMissingMethodDesc.WrapString(httpMethod + " " + servicePath) } // 2. stop if no parameter @@ -22,16 +21,16 @@ func (methodDef *Method) checkAndFormat(servicePath string, httpMethod string) e // 3. for each parameter for pName, pData := range methodDef.Parameters { - // check name + // 3.1. check name if strings.Trim(pName, "_") != pName { - return fmt.Errorf("invalid name '%s' must not begin/end with '_'", pName) + return ErrIllegalParamName.WrapString(httpMethod + " " + servicePath + " {" + pName + "}") } if len(pData.Rename) < 1 { pData.Rename = pName } - // 5. Check for name/rename conflict + // 3.2. Check for name/rename conflict for paramName, param := range methodDef.Parameters { // ignore self @@ -39,39 +38,26 @@ func (methodDef *Method) checkAndFormat(servicePath string, httpMethod string) e continue } - // 1. Same rename field - if pData.Rename == param.Rename { - return fmt.Errorf("rename conflict for %s.%s parameter '%s'", servicePath, httpMethod, pData.Rename) - } - - // 2. Not-renamed field matches a renamed field - if pName == param.Rename { - return fmt.Errorf("name conflict for %s.%s parameter '%s'", servicePath, httpMethod, pName) - } - - // 3. Renamed field matches name - if pData.Rename == paramName { - return fmt.Errorf("name conflict for %s.%s parameter '%s'", servicePath, httpMethod, pName) + // 3.2.1. Same rename field + // 3.2.2. Not-renamed field matches a renamed field + // 3.2.3. Renamed field matches name + if pData.Rename == param.Rename || pName == param.Rename || pData.Rename == paramName { + return ErrParamNameConflict.WrapString(httpMethod + " " + servicePath + " {" + pName + "}") } } - // 6. Manage invalid type - if len(pData.Type) < 1 { - return fmt.Errorf("invalid type for %s.%s parameter '%s'", servicePath, httpMethod, pName) - } - - // 7. Fail on missing description + // 3.3. Fail on missing description if len(pData.Description) < 1 { - return fmt.Errorf("missing description for %s.%s parameter '%s'", servicePath, httpMethod, pName) + return ErrMissingParamDesc.WrapString(httpMethod + " " + servicePath + " {" + pName + "}") } - // 8. Fail on missing type - if len(pData.Type) < 1 { - return fmt.Errorf("missing type for %s.%s parameter '%s'", servicePath, httpMethod, pName) + // 3.4. Manage invalid type + if len(pData.Type) < 1 || pData.Type == "?" { + return ErrMissingParamType.WrapString(httpMethod + " " + servicePath + " {" + pName + "}") } - // 9. Set optional + type + // 3.5. Set optional + type if pData.Type[0] == '?' { pData.Optional = true pData.Type = pData.Type[1:] @@ -81,13 +67,3 @@ func (methodDef *Method) checkAndFormat(servicePath string, httpMethod string) e return nil } - -// scopeHasPermission returns whether the permission fulfills a given scope -func scopeHasPermission(permission string, scope []string) bool { - for _, s := range scope { - if permission == s { - return true - } - } - return false -} diff --git a/internal/config/service.go b/internal/config/service.go index cf223fe..1c97701 100644 --- a/internal/config/service.go +++ b/internal/config/service.go @@ -2,20 +2,11 @@ package config import ( "encoding/json" - "fmt" "io" "net/http" "strings" - - "git.xdrm.io/go/aicra/internal/cerr" ) -// ErrRead - a problem ocurred when trying to read the configuration file -const ErrRead = cerr.Error("cannot read config") - -// ErrFormat - a invalid format has been detected -const ErrFormat = cerr.Error("invalid config format") - // Parse builds a service from a json reader and checks for most format errors. func Parse(r io.Reader) (*Service, error) { receiver := &Service{} @@ -87,20 +78,20 @@ func (svc *Service) checkAndFormat(servicePath string) error { } } - // 1. stop if no child */ + // 2. stop if no child */ if svc.Children == nil || len(svc.Children) < 1 { return nil } - // 2. for each service */ + // 3. for each service */ for childService, ctl := range svc.Children { - // 3. invalid name */ + // 3.1. invalid name */ if strings.ContainsAny(childService, "/-") { - return fmt.Errorf("service '%s' must not contain any slash '/' nor '-' symbols", childService) + return ErrIllegalServiceName.WrapString(childService) } - // 4. check recursively */ + // 3.2. check recursively */ err := ctl.checkAndFormat(childService) if err != nil { return err diff --git a/internal/multipart/types.go b/internal/multipart/types.go index 6860af4..86fda25 100644 --- a/internal/multipart/types.go +++ b/internal/multipart/types.go @@ -1,21 +1,15 @@ package multipart -// ConstError is a wrapper to set constant errors -type ConstError string - -// Error implements error -func (err ConstError) Error() string { - return string(err) -} +import "git.xdrm.io/go/aicra/internal/cerr" // ErrMissingDataName is set when a multipart variable/file has no name="..." -const ErrMissingDataName = ConstError("data has no name") +const ErrMissingDataName = cerr.Error("data has no name") // ErrDataNameConflict is set when a multipart variable/file name is already used -const ErrDataNameConflict = ConstError("data name conflict") +const ErrDataNameConflict = cerr.Error("data name conflict") // ErrNoHeader is set when a multipart variable/file has no (valid) header -const ErrNoHeader = ConstError("data has no header") +const ErrNoHeader = cerr.Error("data has no header") // Component represents a multipart variable/file type Component struct { diff --git a/internal/reqdata/param_reflect.go b/internal/reqdata/param_reflect.go deleted file mode 100644 index 6cbd403..0000000 --- a/internal/reqdata/param_reflect.go +++ /dev/null @@ -1,88 +0,0 @@ -package reqdata - -import ( - "encoding/json" - "fmt" - "reflect" -) - -// parseParameter parses http GET/POST data -// - []string -// - size = 1 : return json of first element -// - size > 1 : return array of json elements -// - string : return json if valid, else return raw string -func parseParameter(data interface{}) interface{} { - dtype := reflect.TypeOf(data) - dvalue := reflect.ValueOf(data) - - switch dtype.Kind() { - - /* (1) []string -> recursive */ - case reflect.Slice: - - // 1. Return nothing if empty - if dvalue.Len() == 0 { - return nil - } - - // 2. only return first element if alone - if dvalue.Len() == 1 { - - element := dvalue.Index(0) - if element.Kind() != reflect.String { - return nil - } - return parseParameter(element.String()) - - } - - // 3. Return all elements if more than 1 - result := make([]interface{}, dvalue.Len()) - - for i, l := 0, dvalue.Len(); i < l; i++ { - element := dvalue.Index(i) - - // ignore non-string - if element.Kind() != reflect.String { - continue - } - - result[i] = parseParameter(element.String()) - } - return result - - /* (2) string -> parse */ - case reflect.String: - - // build json wrapper - wrapper := fmt.Sprintf("{\"wrapped\":%s}", dvalue.String()) - - // try to parse as json - var result interface{} - err := json.Unmarshal([]byte(wrapper), &result) - - // return if success - if err == nil { - - mapval, ok := result.(map[string]interface{}) - if !ok { - return dvalue.String() - } - - wrapped, ok := mapval["wrapped"] - if !ok { - return dvalue.String() - } - - return wrapped - } - - // else return as string - return dvalue.String() - - } - - /* (3) NIL if unknown type */ - return dvalue - -} diff --git a/internal/reqdata/parameter.go b/internal/reqdata/parameter.go index 79201b9..2c80455 100644 --- a/internal/reqdata/parameter.go +++ b/internal/reqdata/parameter.go @@ -1,5 +1,22 @@ package reqdata +import ( + "encoding/json" + "fmt" + "reflect" + + "git.xdrm.io/go/aicra/internal/cerr" +) + +// ErrUnknownType is returned when encountering an unknown type +const ErrUnknownType = cerr.Error("unknown type") + +// ErrInvalidJSON is returned when json parse failed +const ErrInvalidJSON = cerr.Error("invalid json") + +// ErrInvalidRootType is returned when json is a map +const ErrInvalidRootType = cerr.Error("invalid json root type") + // Parameter represents an http request parameter // that can be of type URL, GET, or FORM (multipart, json, urlencoded) type Parameter struct { @@ -16,14 +33,96 @@ type Parameter struct { } // Parse parameter (json-like) if not already done -func (i *Parameter) Parse() { +func (i *Parameter) Parse() error { /* (1) Stop if already parsed or nil*/ if i.Parsed || i.Value == nil { - return + return nil } /* (2) Try to parse value */ - i.Value = parseParameter(i.Value) + parsed, err := parseParameter(i.Value) + if err != nil { + return err + } + + i.Parsed = true + i.Value = parsed + + return nil +} + +// parseParameter parses http GET/POST data +// - []string +// - size = 1 : return json of first element +// - size > 1 : return array of json elements +// - string : return json if valid, else return raw string +func parseParameter(data interface{}) (interface{}, error) { + dtype := reflect.TypeOf(data) + dvalue := reflect.ValueOf(data) + + switch dtype.Kind() { + + /* (1) []string -> recursive */ + case reflect.Slice: + + // 1. ignore empty + if dvalue.Len() == 0 { + return data, nil + } + + // 2. parse each element recursively + result := make([]interface{}, dvalue.Len()) + + for i, l := 0, dvalue.Len(); i < l; i++ { + element := dvalue.Index(i) + + // ignore non-string + if element.Kind() != reflect.String { + result[i] = element.Interface() + continue + } + + parsed, err := parseParameter(element.String()) + if err != nil { + return data, err + } + result[i] = parsed + } + return result, nil + + /* (2) string -> parse */ + case reflect.String: + + // build json wrapper + wrapper := fmt.Sprintf("{\"wrapped\":%s}", dvalue.String()) + + // try to parse as json + var result interface{} + err := json.Unmarshal([]byte(wrapper), &result) + + // return if success + if err == nil { + + mapval, ok := result.(map[string]interface{}) + if !ok { + return dvalue.String(), ErrInvalidRootType + } + + wrapped, ok := mapval["wrapped"] + if !ok { + return dvalue.String(), ErrInvalidJSON + } + + return wrapped, nil + } + + // else return as string + return dvalue.String(), nil + + } + + /* (3) NIL if unknown type */ + return dvalue.Interface(), nil } diff --git a/internal/reqdata/parameter_test.go b/internal/reqdata/parameter_test.go new file mode 100644 index 0000000..29bcbec --- /dev/null +++ b/internal/reqdata/parameter_test.go @@ -0,0 +1,358 @@ +package reqdata + +import ( + "math" + "testing" +) + +func TestSimpleString(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: "some-string"} + + err := p.Parse() + + if err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + cast, canCast := p.Value.(string) + if !canCast { + t.Errorf("expected parameter to be a string") + t.FailNow() + } + + if cast != "some-string" { + t.Errorf("expected parameter to equal 'some-string', got '%s'", cast) + t.FailNow() + } +} + +func TestSimpleFloat(t *testing.T) { + tcases := []float64{12.3456789, -12.3456789, 0.0000001, -0.0000001} + + for i, tcase := range tcases { + t.Run("case "+string(i), func(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: tcase} + + if err := p.Parse(); err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + cast, canCast := p.Value.(float64) + if !canCast { + t.Errorf("expected parameter to be a float64") + t.FailNow() + } + + if math.Abs(cast-tcase) > 0.00000001 { + t.Errorf("expected parameter to equal '%f', got '%f'", tcase, cast) + t.FailNow() + } + }) + } +} + +func TestSimpleBool(t *testing.T) { + tcases := []bool{true, false} + + for i, tcase := range tcases { + t.Run("case "+string(i), func(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: tcase} + + if err := p.Parse(); err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + cast, canCast := p.Value.(bool) + if !canCast { + t.Errorf("expected parameter to be a bool") + t.FailNow() + } + + if cast != tcase { + t.Errorf("expected parameter to equal '%t', got '%t'", tcase, cast) + t.FailNow() + } + }) + } +} + +func TestJsonStringSlice(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: `["str1", "str2"]`} + + err := p.Parse() + + if err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + slice, canCast := p.Value.([]interface{}) + if !canCast { + t.Errorf("expected parameter to be a []interface{}") + t.FailNow() + } + + if len(slice) != 2 { + t.Errorf("expected 2 values, got %d", len(slice)) + t.FailNow() + } + + results := []string{"str1", "str2"} + + for i, res := range results { + + cast, canCast := slice[i].(string) + if !canCast { + t.Errorf("expected parameter %d to be a []string", i) + continue + } + if cast != res { + t.Errorf("expected first value to be '%s', got '%s'", res, cast) + continue + } + + } + +} + +func TestStringSlice(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: []string{"str1", "str2"}} + + err := p.Parse() + + if err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + slice, canCast := p.Value.([]interface{}) + if !canCast { + t.Errorf("expected parameter to be a []interface{}") + t.FailNow() + } + + if len(slice) != 2 { + t.Errorf("expected 2 values, got %d", len(slice)) + t.FailNow() + } + + results := []string{"str1", "str2"} + + for i, res := range results { + + cast, canCast := slice[i].(string) + if !canCast { + t.Errorf("expected parameter %d to be a []string", i) + continue + } + if cast != res { + t.Errorf("expected first value to be '%s', got '%s'", res, cast) + continue + } + + } + +} + +func TestJsonPrimitiveBool(t *testing.T) { + tcases := []struct { + Raw string + BoolValue bool + }{ + {"true", true}, + {"false", false}, + } + + for i, tcase := range tcases { + t.Run("case "+string(i), func(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: tcase.Raw} + + err := p.Parse() + if err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + cast, canCast := p.Value.(bool) + if !canCast { + t.Errorf("expected parameter to be a bool") + t.FailNow() + } + + if cast != tcase.BoolValue { + t.Errorf("expected a value of %t, got %t", tcase.BoolValue, cast) + t.FailNow() + } + }) + } + +} + +func TestJsonPrimitiveFloat(t *testing.T) { + tcases := []struct { + Raw string + FloatValue float64 + }{ + {"1", 1}, + {"-1", -1}, + + {"0.001", 0.001}, + {"-0.001", -0.001}, + + {"1.9992", 1.9992}, + {"-1.9992", -1.9992}, + + {"19992", 19992}, + {"-19992", -19992}, + } + + for i, tcase := range tcases { + t.Run("case "+string(i), func(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: tcase.Raw} + + err := p.Parse() + if err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + cast, canCast := p.Value.(float64) + if !canCast { + t.Errorf("expected parameter to be a float64") + t.FailNow() + } + + if math.Abs(cast-tcase.FloatValue) > 0.00001 { + t.Errorf("expected a value of %f, got %f", tcase.FloatValue, cast) + t.FailNow() + } + }) + } + +} + +func TestJsonBoolSlice(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: []string{"true", "false"}} + + err := p.Parse() + + if err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + slice, canCast := p.Value.([]interface{}) + if !canCast { + t.Errorf("expected parameter to be a []interface{}") + t.FailNow() + } + + if len(slice) != 2 { + t.Errorf("expected 2 values, got %d", len(slice)) + t.FailNow() + } + + results := []bool{true, false} + + for i, res := range results { + + cast, canCast := slice[i].(bool) + if !canCast { + t.Errorf("expected parameter %d to be a []bool", i) + continue + } + if cast != res { + t.Errorf("expected first value to be '%t', got '%t'", res, cast) + continue + } + + } + +} + +func TestBoolSlice(t *testing.T) { + p := Parameter{Parsed: false, File: false, Value: []bool{true, false}} + + err := p.Parse() + + if err != nil { + t.Errorf("unexpected error: <%s>", err) + t.FailNow() + } + + if !p.Parsed { + t.Errorf("expected parameter to be parsed") + t.FailNow() + } + + slice, canCast := p.Value.([]interface{}) + if !canCast { + t.Errorf("expected parameter to be a []interface{}") + t.FailNow() + } + + if len(slice) != 2 { + t.Errorf("expected 2 values, got %d", len(slice)) + t.FailNow() + } + + results := []bool{true, false} + + for i, res := range results { + + cast, canCast := slice[i].(bool) + if !canCast { + t.Errorf("expected parameter %d to be a bool, got %v", i, slice[i]) + continue + } + if cast != res { + t.Errorf("expected first value to be '%t', got '%t'", res, cast) + continue + } + + } + +} diff --git a/internal/reqdata/store.go b/internal/reqdata/store.go index 514d4d4..0a4d81e 100644 --- a/internal/reqdata/store.go +++ b/internal/reqdata/store.go @@ -57,6 +57,11 @@ func New(uriParams []string, req *http.Request) *Store { // 1. set URI parameters ds.setURIParams(uriParams) + // ignore nil requests + if req == nil { + return ds + } + // 2. GET (query) data ds.readQuery(req) diff --git a/internal/reqdata/store_test.go b/internal/reqdata/store_test.go new file mode 100644 index 0000000..0805b84 --- /dev/null +++ b/internal/reqdata/store_test.go @@ -0,0 +1,804 @@ +package reqdata + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" +) + +func TestEmptyStore(t *testing.T) { + store := New(nil, nil) + + if store.URI == nil { + t.Errorf("store 'URI' list should be initialized") + t.Fail() + } + if len(store.URI) != 0 { + t.Errorf("store 'URI' list should be empty") + t.Fail() + } + + if store.Get == nil { + t.Errorf("store 'Get' map should be initialized") + t.Fail() + } + if store.Form == nil { + t.Errorf("store 'Form' map should be initialized") + t.Fail() + } + if store.Set == nil { + t.Errorf("store 'Set' map should be initialized") + t.Fail() + } +} + +func TestStoreWithUri(t *testing.T) { + urilist := []string{"abc", "def"} + store := New(urilist, nil) + + if len(store.URI) != len(urilist) { + t.Errorf("store 'Set' should contain %d elements (got %d)", len(urilist), len(store.URI)) + t.Fail() + } + if len(store.Set) != len(urilist) { + t.Errorf("store 'Set' should contain %d elements (got %d)", len(urilist), len(store.Set)) + t.Fail() + } + + for i, value := range urilist { + + t.Run(fmt.Sprintf("URL#%d='%s'", i, value), func(t *testing.T) { + key := fmt.Sprintf("URL#%d", i) + element, isset := store.Set[key] + + if !isset { + t.Errorf("store should contain element with key '%s'", key) + t.Failed() + } + + if element.Value != value { + t.Errorf("store[%s] should return '%s' (got '%s')", key, value, element.Value) + t.Failed() + } + }) + + } + +} + +func TestStoreWithGet(t *testing.T) { + tests := []struct { + Query string + + InvalidNames []string + ParamNames []string + ParamValues [][]string + }{ + { + Query: "", + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: [][]string{}, + }, + { + Query: "a", + InvalidNames: []string{}, + ParamNames: []string{"a"}, + ParamValues: [][]string{[]string{""}}, + }, + { + Query: "a&b", + InvalidNames: []string{}, + ParamNames: []string{"a", "b"}, + ParamValues: [][]string{[]string{""}, []string{""}}, + }, + { + Query: "a=", + InvalidNames: []string{}, + ParamNames: []string{"a"}, + ParamValues: [][]string{[]string{""}}, + }, + { + Query: "a=&b=x", + InvalidNames: []string{}, + ParamNames: []string{"a", "b"}, + ParamValues: [][]string{[]string{""}, []string{"x"}}, + }, + { + Query: "a=b&c=d", + InvalidNames: []string{}, + ParamNames: []string{"a", "c"}, + ParamValues: [][]string{[]string{"b"}, []string{"d"}}, + }, + { + Query: "a=b&c=d&a=x", + InvalidNames: []string{}, + ParamNames: []string{"a", "c"}, + ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}}, + }, + { + Query: "a=b&_invalid=x", + InvalidNames: []string{"_invalid"}, + ParamNames: []string{"a", "_invalid"}, + ParamValues: [][]string{[]string{"b"}, []string{""}}, + }, + { + Query: "a=b&invalid_=x", + InvalidNames: []string{"invalid_"}, + ParamNames: []string{"a", "invalid_"}, + ParamValues: [][]string{[]string{"b"}, []string{""}}, + }, + { + Query: "a=b&GET@injection=x", + InvalidNames: []string{"GET@injection"}, + ParamNames: []string{"a", "GET@injection"}, + ParamValues: [][]string{[]string{"b"}, []string{""}}, + }, + { // not really useful as all after '#' should be ignored by http clients + Query: "a=b&URL#injection=x", + InvalidNames: []string{"URL#injection"}, + ParamNames: []string{"a", "URL#injection"}, + ParamValues: [][]string{[]string{"b"}, []string{""}}, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) { + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://host.com?%s", test.Query), nil) + store := New(nil, req) + + if test.ParamNames == nil || test.ParamValues == nil { + if len(store.Set) != 0 { + t.Errorf("expected no GET parameters and got %d", len(store.Get)) + t.Failed() + } + + // no param to check + return + } + + if len(test.ParamNames) != len(test.ParamValues) { + t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues)) + t.Failed() + } + + for pi, pName := range test.ParamNames { + key := fmt.Sprintf("GET@%s", pName) + values := test.ParamValues[pi] + + isNameValid := true + for _, invalid := range test.InvalidNames { + if pName == invalid { + isNameValid = false + } + } + + t.Run(key, func(t *testing.T) { + + param, isset := store.Set[key] + if !isset { + if isNameValid { + t.Errorf("store should contain element with key '%s'", key) + t.Failed() + } + return + } + + // if should be invalid + if isset && !isNameValid { + t.Errorf("store should NOT contain element with key '%s' (invalid name)", key) + t.Failed() + } + + cast, canCast := param.Value.([]string) + + if !canCast { + t.Errorf("should return a []string (got '%v')", cast) + t.Failed() + } + + if len(cast) != len(values) { + t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast)) + t.Failed() + } + + for vi, value := range values { + + t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) { + if value != cast[vi] { + t.Errorf("should return '%s' (got '%s')", value, cast[vi]) + t.Failed() + } + }) + } + }) + + } + }) + } + +} +func TestStoreWithUrlEncodedFormParseError(t *testing.T) { + // http.Request.ParseForm() fails when: + // - http.Request.Method is one of [POST,PUT,PATCH] + // - http.Request.Form is not nil (created manually) + // - http.Request.PostForm is nil (deleted manually) + // - http.Request.Body is nil (deleted manually) + + req := httptest.NewRequest(http.MethodPost, "http://host.com/", nil) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + // break everything + req.Body = nil + req.Form = make(url.Values) + req.PostForm = nil + + // defer req.Body.Close() + store := New(nil, req) + if len(store.Form) > 0 { + t.Errorf("expected malformed urlencoded to have failed being parsed (got %d elements)", len(store.Form)) + t.FailNow() + } +} +func TestStoreWithUrlEncodedForm(t *testing.T) { + tests := []struct { + URLEncoded string + + InvalidNames []string + ParamNames []string + ParamValues [][]string + }{ + { + URLEncoded: "", + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: [][]string{}, + }, + { + URLEncoded: "a", + InvalidNames: []string{}, + ParamNames: []string{"a"}, + ParamValues: [][]string{[]string{""}}, + }, + { + URLEncoded: "a&b", + InvalidNames: []string{}, + ParamNames: []string{"a", "b"}, + ParamValues: [][]string{[]string{""}, []string{""}}, + }, + { + URLEncoded: "a=", + InvalidNames: []string{}, + ParamNames: []string{"a"}, + ParamValues: [][]string{[]string{""}}, + }, + { + URLEncoded: "a=&b=x", + InvalidNames: []string{}, + ParamNames: []string{"a", "b"}, + ParamValues: [][]string{[]string{""}, []string{"x"}}, + }, + { + URLEncoded: "a=b&c=d", + InvalidNames: []string{}, + ParamNames: []string{"a", "c"}, + ParamValues: [][]string{[]string{"b"}, []string{"d"}}, + }, + { + URLEncoded: "a=b&c=d&a=x", + InvalidNames: []string{}, + ParamNames: []string{"a", "c"}, + ParamValues: [][]string{[]string{"b", "x"}, []string{"d"}}, + }, + { + URLEncoded: "a=b&_invalid=x", + InvalidNames: []string{"_invalid"}, + ParamNames: []string{"a", "_invalid"}, + ParamValues: [][]string{[]string{"b"}, []string{""}}, + }, + { + URLEncoded: "a=b&invalid_=x", + InvalidNames: []string{"invalid_"}, + ParamNames: []string{"a", "invalid_"}, + ParamValues: [][]string{[]string{"b"}, []string{""}}, + }, + { + URLEncoded: "a=b&GET@injection=x", + InvalidNames: []string{"GET@injection"}, + ParamNames: []string{"a", "GET@injection"}, + ParamValues: [][]string{[]string{"b"}, []string{""}}, + }, + { + URLEncoded: "a=b&URL#injection=x", + InvalidNames: []string{"URL#injection"}, + ParamNames: []string{"a", "URL#injection"}, + ParamValues: [][]string{[]string{"b"}, []string{""}}, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) { + body := strings.NewReader(test.URLEncoded) + req := httptest.NewRequest(http.MethodPost, "http://host.com", body) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + defer req.Body.Close() + store := New(nil, req) + + if test.ParamNames == nil || test.ParamValues == nil { + if len(store.Set) != 0 { + t.Errorf("expected no FORM parameters and got %d", len(store.Get)) + t.Failed() + } + + // no param to check + return + } + + if len(test.ParamNames) != len(test.ParamValues) { + t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues)) + t.Failed() + } + + for pi, pName := range test.ParamNames { + key := pName + values := test.ParamValues[pi] + + isNameValid := true + for _, invalid := range test.InvalidNames { + if pName == invalid { + isNameValid = false + } + } + + t.Run(key, func(t *testing.T) { + + param, isset := store.Set[key] + if !isset { + if isNameValid { + t.Errorf("store should contain element with key '%s'", key) + t.Failed() + } + return + } + + // if should be invalid + if isset && !isNameValid { + t.Errorf("store should NOT contain element with key '%s' (invalid name)", key) + t.Failed() + } + + cast, canCast := param.Value.([]string) + + if !canCast { + t.Errorf("should return a []string (got '%v')", cast) + t.Failed() + } + + if len(cast) != len(values) { + t.Errorf("should return %d string(s) (got '%d')", len(values), len(cast)) + t.Failed() + } + + for vi, value := range values { + + t.Run(fmt.Sprintf("value.%d", vi), func(t *testing.T) { + if value != cast[vi] { + t.Errorf("should return '%s' (got '%s')", value, cast[vi]) + t.Failed() + } + }) + } + }) + + } + }) + } + +} + +func TestJsonParameters(t *testing.T) { + tests := []struct { + RawJson string + + InvalidNames []string + ParamNames []string + ParamValues []interface{} + }{ + // no need to fully check json because it is parsed with the standard library + { + RawJson: "", + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: []interface{}{}, + }, + { + RawJson: "{}", + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: []interface{}{}, + }, + { + RawJson: "{ \"a\": \"b\" }", + InvalidNames: []string{}, + ParamNames: []string{"a"}, + ParamValues: []interface{}{"b"}, + }, + { + RawJson: "{ \"a\": \"b\", \"c\": \"d\" }", + InvalidNames: []string{}, + ParamNames: []string{"a", "c"}, + ParamValues: []interface{}{"b", "d"}, + }, + { + RawJson: "{ \"_invalid\": \"x\" }", + InvalidNames: []string{"_invalid"}, + ParamNames: []string{"_invalid"}, + ParamValues: []interface{}{nil}, + }, + { + RawJson: "{ \"a\": \"b\", \"_invalid\": \"x\" }", + InvalidNames: []string{"_invalid"}, + ParamNames: []string{"a", "_invalid"}, + ParamValues: []interface{}{"b", nil}, + }, + + { + RawJson: "{ \"invalid_\": \"x\" }", + InvalidNames: []string{"invalid_"}, + ParamNames: []string{"invalid_"}, + ParamValues: []interface{}{nil}, + }, + { + RawJson: "{ \"a\": \"b\", \"invalid_\": \"x\" }", + InvalidNames: []string{"invalid_"}, + ParamNames: []string{"a", "invalid_"}, + ParamValues: []interface{}{"b", nil}, + }, + + { + RawJson: "{ \"GET@injection\": \"x\" }", + InvalidNames: []string{"GET@injection"}, + ParamNames: []string{"GET@injection"}, + ParamValues: []interface{}{nil}, + }, + { + RawJson: "{ \"a\": \"b\", \"GET@injection\": \"x\" }", + InvalidNames: []string{"GET@injection"}, + ParamNames: []string{"a", "GET@injection"}, + ParamValues: []interface{}{"b", nil}, + }, + + { + RawJson: "{ \"URL#injection\": \"x\" }", + InvalidNames: []string{"URL#injection"}, + ParamNames: []string{"URL#injection"}, + ParamValues: []interface{}{nil}, + }, + { + RawJson: "{ \"a\": \"b\", \"URL#injection\": \"x\" }", + InvalidNames: []string{"URL#injection"}, + ParamNames: []string{"a", "URL#injection"}, + ParamValues: []interface{}{"b", nil}, + }, + // json parse error + { + RawJson: "{ \"a\": \"b\", }", + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: []interface{}{}, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) { + body := strings.NewReader(test.RawJson) + req := httptest.NewRequest(http.MethodPost, "http://host.com", body) + req.Header.Add("Content-Type", "application/json") + defer req.Body.Close() + store := New(nil, req) + + if test.ParamNames == nil || test.ParamValues == nil { + if len(store.Set) != 0 { + t.Errorf("expected no JSON parameters and got %d", len(store.Get)) + t.Failed() + } + + // no param to check + return + } + + if len(test.ParamNames) != len(test.ParamValues) { + t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues)) + t.Failed() + } + + for pi, pName := range test.ParamNames { + key := pName + value := test.ParamValues[pi] + + isNameValid := true + for _, invalid := range test.InvalidNames { + if pName == invalid { + isNameValid = false + } + } + + t.Run(key, func(t *testing.T) { + + param, isset := store.Set[key] + if !isset { + if isNameValid { + t.Errorf("store should contain element with key '%s'", key) + t.Failed() + } + return + } + + // if should be invalid + if isset && !isNameValid { + t.Errorf("store should NOT contain element with key '%s' (invalid name)", key) + t.Failed() + } + + valueType := reflect.TypeOf(value) + + paramValue := param.Value + paramValueType := reflect.TypeOf(param.Value) + + if valueType != paramValueType { + t.Errorf("should be of type %v (got '%v')", valueType, paramValueType) + t.Failed() + } + + if paramValue != value { + t.Errorf("should return %v (got '%v')", value, paramValue) + t.Failed() + } + + }) + + } + }) + } + +} + +func TestMultipartParameters(t *testing.T) { + tests := []struct { + RawMultipart string + + InvalidNames []string + ParamNames []string + ParamValues []interface{} + }{ + // no need to fully check json because it is parsed with the standard library + { + RawMultipart: ``, + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: []interface{}{}, + }, + { + RawMultipart: `--xxx + `, + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: []interface{}{}, + }, + { + RawMultipart: `--xxx +--xxx--`, + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: []interface{}{}, + }, + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="a" + +b +--xxx--`, + InvalidNames: []string{}, + ParamNames: []string{"a"}, + ParamValues: []interface{}{"b"}, + }, + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="a" + +b +--xxx +Content-Disposition: form-data; name="c" + +d +--xxx--`, + InvalidNames: []string{}, + ParamNames: []string{"a", "c"}, + ParamValues: []interface{}{"b", "d"}, + }, + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="_invalid" + +x +--xxx--`, + InvalidNames: []string{"_invalid"}, + ParamNames: []string{"_invalid"}, + ParamValues: []interface{}{nil}, + }, + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="a" + +b +--xxx +Content-Disposition: form-data; name="_invalid" + +x +--xxx--`, + InvalidNames: []string{"_invalid"}, + ParamNames: []string{"a", "_invalid"}, + ParamValues: []interface{}{"b", nil}, + }, + + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="invalid_" + +x +--xxx--`, + InvalidNames: []string{"invalid_"}, + ParamNames: []string{"invalid_"}, + ParamValues: []interface{}{nil}, + }, + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="a" + +b +--xxx +Content-Disposition: form-data; name="invalid_" + +x +--xxx--`, + InvalidNames: []string{"invalid_"}, + ParamNames: []string{"a", "invalid_"}, + ParamValues: []interface{}{"b", nil}, + }, + + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="GET@injection" + +x +--xxx--`, + InvalidNames: []string{"GET@injection"}, + ParamNames: []string{"GET@injection"}, + ParamValues: []interface{}{nil}, + }, + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="a" + +b +--xxx +Content-Disposition: form-data; name="GET@injection" + +x +--xxx--`, + InvalidNames: []string{"GET@injection"}, + ParamNames: []string{"a", "GET@injection"}, + ParamValues: []interface{}{"b", nil}, + }, + + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="URL#injection" + +x +--xxx--`, + InvalidNames: []string{"URL#injection"}, + ParamNames: []string{"URL#injection"}, + ParamValues: []interface{}{nil}, + }, + { + RawMultipart: `--xxx +Content-Disposition: form-data; name="a" + +b +--xxx +Content-Disposition: form-data; name="URL#injection" + +x +--xxx--`, + InvalidNames: []string{"URL#injection"}, + ParamNames: []string{"a", "URL#injection"}, + ParamValues: []interface{}{"b", nil}, + }, + // json parse error + { + RawMultipart: "{ \"a\": \"b\", }", + InvalidNames: []string{}, + ParamNames: []string{}, + ParamValues: []interface{}{}, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("request.%d", i), func(t *testing.T) { + body := strings.NewReader(test.RawMultipart) + req := httptest.NewRequest(http.MethodPost, "http://host.com", body) + req.Header.Add("Content-Type", "multipart/form-data; boundary=xxx") + defer req.Body.Close() + store := New(nil, req) + + if test.ParamNames == nil || test.ParamValues == nil { + if len(store.Set) != 0 { + t.Errorf("expected no JSON parameters and got %d", len(store.Get)) + t.Failed() + } + + // no param to check + return + } + + if len(test.ParamNames) != len(test.ParamValues) { + t.Errorf("invalid test: names and values differ in size (%d vs %d)", len(test.ParamNames), len(test.ParamValues)) + t.Failed() + } + + for pi, pName := range test.ParamNames { + key := pName + value := test.ParamValues[pi] + + isNameValid := true + for _, invalid := range test.InvalidNames { + if pName == invalid { + isNameValid = false + } + } + + t.Run(key, func(t *testing.T) { + + param, isset := store.Set[key] + if !isset { + if isNameValid { + t.Errorf("store should contain element with key '%s'", key) + t.Failed() + } + return + } + + // if should be invalid + if isset && !isNameValid { + t.Errorf("store should NOT contain element with key '%s' (invalid name)", key) + t.Failed() + } + + valueType := reflect.TypeOf(value) + + paramValue := param.Value + paramValueType := reflect.TypeOf(param.Value) + + if valueType != paramValueType { + t.Errorf("should be of type %v (got '%v')", valueType, paramValueType) + t.Failed() + } + + if paramValue != value { + t.Errorf("should return %v (got '%v')", value, paramValue) + t.Failed() + } + + }) + + } + }) + } + +} diff --git a/server.go b/server.go index a94c55a..4c40977 100644 --- a/server.go +++ b/server.go @@ -13,7 +13,7 @@ import ( // Server represents an AICRA instance featuring: type checkers, services type Server struct { - services *config.Service + config *config.Service Checkers *checker.Set handlers []*api.Handler } @@ -27,7 +27,7 @@ func New(configPath string) (*Server, error) { // 1. init instance var i = &Server{ - services: nil, + config: nil, Checkers: checker.New(), handlers: make([]*api.Handler, 0), } @@ -40,14 +40,14 @@ func New(configPath string) (*Server, error) { defer configFile.Close() // 3. load configuration - i.services, err = config.Parse(configFile) + i.config, err = config.Parse(configFile) if err != nil { return nil, err } // 4. log configuration services - log.Printf("=== Aicra configuration ===\n") - logService(*i.services, "") + log.Printf("🔧 Reading configuration '%s'\n", configPath) + logService(*i.config, "") return i, nil @@ -68,9 +68,9 @@ func (s *Server) Handle(handler *api.Handler) { func (s Server) HTTP() httpServer { // 1. log available handlers - log.Printf("=== Mapped handlers ===\n") + log.Printf("🔗 Mapping handlers\n") for i := 0; i < len(s.handlers); i++ { - log.Printf("* [rest] %s\t'%s'\n", s.handlers[i].GetMethod(), s.handlers[i].GetPath()) + log.Printf(" ->\t%s\t'%s'\n", s.handlers[i].GetMethod(), s.handlers[i].GetPath()) } // 2. cast to http server diff --git a/typecheck/builtin/string_test.go b/typecheck/builtin/string_test.go index 048485b..d23b7bf 100644 --- a/typecheck/builtin/string_test.go +++ b/typecheck/builtin/string_test.go @@ -41,6 +41,18 @@ func TestString_AvailableTypes(t *testing.T) { {"string(1 )", false}, {"string( 1 )", false}, + {"string()", false}, + {"string(a)", false}, + {"string(-1)", false}, + + {"string(,)", false}, + {"string(1,b)", false}, + {"string(a,b)", false}, + {"string(a,1)", false}, + {"string(-1,1)", false}, + {"string(1,-1)", false}, + {"string(-1,-1)", false}, + {"string(1,2)", true}, {"string(1, 2)", true}, {"string(1, 2)", false}, diff --git a/typecheck/builtin/uint_test.go b/typecheck/builtin/uint_test.go index c1d26aa..4a6ceb8 100644 --- a/typecheck/builtin/uint_test.go +++ b/typecheck/builtin/uint_test.go @@ -96,6 +96,11 @@ func TestUint_Values(t *testing.T) { // strane offset because of how precision works {fmt.Sprintf("%f", float64(math.MaxUint64+1024*3)), false}, + {[]byte(fmt.Sprintf("%d", math.MaxInt64)), true}, + {[]byte(fmt.Sprintf("%d", uint(math.MaxUint64))), true}, + // strane offset because of how precision works + {[]byte(fmt.Sprintf("%f", float64(math.MaxUint64+1024*3))), false}, + {"string", false}, {[]byte("bytes"), false}, {-0.1, false}, diff --git a/util.go b/util.go index 2672f76..949acb7 100644 --- a/util.go +++ b/util.go @@ -58,7 +58,7 @@ func (s *httpServer) extractParameters(store *reqdata.Store, methodParam map[str return nil, apiErr } - // 6. do not check if file + // 6. ignore type check if file if gotFile { parameters[param.Rename] = p.Value continue @@ -90,9 +90,9 @@ func logService(s config.Service, path string) { for _, method := range handledMethods { if m := s.Method(method); m != nil { if path == "" { - log.Printf("* [rest] %s\t'/'\n", method) + log.Printf(" ->\t%s\t'/'\n", method) } else { - log.Printf("* [rest] %s\t'%s'\n", method, path) + log.Printf(" ->\t%s\t'%s'\n", method, path) } } }