feat(storage): rpc based implementation
All checks were successful
arcad/edge/pipeline/pr-master This commit looks good

This commit is contained in:
2023-09-12 22:03:25 -06:00
parent c3535a4a9b
commit 8e574c299b
113 changed files with 3007 additions and 263 deletions

View File

@ -0,0 +1,8 @@
package share
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrAttributeRequired = errors.New("attribute required")
)

View File

@ -0,0 +1,121 @@
package share
import (
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
)
type BaseResource struct {
id ResourceID
origin app.ID
attributes []Attribute
}
// Attributes implements Resource
func (r *BaseResource) Attributes() []Attribute {
return r.attributes
}
// ID implements Resource
func (r *BaseResource) ID() ResourceID {
return r.id
}
// Origin implements Resource
func (r *BaseResource) Origin() app.ID {
return r.origin
}
func (r *BaseResource) SetAttribute(attr Attribute) {
for idx, rAttr := range r.attributes {
if rAttr.Name() != attr.Name() {
continue
}
r.attributes[idx] = attr
return
}
r.attributes = append(r.attributes, attr)
}
func NewBaseResource(origin app.ID, resourceID ResourceID, attributes ...Attribute) *BaseResource {
return &BaseResource{
id: resourceID,
origin: origin,
attributes: attributes,
}
}
var _ Resource = &BaseResource{}
type BaseAttribute struct {
name string
valueType ValueType
value any
createdAt time.Time
updatedAt time.Time
}
// CreatedAt implements Attribute
func (a *BaseAttribute) CreatedAt() time.Time {
return a.createdAt
}
// Name implements Attribute
func (a *BaseAttribute) Name() string {
return a.name
}
// Type implements Attribute
func (a *BaseAttribute) Type() ValueType {
return a.valueType
}
// UpdatedAt implements Attribute
func (a *BaseAttribute) UpdatedAt() time.Time {
return a.updatedAt
}
// Value implements Attribute
func (a *BaseAttribute) Value() any {
return a.value
}
func (a *BaseAttribute) SetCreatedAt(createdAt time.Time) {
a.createdAt = createdAt
}
func (a *BaseAttribute) SetUpdatedAt(updatedAt time.Time) {
a.updatedAt = updatedAt
}
func NewBaseAttribute(name string, valueType ValueType, value any) *BaseAttribute {
return &BaseAttribute{
name: name,
valueType: valueType,
value: value,
}
}
var _ Attribute = &BaseAttribute{}
func HasAttribute(res Resource, name string, valueType ValueType) bool {
return GetAttribute(res, name, valueType) != nil
}
func GetAttribute(res Resource, name string, valueType ValueType) Attribute {
for _, attr := range res.Attributes() {
if attr.Name() != name {
continue
}
if attr.Type() == valueType {
return attr
}
}
return nil
}

View File

@ -0,0 +1,30 @@
package share
type FindResourcesOptionFunc func(*FindResourcesOptions)
type FindResourcesOptions struct {
Name *string
ValueType *ValueType
}
func NewFindResourcesOptions(funcs ...FindResourcesOptionFunc) *FindResourcesOptions {
opts := &FindResourcesOptions{}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithName(name string) FindResourcesOptionFunc {
return func(opts *FindResourcesOptions) {
opts.Name = &name
}
}
func WithType(valueType ValueType) FindResourcesOptionFunc {
return func(opts *FindResourcesOptions) {
opts.ValueType = &valueType
}
}

View File

@ -0,0 +1,32 @@
package share
import (
"context"
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
)
type ResourceID string
type Resource interface {
ID() ResourceID
Origin() app.ID
Attributes() []Attribute
}
type Attribute interface {
Name() string
Value() any
Type() ValueType
UpdatedAt() time.Time
CreatedAt() time.Time
}
type Store interface {
DeleteResource(ctx context.Context, origin app.ID, resourceID ResourceID) error
FindResources(ctx context.Context, funcs ...FindResourcesOptionFunc) ([]Resource, error)
GetResource(ctx context.Context, origin app.ID, resourceID ResourceID) (Resource, error)
UpdateAttributes(ctx context.Context, origin app.ID, resourceID ResourceID, attributes ...Attribute) (Resource, error)
DeleteAttributes(ctx context.Context, origin app.ID, resourceID ResourceID, names ...string) error
}

View File

@ -0,0 +1,15 @@
package testsuite
import (
"testing"
"forge.cadoles.com/arcad/edge/pkg/storage/share"
)
type NewTestStoreFunc func(testname string) (share.Store, error)
func TestStore(t *testing.T, newStore NewTestStoreFunc) {
t.Run("Cases", func(t *testing.T) {
runRepositoryTests(t, newStore)
})
}

View File

@ -0,0 +1,342 @@
package testsuite
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/storage/share"
"github.com/pkg/errors"
)
type repositoryTestCase struct {
Name string
Skip bool
Run func(ctx context.Context, t *testing.T, store share.Store) error
}
var repositoryTestCases = []repositoryTestCase{
{
Name: "Update resource attributes",
Skip: false,
Run: func(ctx context.Context, t *testing.T, store share.Store) error {
origin := app.ID("test")
resourceID := share.ResourceID("test")
// Try to create resource without attributes
_, err := store.UpdateAttributes(ctx, origin, resourceID)
if err == nil {
return errors.New("err should not be nil")
}
if !errors.Is(err, share.ErrAttributeRequired) {
return errors.Errorf("err: expected share.ErrAttributeRequired, got '%v'", err)
}
attributes := []share.Attribute{
share.NewBaseAttribute("my_text_attr", share.TypeText, "foo"),
share.NewBaseAttribute("my_number_attr", share.TypeNumber, 5),
share.NewBaseAttribute("my_path_attr", share.TypePath, "/my/path"),
share.NewBaseAttribute("my_bool_attr", share.TypeBool, true),
}
resource, err := store.UpdateAttributes(ctx, origin, resourceID, attributes...)
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resource).IsNil()
if isNil {
return errors.New("resource should not be nil")
}
if e, g := resourceID, resource.ID(); e != g {
return errors.Errorf("resource.ID(): expected '%v', got '%v'", e, g)
}
if e, g := origin, resource.Origin(); e != g {
return errors.Errorf("resource.Origin(): expected '%v', got '%v'", e, g)
}
if e, g := 4, len(resource.Attributes()); e != g {
return errors.Errorf("len(resource.Attributes()): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Find resources by attribute name",
Skip: false,
Run: func(ctx context.Context, t *testing.T, store share.Store) error {
if err := loadTestData(ctx, "testdata/find_resources_by_attribute_name.json", store); err != nil {
return errors.WithStack(err)
}
resources, err := store.FindResources(ctx, share.WithName("my_number"))
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resources).IsNil()
if isNil {
return errors.New("resources should not be nil")
}
if e, g := 2, len(resources); e != g {
return errors.Errorf("len(resources): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Find resources by attribute type",
Skip: false,
Run: func(ctx context.Context, t *testing.T, store share.Store) error {
if err := loadTestData(ctx, "testdata/find_resources_by_attribute_type.json", store); err != nil {
return errors.WithStack(err)
}
resources, err := store.FindResources(ctx, share.WithType(share.TypePath))
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resources).IsNil()
if isNil {
return errors.New("resources should not be nil")
}
if e, g := 1, len(resources); e != g {
return errors.Errorf("len(resources): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Find resources by attribute type and name",
Skip: false,
Run: func(ctx context.Context, t *testing.T, store share.Store) error {
if err := loadTestData(ctx, "testdata/find_resources_by_attribute_type_and_name.json", store); err != nil {
return errors.WithStack(err)
}
resources, err := store.FindResources(ctx, share.WithType(share.TypeText), share.WithName("my_attr"))
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resources).IsNil()
if isNil {
return errors.New("resources should not be nil")
}
if e, g := 1, len(resources); e != g {
return errors.Errorf("len(resources): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Get resource",
Skip: false,
Run: func(ctx context.Context, t *testing.T, store share.Store) error {
if err := loadTestData(ctx, "testdata/get_resource.json", store); err != nil {
return errors.WithStack(err)
}
origin := app.ID("app1.edge.app")
resourceID := share.ResourceID("res-1")
resource, err := store.GetResource(ctx, origin, resourceID)
if err != nil {
return errors.WithStack(err)
}
isNil := reflect.ValueOf(resource).IsNil()
if isNil {
return errors.New("resources should not be nil")
}
if e, g := origin, resource.Origin(); e != g {
return errors.Errorf("resource.Origin(): expected '%v', got '%v'", e, g)
}
if e, g := resourceID, resource.ID(); e != g {
return errors.Errorf("resource.ID(): expected '%v', got '%v'", e, g)
}
resource, err = store.GetResource(ctx, origin, "unexistant-id")
if err == nil {
return errors.New("err should not be nil")
}
if !errors.Is(err, share.ErrNotFound) {
return errors.Errorf("err: expected share.ErrNotFound, got '%+v'", err)
}
return nil
},
},
{
Name: "Delete resource",
Skip: false,
Run: func(ctx context.Context, t *testing.T, store share.Store) error {
if err := loadTestData(ctx, "testdata/delete_resource.json", store); err != nil {
return errors.WithStack(err)
}
origin := app.ID("app1.edge.app")
resourceID := share.ResourceID("res-1")
// It should delete an existing resource
if err := store.DeleteResource(ctx, origin, resourceID); err != nil {
return errors.WithStack(err)
}
_, err := store.GetResource(ctx, origin, resourceID)
if err == nil {
return errors.New("err should not be nil")
}
// The resource should be deleted
if !errors.Is(err, share.ErrNotFound) {
return errors.Errorf("err: expected share.ErrNotFound, got '%+v'", err)
}
// It should not delete an unexistant resource
err = store.DeleteResource(ctx, origin, resourceID)
if err == nil {
return errors.New("err should not be nil")
}
if !errors.Is(err, share.ErrNotFound) {
return errors.Errorf("err: expected share.ErrNotFound, got '%+v'", err)
}
otherOrigin := app.ID("app2.edge.app")
// It should not delete a resource with the same id and another origin
resource, err := store.GetResource(ctx, otherOrigin, resourceID)
if err != nil {
return errors.New("err should not be nil")
}
if e, g := otherOrigin, resource.Origin(); e != g {
return errors.Errorf("resource.Origin(): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Delete attributes",
Skip: false,
Run: func(ctx context.Context, t *testing.T, store share.Store) error {
if err := loadTestData(ctx, "testdata/delete_attributes.json", store); err != nil {
return errors.WithStack(err)
}
origin := app.ID("app1.edge.app")
resourceID := share.ResourceID("res-1")
// It should delete specified attributes
if err := store.DeleteAttributes(ctx, origin, resourceID, "my_text", "my_bool"); err != nil {
return errors.WithStack(err)
}
resource, err := store.GetResource(ctx, origin, resourceID)
if err != nil {
return errors.WithStack(err)
}
if e, g := 1, len(resource.Attributes()); e != g {
return errors.Errorf("len(resource.Attributes()): expected '%v', got '%v'", e, g)
}
attr := share.GetAttribute(resource, "my_number", share.TypeNumber)
if attr == nil {
return errors.New("attr shoudl not be nil")
}
return nil
},
},
}
func runRepositoryTests(t *testing.T, newRepo NewTestStoreFunc) {
for _, tc := range repositoryTestCases {
func(tc repositoryTestCase) {
t.Run(tc.Name, func(t *testing.T) {
if tc.Skip {
t.SkipNow()
return
}
ctx := context.Background()
repo, err := newRepo(tc.Name)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if err := tc.Run(ctx, t, repo); err != nil {
t.Errorf("%+v", errors.WithStack(err))
}
})
}(tc)
}
}
type jsonResource struct {
ID string `json:"id"`
Origin string `json:"origin"`
Attributes []jsonAttribute `json:"attributes"`
}
type jsonAttribute struct {
Name string `json:"name"`
Type share.ValueType `json:"type"`
Value any `json:"value"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func loadTestData(ctx context.Context, jsonFile string, store share.Store) error {
data, err := testData.ReadFile(jsonFile)
if err != nil {
return errors.WithStack(err)
}
var resources []jsonResource
if err := json.Unmarshal(data, &resources); err != nil {
return errors.WithStack(err)
}
for _, res := range resources {
attributes := make([]share.Attribute, len(res.Attributes))
for idx, attr := range res.Attributes {
attributes[idx] = share.NewBaseAttribute(
attr.Name,
attr.Type,
attr.Value,
)
}
_, err := store.UpdateAttributes(ctx, app.ID(res.Origin), share.ResourceID(res.ID), attributes...)
if err != nil {
return errors.WithStack(err)
}
}
return nil
}

View File

@ -0,0 +1,6 @@
package testsuite
import "embed"
//go:embed testdata/*
var testData embed.FS

View File

@ -0,0 +1,11 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_bool", "type": "bool", "value": true },
{ "name":"my_number", "type": "number", "value": 5 }
]
}
]

View File

@ -0,0 +1,16 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "foo" }
]
}
]

View File

@ -0,0 +1,29 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_number", "type": "number", "value": 5 },
{ "name":"my_bool", "type": "bool", "value": true },
{ "name":"my_path", "type": "path", "value": "/my/icon.png" }
]
},
{
"id": "res-2",
"origin": "app1.edge.app",
"attributes": [
{ "name":"other_text", "type": "text", "value": "foo" }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_number", "type": "number", "value": 5 },
{ "name":"my_bool", "type": "bool", "value": true },
{ "name":"my_path", "type": "path", "value": "/my/icon.png" }
]
}
]

View File

@ -0,0 +1,28 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_number", "type": "number", "value": 5 },
{ "name":"my_bool", "type": "bool", "value": true }
]
},
{
"id": "res-2",
"origin": "app1.edge.app",
"attributes": [
{ "name":"other_text", "type": "text", "value": "foo" }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" },
{ "name":"my_number", "type": "number", "value": 5 },
{ "name":"my_bool", "type": "bool", "value": true },
{ "name":"my_path", "type": "path", "value": "/my/icon.png" }
]
}
]

View File

@ -0,0 +1,23 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_attr", "type": "text", "value": "bar" }
]
},
{
"id": "res-2",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_attr", "type": "bool", "value": true }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_attr", "type": "number", "value": 5 }
]
}
]

View File

@ -0,0 +1,16 @@
[
{
"id": "res-1",
"origin": "app1.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "bar" }
]
},
{
"id": "res-1",
"origin": "app2.edge.app",
"attributes": [
{ "name":"my_text", "type": "text", "value": "foo" }
]
}
]

View File

@ -0,0 +1,86 @@
package share
import (
"strings"
"github.com/pkg/errors"
)
type ValueType string
const (
TypeText ValueType = "text"
TypePath ValueType = "path"
TypeNumber ValueType = "number"
TypeBool ValueType = "bool"
)
func AssertType(value any, valueType ValueType) error {
switch valueType {
case TypeText:
if err := AssertTypeText(value); err != nil {
return errors.WithStack(err)
}
case TypeNumber:
if err := AssertTypeNumber(value); err != nil {
return errors.WithStack(err)
}
case TypeBool:
if err := AssertTypeBool(value); err != nil {
return errors.WithStack(err)
}
case TypePath:
if err := AssertTypePath(value); err != nil {
return errors.WithStack(err)
}
default:
return errors.Errorf("value type '%s' does not exist", valueType)
}
return nil
}
func AssertTypeText(value any) error {
_, ok := value.(string)
if !ok {
return errors.Errorf("invalid value for type '%s': '%v'", TypeText, value)
}
return nil
}
func AssertTypeNumber(value any) error {
switch value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return nil
default:
return errors.Errorf("invalid value for type '%s': '%v'", TypeNumber, value)
}
}
func AssertTypeBool(value any) error {
_, ok := value.(bool)
if !ok {
return errors.Errorf("invalid value for type '%s': '%v'", TypeBool, value)
}
return nil
}
func AssertTypePath(value any) error {
path, ok := value.(string)
if !ok {
return errors.Errorf("invalid value for type '%s': '%v'", TypePath, value)
}
if !strings.HasPrefix(path, "/") {
return errors.Errorf("value '%s' should start with a '/'", value)
}
return nil
}

View File

@ -0,0 +1,88 @@
package share
import (
"testing"
"github.com/pkg/errors"
)
type valueTypeTestCase struct {
Name string
Value any
Type ValueType
ShouldFail bool
}
var valueTypeTestCases = []valueTypeTestCase{
{
Name: "Valid text",
Value: "my_text",
Type: TypeText,
},
{
Name: "Invalid text",
Value: 0,
Type: TypeText,
ShouldFail: true,
},
{
Name: "Valid number",
Value: 5.6,
Type: TypeNumber,
},
{
Name: "Invalid number",
Value: "5",
Type: TypeNumber,
ShouldFail: true,
},
{
Name: "Valid bool",
Value: false,
Type: TypeBool,
},
{
Name: "Invalid bool",
Value: "yes",
Type: TypeBool,
ShouldFail: true,
},
{
Name: "Valid path",
Value: "/foo/bar",
Type: TypePath,
},
{
Name: "Invalid path",
Value: true,
Type: TypePath,
ShouldFail: true,
},
{
Name: "Missing slash",
Value: "missing/slash",
Type: TypePath,
ShouldFail: true,
},
}
func TestAssertType(t *testing.T) {
t.Parallel()
for _, tc := range valueTypeTestCases {
func(tc valueTypeTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
err := AssertType(tc.Value, tc.Type)
if tc.ShouldFail && err == nil {
t.Errorf("err should not be nil")
}
if !tc.ShouldFail && err != nil {
t.Errorf("err: expected nil, got '%+v'", errors.WithStack(err))
}
})
}(tc)
}
}