diff --git a/go.mod b/go.mod index f53323bb290e119feda2d24ebe935d02cfdc9e38..14a85cd72aebd444fa384bc1788849052a937c4c 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/gosimple/slug v1.13.1 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/golang-lru v0.5.4 + github.com/json-iterator/go v1.1.12 + github.com/mitchellh/mapstructure v1.4.2 github.com/pkg/errors v0.9.1 github.com/rs/xid v1.4.0 github.com/stretchr/testify v1.8.0 @@ -25,13 +27,23 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-kit/log v0.2.0 // indirect github.com/go-logfmt/logfmt v0.5.1 // indirect + github.com/golang/snappy v0.0.1 // indirect github.com/google/go-cmp v0.5.7 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/klauspost/compress v1.13.6 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.4.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.1 // indirect + github.com/xdg-go/stringprep v1.0.3 // indirect + github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.7.0 // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4 // indirect diff --git a/go.sum b/go.sum index 9dea84ab8c222f13374d0a59a405fa290feed61a..511239a6e719ed8e4be2d67f6fc16c582aad70ef 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,7 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -67,6 +68,7 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gosimple/slug v1.13.1 h1:bQ+kpX9Qa6tHRaK+fZR0A0M2Kd7Pa5eHPPsb1JpHD+Q= github.com/gosimple/slug v1.13.1/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= @@ -79,6 +81,9 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -94,6 +99,14 @@ github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1 github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo= +github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -122,9 +135,13 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1 h1:VOMT+81stJgXW3CpHyqHN3AXDYIMsx56mEFrB37Mb/E= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3 h1:kdwGpVNwPFtjs98xCGkHjQtGKh86rDcRZN17QEMCOIs= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.mongodb.org/mongo-driver v1.11.4 h1:4ayjakA013OdpGyL2K3ZqylTac/rMjrJOMZ1EHizXas= @@ -170,6 +187,7 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/pkg/auth/anonymous.go b/pkg/auth/anonymous.go new file mode 100644 index 0000000000000000000000000000000000000000..62aa8a1ac28c45f09a065906a7735e44c1add05e --- /dev/null +++ b/pkg/auth/anonymous.go @@ -0,0 +1,127 @@ +package auth + +import ( + "context" + "fmt" + + "git.perx.ru/perxis/perxis-go/pkg/environments" + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/members" + "git.perx.ru/perxis/perxis-go/pkg/permission" + "git.perx.ru/perxis/perxis-go/pkg/roles" + "git.perx.ru/perxis/perxis-go/pkg/spaces" +) + +type Anonymous struct { + roles roles.Roles + spaces spaces.Spaces + spaceID string + environments environments.Environments +} + +func (Anonymous) GetID(ctx context.Context) string { return "anonymous" } +func (Anonymous) IsValid(ctx context.Context) bool { return false } +func (Anonymous) IsSystem(ctx context.Context) bool { return false } +func (Anonymous) IsManagementAllowed(ctx context.Context, spaceID string) error { + return ErrAccessDenied +} + +func (a Anonymous) Space(spaceID string) SpaceAccessor { + a.spaceID = spaceID + return &a +} + +func (a *Anonymous) getSpace(ctx context.Context, spaceID string) *spaces.Space { + if spaceID == "" { + return nil + } + space, _ := a.spaces.Get(WithSystem(ctx), spaceID) + return space +} + +func (a *Anonymous) HasSpaceAccess(ctx context.Context, spaceID string) bool { + if a.spaceID == "" || a.spaces == nil { + return false + } + return a.Role(ctx, spaceID) != nil +} + +func (a *Anonymous) Member(ctx context.Context) members.Role { + return members.NotMember +} + +func (a *Anonymous) Role(ctx context.Context, spaceID string) *roles.Role { + if a.spaceID == "" || a.roles == nil { + return nil + } + role, err := a.roles.Get(WithSystem(ctx), spaceID, roles.AnonymousRole) + if err != nil { + return nil + } + return role +} + +func (a *Anonymous) Rules(ctx context.Context, spaceID, envID string) permission.Ruleset { + role := a.Role(WithSystem(ctx), spaceID) + if role == nil { + return nil + } + + if !a.HasEnvironmentAccess(ctx, spaceID, envID) { + return nil + } + + return role.Rules +} + +func (a *Anonymous) HasEnvironmentAccess(ctx context.Context, space, env string) bool { + return hasEnvironmentAccess(ctx, a.environments, a.Role(ctx, space), env) +} + +func (Anonymous) Format(f fmt.State, verb rune) { + f.Write([]byte("AnonymousPrincipal{}")) +} + +func (a Anonymous) HasAccess(ctx context.Context, spaceID, orgID string) error { + if !a.IsValid(ctx) { + return ErrAccessDenied + } + + if a.IsSystem(ctx) { + return nil + } + + if spaceID != "" { + hasAllow, err := a.hasRole(ctx, spaceID) + if err != nil { + return err + } + + if hasAllow { + return nil + } + } + + if a.Member(ctx).IsPrivileged() { + return nil + } + + return ErrAccessDenied +} + +func (a *Anonymous) hasRole(ctx context.Context, spaceID string) (bool, error) { + if a.spaceID == "" || a.roles == nil { + return false, nil + } + _, err := a.roles.Get(WithSystem(ctx), spaceID, roles.AnonymousRole) + if err == nil { + return true, nil + } + + if errors.Is(err, ErrNotFound) { + if sp := a.getSpace(ctx, spaceID); sp == nil { + return false, ErrNotFound + } + } + return false, nil +} diff --git a/pkg/auth/client.go b/pkg/auth/client.go new file mode 100644 index 0000000000000000000000000000000000000000..cf63410f0c2efb4c3d2150ce3956a75c5e1cbfab --- /dev/null +++ b/pkg/auth/client.go @@ -0,0 +1,255 @@ +package auth + +import ( + "context" + "fmt" + + "git.perx.ru/perxis/perxis-go/pkg/clients" + "git.perx.ru/perxis/perxis-go/pkg/collaborators" + "git.perx.ru/perxis/perxis-go/pkg/environments" + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/members" + "git.perx.ru/perxis/perxis-go/pkg/permission" + "git.perx.ru/perxis/perxis-go/pkg/roles" + "git.perx.ru/perxis/perxis-go/pkg/spaces" +) + +type ClientPrincipal struct { + identity *clients.GetByParams + spaceID string + space *spaces.Space + + client *clients.Client + invalid bool + + spaces spaces.Spaces + environments environments.Environments + clients clients.Clients + roles roles.Roles + collaborators collaborators.Collaborators +} + +func NewClientPrincipal(identity *clients.GetByParams) *ClientPrincipal { + return &ClientPrincipal{identity: identity} +} + +func (c ClientPrincipal) Format(f fmt.State, verb rune) { + var identity string + switch { + case c.identity == nil: + identity = "<nil>" + case c.identity.APIKey != "": + identity = fmt.Sprintf("APIKey: '%s'", c.identity.APIKey) + case c.identity.OAuthClientID != "": + identity = fmt.Sprintf("OAuthClientID: '%s'", c.identity.OAuthClientID) + case c.identity.TLSSubject != "": + identity = fmt.Sprintf("TLSSubject: '%s'", c.identity.TLSSubject) + } + + var id string + if c.client != nil { + id = c.client.ID + } + + f.Write([]byte(fmt.Sprintf("ClientPrincipal{ID: '%s', Identity: {%s}}", id, identity))) +} + +func (c *ClientPrincipal) Space(spaceID string) SpaceAccessor { + c.spaceID = spaceID + c.space = nil + c.invalid = false + c.client = nil + return c +} + +func (c *ClientPrincipal) getSpace(ctx context.Context, spaceID string) *spaces.Space { + if spaceID == "" { + return nil + } + space, _ := c.spaces.Get(WithSystem(ctx), spaceID) + return space +} + +func (ClientPrincipal) IsSystem(ctx context.Context) bool { + return false +} + +func (c *ClientPrincipal) IsManagementAllowed(ctx context.Context, spaceID string) error { + if !c.IsValid(ctx) { + return ErrAccessDenied + } + + if role := c.Role(ctx, spaceID); role != nil && role.AllowManagement { + return nil + } + + return ErrAccessDenied +} + +func (c *ClientPrincipal) Member(ctx context.Context) members.Role { + return members.NotMember +} + +func (c *ClientPrincipal) HasSpaceAccess(ctx context.Context, spaceID string) bool { + if c.spaceID == "" { + return false + } + client, _ := c.Client(ctx) + return client != nil && client.SpaceID == spaceID +} + +func (c *ClientPrincipal) GetID(ctx context.Context) string { + client, _ := c.Client(ctx) + if client == nil { + return "" + } + return client.ID +} + +func (c *ClientPrincipal) GetIdentity(ctx context.Context) *clients.GetByParams { + return c.identity +} + +func (c *ClientPrincipal) IsValid(ctx context.Context) bool { + if c == nil { + return false + } + client, _ := c.Client(ctx) + return client != nil +} + +func (c *ClientPrincipal) Client(ctx context.Context) (*clients.Client, error) { + if c.invalid { + return nil, nil + } + + if c.client != nil { + return c.client, nil + } + + if c.clients == nil { + c.invalid = true + return nil, nil + } + + client, err := c.clients.GetBy(WithSystem(ctx), c.spaceID, c.identity) + if err != nil || client == nil || client.IsDisabled() { + c.invalid = true + return nil, err + } + + c.client = client + return c.client, nil +} + +func (c *ClientPrincipal) HasEnvironmentAccess(ctx context.Context, spaceID, envID string) bool { + return hasEnvironmentAccess(ctx, c.environments, c.Role(ctx, spaceID), envID) +} + +func (c *ClientPrincipal) getRoleID(ctx context.Context, spaceID string) (string, bool) { + + if c.spaceID == "" || spaceID == "" { + return "", false + } + + if spaceID == c.spaceID { + cl, _ := c.Client(ctx) + if cl == nil || cl.RoleID == "" { + return "", false + } + + return cl.RoleID, true + } + + rID, err := c.collaborators.Get(WithSystem(ctx), spaceID, c.spaceID) + if err != nil { + rID = roles.ViewRole + } + return rID, true + +} + +func (c *ClientPrincipal) Role(ctx context.Context, spaceID string) *roles.Role { + if c.spaceID == "" { + return nil + } + + rID, ok := c.getRoleID(ctx, spaceID) + if !ok { + return nil + } + + role, err := c.roles.Get(WithSystem(ctx), spaceID, rID) + if err == nil { + //c.hasRole = true + //c.role = role + return role + } + + return nil +} + +func (c *ClientPrincipal) Rules(ctx context.Context, spaceID, envID string) permission.Ruleset { + if c.spaceID == "" || spaceID == "" || envID == "" { + return nil + } + + role := c.Role(ctx, spaceID) + if role == nil { + return nil + } + + if role.AllowManagement { + return permission.PrivilegedRuleset{} + } + + if hasEnvironmentAccess(ctx, c.environments, role, envID) { + return role.Rules + } + return nil +} + +func (c *ClientPrincipal) HasAccess(ctx context.Context, spaceID, orgID string) error { + if !c.IsValid(ctx) { + return ErrAccessDenied + } + + if c.IsSystem(ctx) { + return nil + } + + if spaceID != "" { + if c.spaceID == "" { + return ErrAccessDenied + } + + client, _ := c.Client(ctx) + if client != nil && client.SpaceID == spaceID { + return nil + } + } + + if c.Member(ctx).IsPrivileged() { + return nil + } + + return ErrAccessDenied +} + +func (c *ClientPrincipal) hasRole(ctx context.Context, spaceID string) (bool, error) { + if c.spaceID == "" { + return false, nil + } + + client, err := c.Client(ctx) + if err != nil && errors.Is(err, ErrNotFound) { + if sp := c.getSpace(ctx, spaceID); sp == nil { + return false, ErrNotFound + } + } + if client != nil && client.SpaceID == spaceID { + return true, nil + } + + return false, nil +} diff --git a/pkg/auth/context.go b/pkg/auth/context.go new file mode 100644 index 0000000000000000000000000000000000000000..d447681068fefc974089f351d7654056314796b7 --- /dev/null +++ b/pkg/auth/context.go @@ -0,0 +1,27 @@ +package auth + +import ( + "context" +) + +type principalKey struct{} + +func GetPrincipal(ctx context.Context) Principal { + p, _ := ctx.Value(principalKey{}).(Principal) + if p == nil { + return Anonymous{} + } + return p +} + +func WithPrincipal(ctx context.Context, p Principal) context.Context { + if ctx == nil { + ctx = context.Background() + } + + return context.WithValue(ctx, principalKey{}, p) +} + +func WithSystem(ctx context.Context) context.Context { + return WithPrincipal(ctx, &SystemPrincipal{}) +} diff --git a/pkg/auth/errors.go b/pkg/auth/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..8522ec94ff4ac7e04e6eabfdca561f698645db9d --- /dev/null +++ b/pkg/auth/errors.go @@ -0,0 +1,10 @@ +package auth + +import ( + "git.perx.ru/perxis/perxis-go/pkg/errors" +) + +var ( + ErrAccessDenied = errors.PermissionDenied(errors.New("access denied")) + ErrNotFound = errors.NotFound(errors.New("not found")) +) diff --git a/pkg/auth/factory.go b/pkg/auth/factory.go new file mode 100644 index 0000000000000000000000000000000000000000..2394c62a2f15ca7605959b3f5b31996a5c79a164 --- /dev/null +++ b/pkg/auth/factory.go @@ -0,0 +1,82 @@ +package auth + +import ( + "strings" + + "git.perx.ru/perxis/perxis-go/pkg/clients" + "git.perx.ru/perxis/perxis-go/pkg/collaborators" + "git.perx.ru/perxis/perxis-go/pkg/environments" + "git.perx.ru/perxis/perxis-go/pkg/members" + "git.perx.ru/perxis/perxis-go/pkg/roles" + "git.perx.ru/perxis/perxis-go/pkg/spaces" + "git.perx.ru/perxis/perxis-go/pkg/users" +) + +type PrincipalFactory struct { + users.Users + members.Members + collaborators.Collaborators + roles.Roles + clients.Clients + spaces.Spaces + environments.Environments +} + +func (f PrincipalFactory) User(identity string) Principal { + return &UserPrincipal{ + identity: identity, + users: f.Users, + members: f.Members, + roles: f.Roles, + collaborators: f.Collaborators, + spaces: f.Spaces, + environments: f.Environments, + } +} + +func (f PrincipalFactory) Client(param *clients.GetByParams) Principal { + return &ClientPrincipal{ + identity: param, + //authID: authID, + clients: f.Clients, + environments: f.Environments, + roles: f.Roles, + spaces: f.Spaces, + collaborators: f.Collaborators, + } +} + +func (f PrincipalFactory) Anonymous() Principal { + return &Anonymous{ + roles: f.Roles, + spaces: f.Spaces, + } +} + +func (f PrincipalFactory) System() Principal { + return &SystemPrincipal{} +} + +func (f PrincipalFactory) Principal(principalId string) Principal { + switch { + case strings.Contains(principalId, "Subject="): + return f.Client(&clients.GetByParams{TLSSubject: getSubject(principalId)}) + case strings.HasSuffix(principalId, "@clients"): + return f.Client(&clients.GetByParams{OAuthClientID: strings.TrimSuffix(principalId, "@clients")}) + case strings.HasPrefix(principalId, "API-Key"): + return f.Client(&clients.GetByParams{APIKey: strings.TrimPrefix(principalId, "API-Key ")}) + default: + return f.User(principalId) + } +} + +func getSubject(header string) string { + var p string + for _, part := range strings.Split(header, ";") { + if strings.Contains(part, "Subject") { + p = strings.TrimSuffix(strings.TrimPrefix(part, "Subject=\""), "\"") + break + } + } + return p +} diff --git a/pkg/auth/grpc.go b/pkg/auth/grpc.go new file mode 100644 index 0000000000000000000000000000000000000000..7a566711db76c11b22206d947ba94ecc2bc3b366 --- /dev/null +++ b/pkg/auth/grpc.go @@ -0,0 +1,92 @@ +package auth + +import ( + "context" + + kitgrpc "github.com/go-kit/kit/transport/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const ( + OAuth2IdentityMetadata = "x-perxis-identity" + TLSIdentityMetadata = "x-forwarded-client-cert" + AccessMetadata = "x-perxis-access" + + AuthorizationMetadata = "authorization" +) + +func GRPCToContext(factory *PrincipalFactory) kitgrpc.ServerRequestFunc { + return func(ctx context.Context, md metadata.MD) context.Context { + if identity := md.Get(TLSIdentityMetadata); len(identity) > 0 { + return WithPrincipal(ctx, factory.Principal(identity[0])) + } + + if identity := md.Get(OAuth2IdentityMetadata); len(identity) > 0 { + return WithPrincipal(ctx, factory.Principal(identity[0])) + } + + if identity := md.Get(AuthorizationMetadata); len(identity) > 0 { + return WithPrincipal(ctx, factory.Principal(identity[0])) + } + + if access := md.Get(AccessMetadata); len(access) > 0 { + return WithPrincipal(ctx, factory.System()) + } + + return WithPrincipal(ctx, factory.Anonymous()) + } +} + +func ContextToGRPC() kitgrpc.ClientRequestFunc { + return func(ctx context.Context, md *metadata.MD) context.Context { + p := GetPrincipal(ctx) + + switch p := p.(type) { + case *UserPrincipal: + if p.GetIdentity(ctx) != "" { + (*md)[OAuth2IdentityMetadata] = []string{p.GetIdentity(ctx)} + } + case *ClientPrincipal: + if ident := p.GetIdentity(ctx); ident != nil { + switch { + case ident.OAuthClientID != "": + (*md)[OAuth2IdentityMetadata] = []string{ident.OAuthClientID + "@clients"} + case ident.TLSSubject != "": + (*md)[TLSIdentityMetadata] = []string{ident.TLSSubject} + case ident.APIKey != "": + (*md)[AuthorizationMetadata] = []string{"API-Key " + ident.APIKey} + + } + } + case *SystemPrincipal: + (*md)[AccessMetadata] = []string{p.GetID(ctx)} + } + + return ctx + } +} + +// PrincipalServerInterceptor - grpc-интерсептор, который используется для получения данных принципала РёР· grpc-метаданы Рё добавления РІ контекст ''. Р’ случае, если +// сервис РЅРµ использует проверку прав 'Principal' Рє системе, РІ параметрах передается пустой объект '&PrincipalFactory{}' +func PrincipalServerInterceptor(factory *PrincipalFactory) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + if md, ok := metadata.FromIncomingContext(ctx); ok { + ctx = GRPCToContext(factory)(ctx, md) + } + return handler(ctx, req) + } +} + +// PrincipalClientInterceptor - grpc-интерсептор, который используется для получения данных принципала. Р’ случае, если +// сервис РЅРµ использует проверку прав 'Principal' Рє системе, РІ параметрах передается пустой объект '&PrincipalFactory{}' +func PrincipalClientInterceptor() grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + md = metadata.MD{} + } + ctx = metadata.NewOutgoingContext(ContextToGRPC()(ctx, &md), md) + return invoker(ctx, method, req, reply, cc, opts...) + } +} diff --git a/pkg/auth/principal.go b/pkg/auth/principal.go new file mode 100644 index 0000000000000000000000000000000000000000..78da09cb27f493f034720d40d91f33b7b6ce7313 --- /dev/null +++ b/pkg/auth/principal.go @@ -0,0 +1,86 @@ +package auth + +import ( + "context" + + "git.perx.ru/perxis/perxis-go/pkg/data" + "git.perx.ru/perxis/perxis-go/pkg/environments" + "git.perx.ru/perxis/perxis-go/pkg/members" + "git.perx.ru/perxis/perxis-go/pkg/permission" + "git.perx.ru/perxis/perxis-go/pkg/roles" +) + +type Principal interface { + GetID(ctx context.Context) string + IsValid(ctx context.Context) bool + IsSystem(ctx context.Context) bool + HasAccess(ctx context.Context, spID, orgID string) error + + IsManagementAllowed(ctx context.Context, spaceID string) error +} + +type SpaceAccessor interface { + Principal + Space(spaceID string) SpaceAccessor + + // HasSpaceAccess проверяет, есть ли Сѓ принципала доступ РЅР° чтение пространства + // (просмотр информации Рѕ пространстве, окружений, С‚.Рґ. - доступ Рє записям коллекций + // определяется отдельным набором правил, СЃРј. SpaceAccessor.Rules()) + HasSpaceAccess(ctx context.Context, spaceID string) bool + HasEnvironmentAccess(ctx context.Context, spaceID, env string) bool + + // Member возвращает роль принципала РІ организации + Member(ctx context.Context) members.Role + + Role(ctx context.Context, spaceID string) *roles.Role + + // Rules возвращает набор правил, РїРѕ которым принципал может получить + // доступ Рє записям коллекций пространства. + Rules(ctx context.Context, spaceID, envID string) permission.Ruleset +} + +type OrganizationAccessor interface { + Principal + Organization(orgID string) OrganizationAccessor + Member(ctx context.Context) members.Role +} + +func hasEnvironmentAccess(ctx context.Context, envsrv environments.Environments, role *roles.Role, envID string) bool { + if role == nil || role.SpaceID == "" || envID == "" { + return false + } + + if role.AllowManagement { + return true + } + + envs := role.Environments + + // Если СЏРІРЅРѕ РЅРµ указаны доступные окружения - доступ РїРѕ умолчанию Рє окружению master + if len(envs) == 0 { + envs = []string{environments.DefaultEnvironment} + } + + for _, ce := range envs { + if envID == ce || data.GlobMatch(envID, ce) { + return true + } + } + + e, err := envsrv.Get(WithSystem(ctx), role.SpaceID, envID) + if err != nil || e == nil { + return false + } + + aliases := append(e.Aliases, e.ID) + + for _, ce := range envs { + for _, al := range aliases { + if al == ce || data.GlobMatch(al, ce) { + return true + } + } + } + + return false +} diff --git a/pkg/auth/principal_test.go b/pkg/auth/principal_test.go new file mode 100644 index 0000000000000000000000000000000000000000..54e04ee4a8bf96151f9dfa9cf1edff0342ab03dd --- /dev/null +++ b/pkg/auth/principal_test.go @@ -0,0 +1,178 @@ +package auth + +import ( + "context" + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/environments" + mocksenvs "git.perx.ru/perxis/perxis-go/pkg/environments/mocks" + "git.perx.ru/perxis/perxis-go/pkg/roles" + "github.com/stretchr/testify/mock" +) + +func Test_hasEnvironmentAccess(t *testing.T) { + type args struct { + ctx context.Context + envscall func(envsservice *mocksenvs.Environments) + role *roles.Role + envID string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "simple", + args: args{ + ctx: context.Background(), + role: &roles.Role{ + ID: "1", + SpaceID: "space", + Description: "Current", + Environments: []string{"env1", "env2"}, + }, + envID: "env1", + }, + want: true, + }, + { + name: "glob env in role test: e*", + args: args{ + ctx: context.Background(), + envscall: func(envsservice *mocksenvs.Environments) { + envsservice.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(&environments.Environment{ + ID: "env1", + SpaceID: "space", + Aliases: []string{"master"}, + }, nil).Once() + }, + role: &roles.Role{ + ID: "1", + SpaceID: "space", + Description: "Current", + Environments: []string{"e*"}, + }, + envID: "env", + }, + want: true, + }, + { + name: "glob env in role test: *n*", + args: args{ + ctx: context.Background(), + envscall: func(envsservice *mocksenvs.Environments) { + envsservice.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(&environments.Environment{ + ID: "env1", + SpaceID: "space", + Aliases: []string{"master"}, + }, nil).Once() + }, + role: &roles.Role{ + ID: "1", + SpaceID: "space", + Description: "Current", + Environments: []string{"*n*"}, + }, + envID: "env", + }, + want: true, + }, + { + name: "glob env in role test: *1", + args: args{ + ctx: context.Background(), + envscall: func(envsservice *mocksenvs.Environments) { + envsservice.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(&environments.Environment{ + ID: "env1", + SpaceID: "space", + Aliases: []string{"master"}, + }, nil).Once() + }, + role: &roles.Role{ + ID: "1", + SpaceID: "space", + Description: "Current", + Environments: []string{"*1"}, + }, + envID: "env", + }, + want: true, + }, + { + name: "glob env in role test (alias): ma*", + args: args{ + ctx: context.Background(), + envscall: func(envsservice *mocksenvs.Environments) { + envsservice.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(&environments.Environment{ + ID: "env1", + SpaceID: "space", + Aliases: []string{"master"}, + }, nil).Once() + }, + role: &roles.Role{ + ID: "1", + SpaceID: "space", + Description: "Current", + Environments: []string{"ma*"}, + }, + envID: "env1", + }, + want: true, + }, + { + name: "glob env in role test: *", + args: args{ + ctx: context.Background(), + envscall: func(envsservice *mocksenvs.Environments) { + envsservice.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(&environments.Environment{ + ID: "env1", + SpaceID: "space", + Aliases: []string{"master"}, + }, nil).Once() + }, + role: &roles.Role{ + ID: "1", + SpaceID: "space", + Description: "Current", + Environments: []string{"*"}, + }, + envID: "env1", + }, + want: true, + }, + { + name: "glob env in role test: q*", + args: args{ + ctx: context.Background(), + envscall: func(envsservice *mocksenvs.Environments) { + envsservice.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(&environments.Environment{ + ID: "env1", + SpaceID: "space", + Aliases: []string{"master"}, + }, nil).Once() + }, + role: &roles.Role{ + ID: "1", + SpaceID: "space", + Description: "Current", + Environments: []string{"q*"}, + }, + envID: "env1", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envsservice := &mocksenvs.Environments{} + if tt.args.envscall != nil { + tt.args.envscall(envsservice) + } + + if got := hasEnvironmentAccess(tt.args.ctx, envsservice, tt.args.role, tt.args.envID); got != tt.want { + t.Errorf("hasEnvironmentAccess() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/auth/system.go b/pkg/auth/system.go new file mode 100644 index 0000000000000000000000000000000000000000..b602fe114bb53d5f82ae5f6796806dc20ed75188 --- /dev/null +++ b/pkg/auth/system.go @@ -0,0 +1,39 @@ +package auth + +import ( + "context" + "fmt" + + "git.perx.ru/perxis/perxis-go/pkg/members" + "git.perx.ru/perxis/perxis-go/pkg/permission" + "git.perx.ru/perxis/perxis-go/pkg/roles" +) + +type SystemPrincipal struct{} + +const ( + SystemID = "system" +) + +func (p SystemPrincipal) GetID(ctx context.Context) string { return SystemID } +func (SystemPrincipal) IsValid(ctx context.Context) bool { return true } +func (SystemPrincipal) IsSystem(ctx context.Context) bool { return true } +func (SystemPrincipal) IsManagementAllowed(ctx context.Context, spaceID string) error { return nil } + +func (p SystemPrincipal) Organization(_ string) OrganizationAccessor { return p } + +func (p SystemPrincipal) Space(_ string) SpaceAccessor { return p } +func (SystemPrincipal) HasSpaceAccess(_ context.Context, _ string) bool { return true } +func (SystemPrincipal) HasAccess(ctx context.Context, spaceID, orgID string) error { + return nil +} +func (SystemPrincipal) HasEnvironmentAccess(_ context.Context, _, _ string) bool { return true } +func (SystemPrincipal) Member(_ context.Context) members.Role { return members.NotMember } +func (SystemPrincipal) Role(_ context.Context, _ string) *roles.Role { return nil } +func (SystemPrincipal) Rules(_ context.Context, _, _ string) permission.Ruleset { + return &permission.PrivilegedRuleset{} +} + +func (SystemPrincipal) Format(f fmt.State, verb rune) { + f.Write([]byte("SystemPrincipal{}")) +} diff --git a/pkg/auth/user.go b/pkg/auth/user.go new file mode 100644 index 0000000000000000000000000000000000000000..f34693bf93f9b63c5d5eca84398b096af86e52ef --- /dev/null +++ b/pkg/auth/user.go @@ -0,0 +1,334 @@ +package auth + +import ( + "context" + "fmt" + + "git.perx.ru/perxis/perxis-go/pkg/collaborators" + "git.perx.ru/perxis/perxis-go/pkg/environments" + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/members" + "git.perx.ru/perxis/perxis-go/pkg/permission" + "git.perx.ru/perxis/perxis-go/pkg/roles" + "git.perx.ru/perxis/perxis-go/pkg/spaces" + "git.perx.ru/perxis/perxis-go/pkg/users" +) + +type UserPrincipal struct { + id string + identity string + + user *users.User + invalid bool + spaceID string + orgID string + + users users.Users + members members.Members + hasMemberRole bool + memberRole members.Role + + collaborators collaborators.Collaborators + spaces spaces.Spaces + environments environments.Environments + roles roles.Roles +} + +func (u UserPrincipal) Format(f fmt.State, verb rune) { + f.Write([]byte(fmt.Sprintf("UserPrincipal{ID: '%s', Identity: '%s'}", u.id, u.identity))) +} + +func (u *UserPrincipal) Space(spaceID string) SpaceAccessor { + u.spaceID = spaceID + u.orgID = "" + return u +} + +func (u *UserPrincipal) getSpace(ctx context.Context, spaceID string) *spaces.Space { + if spaceID == "" { + return nil + } + space, _ := u.spaces.Get(WithSystem(ctx), spaceID) + return space +} + +func (u UserPrincipal) Organization(orgID string) OrganizationAccessor { + u.orgID = orgID + return &u +} + +func (u *UserPrincipal) GetID(ctx context.Context) string { + user := u.User(ctx) + if user == nil { + return "" + } + return user.ID +} + +func (u *UserPrincipal) GetIdentity(ctx context.Context) string { + return u.identity +} + +func (u *UserPrincipal) IsValid(ctx context.Context) bool { + if u == nil { + return false + } + + return u.User(ctx) != nil +} + +func (u *UserPrincipal) IsSystem(ctx context.Context) bool { + user := u.User(ctx) + if user != nil { + return user.IsSystem() + } + return false +} + +func (u *UserPrincipal) IsManagementAllowed(ctx context.Context, spaceID string) error { + if !u.IsValid(ctx) { + return ErrAccessDenied + } + + if u.IsSystem(ctx) { + return nil + } + + if u.Member(ctx).IsPrivileged() { + return nil + } + + if role := u.Role(ctx, spaceID); role != nil && role.AllowManagement { + return nil + } + + return ErrAccessDenied +} + +func (u *UserPrincipal) User(ctx context.Context) *users.User { + if u.invalid { + return nil + } + + if u.user != nil { + return u.user + } + if u.users == nil { + u.invalid = true + return nil + } + + var user *users.User + var err error + switch { + case u.id != "": + user, err = u.users.Get(WithSystem(ctx), u.id) + case u.identity != "": + ctx = WithSystem(ctx) + user, err = u.users.GetByIdentity(WithSystem(ctx), u.identity) + } + + if err != nil || user == nil { + u.invalid = true + return nil + } + + u.user = user + return u.user +} + +func (u *UserPrincipal) Member(ctx context.Context) members.Role { + if u.hasMemberRole { + return u.memberRole + } + + if u.members == nil || (u.orgID == "" && u.spaceID == "") { + u.hasMemberRole = true + return members.NotMember + } + + if u.orgID == "" && u.spaceID != "" { + sp := u.getSpace(ctx, u.spaceID) + if sp == nil { + u.hasMemberRole = true + return members.NotMember + } + u.orgID = sp.OrgID + } + + role, err := u.members.Get(WithSystem(ctx), u.orgID, u.GetID(ctx)) + if err != nil { + role = members.NotMember + } + + u.memberRole = role + u.hasMemberRole = true + return u.memberRole +} + +// HasSpaceAccess проверяет, есть ли Сѓ пользователя доступ Рє пространству +// Пользователь имеет доступ Рє пространству если: +// - Является участником пространства (даже если его роль РЅРµ существует) +// - Пространство позволяет доступ для РЅРµ участников (есть роли AnonymousRole/AuthorizedRole/ViewRole) +// Deprecated :use HasAccess +func (u *UserPrincipal) HasSpaceAccess(ctx context.Context, spaceID string) bool { + res, _ := u.hasRole(ctx, spaceID) + return res +} + +// HasAccess проверяет, есть ли Сѓ пользователя доступ Рє пространству +// Пользователь имеет доступ Рє пространству если: +// - Является участником пространства (даже если его роль РЅРµ существует) +// - Пространство позволяет доступ для РЅРµ участников (есть роли AnonymousRole/AuthorizedRole/ViewRole) +func (u *UserPrincipal) HasAccess(ctx context.Context, spaceID, orgID string) error { + if !u.IsValid(ctx) { + return ErrAccessDenied + } + + if u.IsSystem(ctx) { + return nil + } + + if spaceID != "" { + hasAllow, err := u.hasRole(ctx, spaceID) + if err != nil { + return err + } + + if hasAllow { + return nil + } + } + + if orgID != "" { + if u.Organization(orgID).Member(ctx).IsPrivileged() { + return nil + } + } else { + if u.Member(ctx).IsPrivileged() { + return nil + } + } + + return ErrAccessDenied +} + +func (u *UserPrincipal) hasRole(ctx context.Context, spaceID string) (bool, error) { + + if u.spaceID == "" || spaceID == "" { + return false, nil + } + + ctx = WithSystem(ctx) + + if spaceID != u.spaceID { + _, cErr := u.collaborators.Get(ctx, spaceID, u.spaceID) + if cErr == nil { + return true, nil + } + _, rErr := u.roles.Get(ctx, spaceID, roles.ViewRole) + if rErr == nil { + return true, nil + } + if errors.Is(cErr, ErrNotFound) || errors.Is(rErr, ErrNotFound) { + if sp := u.getSpace(ctx, spaceID); sp == nil { + return false, ErrNotFound + } + } + + return false, nil + } + + _, cErr := u.collaborators.Get(ctx, spaceID, u.GetID(ctx)) + if cErr == nil { + return true, nil + } + + _, rErr := u.roles.Get(ctx, spaceID, roles.AuthorizedRole) + if rErr == nil { + return true, nil + } + + if errors.Is(cErr, ErrNotFound) || errors.Is(rErr, ErrNotFound) { + if sp := u.getSpace(ctx, spaceID); sp == nil { + return false, ErrNotFound + } + } + + return false, nil +} + +func (u *UserPrincipal) getRoleID(ctx context.Context, spaceID string) string { + + if u.spaceID == "" || spaceID == "" { + return "" + } + + ctx = WithSystem(ctx) + + if spaceID != u.spaceID { + rID, err := u.collaborators.Get(ctx, spaceID, u.spaceID) + if err != nil { + rID = roles.ViewRole + } + return rID + } + + if roleID, err := u.collaborators.Get(ctx, spaceID, u.GetID(ctx)); err == nil { + return roleID + } + + return roles.AuthorizedRole +} + +func (u *UserPrincipal) Role(ctx context.Context, spaceID string) *roles.Role { + + if roleID := u.getRoleID(ctx, spaceID); roleID != "" { + role, _ := u.roles.Get(WithSystem(ctx), spaceID, roleID) + return role + } + + return nil +} + +func (u *UserPrincipal) Rules(ctx context.Context, spaceID, envID string) permission.Ruleset { + if spaceID == "" || envID == "" { + return nil + } + + if u.spaceID == spaceID && (u.IsSystem(ctx) || u.Member(ctx).IsPrivileged()) { + return permission.PrivilegedRuleset{} + } + + role := u.Role(ctx, spaceID) + if role == nil { + return nil + } + + if !hasEnvironmentAccess(ctx, u.environments, role, envID) { + return nil + } + + return role.Rules +} + +func IsValidUser(ctx context.Context, p Principal) bool { + if p == nil { + return false + } + if u, ok := p.(*UserPrincipal); ok { + return u.IsValid(ctx) + } + return false +} + +func User(ctx context.Context, p Principal) *users.User { + if u, ok := p.(*UserPrincipal); ok { + return u.User(ctx) + } + return nil +} + +func (u *UserPrincipal) HasEnvironmentAccess(ctx context.Context, spaceID, env string) bool { + return hasEnvironmentAccess(ctx, u.environments, u.Role(ctx, spaceID), env) +} diff --git a/pkg/members/members.go b/pkg/members/members.go new file mode 100644 index 0000000000000000000000000000000000000000..0993b6fddfe34cf7f257e6b70433893485a60dba --- /dev/null +++ b/pkg/members/members.go @@ -0,0 +1,35 @@ +package members + +import ( + "fmt" +) + +type Member struct { + OrgId string `bson:"orgId"` + UserId string `bson:"userId"` + Role Role `bson:"role"` +} + +type Role uint + +const ( + NotMember Role = iota + RoleMember + RoleOwner + RoleAdmin +) + +func (r Role) IsPrivileged() bool { + return r == RoleOwner || r == RoleAdmin +} + +func (r Role) Format(s fmt.State, verb rune) { + switch r { + case RoleOwner: + fmt.Fprint(s, "owner") + case RoleAdmin: + fmt.Fprint(s, "admin") + case RoleMember: + fmt.Fprint(s, "member") + } +} diff --git a/pkg/members/mocks/Members.go b/pkg/members/mocks/Members.go new file mode 100644 index 0000000000000000000000000000000000000000..2adeaac705bf18a17eb06f7932d5d3769de1b50f --- /dev/null +++ b/pkg/members/mocks/Members.go @@ -0,0 +1,124 @@ +// Code generated by mockery v2.7.4. DO NOT EDIT. + +package mocks + +import ( + context "context" + + members "git.perx.ru/perxis/perxis-go/pkg/members" + mock "github.com/stretchr/testify/mock" +) + +// Members is an autogenerated mock type for the Members type +type Members struct { + mock.Mock +} + +// Get provides a mock function with given fields: ctx, orgId, userId +func (_m *Members) Get(ctx context.Context, orgId string, userId string) (members.Role, error) { + ret := _m.Called(ctx, orgId, userId) + + var r0 members.Role + if rf, ok := ret.Get(0).(func(context.Context, string, string) members.Role); ok { + r0 = rf(ctx, orgId, userId) + } else { + r0 = ret.Get(0).(members.Role) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, orgId, userId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListMembers provides a mock function with given fields: ctx, orgId +func (_m *Members) ListMembers(ctx context.Context, orgId string) ([]*members.Member, error) { + ret := _m.Called(ctx, orgId) + + var r0 []*members.Member + if rf, ok := ret.Get(0).(func(context.Context, string) []*members.Member); ok { + r0 = rf(ctx, orgId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*members.Member) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, orgId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListOrganizations provides a mock function with given fields: ctx, userId +func (_m *Members) ListOrganizations(ctx context.Context, userId string) ([]*members.Member, error) { + ret := _m.Called(ctx, userId) + + var r0 []*members.Member + if rf, ok := ret.Get(0).(func(context.Context, string) []*members.Member); ok { + r0 = rf(ctx, userId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*members.Member) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, userId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Remove provides a mock function with given fields: ctx, orgId, userId +func (_m *Members) Remove(ctx context.Context, orgId string, userId string) error { + ret := _m.Called(ctx, orgId, userId) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, orgId, userId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// RemoveAll provides a mock function with given fields: ctx, orgId +func (_m *Members) RemoveAll(ctx context.Context, orgId string) error { + ret := _m.Called(ctx, orgId) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, orgId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Set provides a mock function with given fields: ctx, orgId, userId, role +func (_m *Members) Set(ctx context.Context, orgId string, userId string, role members.Role) error { + ret := _m.Called(ctx, orgId, userId, role) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, members.Role) error); ok { + r0 = rf(ctx, orgId, userId, role) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/members/service.go b/pkg/members/service.go new file mode 100644 index 0000000000000000000000000000000000000000..5b88239b9b8509d8c1f65ca1a1a353cfe7281503 --- /dev/null +++ b/pkg/members/service.go @@ -0,0 +1,23 @@ +package members + +import ( + "context" +) + +// @microgen grpc +// @protobuf git.perx.ru/perxis/perxis-go/proto/members +// @grpc-addr account.members.Members +type Members interface { + Set(ctx context.Context, orgId, userId string, role Role) (err error) + + Get(ctx context.Context, orgId, userId string) (role Role, err error) + + Remove(ctx context.Context, orgId, userId string) (err error) + + // @microgen - + RemoveAll(ctx context.Context, orgId string) (err error) + + ListMembers(ctx context.Context, orgId string) (members []*Member, err error) + + ListOrganizations(ctx context.Context, userId string) (organizations []*Member, err error) +} diff --git a/pkg/members/transport/client.microgen.go b/pkg/members/transport/client.microgen.go new file mode 100644 index 0000000000000000000000000000000000000000..4cd10cad5b1b970f30a9380eae77a5d706748a08 --- /dev/null +++ b/pkg/members/transport/client.microgen.go @@ -0,0 +1,86 @@ +// Code generated by microgen 0.9.1. DO NOT EDIT. + +package transport + +import ( + "context" + "errors" + + members "git.perx.ru/perxis/perxis-go/pkg/members" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +func (set EndpointsSet) Set(arg0 context.Context, arg1 string, arg2 string, arg3 members.Role) (res0 error) { + request := SetRequest{ + OrgId: arg1, + Role: arg3, + UserId: arg2, + } + _, res0 = set.SetEndpoint(arg0, &request) + if res0 != nil { + if e, ok := status.FromError(res0); ok || e.Code() == codes.Internal || e.Code() == codes.Unknown { + res0 = errors.New(e.Message()) + } + return + } + return res0 +} + +func (set EndpointsSet) Get(arg0 context.Context, arg1 string, arg2 string) (res0 members.Role, res1 error) { + request := GetRequest{ + OrgId: arg1, + UserId: arg2, + } + response, res1 := set.GetEndpoint(arg0, &request) + if res1 != nil { + if e, ok := status.FromError(res1); ok || e.Code() == codes.Internal || e.Code() == codes.Unknown { + res1 = errors.New(e.Message()) + } + return + } + return response.(*GetResponse).Role, res1 +} + +func (set EndpointsSet) Remove(arg0 context.Context, arg1 string, arg2 string) (res0 error) { + request := RemoveRequest{ + OrgId: arg1, + UserId: arg2, + } + _, res0 = set.RemoveEndpoint(arg0, &request) + if res0 != nil { + if e, ok := status.FromError(res0); ok || e.Code() == codes.Internal || e.Code() == codes.Unknown { + res0 = errors.New(e.Message()) + } + return + } + return res0 +} + +func (set EndpointsSet) RemoveAll(arg0 context.Context, arg1 string) (res0 error) { + return +} + +func (set EndpointsSet) ListMembers(arg0 context.Context, arg1 string) (res0 []*members.Member, res1 error) { + request := ListMembersRequest{OrgId: arg1} + response, res1 := set.ListMembersEndpoint(arg0, &request) + if res1 != nil { + if e, ok := status.FromError(res1); ok || e.Code() == codes.Internal || e.Code() == codes.Unknown { + res1 = errors.New(e.Message()) + } + return + } + return response.(*ListMembersResponse).Members, res1 +} + +func (set EndpointsSet) ListOrganizations(arg0 context.Context, arg1 string) (res0 []*members.Member, res1 error) { + request := ListOrganizationsRequest{UserId: arg1} + response, res1 := set.ListOrganizationsEndpoint(arg0, &request) + if res1 != nil { + if e, ok := status.FromError(res1); ok || e.Code() == codes.Internal || e.Code() == codes.Unknown { + res1 = errors.New(e.Message()) + } + return + } + return response.(*ListOrganizationsResponse).Organizations, res1 +} diff --git a/pkg/members/transport/endpoints.microgen.go b/pkg/members/transport/endpoints.microgen.go new file mode 100644 index 0000000000000000000000000000000000000000..88740bb07c63a46ff111527fdeae40dfc89a6167 --- /dev/null +++ b/pkg/members/transport/endpoints.microgen.go @@ -0,0 +1,14 @@ +// Code generated by microgen 0.9.1. DO NOT EDIT. + +package transport + +import endpoint "github.com/go-kit/kit/endpoint" + +// EndpointsSet implements Members API and used for transport purposes. +type EndpointsSet struct { + SetEndpoint endpoint.Endpoint + GetEndpoint endpoint.Endpoint + RemoveEndpoint endpoint.Endpoint + ListMembersEndpoint endpoint.Endpoint + ListOrganizationsEndpoint endpoint.Endpoint +} diff --git a/pkg/members/transport/exchanges.microgen.go b/pkg/members/transport/exchanges.microgen.go new file mode 100644 index 0000000000000000000000000000000000000000..afa03b65ed508022e89565e2f0881e6afb3c5678 --- /dev/null +++ b/pkg/members/transport/exchanges.microgen.go @@ -0,0 +1,44 @@ +// Code generated by microgen 0.9.1. DO NOT EDIT. + +package transport + +import members "git.perx.ru/perxis/perxis-go/pkg/members" + +type ( + SetRequest struct { + OrgId string `json:"org_id"` + UserId string `json:"user_id"` + Role members.Role `json:"role"` + } + // Formal exchange type, please do not delete. + SetResponse struct{} + + GetRequest struct { + OrgId string `json:"org_id"` + UserId string `json:"user_id"` + } + GetResponse struct { + Role members.Role `json:"role"` + } + + RemoveRequest struct { + OrgId string `json:"org_id"` + UserId string `json:"user_id"` + } + // Formal exchange type, please do not delete. + RemoveResponse struct{} + + ListMembersRequest struct { + OrgId string `json:"org_id"` + } + ListMembersResponse struct { + Members []*members.Member `json:"members"` + } + + ListOrganizationsRequest struct { + UserId string `json:"user_id"` + } + ListOrganizationsResponse struct { + Organizations []*members.Member `json:"organizations"` + } +) diff --git a/pkg/members/transport/grpc/client.microgen.go b/pkg/members/transport/grpc/client.microgen.go new file mode 100644 index 0000000000000000000000000000000000000000..6df6b7951b031d7125b2a93174cf38117671ec7a --- /dev/null +++ b/pkg/members/transport/grpc/client.microgen.go @@ -0,0 +1,54 @@ +// Code generated by microgen 0.9.1. DO NOT EDIT. + +package transportgrpc + +import ( + transport "git.perx.ru/perxis/perxis-go/pkg/members/transport" + pb "git.perx.ru/perxis/perxis-go/proto/members" + grpckit "github.com/go-kit/kit/transport/grpc" + empty "github.com/golang/protobuf/ptypes/empty" + grpc "google.golang.org/grpc" +) + +func NewGRPCClient(conn *grpc.ClientConn, addr string, opts ...grpckit.ClientOption) transport.EndpointsSet { + if addr == "" { + addr = "account.members.Members" + } + return transport.EndpointsSet{ + GetEndpoint: grpckit.NewClient( + conn, addr, "Get", + _Encode_Get_Request, + _Decode_Get_Response, + pb.GetResponse{}, + opts..., + ).Endpoint(), + ListMembersEndpoint: grpckit.NewClient( + conn, addr, "ListMembers", + _Encode_ListMembers_Request, + _Decode_ListMembers_Response, + pb.ListMembersResponse{}, + opts..., + ).Endpoint(), + ListOrganizationsEndpoint: grpckit.NewClient( + conn, addr, "ListOrganizations", + _Encode_ListOrganizations_Request, + _Decode_ListOrganizations_Response, + pb.ListOrganizationsResponse{}, + opts..., + ).Endpoint(), + RemoveEndpoint: grpckit.NewClient( + conn, addr, "Remove", + _Encode_Remove_Request, + _Decode_Remove_Response, + empty.Empty{}, + opts..., + ).Endpoint(), + SetEndpoint: grpckit.NewClient( + conn, addr, "Set", + _Encode_Set_Request, + _Decode_Set_Response, + empty.Empty{}, + opts..., + ).Endpoint(), + } +} diff --git a/pkg/members/transport/grpc/protobuf_endpoint_converters.microgen.go b/pkg/members/transport/grpc/protobuf_endpoint_converters.microgen.go new file mode 100644 index 0000000000000000000000000000000000000000..9ee491a71335a89beb14582f02b8b26fd3a264d6 --- /dev/null +++ b/pkg/members/transport/grpc/protobuf_endpoint_converters.microgen.go @@ -0,0 +1,209 @@ +// Code generated by microgen 0.9.1. DO NOT EDIT. + +// Please, do not change functions names! +package transportgrpc + +import ( + "context" + "errors" + + "git.perx.ru/perxis/perxis-go/pkg/members/transport" + "git.perx.ru/perxis/perxis-go/proto/members" + "github.com/golang/protobuf/ptypes/empty" +) + +func _Encode_Set_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil SetRequest") + } + req := request.(*transport.SetRequest) + reqRole, err := RoleToProto(req.Role) + if err != nil { + return nil, err + } + return &members.SetRequest{ + OrgId: req.OrgId, + Role: reqRole, + UserId: req.UserId, + }, nil +} + +func _Encode_Get_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil GetRequest") + } + req := request.(*transport.GetRequest) + return &members.GetRequest{ + OrgId: req.OrgId, + UserId: req.UserId, + }, nil +} + +func _Encode_Remove_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil RemoveRequest") + } + req := request.(*transport.RemoveRequest) + return &members.RemoveRequest{ + OrgId: req.OrgId, + UserId: req.UserId, + }, nil +} + +func _Encode_ListMembers_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil ListMembersRequest") + } + req := request.(*transport.ListMembersRequest) + return &members.ListMembersRequest{OrgId: req.OrgId}, nil +} + +func _Encode_ListOrganizations_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil ListOrganizationsRequest") + } + req := request.(*transport.ListOrganizationsRequest) + return &members.ListOrganizationsRequest{UserId: req.UserId}, nil +} + +func _Encode_Set_Response(ctx context.Context, response interface{}) (interface{}, error) { + return &empty.Empty{}, nil +} + +func _Encode_Get_Response(ctx context.Context, response interface{}) (interface{}, error) { + if response == nil { + return nil, errors.New("nil GetResponse") + } + resp := response.(*transport.GetResponse) + respRole, err := RoleToProto(resp.Role) + if err != nil { + return nil, err + } + return &members.GetResponse{Role: respRole}, nil +} + +func _Encode_Remove_Response(ctx context.Context, response interface{}) (interface{}, error) { + return &empty.Empty{}, nil +} + +func _Encode_ListMembers_Response(ctx context.Context, response interface{}) (interface{}, error) { + if response == nil { + return nil, errors.New("nil ListMembersResponse") + } + resp := response.(*transport.ListMembersResponse) + respMembers, err := ListPtrMemberToProto(resp.Members) + if err != nil { + return nil, err + } + return &members.ListMembersResponse{Members: respMembers}, nil +} + +func _Encode_ListOrganizations_Response(ctx context.Context, response interface{}) (interface{}, error) { + if response == nil { + return nil, errors.New("nil ListOrganizationsResponse") + } + resp := response.(*transport.ListOrganizationsResponse) + respOrganizations, err := ListPtrMemberToProto(resp.Organizations) + if err != nil { + return nil, err + } + return &members.ListOrganizationsResponse{Organizations: respOrganizations}, nil +} + +func _Decode_Set_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil SetRequest") + } + req := request.(*members.SetRequest) + reqRole, err := ProtoToRole(req.Role) + if err != nil { + return nil, err + } + return &transport.SetRequest{ + OrgId: string(req.OrgId), + Role: reqRole, + UserId: string(req.UserId), + }, nil +} + +func _Decode_Get_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil GetRequest") + } + req := request.(*members.GetRequest) + return &transport.GetRequest{ + OrgId: string(req.OrgId), + UserId: string(req.UserId), + }, nil +} + +func _Decode_Remove_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil RemoveRequest") + } + req := request.(*members.RemoveRequest) + return &transport.RemoveRequest{ + OrgId: string(req.OrgId), + UserId: string(req.UserId), + }, nil +} + +func _Decode_ListMembers_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil ListMembersRequest") + } + req := request.(*members.ListMembersRequest) + return &transport.ListMembersRequest{OrgId: string(req.OrgId)}, nil +} + +func _Decode_ListOrganizations_Request(ctx context.Context, request interface{}) (interface{}, error) { + if request == nil { + return nil, errors.New("nil ListOrganizationsRequest") + } + req := request.(*members.ListOrganizationsRequest) + return &transport.ListOrganizationsRequest{UserId: string(req.UserId)}, nil +} + +func _Decode_Set_Response(ctx context.Context, response interface{}) (interface{}, error) { + return &empty.Empty{}, nil +} + +func _Decode_Get_Response(ctx context.Context, response interface{}) (interface{}, error) { + if response == nil { + return nil, errors.New("nil GetResponse") + } + resp := response.(*members.GetResponse) + respRole, err := ProtoToRole(resp.Role) + if err != nil { + return nil, err + } + return &transport.GetResponse{Role: respRole}, nil +} + +func _Decode_Remove_Response(ctx context.Context, response interface{}) (interface{}, error) { + return &empty.Empty{}, nil +} + +func _Decode_ListMembers_Response(ctx context.Context, response interface{}) (interface{}, error) { + if response == nil { + return nil, errors.New("nil ListMembersResponse") + } + resp := response.(*members.ListMembersResponse) + respMembers, err := ProtoToListPtrMember(resp.Members) + if err != nil { + return nil, err + } + return &transport.ListMembersResponse{Members: respMembers}, nil +} + +func _Decode_ListOrganizations_Response(ctx context.Context, response interface{}) (interface{}, error) { + if response == nil { + return nil, errors.New("nil ListOrganizationsResponse") + } + resp := response.(*members.ListOrganizationsResponse) + respOrganizations, err := ProtoToListPtrMember(resp.Organizations) + if err != nil { + return nil, err + } + return &transport.ListOrganizationsResponse{Organizations: respOrganizations}, nil +} diff --git a/pkg/members/transport/grpc/protobuf_type_converters.microgen.go b/pkg/members/transport/grpc/protobuf_type_converters.microgen.go new file mode 100644 index 0000000000000000000000000000000000000000..70d4628a570042ecad26a9adc557f948a8098778 --- /dev/null +++ b/pkg/members/transport/grpc/protobuf_type_converters.microgen.go @@ -0,0 +1,42 @@ +// Code generated by microgen 0.9.1. DO NOT EDIT. + +// It is better for you if you do not change functions names! +// This file will never be overwritten. +package transportgrpc + +import ( + service "git.perx.ru/perxis/perxis-go/pkg/members" + pb "git.perx.ru/perxis/perxis-go/proto/members" +) + +func RoleToProto(role service.Role) (pb.Role, error) { + return pb.Role(role), nil +} + +func ProtoToRole(protoRole pb.Role) (service.Role, error) { + return service.Role(protoRole), nil +} + +func ListPtrMemberToProto(members []*service.Member) ([]*pb.Member, error) { + res := make([]*pb.Member, 0, len(members)) + for _, m := range members { + res = append(res, &pb.Member{ + OrgId: m.OrgId, + UserId: m.UserId, + Role: pb.Role(m.Role), + }) + } + return res, nil +} + +func ProtoToListPtrMember(protoMembers []*pb.Member) ([]*service.Member, error) { + res := make([]*service.Member, 0, len(protoMembers)) + for _, m := range protoMembers { + res = append(res, &service.Member{ + OrgId: m.OrgId, + UserId: m.UserId, + Role: service.Role(m.Role), + }) + } + return res, nil +} diff --git a/pkg/members/transport/grpc/server.microgen.go b/pkg/members/transport/grpc/server.microgen.go new file mode 100644 index 0000000000000000000000000000000000000000..1d11e1c0990df144d99f511d20b4f844d4431b7a --- /dev/null +++ b/pkg/members/transport/grpc/server.microgen.go @@ -0,0 +1,97 @@ +// Code generated by microgen 0.9.1. DO NOT EDIT. + +// DO NOT EDIT. +package transportgrpc + +import ( + transport "git.perx.ru/perxis/perxis-go/pkg/members/transport" + pb "git.perx.ru/perxis/perxis-go/proto/members" + grpc "github.com/go-kit/kit/transport/grpc" + empty "github.com/golang/protobuf/ptypes/empty" + context "golang.org/x/net/context" +) + +type membersServer struct { + set grpc.Handler + get grpc.Handler + remove grpc.Handler + listMembers grpc.Handler + listOrganizations grpc.Handler + + pb.UnimplementedMembersServer +} + +func NewGRPCServer(endpoints *transport.EndpointsSet, opts ...grpc.ServerOption) pb.MembersServer { + return &membersServer{ + get: grpc.NewServer( + endpoints.GetEndpoint, + _Decode_Get_Request, + _Encode_Get_Response, + opts..., + ), + listMembers: grpc.NewServer( + endpoints.ListMembersEndpoint, + _Decode_ListMembers_Request, + _Encode_ListMembers_Response, + opts..., + ), + listOrganizations: grpc.NewServer( + endpoints.ListOrganizationsEndpoint, + _Decode_ListOrganizations_Request, + _Encode_ListOrganizations_Response, + opts..., + ), + remove: grpc.NewServer( + endpoints.RemoveEndpoint, + _Decode_Remove_Request, + _Encode_Remove_Response, + opts..., + ), + set: grpc.NewServer( + endpoints.SetEndpoint, + _Decode_Set_Request, + _Encode_Set_Response, + opts..., + ), + } +} + +func (S *membersServer) Set(ctx context.Context, req *pb.SetRequest) (*empty.Empty, error) { + _, resp, err := S.set.ServeGRPC(ctx, req) + if err != nil { + return nil, err + } + return resp.(*empty.Empty), nil +} + +func (S *membersServer) Get(ctx context.Context, req *pb.GetRequest) (*pb.GetResponse, error) { + _, resp, err := S.get.ServeGRPC(ctx, req) + if err != nil { + return nil, err + } + return resp.(*pb.GetResponse), nil +} + +func (S *membersServer) Remove(ctx context.Context, req *pb.RemoveRequest) (*empty.Empty, error) { + _, resp, err := S.remove.ServeGRPC(ctx, req) + if err != nil { + return nil, err + } + return resp.(*empty.Empty), nil +} + +func (S *membersServer) ListMembers(ctx context.Context, req *pb.ListMembersRequest) (*pb.ListMembersResponse, error) { + _, resp, err := S.listMembers.ServeGRPC(ctx, req) + if err != nil { + return nil, err + } + return resp.(*pb.ListMembersResponse), nil +} + +func (S *membersServer) ListOrganizations(ctx context.Context, req *pb.ListOrganizationsRequest) (*pb.ListOrganizationsResponse, error) { + _, resp, err := S.listOrganizations.ServeGRPC(ctx, req) + if err != nil { + return nil, err + } + return resp.(*pb.ListOrganizationsResponse), nil +} diff --git a/pkg/members/transport/server.microgen.go b/pkg/members/transport/server.microgen.go new file mode 100644 index 0000000000000000000000000000000000000000..b1c40ac3039b2ce46685880e93651f20d4fe7d16 --- /dev/null +++ b/pkg/members/transport/server.microgen.go @@ -0,0 +1,60 @@ +// Code generated by microgen 0.9.1. DO NOT EDIT. + +package transport + +import ( + "context" + + members "git.perx.ru/perxis/perxis-go/pkg/members" + endpoint "github.com/go-kit/kit/endpoint" +) + +func Endpoints(svc members.Members) EndpointsSet { + return EndpointsSet{ + GetEndpoint: GetEndpoint(svc), + ListMembersEndpoint: ListMembersEndpoint(svc), + ListOrganizationsEndpoint: ListOrganizationsEndpoint(svc), + RemoveEndpoint: RemoveEndpoint(svc), + SetEndpoint: SetEndpoint(svc), + } +} + +func SetEndpoint(svc members.Members) endpoint.Endpoint { + return func(arg0 context.Context, request interface{}) (interface{}, error) { + req := request.(*SetRequest) + res0 := svc.Set(arg0, req.OrgId, req.UserId, req.Role) + return &SetResponse{}, res0 + } +} + +func GetEndpoint(svc members.Members) endpoint.Endpoint { + return func(arg0 context.Context, request interface{}) (interface{}, error) { + req := request.(*GetRequest) + res0, res1 := svc.Get(arg0, req.OrgId, req.UserId) + return &GetResponse{Role: res0}, res1 + } +} + +func RemoveEndpoint(svc members.Members) endpoint.Endpoint { + return func(arg0 context.Context, request interface{}) (interface{}, error) { + req := request.(*RemoveRequest) + res0 := svc.Remove(arg0, req.OrgId, req.UserId) + return &RemoveResponse{}, res0 + } +} + +func ListMembersEndpoint(svc members.Members) endpoint.Endpoint { + return func(arg0 context.Context, request interface{}) (interface{}, error) { + req := request.(*ListMembersRequest) + res0, res1 := svc.ListMembers(arg0, req.OrgId) + return &ListMembersResponse{Members: res0}, res1 + } +} + +func ListOrganizationsEndpoint(svc members.Members) endpoint.Endpoint { + return func(arg0 context.Context, request interface{}) (interface{}, error) { + req := request.(*ListOrganizationsRequest) + res0, res1 := svc.ListOrganizations(arg0, req.UserId) + return &ListOrganizationsResponse{Organizations: res0}, res1 + } +} diff --git a/pkg/roles/role.go b/pkg/roles/role.go index 4c284ee5d45026aedfc284ecc16e146e78b56bb2..eecafb64135a8b4545fe7ef894529641ddbab69a 100644 --- a/pkg/roles/role.go +++ b/pkg/roles/role.go @@ -39,25 +39,33 @@ func (r Role) CanAccessEnvironment(ctx context.Context, service environments.Env return false } + if r.AllowManagement { + return true + } + // Если СЏРІРЅРѕ РЅРµ указаны доступные окружения - доступ РїРѕ умолчанию Рє окружению master if len(r.Environments) == 0 { r.Environments = []string{environments.DefaultEnvironment} } - if data.Contains(envID, r.Environments) { - return true + for _, e := range r.Environments { + if envID == e || data.GlobMatch(envID, e) { + return true + } } - e, err := service.Get(ctx, spaceID, envID) - if err != nil || e == nil { + env, err := service.Get(ctx, spaceID, envID) + if err != nil || env == nil { return false } - aliases := append(e.Aliases, e.ID) + aliases := append(env.Aliases, env.ID) - for _, ce := range r.Environments { - if data.Contains(ce, aliases) { - return true + for _, e := range r.Environments { + for _, a := range aliases { + if a == e || data.GlobMatch(a, e) { + return true + } } } diff --git a/pkg/schema/field/array.go b/pkg/schema/field/array.go new file mode 100644 index 0000000000000000000000000000000000000000..6700eb9b09af7bb714566dca8c82e90d026c7d57 --- /dev/null +++ b/pkg/schema/field/array.go @@ -0,0 +1,161 @@ +package field + +import ( + "context" + "fmt" + "reflect" + "strconv" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "github.com/hashicorp/go-multierror" +) + +var arrayType = &ArrayType{} + +type ArrayParameters struct { + Item *Field `json:"item"` +} + +func (ArrayParameters) Type() Type { return arrayType } + +func (p ArrayParameters) Clone(reset bool) Parameters { + return &ArrayParameters{Item: p.Item.Clone(reset)} +} + +type ArrayType struct{} + +func (ArrayType) Name() string { + return "array" +} + +func (ArrayType) NewParameters() Parameters { + return &ArrayParameters{} +} + +func (ArrayType) IsEmpty(v interface{}) bool { + arr, _ := v.([]interface{}) // todo: РЅСѓР¶РЅРѕ возвращать ошибку? + return len(arr) == 0 +} + +//func (ArrayType) Decode(ctx *Context.Context, field *Field, v interface{}) (interface{}, error) { +// params, ok := field.Params.(*ArrayParameters) +// if !ok { +// return nil, errors.New("field parameters required") +// } +// +// arr, ok := v.([]interface{}) +// if !ok { +// return nil, fmt.Errorf("[]interface{} required") +// } +// +// m := make([]interface{}, 0, len(arr)) +// +// for _, i := range arr { +// item, err := Decode(ctx, params.Item, i) +// if err != nil { +// return nil, err +// } +// m = append(m, item) +// } +// +// return m, nil +//} +// +//func (ArrayType) Encode(ctx *Context.Context, field *Field, v interface{}) (interface{}, error) { +// params, ok := field.Params.(*ArrayParameters) +// if !ok { +// return nil, errors.New("field parameters required") +// } +// +// arr, ok := v.([]interface{}) +// if !ok { +// return nil, fmt.Errorf("[]interface{} required") +// } +// +// m := make([]interface{}, 0, len(arr)) +// +// for _, i := range arr { +// item, err := params.Item.Encode(ctx, i) +// if err != nil { +// return nil, err +// } +// m = append(m, item) +// } +// +// return m, nil +//} + +//func (ArrayType) Validate(ctx *Context.Context, field *Field, v interface{}) error { +// params, ok := field.Params.(*ArrayParameters) +// if !ok { +// return errors.New("field parameters required") +// } +// +// m, ok := v.([]interface{}) +// if !ok { +// return errors.New("[]interface{} is required") +// } +// for _, i := range m { +// err := params.Item.Validate(ctx, i) +// if err != nil { +// return err +// } +// } +// return nil +//} + +func (ArrayType) Walk(ctx context.Context, field *Field, v interface{}, fn WalkFunc, opts *WalkOptions) (interface{}, bool, error) { + var changed bool + params, ok := field.Params.(*ArrayParameters) + if !ok { + return nil, false, errors.New("field parameters required") + } + + // Р’ массиве нет РІ данных Рё РЅРµ выполняется РѕР±С…РѕРґ РїРѕ схеме + if !opts.WalkSchema && v == nil { + return nil, false, nil + } + + // Выполняется РѕР±С…РѕРґ РїРѕ схеме + if opts.WalkSchema && v == nil { + params.Item.Walk(ctx, v, fn, WalkOpts(opts)) + return nil, false, nil + } + + arr, ok := v.([]interface{}) + if !ok { + return nil, false, fmt.Errorf("incorrect type: \"%s\", expected \"[]interface{}\"", reflect.ValueOf(v).Kind()) + } + + m := make([]interface{}, 0, len(arr)) + + var merr *multierror.Error + for i, value := range arr { + + valueNew, valueChanged, err := params.Item.Walk(ctx, value, fn, WalkOpts(opts)) + + if err != nil { + merr = multierror.Append(merr, errors.WithField(err, strconv.Itoa(i))) + } + + if valueChanged { + m = append(m, valueNew) + changed = true + } else { + m = append(m, value) + } + } + + if merr != nil { + merr.ErrorFormat = func(i []error) string { + return fmt.Sprintf("%d error(s)", len(i)) + } + return nil, false, merr + } + + return m, changed, nil +} + +func Array(item *Field, o ...interface{}) *Field { + return NewField(&ArrayParameters{Item: item}, o...) +} diff --git a/pkg/schema/field/array_test.go b/pkg/schema/field/array_test.go new file mode 100644 index 0000000000000000000000000000000000000000..94e60258661932dc72c2c34c27ea7cecb5c6b33d --- /dev/null +++ b/pkg/schema/field/array_test.go @@ -0,0 +1,85 @@ +package field + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArrayField_Decode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + { + "Correct", + Array(Number("float")), + []interface{}{1.0, 2.0}, + []interface{}{1.0, 2.0}, + false, + }, + { + "Incorrect type", + Array(Number("int")), + "1 2 3", + "decode error: incorrect type: \"string\", expected \"[]interface{}\"", + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.want.(string), fmt.Sprintf("Decode() error = %v, want %v", err, tt.want.(string))) + } + if !tt.wantErr { + require.NoError(t, err) + assert.ElementsMatch(t, got, tt.want, fmt.Sprintf("Decode() got = %v, want %v", got, tt.want)) + } + }) + } +} + +func TestArrayField_Encode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + { + "Correct", + Array(Number("float")), + []interface{}{1.0, 2.0}, + []interface{}{1.0, 2.0}, + false, + }, + { + "Incorrect type", + Array(Number("int")), + "1 2 3", + "encode error: incorrect type: \"string\", expected \"[]interface{}\"", + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Encode(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.want.(string), fmt.Sprintf("Decode() error = %v, want %v", err, tt.want.(string))) + } + if !tt.wantErr { + require.NoError(t, err) + assert.ElementsMatch(t, got, tt.want, fmt.Sprintf("Decode() got = %v, want %v", got, tt.want)) + } + }) + } +} diff --git a/pkg/schema/field/boolean.go b/pkg/schema/field/boolean.go new file mode 100644 index 0000000000000000000000000000000000000000..94580541f8e33f9930adad94bff6a5c18915d05b --- /dev/null +++ b/pkg/schema/field/boolean.go @@ -0,0 +1,51 @@ +package field + +import ( + "context" + "fmt" + "reflect" +) + +var boolType = &BoolType{} + +type BoolParameters struct{} + +func (b BoolParameters) Type() Type { return boolType } +func (b *BoolParameters) Clone(reset bool) Parameters { return b } + +type BoolType struct{} + +func (b BoolType) Name() string { + return "bool" +} + +func (b BoolType) NewParameters() Parameters { + return &BoolParameters{} +} +func (BoolType) IsEmpty(v interface{}) bool { + return v == nil +} + +func (b BoolType) Decode(_ context.Context, field *Field, v interface{}) (interface{}, error) { + if v == nil { + return v, nil + } + if _, ok := v.(bool); ok { + return v, nil + } + return nil, fmt.Errorf("incorrect type: \"%s\", expected \"boolean\"", reflect.ValueOf(v).Kind()) +} + +func (b BoolType) Encode(_ context.Context, field *Field, v interface{}) (interface{}, error) { + if v == nil { + return v, nil + } + if _, ok := v.(bool); ok { + return v, nil + } + return nil, fmt.Errorf("incorrect type: \"%s\", expected \"boolean\"", reflect.ValueOf(v).Kind()) +} + +func Bool(o ...interface{}) *Field { + return NewField(&BoolParameters{}, o...) +} diff --git a/pkg/schema/field/boolean_test.go b/pkg/schema/field/boolean_test.go new file mode 100644 index 0000000000000000000000000000000000000000..aaa09dcec353d7648a976759bb4fcc98d0fe0d7a --- /dev/null +++ b/pkg/schema/field/boolean_test.go @@ -0,0 +1,73 @@ +package field + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBooleanField_Decode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct data bool", Bool(), true, true, false}, + {"Correct data nil", Bool(), nil, nil, false}, + {"Wrong data int", Bool(), 1, "decode error: incorrect type: \"int\", expected \"boolean\"", true}, + {"Wrong data float", Bool(), 0.0, "decode error: incorrect type: \"float64\", expected \"boolean\"", true}, + {"Wrong data array", Bool(), [2]bool{true}, "decode error: incorrect type: \"array\", expected \"boolean\"", true}, + {"Wrong data slice", Bool(), []bool{true}, "decode error: incorrect type: \"slice\", expected \"boolean\"", true}, + {"Wrong data map", Bool(), map[bool]int{true: 1}, "decode error: incorrect type: \"map\", expected \"boolean\"", true}, + {"Wrong data string", Bool(), "2", "decode error: incorrect type: \"string\", expected \"boolean\"", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.want.(string), fmt.Sprintf("Decode() error = %v, want %v", err, tt.want.(string))) + } + if !tt.wantErr { + require.NoError(t, err) + assert.Equal(t, got, tt.want, fmt.Sprintf("Decode() got = %v, want %v", got, tt.want)) + } + }) + } +} + +func TestBooleanField_Encode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", Bool(), false, false, false}, + {"Correct data nil", Bool(), nil, nil, false}, + {"Wrong data int", Bool(), 1, "encode error: incorrect type: \"int\", expected \"boolean\"", true}, + {"Wrong data float", Bool(), 0.0, "encode error: incorrect type: \"float64\", expected \"boolean\"", true}, + {"Wrong data array", Bool(), [2]bool{true}, "encode error: incorrect type: \"array\", expected \"boolean\"", true}, + {"Wrong data slice", Bool(), []bool{true}, "encode error: incorrect type: \"slice\", expected \"boolean\"", true}, + {"Wrong data map", Bool(), map[bool]int{true: 1}, "encode error: incorrect type: \"map\", expected \"boolean\"", true}, + {"Wrong data string", Bool(), "2", "encode error: incorrect type: \"string\", expected \"boolean\"", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Encode(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.want.(string), fmt.Sprintf("Encode() error = %v, want %v", err, tt.want.(string))) + } + if !tt.wantErr { + require.NoError(t, err) + assert.Equal(t, got, tt.want, fmt.Sprintf("Encode() got = %v, want %v", got, tt.want)) + } + }) + } +} diff --git a/pkg/schema/field/encode.go b/pkg/schema/field/encode.go new file mode 100644 index 0000000000000000000000000000000000000000..d891b41228e4ff70142cff9bdf3bca8cc99506dd --- /dev/null +++ b/pkg/schema/field/encode.go @@ -0,0 +1,66 @@ +package field + +import ( + "context" + + "git.perx.ru/perxis/perxis-go/pkg/errors" +) + +type Decoder interface { + Decode(ctx context.Context, field *Field, v interface{}) (interface{}, error) +} + +type Encoder interface { + Encode(ctx context.Context, field *Field, v interface{}) (interface{}, error) +} + +func Decode(ctx context.Context, w Walker, v interface{}) (interface{}, error) { + var err error + //if ctx == nil { + // ctx = NewContext() + //} + // + //if m, ok := v.(map[string]interface{}); ok { + // ctx = ctx.ExtendEnv(m) + // ctx.DisableConditions = true + //} + + val, _, err := w.Walk(ctx, v, func(ctx context.Context, f *Field, v interface{}) (res WalkFuncResult, err error) { + if decoder, ok := f.GetType().(Decoder); ok { + if v, err = decoder.Decode(ctx, f, v); err != nil { + return + } + res.Value = v + res.Changed = true + return + } + res.Value = v + return + }) + + if err != nil { + return nil, errors.Wrap(err, "decode error") + } + + return val, nil +} + +func Encode(ctx context.Context, w Walker, v interface{}) (interface{}, error) { + val, _, err := w.Walk(ctx, v, func(ctx context.Context, f *Field, v interface{}) (res WalkFuncResult, err error) { + if encode, ok := f.GetType().(Encoder); ok { + if v, err = encode.Encode(ctx, f, v); err != nil { + return + } + res.Value = v + res.Changed = true + return + } + res.Value = v + return + }) + if err != nil { + return nil, errors.Wrap(err, "encode error") + } + return val, nil + +} diff --git a/pkg/schema/field/errors.go b/pkg/schema/field/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..8ee095285a7493cf177cd9185d7235a344c49e67 --- /dev/null +++ b/pkg/schema/field/errors.go @@ -0,0 +1,7 @@ +package field + +import "errors" + +var ( + ErrSkipOption = errors.New("option invalid, skipped") +) diff --git a/pkg/schema/field/evaluate.go b/pkg/schema/field/evaluate.go new file mode 100644 index 0000000000000000000000000000000000000000..ad273819651a434a93e810564c0de5444579035f --- /dev/null +++ b/pkg/schema/field/evaluate.go @@ -0,0 +1,49 @@ +package field + +import ( + "context" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/expr" +) + +const EvaluatePassesLimit = 10 + +func Evaluate(ctx context.Context, w Walker, v interface{}) (interface{}, error) { + var err error + + chg := true + val := v + i := 0 + + for chg { + m, _ := val.(map[string]interface{}) + + val, chg, err = w.Walk(expr.WithEnv(ctx, m), val, func(ctx context.Context, f *Field, v interface{}) (res WalkFuncResult, err error) { + enabled, _ := f.IsEnabled(ctx) + + if !enabled { + res.Stop = true + if v != nil { + res.Changed = true // Значение изменилось РЅР° пустое + } + return + } + + res.Value = v + return + }) + + if err != nil { + return nil, errors.Wrap(err, "evaluation error") + } + + i += 1 + + if i > EvaluatePassesLimit { + return nil, errors.New("fail to evaluate data conditions") + } + } + + return val, nil +} diff --git a/pkg/schema/field/field.go b/pkg/schema/field/field.go new file mode 100644 index 0000000000000000000000000000000000000000..45ddb40d128ab3f87ff7c41e3cedf75009ba3f98 --- /dev/null +++ b/pkg/schema/field/field.go @@ -0,0 +1,528 @@ +package field + +import ( + "context" + "strings" + + "git.perx.ru/perxis/perxis-go/pkg/data" + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/expr" +) + +const ( + FieldSeparator = "." + IncludeLimit = 10 +) + +type ( + Preparer interface { + Prepare(f *Field) error + } + + Fielder interface { + GetField(path string) *Field + } +) + +type Translation struct { + Locale string `json:"locale,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +type View struct { + Widget string `json:"widget,omitempty"` // Виджет для отображения поля РІ СЃРїРёСЃРєРµ + Options map[string]interface{} `json:"options,omitempty"` // Опции виджета, РЅР° усмотрения виджета +} + +type UI struct { + Widget string `json:"widget,omitempty"` // РРјСЏ виджета для отображения поля РІ пользовательском интерфейсе + Placeholder string `json:"placeholder,omitempty"` // Подсказка для заполнения значения + Options map[string]interface{} `json:"options,omitempty"` // Опции виджета для отображения + ReadView *View `json:"read_view,omitempty"` // Настройки для отображения экрана РІ режиме просмотра элемента + EditView *View `json:"edit_view,omitempty"` // Настройки для отображения экрана РІ режиме редактирования элемента + ListView *View `json:"list_view,omitempty"` // Настройки для отображения экрана РІ режиме СЃРїРёСЃРєРµ элементов +} + +type Include struct { + Ref string `json:"ref,omitempty"` + Optional bool `json:"optional,omitempty"` +} + +type Field struct { + Title string `json:"title,omitempty"` // Название поля (Например: name) + Description string `json:"description,omitempty"` // Описание поле (Например: User name) + Translations []Translation `json:"translations,omitempty"` // Переводы данных РЅР° разных языках + UI *UI `json:"ui,omitempty"` // Опции пользовательского интерфейса + Includes []Include `json:"includes,omitempty"` // РРјРїРѕСЂС‚ схем + SingleLocale bool `json:"singleLocale,omitempty"` // Без перевода + Indexed bool `json:"indexed,omitempty"` // Построить индекс для поля + Unique bool `json:"unique,omitempty"` // Значение поля должны быть уникальными + TextSearch bool `json:"text_search,omitempty"` // Значение поля доступны для полнотекстового РїРѕРёСЃРєР° + Params Parameters `json:"-"` // Параметры поля, определяет так Р¶Рµ тип поля + Options Options `json:"options,omitempty"` // Дополнительные опции + Condition string `json:"condition,omitempty"` // Условие отображения поля + AdditionalValues bool `json:"additional_values,omitempty"` // Разрешает дополнительные значения РІРЅРµ ограничений правил + + prepared bool +} + +// TODO: Replace with Named field??? +type PathField struct { + Field + Name string + Path string +} + +type NamedField struct { + *Field + Name string +} + +func NewField(params Parameters, opts ...interface{}) *Field { + f := &Field{} + f.Params = params + f.Options.Add(opts...) + return f +} + +func (f Field) GetType() Type { + return f.Params.Type() +} + +func (f *Field) AddOptions(t ...interface{}) *Field { + f.Options.Add(t...) + return f +} + +func (f Field) WithUI(ui *UI) *Field { + f.UI = ui + return &f +} + +func (f *Field) SetIncludes(includes ...interface{}) { + f.Includes = make([]Include, 0, len(includes)) + for _, i := range includes { + switch v := i.(type) { + case string: + f.Includes = append(f.Includes, Include{Ref: v}) + case Include: + f.Includes = append(f.Includes, v) + default: + panic("incorrect import type") + } + } +} + +func (f Field) WithIncludes(includes ...interface{}) *Field { + f.SetIncludes(includes...) + return &f +} + +func (f Field) GetIncludes() []string { + return f.getIncludes() +} + +func (f Field) getIncludes() []string { + res := make([]string, len(f.Includes)) + for i, inc := range f.Includes { + res[i] = inc.Ref + } + nested := f.GetNestedFields() + for _, fld := range nested { + res = append(res, fld.getIncludes()...) + } + return res +} + +func (f Field) IsIncluded(name string) bool { + return data.GlobMatch(name, f.GetIncludes()...) +} + +func (f Field) SetTitle(title string) *Field { + f.Title = title + return &f +} + +func (f Field) SetDescription(desc string) *Field { + f.Description = desc + return &f +} + +func (f Field) AddTranslation(locale, title, desc string) *Field { + for i, t := range f.Translations { + if t.Locale == locale { + f.Translations[i] = Translation{Locale: locale, Title: title, Description: desc} + return &f + } + } + + f.Translations = append(f.Translations, Translation{Locale: locale, Title: title, Description: desc}) + return &f +} + +func (f Field) SetSingleLocale(r bool) *Field { + f.SingleLocale = r + return &f +} + +func (f Field) SetIndexed(r bool) *Field { + f.Indexed = r + return &f +} + +func (f Field) SetAdditionalValues() *Field { + f.AdditionalValues = true + return &f +} + +func (f Field) SetUnique(r bool) *Field { + f.Unique = r + return &f +} + +func (f Field) SetTextSearch(r bool) *Field { + f.TextSearch = r + return &f +} + +func (f Field) SetCondition(c string) *Field { + f.Condition = c + return &f +} + +func (f *Field) MustEnabled(ctx context.Context) bool { + if enabled, err := f.IsEnabled(ctx); !enabled || err != nil { + return false + } + return true +} + +func (f *Field) IsEnabled(ctx context.Context) (bool, error) { + if f.Condition != "" { + out, err := expr.Eval(ctx, f.Condition, nil) + if err != nil { + return false, err + } + + if enabled, ok := out.(bool); ok { + return enabled, nil + } + + return false, errors.New("condition returns non-boolean value") + } + + return true, nil +} + +// Walk - выполняет РѕР±С…РѕРґ данных РїРѕ схеме Рё выполняет функцию, которая может модифицировать данные РїСЂРё необходимости +func (f *Field) Walk(ctx context.Context, v interface{}, fn WalkFunc, opt ...WalkOption) (interface{}, bool, error) { + res, err := fn(ctx, f, v) + + if err != nil { + return nil, false, err + } + + if res.Changed || res.Stop { + return res.Value, res.Changed, err + } + + if res.Context != nil { + ctx = res.Context + } + + if walker, ok := f.GetType().(FieldWalker); ok { + val, changed, err := walker.Walk(ctx, f, v, fn, NewWalkOptions(opt...)) + if err != nil { + return nil, false, err + } + return val, changed, err + } + + return v, false, nil +} + +// DEPRECATED +func (f *Field) Prepare() error { + if preparer, ok := f.GetType().(Preparer); ok { + if err := preparer.Prepare(f); err != nil { + return err + } + } + for _, o := range f.Options { + if preparer, ok := o.(Preparer); ok { + if err := preparer.Prepare(f); err != nil { + return err + } + } + } + return nil +} + +// GetField возвращает поле РїРѕ строковому пути +func (f *Field) GetField(path string) *Field { + if path == "" { + switch params := f.Params.(type) { + case *ArrayParameters: + // Возвращаем поле Item если путь указан как "arr." + return params.Item + } + return nil + } + + switch params := f.Params.(type) { + case *ObjectParameters: + pp := strings.SplitN(path, FieldSeparator, 2) + + for k, v := range params.Fields { + + p, ok := v.Params.(*ObjectParameters) + if ok && p.Inline { + f := v.GetField(path) + if f != nil { + return f + } + } + + if k == pp[0] { + if len(pp) == 1 { + return v + } + return v.GetField(pp[1]) + } + } + case Fielder: + return params.GetField(path) + + case *ArrayParameters: + return params.Item.GetField(path) + } + + return nil +} + +// GetFieldsPath возвращает полный путь для массива полей +func GetFieldsPath(flds []PathField) (res []string) { + for _, f := range flds { + res = append(res, f.Path) + } + return res +} + +type FilterFunc func(*Field, string) bool + +func GetAll(field *Field, path string) bool { return true } + +func (f *Field) GetFields(filterFunc FilterFunc, pathPrefix ...string) (res []PathField) { + var path string + + if len(pathPrefix) > 0 { + path = pathPrefix[0] + } + + // добавление корневого объекта для чего-то РЅСѓР¶РЅРѕ? + if path != "" && filterFunc(f, path) { + res = append(res, PathField{ + Field: *f, + Path: path, + }) + } + + switch params := f.Params.(type) { + case *ObjectParameters: + res = append(res, getFieldsObject(path, params, filterFunc, false)...) + case *ArrayParameters: + res = append(res, getFieldsArray(path, params, filterFunc)...) + } + + //if len(pathPrefix) > 0 { + // for _, r := range res { + // r.Path = strings.Join([]string{pathPrefix[0], r.Path}, FieldSeparator) + // } + //} + + return res +} + +func getFieldsArray(path string, params *ArrayParameters, filterFunc FilterFunc) (res []PathField) { + + switch params := params.Item.Params.(type) { + case *ObjectParameters: + res = append(res, getFieldsObject(path, params, filterFunc, params.Inline)...) + + case *ArrayParameters: + res = append(res, getFieldsArray(path, params, filterFunc)...) + } + + return res +} + +func getFieldsObject(path string, params *ObjectParameters, filterFunc FilterFunc, ignoreInline bool) (res []PathField) { + for k, v := range params.Fields { + if v == nil { + continue + } + + var newPath string + lastIdx := strings.LastIndex(path, ".") + + if path == "" || !ignoreInline && params.Inline && lastIdx < 0 { + newPath = k + } else { + if !params.Inline || ignoreInline { + newPath = strings.Join([]string{path, k}, FieldSeparator) + } else { + newPath = strings.Join([]string{path[:lastIdx], k}, FieldSeparator) + } + } + + if flds := v.GetFields(filterFunc, newPath); len(flds) > 0 { + res = append(res, flds...) + } + } + + return res +} + +func (f *Field) GetNestedFields() []*Field { + switch params := f.Params.(type) { + case *ObjectParameters: + flds := make([]*Field, 0, len(params.Fields)) + for _, v := range params.Fields { + if v == nil { + continue + } + flds = append(flds, v) + } + return flds + case *ArrayParameters: + return []*Field{params.Item} + } + + return nil +} + +// Clone создает РєРѕРїРёСЋ поля +// Параметр reset указывает необходимо ли отвязать параметры поля РѕС‚ вложенных полей +func (f Field) Clone(reset bool) *Field { + if f.UI != nil { + ui := *f.UI + f.UI = &ui + } + + if len(f.Translations) > 0 { + f.Translations = append(make([]Translation, 0, len(f.Translations)), f.Translations...) + } + + if f.Options != nil { + opts := make(Options) + for k, v := range f.Options { + opts[k] = v + } + f.Options = opts + } + + if f.Params != nil { + f.Params = f.Params.Clone(reset) + } + + return &f +} + +func (f *Field) mergeField(fld *Field) error { + if f.Title == "" { + f.Title = fld.Title + } + + if f.Description == "" { + f.Description = fld.Description + } + + if len(f.Translations) == 0 { + f.Translations = fld.Translations + } + + if f.UI == nil { + f.UI = fld.UI + } + + if len(f.Includes) > 0 { + f.Includes = fld.Includes + } + + if f.Params == nil { + f.Params = fld.Params + } else if fld.Params != nil { + type Merger interface { + Merge(parameters Parameters) error + } + + if merger, ok := f.Params.(Merger); ok { + if err := merger.Merge(fld.Params); err != nil { + return err + } + } + } + + if f.Options == nil { + f.Options = fld.Options + } + + if f.Condition == "" { + f.Condition = fld.Condition + } + + return nil +} + +func (f *Field) Merge(fields ...*Field) error { + for _, fld := range fields { + f.mergeField(fld) + } + return nil +} + +func (f *Field) loadIncludes(ctx context.Context, loader Loader, depth int) error { + if depth > IncludeLimit { + return errors.New("limit for included fields exceeded") + } + + for _, i := range f.Includes { + if loader == nil { + panic("schema loader not set") + } + importedField, err := loader.Load(ctx, i.Ref) + if err != nil { + if i.Optional { + continue + } + return err + } + + for _, fld := range importedField { + depth += 1 + if err := fld.loadIncludes(ctx, loader, depth); err != nil { + return err + } + } + + if err = f.Merge(importedField...); err != nil { + return err + } + } + for _, i := range f.GetNestedFields() { + if err := i.loadIncludes(ctx, loader, depth); err != nil { + return err + } + } + return nil +} + +func (f *Field) LoadIncludes(ctx context.Context, loader Loader) error { + return f.loadIncludes(ctx, loader, 0) +} + +func (f *Field) LoadRef(ctx context.Context, ref string, loader Loader) error { + f.SetIncludes(ref) + return f.LoadIncludes(ctx, loader) +} diff --git a/pkg/schema/field/field_json.go b/pkg/schema/field/field_json.go new file mode 100644 index 0000000000000000000000000000000000000000..dfb212044838d66da90436b127594427d3f842da --- /dev/null +++ b/pkg/schema/field/field_json.go @@ -0,0 +1,86 @@ +package field + +import ( + "encoding/json" + + jsoniter "github.com/json-iterator/go" + "github.com/pkg/errors" +) + +type FieldData Field + +type jsonField struct { + FieldData + Type string `json:"type"` + Params json.RawMessage `json:"params,omitempty"` +} + +type ParametersConverter interface { + ConvertParameters(p Parameters) (Parameters, error) +} + +type ParametersValidator interface { + ValidateParameters(p Parameters) error +} + +func (f *Field) UnmarshalJSON(b []byte) error { + var j jsonField + if err := jsoniter.Unmarshal(b, &j); err != nil { + return errors.Wrapf(err, "error unmarshal json into field") + } + + typ, ok := GetType(j.Type) + + jsonParams := j.Params + + // Unknown type, создаем поля Unknown которое будет хранить оригинальные параметры + if !ok { + *f = Field(j.FieldData) + f.Params = &UnknownParameters{Typ: j.Type, Params: jsonParams} + return nil + } + + params := typ.NewParameters() + if len(j.Params) > 0 { + if err := jsoniter.Unmarshal(jsonParams, params); err != nil { + return errors.Wrapf(err, "error unmarshal json into field type %s", typ.Name()) + } + } + + if converter, ok := typ.(ParametersConverter); ok { + var err error + if params, err = converter.ConvertParameters(params); err != nil { + return errors.Wrap(err, "error unmarshal json") + } + } + + if validator, ok := typ.(ParametersValidator); ok { + var err error + if err = validator.ValidateParameters(params); err != nil { + return errors.Wrap(err, "error validate json") + } + } + + *f = Field(j.FieldData) + f.Params = params + f.Prepare() + return nil +} + +func (f *Field) MarshalJSON() ([]byte, error) { + j := jsonField{ + FieldData: FieldData(*f), + } + + j.Type = f.GetType().Name() + + if f.Params != nil { + b, err := jsoniter.Marshal(f.Params) + if err != nil { + return nil, err + } + j.Params = b + } + + return jsoniter.Marshal(&j) +} diff --git a/pkg/schema/field/init.go b/pkg/schema/field/init.go new file mode 100644 index 0000000000000000000000000000000000000000..45c4d11c1ecdc51927109cb3202704e093c2d53d --- /dev/null +++ b/pkg/schema/field/init.go @@ -0,0 +1,13 @@ +package field + +func init() { + Register(boolType) + Register(stringType) + Register(numberType) + Register(arrayType) + Register(timeType) + Register(objectType) + Register(unknownType) + Register(locationType) + Register(primaryKeyType) +} diff --git a/pkg/schema/field/loader.go b/pkg/schema/field/loader.go new file mode 100644 index 0000000000000000000000000000000000000000..583ecc2c7c6bdc0d278819d2327280ec87858117 --- /dev/null +++ b/pkg/schema/field/loader.go @@ -0,0 +1,34 @@ +package field + +import ( + "context" + "fmt" +) + +// Loader интерфейс загрузчика схем +type Loader interface { + Load(ctx context.Context, ref string) (fs []*Field, err error) +} + +type LoaderFunc func(ref string) (fs []*Field, err error) + +func (f LoaderFunc) Load(ctx context.Context, ref string) (fs []*Field, err error) { + return f(ref) +} + +type multiLoader struct { + loaders []Loader +} + +func (c *multiLoader) Load(ctx context.Context, ref string) (fs []*Field, err error) { + for _, l := range c.loaders { + if f, err := l.Load(nil, ref); err == nil { + return f, nil + } + } + return nil, fmt.Errorf("invalid schema reference: %s", ref) +} + +func MultiLoader(loaders ...Loader) Loader { + return &multiLoader{loaders: loaders} +} diff --git a/pkg/schema/field/location.go b/pkg/schema/field/location.go new file mode 100644 index 0000000000000000000000000000000000000000..eb6e175552a2504b667658c8b227bc3b7ff661b8 --- /dev/null +++ b/pkg/schema/field/location.go @@ -0,0 +1,137 @@ +package field + +import ( + "context" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "github.com/mitchellh/mapstructure" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.mongodb.org/mongo-driver/x/bsonx" +) + +var locationType = &LocationType{} + +type LocationParameters struct{} + +func (p LocationParameters) Type() Type { return locationType } +func (p LocationParameters) Clone(reset bool) Parameters { return &LocationParameters{} } + +func (p LocationParameters) GetMongoIndexes(path string, f *Field) []mongo.IndexModel { + var add, geo mongo.IndexModel + a := path + ".address" + g := path + ".geometry" + add.Options = options.Index().SetName(a) + //add.Options.SetSparse(true) + add.Options.SetPartialFilterExpression(bson.M{a: bson.M{"$exists": true}}) + geo.Options = options.Index().SetName(g) + if f.Unique { + add.Options.SetUnique(true) + geo.Options.SetUnique(true) + } + + if f.Indexed { + add.Keys = bsonx.Doc{{Key: a, Value: bsonx.Int32(1)}} + geo.Keys = bsonx.Doc{{Key: g, Value: bsonx.String("2dsphere")}} + } + return []mongo.IndexModel{add, geo} +} + +type LocationType struct{} + +type GeoJSON struct { + Type string `json:"type" bson:"type" mapstructure:"type,omitempty"` + Coordinates []float64 `json:"coordinates" bson:"coordinates" mapstructure:"coordinates"` +} + +type GeoObject struct { + Address string `json:"address,omitempty" bson:"address" mapstructure:"address,omitempty"` + Geometry *GeoJSON `json:"geometry,omitempty" bson:"geometry" mapstructure:"geometry,omitempty"` +} + +func (LocationType) Name() string { + return "location" +} + +func (LocationType) NewParameters() Parameters { + return &LocationParameters{} +} + +func (LocationType) IsEmpty(v interface{}) bool { + loc, _ := v.(*GeoObject) + return loc == nil || loc.Address != "" && loc.Geometry != nil +} + +func (LocationType) Decode(_ context.Context, _ *Field, v interface{}) (interface{}, error) { + + if v == nil { + return nil, nil + } + + var g GeoObject + if err := mapstructure.Decode(v, &g); err != nil { + return nil, err + } + + if g.Address == "" && g.Geometry == nil { + return nil, errors.New("address or coordinates required") + } + + if g.Geometry != nil { + if len(g.Geometry.Coordinates) != 2 { + return nil, errors.New("latitude and longitude required") + } + + lat := g.Geometry.Coordinates[0] + lon := g.Geometry.Coordinates[1] + + if lat < -180 || lat > 180 { + return nil, errors.New("invalid longitude values, valid are between -180 and 180") + } + + if lon < -90 || lon > 90 { + return nil, errors.New("invalid latitude values, valid are between -90 and 90") + } + + if g.Geometry.Type != "Point" { + g.Geometry.Type = "Point" + } + } + + return &g, nil +} + +func (LocationType) Encode(_ context.Context, _ *Field, v interface{}) (interface{}, error) { + + if v == nil { + return nil, nil + } + + g, ok := v.(*GeoObject) + if !ok { + return nil, errors.New("couldn't encode GeoObject") + } + + res := make(map[string]interface{}) + if g.Address != "" { + res["address"] = g.Address + } + + if g.Geometry != nil { + if len(g.Geometry.Coordinates) != 2 { + return nil, errors.New("latitude and longitude required") + } + + lat := g.Geometry.Coordinates[0] + lon := g.Geometry.Coordinates[1] + + res["geometry"] = map[string]interface{}{"type": g.Geometry.Type, "coordinates": []interface{}{lat, lon}} + } + + return res, nil +} + +func Location(o ...interface{}) *Field { + return NewField(&LocationParameters{}, o...) +} diff --git a/pkg/schema/field/location_test.go b/pkg/schema/field/location_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5b8206b6128ae7f4bf8f4042cedc0540bf357992 --- /dev/null +++ b/pkg/schema/field/location_test.go @@ -0,0 +1,209 @@ +package field + +import ( + "reflect" + "testing" +) + +func TestLocationField_Decode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", + Location(), + map[string]interface{}{ + "address": "msk", + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{55.7042351, 37.6152822}}, + }, + &GeoObject{"msk", &GeoJSON{"Point", []float64{55.7042351, 37.6152822}}}, + false}, + {"Correct", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{55.7042351, 37.6152822}}, + }, + &GeoObject{Geometry: &GeoJSON{"Point", []float64{55.7042351, 37.6152822}}}, + false}, + {"Correct", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []interface{}{55.7042351, 37.6152822}}, + }, + &GeoObject{Geometry: &GeoJSON{"Point", []float64{55.7042351, 37.6152822}}}, + false}, + {"Correct", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []float64{55.7042351, 37.6152822}}, + }, + &GeoObject{Geometry: &GeoJSON{"Point", []float64{55.7042351, 37.6152822}}}, + false}, + {"Correct", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []interface{}{55, 37}}, + }, + &GeoObject{Geometry: &GeoJSON{"Point", []float64{55, 37}}}, + false}, + {"Correct", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []interface{}{180, 90}}, + }, + &GeoObject{Geometry: &GeoJSON{"Point", []float64{180, 90}}}, + false}, + {"Correct", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []interface{}{-180, -90}}, + }, + &GeoObject{Geometry: &GeoJSON{"Point", []float64{-180, -90}}}, + false}, + {"Correct", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []int{55, 37}}, + }, + &GeoObject{Geometry: &GeoJSON{"Point", []float64{55, 37}}}, + false}, + {"Correct", + Location(), + map[string]interface{}{ + "address": "msk", + }, + &GeoObject{Address: "msk"}, + false}, + {"Correct", Location(), nil, nil, false}, + + {"Wrong data", Location(), "", nil, true}, + {"Wrong data", Location(), []interface{}{"55.7042351", "37.6152822"}, nil, true}, + {"Wrong data", Location(), map[string]interface{}{"type": "Point", "coordinates": [][]interface{}{{55.7042351, 37.6152822}}}, nil, true}, + {"Wrong data", Location(), []interface{}{55.7042351, 37.6152822, 1.0}, nil, true}, + {"Wrong data", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []int{55, 37, 67}}, + }, + nil, + true}, + {"Wrong data", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []interface{}{180}}, + }, + nil, + true}, + {"Wrong data", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []interface{}{-180, -90.1}}, + }, + nil, + true}, + {"Wrong data", + Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"coordinates": []interface{}{180.1, 90.1}}, + }, + nil, + true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLocationField_Encode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", Location(), + &GeoObject{Address: "msk", Geometry: &GeoJSON{"Point", []float64{55.7042351, 37.6152822}}}, + map[string]interface{}{ + "address": "msk", + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{55.7042351, 37.6152822}}}, + false}, + {"Correct", Location(), + &GeoObject{Geometry: &GeoJSON{"Point", []float64{55.7042351, 37.6152822}}}, + map[string]interface{}{ + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{55.7042351, 37.6152822}}}, + false}, + {"Correct", Location(), + &GeoObject{Address: "msk"}, + map[string]interface{}{ + "address": "msk"}, + false}, + {"Correct", Location(), + &GeoObject{Geometry: &GeoJSON{"Point", []float64{55, 37}}}, + map[string]interface{}{ + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{55.0, 37.0}}}, + false}, + {"Correct", Location(), + &GeoObject{Geometry: &GeoJSON{"Point", []float64{180, 90}}}, + map[string]interface{}{ + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{180.0, 90.0}}}, + false}, + {"Correct", Location(), + &GeoObject{Geometry: &GeoJSON{"Point", []float64{-180, -90}}}, + map[string]interface{}{ + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{-180.0, -90.0}}}, + false}, + {"Correct", Location(), nil, nil, false}, + {"Correct", Location(), + &GeoObject{}, + map[string]interface{}{}, + false}, + + {"Wrong data", Location(), "", nil, true}, + {"Wrong data", Location(), []interface{}{55.7042351, 37.6152822}, nil, true}, + {"Wrong data", Location(), + map[string]interface{}{ + "address": "msk", + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{55.7042351, 37.6152822}}}, + nil, + true}, + {"Wrong data", Location(), + map[string]interface{}{ + "geometry": map[string]interface{}{"type": "Point", "coordinates": []interface{}{55.7042351, 37.6152822}}}, + nil, + true}, + {"Wrong data", Location(), + map[string]interface{}{ + "address": "msk"}, + nil, + true}, + {"Wrong data", Location(), &GeoJSON{}, nil, true}, + {"Wrong data", Location(), &GeoJSON{Coordinates: []float64{55.7042351, 37.6152822}}, nil, true}, + {"Wrong data", Location(), &GeoObject{Geometry: &GeoJSON{"Point", []float64{-180, -90, 50}}}, nil, true}, + {"Wrong data", Location(), &GeoObject{Geometry: &GeoJSON{"Point", []float64{-180}}}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Encode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Encode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Encode() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/schema/field/number.go b/pkg/schema/field/number.go new file mode 100644 index 0000000000000000000000000000000000000000..502a2fef7c406b4b4dc96aad34efce2e518a1e28 --- /dev/null +++ b/pkg/schema/field/number.go @@ -0,0 +1,112 @@ +package field + +import ( + "context" + "math" + + "github.com/pkg/errors" +) + +const ( + NumberFormatInt = "int" + NumberFormatFloat = "float" +) + +var numberType = &NumberType{} + +type NumberParameters struct { + Format string `json:"format,omitempty"` +} + +func (NumberParameters) Type() Type { return numberType } +func (p NumberParameters) Clone(reset bool) Parameters { return &p } + +type NumberType struct{} + +func (NumberType) Name() string { + return "number" +} + +func (NumberType) NewParameters() Parameters { + return &NumberParameters{} +} + +func (NumberType) IsEmpty(v interface{}) bool { + return v == nil +} + +func ToNumber(i interface{}) (interface{}, error) { + switch v := i.(type) { + case int64: + return v, nil + case int: + return int64(v), nil + case int8: + return int64(v), nil + case int32: + return int64(v), nil + case uint64: + return v, nil + case uint: + return uint64(v), nil + case uint8: + return uint64(v), nil + case uint32: + return uint64(v), nil + case float32: + return float64(v), nil + case float64: + return v, nil + } + return 0, errors.Errorf("error convert \"%s\" to number", i) +} + +func (n NumberType) Decode(ctx context.Context, field *Field, v interface{}) (interface{}, error) { + return n.decode(ctx, field, v) +} + +func (NumberType) decode(_ context.Context, field *Field, v interface{}) (interface{}, error) { + params, ok := field.Params.(*NumberParameters) + if !ok { + return nil, errors.New("field parameters required") + } + + if v == nil { + return v, nil + } + + n, err := ToNumber(v) + if err != nil { + return nil, err + } + + switch params.Format { + case NumberFormatInt: + switch i := n.(type) { + case int64: + return i, nil + case uint64: + return i, nil + case float64: + return int64(math.Round(i)), nil + } + case NumberFormatFloat: + switch i := n.(type) { + case float64: + return i, nil + case int64: + return float64(i), nil + case uint64: + return float64(i), nil + } + } + return n, nil +} + +func (n NumberType) Encode(ctx context.Context, field *Field, v interface{}) (interface{}, error) { + return n.decode(ctx, field, v) +} + +func Number(format string, o ...interface{}) *Field { + return NewField(&NumberParameters{Format: format}, o...) +} diff --git a/pkg/schema/field/number_test.go b/pkg/schema/field/number_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e731793d12135127dbfa67d949572f52708075a6 --- /dev/null +++ b/pkg/schema/field/number_test.go @@ -0,0 +1,88 @@ +package field + +import ( + "reflect" + "testing" +) + +func TestNumberField_Decode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", Number("int"), int64(2), int64(2), false}, // #0 + {"Correct", Number("int"), 2.2, int64(2), false}, // #1 + {"Correct", Number("int"), 2, int64(2), false}, // #2 + {"Correct", Number("int"), float32(2.2), int64(2), false}, // #3 + {"Correct", Number("int"), float64(2.6), int64(3), false}, // #4 + {"Correct", Number("int"), 2.6, int64(3), false}, // #5 + + {"Correct", Number("float"), int8(2), 2.0, false}, // #6 + {"Correct", Number("float"), 2.2, 2.2, false}, // #7 + {"Correct", Number("float"), 2, 2.0, false}, // #8 + {"Correct", Number("float"), float32(2.2), 2.200000047683716, false}, // #9 + {"Correct", Number("float"), int64(2), 2.0, false}, // #10 + + {"Wrong data", Number("int"), "", nil, true}, // #0 + {"Wrong data", Number("int"), []byte(""), nil, true}, // #1 + + {"Wrong data", Number("float"), "", nil, true}, // #2 + {"Wrong data", Number("float"), []byte(""), nil, true}, // #3 + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNumberField_Encode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", Number("int"), int64(2), int64(2), false}, // #0 + {"Correct", Number("int"), 2.2, int64(2), false}, // #1 + {"Correct", Number("int"), 2, int64(2), false}, // #2 + {"Correct", Number("int"), float32(2.2), int64(2), false}, // #3 + {"Correct", Number("int"), float32(2.6), int64(3), false}, // #4 + {"Correct", Number("int"), 2.6, int64(3), false}, // #5 + + {"Correct", Number("float"), int8(2), 2.0, false}, // #6 + {"Correct", Number("float"), 2.2, 2.2, false}, // #7 + {"Correct", Number("float"), 2, 2.0, false}, // #8 + {"Correct", Number("float"), float32(2.2), 2.200000047683716, false}, // #9 + {"Correct", Number("float"), int64(2), 2.0, false}, // #10 + + {"Wrong data", Number("int"), "", nil, true}, // #0 + {"Wrong data", Number("int"), []byte(""), nil, true}, // #1 + + {"Wrong data", Number("float"), "", nil, true}, // #2 + {"Wrong data", Number("float"), []byte(""), nil, true}, // #3 + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Encode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/schema/field/object.go b/pkg/schema/field/object.go new file mode 100644 index 0000000000000000000000000000000000000000..d86aa2352544f68ea837152ec6799677f4624d1a --- /dev/null +++ b/pkg/schema/field/object.go @@ -0,0 +1,297 @@ +package field + +import ( + "context" + "fmt" + "reflect" + "regexp" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/expr" + "github.com/hashicorp/go-multierror" +) + +var objectType = &ObjectType{} +var isValidName = regexp.MustCompile(`^[a-zA-Z][\w]*$`).MatchString + +type ObjectParameters struct { + Inline bool `json:"inline"` + Fields map[string]*Field `json:"fields"` +} + +func (ObjectParameters) Type() Type { return objectType } + +func (p ObjectParameters) Clone(reset bool) Parameters { + if reset { + p.Fields = nil + return &p + } + + flds := make(map[string]*Field) + for k, v := range p.Fields { + flds[k] = v.Clone(reset) + } + + p.Fields = flds + return &p +} + +// IsInlineObject определяет являться ли поле name инлайн объектом +func (p ObjectParameters) IsInlineObject(name string) bool { + fld, ok := p.Fields[name] + if !ok { + return false + } + + if fldParams, ok := fld.Params.(*ObjectParameters); ok && fldParams.Inline { + return true + } + + return false +} + +// GetFields возвращает поля объекта. +// Указание withInline позволяет так Р¶Рµ включить поля указанные РІРѕ вложенных inline объектам, Рё получиться поля для +// всех данных относящихся Рє текущему объекту. +func (p ObjectParameters) GetFields(withInline bool) map[string]*Field { + fields := make(map[string]*Field) + p.getFields(withInline, fields) + return fields +} + +func (p ObjectParameters) getFields(withInline bool, fields map[string]*Field) { + for k, f := range p.Fields { + if obj, ok := f.Params.(*ObjectParameters); ok && obj.Inline { + obj.getFields(withInline, fields) + continue + } + fields[k] = f + } +} + +func (p *ObjectParameters) Merge(parameters Parameters) error { + op, ok := parameters.(*ObjectParameters) + if !ok { + return errors.New("invalid object parameters") + } + for k, fld := range op.Fields { + if f, ok := p.Fields[k]; ok { + if err := f.Merge(fld); err != nil { + return err + } + } else { + p.Fields[k] = fld + } + } + return nil +} + +type ObjectType struct{} + +func (ObjectType) Name() string { + return "object" +} + +func (ObjectType) NewParameters() Parameters { + return &ObjectParameters{} +} + +func (ObjectType) IsEmpty(v interface{}) bool { + m := reflect.ValueOf(v) + return m.IsNil() || m.Len() == 0 +} + +type fieldNameCtx struct{} + +var FieldName = fieldNameCtx{} + +func (ObjectType) Walk(ctx context.Context, field *Field, v interface{}, fn WalkFunc, opts *WalkOptions) (interface{}, bool, error) { + params, ok := field.Params.(*ObjectParameters) + if !ok { + return nil, false, errors.New("field parameters required") + } + + // Объекта нет РІ данных, спускаться Рє полям РјС‹ РЅРµ будем + // Если необходимо что Р±С‹ выполнялся Walk РїРѕ полям необходимо передать пустой объект + // Если РЅСѓР¶РЅРѕ что Р±С‹ всегда объект был, это РјРѕР¶РЅРѕ сделать через Default + if !opts.WalkSchema && v == nil { + return nil, false, nil + } + + m := reflect.ValueOf(v) + + if m.IsValid() { + if m.Kind() != reflect.Map { + return nil, false, errors.Errorf("incorrect type: \"%s\", expected \"map\"", m.Kind()) + } + } + + if !opts.WalkSchema && m.IsNil() { + return nil, false, nil + } + + // Добавляем Рє переменным уровень объекта + ctx = expr.WithEnvKV(ctx, "_", v) + + mapNew := make(map[string]interface{}) + + var merr *multierror.Error + var changed bool + for name, fld := range params.Fields { + ctxField := context.WithValue(ctx, FieldName, name) + + // Если поле является Inline-объектом, то передаются данные текущего объекта + if p, ok := fld.Params.(*ObjectParameters); ok && p.Inline { + valueNew, valueChanged, err := fld.Walk(ctxField, v, fn, WalkOpts(opts)) + + if err != nil { + merr = multierror.Append(merr, errors.WithField(err, name)) + } + + // Значение было изменено Рё РѕРЅРѕ РЅРµ пустое (Inline объект РЅРµ активен) + if valueChanged && valueNew != nil { + changed = true + } + + if valueNew != nil { + for n, v := range valueNew.(map[string]interface{}) { + mapNew[n] = v + } + } + } else { + // Если значение нет, РјС‹ используем nil + var value interface{} + if m.IsValid() && !m.IsZero() && !m.IsNil() { + fieldValue := m.MapIndex(reflect.ValueOf(name)) + if fieldValue.IsValid() { + value = fieldValue.Interface() + } + } + + valueNew, valueChanged, err := fld.Walk(ctxField, value, fn, WalkOpts(opts)) + + if err != nil { + merr = multierror.Append(merr, errors.WithField(err, name)) + } + + // Если значение было изменено РјС‹ заменяем его РЅР° РЅРѕРІРѕРµ + if valueChanged { + changed = true + value = valueNew + } + + // Если значение РЅРµ пустое, РјС‹ записываем поле РІ результат + if value != nil { + mapNew[name] = value + } + + } + } + + if merr != nil { + //merr.ErrorFormat = func(i []error) string { + // return fmt.Sprintf("%d error(s)", len(i)) + //} + return nil, false, merr + } + + if v == nil || !m.IsValid() || m.IsZero() || m.IsNil() { + return nil, false, nil + } + + // Проверяем изменилось ли количество полей объекта. + // Inline-объект игнорирует изменение количества полей, так как получает так Р¶Рµ поля родительского объекта. + if !changed && !params.Inline { + changed = m.Len() != len(mapNew) + } + + // Объект всегда возвращает новый модифицированный результат + return mapNew, changed, nil +} + +func (ObjectType) ValidateParameters(p Parameters) error { + params, ok := p.(*ObjectParameters) + if !ok { + return nil + } + + if len(params.Fields) > 0 { + for k := range params.Fields { + if !isValidName(k) { + return fmt.Errorf("field name '%s' must be in Latin, must not start with a number, "+ + "must not contain spaces - only characters '_' can be used", k) + } + } + } + return nil +} + +func Object(kv ...interface{}) *Field { + params := &ObjectParameters{Fields: make(map[string]*Field)} + + if len(kv) > 0 { + inline, ok := kv[0].(bool) + if ok { + params.Inline = inline + kv = kv[1:] + } + } + + var order []interface{} + i := 0 + for { + if i+2 > len(kv) { + break + } + k, v := kv[i], kv[i+1] + name, kOk := k.(string) + field, vOk := v.(*Field) + if !kOk || !vOk { + break + } + + params.Fields[name] = field + order = append(order, name) + + err := objectType.ValidateParameters(params) + if err != nil { + panic(err.Error()) + } + + i += 2 + } + + fld := NewField(params, kv[i:]...) + if len(order) > 0 { + fld = fld.WithUI(&UI{ + Options: map[string]interface{}{"fields": order}, + }) + } + + return fld +} + +func AddField(field *Field, name string, fld *Field) error { + switch params := field.Params.(type) { + case *ObjectParameters: + if params.Fields == nil { + params.Fields = make(map[string]*Field) + } + params.Fields[name] = fld + case *ArrayParameters: + params.Item = fld + default: + return errors.New("AddField not supported") + } + + return nil +} + +func RemoveAllFields(obj *Field) error { + params, ok := obj.Params.(*ObjectParameters) + if !ok { + return errors.New("obj is not an object") + } + params.Fields = make(map[string]*Field) + return nil +} diff --git a/pkg/schema/field/object_test.go b/pkg/schema/field/object_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2ada594bcdeba09fab5dfcc95eb947e2028766d9 --- /dev/null +++ b/pkg/schema/field/object_test.go @@ -0,0 +1,232 @@ +package field + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObjectField_Decode(t *testing.T) { + w, _ := time.Parse(time.RFC3339, "2012-11-01T22:08:41Z") + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + { + "Correct", + Object("name", String(), "date", Time(), "bool", Bool()), + map[string]interface{}{"name": "string", "date": "2012-11-01T22:08:41Z", "bool": true}, + map[string]interface{}{"name": "string", "date": w, "bool": true}, + false, + }, + { + "Remove undefined fields", + Object("name", String(), "date", Time(), "bool", Bool()), + map[string]interface{}{"name": "string", "date": "2012-11-01T22:08:41Z", "bool": true, "extra": "string"}, + map[string]interface{}{"name": "string", "date": w, "bool": true}, + false, + }, + { + "Empty data", + Object("name", String(), "date", Time(), "bool", Bool()), + map[string]interface{}{}, + map[string]interface{}{}, + false, + }, + { + "Nil data", + Object("name", String(), "date", Time(), "bool", Bool()), + nil, + nil, + false, + }, + { + "Incorrect field", + Object("name", String(), "date", Time(), "bool", Bool()), + map[string]interface{}{"name": "string", "date": "2012-11-01"}, + "decode error: 1 error occurred:\n\t* field 'date': TimeType: decode error parsing time \"2012-11-01\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"\" as \"T\"\n\n", + true, + }, + { + "Incorrect type#1", + Object("name", String(), "date", Time(), "bool", Bool()), + []interface{}{"name", "string", "date", "2012-11-01"}, + "decode error: incorrect type: \"slice\", expected \"map\"", + true, + }, + { + "Incorrect type#2", + Object("name", String(), "date", Time(), "bool", Bool()), + "", + "decode error: incorrect type: \"string\", expected \"map\"", + true, + }, + { + "Incorrect type#3", + Object("name", String(), "date", Time(), "bool", Bool()), + "some", + "decode error: incorrect type: \"string\", expected \"map\"", + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.want.(string), fmt.Sprintf("Decode() error = %v, want %v", err, tt.want.(string))) + } + if !tt.wantErr { + require.NoError(t, err) + assert.Equal(t, got, tt.want, fmt.Sprintf("Decode() got = %v, want %v", got, tt.want)) + } + }) + } +} + +func TestObjectField_Encode(t *testing.T) { + w, _ := time.Parse(time.RFC3339, "2012-11-01T22:08:41Z") + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + { + "Correct", + Object("name", String(), "date", Time(), "bool", Bool()), + + map[string]interface{}{"name": "string", "date": w, "bool": true}, + map[string]interface{}{"bool": true, "name": "string", "date": "2012-11-01T22:08:41Z"}, + false, + }, + { + "Additional properties", + Object("name", String(), "date", Time(), "bool", Bool()), + map[string]interface{}{"name": "string", "date": w, "extra": "string", "bool": true}, + map[string]interface{}{"bool": true, "name": "string", "date": "2012-11-01T22:08:41Z"}, + false, + }, + { + "Empty data", + Object("name", String(), "date", Time(), "bool", Bool()), + map[string]interface{}{}, + map[string]interface{}{}, + false, + }, + { + "Nil data", + Object("name", String(), "date", Time(), "bool", Bool()), + nil, + nil, + false, + }, + { + "Incorrect type#1", + Object("name", String(), "date", Time(), "bool", Bool()), + []interface{}{}, + "encode error: incorrect type: \"slice\", expected \"map\"", + true, + }, + { + "Incorrect type#2", + Object("name", String(), "date", Time(), "bool", Bool()), + "", + "encode error: incorrect type: \"string\", expected \"map\"", + true, + }, + { + "Incorrect type#3", + Object("name", String(), "date", Time(), "bool", Bool()), + "some", + "encode error: incorrect type: \"string\", expected \"map\"", + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Encode(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.want.(string), fmt.Sprintf("Encode() error = %v, want %v", err, tt.want.(string))) + } + if !tt.wantErr { + require.NoError(t, err) + assert.Equal(t, got, tt.want, fmt.Sprintf("Encode() got = %v, want %v", got, tt.want)) + } + }) + } +} + +func TestFieldNameValidate(t *testing.T) { + tests := []struct { + testName string + fieldName string + wantErr bool + }{ + { + "Correct field name", + "name", + false, + }, + { + "Not Latin", + "название", + true, + }, + { + "Start with a number", + "1name", + true, + }, + { + "Contains space", + "field name", + true, + }, + { + "Contains symbols", + "name!", + true, + }, + { + "Contains hyphen", + "field-name", + true, + }, + { + "Contains underscore (success)", + "field_name", + false, + }, + { + "Start with a capital letter (success)", + "Name", + false, + }, + { + "Contain a capital letter (success)", + "fieldName", + false, + }, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + params := &ObjectParameters{Fields: map[string]*Field{tt.fieldName: String()}} + err := objectType.ValidateParameters(params) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/schema/field/options.go b/pkg/schema/field/options.go new file mode 100644 index 0000000000000000000000000000000000000000..c0c6e4e14e4a0a9073dffe91031dbe3d3ceb259f --- /dev/null +++ b/pkg/schema/field/options.go @@ -0,0 +1,175 @@ +package field + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "sync" +) + +type Option interface { + Transform(f *Field, v interface{}) (interface{}, error) +} + +type PriorityOption interface { + GetPriority() int +} + +type NamedOption interface { + GetName() string +} + +type OptionValidator interface { + ValidateOption() error +} + +//type jsonTransform struct { +// Name string `json:"name"` +// Options json.RawMessage `json:"options,omitempty"` +//} +// +//func (t *Option) MarshalJSON() ([]byte, error) { +// b, err := json.Marshal(t.Transformation) +// if err != nil { +// return nil, err +// } +// +// j := jsonTransform{Name: GetOptionName(t.Transformation), Options: b} +// +// return json.Marshal(&j) +//} +// +//func (t *Option) UnmarshalJSON(b []byte) error { +// var j jsonTransform +// if err := json.Unmarshal(b, &j); err != nil { +// return err +// } +// +// i, ok := nameToOption.Load(j.Name) +// if !ok { +// return fmt.Errorf("unknown transformer name \"%s\"", j.Name) +// } +// typ := i.(reflect.Type) +// val := reflect.New(typ) +// v := val.Interface() +// +// if len(j.Options) > 0 { +// if err := json.Unmarshal(j.Options, v); err != nil { +// return err +// } +// } +// +// tr, _ := v.(Transformation) +// *t = Option{Transformation: tr} +// return nil +//} + +var ( + nameToOption sync.Map + optionToName sync.Map +) + +func GetOptionName(o interface{}) string { + typ := reflect.TypeOf(o) + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + if val, ok := optionToName.Load(typ); ok { + v := val.(string) + return v + } + return "" +} + +func RegisterOption(o interface{}) { + var name string + typ := reflect.TypeOf(o) + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + + if namer, ok := o.(NamedOption); ok { + name = namer.GetName() + } else { + name = typ.Name() + } + + nameToOption.Store(name, typ) + optionToName.Store(typ, name) +} + +type Options map[string]interface{} + +func (options *Options) Add(opts ...interface{}) { + if len(opts) == 0 { + return + } + if *options == nil { + *options = make(Options) + } + for _, o := range opts { + name := GetOptionName(o) + (*options)[name] = o + } +} + +//func (options Options) MarshalJSON() ([]byte, error) { +// m := make(map[string]json.RawMessage) +// +// for k,v := range options { +// name := GetOptionName(t) +// b, err := json.Marshal(t) +// if err != nil { +// return nil, err +// } +// m[name] = b +// } +// return json.Marshal(&m) +//} + +func (options *Options) UnmarshalJSON(b []byte) error { + m := make(map[string]json.RawMessage) + *options = make(Options) + if err := json.Unmarshal(b, &m); err != nil { + return err + } + + for name, opts := range m { + i, ok := nameToOption.Load(name) + if !ok { + return fmt.Errorf("unknown option name \"%s\"", name) + } + typ := i.(reflect.Type) + val := reflect.New(typ) + v := val.Interface() + if len(opts) > 0 { + if err := json.Unmarshal(opts, v); err != nil { + return err + } + } + if validator, ok := v.(OptionValidator); ok { + err := validator.ValidateOption() + if errors.Is(err, ErrSkipOption) { + continue + } + if err != nil { + return err + } + } + options.Add(v) + } + return nil +} + +func (options Options) Transform(field *Field, v interface{}) (interface{}, error) { + var err error + for _, t := range options { + o := t.(Option) + v, err = o.Transform(field, v) + if err != nil { + return nil, err + } + } + return v, nil +} diff --git a/pkg/schema/field/primary_key.go b/pkg/schema/field/primary_key.go new file mode 100644 index 0000000000000000000000000000000000000000..b0b26e16a91cd506231ba46307485290a138bfb8 --- /dev/null +++ b/pkg/schema/field/primary_key.go @@ -0,0 +1,55 @@ +package field + +import ( + "context" + "fmt" + "reflect" +) + +//TODO readonly + +var primaryKeyType = &PrimaryKeyType{} + +type PrimaryKeyParameters struct{} + +func (p PrimaryKeyParameters) Type() Type { return primaryKeyType } +func (p *PrimaryKeyParameters) Clone(reset bool) Parameters { return p } + +type PrimaryKeyType struct{} + +func (p PrimaryKeyType) Name() string { + return "PrimaryKey" +} + +func (PrimaryKeyType) NewParameters() Parameters { + return &PrimaryKeyParameters{} +} + +func (PrimaryKeyType) IsEmpty(v interface{}) bool { + s, _ := v.(string) + return s == "" +} + +func (PrimaryKeyType) Decode(_ context.Context, _ *Field, v interface{}) (interface{}, error) { + if v == nil { + return nil, nil + } + if _, ok := v.(string); ok { + return v, nil + } + return nil, fmt.Errorf("PrimaryKeyField decode error: unsupported value type : \"%s\"", reflect.ValueOf(v).Kind()) +} + +func (PrimaryKeyType) Encode(_ context.Context, _ *Field, v interface{}) (interface{}, error) { + if v == nil { + return nil, nil + } + if _, ok := v.(string); ok { + return v, nil + } + return nil, fmt.Errorf("PrimaryKeyField encode error: unsupported value type : \"%s\"", reflect.ValueOf(v).Kind()) +} + +func PrimaryKey(o ...interface{}) *Field { + return NewField(&PrimaryKeyParameters{}, o...) +} diff --git a/pkg/schema/field/primary_key_test.go b/pkg/schema/field/primary_key_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f74f32cafc72612d9b1575561718e6c625334d53 --- /dev/null +++ b/pkg/schema/field/primary_key_test.go @@ -0,0 +1,56 @@ +package field + +import ( + "reflect" + "testing" +) + +func TestPrimaryKeyField_Decode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", PrimaryKey(), "custom_id", "custom_id", false}, + {"Wrong data", PrimaryKey(), 2, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPrimaryKeyField_Encode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", PrimaryKey(), "primary key", "primary key", false}, + {"Wrong data", PrimaryKey(), 2, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Encode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/schema/field/string.go b/pkg/schema/field/string.go new file mode 100644 index 0000000000000000000000000000000000000000..b7e548b65f5c1572cc515cad19192899843ab62f --- /dev/null +++ b/pkg/schema/field/string.go @@ -0,0 +1,53 @@ +package field + +import ( + "context" + "fmt" + "reflect" +) + +var stringType = &StringType{} + +type StringParameters struct{} + +func (s StringParameters) Type() Type { return stringType } +func (s *StringParameters) Clone(reset bool) Parameters { return s } + +type StringType struct{} + +func (s StringType) Name() string { + return "string" +} + +func (StringType) NewParameters() Parameters { + return &StringParameters{} +} + +func (StringType) IsEmpty(v interface{}) bool { + s, _ := v.(string) + return s == "" +} + +func (StringType) Decode(_ context.Context, _ *Field, v interface{}) (interface{}, error) { + if v == nil { + return nil, nil + } + if _, ok := v.(string); ok { + return v, nil + } + return nil, fmt.Errorf("StringField decode error: unsupported value type : \"%s\"", reflect.ValueOf(v).Kind()) +} + +func (StringType) Encode(_ context.Context, _ *Field, v interface{}) (interface{}, error) { + if v == nil { + return nil, nil + } + if _, ok := v.(string); ok { + return v, nil + } + return nil, fmt.Errorf("StringField encode error: unsupported value type : \"%s\"", reflect.ValueOf(v).Kind()) +} + +func String(o ...interface{}) *Field { + return NewField(&StringParameters{}, o...) +} diff --git a/pkg/schema/field/string_test.go b/pkg/schema/field/string_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d0fca29b8ed3f0c4ec987bf6705b7ea435116e40 --- /dev/null +++ b/pkg/schema/field/string_test.go @@ -0,0 +1,56 @@ +package field + +import ( + "reflect" + "testing" +) + +func TestStringField_Decode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", String(), "string", "string", false}, + {"Wrong data", String(), 2, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStringField_Encode(t *testing.T) { + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", String(), "string", "string", false}, + {"Wrong data", String(), 2, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Encode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/schema/field/time.go b/pkg/schema/field/time.go new file mode 100644 index 0000000000000000000000000000000000000000..064906f236371d2914a0544c3f6abef83fd77f65 --- /dev/null +++ b/pkg/schema/field/time.go @@ -0,0 +1,85 @@ +package field + +import ( + "context" + "fmt" + "reflect" + "time" + + "github.com/pkg/errors" +) + +const DefaultTimeLayout = time.RFC3339 + +var timeType = &TimeType{} + +type TimeParameters struct { + Layout string `json:"layout,omitempty"` +} + +func (p TimeParameters) Type() Type { return timeType } +func (p TimeParameters) Clone(reset bool) Parameters { return &p } + +func (p TimeParameters) GetLayout() string { + if p.Layout != "" { + return p.Layout + } + return DefaultTimeLayout +} + +type TimeType struct{} + +func (TimeType) Name() string { + return "time" +} + +func (TimeType) NewParameters() Parameters { + return &TimeParameters{} +} + +func (TimeType) IsEmpty(v interface{}) bool { + t, _ := v.(time.Time) + return t.IsZero() +} + +func (TimeType) Decode(_ context.Context, field *Field, v interface{}) (interface{}, error) { + params, ok := field.Params.(*TimeParameters) + if !ok { + return nil, errors.New("TimeType: field type parameters required") + } + + if v == nil { + return v, nil + } + switch val := v.(type) { + + case string: + if t, err := time.Parse(params.GetLayout(), val); err != nil { + return nil, fmt.Errorf("TimeType: decode error %w", err) + } else { + return t, nil + } + case time.Time: + return v, nil + } + return nil, fmt.Errorf("TimeType: decode: unsupported value type : \"%s\"", reflect.ValueOf(v).Kind()) +} + +func (TimeType) Encode(_ context.Context, field *Field, v interface{}) (interface{}, error) { + params, ok := field.Params.(*TimeParameters) + if !ok { + return nil, errors.New("TimeType: field type parameters required") + } + + if v == nil { + return v, nil + } + if t, ok := v.(time.Time); ok { + return t.Format(params.GetLayout()), nil + } + return nil, fmt.Errorf("TimeType: encode: unsupported value type : \"%s\"", reflect.ValueOf(v).Kind()) +} + +func Time(o ...interface{}) *Field { + return NewField(&TimeParameters{}, o...) +} diff --git a/pkg/schema/field/time_test.go b/pkg/schema/field/time_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f9a8fb44552a0e0a704f7baae7ab10019c889bec --- /dev/null +++ b/pkg/schema/field/time_test.go @@ -0,0 +1,61 @@ +package field + +import ( + "reflect" + "testing" + "time" +) + +func TestTimeField_Decode(t *testing.T) { + w, _ := time.Parse(time.RFC3339, "2012-11-01T22:08:41Z") + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", Time(), "2012-11-01T22:08:41Z", w, false}, + {"Incorrect format", Time(), "2012-11-01", nil, true}, + {"Incorrect type", Time(), 2, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTimeField_Encode(t *testing.T) { + w, _ := time.Parse(time.RFC3339, "2012-11-01T22:08:41Z") + tests := []struct { + name string + field *Field + data interface{} + want interface{} + wantErr bool + }{ + {"Correct", Time(), w, "2012-11-01T22:08:41Z", false}, + {"Incorrect type string", Time(), "2012-11-01T22:08:41Z", nil, true}, + {"Incorrect type int", Time(), 2, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Encode(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Encode() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Encode() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/schema/field/type.go b/pkg/schema/field/type.go new file mode 100644 index 0000000000000000000000000000000000000000..edae76734d562d2546d3b68e89e125c7a87744a6 --- /dev/null +++ b/pkg/schema/field/type.go @@ -0,0 +1,47 @@ +package field + +import ( + "fmt" + "reflect" + "sync" +) + +var ( + registry sync.Map +) + +// Parameters - интерфейс который должен реализовывать параметр конкретного типа +type Parameters interface { + Type() Type + Clone(reset bool) Parameters +} + +// Type - тип поля, отвечает Р·Р° получение, кодирование Рё декодирование параметров для данного типа +type Type interface { + Name() string + NewParameters() Parameters + //Encode(ctx *Context.Context, field *Field, v interface{}) (interface{}, error) + //Decode(ctx *Context.Context, field *Field, v interface{}) (interface{}, error) +} + +func Register(typ Type) { + registry.Store(typ.Name(), typ) +} + +func Unregister(typ interface{}) { + switch t := typ.(type) { + case Type: + registry.Delete(t.Name()) + case string: + registry.Delete(t) + default: + panic(fmt.Sprintf("unknown type: \"%s\"", reflect.ValueOf(t).Kind())) + } +} + +func GetType(name string) (Type, bool) { + if i, ok := registry.Load(name); ok { + return i.(Type), true + } + return nil, false +} diff --git a/pkg/schema/field/unknown.go b/pkg/schema/field/unknown.go new file mode 100644 index 0000000000000000000000000000000000000000..f28a51cd1fbdc20a6b026d4504872149c939e727 --- /dev/null +++ b/pkg/schema/field/unknown.go @@ -0,0 +1,57 @@ +package field + +import ( + "encoding/json" + + jsoniter "github.com/json-iterator/go" + "github.com/pkg/errors" +) + +var unknownType = &UnknownType{} + +type UnknownParameters struct { + Typ string `json:"type,omitempty"` + Params json.RawMessage `json:"params,omitempty"` +} + +func (UnknownParameters) Type() Type { return unknownType } +func (p UnknownParameters) Clone(reset bool) Parameters { return &p } + +type UnknownType struct{} + +func (UnknownType) Name() string { + return "unknown" +} + +func (UnknownType) NewParameters() Parameters { + return &UnknownParameters{} +} + +func (UnknownType) ConvertParameters(p Parameters) (Parameters, error) { + unknownParams, ok := p.(*UnknownParameters) + if !ok { + return p, nil + } + + // Проверяем возможность восстановления РёСЃС…РѕРґРЅРѕРіРѕ типа поля Рё возвращаем его если РІРѕР·РјРѕР¶РЅРѕ + typ, ok := GetType(unknownParams.Typ) + if !ok { + return p, nil + } + + params := typ.NewParameters() + if len(unknownParams.Params) > 0 { + if err := jsoniter.Unmarshal(unknownParams.Params, params); err != nil { + return p, errors.Wrapf(err, "error recover field type %s", typ.Name()) + } + } + + return params, nil +} + +func Unknown(typ string, params json.RawMessage, o ...interface{}) *Field { + var pc ParametersConverter + pc = unknownType + _ = pc + return NewField(&UnknownParameters{Typ: typ, Params: params}, o...) +} diff --git a/pkg/schema/field/walk.go b/pkg/schema/field/walk.go new file mode 100644 index 0000000000000000000000000000000000000000..d7b24a10098944b6c742421027075a0e2febe54e --- /dev/null +++ b/pkg/schema/field/walk.go @@ -0,0 +1,57 @@ +package field + +import "context" + +type WalkFuncResult struct { + Context context.Context + Value interface{} + Changed bool + Stop bool +} + +// WalkFunc - функция которая будет применена Рє значению каждого поля РІ результате РѕР±С…РѕРґ данных функцией `Walk` +// Возвращает РЅРѕРІРѕРµ значение если таковое имеется, признак изменения данных Рё ошибку РІ случае возникновения +type WalkFunc func(ctx context.Context, fld *Field, v interface{}) (result WalkFuncResult, err error) + +// FieldWalker - интерфейс позволяющий выполнить РѕР±С…РѕРґ предоставленного поля +// Реализуется РІ типах +type FieldWalker interface { + // Walk выполняет РѕР±С…РѕРґ данных РІ соответствии СЃ полем. Функция возвращает результат: + // результат РѕР±С…РѕРґР°, флаг что данные изменены, ошибку + Walk(ctx context.Context, fld *Field, v interface{}, fn WalkFunc, opts *WalkOptions) (interface{}, bool, error) +} + +type WalkOptions struct { + WalkSchema bool +} + +type WalkOption func(opts *WalkOptions) + +// WalkSchema указывает что необходимо выполнять РѕР±С…РѕРґ РїРѕ схеме, вместо РѕР±С…РѕРґР° РїРѕ данным. +// РџСЂРё РѕР±С…РѕРґРµ РїРѕ данным РІ случае отсутствия данных для поля дальнейшая обработка данного поля РЅРµ выполняется. +// РџСЂРё РѕР±С…РѕРґРµ РїРѕ схемы Р±СѓРґСѓС‚ обработаны РІСЃРµ поля присутствующие РІ схеме РІРЅРµ зависимости РѕС‚ наличия данных. +func WalkSchema() WalkOption { + return func(opts *WalkOptions) { + opts.WalkSchema = true + } +} + +func WalkOpts(o *WalkOptions) WalkOption { + return func(opts *WalkOptions) { + *opts = *o + } +} + +func NewWalkOptions(opt ...WalkOption) *WalkOptions { + opts := &WalkOptions{} + for _, o := range opt { + o(opts) + } + return opts +} + +// Walker - интерфейс позволяющий выполнить РѕР±С…РѕРґ данных Рё содержит схему внутри +// Реализовано РІ Field, Schema +type Walker interface { + Walk(ctx context.Context, v interface{}, fn WalkFunc, opt ...WalkOption) (interface{}, bool, error) +} diff --git a/pkg/schema/loader.go b/pkg/schema/loader.go new file mode 100644 index 0000000000000000000000000000000000000000..5a646e2398eec49bdd1b8be135f9b9a54952a113 --- /dev/null +++ b/pkg/schema/loader.go @@ -0,0 +1,25 @@ +package schema + +import ( + "context" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +var defaultLoader field.Loader + +func SetDefaultLoader(l field.Loader) { + defaultLoader = l +} + +func GetLoader() field.Loader { + return defaultLoader +} + +func Load(ctx context.Context, ref string) (*Schema, error) { + s := New() + if err := s.Field.LoadRef(ctx, ref, GetLoader()); err != nil { + return nil, err + } + return s, nil +} diff --git a/pkg/schema/modify/default.go b/pkg/schema/modify/default.go new file mode 100644 index 0000000000000000000000000000000000000000..66ce6b89c0619ceb232050fa5ab057e4330fdfcb --- /dev/null +++ b/pkg/schema/modify/default.go @@ -0,0 +1,47 @@ +package modify + +import ( + "context" + "encoding/json" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +type defaultValue struct { + Value interface{} + ready bool +} + +func (d *defaultValue) Prepare(f *field.Field) error { + var err error + d.Value, err = field.Decode(nil, f, d.Value) + return err +} + +func (d *defaultValue) UnmarshalJSON(bytes []byte) error { + return json.Unmarshal(bytes, &d.Value) +} + +func (d defaultValue) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Value) +} + +func Default(v interface{}) Modifier { + return &defaultValue{Value: v} +} + +func (defaultValue) GetName() string { return "default" } + +func (d defaultValue) Modify(ctx context.Context, f *field.Field, v interface{}) (interface{}, bool, error) { + if !d.ready { + var err error + if d.Value, err = field.Decode(ctx, f, d.Value); err != nil { + return nil, false, err + } + } + + if v == nil && d.Value != nil { + return d.Value, true, nil + } + return v, false, nil +} diff --git a/pkg/schema/modify/default_test.go b/pkg/schema/modify/default_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b99630dc65a9249d04bc1b29ac9e9fc902c745cf --- /dev/null +++ b/pkg/schema/modify/default_test.go @@ -0,0 +1,58 @@ +package modify + +import ( + "testing" + "time" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefault(t *testing.T) { + now := time.Now() + _ = now + w, _ := time.Parse(time.RFC3339, "2012-11-01T22:08:41Z") + + tests := []struct { + name string + field *field.Field + data interface{} + want interface{} + wantErr bool + }{ + {"String value", field.String(Default("some")), "same", "same", false}, + {"String", field.String(Default("some")), nil, "some", false}, + {"Int", field.Number(field.NumberFormatInt, Default(2)), nil, int64(2), false}, + {"Time", field.Time(Default(now)), nil, now, false}, + {"Bool", field.Bool(Default(true)), nil, true, false}, + {"Object: nil with field default", field.Object("name", field.String(Default("test"))), nil, nil, false}, + {"Object: nil with object default", field.Object("name", field.String(Default("test"))).AddOptions(Default(map[string]interface{}{"name": "a"})), nil, map[string]interface{}{"name": "a"}, false}, + {"Object: empty", field.Object( + "a", field.String(Default("a")), + "b", field.Number(field.NumberFormatInt, Default(1)), + "c", field.String(), + ), + map[string]interface{}{}, + map[string]interface{}{"a": "a", "b": int64(1)}, + false}, + {"Array of time", + field.Object("array", field.Array(field.Time())), + map[string]interface{}{"array": []interface{}{"2012-11-01T22:08:41Z", "2012-11-01T22:08:41Z"}}, + map[string]interface{}{"array": []interface{}{w, w}}, + false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := field.Decode(nil, tt.field, tt.data) + require.NoError(t, err) + got, _, err = Modify(nil, tt.field, got) + if (err != nil) != tt.wantErr { + t.Errorf("Modify() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/schema/modify/modify.go b/pkg/schema/modify/modify.go new file mode 100644 index 0000000000000000000000000000000000000000..191a428c4efac916511dd37481d7e1bac00a87bc --- /dev/null +++ b/pkg/schema/modify/modify.go @@ -0,0 +1,106 @@ +package modify + +import ( + "context" + "sort" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/expr" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +const ModifierPriority = 1000 + +type Modifier interface { + Modify(ctx context.Context, f *field.Field, v interface{}) (interface{}, bool, error) +} + +type Modifiers []Modifier + +func (l Modifiers) Len() int { return len(l) } +func (l Modifiers) Less(i, j int) bool { + pi, pj := ModifierPriority, ModifierPriority + if o, ok := l[i].(field.PriorityOption); ok { + pi = o.GetPriority() + } + if o, ok := l[j].(field.PriorityOption); ok { + pj = o.GetPriority() + } + if pi == pj { + return field.GetOptionName(l[i]) < field.GetOptionName(l[j]) + } + return pi < pj +} +func (l Modifiers) Swap(i, j int) { l[i], l[j] = l[j], l[i] } + +func getModifiers(f *field.Field) Modifiers { + var ms Modifiers + for _, o := range f.Options { + if m, ok := o.(Modifier); ok { + ms = append(ms, m) + } + } + sort.Sort(ms) + return ms +} + +func applyModifiers(ctx context.Context, f *field.Field, v interface{}) (interface{}, bool, error) { + var err error + var ok, modified bool + modifiers := getModifiers(f) + for _, i := range modifiers { + v, ok, err = i.Modify(ctx, f, v) + if err != nil { + return nil, false, err + } + modified = modified || ok + } + return v, modified, nil +} + +func Modify(ctx context.Context, w field.Walker, v interface{}) (interface{}, bool, error) { + if m, ok := v.(map[string]interface{}); ok { + ctx = expr.WithEnv(ctx, m) + } + + v, c, err := w.Walk(ctx, v, func(ctx context.Context, fld *field.Field, v interface{}) (res field.WalkFuncResult, err error) { + var vv interface{} + var changed bool + + if vv, changed, err = applyModifiers(ctx, fld, v); err != nil { + return + } + + if changed { + v = vv + } + + if modifier, ok := fld.GetType().(Modifier); ok { + vv, ch, err := modifier.Modify(ctx, fld, v) + + if err != nil { + return res, err + } + + if ch { + v = vv + } + } + + res.Value = v + res.Changed = changed + return + }) + + if err != nil { + return nil, false, errors.Wrap(err, "modification error") + } + + return v, c, nil +} + +func init() { + field.RegisterOption(trimSpace(true)) + field.RegisterOption(defaultValue{}) + field.RegisterOption(value{}) +} diff --git a/pkg/schema/modify/string.go b/pkg/schema/modify/string.go new file mode 100644 index 0000000000000000000000000000000000000000..2f0ecc6f24e114f89d97b5e0d54251da68c4aebf --- /dev/null +++ b/pkg/schema/modify/string.go @@ -0,0 +1,33 @@ +package modify + +import ( + "context" + "fmt" + "reflect" + "strings" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +type trimSpace bool + +func TrimSpace() Modifier { + t := trimSpace(true) + return &t +} + +func (c trimSpace) Modify(ctx context.Context, field *field.Field, v interface{}) (interface{}, bool, error) { + if !c { + return v, false, nil + } + + if v == nil { + return nil, false, nil + } + + if s, ok := v.(string); ok { + s = strings.TrimSpace(s) + return s, true, nil + } + return nil, false, fmt.Errorf("incorrect type: \"%s\", expected \"string\"", reflect.ValueOf(v).Kind()) +} diff --git a/pkg/schema/modify/string_test.go b/pkg/schema/modify/string_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ef90f7f6f4d6a43ff13b3ac10bda68602f09c5f4 --- /dev/null +++ b/pkg/schema/modify/string_test.go @@ -0,0 +1,78 @@ +package modify + +import ( + "reflect" + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTrimSpace(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + want interface{} + wantErr bool + }{ + {"String with spaces", field.String().AddOptions(TrimSpace()), " string string ", "string string", false}, + {"Nil", field.String().AddOptions(TrimSpace()), nil, nil, false}, + {"Spaces", field.String().AddOptions(TrimSpace()), " ", "", false}, + {"Empty string", field.String().AddOptions(TrimSpace()), "", "", false}, + {"Not a string", field.String().AddOptions(TrimSpace()), 2, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, _, err := Modify(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Modify() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Modify() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestModify(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + error string + }{ + {"String Length Max", field.String().AddOptions(TrimSpace()), " ", false, ""}, + {"String Length Min", field.String().AddOptions(TrimSpace()), " ", false, ""}, + {"Nil Length Max", field.String().AddOptions(TrimSpace()), nil, false, ""}, + {"Nil Length Min", field.String().AddOptions(TrimSpace()), nil, false, ""}, + {"Int Length Max", field.String().AddOptions(TrimSpace()), 1, true, "modification error: incorrect type: \"int\", expected \"string\""}, + {"Int Length Min", field.String().AddOptions(TrimSpace()), 1, true, "modification error: incorrect type: \"int\", expected \"string\""}, + {"Float Length Max", field.String().AddOptions(TrimSpace()), 1.0, true, "modification error: incorrect type: \"float64\", expected \"string\""}, + {"Float Length Min", field.String().AddOptions(TrimSpace()), 1.0, true, "modification error: incorrect type: \"float64\", expected \"string\""}, + {"Bool Length Max", field.String().AddOptions(TrimSpace()), true, true, "modification error: incorrect type: \"bool\", expected \"string\""}, + {"Bool Length Min", field.String().AddOptions(TrimSpace()), true, true, "modification error: incorrect type: \"bool\", expected \"string\""}, + {"Array Length Max", field.String().AddOptions(TrimSpace()), [1]string{""}, true, "modification error: incorrect type: \"array\", expected \"string\""}, + {"Array Length Min", field.String().AddOptions(TrimSpace()), [1]string{""}, true, "modification error: incorrect type: \"array\", expected \"string\""}, + {"Slice Length Max", field.String().AddOptions(TrimSpace()), []string{""}, true, "modification error: incorrect type: \"slice\", expected \"string\""}, + {"Slice Length Min", field.String().AddOptions(TrimSpace()), []string{""}, true, "modification error: incorrect type: \"slice\", expected \"string\""}, + {"Map Length Max", field.String().AddOptions(TrimSpace()), map[string]string{"": ""}, true, "modification error: incorrect type: \"map\", expected \"string\""}, + {"Map Length Min", field.String().AddOptions(TrimSpace()), map[string]string{"": ""}, true, "modification error: incorrect type: \"map\", expected \"string\""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := Modify(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.error) + } + if !tt.wantErr { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/schema/modify/value.go b/pkg/schema/modify/value.go new file mode 100644 index 0000000000000000000000000000000000000000..1f87c80387c802eaf7fb7e3fec221ea263092050 --- /dev/null +++ b/pkg/schema/modify/value.go @@ -0,0 +1,39 @@ +package modify + +import ( + "context" + "encoding/json" + "reflect" + + "git.perx.ru/perxis/perxis-go/pkg/expr" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/pkg/errors" +) + +type value struct { + Expression string +} + +func (d *value) UnmarshalJSON(bytes []byte) error { + return json.Unmarshal(bytes, &d.Expression) +} + +func (d value) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Expression) +} + +func Value(exp string) Modifier { + return &value{Expression: exp} +} + +func (d value) Modify(ctx context.Context, f *field.Field, v interface{}) (interface{}, bool, error) { + val, err := expr.EvalKV(ctx, d.Expression, "_value", v) + if err != nil { + return nil, false, errors.Wrap(err, "value evaluation error") + } + // использование reflect.DeepEqual для учета случаев, РєРѕРіРґР° РІ val массив или объект + if !reflect.DeepEqual(val, v) { + return val, true, nil + } + return v, false, nil +} diff --git a/pkg/schema/modify/value_test.go b/pkg/schema/modify/value_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9878fc70663005f7650ac567899634a94446e8b9 --- /dev/null +++ b/pkg/schema/modify/value_test.go @@ -0,0 +1,63 @@ +package modify + +import ( + "reflect" + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +func TestValue(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + want interface{} + wantErr bool + }{ + {"Condition with _value", field.String().AddOptions(Value("_value == 'ab' ? _value : _value + 'b'")), "a", "ab", false}, + {"Condition with _value", field.String().AddOptions(Value("_value == 'ab' ? _value : _value + 'b'")), "ab", "ab", false}, + {"Default with _value", field.String().AddOptions(Value("_value == nil ? 'abc' : _value")), nil, "abc", false}, + {"Field global", field.Object( + "fld1", field.String(), + "fld2", field.String().AddOptions(Value("fld1 + 'b'")), + ), + map[string]interface{}{"fld1": "a"}, + map[string]interface{}{"fld1": "a", "fld2": "ab"}, false}, + {"Field local", field.Object( + "fld1", field.String(), + "fld2", field.String().AddOptions(Value("_.fld1 + 'b'")), + ), + map[string]interface{}{"fld1": "a"}, + map[string]interface{}{"fld1": "a", "fld2": "ab"}, false}, + {"Multiple fields ", field.Object( + "fld1", field.String(), + "fld2", field.String(), + "fld3", field.String(), + "fld4", field.String(), + "fld5", field.String().AddOptions(Value("_.fld1 + fld2 + _.fld3+fld4")), + ), + map[string]interface{}{"fld1": "a", "fld2": "b", "fld3": "c", "fld4": "d"}, + map[string]interface{}{"fld1": "a", "fld2": "b", "fld3": "c", "fld4": "d", "fld5": "abcd"}, + false}, + {"Nil field error", field.Object( + "fld1", field.String(), + "fld2", field.String().AddOptions(Value("_.fld1 + 'b'")), + ), + map[string]interface{}{}, + map[string]interface{}{}, + true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, _, err := Modify(nil, tt.field, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("Modify() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("Modify() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go new file mode 100644 index 0000000000000000000000000000000000000000..e0eca6b6ffc02511acbe3712b2f2bf83fc5bc05e --- /dev/null +++ b/pkg/schema/schema.go @@ -0,0 +1,215 @@ +package schema + +import ( + "context" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/expr" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "git.perx.ru/perxis/perxis-go/pkg/schema/modify" + "git.perx.ru/perxis/perxis-go/pkg/schema/validate" +) + +type Schema struct { + field.Field + Loaded bool `json:"loaded"` +} + +func New(kv ...interface{}) *Schema { + return &Schema{Field: *field.Object(kv...)} +} + +func NewFromField(f *field.Field) *Schema { + return &Schema{Field: *f} +} + +var ( + Encode = field.Encode + Decode = field.Decode + Modify = modify.Modify + Validate = validate.Validate + Evaluate = field.Evaluate +) + +func (s *Schema) Clone(reset bool) *Schema { + return &Schema{ + Field: *s.Field.Clone(reset), + Loaded: s.Loaded, + } +} + +func (s Schema) WithIncludes(includes ...interface{}) *Schema { + s.Field.SetIncludes(includes...) + return &s +} + +func (s *Schema) Load(ctx context.Context) error { + if s.Loaded { + return nil + } + return s.LoadIncludes(ctx, nil) +} + +func (s *Schema) LoadIncludes(ctx context.Context, loader field.Loader) (err error) { + if loader == nil { + loader = GetLoader() + } + err = s.Field.LoadIncludes(ctx, loader) + if err == nil { + s.Loaded = true + } + return +} + +func (s *Schema) Modify(ctx context.Context, data map[string]interface{}) (res map[string]interface{}, err error) { + if err = s.Load(ctx); err != nil { + return nil, err + } + + v, _, err := Modify(ctx, s, data) + if err != nil || v == nil { + return + } + + res, _ = v.(map[string]interface{}) + return +} + +func (s *Schema) Validate(ctx context.Context, data map[string]interface{}) (err error) { + if err = s.Load(ctx); err != nil { + return err + } + + return Validate(ctx, s, data) +} + +func (s *Schema) Evaluate(ctx context.Context, data map[string]interface{}) (res map[string]interface{}, err error) { + if err = s.Load(ctx); err != nil { + return nil, err + } + + v, err := Evaluate(ctx, s, data) + if err != nil || v == nil { + return + } + res, _ = v.(map[string]interface{}) + return +} + +func (s *Schema) Decode(ctx context.Context, v interface{}) (res map[string]interface{}, err error) { + if err = s.Load(ctx); err != nil { + return nil, err + } + + if v, err = Decode(ctx, s, v); err != nil { + return nil, err + } + res, _ = v.(map[string]interface{}) + return +} + +func (s *Schema) Encode(ctx context.Context, v interface{}) (interface{}, error) { + if err := s.Load(ctx); err != nil { + return nil, err + } + + var res interface{} + var err error + + if res, err = Encode(ctx, s, v); err != nil { + return nil, err + } + + return res, nil +} + +func (s *Schema) ToValue(ctx context.Context, data map[string]interface{}) (res map[string]interface{}, err error) { + if err = s.Load(ctx); err != nil { + return nil, err + } + + if data, err = s.Decode(ctx, data); err != nil { + return nil, err + } + if data, err = s.Modify(ctx, data); err != nil { + return nil, err + } + if data, err = s.Evaluate(ctx, data); err != nil { + return nil, err + } + if err = s.Validate(ctx, data); err != nil { + return nil, err + } + return data, err +} + +type parentFieldCtxKey struct{} + +func (s *Schema) Introspect(ctx context.Context, data map[string]interface{}) (map[string]interface{}, *Schema, error) { + if err := s.Load(ctx); err != nil { + return nil, nil, err + } + + var err error + + chg := true + val := data + i := 0 + + var mutatedSchema *Schema + + for chg { + mutatedSchema = nil + + var res interface{} + res, chg, err = s.Walk(expr.WithEnv(ctx, val), val, func(ctx context.Context, f *field.Field, v interface{}) (res field.WalkFuncResult, err error) { + parent, _ := ctx.Value(parentFieldCtxKey{}).(*field.Field) + name, _ := ctx.Value(field.FieldName).(string) + enabled, err := f.IsEnabled(ctx) + if err != nil { + return + } + + if !enabled { + res.Stop = true + if v != nil { + res.Changed = true + } + return + } + + fld := f.Clone(true) + if mutatedSchema == nil { + mutatedSchema = &Schema{Field: *fld} + fld = &mutatedSchema.Field + } + + if parent != nil && name != "" { + field.AddField(parent, name, fld) + } + + ctx = context.WithValue(ctx, parentFieldCtxKey{}, fld) + res.Context = ctx + + return + }, field.WalkSchema()) + + if err != nil { + return nil, nil, errors.Wrap(err, "evaluation error") + } + + if res != nil { + val = res.(map[string]interface{}) + } else { + val = nil + } + + i += 1 + + if i > field.EvaluatePassesLimit { + return nil, nil, errors.New("fail to evaluate data conditions") + } + } + + return val, mutatedSchema, nil +} diff --git a/pkg/schema/schema_json.go b/pkg/schema/schema_json.go new file mode 100644 index 0000000000000000000000000000000000000000..906acb5d0d361611d01f43d10bd0f80377ccb4a7 --- /dev/null +++ b/pkg/schema/schema_json.go @@ -0,0 +1,54 @@ +package schema + +import ( + "git.perx.ru/perxis/perxis-go/pkg/errors" + jsoniter "github.com/json-iterator/go" +) + +type jsonSchema struct { + //Field json.RawMessage `json:"field,inline"` + Loaded bool `json:"loaded"` +} + +func (s *Schema) UnmarshalJSON(b []byte) error { + + var j *jsonSchema + if err := jsoniter.Unmarshal(b, &j); err != nil { + return errors.Wrapf(err, "error unmarshal json into field") + } + s.Loaded = j.Loaded + + if err := s.Field.UnmarshalJSON(b); err != nil { + return err + } + + //if len(j.Field) > 0 { + // if err := s.Field.UnmarshalJSON(j.Field); err != nil { + // return err + // } + // //if err := jsoniter.Unmarshal(j.Field, &s.Field); err != nil { + // // return err + // //} + //} + + return nil +} + +func (s *Schema) MarshalJSON() ([]byte, error) { + + jsonField, err := s.Field.MarshalJSON() + if err != nil { + return nil, err + } + + jsonSch, err := jsoniter.Marshal(jsonSchema{ + //Field: b, + Loaded: s.Loaded, + }) + if err != nil { + return nil, err + } + jsonSch[0] = ',' // вместо такого РјРѕР¶РЅРѕ сначала jsonField размаршалить РІ map[string]interface{}, добавить поле Рё замаршалить еще раз + + return append(jsonField[:len(jsonField)-1], jsonSch...), nil +} diff --git a/pkg/schema/test/object_test.go b/pkg/schema/test/object_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e5af975cdb9ed8e86044e5d357530dd2420afee9 --- /dev/null +++ b/pkg/schema/test/object_test.go @@ -0,0 +1,1490 @@ +package test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "git.perx.ru/perxis/perxis-go/pkg/data" + "git.perx.ru/perxis/perxis-go/pkg/schema" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "git.perx.ru/perxis/perxis-go/pkg/schema/modify" + "git.perx.ru/perxis/perxis-go/pkg/schema/validate" + "github.com/hashicorp/go-multierror" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultTimeField_JSON(t *testing.T) { + w, _ := time.Parse(time.RFC3339, "2012-11-01T22:08:41Z") + fld := field.Object( + "timeField", field.Time(modify.Default(w)), + ) + + b, err := json.MarshalIndent(fld, "", " ") + require.NoError(t, err) + + res := &field.Field{} + err = json.Unmarshal(b, res) + require.NoError(t, err) + + assert.Equal(t, fld, res) +} + +func TestStringField_JSON(t *testing.T) { + fld := field.String().AddOptions(validate.MaxLength(200), modify.TrimSpace()) + + b, err := json.MarshalIndent(fld, "", " ") + require.NoError(t, err) + + res := field.NewField(nil) + err = json.Unmarshal(b, res) + require.NoError(t, err) + + assert.Equal(t, fld, res) +} + +func TestNumberField_JSON(t *testing.T) { + fld := field.Number(field.NumberFormatInt).AddOptions( + validate.Min(0), + validate.Max(10), + validate.MultipleOf(2), + validate.Enum( + validate.EnumOpt{ + Name: "N 1", + Value: 1.0, + }, + validate.EnumOpt{ + Name: "N 2", + Value: 2.0, + }, + ), + ) + + b, err := json.MarshalIndent(fld, "", " ") + require.NoError(t, err) + //fmt.Println(string(b)) + + res := field.NewField(nil) + err = json.Unmarshal(b, res) + require.NoError(t, err) + + assert.Equal(t, fld, res) +} + +func TestSchema_JSON(t *testing.T) { + enumStr := field.String().AddOptions( + validate.Enum( + validate.EnumOpt{ + Name: "N 1", + Value: "n1", + }, validate.EnumOpt{ + Name: "N 2", + Value: "n2", + }, + ), + ).SetAdditionalValues() + enumInt := field.Number(field.NumberFormatFloat).AddOptions( + validate.Enum( + validate.EnumOpt{ + Name: "N 1", + Value: 1.1, + }, validate.EnumOpt{ + Name: "N 2", + Value: 2.5, + }, + ), + ) + sch := schema.New( + "stringField", field.String().WithUI(&field.UI{Placeholder: "Test name"}).AddOptions(modify.TrimSpace()).AddOptions(validate.MinLength(2), validate.MaxLength(10)), + "stringField2", field.String(modify.Default("default")), + "intField", field.Number("int", validate.Required()), + "floatField", field.Number("float").SetIndexed(true), + "enumStringField", enumStr, + "enumIntField", enumInt, + "timeField", field.Time().SetSingleLocale(true), + "arrayField", field.Array(field.String(modify.Default("default"))), + "objectField", field.Object("innerStringField", field.String()).WithIncludes("ref1", field.Include{Ref: "ref2", Optional: true}), + "evaluatedField", field.String(modify.Value("stringField2 + '_' ")), + ) + sch.Loaded = true + + b, err := json.MarshalIndent(sch, "", " ") + require.NoError(t, err) + //fmt.Println(string(b)) + + res := schema.New() + err = json.Unmarshal(b, res) + require.NoError(t, err) + + assert.Equal(t, sch, res) +} + +func TestSchemaUI_UnmarshalJSON(t *testing.T) { + vw := &field.View{ + Widget: "Widget", + Options: map[string]interface{}{"title": "name", "key": "name"}, + } + ui := &field.UI{ + Widget: "Widget", + Placeholder: "Placeholder", + Options: map[string]interface{}{"title": "name", "key": "name"}, + ListView: vw, + ReadView: vw, + EditView: vw, + } + schm := schema.New( + "name", field.String().WithUI(ui), + ) + schm.UI = ui + + j := `{ + "ui": { + "widget": "Widget", + "placeholder": "Placeholder", + "options": { + "title": "name", + "key": "name" + }, + "read_view": { + "widget": "Widget", + "options": { + "title": "name", + "key": "name" + } + }, + "edit_view": { + "widget": "Widget", + "options": { + "title": "name", + "key": "name" + } + }, + "list_view": { + "widget": "Widget", + "options": { + "title": "name", + "key": "name" + } + } + }, + "type": "object", + "params": { + "inline": false, + "fields": { + "name": { + "ui": { + "widget": "Widget", + "placeholder": "Placeholder", + "options": { + "title": "name", + "key": "name" + }, + "read_view": { + "widget": "Widget", + "options": { + "title": "name", + "key": "name" + } + }, + "edit_view": { + "widget": "Widget", + "options": { + "title": "name", + "key": "name" + } + }, + "list_view": { + "widget": "Widget", + "options": { + "title": "name", + "key": "name" + } + } + }, + "type": "string", + "params": {} + } + } + }, + "loaded": false +}` + + sch := schema.New() + err := sch.UnmarshalJSON([]byte(j)) + require.NoError(t, err) + assert.Equal(t, sch, schm) +} + +func TestSchema_GetField(t *testing.T) { + + sch := schema.New( + "str", field.String(), + "num", field.Number(field.NumberFormatInt), + "obj", field.Object( + "bool", field.Bool(), + "arr", field.Array(field.Time()), + "list", field.Array( + field.Object( + "num1", field.Number(field.NumberFormatFloat), + "str1", field.String(), + "obj1", field.Object( + "str2", field.String(), + ), + ), + ), + "geo", field.Location(), + ), + ) + + data := []struct { + fld string + exists bool + typ string + }{ + {"str", true, "string"}, + {"obj.bool", true, "bool"}, + {"obj.list.num1", true, "number"}, + {"obj.list.obj1.str2", true, "string"}, + {"obj_list", false, ""}, + {"zzz", false, ""}, + {"obj.geo", true, "location"}, + } + + for _, d := range data { + t.Run(d.fld, func(t *testing.T) { + f := sch.GetField(d.fld) + if d.exists { + require.NotNil(t, f, fmt.Sprintf("not found '%s'", d.fld)) + assert.Equal(t, d.typ, f.GetType().Name(), fmt.Sprintf("field '%s'", d.fld)) + } else { + require.Nil(t, f) + } + }) + } +} + +func TestSchema_GetField_WithInline(t *testing.T) { + + sch := schema.New( + "str", field.String(), + "obj1", field.Object( + true, + "obj11", field.Object( + true, + "obj111", field.Object( + true, + "str1", field.String(), + "str2", field.String(), + ), + "arr1", field.Array(field.Object( + "str3", field.String(), + )), + "arr2", field.Array(field.String()), + ), + ), + "obj2", field.Object( + true, + "a", field.String(), + "b", field.String(), + ), + "zz", field.Object( + true, + "zz", field.Array(field.Object( + "str3", field.String(), + )), + ), + ) + + data := []struct { + fld string + exists bool + typ string + }{ + {"str", true, "string"}, + {"a", true, "string"}, + {"b", true, "string"}, + {"str1", true, "string"}, + {"str2", true, "string"}, + {"arr1", true, "array"}, + {"arr2", true, "array"}, + {"arr1.str3", true, "string"}, + {"zz.str3", true, "string"}, + } + + for _, d := range data { + t.Run(d.fld, func(t *testing.T) { + f := sch.GetField(d.fld) + if d.exists { + require.NotNil(t, f, fmt.Sprintf("not found '%s'", d.fld)) + assert.Equal(t, d.typ, f.GetType().Name(), fmt.Sprintf("field '%s'", d.fld)) + } else { + require.Nil(t, f) + } + }) + } +} + +func TestSchema_GetFields(t *testing.T) { + sch := schema.New( + "str", field.String().SetTitle("Str"), + "num", field.Number(field.NumberFormatInt).SetIndexed(true).SetTitle("Num"), + "obj", field.Object( + "arr", field.Array( + field.Array(field.Time()).SetIndexed(true).SetTitle("NestedArr"), + ).SetTitle("Arr"), + "list", field.Array( + field.Object( + "obj3", field.Object( + "str", field.String().SetIndexed(true).SetTitle("Obj2.List.Str"), + ).SetTitle("Obj3"), + ).SetTitle("Obj2"), + ).SetTitle("List"), + "geo", field.Location().SetTitle("Geo"), + ).SetTitle("Obj"), + ) + + flds := sch.GetFields(func(f *field.Field, path string) bool { return true }) + assert.Len(t, flds, 8) + + paths := make([]string, 0, len(flds)) + + for _, fld := range flds { + switch fld.Path { + case "str": + assert.IsType(t, &field.StringParameters{}, fld.Params) + case "num": + assert.IsType(t, &field.NumberParameters{}, fld.Params) + case "obj": + assert.IsType(t, &field.ObjectParameters{}, fld.Params) + case "obj.arr": + assert.IsType(t, &field.ArrayParameters{}, fld.Params) + case "obj.list": + assert.IsType(t, &field.ArrayParameters{}, fld.Params) + case "obj.list.obj3": + assert.IsType(t, &field.ObjectParameters{}, fld.Params) + case "obj.list.obj3.str": + assert.IsType(t, &field.StringParameters{}, fld.Params) + case "obj.geo": + assert.IsType(t, &field.LocationParameters{}, fld.Params) + + } + + paths = append(paths, fld.Path) + } + + assert.ElementsMatch( + t, + []string{"str", "num", "obj", "obj.arr", "obj.list", "obj.list.obj3", "obj.list.obj3.str", "obj.geo"}, + paths, + ) + +} + +func TestSchema_GetFieldByPath(t *testing.T) { + sch := schema.New( + "str", field.String().SetTitle("Str"), + "num", field.Number(field.NumberFormatInt).SetIndexed(true).SetTitle("Num"), + "obj", field.Object( + "arr", field.Array( + field.Array(field.Time()).SetIndexed(true).SetTitle("NestedArr"), + ).SetTitle("Arr"), + "list", field.Array( + field.Object( + "obj3", field.Object( + "str", field.String().SetIndexed(true).SetTitle("Obj2.List.Str"), + ).SetTitle("Obj3"), + ).SetTitle("Obj2"), + ).SetTitle("List"), + "geo", field.Location().SetTitle("Geo"), + ).SetTitle("Obj"), + ) + + dt := []struct { + name string + paths []string + want []string + }{ + { + "all", + []string{"*"}, + []string{"str", "num", "obj", "obj.arr", "obj.list", "obj.list.obj3", "obj.list.obj3.str", "obj.geo"}, + }, + { + "full match", + []string{"str", "obj.list.obj3", "some"}, + []string{"str", "obj.list.obj3"}, + }, + { + "glob", + []string{"str*", "obj.list*", "*geo"}, + []string{"str", "obj.list", "obj.list.obj3", "obj.list.obj3.str", "obj.geo"}, + }, + } + + for _, d := range dt { + t.Run(d.name, func(t *testing.T) { + got := field.GetFieldsPath(sch.GetFields(func(f *field.Field, path string) bool { + return data.GlobMatch(path, d.paths...) + })) + assert.ElementsMatch(t, d.want, got) + }) + } +} + +func TestSchema_GetFieldsInline(t *testing.T) { + t.Run("Basic", func(t *testing.T) { + sch := schema.New( + "str_1", field.String(), + "num", field.Number(field.NumberFormatInt).SetIndexed(true), + "obj_1", field.Object( + "arr", field.Array(field.Time()).SetIndexed(true), + "list", field.Array( + field.Object( + "obj_2", field.Object( + "str_2", field.String().SetIndexed(true), + ), + ), + ), + "geo", field.Location(), + ), + ) + + flds := field.GetFieldsPath(sch.GetFields(func(f *field.Field, p string) bool { + return true + }, "data")) + assert.ElementsMatch( + t, + []string{ + "data", + "data.str_1", + "data.num", + "data.obj_1", + "data.obj_1.arr", + "data.obj_1.list", + "data.obj_1.list.obj_2", + "data.obj_1.list.obj_2.str_2", + "data.obj_1.geo", + }, + flds, + ) + }) + t.Run("Inline fields in schema in a row", func(t *testing.T) { + sch := schema.New( + "obj_inline_1", field.Object( + true, + "inline_field1", field.String().SetUnique(true), + "obj_inline_2", field.Object(true, + "inline_field2", field.String(), + "arr", field.Array(field.Object(true, + "inline_field3", field.String(), + )), + ), + ), + ) + + flds := field.GetFieldsPath(sch.GetFields(func(f *field.Field, p string) bool { + return true + })) + assert.ElementsMatch( + t, + []string{ + "obj_inline_1", + "inline_field1", + "obj_inline_2", + "inline_field2", + "arr", + "arr.inline_field3", + }, + flds, + ) + }) + t.Run("Inline fields in schema in a row with prefix", func(t *testing.T) { + sch := schema.New( + "obj_inline_1", field.Object(true, + "inline_field1", field.String().SetUnique(true), + "obj_inline_2", field.Object(true, + "inline_field2", field.String(), + "obj_inline_3", field.Object(true, + "inline_field3", field.String(), + ), + ), + ), + ) + + flds := field.GetFieldsPath(sch.GetFields(func(f *field.Field, p string) bool { + return true + }, "data")) + assert.ElementsMatch( + t, + []string{ + "data", + "data.obj_inline_1", + "data.inline_field1", + "data.obj_inline_2", + "data.inline_field2", + "data.obj_inline_3", + "data.inline_field3", + }, + flds, + ) + }) + t.Run("Mixed fields in schema in a row", func(t *testing.T) { + sch := schema.New( + "obj_not_inline_1", field.Object( + "not_inline_field_1", field.String().SetUnique(true), + "obj_inline_1", field.Object(true, + "inline_field1", field.String(), + "obj_not_inline_2", field.Object( + "not_inline_field_2", field.String(), + "obj_inline_2", field.Object(true, + "inline_field2", field.String(), + ), + ), + ), + ), + ) + + flds := field.GetFieldsPath(sch.GetFields(func(f *field.Field, p string) bool { + return true + }, "data")) + assert.ElementsMatch( + t, + []string{ + "data", + "data.obj_not_inline_1", + "data.obj_not_inline_1.not_inline_field_1", + "data.obj_not_inline_1.obj_inline_1", + "data.obj_not_inline_1.inline_field1", + "data.obj_not_inline_1.obj_not_inline_2", + "data.obj_not_inline_1.obj_not_inline_2.not_inline_field_2", + "data.obj_not_inline_1.obj_not_inline_2.obj_inline_2", + "data.obj_not_inline_1.obj_not_inline_2.inline_field2", + }, + flds, + ) + }) +} + +func TestSchema_Clone(t *testing.T) { + sch := schema.New( + "f", field.String().WithUI(&field.UI{Placeholder: "Test name"}).AddOptions(modify.TrimSpace()).AddTranslation("ru", "С„", "Поле Р¤"), + "obj", field.Object( + "list", field.Array( + field.Object( + "obj", field.Object( + "field", field.String(), + ), + ), + ), + ), + ) + + t.Run("Simple", func(t *testing.T) { + f := sch.GetField("f") + fld := f.Clone(false) + + assert.Equal(t, f.UI, fld.UI) + assert.Equal(t, f.Options, fld.Options) + assert.Equal(t, f.Translations, fld.Translations) + assert.Equal(t, f.Params, fld.Params) + }) + + t.Run("Reset", func(t *testing.T) { + f := sch.GetField("obj") + fld := f.Clone(true) + + assert.Equal(t, f.UI, fld.UI) + assert.Equal(t, f.Options, fld.Options) + assert.Equal(t, f.Translations, fld.Translations) + assert.NotEqual(t, f.Params, fld.Params) + + f = sch.GetField("obj.list") + fld = f.Clone(true) + + assert.Equal(t, f.UI, fld.UI) + assert.Equal(t, f.Options, fld.Options) + assert.Equal(t, f.Translations, fld.Translations) + assert.NotEqual(t, f.Params, fld.Params) + + f = sch.GetField("obj.list.obj") + fld = f.Clone(true) + + assert.Equal(t, f.UI, fld.UI) + assert.Equal(t, f.Options, fld.Options) + assert.Equal(t, f.Translations, fld.Translations) + assert.NotEqual(t, f.Params, fld.Params) + }) +} + +func TestSchema_Modify(t *testing.T) { + sch := schema.New( + "name", field.String(validate.Required()), + "last_name", field.String(validate.Required()), + "got_nobel", field.Bool(), + "times", field.Number("int"), + "dates", field.Array(field.Time()), + ) + + in := map[string]interface{}{"last_name": "Curie", "name": "Marie"} + _, _, err := modify.Modify(nil, sch, in) + require.NoError(t, err) +} + +func TestSchema_Validate(t *testing.T) { + sch := schema.New( + "name", field.String(validate.Required()), + "last_name", field.String(), + "info", field.Object( + "time", field.Time(), + "numbers", field.Number( + field.NumberFormatInt, + validate.Enum( + validate.EnumOpt{Name: "first", Value: 1}, + validate.EnumOpt{Name: "second", Value: 2}, + ), + ), + ), + ) + + in := map[string]interface{}{"info": map[string]interface{}{"time": time.Now()}, "name": "Name"} + err := validate.Validate(nil, sch, in) + require.NoError(t, err) +} + +func TestSchema_DecodeErrors(t *testing.T) { + sch := schema.New( + "name", field.String(validate.Required()), + "last_name", field.String(), + "a", field.Object( + "time", field.Time(), + "num1", field.Number(field.NumberFormatInt), + "num2", field.Number(field.NumberFormatInt), + "num3", field.Number(field.NumberFormatInt), + "b", field.Object( + "num1", field.Number(field.NumberFormatInt), + "num2", field.Number(field.NumberFormatInt), + "num3", field.Number(field.NumberFormatInt), + ), + "c", field.Array(field.Number(field.NumberFormatInt)), + "d", field.Number(field.NumberFormatInt, validate.Max(10)), + ), + ) + + in := map[string]interface{}{"a": map[string]interface{}{"time": time.Now(), "num1": "a", "num2": "b", "num3": "c", "d": 20, + "b": map[string]interface{}{"time": time.Now(), "num1": "a", "num2": "b", "num3": "c", "str": "s"}, "c": []interface{}{"a", "b", "c"}}, + "name": "Name"} + _, err := schema.Decode(nil, sch, in) + assert.Error(t, err) + assert.Contains(t, err.Error(), "decode error") +} + +func TestSchema_ValidateErrors(t *testing.T) { + sch := schema.New( + "a", field.Object( + "num1", field.Number(field.NumberFormatInt, validate.Required()), + "num2", field.Number(field.NumberFormatInt, validate.Max(10)), + "num3", field.Number(field.NumberFormatInt, validate.Min(10)), + "str1", field.String(validate.MaxLength(5)), + "str2", field.String(validate.MinLength(5)), + "str3", field.String(validate.MinLength(5), validate.Enum(validate.EnumOpt{Value: "somesome"}, validate.EnumOpt{Value: "romoromo"})), + ), + ) + + in := map[string]interface{}{"a": map[string]interface{}{"num2": 20, "num3": 5, "str1": "123456", "str2": "123", "str3": "some"}} + decoded, err := schema.Decode(nil, sch, in) + require.NoError(t, err) + err = validate.Validate(nil, sch, decoded) + require.Error(t, err) + require.Contains(t, err.Error(), "validation error") + var merr *multierror.Error + require.ErrorAs(t, err, &merr) + assert.Len(t, merr.Errors, 6) +} + +func TestSchema_ValidateEmptyObject(t *testing.T) { + { + sch := schema.New( + "num1", field.Number(field.NumberFormatInt, validate.Required()), + ) + + res, err := schema.Decode(nil, sch, nil) + require.NoError(t, err) + res, _, err = modify.Modify(nil, sch, res) + require.NoError(t, err) + err = validate.Validate(nil, sch, res) + require.NoError(t, err, "поля объекта nil РЅРµ проверяются") + } + { + sch := schema.New( + "num1", field.Number(field.NumberFormatInt, validate.Required()), + ) + + res, err := schema.Decode(nil, sch, map[string]interface{}{}) + require.NoError(t, err) + res, _, err = modify.Modify(nil, sch, res) + require.NoError(t, err) + err = validate.Validate(nil, sch, res) + require.Error(t, err, "поля пустого объекта проверяются") + } + { + sch := schema.New( + "num1", field.Number(field.NumberFormatInt, validate.Required()), + ) + + res, err := schema.Decode(nil, sch, map[string]interface{}{"a": "sss"}) + require.NoError(t, err) + res, _, err = modify.Modify(nil, sch, res) + require.NoError(t, err) + err = validate.Validate(nil, sch, res) + require.Error(t, err, "поля объекта СЃ некорректными данными проверяются") + } + + { + sch := schema.New( + "num1", field.Number(field.NumberFormatInt, validate.Required()), + ).AddOptions(modify.Default(map[string]interface{}{})) + + res, err := schema.Decode(nil, sch, nil) + require.NoError(t, err) + res, _, err = modify.Modify(nil, sch, res) + require.NoError(t, err) + err = validate.Validate(nil, sch, res) + require.Error(t, err, "поля nil объекта Default данными проверяются") + } +} + +func TestSchema_ModificationErrors(t *testing.T) { + sch := schema.New( + "a", field.Object( + "num1", field.Number(field.NumberFormatInt, modify.TrimSpace()), + "str1", field.String(modify.TrimSpace()), + ), + ) + + in := map[string]interface{}{"a": map[string]interface{}{"num1": 20, "num3": 5, "str1": "123456", "str2": "123", "str3": "some"}} + decoded, err := schema.Decode(nil, sch, in) + require.NoError(t, err) + _, _, err = modify.Modify(nil, sch, decoded) + require.Error(t, err) + require.Contains(t, err.Error(), "modification error") + var merr *multierror.Error + require.ErrorAs(t, err, &merr) + assert.Len(t, merr.Errors, 1) +} + +func TestSchema_UnknownJSON(t *testing.T) { + sch := schema.New( + "name", field.String(validate.Required()), + "last_name", field.String(validate.Required()), + "got_nobel", field.Bool(), + "times", field.Number("int"), + "dates", field.Array(field.Time()), + ) + + b, err := json.Marshal(sch) + require.NoError(t, err) + field.Unregister("object") + + s1 := schema.New() + err = json.Unmarshal(b, s1) + require.NoError(t, err) + assert.Equal(t, "unknown", s1.GetType().Name(), "Схема неизвестного типа должна определяться как unknown") + + in := map[string]interface{}{"info": map[string]interface{}{"time": time.Now()}, "name": "Name"} + out, err := field.Decode(nil, s1, in) + require.NoError(t, err) + assert.Equal(t, in, out, "Данные неизвестного типа РЅРµ изменяются РїСЂРё декодировании") + err = validate.Validate(nil, s1, in) + require.NoError(t, err, "Данные неизвестного типа РЅРµ валидируются вглубь") + + b, err = json.Marshal(s1) + require.NoError(t, err) + s2 := schema.New() + err = json.Unmarshal(b, s2) + require.NoError(t, err) + b, err = json.Marshal(s2) + require.NoError(t, err) + assert.Equal(t, "unknown", s2.GetType().Name(), "Схема неизвестного типа должна определяться как unknown") + assert.Equal(t, s1, s2, "Схема РЅРµ должна меняться РїСЂРё повторном маршалинге") + + field.Register(&field.ObjectType{}) + s3 := schema.New() + err = json.Unmarshal(b, s3) + require.NoError(t, err) + assert.Equal(t, "object", s3.GetType().Name(), "Схема должна восстановить тип object РїСЂРё восстановление регистрации типа") + assert.Equal(t, sch, s3, "Схема должна восстановиться РїСЂРё восстановление регистрации типа") +} + +func TestSchema_ValidOptions(t *testing.T) { + t.Run("Valid Options", func(t *testing.T) { + schm := `{ + "type": "object", + "params": { + "fields": { + "required": { + "options": { + "required": true + }, + "type": "string" + }, + "readonly": { + "options": { + "readonly": true + }, + "type": "string" + }, + "enum": { + "options": { + "enum": [{ + "name": "One", + "value": "one" + }, + { + "name": "Two", + "value": "two" + } + ] + }, + "type": "string" + } + } + } +}` + + s := schema.New() + err := json.Unmarshal([]byte(schm), s) + require.NoError(t, err) + + required := s.GetField("required") + readonly := s.GetField("readonly") + enum := s.GetField("enum") + + require.NotEmpty(t, required.Options) + require.NotEmpty(t, readonly.Options) + require.NotEmpty(t, enum.Options) + }) + + t.Run("Invalid Options", func(t *testing.T) { + schm := `{ + "type": "object", + "params": { + "fields": { + "required": { + "options": { + "required": false + }, + "type": "string" + }, + "readonly": { + "options": { + "readonly": false + }, + "type": "string" + } + } + } +}` + + s := schema.New() + err := json.Unmarshal([]byte(schm), s) + require.NoError(t, err) + + required := s.GetField("required") + readonly := s.GetField("readonly") + + require.Empty(t, required.Options) + require.Empty(t, readonly.Options) + }) + + t.Run("Required Enum Name", func(t *testing.T) { + schm := `{ + "type": "object", + "params": { + "fields": { + "enum": { + "options": { + "enum": [{ + "value": "one" + }, + { + "value": "two" + } + ] + }, + "type": "string" + } + } + } +}` + s := schema.New() + err := json.Unmarshal([]byte(schm), s) + require.Error(t, err) + assert.Contains(t, err.Error(), "enum name is required") + }) +} + +func TestSchema_Condition(t *testing.T) { + sch := schema.New( + "type", field.String(modify.TrimSpace()), + "a", field.Number(field.NumberFormatInt).SetCondition("type contains 'a'"), + "b", field.Number(field.NumberFormatInt, validate.Required()).SetCondition("type contains 'b'"), + "c", field.Number(field.NumberFormatInt).SetCondition("a==10"), + "obj", field.Object( + "a", field.Number(field.NumberFormatInt).SetCondition("type contains 'a'"), + "b", field.Number(field.NumberFormatInt).SetCondition("type contains 'b'"), + "c", field.Number(field.NumberFormatInt).SetCondition("_.a < 10"), + "d", field.Number(field.NumberFormatInt, modify.Default(11)).SetCondition("_.a < 10"), + ), + "obj3", field.Object( + "fld1", field.Number(field.NumberFormatInt), + ).SetCondition("obj.d > 10"), + ) + + tests := []struct { + name string + data map[string]interface{} + want map[string]interface{} + wantErr bool + }{ + {"type a", + map[string]interface{}{ + "type": "a", + "a": int64(10), + "b": int64(10), + "c": int64(1), + "obj": map[string]interface{}{"a": int64(1), "b": int64(20), "c": int64(11), "d": int64(11)}, + "obj3": map[string]interface{}{"fld1": int64(6)}, + }, + map[string]interface{}{ + "type": "a", + "a": int64(10), + "c": int64(1), + "obj": map[string]interface{}{"a": int64(1), "c": int64(11), "d": int64(11)}, + "obj3": map[string]interface{}{"fld1": int64(6)}, + }, + false}, + {"type b", + map[string]interface{}{ + "type": "b", + "a": int64(10), + "b": int64(10), + "c": int64(1), + "obj": map[string]interface{}{"a": int64(1), "b": int64(20), "c": int64(11), "d": int64(11)}, + "obj3": map[string]interface{}{"fld1": int64(6)}, + }, + map[string]interface{}{ + "type": "b", + "b": int64(10), + "obj": map[string]interface{}{"b": int64(20)}, + }, + false}, + {"type ab + default", + map[string]interface{}{ + "type": " ab ", + "a": int64(1), + "b": int64(10), + "c": int64(1), + "obj": map[string]interface{}{"a": int64(1), "b": int64(20), "c": int64(11)}, + "obj3": map[string]interface{}{"fld1": int64(6)}, + }, + map[string]interface{}{ + "type": "ab", + "a": int64(1), + "b": int64(10), + "obj": map[string]interface{}{"a": int64(1), "b": int64(20), "c": int64(11), "d": int64(11)}, + "obj3": map[string]interface{}{"fld1": int64(6)}, + }, + false}, + } + + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sch.ToValue(ctx, tt.data) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } + +} + +func TestSchema_Inline(t *testing.T) { + sch := schema.New( + "a", field.String(), + "b", field.String(), + "obj", field.Object( + true, + "c", field.String(), + "d", field.Number(field.NumberFormatInt), + "inner_obj", field.Object( + true, + "f", field.String(), + ), + ).SetCondition("a == 'universe'"), + "overlap", field.Object( + "obj1", field.Object( + true, + "f1", field.Number(field.NumberFormatInt), + "f2", field.String(), + ), + "obj2", field.Object( + true, + "f1", field.Number(field.NumberFormatInt), + "f2", field.Number(field.NumberFormatInt), + ), + ), + "arr", field.Array( + field.Object( + true, + "x", field.String(), + ), + ), + ) + + tests := []struct { + name string + data map[string]interface{} + want map[string]interface{} + wantErr bool + }{ + {"Condition success", + map[string]interface{}{"a": "universe", "b": "life", "c": "everything", "d": int64(42)}, + map[string]interface{}{"a": "universe", "b": "life", "c": "everything", "d": int64(42)}, + false, + }, + {"Condition fail", + map[string]interface{}{"a": "life", "b": "universe", "c": "everything", "d": int64(42)}, + map[string]interface{}{"a": "life", "b": "universe"}, + false, + }, + {"Condition success, level 2 inline", + map[string]interface{}{"a": "universe", "b": "life", "c": "everything", "d": int64(42), "f": "some"}, + map[string]interface{}{"a": "universe", "b": "life", "c": "everything", "d": int64(42), "f": "some"}, + false, + }, + {"Condition fail, level 2 inline", + map[string]interface{}{"a": "life", "b": "universe", "c": "everything", "d": int64(42), "f": "some"}, + map[string]interface{}{"a": "life", "b": "universe"}, + false, + }, + {"Overlapped", + map[string]interface{}{"overlap": map[string]interface{}{"f1": 42}}, + map[string]interface{}{"overlap": map[string]interface{}{"f1": int64(42)}}, + false, + }, + {"Overlapped, type conflict", + map[string]interface{}{"overlap": map[string]interface{}{"f1": 42, "f2": "everything"}}, + nil, + true, + }, + {"Array, ignore inline", + map[string]interface{}{"a": "life", "b": "universe", "c": "everything", "d": int64(42), "x": "some", "arr": []interface{}{map[string]interface{}{"x": "some"}}}, + map[string]interface{}{"a": "life", "b": "universe", "arr": []interface{}{map[string]interface{}{"x": "some"}}}, + false, + }, + } + + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sch.ToValue(ctx, tt.data) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSchema_Introspect(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + schema *schema.Schema + want map[string]interface{} + wantFields []string + dontWantFields []string + wantErr bool + }{ + {"single true condition", + map[string]interface{}{ + "a": "b", + "b": "b", + }, + schema.New( + "a", field.String(), + "b", field.String().SetCondition("a == 'b'"), + ), + map[string]interface{}{ + "a": "b", + "b": "b", + }, + []string{"a", "b"}, + []string{}, + false}, + {"single false condition", + map[string]interface{}{ + "a": "a", + "b": "b", + }, + schema.New( + "a", field.String(), + "b", field.String().SetCondition("a == 'b'"), + ), + map[string]interface{}{ + "a": "a", + }, + []string{"a"}, + []string{"b"}, + false}, + {"multiple true conditions", + map[string]interface{}{ + "a": "a", + "b": "b", + "c": "c", + "d": "d", + }, + schema.New( + "a", field.String(), + "b", field.String().SetCondition("a == 'a'"), + "c", field.String().SetCondition("b == 'b'"), + "d", field.String().SetCondition("c == 'c'"), + ), + map[string]interface{}{ + "a": "a", + "b": "b", + "c": "c", + "d": "d", + }, + []string{"a", "b", "c", "d"}, + []string{}, + false}, + {"multiple conditions some true", + map[string]interface{}{ + "a": "a", + "b": "bb", + "c": "c", + "d": "d", + }, + schema.New( + "a", field.String(), + "b", field.String().SetCondition("a == 'a'"), + "c", field.String().SetCondition("b == 'b'"), + "d", field.String().SetCondition("c == 'c'"), + ), + map[string]interface{}{ + "a": "a", + "b": "bb", + }, + []string{"a", "b"}, + []string{"c", "d"}, + false}, + {"nil data", + nil, + schema.New( + "a", field.String(), + "b", field.String(), + ), + nil, + []string{"a", "b"}, + nil, + false}, + {"empty data", + map[string]interface{}{}, + schema.New( + "a", field.String(), + "b", field.String(), + ), + map[string]interface{}{}, + []string{"a", "b"}, + nil, + false}, + {"data with other fields", + map[string]interface{}{"some": "some"}, + schema.New( + "a", field.String(), + "b", field.String(), + ), + map[string]interface{}{}, + []string{"a", "b"}, + nil, + false}, + {"nil object", + map[string]interface{}{"a": "aa"}, + schema.New( + "a", field.String(), + "j", field.Object( + "aa", field.String(), + "bb", field.Number(field.NumberFormatInt), + ), + ), + map[string]interface{}{"a": "aa"}, + []string{"a", "j", "j.aa", "j.bb"}, + nil, + false}, + { + "object condition", + map[string]interface{}{"key": "a", "object_b": map[string]interface{}{"field1": "a", "field2": "a"}}, + schema.New( + "key", field.String(modify.Default("default")), + "object_b", field.Object( + "field1", field.String(), + "field2", field.String(), + ), + "object_a", field.Object( + "field1", field.String(), + "field2", field.String(), + ).SetCondition("key=='a'"), + ), + map[string]interface{}{"key": "a", "object_b": map[string]interface{}{"field1": "a", "field2": "a"}}, + []string{"key", "object_b", "object_a", "object_b.field1", "object_b.field2"}, + []string{"field1", "field2"}, + false, + }, + { + "object condition with nil data", + nil, + schema.New( + "key", field.String(modify.Default("default")), + "object_b", field.Object( + "field1", field.String(), + "field2", field.String(), + ), + "object_a", field.Object( + "field1", field.String(), + "field2", field.String(), + ).SetCondition("key=='a'"), + ), + nil, + []string{"key", "object_b", "object_b.field1", "object_b.field2"}, + []string{"object_a", "field1", "field2"}, + false, + }, + } + + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotValue, gotSchema, err := tt.schema.Introspect(ctx, tt.data) + require.NoError(t, err) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + for _, f := range tt.wantFields { + fld := gotSchema.GetField(f) + assert.NotNil(t, fld, fmt.Sprintf("поле '%s' должно присутствовать РІ схеме", f)) + } + + for _, f := range tt.dontWantFields { + fld := gotSchema.GetField(f) + assert.Nil(t, fld, fmt.Sprintf("поле '%s' должно отсутствовать РІ схеме", f)) + } + + //b, err := json.MarshalIndent(got.Schema, "", " ") + //require.NoError(t, err) + //fmt.Printf("---\n%s\n---\n", b) + assert.Equal(t, tt.want, gotValue) + }) + } + +} + +func TestSchema_IntrospectObjectArray(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + schema *schema.Schema + want map[string]interface{} + wantParams []string + wantErr bool + }{ + { + "simple", + map[string]interface{}{"array": []interface{}{map[string]interface{}{"field1": "a", "field2": "a"}}}, + schema.New( + "array", field.Array( + field.Object( + "field1", field.String(), + "field2", field.String(), + ), + )), + map[string]interface{}{"array": []interface{}{map[string]interface{}{"field1": "a", "field2": "a"}}}, + []string{"field1", "field2"}, + false, + }, + { + "empty data", + nil, + schema.New( + "array", field.Array( + field.Object( + "field1", field.String(), + "field2", field.String(), + ), + )), + nil, + []string{"field1", "field2"}, + false, + }, + } + + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotValue, gotSchema, err := tt.schema.Introspect(ctx, tt.data) + require.NoError(t, err) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + for _, f := range tt.wantParams { + fld := gotSchema.GetField("array") + p, ok := fld.Params.(*field.ArrayParameters).Item.Params.(*field.ObjectParameters) + assert.True(t, ok) + assert.Contains(t, p.Fields, f, fmt.Sprintf("поле '%s' должно присутствовать РІ параметрах Item", f)) + } + assert.Equal(t, tt.want, gotValue) + }) + } + +} + +func TestSchema_Load(t *testing.T) { + f1 := schema.New( + "f", field.String(), + "s3", field.Object("a1", field.Number(field.NumberFormatInt), "f", field.String()).WithIncludes("f2"), + ) + f2 := schema.New("a", field.String()) + + sch := schema.NewFromField(field.Object( + "s1", field.String(), + "s2", field.String(), + "s3", field.Object("a1", field.String(), "a2", field.String()), + "s4", field.Array(field.Object().WithIncludes("f2")), + ).WithIncludes("f1", "f2"), + ) + + loader := field.MultiLoader( + field.LoaderFunc(func(ref string) (fs []*field.Field, err error) { + if ref == "f1" { + f := f1.Field // copy + return []*field.Field{&f}, nil + } + return nil, fmt.Errorf("invalid schema reference: %s", ref) + }), + field.LoaderFunc(func(ref string) (fs []*field.Field, err error) { + if ref == "f2" { + f := f2.Field // copy + return []*field.Field{&f}, nil + } + return nil, fmt.Errorf("invalid schema reference: %s", ref) + }), + ) + schema.SetDefaultLoader(loader) + + err := sch.Load(nil) + require.NoError(t, err) + + //b, _ := json.MarshalIndent(sch, "", " ") + //fmt.Println(string(b)) + + assert.NotNil(t, sch.GetField("s1")) + assert.NotNil(t, sch.GetField("s2")) + assert.NotNil(t, sch.GetField("f")) + assert.NotNil(t, sch.GetField("a")) + assert.NotNil(t, sch.GetField("s3")) + assert.NotNil(t, sch.GetField("s3.f")) + assert.NotNil(t, sch.GetField("s3.a")) + { + f := sch.GetField("s3.a1") + require.NotNil(t, f) + assert.Equal(t, f.GetType(), &field.StringType{}) + } + assert.NotNil(t, sch.GetField("s4.a")) + +} + +func TestSchema_WithIncludesCircle(t *testing.T) { + f1 := schema.New("f2", field.Object().WithIncludes("f2")) + f2 := schema.New("f3", field.Object().WithIncludes("f3")) + f3 := schema.New("f1", field.Object().WithIncludes("f1")) + + loader := field.MultiLoader( + field.LoaderFunc(func(ref string) (fs []*field.Field, err error) { + if ref == "f1" { + f := f1.Field // copy + return []*field.Field{&f}, nil + } + return nil, fmt.Errorf("invalid schema reference: %s", ref) + }), + field.LoaderFunc(func(ref string) (fs []*field.Field, err error) { + if ref == "f2" { + f := f2.Field // copy + return []*field.Field{&f}, nil + } + return nil, fmt.Errorf("invalid schema reference: %s", ref) + }), + field.LoaderFunc(func(ref string) (fs []*field.Field, err error) { + if ref == "f3" { + f := f3.Field // copy + return []*field.Field{&f}, nil + } + return nil, fmt.Errorf("invalid schema reference: %s", ref) + }), + ) + schema.SetDefaultLoader(loader) + sch := schema.NewFromField(field.Object().WithIncludes("f1")) + + err := sch.Load(nil) + require.Error(t, err) + assert.EqualError(t, err, "limit for included fields exceeded") +} + +func TestSchema_EnumUIOptions(t *testing.T) { + schm := `{ + "type": "object", + "params": { + "fields": { + "enum": { + "options": { + "enum": [{ + "name": "1", + "value": "one", + "ui" : { + "color": "color", + "icon": "icon", + "spin": true, + "blink": false + } + }, + { + "name": "2", + "value": "two" + } + ] + }, + "type": "string" + } + } + } +}` + s := schema.New() + err := json.Unmarshal([]byte(schm), s) + require.NoError(t, err) +} diff --git a/pkg/schema/validate/array.go b/pkg/schema/validate/array.go new file mode 100644 index 0000000000000000000000000000000000000000..09e17e289cdfeca4cbe743271b77300a4cb7a0d8 --- /dev/null +++ b/pkg/schema/validate/array.go @@ -0,0 +1,51 @@ +package validate + +import ( + "context" + "fmt" + "reflect" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +type maxItems int + +func MaxItems(max int) Validator { + v := maxItems(max) + return &v +} + +func (t maxItems) Validate(_ context.Context, _ *field.Field, value interface{}) error { + if value == nil { + return nil + } + v := reflect.ValueOf(value) + if !v.IsValid() || v.Kind() != reflect.Array && v.Kind() != reflect.Slice { + return fmt.Errorf("incorrect type: \"%s\", expected \"array\"", v.Kind()) + } + if t > 0 && v.Len() > int(t) { + return fmt.Errorf("maximum elements number is %d", t) + } + return nil +} + +type minItems int + +func MinItems(min int) Validator { + v := minItems(min) + return &v +} + +func (t minItems) Validate(_ context.Context, _ *field.Field, value interface{}) error { + if value == nil { + return nil + } + v := reflect.ValueOf(value) + if !v.IsValid() || v.Kind() != reflect.Array && v.Kind() != reflect.Slice { + return fmt.Errorf("incorrect type: \"%s\", expected \"array\"", v.Kind()) + } + if t > 0 && v.Len() < int(t) { + return fmt.Errorf("minimum elements number is %d", t) + } + return nil +} diff --git a/pkg/schema/validate/array_test.go b/pkg/schema/validate/array_test.go new file mode 100644 index 0000000000000000000000000000000000000000..57b99445d4681b3994cb9cc5e3c35822a38a4900 --- /dev/null +++ b/pkg/schema/validate/array_test.go @@ -0,0 +1,80 @@ +package validate + +import ( + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArray(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + }{ + {"Max Array", field.Array(field.String()).AddOptions(MaxItems(2)), []interface{}{"a", "b"}, false}, + {"Max Array (invalid)", field.Array(field.String()).AddOptions(MaxItems(2)), []interface{}{"a", "b", "c"}, true}, + {"Max Array (negative max number)", field.Array(field.String()).AddOptions(MaxItems(-2)), []interface{}{"a", "b", "c"}, false}, + {"Max Array (zero max number)", field.Array(field.String()).AddOptions(MaxItems(0)), []interface{}{"a", "b", "c"}, false}, + + {"Min Array", field.Array(field.String()).AddOptions(MinItems(2)), []interface{}{"a", "b"}, false}, + {"Min Array (invalid)", field.Array(field.String()).AddOptions(MinItems(2)), []interface{}{"a"}, true}, + {"Min Array (negative max number)", field.Array(field.String()).AddOptions(MinItems(-2)), []interface{}{"a", "b", "c"}, false}, + {"Min Array (zero max number)", field.Array(field.String()).AddOptions(MinItems(0)), []interface{}{"a", "b", "c"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := field.Decode(nil, tt.field, tt.data) + require.NoError(t, err) + err = Validate(nil, tt.field, got) + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestArrayValidate(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + error string + }{ + {"Nil Max Items", field.Array(field.String()).AddOptions(MaxItems(1)), nil, false, ""}, + {"Nil Min Items", field.Array(field.String()).AddOptions(MinItems(1)), nil, false, ""}, + {"Array Max Items", field.Array(field.String()).AddOptions(MaxItems(1)), [1]interface{}{1}, true, "validation error: incorrect type: \"array\", expected \"[]interface{}\""}, + {"Array Min Items", field.Array(field.String()).AddOptions(MinItems(1)), [1]interface{}{1}, true, "validation error: incorrect type: \"array\", expected \"[]interface{}\""}, + {"Slice Max Items", field.Array(field.String()).AddOptions(MaxItems(0)), []interface{}{}, false, ""}, + {"Slice Min Items", field.Array(field.String()).AddOptions(MinItems(0)), []interface{}{}, false, ""}, + {"Bool Max Items", field.Array(field.String()).AddOptions(MaxItems(1)), true, true, "validation error: incorrect type: \"bool\", expected \"array\""}, + {"Bool Min Items", field.Array(field.String()).AddOptions(MinItems(1)), true, true, "validation error: incorrect type: \"bool\", expected \"array\""}, + {"Int Max Items", field.Array(field.String()).AddOptions(MaxItems(1)), 1, true, "validation error: incorrect type: \"int\", expected \"array\""}, + {"Int Min Items", field.Array(field.String()).AddOptions(MinItems(1)), 1, true, "validation error: incorrect type: \"int\", expected \"array\""}, + {"Float Max Items", field.Array(field.String()).AddOptions(MaxItems(1)), 1.0, true, "validation error: incorrect type: \"float64\", expected \"array\""}, + {"Float Min Items", field.Array(field.String()).AddOptions(MinItems(1)), 1.0, true, "validation error: incorrect type: \"float64\", expected \"array\""}, + {"String Max Items", field.Array(field.String()).AddOptions(MaxItems(1)), "1", true, "validation error: incorrect type: \"string\", expected \"array\""}, + {"String Min Items", field.Array(field.String()).AddOptions(MinItems(1)), "1", true, "validation error: incorrect type: \"string\", expected \"array\""}, + {"Map Max Items", field.Array(field.String()).AddOptions(MaxItems(1)), map[int]string{1: "1"}, true, "validation error: incorrect type: \"map\", expected \"array\""}, + {"Map Min Items", field.Array(field.String()).AddOptions(MinItems(1)), map[int]string{1: "1"}, true, "validation error: incorrect type: \"map\", expected \"array\""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.error) + } + if !tt.wantErr { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/schema/validate/enum.go b/pkg/schema/validate/enum.go new file mode 100644 index 0000000000000000000000000000000000000000..76d2ecc698792346e1f9dd60940e47d050593b96 --- /dev/null +++ b/pkg/schema/validate/enum.go @@ -0,0 +1,68 @@ +package validate + +import ( + "context" + "fmt" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/pkg/errors" +) + +type EnumUI struct { + Color string `json:"color,omitempty"` + Icon string `json:"icon,omitempty"` + Spin bool `json:"spin,omitempty"` + Blink bool `json:"blink,omitempty"` +} + +type EnumOpt struct { + Name string `json:"name"` + Value interface{} `json:"value"` + UI *EnumUI `json:"ui,omitempty"` +} + +func (o EnumOpt) String() string { + return fmt.Sprintf("%s", o.Value) +} + +type enum []EnumOpt + +func Enum(opts ...EnumOpt) Validator { + var e enum + for _, i := range opts { + e = append(e, i) + } + return &e +} + +func (t enum) Validate(ctx context.Context, f *field.Field, value interface{}) error { + // Значение может отсутствовать, что РЅРµ является ошибкой + if value == nil { + return nil + } + + if f.AdditionalValues { + return nil + } + + for _, i := range t { + enumValue, err := field.Decode(ctx, f, i.Value) + if err != nil { + return fmt.Errorf("error decode option value: %w", err) + } + if value == enumValue { + return nil + } + } + + return errors.Errorf("value required to be one of %s", t) +} + +func (t enum) ValidateOption() error { + for _, i := range t { + if i.Name == "" { + return errors.Errorf("enum name is required") + } + } + return nil +} diff --git a/pkg/schema/validate/enum_test.go b/pkg/schema/validate/enum_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a473dc015f80e92a2b0845228b02023f3f92b63a --- /dev/null +++ b/pkg/schema/validate/enum_test.go @@ -0,0 +1,69 @@ +package validate + +import ( + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/require" +) + +func TestEnum(t *testing.T) { + stringEnum := Enum( + EnumOpt{ + Name: "N 1", + Value: "n1", + }, + EnumOpt{ + Name: "N 2", + Value: "n2", + }, + ) + intEnum := Enum( + EnumOpt{ + Name: "N 1", + Value: 1, + }, + EnumOpt{ + Name: "N 2", + Value: 2, + }, + ) + floatEnum := Enum( + EnumOpt{ + Name: "N 1", + Value: 1.1, + }, + EnumOpt{ + Name: "N 2", + Value: int(2), + }, + ) + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + }{ + {"Correct string", field.String(stringEnum), "n1", false}, + {"Correct int", field.Number(field.NumberFormatInt, intEnum), 1, false}, + {"Correct float", field.Number(field.NumberFormatFloat, floatEnum), 1.1, false}, + {"Correct float", field.Number(field.NumberFormatFloat, floatEnum), int(2), false}, + {"Incorrect string", field.String(stringEnum), "n3", true}, + {"Incorrect int", field.Number(field.NumberFormatInt, intEnum), 3, true}, + {"Incorrect float", field.Number(field.NumberFormatFloat, floatEnum), 1.0, true}, + {"Incorrect enum value", field.String(intEnum), "n1", true}, + {"Incorrect enum value", field.String(stringEnum).SetAdditionalValues(), "non enum value", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := field.Decode(nil, tt.field, tt.data) + require.NoError(t, err) + err = Validate(nil, tt.field, got) + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} diff --git a/pkg/schema/validate/number.go b/pkg/schema/validate/number.go new file mode 100644 index 0000000000000000000000000000000000000000..81ab150b21d0af3a5e6cc91dac664c3a479a9f11 --- /dev/null +++ b/pkg/schema/validate/number.go @@ -0,0 +1,136 @@ +package validate + +import ( + "context" + "fmt" + "math" + "reflect" + "strconv" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +type max float64 + +func Max(m float64) Validator { + v := max(m) + return &v +} + +func (t max) Validate(_ context.Context, field *field.Field, value interface{}) error { + var num float64 + switch v := value.(type) { + case int: + num = float64(v) + case int8: + num = float64(v) + case int32: + num = float64(v) + case int64: + num = float64(v) + case uint: + num = float64(v) + case uint8: + num = float64(v) + case uint32: + num = float64(v) + case uint64: + num = float64(v) + case float32: + num = float64(v) + case float64: + num = v + case nil: + return nil + default: + return fmt.Errorf("incorrect type: \"%s\", expected \"number\"", reflect.ValueOf(v).Kind()) + } + if num > float64(t) { + return fmt.Errorf("maximum number is %s, got %s", strconv.FormatFloat(float64(t), 'f', -1, 64), strconv.FormatFloat(float64(num), 'f', -1, 64)) + } + return nil + +} + +type min float64 + +func Min(m float64) Validator { + v := min(m) + return &v +} + +func (t min) Validate(_ context.Context, fld *field.Field, value interface{}) error { + var num float64 + switch v := value.(type) { + case int: + num = float64(v) + case int8: + num = float64(v) + case int32: + num = float64(v) + case int64: + num = float64(v) + case uint: + num = float64(v) + case uint8: + num = float64(v) + case uint32: + num = float64(v) + case uint64: + num = float64(v) + case float32: + num = float64(v) + case float64: + num = v + case nil: + return nil + default: + return fmt.Errorf("incorrect type: \"%s\", expected \"number\"", reflect.ValueOf(v).Kind()) + } + if num < float64(t) { + return fmt.Errorf("minimum number is %s, got %s", strconv.FormatFloat(float64(t), 'f', -1, 64), strconv.FormatFloat(float64(num), 'f', -1, 64)) + } + return nil +} + +type multipleOf float64 + +func MultipleOf(d float64) Validator { + v := multipleOf(d) + return &v +} + +func (t multipleOf) Validate(_ context.Context, field *field.Field, value interface{}) error { + var num float64 + switch v := value.(type) { + case int: + num = float64(v) + case int8: + num = float64(v) + case int32: + num = float64(v) + case int64: + num = float64(v) + case uint: + num = float64(v) + case uint8: + num = float64(v) + case uint32: + num = float64(v) + case uint64: + num = float64(v) + case float32: + num = float64(v) + case float64: + num = v + case nil: + return nil + default: + return fmt.Errorf("incorrect type: \"%s\", expected \"number\"", reflect.ValueOf(v).Kind()) + } + if math.Mod(num, float64(t)) != 0 { + return fmt.Errorf("number must be a multiple of %f", t) + + } + return nil +} diff --git a/pkg/schema/validate/number_test.go b/pkg/schema/validate/number_test.go new file mode 100644 index 0000000000000000000000000000000000000000..edbd218cf3b19ecd5b412e5a7e74015a8dfaecb5 --- /dev/null +++ b/pkg/schema/validate/number_test.go @@ -0,0 +1,99 @@ +package validate + +import ( + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNumber(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + }{ + {"Max Int", field.Number("int").AddOptions(Max(5.0)), 6, true}, + {"Min Int", field.Number("float").AddOptions(Min(10)), 9.0, true}, + + {"Max Float", field.Number("float").AddOptions(Max(5.5)), 5.6, true}, + {"Min Float", field.Number("float").AddOptions(Min(10.1)), 9.9, true}, + + {"MultipleOf Int", field.Number("float").AddOptions(MultipleOf(5)), 1.0, true}, + {"MultipleOf Int", field.Number("float").AddOptions(MultipleOf(5)), 10.0, false}, + + {"MultipleOf Float", field.Number("float").AddOptions(MultipleOf(0.01)), 0.2, true}, + {"MultipleOf Float", field.Number("float").AddOptions(MultipleOf(0.05)), 0.1, false}, + + {"MinMaxMultipleOf Int", field.Number("int").AddOptions(Max(5), Min(2), MultipleOf(5)), 6, true}, + {"MinMaxMultipleOf Int", field.Number("int").AddOptions(Max(5), Min(2), MultipleOf(2)), 4, false}, + {"MinMaxMultipleOf Int", field.Number("int").AddOptions(Max(5), Min(1), MultipleOf(2)), -2, true}, + {"MinMaxMultipleOf Int", field.Number("int").AddOptions(Max(5), Min(-1), MultipleOf(2)), 0, false}, + {"MinMaxMultipleOf Int", field.Number("int").AddOptions(Max(10), Min(7), MultipleOf(5)), 6, true}, + {"MinMaxMultipleOf Float", field.Number("int").AddOptions(Max(5.5), Min(2.3), MultipleOf(5)), 6, true}, + {"MinMaxMultipleOf Float", field.Number("float").AddOptions(Max(10.1), Min(7.7), MultipleOf(0.1)), 6.1, true}, + + {"Enum miss", field.Number("int").AddOptions(Enum(EnumOpt{Name: "N 1", Value: 1}, EnumOpt{Name: "N 2", Value: 2})), 3, true}, + {"Enum match", field.Number("int").AddOptions(Enum(EnumOpt{Name: "N 1", Value: 1}, EnumOpt{Name: "N 2", Value: 2})), 2, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := field.Decode(nil, tt.field, tt.data) + require.NoError(t, err) + err = Validate(nil, tt.field, got) + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestNumberValidate(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + error string + }{ + {"Nil Max", field.Number("int").AddOptions(Max(1.0)), nil, false, ""}, + {"Nil Min", field.Number("int").AddOptions(Min(1.0)), nil, false, ""}, + {"Nil MultipleOf", field.Number("int").AddOptions(MultipleOf(1)), nil, false, ""}, + {"Int Max", field.Number("int").AddOptions(Max(1.0)), 1, false, ""}, + {"Int Min", field.Number("int").AddOptions(Min(1.0)), 1, false, ""}, + {"Int MultipleOf", field.Number("int").AddOptions(Min(1.0)), 1, false, ""}, + {"FlРѕat Max", field.Number("float").AddOptions(Max(1.0)), 1.0, false, ""}, + {"FlРѕat Min", field.Number("float").AddOptions(Min(1.0)), 1.0, false, ""}, + {"FlРѕat MultipleOf", field.Number("float").AddOptions(Min(1.0)), 1.0, false, ""}, + {"Bool Max Num", field.Number("int").AddOptions(Max(1.0)), true, true, "validation error: incorrect type: \"bool\", expected \"number\""}, + {"Bool Min Num", field.Number("int").AddOptions(Min(1.0)), true, true, "validation error: incorrect type: \"bool\", expected \"number\""}, + {"Bool MultipleOf Num", field.Number("int").AddOptions(MultipleOf(1)), true, true, "validation error: incorrect type: \"bool\", expected \"number\""}, + {"String Max Num", field.Number("int").AddOptions(Max(1.0)), "1", true, "validation error: incorrect type: \"string\", expected \"number\""}, + {"String Min Num", field.Number("int").AddOptions(Min(1.0)), "1", true, "validation error: incorrect type: \"string\", expected \"number\""}, + {"String MultipleOf Num", field.Number("int").AddOptions(MultipleOf(1)), "1", true, "validation error: incorrect type: \"string\", expected \"number\""}, + {"Array Max Num", field.Number("int").AddOptions(Max(1.0)), [1]int{1}, true, "validation error: incorrect type: \"array\", expected \"number\""}, + {"Array Min Num", field.Number("int").AddOptions(Min(1.0)), [1]int{1}, true, "validation error: incorrect type: \"array\", expected \"number\""}, + {"Slice Max Num", field.Number("int").AddOptions(Max(1.0)), []int{1}, true, "validation error: incorrect type: \"slice\", expected \"number\""}, + {"Slice Min Num", field.Number("int").AddOptions(Min(1.0)), []int{1}, true, "validation error: incorrect type: \"slice\", expected \"number\""}, + {"Slice MultipleOf Num", field.Number("int").AddOptions(MultipleOf(1)), []int{}, true, "validation error: incorrect type: \"slice\", expected \"number\""}, + {"Map Max Num", field.Number("int").AddOptions(Max(1.0)), map[int]int{}, true, "validation error: incorrect type: \"map\", expected \"number\""}, + {"Map Min Num", field.Number("int").AddOptions(Min(1.0)), map[int]int{}, true, "validation error: incorrect type: \"map\", expected \"number\""}, + {"Map MultipleOf Num", field.Number("int").AddOptions(MultipleOf(1)), map[int]int{}, true, "validation error: incorrect type: \"map\", expected \"number\""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.error) + } + if !tt.wantErr { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/schema/validate/readonly.go b/pkg/schema/validate/readonly.go new file mode 100644 index 0000000000000000000000000000000000000000..8738d747dd1d86fad7203f20b9df83cb5d2334a4 --- /dev/null +++ b/pkg/schema/validate/readonly.go @@ -0,0 +1,30 @@ +package validate + +import ( + "context" + "errors" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +type readonly bool + +func ReadOnly() Validator { + r := readonly(true) + return &r +} + +func (r readonly) Validate(_ context.Context, f *field.Field, value interface{}) error { + if value != nil { + return errors.New("value is readonly") + } + return nil + +} + +func (r readonly) ValidateOption() error { + if !r { + return field.ErrSkipOption + } + return nil +} diff --git a/pkg/schema/validate/readonly_test.go b/pkg/schema/validate/readonly_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ee8ca9ba89a16a02375556d365884bb35d378f83 --- /dev/null +++ b/pkg/schema/validate/readonly_test.go @@ -0,0 +1,32 @@ +package validate + +import ( + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/require" +) + +func TestReadonly(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + }{ + {"ReadOnly", field.String(ReadOnly()), nil, false}, + {"ReadOnly error", field.String(ReadOnly()), "1234567", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := field.Decode(nil, tt.field, tt.data) + require.NoError(t, err) + err = Validate(nil, tt.field, got) + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} diff --git a/pkg/schema/validate/required.go b/pkg/schema/validate/required.go new file mode 100644 index 0000000000000000000000000000000000000000..96591e6eaf4134f07523ab87f29e932b8fcee60e --- /dev/null +++ b/pkg/schema/validate/required.go @@ -0,0 +1,48 @@ +package validate + +import ( + "context" + "errors" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +type required bool + +func Required() Validator { + r := required(true) + return &r +} + +func (r required) Validate(_ context.Context, f *field.Field, value interface{}) error { + + if value != nil { + t, ok := f.GetType().(interface{ IsEmpty(interface{}) bool }) + if ok && !t.IsEmpty(value) { + return nil + } + + /* + tt, ok := f.GetType().(interface { + IsEmpty(interface{}) (bool, error) + }) + empty, err := tt.IsEmpty(value) + if err != nil { + return err + } + if ok && !empty { + return nil + } + */ + } + + return errors.New("value is required") + +} + +func (r required) ValidateOption() error { + if !r { + return field.ErrSkipOption + } + return nil +} diff --git a/pkg/schema/validate/required_test.go b/pkg/schema/validate/required_test.go new file mode 100644 index 0000000000000000000000000000000000000000..41a03ab50d6fab906e7f78229b560e4298475dde --- /dev/null +++ b/pkg/schema/validate/required_test.go @@ -0,0 +1,53 @@ +package validate + +import ( + "testing" + "time" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/require" +) + +func TestRequired(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + }{ + {"Array no value", field.Array(field.String(), Required()), nil, true}, + {"Array empty", field.Array(field.String(), Required()), []interface{}{}, true}, + {"Array not empty", field.Array(field.String(), Required()), []interface{}{"a", "b"}, false}, + {"Boolean no value", field.Bool(Required()), nil, true}, + {"Boolean", field.Bool(Required()), false, false}, + {"Location: no value", field.Location(Required()), nil, true}, + //{"Location: empty", field.Location(Required()), map[string]interface{}{}, true}, // РЅРµ имеет смысла, РїСЂРё Decode вернется ошибка если объект пустой + {"Location: not empty", field.Location(Required()), &field.GeoObject{Address: "addr"}, false}, + {"Number (int) no value", field.Number(field.NumberFormatInt, Required()), nil, true}, + {"Number (int) empty", field.Number(field.NumberFormatInt, Required()), 0, false}, + {"Number (int) not empty", field.Number(field.NumberFormatInt, Required()), 42, false}, + {"Number (float) no value", field.Number(field.NumberFormatFloat, Required()), nil, true}, + {"Number (float) empty", field.Number(field.NumberFormatFloat, Required()), 0.0, false}, + {"Number (float) not empty", field.Number(field.NumberFormatFloat, Required()), 42.2, false}, + {"Object no value", field.Object("a", field.String(), "b", field.Bool(), Required()), nil, true}, + {"Object no value", field.Object("a", field.String(), "b", field.Bool(), Required()), map[string]interface{}{}, true}, + {"Object not empty", field.Object("a", field.String(), "b", field.Bool(), Required()), map[string]interface{}{"b": true}, false}, + {"String no value", field.String(Required()), nil, true}, + {"String empty", field.String(Required()), "", true}, + {"String not empty", field.String(Required()), "1234567", false}, + {"Time no value", field.Time(Required()), nil, true}, + {"Time not empty", field.Time(Required()), time.Now().Format(time.RFC3339), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := field.Decode(nil, tt.field, tt.data) + require.NoError(t, err) + err = Validate(nil, tt.field, got) + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} diff --git a/pkg/schema/validate/string.go b/pkg/schema/validate/string.go new file mode 100644 index 0000000000000000000000000000000000000000..f05ca540034e3cfab7e57c1aa324bcb343b97431 --- /dev/null +++ b/pkg/schema/validate/string.go @@ -0,0 +1,71 @@ +package validate + +import ( + "context" + "fmt" + "reflect" + "unicode/utf8" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/pkg/errors" +) + +type maxLength int + +func MaxLength(max int) Validator { + v := maxLength(max) + return &v +} + +func (t maxLength) Validate(_ context.Context, field *field.Field, value interface{}) error { + if s, ok := value.(string); ok { + n := utf8.RuneCountInString(s) + if t > 0 && n > int(t) { + return fmt.Errorf("maximum string length is %d, got \"%s\" (length=%d)", t, s, len(s)) + } + return nil + } + return fmt.Errorf("incorrect type: \"%s\", expected \"string\"", reflect.ValueOf(value).Kind()) +} + +type minLength int + +func MinLength(max int) Validator { + v := minLength(max) + return &v +} + +func (t minLength) Validate(_ context.Context, field *field.Field, value interface{}) error { + if s, ok := value.(string); ok { + n := utf8.RuneCountInString(s) + if n < int(t) { + return fmt.Errorf("minimum string length is %d, got \"%s\" (length=%d)", t, s, len(s)) + } + return nil + } + return fmt.Errorf("incorrect type: \"%s\", expected \"string\"", reflect.ValueOf(value).Kind().String()) +} + +type schema bool + +func Schema() Validator { + v := schema(true) + return &v +} + +func (t schema) Validate(_ context.Context, _ *field.Field, value interface{}) error { + if value == nil { + return nil + } + if s, ok := value.(string); ok { + if s == "" { + return nil + } + sch := field.Object() + if err := sch.UnmarshalJSON([]byte(s)); err != nil { + return errors.New("value is not valid schema") + } + return nil + } + return errors.Errorf("incorrect type: \"%s\", expected string", reflect.ValueOf(value).Kind()) +} diff --git a/pkg/schema/validate/string_test.go b/pkg/schema/validate/string_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b67e8c8d4f5056c61d5483a536701777d716bead --- /dev/null +++ b/pkg/schema/validate/string_test.go @@ -0,0 +1,160 @@ +package validate + +import ( + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestString(t *testing.T) { + invalidOptionsSchema := `{ + "type": "object", + "params": { + "fields": { + "required": { + "options": { + "err": true + }, + "type": "string" + } + } + } +}` + requiredOptionsSchema := `{ + "type": "object", + "params": { + "fields": { + "enum": { + "options": { + "enum": [{ + "value": "one" + }, + { + "value": "two" + } + ] + }, + "type": "string" + } + } + } +}` + + validSchema := `{ + "type": "object", + "params": { + "fields": { + "required": { + "options": { + "required": true + }, + "type": "string" + }, + "readonly": { + "options": { + "readonly": true + }, + "type": "string" + }, + "enum": { + "options": { + "enum": [{ + "name": "One", + "value": "one" + }, + { + "name": "Two", + "value": "two" + } + ] + }, + "type": "string" + } + } + } +}` + + unknownFieldSchema := `{ + "type": "object", + "params": { + "fields": { + "string": {} + } + } +}` + + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + }{ + {"Length Max", field.String().AddOptions(MaxLength(5)), "1234567", true}, + {"Length Min", field.String().AddOptions(MinLength(10)), "1234", true}, + {"Length MinMax", field.String().AddOptions(MaxLength(6), MinLength(2)), "1234567", true}, + {"Length MinMax", field.String().AddOptions(MaxLength(10), MinLength(7)), "123456", true}, + {"Enum miss", field.String().AddOptions(Enum(EnumOpt{Name: "N 1", Value: "n1"}, EnumOpt{Name: "N 2", Value: "n2"})), "n3", true}, + {"Enum match", field.String().AddOptions(Enum(EnumOpt{Name: "N 1", Value: "n1"}, EnumOpt{Name: "N 2", Value: "n2"})), "n2", false}, + {"Invalid Schema Options", field.String().AddOptions(Schema()), invalidOptionsSchema, true}, + {"Required Schema Options", field.String().AddOptions(Schema()), requiredOptionsSchema, true}, + {"Valid Schema", field.String().AddOptions(Schema()), validSchema, false}, + {"Invalid Schema#1", field.String().AddOptions(Schema()), "test", true}, + {"Unknown Field", field.String().AddOptions(Schema()), unknownFieldSchema, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := field.Decode(nil, tt.field, tt.data) + require.NoError(t, err) + err = Validate(nil, tt.field, got) + if tt.wantErr { + require.Error(t, err) + } + if !tt.wantErr { + require.NoError(t, err) + } + }) + } +} + +func TestStringValidate(t *testing.T) { + tests := []struct { + name string + field *field.Field + data interface{} + wantErr bool + error string + }{ + {"String Length Max", field.String().AddOptions(MaxLength(1)), "1", false, ""}, + {"String Length Min", field.String().AddOptions(MinLength(1)), "1", false, ""}, + {"Nil Length Max", field.String().AddOptions(MaxLength(1)), nil, true, "validation error: incorrect type: \"invalid\", expected \"string\""}, + {"Nil Length Min", field.String().AddOptions(MinLength(1)), nil, true, "validation error: incorrect type: \"invalid\", expected \"string\""}, + {"Int Length Max", field.String().AddOptions(MaxLength(1)), 1, true, "validation error: incorrect type: \"int\", expected \"string\""}, + {"Int Length Min", field.String().AddOptions(MinLength(1)), 1, true, "validation error: incorrect type: \"int\", expected \"string\""}, + {"Float Length Max", field.String().AddOptions(MaxLength(1)), 1.0, true, "validation error: incorrect type: \"float64\", expected \"string\""}, + {"Float Length Min", field.String().AddOptions(MinLength(1)), 1.0, true, "validation error: incorrect type: \"float64\", expected \"string\""}, + {"Bool Length Max", field.String().AddOptions(MaxLength(1)), true, true, "validation error: incorrect type: \"bool\", expected \"string\""}, + {"Bool Length Min", field.String().AddOptions(MinLength(1)), true, true, "validation error: incorrect type: \"bool\", expected \"string\""}, + {"Array Length Max", field.String().AddOptions(MaxLength(1)), [1]string{""}, true, "validation error: incorrect type: \"array\", expected \"string\""}, + {"Array Length Min", field.String().AddOptions(MinLength(1)), [1]string{""}, true, "validation error: incorrect type: \"array\", expected \"string\""}, + {"Slice Length Max", field.String().AddOptions(MaxLength(1)), []string{""}, true, "validation error: incorrect type: \"slice\", expected \"string\""}, + {"Slice Length Min", field.String().AddOptions(MinLength(1)), []string{""}, true, "validation error: incorrect type: \"slice\", expected \"string\""}, + {"Map Length Max", field.String().AddOptions(MaxLength(1)), map[string]string{"": ""}, true, "validation error: incorrect type: \"map\", expected \"string\""}, + {"Map Length Min", field.String().AddOptions(MinLength(1)), map[string]string{"": ""}, true, "validation error: incorrect type: \"map\", expected \"string\""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(nil, tt.field, tt.data) + if tt.wantErr { + require.Error(t, err) + assert.EqualError(t, err, tt.error) + } + if !tt.wantErr { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/schema/validate/validate.go b/pkg/schema/validate/validate.go new file mode 100644 index 0000000000000000000000000000000000000000..65ac4c400d19055deae67ddfcc5678775d0e3376 --- /dev/null +++ b/pkg/schema/validate/validate.go @@ -0,0 +1,99 @@ +package validate + +import ( + "context" + "sort" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "git.perx.ru/perxis/perxis-go/pkg/expr" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +const ValidatorPriority = 2000 + +type Validator interface { + Validate(ctx context.Context, f *field.Field, v interface{}) error +} + +type Validators []Validator + +func (l Validators) Len() int { return len(l) } +func (l Validators) Less(i, j int) bool { + pi, pj := ValidatorPriority, ValidatorPriority + if o, ok := l[i].(field.PriorityOption); ok { + pi = o.GetPriority() + } + if o, ok := l[j].(field.PriorityOption); ok { + pj = o.GetPriority() + } + if pi == pj { + return field.GetOptionName(l[i]) < field.GetOptionName(l[j]) + } + return pi < pj +} +func (l Validators) Swap(i, j int) { l[i], l[j] = l[j], l[i] } + +func getValidators(f *field.Field) Validators { + var vs Validators + for _, o := range f.Options { + if v, ok := o.(Validator); ok { + vs = append(vs, v) + } + } + sort.Sort(vs) + return vs +} + +func validateOptions(ctx context.Context, f *field.Field, v interface{}) error { + var err error + validators := getValidators(f) + for _, i := range validators { + err = i.Validate(ctx, f, v) + if err != nil { + return err + } + } + return nil +} + +func Validate(ctx context.Context, w field.Walker, v interface{}) error { + if m, ok := v.(map[string]interface{}); ok { + ctx = expr.WithEnv(ctx, m) + } + + _, _, err := w.Walk(ctx, v, func(ctx context.Context, fld *field.Field, v interface{}) (res field.WalkFuncResult, err error) { + enabled, _ := fld.IsEnabled(ctx) + + res.Value = v // Значение РЅРµ меняется + + if !enabled { + res.Stop = true + return + } + + if err = validateOptions(ctx, fld, v); err != nil { + return + } + + if validator, ok := fld.GetType().(Validator); ok { + err = validator.Validate(ctx, fld, v) + } + + return + }) + + return errors.Wrap(err, "validation error") +} + +func init() { + field.RegisterOption(minLength(0)) + field.RegisterOption(maxLength(0)) + field.RegisterOption(min(0)) + field.RegisterOption(max(0)) + field.RegisterOption(multipleOf(1)) + field.RegisterOption(enum{}) + field.RegisterOption(readonly(true)) + field.RegisterOption(required(true)) + field.RegisterOption(maxItems(0)) + field.RegisterOption(schema(true)) +} diff --git a/pkg/schema/walk/fn.go b/pkg/schema/walk/fn.go new file mode 100644 index 0000000000000000000000000000000000000000..c3eaf35c569c18ba8ead7c7ff48c08d03f2a9d90 --- /dev/null +++ b/pkg/schema/walk/fn.go @@ -0,0 +1,11 @@ +package walk + +func GenericMerge(c *WalkContext) (err error) { + return +} + +func KeepSrc(c *WalkContext) (err error) { + c.Dst = c.Src + c.Changed = true + return +} diff --git a/pkg/schema/walk/walk.go b/pkg/schema/walk/walk.go new file mode 100644 index 0000000000000000000000000000000000000000..4757742f82efcaffd5faba47eb076ed727783c75 --- /dev/null +++ b/pkg/schema/walk/walk.go @@ -0,0 +1,182 @@ +package walk + +import ( + "context" + "fmt" + "strings" + + "git.perx.ru/perxis/perxis-go/pkg/schema" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" +) + +// DataFunc тип для функции обработки данных +type DataFunc func(c *WalkContext) error + +// FieldConfig описывает какие действия СЃ полем РЅСѓР¶РЅРѕ предпринять РїСЂРё РѕР±С…РѕРґРµ данных +type FieldConfig struct { + Fn DataFunc // Пользовательская функция обработки данных поля +} + +// WalkConfig настройки РѕР±С…РѕРґР° данных +type WalkConfig struct { + // Настройки для полей, РІ качестве ключа указывается абсолютный путь поля + // Например: "a.b.c.1" (числа для slice) + Fields map[string]FieldConfig +} + +// Walker позволяет выполнять РѕР±С…РѕРґ данных для соответствующей схемы +type Walker struct { + schema *schema.Schema + config *WalkConfig + DefaultFn DataFunc // Функция обработки данных применяемая РїРѕ умолчанию +} + +// NewWalker создает экземпляр +func NewWalker(schema *schema.Schema, config *WalkConfig) *Walker { + return &Walker{ + schema: schema, + config: config, + DefaultFn: GenericMerge, + } +} + +// WalkContext контекст РѕР±С…РѕРґР° данных +type WalkContext struct { + Ctx context.Context + Path string // Путь Рє родительским данным + Key interface{} // Ключ или индекс текущих данных + Field *field.Field // Поля схемы соответсвующее текущим данным + Dst interface{} // Данные приемника + Src interface{} // Данные источника + Changed bool // Флаг показывающий, что данные приемника изменились +} + +// GetPath возвращает путь соответсвующий текущему контексту +func (w WalkContext) GetPath(keys ...interface{}) string { + p := make([]string, 0, 10) + + if w.Path != "" { + p = append(p, w.Path) + } + + if w.Key != nil { + p = append(p, fmt.Sprintf("%v", w.Key)) + } + + for _, k := range keys { + p = append(p, fmt.Sprintf("%v", k)) + } + + return strings.Join(p, ".") +} + +// Clone создает РєРѕРїРёСЋ контекста +func (w WalkContext) Clone() *WalkContext { + return &w +} + +// DataWalk выполняет РѕР±С…РѕРґ данных Рё возвращает измененные данные +func (m *Walker) DataWalk(ctx context.Context, dst, src interface{}) (res interface{}, changed bool, err error) { + wc := &WalkContext{ + Ctx: ctx, + Field: &m.schema.Field, + Dst: dst, + Src: src, + } + + err = m.datawalk(wc) + + return wc.Dst, wc.Changed, err +} + +func (m *Walker) datawalk(w *WalkContext) (err error) { + path := w.GetPath() + fn := m.DefaultFn + + fieldCfg, _ := m.config.Fields[path] + + if fieldCfg.Fn != nil { + fn = fieldCfg.Fn + } + + if err = fn(w); err != nil { + return + } + + switch p := w.Field.Params.(type) { + + case *field.ObjectParameters: + d, _ := w.Dst.(map[string]interface{}) + s, _ := w.Src.(map[string]interface{}) + res := make(map[string]interface{}) + + keys := make(map[string]struct{}) + for k := range d { + keys[k] = struct{}{} + } + for k := range s { + keys[k] = struct{}{} + } + + fields := p.GetFields(true) + + for k := range keys { + f, ok := fields[k] + if !ok { + continue + } + + wc := WalkContext{ + Ctx: w.Ctx, + Path: w.GetPath(), + Key: k, + Field: f, + Dst: d[k], + Src: s[k], + } + + if err = m.datawalk(&wc); err != nil { + return + } + + if wc.Dst != nil { + res[k] = wc.Dst + } + + if wc.Changed { + w.Changed = true + } + } + if len(res) > 0 { + w.Dst = res + } + + case *field.ArrayParameters: + d, _ := w.Dst.([]interface{}) + s, _ := w.Src.([]interface{}) + for i, v := range d { + var src_v interface{} + if i < len(s) { + src_v = s[i] + } + wc := WalkContext{ + Ctx: w.Ctx, + Path: w.GetPath(), + Key: i, + Field: p.Item, + Dst: v, + Src: src_v, + } + if err = m.datawalk(&wc); err != nil { + return + } + if wc.Changed { + d[i] = wc.Dst + w.Changed = true + } + } + + } + + return +} diff --git a/pkg/schema/walk/walk_test.go b/pkg/schema/walk/walk_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e20adb462735e83c92c8b04f5c159e336e49f2b0 --- /dev/null +++ b/pkg/schema/walk/walk_test.go @@ -0,0 +1,115 @@ +package walk + +import ( + "context" + "testing" + + "git.perx.ru/perxis/perxis-go/pkg/schema" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWalker_DataWalk(t *testing.T) { + s := schema.New( + "a", field.String(), + "b", field.String(), + "obj1", field.Object( + "a", field.String(), + "b", field.String(), + "obj2", field.Object( + "a", field.String(), + "b", field.String(), + ), + "obj3", field.Object( + "e", field.String(), + ), + ), + "slice", field.Array(field.String()), + "inline_outer_str", field.Object( + true, + "inline_str_1", field.String(), + "inline_obj", field.Object( + true, + "inline_str_2", field.String(), + ), + ), + ) + + tests := []struct { + name string + config *WalkConfig + src map[string]interface{} + dst map[string]interface{} + res map[string]interface{} + wantChanged bool + wantErr bool + }{ + {"generic", + &WalkConfig{ + Fields: map[string]FieldConfig{ + "obj1.a": {Fn: KeepSrc}, + "slice.1": {Fn: KeepSrc}, + "inline_str_1": {Fn: KeepSrc}, + "inline_str_2": {Fn: KeepSrc}, + }, + }, + map[string]interface{}{ + "a": "src_a", + "b": "src_b", + "obj1": map[string]interface{}{ + "a": "src_obj1_a", + "b": "src_obj1_b", + "obj2": map[string]interface{}{ + "a": "dst_obj1_obj2_a", + }, + "obj3": map[string]interface{}{ + "e": "dst_obj1_obj3_e", + }, + }, + "inline_str_1": "src_inline_1", + "inline_str_2": "src_inline_2", + "slice": []interface{}{"src_s1", "src_s2"}, + }, + map[string]interface{}{ + "a": "dst_a", + "field_not_extists": "remove", + "obj1": map[string]interface{}{ + "a": "dst_obj1_a", + "obj2": map[string]interface{}{ + "a": "dst_obj1_obj2_a", + }, + }, + "inline_str_1": "dst_inline_1", + "inline_str_2": "dst_inline_2", + "slice": []interface{}{"dst_s1", "dst_s2", "dst_s3"}, + }, + map[string]interface{}{ + "a": "dst_a", + "obj1": map[string]interface{}{ + "a": "src_obj1_a", + "obj2": map[string]interface{}{ + "a": "dst_obj1_obj2_a", + }, + }, + "inline_str_1": "src_inline_1", + "inline_str_2": "src_inline_2", + "slice": []interface{}{"dst_s1", "src_s2", "dst_s3"}, + }, + false, false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewWalker(s, tt.config) + dst := tt.dst + res, _, err := m.DataWalk(context.Background(), dst, tt.src) + assert.Equal(t, tt.res, res) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +}