Compare commits

...

17 Commits

Author SHA1 Message Date
0bb7f2cd85 chore: refactor client sdk tests 2023-03-03 11:42:20 +01:00
b61bf52df9 fix(module,net): add missing context to server message
fix #6
2023-03-03 10:43:13 +01:00
f1dd467c95 fix(doc): typo 2023-03-01 15:15:47 +01:00
3136d71032 feat(http): handle html5 routing
ref #5
2023-03-01 14:39:45 +01:00
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
56 changed files with 1828 additions and 884 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,19 +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/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"
@ -53,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))
@ -81,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()
@ -89,21 +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(),
cast.CastModuleFactory(),
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")
@ -121,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

@ -20,10 +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)
- [`cast`](./cast.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

@ -178,6 +178,6 @@ var results = store.query(ctx, "myCollection", {
limit: 10, limit: 10,
offset: 5, offset: 5,
orderBy: "foo", orderBy: "foo",
orderDirection: store.ASC, orderDirection: store.DIRECTION_ASC,
}); });
``` ```

1
go.mod
View File

@ -6,6 +6,7 @@ require modernc.org/sqlite v1.20.4
require ( require (
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e // indirect 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/go.net v0.0.0-20151006203346-104dcad90073 // indirect
github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect
github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8 // indirect github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8 // indirect

2
go.sum
View File

@ -110,6 +110,8 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyL
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 h1:eeyMpoxANuWNQ9O2auv4wXxJsrXzLUhdHaOmNWEGkRY=
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 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=

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Client SDK Test suite</title> <title>Client SDK Test suite</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="vendor/mocha.css" /> <link rel="stylesheet" href="/vendor/mocha.css" />
<style> <style>
body { body {
background-color: white; background-color: white;
@ -13,14 +13,18 @@
</head> </head>
<body> <body>
<div id="mocha"></div> <div id="mocha"></div>
<script src="vendor/chai.js"></script> <script src="/vendor/chai.js"></script>
<script src="vendor/mocha.js"></script> <script src="/vendor/mocha.js"></script>
<script class="mocha-init"> <script class="mocha-init">
mocha.setup('bdd'); mocha.setup('bdd');
mocha.checkLeaks(); mocha.checkLeaks();
</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 src="/test/net-module.js"></script>
<script src="/test/rpc-module.js"></script>
<script src="/test/file-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

@ -21,128 +21,5 @@ describe('Edge', function() {
chai.assert.isNull(Edge._conn); chai.assert.isNull(Edge._conn);
}); });
}); });
describe('#send()', function() {
this.timeout(5000);
before(() => {
return Edge.connect();
});
after(() => {
Edge.disconnect();
});
it('should send a message to the server and echo back', function(done) {
const now = new Date();
const handler = evt => {
chai.assert.equal(evt.detail.now, now.toJSON());
Edge.removeEventListener('message', handler);
done();
}
// Server should echo back message
Edge.addEventListener('message', handler);
// Send message to server
Edge.send({ now });
});
});
}); });
describe('Remote Procedure Call', function() {
before(() => {
return Edge.connect();
});
after(() => {
Edge.disconnect();
});
it('should call the remote echo() method and resolve the returned value', function() {
const foo = "bar";
return Edge.rpc('echo', { foo })
.then(result => {
chai.assert.equal(result.foo, foo);
});
});
it('should call the remote throwErrorFromClient() method and reject with an error', function() {
return Edge.rpc('throwErrorFromClient')
.catch(err => {
// Assert that it's an "internal" error
// See https://www.jsonrpc.org/specification#error_object
chai.assert.equal(err.code, -32603);
});
});
it('should call an unregistered method and reject with an error', function() {
return Edge.rpc('unregisteredMethod')
.catch(err => {
// Assert that it's an "method not found" error
// See https://www.jsonrpc.org/specification#error_object
chai.assert.equal(err.code, -32601);
});
});
it('should call the add() method repetitively and keep count of the sent values', function() {
this.timeout(10000);
const values = [];
for(let i = 0; i <= 1000; i++) {
values.push((Math.random() * 1000 | 0));
}
return Edge.rpc('reset')
.then(() => {
return Promise.all(values.map(v => Edge.rpc("add", {value: v})));
})
.then(() => Edge.rpc('total'))
.then(remoteTotal => {
const localTotal = values.reduce((t, v) => t+v);
console.log("Remote total:", remoteTotal, "Local total:", localTotal);
chai.assert.equal(remoteTotal, localTotal)
})
});
});
describe('File Module', function() {
before(() => {
return Edge.connect();
});
after(() => {
Edge.disconnect();
});
it('should upload then download a blob', function() {
const content = JSON.stringify({"date": new Date()});
const blob = new Blob([content], {type: "application/json"});
return Edge.upload(blob)
.then(upload => upload.result())
.then(result => {
chai.assert.isNotEmpty(result.blobId);
chai.assert.isNotEmpty(result.bucket);
const blobUrl = Edge.blobUrl(result.bucket, result.blobId);
chai.assert.isNotEmpty(blobUrl);
return fetch(blobUrl)
.then(res => res.text())
.then(blobContent => {
chai.assert.equal(content, blobContent);
});
})
.catch(err => {
chai.assert.fail(err);
})
});
});

View File

@ -0,0 +1,36 @@
describe('File Module', function () {
before(() => {
return Edge.connect();
});
after(() => {
Edge.disconnect();
});
it('should upload then download a blob', function () {
const content = JSON.stringify({ "date": new Date() });
const blob = new Blob([content], { type: "application/json" });
return Edge.upload(blob)
.then(upload => upload.result())
.then(result => {
chai.assert.isNotEmpty(result.blobId);
chai.assert.isNotEmpty(result.bucket);
const blobUrl = Edge.blobUrl(result.bucket, result.blobId);
chai.assert.isNotEmpty(blobUrl);
return fetch(blobUrl)
.then(res => res.text())
.then(blobContent => {
chai.assert.equal(content, blobContent);
});
})
.catch(err => {
chai.assert.fail(err);
})
});
});

View File

@ -0,0 +1,49 @@
describe('Net Module', function () {
this.timeout(5000);
before(() => {
return Edge.connect();
});
after(() => {
Edge.disconnect();
});
it('should broadcast a message from server', function (done) {
const message = { test: 'broadcast', now: Date.now() };
const handler = (evt) => {
const receivedMessage = evt.detail;
if (receivedMessage.test !== 'broadcast') return;
chai.assert.deepEqual(message, evt.detail);
Edge.removeEventListener('message', handler);
done();
};
Edge.addEventListener("message", handler);
Edge.send(message);
});
it('should send a message to the server and echo back', function(done) {
const now = new Date();
const handler = evt => {
const receivedMessage = evt.detail;
if (receivedMessage.test !== 'echo') return;
chai.assert.equal(receivedMessage.now, now.toJSON());
Edge.removeEventListener('message', handler);
done();
}
// Server should echo back message
Edge.addEventListener('message', handler);
// Send message to server
Edge.send({ test: 'echo', now });
});
});

View File

@ -0,0 +1,59 @@
describe('Remote Procedure Call', function () {
before(() => {
return Edge.connect();
});
after(() => {
Edge.disconnect();
});
it('should call the remote echo() method and resolve the returned value', function () {
const foo = "bar";
return Edge.rpc('echo', { foo })
.then(result => {
console.log(result);
chai.assert.equal(result.foo, foo);
});
});
it('should call the remote throwErrorFromClient() method and reject with an error', function () {
return Edge.rpc('throwErrorFromClient')
.catch(err => {
// Assert that it's an "internal" error
// See https://www.jsonrpc.org/specification#error_object
chai.assert.equal(err.code, -32603);
});
});
it('should call an unregistered method and reject with an error', function () {
return Edge.rpc('unregisteredMethod')
.catch(err => {
// Assert that it's an "method not found" error
// See https://www.jsonrpc.org/specification#error_object
chai.assert.equal(err.code, -32601);
});
});
it('should call the add() method repetitively and keep count of the sent values', function () {
this.timeout(10000);
const values = [];
for (let i = 0; i <= 1000; i++) {
values.push((Math.random() * 1000 | 0));
}
return Edge.rpc('reset')
.then(() => {
return Promise.all(values.map(v => Edge.rpc("add", { value: v })));
})
.then(() => Edge.rpc('total'))
.then(remoteTotal => {
const localTotal = values.reduce((t, v) => t + v);
console.log("Remote total:", remoteTotal, "Local total:", localTotal);
chai.assert.equal(remoteTotal, localTotal)
})
});
});

View File

@ -10,13 +10,20 @@ 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, message) {
var sessionId = context.get(ctx, context.SESSION_ID); console.log("onClientMessage", message);
console.log("onClientMessage", sessionId, data.now);
net.send(ctx, { now: data.now }); switch (message.test) {
case "broadcast":
net.broadcast(message);
break;
default:
net.send(ctx, message);
}
} }
// Called for each blob upload request // Called for each blob upload request
@ -60,4 +67,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,6 +1,6 @@
**/*.go **/*.go
pkg/app/sdk/client/src/**/*.js pkg/sdk/client/src/**/*.js
pkg/app/sdk/client/src/**/*.ts pkg/sdk/client/src/**/*.ts
misc/client-sdk-testsuite/src/**/* misc/client-sdk-testsuite/src/**/*
modd.conf modd.conf
{ {

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

View File

@ -99,3 +99,5 @@ func (f *File) Readdir(count int) ([]os.FileInfo, error) {
func (f *File) Stat() (os.FileInfo, error) { func (f *File) Stat() (os.FileInfo, error) {
return f.fi, nil return f.fi, nil
} }
var _ http.FileSystem = &FileSystem{}

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

@ -59,7 +59,7 @@ func (h *Handler) Load(bdle bundle.Bundle) error {
} }
fs := bundle.NewFileSystem("public", bdle) fs := bundle.NewFileSystem("public", bdle)
public := http.FileServer(fs) public := HTML5Fileserver(fs)
sockjs := sockjs.NewHandler(sockJSPathPrefix, h.sockjsOpts, h.handleSockJSSession) sockjs := sockjs.NewHandler(sockJSPathPrefix, h.sockjsOpts, h.handleSockJSSession)
if h.server != nil { if h.server != nil {

View File

@ -0,0 +1,54 @@
package http
import (
"net/http"
"os"
"path"
"strings"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func HTML5Fileserver(fs http.FileSystem) http.Handler {
handler := http.FileServer(fs)
fn := func(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
if !strings.HasPrefix(urlPath, "/") {
urlPath = "/" + urlPath
r.URL.Path = urlPath
}
urlPath = path.Clean(urlPath)
file, err := fs.Open(urlPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
r.URL.Path = "/"
handler.ServeHTTP(w, r)
return
}
logger.Error(r.Context(), "could not open bundle file", logger.E(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
defer func() {
if err := file.Close(); err != nil {
logger.Error(r.Context(), "could not close file", logger.E(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}()
handler.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

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

View File

@ -60,7 +60,7 @@ func (m *Module) refreshDevices(call goja.FunctionCall, rt *goja.Runtime) goja.V
timeout, err := m.parseTimeout(rawTimeout) timeout, err := m.parseTimeout(rawTimeout)
if err != nil { if err != nil {
panic(errors.WithStack(err)) panic(rt.ToValue(errors.WithStack(err)))
} }
promise := m.server.NewPromise() promise := m.server.NewPromise()
@ -106,7 +106,7 @@ func (m *Module) getDevices(call goja.FunctionCall, rt *goja.Runtime) goja.Value
func (m *Module) loadUrl(call goja.FunctionCall, rt *goja.Runtime) goja.Value { func (m *Module) loadUrl(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 2 { if len(call.Arguments) < 2 {
panic(errors.WithStack(module.ErrUnexpectedArgumentsNumber)) panic(rt.ToValue(errors.WithStack(module.ErrUnexpectedArgumentsNumber)))
} }
deviceUUID := call.Argument(0).String() deviceUUID := call.Argument(0).String()
@ -116,7 +116,7 @@ func (m *Module) loadUrl(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
timeout, err := m.parseTimeout(rawTimeout) timeout, err := m.parseTimeout(rawTimeout)
if err != nil { if err != nil {
panic(errors.WithStack(err)) panic(rt.ToValue(errors.WithStack(err)))
} }
promise := m.server.NewPromise() promise := m.server.NewPromise()
@ -144,9 +144,9 @@ func (m *Module) loadUrl(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
return m.server.ToValue(promise) return m.server.ToValue(promise)
} }
func (m *Module) stopCast(call goja.FunctionCall) goja.Value { func (m *Module) stopCast(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
panic(errors.WithStack(module.ErrUnexpectedArgumentsNumber)) panic(rt.ToValue(errors.WithStack(module.ErrUnexpectedArgumentsNumber)))
} }
deviceUUID := call.Argument(0).String() deviceUUID := call.Argument(0).String()
@ -154,7 +154,7 @@ func (m *Module) stopCast(call goja.FunctionCall) goja.Value {
timeout, err := m.parseTimeout(rawTimeout) timeout, err := m.parseTimeout(rawTimeout)
if err != nil { if err != nil {
panic(errors.WithStack(err)) panic(rt.ToValue(errors.WithStack(err)))
} }
promise := m.server.NewPromise() promise := m.server.NewPromise()
@ -182,9 +182,9 @@ func (m *Module) stopCast(call goja.FunctionCall) goja.Value {
return m.server.ToValue(promise) return m.server.ToValue(promise)
} }
func (m *Module) getStatus(call goja.FunctionCall) goja.Value { func (m *Module) getStatus(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
if len(call.Arguments) < 1 { if len(call.Arguments) < 1 {
panic(errors.WithStack(module.ErrUnexpectedArgumentsNumber)) panic(rt.ToValue(errors.WithStack(module.ErrUnexpectedArgumentsNumber)))
} }
deviceUUID := call.Argument(0).String() deviceUUID := call.Argument(0).String()
@ -192,7 +192,7 @@ func (m *Module) getStatus(call goja.FunctionCall) goja.Value {
timeout, err := m.parseTimeout(rawTimeout) timeout, err := m.parseTimeout(rawTimeout)
if err != nil { if err != nil {
panic(errors.WithStack(err)) panic(rt.ToValue(errors.WithStack(err)))
} }
promise := m.server.NewPromise() promise := m.server.NewPromise()

View File

@ -1,6 +1,7 @@
package cast package cast
import ( import (
"context"
"io/ioutil" "io/ioutil"
"os" "os"
"testing" "testing"
@ -79,7 +80,7 @@ func TestCastModuleRefreshDevices(t *testing.T) {
defer server.Stop() defer server.Stop()
result, err := server.ExecFuncByName("refreshDevices") result, err := server.ExecFuncByName(context.Background(), "refreshDevices")
if err != nil { if err != nil {
t.Error(errors.WithStack(err)) t.Error(errors.WithStack(err))
} }

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

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

@ -0,0 +1,159 @@
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()
ctx := context.Background()
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) 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,7 +85,7 @@ 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)
@ -113,7 +114,7 @@ func (m *StoreModule) query(call goja.FunctionCall, rt *goja.Runtime) goja.Value
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))
@ -125,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
@ -157,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
@ -190,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,11 +90,10 @@ 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;
if (!evt.detail.hasOwnProperty("error") && !evt.detail.hasOwnProperty("result")) return;
// Prevent additional handlers to catch this event // Prevent additional handlers to catch this event
evt.stopImmediatePropagation(); evt.stopImmediatePropagation();

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

@ -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,6 +91,11 @@ 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 {
@ -120,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),
@ -188,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 (
@ -329,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

@ -3,10 +3,36 @@ package sqlite
import ( import (
"fmt" "fmt"
"forge.cadoles.com/arcad/edge/pkg/storage"
"forge.cadoles.com/arcad/edge/pkg/storage/filter/sql" "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) { 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 { switch operator {
case sql.OpIn: case sql.OpIn:
return transformInOperator(key, value, option) return transformInOperator(key, value, option)

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,128 +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)
}
},
},
{
Name: "IN Operator",
Before: func(ctx context.Context, store storage.DocumentStore) error {
docs := []storage.Document{
{
"counter": 1,
"tags": []string{"foo", "bar"},
},
{
"counter": 1,
"tags": []string{"nope"},
},
}
for _, doc := range docs {
if _, err := store.Upsert(ctx, "in_operator", doc); err != nil {
return errors.WithStack(err)
}
}
return nil
},
Collection: "in_operator",
Filter: filter.New(
filter.NewAndOperator(
filter.NewEqOperator(map[string]any{
"counter": 1,
}),
filter.NewInOperator(map[string]any{
"tags": "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)
}
},
},
}
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)
}
}