feat(module,share): cross-app resource sharing module
All checks were successful
arcad/edge/pipeline/head This commit looks good
All checks were successful
arcad/edge/pipeline/head This commit looks good
This commit is contained in:
parent
1606ff5937
commit
f99b1ac6ac
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
143
doc/apps/server-api/share.md
Normal file
143
doc/apps/server-api/share.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
8
pkg/module/share/error.go
Normal file
8
pkg/module/share/error.go
Normal file
@ -0,0 +1,8 @@
|
||||
package share
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAttributeRequired = errors.New("attribute required")
|
||||
)
|
341
pkg/module/share/module.go
Normal file
341
pkg/module/share/module.go
Normal file
@ -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
|
||||
}
|
30
pkg/module/share/options.go
Normal file
30
pkg/module/share/options.go
Normal file
@ -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
|
||||
}
|
||||
}
|
32
pkg/module/share/repository.go
Normal file
32
pkg/module/share/repository.go
Normal 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 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
|
||||
}
|
121
pkg/module/share/resource.go
Normal file
121
pkg/module/share/resource.go
Normal 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
|
||||
}
|
13
pkg/module/share/sqlite/module_test.go
Normal file
13
pkg/module/share/sqlite/module_test.go
Normal file
@ -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)
|
||||
}
|
429
pkg/module/share/sqlite/repository.go
Normal file
429
pkg/module/share/sqlite/repository.go
Normal file
@ -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{}
|
33
pkg/module/share/sqlite/repository_test.go
Normal file
33
pkg/module/share/sqlite/repository_test.go
Normal file
@ -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
|
||||
}
|
1
pkg/module/share/sqlite/testdata/.gitignore
vendored
Normal file
1
pkg/module/share/sqlite/testdata/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.sqlite*
|
47
pkg/module/share/testsuite/module.go
Normal file
47
pkg/module/share/testsuite/module.go
Normal file
@ -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()
|
||||
}
|
16
pkg/module/share/testsuite/repository.go
Normal file
16
pkg/module/share/testsuite/repository.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
344
pkg/module/share/testsuite/repository_cases.go
Normal file
344
pkg/module/share/testsuite/repository_cases.go
Normal file
@ -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
|
||||
}
|
6
pkg/module/share/testsuite/testdata.go
Normal file
6
pkg/module/share/testsuite/testdata.go
Normal file
@ -0,0 +1,6 @@
|
||||
package testsuite
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed testdata/*
|
||||
var testData embed.FS
|
11
pkg/module/share/testsuite/testdata/delete_attributes.json
vendored
Normal file
11
pkg/module/share/testsuite/testdata/delete_attributes.json
vendored
Normal 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 }
|
||||
]
|
||||
}
|
||||
]
|
16
pkg/module/share/testsuite/testdata/delete_resource.json
vendored
Normal file
16
pkg/module/share/testsuite/testdata/delete_resource.json
vendored
Normal 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" }
|
||||
]
|
||||
}
|
||||
]
|
29
pkg/module/share/testsuite/testdata/find_resources_by_attribute_name.json
vendored
Normal file
29
pkg/module/share/testsuite/testdata/find_resources_by_attribute_name.json
vendored
Normal 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" }
|
||||
]
|
||||
}
|
||||
]
|
28
pkg/module/share/testsuite/testdata/find_resources_by_attribute_type.json
vendored
Normal file
28
pkg/module/share/testsuite/testdata/find_resources_by_attribute_type.json
vendored
Normal 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" }
|
||||
]
|
||||
}
|
||||
]
|
23
pkg/module/share/testsuite/testdata/find_resources_by_attribute_type_and_name.json
vendored
Normal file
23
pkg/module/share/testsuite/testdata/find_resources_by_attribute_type_and_name.json
vendored
Normal 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 }
|
||||
]
|
||||
}
|
||||
]
|
16
pkg/module/share/testsuite/testdata/get_resource.json
vendored
Normal file
16
pkg/module/share/testsuite/testdata/get_resource.json
vendored
Normal 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" }
|
||||
]
|
||||
}
|
||||
]
|
82
pkg/module/share/testsuite/testdata/share.js
vendored
Normal file
82
pkg/module/share/testsuite/testdata/share.js
vendored
Normal file
@ -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+"'")
|
||||
}
|
||||
}
|
||||
|
||||
|
86
pkg/module/share/value_type.go
Normal file
86
pkg/module/share/value_type.go
Normal 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
|
||||
}
|
88
pkg/module/share/value_type_test.go
Normal file
88
pkg/module/share/value_type_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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{}
|
||||
|
Loading…
Reference in New Issue
Block a user