diff --git a/config/config_test.go b/config/config_test.go index 2776292..c7913d6 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,8 +3,12 @@ package config import ( "errors" "fmt" + "net/http" + "net/http/httptest" "strings" "testing" + + "git.xdrm.io/go/aicra/config/datatype/builtin" ) func TestLegalServiceName(t *testing.T) { @@ -219,22 +223,22 @@ func TestParamEmptyRenameNoRename(t *testing.T) { "path": "/", "info": "valid-description", "in": { - "original": { "info": "valid-desc", "type": "valid-type", "name": "" } + "original": { "info": "valid-desc", "type": "any", "name": "" } } } ]`) - srv, err := Parse(reader) + srv, err := Parse(reader, builtin.AnyDataType{}) if err != nil { t.Errorf("unexpected error: '%s'", err) t.FailNow() } - if len(srv) < 1 { + if len(srv.services) < 1 { t.Errorf("expected a service") t.FailNow() } - for _, param := range (srv)[0].Input { + for _, param := range srv.services[0].Input { if param.Rename != "original" { t.Errorf("expected the parameter 'original' not to be renamed to '%s'", param.Rename) t.FailNow() @@ -249,24 +253,24 @@ func TestOptionalParam(t *testing.T) { "path": "/", "info": "valid-description", "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" } + "optional": { "info": "optional-type", "type": "?bool" }, + "required": { "info": "required-type", "type": "bool" }, + "required2": { "info": "required", "type": "any" }, + "optional2": { "info": "optional", "type": "?any" } } } ]`) - srv, err := Parse(reader) + srv, err := Parse(reader, builtin.AnyDataType{}, builtin.BoolDataType{}) if err != nil { t.Errorf("unexpected error: '%s'", err) t.FailNow() } - if len(srv) < 1 { + if len(srv.services) < 1 { t.Errorf("expected a service") t.FailNow() } - for pName, param := range (srv)[0].Input { + for pName, param := range srv.services[0].Input { if pName == "optional" || pName == "optional2" { if !param.Optional { @@ -389,7 +393,7 @@ func TestParseParameters(t *testing.T) { "path": "/", "info": "info", "in": { - "param1": { "info": "valid", "type": "a" } + "param1": { "info": "valid", "type": "any" } } } ]`, @@ -402,7 +406,7 @@ func TestParseParameters(t *testing.T) { "path": "/", "info": "info", "in": { - "param1": { "info": "valid", "type": "?valid" } + "param1": { "info": "valid", "type": "?any" } } } ]`, @@ -416,8 +420,8 @@ func TestParseParameters(t *testing.T) { "path": "/", "info": "info", "in": { - "param1": { "info": "valid", "type": "valid" }, - "param2": { "info": "valid", "type": "valid", "name": "param1" } + "param1": { "info": "valid", "type": "any" }, + "param2": { "info": "valid", "type": "any", "name": "param1" } } } ]`, @@ -431,8 +435,8 @@ func TestParseParameters(t *testing.T) { "path": "/", "info": "info", "in": { - "param1": { "info": "valid", "type": "valid", "name": "param2" }, - "param2": { "info": "valid", "type": "valid" } + "param1": { "info": "valid", "type": "any", "name": "param2" }, + "param2": { "info": "valid", "type": "any" } } } ]`, @@ -446,8 +450,8 @@ func TestParseParameters(t *testing.T) { "path": "/", "info": "info", "in": { - "param1": { "info": "valid", "type": "valid", "name": "conflict" }, - "param2": { "info": "valid", "type": "valid", "name": "conflict" } + "param1": { "info": "valid", "type": "any", "name": "conflict" }, + "param2": { "info": "valid", "type": "any", "name": "conflict" } } } ]`, @@ -462,8 +466,8 @@ func TestParseParameters(t *testing.T) { "path": "/", "info": "info", "in": { - "param1": { "info": "valid", "type": "valid", "name": "freename" }, - "param2": { "info": "valid", "type": "valid", "name": "freename2" } + "param1": { "info": "valid", "type": "any", "name": "freename" }, + "param2": { "info": "valid", "type": "any", "name": "freename2" } } } ]`, @@ -474,7 +478,7 @@ func TestParseParameters(t *testing.T) { for i, test := range tests { t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { - _, err := Parse(strings.NewReader(test.Raw)) + _, err := Parse(strings.NewReader(test.Raw), builtin.AnyDataType{}) if err == nil && test.Error != nil { t.Errorf("expected an error: '%s'", test.Error.Error()) @@ -497,136 +501,141 @@ func TestParseParameters(t *testing.T) { } // todo: rewrite with new api format -// func TestMatchSimple(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, -// }, +func TestMatchSimple(t *testing.T) { + tests := []struct { + Config string + URL string + Match bool + }{ + { // false positive -1 + `[ { + "method": "GET", + "path": "/a", + "info": "info", + "in": {} + } ]`, + "/", + false, + }, + { // false positive +1 + `[ { + "method": "GET", + "path": "/", + "info": "info", + "in": {} + } ]`, + "/a", + false, + }, + { + `[ { + "method": "GET", + "path": "/a", + "info": "info", + "in": {} + } ]`, + "/a", + true, + }, + { + `[ { + "method": "GET", + "path": "/a", + "info": "info", + "in": {} + } ]`, + "/a/", + true, + }, + { + `[ { + "method": "GET", + "path": "/a/{id}", + "info": "info", + "in": { + "{id}": { + "info": "info", + "type": "bool" + } + } + } ]`, + "/a/12/", + false, + }, + { + `[ { + "method": "GET", + "path": "/a/{id}", + "info": "info", + "in": { + "{id}": { + "info": "info", + "type": "int" + } + } + } ]`, + "/a/12/", + true, + }, + { + `[ { + "method": "GET", + "path": "/a/{valid}", + "info": "info", + "in": { + "{id}": { + "info": "info", + "type": "bool" + } + } + } ]`, + "/a/12/", + false, + }, + { + `[ { + "method": "GET", + "path": "/a/{valid}", + "info": "info", + "in": { + "{id}": { + "info": "info", + "type": "bool" + } + } + } ]`, + "/a/true/", + true, + }, + } -// { -// `{ -// "/" : { -// "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 { -// for i, test := range tests { + t.Run(fmt.Sprintf("method.%d", i), func(t *testing.T) { + srv, err := Parse(strings.NewReader(test.Config), builtin.AnyDataType{}, builtin.IntDataType{}, builtin.BoolDataType{}) -// 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() + } -// if err != nil { -// t.Errorf("unexpected error: '%s'", err) -// t.FailNow() -// } + if len(srv.services) != 1 { + t.Errorf("expected to have 1 service, got %d", len(srv.services)) + t.FailNow() + } -// _, depth := srv.Match(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() -// } + req := httptest.NewRequest(http.MethodGet, test.URL, nil) -// } -// }) -// } + match := srv.services[0].Match(req) + if test.Match && !match { + t.Errorf("expected '%s' to match", test.URL) + t.FailNow() + } + if !test.Match && match { + t.Errorf("expected '%s' NOT to match", test.URL) + t.FailNow() + } + }) + } -// } +} diff --git a/config/errors.go b/config/errors.go index ff9d5d1..4795e57 100644 --- a/config/errors.go +++ b/config/errors.go @@ -38,6 +38,9 @@ const ErrMissingDescription = Error("missing description") // ErrMissingParamDesc - a parameter is missing its description const ErrMissingParamDesc = Error("missing parameter description") +// ErrUnknownDataType - a parameter has an unknown datatype name +const ErrUnknownDataType = Error("unknown data type") + // ErrIllegalParamName - a parameter has an illegal name const ErrIllegalParamName = Error("parameter name must not begin/end with '_'") diff --git a/config/func.go b/config/func.go new file mode 100644 index 0000000..2d7f479 --- /dev/null +++ b/config/func.go @@ -0,0 +1,15 @@ +package config + +import "strings" + +// splits an URL without empty sets +func splitURL(url string) []string { + trimmed := strings.Trim(url, " /\t\r\n") + split := strings.Split(trimmed, "/") + + // remove empty set when empty url + if len(split) == 1 && len(split[0]) == 0 { + return []string{} + } + return split +} diff --git a/config/parameter.go b/config/parameter.go index a64ee53..7537d01 100644 --- a/config/parameter.go +++ b/config/parameter.go @@ -1,5 +1,7 @@ package config +import "git.xdrm.io/go/aicra/config/datatype" + func (param *Parameter) checkAndFormat() error { // missing description @@ -20,3 +22,14 @@ func (param *Parameter) checkAndFormat() error { return nil } + +// assigns the first matching data type from the type definition +func (param *Parameter) assignDataType(types []datatype.DataType) bool { + for _, dtype := range types { + param.Validator = dtype.Build(param.Type) + if param.Validator != nil { + return true + } + } + return false +} diff --git a/config/service.go b/config/service.go index cd2a0ed..808f8bb 100644 --- a/config/service.go +++ b/config/service.go @@ -4,11 +4,26 @@ import ( "fmt" "net/http" "strings" + + "git.xdrm.io/go/aicra/config/datatype" ) // Match returns if this service would handle this HTTP request func (svc *Service) Match(req *http.Request) bool { - return false + // method + if req.Method != svc.Method { + return false + } + + // check path + if !svc.matchPattern(req.RequestURI) { + return false + } + + // check and extract input + // todo: check if input match + + return true } func (svc *Service) checkMethod() error { @@ -68,7 +83,7 @@ func (svc *Service) checkPattern() error { return nil } -func (svc *Service) checkAndFormatInput() error { +func (svc *Service) checkAndFormatInput(types []datatype.DataType) error { // ignore no parameter if svc.Input == nil || len(svc.Input) < 1 { @@ -94,6 +109,10 @@ func (svc *Service) checkAndFormatInput() error { return fmt.Errorf("%s: %w", paramName, err) } + if !param.assignDataType(types) { + return fmt.Errorf("%s: %w", paramName, ErrUnknownDataType) + } + // check for name/rename conflict for paramName2, param2 := range svc.Input { // ignore self @@ -114,3 +133,48 @@ func (svc *Service) checkAndFormatInput() error { return nil } + +// checks if an uri matches the service's pattern +func (svc *Service) matchPattern(uri string) bool { + uriparts := splitURL(uri) + parts := splitURL(svc.Pattern) + + // fail if size differ + if len(uriparts) != len(parts) { + return false + } + + // root url '/' + if len(parts) == 0 { + return true + } + + // check part by part + for i, part := range parts { + uripart := uriparts[i] + + isCapture := len(part) > 0 && part[0] == '{' + + // if no capture -> check equality + if !isCapture { + if part != uripart { + return false + } + continue + } + + param, exists := svc.Input[part] + + // fail if no validator + if !exists || param.Validator == nil { + return false + } + + // fail if not type-valid + if _, valid := param.Validator(uripart); !valid { + return false + } + } + + return true +} diff --git a/config/services.go b/config/services.go index 6743629..625570e 100644 --- a/config/services.go +++ b/config/services.go @@ -6,38 +6,123 @@ import ( "io" "net/http" "strings" + + "git.xdrm.io/go/aicra/config/datatype" ) // Parse builds a server configuration from a json reader and checks for most format errors. -func Parse(r io.Reader) (Services, error) { - services := make(Services, 0) +// you can provide additional DataTypes as variadic arguments +func Parse(r io.Reader, dtypes ...datatype.DataType) (*Server, error) { + server := &Server{ + types: make([]datatype.DataType, 0), + services: make([]*Service, 0), + } + // add data types + for _, dtype := range dtypes { + server.types = append(server.types, dtype) + } - err := json.NewDecoder(r).Decode(&services) - if err != nil { + // parse JSON + if err := json.NewDecoder(r).Decode(&server.services); err != nil { return nil, fmt.Errorf("%s: %w", ErrRead, err) } - err = services.checkAndFormat() - if err != nil { + // check services + if err := server.checkAndFormat(); err != nil { return nil, fmt.Errorf("%s: %w", ErrFormat, err) } - if services.collide() { - return nil, fmt.Errorf("%s: %w", ErrFormat, ErrPatternCollision) + // check collisions + if err := server.collide(); err != nil { + return nil, fmt.Errorf("%s: %w", ErrFormat, err) } - return services, nil + return server, nil } // collide returns if there is collision between services -func (svc Services) collide() bool { - // todo: implement pattern collision using types to check if braces can be equal to fixed uri parts - return false +func (server *Server) collide() error { + length := len(server.services) + + // for each service combination + for a := 0; a < length; a++ { + for b := a + 1; b < length; b++ { + aService := server.services[a] + bService := server.services[b] + + // ignore different method + if aService.Method != bService.Method { + continue + } + + aParts := splitURL(aService.Pattern) + bParts := splitURL(bService.Pattern) + + // not same size + if len(aParts) != len(bParts) { + continue + } + + // for each part + for pi, aPart := range aParts { + bPart := bParts[pi] + + aIsCapture := len(aPart) > 1 && aPart[0] == '{' + bIsCapture := len(bPart) > 1 && bPart[0] == '{' + + // both captures -> as we cannot check, consider a collision + if aIsCapture && bIsCapture { + return fmt.Errorf("%s: %s '%s'", ErrPatternCollision, aService.Method, aService.Pattern) + } + + // no capture -> check equal + if !aIsCapture && !bIsCapture { + if aPart == bPart { + return fmt.Errorf("%s: %s '%s'", ErrPatternCollision, aService.Method, aService.Pattern) + } + continue + } + + // A captures B -> check type (B is A ?) + if aIsCapture { + input, exists := aService.Input[aPart] + + // fail if no type or no validator + if !exists || input.Validator == nil { + return fmt.Errorf("%s: %s '%s'", ErrPatternCollision, aService.Method, aService.Pattern) + } + + // fail if not valid + if _, valid := input.Validator(aPart); !valid { + return fmt.Errorf("%s: %s '%s'", ErrPatternCollision, aService.Method, aService.Pattern) + } + + // B captures A -> check type (A is B ?) + } else { + input, exists := bService.Input[bPart] + + // fail if no type or no validator + if !exists || input.Validator == nil { + return fmt.Errorf("%s: %s '%s'", ErrPatternCollision, aService.Method, aService.Pattern) + } + + // fail if not valid + if _, valid := input.Validator(bPart); !valid { + return fmt.Errorf("%s: %s '%s'", ErrPatternCollision, aService.Method, aService.Pattern) + } + } + + } + + } + } + + return nil } // Find a service matching an incoming HTTP request -func (svc Services) Find(r *http.Request) *Service { - for _, service := range svc { +func (server Server) Find(r *http.Request) *Service { + for _, service := range server.services { if service.Match(r) { return service } @@ -47,8 +132,8 @@ func (svc Services) Find(r *http.Request) *Service { } // checkAndFormat checks for errors and missing fields and sets default values for optional fields. -func (svc Services) checkAndFormat() error { - for _, service := range svc { +func (server Server) checkAndFormat() error { + for _, service := range server.services { // check method err := service.checkMethod() @@ -69,7 +154,7 @@ func (svc Services) checkAndFormat() error { } // check input parameters - err = service.checkAndFormatInput() + err = service.checkAndFormatInput(server.types) if err != nil { return fmt.Errorf("%s '%s' [in]: %w", service.Method, service.Pattern, err) } diff --git a/config/types.go b/config/types.go index 8a24ffc..b3da173 100644 --- a/config/types.go +++ b/config/types.go @@ -13,10 +13,11 @@ type Parameter struct { Description string `json:"info"` Type string `json:"type"` Rename string `json:"name,omitempty"` - Optional bool + // Optional is set to true when the type is prefixed with '?' + Optional bool - // validator is set from the @Type - validator datatype.Validator + // Validator is inferred from @Type + Validator datatype.Validator } // Service represents a service definition (from api.json) @@ -25,10 +26,13 @@ type Service struct { Pattern string `json:"path"` Scope [][]string `json:"scope"` Description string `json:"info"` - Download *bool `json:"download"` Input map[string]*Parameter `json:"in"` + // Download *bool `json:"download"` // Output map[string]*Parameter `json:"out"` } -// Services contains every service that represents a server configuration -type Services []*Service +// Server represents a full server configuration +type Server struct { + types []datatype.DataType + services []*Service +}