Compare commits

...

17 Commits

Author SHA1 Message Date
8680e139e7 fix(storage,document,sqlite): fix nil pointer exception in query options 2023-03-01 13:06:32 +01:00
8789b85d92 feat(server): handle panic in runtime
ref #2
2023-03-01 13:04:40 +01:00
fefcba5901 Merge pull request 'feat(storage,document,sqlite): implements order by and limit' (#4) from issue-3 into master
Reviewed-on: #4
2023-03-01 12:13:32 +01:00
5ad4ab2e23 feat(storage,document,sqlite): implements order by and limit 2023-03-01 12:12:11 +01:00
4eb1f8fc90 fix(doc): typo 2023-02-24 14:44:20 +01:00
640f429580 feat(module,auth): authentication module with arbitrary claims support 2023-02-24 14:40:28 +01:00
7f07e52ae0 chore: remove obsolete modules 2023-02-24 10:39:46 +01:00
c4865c149f feat(store,document,sqlite): log upsert query in debug level 2023-02-24 10:38:48 +01:00
b9f985ab0c fix(module,store): allow querying on _id, _createdAt, _updatedAt attributes 2023-02-21 15:05:01 +01:00
f01b1ef3b2 feat(module,auth): auth based on jwt 2023-02-21 12:14:29 +01:00
c721d46218 fix(storage,document): sequential upserts 2023-02-17 22:26:12 +01:00
a13dfffd5c fix(http): default upload max size 2023-02-17 22:25:23 +01:00
19cd4d56e7 fix(command,package): correctly set modtime on zipped files 2023-02-17 17:26:25 +01:00
85f50eb9d5 fix(storage,sqlite): in operator sqlite translation 2023-02-17 16:59:05 +01:00
673586c2f7 fix(module,cast): jsonize device attributes 2023-02-17 13:03:10 +01:00
4606d7a08d fix(module,store): nillable filter and query options 2023-02-17 13:01:39 +01:00
3e601272d7 feat(server,module): add cast module 2023-02-17 10:41:16 +01:00
57 changed files with 2420 additions and 733 deletions

View File

@ -125,23 +125,34 @@ func copyDir(writer *zip.Writer, baseDir string, zipBasePath string) error {
} }
func copyFile(writer *zip.Writer, srcPath string, zipPath string) error { func copyFile(writer *zip.Writer, srcPath string, zipPath string) error {
r, err := os.Open(srcPath) srcFile, err := os.Open(srcPath)
if err != nil {
return errors.WithStack(err)
}
srcStat, err := os.Stat(srcPath)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
defer func() { defer func() {
if err := r.Close(); err != nil { if err := srcFile.Close(); err != nil {
panic(errors.WithStack(err)) panic(errors.WithStack(err))
} }
}() }()
f, err := writer.Create(zipPath) fileHeader := &zip.FileHeader{
Name: zipPath,
Modified: srcStat.ModTime().UTC(),
Method: zip.Deflate,
}
file, err := writer.CreateHeader(fileHeader)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
if _, err = io.Copy(f, r); err != nil { if _, err = io.Copy(file, srcFile); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }

View File

@ -2,18 +2,28 @@ package app
import ( import (
"database/sql" "database/sql"
"fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus"
"forge.cadoles.com/arcad/edge/pkg/bus/memory" "forge.cadoles.com/arcad/edge/pkg/bus/memory"
appHTTP "forge.cadoles.com/arcad/edge/pkg/http" appHTTP "forge.cadoles.com/arcad/edge/pkg/http"
"forge.cadoles.com/arcad/edge/pkg/module" "forge.cadoles.com/arcad/edge/pkg/module"
"forge.cadoles.com/arcad/edge/pkg/module/auth"
"forge.cadoles.com/arcad/edge/pkg/module/cast"
"forge.cadoles.com/arcad/edge/pkg/module/net"
"forge.cadoles.com/arcad/edge/pkg/storage"
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite" "forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
"forge.cadoles.com/arcad/edge/pkg/bundle" "forge.cadoles.com/arcad/edge/pkg/bundle"
"github.com/dop251/goja"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/golang-jwt/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -52,14 +62,35 @@ func RunCommand() *cli.Command {
Usage: "use `FILE` for SQLite storage database", Usage: "use `FILE` for SQLite storage database",
Value: "data.sqlite", Value: "data.sqlite",
}, },
&cli.StringFlag{
Name: "auth-subject",
Usage: "set the `SUBJECT` associated with the simulated connected user",
Value: "jdoe",
},
&cli.StringFlag{
Name: "auth-role",
Usage: "set the `ROLE` associated with the simulated connected user",
Value: "user",
},
&cli.StringFlag{
Name: "auth-preferred-username",
Usage: "set the `PREFERRED_USERNAME` associated with the simulated connected user",
Value: "Jane Doe",
},
}, },
Action: func(ctx *cli.Context) error { Action: func(ctx *cli.Context) error {
address := ctx.String("address") address := ctx.String("address")
path := ctx.String("path") path := ctx.String("path")
logFormat := ctx.String("log-format") logFormat := ctx.String("log-format")
logLevel := ctx.Int("log-level") logLevel := ctx.Int("log-level")
storageFile := ctx.String("storage-file") storageFile := ctx.String("storage-file")
authSubject := ctx.String("auth-subject")
authRole := ctx.String("auth-role")
authPreferredUsername := ctx.String("auth-preferred-username")
logger.SetFormat(logger.Format(logFormat)) logger.SetFormat(logger.Format(logFormat))
logger.SetLevel(logger.Level(logLevel)) logger.SetLevel(logger.Level(logLevel))
@ -80,6 +111,7 @@ func RunCommand() *cli.Command {
mux := chi.NewMux() mux := chi.NewMux()
mux.Use(middleware.Logger) mux.Use(middleware.Logger)
mux.Use(dummyAuthMiddleware(authSubject, authRole, authPreferredUsername))
bus := memory.NewBus() bus := memory.NewBus()
@ -88,20 +120,12 @@ func RunCommand() *cli.Command {
return errors.Wrapf(err, "could not open database with path '%s'", storageFile) return errors.Wrapf(err, "could not open database with path '%s'", storageFile)
} }
documentStore := sqlite.NewDocumentStoreWithDB(db) ds := sqlite.NewDocumentStoreWithDB(db)
blobStore := sqlite.NewBlobStoreWithDB(db) bs := sqlite.NewBlobStoreWithDB(db)
handler := appHTTP.NewHandler( handler := appHTTP.NewHandler(
appHTTP.WithBus(bus), appHTTP.WithBus(bus),
appHTTP.WithServerModules( appHTTP.WithServerModules(getServerModules(bus, ds, bs)...),
module.ContextModuleFactory(),
module.ConsoleModuleFactory(),
module.LifecycleModuleFactory(bus),
module.NetModuleFactory(bus),
module.RPCModuleFactory(bus),
module.StoreModuleFactory(documentStore),
module.BlobModuleFactory(bus, blobStore),
),
) )
if err := handler.Load(bundle); err != nil { if err := handler.Load(bundle); err != nil {
return errors.Wrap(err, "could not load app bundle") return errors.Wrap(err, "could not load app bundle")
@ -119,3 +143,89 @@ func RunCommand() *cli.Command {
}, },
} }
} }
func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore) []app.ServerModuleFactory {
return []app.ServerModuleFactory{
module.ContextModuleFactory(),
module.ConsoleModuleFactory(),
cast.CastModuleFactory(),
module.LifecycleModuleFactory(),
net.ModuleFactory(bus),
module.RPCModuleFactory(bus),
module.StoreModuleFactory(ds),
module.BlobModuleFactory(bus, bs),
module.Extends(
auth.ModuleFactory(
auth.WithJWT(dummyKeyFunc),
),
func(o *goja.Object) {
if err := o.Set("CLAIM_ROLE", "role"); err != nil {
panic(errors.New("could not set 'CLAIM_ROLE' property"))
}
if err := o.Set("CLAIM_PREFERRED_USERNAME", "preferred_username"); err != nil {
panic(errors.New("could not set 'CLAIM_PREFERRED_USERNAME' property"))
}
},
),
}
}
var dummySecret = []byte("not_so_secret")
func dummyKeyFunc(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"])
}
return dummySecret, nil
}
func dummyAuthMiddleware(subject, role, username string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
unauthenticated := subject == "" && role == "" && username == ""
if unauthenticated {
h.ServeHTTP(w, r)
return
}
claims := jwt.MapClaims{
"nbf": time.Now().UTC().Unix(),
}
if subject != "" {
claims["sub"] = subject
}
if role != "" {
claims["role"] = role
}
if username != "" {
claims["preferred_username"] = username
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ctx := r.Context()
rawToken, err := token.SignedString(dummySecret)
if err != nil {
logger.Error(ctx, "could not sign token", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
r.Header.Add("Authorization", "Bearer "+rawToken)
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}

View File

@ -12,7 +12,7 @@ Comme son nom l'indique, elle permet d'exécuter des opérations d'initialisatio
```js ```js
function onInit() { function onInit() {
console.log("My app booted !")
} }
``` ```
@ -20,9 +20,11 @@ function onInit() {
Listes des modules disponibles côté serveur. Listes des modules disponibles côté serveur.
- [`auth`](./auth.md)
- [`blob`](./blob.md)
- [`cast`](./cast.md)
- [`console`](./console.md) - [`console`](./console.md)
- [`context`](./context.md) - [`context`](./context.md)
- [`net`](./net.md) - [`net`](./net.md)
- [`rpc`](./rpc.md) - [`rpc`](./rpc.md)
- [`store`](./store.md) - [`store`](./store.md)
- [`blob`](./blob.md)

View File

@ -0,0 +1,41 @@
# Module `auth`
Ce module permet de récupérer des informations concernant l'utilisateur connecté et ses attributs.
## Méthodes
### `auth.getClaim(ctx: Context, name: string): string`
Récupère un attribut associé à l'utilisateur.
#### Arguments
- `ctx` **Context** Le contexte d'exécution. Voir la documentation du module [`context`](./context.md)
- `name` **string** Nom de l'attribut à retrouver
#### Valeur de retour
Valeur de l'attribut associé ou vide si la requête est non authentifiée ou que l'attribut n'a pas été trouvé.
#### Usage
```js
function onClientMessage(ctx, message) {
var subject = auth.getClaim(ctx, auth.CLAIM_SUBJECT);
console.log("Connected user is", subject);
}
```
## Propriétés
### `auth.CLAIM_SUBJECT`
Cette propriété identifie l'utilisateur connecté. Si la valeur retournée par la méthode `getClaim()` est vide, alors l'utilisateur n'est pas connecté.
### `auth.CLAIM_ROLE`
Cette propriété retourne le rôle de l'utilisateur connecté au sein du "tenant" courant. Si la valeur retournée par la méthode `getClaim()` est vide, alors l'utilisateur n'est pas connecté.
### `auth.CLAIM_PREFERRED_USERNAME`
Cette propriété retourne le nom "préféré pour l'affichage" de l'utilisateur connecté. Si la valeur retournée par la méthode `getClaim()` est vide, alors l'utilisateur n'est pas connecté ou l'utilisateur n'a pas défini de nom d'utilisateur particulier.

View File

@ -0,0 +1,38 @@
# Module `cast`
Ce module permet de communiquer avec des appareils de présentation de type [Chromecast](https://store.google.com/fr/product/chromecast_setup?hl=fr).
## Méthodes
### `cast.refreshDevices(timeout?: string = '30s'): Promise<Device[]>`
Rafraichit la liste locale des appareils de présentation disponibles sur les réseaux locaux de la borne.
L'appel à cette méthode rafraîchit également la liste mise en cache et renvoyée par `cast.getDevices()`.
### `cast.getDevices(): []Device`
Retourne la liste mise en cache des appareils de présentation disponibles sur les réseaux locaux de la borne.
La liste est initialement vide. Un appel initial à `cast.refreshDevices()` est nécessaire afin de mettre à jour celle ci.
### `cast.loadUrl(deviceUuid: string, url: string, timeout?: string = '30s'): Promise<void>`
Charge l'URL donnée sur l'appareil de présentation identifié par l'UUID `deviceUuid`.
### `cast.stopCast(deviceUuid: string, timeout?: string = '30s'): Promise<void>`
Stoppe l'application courante sur l'appareil de présentation identifié par l'UUID `deviceUuid`.
## Objets
### `Device`
```typescript
interface Device {
uuid: string // UUID de l'appareil
name: string // Nom de l'appareil
host: string // Adresse IPv4 de l'appareil
port: number // Port distant du service
}
```

9
go.mod
View File

@ -4,9 +4,18 @@ go 1.19
require modernc.org/sqlite v1.20.4 require modernc.org/sqlite v1.20.4
require (
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/hashicorp/go.net v0.0.0-20151006203346-104dcad90073 // indirect
github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect
github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8 // indirect
)
require ( require (
cdr.dev/slog v1.4.0 // indirect cdr.dev/slog v1.4.0 // indirect
github.com/alecthomas/chroma v0.7.0 // indirect github.com/alecthomas/chroma v0.7.0 // indirect
github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect

17
go.sum
View File

@ -52,6 +52,8 @@ github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYU
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692 h1:JW4WZlqyaNWUUahfr7MigeDW6jmtam5cTzzo1lwsFhE=
github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692/go.mod h1:Au0ipPuCBA7zsOC61SnyrYetm8VT3vo1UJtwHeYke44=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@ -66,6 +68,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.0.1-0.20160907170601-6d212800a42e/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -105,6 +108,10 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e h1:eeyMpoxANuWNQ9O2auv4wXxJsrXzLUhdHaOmNWEGkRY=
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -172,9 +179,13 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go.net v0.0.0-20151006203346-104dcad90073 h1:9dodOMuH6u7LvPEkVydBv6KTHdm+SqsHOxHTzRW+1+w=
github.com/hashicorp/go.net v0.0.0-20151006203346-104dcad90073/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 h1:yupxZNIxm5U8Tfb8g65irIuHkgF8c4koHC7daPSyMTE=
github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8/go.mod h1:aa76Av3qgPeIQp9Y3qIkTBPieQYNkQ13Kxe7pze9Wb0=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/igm/sockjs-go/v3 v3.0.2 h1:2m0k53w0DBiGozeQUIEPR6snZFmpFpYvVsGnfLPNXbE= github.com/igm/sockjs-go/v3 v3.0.2 h1:2m0k53w0DBiGozeQUIEPR6snZFmpFpYvVsGnfLPNXbE=
@ -202,6 +213,8 @@ github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGe
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8 h1:ALvJ9V8nNf04PFHMR2sot56N/pjrx5LzZGvUlnhdiCE=
github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
@ -217,6 +230,7 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
@ -229,11 +243,13 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm
github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk= github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.1.5-0.20160925220609-976c720a22c8/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.24.3 h1:7Q1w8VN8yE0MJEHP06bv89PjYsN4IHWED2s1v/Zlfm0= github.com/urfave/cli/v2 v2.24.3 h1:7Q1w8VN8yE0MJEHP06bv89PjYsN4IHWED2s1v/Zlfm0=
github.com/urfave/cli/v2 v2.24.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/urfave/cli/v2 v2.24.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
@ -299,6 +315,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20161013035702-8b4af36cd21a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=

View File

@ -21,6 +21,7 @@
</script> </script>
<script src="/edge/sdk/client.js"></script> <script src="/edge/sdk/client.js"></script>
<script src="test/client-sdk.js"></script> <script src="test/client-sdk.js"></script>
<script src="test/auth-module.js"></script>
<script class="mocha-exec"> <script class="mocha-exec">
mocha.run(); mocha.run();
</script> </script>

View File

@ -0,0 +1,20 @@
describe('Auth Module', function() {
before(() => {
return Edge.connect();
});
after(() => {
Edge.disconnect();
});
it('should retrieve user informations', function() {
return Edge.rpc("getUserInfo")
.then(userInfo => {
chai.assert.isNotNull(userInfo.subject);
chai.assert.isNotNull(userInfo.role);
chai.assert.isNotNull(userInfo.preferredUsername);
})
});
});

View File

@ -10,12 +10,12 @@ function onInit() {
rpc.register("add", add); rpc.register("add", add);
rpc.register("reset", reset); rpc.register("reset", reset);
rpc.register("total", total); rpc.register("total", total);
rpc.register("getUserInfo", getUserInfo);
} }
// Called for each client message // Called for each client message
function onClientMessage(ctx, data) { function onClientMessage(ctx, data) {
var sessionId = context.get(ctx, context.SESSION_ID); console.log("onClientMessage", data.now);
console.log("onClientMessage", sessionId, data.now);
net.send(ctx, { now: data.now }); net.send(ctx, { now: data.now });
} }
@ -60,4 +60,16 @@ function reset(ctx, params) {
function total(ctx, params) { function total(ctx, params) {
return count; return count;
}
function getUserInfo(ctx, params) {
var subject = auth.getClaim(ctx, auth.CLAIM_SUBJECT);
var role = auth.getClaim(ctx, auth.CLAIM_ROLE);
var preferredUsername = auth.getClaim(ctx, auth.CLAIM_PREFERRED_USERNAME);
return {
subject: subject,
role: role,
preferredUsername: preferredUsername,
};
} }

View File

@ -1,15 +1,20 @@
package app package app
import ( import (
"context"
"math/rand" "math/rand"
"sync" "sync"
"github.com/dop251/goja" "github.com/dop251/goja"
"github.com/dop251/goja_nodejs/eventloop" "github.com/dop251/goja_nodejs/eventloop"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
) )
var ErrFuncDoesNotExist = errors.New("function does not exist") var (
ErrFuncDoesNotExist = errors.New("function does not exist")
ErUnknownError = errors.New("unknown error")
)
type Server struct { type Server struct {
runtime *goja.Runtime runtime *goja.Runtime
@ -26,16 +31,18 @@ func (s *Server) Load(name string, src string) error {
return nil return nil
} }
func (s *Server) ExecFuncByName(funcName string, args ...interface{}) (goja.Value, error) { func (s *Server) ExecFuncByName(ctx context.Context, funcName string, args ...interface{}) (goja.Value, error) {
ctx = logger.With(ctx, logger.F("function", funcName), logger.F("args", args))
callable, ok := goja.AssertFunction(s.runtime.Get(funcName)) callable, ok := goja.AssertFunction(s.runtime.Get(funcName))
if !ok { if !ok {
return nil, errors.WithStack(ErrFuncDoesNotExist) return nil, errors.WithStack(ErrFuncDoesNotExist)
} }
return s.Exec(callable, args...) return s.Exec(ctx, callable, args...)
} }
func (s *Server) Exec(callable goja.Callable, args ...interface{}) (goja.Value, error) { func (s *Server) Exec(ctx context.Context, callable goja.Callable, args ...interface{}) (goja.Value, error) {
var ( var (
wg sync.WaitGroup wg sync.WaitGroup
value goja.Value value goja.Value
@ -45,6 +52,25 @@ func (s *Server) Exec(callable goja.Callable, args ...interface{}) (goja.Value,
wg.Add(1) wg.Add(1)
s.loop.RunOnLoop(func(vm *goja.Runtime) { s.loop.RunOnLoop(func(vm *goja.Runtime) {
logger.Debug(ctx, "executing callable")
defer wg.Done()
defer func() {
if recovered := recover(); recovered != nil {
revoveredErr, ok := recovered.(error)
if ok {
logger.Error(ctx, "recovered runtime error", logger.E(errors.WithStack(revoveredErr)))
err = errors.WithStack(ErUnknownError)
return
}
panic(recovered)
}
}()
jsArgs := make([]goja.Value, 0, len(args)) jsArgs := make([]goja.Value, 0, len(args))
for _, a := range args { for _, a := range args {
jsArgs = append(jsArgs, vm.ToValue(a)) jsArgs = append(jsArgs, vm.ToValue(a))
@ -54,8 +80,6 @@ func (s *Server) Exec(callable goja.Callable, args ...interface{}) (goja.Value,
if err != nil { if err != nil {
err = errors.WithStack(err) err = errors.WithStack(err)
} }
wg.Done()
}) })
wg.Wait() wg.Wait()
@ -145,7 +169,7 @@ func (s *Server) Stop() {
func (s *Server) initModules(factories ...ServerModuleFactory) { func (s *Server) initModules(factories ...ServerModuleFactory) {
runtime := goja.New() runtime := goja.New()
runtime.SetFieldNameMapper(goja.UncapFieldNameMapper()) runtime.SetFieldNameMapper(goja.TagFieldNameMapper("goja", true))
runtime.SetRandSource(createRandomSource()) runtime.SetRandSource(createRandomSource())
modules := make([]ServerModule, 0, len(factories)) modules := make([]ServerModule, 0, len(factories))

View File

@ -65,7 +65,7 @@ func (h *Handler) handleAppUpload(w http.ResponseWriter, r *http.Request) {
} }
ctx = module.WithContext(ctx, map[module.ContextKey]any{ ctx = module.WithContext(ctx, map[module.ContextKey]any{
module.ContextKeyOriginRequest: r, ContextKeyOriginRequest: r,
}) })
requestMsg := module.NewMessageUploadRequest(ctx, fileHeader, metadata) requestMsg := module.NewMessageUploadRequest(ctx, fileHeader, metadata)
@ -117,7 +117,7 @@ func (h *Handler) handleAppDownload(w http.ResponseWriter, r *http.Request) {
ctx := logger.With(r.Context(), logger.F("blobID", blobID), logger.F("bucket", bucket)) ctx := logger.With(r.Context(), logger.F("blobID", blobID), logger.F("bucket", bucket))
ctx = module.WithContext(ctx, map[module.ContextKey]any{ ctx = module.WithContext(ctx, map[module.ContextKey]any{
module.ContextKeyOriginRequest: r, ContextKeyOriginRequest: r,
}) })
requestMsg := module.NewMessageDownloadRequest(ctx, bucket, storage.BlobID(blobID)) requestMsg := module.NewMessageDownloadRequest(ctx, bucket, storage.BlobID(blobID))

View File

@ -26,7 +26,7 @@ func defaultHandlerOptions() *HandlerOptions {
Bus: memory.NewBus(), Bus: memory.NewBus(),
SockJS: sockjsOptions, SockJS: sockjsOptions,
ServerModuleFactories: make([]app.ServerModuleFactory, 0), ServerModuleFactories: make([]app.ServerModuleFactory, 0),
UploadMaxFileSize: 1024 * 10, // 10Mb UploadMaxFileSize: 10 << (10 * 2), // 10Mb
} }
} }

View File

@ -15,6 +15,11 @@ const (
statusChannelClosed = iota statusChannelClosed = iota
) )
const (
ContextKeySessionID module.ContextKey = "sessionId"
ContextKeyOriginRequest module.ContextKey = "originRequest"
)
func (h *Handler) handleSockJS(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleSockJS(w http.ResponseWriter, r *http.Request) {
h.mutex.RLock() h.mutex.RLock()
defer h.mutex.RUnlock() defer h.mutex.RUnlock()
@ -79,7 +84,7 @@ func (h *Handler) handleServerMessages(ctx context.Context, sess sockjs.Session)
continue continue
} }
sessionID := module.ContextValue[string](serverMessage.Context, module.ContextKeySessionID) sessionID := module.ContextValue[string](serverMessage.Context, ContextKeySessionID)
isDest := sessionID == "" || sessionID == sess.ID() isDest := sessionID == "" || sessionID == sess.ID()
if !isDest { if !isDest {
@ -182,8 +187,8 @@ func (h *Handler) handleClientMessages(ctx context.Context, sess sockjs.Session)
ctx := logger.With(ctx, logger.F("payload", payload)) ctx := logger.With(ctx, logger.F("payload", payload))
ctx = module.WithContext(ctx, map[module.ContextKey]any{ ctx = module.WithContext(ctx, map[module.ContextKey]any{
module.ContextKeySessionID: sess.ID(), ContextKeySessionID: sess.ID(),
module.ContextKeyOriginRequest: sess.Request(), ContextKeyOriginRequest: sess.Request(),
}) })
clientMessage := module.NewClientMessage(ctx, payload) clientMessage := module.NewClientMessage(ctx, payload)

View File

@ -1,28 +0,0 @@
package module
import (
"context"
"fmt"
"github.com/dop251/goja"
)
func assertType[T any](v goja.Value, rt *goja.Runtime) T {
if c, ok := v.Export().(T); ok {
return c
}
panic(rt.NewTypeError(fmt.Sprintf("expected value to be a '%T', got '%T'", new(T), v.Export())))
}
func assertContext(v goja.Value, r *goja.Runtime) context.Context {
return assertType[context.Context](v, r)
}
func assertObject(v goja.Value, r *goja.Runtime) map[string]any {
return assertType[map[string]any](v, r)
}
func assertString(v goja.Value, r *goja.Runtime) string {
return assertType[string](v, r)
}

8
pkg/module/auth/error.go Normal file
View File

@ -0,0 +1,8 @@
package auth
import "errors"
var (
ErrUnauthenticated = errors.New("unauthenticated")
ErrClaimNotFound = errors.New("claim not found")
)

60
pkg/module/auth/jwt.go Normal file
View File

@ -0,0 +1,60 @@
package auth
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt"
"github.com/pkg/errors"
)
func WithJWT(keyFunc jwt.Keyfunc) OptionFunc {
return func(o *Option) {
o.GetClaim = func(ctx context.Context, r *http.Request, claimName string) (string, error) {
claim, err := getClaim[string](r, claimName, keyFunc)
if err != nil {
return "", errors.WithStack(err)
}
return claim, nil
}
}
}
func getClaim[T any](r *http.Request, claimAttr string, keyFunc jwt.Keyfunc) (T, error) {
rawToken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if rawToken == "" {
rawToken = r.URL.Query().Get("token")
}
if rawToken == "" {
return *new(T), errors.WithStack(ErrUnauthenticated)
}
token, err := jwt.Parse(rawToken, keyFunc)
if err != nil {
return *new(T), errors.WithStack(err)
}
if !token.Valid {
return *new(T), errors.Errorf("invalid jwt token: '%v'", token.Raw)
}
mapClaims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return *new(T), errors.Errorf("unexpected claims type '%T'", token.Claims)
}
rawClaim, exists := mapClaims[claimAttr]
if !exists {
return *new(T), errors.WithStack(ErrClaimNotFound)
}
claim, ok := rawClaim.(T)
if !ok {
return *new(T), errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", claimAttr, new(T), rawClaim)
}
return claim, nil
}

69
pkg/module/auth/module.go Normal file
View File

@ -0,0 +1,69 @@
package auth
import (
"net/http"
"forge.cadoles.com/arcad/edge/pkg/app"
edgeHTTP "forge.cadoles.com/arcad/edge/pkg/http"
"forge.cadoles.com/arcad/edge/pkg/module/util"
"github.com/dop251/goja"
"github.com/pkg/errors"
)
const (
ClaimSubject = "sub"
)
type Module struct {
server *app.Server
getClaimFunc GetClaimFunc
}
func (m *Module) Name() string {
return "auth"
}
func (m *Module) Export(export *goja.Object) {
if err := export.Set("getClaim", m.getClaim); err != nil {
panic(errors.Wrap(err, "could not set 'getClaim' function"))
}
if err := export.Set("CLAIM_SUBJECT", ClaimSubject); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_SUBJECT' property"))
}
}
func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := util.AssertContext(call.Argument(0), rt)
claimName := util.AssertString(call.Argument(1), rt)
req, ok := ctx.Value(edgeHTTP.ContextKeyOriginRequest).(*http.Request)
if !ok {
panic(rt.ToValue(errors.New("could not find http request in context")))
}
claim, err := m.getClaimFunc(ctx, req, claimName)
if err != nil {
if errors.Is(err, ErrUnauthenticated) || errors.Is(err, ErrClaimNotFound) {
return nil
}
panic(rt.ToValue(errors.WithStack(err)))
}
return rt.ToValue(claim)
}
func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
opt := &Option{}
for _, fn := range funcs {
fn(opt)
}
return func(server *app.Server) app.ServerModule {
return &Module{
server: server,
getClaimFunc: opt.GetClaim,
}
}
}

View File

@ -0,0 +1,124 @@
package auth
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
"cdr.dev/slog"
"forge.cadoles.com/arcad/edge/pkg/app"
edgeHTTP "forge.cadoles.com/arcad/edge/pkg/http"
"forge.cadoles.com/arcad/edge/pkg/module"
"github.com/golang-jwt/jwt"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func TestAuthModule(t *testing.T) {
t.Parallel()
logger.SetLevel(slog.LevelDebug)
keyFunc, secret := getKeyFunc()
server := app.NewServer(
module.ConsoleModuleFactory(),
ModuleFactory(
WithJWT(keyFunc),
),
)
data, err := ioutil.ReadFile("testdata/auth.js")
if err != nil {
t.Fatal(err)
}
if err := server.Load("testdata/auth.js", string(data)); err != nil {
t.Fatal(err)
}
if err := server.Start(); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
defer server.Stop()
req, err := http.NewRequest("GET", "/foo", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "jdoe",
"nbf": time.Now().UTC().Unix(),
})
rawToken, err := token.SignedString(secret)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
req.Header.Add("Authorization", "Bearer "+rawToken)
ctx := context.WithValue(context.Background(), edgeHTTP.ContextKeyOriginRequest, req)
if _, err := server.ExecFuncByName(ctx, "testAuth", ctx); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
}
func TestAuthAnonymousModule(t *testing.T) {
t.Parallel()
logger.SetLevel(slog.LevelDebug)
keyFunc, _ := getKeyFunc()
server := app.NewServer(
module.ConsoleModuleFactory(),
ModuleFactory(WithJWT(keyFunc)),
)
data, err := ioutil.ReadFile("testdata/auth_anonymous.js")
if err != nil {
t.Fatal(err)
}
if err := server.Load("testdata/auth_anonymous.js", string(data)); err != nil {
t.Fatal(err)
}
if err := server.Start(); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
defer server.Stop()
req, err := http.NewRequest("GET", "/foo", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
ctx := context.WithValue(context.Background(), edgeHTTP.ContextKeyOriginRequest, req)
if _, err := server.ExecFuncByName(ctx, "testAuth", ctx); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
}
func getKeyFunc() (jwt.Keyfunc, []byte) {
secret := []byte("not_so_secret")
keyFunc := func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
}
return keyFunc, secret
}

20
pkg/module/auth/option.go Normal file
View File

@ -0,0 +1,20 @@
package auth
import (
"context"
"net/http"
)
type GetClaimFunc func(ctx context.Context, r *http.Request, claimName string) (string, error)
type Option struct {
GetClaim GetClaimFunc
}
type OptionFunc func(*Option)
func WithGetClaim(fn GetClaimFunc) OptionFunc {
return func(o *Option) {
o.GetClaim = fn
}
}

9
pkg/module/auth/testdata/auth.js vendored Normal file
View File

@ -0,0 +1,9 @@
function testAuth(ctx) {
var subject = auth.getClaim(ctx, auth.CLAIM_SUBJECT);
if (subject !== "jdoe") {
throw new Error("subject: expected 'jdoe', got '"+subject+"'");
}
}

View File

@ -0,0 +1,9 @@
function testAuth(ctx) {
var subject = auth.getClaim(ctx, auth.CLAIM_SUBJECT);
if (subject !== undefined) {
throw new Error("subject: expected undefined, got '"+subject+"'");
}
}

View File

@ -1,109 +0,0 @@
package module
// import (
// "context"
// "sync"
// "forge.cadoles.com/arcad/edge/pkg/app"
// "forge.cadoles.com/arcad/edge/pkg/bus"
// "forge.cadoles.com/arcad/edge/pkg/repository"
// "github.com/dop251/goja"
// "github.com/pkg/errors"
// "gitlab.com/wpetit/goweb/logger"
// )
// type AuthorizationModule struct {
// appID app.ID
// bus bus.Bus
// backend *app.Server
// admins sync.Map
// }
// func (m *AuthorizationModule) Name() string {
// return "authorization"
// }
// func (m *AuthorizationModule) Export(export *goja.Object) {
// if err := export.Set("isAdmin", m.isAdmin); err != nil {
// panic(errors.Wrap(err, "could not set 'register' function"))
// }
// }
// func (m *AuthorizationModule) isAdmin(call goja.FunctionCall) goja.Value {
// userID := call.Argument(0).String()
// if userID == "" {
// panic(errors.New("first argument must be a user id"))
// }
// rawValue, exists := m.admins.Load(repository.UserID(userID))
// if !exists {
// return m.backend.ToValue(false)
// }
// isAdmin, ok := rawValue.(bool)
// if !ok {
// return m.backend.ToValue(false)
// }
// return m.backend.ToValue(isAdmin)
// }
// func (m *AuthorizationModule) handleEvents() {
// ctx := logger.With(context.Background(), logger.F("moduleAppID", m.appID))
// ns := AppMessageNamespace(m.appID)
// userConnectedMessages, err := m.bus.Subscribe(ctx, ns, MessageTypeUserConnected)
// if err != nil {
// panic(errors.WithStack(err))
// }
// userDisconnectedMessages, err := m.bus.Subscribe(ctx, ns, MessageTypeUserDisconnected)
// if err != nil {
// panic(errors.WithStack(err))
// }
// defer func() {
// m.bus.Unsubscribe(ctx, ns, MessageTypeUserConnected, userConnectedMessages)
// m.bus.Unsubscribe(ctx, ns, MessageTypeUserDisconnected, userDisconnectedMessages)
// }()
// for {
// select {
// case msg := <-userConnectedMessages:
// userConnectedMsg, ok := msg.(*MessageUserConnected)
// if !ok {
// continue
// }
// logger.Debug(ctx, "user connected", logger.F("msg", userConnectedMsg))
// m.admins.Store(userConnectedMsg.UserID, userConnectedMsg.IsAdmin)
// case msg := <-userDisconnectedMessages:
// userDisconnectedMsg, ok := msg.(*MessageUserDisconnected)
// if !ok {
// continue
// }
// logger.Debug(ctx, "user disconnected", logger.F("msg", userDisconnectedMsg))
// m.admins.Delete(userDisconnectedMsg.UserID)
// }
// }
// }
// func AuthorizationModuleFactory(b bus.Bus) app.ServerModuleFactory {
// return func(appID app.ID, backend *app.Server) app.ServerModule {
// mod := &AuthorizationModule{
// appID: appID,
// bus: b,
// backend: backend,
// admins: sync.Map{},
// }
// go mod.handleEvents()
// return mod
// }
// }

View File

@ -1,103 +0,0 @@
package module
// import (
// "context"
// "io/ioutil"
// "testing"
// "time"
// "forge.cadoles.com/arcad/edge/pkg/app"
// "forge.cadoles.com/arcad/edge/pkg/bus/memory"
// )
// func TestAuthorizationModule(t *testing.T) {
// t.Parallel()
// testAppID := app.ID("test-app")
// b := memory.NewBus()
// backend := app.NewServer(testAppID,
// ConsoleModuleFactory(),
// AuthorizationModuleFactory(b),
// )
// data, err := ioutil.ReadFile("testdata/authorization.js")
// if err != nil {
// t.Fatal(err)
// }
// if err := backend.Load(string(data)); err != nil {
// t.Fatal(err)
// }
// backend.Start()
// defer backend.Stop()
// if err := backend.OnInit(); err != nil {
// t.Error(err)
// }
// // Test non connected user
// retValue, err := backend.ExecFuncByName("isAdmin", testUserID)
// if err != nil {
// t.Error(err)
// }
// isAdmin := retValue.ToBoolean()
// if e, g := false, isAdmin; e != g {
// t.Errorf("isAdmin: expected '%v', got '%v'", e, g)
// }
// // Test user connection as normal user
// ctx := context.Background()
// b.Publish(ctx, NewMessageUserConnected(testAppID, testUserID, false))
// time.Sleep(2 * time.Second)
// retValue, err = backend.ExecFuncByName("isAdmin", testUserID)
// if err != nil {
// t.Error(err)
// }
// isAdmin = retValue.ToBoolean()
// if e, g := false, isAdmin; e != g {
// t.Errorf("isAdmin: expected '%v', got '%v'", e, g)
// }
// // Test user connection as admin
// b.Publish(ctx, NewMessageUserConnected(testAppID, testUserID, true))
// time.Sleep(2 * time.Second)
// retValue, err = backend.ExecFuncByName("isAdmin", testUserID)
// if err != nil {
// t.Error(err)
// }
// isAdmin = retValue.ToBoolean()
// if e, g := true, isAdmin; e != g {
// t.Errorf("isAdmin: expected '%v', got '%v'", e, g)
// }
// // Test user disconnection
// b.Publish(ctx, NewMessageUserDisconnected(testAppID, testUserID))
// time.Sleep(2 * time.Second)
// retValue, err = backend.ExecFuncByName("isAdmin", testUserID)
// if err != nil {
// t.Error(err)
// }
// isAdmin = retValue.ToBoolean()
// if e, g := false, isAdmin; e != g {
// t.Errorf("isAdmin: expected '%v', got '%v'", e, g)
// }
// }

View File

@ -88,7 +88,7 @@ func (m *BlobModule) handleUploadRequest(req *MessageUploadRequest) (*MessageUpl
"contentType": req.FileHeader.Header.Get("Content-Type"), "contentType": req.FileHeader.Header.Get("Content-Type"),
} }
rawResult, err := m.server.ExecFuncByName("onBlobUpload", ctx, blobID, blobInfo, req.Metadata) rawResult, err := m.server.ExecFuncByName(ctx, "onBlobUpload", ctx, blobID, blobInfo, req.Metadata)
if err != nil { if err != nil {
if errors.Is(err, app.ErrFuncDoesNotExist) { if errors.Is(err, app.ErrFuncDoesNotExist) {
res.Allow = false res.Allow = false
@ -193,7 +193,7 @@ func (m *BlobModule) saveBlob(ctx context.Context, bucketName string, blobID sto
func (m *BlobModule) handleDownloadRequest(req *MessageDownloadRequest) (*MessageDownloadResponse, error) { func (m *BlobModule) handleDownloadRequest(req *MessageDownloadRequest) (*MessageDownloadResponse, error) {
res := NewMessageDownloadResponse(req.RequestID) res := NewMessageDownloadResponse(req.RequestID)
rawResult, err := m.server.ExecFuncByName("onBlobDownload", req.Context, req.Bucket, req.BlobID) rawResult, err := m.server.ExecFuncByName(req.Context, "onBlobDownload", req.Context, req.Bucket, req.BlobID)
if err != nil { if err != nil {
if errors.Is(err, app.ErrFuncDoesNotExist) { if errors.Is(err, app.ErrFuncDoesNotExist) {
res.Allow = false res.Allow = false

209
pkg/module/cast/cast.go Normal file
View File

@ -0,0 +1,209 @@
package cast
import (
"context"
"net"
"time"
"github.com/barnybug/go-cast"
"github.com/barnybug/go-cast/discovery"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Device struct {
UUID string `goja:"uuid" json:"uuid"`
Host net.IP `goja:"host" json:"host"`
Port int `goja:"port" json:"port"`
Name string `goja:"name" json:"name"`
}
type DeviceStatus struct {
CurrentApp DeviceStatusCurrentApp `goja:"currentApp" json:"currentApp"`
Volume DeviceStatusVolume `goja:"volume" json:"volume"`
}
type DeviceStatusCurrentApp struct {
ID string `goja:"id" json:"id"`
DisplayName string `goja:"displayName" json:"displayName"`
StatusText string `goja:"statusText" json:"statusText"`
}
type DeviceStatusVolume struct {
Level float64 `goja:"level" json:"level"`
Muted bool `goja:"muted" json:"muted"`
}
const (
serviceDiscoveryPollingInterval time.Duration = 2 * time.Second
)
func getDeviceClientByUUID(ctx context.Context, uuid string) (*cast.Client, error) {
device, err := findDeviceByUUID(ctx, uuid)
if err != nil {
return nil, errors.WithStack(err)
}
client := cast.NewClient(device.Host, device.Port)
return client, nil
}
func findDeviceByUUID(ctx context.Context, uuid string) (*Device, error) {
service := discovery.NewService(ctx)
defer service.Stop()
go func() {
if err := service.Run(ctx, serviceDiscoveryPollingInterval); err != nil {
logger.Error(ctx, "error while running cast service discovery", logger.E(errors.WithStack(err)))
}
}()
LOOP:
for {
select {
case c := <-service.Found():
if c.Uuid() == uuid {
return &Device{
Host: c.IP().To4(),
Port: c.Port(),
Name: c.Name(),
UUID: c.Uuid(),
}, nil
}
case <-ctx.Done():
break LOOP
}
}
if err := ctx.Err(); err != nil {
return nil, errors.WithStack(err)
}
return nil, errors.WithStack(ErrDeviceNotFound)
}
func findDevices(ctx context.Context) ([]*Device, error) {
service := discovery.NewService(ctx)
defer service.Stop()
go func() {
if err := service.Run(ctx, serviceDiscoveryPollingInterval); err != nil && !errors.Is(err, context.DeadlineExceeded) {
logger.Error(ctx, "error while running cast service discovery", logger.E(errors.WithStack(err)))
}
}()
devices := make([]*Device, 0)
found := make(map[string]struct{})
LOOP:
for {
select {
case c := <-service.Found():
if _, exists := found[c.Uuid()]; exists {
continue
}
devices = append(devices, &Device{
Host: c.IP().To4(),
Port: c.Port(),
Name: c.Name(),
UUID: c.Uuid(),
})
found[c.Uuid()] = struct{}{}
case <-ctx.Done():
break LOOP
}
}
if err := ctx.Err(); err != nil && !errors.Is(err, context.DeadlineExceeded) {
return nil, errors.WithStack(err)
}
return devices, nil
}
func loadURL(ctx context.Context, deviceUUID string, url string) error {
client, err := getDeviceClientByUUID(ctx, deviceUUID)
if err != nil {
return errors.WithStack(err)
}
if err := client.Connect(ctx); err != nil {
return errors.WithStack(err)
}
defer client.Close()
controller, err := client.URL(ctx)
if err != nil {
return errors.WithStack(err)
}
// Ignore context.DeadlineExceeded errors. github.com/barnybug/go-cast bug ?
if _, err := controller.LoadURL(ctx, url); err != nil && !isLoadURLContextExceeded(err) {
return errors.WithStack(err)
}
return nil
}
// False positive workaround.
func isLoadURLContextExceeded(err error) bool {
return err.Error() == "Failed to send load command: context deadline exceeded"
}
func stopCast(ctx context.Context, deviceUUID string) error {
client, err := getDeviceClientByUUID(ctx, deviceUUID)
if err != nil {
return errors.WithStack(err)
}
if err := client.Connect(ctx); err != nil {
return errors.WithStack(err)
}
defer client.Close()
if _, err := client.Receiver().QuitApp(ctx); err != nil {
return errors.WithStack(err)
}
return nil
}
func getStatus(ctx context.Context, deviceUUID string) (*DeviceStatus, error) {
client, err := getDeviceClientByUUID(ctx, deviceUUID)
if err != nil {
return nil, errors.WithStack(err)
}
if err := client.Connect(ctx); err != nil {
return nil, errors.WithStack(err)
}
defer client.Close()
ctrlStatus, err := client.Receiver().GetStatus(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
status := &DeviceStatus{
CurrentApp: DeviceStatusCurrentApp{
ID: "",
DisplayName: "",
StatusText: "",
},
Volume: DeviceStatusVolume{
Level: *ctrlStatus.Volume.Level,
Muted: *ctrlStatus.Volume.Muted,
},
}
if len(ctrlStatus.Applications) > 0 {
status.CurrentApp.ID = *ctrlStatus.Applications[0].AppID
status.CurrentApp.DisplayName = *ctrlStatus.Applications[0].DisplayName
status.CurrentApp.StatusText = *ctrlStatus.Applications[0].StatusText
}
return status, nil
}

View File

@ -0,0 +1,63 @@
package cast
import (
"context"
"os"
"testing"
"time"
"cdr.dev/slog"
"github.com/davecgh/go-spew/spew"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func TestCastLoadURL(t *testing.T) {
t.Parallel()
if os.Getenv("TEST_CAST_MODULE") != "yes" {
t.Skip("Test skipped. Set environment variable TEST_CAST_MODULE=yes to run.")
return
}
logger.SetLevel(slog.LevelDebug)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
devices, err := findDevices(ctx)
if err != nil {
t.Error(errors.WithStack(err))
}
if e, g := 1, len(devices); e != g {
t.Fatalf("len(devices): expected '%v', got '%v'", e, g)
}
dev := devices[0]
ctx, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel2()
if err := loadURL(ctx, dev.UUID, "https://go.dev"); err != nil {
t.Error(errors.WithStack(err))
}
ctx, cancel3 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel3()
status, err := getStatus(ctx, dev.UUID)
if err != nil {
t.Error(errors.WithStack(err))
}
spew.Dump(status)
ctx, cancel4 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel4()
if err := stopCast(ctx, dev.UUID); err != nil {
t.Error(errors.WithStack(err))
}
}

5
pkg/module/cast/error.go Normal file
View File

@ -0,0 +1,5 @@
package cast
import "errors"
var ErrDeviceNotFound = errors.New("device not found")

263
pkg/module/cast/module.go Normal file
View File

@ -0,0 +1,263 @@
package cast
import (
"context"
"sync"
"time"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module"
"github.com/dop251/goja"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const (
defaultTimeout = 30 * time.Second
)
type Module struct {
ctx context.Context
server *app.Server
mutex struct {
devices sync.RWMutex
refreshDevices sync.Mutex
loadURL sync.Mutex
quitApp sync.Mutex
getStatus sync.Mutex
}
devices []*Device
}
func (m *Module) Name() string {
return "cast"
}
func (m *Module) Export(export *goja.Object) {
if err := export.Set("refreshDevices", m.refreshDevices); err != nil {
panic(errors.Wrap(err, "could not set 'refreshDevices' function"))
}
if err := export.Set("getDevices", m.getDevices); err != nil {
panic(errors.Wrap(err, "could not set 'getDevices' function"))
}
if err := export.Set("loadUrl", m.loadUrl); err != nil {
panic(errors.Wrap(err, "could not set 'loadUrl' function"))
}
if err := export.Set("stopCast", m.stopCast); err != nil {
panic(errors.Wrap(err, "could not set 'stopCast' function"))
}
if err := export.Set("getStatus", m.getStatus); err != nil {
panic(errors.Wrap(err, "could not set 'getStatus' function"))
}
}
func (m *Module) refreshDevices(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
rawTimeout := call.Argument(0).String()
timeout, err := m.parseTimeout(rawTimeout)
if err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
promise := m.server.NewPromise()
go func() {
m.mutex.refreshDevices.Lock()
defer m.mutex.refreshDevices.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
devices, err := findDevices(ctx)
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
err = errors.WithStack(err)
logger.Error(ctx, "error refreshing casting devices list", logger.E(errors.WithStack(err)))
promise.Reject(err)
return
}
if err == nil {
m.mutex.devices.Lock()
m.devices = devices
m.mutex.devices.Unlock()
}
devicesCopy := m.getDevicesCopy(devices)
promise.Resolve(devicesCopy)
}()
return rt.ToValue(promise)
}
func (m *Module) getDevices(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
m.mutex.devices.RLock()
defer m.mutex.devices.RUnlock()
devices := m.getDevicesCopy(m.devices)
return rt.ToValue(devices)
}
func (m *Module) loadUrl(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 2 {
panic(rt.ToValue(errors.WithStack(module.ErrUnexpectedArgumentsNumber)))
}
deviceUUID := call.Argument(0).String()
url := call.Argument(1).String()
rawTimeout := call.Argument(2).String()
timeout, err := m.parseTimeout(rawTimeout)
if err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
promise := m.server.NewPromise()
go func() {
m.mutex.loadURL.Lock()
defer m.mutex.loadURL.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
err := loadURL(ctx, deviceUUID, url)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "error while casting url", logger.E(err))
promise.Reject(err)
return
}
promise.Resolve(nil)
}()
return m.server.ToValue(promise)
}
func (m *Module) stopCast(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 1 {
panic(rt.ToValue(errors.WithStack(module.ErrUnexpectedArgumentsNumber)))
}
deviceUUID := call.Argument(0).String()
rawTimeout := call.Argument(1).String()
timeout, err := m.parseTimeout(rawTimeout)
if err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
promise := m.server.NewPromise()
go func() {
m.mutex.quitApp.Lock()
defer m.mutex.quitApp.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
err := stopCast(ctx, deviceUUID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "error while quitting casting device app", logger.E(errors.WithStack(err)))
promise.Reject(err)
return
}
promise.Resolve(nil)
}()
return m.server.ToValue(promise)
}
func (m *Module) getStatus(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 1 {
panic(rt.ToValue(errors.WithStack(module.ErrUnexpectedArgumentsNumber)))
}
deviceUUID := call.Argument(0).String()
rawTimeout := call.Argument(1).String()
timeout, err := m.parseTimeout(rawTimeout)
if err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
promise := m.server.NewPromise()
go func() {
m.mutex.getStatus.Lock()
defer m.mutex.getStatus.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
status, err := getStatus(ctx, deviceUUID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "error while getting casting device status", logger.E(err))
promise.Reject(err)
return
}
promise.Resolve(status)
}()
return m.server.ToValue(promise)
}
func (m *Module) getDevicesCopy(devices []*Device) []Device {
devicesCopy := make([]Device, 0, len(m.devices))
for _, d := range devices {
devicesCopy = append(devicesCopy, Device{
UUID: d.UUID,
Name: d.Name,
Host: d.Host,
Port: d.Port,
})
}
return devicesCopy
}
func (m *Module) parseTimeout(rawTimeout string) (time.Duration, error) {
var (
timeout time.Duration
err error
)
if rawTimeout == "undefined" {
timeout = defaultTimeout
} else {
timeout, err = time.ParseDuration(rawTimeout)
if err != nil {
return defaultTimeout, errors.Wrapf(err, "invalid duration format '%s'", rawTimeout)
}
}
return timeout, nil
}
func CastModuleFactory() app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule {
return &Module{
server: server,
devices: make([]*Device, 0),
}
}
}

View File

@ -0,0 +1,96 @@
package cast
import (
"context"
"io/ioutil"
"os"
"testing"
"time"
"cdr.dev/slog"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module"
"github.com/davecgh/go-spew/spew"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func TestCastModule(t *testing.T) {
t.Parallel()
if os.Getenv("TEST_CAST_MODULE") != "yes" {
t.Skip("Test skipped. Set environment variable TEST_CAST_MODULE=yes to run.")
return
}
logger.SetLevel(slog.LevelDebug)
server := app.NewServer(
module.ConsoleModuleFactory(),
CastModuleFactory(),
)
data, err := ioutil.ReadFile("testdata/cast.js")
if err != nil {
t.Fatal(err)
}
if err := server.Load("testdata/cast.js", string(data)); err != nil {
t.Fatal(err)
}
if err := server.Start(); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
defer server.Stop()
time.Sleep(20 * time.Second)
}
func TestCastModuleRefreshDevices(t *testing.T) {
t.Parallel()
if os.Getenv("TEST_CAST_MODULE") != "yes" {
t.Skip("Test skipped. Set environment variable TEST_CAST_MODULE=yes to run.")
return
}
logger.SetLevel(slog.LevelDebug)
server := app.NewServer(
module.ConsoleModuleFactory(),
CastModuleFactory(),
)
data, err := ioutil.ReadFile("testdata/refresh_devices.js")
if err != nil {
t.Fatal(err)
}
if err := server.Load("testdata/refresh_devices.js", string(data)); err != nil {
t.Fatal(err)
}
if err := server.Start(); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
defer server.Stop()
result, err := server.ExecFuncByName(context.Background(), "refreshDevices")
if err != nil {
t.Error(errors.WithStack(err))
}
promise, ok := server.IsPromise(result)
if !ok {
t.Fatal("expected promise")
}
value := server.WaitForPromise(promise)
spew.Dump(value.Export())
}

21
pkg/module/cast/testdata/cast.js vendored Normal file
View File

@ -0,0 +1,21 @@
cast.refreshDevices('5s')
.then(function(devices) {
console.log(devices)
if (devices === null) {
throw new Error("devices should not be null");
}
if (devices.length === 0) {
throw new Error("devices.length should not be 0");
}
return devices
})
.then(function(devices) {
return cast.getStatus(devices[0].uuid)
})
.then(function(status) {
console.log(status)
})

View File

@ -0,0 +1,6 @@
function refreshDevices() {
return cast.refreshDevices('5s')
.then(function(devices) {
return devices
})
}

View File

@ -4,17 +4,13 @@ import (
"context" "context"
"forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module/util"
"github.com/dop251/goja" "github.com/dop251/goja"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
type ContextKey string type ContextKey string
const (
ContextKeySessionID ContextKey = "sessionId"
ContextKeyOriginRequest ContextKey = "originRequest"
)
type ContextModule struct{} type ContextModule struct{}
func (m *ContextModule) Name() string { func (m *ContextModule) Name() string {
@ -26,8 +22,8 @@ func (m *ContextModule) new(call goja.FunctionCall, rt *goja.Runtime) goja.Value
} }
func (m *ContextModule) with(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *ContextModule) with(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := assertContext(call.Argument(0), rt) ctx := util.AssertContext(call.Argument(0), rt)
rawValues := assertObject(call.Argument(1), rt) rawValues := util.AssertObject(call.Argument(1), rt)
values := make(map[ContextKey]any) values := make(map[ContextKey]any)
for k, v := range rawValues { for k, v := range rawValues {
@ -40,8 +36,8 @@ func (m *ContextModule) with(call goja.FunctionCall, rt *goja.Runtime) goja.Valu
} }
func (m *ContextModule) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *ContextModule) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := assertContext(call.Argument(0), rt) ctx := util.AssertContext(call.Argument(0), rt)
rawKey := assertString(call.Argument(1), rt) rawKey := util.AssertString(call.Argument(1), rt)
value := ctx.Value(ContextKey(rawKey)) value := ctx.Value(ContextKey(rawKey))
@ -60,14 +56,6 @@ func (m *ContextModule) Export(export *goja.Object) {
if err := export.Set("with", m.with); err != nil { if err := export.Set("with", m.with); err != nil {
panic(errors.Wrap(err, "could not set 'with' function")) panic(errors.Wrap(err, "could not set 'with' function"))
} }
if err := export.Set("ORIGIN_REQUEST", string(ContextKeyOriginRequest)); err != nil {
panic(errors.Wrap(err, "could not set 'ORIGIN_REQUEST' property"))
}
if err := export.Set("SESSION_ID", string(ContextKeySessionID)); err != nil {
panic(errors.Wrap(err, "could not set 'SESSION_ID' property"))
}
} }
func ContextModuleFactory() app.ServerModuleFactory { func ContextModuleFactory() app.ServerModuleFactory {

40
pkg/module/extension.go Normal file
View File

@ -0,0 +1,40 @@
package module
import (
"forge.cadoles.com/arcad/edge/pkg/app"
"github.com/dop251/goja"
)
type ExtensionFunc func(*goja.Object)
type ExtendedModule struct {
module app.ServerModule
extensions []ExtensionFunc
}
// Export implements app.ServerModule.
func (m *ExtendedModule) Export(exports *goja.Object) {
m.module.Export(exports)
for _, ext := range m.extensions {
ext(exports)
}
}
// Name implements app.ServerModule.
func (m *ExtendedModule) Name() string {
return m.module.Name()
}
func Extends(factory app.ServerModuleFactory, extensions ...ExtensionFunc) app.ServerModuleFactory {
return func(s *app.Server) app.ServerModule {
module := factory(s)
return &ExtendedModule{
module: module,
extensions: extensions,
}
}
}
var _ app.ServerModule = &ExtendedModule{}

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus"
"github.com/dop251/goja" "github.com/dop251/goja"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
@ -12,7 +11,6 @@ import (
type LifecycleModule struct { type LifecycleModule struct {
server *app.Server server *app.Server
bus bus.Bus
} }
func (m *LifecycleModule) Name() string { func (m *LifecycleModule) Name() string {
@ -23,7 +21,7 @@ func (m *LifecycleModule) Export(export *goja.Object) {
} }
func (m *LifecycleModule) OnInit() error { func (m *LifecycleModule) OnInit() error {
if _, err := m.server.ExecFuncByName("onInit"); err != nil { if _, err := m.server.ExecFuncByName(context.Background(), "onInit"); err != nil {
if errors.Is(err, app.ErrFuncDoesNotExist) { if errors.Is(err, app.ErrFuncDoesNotExist) {
logger.Warn(context.Background(), "could not find onInit() function", logger.E(errors.WithStack(err))) logger.Warn(context.Background(), "could not find onInit() function", logger.E(errors.WithStack(err)))
@ -36,84 +34,12 @@ func (m *LifecycleModule) OnInit() error {
return nil return nil
} }
func (m *LifecycleModule) handleMessages() { func LifecycleModuleFactory() app.ServerModuleFactory {
ctx := context.Background()
logger.Debug(
ctx,
"subscribing to bus messages",
)
clientMessages, err := m.bus.Subscribe(ctx, MessageNamespaceClient)
if err != nil {
panic(errors.WithStack(err))
}
defer func() {
logger.Debug(
ctx,
"unsubscribing from bus messages",
)
m.bus.Unsubscribe(ctx, MessageNamespaceClient, clientMessages)
}()
for {
logger.Debug(
ctx,
"waiting for next message",
)
select {
case <-ctx.Done():
logger.Debug(
ctx,
"context done",
)
return
case msg := <-clientMessages:
clientMessage, ok := msg.(*ClientMessage)
if !ok {
logger.Error(
ctx,
"unexpected message type",
logger.F("message", msg),
)
continue
}
logger.Debug(
ctx,
"received client message",
logger.F("message", clientMessage),
)
if _, err := m.server.ExecFuncByName("onClientMessage", clientMessage.Context, clientMessage.Data); err != nil {
if errors.Is(err, app.ErrFuncDoesNotExist) {
continue
}
logger.Error(
ctx,
"on client message error",
logger.E(err),
)
}
}
}
}
func LifecycleModuleFactory(bus bus.Bus) app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule { return func(server *app.Server) app.ServerModule {
module := &LifecycleModule{ module := &LifecycleModule{
server: server, server: server,
bus: bus,
} }
go module.handleMessages()
return module return module
} }
} }

View File

@ -1,81 +0,0 @@
package module
import (
"context"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus"
"github.com/dop251/goja"
"github.com/pkg/errors"
)
type NetModule struct {
server *app.Server
bus bus.Bus
}
func (m *NetModule) Name() string {
return "net"
}
func (m *NetModule) Export(export *goja.Object) {
if err := export.Set("broadcast", m.broadcast); err != nil {
panic(errors.Wrap(err, "could not set 'broadcast' function"))
}
if err := export.Set("send", m.send); err != nil {
panic(errors.Wrap(err, "could not set 'send' function"))
}
}
func (m *NetModule) broadcast(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
panic(m.server.ToValue("invalid number of argument"))
}
data := call.Argument(0).Export()
msg := NewServerMessage(nil, data)
if err := m.bus.Publish(context.Background(), msg); err != nil {
panic(errors.WithStack(err))
}
return nil
}
func (m *NetModule) send(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 2 {
panic(m.server.ToValue("invalid number of argument"))
}
var ctx context.Context
firstArg := call.Argument(0)
sessionID, ok := firstArg.Export().(string)
if ok {
ctx = WithContext(context.Background(), map[ContextKey]any{
ContextKeySessionID: sessionID,
})
} else {
ctx = assertContext(firstArg, rt)
}
data := call.Argument(1).Export()
msg := NewServerMessage(ctx, data)
if err := m.bus.Publish(ctx, msg); err != nil {
panic(errors.WithStack(err))
}
return nil
}
func NetModuleFactory(bus bus.Bus) app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule {
return &NetModule{
server: server,
bus: bus,
}
}
}

158
pkg/module/net/module.go Normal file
View File

@ -0,0 +1,158 @@
package net
import (
"context"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus"
edgeHTTP "forge.cadoles.com/arcad/edge/pkg/http"
"forge.cadoles.com/arcad/edge/pkg/module"
"forge.cadoles.com/arcad/edge/pkg/module/util"
"github.com/dop251/goja"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Module struct {
server *app.Server
bus bus.Bus
}
func (m *Module) Name() string {
return "net"
}
func (m *Module) Export(export *goja.Object) {
if err := export.Set("broadcast", m.broadcast); err != nil {
panic(errors.Wrap(err, "could not set 'broadcast' function"))
}
if err := export.Set("send", m.send); err != nil {
panic(errors.Wrap(err, "could not set 'send' function"))
}
}
func (m *Module) broadcast(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 1 {
panic(rt.ToValue(errors.New("invalid number of argument")))
}
data := call.Argument(0).Export()
msg := module.NewServerMessage(nil, data)
if err := m.bus.Publish(context.Background(), msg); err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
return nil
}
func (m *Module) send(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 2 {
panic(rt.ToValue(errors.New("invalid number of argument")))
}
var ctx context.Context
firstArg := call.Argument(0)
sessionID, ok := firstArg.Export().(string)
if ok {
ctx = module.WithContext(context.Background(), map[module.ContextKey]any{
edgeHTTP.ContextKeySessionID: sessionID,
})
} else {
ctx = util.AssertContext(firstArg, rt)
}
data := call.Argument(1).Export()
msg := module.NewServerMessage(ctx, data)
if err := m.bus.Publish(ctx, msg); err != nil {
panic(rt.ToValue(errors.WithStack(err)))
}
return nil
}
func (m *Module) handleClientMessages() {
ctx := context.Background()
logger.Debug(
ctx,
"subscribing to bus messages",
)
clientMessages, err := m.bus.Subscribe(ctx, module.MessageNamespaceClient)
if err != nil {
panic(errors.WithStack(err))
}
defer func() {
logger.Debug(
ctx,
"unsubscribing from bus messages",
)
m.bus.Unsubscribe(ctx, module.MessageNamespaceClient, clientMessages)
}()
for {
logger.Debug(
ctx,
"waiting for next message",
)
select {
case <-ctx.Done():
logger.Debug(
ctx,
"context done",
)
return
case msg := <-clientMessages:
clientMessage, ok := msg.(*module.ClientMessage)
if !ok {
logger.Error(
ctx,
"unexpected message type",
logger.F("message", msg),
)
continue
}
logger.Debug(
ctx,
"received client message",
logger.F("message", clientMessage),
)
if _, err := m.server.ExecFuncByName(clientMessage.Context, "onClientMessage", clientMessage.Context, clientMessage.Data); err != nil {
if errors.Is(err, app.ErrFuncDoesNotExist) {
continue
}
logger.Error(
ctx,
"on client message error",
logger.E(err),
)
}
}
}
}
func ModuleFactory(bus bus.Bus) app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule {
module := &Module{
server: server,
bus: bus,
}
go module.handleClientMessages()
return module
}
}

View File

@ -7,6 +7,7 @@ import (
"forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus" "forge.cadoles.com/arcad/edge/pkg/bus"
"forge.cadoles.com/arcad/edge/pkg/module/util"
"github.com/dop251/goja" "github.com/dop251/goja"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
@ -51,7 +52,7 @@ func (m *RPCModule) Export(export *goja.Object) {
} }
func (m *RPCModule) register(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *RPCModule) register(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
fnName := assertString(call.Argument(0), rt) fnName := util.AssertString(call.Argument(0), rt)
var ( var (
callable goja.Callable callable goja.Callable
@ -78,7 +79,7 @@ func (m *RPCModule) register(call goja.FunctionCall, rt *goja.Runtime) goja.Valu
} }
func (m *RPCModule) unregister(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *RPCModule) unregister(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
fnName := assertString(call.Argument(0), rt) fnName := util.AssertString(call.Argument(0), rt)
m.callbacks.Delete(fnName) m.callbacks.Delete(fnName)
@ -160,8 +161,14 @@ func (m *RPCModule) handleMessages() {
continue continue
} }
result, err := m.server.Exec(callable, ctx, req.Params) result, err := m.server.Exec(clientMessage.Context, callable, clientMessage.Context, req.Params)
if err != nil { if err != nil {
logger.Error(
ctx, "rpc call error",
logger.E(errors.WithStack(err)),
logger.F("request", req),
)
if err := m.sendErrorResponse(clientMessage.Context, req, err); err != nil { if err := m.sendErrorResponse(clientMessage.Context, req, err); err != nil {
logger.Error( logger.Error(
ctx, "could not send error response", ctx, "could not send error response",

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module/util"
"forge.cadoles.com/arcad/edge/pkg/storage" "forge.cadoles.com/arcad/edge/pkg/storage"
"forge.cadoles.com/arcad/edge/pkg/storage/filter" "forge.cadoles.com/arcad/edge/pkg/storage/filter"
"github.com/dop251/goja" "github.com/dop251/goja"
@ -47,20 +48,20 @@ func (m *StoreModule) Export(export *goja.Object) {
} }
func (m *StoreModule) upsert(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *StoreModule) upsert(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := assertContext(call.Argument(0), rt) ctx := util.AssertContext(call.Argument(0), rt)
collection := m.assertCollection(call.Argument(1), rt) collection := m.assertCollection(call.Argument(1), rt)
document := m.assertDocument(call.Argument(2), rt) document := m.assertDocument(call.Argument(2), rt)
document, err := m.store.Upsert(ctx, collection, document) document, err := m.store.Upsert(ctx, collection, document)
if err != nil { if err != nil {
panic(errors.Wrapf(err, "error while upserting document in collection '%s'", collection)) panic(rt.ToValue(errors.Wrapf(err, "error while upserting document in collection '%s'", collection)))
} }
return rt.ToValue(map[string]interface{}(document)) return rt.ToValue(map[string]interface{}(document))
} }
func (m *StoreModule) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *StoreModule) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := assertContext(call.Argument(0), rt) ctx := util.AssertContext(call.Argument(0), rt)
collection := m.assertCollection(call.Argument(1), rt) collection := m.assertCollection(call.Argument(1), rt)
documentID := m.assertDocumentID(call.Argument(2), rt) documentID := m.assertDocumentID(call.Argument(2), rt)
@ -70,7 +71,7 @@ func (m *StoreModule) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
return nil return nil
} }
panic(errors.Wrapf(err, "error while getting document '%s' in collection '%s'", documentID, collection)) panic(rt.ToValue(errors.Wrapf(err, "error while getting document '%s' in collection '%s'", documentID, collection)))
} }
return rt.ToValue(map[string]interface{}(document)) return rt.ToValue(map[string]interface{}(document))
@ -84,34 +85,36 @@ type queryOptions struct {
} }
func (m *StoreModule) query(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *StoreModule) query(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := assertContext(call.Argument(0), rt) ctx := util.AssertContext(call.Argument(0), rt)
collection := m.assertCollection(call.Argument(1), rt) collection := m.assertCollection(call.Argument(1), rt)
filter := m.assertFilter(call.Argument(2), rt) filter := m.assertFilter(call.Argument(2), rt)
queryOptions := m.assertQueryOptions(call.Argument(3), rt) queryOptions := m.assertQueryOptions(call.Argument(3), rt)
queryOptionsFuncs := make([]storage.QueryOptionFunc, 0) queryOptionsFuncs := make([]storage.QueryOptionFunc, 0)
if queryOptions.Limit != nil { if queryOptions != nil {
queryOptionsFuncs = append(queryOptionsFuncs, storage.WithLimit(*queryOptions.Limit)) if queryOptions.Limit != nil {
} queryOptionsFuncs = append(queryOptionsFuncs, storage.WithLimit(*queryOptions.Limit))
}
if queryOptions.OrderBy != nil { if queryOptions.OrderBy != nil {
queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOrderBy(*queryOptions.OrderBy)) queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOrderBy(*queryOptions.OrderBy))
} }
if queryOptions.Offset != nil { if queryOptions.Offset != nil {
queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOffset(*queryOptions.Limit)) queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOffset(*queryOptions.Limit))
} }
if queryOptions.OrderDirection != nil { if queryOptions.OrderDirection != nil {
queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOrderDirection( queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOrderDirection(
storage.OrderDirection(*queryOptions.OrderDirection), storage.OrderDirection(*queryOptions.OrderDirection),
)) ))
}
} }
documents, err := m.store.Query(ctx, collection, filter, queryOptionsFuncs...) documents, err := m.store.Query(ctx, collection, filter, queryOptionsFuncs...)
if err != nil { if err != nil {
panic(errors.Wrapf(err, "error while querying documents in collection '%s'", collection)) panic(rt.ToValue(errors.Wrapf(err, "error while querying documents in collection '%s'", collection)))
} }
rawDocuments := make([]map[string]interface{}, len(documents)) rawDocuments := make([]map[string]interface{}, len(documents))
@ -123,12 +126,12 @@ func (m *StoreModule) query(call goja.FunctionCall, rt *goja.Runtime) goja.Value
} }
func (m *StoreModule) delete(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *StoreModule) delete(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := assertContext(call.Argument(0), rt) ctx := util.AssertContext(call.Argument(0), rt)
collection := m.assertCollection(call.Argument(1), rt) collection := m.assertCollection(call.Argument(1), rt)
documentID := m.assertDocumentID(call.Argument(2), rt) documentID := m.assertDocumentID(call.Argument(2), rt)
if err := m.store.Delete(ctx, collection, documentID); err != nil { if err := m.store.Delete(ctx, collection, documentID); err != nil {
panic(errors.Wrapf(err, "error while deleting document '%s' in collection '%s'", documentID, collection)) panic(rt.ToValue(errors.Wrapf(err, "error while deleting document '%s' in collection '%s'", documentID, collection)))
} }
return nil return nil
@ -144,6 +147,10 @@ func (m *StoreModule) assertCollection(value goja.Value, rt *goja.Runtime) strin
} }
func (m *StoreModule) assertFilter(value goja.Value, rt *goja.Runtime) *filter.Filter { func (m *StoreModule) assertFilter(value goja.Value, rt *goja.Runtime) *filter.Filter {
if value.Export() == nil {
return nil
}
rawFilter, ok := value.Export().(map[string]interface{}) rawFilter, ok := value.Export().(map[string]interface{})
if !ok { if !ok {
panic(rt.NewTypeError(fmt.Sprintf("filter must be an object, got '%T'", value.Export()))) panic(rt.NewTypeError(fmt.Sprintf("filter must be an object, got '%T'", value.Export())))
@ -151,7 +158,7 @@ func (m *StoreModule) assertFilter(value goja.Value, rt *goja.Runtime) *filter.F
filter, err := filter.NewFrom(rawFilter) filter, err := filter.NewFrom(rawFilter)
if err != nil { if err != nil {
panic(errors.Wrap(err, "could not convert object to filter")) panic(rt.ToValue(errors.Wrap(err, "could not convert object to filter")))
} }
return filter return filter
@ -172,6 +179,10 @@ func (m *StoreModule) assertDocumentID(value goja.Value, rt *goja.Runtime) stora
} }
func (m *StoreModule) assertQueryOptions(value goja.Value, rt *goja.Runtime) *queryOptions { func (m *StoreModule) assertQueryOptions(value goja.Value, rt *goja.Runtime) *queryOptions {
if value.Export() == nil {
return nil
}
rawQueryOptions, ok := value.Export().(map[string]interface{}) rawQueryOptions, ok := value.Export().(map[string]interface{})
if !ok { if !ok {
panic(rt.NewTypeError(fmt.Sprintf("query options must be an object, got '%T'", value.Export()))) panic(rt.NewTypeError(fmt.Sprintf("query options must be an object, got '%T'", value.Export())))
@ -180,7 +191,7 @@ func (m *StoreModule) assertQueryOptions(value goja.Value, rt *goja.Runtime) *qu
queryOptions := &queryOptions{} queryOptions := &queryOptions{}
if err := mapstructure.Decode(rawQueryOptions, queryOptions); err != nil { if err := mapstructure.Decode(rawQueryOptions, queryOptions); err != nil {
panic(errors.Wrap(err, "could not convert object to query options")) panic(rt.ToValue(errors.Wrap(err, "could not convert object to query options")))
} }
return queryOptions return queryOptions

216
pkg/module/store/module.go Normal file
View File

@ -0,0 +1,216 @@
package store
import (
"fmt"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module/util"
"forge.cadoles.com/arcad/edge/pkg/storage"
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
"github.com/dop251/goja"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
)
type Module struct {
server *app.Server
store storage.DocumentStore
}
func (m *Module) Name() string {
return "store"
}
func (m *Module) Export(export *goja.Object) {
if err := export.Set("upsert", m.upsert); err != nil {
panic(errors.Wrap(err, "could not set 'upsert' function"))
}
if err := export.Set("get", m.get); err != nil {
panic(errors.Wrap(err, "could not set 'get' function"))
}
if err := export.Set("query", m.query); err != nil {
panic(errors.Wrap(err, "could not set 'query' function"))
}
if err := export.Set("delete", m.delete); err != nil {
panic(errors.Wrap(err, "could not set 'delete' function"))
}
if err := export.Set("DIRECTION_ASC", storage.OrderDirectionAsc); err != nil {
panic(errors.Wrap(err, "could not set 'DIRECTION_ASC' property"))
}
if err := export.Set("DIRECTION_DESC", storage.OrderDirectionDesc); err != nil {
panic(errors.Wrap(err, "could not set 'DIRECTION_DESC' property"))
}
}
func (m *Module) upsert(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := util.AssertContext(call.Argument(0), rt)
collection := m.assertCollection(call.Argument(1), rt)
document := m.assertDocument(call.Argument(2), rt)
document, err := m.store.Upsert(ctx, collection, document)
if err != nil {
panic(errors.Wrapf(err, "error while upserting document in collection '%s'", collection))
}
return rt.ToValue(map[string]interface{}(document))
}
func (m *Module) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := util.AssertContext(call.Argument(0), rt)
collection := m.assertCollection(call.Argument(1), rt)
documentID := m.assertDocumentID(call.Argument(2), rt)
document, err := m.store.Get(ctx, collection, documentID)
if err != nil {
if errors.Is(err, storage.ErrDocumentNotFound) {
return nil
}
panic(errors.Wrapf(err, "error while getting document '%s' in collection '%s'", documentID, collection))
}
return rt.ToValue(map[string]interface{}(document))
}
type queryOptions struct {
Limit *int `mapstructure:"limit"`
Offset *int `mapstructure:"offset"`
OrderBy *string `mapstructure:"orderBy"`
OrderDirection *string `mapstructure:"orderDirection"`
}
func (m *Module) query(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := util.AssertContext(call.Argument(0), rt)
collection := m.assertCollection(call.Argument(1), rt)
filter := m.assertFilter(call.Argument(2), rt)
queryOptions := m.assertQueryOptions(call.Argument(3), rt)
queryOptionsFuncs := make([]storage.QueryOptionFunc, 0)
if queryOptions != nil {
if queryOptions.Limit != nil {
queryOptionsFuncs = append(queryOptionsFuncs, storage.WithLimit(*queryOptions.Limit))
}
if queryOptions.OrderBy != nil {
queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOrderBy(*queryOptions.OrderBy))
}
if queryOptions.Offset != nil {
queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOffset(*queryOptions.Offset))
}
if queryOptions.OrderDirection != nil {
queryOptionsFuncs = append(queryOptionsFuncs, storage.WithOrderDirection(
storage.OrderDirection(*queryOptions.OrderDirection),
))
}
}
documents, err := m.store.Query(ctx, collection, filter, queryOptionsFuncs...)
if err != nil {
panic(errors.Wrapf(err, "error while querying documents in collection '%s'", collection))
}
rawDocuments := make([]map[string]interface{}, len(documents))
for idx, doc := range documents {
rawDocuments[idx] = map[string]interface{}(doc)
}
return rt.ToValue(rawDocuments)
}
func (m *Module) delete(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
ctx := util.AssertContext(call.Argument(0), rt)
collection := m.assertCollection(call.Argument(1), rt)
documentID := m.assertDocumentID(call.Argument(2), rt)
if err := m.store.Delete(ctx, collection, documentID); err != nil {
panic(errors.Wrapf(err, "error while deleting document '%s' in collection '%s'", documentID, collection))
}
return nil
}
func (m *Module) assertCollection(value goja.Value, rt *goja.Runtime) string {
collection, ok := value.Export().(string)
if !ok {
panic(rt.NewTypeError(fmt.Sprintf("collection must be a string, got '%T'", value.Export())))
}
return collection
}
func (m *Module) assertFilter(value goja.Value, rt *goja.Runtime) *filter.Filter {
if value.Export() == nil {
return nil
}
rawFilter, ok := value.Export().(map[string]interface{})
if !ok {
panic(rt.NewTypeError(fmt.Sprintf("filter must be an object, got '%T'", value.Export())))
}
filter, err := filter.NewFrom(rawFilter)
if err != nil {
panic(errors.Wrap(err, "could not convert object to filter"))
}
return filter
}
func (m *Module) assertDocumentID(value goja.Value, rt *goja.Runtime) storage.DocumentID {
documentID, ok := value.Export().(storage.DocumentID)
if !ok {
rawDocumentID, ok := value.Export().(string)
if !ok {
panic(rt.NewTypeError(fmt.Sprintf("document id must be a documentid or a string, got '%T'", value.Export())))
}
documentID = storage.DocumentID(rawDocumentID)
}
return documentID
}
func (m *Module) assertQueryOptions(value goja.Value, rt *goja.Runtime) *queryOptions {
if value.Export() == nil {
return nil
}
rawQueryOptions, ok := value.Export().(map[string]interface{})
if !ok {
panic(rt.NewTypeError(fmt.Sprintf("query options must be an object, got '%T'", value.Export())))
}
queryOptions := &queryOptions{}
if err := mapstructure.Decode(rawQueryOptions, queryOptions); err != nil {
panic(errors.Wrap(err, "could not convert object to query options"))
}
return queryOptions
}
func (m *Module) assertDocument(value goja.Value, rt *goja.Runtime) storage.Document {
document, ok := value.Export().(map[string]interface{})
if !ok {
panic(rt.NewTypeError("document must be an object"))
}
return document
}
func ModuleFactory(store storage.DocumentStore) app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule {
return &Module{
server: server,
store: store,
}
}
}

View File

@ -1,10 +1,12 @@
package module package store
import ( import (
"context"
"io/ioutil" "io/ioutil"
"testing" "testing"
"forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module"
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite" "forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
@ -15,9 +17,9 @@ func TestStoreModule(t *testing.T) {
store := sqlite.NewDocumentStore(":memory:") store := sqlite.NewDocumentStore(":memory:")
server := app.NewServer( server := app.NewServer(
ContextModuleFactory(), module.ContextModuleFactory(),
ConsoleModuleFactory(), module.ConsoleModuleFactory(),
StoreModuleFactory(store), ModuleFactory(store),
) )
data, err := ioutil.ReadFile("testdata/store.js") data, err := ioutil.ReadFile("testdata/store.js")
@ -33,7 +35,7 @@ func TestStoreModule(t *testing.T) {
t.Fatalf("%+v", errors.WithStack(err)) t.Fatalf("%+v", errors.WithStack(err))
} }
if _, err := server.ExecFuncByName("testStore"); err != nil { if _, err := server.ExecFuncByName(context.Background(), "testStore"); err != nil {
t.Fatalf("%+v", errors.WithStack(err)) t.Fatalf("%+v", errors.WithStack(err))
} }

View File

@ -1,59 +0,0 @@
package module
// import (
// "context"
// "github.com/dop251/goja"
// "github.com/pkg/errors"
// "forge.cadoles.com/arcad/edge/pkg/app"
// "forge.cadoles.com/arcad/edge/pkg/repository"
// "gitlab.com/wpetit/goweb/logger"
// )
// type UserModule struct {
// appID app.ID
// repo repository.UserRepository
// backend *app.Server
// ctx context.Context
// }
// func (m *UserModule) Name() string {
// return "user"
// }
// func (m *UserModule) Export(export *goja.Object) {
// if err := export.Set("getUserById", m.getUserByID); err != nil {
// panic(errors.Wrap(err, "could not set 'getUserById' function"))
// }
// }
// func (m *UserModule) getUserByID(call goja.FunctionCall) goja.Value {
// if len(call.Arguments) != 1 {
// panic(m.backend.ToValue("invalid number of arguments"))
// }
// userID := repository.UserID(call.Arguments[0].String())
// user, err := m.repo.Get(userID)
// if err != nil {
// err = errors.Wrapf(err, "could not find user '%s'", userID)
// logger.Error(m.ctx, "could not find user", logger.E(err), logger.F("userID", userID))
// panic(m.backend.ToValue(err))
// }
// return m.backend.ToValue(user)
// }
// func UserModuleFactory(repo repository.UserRepository) app.ServerModuleFactory {
// return func(appID app.ID, backend *app.Server) app.ServerModule {
// return &UserModule{
// appID: appID,
// repo: repo,
// backend: backend,
// ctx: logger.With(
// context.Background(),
// logger.F("appID", appID),
// ),
// }
// }
// }

View File

@ -1,70 +0,0 @@
package module
// import (
// "errors"
// "io/ioutil"
// "testing"
// "gitlab.com/arcadbox/arcad/internal/app"
// "gitlab.com/arcadbox/arcad/internal/repository"
// )
// func TestUserModuleGetUserByID(t *testing.T) {
// repo := &fakeUserRepository{}
// appID := app.ID("test")
// backend := app.NewServer(appID,
// ConsoleModuleFactory(),
// UserModuleFactory(repo),
// )
// data, err := ioutil.ReadFile("testdata/user_getbyid.js")
// if err != nil {
// t.Fatal(err)
// }
// if err := backend.Load(string(data)); err != nil {
// t.Fatal(err)
// }
// backend.Start()
// defer backend.Stop()
// if err := backend.OnInit(); err != nil {
// t.Error(err)
// }
// }
// type fakeUserRepository struct{}
// func (r *fakeUserRepository) Create() (*repository.User, error) {
// return nil, errors.New("not implemented")
// }
// func (r *fakeUserRepository) Save(user *repository.User) error {
// return errors.New("not implemented")
// }
// func (r *fakeUserRepository) Get(userID repository.UserID) (*repository.User, error) {
// if userID == "0" {
// return &repository.User{}, nil
// }
// return nil, errors.New("not implemented")
// }
// func (r *fakeUserRepository) Delete(userID repository.UserID) error {
// return errors.New("not implemented")
// }
// func (r *fakeUserRepository) Touch(userID repository.UserID, rawUserAgent string) error {
// return errors.New("not implemented")
// }
// func (r *fakeUserRepository) List() ([]*repository.User, error) {
// return nil, errors.New("not implemented")
// }
// func (r *fakeUserRepository) ListByID(userIDs ...repository.UserID) ([]*repository.User, error) {
// return nil, errors.New("not implemented")
// }

28
pkg/module/util/assert.go Normal file
View File

@ -0,0 +1,28 @@
package util
import (
"context"
"github.com/dop251/goja"
"github.com/pkg/errors"
)
func AssertType[T any](v goja.Value, rt *goja.Runtime) T {
if c, ok := v.Export().(T); ok {
return c
}
panic(rt.ToValue(errors.Errorf("expected value to be a '%T', got '%T'", *new(T), v.Export())))
}
func AssertContext(v goja.Value, r *goja.Runtime) context.Context {
return AssertType[context.Context](v, r)
}
func AssertObject(v goja.Value, r *goja.Runtime) map[string]any {
return AssertType[map[string]any](v, r)
}
func AssertString(v goja.Value, r *goja.Runtime) string {
return AssertType[string](v, r)
}

View File

@ -90,8 +90,6 @@ export class Client extends EventTarget {
} }
_handleRPCResponse(evt) { _handleRPCResponse(evt) {
console.log(evt);
const { jsonrpc, id, error, result } = evt.detail; const { jsonrpc, id, error, result } = evt.detail;
if (jsonrpc !== '2.0' || id === undefined) return; if (jsonrpc !== '2.0' || id === undefined) return;

View File

@ -31,12 +31,17 @@ func (d Document) ID() (DocumentID, bool) {
return "", false return "", false
} }
id, ok := rawID.(string) strID, ok := rawID.(string)
if ok { if ok {
return "", false return DocumentID(strID), true
} }
return DocumentID(id), true docID, ok := rawID.(DocumentID)
if ok {
return docID, true
}
return "", false
} }
func (d Document) CreatedAt() (time.Time, bool) { func (d Document) CreatedAt() (time.Time, bool) {
@ -54,7 +59,7 @@ func (d Document) timeAttr(attr string) (time.Time, bool) {
} }
t, ok := rawTime.(time.Time) t, ok := rawTime.(time.Time)
if ok { if !ok {
return time.Time{}, false return time.Time{}, false
} }

View File

@ -0,0 +1,15 @@
package sql
const (
OpIn = "IN"
OpLesserThan = "<"
OpLesserThanEqual = "<="
OpEqual = "="
OpNotEqual = "!="
OpSuperiorThan = ">"
OpSuperiorThanEqual = ">="
OpAnd = "AND"
OpOr = "OR"
OpLike = "LIKE"
OpNot = "NOT"
)

View File

@ -71,6 +71,12 @@ func WithDefaultTransform() OptionFunc {
} }
} }
func WithTransform(transform TransformFunc) OptionFunc {
return func(opt *Option) {
opt.Transform = transform
}
}
func WithNoOpValueTransform() OptionFunc { func WithNoOpValueTransform() OptionFunc {
return WithValueTransform(func(value interface{}) interface{} { return WithValueTransform(func(value interface{}) interface{} {
return value return value

View File

@ -60,7 +60,7 @@ func transformAndOperator(op filter.Operator, option *Option) (string, []interfa
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenAnd, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenAnd, op.Token())
} }
return aggregatorToSQL("AND", option, andOp.Children()...) return aggregatorToSQL(OpAnd, option, andOp.Children()...)
} }
func transformOrOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformOrOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -69,7 +69,7 @@ func transformOrOperator(op filter.Operator, option *Option) (string, []interfac
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenOr, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenOr, op.Token())
} }
return aggregatorToSQL("OR", option, orOp.Children()...) return aggregatorToSQL(OpOr, option, orOp.Children()...)
} }
func transformEqOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformEqOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -78,7 +78,7 @@ func transformEqOperator(op filter.Operator, option *Option) (string, []interfac
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenEq, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenEq, op.Token())
} }
return fieldsToSQL("=", false, eqOp.Fields(), option) return fieldsToSQL(OpEqual, false, eqOp.Fields(), option)
} }
func transformNeqOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformNeqOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -87,7 +87,7 @@ func transformNeqOperator(op filter.Operator, option *Option) (string, []interfa
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenNeq, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenNeq, op.Token())
} }
return fieldsToSQL("!=", false, eqOp.Fields(), option) return fieldsToSQL(OpNotEqual, false, eqOp.Fields(), option)
} }
func transformGtOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformGtOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -96,7 +96,7 @@ func transformGtOperator(op filter.Operator, option *Option) (string, []interfac
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenGt, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenGt, op.Token())
} }
return fieldsToSQL(">", false, gtOp.Fields(), option) return fieldsToSQL(OpSuperiorThan, false, gtOp.Fields(), option)
} }
func transformGteOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformGteOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -105,7 +105,7 @@ func transformGteOperator(op filter.Operator, option *Option) (string, []interfa
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenGte, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenGte, op.Token())
} }
return fieldsToSQL(">=", false, gteOp.Fields(), option) return fieldsToSQL(OpSuperiorThanEqual, false, gteOp.Fields(), option)
} }
func transformLtOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformLtOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -114,7 +114,7 @@ func transformLtOperator(op filter.Operator, option *Option) (string, []interfac
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLt, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLt, op.Token())
} }
return fieldsToSQL("<", false, ltOp.Fields(), option) return fieldsToSQL(OpLesserThan, false, ltOp.Fields(), option)
} }
func transformLteOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformLteOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -123,7 +123,7 @@ func transformLteOperator(op filter.Operator, option *Option) (string, []interfa
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLte, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLte, op.Token())
} }
return fieldsToSQL("<=", false, lteOp.Fields(), option) return fieldsToSQL(OpLesserThanEqual, false, lteOp.Fields(), option)
} }
func transformInOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformInOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -132,7 +132,7 @@ func transformInOperator(op filter.Operator, option *Option) (string, []interfac
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenIn, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenIn, op.Token())
} }
return fieldsToSQL("IN", true, inOp.Fields(), option) return fieldsToSQL(OpIn, true, inOp.Fields(), option)
} }
func transformLikeOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformLikeOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -141,7 +141,7 @@ func transformLikeOperator(op filter.Operator, option *Option) (string, []interf
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLike, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLike, op.Token())
} }
return fieldsToSQL("LIKE", false, likeOp.Fields(), option) return fieldsToSQL(OpLike, false, likeOp.Fields(), option)
} }
func transformNotOperator(op filter.Operator, option *Option) (string, []interface{}, error) { func transformNotOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
@ -150,10 +150,10 @@ func transformNotOperator(op filter.Operator, option *Option) (string, []interfa
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenNot, op.Token()) return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenNot, op.Token())
} }
sql, args, err := aggregatorToSQL("AND", option, notOp.Children()...) sql, args, err := aggregatorToSQL(OpAnd, option, notOp.Children()...)
if err != nil { if err != nil {
return "", nil, errors.WithStack(err) return "", nil, errors.WithStack(err)
} }
return "NOT " + sql, args, nil return OpNot + " " + sql, args, nil
} }

View File

@ -32,7 +32,7 @@ func DefaultTransform(operator string, invert bool, key string, value interface{
return "", nil, errors.WithStack(err) return "", nil, errors.WithStack(err)
} }
if _, err := sb.WriteString(key); err != nil { if _, err := sb.WriteString(option.KeyTransform(key)); err != nil {
return "", nil, errors.WithStack(err) return "", nil, errors.WithStack(err)
} }
} else { } else {

View File

@ -7,35 +7,35 @@ const (
OrderDirectionDesc OrderDirection = "DESC" OrderDirectionDesc OrderDirection = "DESC"
) )
type QueryOption struct { type QueryOptions struct {
Limit *int Limit *int
Offset *int Offset *int
OrderBy *string OrderBy *string
OrderDirection *OrderDirection OrderDirection *OrderDirection
} }
type QueryOptionFunc func(o *QueryOption) type QueryOptionFunc func(o *QueryOptions)
func WithLimit(limit int) QueryOptionFunc { func WithLimit(limit int) QueryOptionFunc {
return func(o *QueryOption) { return func(o *QueryOptions) {
o.Limit = &limit o.Limit = &limit
} }
} }
func WithOffset(offset int) QueryOptionFunc { func WithOffset(offset int) QueryOptionFunc {
return func(o *QueryOption) { return func(o *QueryOptions) {
o.Offset = &offset o.Offset = &offset
} }
} }
func WithOrderBy(orderBy string) QueryOptionFunc { func WithOrderBy(orderBy string) QueryOptionFunc {
return func(o *QueryOption) { return func(o *QueryOptions) {
o.OrderBy = &orderBy o.OrderBy = &orderBy
} }
} }
func WithOrderDirection(direction OrderDirection) QueryOptionFunc { func WithOrderDirection(direction OrderDirection) QueryOptionFunc {
return func(o *QueryOption) { return func(o *QueryOptions) {
o.OrderDirection = &direction o.OrderDirection = &direction
} }
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"math"
"sync" "sync"
"time" "time"
@ -90,18 +91,31 @@ func (s *DocumentStore) Get(ctx context.Context, collection string, id storage.D
// Query implements storage.DocumentStore // Query implements storage.DocumentStore
func (s *DocumentStore) Query(ctx context.Context, collection string, filter *filter.Filter, funcs ...storage.QueryOptionFunc) ([]storage.Document, error) { func (s *DocumentStore) Query(ctx context.Context, collection string, filter *filter.Filter, funcs ...storage.QueryOptionFunc) ([]storage.Document, error) {
opts := &storage.QueryOptions{}
for _, fn := range funcs {
fn(opts)
}
var documents []storage.Document var documents []storage.Document
err := s.withTx(ctx, func(tx *sql.Tx) error { err := s.withTx(ctx, func(tx *sql.Tx) error {
criteria, args, err := filterSQL.ToSQL( criteria := "1 = 1"
filter.Root(), args := make([]any, 0)
filterSQL.WithPreparedParameter("$", 2),
filterSQL.WithKeyTransform(func(key string) string { var err error
return fmt.Sprintf("json_extract(data, '$.%s')", key)
}), if filter != nil {
) criteria, args, err = filterSQL.ToSQL(
if err != nil { filter.Root(),
return errors.WithStack(err) filterSQL.WithPreparedParameter("$", 2),
filterSQL.WithTransform(transformOperator),
filterSQL.WithKeyTransform(func(key string) string {
return fmt.Sprintf("json_extract(data, '$.%s')", key)
}),
)
if err != nil {
return errors.WithStack(err)
}
} }
query := ` query := `
@ -112,6 +126,29 @@ func (s *DocumentStore) Query(ctx context.Context, collection string, filter *fi
args = append([]interface{}{collection}, args...) args = append([]interface{}{collection}, args...)
if opts.OrderBy != nil {
direction := storage.OrderDirectionAsc
if opts.OrderDirection != nil {
direction = *opts.OrderDirection
}
query, args = withOrderByClause(query, args, *opts.OrderBy, direction)
}
if opts.Offset != nil || opts.Limit != nil {
offset := 0
if opts.Offset != nil {
offset = *opts.Offset
}
limit := math.MaxInt
if opts.Limit != nil {
limit = *opts.Limit
}
query, args = withLimitOffsetClause(query, args, limit, offset)
}
logger.Debug( logger.Debug(
ctx, "executing query", ctx, "executing query",
logger.F("query", query), logger.F("query", query),
@ -180,12 +217,14 @@ func (s *DocumentStore) Upsert(ctx context.Context, collection string, document
id = storage.NewDocumentID() id = storage.NewDocumentID()
} }
delete(document, storage.DocumentAttrID)
delete(document, storage.DocumentAttrCreatedAt)
delete(document, storage.DocumentAttrUpdatedAt)
args := []any{id, collection, JSONMap(document), now, now} args := []any{id, collection, JSONMap(document), now, now}
logger.Debug(
ctx, "executing query",
logger.F("query", query),
logger.F("args", args),
)
row := tx.QueryRowContext(ctx, query, args...) row := tx.QueryRowContext(ctx, query, args...)
var ( var (
@ -321,6 +360,41 @@ func (s *DocumentStore) ensureTables(ctx context.Context, db *sql.DB) error {
return nil return nil
} }
func withOrderByClause(query string, args []any, orderBy string, orderDirection storage.OrderDirection) (string, []any) {
direction := "ASC"
if orderDirection == storage.OrderDirectionDesc {
direction = "DESC"
}
var column string
switch orderBy {
case storage.DocumentAttrID:
column = "id"
case storage.DocumentAttrCreatedAt:
column = "created_at"
case storage.DocumentAttrUpdatedAt:
column = "updated_at"
default:
column = fmt.Sprintf("json_extract(data, '$.' || $%d)", len(args)+1)
args = append(args, orderBy)
}
query += fmt.Sprintf(` ORDER BY %s %s`, column, direction)
return query, args
}
func withLimitOffsetClause(query string, args []any, limit int, offset int) (string, []any) {
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
return query, args
}
func NewDocumentStore(path string) *DocumentStore { func NewDocumentStore(path string) *DocumentStore {
return &DocumentStore{ return &DocumentStore{
db: nil, db: nil,

View File

@ -0,0 +1,50 @@
package sqlite
import (
"fmt"
"forge.cadoles.com/arcad/edge/pkg/storage"
"forge.cadoles.com/arcad/edge/pkg/storage/filter/sql"
)
func transformOperator(operator string, invert bool, key string, value any, option *sql.Option) (string, any, error) {
isDataAttr := true
switch key {
case storage.DocumentAttrCreatedAt:
key = "created_at"
isDataAttr = false
case storage.DocumentAttrUpdatedAt:
key = "updated_at"
isDataAttr = false
case storage.DocumentAttrID:
key = "id"
isDataAttr = false
}
if !isDataAttr {
option = &sql.Option{
PreparedParameter: option.PreparedParameter,
KeyTransform: func(key string) string {
return key
},
ValueTransform: option.ValueTransform,
Transform: option.Transform,
}
}
switch operator {
case sql.OpIn:
return transformInOperator(key, value, option)
default:
return sql.DefaultTransform(operator, invert, key, value, option)
}
}
func transformInOperator(key string, value any, option *sql.Option) (string, any, error) {
return fmt.Sprintf(
"EXISTS (SELECT 1 FROM json_each(json_extract(data, \"$.%v\")) WHERE value = %v)",
key,
option.PreparedParameter(),
), option.ValueTransform(value), nil
}

View File

@ -7,8 +7,8 @@ import (
) )
func TestDocumentStore(t *testing.T, store storage.DocumentStore) { func TestDocumentStore(t *testing.T, store storage.DocumentStore) {
t.Run("Query", func(t *testing.T) { t.Run("Ops", func(t *testing.T) {
// t.Parallel() // t.Parallel()
testDocumentStoreQuery(t, store) testDocumentStoreOps(t, store)
}) })
} }

View File

@ -0,0 +1,446 @@
package testsuite
import (
"context"
"testing"
"forge.cadoles.com/arcad/edge/pkg/storage"
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
"github.com/pkg/errors"
)
type documentStoreOpsTestCase struct {
Name string
Run func(ctx context.Context, store storage.DocumentStore) error
}
var documentStoreOpsTestCases = []documentStoreOpsTestCase{
{
Name: "Basic query",
Run: func(ctx context.Context, store storage.DocumentStore) error {
collection := "simple_select"
docs := []storage.Document{
{
"attr1": "Foo",
},
{
"attr1": "Bar",
},
}
for _, d := range docs {
if _, err := store.Upsert(ctx, collection, d); err != nil {
return errors.WithStack(err)
}
}
filter := filter.New(
filter.NewEqOperator(map[string]interface{}{
"attr1": "Foo",
}),
)
results, err := store.Query(ctx, collection, filter)
if err != nil {
return errors.WithStack(err)
}
if e, g := 1, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
if e, g := "Foo", results[0]["attr1"]; e != g {
return errors.Errorf("results[0][\"Attr1\"]: expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Query on _id",
Run: func(ctx context.Context, store storage.DocumentStore) error {
collection := "query_on_id"
doc := storage.Document{
"attr1": "Foo",
}
upsertedDoc, err := store.Upsert(ctx, collection, doc)
if err != nil {
return errors.WithStack(err)
}
docID, ok := upsertedDoc.ID()
if !ok {
return errors.Errorf("")
}
filter := filter.New(
filter.NewEqOperator(map[string]interface{}{
"_id": docID,
}),
)
results, err := store.Query(ctx, collection, filter)
if err != nil {
return errors.WithStack(err)
}
if e, g := 1, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Query with 'IN' operator",
Run: func(ctx context.Context, store storage.DocumentStore) error {
docs := []storage.Document{
{
"counter": 1,
"tags": []string{"foo", "bar"},
},
{
"counter": 1,
"tags": []string{"nope"},
},
}
collection := "in_operator"
for _, doc := range docs {
if _, err := store.Upsert(ctx, collection, doc); err != nil {
return errors.WithStack(err)
}
}
filter := filter.New(
filter.NewAndOperator(
filter.NewEqOperator(map[string]any{
"counter": 1,
}),
filter.NewInOperator(map[string]any{
"tags": "foo",
}),
),
)
results, err := store.Query(ctx, collection, filter)
if err != nil {
return errors.WithStack(err)
}
if e, g := 1, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Double upsert",
Run: func(ctx context.Context, store storage.DocumentStore) error {
collection := "double_upsert"
oriDoc := storage.Document{
"attr1": "Foo",
}
// Upsert document for the first time
upsertedDoc, err := store.Upsert(ctx, collection, oriDoc)
if err != nil {
return errors.WithStack(err)
}
id, exists := upsertedDoc.ID()
if !exists {
return errors.New("id, exists := upsertedDoc.ID(): 'exists' should be true")
}
if id == storage.DocumentID("") {
return errors.New("id, exists := upsertedDoc.ID(): 'id' should not be an empty string")
}
createdAt, exists := upsertedDoc.CreatedAt()
if !exists {
return errors.New("createdAt, exists := upsertedDoc.CreatedAt(): 'exists' should be true")
}
if createdAt.IsZero() {
return errors.New("createdAt, exists := upsertedDoc.CreatedAt(): 'createdAt' should not be zero time")
}
updatedAt, exists := upsertedDoc.UpdatedAt()
if !exists {
return errors.New("updatedAt, exists := upsertedDoc.UpdatedAt(): 'exists' should be true")
}
if updatedAt.IsZero() {
return errors.New("updatedAt, exists := upsertedDoc.UpdatedAt(): 'updatedAt' should not be zero time")
}
if e, g := oriDoc["attr1"], upsertedDoc["attr1"]; e != g {
return errors.Errorf("upsertedDoc[\"attr1\"]: expected '%v', got '%v'", e, g)
}
// Check that document does not have unexpected properties
if e, g := 4, len(upsertedDoc); e != g {
return errors.Errorf("len(upsertedDoc): expected '%v', got '%v'", e, g)
}
// Upsert document for the second time
upsertedDoc2, err := store.Upsert(ctx, collection, upsertedDoc)
if err != nil {
return errors.WithStack(err)
}
prevID, _ := upsertedDoc.ID()
newID, _ := upsertedDoc2.ID()
if e, g := prevID, newID; e != g {
return errors.Errorf("newID: expected '%v', got '%v'", e, g)
}
createdAt1, _ := upsertedDoc.CreatedAt()
createdAt2, _ := upsertedDoc2.CreatedAt()
if e, g := createdAt1, createdAt2; e != g {
return errors.Errorf("upsertedDoc2.CreatedAt(): expected '%v', got '%v'", e, g)
}
updatedAt1, _ := upsertedDoc.UpdatedAt()
updatedAt2, _ := upsertedDoc2.UpdatedAt()
if e, g := updatedAt1, updatedAt2; e == g {
return errors.New("upsertedDoc2.UpdatedAt() should have been different than upsertedDoc.UpdatedAt()")
}
// Verify that there is no additional created document in the collection
results, err := store.Query(ctx, collection, nil)
if err != nil {
return errors.WithStack(err)
}
if e, g := 1, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Query order by document field",
Run: func(ctx context.Context, store storage.DocumentStore) error {
docs := []storage.Document{
{
"sortedField": 0,
"name": "Item 1",
},
{
"sortedField": 1,
"name": "Item 2",
},
{
"sortedField": 2,
"name": "Item 3",
},
}
collection := "ordered_query_by_document_field"
for _, doc := range docs {
if _, err := store.Upsert(ctx, collection, doc); err != nil {
return errors.WithStack(err)
}
}
results, err := store.Query(
ctx, collection, nil,
storage.WithOrderBy("sortedField"),
storage.WithOrderDirection(storage.OrderDirectionAsc),
)
if err != nil {
return errors.WithStack(err)
}
if e, g := 3, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
if e, g := docs[0]["name"], results[0]["name"]; e != g {
return errors.Errorf("results[0][\"name\"]: expected '%v', got '%v'", e, g)
}
if e, g := docs[2]["name"], results[2]["name"]; e != g {
return errors.Errorf("results[2][\"name\"]: expected '%v', got '%v'", e, g)
}
results, err = store.Query(
ctx, collection, nil,
storage.WithOrderBy("sortedField"),
storage.WithOrderDirection(storage.OrderDirectionDesc),
)
if err != nil {
return errors.WithStack(err)
}
if e, g := 3, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
if e, g := docs[2]["name"], results[0]["name"]; e != g {
return errors.Errorf("results[0][\"name\"]: expected '%v', got '%v'", e, g)
}
if e, g := docs[0]["name"], results[2]["name"]; e != g {
return errors.Errorf("results[2][\"name\"]: expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Query order by special attr",
Run: func(ctx context.Context, store storage.DocumentStore) error {
docs := []storage.Document{
{
"name": "Item 1",
},
{
"name": "Item 2",
},
{
"name": "Item 3",
},
}
collection := "ordered_query_by_special_attr"
for _, doc := range docs {
if _, err := store.Upsert(ctx, collection, doc); err != nil {
return errors.WithStack(err)
}
}
results, err := store.Query(
ctx, collection, nil,
storage.WithOrderBy(storage.DocumentAttrCreatedAt),
storage.WithOrderDirection(storage.OrderDirectionAsc),
)
if err != nil {
return errors.WithStack(err)
}
if e, g := 3, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
if e, g := docs[0]["name"], results[0]["name"]; e != g {
return errors.Errorf("results[0][\"name\"]: expected '%v', got '%v'", e, g)
}
if e, g := docs[2]["name"], results[2]["name"]; e != g {
return errors.Errorf("results[2][\"name\"]: expected '%v', got '%v'", e, g)
}
results, err = store.Query(
ctx, collection, nil,
storage.WithOrderBy(storage.DocumentAttrCreatedAt),
storage.WithOrderDirection(storage.OrderDirectionDesc),
)
if err != nil {
return errors.WithStack(err)
}
if e, g := 3, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
if e, g := docs[2]["name"], results[0]["name"]; e != g {
return errors.Errorf("results[0][\"name\"]: expected '%v', got '%v'", e, g)
}
if e, g := docs[0]["name"], results[2]["name"]; e != g {
return errors.Errorf("results[2][\"name\"]: expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Query limit and offset",
Run: func(ctx context.Context, store storage.DocumentStore) error {
docs := []storage.Document{
{"name": "Item 1"},
{"name": "Item 2"},
{"name": "Item 3"},
{"name": "Item 4"},
}
collection := "query_limit_and_offset"
for _, doc := range docs {
if _, err := store.Upsert(ctx, collection, doc); err != nil {
return errors.WithStack(err)
}
}
results, err := store.Query(
ctx, collection, nil,
storage.WithLimit(2),
)
if err != nil {
return errors.WithStack(err)
}
if e, g := 2, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
if e, g := docs[0]["name"], results[0]["name"]; e != g {
return errors.Errorf("results[0][\"name\"]: expected '%v', got '%v'", e, g)
}
if e, g := docs[1]["name"], results[1]["name"]; e != g {
return errors.Errorf("results[1][\"name\"]: expected '%v', got '%v'", e, g)
}
results, err = store.Query(
ctx, collection, nil,
storage.WithOffset(2),
)
if err != nil {
return errors.WithStack(err)
}
if e, g := 2, len(results); e != g {
return errors.Errorf("len(results): expected '%v', got '%v'", e, g)
}
if e, g := docs[2]["name"], results[0]["name"]; e != g {
return errors.Errorf("results[0][\"name\"]: expected '%v', got '%v'", e, g)
}
if e, g := docs[3]["name"], results[1]["name"]; e != g {
return errors.Errorf("results[1][\"name\"]: expected '%v', got '%v'", e, g)
}
return nil
},
},
}
func testDocumentStoreOps(t *testing.T, store storage.DocumentStore) {
for _, tc := range documentStoreOpsTestCases {
func(tc documentStoreOpsTestCase) {
t.Run(tc.Name, func(t *testing.T) {
if err := tc.Run(context.Background(), store); err != nil {
t.Errorf("%+v", errors.WithStack(err))
}
})
}(tc)
}
}

View File

@ -1,85 +0,0 @@
package testsuite
import (
"context"
"testing"
"forge.cadoles.com/arcad/edge/pkg/storage"
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
"github.com/pkg/errors"
)
type documentStoreQueryTestCase struct {
Name string
Before func(ctx context.Context, store storage.DocumentStore) error
Collection string
Filter *filter.Filter
QueryOptionsFuncs []storage.QueryOptionFunc
After func(t *testing.T, results []storage.Document, err error)
}
var documentStoreQueryTestCases = []documentStoreQueryTestCase{
{
Name: "Simple select",
Before: func(ctx context.Context, store storage.DocumentStore) error {
doc1 := storage.Document{
"attr1": "Foo",
}
if _, err := store.Upsert(ctx, "simple_select", doc1); err != nil {
return errors.WithStack(err)
}
doc2 := storage.Document{
"attr1": "Bar",
}
if _, err := store.Upsert(ctx, "simple_select", doc2); err != nil {
return errors.WithStack(err)
}
return nil
},
Collection: "simple_select",
Filter: filter.New(
filter.NewEqOperator(map[string]interface{}{
"attr1": "Foo",
}),
),
After: func(t *testing.T, results []storage.Document, err error) {
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := 1, len(results); e != g {
t.Errorf("len(results): expected '%v', got '%v'", e, g)
}
if e, g := "Foo", results[0]["attr1"]; e != g {
t.Errorf("results[0][\"Attr1\"]: expected '%v', got '%v'", e, g)
}
},
},
}
func testDocumentStoreQuery(t *testing.T, store storage.DocumentStore) {
for _, tc := range documentStoreQueryTestCases {
func(tc documentStoreQueryTestCase) {
t.Run(tc.Name, func(t *testing.T) {
// t.Parallel()
ctx := context.Background()
if tc.Before != nil {
if err := tc.Before(ctx, store); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
}
documents, err := store.Query(ctx, tc.Collection, tc.Filter, tc.QueryOptionsFuncs...)
tc.After(t, documents, err)
})
}(tc)
}
}