feat: initial commit
This commit is contained in:
16
pkg/app/app.go
Normal file
16
pkg/app/app.go
Normal file
@ -0,0 +1,16 @@
|
||||
package app
|
||||
|
||||
type ID string
|
||||
|
||||
type Manifest struct {
|
||||
ID ID `yaml:"id"`
|
||||
Version string `yaml:"version"`
|
||||
Title string `yaml:"title"`
|
||||
Description string `yaml:"description"`
|
||||
Tags []string `yaml:"tags"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
ID ID
|
||||
Manifest *Manifest
|
||||
}
|
25
pkg/app/crypto.go
Normal file
25
pkg/app/crypto.go
Normal file
@ -0,0 +1,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type cryptoSource struct{}
|
||||
|
||||
func (s cryptoSource) Seed(seed int64) {}
|
||||
|
||||
func (s cryptoSource) Int63() int64 {
|
||||
return int64(s.Uint64() & ^uint64(1<<63))
|
||||
}
|
||||
|
||||
func (s cryptoSource) Uint64() (v uint64) {
|
||||
err := binary.Read(rand.Reader, binary.BigEndian, &v)
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "could not read number for random source"))
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
5
pkg/app/error.go
Normal file
5
pkg/app/error.go
Normal file
@ -0,0 +1,5 @@
|
||||
package app
|
||||
|
||||
import "github.com/pkg/errors"
|
||||
|
||||
var ErrUnknownBundleArchiveFormat = errors.New("unknown bundle archive format")
|
101
pkg/app/loader.go
Normal file
101
pkg/app/loader.go
Normal file
@ -0,0 +1,101 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bundle"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type FilesystemLoader struct {
|
||||
searchPatterns []string
|
||||
}
|
||||
|
||||
type LoadedApp struct {
|
||||
App *App
|
||||
Bundle bundle.Bundle
|
||||
}
|
||||
|
||||
func (l *FilesystemLoader) Load(ctx context.Context) ([]*LoadedApp, error) {
|
||||
apps := make([]*LoadedApp, 0)
|
||||
|
||||
for _, seachPattern := range l.searchPatterns {
|
||||
absSearchPattern, err := filepath.Abs(seachPattern)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not generate absolute path for '%s'", seachPattern)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "searching apps in filesystem", logger.F("searchPattern", absSearchPattern))
|
||||
|
||||
files, err := filepath.Glob(absSearchPattern)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not search files with pattern '%s'", absSearchPattern)
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
loopCtx := logger.With(ctx, logger.F("file", f))
|
||||
|
||||
logger.Debug(loopCtx, "found app bundle")
|
||||
|
||||
b, err := bundle.FromPath(f)
|
||||
if err != nil {
|
||||
logger.Error(loopCtx, "could not load bundle", logger.E(errors.WithStack(err)))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug(loopCtx, "loading app manifest")
|
||||
|
||||
appManifest, err := LoadAppManifest(b)
|
||||
if err != nil {
|
||||
logger.Error(loopCtx, "could not load app manifest", logger.E(errors.WithStack(err)))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
g := &App{
|
||||
ID: appManifest.ID,
|
||||
Manifest: appManifest,
|
||||
}
|
||||
|
||||
apps = append(apps, &LoadedApp{
|
||||
App: g,
|
||||
Bundle: b,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func NewFilesystemLoader(searchPatterns ...string) *FilesystemLoader {
|
||||
return &FilesystemLoader{
|
||||
searchPatterns: searchPatterns,
|
||||
}
|
||||
}
|
||||
|
||||
func LoadAppManifest(b bundle.Bundle) (*Manifest, error) {
|
||||
reader, _, err := b.File("manifest.yml")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read manifest.yml")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
manifest := &Manifest{}
|
||||
|
||||
decoder := yaml.NewDecoder(reader)
|
||||
if err := decoder.Decode(manifest); err != nil {
|
||||
return nil, errors.Wrap(err, "could not decode manifest.yml")
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
41
pkg/app/promise_proxy.go
Normal file
41
pkg/app/promise_proxy.go
Normal file
@ -0,0 +1,41 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type PromiseProxy struct {
|
||||
*goja.Promise
|
||||
wg sync.WaitGroup
|
||||
resolve func(result interface{})
|
||||
reject func(reason interface{})
|
||||
}
|
||||
|
||||
func (p *PromiseProxy) Resolve(result interface{}) {
|
||||
defer p.wg.Done()
|
||||
p.resolve(result)
|
||||
}
|
||||
|
||||
func (p *PromiseProxy) Reject(reason interface{}) {
|
||||
defer p.wg.Done()
|
||||
p.resolve(reason)
|
||||
}
|
||||
|
||||
func (p *PromiseProxy) Wait() {
|
||||
p.wg.Wait()
|
||||
}
|
||||
|
||||
func NewPromiseProxy(promise *goja.Promise, resolve func(result interface{}), reject func(reason interface{})) *PromiseProxy {
|
||||
proxy := &PromiseProxy{
|
||||
Promise: promise,
|
||||
wg: sync.WaitGroup{},
|
||||
resolve: resolve,
|
||||
reject: reject,
|
||||
}
|
||||
|
||||
proxy.wg.Add(1)
|
||||
|
||||
return proxy
|
||||
}
|
182
pkg/app/server.go
Normal file
182
pkg/app/server.go
Normal file
@ -0,0 +1,182 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrFuncDoesNotExist = errors.New("function does not exist")
|
||||
|
||||
type Server struct {
|
||||
runtime *goja.Runtime
|
||||
loop *eventloop.EventLoop
|
||||
modules []ServerModule
|
||||
}
|
||||
|
||||
func (s *Server) Load(name string, src string) error {
|
||||
_, err := s.runtime.RunScript(name, src)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not run js script")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ExecFuncByName(funcName string, args ...interface{}) (goja.Value, error) {
|
||||
callable, ok := goja.AssertFunction(s.runtime.Get(funcName))
|
||||
if !ok {
|
||||
return nil, errors.WithStack(ErrFuncDoesNotExist)
|
||||
}
|
||||
|
||||
return s.Exec(callable, args...)
|
||||
}
|
||||
|
||||
func (s *Server) Exec(callable goja.Callable, args ...interface{}) (goja.Value, error) {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
value goja.Value
|
||||
err error
|
||||
)
|
||||
|
||||
wg.Add(1)
|
||||
|
||||
s.loop.RunOnLoop(func(vm *goja.Runtime) {
|
||||
jsArgs := make([]goja.Value, 0, len(args))
|
||||
for _, a := range args {
|
||||
jsArgs = append(jsArgs, vm.ToValue(a))
|
||||
}
|
||||
|
||||
value, err = callable(nil, jsArgs...)
|
||||
if err != nil {
|
||||
err = errors.WithStack(err)
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *Server) IsPromise(v goja.Value) (*goja.Promise, bool) {
|
||||
promise, ok := v.Export().(*goja.Promise)
|
||||
return promise, ok
|
||||
}
|
||||
|
||||
func (s *Server) WaitForPromise(promise *goja.Promise) goja.Value {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
value goja.Value
|
||||
)
|
||||
|
||||
wg.Add(1)
|
||||
|
||||
// Wait for promise completion
|
||||
go func() {
|
||||
for {
|
||||
var loopWait sync.WaitGroup
|
||||
loopWait.Add(1)
|
||||
|
||||
breakLoop := false
|
||||
|
||||
s.loop.RunOnLoop(func(vm *goja.Runtime) {
|
||||
defer loopWait.Done()
|
||||
|
||||
if promise.State() == goja.PromiseStatePending {
|
||||
return
|
||||
}
|
||||
|
||||
value = promise.Result()
|
||||
|
||||
breakLoop = true
|
||||
})
|
||||
|
||||
loopWait.Wait()
|
||||
|
||||
if breakLoop {
|
||||
wg.Done()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Server) NewPromise() *PromiseProxy {
|
||||
promise, resolve, reject := s.runtime.NewPromise()
|
||||
|
||||
return NewPromiseProxy(promise, resolve, reject)
|
||||
}
|
||||
|
||||
func (s *Server) ToValue(v interface{}) goja.Value {
|
||||
return s.runtime.ToValue(v)
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
s.loop.Start()
|
||||
|
||||
for _, mod := range s.modules {
|
||||
initMod, ok := mod.(InitializableModule)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := initMod.OnInit(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
s.loop.Stop()
|
||||
}
|
||||
|
||||
func (s *Server) initModules(factories ...ServerModuleFactory) {
|
||||
runtime := goja.New()
|
||||
|
||||
runtime.SetFieldNameMapper(goja.UncapFieldNameMapper())
|
||||
runtime.SetRandSource(createRandomSource())
|
||||
|
||||
modules := make([]ServerModule, 0, len(factories))
|
||||
|
||||
for _, moduleFactory := range factories {
|
||||
mod := moduleFactory(s)
|
||||
export := runtime.NewObject()
|
||||
mod.Export(export)
|
||||
runtime.Set(mod.Name(), export)
|
||||
|
||||
modules = append(modules, mod)
|
||||
}
|
||||
|
||||
s.runtime = runtime
|
||||
s.modules = modules
|
||||
}
|
||||
|
||||
func NewServer(factories ...ServerModuleFactory) *Server {
|
||||
server := &Server{
|
||||
loop: eventloop.NewEventLoop(
|
||||
eventloop.EnableConsole(false),
|
||||
),
|
||||
}
|
||||
|
||||
server.initModules(factories...)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func createRandomSource() goja.RandSource {
|
||||
rnd := rand.New(&cryptoSource{})
|
||||
|
||||
return rnd.Float64
|
||||
}
|
17
pkg/app/server_module.go
Normal file
17
pkg/app/server_module.go
Normal file
@ -0,0 +1,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type ServerModuleFactory func(*Server) ServerModule
|
||||
|
||||
type ServerModule interface {
|
||||
Name() string
|
||||
Export(*goja.Object)
|
||||
}
|
||||
|
||||
type InitializableModule interface {
|
||||
ServerModule
|
||||
OnInit() error
|
||||
}
|
11
pkg/bundle/bundle.go
Normal file
11
pkg/bundle/bundle.go
Normal file
@ -0,0 +1,11 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Bundle interface {
|
||||
File(string) (io.ReadCloser, os.FileInfo, error)
|
||||
Dir(string) ([]os.FileInfo, error)
|
||||
}
|
70
pkg/bundle/bundle_test.go
Normal file
70
pkg/bundle/bundle_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func TestBundle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
|
||||
bundles := []Bundle{
|
||||
NewDirectoryBundle("testdata/bundle"),
|
||||
NewTarBundle("testdata/bundle.tar.gz"),
|
||||
NewZipBundle("testdata/bundle.zip"),
|
||||
}
|
||||
|
||||
for _, b := range bundles {
|
||||
func(b Bundle) {
|
||||
t.Run(fmt.Sprintf("'%T'", b), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reader, info, err := b.File("data/test/foo.txt")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if reader == nil {
|
||||
t.Fatal("File(data/test/foo.txt): reader should not be nil")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
if info == nil {
|
||||
t.Error("File(data/test/foo.txt): info should not be nil")
|
||||
}
|
||||
|
||||
files, err := b.Dir("data")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if e, g := 1, len(files); e != g {
|
||||
t.Errorf("len(files): expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
files, err = b.Dir("data/test")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if e, g := 1, len(files); e != g {
|
||||
t.Fatalf("len(files): expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
if e, g := "foo.txt", files[0].Name(); e != g {
|
||||
t.Errorf("files[0].Name(): expected '%v', got '%v'", e, g)
|
||||
}
|
||||
})
|
||||
}(b)
|
||||
}
|
||||
}
|
54
pkg/bundle/directory_bundle.go
Normal file
54
pkg/bundle/directory_bundle.go
Normal file
@ -0,0 +1,54 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type DirectoryBundle struct {
|
||||
baseDir string
|
||||
}
|
||||
|
||||
func (b *DirectoryBundle) File(filename string) (io.ReadCloser, os.FileInfo, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
fullPath := path.Join(b.baseDir, filename)
|
||||
|
||||
logger.Debug(ctx, "accessing bundle file", logger.F("file", fullPath))
|
||||
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return nil, nil, errors.Wrapf(err, "stat '%s'", fullPath)
|
||||
}
|
||||
|
||||
reader, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrapf(err, "open '%s'", fullPath)
|
||||
}
|
||||
|
||||
return reader, info, nil
|
||||
}
|
||||
|
||||
func (b *DirectoryBundle) Dir(dirname string) ([]os.FileInfo, error) {
|
||||
fullPath := path.Join(b.baseDir, dirname)
|
||||
ctx := context.Background()
|
||||
logger.Debug(ctx, "accessing bundle directory", logger.F("file", fullPath))
|
||||
return ioutil.ReadDir(fullPath)
|
||||
}
|
||||
|
||||
func NewDirectoryBundle(baseDir string) *DirectoryBundle {
|
||||
return &DirectoryBundle{
|
||||
baseDir: baseDir,
|
||||
}
|
||||
}
|
5
pkg/bundle/error.go
Normal file
5
pkg/bundle/error.go
Normal file
@ -0,0 +1,5 @@
|
||||
package bundle
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrUnknownBundleArchiveExt = errors.New("unknown bundle archive extension")
|
101
pkg/bundle/filesystem.go
Normal file
101
pkg/bundle/filesystem.go
Normal file
@ -0,0 +1,101 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type FileSystem struct {
|
||||
prefix string
|
||||
bundle Bundle
|
||||
}
|
||||
|
||||
func (fs *FileSystem) Open(name string) (http.File, error) {
|
||||
if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) ||
|
||||
strings.Contains(name, "\x00") {
|
||||
return nil, errors.New("http: invalid character in file path")
|
||||
}
|
||||
|
||||
p := path.Join(fs.prefix, strings.TrimPrefix(name, "/"))
|
||||
|
||||
ctx := logger.With(
|
||||
context.Background(),
|
||||
logger.F("filename", name),
|
||||
)
|
||||
|
||||
logger.Debug(ctx, "opening file")
|
||||
|
||||
readCloser, fileInfo, err := fs.bundle.File(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not open bundle file", logger.E(err))
|
||||
|
||||
return nil, errors.Wrapf(err, "could not open bundle file '%s'", p)
|
||||
}
|
||||
defer readCloser.Close()
|
||||
|
||||
file := &File{
|
||||
fi: fileInfo,
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
files, err := fs.bundle.Dir(p)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not read bundle directory", logger.E(err))
|
||||
|
||||
return nil, errors.Wrapf(err, "could not read bundle directory '%s'", p)
|
||||
}
|
||||
|
||||
file.files = files
|
||||
} else {
|
||||
data, err := ioutil.ReadAll(readCloser)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not read bundle file", logger.E(err))
|
||||
|
||||
return nil, errors.Wrapf(err, "could not read bundle file '%s'", p)
|
||||
}
|
||||
|
||||
file.Reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func NewFileSystem(prefix string, bundle Bundle) *FileSystem {
|
||||
return &FileSystem{prefix, bundle}
|
||||
}
|
||||
|
||||
type File struct {
|
||||
*bytes.Reader
|
||||
fi os.FileInfo
|
||||
files []os.FileInfo
|
||||
}
|
||||
|
||||
// A noop-closer.
|
||||
func (f *File) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *File) Readdir(count int) ([]os.FileInfo, error) {
|
||||
if f.fi.IsDir() && f.files != nil {
|
||||
return f.files, nil
|
||||
}
|
||||
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (f *File) Stat() (os.FileInfo, error) {
|
||||
return f.fi, nil
|
||||
}
|
60
pkg/bundle/from_path.go
Normal file
60
pkg/bundle/from_path.go
Normal file
@ -0,0 +1,60 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ArchiveExt string
|
||||
|
||||
const (
|
||||
ExtZip ArchiveExt = "zip"
|
||||
ExtTarGz ArchiveExt = "tar.gz"
|
||||
)
|
||||
|
||||
func FromPath(path string) (Bundle, error) {
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not stat file '%s'", path)
|
||||
}
|
||||
|
||||
var b Bundle
|
||||
|
||||
if stat.IsDir() {
|
||||
b = NewDirectoryBundle(path)
|
||||
} else {
|
||||
b, err = matchArchivePattern(path)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func matchArchivePattern(archivePath string) (Bundle, error) {
|
||||
base := filepath.Base(archivePath)
|
||||
|
||||
matches, err := filepath.Match(fmt.Sprintf("*.%s", ExtTarGz), base)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not match file archive '%s'", archivePath)
|
||||
}
|
||||
|
||||
if matches {
|
||||
return NewTarBundle(archivePath), nil
|
||||
}
|
||||
|
||||
matches, err = filepath.Match(fmt.Sprintf("*.%s", ExtZip), base)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not match file archive '%s'", archivePath)
|
||||
}
|
||||
|
||||
if matches {
|
||||
return NewZipBundle(archivePath), nil
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(ErrUnknownBundleArchiveExt)
|
||||
}
|
146
pkg/bundle/tar_bundle.go
Normal file
146
pkg/bundle/tar_bundle.go
Normal file
@ -0,0 +1,146 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type TarBundle struct {
|
||||
archivePath string
|
||||
}
|
||||
|
||||
func (b *TarBundle) File(filename string) (io.ReadCloser, os.FileInfo, error) {
|
||||
reader, archive, err := b.openArchive()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ctx := logger.With(
|
||||
context.Background(),
|
||||
logger.F("filename", filename),
|
||||
)
|
||||
|
||||
logger.Debug(ctx, "opening file")
|
||||
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "could not get next tar file")
|
||||
}
|
||||
|
||||
p := strings.TrimPrefix(strings.TrimSuffix(header.Name, "/"), "./")
|
||||
|
||||
logger.Debug(ctx, "reading archive file", logger.F("path", p))
|
||||
|
||||
if filename != p {
|
||||
continue
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir {
|
||||
continue
|
||||
}
|
||||
|
||||
rc := &archiveFile{reader, archive}
|
||||
|
||||
return rc, header.FileInfo(), nil
|
||||
}
|
||||
|
||||
return nil, nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (b *TarBundle) Dir(dirname string) ([]os.FileInfo, error) {
|
||||
reader, archive, err := b.openArchive()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer archive.Close()
|
||||
|
||||
files := make([]os.FileInfo, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get next tar file")
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(header.Name, dirname) {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(dirname, header.Name)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get relative path")
|
||||
}
|
||||
|
||||
logger.Debug(
|
||||
ctx, "checking file prefix",
|
||||
logger.F("dirname", dirname),
|
||||
logger.F("filename", header.Name),
|
||||
logger.F("relpath", relPath),
|
||||
)
|
||||
|
||||
if relPath == filepath.Base(header.Name) {
|
||||
files = append(files, header.FileInfo())
|
||||
}
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (b *TarBundle) openArchive() (*tar.Reader, *os.File, error) {
|
||||
f, err := os.Open(b.archivePath)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrapf(err, "could not open '%v'", b.archivePath)
|
||||
}
|
||||
|
||||
gzf, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrapf(err, "could not decompress '%v'", b.archivePath)
|
||||
}
|
||||
|
||||
tr := tar.NewReader(gzf)
|
||||
|
||||
return tr, f, nil
|
||||
}
|
||||
|
||||
func NewTarBundle(archivePath string) *TarBundle {
|
||||
return &TarBundle{
|
||||
archivePath: archivePath,
|
||||
}
|
||||
}
|
||||
|
||||
type archiveFile struct {
|
||||
reader io.Reader
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
func (f *archiveFile) Read(p []byte) (n int, err error) {
|
||||
return f.reader.Read(p)
|
||||
}
|
||||
|
||||
func (f *archiveFile) Close() error {
|
||||
return f.closer.Close()
|
||||
}
|
BIN
pkg/bundle/testdata/bundle.tar.gz
vendored
Normal file
BIN
pkg/bundle/testdata/bundle.tar.gz
vendored
Normal file
Binary file not shown.
BIN
pkg/bundle/testdata/bundle.zip
vendored
Normal file
BIN
pkg/bundle/testdata/bundle.zip
vendored
Normal file
Binary file not shown.
1
pkg/bundle/testdata/bundle/data/test/foo.txt
vendored
Normal file
1
pkg/bundle/testdata/bundle/data/test/foo.txt
vendored
Normal file
@ -0,0 +1 @@
|
||||
bar
|
98
pkg/bundle/zip_bundle.go
Normal file
98
pkg/bundle/zip_bundle.go
Normal file
@ -0,0 +1,98 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type ZipBundle struct {
|
||||
archivePath string
|
||||
}
|
||||
|
||||
func (b *ZipBundle) File(filename string) (io.ReadCloser, os.FileInfo, error) {
|
||||
reader, err := b.openArchive()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ctx := logger.With(
|
||||
context.Background(),
|
||||
logger.F("filename", filename),
|
||||
)
|
||||
|
||||
logger.Debug(ctx, "opening file")
|
||||
|
||||
f, err := reader.Open(filename)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return f, stat, nil
|
||||
}
|
||||
|
||||
func (b *ZipBundle) Dir(dirname string) ([]os.FileInfo, error) {
|
||||
reader, err := b.openArchive()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
files := make([]os.FileInfo, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, f := range reader.File {
|
||||
if !strings.HasPrefix(f.Name, dirname) {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(dirname, f.Name)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get relative path")
|
||||
}
|
||||
|
||||
logger.Debug(
|
||||
ctx, "checking file prefix",
|
||||
logger.F("dirname", dirname),
|
||||
logger.F("filename", f.Name),
|
||||
logger.F("relpath", relPath),
|
||||
)
|
||||
|
||||
if relPath == filepath.Base(f.Name) {
|
||||
files = append(files, f.FileInfo())
|
||||
}
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (b *ZipBundle) openArchive() (*zip.ReadCloser, error) {
|
||||
zr, err := zip.OpenReader(b.archivePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not decompress '%v'", b.archivePath)
|
||||
}
|
||||
|
||||
return zr, nil
|
||||
}
|
||||
|
||||
func NewZipBundle(archivePath string) *ZipBundle {
|
||||
return &ZipBundle{
|
||||
archivePath: archivePath,
|
||||
}
|
||||
}
|
13
pkg/bus/bus.go
Normal file
13
pkg/bus/bus.go
Normal file
@ -0,0 +1,13 @@
|
||||
package bus
|
||||
|
||||
import "context"
|
||||
|
||||
type Bus interface {
|
||||
Subscribe(ctx context.Context, ns MessageNamespace) (<-chan Message, error)
|
||||
Unsubscribe(ctx context.Context, ns MessageNamespace, ch <-chan Message)
|
||||
Publish(ctx context.Context, msg Message) error
|
||||
Request(ctx context.Context, msg Message) (Message, error)
|
||||
Reply(ctx context.Context, ns MessageNamespace, h RequestHandler) error
|
||||
}
|
||||
|
||||
type RequestHandler func(msg Message) (Message, error)
|
9
pkg/bus/error.go
Normal file
9
pkg/bus/error.go
Normal file
@ -0,0 +1,9 @@
|
||||
package bus
|
||||
|
||||
import "github.com/pkg/errors"
|
||||
|
||||
var (
|
||||
ErrPublishTimeout = errors.New("publish timeout")
|
||||
ErrUnexpectedMessage = errors.New("unexpected message")
|
||||
ErrNoResponse = errors.New("no response")
|
||||
)
|
91
pkg/bus/memory/bus.go
Normal file
91
pkg/bus/memory/bus.go
Normal file
@ -0,0 +1,91 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
cmap "github.com/orcaman/concurrent-map"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Bus struct {
|
||||
opt *Option
|
||||
dispatchers cmap.ConcurrentMap
|
||||
nextRequestID uint64
|
||||
}
|
||||
|
||||
func (b *Bus) Subscribe(ctx context.Context, ns bus.MessageNamespace) (<-chan bus.Message, error) {
|
||||
logger.Debug(
|
||||
ctx, "subscribing to messages",
|
||||
logger.F("messageNamespace", ns),
|
||||
)
|
||||
|
||||
dispatchers := b.getDispatchers(ns)
|
||||
d := newEventDispatcher(b.opt.BufferSize)
|
||||
|
||||
go d.Run()
|
||||
|
||||
dispatchers.Add(d)
|
||||
|
||||
return d.Out(), nil
|
||||
}
|
||||
|
||||
func (b *Bus) Unsubscribe(ctx context.Context, ns bus.MessageNamespace, ch <-chan bus.Message) {
|
||||
logger.Debug(
|
||||
ctx, "unsubscribing from messages",
|
||||
logger.F("messageNamespace", ns),
|
||||
)
|
||||
|
||||
dispatchers := b.getDispatchers(ns)
|
||||
dispatchers.RemoveByOutChannel(ch)
|
||||
}
|
||||
|
||||
func (b *Bus) Publish(ctx context.Context, msg bus.Message) error {
|
||||
dispatchers := b.getDispatchers(msg.MessageNamespace())
|
||||
dispatchersList := dispatchers.List()
|
||||
|
||||
logger.Debug(
|
||||
ctx, "publishing message",
|
||||
logger.F("dispatchers", len(dispatchersList)),
|
||||
logger.F("messageNamespace", msg.MessageNamespace()),
|
||||
)
|
||||
|
||||
for _, d := range dispatchersList {
|
||||
if err := d.In(msg); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bus) getDispatchers(namespace bus.MessageNamespace) *eventDispatcherSet {
|
||||
strNamespace := string(namespace)
|
||||
|
||||
rawDispatchers, exists := b.dispatchers.Get(strNamespace)
|
||||
dispatchers, ok := rawDispatchers.(*eventDispatcherSet)
|
||||
|
||||
if !exists || !ok {
|
||||
dispatchers = newEventDispatcherSet()
|
||||
b.dispatchers.Set(strNamespace, dispatchers)
|
||||
}
|
||||
|
||||
return dispatchers
|
||||
}
|
||||
|
||||
func NewBus(funcs ...OptionFunc) *Bus {
|
||||
opt := DefaultOption()
|
||||
|
||||
for _, fn := range funcs {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
return &Bus{
|
||||
opt: opt,
|
||||
dispatchers: cmap.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Check bus implementation.
|
||||
var _ bus.Bus = NewBus()
|
29
pkg/bus/memory/bus_test.go
Normal file
29
pkg/bus/memory/bus_test.go
Normal file
@ -0,0 +1,29 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
busTesting "forge.cadoles.com/arcad/edge/pkg/bus/testing"
|
||||
)
|
||||
|
||||
func TestMemoryBus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Test disabled when -short flag is set")
|
||||
}
|
||||
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PublishSubscribe", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := NewBus()
|
||||
busTesting.TestPublishSubscribe(t, b)
|
||||
})
|
||||
|
||||
t.Run("RequestReply", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := NewBus()
|
||||
busTesting.TestRequestReply(t, b)
|
||||
})
|
||||
}
|
117
pkg/bus/memory/event_dispatcher.go
Normal file
117
pkg/bus/memory/event_dispatcher.go
Normal file
@ -0,0 +1,117 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type eventDispatcherSet struct {
|
||||
mutex sync.Mutex
|
||||
items map[*eventDispatcher]struct{}
|
||||
}
|
||||
|
||||
func (s *eventDispatcherSet) Add(d *eventDispatcher) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.items[d] = struct{}{}
|
||||
}
|
||||
|
||||
func (s *eventDispatcherSet) RemoveByOutChannel(out <-chan bus.Message) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
for d := range s.items {
|
||||
if d.IsOut(out) {
|
||||
d.Close()
|
||||
delete(s.items, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *eventDispatcherSet) List() []*eventDispatcher {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
dispatchers := make([]*eventDispatcher, 0, len(s.items))
|
||||
|
||||
for d := range s.items {
|
||||
dispatchers = append(dispatchers, d)
|
||||
}
|
||||
|
||||
return dispatchers
|
||||
}
|
||||
|
||||
func newEventDispatcherSet() *eventDispatcherSet {
|
||||
return &eventDispatcherSet{
|
||||
items: make(map[*eventDispatcher]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
type eventDispatcher struct {
|
||||
in chan bus.Message
|
||||
out chan bus.Message
|
||||
mutex sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (d *eventDispatcher) Close() {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
d.closed = true
|
||||
close(d.in)
|
||||
}
|
||||
|
||||
func (d *eventDispatcher) In(msg bus.Message) (err error) {
|
||||
d.mutex.RLock()
|
||||
defer d.mutex.RUnlock()
|
||||
|
||||
if d.closed {
|
||||
return
|
||||
}
|
||||
|
||||
d.in <- msg
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *eventDispatcher) Out() <-chan bus.Message {
|
||||
return d.out
|
||||
}
|
||||
|
||||
func (d *eventDispatcher) IsOut(out <-chan bus.Message) bool {
|
||||
return d.out == out
|
||||
}
|
||||
|
||||
func (d *eventDispatcher) Run() {
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
msg, ok := <-d.in
|
||||
if !ok {
|
||||
close(d.out)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timeout := time.After(2 * time.Second)
|
||||
select {
|
||||
case d.out <- msg:
|
||||
case <-timeout:
|
||||
logger.Error(ctx, "message out chan timed out", logger.F("message", msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newEventDispatcher(bufferSize int64) *eventDispatcher {
|
||||
return &eventDispatcher{
|
||||
in: make(chan bus.Message, bufferSize),
|
||||
out: make(chan bus.Message, bufferSize),
|
||||
closed: false,
|
||||
}
|
||||
}
|
19
pkg/bus/memory/option.go
Normal file
19
pkg/bus/memory/option.go
Normal file
@ -0,0 +1,19 @@
|
||||
package memory
|
||||
|
||||
type Option struct {
|
||||
BufferSize int64
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func DefaultOption() *Option {
|
||||
return &Option{
|
||||
BufferSize: 16, // nolint: gomnd
|
||||
}
|
||||
}
|
||||
|
||||
func WithBufferSize(size int64) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.BufferSize = size
|
||||
}
|
||||
}
|
151
pkg/bus/memory/request_reply.go
Normal file
151
pkg/bus/memory/request_reply.go
Normal file
@ -0,0 +1,151 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageNamespaceRequest bus.MessageNamespace = "reqrep/request"
|
||||
MessageNamespaceReply bus.MessageNamespace = "reqrep/reply"
|
||||
)
|
||||
|
||||
type RequestMessage struct {
|
||||
RequestID uint64
|
||||
|
||||
Message bus.Message
|
||||
|
||||
ns bus.MessageNamespace
|
||||
}
|
||||
|
||||
func (m *RequestMessage) MessageNamespace() bus.MessageNamespace {
|
||||
return m.ns
|
||||
}
|
||||
|
||||
type ReplyMessage struct {
|
||||
RequestID uint64
|
||||
Message bus.Message
|
||||
Error error
|
||||
|
||||
ns bus.MessageNamespace
|
||||
}
|
||||
|
||||
func (m *ReplyMessage) MessageNamespace() bus.MessageNamespace {
|
||||
return m.ns
|
||||
}
|
||||
|
||||
func (b *Bus) Request(ctx context.Context, msg bus.Message) (bus.Message, error) {
|
||||
requestID := atomic.AddUint64(&b.nextRequestID, 1)
|
||||
|
||||
req := &RequestMessage{
|
||||
RequestID: requestID,
|
||||
Message: msg,
|
||||
ns: msg.MessageNamespace(),
|
||||
}
|
||||
|
||||
replyNamespace := createReplyNamespace(requestID)
|
||||
|
||||
replies, err := b.Subscribe(ctx, replyNamespace)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
b.Unsubscribe(ctx, replyNamespace, replies)
|
||||
}()
|
||||
|
||||
logger.Debug(ctx, "publishing request", logger.F("request", req))
|
||||
|
||||
if err := b.Publish(ctx, req); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errors.WithStack(ctx.Err())
|
||||
|
||||
case msg, ok := <-replies:
|
||||
if !ok {
|
||||
return nil, errors.WithStack(bus.ErrNoResponse)
|
||||
}
|
||||
|
||||
reply, ok := msg.(*ReplyMessage)
|
||||
if !ok {
|
||||
return nil, errors.WithStack(bus.ErrUnexpectedMessage)
|
||||
}
|
||||
|
||||
if reply.Error != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return reply.Message, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RequestHandler func(evt bus.Message) (bus.Message, error)
|
||||
|
||||
func (b *Bus) Reply(ctx context.Context, msgNamespace bus.MessageNamespace, h bus.RequestHandler) error {
|
||||
requests, err := b.Subscribe(ctx, msgNamespace)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
b.Unsubscribe(ctx, msgNamespace, requests)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.WithStack(ctx.Err())
|
||||
|
||||
case msg, ok := <-requests:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
request, ok := msg.(*RequestMessage)
|
||||
if !ok {
|
||||
return errors.WithStack(bus.ErrUnexpectedMessage)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "handling request", logger.F("request", request))
|
||||
|
||||
msg, err := h(request.Message)
|
||||
|
||||
reply := &ReplyMessage{
|
||||
RequestID: request.RequestID,
|
||||
Message: nil,
|
||||
Error: nil,
|
||||
|
||||
ns: createReplyNamespace(request.RequestID),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
reply.Error = errors.WithStack(err)
|
||||
} else {
|
||||
reply.Message = msg
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "publishing reply", logger.F("reply", reply))
|
||||
|
||||
if err := b.Publish(ctx, reply); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createReplyNamespace(requestID uint64) bus.MessageNamespace {
|
||||
return bus.NewMessageNamespace(
|
||||
MessageNamespaceReply,
|
||||
bus.MessageNamespace(strconv.FormatUint(requestID, 10)),
|
||||
)
|
||||
}
|
33
pkg/bus/message.go
Normal file
33
pkg/bus/message.go
Normal file
@ -0,0 +1,33 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type (
|
||||
MessageNamespace string
|
||||
)
|
||||
|
||||
type Message interface {
|
||||
MessageNamespace() MessageNamespace
|
||||
}
|
||||
|
||||
func NewMessageNamespace(namespaces ...MessageNamespace) MessageNamespace {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, ns := range namespaces {
|
||||
if i != 0 {
|
||||
if _, err := sb.WriteString(":"); err != nil {
|
||||
panic(errors.Wrap(err, "could not build new message namespace"))
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := sb.WriteString(string(ns)); err != nil {
|
||||
panic(errors.Wrap(err, "could not build new message namespace"))
|
||||
}
|
||||
}
|
||||
|
||||
return MessageNamespace(sb.String())
|
||||
}
|
96
pkg/bus/testing/publish_subscribe.go
Normal file
96
pkg/bus/testing/publish_subscribe.go
Normal file
@ -0,0 +1,96 @@
|
||||
package testing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
testNamespace bus.MessageNamespace = "testNamespace"
|
||||
)
|
||||
|
||||
type testMessage struct{}
|
||||
|
||||
func (e *testMessage) MessageNamespace() bus.MessageNamespace {
|
||||
return testNamespace
|
||||
}
|
||||
|
||||
func TestPublishSubscribe(t *testing.T, b bus.Bus) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
t.Log("subscribe")
|
||||
|
||||
messages, err := b.Subscribe(ctx, testNamespace)
|
||||
if err != nil {
|
||||
t.Fatal(errors.WithStack(err))
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(5)
|
||||
|
||||
go func() {
|
||||
// 5 events should be received
|
||||
t.Log("publish 0")
|
||||
|
||||
if err := b.Publish(ctx, &testMessage{}); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
t.Log("publish 1")
|
||||
|
||||
if err := b.Publish(ctx, &testMessage{}); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
t.Log("publish 2")
|
||||
|
||||
if err := b.Publish(ctx, &testMessage{}); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
t.Log("publish 3")
|
||||
|
||||
if err := b.Publish(ctx, &testMessage{}); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
t.Log("publish 4")
|
||||
|
||||
if err := b.Publish(ctx, &testMessage{}); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
var count int32 = 0
|
||||
|
||||
go func() {
|
||||
t.Log("range for events")
|
||||
|
||||
for msg := range messages {
|
||||
t.Logf("received msg %d", atomic.LoadInt32(&count))
|
||||
atomic.AddInt32(&count, 1)
|
||||
|
||||
if e, g := testNamespace, msg.MessageNamespace(); e != g {
|
||||
t.Errorf("evt.MessageNamespace(): expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
b.Unsubscribe(ctx, testNamespace, messages)
|
||||
|
||||
if e, g := int32(5), count; e != g {
|
||||
t.Errorf("message received count: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
}
|
110
pkg/bus/testing/request_reply.go
Normal file
110
pkg/bus/testing/request_reply.go
Normal file
@ -0,0 +1,110 @@
|
||||
package testing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
testTypeReqRes bus.MessageNamespace = "testNamspaceReqRes"
|
||||
)
|
||||
|
||||
type testReqResMessage struct {
|
||||
i int
|
||||
}
|
||||
|
||||
func (m *testReqResMessage) MessageNamespace() bus.MessageNamespace {
|
||||
return testNamespace
|
||||
}
|
||||
|
||||
func TestRequestReply(t *testing.T, b bus.Bus) {
|
||||
expectedRoundTrips := 256
|
||||
timeout := time.Now().Add(time.Duration(expectedRoundTrips) * time.Second)
|
||||
|
||||
var (
|
||||
initWaitGroup sync.WaitGroup
|
||||
resWaitGroup sync.WaitGroup
|
||||
)
|
||||
|
||||
initWaitGroup.Add(1)
|
||||
|
||||
go func() {
|
||||
repondCtx, cancelRespond := context.WithDeadline(context.Background(), timeout)
|
||||
defer cancelRespond()
|
||||
|
||||
initWaitGroup.Done()
|
||||
|
||||
err := b.Reply(repondCtx, testNamespace, func(msg bus.Message) (bus.Message, error) {
|
||||
defer resWaitGroup.Done()
|
||||
|
||||
req, ok := msg.(*testReqResMessage)
|
||||
if !ok {
|
||||
return nil, errors.WithStack(bus.ErrUnexpectedMessage)
|
||||
}
|
||||
|
||||
result := &testReqResMessage{req.i}
|
||||
|
||||
// Simulate random work
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
t.Logf("[RES] sending res #%d", req.i)
|
||||
|
||||
return result, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
initWaitGroup.Wait()
|
||||
|
||||
var reqWaitGroup sync.WaitGroup
|
||||
|
||||
for i := 0; i < expectedRoundTrips; i++ {
|
||||
resWaitGroup.Add(1)
|
||||
reqWaitGroup.Add(1)
|
||||
|
||||
go func(i int) {
|
||||
defer reqWaitGroup.Done()
|
||||
|
||||
requestCtx, cancelRequest := context.WithDeadline(context.Background(), timeout)
|
||||
defer cancelRequest()
|
||||
|
||||
req := &testReqResMessage{i}
|
||||
|
||||
t.Logf("[REQ] sending req #%d", i)
|
||||
|
||||
result, err := b.Request(requestCtx, req)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
t.Logf("[REQ] received req #%d reply", i)
|
||||
|
||||
if result == nil {
|
||||
t.Error("result should not be nil")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res, ok := result.(*testReqResMessage)
|
||||
if !ok {
|
||||
t.Error(errors.WithStack(bus.ErrUnexpectedMessage))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if e, g := req.i, res.i; e != g {
|
||||
t.Errorf("res.i: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
reqWaitGroup.Wait()
|
||||
resWaitGroup.Wait()
|
||||
}
|
281
pkg/http/blob.go
Normal file
281
pkg/http/blob.go
Normal file
@ -0,0 +1,281 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
errorCodeForbidden = "forbidden"
|
||||
errorCodeInternalError = "internal-error"
|
||||
errorCodeBadRequest = "bad-request"
|
||||
errorCodeNotFound = "not-found"
|
||||
)
|
||||
|
||||
type uploadResponse struct {
|
||||
Bucket string `json:"bucket"`
|
||||
BlobID storage.BlobID `json:"blobId"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleAppUpload(w http.ResponseWriter, r *http.Request) {
|
||||
h.mutex.RLock()
|
||||
defer h.mutex.RUnlock()
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, h.uploadMaxFileSize)
|
||||
|
||||
if err := r.ParseMultipartForm(h.uploadMaxFileSize); err != nil {
|
||||
logger.Error(ctx, "could not parse multipart form", logger.E(errors.WithStack(err)))
|
||||
jsonError(w, http.StatusBadRequest, errorCodeBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, fileHeader, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not read form file", logger.E(errors.WithStack(err)))
|
||||
jsonError(w, http.StatusBadRequest, errorCodeBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var metadata map[string]any
|
||||
|
||||
rawMetadata := r.Form.Get("metadata")
|
||||
if rawMetadata != "" {
|
||||
if err := json.Unmarshal([]byte(rawMetadata), &metadata); err != nil {
|
||||
logger.Error(ctx, "could not parse metadata", logger.E(errors.WithStack(err)))
|
||||
jsonError(w, http.StatusBadRequest, errorCodeBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx = module.WithContext(ctx, map[module.ContextKey]any{
|
||||
module.ContextKeyOriginRequest: r,
|
||||
})
|
||||
|
||||
requestMsg := module.NewMessageUploadRequest(ctx, fileHeader, metadata)
|
||||
|
||||
reply, err := h.bus.Request(ctx, requestMsg)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not retrieve file", logger.E(errors.WithStack(err)))
|
||||
jsonError(w, http.StatusInternalServerError, errorCodeInternalError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "upload reply", logger.F("reply", reply))
|
||||
|
||||
responseMsg, ok := reply.(*module.MessageUploadResponse)
|
||||
if !ok {
|
||||
logger.Error(
|
||||
ctx, "unexpected upload response message",
|
||||
logger.F("message", reply),
|
||||
)
|
||||
jsonError(w, http.StatusInternalServerError, errorCodeInternalError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !responseMsg.Allow {
|
||||
jsonError(w, http.StatusForbidden, errorCodeForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
res := &uploadResponse{
|
||||
Bucket: responseMsg.Bucket,
|
||||
BlobID: responseMsg.BlobID,
|
||||
}
|
||||
|
||||
if err := encoder.Encode(res); err != nil {
|
||||
panic(errors.Wrap(err, "could not encode upload response"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleAppDownload(w http.ResponseWriter, r *http.Request) {
|
||||
h.mutex.RLock()
|
||||
defer h.mutex.RUnlock()
|
||||
|
||||
bucket := chi.URLParam(r, "bucket")
|
||||
blobID := chi.URLParam(r, "blobID")
|
||||
|
||||
ctx := logger.With(r.Context(), logger.F("blobID", blobID), logger.F("bucket", bucket))
|
||||
ctx = module.WithContext(ctx, map[module.ContextKey]any{
|
||||
module.ContextKeyOriginRequest: r,
|
||||
})
|
||||
|
||||
requestMsg := module.NewMessageDownloadRequest(ctx, bucket, storage.BlobID(blobID))
|
||||
|
||||
reply, err := h.bus.Request(ctx, requestMsg)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not retrieve file", logger.E(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
replyMsg, ok := reply.(*module.MessageDownloadResponse)
|
||||
if !ok {
|
||||
logger.Error(
|
||||
ctx, "unexpected download response message",
|
||||
logger.E(errors.WithStack(bus.ErrUnexpectedMessage)),
|
||||
logger.F("message", reply),
|
||||
)
|
||||
jsonError(w, http.StatusInternalServerError, errorCodeInternalError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !replyMsg.Allow {
|
||||
jsonError(w, http.StatusForbidden, errorCodeForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if replyMsg.Blob == nil {
|
||||
jsonError(w, http.StatusNotFound, errorCodeNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := replyMsg.Blob.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close blob", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
http.ServeContent(w, r, string(replyMsg.BlobInfo.ID()), replyMsg.BlobInfo.ModTime(), replyMsg.Blob)
|
||||
}
|
||||
|
||||
func serveFile(w http.ResponseWriter, r *http.Request, fs fs.FS, path string) {
|
||||
ctx := logger.With(r.Context(), logger.F("path", path))
|
||||
|
||||
file, err := fs.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "error while opening fs file", logger.E(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
logger.Error(ctx, "error while closing fs file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
logger.Error(ctx, "error while retrieving fs file stat", logger.E(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reader, ok := file.(io.ReadSeeker)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, path, info.ModTime(), reader)
|
||||
}
|
||||
|
||||
type jsonErrorResponse struct {
|
||||
Error jsonErr `json:"error"`
|
||||
}
|
||||
|
||||
type jsonErr struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, status int, code string) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
response := jsonErrorResponse{
|
||||
Error: jsonErr{
|
||||
Code: code,
|
||||
},
|
||||
}
|
||||
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
||||
type uploadedFile struct {
|
||||
multipart.File
|
||||
header *multipart.FileHeader
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// Stat implements fs.File
|
||||
func (f *uploadedFile) Stat() (fs.FileInfo, error) {
|
||||
return &uploadedFileInfo{
|
||||
header: f.header,
|
||||
modTime: f.modTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type uploadedFileInfo struct {
|
||||
header *multipart.FileHeader
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// IsDir implements fs.FileInfo
|
||||
func (i *uploadedFileInfo) IsDir() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ModTime implements fs.FileInfo
|
||||
func (i *uploadedFileInfo) ModTime() time.Time {
|
||||
return i.modTime
|
||||
}
|
||||
|
||||
// Mode implements fs.FileInfo
|
||||
func (i *uploadedFileInfo) Mode() fs.FileMode {
|
||||
return os.ModePerm
|
||||
}
|
||||
|
||||
// Name implements fs.FileInfo
|
||||
func (i *uploadedFileInfo) Name() string {
|
||||
return i.header.Filename
|
||||
}
|
||||
|
||||
// Size implements fs.FileInfo
|
||||
func (i *uploadedFileInfo) Size() int64 {
|
||||
return i.header.Size
|
||||
}
|
||||
|
||||
// Sys implements fs.FileInfo
|
||||
func (i *uploadedFileInfo) Sys() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ fs.File = &uploadedFile{}
|
||||
_ fs.FileInfo = &uploadedFileInfo{}
|
||||
)
|
22
pkg/http/client.go
Normal file
22
pkg/http/client.go
Normal file
@ -0,0 +1,22 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/sdk"
|
||||
)
|
||||
|
||||
func (h *Handler) handleSDKClient(w http.ResponseWriter, r *http.Request) {
|
||||
serveFile(w, r, &sdk.FS, "client/dist/client.js")
|
||||
}
|
||||
|
||||
func (h *Handler) handleSDKClientMap(w http.ResponseWriter, r *http.Request) {
|
||||
serveFile(w, r, &sdk.FS, "client/dist/client.js.map")
|
||||
}
|
||||
|
||||
func (h *Handler) handleAppFiles(w http.ResponseWriter, r *http.Request) {
|
||||
h.mutex.RLock()
|
||||
defer h.mutex.RUnlock()
|
||||
|
||||
h.public.ServeHTTP(w, r)
|
||||
}
|
114
pkg/http/handler.go
Normal file
114
pkg/http/handler.go
Normal file
@ -0,0 +1,114 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bundle"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/igm/sockjs-go/v3/sockjs"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
sockJSPathPrefix = "/edge/sock"
|
||||
serverMainScript = "server/main.js"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
bundle bundle.Bundle
|
||||
public http.Handler
|
||||
router chi.Router
|
||||
|
||||
sockjs http.Handler
|
||||
bus bus.Bus
|
||||
sockjsOpts sockjs.Options
|
||||
uploadMaxFileSize int64
|
||||
|
||||
server *app.Server
|
||||
serverModuleFactories []app.ServerModuleFactory
|
||||
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) Load(bdle bundle.Bundle) error {
|
||||
h.mutex.Lock()
|
||||
defer h.mutex.Unlock()
|
||||
|
||||
file, _, err := bdle.File(serverMainScript)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not open server main script")
|
||||
}
|
||||
|
||||
mainScript, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not read server main script")
|
||||
}
|
||||
|
||||
server := app.NewServer(h.serverModuleFactories...)
|
||||
|
||||
if err := server.Load(serverMainScript, string(mainScript)); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
fs := bundle.NewFileSystem("public", bdle)
|
||||
public := http.FileServer(fs)
|
||||
sockjs := sockjs.NewHandler(sockJSPathPrefix, h.sockjsOpts, h.handleSockJSSession)
|
||||
|
||||
if h.server != nil {
|
||||
h.server.Stop()
|
||||
}
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
h.bundle = bdle
|
||||
h.server = server
|
||||
h.public = public
|
||||
h.sockjs = sockjs
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewHandler(funcs ...HandlerOptionFunc) *Handler {
|
||||
opts := defaultHandlerOptions()
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
|
||||
handler := &Handler{
|
||||
uploadMaxFileSize: opts.UploadMaxFileSize,
|
||||
sockjsOpts: opts.SockJS,
|
||||
router: router,
|
||||
serverModuleFactories: opts.ServerModuleFactories,
|
||||
bus: opts.Bus,
|
||||
}
|
||||
|
||||
router.Route("/edge", func(r chi.Router) {
|
||||
r.Route("/sdk", func(r chi.Router) {
|
||||
r.Get("/client.js", handler.handleSDKClient)
|
||||
r.Get("/client.js.map", handler.handleSDKClientMap)
|
||||
})
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Post("/upload", handler.handleAppUpload)
|
||||
r.Get("/download/{bucket}/{blobID}", handler.handleAppDownload)
|
||||
})
|
||||
|
||||
r.HandleFunc("/sock/*", handler.handleSockJS)
|
||||
})
|
||||
|
||||
router.Get("/*", handler.handleAppFiles)
|
||||
|
||||
return handler
|
||||
}
|
57
pkg/http/options.go
Normal file
57
pkg/http/options.go
Normal file
@ -0,0 +1,57 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus/memory"
|
||||
"github.com/igm/sockjs-go/v3/sockjs"
|
||||
)
|
||||
|
||||
type HandlerOptions struct {
|
||||
Bus bus.Bus
|
||||
SockJS sockjs.Options
|
||||
ServerModuleFactories []app.ServerModuleFactory
|
||||
UploadMaxFileSize int64
|
||||
}
|
||||
|
||||
func defaultHandlerOptions() *HandlerOptions {
|
||||
sockjsOptions := func() sockjs.Options {
|
||||
return sockjs.DefaultOptions
|
||||
}()
|
||||
sockjsOptions.DisconnectDelay = 10 * time.Second
|
||||
|
||||
return &HandlerOptions{
|
||||
Bus: memory.NewBus(),
|
||||
SockJS: sockjsOptions,
|
||||
ServerModuleFactories: make([]app.ServerModuleFactory, 0),
|
||||
UploadMaxFileSize: 1024 * 10, // 10Mb
|
||||
}
|
||||
}
|
||||
|
||||
type HandlerOptionFunc func(*HandlerOptions)
|
||||
|
||||
func WithServerModules(factories ...app.ServerModuleFactory) HandlerOptionFunc {
|
||||
return func(opts *HandlerOptions) {
|
||||
opts.ServerModuleFactories = factories
|
||||
}
|
||||
}
|
||||
|
||||
func WithSockJS(options sockjs.Options) HandlerOptionFunc {
|
||||
return func(opts *HandlerOptions) {
|
||||
opts.SockJS = options
|
||||
}
|
||||
}
|
||||
|
||||
func WithBus(bus bus.Bus) HandlerOptionFunc {
|
||||
return func(opts *HandlerOptions) {
|
||||
opts.Bus = bus
|
||||
}
|
||||
}
|
||||
|
||||
func WithUploadMaxFileSize(size int64) HandlerOptionFunc {
|
||||
return func(opts *HandlerOptions) {
|
||||
opts.UploadMaxFileSize = size
|
||||
}
|
||||
}
|
233
pkg/http/sockjs.go
Normal file
233
pkg/http/sockjs.go
Normal file
@ -0,0 +1,233 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"github.com/igm/sockjs-go/v3/sockjs"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
statusChannelClosed = iota
|
||||
)
|
||||
|
||||
func (h *Handler) handleSockJS(w http.ResponseWriter, r *http.Request) {
|
||||
h.mutex.RLock()
|
||||
defer h.mutex.RUnlock()
|
||||
|
||||
h.sockjs.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSockJSSession(sess sockjs.Session) {
|
||||
ctx := logger.With(sess.Request().Context(),
|
||||
logger.F("sessionID", sess.ID()),
|
||||
)
|
||||
|
||||
logger.Debug(ctx, "new sockjs session")
|
||||
|
||||
defer func() {
|
||||
if sess.GetSessionState() == sockjs.SessionActive {
|
||||
if err := sess.Close(statusChannelClosed, "channel closed"); err != nil {
|
||||
logger.Error(ctx, "could not close sockjs session", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go h.handleServerMessages(ctx, sess)
|
||||
h.handleClientMessages(ctx, sess)
|
||||
}
|
||||
|
||||
func (h *Handler) handleServerMessages(ctx context.Context, sess sockjs.Session) {
|
||||
messages, err := h.bus.Subscribe(ctx, module.MessageNamespaceServer)
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Close messages subscriber
|
||||
h.bus.Unsubscribe(ctx, module.MessageNamespaceServer, messages)
|
||||
|
||||
logger.Debug(ctx, "unsubscribed")
|
||||
|
||||
if sess.GetSessionState() != sockjs.SessionActive {
|
||||
return
|
||||
}
|
||||
|
||||
if err := sess.Close(statusChannelClosed, "channel closed"); err != nil {
|
||||
logger.Error(ctx, "could not close sockjs session", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case msg := <-messages:
|
||||
serverMessage, ok := msg.(*module.ServerMessage)
|
||||
if !ok {
|
||||
logger.Error(
|
||||
ctx,
|
||||
"unexpected server message",
|
||||
logger.F("message", msg),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
sessionID := module.ContextValue[string](serverMessage.Context, module.ContextKeySessionID)
|
||||
|
||||
isDest := sessionID == "" || sessionID == sess.ID()
|
||||
if !isDest {
|
||||
continue
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(serverMessage.Data)
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
ctx,
|
||||
"could not encode message",
|
||||
logger.E(err),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
message := NewWebsocketMessage(
|
||||
WebsocketMessageTypeMessage,
|
||||
json.RawMessage(payload),
|
||||
)
|
||||
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
ctx,
|
||||
"could not encode message",
|
||||
logger.E(err),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "sending message")
|
||||
|
||||
// Send message
|
||||
if err := sess.Send(string(data)); err != nil {
|
||||
logger.Error(
|
||||
ctx,
|
||||
"could not send message",
|
||||
logger.E(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleClientMessages(ctx context.Context, sess sockjs.Session) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Debug(ctx, "context done")
|
||||
|
||||
return
|
||||
|
||||
default:
|
||||
logger.Debug(ctx, "waiting for websocket data")
|
||||
|
||||
data, err := sess.RecvCtx(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sockjs.ErrSessionNotOpen) {
|
||||
break
|
||||
}
|
||||
|
||||
logger.Error(
|
||||
ctx,
|
||||
"could not read message",
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "websocket data received", logger.F("data", data))
|
||||
|
||||
message := &WebsocketMessage{}
|
||||
if err := json.Unmarshal([]byte(data), message); err != nil {
|
||||
logger.Error(
|
||||
ctx,
|
||||
"could not decode message",
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
switch {
|
||||
|
||||
case message.Type == WebsocketMessageTypeMessage:
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(message.Payload, &payload); err != nil {
|
||||
logger.Error(
|
||||
ctx,
|
||||
"could not decode payload",
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx := logger.With(ctx, logger.F("payload", payload))
|
||||
ctx = module.WithContext(ctx, map[module.ContextKey]any{
|
||||
module.ContextKeySessionID: sess.ID(),
|
||||
module.ContextKeyOriginRequest: sess.Request(),
|
||||
})
|
||||
|
||||
clientMessage := module.NewClientMessage(ctx, payload)
|
||||
|
||||
logger.Debug(ctx, "publishing new client message", logger.F("message", clientMessage))
|
||||
|
||||
if err := h.bus.Publish(ctx, clientMessage); err != nil {
|
||||
logger.Error(ctx, "could not publish message",
|
||||
logger.E(errors.WithStack(err)),
|
||||
logger.F("message", clientMessage),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "new client message published", logger.F("message", clientMessage))
|
||||
|
||||
default:
|
||||
logger.Error(
|
||||
ctx,
|
||||
"unsupported message type",
|
||||
logger.F("messageType", message.Type),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
WebsocketMessageTypeMessage = "message"
|
||||
)
|
||||
|
||||
type WebsocketMessage struct {
|
||||
Type string `json:"t"`
|
||||
Payload json.RawMessage `json:"p"`
|
||||
}
|
||||
|
||||
type WebsocketMessagePayload struct {
|
||||
Data map[string]interface{} `json:"d"`
|
||||
}
|
||||
|
||||
func NewWebsocketMessage(dataType string, payload json.RawMessage) *WebsocketMessage {
|
||||
return &WebsocketMessage{
|
||||
Type: dataType,
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
1
pkg/module/.gitignore
vendored
Normal file
1
pkg/module/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.sqlite
|
28
pkg/module/assert.go
Normal file
28
pkg/module/assert.go
Normal file
@ -0,0 +1,28 @@
|
||||
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)
|
||||
}
|
109
pkg/module/authorization.go
Normal file
109
pkg/module/authorization.go
Normal file
@ -0,0 +1,109 @@
|
||||
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
|
||||
// }
|
||||
// }
|
103
pkg/module/authorization_test.go
Normal file
103
pkg/module/authorization_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
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)
|
||||
// }
|
||||
// }
|
282
pkg/module/blob.go
Normal file
282
pkg/module/blob.go
Normal file
@ -0,0 +1,282 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBlobBucket string = "default"
|
||||
)
|
||||
|
||||
type BlobModule struct {
|
||||
server *app.Server
|
||||
bus bus.Bus
|
||||
store storage.BlobStore
|
||||
}
|
||||
|
||||
func (m *BlobModule) Name() string {
|
||||
return "blob"
|
||||
}
|
||||
|
||||
func (m *BlobModule) Export(export *goja.Object) {
|
||||
}
|
||||
|
||||
func (m *BlobModule) handleMessages() {
|
||||
ctx := context.Background()
|
||||
|
||||
go func() {
|
||||
err := m.bus.Reply(ctx, MessageNamespaceUploadRequest, func(msg bus.Message) (bus.Message, error) {
|
||||
uploadRequest, ok := msg.(*MessageUploadRequest)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(bus.ErrUnexpectedMessage, "expected message upload request, got '%T'", msg)
|
||||
}
|
||||
|
||||
res, err := m.handleUploadRequest(uploadRequest)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not handle upload request", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "upload request response", logger.F("response", res))
|
||||
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
err := m.bus.Reply(ctx, MessageNamespaceDownloadRequest, func(msg bus.Message) (bus.Message, error) {
|
||||
downloadRequest, ok := msg.(*MessageDownloadRequest)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(bus.ErrUnexpectedMessage, "expected message download request, got '%T'", msg)
|
||||
}
|
||||
|
||||
res, err := m.handleDownloadRequest(downloadRequest)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not handle download request", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BlobModule) handleUploadRequest(req *MessageUploadRequest) (*MessageUploadResponse, error) {
|
||||
blobID := storage.NewBlobID()
|
||||
res := NewMessageUploadResponse(req.RequestID)
|
||||
|
||||
ctx := logger.With(req.Context, logger.F("blobID", blobID))
|
||||
|
||||
blobInfo := map[string]interface{}{
|
||||
"size": req.FileHeader.Size,
|
||||
"filename": req.FileHeader.Filename,
|
||||
"contentType": req.FileHeader.Header.Get("Content-Type"),
|
||||
}
|
||||
|
||||
rawResult, err := m.server.ExecFuncByName("onBlobUpload", ctx, blobID, blobInfo, req.Metadata)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrFuncDoesNotExist) {
|
||||
res.Allow = false
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
result, ok := rawResult.Export().(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.Errorf(
|
||||
"unexpected onBlobUpload result: expected 'map[string]interface{}', got '%T'",
|
||||
rawResult.Export(),
|
||||
)
|
||||
}
|
||||
|
||||
var allow bool
|
||||
|
||||
rawAllow, exists := result["allow"]
|
||||
if !exists {
|
||||
allow = false
|
||||
} else {
|
||||
allow, ok = rawAllow.(bool)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'allow' result property: got type '%T', expected type '%T'", rawAllow, false)
|
||||
}
|
||||
}
|
||||
|
||||
res.Allow = allow
|
||||
|
||||
if res.Allow {
|
||||
bucket := DefaultBlobBucket
|
||||
|
||||
rawBucket, exists := result["bucket"]
|
||||
if exists {
|
||||
bucket, ok = rawBucket.(string)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'bucket' result property: got type '%T', expected type '%T'", bucket, "")
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.saveBlob(ctx, bucket, blobID, *req.FileHeader); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
res.Bucket = bucket
|
||||
res.BlobID = blobID
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *BlobModule) saveBlob(ctx context.Context, bucketName string, blobID storage.BlobID, fileHeader multipart.FileHeader) error {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := bucket.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close bucket", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
writer, err := bucket.NewWriter(ctx, blobID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
if err := writer.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close writer", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(writer, file); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *BlobModule) handleDownloadRequest(req *MessageDownloadRequest) (*MessageDownloadResponse, error) {
|
||||
res := NewMessageDownloadResponse(req.RequestID)
|
||||
|
||||
rawResult, err := m.server.ExecFuncByName("onBlobDownload", req.Context, req.Bucket, req.BlobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrFuncDoesNotExist) {
|
||||
res.Allow = false
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
result, ok := rawResult.Export().(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.Errorf(
|
||||
"unexpected onBlobDownload result: expected 'map[string]interface{}', got '%T'",
|
||||
rawResult.Export(),
|
||||
)
|
||||
}
|
||||
|
||||
var allow bool
|
||||
|
||||
rawAllow, exists := result["allow"]
|
||||
if !exists {
|
||||
allow = false
|
||||
} else {
|
||||
allow, ok = rawAllow.(bool)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'allow' result property: got type '%T', expected type '%T'", rawAllow, false)
|
||||
}
|
||||
}
|
||||
|
||||
res.Allow = allow
|
||||
|
||||
reader, info, err := m.openBlob(req.Context, req.Bucket, req.BlobID)
|
||||
if err != nil && !errors.Is(err, storage.ErrBlobNotFound) {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if reader != nil {
|
||||
res.Blob = reader
|
||||
}
|
||||
|
||||
if info != nil {
|
||||
res.BlobInfo = info
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *BlobModule) openBlob(ctx context.Context, bucketName string, blobID storage.BlobID) (io.ReadSeekCloser, storage.BlobInfo, error) {
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := bucket.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close bucket", logger.E(errors.WithStack(err)), logger.F("bucket", bucket))
|
||||
}
|
||||
}()
|
||||
|
||||
info, err := bucket.Get(ctx, blobID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
reader, err := bucket.NewReader(ctx, blobID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return reader, info, nil
|
||||
}
|
||||
|
||||
func BlobModuleFactory(bus bus.Bus, store storage.BlobStore) app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
mod := &BlobModule{
|
||||
store: store,
|
||||
bus: bus,
|
||||
server: server,
|
||||
}
|
||||
|
||||
go mod.handleMessages()
|
||||
|
||||
return mod
|
||||
}
|
||||
}
|
92
pkg/module/blob_message.go
Normal file
92
pkg/module/blob_message.go
Normal file
@ -0,0 +1,92 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageNamespaceUploadRequest bus.MessageNamespace = "uploadRequest"
|
||||
MessageNamespaceUploadResponse bus.MessageNamespace = "uploadResponse"
|
||||
MessageNamespaceDownloadRequest bus.MessageNamespace = "downloadRequest"
|
||||
MessageNamespaceDownloadResponse bus.MessageNamespace = "downloadResponse"
|
||||
)
|
||||
|
||||
type MessageUploadRequest struct {
|
||||
Context context.Context
|
||||
RequestID string
|
||||
FileHeader *multipart.FileHeader
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *MessageUploadRequest) MessageNamespace() bus.MessageNamespace {
|
||||
return MessageNamespaceUploadRequest
|
||||
}
|
||||
|
||||
func NewMessageUploadRequest(ctx context.Context, fileHeader *multipart.FileHeader, metadata map[string]interface{}) *MessageUploadRequest {
|
||||
return &MessageUploadRequest{
|
||||
Context: ctx,
|
||||
RequestID: ulid.Make().String(),
|
||||
FileHeader: fileHeader,
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
type MessageUploadResponse struct {
|
||||
RequestID string
|
||||
BlobID storage.BlobID
|
||||
Bucket string
|
||||
Allow bool
|
||||
}
|
||||
|
||||
func (m *MessageUploadResponse) MessageNamespace() bus.MessageNamespace {
|
||||
return MessageNamespaceDownloadResponse
|
||||
}
|
||||
|
||||
func NewMessageUploadResponse(requestID string) *MessageUploadResponse {
|
||||
return &MessageUploadResponse{
|
||||
RequestID: requestID,
|
||||
}
|
||||
}
|
||||
|
||||
type MessageDownloadRequest struct {
|
||||
Context context.Context
|
||||
RequestID string
|
||||
Bucket string
|
||||
BlobID storage.BlobID
|
||||
}
|
||||
|
||||
func (m *MessageDownloadRequest) MessageNamespace() bus.MessageNamespace {
|
||||
return MessageNamespaceDownloadRequest
|
||||
}
|
||||
|
||||
func NewMessageDownloadRequest(ctx context.Context, bucket string, blobID storage.BlobID) *MessageDownloadRequest {
|
||||
return &MessageDownloadRequest{
|
||||
Context: ctx,
|
||||
RequestID: ulid.Make().String(),
|
||||
Bucket: bucket,
|
||||
BlobID: blobID,
|
||||
}
|
||||
}
|
||||
|
||||
type MessageDownloadResponse struct {
|
||||
RequestID string
|
||||
Allow bool
|
||||
BlobInfo storage.BlobInfo
|
||||
Blob io.ReadSeekCloser
|
||||
}
|
||||
|
||||
func (m *MessageDownloadResponse) MessageNamespace() bus.MessageNamespace {
|
||||
return MessageNamespaceDownloadResponse
|
||||
}
|
||||
|
||||
func NewMessageDownloadResponse(requestID string) *MessageDownloadResponse {
|
||||
return &MessageDownloadResponse{
|
||||
RequestID: requestID,
|
||||
}
|
||||
}
|
51
pkg/module/console.go
Normal file
51
pkg/module/console.go
Normal file
@ -0,0 +1,51 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ConsoleModule struct{}
|
||||
|
||||
func (m *ConsoleModule) Name() string {
|
||||
return "console"
|
||||
}
|
||||
|
||||
func (m *ConsoleModule) log(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
var sb strings.Builder
|
||||
|
||||
fields := make([]logger.Field, 0)
|
||||
|
||||
stack := rt.CaptureCallStack(0, nil)
|
||||
if len(stack) > 1 {
|
||||
fields = append(fields, logger.F("source", stack[1].Position().String()))
|
||||
}
|
||||
|
||||
for _, arg := range call.Arguments {
|
||||
sb.WriteString(fmt.Sprintf("%+v", arg.Export()))
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
|
||||
logger.Debug(context.Background(), sb.String(), fields...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ConsoleModule) Export(export *goja.Object) {
|
||||
if err := export.Set("log", m.log); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'log' function"))
|
||||
}
|
||||
}
|
||||
|
||||
func ConsoleModuleFactory() app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
return &ConsoleModule{}
|
||||
}
|
||||
}
|
94
pkg/module/context.go
Normal file
94
pkg/module/context.go
Normal file
@ -0,0 +1,94 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
ContextKeySessionID ContextKey = "sessionId"
|
||||
ContextKeyOriginRequest ContextKey = "originRequest"
|
||||
)
|
||||
|
||||
type ContextModule struct{}
|
||||
|
||||
func (m *ContextModule) Name() string {
|
||||
return "context"
|
||||
}
|
||||
|
||||
func (m *ContextModule) new(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
return rt.ToValue(context.Background())
|
||||
}
|
||||
|
||||
func (m *ContextModule) with(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := assertContext(call.Argument(0), rt)
|
||||
rawValues := assertObject(call.Argument(1), rt)
|
||||
|
||||
values := make(map[ContextKey]any)
|
||||
for k, v := range rawValues {
|
||||
values[ContextKey(k)] = v
|
||||
}
|
||||
|
||||
ctx = WithContext(ctx, values)
|
||||
|
||||
return rt.ToValue(ctx)
|
||||
}
|
||||
|
||||
func (m *ContextModule) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := assertContext(call.Argument(0), rt)
|
||||
rawKey := assertString(call.Argument(1), rt)
|
||||
|
||||
value := ctx.Value(ContextKey(rawKey))
|
||||
|
||||
return rt.ToValue(value)
|
||||
}
|
||||
|
||||
func (m *ContextModule) Export(export *goja.Object) {
|
||||
if err := export.Set("new", m.new); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'new' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("get", m.get); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'get' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("with", m.with); err != nil {
|
||||
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 {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
return &ContextModule{}
|
||||
}
|
||||
}
|
||||
|
||||
func ContextValue[T any](ctx context.Context, key ContextKey) T {
|
||||
value, ok := ctx.Value(key).(T)
|
||||
if !ok {
|
||||
return *new(T)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func WithContext(ctx context.Context, values map[ContextKey]any) context.Context {
|
||||
for k, v := range values {
|
||||
ctx = context.WithValue(ctx, k, v)
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
5
pkg/module/error.go
Normal file
5
pkg/module/error.go
Normal file
@ -0,0 +1,5 @@
|
||||
package module
|
||||
|
||||
import "github.com/pkg/errors"
|
||||
|
||||
var ErrUnexpectedArgumentsNumber = errors.New("unexpected number of arguments")
|
121
pkg/module/lifecycle.go
Normal file
121
pkg/module/lifecycle.go
Normal file
@ -0,0 +1,121 @@
|
||||
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"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type LifecycleModule struct {
|
||||
server *app.Server
|
||||
bus bus.Bus
|
||||
}
|
||||
|
||||
func (m *LifecycleModule) Name() string {
|
||||
return "lifecycle"
|
||||
}
|
||||
|
||||
func (m *LifecycleModule) Export(export *goja.Object) {
|
||||
}
|
||||
|
||||
func (m *LifecycleModule) OnInit() error {
|
||||
if _, err := m.server.ExecFuncByName("onInit"); err != nil {
|
||||
if errors.Is(err, app.ErrFuncDoesNotExist) {
|
||||
logger.Warn(context.Background(), "could not find onInit() function", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *LifecycleModule) handleMessages() {
|
||||
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 {
|
||||
module := &LifecycleModule{
|
||||
server: server,
|
||||
bus: bus,
|
||||
}
|
||||
|
||||
go module.handleMessages()
|
||||
|
||||
return module
|
||||
}
|
||||
}
|
||||
|
||||
var _ app.InitializableModule = &LifecycleModule{}
|
38
pkg/module/message.go
Normal file
38
pkg/module/message.go
Normal file
@ -0,0 +1,38 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageNamespaceClient bus.MessageNamespace = "client"
|
||||
MessageNamespaceServer bus.MessageNamespace = "server"
|
||||
)
|
||||
|
||||
type ServerMessage struct {
|
||||
Context context.Context
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
func (m *ServerMessage) MessageNamespace() bus.MessageNamespace {
|
||||
return MessageNamespaceServer
|
||||
}
|
||||
|
||||
func NewServerMessage(ctx context.Context, data interface{}) *ServerMessage {
|
||||
return &ServerMessage{ctx, data}
|
||||
}
|
||||
|
||||
type ClientMessage struct {
|
||||
Context context.Context
|
||||
Data map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *ClientMessage) MessageNamespace() bus.MessageNamespace {
|
||||
return MessageNamespaceClient
|
||||
}
|
||||
|
||||
func NewClientMessage(ctx context.Context, data map[string]interface{}) *ClientMessage {
|
||||
return &ClientMessage{ctx, data}
|
||||
}
|
81
pkg/module/net.go
Normal file
81
pkg/module/net.go
Normal file
@ -0,0 +1,81 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
263
pkg/module/rpc.go
Normal file
263
pkg/module/rpc.go
Normal file
@ -0,0 +1,263 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type RPCRequest struct {
|
||||
Method string
|
||||
Params interface{}
|
||||
ID interface{}
|
||||
}
|
||||
|
||||
type RPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type RPCResponse struct {
|
||||
Result interface{}
|
||||
Error *RPCError
|
||||
ID interface{}
|
||||
}
|
||||
|
||||
type RPCModule struct {
|
||||
server *app.Server
|
||||
bus bus.Bus
|
||||
callbacks sync.Map
|
||||
}
|
||||
|
||||
func (m *RPCModule) Name() string {
|
||||
return "rpc"
|
||||
}
|
||||
|
||||
func (m *RPCModule) Export(export *goja.Object) {
|
||||
if err := export.Set("register", m.register); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'register' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("unregister", m.unregister); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'unregister' function"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *RPCModule) register(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
fnName := assertString(call.Argument(0), rt)
|
||||
|
||||
var (
|
||||
callable goja.Callable
|
||||
ok bool
|
||||
)
|
||||
|
||||
if len(call.Arguments) > 1 {
|
||||
callable, ok = goja.AssertFunction(call.Argument(1))
|
||||
} else {
|
||||
callable, ok = goja.AssertFunction(rt.Get(fnName))
|
||||
}
|
||||
|
||||
if !ok {
|
||||
panic(rt.NewTypeError("method should be a valid function"))
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
logger.Debug(ctx, "registering method", logger.F("method", fnName))
|
||||
|
||||
m.callbacks.Store(fnName, callable)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *RPCModule) unregister(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
fnName := assertString(call.Argument(0), rt)
|
||||
|
||||
m.callbacks.Delete(fnName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *RPCModule) handleMessages() {
|
||||
ctx := context.Background()
|
||||
|
||||
clientMessages, err := m.bus.Subscribe(ctx, MessageNamespaceClient)
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
m.bus.Unsubscribe(ctx, MessageNamespaceClient, clientMessages)
|
||||
}()
|
||||
|
||||
sendRes := func(ctx context.Context, req *RPCRequest, result goja.Value) {
|
||||
res := &RPCResponse{
|
||||
ID: req.ID,
|
||||
Result: result.Export(),
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "sending rpc response", logger.F("response", res))
|
||||
|
||||
if err := m.sendResponse(ctx, res); err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not send response",
|
||||
logger.E(errors.WithStack(err)),
|
||||
logger.F("response", res),
|
||||
logger.F("request", req),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for msg := range clientMessages {
|
||||
clientMessage, ok := msg.(*ClientMessage)
|
||||
if !ok {
|
||||
logger.Warn(ctx, "unexpected bus message", logger.F("message", msg))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
ok, req := m.isRPCRequest(clientMessage)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "received rpc request", logger.F("request", req))
|
||||
|
||||
rawCallable, exists := m.callbacks.Load(req.Method)
|
||||
if !exists {
|
||||
logger.Debug(ctx, "method not found", logger.F("req", req))
|
||||
|
||||
if err := m.sendMethodNotFoundResponse(clientMessage.Context, req); err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not send method not found response",
|
||||
logger.E(errors.WithStack(err)),
|
||||
logger.F("request", req),
|
||||
)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
callable, ok := rawCallable.(goja.Callable)
|
||||
if !ok {
|
||||
logger.Debug(ctx, "invalid method", logger.F("req", req))
|
||||
|
||||
if err := m.sendMethodNotFoundResponse(clientMessage.Context, req); err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not send method not found response",
|
||||
logger.E(errors.WithStack(err)),
|
||||
logger.F("request", req),
|
||||
)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := m.server.Exec(callable, ctx, req.Params)
|
||||
if err != nil {
|
||||
if err := m.sendErrorResponse(clientMessage.Context, req, err); err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not send error response",
|
||||
logger.E(errors.WithStack(err)),
|
||||
logger.F("originalError", err),
|
||||
logger.F("request", req),
|
||||
)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
promise, ok := m.server.IsPromise(result)
|
||||
if ok {
|
||||
go func(ctx context.Context, req *RPCRequest, promise *goja.Promise) {
|
||||
result := m.server.WaitForPromise(promise)
|
||||
sendRes(ctx, req, result)
|
||||
}(clientMessage.Context, req, promise)
|
||||
} else {
|
||||
sendRes(clientMessage.Context, req, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *RPCModule) sendErrorResponse(ctx context.Context, req *RPCRequest, err error) error {
|
||||
return m.sendResponse(ctx, &RPCResponse{
|
||||
ID: req.ID,
|
||||
Result: nil,
|
||||
Error: &RPCError{
|
||||
Code: -32603,
|
||||
Message: err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *RPCModule) sendMethodNotFoundResponse(ctx context.Context, req *RPCRequest) error {
|
||||
return m.sendResponse(ctx, &RPCResponse{
|
||||
ID: req.ID,
|
||||
Result: nil,
|
||||
Error: &RPCError{
|
||||
Code: -32601,
|
||||
Message: fmt.Sprintf("method not found"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (m *RPCModule) sendResponse(ctx context.Context, res *RPCResponse) error {
|
||||
msg := NewServerMessage(ctx, map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": res.ID,
|
||||
"error": res.Error,
|
||||
"result": res.Result,
|
||||
})
|
||||
|
||||
if err := m.bus.Publish(ctx, msg); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *RPCModule) isRPCRequest(msg *ClientMessage) (bool, *RPCRequest) {
|
||||
jsonRPC, exists := msg.Data["jsonrpc"]
|
||||
if !exists || jsonRPC != "2.0" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
rawMethod, exists := msg.Data["method"]
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
method, ok := rawMethod.(string)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
id := msg.Data["id"]
|
||||
params := msg.Data["params"]
|
||||
|
||||
return true, &RPCRequest{
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
|
||||
func RPCModuleFactory(bus bus.Bus) app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
mod := &RPCModule{
|
||||
server: server,
|
||||
bus: bus,
|
||||
}
|
||||
|
||||
go mod.handleMessages()
|
||||
|
||||
return mod
|
||||
}
|
||||
}
|
205
pkg/module/store.go
Normal file
205
pkg/module/store.go
Normal file
@ -0,0 +1,205 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"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 StoreModule struct {
|
||||
server *app.Server
|
||||
store storage.DocumentStore
|
||||
}
|
||||
|
||||
func (m *StoreModule) Name() string {
|
||||
return "store"
|
||||
}
|
||||
|
||||
func (m *StoreModule) 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 *StoreModule) upsert(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := 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 *StoreModule) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := 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 *StoreModule) query(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := 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.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.Limit))
|
||||
}
|
||||
|
||||
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 *StoreModule) delete(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := 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 *StoreModule) 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 *StoreModule) assertFilter(value goja.Value, rt *goja.Runtime) *filter.Filter {
|
||||
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 *StoreModule) 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 *StoreModule) assertQueryOptions(value goja.Value, rt *goja.Runtime) *queryOptions {
|
||||
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 *StoreModule) 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 StoreModuleFactory(store storage.DocumentStore) app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
return &StoreModule{
|
||||
server: server,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
}
|
41
pkg/module/store_test.go
Normal file
41
pkg/module/store_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func TestStoreModule(t *testing.T) {
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
|
||||
store := sqlite.NewDocumentStore(":memory:")
|
||||
server := app.NewServer(
|
||||
ContextModuleFactory(),
|
||||
ConsoleModuleFactory(),
|
||||
StoreModuleFactory(store),
|
||||
)
|
||||
|
||||
data, err := ioutil.ReadFile("testdata/store.js")
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if err := server.Load("testdata/store.js", string(data)); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if _, err := server.ExecFuncByName("testStore"); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
server.Stop()
|
||||
}
|
32
pkg/module/testdata/store.js
vendored
Normal file
32
pkg/module/testdata/store.js
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
function testStore() {
|
||||
var ctx = context.new()
|
||||
|
||||
var obj = store.upsert(ctx, "test", {"foo": "bar"});
|
||||
var obj1 = store.get(ctx, "test", obj._id);
|
||||
|
||||
console.log(obj, obj1);
|
||||
|
||||
for (var key in obj) {
|
||||
if (!obj.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obj[key].toString() !== obj1[key].toString()) {
|
||||
throw new Error("obj['"+key+"'] !== obj1['"+key+"']");
|
||||
}
|
||||
}
|
||||
|
||||
var results = store.query(ctx, "test", { "eq": {"foo": "bar"} }, {"orderBy": "foo", "limit": 10, "skip": 0});
|
||||
|
||||
if (!results || results.length !== 1) {
|
||||
throw new Error("results should contains 1 item");
|
||||
}
|
||||
|
||||
store.delete(ctx, "test", obj._id);
|
||||
|
||||
var obj2 = store.get(ctx, "test", obj._id);
|
||||
|
||||
if (obj2 != null) {
|
||||
throw new Error("obj2 should be null");
|
||||
}
|
||||
}
|
59
pkg/module/user.go
Normal file
59
pkg/module/user.go
Normal file
@ -0,0 +1,59 @@
|
||||
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),
|
||||
// ),
|
||||
// }
|
||||
// }
|
||||
// }
|
70
pkg/module/user_test.go
Normal file
70
pkg/module/user_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
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")
|
||||
// }
|
255
pkg/sdk/client/src/client.ts
Normal file
255
pkg/sdk/client/src/client.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import { EventTarget } from "./event-target";
|
||||
import { messageFrom,Message, TypeMessage } from "./message";
|
||||
import { RPCError } from "./rpc-error";
|
||||
import SockJS from 'sockjs-client';
|
||||
|
||||
const EventTypeMessage = "message";
|
||||
|
||||
export class Client extends EventTarget {
|
||||
|
||||
_conn: any
|
||||
_rpcID: number
|
||||
_pendingRPC: {}
|
||||
_queue: Message[]
|
||||
_reconnectionDelay: number
|
||||
_autoReconnect: boolean
|
||||
debug: boolean
|
||||
|
||||
constructor(autoReconnect = true) {
|
||||
super();
|
||||
this._conn = null;
|
||||
this._onConnectionClose = this._onConnectionClose.bind(this);
|
||||
this._onConnectionMessage = this._onConnectionMessage.bind(this);
|
||||
this._handleRPCResponse = this._handleRPCResponse.bind(this);
|
||||
this._rpcID = 0;
|
||||
this._pendingRPC = {};
|
||||
this._queue = [];
|
||||
this._reconnectionDelay = 250;
|
||||
this._autoReconnect = autoReconnect;
|
||||
|
||||
this.debug = false;
|
||||
|
||||
this.connect = this.connect.bind(this);
|
||||
this.disconnect = this.disconnect.bind(this);
|
||||
this.rpc = this.rpc.bind(this);
|
||||
this.send = this.send.bind(this);
|
||||
this.upload = this.upload.bind(this);
|
||||
|
||||
this.addEventListener("message", this._handleRPCResponse);
|
||||
}
|
||||
|
||||
connect(token = "") {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `//${document.location.host}/edge/sock?token=${token}`;
|
||||
this._log("opening connection to", url);
|
||||
const conn: any = new SockJS(url);
|
||||
|
||||
const onOpen = () => {
|
||||
this._log('client connected');
|
||||
resetHandlers();
|
||||
conn.onclose = this._onConnectionClose;
|
||||
conn.onmessage = this._onConnectionMessage;
|
||||
this._conn = conn;
|
||||
this._sendQueued();
|
||||
setTimeout(() => {
|
||||
this._dispatchConnect();
|
||||
}, 0);
|
||||
return resolve(this);
|
||||
};
|
||||
|
||||
const onError = (evt) => {
|
||||
resetHandlers();
|
||||
this._scheduleReconnection();
|
||||
return reject(evt);
|
||||
};
|
||||
|
||||
const resetHandlers = () => {
|
||||
conn.removeEventListener('open', onOpen);
|
||||
conn.removeEventListener('close', onError);
|
||||
conn.removeEventListener('error', onError);
|
||||
};
|
||||
|
||||
conn.addEventListener('open', onOpen);
|
||||
conn.addEventListener('error', onError);
|
||||
conn.addEventListener('close', onError);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._cleanupConnection();
|
||||
}
|
||||
|
||||
_onConnectionMessage(evt) {
|
||||
const rawMessage = JSON.parse(evt.data);
|
||||
const message = messageFrom(rawMessage);
|
||||
const event = new CustomEvent(message.getType(), {
|
||||
cancelable: true,
|
||||
detail: message.getPayload()
|
||||
});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
_handleRPCResponse(evt) {
|
||||
console.log(evt);
|
||||
|
||||
const { jsonrpc, id, error, result } = evt.detail;
|
||||
|
||||
if (jsonrpc !== '2.0' || id === undefined) return;
|
||||
|
||||
// Prevent additional handlers to catch this event
|
||||
evt.stopImmediatePropagation();
|
||||
|
||||
const pending = this._pendingRPC[id];
|
||||
if (!pending) return;
|
||||
|
||||
delete this._pendingRPC[id];
|
||||
|
||||
if (error) {
|
||||
pending.reject(new RPCError(error.code, error.message, error.data));
|
||||
return;
|
||||
}
|
||||
|
||||
pending.resolve(result);
|
||||
}
|
||||
|
||||
_onConnectionClose(evt) {
|
||||
this._log('client disconnected');
|
||||
this._dispatchDisconnect();
|
||||
this._cleanupConnection();
|
||||
this._scheduleReconnection();
|
||||
}
|
||||
|
||||
_dispatchDisconnect() {
|
||||
const event = new CustomEvent('disconnect');
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
_dispatchConnect() {
|
||||
const event = new CustomEvent('connect');
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
_scheduleReconnection() {
|
||||
if (!this._autoReconnect) return;
|
||||
|
||||
this._reconnectionDelay = this._reconnectionDelay * 2 + Math.random();
|
||||
this._log('client will try to reconnect in %dms', this._reconnectionDelay);
|
||||
setTimeout(this.connect.bind(this), this._reconnectionDelay);
|
||||
}
|
||||
|
||||
_cleanupConnection() {
|
||||
if (!this._conn) return;
|
||||
this._conn.onopen = null;
|
||||
this._conn.onerror = null;
|
||||
this._conn.onclose = null;
|
||||
this._conn.onmessage = null;
|
||||
this._conn.close();
|
||||
this._conn = null;
|
||||
}
|
||||
|
||||
_send(message) {
|
||||
if (!this._conn) return false;
|
||||
this._log('sending message', message);
|
||||
this._conn.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
_sendQueued() {
|
||||
this._log("sending queued messages", this._queue.length);
|
||||
let msg = this._queue.shift();
|
||||
while (msg) {
|
||||
const sent = this._send(msg);
|
||||
if (!sent) return;
|
||||
msg = this._queue.shift();
|
||||
}
|
||||
}
|
||||
|
||||
_log(...args) {
|
||||
if (!this.debug) return;
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
_sendOrQueue(msg) {
|
||||
if (this.isConnected()) {
|
||||
this._sendQueued();
|
||||
this._send(msg);
|
||||
} else {
|
||||
this._log('queuing message', msg);
|
||||
this._queue.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
const msg = new Message("message", data);
|
||||
this._sendOrQueue(msg);
|
||||
}
|
||||
|
||||
rpc(method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this._rpcID++;
|
||||
const rpc = new Message(TypeMessage, {
|
||||
jsonrpc: '2.0',
|
||||
id, method, params
|
||||
});
|
||||
this._sendOrQueue(rpc);
|
||||
this._pendingRPC[id.toString()] = { resolve, reject };
|
||||
});
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this._conn !== null;
|
||||
}
|
||||
|
||||
upload(blob: string|Blob, metadata: any) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", blob);
|
||||
|
||||
if (metadata) {
|
||||
try {
|
||||
formData.set("metadata", JSON.stringify(metadata));
|
||||
} catch(err) {
|
||||
return reject(err);
|
||||
}
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
const result = {
|
||||
onProgress: null,
|
||||
abort: () => xhr.abort(),
|
||||
result: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
xhr.onload = () => {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch(err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(data);
|
||||
};
|
||||
xhr.onerror = reject;
|
||||
xhr.onabort = reject;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
xhr.upload.onprogress = evt => {
|
||||
if (typeof result.onProgress !== 'function') return;
|
||||
(result as any).onProgress(evt.loaded, evt.total);
|
||||
};
|
||||
xhr.onabort = reject;
|
||||
xhr.onerror = reject;
|
||||
|
||||
xhr.open('POST', `/edge/api/v1/upload`);
|
||||
xhr.send(formData);
|
||||
|
||||
resolve(result);
|
||||
});
|
||||
}
|
||||
|
||||
blobUrl(bucket: string, blobId: string) {
|
||||
return `/edge/api/v1/download/${bucket}/${blobId}`;
|
||||
}
|
||||
}
|
44
pkg/sdk/client/src/event-target.ts
Normal file
44
pkg/sdk/client/src/event-target.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export class EventTarget {
|
||||
listeners: {
|
||||
[type: string]: Function[]
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
addEventListener(type: string, callback: Function) {
|
||||
if (!(type in this.listeners)) {
|
||||
this.listeners[type] = [];
|
||||
}
|
||||
this.listeners[type].push(callback);
|
||||
};
|
||||
|
||||
removeEventListener(type: string, callback: Function) {
|
||||
if (!(type in this.listeners)) {
|
||||
return;
|
||||
}
|
||||
const stack = this.listeners[type];
|
||||
for (var i = 0, l = stack.length; i < l; i++) {
|
||||
if (stack[i] === callback){
|
||||
stack.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dispatchEvent(event: Event) {
|
||||
if (!(event.type in this.listeners)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const stack = this.listeners[event.type].slice();
|
||||
|
||||
for (let i = 0, l = stack.length; i < l; i++) {
|
||||
stack[i].call(this, event);
|
||||
if (event.cancelBubble) return;
|
||||
}
|
||||
return !event.defaultPrevented;
|
||||
};
|
||||
|
||||
}
|
3
pkg/sdk/client/src/index.ts
Normal file
3
pkg/sdk/client/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { Client } from './client.js';
|
||||
|
||||
export default new Client();
|
32
pkg/sdk/client/src/message.ts
Normal file
32
pkg/sdk/client/src/message.ts
Normal file
@ -0,0 +1,32 @@
|
||||
|
||||
export const TypeMessage = "message"
|
||||
|
||||
export class Message {
|
||||
_type: string
|
||||
_payload: any
|
||||
|
||||
constructor(type, payload) {
|
||||
this._type = type;
|
||||
this._payload = payload;
|
||||
}
|
||||
|
||||
getType() {
|
||||
return this._type;
|
||||
}
|
||||
|
||||
getPayload() {
|
||||
return this._payload;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
t: this._type,
|
||||
p: this._payload
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function messageFrom(raw) {
|
||||
return new Message(raw.t, raw.p);
|
||||
}
|
11
pkg/sdk/client/src/rpc-error.ts
Normal file
11
pkg/sdk/client/src/rpc-error.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export class RPCError extends Error {
|
||||
code: string
|
||||
data: any
|
||||
|
||||
constructor(code, message, data) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
if((Error as any).captureStackTrace) (Error as any).captureStackTrace(this, RPCError);
|
||||
}
|
||||
}
|
3
pkg/sdk/client/src/sock.ts
Normal file
3
pkg/sdk/client/src/sock.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import SockJS from 'sockjs-client';
|
||||
|
||||
window.SockJS = SockJS;
|
6
pkg/sdk/sdk.go
Normal file
6
pkg/sdk/sdk.go
Normal file
@ -0,0 +1,6 @@
|
||||
package sdk
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed client/dist/*.js client/dist/*.js.map
|
||||
var FS embed.FS
|
66
pkg/storage/blob_store.go
Normal file
66
pkg/storage/blob_store.go
Normal file
@ -0,0 +1,66 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBucketClosed = errors.New("bucket closed")
|
||||
ErrBlobNotFound = errors.New("blob not found")
|
||||
)
|
||||
|
||||
type BlobID string
|
||||
|
||||
func NewBlobID() BlobID {
|
||||
return BlobID(ulid.Make().String())
|
||||
}
|
||||
|
||||
type BlobStore interface {
|
||||
OpenBucket(ctx context.Context, name string) (BlobBucket, error)
|
||||
ListBuckets(ctx context.Context) ([]string, error)
|
||||
DeleteBucket(ctx context.Context, name string) error
|
||||
}
|
||||
|
||||
type BlobBucket interface {
|
||||
Name() string
|
||||
Close() error
|
||||
|
||||
Get(ctx context.Context, id BlobID) (BlobInfo, error)
|
||||
Delete(ctx context.Context, id BlobID) error
|
||||
NewReader(ctx context.Context, id BlobID) (io.ReadSeekCloser, error)
|
||||
NewWriter(ctx context.Context, id BlobID) (io.WriteCloser, error)
|
||||
List(ctx context.Context) ([]BlobInfo, error)
|
||||
Size(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
type BlobInfo interface {
|
||||
ID() BlobID
|
||||
Bucket() string
|
||||
ModTime() time.Time
|
||||
Size() int64
|
||||
ContentType() string
|
||||
}
|
||||
|
||||
type BucketListOptions struct {
|
||||
Limit *int
|
||||
Offset *int
|
||||
}
|
||||
|
||||
type BucketListOptionsFunc func(o *BucketListOptions)
|
||||
|
||||
func WithBucketListLimit(limit int) BucketListOptionsFunc {
|
||||
return func(o *BucketListOptions) {
|
||||
o.Limit = &limit
|
||||
}
|
||||
}
|
||||
|
||||
func WithBucketListOffset(offset int) BucketListOptionsFunc {
|
||||
return func(o *BucketListOptions) {
|
||||
o.Offset = &offset
|
||||
}
|
||||
}
|
69
pkg/storage/document_store.go
Normal file
69
pkg/storage/document_store.go
Normal file
@ -0,0 +1,69 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
var ErrDocumentNotFound = errors.New("document not found")
|
||||
|
||||
type DocumentID string
|
||||
|
||||
const (
|
||||
DocumentAttrID = "_id"
|
||||
DocumentAttrCreatedAt = "_createdAt"
|
||||
DocumentAttrUpdatedAt = "_updatedAt"
|
||||
)
|
||||
|
||||
func NewDocumentID() DocumentID {
|
||||
return DocumentID(ulid.Make().String())
|
||||
}
|
||||
|
||||
type Document map[string]interface{}
|
||||
|
||||
func (d Document) ID() (DocumentID, bool) {
|
||||
rawID, exists := d[DocumentAttrID]
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
|
||||
id, ok := rawID.(string)
|
||||
if ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return DocumentID(id), true
|
||||
}
|
||||
|
||||
func (d Document) CreatedAt() (time.Time, bool) {
|
||||
return d.timeAttr(DocumentAttrCreatedAt)
|
||||
}
|
||||
|
||||
func (d Document) UpdatedAt() (time.Time, bool) {
|
||||
return d.timeAttr(DocumentAttrUpdatedAt)
|
||||
}
|
||||
|
||||
func (d Document) timeAttr(attr string) (time.Time, bool) {
|
||||
rawTime, exists := d[attr]
|
||||
if !exists {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
t, ok := rawTime.(time.Time)
|
||||
if ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return t, true
|
||||
}
|
||||
|
||||
type DocumentStore interface {
|
||||
Get(ctx context.Context, collection string, id DocumentID) (Document, error)
|
||||
Query(ctx context.Context, collection string, filter *filter.Filter, funcs ...QueryOptionFunc) ([]Document, error)
|
||||
Upsert(ctx context.Context, collection string, document Document) (Document, error)
|
||||
Delete(ctx context.Context, collection string, id DocumentID) error
|
||||
}
|
17
pkg/storage/filter/and.go
Normal file
17
pkg/storage/filter/and.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type AndOperator struct {
|
||||
children []Operator
|
||||
}
|
||||
|
||||
func (o *AndOperator) Token() Token {
|
||||
return TokenAnd
|
||||
}
|
||||
|
||||
func (o *AndOperator) Children() []Operator {
|
||||
return o.children
|
||||
}
|
||||
|
||||
func NewAndOperator(ops ...Operator) *AndOperator {
|
||||
return &AndOperator{ops}
|
||||
}
|
17
pkg/storage/filter/eq.go
Normal file
17
pkg/storage/filter/eq.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type EqOperator struct {
|
||||
fields map[string]interface{}
|
||||
}
|
||||
|
||||
func (o *EqOperator) Token() Token {
|
||||
return TokenEq
|
||||
}
|
||||
|
||||
func (o *EqOperator) Fields() map[string]interface{} {
|
||||
return o.fields
|
||||
}
|
||||
|
||||
func NewEqOperator(fields map[string]interface{}) *EqOperator {
|
||||
return &EqOperator{fields}
|
||||
}
|
13
pkg/storage/filter/error.go
Normal file
13
pkg/storage/filter/error.go
Normal file
@ -0,0 +1,13 @@
|
||||
package filter
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidFieldOperator = errors.New("invalid field operator")
|
||||
ErrInvalidAggregationOperator = errors.New("invalid aggregation operator")
|
||||
ErrInvalidFieldMap = errors.New("invalid field map")
|
||||
ErrUnknownOperator = errors.New("unknown operator")
|
||||
ErrUnexpectedOperator = errors.New("unexpected operator")
|
||||
ErrUnsupportedOperator = errors.New("unsupported operator")
|
||||
ErrInvalidRoot = errors.New("invalid root")
|
||||
)
|
136
pkg/storage/filter/filter.go
Normal file
136
pkg/storage/filter/filter.go
Normal file
@ -0,0 +1,136 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Filter struct {
|
||||
root Operator
|
||||
}
|
||||
|
||||
func (f *Filter) Root() Operator {
|
||||
return f.root
|
||||
}
|
||||
|
||||
func New(root Operator) *Filter {
|
||||
return &Filter{root}
|
||||
}
|
||||
|
||||
func NewFrom(raw map[string]interface{}) (*Filter, error) {
|
||||
if len(raw) != 1 {
|
||||
return nil, errors.WithStack(ErrInvalidRoot)
|
||||
}
|
||||
|
||||
op, err := toFieldOperator(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Filter{op}, nil
|
||||
}
|
||||
|
||||
func toFieldOperator(v interface{}) (Operator, error) {
|
||||
vv, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.WithStack(ErrInvalidFieldOperator)
|
||||
}
|
||||
|
||||
ops := make([]Operator, 0)
|
||||
|
||||
for rawToken, val := range vv {
|
||||
var (
|
||||
op Operator
|
||||
err error
|
||||
)
|
||||
|
||||
token := Token(rawToken)
|
||||
|
||||
switch {
|
||||
case isAggregatorToken(token):
|
||||
op, err = toAggregateOperator(token, val)
|
||||
|
||||
case isFieldToken(token):
|
||||
fields, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.WithStack(ErrInvalidFieldMap)
|
||||
}
|
||||
|
||||
switch token {
|
||||
case TokenEq:
|
||||
op = NewEqOperator(fields)
|
||||
case TokenNeq:
|
||||
op = NewNeqOperator(fields)
|
||||
case TokenGt:
|
||||
op = NewGtOperator(fields)
|
||||
case TokenGte:
|
||||
op = NewGteOperator(fields)
|
||||
case TokenLt:
|
||||
op = NewLtOperator(fields)
|
||||
case TokenLte:
|
||||
op = NewLteOperator(fields)
|
||||
case TokenIn:
|
||||
op = NewInOperator(fields)
|
||||
case TokenLike:
|
||||
op = NewLikeOperator(fields)
|
||||
default:
|
||||
return nil, errors.Wrapf(ErrUnknownOperator, "unknown operator field '%s'", token)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, errors.Wrapf(ErrUnknownOperator, "unknown operator field '%s'", token)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ops = append(ops, op)
|
||||
}
|
||||
|
||||
and := NewAndOperator(ops...)
|
||||
|
||||
return and, nil
|
||||
}
|
||||
|
||||
func toAggregateOperator(token Token, v interface{}) (Operator, error) {
|
||||
vv, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, errors.WithStack(ErrInvalidAggregationOperator)
|
||||
}
|
||||
|
||||
ops := make([]Operator, 0)
|
||||
|
||||
for _, c := range vv {
|
||||
op, err := toFieldOperator(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ops = append(ops, op)
|
||||
}
|
||||
|
||||
var aggregator Operator
|
||||
|
||||
switch token {
|
||||
case TokenAnd:
|
||||
aggregator = NewAndOperator(ops...)
|
||||
case TokenOr:
|
||||
aggregator = NewOrOperator(ops...)
|
||||
case TokenNot:
|
||||
aggregator = NewNotOperator(ops...)
|
||||
}
|
||||
|
||||
return aggregator, nil
|
||||
}
|
||||
|
||||
func isAggregatorToken(token Token) bool {
|
||||
return token == TokenAnd || token == TokenOr || token == TokenNot
|
||||
}
|
||||
|
||||
func isFieldToken(token Token) bool {
|
||||
return token == TokenEq ||
|
||||
token == TokenGt || token == TokenGte ||
|
||||
token == TokenLt || token == TokenLte ||
|
||||
token == TokenNeq || token == TokenIn ||
|
||||
token == TokenLike
|
||||
}
|
17
pkg/storage/filter/gt.go
Normal file
17
pkg/storage/filter/gt.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type GtOperator struct {
|
||||
fields map[string]interface{}
|
||||
}
|
||||
|
||||
func (o *GtOperator) Token() Token {
|
||||
return TokenGt
|
||||
}
|
||||
|
||||
func (o *GtOperator) Fields() map[string]interface{} {
|
||||
return o.fields
|
||||
}
|
||||
|
||||
func NewGtOperator(fields OperatorFields) *GtOperator {
|
||||
return &GtOperator{fields}
|
||||
}
|
17
pkg/storage/filter/gte.go
Normal file
17
pkg/storage/filter/gte.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type GteOperator struct {
|
||||
fields OperatorFields
|
||||
}
|
||||
|
||||
func (o *GteOperator) Token() Token {
|
||||
return TokenGte
|
||||
}
|
||||
|
||||
func (o *GteOperator) Fields() map[string]interface{} {
|
||||
return o.fields
|
||||
}
|
||||
|
||||
func NewGteOperator(fields OperatorFields) *GteOperator {
|
||||
return &GteOperator{fields}
|
||||
}
|
17
pkg/storage/filter/in.go
Normal file
17
pkg/storage/filter/in.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type InOperator struct {
|
||||
fields map[string]interface{}
|
||||
}
|
||||
|
||||
func (o *InOperator) Token() Token {
|
||||
return TokenIn
|
||||
}
|
||||
|
||||
func (o *InOperator) Fields() map[string]interface{} {
|
||||
return o.fields
|
||||
}
|
||||
|
||||
func NewInOperator(fields OperatorFields) *InOperator {
|
||||
return &InOperator{fields}
|
||||
}
|
17
pkg/storage/filter/like.go
Normal file
17
pkg/storage/filter/like.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type LikeOperator struct {
|
||||
fields map[string]interface{}
|
||||
}
|
||||
|
||||
func (o *LikeOperator) Token() Token {
|
||||
return TokenLike
|
||||
}
|
||||
|
||||
func (o *LikeOperator) Fields() map[string]interface{} {
|
||||
return o.fields
|
||||
}
|
||||
|
||||
func NewLikeOperator(fields OperatorFields) *LikeOperator {
|
||||
return &LikeOperator{fields}
|
||||
}
|
17
pkg/storage/filter/lt.go
Normal file
17
pkg/storage/filter/lt.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type LtOperator struct {
|
||||
fields map[string]interface{}
|
||||
}
|
||||
|
||||
func (o *LtOperator) Token() Token {
|
||||
return TokenLt
|
||||
}
|
||||
|
||||
func (o *LtOperator) Fields() map[string]interface{} {
|
||||
return o.fields
|
||||
}
|
||||
|
||||
func NewLtOperator(fields OperatorFields) *LtOperator {
|
||||
return &LtOperator{fields}
|
||||
}
|
17
pkg/storage/filter/lte.go
Normal file
17
pkg/storage/filter/lte.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type LteOperator struct {
|
||||
fields map[string]interface{}
|
||||
}
|
||||
|
||||
func (o *LteOperator) Token() Token {
|
||||
return TokenLte
|
||||
}
|
||||
|
||||
func (o *LteOperator) Fields() map[string]interface{} {
|
||||
return o.fields
|
||||
}
|
||||
|
||||
func NewLteOperator(fields OperatorFields) *LteOperator {
|
||||
return &LteOperator{fields}
|
||||
}
|
17
pkg/storage/filter/neq.go
Normal file
17
pkg/storage/filter/neq.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type NeqOperator struct {
|
||||
fields map[string]interface{}
|
||||
}
|
||||
|
||||
func (o *NeqOperator) Token() Token {
|
||||
return TokenNeq
|
||||
}
|
||||
|
||||
func (o *NeqOperator) Fields() map[string]interface{} {
|
||||
return o.fields
|
||||
}
|
||||
|
||||
func NewNeqOperator(fields map[string]interface{}) *NeqOperator {
|
||||
return &NeqOperator{fields}
|
||||
}
|
17
pkg/storage/filter/not.go
Normal file
17
pkg/storage/filter/not.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type NotOperator struct {
|
||||
children []Operator
|
||||
}
|
||||
|
||||
func (o *NotOperator) Token() Token {
|
||||
return TokenOr
|
||||
}
|
||||
|
||||
func (o *NotOperator) Children() []Operator {
|
||||
return o.children
|
||||
}
|
||||
|
||||
func NewNotOperator(ops ...Operator) *NotOperator {
|
||||
return &NotOperator{ops}
|
||||
}
|
23
pkg/storage/filter/operator.go
Normal file
23
pkg/storage/filter/operator.go
Normal file
@ -0,0 +1,23 @@
|
||||
package filter
|
||||
|
||||
type Token string
|
||||
|
||||
const (
|
||||
TokenAnd Token = "and"
|
||||
TokenOr Token = "or"
|
||||
TokenNot Token = "not"
|
||||
TokenEq Token = "eq"
|
||||
TokenNeq Token = "neq"
|
||||
TokenGt Token = "gt"
|
||||
TokenGte Token = "gte"
|
||||
TokenLt Token = "lt"
|
||||
TokenLte Token = "lte"
|
||||
TokenIn Token = "in"
|
||||
TokenLike Token = "like"
|
||||
)
|
||||
|
||||
type OperatorFields map[string]interface{}
|
||||
|
||||
type Operator interface {
|
||||
Token() Token
|
||||
}
|
17
pkg/storage/filter/or.go
Normal file
17
pkg/storage/filter/or.go
Normal file
@ -0,0 +1,17 @@
|
||||
package filter
|
||||
|
||||
type OrOperator struct {
|
||||
children []Operator
|
||||
}
|
||||
|
||||
func (o *OrOperator) Token() Token {
|
||||
return TokenOr
|
||||
}
|
||||
|
||||
func (o *OrOperator) Children() []Operator {
|
||||
return o.children
|
||||
}
|
||||
|
||||
func NewOrOperator(ops ...Operator) *OrOperator {
|
||||
return &OrOperator{ops}
|
||||
}
|
87
pkg/storage/filter/sql/helper.go
Normal file
87
pkg/storage/filter/sql/helper.go
Normal file
@ -0,0 +1,87 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func aggregatorToSQL(operator string, opt *Option, children ...filter.Operator) (string, []interface{}, error) {
|
||||
args := make([]interface{}, 0)
|
||||
|
||||
if len(children) == 0 {
|
||||
return "", args, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
if _, err := sb.WriteString("("); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
for i, c := range children {
|
||||
if i != 0 {
|
||||
if _, err := sb.WriteString(" " + operator + " "); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
cSQL, cArgs, err := toSQL(c, opt)
|
||||
if err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
args = append(args, cArgs...)
|
||||
|
||||
if _, err := sb.WriteString(cSQL); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := sb.WriteString(")"); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
result := sb.String()
|
||||
if result == "()" {
|
||||
return "", args, nil
|
||||
}
|
||||
|
||||
return result, args, nil
|
||||
}
|
||||
|
||||
func fieldsToSQL(operator string, invert bool, fields map[string]interface{}, option *Option) (string, []interface{}, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
args := make([]interface{}, 0)
|
||||
|
||||
i := 0
|
||||
for k, v := range fields {
|
||||
if i != 0 {
|
||||
if _, err := sb.WriteString(" AND "); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
tr string
|
||||
err error
|
||||
)
|
||||
|
||||
tr, v, err = option.Transform(operator, invert, k, v, option)
|
||||
if err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if _, err := sb.WriteString(tr); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
args = append(args, option.ValueTransform(v))
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
return sb.String(), args, nil
|
||||
}
|
78
pkg/storage/filter/sql/option.go
Normal file
78
pkg/storage/filter/sql/option.go
Normal file
@ -0,0 +1,78 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type (
|
||||
PreparedParameterFunc func() string
|
||||
KeyTransformFunc func(key string) string
|
||||
ValueTransformFunc func(v interface{}) interface{}
|
||||
TransformFunc func(operator string, invert bool, key string, value interface{}, option *Option) (string, interface{}, error)
|
||||
)
|
||||
|
||||
type Option struct {
|
||||
PreparedParameter PreparedParameterFunc
|
||||
KeyTransform KeyTransformFunc
|
||||
ValueTransform ValueTransformFunc
|
||||
Transform TransformFunc
|
||||
}
|
||||
|
||||
func DefaultOption() *Option {
|
||||
opt := &Option{}
|
||||
defaults := []OptionFunc{
|
||||
WithPreparedParameter("$", 1),
|
||||
WithNoOpKeyTransform(),
|
||||
WithNoOpValueTransform(),
|
||||
WithDefaultTransform(),
|
||||
}
|
||||
|
||||
for _, fn := range defaults {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func WithPreparedParameter(prefix string, index int) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.PreparedParameter = func() string {
|
||||
param := prefix + strconv.FormatInt(int64(index), 10)
|
||||
index++
|
||||
|
||||
return param
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithKeyTransform(transform KeyTransformFunc) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.KeyTransform = transform
|
||||
}
|
||||
}
|
||||
|
||||
func WithNoOpKeyTransform() OptionFunc {
|
||||
return WithKeyTransform(func(key string) string {
|
||||
return key
|
||||
})
|
||||
}
|
||||
|
||||
func WithValueTransform(transform ValueTransformFunc) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.ValueTransform = transform
|
||||
}
|
||||
}
|
||||
|
||||
func WithDefaultTransform() OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.Transform = DefaultTransform
|
||||
}
|
||||
}
|
||||
|
||||
func WithNoOpValueTransform() OptionFunc {
|
||||
return WithValueTransform(func(value interface{}) interface{} {
|
||||
return value
|
||||
})
|
||||
}
|
159
pkg/storage/filter/sql/sql.go
Normal file
159
pkg/storage/filter/sql/sql.go
Normal file
@ -0,0 +1,159 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type transformFunc func(op filter.Operator, option *Option) (string, []interface{}, error)
|
||||
|
||||
var transforms map[filter.Token]transformFunc
|
||||
|
||||
func init() {
|
||||
// Initialise transforms map
|
||||
transforms = map[filter.Token]transformFunc{
|
||||
filter.TokenAnd: transformAndOperator,
|
||||
filter.TokenOr: transformOrOperator,
|
||||
filter.TokenNot: transformNotOperator,
|
||||
filter.TokenEq: transformEqOperator,
|
||||
filter.TokenNeq: transformNeqOperator,
|
||||
filter.TokenGt: transformGtOperator,
|
||||
filter.TokenGte: transformGteOperator,
|
||||
filter.TokenLte: transformLteOperator,
|
||||
filter.TokenLt: transformLtOperator,
|
||||
filter.TokenLike: transformLikeOperator,
|
||||
filter.TokenIn: transformInOperator,
|
||||
}
|
||||
}
|
||||
|
||||
func ToSQL(op filter.Operator, funcs ...OptionFunc) (string, []interface{}, error) {
|
||||
opt := DefaultOption()
|
||||
|
||||
for _, fn := range funcs {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
return toSQL(op, opt)
|
||||
}
|
||||
|
||||
func toSQL(op filter.Operator, opt *Option) (string, []interface{}, error) {
|
||||
if op == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
transform, exists := transforms[op.Token()]
|
||||
if !exists {
|
||||
return "", nil, errors.WithStack(filter.ErrUnsupportedOperator)
|
||||
}
|
||||
|
||||
sql, args, err := transform(op, opt)
|
||||
if err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return sql, args, nil
|
||||
}
|
||||
|
||||
func transformAndOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
andOp, ok := op.(*filter.AndOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenAnd, op.Token())
|
||||
}
|
||||
|
||||
return aggregatorToSQL("AND", option, andOp.Children()...)
|
||||
}
|
||||
|
||||
func transformOrOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
orOp, ok := op.(*filter.OrOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenOr, op.Token())
|
||||
}
|
||||
|
||||
return aggregatorToSQL("OR", option, orOp.Children()...)
|
||||
}
|
||||
|
||||
func transformEqOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
eqOp, ok := op.(*filter.EqOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenEq, op.Token())
|
||||
}
|
||||
|
||||
return fieldsToSQL("=", false, eqOp.Fields(), option)
|
||||
}
|
||||
|
||||
func transformNeqOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
eqOp, ok := op.(*filter.NeqOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenNeq, op.Token())
|
||||
}
|
||||
|
||||
return fieldsToSQL("!=", false, eqOp.Fields(), option)
|
||||
}
|
||||
|
||||
func transformGtOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
gtOp, ok := op.(*filter.GtOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenGt, op.Token())
|
||||
}
|
||||
|
||||
return fieldsToSQL(">", false, gtOp.Fields(), option)
|
||||
}
|
||||
|
||||
func transformGteOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
gteOp, ok := op.(*filter.GteOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenGte, op.Token())
|
||||
}
|
||||
|
||||
return fieldsToSQL(">=", false, gteOp.Fields(), option)
|
||||
}
|
||||
|
||||
func transformLtOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
ltOp, ok := op.(*filter.LtOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLt, op.Token())
|
||||
}
|
||||
|
||||
return fieldsToSQL("<", false, ltOp.Fields(), option)
|
||||
}
|
||||
|
||||
func transformLteOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
lteOp, ok := op.(*filter.LteOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLte, op.Token())
|
||||
}
|
||||
|
||||
return fieldsToSQL("<=", false, lteOp.Fields(), option)
|
||||
}
|
||||
|
||||
func transformInOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
inOp, ok := op.(*filter.InOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenIn, op.Token())
|
||||
}
|
||||
|
||||
return fieldsToSQL("IN", true, inOp.Fields(), option)
|
||||
}
|
||||
|
||||
func transformLikeOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
likeOp, ok := op.(*filter.LikeOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenLike, op.Token())
|
||||
}
|
||||
|
||||
return fieldsToSQL("LIKE", false, likeOp.Fields(), option)
|
||||
}
|
||||
|
||||
func transformNotOperator(op filter.Operator, option *Option) (string, []interface{}, error) {
|
||||
notOp, ok := op.(*filter.NotOperator)
|
||||
if !ok {
|
||||
return "", nil, errors.Wrapf(filter.ErrUnexpectedOperator, "expected '%s', got '%s'", filter.TokenNot, op.Token())
|
||||
}
|
||||
|
||||
sql, args, err := aggregatorToSQL("AND", option, notOp.Children()...)
|
||||
if err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return "NOT " + sql, args, nil
|
||||
}
|
84
pkg/storage/filter/sql/sql_test.go
Normal file
84
pkg/storage/filter/sql/sql_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
|
||||
)
|
||||
|
||||
type (
|
||||
op map[string]interface{}
|
||||
aggr []interface{}
|
||||
)
|
||||
|
||||
type testCase struct {
|
||||
RawFilter string
|
||||
ExpectedSQL string
|
||||
ExpectedArgs []interface{}
|
||||
}
|
||||
|
||||
var testCases = []testCase{
|
||||
{
|
||||
RawFilter: `
|
||||
{
|
||||
"or": [
|
||||
{"eq": {"foo": "bar"}},
|
||||
{"neq": {"hello": "world"}}
|
||||
]
|
||||
}
|
||||
`,
|
||||
ExpectedSQL: "(((foo = $1) OR (hello != $2)))",
|
||||
ExpectedArgs: []interface{}{"bar", "world"},
|
||||
},
|
||||
}
|
||||
|
||||
func TestSQLFilter(t *testing.T) {
|
||||
for i, tc := range testCases {
|
||||
func(tc testCase) {
|
||||
t.Run(fmt.Sprintf("Test case #%d", i), func(t *testing.T) {
|
||||
raw := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(tc.RawFilter), &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
query, err := filter.NewFrom(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sql, args, err := ToSQL(query.Root())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if e, g := tc.ExpectedSQL, sql; e != g {
|
||||
t.Errorf("sql: expected '%s', got '%s'", e, g)
|
||||
}
|
||||
|
||||
if args == nil {
|
||||
t.Fatal("args should not be nil")
|
||||
}
|
||||
|
||||
for i, a := range args {
|
||||
if i >= len(tc.ExpectedArgs) {
|
||||
t.Errorf("args[%d]: expected nil, got '%v'", i, a)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if e, g := tc.ExpectedArgs[i], a; e != g {
|
||||
t.Errorf("args[%d]: expected '%v', got '%v'", i, e, g)
|
||||
}
|
||||
}
|
||||
|
||||
for i, a := range tc.ExpectedArgs {
|
||||
if i >= len(args) {
|
||||
t.Errorf("args[%d]: expected '%v', got nil", i, a)
|
||||
}
|
||||
}
|
||||
})
|
||||
}(tc)
|
||||
}
|
||||
}
|
45
pkg/storage/filter/sql/transform.go
Normal file
45
pkg/storage/filter/sql/transform.go
Normal file
@ -0,0 +1,45 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func DefaultTransform(operator string, invert bool, key string, value interface{}, option *Option) (string, interface{}, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
if invert {
|
||||
if _, err := sb.WriteString(option.PreparedParameter()); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
} else {
|
||||
if _, err := sb.WriteString(option.KeyTransform(key)); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := sb.WriteString(" "); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if _, err := sb.WriteString(operator); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if invert {
|
||||
if _, err := sb.WriteString(" "); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if _, err := sb.WriteString(key); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
} else {
|
||||
if _, err := sb.WriteString(" " + option.PreparedParameter()); err != nil {
|
||||
return "", nil, errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String(), value, nil
|
||||
}
|
41
pkg/storage/query_option.go
Normal file
41
pkg/storage/query_option.go
Normal file
@ -0,0 +1,41 @@
|
||||
package storage
|
||||
|
||||
type OrderDirection string
|
||||
|
||||
const (
|
||||
OrderDirectionAsc OrderDirection = "ASC"
|
||||
OrderDirectionDesc OrderDirection = "DESC"
|
||||
)
|
||||
|
||||
type QueryOption struct {
|
||||
Limit *int
|
||||
Offset *int
|
||||
OrderBy *string
|
||||
OrderDirection *OrderDirection
|
||||
}
|
||||
|
||||
type QueryOptionFunc func(o *QueryOption)
|
||||
|
||||
func WithLimit(limit int) QueryOptionFunc {
|
||||
return func(o *QueryOption) {
|
||||
o.Limit = &limit
|
||||
}
|
||||
}
|
||||
|
||||
func WithOffset(offset int) QueryOptionFunc {
|
||||
return func(o *QueryOption) {
|
||||
o.Offset = &offset
|
||||
}
|
||||
}
|
||||
|
||||
func WithOrderBy(orderBy string) QueryOptionFunc {
|
||||
return func(o *QueryOption) {
|
||||
o.OrderBy = &orderBy
|
||||
}
|
||||
}
|
||||
|
||||
func WithOrderDirection(direction OrderDirection) QueryOptionFunc {
|
||||
return func(o *QueryOption) {
|
||||
o.OrderDirection = &direction
|
||||
}
|
||||
}
|
424
pkg/storage/sqlite/blob_bucket.go
Normal file
424
pkg/storage/sqlite/blob_bucket.go
Normal file
@ -0,0 +1,424 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type BlobBucket struct {
|
||||
name string
|
||||
getDB getDBFunc
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Size implements storage.BlobBucket
|
||||
func (b *BlobBucket) Size(ctx context.Context) (int64, error) {
|
||||
var size int64
|
||||
|
||||
err := b.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT SUM(size) FROM blobs WHERE bucket = $1`
|
||||
|
||||
row := tx.QueryRowContext(ctx, query, b.name)
|
||||
|
||||
var nullSize sql.NullInt64
|
||||
|
||||
if err := row.Scan(&nullSize); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
size = nullSize.Int64
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// Name implements storage.BlobBucket
|
||||
func (b *BlobBucket) Name() string {
|
||||
return b.name
|
||||
}
|
||||
|
||||
// Close implements storage.BlobBucket
|
||||
func (b *BlobBucket) Close() error {
|
||||
logger.Debug(
|
||||
context.Background(), "closing bucket",
|
||||
logger.F("alreadyClosed", b.closed),
|
||||
logger.F("name", b.name),
|
||||
)
|
||||
|
||||
b.closed = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete implements storage.BlobBucket
|
||||
func (b *BlobBucket) Delete(ctx context.Context, id storage.BlobID) error {
|
||||
err := b.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `DELETE FROM blobs WHERE bucket = $1 AND id = $2`
|
||||
|
||||
if _, err := tx.ExecContext(ctx, query, b.name, id); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get implements storage.BlobBucket
|
||||
func (b *BlobBucket) Get(ctx context.Context, id storage.BlobID) (storage.BlobInfo, error) {
|
||||
var blobInfo *BlobInfo
|
||||
|
||||
err := b.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT content_type, mod_time, size FROM blobs WHERE bucket = $1 AND id = $2`
|
||||
row := tx.QueryRowContext(ctx, query, b.name, id)
|
||||
|
||||
var (
|
||||
contentType string
|
||||
modTime time.Time
|
||||
size int64
|
||||
)
|
||||
|
||||
if err := row.Scan(&contentType, &modTime, &size); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.WithStack(storage.ErrBlobNotFound)
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
blobInfo = &BlobInfo{
|
||||
id: id,
|
||||
bucket: b.name,
|
||||
contentType: contentType,
|
||||
modTime: modTime,
|
||||
size: size,
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return blobInfo, nil
|
||||
}
|
||||
|
||||
// List implements storage.BlobBucket
|
||||
func (b *BlobBucket) List(ctx context.Context) ([]storage.BlobInfo, error) {
|
||||
var blobs []storage.BlobInfo
|
||||
|
||||
err := b.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT id, content_type, mod_time, size FROM blobs WHERE bucket = $1`
|
||||
|
||||
rows, err := tx.QueryContext(ctx, query, b.name)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
blobs = make([]storage.BlobInfo, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
blobID string
|
||||
contentType string
|
||||
modTime time.Time
|
||||
size int64
|
||||
)
|
||||
|
||||
if err := rows.Scan(&blobID, &contentType, &modTime, &size); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.WithStack(storage.ErrBlobNotFound)
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
blobInfo := &BlobInfo{
|
||||
id: storage.BlobID(blobID),
|
||||
bucket: b.name,
|
||||
contentType: contentType,
|
||||
modTime: modTime,
|
||||
size: size,
|
||||
}
|
||||
|
||||
blobs = append(blobs, blobInfo)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return blobs, nil
|
||||
}
|
||||
|
||||
// NewReader implements storage.BlobBucket
|
||||
func (b *BlobBucket) NewReader(ctx context.Context, id storage.BlobID) (io.ReadSeekCloser, error) {
|
||||
if b.closed {
|
||||
return nil, errors.WithStack(storage.ErrBucketClosed)
|
||||
}
|
||||
|
||||
return &blobReaderCloser{
|
||||
id: id,
|
||||
bucket: b.name,
|
||||
getDB: b.getDB,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWriter implements storage.BlobBucket
|
||||
func (b *BlobBucket) NewWriter(ctx context.Context, id storage.BlobID) (io.WriteCloser, error) {
|
||||
if b.closed {
|
||||
return nil, errors.WithStack(storage.ErrBucketClosed)
|
||||
}
|
||||
|
||||
return &blobWriterCloser{
|
||||
id: id,
|
||||
bucket: b.name,
|
||||
getDB: b.getDB,
|
||||
buf: bytes.Buffer{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *BlobBucket) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
||||
if b.closed {
|
||||
return errors.WithStack(storage.ErrBucketClosed)
|
||||
}
|
||||
|
||||
db, err := b.getDB(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := withTx(ctx, db, fn); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type blobWriterCloser struct {
|
||||
id storage.BlobID
|
||||
bucket string
|
||||
getDB getDBFunc
|
||||
buf bytes.Buffer
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Write implements io.WriteCloser
|
||||
func (wbc *blobWriterCloser) Write(p []byte) (int, error) {
|
||||
logger.Debug(context.Background(), "writing data to blob", logger.F("data", p))
|
||||
|
||||
n, err := wbc.buf.Write(p)
|
||||
if err != nil {
|
||||
return n, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Close implements io.WriteCloser
|
||||
func (wbc *blobWriterCloser) Close() error {
|
||||
ctx := context.Background()
|
||||
|
||||
logger.Debug(
|
||||
ctx, "closing writer",
|
||||
logger.F("alreadyClosed", wbc.closed),
|
||||
logger.F("bucket", wbc.bucket),
|
||||
logger.F("blobID", wbc.id),
|
||||
)
|
||||
|
||||
if wbc.closed {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := wbc.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `
|
||||
INSERT INTO blobs (bucket, id, data, content_type, mod_time, size)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id, bucket) DO UPDATE SET
|
||||
data = $3, content_type = $4, mod_time = $5, size = $6
|
||||
`
|
||||
|
||||
data := wbc.buf.Bytes()
|
||||
mime := mimetype.Detect(data)
|
||||
modTime := time.Now().UTC()
|
||||
|
||||
_, err := tx.Exec(
|
||||
query,
|
||||
wbc.bucket,
|
||||
wbc.id,
|
||||
data,
|
||||
mime.String(),
|
||||
modTime,
|
||||
len(data),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
wbc.closed = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wbc *blobWriterCloser) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
||||
if wbc.closed {
|
||||
return errors.WithStack(io.ErrClosedPipe)
|
||||
}
|
||||
|
||||
db, err := wbc.getDB(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := withTx(ctx, db, fn); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type blobReaderCloser struct {
|
||||
id storage.BlobID
|
||||
bucket string
|
||||
getDB getDBFunc
|
||||
reader bytes.Reader
|
||||
once sync.Once
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Read implements io.ReadSeekCloser
|
||||
func (brc *blobReaderCloser) Read(p []byte) (int, error) {
|
||||
var err error
|
||||
|
||||
brc.once.Do(func() {
|
||||
err = brc.loadBlob()
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
n, err := brc.reader.Read(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return n, io.EOF
|
||||
}
|
||||
|
||||
return n, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Seek implements io.ReadSeekCloser
|
||||
func (brc *blobReaderCloser) Seek(offset int64, whence int) (int64, error) {
|
||||
var err error
|
||||
|
||||
brc.once.Do(func() {
|
||||
err = brc.loadBlob()
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
n, err := brc.reader.Seek(offset, whence)
|
||||
if err != nil {
|
||||
return n, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (brc *blobReaderCloser) loadBlob() error {
|
||||
ctx := context.Background()
|
||||
logger.Debug(ctx, "loading blob", logger.F("alreadyClosed", brc.closed))
|
||||
|
||||
err := brc.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT data FROM blobs WHERE bucket = $1 AND id = $2`
|
||||
row := tx.QueryRow(query, brc.bucket, brc.id)
|
||||
|
||||
var data []byte
|
||||
|
||||
if err := row.Scan(&data); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.WithStack(storage.ErrBlobNotFound)
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
brc.reader = *bytes.NewReader(data)
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close implements io.ReadSeekCloser
|
||||
func (brc *blobReaderCloser) Close() error {
|
||||
logger.Debug(
|
||||
context.Background(), "closing reader",
|
||||
logger.F("alreadyClosed", brc.closed),
|
||||
logger.F("bucket", brc.bucket),
|
||||
logger.F("blobID", brc.id),
|
||||
)
|
||||
|
||||
brc.closed = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (brc *blobReaderCloser) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
||||
db, err := brc.getDB(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := withTx(ctx, db, fn); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ storage.BlobBucket = &BlobBucket{}
|
||||
_ storage.BlobInfo = &BlobInfo{}
|
||||
_ io.WriteCloser = &blobWriterCloser{}
|
||||
_ io.ReadSeekCloser = &blobReaderCloser{}
|
||||
)
|
40
pkg/storage/sqlite/blob_info.go
Normal file
40
pkg/storage/sqlite/blob_info.go
Normal file
@ -0,0 +1,40 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
)
|
||||
|
||||
type BlobInfo struct {
|
||||
id storage.BlobID
|
||||
bucket string
|
||||
contentType string
|
||||
modTime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// Bucket implements storage.BlobInfo
|
||||
func (i *BlobInfo) Bucket() string {
|
||||
return i.bucket
|
||||
}
|
||||
|
||||
// ID implements storage.BlobInfo
|
||||
func (i *BlobInfo) ID() storage.BlobID {
|
||||
return i.id
|
||||
}
|
||||
|
||||
// ContentType implements storage.BlobInfo
|
||||
func (i *BlobInfo) ContentType() string {
|
||||
return i.contentType
|
||||
}
|
||||
|
||||
// ModTime implements storage.BlobInfo
|
||||
func (i *BlobInfo) ModTime() time.Time {
|
||||
return i.modTime
|
||||
}
|
||||
|
||||
// Size implements storage.BlobInfo
|
||||
func (i *BlobInfo) Size() int64 {
|
||||
return i.size
|
||||
}
|
136
pkg/storage/sqlite/blob_store.go
Normal file
136
pkg/storage/sqlite/blob_store.go
Normal file
@ -0,0 +1,136 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type BlobStore struct {
|
||||
getDB getDBFunc
|
||||
}
|
||||
|
||||
// DeleteBucket implements storage.BlobStore
|
||||
func (s *BlobStore) DeleteBucket(ctx context.Context, name string) error {
|
||||
err := s.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `DELETE FROM blobs WHERE bucket = $1`
|
||||
_, err := tx.ExecContext(ctx, query, name)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBuckets implements storage.BlobStore
|
||||
func (s *BlobStore) ListBuckets(ctx context.Context) ([]string, error) {
|
||||
buckets := make([]string, 0)
|
||||
|
||||
err := s.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT DISTINCT name FROM blobs`
|
||||
rows, err := tx.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := rows.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
buckets = append(buckets, name)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
// OpenBucket implements storage.BlobStore
|
||||
func (s *BlobStore) OpenBucket(ctx context.Context, name string) (storage.BlobBucket, error) {
|
||||
return &BlobBucket{
|
||||
name: name,
|
||||
getDB: s.getDB,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureBlobTables(ctx context.Context, db *sql.DB) error {
|
||||
logger.Debug(ctx, "creating blobs table")
|
||||
|
||||
err := withTx(ctx, db, func(tx *sql.Tx) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
id TEXT,
|
||||
bucket TEXT,
|
||||
data BLOB,
|
||||
content_type TEXT NOT NULL,
|
||||
mod_time TIMESTAMP NOT NULL,
|
||||
size INTEGER,
|
||||
PRIMARY KEY (id, bucket)
|
||||
);
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BlobStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
||||
var db *sql.DB
|
||||
|
||||
db, err := s.getDB(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := withTx(ctx, db, fn); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewBlobStore(dsn string) *BlobStore {
|
||||
getDB := newGetDBFunc(dsn, ensureBlobTables)
|
||||
|
||||
return &BlobStore{getDB}
|
||||
}
|
||||
|
||||
func NewBlobStoreWithDB(db *sql.DB) *BlobStore {
|
||||
getDB := newGetDBFuncFromDB(db, ensureBlobTables)
|
||||
|
||||
return &BlobStore{getDB}
|
||||
}
|
||||
|
||||
var _ storage.BlobStore = &BlobStore{}
|
25
pkg/storage/sqlite/blob_store_test.go
Normal file
25
pkg/storage/sqlite/blob_store_test.go
Normal file
@ -0,0 +1,25 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/testsuite"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func TestBlobStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
|
||||
file := "./testdata/blobstore_test.sqlite"
|
||||
|
||||
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
store := NewBlobStore(file)
|
||||
|
||||
testsuite.TestBlobStore(t, store)
|
||||
}
|
340
pkg/storage/sqlite/document_store.go
Normal file
340
pkg/storage/sqlite/document_store.go
Normal file
@ -0,0 +1,340 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
|
||||
filterSQL "forge.cadoles.com/arcad/edge/pkg/storage/filter/sql"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DocumentStore struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
openOnce sync.Once
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// Delete implements storage.DocumentStore
|
||||
func (s *DocumentStore) Delete(ctx context.Context, collection string, id storage.DocumentID) error {
|
||||
err := s.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `
|
||||
DELETE FROM documents
|
||||
WHERE collection = $1 AND id = $2
|
||||
`
|
||||
|
||||
_, err := tx.ExecContext(ctx, query, collection, string(id))
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get implements storage.DocumentStore
|
||||
func (s *DocumentStore) Get(ctx context.Context, collection string, id storage.DocumentID) (storage.Document, error) {
|
||||
var document storage.Document
|
||||
|
||||
err := s.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `
|
||||
SELECT id, data, created_at, updated_at
|
||||
FROM documents
|
||||
WHERE collection = $1 AND id = $2
|
||||
`
|
||||
|
||||
row := tx.QueryRowContext(ctx, query, collection, string(id))
|
||||
|
||||
var (
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
data JSONMap
|
||||
)
|
||||
|
||||
err := row.Scan(&id, &data, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.WithStack(storage.ErrDocumentNotFound)
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
document = storage.Document(data)
|
||||
|
||||
document[storage.DocumentAttrID] = id
|
||||
document[storage.DocumentAttrCreatedAt] = createdAt
|
||||
document[storage.DocumentAttrUpdatedAt] = updatedAt
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
// Query implements storage.DocumentStore
|
||||
func (s *DocumentStore) Query(ctx context.Context, collection string, filter *filter.Filter, funcs ...storage.QueryOptionFunc) ([]storage.Document, error) {
|
||||
var documents []storage.Document
|
||||
|
||||
err := s.withTx(ctx, func(tx *sql.Tx) error {
|
||||
criteria, args, err := filterSQL.ToSQL(
|
||||
filter.Root(),
|
||||
filterSQL.WithPreparedParameter("$", 2),
|
||||
filterSQL.WithKeyTransform(func(key string) string {
|
||||
return fmt.Sprintf("json_extract(data, '$.%s')", key)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, data, created_at, updated_at
|
||||
FROM documents
|
||||
WHERE collection = $1 AND (` + criteria + `)
|
||||
`
|
||||
|
||||
args = append([]interface{}{collection}, args...)
|
||||
|
||||
logger.Debug(
|
||||
ctx, "executing query",
|
||||
logger.F("query", query),
|
||||
logger.F("args", args),
|
||||
)
|
||||
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
documents = make([]storage.Document, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id storage.DocumentID
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
data JSONMap
|
||||
)
|
||||
|
||||
if err := rows.Scan(&id, &data, &createdAt, &updatedAt); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
document := storage.Document(data)
|
||||
document[storage.DocumentAttrID] = id
|
||||
document[storage.DocumentAttrCreatedAt] = createdAt
|
||||
document[storage.DocumentAttrUpdatedAt] = updatedAt
|
||||
|
||||
documents = append(documents, document)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return documents, nil
|
||||
}
|
||||
|
||||
// Upsert implements storage.DocumentStore
|
||||
func (s *DocumentStore) Upsert(ctx context.Context, collection string, document storage.Document) (storage.Document, error) {
|
||||
var upsertedDocument storage.Document
|
||||
|
||||
err := s.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `
|
||||
INSERT INTO documents (id, collection, data, created_at, updated_at)
|
||||
VALUES($1, $2, $3, $4, $4)
|
||||
ON CONFLICT (id, collection) DO UPDATE SET
|
||||
data = $3, updated_at = $4
|
||||
RETURNING "id", "data", "created_at", "updated_at"
|
||||
`
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
id, exists := document.ID()
|
||||
if !exists || id == "" {
|
||||
id = storage.NewDocumentID()
|
||||
}
|
||||
|
||||
delete(document, storage.DocumentAttrID)
|
||||
delete(document, storage.DocumentAttrCreatedAt)
|
||||
delete(document, storage.DocumentAttrUpdatedAt)
|
||||
|
||||
args := []any{id, collection, JSONMap(document), now, now}
|
||||
|
||||
row := tx.QueryRowContext(ctx, query, args...)
|
||||
|
||||
var (
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
data JSONMap
|
||||
)
|
||||
|
||||
err := row.Scan(&id, &data, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
upsertedDocument = storage.Document(data)
|
||||
|
||||
upsertedDocument[storage.DocumentAttrID] = id
|
||||
upsertedDocument[storage.DocumentAttrCreatedAt] = createdAt
|
||||
upsertedDocument[storage.DocumentAttrUpdatedAt] = updatedAt
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return upsertedDocument, nil
|
||||
}
|
||||
|
||||
func (s *DocumentStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
||||
var db *sql.DB
|
||||
|
||||
db, err := s.getDatabase(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := withTx(ctx, db, fn); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentStore) getDatabase(ctx context.Context) (*sql.DB, error) {
|
||||
s.mutex.RLock()
|
||||
if s.db != nil {
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
var err error
|
||||
|
||||
s.openOnce.Do(func() {
|
||||
if err = s.ensureTables(ctx, s.db); err != nil {
|
||||
err = errors.WithStack(err)
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return s.db, nil
|
||||
}
|
||||
|
||||
s.mutex.RUnlock()
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
err error
|
||||
)
|
||||
|
||||
s.openOnce.Do(func() {
|
||||
db, err = sql.Open("sqlite", s.path)
|
||||
if err != nil {
|
||||
err = errors.WithStack(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.ensureTables(ctx, db); err != nil {
|
||||
err = errors.WithStack(err)
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if db != nil {
|
||||
s.mutex.Lock()
|
||||
s.db = db
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
return s.db, nil
|
||||
}
|
||||
|
||||
func (s *DocumentStore) ensureTables(ctx context.Context, db *sql.DB) error {
|
||||
err := withTx(ctx, db, func(tx *sql.Tx) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection TEXT NOT NULL,
|
||||
data TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(id, collection) ON CONFLICT REPLACE
|
||||
);
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
query = `
|
||||
CREATE INDEX IF NOT EXISTS collection_idx ON documents (collection);
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewDocumentStore(path string) *DocumentStore {
|
||||
return &DocumentStore{
|
||||
db: nil,
|
||||
path: path,
|
||||
openOnce: sync.Once{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentStoreWithDB(db *sql.DB) *DocumentStore {
|
||||
return &DocumentStore{
|
||||
db: db,
|
||||
path: "",
|
||||
openOnce: sync.Once{},
|
||||
}
|
||||
}
|
||||
|
||||
var _ storage.DocumentStore = &DocumentStore{}
|
25
pkg/storage/sqlite/document_store_test.go
Normal file
25
pkg/storage/sqlite/document_store_test.go
Normal file
@ -0,0 +1,25 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/testsuite"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func TestDocumentStore(t *testing.T) {
|
||||
// t.Parallel()
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
|
||||
file := "./testdata/documentstore_test.sqlite"
|
||||
|
||||
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
store := NewDocumentStore(file)
|
||||
|
||||
testsuite.TestDocumentStore(t, store)
|
||||
}
|
42
pkg/storage/sqlite/json.go
Normal file
42
pkg/storage/sqlite/json.go
Normal file
@ -0,0 +1,42 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type JSONMap map[string]any
|
||||
|
||||
func (j *JSONMap) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data []byte
|
||||
|
||||
switch typ := value.(type) {
|
||||
case []byte:
|
||||
data = typ
|
||||
case string:
|
||||
data = []byte(typ)
|
||||
default:
|
||||
return errors.Errorf("unexpected type '%T'", value)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &j); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j JSONMap) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(j)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
99
pkg/storage/sqlite/sql.go
Normal file
99
pkg/storage/sqlite/sql.go
Normal file
@ -0,0 +1,99 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
var tx *sql.Tx
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
if errors.Is(err, sql.ErrTxDone) {
|
||||
return
|
||||
}
|
||||
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
if err = fn(tx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type getDBFunc func(ctx context.Context) (*sql.DB, error)
|
||||
|
||||
func newGetDBFunc(dsn string, initFunc func(ctx context.Context, db *sql.DB) error) getDBFunc {
|
||||
var (
|
||||
db *sql.DB
|
||||
mutex sync.RWMutex
|
||||
)
|
||||
|
||||
return func(ctx context.Context) (*sql.DB, error) {
|
||||
mutex.RLock()
|
||||
if db != nil {
|
||||
defer mutex.RUnlock()
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
mutex.RUnlock()
|
||||
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
logger.Debug(ctx, "opening database", logger.F("dsn", dsn))
|
||||
|
||||
newDB, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "initializing database")
|
||||
|
||||
if err = initFunc(ctx, newDB); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
db = newDB
|
||||
|
||||
return db, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newGetDBFuncFromDB(db *sql.DB, initFunc func(ctx context.Context, db *sql.DB) error) getDBFunc {
|
||||
var err error
|
||||
|
||||
initOnce := &sync.Once{}
|
||||
|
||||
return func(ctx context.Context) (*sql.DB, error) {
|
||||
initOnce.Do(func() {
|
||||
logger.Debug(ctx, "initializing database")
|
||||
|
||||
err = initFunc(ctx, db)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
}
|
1
pkg/storage/sqlite/testdata/.gitignore
vendored
Normal file
1
pkg/storage/sqlite/testdata/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/*.sqlite
|
14
pkg/storage/testsuite/blob_store.go
Normal file
14
pkg/storage/testsuite/blob_store.go
Normal file
@ -0,0 +1,14 @@
|
||||
package testsuite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
)
|
||||
|
||||
func TestBlobStore(t *testing.T, store storage.BlobStore) {
|
||||
t.Run("Ops", func(t *testing.T) {
|
||||
// t.Parallel()
|
||||
testBlobStoreOps(t, store)
|
||||
})
|
||||
}
|
129
pkg/storage/testsuite/blob_store_ops.go
Normal file
129
pkg/storage/testsuite/blob_store_ops.go
Normal file
@ -0,0 +1,129 @@
|
||||
package testsuite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type blobStoreTestCase struct {
|
||||
Name string
|
||||
Run func(ctx context.Context, store storage.BlobStore) error
|
||||
}
|
||||
|
||||
var blobStoreTestCases = []blobStoreTestCase{
|
||||
{
|
||||
Name: "Open new bucket",
|
||||
Run: func(ctx context.Context, store storage.BlobStore) error {
|
||||
bucket, err := store.OpenBucket(ctx, "open-new-bucket")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if bucket == nil {
|
||||
return errors.New("bucket should not be nil")
|
||||
}
|
||||
|
||||
size, err := bucket.Size(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if e, g := int64(0), size; e != g {
|
||||
return errors.Errorf("bucket size: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
blobs, err := bucket.List(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if e, g := 0, len(blobs); e != g {
|
||||
return errors.Errorf("len(blobs): expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
if err := bucket.Close(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Create blob",
|
||||
Run: func(ctx context.Context, store storage.BlobStore) error {
|
||||
bucket, err := store.OpenBucket(ctx, "create-blob")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
blobID := storage.NewBlobID()
|
||||
|
||||
writer, err := bucket.NewWriter(ctx, blobID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
data := []byte("foo")
|
||||
|
||||
written, err := writer.Write(data)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if e, g := len(data), written; e != g {
|
||||
return errors.Errorf("length of written data: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
reader, err := bucket.NewReader(ctx, blobID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
written64, err := io.Copy(&buf, reader)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if e, g := int64(len(data)), written64; e != g {
|
||||
return errors.Errorf("length of written data: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
if err := reader.Close(); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
if err := bucket.Close(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func testBlobStoreOps(t *testing.T, store storage.BlobStore) {
|
||||
for _, tc := range blobStoreTestCases {
|
||||
func(tc blobStoreTestCase) {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if err := tc.Run(ctx, store); err != nil {
|
||||
t.Errorf("%+v", errors.WithStack(err))
|
||||
}
|
||||
})
|
||||
}(tc)
|
||||
}
|
||||
}
|
14
pkg/storage/testsuite/document_store.go
Normal file
14
pkg/storage/testsuite/document_store.go
Normal file
@ -0,0 +1,14 @@
|
||||
package testsuite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
)
|
||||
|
||||
func TestDocumentStore(t *testing.T, store storage.DocumentStore) {
|
||||
t.Run("Query", func(t *testing.T) {
|
||||
// t.Parallel()
|
||||
testDocumentStoreQuery(t, store)
|
||||
})
|
||||
}
|
85
pkg/storage/testsuite/document_store_query.go
Normal file
85
pkg/storage/testsuite/document_store_query.go
Normal file
@ -0,0 +1,85 @@
|
||||
package testsuite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/filter"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type documentStoreQueryTestCase struct {
|
||||
Name string
|
||||
Before func(ctx context.Context, store storage.DocumentStore) error
|
||||
Collection string
|
||||
Filter *filter.Filter
|
||||
QueryOptionsFuncs []storage.QueryOptionFunc
|
||||
After func(t *testing.T, results []storage.Document, err error)
|
||||
}
|
||||
|
||||
var documentStoreQueryTestCases = []documentStoreQueryTestCase{
|
||||
{
|
||||
Name: "Simple select",
|
||||
Before: func(ctx context.Context, store storage.DocumentStore) error {
|
||||
doc1 := storage.Document{
|
||||
"attr1": "Foo",
|
||||
}
|
||||
|
||||
if _, err := store.Upsert(ctx, "simple_select", doc1); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
doc2 := storage.Document{
|
||||
"attr1": "Bar",
|
||||
}
|
||||
|
||||
if _, err := store.Upsert(ctx, "simple_select", doc2); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Collection: "simple_select",
|
||||
Filter: filter.New(
|
||||
filter.NewEqOperator(map[string]interface{}{
|
||||
"attr1": "Foo",
|
||||
}),
|
||||
),
|
||||
After: func(t *testing.T, results []storage.Document, err error) {
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if e, g := 1, len(results); e != g {
|
||||
t.Errorf("len(results): expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
if e, g := "Foo", results[0]["attr1"]; e != g {
|
||||
t.Errorf("results[0][\"Attr1\"]: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func testDocumentStoreQuery(t *testing.T, store storage.DocumentStore) {
|
||||
for _, tc := range documentStoreQueryTestCases {
|
||||
func(tc documentStoreQueryTestCase) {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
// t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if tc.Before != nil {
|
||||
if err := tc.Before(ctx, store); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
||||
documents, err := store.Query(ctx, tc.Collection, tc.Filter, tc.QueryOptionsFuncs...)
|
||||
|
||||
tc.After(t, documents, err)
|
||||
})
|
||||
}(tc)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user