diff --git a/cmd/cli/command/app/run.go b/cmd/cli/command/app/run.go index 2542b30..c15f66d 100644 --- a/cmd/cli/command/app/run.go +++ b/cmd/cli/command/app/run.go @@ -26,8 +26,10 @@ import ( "forge.cadoles.com/arcad/edge/pkg/module/cast" "forge.cadoles.com/arcad/edge/pkg/module/fetch" netModule "forge.cadoles.com/arcad/edge/pkg/module/net" + shareModule "forge.cadoles.com/arcad/edge/pkg/module/share" + shareSqlite "forge.cadoles.com/arcad/edge/pkg/module/share/sqlite" "forge.cadoles.com/arcad/edge/pkg/storage" - "forge.cadoles.com/arcad/edge/pkg/storage/sqlite" + storageSqlite "forge.cadoles.com/arcad/edge/pkg/storage/sqlite" "gitlab.com/wpetit/goweb/logger" "forge.cadoles.com/arcad/edge/pkg/bundle" @@ -76,6 +78,11 @@ func RunCommand() *cli.Command { Usage: "use `FILE` for SQLite storage database", Value: ".edge/%APPID%/data.sqlite?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000", }, + &cli.StringFlag{ + Name: "shared-resources-file", + Usage: "use `FILE` for SQLite shared resources database", + Value: ".edge/shared-resources.sqlite?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000", + }, &cli.StringFlag{ Name: "accounts-file", Usage: "use `FILE` as local accounts", @@ -90,6 +97,7 @@ func RunCommand() *cli.Command { logLevel := ctx.Int("log-level") storageFile := ctx.String("storage-file") accountsFile := ctx.String("accounts-file") + sharedResourcesFile := ctx.String("shared-resources-file") logger.SetFormat(logger.Format(logFormat)) logger.SetLevel(logger.Level(logLevel)) @@ -135,7 +143,7 @@ func RunCommand() *cli.Command { appCtx := logger.With(cmdCtx, logger.F("address", address)) - if err := runApp(appCtx, path, address, storageFile, accountsFile, appsRepository); err != nil { + if err := runApp(appCtx, path, address, storageFile, accountsFile, appsRepository, sharedResourcesFile); err != nil { logger.Error(appCtx, "could not run app", logger.E(errors.WithStack(err))) } }(p, port, idx) @@ -148,7 +156,7 @@ func RunCommand() *cli.Command { } } -func runApp(ctx context.Context, path string, address string, storageFile string, accountsFile string, appRepository appModule.Repository) error { +func runApp(ctx context.Context, path string, address string, storageFile string, accountsFile string, appRepository appModule.Repository, sharedResourcesFile string) error { absPath, err := filepath.Abs(path) if err != nil { return errors.Wrapf(err, "could not resolve path '%s'", path) @@ -172,44 +180,37 @@ func runApp(ctx context.Context, path string, address string, storageFile string ctx = logger.With(ctx, logger.F("appID", manifest.ID)) - storageFile = injectAppID(storageFile, manifest.ID) - - if err := ensureDir(storageFile); err != nil { - return errors.WithStack(err) - } - - db, err := sqlite.Open(storageFile) - if err != nil { - return errors.WithStack(err) - } - - accountsFile = injectAppID(accountsFile, manifest.ID) - - accounts, err := loadLocalAccounts(accountsFile) - if err != nil { - return errors.Wrap(err, "could not load local accounts") - } - // Add auth handler key, err := dummyKey() if err != nil { return errors.WithStack(err) } - ds := sqlite.NewDocumentStoreWithDB(db) - bs := sqlite.NewBlobStoreWithDB(db) - bus := memory.NewBus() + deps := &moduleDeps{} + funcs := []ModuleDepFunc{ + initMemoryBus, + initDatastores(storageFile, manifest.ID), + initAccounts(accountsFile, manifest.ID), + initShareRepository(sharedResourcesFile), + initAppRepository(appRepository), + } + + for _, fn := range funcs { + if err := fn(deps); err != nil { + return errors.WithStack(err) + } + } handler := appHTTP.NewHandler( - appHTTP.WithBus(bus), - appHTTP.WithServerModules(getServerModules(bus, ds, bs, appRepository)...), + appHTTP.WithBus(deps.Bus), + appHTTP.WithServerModules(getServerModules(deps)...), appHTTP.WithHTTPMounts( appModule.Mount(appRepository), authModule.Mount( authHTTP.NewLocalHandler( jwa.HS256, key, authHTTP.WithRoutePrefix("/auth"), - authHTTP.WithAccounts(accounts...), + authHTTP.WithAccounts(deps.Accounts...), ), authModule.WithJWT(dummyKeySet), ), @@ -235,21 +236,34 @@ func runApp(ctx context.Context, path string, address string, storageFile string return nil } -func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore, appRepository appModule.Repository) []app.ServerModuleFactory { +type moduleDeps struct { + AppID app.ID + Bus bus.Bus + DocumentStore storage.DocumentStore + BlobStore storage.BlobStore + AppRepository appModule.Repository + ShareRepository shareModule.Repository + Accounts []authHTTP.LocalAccount +} + +type ModuleDepFunc func(*moduleDeps) error + +func getServerModules(deps *moduleDeps) []app.ServerModuleFactory { return []app.ServerModuleFactory{ module.ContextModuleFactory(), module.ConsoleModuleFactory(), cast.CastModuleFactory(), module.LifecycleModuleFactory(), - netModule.ModuleFactory(bus), - module.RPCModuleFactory(bus), - module.StoreModuleFactory(ds), - blob.ModuleFactory(bus, bs), + netModule.ModuleFactory(deps.Bus), + module.RPCModuleFactory(deps.Bus), + module.StoreModuleFactory(deps.DocumentStore), + blob.ModuleFactory(deps.Bus, deps.BlobStore), authModule.ModuleFactory( authModule.WithJWT(dummyKeySet), ), - appModule.ModuleFactory(appRepository), - fetch.ModuleFactory(bus), + appModule.ModuleFactory(deps.AppRepository), + fetch.ModuleFactory(deps.Bus), + shareModule.ModuleFactory(deps.AppID, deps.ShareRepository), } } @@ -403,3 +417,65 @@ func newAppRepository(host string, basePort uint64, manifests ...*app.Manifest) manifests..., ) } + +func initAppRepository(repo appModule.Repository) ModuleDepFunc { + return func(deps *moduleDeps) error { + deps.AppRepository = repo + return nil + } +} + +func initMemoryBus(deps *moduleDeps) error { + deps.Bus = memory.NewBus() + + return nil +} + +func initDatastores(storageFile string, appID app.ID) ModuleDepFunc { + return func(deps *moduleDeps) error { + storageFile = injectAppID(storageFile, appID) + + if err := ensureDir(storageFile); err != nil { + return errors.WithStack(err) + } + + db, err := storageSqlite.Open(storageFile) + if err != nil { + return errors.WithStack(err) + } + + deps.DocumentStore = storageSqlite.NewDocumentStoreWithDB(db) + deps.BlobStore = storageSqlite.NewBlobStoreWithDB(db) + + return nil + } +} + +func initAccounts(accountsFile string, appID app.ID) ModuleDepFunc { + return func(deps *moduleDeps) error { + accountsFile = injectAppID(accountsFile, appID) + + accounts, err := loadLocalAccounts(accountsFile) + if err != nil { + return errors.Wrap(err, "could not load local accounts") + } + + deps.Accounts = accounts + + return nil + } +} + +func initShareRepository(shareRepositoryFile string) ModuleDepFunc { + return func(deps *moduleDeps) error { + if err := ensureDir(shareRepositoryFile); err != nil { + return errors.WithStack(err) + } + + repo := shareSqlite.NewRepository(shareRepositoryFile) + + deps.ShareRepository = repo + + return nil + } +} diff --git a/doc/apps/server-api/README.md b/doc/apps/server-api/README.md index 0702304..896cc4b 100644 --- a/doc/apps/server-api/README.md +++ b/doc/apps/server-api/README.md @@ -29,4 +29,5 @@ Listes des modules disponibles côté serveur. - [`fetch`](./fetch.md) - [`net`](./net.md) - [`rpc`](./rpc.md) +- [`share`](./share.md) - [`store`](./store.md) diff --git a/doc/apps/server-api/share.md b/doc/apps/server-api/share.md new file mode 100644 index 0000000..45a560f --- /dev/null +++ b/doc/apps/server-api/share.md @@ -0,0 +1,143 @@ +# Module `share` + +Ce module permet partager des ressources à destination des autres applications exécutées dans l'environnement Edge. + +## Propriétés + +### `share.ANY_TYPE`, `share.ANY_NAME` + +Les propriétés `share.ANY_TYPE` et `share.ANY_NAME` sont utilisées dans la méthode `share.findResources()` pour récupérer ne pas appliquer de filtre spécifique au type ou au nom des attributs respectivement. + +### `share.TYPE_TEXT`, `share.TYPE_NUMBER`, `share.TYPE_PATH`, `share.TYPE_BOOL` + +Ces propriétés correspondant aux types d'attributs possibles dans une ressource. + +Le type `share.TYPE_PATH` décrit un "chemin" destiné à être transformé en URL par l'application consommatrice de la ressource sous la forme `${APP_URL}${PATH}`. Ce type d'attribut peut être utilisé pour partager des URLs (médias, pages, etc) entre applications. + +## Méthodes + +### `share.upsertResource(ctx: Context, resourceId: string, ...attributes: Attribute[]): Resource` + +Cette méthode permet de créer une ressource ou de la mettre à jour si elle existe déjà. Elle prend en paramètre le contexte d'exécution, l'identifiant de la ressource et une liste d'attributs. + +Si la ressource n'existe pas, elle sera créée avec les attributs fournis. Si elle existe, les attributs existants seront mis à jour avec les valeurs fournies. + +#### Arguments + +- `ctx: Context`: Le contexte d'exécution. +- `resourceId: string`: L'identifiant de la ressource. +- `...attributes: Attribute[]`: Une liste d'attributs. Chaque attribut est représenté par un objet de type `Attribute`. + +#### Valeur de retour + +La méthode retourne un objet de type `Resource` qui représente la ressource créée ou mise à jour. + +#### Usage + +```javascript +const resource = share.upsertResource(ctx, "my-resource", { name: "color", type: share.TYPE_TEXT, value: "red" }); +console.log(resource); +// Output: { id: "my-resource", origin: "my.app", attributes: [{ name: "color", type: "text", value: "red", createdAt: "2023-04-21T14:30:00Z", updatedAt: "2023-04-21T14:30:00Z" }] } +``` + +### `share.findResources(ctx: Context, withAttribute?: string, withType?: string): []Resource` + +Cette méthode permet de rechercher des ressources en fonction de leurs attributs. Elle prend en paramètre le contexte d'exécution et deux paramètres optionnels qui permettent de filtrer les ressources. + +#### Arguments + +- `ctx: Context`: Le contexte d'exécution. +- `withAttribute?: string`: (optionnel) Le nom de l'attribut à rechercher (`share.ANY_NAME` par défaut) +- `withType?: string`: (optionnel) Le type de l'attribut à rechercher (`share.ANY_TYPE` par défaut) + +#### Valeur de retour + +La méthode retourne un tableau d'objets de type `Resource` qui représentent les ressources correspondant aux critères de recherche. + +#### Usage + +```typescript +const resources = share.findResources(ctx, "color", share.TYPE_TEXT); +console.log(resources); +// Output: [{ id: "my-resource", origin: "my/app", attributes: [{ name: "color", type: "text", value: "red", createdAt: "2023-04-21T14:30:00Z", updatedAt: "2023-04-21T14:30:00Z" }] }] +``` + +### `share.deleteAttributes(ctx: Context, resourceId: string, ...names: string[]): Resource` + +Cette méthode supprime un ou plusieurs attributs de la ressource spécifiée. + +#### Arguments + +- `ctx: Context`: contexte d'exécution +- `resourceId: string`: identifiant unique de la ressource à modifier +- `...names: string[]`: tableau de noms d'attributs à supprimer + +#### Valeur de retour + +La méthode retourne un objet de type `Resource` qui représente la ressource modifiée. + +#### Usage + +```typescript +const resource = share.upsertResource(ctx, "my-resource", { name: "color", type: share.TYPE_TEXT, value: "red" }); +console.log(resource); +// Output: { id: "my-resource", origin: "my.app", attributes: [{ name: "color", type: "text", value: "red", createdAt: "2023-04-21T14:30:00Z", updatedAt: "2023-04-21T14:30:00Z" }] } +``` + +### `share.deleteResource(ctx: Context, resourceId: string)` + +Cette méthode supprime la ressource spécifiée. + +#### Arguments + +- `ctx: Context`: contexte d'exécution +- `resourceId: string`: identifiant unique de la ressource à supprimer + +#### Valeur de retour + +La méthode ne retourne pas de valeur. + +#### Usage + +```typescript +const resource = share.deleteResource(ctx, "my-resource"); +``` + +## Objets + +### `Context` + +Voir la documentation du module [`context`](./context.md) + + +### `Resource` + +```typescript +interface Resource { + id: string + origin: string + attributes: Attribute[] +} +``` + +### `Attribute` + +```typescript +interface Attribute { + name: string + type: ValueType + createdAt: string + updatedAt: string +} +``` + +### `ValueType` + +```typescript +enum ValueType { + TYPE_TEXT = "text", + TYPE_PATH = "path", + TYPE_NUMBER = "number", + TYPE_BOOL = "bool" +} +``` \ No newline at end of file diff --git a/pkg/module/share/error.go b/pkg/module/share/error.go new file mode 100644 index 0000000..4f0b3d7 --- /dev/null +++ b/pkg/module/share/error.go @@ -0,0 +1,8 @@ +package share + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrAttributeRequired = errors.New("attribute required") +) diff --git a/pkg/module/share/module.go b/pkg/module/share/module.go new file mode 100644 index 0000000..9b50077 --- /dev/null +++ b/pkg/module/share/module.go @@ -0,0 +1,341 @@ +package share + +import ( + "time" + + "forge.cadoles.com/arcad/edge/pkg/app" + "forge.cadoles.com/arcad/edge/pkg/module/util" + "github.com/dop251/goja" + "github.com/pkg/errors" +) + +const ( + AnyType ValueType = "*" + AnyName string = "*" +) + +type Module struct { + appID app.ID + repository Repository +} + +func (m *Module) Name() string { + return "share" +} + +func (m *Module) Export(export *goja.Object) { + if err := export.Set("upsertResource", m.upsertResource); err != nil { + panic(errors.Wrap(err, "could not set 'upsertResource' function")) + } + + if err := export.Set("findResources", m.findResources); err != nil { + panic(errors.Wrap(err, "could not set 'findResources' function")) + } + + if err := export.Set("deleteAttributes", m.deleteAttributes); err != nil { + panic(errors.Wrap(err, "could not set 'deleteAttributes' function")) + } + + if err := export.Set("deleteResource", m.deleteResource); err != nil { + panic(errors.Wrap(err, "could not set 'deleteResource' function")) + } + + if err := export.Set("ANY_TYPE", AnyType); err != nil { + panic(errors.Wrap(err, "could not set 'ANY_TYPE' property")) + } + + if err := export.Set("ANY_NAME", AnyName); err != nil { + panic(errors.Wrap(err, "could not set 'ANY_NAME' property")) + } + + if err := export.Set("TYPE_TEXT", TypeText); err != nil { + panic(errors.Wrap(err, "could not set 'TYPE_TEXT' property")) + } + + if err := export.Set("TYPE_NUMBER", TypeNumber); err != nil { + panic(errors.Wrap(err, "could not set 'TYPE_NUMBER' property")) + } + + if err := export.Set("TYPE_BOOL", TypeBool); err != nil { + panic(errors.Wrap(err, "could not set 'TYPE_BOOL' property")) + } + + if err := export.Set("TYPE_PATH", TypePath); err != nil { + panic(errors.Wrap(err, "could not set 'TYPE_PATH' property")) + } +} + +func (m *Module) upsertResource(call goja.FunctionCall, rt *goja.Runtime) goja.Value { + ctx := util.AssertContext(call.Argument(0), rt) + resourceID := assertResourceID(call.Argument(1), rt) + + var attributes []Attribute + if len(call.Arguments) > 2 { + attributes = assertAttributes(call.Arguments[2:], rt) + } else { + attributes = make([]Attribute, 0) + } + + for _, attr := range attributes { + if err := AssertType(attr.Value(), attr.Type()); err != nil { + panic(rt.ToValue(errors.WithStack(err))) + } + } + + resource, err := m.repository.UpdateAttributes(ctx, m.appID, resourceID, attributes...) + if err != nil { + panic(rt.ToValue(errors.WithStack(err))) + } + + return rt.ToValue(toGojaResource(resource)) +} + +func (m *Module) deleteAttributes(call goja.FunctionCall, rt *goja.Runtime) goja.Value { + ctx := util.AssertContext(call.Argument(0), rt) + resourceID := assertResourceID(call.Argument(1), rt) + + var names []string + if len(call.Arguments) > 2 { + names = assertStrings(call.Arguments[2:], rt) + } else { + names = make([]string, 0) + } + + err := m.repository.DeleteAttributes(ctx, m.appID, resourceID, names...) + if err != nil { + panic(rt.ToValue(errors.WithStack(err))) + } + + return nil +} + +func (m *Module) findResources(call goja.FunctionCall, rt *goja.Runtime) goja.Value { + ctx := util.AssertContext(call.Argument(0), rt) + + funcs := make([]FindResourcesOptionFunc, 0) + + if len(call.Arguments) > 1 { + name := util.AssertString(call.Argument(1), rt) + if name != AnyName { + funcs = append(funcs, WithName(name)) + } + } + + if len(call.Arguments) > 2 { + valueType := assertValueType(call.Argument(2), rt) + if valueType != AnyType { + funcs = append(funcs, WithType(valueType)) + } + } + + resources, err := m.repository.FindResources(ctx, funcs...) + if err != nil { + panic(rt.ToValue(errors.WithStack(err))) + } + + return rt.ToValue(toGojaResources(resources)) +} + +func (m *Module) deleteResource(call goja.FunctionCall, rt *goja.Runtime) goja.Value { + ctx := util.AssertContext(call.Argument(0), rt) + resourceID := assertResourceID(call.Argument(1), rt) + + err := m.repository.DeleteResource(ctx, m.appID, resourceID) + if err != nil { + panic(rt.ToValue(errors.WithStack(err))) + } + + return nil +} + +func ModuleFactory(appID app.ID, repository Repository) app.ServerModuleFactory { + return func(server *app.Server) app.ServerModule { + return &Module{ + appID: appID, + repository: repository, + } + } +} + +func assertResourceID(v goja.Value, r *goja.Runtime) ResourceID { + value := v.Export() + switch typ := value.(type) { + case string: + return ResourceID(typ) + case ResourceID: + return typ + default: + panic(r.ToValue(errors.Errorf("expected value to be a string or ResourceID, got '%T'", value))) + } +} + +func assertAttributes(values []goja.Value, r *goja.Runtime) []Attribute { + attributes := make([]Attribute, len(values)) + + for idx, val := range values { + export := val.Export() + + rawAttr, ok := export.(map[string]any) + if !ok { + panic(r.ToValue(errors.Errorf("unexpected attribute value, got '%v'", export))) + } + + rawName, exists := rawAttr["name"] + if !exists { + panic(r.ToValue(errors.Errorf("could not find 'name' property on attribute '%v'", export))) + } + + name, ok := rawName.(string) + if !ok { + panic(r.ToValue(errors.Errorf("unexpected value for attribute property 'name': expected 'string', got '%T'", rawName))) + } + + rawType, exists := rawAttr["type"] + if !exists { + panic(r.ToValue(errors.Errorf("could not find 'type' property on attribute '%v'", export))) + } + + var valueType ValueType + switch typ := rawType.(type) { + case ValueType: + valueType = typ + case string: + valueType = ValueType(typ) + + default: + panic(r.ToValue(errors.Errorf("unexpected value for attribute property 'type': expected 'string' or 'ValueType', got '%T'", rawType))) + } + + value, exists := rawAttr["value"] + if !exists { + panic(r.ToValue(errors.Errorf("could not find 'value' property on attribute '%v'", export))) + } + + attributes[idx] = NewBaseAttribute( + name, + valueType, + value, + ) + + } + + return attributes +} + +func assertStrings(values []goja.Value, r *goja.Runtime) []string { + strings := make([]string, len(values)) + + for idx, v := range values { + strings[idx] = util.AssertString(v, r) + } + + return strings +} + +func assertValueType(v goja.Value, r *goja.Runtime) ValueType { + value := v.Export() + switch typ := value.(type) { + case string: + return ValueType(typ) + case ValueType: + return typ + default: + panic(r.ToValue(errors.Errorf("expected value to be a string or ValueType, got '%T'", value))) + } +} + +type gojaResource struct { + ID ResourceID `goja:"id" json:"id"` + Origin app.ID `goja:"origin" json:"origin"` + Attributes []*gojaAttribute `goja:"attributes" json:"attributes"` +} + +func (r *gojaResource) Has(call goja.FunctionCall, rt *goja.Runtime) goja.Value { + name := util.AssertString(call.Argument(0), rt) + valueType := assertValueType(call.Argument(1), rt) + + hasAttr := HasAttribute(toResource(r), name, valueType) + + return rt.ToValue(hasAttr) +} + +func (r *gojaResource) Get(call goja.FunctionCall, rt *goja.Runtime) goja.Value { + name := util.AssertString(call.Argument(0), rt) + valueType := assertValueType(call.Argument(1), rt) + + var defaultValue any + if len(call.Arguments) > 2 { + defaultValue = call.Argument(2).Export() + } + + attr := GetAttribute(toResource(r), name, valueType) + + if attr == nil { + return rt.ToValue(defaultValue) + } + + return rt.ToValue(attr.Value()) +} + +type gojaAttribute struct { + Name string `goja:"name" json:"name"` + Type ValueType `goja:"type" json:"type"` + Value any `goja:"value" json:"value"` + CreatedAt time.Time `goja:"createdAt" json:"createdAt"` + UpdatedAt time.Time `goja:"updatedAt" json:"updatedAt"` +} + +func toGojaResource(res Resource) *gojaResource { + attributes := make([]*gojaAttribute, len(res.Attributes())) + + for idx, attr := range res.Attributes() { + attributes[idx] = &gojaAttribute{ + Name: attr.Name(), + Type: attr.Type(), + Value: attr.Value(), + CreatedAt: attr.CreatedAt(), + UpdatedAt: attr.UpdatedAt(), + } + } + + return &gojaResource{ + ID: res.ID(), + Origin: res.Origin(), + Attributes: attributes, + } +} + +func toGojaResources(resources []Resource) []*gojaResource { + gojaResources := make([]*gojaResource, len(resources)) + for idx, res := range resources { + gojaResources[idx] = toGojaResource(res) + } + return gojaResources +} + +func toResource(res *gojaResource) Resource { + return NewBaseResource( + res.Origin, + res.ID, + toAttributes(res.Attributes)..., + ) +} + +func toAttributes(gojaAttributes []*gojaAttribute) []Attribute { + attributes := make([]Attribute, len(gojaAttributes)) + + for idx, gojaAttr := range gojaAttributes { + attr := NewBaseAttribute( + gojaAttr.Name, + gojaAttr.Type, + gojaAttr.Value, + ) + + attr.SetCreatedAt(gojaAttr.CreatedAt) + attr.SetUpdatedAt(gojaAttr.UpdatedAt) + + attributes[idx] = attr + } + + return attributes +} diff --git a/pkg/module/share/options.go b/pkg/module/share/options.go new file mode 100644 index 0000000..08aac90 --- /dev/null +++ b/pkg/module/share/options.go @@ -0,0 +1,30 @@ +package share + +type FindResourcesOptionFunc func(*FindResourcesOptions) + +type FindResourcesOptions struct { + Name *string + ValueType *ValueType +} + +func FillFindResourcesOptions(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 + } +} diff --git a/pkg/module/share/repository.go b/pkg/module/share/repository.go new file mode 100644 index 0000000..c5f4428 --- /dev/null +++ b/pkg/module/share/repository.go @@ -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 Repository 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 +} diff --git a/pkg/module/share/resource.go b/pkg/module/share/resource.go new file mode 100644 index 0000000..c78f9a8 --- /dev/null +++ b/pkg/module/share/resource.go @@ -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 +} diff --git a/pkg/module/share/sqlite/module_test.go b/pkg/module/share/sqlite/module_test.go new file mode 100644 index 0000000..4b498fc --- /dev/null +++ b/pkg/module/share/sqlite/module_test.go @@ -0,0 +1,13 @@ +package sqlite + +import ( + "testing" + + "forge.cadoles.com/arcad/edge/pkg/module/share/testsuite" + "gitlab.com/wpetit/goweb/logger" +) + +func TestModule(t *testing.T) { + logger.SetLevel(logger.LevelDebug) + testsuite.TestModule(t, newTestRepo) +} diff --git a/pkg/module/share/sqlite/repository.go b/pkg/module/share/sqlite/repository.go new file mode 100644 index 0000000..cb36d71 --- /dev/null +++ b/pkg/module/share/sqlite/repository.go @@ -0,0 +1,429 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "forge.cadoles.com/arcad/edge/pkg/app" + "forge.cadoles.com/arcad/edge/pkg/module/share" + "forge.cadoles.com/arcad/edge/pkg/storage/sqlite" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +type Repository struct { + getDB sqlite.GetDBFunc +} + +// DeleteAttributes implements share.Repository +func (r *Repository) DeleteAttributes(ctx context.Context, origin app.ID, resourceID share.ResourceID, names ...string) error { + err := r.withTx(ctx, func(tx *sql.Tx) error { + query := ` + DELETE FROM resources + WHERE origin = $1 AND resource_id = $2 + ` + args := []any{origin, resourceID} + criteria := "" + + for idx, name := range names { + if idx == 0 { + criteria += " AND (" + } + + if idx != 0 { + criteria += " OR " + } + + criteria += fmt.Sprintf(" name = $%d", len(args)+1) + args = append(args, name) + + if idx == len(names)-1 { + criteria += " )" + } + } + + query += criteria + + logger.Debug( + ctx, "executing query", + logger.F("query", query), + logger.F("args", args), + ) + + res, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return errors.WithStack(err) + } + + affected, err := res.RowsAffected() + if err != nil { + return errors.WithStack(err) + } + + if affected == 0 { + return errors.WithStack(share.ErrNotFound) + } + + return nil + }) + if err != nil { + return errors.WithStack(err) + } + + return err +} + +// DeleteResource implements share.Repository +func (r *Repository) DeleteResource(ctx context.Context, origin app.ID, resourceID share.ResourceID) error { + err := r.withTx(ctx, func(tx *sql.Tx) error { + query := ` + DELETE FROM resources + WHERE origin = $1 AND resource_id = $2 + ` + + args := []any{origin, resourceID} + + logger.Debug( + ctx, "executing query", + logger.F("query", query), + logger.F("args", args), + ) + + res, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return errors.WithStack(err) + } + + affected, err := res.RowsAffected() + if err != nil { + return errors.WithStack(err) + } + + if affected == 0 { + return errors.WithStack(share.ErrNotFound) + } + + return nil + }) + if err != nil { + return errors.WithStack(err) + } + + return err +} + +// FindResources implements share.Repository +func (r *Repository) FindResources(ctx context.Context, funcs ...share.FindResourcesOptionFunc) ([]share.Resource, error) { + opts := share.FillFindResourcesOptions(funcs...) + + var resources []share.Resource + + err := r.withTx(ctx, func(tx *sql.Tx) error { + query := ` + SELECT + main.origin, main.resource_id, + main.name, main.type, main.value, + main.created_at, main.updated_at + FROM resources AS main + JOIN resources AS sub ON + main.resource_id = sub.resource_id + AND main.origin = sub.origin + ` + + criteria := " WHERE 1 = 1" + preparedArgIndex := 1 + args := make([]any, 0) + + if opts.Name != nil { + criteria += fmt.Sprintf(" AND sub.name = $%d", preparedArgIndex) + args = append(args, *opts.Name) + preparedArgIndex++ + } + + if opts.ValueType != nil { + criteria += fmt.Sprintf(" AND sub.type = $%d", preparedArgIndex) + args = append(args, *opts.ValueType) + preparedArgIndex++ + } + + query += criteria + + logger.Debug( + ctx, "executing query", + logger.F("query", query), + logger.F("args", args), + ) + + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return errors.WithStack(err) + } + + defer func() { + if err := rows.Close(); err != nil { + logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err))) + } + }() + + indexedResources := make(map[string]*share.BaseResource) + + for rows.Next() { + var ( + origin string + resourceID string + name string + valueType string + value any + updatedAt time.Time + createdAt time.Time + ) + + if err := rows.Scan(&origin, &resourceID, &name, &valueType, &value, &createdAt, &updatedAt); err != nil { + return errors.WithStack(err) + } + + resourceKey := origin + resourceID + resource, exists := indexedResources[resourceKey] + if !exists { + resource = share.NewBaseResource(app.ID(origin), share.ResourceID(resourceID)) + indexedResources[resourceKey] = resource + } + + attr := share.NewBaseAttribute( + name, + share.ValueType(valueType), + value, + ) + + attr.SetCreatedAt(createdAt) + attr.SetUpdatedAt(updatedAt) + + resource.SetAttribute(attr) + } + + if err := rows.Err(); err != nil { + return errors.WithStack(err) + } + + resources = make([]share.Resource, 0, len(indexedResources)) + for _, res := range indexedResources { + resources = append(resources, res) + } + + return nil + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return resources, nil +} + +// GetResource implements share.Repository +func (r *Repository) GetResource(ctx context.Context, origin app.ID, resourceID share.ResourceID) (share.Resource, error) { + var ( + resource *share.BaseResource + err error + ) + + err = r.withTx(ctx, func(tx *sql.Tx) error { + resource, err = r.getResourceWithinTx(ctx, tx, origin, resourceID) + if err != nil { + return errors.WithStack(err) + } + + return nil + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return resource, nil +} + +// UpdateAttributes implements share.Repository +func (r *Repository) UpdateAttributes(ctx context.Context, origin app.ID, resourceID share.ResourceID, attributes ...share.Attribute) (share.Resource, error) { + if len(attributes) == 0 { + return nil, errors.WithStack(share.ErrAttributeRequired) + } + + var resource *share.BaseResource + err := r.withTx(ctx, func(tx *sql.Tx) error { + query := ` + INSERT INTO resources (origin, resource_id, name, type, value, created_at, updated_at) + VALUES($1, $2, $3, $4, $5, $6, $6) + ON CONFLICT (origin, resource_id, name) DO UPDATE SET + type = $4, value = $5, updated_at = $6 + ` + + stmt, err := tx.PrepareContext(ctx, query) + if err != nil { + return errors.WithStack(err) + } + + defer func() { + if err := stmt.Close(); err != nil { + logger.Error(ctx, "could not close statement", logger.E(errors.WithStack(err))) + } + }() + + now := time.Now().UTC() + + for _, attr := range attributes { + args := []any{ + string(origin), string(resourceID), + attr.Name(), string(attr.Type()), attr.Value(), + now, now, + } + + logger.Debug( + ctx, "executing query", + logger.F("query", query), + logger.F("args", args), + ) + + if _, err := stmt.ExecContext(ctx, args...); err != nil { + return errors.WithStack(err) + } + } + + resource, err = r.getResourceWithinTx(ctx, tx, origin, resourceID) + if err != nil { + return errors.WithStack(err) + } + + return nil + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return resource, nil +} + +func (r *Repository) getResourceWithinTx(ctx context.Context, tx *sql.Tx, origin app.ID, resourceID share.ResourceID) (*share.BaseResource, error) { + query := ` + SELECT name, type, value, created_at, updated_at + FROM resources + WHERE origin = $1 AND resource_id = $2 + ` + + rows, err := tx.QueryContext(ctx, query, origin, resourceID) + if err != nil { + return nil, errors.WithStack(err) + } + + defer func() { + if err := rows.Close(); err != nil { + logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err))) + } + }() + + attributes := make([]share.Attribute, 0) + + for rows.Next() { + var ( + name string + valueType string + value any + updatedAt time.Time + createdAt time.Time + ) + + if err := rows.Scan(&name, &valueType, &value, &createdAt, &updatedAt); err != nil { + return nil, errors.WithStack(err) + } + + attr := share.NewBaseAttribute( + name, + share.ValueType(valueType), + value, + ) + + attr.SetCreatedAt(createdAt) + attr.SetUpdatedAt(updatedAt) + + attributes = append(attributes, attr) + } + + if err := rows.Err(); err != nil { + return nil, errors.WithStack(err) + } + + if len(attributes) == 0 { + return nil, errors.WithStack(share.ErrNotFound) + } + + resource := share.NewBaseResource(origin, resourceID, attributes...) + + return resource, nil +} + +func (r *Repository) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error { + var db *sql.DB + + db, err := r.getDB(ctx) + if err != nil { + return errors.WithStack(err) + } + + if err := sqlite.WithTx(ctx, db, fn); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func ensureTables(ctx context.Context, db *sql.DB) error { + err := sqlite.WithTx(ctx, db, func(tx *sql.Tx) error { + query := ` + CREATE TABLE IF NOT EXISTS resources ( + resource_id TEXT NOT NULL, + origin TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + value TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(origin, resource_id, name) ON CONFLICT REPLACE + ); + ` + if _, err := tx.ExecContext(ctx, query); err != nil { + return errors.WithStack(err) + } + + query = ` + CREATE INDEX IF NOT EXISTS resource_idx ON resources (origin, resource_id, name); + ` + if _, err := tx.ExecContext(ctx, query); err != nil { + return errors.WithStack(err) + } + + return nil + }) + if err != nil { + return errors.WithStack(err) + } + + return nil +} + +func NewRepository(path string) *Repository { + getDB := sqlite.NewGetDBFunc(path, ensureTables) + + return &Repository{ + getDB: getDB, + } +} + +func NewRepositoryWithDB(db *sql.DB) *Repository { + getDB := sqlite.NewGetDBFuncFromDB(db, ensureTables) + + return &Repository{ + getDB: getDB, + } +} + +var _ share.Repository = &Repository{} diff --git a/pkg/module/share/sqlite/repository_test.go b/pkg/module/share/sqlite/repository_test.go new file mode 100644 index 0000000..dd6d4dc --- /dev/null +++ b/pkg/module/share/sqlite/repository_test.go @@ -0,0 +1,33 @@ +package sqlite + +import ( + "fmt" + "os" + "strings" + "testing" + "time" + + "forge.cadoles.com/arcad/edge/pkg/module/share" + "forge.cadoles.com/arcad/edge/pkg/module/share/testsuite" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +func TestRepository(t *testing.T) { + logger.SetLevel(logger.LevelDebug) + testsuite.TestRepository(t, newTestRepo) +} + +func newTestRepo(testName string) (share.Repository, error) { + filename := strings.ToLower(strings.ReplaceAll(testName, " ", "_")) + file := fmt.Sprintf("./testdata/%s.sqlite", filename) + + if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, errors.WithStack(err) + } + + dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds()) + repo := NewRepository(dsn) + + return repo, nil +} diff --git a/pkg/module/share/sqlite/testdata/.gitignore b/pkg/module/share/sqlite/testdata/.gitignore new file mode 100644 index 0000000..885029a --- /dev/null +++ b/pkg/module/share/sqlite/testdata/.gitignore @@ -0,0 +1 @@ +*.sqlite* \ No newline at end of file diff --git a/pkg/module/share/testsuite/module.go b/pkg/module/share/testsuite/module.go new file mode 100644 index 0000000..6737440 --- /dev/null +++ b/pkg/module/share/testsuite/module.go @@ -0,0 +1,47 @@ +package testsuite + +import ( + "context" + "io/fs" + "testing" + + "forge.cadoles.com/arcad/edge/pkg/app" + "forge.cadoles.com/arcad/edge/pkg/module" + "forge.cadoles.com/arcad/edge/pkg/module/share" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +func TestModule(t *testing.T, newRepo NewTestRepoFunc) { + logger.SetLevel(logger.LevelDebug) + + repo, err := newRepo("module") + if err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + server := app.NewServer( + module.ContextModuleFactory(), + module.ConsoleModuleFactory(), + share.ModuleFactory("test.app.edge", repo), + ) + + data, err := fs.ReadFile(testData, "testdata/share.js") + if err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + if err := server.Load("testdata/share.js", string(data)); err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + if err := server.Start(); err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + if _, err := server.ExecFuncByName(context.Background(), "testModule"); err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + server.Stop() +} diff --git a/pkg/module/share/testsuite/repository.go b/pkg/module/share/testsuite/repository.go new file mode 100644 index 0000000..46867d1 --- /dev/null +++ b/pkg/module/share/testsuite/repository.go @@ -0,0 +1,16 @@ +package testsuite + +import ( + "testing" + + "forge.cadoles.com/arcad/edge/pkg/module/share" +) + +type NewTestRepoFunc func(testname string) (share.Repository, error) + +func TestRepository(t *testing.T, newRepo NewTestRepoFunc) { + t.Run("Cases", func(t *testing.T) { + t.Parallel() + runRepositoryTests(t, newRepo) + }) +} diff --git a/pkg/module/share/testsuite/repository_cases.go b/pkg/module/share/testsuite/repository_cases.go new file mode 100644 index 0000000..f8b1f83 --- /dev/null +++ b/pkg/module/share/testsuite/repository_cases.go @@ -0,0 +1,344 @@ +package testsuite + +import ( + "context" + "encoding/json" + "reflect" + "testing" + "time" + + "forge.cadoles.com/arcad/edge/pkg/app" + "forge.cadoles.com/arcad/edge/pkg/module/share" + "github.com/pkg/errors" +) + +type repositoryTestCase struct { + Name string + Skip bool + Run func(ctx context.Context, t *testing.T, repo share.Repository) error +} + +var repositoryTestCases = []repositoryTestCase{ + { + Name: "Update resource attributes", + Skip: false, + Run: func(ctx context.Context, t *testing.T, repo share.Repository) error { + origin := app.ID("test") + resourceID := share.ResourceID("test") + + // Try to create resource without attributes + _, err := repo.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 := repo.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, repo share.Repository) error { + if err := loadTestData(ctx, "testdata/find_resources_by_attribute_name.json", repo); err != nil { + return errors.WithStack(err) + } + + resources, err := repo.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, repo share.Repository) error { + if err := loadTestData(ctx, "testdata/find_resources_by_attribute_type.json", repo); err != nil { + return errors.WithStack(err) + } + + resources, err := repo.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, repo share.Repository) error { + if err := loadTestData(ctx, "testdata/find_resources_by_attribute_type_and_name.json", repo); err != nil { + return errors.WithStack(err) + } + + resources, err := repo.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, repo share.Repository) error { + if err := loadTestData(ctx, "testdata/get_resource.json", repo); err != nil { + return errors.WithStack(err) + } + + origin := app.ID("app1.edge.app") + resourceID := share.ResourceID("res-1") + + resource, err := repo.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 = repo.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, repo share.Repository) error { + if err := loadTestData(ctx, "testdata/delete_resource.json", repo); 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 := repo.DeleteResource(ctx, origin, resourceID); err != nil { + return errors.WithStack(err) + } + + _, err := repo.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 = repo.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 := repo.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, repo share.Repository) error { + if err := loadTestData(ctx, "testdata/delete_attributes.json", repo); err != nil { + return errors.WithStack(err) + } + + origin := app.ID("app1.edge.app") + resourceID := share.ResourceID("res-1") + + // It should delete specified attributes + if err := repo.DeleteAttributes(ctx, origin, resourceID, "my_text", "my_bool"); err != nil { + return errors.WithStack(err) + } + + resource, err := repo.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 NewTestRepoFunc) { + for _, tc := range repositoryTestCases { + func(tc repositoryTestCase) { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + 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, repo share.Repository) 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 := repo.UpdateAttributes(ctx, app.ID(res.Origin), share.ResourceID(res.ID), attributes...) + if err != nil { + return errors.WithStack(err) + } + } + + return nil +} diff --git a/pkg/module/share/testsuite/testdata.go b/pkg/module/share/testsuite/testdata.go new file mode 100644 index 0000000..9624c6d --- /dev/null +++ b/pkg/module/share/testsuite/testdata.go @@ -0,0 +1,6 @@ +package testsuite + +import "embed" + +//go:embed testdata/* +var testData embed.FS diff --git a/pkg/module/share/testsuite/testdata/delete_attributes.json b/pkg/module/share/testsuite/testdata/delete_attributes.json new file mode 100644 index 0000000..46bbab1 --- /dev/null +++ b/pkg/module/share/testsuite/testdata/delete_attributes.json @@ -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 } + ] + } +] \ No newline at end of file diff --git a/pkg/module/share/testsuite/testdata/delete_resource.json b/pkg/module/share/testsuite/testdata/delete_resource.json new file mode 100644 index 0000000..35e3c1c --- /dev/null +++ b/pkg/module/share/testsuite/testdata/delete_resource.json @@ -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" } + ] + } +] \ No newline at end of file diff --git a/pkg/module/share/testsuite/testdata/find_resources_by_attribute_name.json b/pkg/module/share/testsuite/testdata/find_resources_by_attribute_name.json new file mode 100644 index 0000000..85baae2 --- /dev/null +++ b/pkg/module/share/testsuite/testdata/find_resources_by_attribute_name.json @@ -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" } + ] + } +] \ No newline at end of file diff --git a/pkg/module/share/testsuite/testdata/find_resources_by_attribute_type.json b/pkg/module/share/testsuite/testdata/find_resources_by_attribute_type.json new file mode 100644 index 0000000..62288ce --- /dev/null +++ b/pkg/module/share/testsuite/testdata/find_resources_by_attribute_type.json @@ -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" } + ] + } +] \ No newline at end of file diff --git a/pkg/module/share/testsuite/testdata/find_resources_by_attribute_type_and_name.json b/pkg/module/share/testsuite/testdata/find_resources_by_attribute_type_and_name.json new file mode 100644 index 0000000..f4d0817 --- /dev/null +++ b/pkg/module/share/testsuite/testdata/find_resources_by_attribute_type_and_name.json @@ -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 } + ] + } +] \ No newline at end of file diff --git a/pkg/module/share/testsuite/testdata/get_resource.json b/pkg/module/share/testsuite/testdata/get_resource.json new file mode 100644 index 0000000..35e3c1c --- /dev/null +++ b/pkg/module/share/testsuite/testdata/get_resource.json @@ -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" } + ] + } +] \ No newline at end of file diff --git a/pkg/module/share/testsuite/testdata/share.js b/pkg/module/share/testsuite/testdata/share.js new file mode 100644 index 0000000..60bc8c5 --- /dev/null +++ b/pkg/module/share/testsuite/testdata/share.js @@ -0,0 +1,82 @@ + +function testModule() { + var ctx = context.new(); + var resourceId = "my-first-res"; + var attributes = [ + { name: "my_text", type: share.TYPE_TEXT, value: "my_text" }, + { name: "my_number", type: share.TYPE_NUMBER, value: 5 }, + { name: "my_path", type: share.TYPE_PATH, value: "/my/path" }, + { name: "my_bool", type: share.TYPE_BOOL, value: true }, + ] + + // Create resource with attributes + + var resource = share.upsertResource( + ctx, resourceId, + attributes[0], + attributes[1], + attributes[2], + attributes[3] + ); + + if (resource.id != resourceId) { + throw new Error("resource.id: expected '"+resourceId+"', got '"+resource.id+"'") + } + + if (resource.origin != "test.app.edge") { + throw new Error("resource.origin: expected 'test.app.edge', got '"+resource.origin+"'") + } + + if (resource.attributes.length != 4) { + throw new Error("resource.attributes.length: expected '1', got '"+resource.attributes.length+"'") + } + + for(var attr, i = 0;( attr = attributes[i] ); i++) { + var exists = resource.has(attr.name, attr.type); + if (!exists) { + throw new Error("resource.has('"+attr.name+"'): expected 'true', got '"+hasAttr+"'") + } + + var value = resource.get(attr.name, attr.type); + if (value != attr.value) { + throw new Error("value: expected '"+attr.value+"', got '"+value+"'") + } + } + + // Test acces of unexistant attribute + + var unexistantAttr = "unexistant_attr" + + var exists = resource.has(unexistantAttr, share.TYPE_TEXT); + if (exists) { + throw new Error("attr '"+unexistantAttr+"' should not exist") + } + + var expected = "foo" + var value = resource.get(unexistantAttr, share.TYPE_TEXT, expected); + if (value != expected) { + throw new Error("resource.get('"+attr.name+"', share.TYPE_TEXT, '"+expected+"'): expected '"+expected+"', got '"+value+"'") + } + + // Search resources + + // With any attribute + var results = share.findResources(ctx, share.ANY_NAME, share.ANY_TYPE); + if (results.length != 1) { + throw new Error("results.length: expected '1', got '"+results.length+"'") + } + + // With an unexistant attribute + var results = share.findResources(ctx, unexistantAttr, share.ANY_TYPE); + if (results.length != 0) { + throw new Error("results.length: expected '0', got '"+results.length+"'") + } + + // With a wrong type + var results = share.findResources(ctx, "my_text", share.TYPE_NUMBER); + if (results.length != 0) { + throw new Error("results.length: expected '0', got '"+results.length+"'") + } +} + + \ No newline at end of file diff --git a/pkg/module/share/value_type.go b/pkg/module/share/value_type.go new file mode 100644 index 0000000..4cf9f65 --- /dev/null +++ b/pkg/module/share/value_type.go @@ -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 +} diff --git a/pkg/module/share/value_type_test.go b/pkg/module/share/value_type_test.go new file mode 100644 index 0000000..56ff605 --- /dev/null +++ b/pkg/module/share/value_type_test.go @@ -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) + } +} diff --git a/pkg/module/util/assert.go b/pkg/module/util/assert.go index 7990534..f8e991c 100644 --- a/pkg/module/util/assert.go +++ b/pkg/module/util/assert.go @@ -3,6 +3,7 @@ package util import ( "context" + "forge.cadoles.com/arcad/edge/pkg/app" "github.com/dop251/goja" "github.com/pkg/errors" ) @@ -26,3 +27,15 @@ func AssertObject(v goja.Value, r *goja.Runtime) map[string]any { func AssertString(v goja.Value, r *goja.Runtime) string { return AssertType[string](v, r) } + +func AssertAppID(v goja.Value, r *goja.Runtime) app.ID { + value := v.Export() + switch typ := value.(type) { + case string: + return app.ID(typ) + case app.ID: + return typ + default: + panic(r.ToValue(errors.Errorf("expected value to be a string or app.ID, got '%T'", value))) + } +} diff --git a/pkg/storage/sqlite/blob_bucket.go b/pkg/storage/sqlite/blob_bucket.go index 0661f81..a096dcc 100644 --- a/pkg/storage/sqlite/blob_bucket.go +++ b/pkg/storage/sqlite/blob_bucket.go @@ -16,7 +16,7 @@ import ( type BlobBucket struct { name string - getDB getDBFunc + getDB GetDBFunc closed bool } @@ -236,7 +236,7 @@ func (b *BlobBucket) withTx(ctx context.Context, fn func(tx *sql.Tx) error) erro return errors.WithStack(err) } - if err := withTx(ctx, db, fn); err != nil { + if err := WithTx(ctx, db, fn); err != nil { return errors.WithStack(err) } @@ -246,7 +246,7 @@ func (b *BlobBucket) withTx(ctx context.Context, fn func(tx *sql.Tx) error) erro type blobWriterCloser struct { id storage.BlobID bucket string - getDB getDBFunc + getDB GetDBFunc buf bytes.Buffer closed bool } @@ -335,7 +335,7 @@ func (wbc *blobWriterCloser) withTx(ctx context.Context, fn func(tx *sql.Tx) err return errors.WithStack(err) } - if err := withTx(ctx, db, fn); err != nil { + if err := WithTx(ctx, db, fn); err != nil { return errors.WithStack(err) } @@ -345,7 +345,7 @@ func (wbc *blobWriterCloser) withTx(ctx context.Context, fn func(tx *sql.Tx) err type blobReaderCloser struct { id storage.BlobID bucket string - getDB getDBFunc + getDB GetDBFunc reader bytes.Reader once sync.Once closed bool @@ -444,7 +444,7 @@ func (brc *blobReaderCloser) withTx(ctx context.Context, fn func(tx *sql.Tx) err return errors.WithStack(err) } - if err := withTx(ctx, db, fn); err != nil { + if err := WithTx(ctx, db, fn); err != nil { return errors.WithStack(err) } diff --git a/pkg/storage/sqlite/blob_store.go b/pkg/storage/sqlite/blob_store.go index 4cb2baf..4b59423 100644 --- a/pkg/storage/sqlite/blob_store.go +++ b/pkg/storage/sqlite/blob_store.go @@ -10,7 +10,7 @@ import ( ) type BlobStore struct { - getDB getDBFunc + getDB GetDBFunc } // DeleteBucket implements storage.BlobStore @@ -81,7 +81,7 @@ func (s *BlobStore) OpenBucket(ctx context.Context, name string) (storage.BlobBu func ensureBlobTables(ctx context.Context, db *sql.DB) error { logger.Debug(ctx, "creating blobs table") - err := withTx(ctx, db, func(tx *sql.Tx) error { + err := WithTx(ctx, db, func(tx *sql.Tx) error { query := ` CREATE TABLE IF NOT EXISTS blobs ( id TEXT, @@ -114,7 +114,7 @@ func (s *BlobStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error return errors.WithStack(err) } - if err := withTx(ctx, db, fn); err != nil { + if err := WithTx(ctx, db, fn); err != nil { return errors.WithStack(err) } @@ -122,13 +122,13 @@ func (s *BlobStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error } func NewBlobStore(dsn string) *BlobStore { - getDB := newGetDBFunc(dsn, ensureBlobTables) + getDB := NewGetDBFunc(dsn, ensureBlobTables) return &BlobStore{getDB} } func NewBlobStoreWithDB(db *sql.DB) *BlobStore { - getDB := newGetDBFuncFromDB(db, ensureBlobTables) + getDB := NewGetDBFuncFromDB(db, ensureBlobTables) return &BlobStore{getDB} } diff --git a/pkg/storage/sqlite/document_store.go b/pkg/storage/sqlite/document_store.go index 057903a..d7bbc40 100644 --- a/pkg/storage/sqlite/document_store.go +++ b/pkg/storage/sqlite/document_store.go @@ -17,7 +17,7 @@ import ( ) type DocumentStore struct { - getDB getDBFunc + getDB GetDBFunc } // Delete implements storage.DocumentStore @@ -269,7 +269,7 @@ func (s *DocumentStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) e return errors.WithStack(err) } - if err := withTx(ctx, db, fn); err != nil { + if err := WithTx(ctx, db, fn); err != nil { return errors.WithStack(err) } @@ -277,7 +277,7 @@ func (s *DocumentStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) e } func ensureTables(ctx context.Context, db *sql.DB) error { - err := withTx(ctx, db, func(tx *sql.Tx) error { + err := WithTx(ctx, db, func(tx *sql.Tx) error { query := ` CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, @@ -344,7 +344,7 @@ func withLimitOffsetClause(query string, args []any, limit int, offset int) (str } func NewDocumentStore(path string) *DocumentStore { - getDB := newGetDBFunc(path, ensureTables) + getDB := NewGetDBFunc(path, ensureTables) return &DocumentStore{ getDB: getDB, @@ -352,7 +352,7 @@ func NewDocumentStore(path string) *DocumentStore { } func NewDocumentStoreWithDB(db *sql.DB) *DocumentStore { - getDB := newGetDBFuncFromDB(db, ensureTables) + getDB := NewGetDBFuncFromDB(db, ensureTables) return &DocumentStore{ getDB: getDB, diff --git a/pkg/storage/sqlite/sql.go b/pkg/storage/sqlite/sql.go index 2f5c0b2..08cb627 100644 --- a/pkg/storage/sqlite/sql.go +++ b/pkg/storage/sqlite/sql.go @@ -22,7 +22,7 @@ func Open(path string) (*sql.DB, error) { return db, nil } -func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { +func WithTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { var tx *sql.Tx tx, err := db.BeginTx(ctx, nil) @@ -70,9 +70,9 @@ func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { return nil } -type getDBFunc func(ctx context.Context) (*sql.DB, error) +type GetDBFunc func(ctx context.Context) (*sql.DB, error) -func newGetDBFunc(dsn string, initFunc func(ctx context.Context, db *sql.DB) error) getDBFunc { +func NewGetDBFunc(dsn string, initFunc func(ctx context.Context, db *sql.DB) error) GetDBFunc { var ( db *sql.DB mutex sync.RWMutex @@ -110,7 +110,7 @@ func newGetDBFunc(dsn string, initFunc func(ctx context.Context, db *sql.DB) err } } -func newGetDBFuncFromDB(db *sql.DB, initFunc func(ctx context.Context, db *sql.DB) error) getDBFunc { +func NewGetDBFuncFromDB(db *sql.DB, initFunc func(ctx context.Context, db *sql.DB) error) GetDBFunc { var err error initOnce := &sync.Once{}