feat(module,share): cross-app resource sharing module
All checks were successful
arcad/edge/pipeline/head This commit looks good

This commit is contained in:
wpetit 2023-04-13 10:16:48 +02:00
parent 1606ff5937
commit f99b1ac6ac
30 changed files with 2087 additions and 54 deletions

View File

@ -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
}
}

View File

@ -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)

View 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"
}
```

View 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
View 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
}

View 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
}
}

View File

@ -0,0 +1,32 @@
package share
import (
"context"
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
)
type ResourceID string
type Resource interface {
ID() ResourceID
Origin() app.ID
Attributes() []Attribute
}
type Attribute interface {
Name() string
Value() any
Type() ValueType
UpdatedAt() time.Time
CreatedAt() time.Time
}
type 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
}

View File

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

View File

@ -0,0 +1,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)
}

View 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{}

View 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
}

View File

@ -0,0 +1 @@
*.sqlite*

View 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()
}

View 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)
})
}

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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+"'")
}
}

View File

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

View File

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

View File

@ -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)))
}
}

View File

@ -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)
}

View File

@ -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}
}

View File

@ -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,

View File

@ -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{}