diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..41243b2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+test.db
+*.mercury
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..1e1cc2b
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,23 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Launch Package",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${fileDirname}",
+ "cwd": "${workspaceFolder}"
+ },
+ {
+ "name": "Attach to Process",
+ "type": "go",
+ "request": "attach",
+ "mode": "local",
+ "processId": 0
+ }
+ ]
+}
\ No newline at end of file
diff --git a/cmd/mercury/app.info.go b/cmd/mercury/app.info.go
new file mode 100644
index 0000000..2a13ed0
--- /dev/null
+++ b/cmd/mercury/app.info.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/service"
+)
+
+var _ = apps.Register(50, func(ctx context.Context, svc *service.Harness) error {
+ _, span := lg.Span(ctx)
+ defer span.End()
+
+ svc.Add(&info{})
+
+ return nil
+})
+
+type info struct{}
+
+func (info) RegisterHTTP(mux *http.ServeMux) {}
+func (info) RegisterAPIv1(mux *http.ServeMux) {
+ mux.HandleFunc("/app-info", func(w http.ResponseWriter, r *http.Request) {
+ _, version := service.AppName()
+ fmt.Fprint(w, "mercury", " :: ", version)
+ })
+}
diff --git a/cmd/mercury/app.mercury.go b/cmd/mercury/app.mercury.go
new file mode 100644
index 0000000..f025024
--- /dev/null
+++ b/cmd/mercury/app.mercury.go
@@ -0,0 +1,107 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ _ "github.com/lib/pq"
+ _ "modernc.org/sqlite"
+ "github.com/oklog/ulid/v2"
+
+ "go.sour.is/passwd"
+ "go.sour.is/passwd/pkg/argon2"
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/mercury/app"
+ "go.sour.is/pkg/mercury/http"
+ "go.sour.is/pkg/ident"
+ "go.sour.is/pkg/ident/source"
+ "go.sour.is/pkg/mercury/mqtt"
+ "go.sour.is/pkg/mercury/sql"
+ "go.sour.is/pkg/service"
+ "go.sour.is/pkg/xdg"
+
+ "go.sour.is/pkg/mercury"
+)
+
+var baseConfig = `
+# mercury.source [handler] [name]
+#@mercury.source.http-notify.default
+
+@mercury.source.http-notify.default
+match :1 d42.*
+endpoint :http://example.com/webhook
+
+@mercury.source.mqtt-notify.default
+#match :0 *
+#endpoint :mqtt://example.com/topic
+disabled :
+
+@mercury.source.mercury-default.default
+match :1 mercury.*
+
+@mercury.source.mercury-environ.environ
+match :1 mercury.*
+`
+
+var _ = apps.Register(50, func(ctx context.Context, svc *service.Harness) error {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ conf, err := readConfig(ctx)
+ if err != nil {
+ return err
+ }
+
+ app.Register("local", conf)
+ http.Register()
+ mqtt.Register()
+ sql.Register()
+
+ err = mercury.Registry.Configure(conf)
+ if err != nil {
+ return err
+ }
+
+ span.AddEvent("Enable Mercury")
+ idm := ident.NewIDM(passwd.New(argon2.Argon2id), ulid.DefaultEntropy())
+
+ idm.Add(1, source.NewMercury(mercury.Registry, idm))
+ svc.Add(mercury.NewHTTP(), ident.NewHTTP(idm))
+
+ return nil
+})
+
+func readConfig(ctx context.Context) (mercury.SpaceMap, error) {
+ _, span := lg.Span(ctx)
+ defer span.End()
+
+ conf, err := mercury.ParseText(strings.NewReader(baseConfig))
+ if err != nil {
+ return nil, err
+ }
+
+ wd, err := os.Getwd()
+ if err != err {
+ return nil, err
+ }
+
+ for _, fn := range xdg.Find(wd+":"+xdg.EnvConfigDirs, "config.mercury") {
+ span.AddEvent(fmt.Sprint("config:", fn))
+
+ fd, err := os.Open(fn)
+ if err != nil {
+ return nil, err
+ }
+ defer fd.Close()
+
+ c, err := mercury.ParseText(fd)
+ if err != nil {
+ return nil, err
+ }
+ conf.MergeMap(c)
+ }
+
+ return conf, nil
+}
diff --git a/cmd/mercury/main.go b/cmd/mercury/main.go
new file mode 100644
index 0000000..9092b25
--- /dev/null
+++ b/cmd/mercury/main.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "strconv"
+
+ "go.sour.is/pkg/env"
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/service"
+)
+
+var apps service.Apps
+var appName, version = service.AppName()
+
+func main() {
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
+ go func() {
+ <-ctx.Done()
+ defer cancel() // restore interrupt function
+ }()
+ if err := run(ctx); err != nil {
+ log.Fatal(err)
+ os.Exit(1)
+ }
+}
+func run(ctx context.Context) error {
+ level := slog.LevelError
+ if ok, _ := strconv.ParseBool(env.Default("LOG_DEBUG", "TRUE")); ok {
+ level = slog.LevelDebug
+ }
+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})))
+
+ svc := &service.Harness{}
+ os.Setenv("TRACE_ENDPOINT", "atombox:4318")
+ os.Setenv("EV_TRACE_SAMPLE", "always")
+ ctx, stop := lg.Init(ctx, appName+"-mercury")
+ svc.OnStop(stop)
+
+ svc.Add(lg.NewHTTP(ctx))
+ svc.Setup(ctx, apps.Apps()...)
+
+ // Run application
+ if err := svc.Run(ctx, appName+"-mercury", version); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ return nil
+}
diff --git a/cmd/mercury/svc.http.go b/cmd/mercury/svc.http.go
new file mode 100644
index 0000000..1f2a380
--- /dev/null
+++ b/cmd/mercury/svc.http.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/rs/cors"
+
+ "go.sour.is/pkg/env"
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/mux"
+ "go.sour.is/pkg/service"
+ "go.sour.is/pkg/slice"
+)
+
+var _ = apps.Register(20, func(ctx context.Context, svc *service.Harness) error {
+ s := &http.Server{}
+ svc.Add(s)
+
+ mux := mux.New()
+ s.Handler = cors.AllowAll().Handler(mux)
+
+ // s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // log.Println(r.URL.Path)
+ // mux.ServeHTTP(w, r)
+ // })
+
+ s.Addr = env.Default("EV_HTTP", ":4088")
+ if strings.HasPrefix(s.Addr, ":") {
+ s.Addr = "[::]" + s.Addr
+ }
+ svc.OnStart(func(ctx context.Context) error {
+ _, span := lg.Span(ctx)
+ defer span.End()
+
+ log.Print("Listen on ", s.Addr)
+ span.AddEvent("begin listen and serve on " + s.Addr)
+
+ mux.Add(slice.FilterType[interface{ RegisterHTTP(*http.ServeMux) }](svc.Services...)...)
+
+ return s.ListenAndServe()
+ })
+ svc.OnStop(s.Shutdown)
+
+ return nil
+})
diff --git a/cmd/testsql/main.go b/cmd/testsql/main.go
new file mode 100644
index 0000000..9b21798
--- /dev/null
+++ b/cmd/testsql/main.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "database/sql"
+ "log"
+
+ _ "modernc.org/sqlite"
+)
+
+func main() {
+ db, err := sql.Open("sqlite", "./test.db")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer db.Close()
+
+ _, err = db.Exec(`drop table if exists foo`)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ _, err = db.Exec(`create table foo (bar jsonb)`)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ _, err = db.Exec(`insert into foo (bar) values ('["one"]')`)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ rows, err := db.Query(`select j.value from foo, json_each(bar) j `)
+ if err != nil {
+ log.Fatal(err)
+ }
+ for rows.Next() {
+ var s string
+ err = rows.Scan(&s)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ log.Println("GOT: ", s)
+ }
+}
\ No newline at end of file
diff --git a/cron/cron.go b/cron/cron.go
index e912fc8..43414c4 100644
--- a/cron/cron.go
+++ b/cron/cron.go
@@ -127,9 +127,6 @@ func (c *cron) run(ctx context.Context, now time.Time) {
case <-timer.C:
}
- span.AddEvent("Cron Run: " + now.Format(time.RFC822))
- // fmt.Println("Cron Run: ", now.Format(time.RFC822))
-
c.state.Use(ctx, func(ctx context.Context, state *state) error {
run = append(run, state.queue...)
state.queue = state.queue[:0]
@@ -148,6 +145,9 @@ func (c *cron) run(ctx context.Context, now time.Time) {
return
}
+ span.AddEvent("Cron Run: " + now.Format(time.RFC822))
+ // fmt.Println("Cron Run: ", now.Format(time.RFC822))
+
wg, _ := errgroup.WithContext(ctx)
for i := range run {
diff --git a/env/env.go b/env/env.go
index 0f52570..460dc26 100644
--- a/env/env.go
+++ b/env/env.go
@@ -9,35 +9,16 @@ import (
"strings"
)
-func Default(name, defaultValue string) string {
+func Default(name, defaultValue string) (s string) {
name = strings.TrimSpace(name)
- defaultValue = strings.TrimSpace(defaultValue)
- if v := strings.TrimSpace(os.Getenv(name)); v != "" {
- slog.Info("env", name, v)
- return v
- }
- slog.Info("env", name, defaultValue+" (default)")
- return defaultValue
-}
+ s = strings.TrimSpace(defaultValue)
-type secret string
+ if v, ok := os.LookupEnv(name); ok {
+ s = strings.TrimSpace(v)
+ slog.Info("env", slog.String(name, v))
+ return
+ }
-func (s secret) String() string {
- if s == "" {
- return "(nil)"
- }
- return "***"
-}
-func (s secret) Secret() string {
- return string(s)
-}
-func Secret(name, defaultValue string) secret {
- name = strings.TrimSpace(name)
- defaultValue = strings.TrimSpace(defaultValue)
- if v := strings.TrimSpace(os.Getenv(name)); v != "" {
- slog.Info("env", name, secret(v))
- return secret(v)
- }
- slog.Info("env", name, secret(defaultValue).String()+" (default)")
- return secret(defaultValue)
+ slog.Info("env", slog.String(name, s+" (default)"))
+ return
}
diff --git a/env/secret.go b/env/secret.go
new file mode 100644
index 0000000..ac7bc98
--- /dev/null
+++ b/env/secret.go
@@ -0,0 +1,35 @@
+package env
+
+import (
+ "os"
+ "log/slog"
+ "strings"
+)
+
+type secret string
+
+func (s secret) String() string {
+ if s == "" {
+ return "(nil)"
+ }
+ return "***"
+}
+
+func (s secret) Secret() string {
+ return string(s)
+}
+
+func Secret(name, defaultValue string) (s secret) {
+ name = strings.TrimSpace(name)
+ s = secret(strings.TrimSpace(defaultValue))
+
+ if v, ok := os.LookupEnv(name); ok {
+ s = secret(strings.TrimSpace(v))
+ slog.Info("env", slog.String(name, s.String()))
+ return
+ }
+
+ slog.Info("env", slog.String(name, s.String()+" (default)"))
+ return
+}
+
diff --git a/go.mod b/go.mod
index 85d54e1..ae0e0d9 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module go.sour.is/pkg
-go 1.21
+go 1.22.0
require (
github.com/99designs/gqlgen v0.17.34
@@ -12,33 +12,60 @@ require (
go.opentelemetry.io/otel v1.18.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.18.0
go.opentelemetry.io/otel/sdk/metric v0.41.0
+ go.sour.is/passwd v0.2.0
go.uber.org/multierr v1.11.0
- golang.org/x/sync v0.3.0
+ golang.org/x/sync v0.6.0
)
require (
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
+ github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.3 // indirect
+ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
+ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
+ github.com/mattn/go-isatty v0.0.19 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.opentelemetry.io/contrib v1.16.1 // indirect
+ golang.org/x/crypto v0.18.0 // indirect
+ golang.org/x/mod v0.14.0 // indirect
+ golang.org/x/tools v0.17.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect
+ lukechampine.com/uint128 v1.2.0 // indirect
+ modernc.org/cc/v3 v3.40.0 // indirect
+ modernc.org/ccgo/v3 v3.16.13 // indirect
+ modernc.org/libc v1.29.0 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.7.2 // indirect
+ modernc.org/opt v0.1.3 // indirect
+ modernc.org/strutil v1.1.3 // indirect
+ modernc.org/token v1.0.1 // indirect
)
require (
+ github.com/BurntSushi/toml v1.3.2
+ github.com/Masterminds/squirrel v1.5.4
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/go-logr/logr v1.2.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f
github.com/golang/protobuf v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
+ github.com/lib/pq v1.10.9
+ github.com/oklog/ulid/v2 v2.1.0
github.com/prometheus/client_golang v1.17.0
+ github.com/rs/cors v1.10.1
+ go.nhat.io/otelsql v0.12.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.18.0 // indirect
@@ -47,9 +74,11 @@ require (
go.opentelemetry.io/otel/sdk v1.18.0
go.opentelemetry.io/otel/trace v1.18.0
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
- golang.org/x/net v0.17.0 // indirect
- golang.org/x/sys v0.13.0 // indirect
- golang.org/x/text v0.13.0 // indirect
- google.golang.org/grpc v1.58.0 // indirect
+ golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3
+ golang.org/x/net v0.20.0 // indirect
+ golang.org/x/sys v0.16.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+ google.golang.org/grpc v1.58.3 // indirect
google.golang.org/protobuf v1.31.0 // indirect
+ modernc.org/sqlite v1.28.0
)
diff --git a/go.sum b/go.sum
index 3609787..e1638a2 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,13 @@
+cloud.google.com/go v0.16.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/99designs/gqlgen v0.17.34 h1:5cS5/OKFguQt+Ws56uj9FlG2xm1IlcJWNF2jrMIKYFQ=
github.com/99designs/gqlgen v0.17.34/go.mod h1:Axcd3jIFHBVcqzixujJQr1wGqE+lGTpz6u4iZBZg1G8=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
+github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
+github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
+github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
@@ -8,6 +16,9 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
+github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
+github.com/bradfitz/gomemcache v0.0.0-20170208213004-1952afaa557d/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
@@ -17,37 +28,81 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fsnotify/fsnotify v1.4.3-0.20170329110642-4da3e2cfbabc/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/garyburd/redigo v1.1.1-0.20170914051019-70e1b1943d4f/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f h1:16RtHeWGkJMc80Etb8RPCcKevXGldr57+LOyZt8zOlg=
+github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f/go.mod h1:ijRvpgDJDI262hYq/IQVYgf8hd8IHUs93Ol0kvMBAx4=
github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE=
+github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ=
+github.com/golang/lint v0.0.0-20170918230701-e5d664eb928e/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.1.1-0.20171103154506-982329095285/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gregjones/httpcache v0.0.0-20170920190843-316c5e0ff04e/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/hashicorp/golang-lru/v2 v2.0.3 h1:kmRrRLlInXvng0SmLxmQpQkpbYAvcXm7NPDrgxJa9mE=
github.com/hashicorp/golang-lru/v2 v2.0.3/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hashicorp/hcl v0.0.0-20170914154624-68e816d1c783/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w=
+github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
+github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
+github.com/inconshreveable/log15 v0.0.0-20170622235902-74a0988b5f80/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/magiconair/properties v1.7.4-0.20170902060319-8d7837e64d3c/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.0.10-0.20170816031813-ad5389df28cd/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-isatty v0.0.2/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
+github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
+github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
+github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
+github.com/pelletier/go-toml v1.0.1-0.20170904195809-1d6b12b7cb29/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
@@ -60,13 +115,32 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/ravilushqa/otelgqlgen v0.13.1 h1:V+zFE75iDd2/CSzy5kKnb+Fi09SsE5535wv9U2nUEFE=
github.com/ravilushqa/otelgqlgen v0.13.1/go.mod h1:ZIyWykK2paCuNi9k8gk5edcNSwDJuxZaW90vZXpafxw=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
+github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/spf13/afero v0.0.0-20170901052352-ee1bd8ee15a1/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/cast v1.1.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
+github.com/spf13/jwalterweatherman v0.0.0-20170901151539-12bd96e66386/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
+github.com/spf13/pflag v1.0.1-0.20170901120850-7aff26db30c1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
+github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
github.com/vektah/gqlparser/v2 v2.5.6 h1:Ou14T0N1s191eRMZ1gARVqohcbe1e8FrcONScsq8cRU=
github.com/vektah/gqlparser/v2 v2.5.6/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME=
+github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
+github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
+github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
+github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
+go.nhat.io/otelsql v0.12.0 h1:/rBhWZiwHFLpCm5SGdafm+Owm0OmGmnF31XWxgecFtY=
+go.nhat.io/otelsql v0.12.0/go.mod h1:39Hc9/JDfCl7NGrBi1uPP3QPofqwnC/i5SFd7gtDMWM=
go.opentelemetry.io/contrib v1.16.1 h1:EpASvVyGx6/ZTlmXzxYfTMZxHROelCeXXa2uLiwltcs=
go.opentelemetry.io/contrib v1.16.1/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 h1:KfYpVmrjI7JuToy5k8XV3nkapjWx48k4E4JOtVstzQI=
@@ -81,6 +155,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.18.0 h1:6pu8t
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.18.0/go.mod h1:IOmXxPrxoxFMXdNy7lfDmE8MzE61YPcurbUm0SMjerI=
go.opentelemetry.io/otel/exporters/prometheus v0.41.0 h1:A3/bhjP5SmELy8dcpK+uttHeh9Qrh+YnS16/VzrztRQ=
go.opentelemetry.io/otel/exporters/prometheus v0.41.0/go.mod h1:mKuXEMi9suyyNJQ99SZCO0mpWGFe0MIALtjd3r6uo7Q=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.40.0 h1:hf7JSONqAuXT1PDYYlVhKNMPLe4060d+4RFREcv7X2c=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.40.0/go.mod h1:IxD5qbw/XcnFB7i5k4d7J1aW5iBU2h4DgSxtk4YqR4c=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.17.0 h1:Ut6hgtYcASHwCzRHkXEtSsM251cXJPW+Z9DyLwEn6iI=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.17.0/go.mod h1:TYeE+8d5CjrgBa0ZuRaDeMpIC1xZ7atg4g+nInjuSjc=
go.opentelemetry.io/otel/metric v1.18.0 h1:JwVzw94UYmbx3ej++CwLUQZxEODDj/pOuTCvzhtRrSQ=
go.opentelemetry.io/otel/metric v1.18.0/go.mod h1:nNSpsVDjWGfb7chbRLUNW+PBNdcSTHD4Uu5pfFMOI0k=
go.opentelemetry.io/otel/sdk v1.18.0 h1:e3bAB0wB3MljH38sHzpV/qWrOTCFrdZF2ct9F8rBkcY=
@@ -91,25 +169,50 @@ go.opentelemetry.io/otel/trace v1.18.0 h1:NY+czwbHbmndxojTEKiSMHkG2ClNH2PwmcHrdo
go.opentelemetry.io/otel/trace v1.18.0/go.mod h1:T2+SGJGuYZY3bjj5rgh/hN7KIrlpWC5nS8Mjvzckz+0=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
+go.sour.is/passwd v0.2.0 h1:eFLrvcayaS2e8ItjTU/tzBWtt1Am9xH97uBGqCCxdkk=
+go.sour.is/passwd v0.2.0/go.mod h1:xDqWTLiztFhr1KvUh//lvmJfMg+9piWt7K+d1JX3n0s=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
-golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
+golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
+golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
+golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
+golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
+golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
+golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
+golang.org/x/oauth2 v0.0.0-20170912212905-13449ad91cb2/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20170517211232-f52d1811a629/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
-golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
-golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/time v0.0.0-20170424234030-8be79e1e0910/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
+golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.0.0-20170921000349-586095a6e407/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20170918111702-1e559d0a00ee/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 h1:Z0hjGZePRE0ZBWotvtrwxFNrNE9CUAGtplaDK5NNI/g=
+google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98/go.mod h1:S7mY02OqCJTD0E1OiQy1F72PWFB4bZJ87cAtLPYgDR0=
google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 h1:FmF5cCW94Ij59cfpoLiwTgodWmm60eEV0CjlsVg2fuw=
google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM=
-google.golang.org/grpc v1.58.0 h1:32JY8YpPMSR45K+c3o6b8VL73V+rR8k+DeMIr4vRH8o=
-google.golang.org/grpc v1.58.0/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
+google.golang.org/grpc v1.2.1-0.20170921194603-d4b75ebd4f9f/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
+google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ=
+google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
@@ -120,3 +223,32 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
+lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
+modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
+modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
+modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
+modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
+modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
+modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
+modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
+modernc.org/libc v1.29.0 h1:tTFRFq69YKCF2QyGNuRUQxKBm1uZZLubf6Cjh/pVHXs=
+modernc.org/libc v1.29.0/go.mod h1:DaG/4Q3LRRdqpiLyP0C2m1B8ZMGkQ+cCgOIjEtQlYhQ=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
+modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
+modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
+modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
+modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
+modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
+modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
+modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
+modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
+modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
diff --git a/ident/ident.go b/ident/ident.go
new file mode 100644
index 0000000..7c85a56
--- /dev/null
+++ b/ident/ident.go
@@ -0,0 +1,115 @@
+package ident
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+
+ "github.com/oklog/ulid/v2"
+ "go.sour.is/passwd"
+)
+
+// Ident interface for a logged in user
+type Ident interface {
+ Identity() string
+ Session() *SessionInfo
+}
+
+type SessionInfo struct {
+ SessionID ulid.ULID
+ Active bool
+}
+
+func (s *SessionInfo) Session() *SessionInfo { return s }
+
+// Handler handler function to read ident from HTTP request
+type Handler interface {
+ ReadIdent(r *http.Request) (Ident, error)
+}
+type HandleGet interface {
+ GetIdent(context.Context /* identity */, string) (Ident, error)
+}
+type HandleRegister interface {
+ RegisterIdent(ctx context.Context, identity, displayName string, passwd []byte) (Ident, error)
+}
+
+type source struct {
+ Handler
+ priority int
+}
+
+var contextKey = struct{ key string }{"ident"}
+
+func FromContext(ctx context.Context) Ident {
+ if id, ok := ctx.Value(contextKey).(Ident); ok {
+ return id
+ }
+ return Anonymous
+}
+
+type IDM struct {
+ rand io.Reader
+ sources []source
+ pwd *passwd.Passwd
+}
+
+func NewIDM(pwd *passwd.Passwd, rand io.Reader) *IDM {
+ return &IDM{pwd: pwd, rand:rand}
+}
+
+func (idm *IDM) Add(p int, h Handler) {
+ idm.sources = append(idm.sources, source{priority: p, Handler: h})
+ sort.Slice(idm.sources, func(i, j int) bool { return idm.sources[i].priority < idm.sources[j].priority })
+}
+
+func (idm *IDM) Passwd(pass, hash []byte) ([]byte, error) {
+ return idm.pwd.Passwd(pass, hash)
+}
+
+// ReadIdent read ident from a list of ident handlers
+func (idm *IDM) ReadIdent(r *http.Request) (Ident, error) {
+ for _, source := range idm.sources {
+ u, err := source.ReadIdent(r)
+ if err != nil {
+ return Anonymous, err
+ }
+
+ if u.Session().Active {
+ return u, err
+ }
+ }
+
+ return Anonymous, nil
+}
+
+func (idm *IDM) RegisterIdent(ctx context.Context, identity, displayName string, passwd []byte) (Ident, error) {
+ for _, source := range idm.sources {
+ if source, ok := source.Handler.(HandleRegister); ok {
+ return source.RegisterIdent(ctx, identity, displayName, passwd)
+ }
+ }
+
+ return nil, fmt.Errorf("no HandleRegister source registered")
+}
+
+func (idm *IDM) GetIdent(ctx context.Context, identity string) (Ident, error) {
+ for _, source := range idm.sources {
+ if source, ok := source.Handler.(HandleGet); ok {
+ return source.GetIdent(ctx, identity)
+ }
+ }
+
+ return nil, fmt.Errorf("no HandleGet source registered")
+}
+
+func (idm *IDM) NewSessionInfo() (session SessionInfo, err error) {
+ session.SessionID, err = ulid.New(ulid.Now(), idm.rand)
+ if err != nil {
+ return
+ }
+ session.Active = true
+
+ return session, nil
+}
diff --git a/ident/null-user.go b/ident/null-user.go
new file mode 100644
index 0000000..f67a121
--- /dev/null
+++ b/ident/null-user.go
@@ -0,0 +1,75 @@
+package ident
+
+import "net/http"
+
+// nullUser implements a null ident
+type nullUser struct {
+ identity string
+ aspect string
+ displayName string
+ SessionInfo
+}
+
+// Anonymous is a logged out user
+var Anonymous = NewNullUser("anon", "none", "Guest User", false)
+
+// NewNullUser creates a null user ident
+func NewNullUser(ident, aspect, name string, active bool) *nullUser {
+ return &nullUser{ident, aspect, name, SessionInfo{Active: active}}
+}
+
+func (id nullUser) String() string {
+ return "id: " + id.identity + " dn: " + id.displayName
+}
+
+// GetIdentity returns identity
+func (m nullUser) Identity() string {
+ return m.identity
+}
+
+// GetAspect returns aspect
+func (m nullUser) Aspect() string {
+ return m.aspect
+}
+
+// HasRole returns true if matches role
+func (m nullUser) Role(r ...string) bool {
+ return m.Active
+}
+
+// HasGroup returns true if matches group
+func (m nullUser) Group(g ...string) bool {
+ return m.Active
+}
+
+// GetGroups returns empty list
+func (m nullUser) Groups() []string {
+ return []string{}
+}
+
+// GetRoles returns empty list
+func (m nullUser) Roles() []string {
+ return []string{}
+}
+
+// GetMeta returns empty list
+func (m nullUser) Meta() map[string]string {
+ return make(map[string]string)
+}
+
+// IsActive returns true if active
+func (m nullUser) IsActive() bool {
+ return m.Active
+}
+
+// GetDisplay returns display name
+func (m nullUser) Display() string {
+ return m.displayName
+}
+
+// MakeHandlerFunc returns handler func
+func (m nullUser) HandlerFunc() func(r *http.Request) Ident {
+ return func(r *http.Request) Ident {
+ return &m
+ }
+}
diff --git a/ident/routes.go b/ident/routes.go
new file mode 100644
index 0000000..cb3702b
--- /dev/null
+++ b/ident/routes.go
@@ -0,0 +1,360 @@
+package ident
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+
+ "go.sour.is/passwd"
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/locker"
+)
+
+var (
+ loginForm = func(nick string, valid bool) string {
+ indicator := ""
+ if !valid {
+ indicator = `class="invalid"`
+ }
+ if nick != "" {
+ nick = `value="` + nick + `"`
+ }
+ return `
+
`
+ }
+ logoutForm = func(display string) string {
+ return ``
+ }
+ registerForm = `
+ `
+)
+
+type sessions map[ulid.ULID]Ident
+
+type root struct {
+ idm *IDM
+ sessions *locker.Locked[sessions]
+}
+
+func NewHTTP(idm *IDM) *root {
+ sessions := make(sessions)
+ return &root{
+ idm: idm,
+ sessions: locker.New(sessions),
+ }
+}
+
+func (s *root) RegisterHTTP(mux *http.ServeMux) {
+ mux.HandleFunc("/ident", s.get)
+ mux.HandleFunc("/ident/register", s.register)
+ mux.HandleFunc("/ident/login", s.login)
+ mux.HandleFunc("/ident/logout", s.logout)
+}
+func (s *root) RegisterAPIv1(mux *http.ServeMux) {
+ mux.HandleFunc("POST /ident", s.registerV1)
+ mux.HandleFunc("POST /ident/session", s.loginV1)
+ mux.HandleFunc("DELETE /ident/session", s.logoutV1)
+ mux.HandleFunc("GET /ident", s.getV1)
+}
+func (s *root) RegisterMiddleware(hdlr http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ cookie, err := r.Cookie("sour.is-ident")
+ span.RecordError(err)
+ if err != nil {
+ hdlr.ServeHTTP(w, r)
+ return
+ }
+
+ sessionID, err := ulid.Parse(cookie.Value)
+ span.RecordError(err)
+
+ var id Ident = Anonymous
+ if err == nil {
+ err = s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error {
+ if session, ok := sessions[sessionID]; ok {
+ id = session
+ }
+ return nil
+ })
+ }
+ span.RecordError(err)
+
+ r = r.WithContext(context.WithValue(r.Context(), contextKey, id))
+
+ hdlr.ServeHTTP(w, r)
+ })
+}
+func (s *root) createSession(ctx context.Context, id Ident) error {
+ return s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error {
+ sessions[id.Session().SessionID] = id
+ return nil
+ })
+}
+func (s *root) destroySession(ctx context.Context, id Ident) error {
+ session := id.Session()
+ session.Active = false
+
+ return s.sessions.Use(ctx, func(ctx context.Context, sessions sessions) error {
+ delete(sessions, session.SessionID)
+ return nil
+ })
+}
+
+func (s *root) getV1(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ var id Ident = FromContext(ctx)
+ if id == nil {
+ http.Error(w, "NO_AUTH", http.StatusUnauthorized)
+ return
+ }
+ fmt.Fprint(w, id)
+}
+func (s *root) loginV1(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ id, err := s.idm.ReadIdent(r)
+ span.RecordError(err)
+ if err != nil {
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+ if !id.Session().Active {
+ http.Error(w, "NO_AUTH", http.StatusUnauthorized)
+ return
+ }
+
+ err = s.createSession(ctx, id)
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "sour.is-ident",
+ Value: id.Session().SessionID.String(),
+ Expires: time.Time{},
+ Path: "/",
+ Secure: false,
+ HttpOnly: true,
+ })
+
+ fmt.Fprint(w, id)
+}
+func (s *root) logoutV1(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "ERR", http.StatusMethodNotAllowed)
+ return
+ }
+
+ err := s.destroySession(ctx, FromContext(ctx))
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "NO_AUTH", http.StatusUnauthorized)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{Name: "sour.is-ident", MaxAge: -1})
+
+ http.Error(w, "GONE", http.StatusGone)
+}
+func (s *root) registerV1(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "ERR", http.StatusMethodNotAllowed)
+ return
+ }
+ r.ParseForm()
+
+ identity := r.Form.Get("identity")
+ display := r.Form.Get("displayName")
+ passwd, err := s.idm.Passwd([]byte(r.Form.Get("passwd")), nil)
+ if err != nil {
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+
+ _, err = s.idm.RegisterIdent(ctx, identity, display, passwd)
+ if err != nil {
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+
+ http.Error(w, "OK "+identity, http.StatusCreated)
+}
+
+func (s *root) get(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ var id Ident = FromContext(ctx)
+ if id == nil {
+ http.Error(w, loginForm("", true), http.StatusOK)
+ return
+ }
+
+ if !id.Session().Active {
+ http.Error(w, loginForm("", true), http.StatusOK)
+ return
+ }
+
+ display := id.Identity()
+ if id, ok := id.(interface{ DisplayName() string }); ok {
+ display = id.DisplayName()
+ }
+ fmt.Fprint(w, logoutForm(display))
+}
+func (s *root) login(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ if r.Method == http.MethodGet {
+ fmt.Fprint(w, loginForm("", true))
+ return
+ }
+
+ id, err := s.idm.ReadIdent(r)
+ span.RecordError(err)
+ if err != nil {
+ if errors.Is(err, passwd.ErrNoMatch) {
+ http.Error(w, loginForm("", false), http.StatusOK)
+ return
+ }
+
+ http.Error(w, "ERROR", http.StatusInternalServerError)
+ return
+ }
+
+ if !id.Session().Active {
+ http.Error(w, loginForm("", false), http.StatusOK)
+ return
+ }
+
+ err = s.createSession(ctx, id)
+ span.RecordError(err)
+ if err != nil {
+ http.Error(w, "ERROR", http.StatusInternalServerError)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "sour.is-ident",
+ Value: id.Session().SessionID.String(),
+ Expires: time.Time{},
+ Path: "/",
+ Secure: false,
+ HttpOnly: true,
+ })
+
+ display := id.Identity()
+ if id, ok := id.(interface{ DisplayName() string }); ok {
+ display = id.DisplayName()
+ }
+ fmt.Fprint(w, logoutForm(display))
+}
+func (s *root) logout(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "ERR", http.StatusMethodNotAllowed)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{Name: "sour.is-ident", MaxAge: -1})
+
+ err := s.destroySession(ctx, FromContext(ctx))
+ span.RecordError(err)
+ if err != nil {
+ http.Error(w, loginForm("", true), http.StatusUnauthorized)
+ return
+ }
+
+ fmt.Fprint(w, loginForm("", true))
+}
+func (s *root) register(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ if r.Method == http.MethodGet {
+ fmt.Fprint(w, registerForm)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "ERR", http.StatusMethodNotAllowed)
+ return
+ }
+
+ r.ParseForm()
+ identity := r.Form.Get("identity")
+ display := r.Form.Get("displayName")
+
+ passwd, err := s.idm.Passwd([]byte(r.Form.Get("passwd")), nil)
+ if err != nil {
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+
+ id, err := s.idm.RegisterIdent(ctx, identity, display, passwd)
+ if err != nil {
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+
+ if !id.Session().Active {
+ http.Error(w, loginForm("", false), http.StatusUnauthorized)
+ return
+ }
+
+ err = s.createSession(ctx, id)
+ span.RecordError(err)
+ if err != nil {
+ http.Error(w, "ERROR", http.StatusInternalServerError)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "sour.is-ident",
+ Value: id.Session().SessionID.String(),
+ Expires: time.Time{},
+ Path: "/",
+ Secure: false,
+ HttpOnly: true,
+ })
+
+ display = id.Identity()
+ if id, ok := id.(interface{ DisplayName() string }); ok {
+ display = id.DisplayName()
+ }
+
+ http.Error(w, logoutForm(display), http.StatusCreated)
+}
diff --git a/ident/source/mercury.go b/ident/source/mercury.go
new file mode 100644
index 0000000..c54ed01
--- /dev/null
+++ b/ident/source/mercury.go
@@ -0,0 +1,169 @@
+package source
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/mercury"
+ "go.sour.is/pkg/ident"
+)
+
+type registry interface {
+ GetIndex(ctx context.Context, match, search string) (c mercury.Config, err error)
+ GetConfig(ctx context.Context, match, search, fields string) (mercury.Config, error)
+ WriteConfig(ctx context.Context, spaces mercury.Config) error
+}
+
+type mercuryIdent struct {
+ identity string
+ display string
+ passwd []byte
+ ident.SessionInfo
+}
+
+func (id *mercuryIdent) Identity() string { return id.identity }
+func (id *mercuryIdent) DisplayName() string { return id.display }
+func (id *mercuryIdent) Space() string { return "mercury.@" + id.identity }
+
+func (id *mercuryIdent) FromConfig(cfg mercury.Config) error {
+ if id == nil {
+ return fmt.Errorf("nil ident")
+ }
+
+ for _, s := range cfg {
+ if !strings.HasPrefix(s.Space, "mercury.") {
+ continue
+ }
+ if id.identity == "" {
+ _, id.identity, _ = strings.Cut(s.Space, ".@")
+ id.identity, _, _ = strings.Cut(id.identity, ".")
+ }
+
+ switch {
+ case strings.HasSuffix(s.Space, ".ident"):
+ id.passwd = []byte(s.FirstValue("passwd").First())
+ default:
+ id.display = s.FirstValue("displayName").First()
+ }
+ }
+ return nil
+}
+
+func (id *mercuryIdent) ToConfig() mercury.Config {
+ space := id.Space()
+ return mercury.Config{
+ &mercury.Space{
+ Space: space,
+ List: []mercury.Value{
+ {
+ Space: space,
+ Seq: 1,
+ Name: "displayName",
+ Values: []string{id.display},
+ },
+ {
+ Space: space,
+ Seq: 2,
+ Name: "lastLogin",
+ Values: []string{time.UnixMilli(int64(id.Session().SessionID.Time())).Format(time.RFC3339)},
+ },
+ },
+ },
+ &mercury.Space{
+ Space: space + ".ident",
+ List: []mercury.Value{
+ {
+ Space: space + ".ident",
+ Seq: 1,
+ Name: "passwd",
+ Values: []string{string(id.passwd)},
+ },
+ },
+ },
+ }
+}
+
+func (id *mercuryIdent) String() string {
+ return "id: " + id.identity + " sp: " + id.Space() + " dn: " + id.display // + " ps: " + string(id.passwd)
+}
+
+func (id *mercuryIdent) HasRole(r ...string) bool {
+ return false
+}
+
+type mercurySource struct {
+ r registry
+ idm *ident.IDM
+}
+
+func NewMercury(r registry, pwd *ident.IDM) *mercurySource {
+ return &mercurySource{r, pwd}
+}
+
+func (s *mercurySource) ReadIdent(r *http.Request) (ident.Ident, error) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ if r.Method != http.MethodPost {
+ return nil, fmt.Errorf("method not allowed")
+ }
+ r.ParseForm()
+ id := &mercuryIdent{
+ identity: r.Form.Get("identity"),
+ passwd: []byte(r.Form.Get("passwd")),
+ }
+
+ space := id.Space()
+ c, err := s.r.GetConfig(ctx, "trace:"+space+".ident", "", "")
+ if err != nil {
+ span.RecordError(err)
+ return id, err
+ }
+ var current mercuryIdent
+ current.FromConfig(c)
+ if len(current.passwd) == 0 {
+ return nil, fmt.Errorf("not registered")
+ }
+ _, err = s.idm.Passwd(id.passwd, current.passwd)
+ if err != nil {
+ return id, err
+ }
+ current.SessionInfo, err = s.idm.NewSessionInfo()
+ if err != nil {
+ return id, err
+ }
+
+ err = s.r.WriteConfig(ctx, current.ToConfig())
+ if err != nil {
+ return ¤t, err
+ }
+
+ return ¤t, nil
+}
+func (s *mercurySource) RegisterIdent(ctx context.Context, identity, display string, passwd []byte) (ident.Ident, error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ id := &mercuryIdent{identity: identity, display: display, passwd: passwd}
+ space := id.Space()
+
+ _, err := s.r.GetIndex(ctx, space, "")
+ if err != nil {
+ return nil, err
+ }
+
+ id.SessionInfo, err = s.idm.NewSessionInfo()
+ if err != nil {
+ return id, err
+ }
+
+ err = s.r.WriteConfig(ctx, id.ToConfig())
+ if err != nil {
+ return nil, err
+ }
+ return id, nil
+}
diff --git a/lg/tracer.go b/lg/tracer.go
index 99b0190..5e98b69 100644
--- a/lg/tracer.go
+++ b/lg/tracer.go
@@ -58,17 +58,56 @@ type wrapSpan struct {
trace.Span
}
+func LogQuery(q string, args []any, err error) (string, trace.EventOption) {
+ var attrs []attribute.KeyValue
+ for k, v := range args {
+ var attr attribute.KeyValue
+ switch v:=v.(type) {
+ case int64:
+ attr = attribute.Int64(
+ fmt.Sprintf("$%d", k),
+ v,
+ )
+ case string:
+ attr = attribute.String(
+ fmt.Sprintf("$%d", k),
+ v,
+ )
+ default:
+ attr = attribute.String(
+ fmt.Sprintf("$%d", k),
+ fmt.Sprint(v),
+ )
+ }
+
+ attrs = append(attrs, attr)
+ }
+
+ return q, trace.WithAttributes(attrs...)
+}
+
func (w wrapSpan) AddEvent(name string, options ...trace.EventOption) {
w.Span.AddEvent(name, options...)
cfg := trace.NewEventConfig(options...)
attrs := cfg.Attributes()
- args := make([]any, len(attrs)*2)
+ args := make([]any, len(attrs))
for i, a := range attrs {
- args[2*i] = a.Key
- args[2*i+1] = a.Value
+ switch a.Value.Type() {
+ case attribute.BOOL:
+ args[i] = slog.Bool(string(a.Key), a.Value.AsBool())
+ case attribute.INT64:
+ args[i] = slog.Int64(string(a.Key), a.Value.AsInt64())
+ case attribute.FLOAT64:
+ args[i] = slog.Float64(string(a.Key), a.Value.AsFloat64())
+ case attribute.STRING:
+ args[i] = slog.String(string(a.Key), a.Value.AsString())
+ default:
+ args[i] = slog.Any(string(a.Key), a.Value.AsInterface())
+ }
+
}
slog.Debug(name, args...)
diff --git a/mercury/app/app_test.go b/mercury/app/app_test.go
new file mode 100644
index 0000000..3824501
--- /dev/null
+++ b/mercury/app/app_test.go
@@ -0,0 +1,157 @@
+package app
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ "go.sour.is/pkg/mercury"
+ "go.sour.is/pkg/ident"
+)
+
+type mockUser struct {
+ roles map[string]struct{}
+ ident.SessionInfo
+}
+
+func (m *mockUser) Identity() string { return "user" }
+func (m *mockUser) HasRole(roles ...string) bool {
+ var found bool
+ for _, role := range roles {
+ if _, ok := m.roles[role]; ok {
+ found = true
+ }
+ }
+
+ return found
+}
+
+func Test_appConfig_GetRules(t *testing.T) {
+ type args struct {
+ u ident.Ident
+ }
+ tests := []struct {
+ name string
+ args args
+ wantLis mercury.Rules
+ }{
+ {"normal", args{&mockUser{}}, nil},
+ {
+ "admin",
+ args{
+ &mockUser{
+ SessionInfo: ident.SessionInfo{Active: true},
+ roles: map[string]struct{}{"admin": {}},
+ },
+ },
+ mercury.Rules{
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: "mercury.source.*",
+ },
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: "mercury.priority",
+ },
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: "mercury.host",
+ },
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: "mercury.environ",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ a := mercuryEnviron{}
+ if gotLis, _ := a.GetRules(context.TODO(), tt.args.u); !reflect.DeepEqual(gotLis, tt.wantLis) {
+ t.Errorf("appConfig.GetRules() = %v, want %v", gotLis, tt.wantLis)
+ }
+ })
+ }
+}
+
+// func Test_appConfig_GetIndex(t *testing.T) {
+// type args struct {
+// search mercury.NamespaceSearch
+// in1 *rsql.Program
+// }
+// tests := []struct {
+// name string
+// args args
+// wantLis mercury.Config
+// }{
+// {"nil", args{
+// nil,
+// nil,
+// }, nil},
+
+// {"app.settings", args{
+// mercury.ParseNamespace("app.settings"),
+// nil,
+// }, mercury.Config{&mercury.Space{Space: "app.settings"}}},
+// }
+// for _, tt := range tests {
+// t.Run(tt.name, func(t *testing.T) {
+// a := mercuryEnviron{}
+// if gotLis, _ := a.GetIndex(tt.args.search, tt.args.in1); !reflect.DeepEqual(gotLis, tt.wantLis) {
+// t.Errorf("appConfig.GetIndex() = %#v, want %#v", gotLis, tt.wantLis)
+// }
+// })
+// }
+// }
+
+// func Test_appConfig_GetObjects(t *testing.T) {
+// cfg, err := mercury.ParseText(strings.NewReader(`
+// @mercury.source.mercury-settings.default
+// match :0 *
+// `))
+
+// type args struct {
+// search mercury.NamespaceSearch
+// in1 *rsql.Program
+// in2 []string
+// }
+// tests := []struct {
+// name string
+// args args
+// wantLis mercury.Config
+// }{
+// {"nil", args{
+// nil,
+// nil,
+// nil,
+// }, nil},
+
+// {"app.settings", args{
+// mercury.ParseNamespace("app.settings"),
+// nil,
+// nil,
+// }, mercury.Config{
+// &mercury.Space{
+// Space: "app.settings",
+// List: []mercury.Value{{
+// Space: "app.settings",
+// Name: "app.setting",
+// Values: []string{"TRUE"}},
+// },
+// },
+// }},
+// }
+// for _, tt := range tests {
+// cfg, err :=
+// t.Run(tt.name, func(t *testing.T) {
+// a := appConfig{cfg: }
+// if gotLis, _ := a.GetConfig(tt.args.search, tt.args.in1, tt.args.in2); !reflect.DeepEqual(gotLis, tt.wantLis) {
+// t.Errorf("appConfig.GetIndex() = %#v, want %#v", gotLis, tt.wantLis)
+// }
+// })
+// }
+// }
diff --git a/mercury/app/default-rules.go b/mercury/app/default-rules.go
new file mode 100644
index 0000000..eb7e841
--- /dev/null
+++ b/mercury/app/default-rules.go
@@ -0,0 +1,98 @@
+package app
+
+import (
+ "context"
+ "strings"
+
+ "go.sour.is/pkg/mercury"
+ "go.sour.is/pkg/ident"
+)
+
+type mercuryDefault struct {
+ name string
+ cfg mercury.SpaceMap
+}
+
+var (
+ _ mercury.GetRules = (*mercuryDefault)(nil)
+
+ _ mercury.GetIndex = (*mercuryEnviron)(nil)
+ _ mercury.GetConfig = (*mercuryEnviron)(nil)
+ _ mercury.GetRules = (*mercuryEnviron)(nil)
+)
+
+// GetRules returns default rules for user role.
+func (app *mercuryDefault) GetRules(ctx context.Context, id ident.Ident) (lis mercury.Rules, err error) {
+ identity := id.Identity()
+
+ lis = append(lis,
+ mercury.Rule{
+ Role: "write",
+ Type: "NS",
+ Match: "mercury.@" + identity,
+ },
+ mercury.Rule{
+ Role: "write",
+ Type: "NS",
+ Match: "mercury.@" + identity + ".*",
+ },
+ )
+
+ groups := groups(identity, &app.cfg)
+
+ if s, ok := app.cfg.Space("mercury.policy."+app.name); ok {
+ for _, p := range s.List {
+ if groups.Has(p.Name) {
+ for _, r := range p.Values {
+ fds := strings.Fields(r)
+ if len(fds) < 3 {
+ continue
+ }
+ lis = append(lis, mercury.Rule{
+ Role: fds[0],
+ Type: fds[1],
+ Match: fds[2],
+ })
+ }
+ }
+ }
+ }
+
+ if u, ok := id.(hasRole); groups.Has("admin") || ok && u.HasRole("admin") {
+ lis = append(lis,
+ mercury.Rule{
+ Role: "admin",
+ Type: "NS",
+ Match: "*",
+ },
+ mercury.Rule{
+ Role: "write",
+ Type: "NS",
+ Match: "*",
+ },
+ mercury.Rule{
+ Role: "admin",
+ Type: "GR",
+ Match: "*",
+ },
+ )
+ } else if u.HasRole("write") {
+ lis = append(lis,
+ mercury.Rule{
+ Role: "write",
+ Type: "NS",
+ Match: "*",
+ },
+ )
+ } else if u.HasRole("read") {
+ lis = append(lis,
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: "*",
+ },
+ )
+ }
+
+ return lis, nil
+}
diff --git a/mercury/app/environ.go b/mercury/app/environ.go
new file mode 100644
index 0000000..9a8aa76
--- /dev/null
+++ b/mercury/app/environ.go
@@ -0,0 +1,288 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/user"
+ "sort"
+ "strings"
+
+ "go.sour.is/pkg/mercury"
+ "go.sour.is/pkg/ident"
+ "go.sour.is/pkg/rsql"
+ "go.sour.is/pkg/set"
+)
+
+const (
+ mercurySource = "mercury.source.*"
+ mercuryPriority = "mercury.priority"
+ mercuryHost = "mercury.host"
+ appDotEnviron = "mercury.environ"
+)
+var (
+ mercuryPolicy = func(id string) string { return "mercury.@" + id + ".policy" }
+)
+
+func Register(name string, cfg mercury.SpaceMap) {
+ for _, c := range cfg {
+ c.Tags = append(c.Tags, "RO")
+ }
+ mercury.Registry.Register("mercury-default", func(s *mercury.Space) any { return &mercuryDefault{name: name, cfg: cfg} })
+ mercury.Registry.Register("mercury-environ", func(s *mercury.Space) any { return &mercuryEnviron{cfg: cfg, lookup: mercury.Registry.GetRules} })
+}
+
+type hasRole interface {
+ HasRole(r ...string) bool
+}
+
+type mercuryEnviron struct {
+ cfg mercury.SpaceMap
+ lookup func(context.Context, ident.Ident) (mercury.Rules, error)
+}
+
+// Index returns nil
+func (app *mercuryEnviron) GetIndex(ctx context.Context, search mercury.NamespaceSearch, _ *rsql.Program) (lis mercury.Config, err error) {
+
+ if search.Match(mercurySource) {
+ for _, s := range app.cfg.ToArray() {
+ if search.Match(s.Space) {
+ lis = append(lis, &mercury.Space{Space: s.Space, Tags: []string{"RO"}})
+ }
+ }
+ }
+
+ if search.Match(mercuryPriority) {
+ lis = append(lis, &mercury.Space{Space: mercuryPriority, Tags: []string{"RO"}})
+ }
+
+ if search.Match(mercuryHost) {
+ lis = append(lis, &mercury.Space{Space: mercuryHost, Tags: []string{"RO"}})
+ }
+
+ if search.Match(appDotEnviron) {
+ lis = append(lis, &mercury.Space{Space: appDotEnviron, Tags: []string{"RO"}})
+ }
+ if id := ident.FromContext(ctx); id != nil {
+ identity := id.Identity()
+ match := mercuryPolicy(identity)
+ if search.Match(match) {
+ lis = append(lis, &mercury.Space{Space: match, Tags: []string{"RO"}})
+ }
+ }
+ return
+}
+
+// Objects returns nil
+func (app *mercuryEnviron) GetConfig(ctx context.Context, search mercury.NamespaceSearch, _ *rsql.Program, _ []string) (lis mercury.Config, err error) {
+ if search.Match(mercurySource) {
+ for _, s := range app.cfg.ToArray() {
+ if search.Match(s.Space) {
+ lis = append(lis, s)
+ }
+ }
+ }
+
+ if search.Match(mercuryPriority) {
+ space := mercury.Space{
+ Space: mercuryPriority,
+ Tags: []string{"RO"},
+ }
+
+ // for i, key := range mercury.Registry {
+ // space.List = append(space.List, mercury.Value{
+ // Space: appDotPriority,
+ // Seq: uint64(i),
+ // Name: key.Match,
+ // Values: []string{fmt.Sprint(key.Priority)},
+ // })
+ // }
+
+ lis = append(lis, &space)
+ }
+
+ if search.Match(mercuryHost) {
+ if usr, err := user.Current(); err == nil {
+ space := mercury.Space{
+ Space: mercuryHost,
+ Tags: []string{"RO"},
+ }
+
+ hostname, _ := os.Hostname()
+ wd, _ := os.Getwd()
+ grp, _ := usr.GroupIds()
+ space.List = []mercury.Value{
+ {
+ Space: mercuryHost,
+ Seq: 1,
+ Name: "hostname",
+ Values: []string{hostname},
+ },
+ {
+ Space: mercuryHost,
+ Seq: 2,
+ Name: "username",
+ Values: []string{usr.Username},
+ },
+ {
+ Space: mercuryHost,
+ Seq: 3,
+ Name: "uid",
+ Values: []string{usr.Uid},
+ },
+ {
+ Space: mercuryHost,
+ Seq: 4,
+ Name: "gid",
+ Values: []string{usr.Gid},
+ },
+ {
+ Space: mercuryHost,
+ Seq: 5,
+ Name: "display",
+ Values: []string{usr.Name},
+ },
+ {
+ Space: mercuryHost,
+ Seq: 6,
+ Name: "home",
+ Values: []string{usr.HomeDir},
+ },
+ {
+ Space: mercuryHost,
+ Seq: 7,
+ Name: "groups",
+ Values: grp,
+ },
+ {
+ Space: mercuryHost,
+ Seq: 8,
+ Name: "pid",
+ Values: []string{fmt.Sprintf("%v", os.Getpid())},
+ },
+ {
+ Space: mercuryHost,
+ Seq: 9,
+ Name: "wd",
+ Values: []string{wd},
+ },
+ }
+
+ lis = append(lis, &space)
+ }
+ }
+
+ if search.Match(appDotEnviron) {
+ env := os.Environ()
+ space := mercury.Space{
+ Space: appDotEnviron,
+ Tags: []string{"RO"},
+ }
+
+ sort.Strings(env)
+ for i, s := range env {
+ key, val, _ := strings.Cut(s, "=")
+
+ vals := []string{val}
+ if strings.Contains(key, "PATH") || strings.Contains(key, "XDG") {
+ vals = strings.Split(val, ":")
+ }
+
+ space.List = append(space.List, mercury.Value{
+ Space: appDotEnviron,
+ Seq: uint64(i),
+ Name: key,
+ Values: vals,
+ })
+ }
+ lis = append(lis, &space)
+ }
+
+ if id := ident.FromContext(ctx); id != nil {
+ identity := id.Identity()
+ groups := groups(identity, &app.cfg)
+ match := mercuryPolicy(identity)
+ if search.Match(match) {
+ space := &mercury.Space{
+ Space: match,
+ Tags: []string{"RO"},
+ }
+
+ lis = append(lis, space)
+ rules, err := app.lookup(ctx, id)
+ if err != nil {
+ space.AddNotes(err.Error())
+ } else {
+ k := mercury.NewValue("groups")
+ k.AddValues(strings.Join(groups.Values(), " "))
+ space.AddKeys(k)
+
+ k = mercury.NewValue("rules")
+ for _, r := range rules {
+ k.AddValues(strings.Join([]string{r.Role, r.Type, r.Match}, " "))
+ }
+ space.AddKeys(k)
+ }
+ }
+ }
+
+ return
+}
+
+// Rules returns nil
+func (app *mercuryEnviron) GetRules(ctx context.Context, id ident.Ident) (lis mercury.Rules, err error) {
+ identity := id.Identity()
+
+ lis = append(lis,
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: mercuryPolicy(identity),
+ },
+ )
+
+ groups := groups(identity, &app.cfg)
+
+ if u, ok := id.(hasRole); groups.Has("admin") || ok && u.HasRole("admin") {
+ lis = append(lis,
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: mercurySource,
+ },
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: mercuryPriority,
+ },
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: mercuryHost,
+ },
+ mercury.Rule{
+ Role: "read",
+ Type: "NS",
+ Match: appDotEnviron,
+ },
+ )
+ }
+
+ return lis, nil
+}
+
+func groups(identity string, cfg *mercury.SpaceMap) set.Set[string] {
+ groups := set.New[string]()
+ if s, ok := cfg.Space("mercury.groups"); ok {
+ for _, g := range s.List {
+ for _, v := range g.Values {
+ for _, u := range strings.Fields(v) {
+ if u == identity {
+ groups.Add(g.Name)
+ }
+ }
+ }
+ }
+ }
+ return groups
+}
diff --git a/mercury/http/notify.go b/mercury/http/notify.go
new file mode 100644
index 0000000..44cf8e6
--- /dev/null
+++ b/mercury/http/notify.go
@@ -0,0 +1,63 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "net/http"
+
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/mercury"
+)
+
+type httpNotify struct{}
+
+func (httpNotify) SendNotify(ctx context.Context, n mercury.Notify) error {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ cl := &http.Client{}
+ caCertPool, err := x509.SystemCertPool()
+ if err != nil {
+ caCertPool = x509.NewCertPool()
+ }
+
+ // Setup HTTPS client
+ tlsConfig := &tls.Config{
+ RootCAs: caCertPool,
+ }
+
+ transport := &http.Transport{TLSClientConfig: tlsConfig}
+
+ cl.Transport = transport
+
+ var req *http.Request
+ req, err = http.NewRequestWithContext(ctx, n.Method, n.URL, bytes.NewBufferString(""))
+ if err != nil {
+ span.RecordError(err)
+ return err
+ }
+ req.Header.Set("content-type", "application/json")
+
+ span.AddEvent(fmt.Sprint("URL: ", n.URL))
+ res, err := cl.Do(req)
+ if err != nil {
+ span.RecordError(err)
+ return err
+ }
+ res.Body.Close()
+ span.AddEvent(fmt.Sprint(res.Status))
+ if res.StatusCode != 200 {
+ span.RecordError(err)
+ err = fmt.Errorf("unable to read config")
+ return err
+ }
+
+ return nil
+}
+
+func Register() {
+ mercury.Registry.Register("http-notify", func(s *mercury.Space) any { return httpNotify{} })
+}
diff --git a/mercury/mercury.go b/mercury/mercury.go
new file mode 100644
index 0000000..29ce596
--- /dev/null
+++ b/mercury/mercury.go
@@ -0,0 +1,695 @@
+package mercury
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/exp/maps"
+)
+
+type Config []*Space
+
+func NewConfig(spaces ...*Space) Config {
+ return spaces
+}
+func (c *Config) AddSpace(spaces ...*Space) *Config {
+ *c = append(*c, spaces...)
+ return c
+}
+
+// Len implements Len for sort.interface
+func (lis Config) Len() int {
+ return len(lis)
+}
+
+// Less implements Less for sort.interface
+func (lis Config) Less(i, j int) bool {
+ return lis[i].Space < lis[j].Space
+}
+
+// Swap implements Swap for sort.interface
+func (lis Config) Swap(i, j int) { lis[i], lis[j] = lis[j], lis[i] }
+
+// StringList returns the space names as a list
+func (lis Config) StringList() string {
+ var buf strings.Builder
+ for _, o := range lis {
+ if len(o.Notes) > 0 {
+ buf.WriteString("# ")
+ buf.WriteString(strings.Join(o.Notes, "\n# "))
+ buf.WriteRune('\n')
+ }
+ buf.WriteRune('@')
+ buf.WriteString(o.Space)
+ if len(o.Tags) > 0 {
+ buf.WriteRune(' ')
+ buf.WriteString(strings.Join(o.Tags, " "))
+ }
+ buf.WriteRune('\n')
+ }
+ return buf.String()
+}
+
+// ToSpaceMap formats as SpaceMap
+func (lis Config) ToSpaceMap() SpaceMap {
+ out := make(SpaceMap)
+ for _, c := range lis {
+ out[c.Space] = c
+ }
+ return out
+}
+
+// String format config as string
+func (lis Config) String() string {
+ attLen := 0
+ tagLen := 0
+
+ for _, o := range lis {
+ for _, v := range o.List {
+ l := len(v.Name)
+ if attLen <= l {
+ attLen = l
+ }
+
+ t := len(strings.Join(v.Tags, " "))
+ if tagLen <= t {
+ tagLen = t
+ }
+ }
+ }
+
+ var buf strings.Builder
+ for _, o := range lis {
+ if len(o.Notes) > 0 {
+ buf.WriteString("# ")
+ buf.WriteString(strings.Join(o.Notes, "\n# "))
+ buf.WriteRune('\n')
+ }
+
+ buf.WriteRune('@')
+ buf.WriteString(o.Space)
+ if len(o.Tags) > 0 {
+ buf.WriteRune(' ')
+ buf.WriteString(strings.Join(o.Tags, " "))
+ }
+ buf.WriteRune('\n')
+
+ for _, v := range o.List {
+ if len(v.Notes) > 0 {
+ buf.WriteString("# ")
+ buf.WriteString(strings.Join(v.Notes, "\n# "))
+ buf.WriteString("\n")
+ }
+
+ buf.WriteString(v.Name)
+ buf.WriteString(strings.Repeat(" ", attLen-len(v.Name)+1))
+
+ if len(v.Tags) > 0 {
+ t := strings.Join(v.Tags, " ")
+ buf.WriteString(t)
+ buf.WriteString(strings.Repeat(" ", tagLen-len(t)+1))
+ } else {
+ buf.WriteString(strings.Repeat(" ", tagLen+1))
+ }
+
+ switch len(v.Values) {
+ case 0:
+ buf.WriteString("\n")
+ case 1:
+ buf.WriteString(" :")
+ buf.WriteString(v.Values[0])
+ buf.WriteString("\n")
+ default:
+ buf.WriteString(" :")
+ buf.WriteString(v.Values[0])
+ buf.WriteString("\n")
+ for _, s := range v.Values[1:] {
+ buf.WriteString(strings.Repeat(" ", attLen+tagLen+3))
+ buf.WriteString(":")
+ buf.WriteString(s)
+ buf.WriteString("\n")
+ }
+ }
+ }
+
+ buf.WriteRune('\n')
+ }
+
+ return buf.String()
+}
+
+// EnvString format config as environ
+func (lis Config) EnvString() string {
+ var buf strings.Builder
+ for _, o := range lis {
+ for _, v := range o.List {
+ buf.WriteString(o.Space)
+ for _, t := range o.Tags {
+ buf.WriteRune(' ')
+ buf.WriteString(t)
+ }
+ buf.WriteRune(':')
+ buf.WriteString(v.Name)
+ for _, t := range v.Tags {
+ buf.WriteRune(' ')
+ buf.WriteString(t)
+ }
+ switch len(v.Values) {
+ case 0:
+ buf.WriteRune('=')
+ buf.WriteRune('\n')
+ case 1:
+ buf.WriteRune('=')
+ buf.WriteString(v.Values[0])
+ buf.WriteRune('\n')
+ default:
+ buf.WriteRune('+')
+ buf.WriteRune('=')
+ buf.WriteString(v.Values[0])
+ buf.WriteRune('\n')
+ for _, s := range v.Values[1:] {
+ buf.WriteString(o.Space)
+ buf.WriteRune(':')
+ buf.WriteString(v.Name)
+ buf.WriteRune('+')
+ buf.WriteRune('=')
+ buf.WriteString(s)
+ buf.WriteRune('\n')
+ }
+ }
+ }
+ }
+
+ return buf.String()
+}
+
+// INIString format config as ini
+func (lis Config) INIString() string {
+ var buf strings.Builder
+ for _, o := range lis {
+ buf.WriteRune('[')
+ buf.WriteString(o.Space)
+ buf.WriteRune(']')
+ buf.WriteRune('\n')
+ for _, v := range o.List {
+ buf.WriteString(v.Name)
+ switch len(v.Values) {
+ case 0:
+ buf.WriteRune('=')
+ buf.WriteRune('\n')
+ case 1:
+ buf.WriteRune('=')
+ buf.WriteString(v.Values[0])
+ buf.WriteRune('\n')
+ default:
+ buf.WriteRune('[')
+ buf.WriteRune('0')
+ buf.WriteRune(']')
+
+ buf.WriteRune('=')
+ buf.WriteString(v.Values[0])
+ buf.WriteRune('\n')
+ for i, s := range v.Values[1:] {
+ buf.WriteString(v.Name)
+ buf.WriteRune('[')
+ buf.WriteString(fmt.Sprintf("%d", i))
+ buf.WriteRune(']')
+ buf.WriteRune('=')
+ buf.WriteString(s)
+ buf.WriteRune('\n')
+ }
+ }
+ }
+ }
+
+ return buf.String()
+}
+
+// String format config as string
+func (lis Config) HTMLString() string {
+ attLen := 0
+ tagLen := 0
+
+ for _, o := range lis {
+ for _, v := range o.List {
+ l := len(v.Name)
+ if attLen <= l {
+ attLen = l
+ }
+
+ t := len(strings.Join(v.Tags, " "))
+ if tagLen <= t {
+ tagLen = t
+ }
+ }
+ }
+
+ var buf strings.Builder
+ for _, o := range lis {
+ if len(o.Notes) > 0 {
+ buf.WriteString("")
+ buf.WriteString("# ")
+ buf.WriteString(strings.Join(o.Notes, "\n# "))
+ buf.WriteString("")
+ buf.WriteRune('\n')
+ }
+
+ buf.WriteString("")
+ buf.WriteRune('@')
+ buf.WriteString(o.Space)
+ buf.WriteString("")
+ if len(o.Tags) > 0 {
+ buf.WriteRune(' ')
+ buf.WriteString("")
+ buf.WriteString(strings.Join(o.Tags, " "))
+ buf.WriteString("")
+ }
+ buf.WriteRune('\n')
+
+ for _, v := range o.List {
+ if len(v.Notes) > 0 {
+ buf.WriteString("")
+ buf.WriteString("# ")
+ buf.WriteString(strings.Join(v.Notes, "\n# "))
+ buf.WriteString("")
+ buf.WriteString("\n")
+ }
+
+ buf.WriteString("")
+ buf.WriteString(v.Name)
+ buf.WriteString("")
+ buf.WriteString(strings.Repeat(" ", attLen-len(v.Name)+1))
+
+ if len(v.Tags) > 0 {
+ t := strings.Join(v.Tags, " ")
+ buf.WriteString("")
+ buf.WriteString(t)
+ buf.WriteString(strings.Repeat(" ", tagLen-len(t)+1))
+ buf.WriteString("")
+ } else {
+ buf.WriteString(strings.Repeat(" ", tagLen+1))
+ }
+
+ switch len(v.Values) {
+ case 0:
+ buf.WriteString("\n")
+ case 1:
+ buf.WriteString(" :")
+ buf.WriteString(v.Values[0])
+ buf.WriteString("\n")
+ default:
+ buf.WriteString(" :")
+ buf.WriteString(v.Values[0])
+ buf.WriteString("\n")
+ for _, s := range v.Values[1:] {
+ buf.WriteString(strings.Repeat(" ", attLen+tagLen+3))
+ buf.WriteString(":")
+ buf.WriteString(s)
+ buf.WriteString("\n")
+ }
+ }
+ }
+
+ buf.WriteRune('\n')
+ }
+
+ return buf.String()
+}
+
+// Space stores a registry of spaces
+type Space struct {
+ Space string `json:"space"`
+ Tags []string `json:"tags,omitempty"`
+ Notes []string `json:"notes,omitempty"`
+ List []Value `json:"list,omitempty"`
+}
+
+func NewSpace(space string) *Space {
+ return &Space{Space: space}
+}
+
+// HasTag returns true if needle is found
+// If the needle ends with a / it will be treated
+// as a prefix for tag meta data.
+func (s *Space) HasTag(needle string) bool {
+ isPrefix := strings.HasSuffix(needle, "/")
+ for i := range s.Tags {
+ switch isPrefix {
+ case true:
+ if strings.HasPrefix(s.Tags[i], needle) {
+ return true
+ }
+ case false:
+ if s.Tags[i] == needle {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// GetTagMeta retuns the value after a '/' in a tag.
+// Tags are in the format 'name' or 'name/value'
+// This function returns the value.
+func (s *Space) GetTagMeta(needle string, offset int) string {
+ if !strings.HasSuffix(needle, "/") {
+ needle += "/"
+ }
+
+ for i := range s.Tags {
+ if strings.HasPrefix(s.Tags[i], needle) {
+ if offset > 0 {
+ offset--
+ continue
+ }
+
+ return strings.TrimPrefix(s.Tags[i], needle)
+ }
+ }
+
+ return ""
+}
+
+// FirstTagMeta returns the first meta tag value.
+func (s *Space) FirstTagMeta(needle string) string {
+ return s.GetTagMeta(needle, 0)
+}
+
+// GetValues that match name
+func (s *Space) GetValues(name string) (lis []Value) {
+ for i := range s.List {
+ if s.List[i].Name == name {
+ lis = append(lis, s.List[i])
+ }
+ }
+ return
+}
+
+// FirstValue that matches name
+func (s *Space) FirstValue(name string) Value {
+ for i := range s.List {
+ if s.List[i].Name == name {
+ return s.List[i]
+ }
+ }
+ return Value{}
+}
+
+func (s *Space) SetTags(tags ...string) *Space {
+ s.Tags = tags
+ return s
+}
+func (s *Space) AddTags(tags ...string) *Space {
+ s.Tags = append(s.Tags, tags...)
+ return s
+}
+func (s *Space) SetNotes(notes ...string) *Space {
+ s.Notes = notes
+ return s
+}
+func (s *Space) AddNotes(notes ...string) *Space {
+ s.Notes = append(s.Notes, notes...)
+ return s
+}
+func (s *Space) SetKeys(keys ...*Value) *Space {
+ for i := range keys {
+ k := *keys[i]
+ k.Seq = uint64(i)
+ s.List = append(s.List, k)
+ }
+
+ return s
+}
+func (s *Space) AddKeys(keys ...*Value) *Space {
+ l := uint64(len(s.List))
+ for i := range keys {
+ k := *keys[i]
+ k.Seq = uint64(i) + l
+ s.List = append(s.List, k)
+ }
+ return s
+}
+
+// SpaceMap generic map of space values
+type SpaceMap map[string]*Space
+
+func (m SpaceMap) Space(name string) (*Space, bool) {
+ s, ok := m[name]
+ return s, ok
+}
+
+// Rule is a type of rule
+type Rule struct {
+ Role string
+ Type string
+ Match string
+}
+
+// Rules is a list of rules
+type Rules []Rule
+
+// GetNamespaceSearch returns a default search for users rules.
+func (r Rules) GetNamespaceSearch() (lis NamespaceSearch) {
+ for _, o := range r {
+ if o.Type == "NS" && (o.Role == "read" || o.Role == "write") {
+ lis = append(lis, NamespaceStar(o.Match))
+ }
+ }
+ return
+}
+
+// Check if name matches rule
+func (r Rule) Check(name string) bool {
+ ok, err := filepath.Match(r.Match, name)
+ if err != nil {
+ return false
+ }
+ return ok
+}
+
+// CheckNamespace verifies user has access
+func (r Rules) CheckNamespace(search NamespaceSearch) bool {
+ for _, ns := range search {
+ if !r.GetRoles("NS", ns.Value()).HasRole("read", "write") {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (r Rules) Less(i, j int) bool {
+ si, sj := scoreRule(r[i]), scoreRule(r[j])
+ if si != sj {
+ return si < sj
+ }
+ return len(r[i].Match) < len(r[j].Match)
+}
+func (r Rules) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
+func (r Rules) Len() int { return len(r) }
+
+func scoreRule(r Rule) int {
+ score := 0
+ if r.Type == "GR" {
+ score += 1000
+ }
+ switch r.Role {
+ case "admin":
+ score += 100
+ case "write":
+ score += 50
+ case "read":
+ score += 10
+ }
+ return score
+}
+
+// ReduceSearch verifies user has access
+func (r Rules) ReduceSearch(search NamespaceSearch) (out NamespaceSearch) {
+ rules := r.GetNamespaceSearch()
+ skip := make(map[string]struct{})
+ out = make(NamespaceSearch, 0, len(rules))
+
+ for _, rule := range rules {
+ if _, ok := skip[rule.Raw()]; ok {
+ continue
+ }
+ for _, ck := range search {
+ if _, ok := skip[ck.Raw()]; ok {
+ continue
+ } else if rule.Match(ck.Raw()) {
+ skip[ck.Raw()] = struct{}{}
+ out = append(out, ck)
+ } else if ck.Match(rule.Raw()) {
+ out = append(out, rule)
+ }
+ }
+ }
+
+ return
+}
+
+// Roles is a list of roles for a resource
+type Roles map[string]struct{}
+
+// GetRoles returns a list of Roles
+func (r Rules) GetRoles(typ, name string) (lis Roles) {
+ lis = make(Roles)
+ for _, o := range r {
+ if typ == o.Type && o.Check(name) {
+ lis[o.Role] = struct{}{}
+ }
+ }
+ return
+}
+
+// HasRole is a valid role
+func (r Roles) HasRole(roles ...string) bool {
+ for _, role := range roles {
+ if _, ok := r[role]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+// ToArray converts SpaceMap to ArraySpace
+func (m SpaceMap) ToArray() Config {
+ a := make(Config, 0, len(m))
+ for _, s := range m {
+ a = append(a, s)
+ }
+ return a
+}
+func (m *SpaceMap) MergeMap(s SpaceMap) {
+ m.Merge(maps.Values(s)...)
+}
+func (m *SpaceMap) Merge(lis ...*Space) {
+ for _, s := range lis {
+ // Only accept first version based on priority.
+ if _, ok := (*m)[s.Space]; ok {
+ continue
+ }
+
+ (*m)[s.Space] = s
+
+ // // Merge values together.
+ // c, ok := (*m)[s.Space]
+ // if ok {
+ // c = &Space{}
+ // }
+ // c.Notes = append(c.Notes, s.Notes...)
+ // c.Tags = append(c.Tags, s.Tags...)
+ // last := c.List[len(c.List)-1].Seq
+ // for i := range s.List {
+ // v := s.List[i]
+ // v.Seq += last
+ // c.List = append(c.List, v)
+ // }
+ // (*m)[s.Space] = c
+ }
+}
+
+// Value stores the attributes for space values
+type Value struct {
+ Space string `json:"-" db:"space"`
+ Seq uint64 `json:"-" db:"seq"`
+ Name string `json:"name"`
+ Values []string `json:"values"`
+ Notes []string `json:"notes"`
+ Tags []string `json:"tags"`
+}
+
+// func (v *Value) ID() string {
+// return gql.FmtID("MercurySpace:%v:%v", v.Space, v.Seq)
+// }
+
+// HasTag returns true if needle is found
+// If the needle ends with a / it will be treated
+// as a prefix for tag meta data.
+func (v Value) HasTag(needle string) bool {
+ isPrefix := strings.HasSuffix(needle, "/")
+ for i := range v.Tags {
+ switch isPrefix {
+ case true:
+ if strings.HasPrefix(v.Tags[i], needle) {
+ return true
+ }
+ case false:
+ if v.Tags[i] == needle {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// GetTagMeta retuns the value after a '/' in a tag.
+// Tags are in the format 'name' or 'name/value'
+// This function returns the value.
+func (v Value) GetTagMeta(needle string, offset int) string {
+ if !strings.HasSuffix(needle, "/") {
+ needle += "/"
+ }
+
+ for i := range v.Tags {
+ if strings.HasPrefix(v.Tags[i], needle) {
+ if offset > 0 {
+ offset--
+ continue
+ }
+
+ return strings.TrimPrefix(v.Tags[i], needle)
+ }
+ }
+
+ return ""
+}
+
+// FirstTagMeta returns the first meta tag value.
+func (v Value) FirstTagMeta(needle string) string {
+ return v.GetTagMeta(needle, 0)
+}
+
+// First value in array.
+func (v Value) First() string {
+ if len(v.Values) < 1 {
+ return ""
+ }
+
+ return v.Values[0]
+}
+
+// Join values with newlines.
+func (v Value) Join() string {
+ return strings.Join(v.Values, "\n")
+}
+
+func NewValue(name string) *Value {
+ return &Value{Name: name}
+}
+func (v *Value) SetTags(tags ...string) *Value {
+ v.Tags = tags
+ return v
+}
+func (v *Value) AddTags(tags ...string) *Value {
+ v.Tags = append(v.Tags, tags...)
+ return v
+}
+func (v *Value) SetNotes(notes ...string) *Value {
+ v.Notes = notes
+ return v
+}
+func (v *Value) AddNotes(notes ...string) *Value {
+ v.Notes = append(v.Notes, notes...)
+ return v
+}
+func (v *Value) SetValues(values ...string) *Value {
+ v.Values = values
+ return v
+}
+func (v *Value) AddValues(values ...string) *Value {
+ v.Values = append(v.Values, values...)
+ return v
+}
diff --git a/mercury/mqtt/notify.go b/mercury/mqtt/notify.go
new file mode 100644
index 0000000..f67b057
--- /dev/null
+++ b/mercury/mqtt/notify.go
@@ -0,0 +1,27 @@
+package mqtt
+
+import (
+ "context"
+
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/mercury"
+)
+
+type mqttNotify struct{}
+
+func (mqttNotify) SendNotify(ctx context.Context, n mercury.Notify) {
+ _, span := lg.Span(ctx)
+ defer span.End()
+ // var m mqtt.Message
+ // m, err = mqtt.NewMessage(n.URL, n)
+ // if err != nil {
+ // return
+ // }
+ // log.Debug(n)
+ // err = mqtt.Publish(m)
+ // return
+}
+
+func Register() {
+ mercury.Registry.Register("mqtt-notify", func(s *mercury.Space) any { return &mqttNotify{} })
+}
diff --git a/mercury/namespace.go b/mercury/namespace.go
new file mode 100644
index 0000000..c783714
--- /dev/null
+++ b/mercury/namespace.go
@@ -0,0 +1,125 @@
+package mercury
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// NamespaceSpec implements a parsed namespace search
+type NamespaceSpec interface {
+ Value() string
+ String() string
+ Raw() string
+ Match(string) bool
+}
+
+// NamespaceSearch list of namespace specs
+type NamespaceSearch []NamespaceSpec
+
+// ParseNamespace returns a list of parsed values
+func ParseNamespace(ns string) (lis NamespaceSearch) {
+ for _, part := range strings.Split(ns, ";") {
+ if strings.HasPrefix(part, "trace:") {
+ for _, s := range strings.Split(part[6:], ",") {
+ lis = append(lis, NamespaceTrace(s))
+ }
+ } else {
+ for _, s := range strings.Split(part, ",") {
+ if strings.Contains(s, "*") {
+ lis = append(lis, NamespaceStar(s))
+ } else {
+ lis = append(lis, NamespaceNode(s))
+ }
+ }
+ }
+ }
+
+ return
+}
+
+// String output string value
+func (n NamespaceSearch) String() string {
+ lis := make([]string, 0, len(n))
+
+ for _, v := range n {
+ lis = append(lis, v.String())
+ }
+ return strings.Join(lis, ",")
+}
+
+// Match returns true if any match.
+func (n NamespaceSearch) Match(s string) bool {
+ for _, m := range n {
+ ok, err := filepath.Match(m.Raw(), s)
+ if err != nil {
+ return false
+ }
+ if ok {
+ return true
+ }
+ }
+
+ return false
+}
+
+// NamespaceNode implements a node search value
+type NamespaceNode string
+
+// String output string value
+func (n NamespaceNode) String() string { return string(n) }
+
+// Quote return quoted value.
+// func (n NamespaceNode) Quote() string { return `'` + n.Value() + `'` }
+
+// Value to return the value
+func (n NamespaceNode) Value() string { return string(n) }
+
+// Raw return raw value.
+func (n NamespaceNode) Raw() string { return string(n) }
+
+// Match returns true if any match.
+func (n NamespaceNode) Match(s string) bool { return match(n, s) }
+
+// NamespaceTrace implements a trace search value
+type NamespaceTrace string
+
+// String output string value
+func (n NamespaceTrace) String() string { return "trace:" + string(n) }
+
+// Quote return quoted value.
+// func (n NamespaceTrace) Quote() string { return `'` + n.Value() + `'` }
+
+// Value to return the value
+func (n NamespaceTrace) Value() string { return strings.Replace(string(n), "*", "%", -1) }
+
+// Raw return raw value.
+func (n NamespaceTrace) Raw() string { return string(n) }
+
+// Match returns true if any match.
+func (n NamespaceTrace) Match(s string) bool { return match(n, s) }
+
+// NamespaceStar implements a trace search value
+type NamespaceStar string
+
+// String output string value
+func (n NamespaceStar) String() string { return string(n) }
+
+// Quote return quoted value.
+// func (n NamespaceStar) Quote() string { return `'` + n.Value() + `'` }
+
+// Value to return the value
+func (n NamespaceStar) Value() string { return strings.Replace(string(n), "*", "%", -1) }
+
+// Raw return raw value.
+func (n NamespaceStar) Raw() string { return string(n) }
+
+// Match returns true if any match.
+func (n NamespaceStar) Match(s string) bool { return match(n, s) }
+
+func match(n NamespaceSpec, s string) bool {
+ ok, err := filepath.Match(n.Raw(), s)
+ if err != nil {
+ return false
+ }
+ return ok
+}
diff --git a/mercury/parse.go b/mercury/parse.go
new file mode 100644
index 0000000..6bcb33d
--- /dev/null
+++ b/mercury/parse.go
@@ -0,0 +1,110 @@
+package mercury
+
+import (
+ "bufio"
+ "io"
+ "strings"
+)
+
+func ParseText(body io.Reader) (config SpaceMap, err error) {
+ config = make(SpaceMap)
+
+ var space string
+ var name string
+ var tags []string
+ var notes []string
+ var seq uint64
+
+ scanner := bufio.NewScanner(body)
+ for scanner.Scan() {
+ line := scanner.Text()
+
+ if len(line) == 0 {
+ continue
+ }
+
+ if strings.HasPrefix(line, "#") {
+ notes = append(notes, strings.TrimPrefix(line, "# "))
+ continue
+ }
+
+ if strings.HasPrefix(line, "@") {
+ var c *Space
+ var ok bool
+
+ sp := strings.Fields(strings.TrimPrefix(line, "@"))
+ space = sp[0]
+
+ if c, ok = config[space]; !ok {
+ c = &Space{Space: space}
+ }
+
+ c.Notes = append(make([]string, 0, len(notes)), notes...)
+ c.Tags = append(make([]string, 0, len(sp[1:])), sp[1:]...)
+
+ config[space] = c
+ notes = notes[:0]
+ tags = tags[:0]
+
+ continue
+ }
+
+ if space == "" {
+ continue
+ }
+
+ sp := strings.SplitN(line, ":", 2)
+ if len(sp) < 2 {
+ continue
+ }
+
+ if strings.TrimSpace(sp[0]) == "" {
+ var c *Space
+ var ok bool
+
+ if c, ok = config[space]; !ok {
+ c = &Space{Space: space}
+ }
+
+ c.List[len(c.List)-1].Values = append(c.List[len(c.List)-1].Values, sp[1])
+ config[space] = c
+
+ continue
+ }
+
+ fields := strings.Fields(sp[0])
+ name = fields[0]
+ if len(fields) > 1 {
+ tags = fields[1:]
+ }
+
+ var c *Space
+ var ok bool
+
+ if c, ok = config[space]; !ok {
+ c = &Space{Space: space}
+ }
+
+ seq++
+ c.List = append(
+ c.List,
+ Value{
+ Seq: seq,
+ Name: name,
+ Tags: append(make([]string, 0, len(tags)), tags...),
+ Notes: append(make([]string, 0, len(notes)), notes...),
+ Values: []string{sp[1]},
+ },
+ )
+ config[space] = c
+
+ notes = notes[:0]
+ tags = tags[:0]
+ }
+
+ if err = scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ return
+}
diff --git a/mercury/public/favicon.ico b/mercury/public/favicon.ico
new file mode 100644
index 0000000..30e536c
Binary files /dev/null and b/mercury/public/favicon.ico differ
diff --git a/mercury/public/index.html b/mercury/public/index.html
new file mode 100644
index 0000000..9a020b1
--- /dev/null
+++ b/mercury/public/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+ ☿ Mercury ☿
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mercury/public/style.css b/mercury/public/style.css
new file mode 100644
index 0000000..d29091e
--- /dev/null
+++ b/mercury/public/style.css
@@ -0,0 +1,212 @@
+* {
+ font-weight: lighter;
+ font-family: 'fira code', monospace;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+}
+
+header {
+ margin: 0 50px;
+ background: rgb(63, 94, 251);
+ background: radial-gradient(circle, rgba(63, 94, 251, 1) 0%, rgba(252, 70, 107, 1) 100%);
+ height: 100px;
+ user-select: none;
+}
+
+h1 {
+ text-align: center;
+ color: white;
+ line-height: 100px;
+ margin: 0;
+}
+
+input,
+textarea {
+ font-size: small;
+ border: 2px solid cornflowerblue;
+}
+
+input.invalid {
+ border-color: red;
+}
+
+button {
+ border: 2px solid cornflowerblue;
+}
+
+code,
+pre {
+ font-size: small;
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+code strong {
+ font-weight: bold;
+}
+
+code dfn {
+ color: green
+}
+
+code i {
+ color: grey
+}
+
+code em {
+ color: orange;
+}
+
+code:focus {
+ animation: select 100ms step-end forwards;
+}
+
+footer {
+ position: fixed;
+ font-size: x-small;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 1em;
+ padding: 4px;
+ color: white;
+ border-top: 1px solid black;
+ background: cornflowerblue;
+}
+
+footer>span {
+ float: right;
+}
+
+.container {
+ margin: 0 50px;
+ display: grid;
+ min-height: 100vh;
+}
+
+.container .open {
+ grid-template-columns: 1fr 1fr;
+}
+
+.container>div {
+ overflow: auto;
+ padding: 10px;
+ background: rgb(238, 174, 202);
+ border: 0px ;
+}
+
+.container>div>code {
+ -webkit-user-select: all;
+ /* for Safari */
+ user-select: all;
+}
+
+.search {
+ display: flex
+}
+
+.search>div {
+ background-color: lightgrey;
+ border-radius: 5px 0px 0px 5px;
+ border: 2px solid cornflowerblue;
+ border-right: 0;
+ user-select: none;
+ padding: 0 2px;
+}
+
+.search>button {
+ border-left: 0;
+}
+
+.search>div {
+ line-height: 30px;
+}
+
+.search>input {
+ flex-grow: 5;
+}
+
+.search>button {
+ flex-grow: 2;
+ border-radius: 0px 5px 5px 0px;
+}
+
+.edit {
+ display: grid;
+ grid-template-columns: 1fr;
+}
+
+.edit>button {
+ line-height: 28px;
+ border-bottom: 0;
+ border-radius: 5px 5px 0 0;
+}
+
+.edit>textarea {
+ margin-top: 0;
+ border-radius: 0 0 5px 5px;
+}
+
+@keyframes select {
+ to {
+ -webkit-user-select: text;
+ user-select: text;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ html {
+ color: white;
+ background: #111
+ }
+
+ header {
+ background: #111;
+ background: radial-gradient(circle, rgba(2, 0, 36, 1) 0%, rgba(7, 80, 29, 1) 35%, rgba(0, 0, 0, 1) 100%);
+ }
+
+ h1 {
+ color: white;
+ }
+
+ .container>div {
+ background: #111;
+ /* background: linear-gradient(304deg, rgba(2, 0, 36, 1) 0%, rgba(77, 77, 77, 1) 18%, rgba(0, 0, 0, 1) 100%); */
+ border: 2px solid rgb(117, 117, 117);
+ }
+
+ .search>div {
+ background-color: grey;
+ border-color: rgb(117, 117, 117);
+ }
+
+ button {
+ color: white;
+ background: rgba(7, 80, 29, 1);
+ border: 2px solid rgb(117, 117, 117);
+ }
+
+ button:hover {
+ background: rgb(11, 121, 44);
+ }
+
+ button:active {
+ background: rgb(5, 59, 21);
+ }
+
+ input,
+ textarea {
+ color: white;
+ background-color: #111;
+ border: 2px solid rgb(117, 117, 117);
+ }
+ footer {
+ color: white;
+ border-top: 1px solid white;
+ background: #222;
+
+ }
+}
\ No newline at end of file
diff --git a/mercury/registry.go b/mercury/registry.go
new file mode 100644
index 0000000..9d53ff2
--- /dev/null
+++ b/mercury/registry.go
@@ -0,0 +1,400 @@
+package mercury
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/ident"
+ "go.sour.is/pkg/rsql"
+ "go.sour.is/pkg/set"
+ "golang.org/x/sync/errgroup"
+)
+
+type GetIndex interface {
+ GetIndex(context.Context, NamespaceSearch, *rsql.Program) (Config, error)
+}
+type GetConfig interface {
+ GetConfig(context.Context, NamespaceSearch, *rsql.Program, []string) (Config, error)
+}
+type WriteConfig interface {
+ WriteConfig(context.Context, Config) error
+}
+type GetRules interface {
+ GetRules(context.Context, ident.Ident) (Rules, error)
+}
+type GetNotify interface {
+ GetNotify(context.Context, string) (ListNotify, error)
+}
+type SendNotify interface {
+ SendNotify(context.Context, Notify) error
+}
+
+// type nobody struct{}
+
+// func (nobody) IsActive() bool { return true }
+// func (nobody) Identity() string { return "xuu" }
+// func (nobody) HasRole(r ...string) bool { return true }
+
+func (reg *registry) accessFilter(rules Rules, lis Config) (out Config, err error) {
+ accessList := make(map[string]struct{})
+ for _, o := range lis {
+ if _, ok := accessList[o.Space]; ok {
+ out = append(out, o)
+ continue
+ }
+
+ if role := rules.GetRoles("NS", o.Space); role.HasRole("read", "write") && !role.HasRole("deny") {
+ accessList[o.Space] = struct{}{}
+ out = append(out, o)
+ }
+ }
+
+ return
+}
+
+// HandlerItem a single handler matching
+type matcher[T any] struct {
+ Name string
+ Match NamespaceSearch
+ Priority int
+ Handler T
+}
+type matchers struct {
+ getIndex []matcher[GetIndex]
+ getConfig []matcher[GetConfig]
+ writeConfig []matcher[WriteConfig]
+ getRules []matcher[GetRules]
+ getNotify []matcher[GetNotify]
+ sendNotify []matcher[SendNotify]
+}
+
+// registry a list of handlers
+type registry struct {
+ handlers map[string]func(*Space) any
+ matchers matchers
+}
+
+func (m matcher[T]) String() string {
+ return fmt.Sprintf("%d: %s", m.Priority, m.Match)
+}
+
+// Registry handler
+var Registry *registry = ®istry{}
+
+func (r registry) String() string {
+ var buf strings.Builder
+ for h := range r.handlers {
+ buf.WriteString(h)
+ buf.WriteRune('\n')
+ }
+
+ return buf.String()
+}
+
+func (r *registry) resetMatchers() {
+ r.matchers.getIndex = r.matchers.getIndex[:0]
+ r.matchers.getConfig = r.matchers.getConfig[:0]
+ r.matchers.writeConfig = r.matchers.writeConfig[:0]
+ r.matchers.getRules = r.matchers.getRules[:0]
+ r.matchers.getNotify = r.matchers.getNotify[:0]
+ r.matchers.sendNotify = r.matchers.sendNotify[:0]
+}
+func (r *registry) sortMatchers() {
+ sort.Slice(r.matchers.getConfig, func(i, j int) bool { return r.matchers.getConfig[i].Priority < r.matchers.getConfig[j].Priority })
+ sort.Slice(r.matchers.getIndex, func(i, j int) bool { return r.matchers.getIndex[i].Priority < r.matchers.getIndex[j].Priority })
+ sort.Slice(r.matchers.writeConfig, func(i, j int) bool { return r.matchers.writeConfig[i].Priority < r.matchers.writeConfig[j].Priority })
+ sort.Slice(r.matchers.getRules, func(i, j int) bool { return r.matchers.getRules[i].Priority < r.matchers.getRules[j].Priority })
+ sort.Slice(r.matchers.getNotify, func(i, j int) bool { return r.matchers.getNotify[i].Priority < r.matchers.getNotify[j].Priority })
+ sort.Slice(r.matchers.sendNotify, func(i, j int) bool { return r.matchers.sendNotify[i].Priority < r.matchers.sendNotify[j].Priority })
+}
+func (r *registry) Register(name string, h func(*Space) any) {
+ if r.handlers == nil {
+ r.handlers = make(map[string]func(*Space) any)
+ }
+ r.handlers[name] = h
+}
+
+func (r *registry) Configure(m SpaceMap) error {
+ r.resetMatchers()
+ for space, c := range m {
+ space = strings.TrimPrefix(space, "mercury.source.")
+ handler, name, _ := strings.Cut(space, ".")
+ matches := c.FirstValue("match")
+ for _, match := range matches.Values {
+ ps := strings.Fields(match)
+ priority, err := strconv.Atoi(ps[0])
+ if err != nil {
+ return err
+ }
+ r.add(name, handler, ps[1], priority, c)
+ }
+ }
+
+ r.sortMatchers()
+ return nil
+}
+
+// Register add a handler to registry
+func (r *registry) add(name, handler, match string, priority int, cfg *Space) error {
+ // log.Infos("mercury regster", "match", match, "pri", priority)
+ mkHandler, ok := r.handlers[handler]
+ if !ok {
+ return fmt.Errorf("handler not registered: %s", handler)
+ }
+ hdlr := mkHandler(cfg)
+ if err, ok := hdlr.(error); ok {
+ return fmt.Errorf("%w: failed to config %s as handler: %s", err, name, handler)
+ }
+ if hdlr == nil {
+ return fmt.Errorf("failed to config %s as handler: %s", name, handler)
+ }
+
+ if hdlr, ok := hdlr.(GetIndex); ok {
+ r.matchers.getIndex = append(
+ r.matchers.getIndex,
+ matcher[GetIndex]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+ )
+ }
+ if hdlr, ok := hdlr.(GetConfig); ok {
+ r.matchers.getConfig = append(
+ r.matchers.getConfig,
+ matcher[GetConfig]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+ )
+ }
+
+ if hdlr, ok := hdlr.(WriteConfig); ok {
+
+ r.matchers.writeConfig = append(
+ r.matchers.writeConfig,
+ matcher[WriteConfig]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+ )
+ }
+ if hdlr, ok := hdlr.(GetRules); ok {
+ r.matchers.getRules = append(
+ r.matchers.getRules,
+ matcher[GetRules]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+ )
+ }
+ if hdlr, ok := hdlr.(GetNotify); ok {
+ r.matchers.getNotify = append(
+ r.matchers.getNotify,
+ matcher[GetNotify]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+ )
+ }
+ if hdlr, ok := hdlr.(SendNotify); ok {
+ r.matchers.sendNotify = append(
+ r.matchers.sendNotify,
+ matcher[SendNotify]{Name: name, Match: ParseNamespace(match), Priority: priority, Handler: hdlr},
+ )
+ }
+
+ return nil
+}
+
+// GetIndex query each handler that match namespace.
+func (r *registry) GetIndex(ctx context.Context, match, search string) (c Config, err error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ spec := ParseNamespace(match)
+ pgm := rsql.DefaultParse(search)
+ matches := make([]NamespaceSearch, len(r.matchers.getIndex))
+
+ for _, n := range spec {
+ for i, hdlr := range r.matchers.getIndex {
+ if hdlr.Match.Match(n.Raw()) {
+ matches[i] = append(matches[i], n)
+ }
+ }
+ }
+
+ wg, ctx := errgroup.WithContext(ctx)
+ slots := make(chan Config, len(r.matchers.getConfig))
+ wg.Go(func() error {
+ i := 0
+ for lis := range slots {
+ c = append(c, lis...)
+ i++
+ if i > len(slots) {
+ break
+ }
+ }
+ return nil
+ })
+
+ for i, hdlr := range r.matchers.getIndex {
+ i, hdlr := i, hdlr
+
+ wg.Go(func() error {
+ span.AddEvent(fmt.Sprintf("INDEX %s %s", hdlr.Name, hdlr.Match))
+ lis, err := hdlr.Handler.GetIndex(ctx, matches[i], pgm)
+ slots <- lis
+ return err
+ })
+ }
+
+ err = wg.Wait()
+ if err != nil {
+ return nil, err
+ }
+
+ return
+}
+
+// Search query each handler with a key=value search
+
+// GetConfig query each handler that match for fully qualified namespaces.
+func (r *registry) GetConfig(ctx context.Context, match, search, fields string) (Config, error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ spec := ParseNamespace(match)
+ pgm := rsql.DefaultParse(search)
+ flds := strings.Split(fields, ",")
+
+ matches := make([]NamespaceSearch, len(r.matchers.getConfig))
+
+ for _, n := range spec {
+ for i, hdlr := range r.matchers.getConfig {
+ if hdlr.Match.Match(n.Raw()) {
+ matches[i] = append(matches[i], n)
+ }
+ }
+ }
+
+ m := make(SpaceMap)
+ for i, hdlr := range r.matchers.getConfig {
+ if len(matches[i]) == 0 {
+ continue
+ }
+ span.AddEvent(fmt.Sprintf("QUERY %s %s", hdlr.Name, hdlr.Match))
+ lis, err := hdlr.Handler.GetConfig(ctx, matches[i], pgm, flds)
+ if err != nil {
+ return nil, err
+ }
+ m.Merge(lis...)
+ }
+
+ return m.ToArray(), nil
+}
+
+// WriteConfig write objects to backends
+func (r *registry) WriteConfig(ctx context.Context, spaces Config) error {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ matches := make([]Config, len(r.matchers.writeConfig))
+
+ for _, s := range spaces {
+ for i, hdlr := range r.matchers.writeConfig {
+ if hdlr.Match.Match(s.Space) {
+ matches[i] = append(matches[i], s)
+ break
+ }
+ }
+ }
+
+ for i, hdlr := range r.matchers.writeConfig {
+ if len(matches[i]) == 0 {
+ continue
+ }
+ span.AddEvent(fmt.Sprint("WRITE MATCH", hdlr.Name, hdlr.Match))
+ err := hdlr.Handler.WriteConfig(ctx, matches[i])
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// GetRules query each of the handlers for rules.
+func (r *registry) GetRules(ctx context.Context, user ident.Ident) (Rules, error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ s := set.New[Rule]()
+ for _, hdlr := range r.matchers.getRules {
+ span.AddEvent(fmt.Sprint("RULES", hdlr.Name, hdlr.Match))
+ lis, err := hdlr.Handler.GetRules(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+ s.Add(lis...)
+ }
+ var rules Rules = s.Values()
+ sort.Sort(rules)
+ return rules, nil
+}
+
+// GetNotify query each of the handlers for rules.
+func (r *registry) GetNotify(ctx context.Context, event string) (ListNotify, error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ s := set.New[Notify]()
+ for _, hdlr := range r.matchers.getNotify {
+ span.AddEvent(fmt.Sprint("GET NOTIFY", hdlr.Name, hdlr.Match))
+
+ lis, err := hdlr.Handler.GetNotify(ctx, event)
+ if err != nil {
+ return nil, err
+ }
+ s.Add(lis...)
+ }
+
+ return s.Values(), nil
+}
+
+func (r *registry) SendNotify(ctx context.Context, n Notify) (err error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ for _, hdlr := range r.matchers.sendNotify {
+ span.AddEvent(fmt.Sprint("SEND NOTIFY", hdlr.Name, hdlr.Match))
+
+ err := hdlr.Handler.SendNotify(ctx, n)
+ if err != nil {
+ return err
+ }
+ }
+
+ return
+}
+
+// Check if name matches notify
+func (n Notify) Check(name string) bool {
+ ok, err := filepath.Match(n.Match, name)
+ if err != nil {
+ return false
+ }
+ return ok
+}
+
+// Notify stores the attributes for a registry space
+type Notify struct {
+ Name string
+ Match string
+ Event string
+ Method string
+ URL string
+}
+
+// ListNotify array of notify
+type ListNotify []Notify
+
+// Find returns list of notify that match name.
+func (ln ListNotify) Find(name string) (lis ListNotify) {
+ lis = make(ListNotify, 0, len(ln))
+ for _, o := range ln {
+ if o.Check(name) {
+ lis = append(lis, o)
+ }
+ }
+ return
+}
diff --git a/mercury/routes.go b/mercury/routes.go
new file mode 100644
index 0000000..4041fdd
--- /dev/null
+++ b/mercury/routes.go
@@ -0,0 +1,267 @@
+package mercury
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/BurntSushi/toml"
+ "github.com/golang/gddo/httputil"
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/ident"
+)
+
+type root struct{}
+
+func NewHTTP() *root {
+ return &root{}
+}
+
+func (s *root) RegisterHTTP(mux *http.ServeMux) {
+ mux.Handle("/", http.FileServer(http.Dir("./mercury/public")))
+}
+func (s *root) RegisterAPIv1(mux *http.ServeMux) {
+ mux.HandleFunc("GET /mercury", s.indexV1)
+ // mux.HandleFunc("/mercury/config", s.configV1)
+ mux.HandleFunc("GET /mercury/config", s.configV1)
+ mux.HandleFunc("POST /mercury/config", s.storeV1)
+}
+
+func (s *root) configV1(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ s.storeV1(w, r)
+ return
+ }
+
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ var id ident.Ident = ident.FromContext(ctx)
+
+ if !id.Session().Active {
+ span.RecordError(fmt.Errorf("NO_AUTH"))
+ http.Error(w, "NO_AUTH", http.StatusUnauthorized)
+ return
+ }
+
+ rules, err := Registry.GetRules(ctx, id)
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ space := r.URL.Query().Get("space")
+ if space == "" {
+ space = "*"
+ }
+
+ log.Print("SPC: ", space)
+ ns := ParseNamespace(space)
+ log.Print("PRE: ", ns)
+ ns = rules.ReduceSearch(ns)
+ log.Print("POST: ", ns)
+
+ lis, err := Registry.GetConfig(ctx, ns.String(), "", "")
+ if err != nil {
+ http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ lis, err = Registry.accessFilter(rules, lis)
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ sort.Sort(lis)
+ var content string
+
+ switch httputil.NegotiateContentType(r, []string{
+ "text/plain",
+ "text/html",
+ "application/environ",
+ "application/ini",
+ "application/json",
+ "application/toml",
+ }, "text/plain") {
+ case "text/plain":
+ content = lis.String()
+ case "text/html":
+ content = lis.HTMLString()
+ case "application/environ":
+ content = lis.EnvString()
+ case "application/ini":
+ content = lis.INIString()
+ case "application/json":
+ json.NewEncoder(w).Encode(lis)
+ case "application/toml":
+ w.WriteHeader(200)
+ m := make(map[string]map[string][]string)
+ for _, o := range lis {
+ if _, ok := m[o.Space]; !ok {
+ m[o.Space] = make(map[string][]string)
+ }
+ for _, v := range o.List {
+ m[o.Space][v.Name] = append(m[o.Space][v.Name], v.Values...)
+ }
+ }
+ err := toml.NewEncoder(w).Encode(m)
+ if err != nil {
+ // log.Error(err)
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+ }
+
+ fmt.Fprint(w, content)
+}
+
+func (s *root) storeV1(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ var id = ident.FromContext(ctx)
+
+ if !id.Session().Active {
+ span.RecordError(fmt.Errorf("NO_AUTH"))
+ http.Error(w, "NO_AUTH", http.StatusUnauthorized)
+ return
+ }
+
+ var config SpaceMap
+ var err error
+ contentType, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
+ switch contentType {
+ case "text/plain":
+ config, err = ParseText(r.Body)
+ r.Body.Close()
+ case "application/x-www-form-urlencoded":
+ r.ParseForm()
+ config, err = ParseText(strings.NewReader(r.Form.Get("content")))
+ case "multipart/form-data":
+ r.ParseMultipartForm(1 << 20)
+ config, err = ParseText(strings.NewReader(r.Form.Get("content")))
+ default:
+ http.Error(w, "PARSE_ERR", http.StatusUnsupportedMediaType)
+ return
+ }
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "PARSE_ERR", 400)
+ return
+ }
+
+ {
+ rules, err := Registry.GetRules(ctx, id)
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ notify, err := Registry.GetNotify(ctx, "updated")
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+ _ = rules
+ var notifyActive = make(map[string]struct{})
+ var filteredConfigs Config
+ for ns, c := range config {
+ if !rules.GetRoles("NS", ns).HasRole("write") {
+ span.AddEvent(fmt.Sprint("SKIP", ns))
+ continue
+ }
+
+ span.AddEvent(fmt.Sprint("SAVE", ns))
+ for _, n := range notify.Find(ns) {
+ notifyActive[n.Name] = struct{}{}
+ }
+ filteredConfigs = append(filteredConfigs, c)
+ }
+
+ err = Registry.WriteConfig(ctx, filteredConfigs)
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+
+ span.AddEvent(fmt.Sprint("SEND NOTIFYS ", notifyActive))
+ for _, n := range notify {
+ if _, ok := notifyActive[n.Name]; ok {
+ err = Registry.SendNotify(ctx, n)
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR", http.StatusInternalServerError)
+ return
+ }
+ }
+ }
+ span.AddEvent("DONE!")
+ }
+
+ w.WriteHeader(202)
+ fmt.Fprint(w, "OK")
+}
+
+func (s *root) indexV1(w http.ResponseWriter, r *http.Request) {
+ ctx, span := lg.Span(r.Context())
+ defer span.End()
+
+ var id = ident.FromContext(ctx)
+
+ timer := time.Now()
+ defer func() { fmt.Println(time.Since(timer)) }()
+
+ if !id.Session().Active {
+ span.RecordError(fmt.Errorf("NO_AUTH"))
+ http.Error(w, "NO_AUTH", http.StatusUnauthorized)
+ return
+ }
+
+ rules, err := Registry.GetRules(ctx, id)
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ span.AddEvent(fmt.Sprint(rules))
+
+ space := r.URL.Query().Get("space")
+ if space == "" {
+ space = "*"
+ }
+
+ ns := ParseNamespace(space)
+ ns = rules.ReduceSearch(ns)
+ span.AddEvent(ns.String())
+
+ lis, err := Registry.GetIndex(ctx, ns.String(), "")
+ if err != nil {
+ span.RecordError(err)
+ http.Error(w, "ERR: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ sort.Sort(lis)
+
+ switch httputil.NegotiateContentType(r, []string{
+ "text/plain",
+ "application/json",
+ }, "text/plain") {
+ case "text/plain":
+ _, err = fmt.Fprint(w, lis.StringList())
+ span.RecordError(err)
+ case "application/json":
+ err = json.NewEncoder(w).Encode(lis)
+ span.RecordError(err)
+ }
+}
diff --git a/mercury/sql/init-pg.sql b/mercury/sql/init-pg.sql
new file mode 100644
index 0000000..780ed09
--- /dev/null
+++ b/mercury/sql/init-pg.sql
@@ -0,0 +1,116 @@
+CREATE SEQUENCE IF NOT EXISTS mercury_spaces_id_seq;
+
+CREATE TABLE IF NOT EXISTS mercury_spaces
+(
+ space character varying NOT NULL,
+ id integer NOT NULL DEFAULT nextval('mercury_spaces_id_seq'::regclass),
+ notes character varying[] NOT NULL DEFAULT '{}'::character varying[],
+ tags character varying[] NOT NULL DEFAULT '{}'::character varying[],
+ CONSTRAINT mercury_namespace_pk PRIMARY KEY (id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS mercury_namespace_space_uindex
+ ON mercury_spaces USING btree
+ (space ASC NULLS LAST);
+
+CREATE TABLE IF NOT EXISTS mercury_values
+(
+ id integer NOT NULL,
+ seq integer NOT NULL,
+ name character varying NOT NULL,
+ "values" character varying[] NOT NULL DEFAULT '{}'::character varying[],
+ tags character varying[] NOT NULL DEFAULT '{}'::character varying[],
+ notes character varying[] NOT NULL DEFAULT '{}'::character varying[],
+ CONSTRAINT mercury_values_pk PRIMARY KEY (id, seq)
+);
+CREATE INDEX IF NOT EXISTS mercury_values_name_index
+ ON mercury_values USING btree
+ (name ASC NULLS LAST);
+
+CREATE OR REPLACE VIEW mercury_registry_vw
+ AS
+ SELECT
+ s.id,
+ v.seq,
+ s.space,
+ v.name,
+ v."values",
+ v.notes,
+ v.tags
+ FROM mercury_spaces s
+ JOIN mercury_values v ON s.id = v.id;
+
+CREATE OR REPLACE VIEW mercury_groups_vw
+ AS
+ SELECT DISTINCT
+ unnest(vw."values") AS user_id,
+ vw.name AS group_id
+ FROM mercury_registry_vw vw
+ WHERE vw.space::text = 'mercury.groups'::text;
+
+CREATE OR REPLACE VIEW mercury_group_rules_vw
+ AS
+ WITH
+ tt as (
+ SELECT DISTINCT
+ vw.name AS group_id,
+ unnest(vw."values") AS rules
+ FROM mercury_registry_vw vw
+ WHERE vw.space::text = 'mercury.policy'::text
+ )
+ SELECT tt.group_id,
+ split_part(tt.rules::text, ' '::text, 1) AS role,
+ split_part(tt.rules::text, ' '::text, 2) AS type,
+ split_part(tt.rules::text, ' '::text, 3) AS match
+ FROM tt;
+
+CREATE OR REPLACE VIEW mercury_user_rules_vw
+ AS
+ WITH
+ tt as (
+ SELECT DISTINCT
+ vw.name AS group_id,
+ unnest(vw."values") AS rules
+ FROM mercury_registry_vw vw
+ WHERE vw.space::text = 'mercury.policy'::text
+ )
+ SELECT
+ g.user_id,
+ split_part(tt.rules::text, ' '::text, 1) AS role,
+ split_part(tt.rules::text, ' '::text, 2) AS type,
+ split_part(tt.rules::text, ' '::text, 3) AS match
+ FROM mercury_groups_vw g
+ JOIN tt ON g.group_id::text = tt.group_id::text;
+
+CREATE OR REPLACE VIEW mercury_rules_vw
+ AS
+ SELECT
+ 'U-'::text || vw.user_id::text AS id,
+ vw.role,
+ vw.type,
+ vw.match
+ FROM mercury_user_rules_vw vw
+ UNION
+ SELECT
+ 'G-'::text || vw.group_id::text AS id,
+ vw.role,
+ vw.type,
+ vw.match
+ FROM mercury_group_rules_vw vw;
+
+CREATE OR REPLACE VIEW mercury_notify_vw
+ AS
+ WITH
+ tt as (
+ SELECT DISTINCT
+ vw.name,
+ unnest(vw."values") AS rules
+ FROM mercury_registry_vw vw
+ WHERE vw.space::text = 'mercury.notify'::text
+ )
+ SELECT
+ tt.name,
+ split_part(tt.rules::text, ' '::text, 1) AS match,
+ split_part(tt.rules::text, ' '::text, 2) AS event,
+ split_part(tt.rules::text, ' '::text, 3) AS method,
+ split_part(tt.rules::text, ' '::text, 4) AS url
+ FROM tt;
diff --git a/mercury/sql/init-sql3.sql b/mercury/sql/init-sql3.sql
new file mode 100644
index 0000000..0be20bf
--- /dev/null
+++ b/mercury/sql/init-sql3.sql
@@ -0,0 +1,118 @@
+CREATE TABLE IF NOT EXISTS mercury_spaces
+(
+ space character varying NOT NULL unique,
+ id integer NOT NULL CONSTRAINT mercury_namespace_pk PRIMARY KEY autoincrement,
+ notes json NOT NULL DEFAULT '[]',
+ tags json NOT NULL DEFAULT '[]'
+);
+
+CREATE TABLE IF NOT EXISTS mercury_values
+(
+ id integer NOT NULL,
+ seq integer NOT NULL,
+ name character varying NOT NULL,
+ "values" json NOT NULL DEFAULT '[]',
+ tags json NOT NULL DEFAULT '[]',
+ notes json NOT NULL DEFAULT '[]',
+ CONSTRAINT mercury_values_pk PRIMARY KEY (id, seq)
+);
+
+drop view if exists mercury_registry_vw;
+CREATE VIEW if not exists mercury_registry_vw
+ AS
+ SELECT
+ s.id,
+ v.seq,
+ s.space,
+ v.name,
+ v."values",
+ v.notes,
+ v.tags
+ FROM mercury_spaces s
+ JOIN mercury_values v ON s.id = v.id;
+
+drop view if exists mercury_groups_vw;
+CREATE VIEW if not exists mercury_groups_vw
+ AS
+ SELECT DISTINCT
+ j.value AS user_id,
+ vw.name AS group_id
+ FROM mercury_registry_vw vw, json_each(vw."values") j
+ WHERE vw.space = 'mercury.groups';
+
+drop view if exists mercury_group_rules_vw;
+CREATE VIEW if not exists mercury_group_rules_vw
+ AS
+ WITH
+ tt as (
+ SELECT DISTINCT
+ vw.name AS group_id,
+ j.value AS rules
+ FROM mercury_registry_vw vw, json_each(vw."values") j
+ WHERE vw.space = 'mercury.policy'
+ )
+ SELECT tt.group_id,
+ tt.rules rule,
+ '' AS role,
+ '' AS type,
+ ''AS match
+ FROM tt;
+
+drop view if exists mercury_user_rules_vw;
+CREATE VIEW if not exists mercury_user_rules_vw
+ AS
+ WITH
+ tt as (
+ SELECT DISTINCT
+ vw.name AS group_id,
+ j.value AS rules
+ FROM mercury_registry_vw vw, json_each(vw."values") j
+ WHERE vw.space = 'mercury.policy'
+ )
+ SELECT
+ g.user_id,
+ tt.rules rule,
+ '' AS role,
+ '' AS type,
+ '' AS match
+ FROM mercury_groups_vw g
+ JOIN tt ON g.group_id = tt.group_id;
+
+drop view if exists mercury_rules_vw;
+CREATE VIEW if not exists mercury_rules_vw
+ AS
+ SELECT
+ 'U-' || vw.user_id AS id,
+ vw.rule,
+ vw.role,
+ vw.type,
+ vw.match
+ FROM mercury_user_rules_vw vw
+ UNION
+ SELECT
+ 'G-' || vw.group_id AS id,
+ vw.rule,
+ vw.role,
+ vw.type,
+ vw.match
+ FROM mercury_group_rules_vw vw;
+
+drop view if exists mercury_notify_vw;
+CREATE VIEW if not exists mercury_notify_vw
+ AS
+ WITH
+ tt as (
+ SELECT DISTINCT
+ vw.name,
+ j.value AS rules
+ FROM mercury_registry_vw vw, json_each(vw."values") j
+ WHERE vw.space = 'mercury.notify'
+ )
+ SELECT
+ tt.name,
+ tt.rules rule,
+ substr(tt.rules, 1, instr(tt.rules, ' ')-1) AS match,
+ substr(tt.rules, instr(tt.rules, ' ')+1, instr(substr(tt.rules, instr(tt.rules, ' ')+1), ' ')-1) AS event,
+ '' AS method,
+ '' as url
+ FROM tt;
diff --git a/mercury/sql/list-string.go b/mercury/sql/list-string.go
new file mode 100644
index 0000000..6b6fa2f
--- /dev/null
+++ b/mercury/sql/list-string.go
@@ -0,0 +1,130 @@
+package sql
+
+import (
+ "database/sql/driver"
+ "fmt"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+type valueFn func() (v driver.Value, err error)
+
+func (fn valueFn) Value() (v driver.Value, err error) {
+ return fn()
+}
+
+type scanFn func(value any) error
+
+func (fn scanFn) Scan(v any) error {
+ return fn(v)
+}
+
+func listScan(e *[]string, ends [2]rune) scanFn {
+ return func(value any) error {
+ var str string
+ switch v := value.(type) {
+ case string:
+ str = v
+ case []byte:
+ str = string(v)
+ case []rune:
+ str = string(v)
+ default:
+ return fmt.Errorf("array must be uint64, got: %T", value)
+ }
+
+ if e == nil {
+ *e = []string{}
+ }
+
+ str = trim(str, ends[0], ends[1])
+ if len(str) == 0 {
+ return nil
+ }
+
+ for _, s := range splitComma(string(str)) {
+ *e = append(*e, s)
+ }
+
+ return nil
+ }
+}
+
+func listValue(e []string, ends [2]rune) valueFn {
+ return func() (value driver.Value, err error) {
+ var b strings.Builder
+
+ if len(e) == 0 {
+ return string(ends[:]), nil
+ }
+
+ _, err = b.WriteRune(ends[0])
+ if err != nil {
+ return
+ }
+
+ var arr []string
+ for _, s := range e {
+ arr = append(arr, `"`+s+`"`)
+ }
+ _, err = b.WriteString(strings.Join(arr, ","))
+ if err != nil {
+ return
+ }
+
+ _, err = b.WriteRune(ends[1])
+ if err != nil {
+ return
+ }
+
+ return b.String(), nil
+ }
+}
+
+func splitComma(s string) []string {
+ lastQuote := rune(0)
+ f := func(c rune) bool {
+ switch {
+ case c == lastQuote:
+ lastQuote = rune(0)
+ return false
+ case lastQuote != rune(0):
+ return false
+ case unicode.In(c, unicode.Quotation_Mark):
+ lastQuote = c
+ return false
+ default:
+ return c == ','
+ }
+ }
+ lis := strings.FieldsFunc(s, f)
+
+ var out []string
+ for _, s := range lis {
+ s = trim(s, '"', '"')
+ out = append(out, s)
+ }
+
+ return out
+}
+
+func trim(s string, start, end rune) string {
+ r0, size0 := utf8.DecodeRuneInString(s)
+ if size0 == 0 {
+ return s
+ }
+ if r0 != start {
+ return s
+ }
+
+ r1, size1 := utf8.DecodeLastRuneInString(s)
+ if size1 == 0 {
+ return s
+ }
+ if r1 != end {
+ return s
+ }
+
+ return s[size0 : len(s)-size1]
+}
diff --git a/mercury/sql/notify.go b/mercury/sql/notify.go
new file mode 100644
index 0000000..3f8711b
--- /dev/null
+++ b/mercury/sql/notify.go
@@ -0,0 +1,55 @@
+package sql
+
+import (
+ "context"
+ "strings"
+
+ "github.com/Masterminds/squirrel"
+
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/mercury"
+)
+
+// Notify stores the attributes for a registry space
+type Notify struct {
+ Name string `json:"name" view:"mercury_notify_vw"`
+ Match string `json:"match"`
+ Event string `json:"event"`
+ Method string `json:"-" db:"method"`
+ URL string `json:"-" db:"url"`
+}
+
+// GetNotify get list of rules
+func (pgm *sqlHandler) GetNotify(ctx context.Context, event string) (lis mercury.ListNotify, err error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ rows, err := squirrel.Select(`"name"`, `"match"`, `"event"`, `"method"`, `"url"`, `"rule"`).
+ From("mercury_notify_vw").
+ Where(squirrel.Eq{"event": event}).
+ PlaceholderFormat(squirrel.Dollar).
+ RunWith(pgm.db).
+ QueryContext(context.TODO())
+
+ if err != nil {
+ return nil, err
+ }
+
+ defer rows.Close()
+ for rows.Next() {
+ var s mercury.Notify
+ var rule string
+ err = rows.Scan(&s.Name, &s.Match, &s.Event, &s.Method, &s.URL, &rule)
+ if err != nil {
+ return nil, err
+ }
+ if rule != "" {
+ s.Match, rule, _ = strings.Cut(rule, " ")
+ s.Event, rule, _ = strings.Cut(rule, " ")
+ s.Method, s.URL, _ = strings.Cut(rule, " ")
+ }
+ lis = append(lis, s)
+ }
+
+ return lis, rows.Err()
+}
diff --git a/mercury/sql/otel.go b/mercury/sql/otel.go
new file mode 100644
index 0000000..354067a
--- /dev/null
+++ b/mercury/sql/otel.go
@@ -0,0 +1,40 @@
+package sql
+
+import (
+ "database/sql"
+
+ "go.nhat.io/otelsql"
+ semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
+)
+
+func openDB(driver, dsn string) (*sql.DB, error) {
+ system := semconv.DBSystemPostgreSQL
+ if driver == "sqlite" {
+ system = semconv.DBSystemSqlite
+ }
+
+ // Register the otelsql wrapper for the provided postgres driver.
+ driverName, err := otelsql.Register(driver,
+ otelsql.AllowRoot(),
+ otelsql.TraceQueryWithoutArgs(),
+ otelsql.TraceRowsClose(),
+ otelsql.TraceRowsAffected(),
+ // otelsql.WithDatabaseName("my_database"), // Optional.
+ otelsql.WithSystem(system), // Optional.
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // Connect to a Postgres database using the postgres driver wrapper.
+ db, err := sql.Open(driverName, dsn)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := otelsql.RecordStats(db); err != nil {
+ return nil, err
+ }
+
+ return db, nil
+}
diff --git a/mercury/sql/rules.go b/mercury/sql/rules.go
new file mode 100644
index 0000000..90808be
--- /dev/null
+++ b/mercury/sql/rules.go
@@ -0,0 +1,99 @@
+package sql
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/Masterminds/squirrel"
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/mercury"
+ "go.sour.is/pkg/ident"
+)
+
+type grouper interface {
+ GetGroups() []string
+}
+
+// GetRules get list of rules
+func (p *sqlHandler) GetRules(ctx context.Context, user ident.Ident) (lis mercury.Rules, err error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ var ids []string
+ ids = append(ids, "U-"+user.Identity())
+ switch u := user.(type) {
+ case grouper:
+ for _, g := range u.GetGroups() {
+ ids = append(ids, "G-"+g)
+ }
+ }
+ if groups, err := p.getGroups(ctx, user.Identity()); err != nil {
+ for _, g := range groups {
+ ids = append(ids, "G-"+g)
+ }
+ }
+
+ query := squirrel.Select(`"role"`, `"type"`, `"match"`, `"rule"`).
+ From("mercury_rules_vw").
+ Where(squirrel.Eq{"id": ids}).
+ PlaceholderFormat(squirrel.Dollar)
+ rows, err := query.
+ RunWith(p.db).
+ QueryContext(ctx)
+
+ if err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+
+ defer rows.Close()
+ for rows.Next() {
+ var s mercury.Rule
+ var rule string
+ err = rows.Scan(&s.Role, &s.Type, &s.Match, &rule)
+ if err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ if rule != "" {
+ s.Role, rule, _ = strings.Cut(rule, " ")
+ s.Type, s.Match, _ = strings.Cut(rule, " ")
+ }
+ lis = append(lis, s)
+ }
+ err = rows.Err()
+ span.RecordError(err)
+
+ span.AddEvent(fmt.Sprint("read rules ", len(lis)))
+ return lis, err
+}
+
+// getGroups get list of groups
+func (pgm *sqlHandler) getGroups(ctx context.Context, user string) (lis []string, err error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ rows, err := squirrel.Select("group_id").
+ From("mercury_groups_vw").
+ Where(squirrel.Eq{"user_id": user}).
+ PlaceholderFormat(squirrel.Dollar).
+ RunWith(pgm.db).
+ QueryContext(ctx)
+
+ if err != nil {
+ return nil, err
+ }
+
+ defer rows.Close()
+ for rows.Next() {
+ var s string
+ err = rows.Scan(&s)
+ if err != nil {
+ return nil, err
+ }
+ lis = append(lis, s)
+ }
+
+ return lis, rows.Err()
+}
diff --git a/mercury/sql/sql.go b/mercury/sql/sql.go
new file mode 100644
index 0000000..92193a1
--- /dev/null
+++ b/mercury/sql/sql.go
@@ -0,0 +1,419 @@
+package sql
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log"
+ "strings"
+
+ sq "github.com/Masterminds/squirrel"
+ "go.sour.is/pkg/lg"
+ "go.sour.is/pkg/mercury"
+ "go.sour.is/pkg/rsql"
+ "golang.org/x/exp/maps"
+)
+
+type sqlHandler struct {
+ db *sql.DB
+ paceholderFormat sq.PlaceholderFormat
+ listFormat [2]rune
+}
+
+var (
+ _ mercury.GetIndex = (*sqlHandler)(nil)
+ _ mercury.GetConfig = (*sqlHandler)(nil)
+ _ mercury.GetRules = (*sqlHandler)(nil)
+ _ mercury.WriteConfig = (*sqlHandler)(nil)
+)
+
+func Register() {
+ mercury.Registry.Register("sql", func(s *mercury.Space) any {
+ var dsn string
+ var opts strings.Builder
+ var dbtype string
+ for _, c := range s.List {
+ if c.Name == "match" {
+ continue
+ }
+ if c.Name == "dbtype" {
+ dbtype = c.First()
+ continue
+ }
+ if c.Name == "dsn" {
+ dsn = c.First()
+ break
+ }
+ fmt.Fprintln(&opts, c.Name, "=", c.First())
+ }
+ if dsn == "" {
+ dsn = opts.String()
+ }
+
+ db, err := openDB(dbtype, dsn)
+ if err != nil {
+ return err
+ }
+ if err = db.Ping(); err != nil {
+ return err
+ }
+ switch dbtype {
+ case "sqlite":
+ return &sqlHandler{db, sq.Dollar, [2]rune{'[', ']'}}
+ case "postgres":
+ return &sqlHandler{db, sq.Dollar, [2]rune{'{', '}'}}
+ default:
+ return fmt.Errorf("unsupported dbtype: %s", dbtype)
+ }
+ })
+}
+
+type Space struct {
+ mercury.Space
+ ID uint64
+}
+type Value struct {
+ mercury.Value
+ ID uint64
+}
+
+func (p *sqlHandler) GetIndex(ctx context.Context, search mercury.NamespaceSearch, pgm *rsql.Program) (mercury.Config, error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ cols := rsql.GetDbColumns(mercury.Space{})
+ where, err := getWhere(search, cols)
+ if err != nil {
+ return nil, err
+ }
+ lis, err := p.listSpace(ctx, nil, where)
+ if err != nil {
+ log.Println(err)
+ return nil, err
+ }
+
+ config := make(mercury.Config, len(lis))
+ for i, s := range lis {
+ config[i] = &s.Space
+ }
+
+ return config, nil
+}
+
+func (p *sqlHandler) GetConfig(ctx context.Context, search mercury.NamespaceSearch, pgm *rsql.Program, fields []string) (mercury.Config, error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ idx, err := p.GetIndex(ctx, search, pgm)
+ if err != nil {
+ return nil, err
+ }
+ spaceMap := make(map[string]int, len(idx))
+ for u, s := range idx {
+ spaceMap[s.Space] = u
+ }
+
+ where, err := getWhere(search, rsql.GetDbColumns(mercury.Value{}))
+ if err != nil {
+ return nil, err
+ }
+ query := sq.Select(`"space"`, `"name"`, `"seq"`, `"notes"`, `"tags"`, `"values"`).
+ From("mercury_registry_vw").
+ Where(where).
+ OrderBy("space asc", "name asc").
+ PlaceholderFormat(p.paceholderFormat)
+ span.AddEvent(lg.LogQuery(query.ToSql()))
+ rows, err := query.RunWith(p.db).
+ QueryContext(ctx)
+
+ if err != nil {
+ log.Println(err)
+ return nil, err
+ }
+
+ defer rows.Close()
+ for rows.Next() {
+ var s mercury.Value
+
+ err = rows.Scan(
+ &s.Space,
+ &s.Name,
+ &s.Seq,
+ listScan(&s.Notes, p.listFormat),
+ listScan(&s.Tags, p.listFormat),
+ listScan(&s.Values, p.listFormat),
+ )
+ if err != nil {
+ return nil, err
+ }
+ if u, ok := spaceMap[s.Space]; ok {
+ idx[u].List = append(idx[u].List, s)
+ }
+ }
+
+ err = rows.Err()
+ span.RecordError(err)
+
+ span.AddEvent(fmt.Sprint("read index ", len(idx)))
+ return idx, err
+}
+
+func (p *sqlHandler) listSpace(ctx context.Context, tx sq.BaseRunner, where sq.Sqlizer) ([]*Space, error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ if tx == nil {
+ tx = p.db
+ }
+
+ query := sq.Select(`"id"`, `"space"`, `"tags"`, `"notes"`).
+ From("mercury_spaces").
+ Where(where).
+ OrderBy("space asc").
+ PlaceholderFormat(sq.Dollar)
+ span.AddEvent(lg.LogQuery(query.ToSql()))
+ rows, err := query.RunWith(tx).
+ QueryContext(ctx)
+
+ if err != nil {
+ log.Println(err)
+ return nil, err
+ }
+ defer rows.Close()
+
+ var lis []*Space
+ for rows.Next() {
+ var s Space
+ err = rows.Scan(
+ &s.ID,
+ &s.Space.Space,
+ listScan(&s.Space.Notes, p.listFormat),
+ listScan(&s.Space.Tags, p.listFormat),
+ )
+ if err != nil {
+ return nil, err
+ }
+ lis = append(lis, &s)
+ }
+
+ err = rows.Err()
+ span.RecordError(err)
+
+ span.AddEvent(fmt.Sprint("read config ", len(lis)))
+ return lis, err
+}
+
+// WriteConfig writes a config map to database
+func (p *sqlHandler) WriteConfig(ctx context.Context, config mercury.Config) (err error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ // Delete spaces that are present in input but are empty.
+ deleteSpaces := make(map[string]struct{})
+
+ // get names of each space
+ var names = make(map[string]int)
+ for i, v := range config {
+ names[v.Space] = i
+
+ if len(v.Tags) == 0 && len(v.Notes) == 0 && len(v.List) == 0 {
+ deleteSpaces[v.Space] = struct{}{}
+ }
+ }
+
+ tx, err := p.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil && tx != nil {
+ tx.Rollback()
+ }
+ }()
+
+ // get current spaces
+ lis, err := p.listSpace(ctx, tx, sq.Eq{"space": maps.Keys(names)})
+ if err != nil {
+ return
+ }
+
+ // determine which are being updated
+ var deleteIDs []uint64
+ var updateIDs []uint64
+ var currentNames = make(map[string]struct{}, len(lis))
+ var updateSpaces []*mercury.Space
+ var insertSpaces []*mercury.Space
+
+ for _, s := range lis {
+ spaceName := s.Space.Space
+ currentNames[spaceName] = struct{}{}
+
+ if _, ok := deleteSpaces[spaceName]; ok {
+ deleteIDs = append(deleteIDs, s.ID)
+ continue
+ }
+
+ updateSpaces = append(updateSpaces, config[names[spaceName]])
+ updateIDs = append(updateIDs, s.ID)
+ }
+ for _, s := range config {
+ spaceName := s.Space
+ if _, ok := currentNames[spaceName]; !ok {
+ insertSpaces = append(insertSpaces, s)
+ }
+ }
+
+ // delete spaces
+ if ids := deleteIDs; len(ids) > 0 {
+ _, err = sq.Delete("mercury_spaces").Where(sq.Eq{"id": ids}).RunWith(tx).PlaceholderFormat(sq.Dollar).ExecContext(ctx)
+ if err != nil {
+ return err
+ }
+ }
+
+ // delete values
+ if ids := append(updateIDs, deleteIDs...); len(ids) > 0 {
+ _, err = sq.Delete("mercury_values").Where(sq.Eq{"id": ids}).RunWith(tx).PlaceholderFormat(sq.Dollar).ExecContext(ctx)
+ if err != nil {
+ return err
+ }
+ }
+
+ var newValues []*Value
+
+ // update spaces
+ for i, u := range updateSpaces {
+ query := sq.Update("mercury_spaces").
+ Where(sq.Eq{"id": updateIDs[i]}).
+ Set("tags", listValue(u.Tags, p.listFormat)).
+ Set("notes", listValue(u.Notes, p.listFormat)).
+ PlaceholderFormat(sq.Dollar)
+ span.AddEvent(lg.LogQuery(query.ToSql()))
+ _, err := query.RunWith(tx).ExecContext(ctx)
+
+ if err != nil {
+ return err
+ }
+ // log.Debugf("UPDATED %d SPACES", len(updateSpaces))
+ for _, v := range u.List {
+ newValues = append(newValues, &Value{Value: v, ID: updateIDs[i]})
+ }
+ }
+
+ // insert spaces
+ for _, s := range insertSpaces {
+ var id uint64
+ query := sq.Insert("mercury_spaces").
+ PlaceholderFormat(sq.Dollar).
+ Columns("space", "tags", "notes").
+ Values(s.Space, listValue(s.Tags, p.listFormat), listValue(s.Notes, p.listFormat)).
+ Suffix("RETURNING \"id\"")
+ span.AddEvent(lg.LogQuery(query.ToSql()))
+
+ err := query.
+ RunWith(tx).
+ QueryRowContext(ctx).
+ Scan(&id)
+ if err != nil {
+ s, v, _ := query.ToSql()
+ log.Println(s, v, err)
+ return err
+ }
+ for _, v := range s.List {
+ newValues = append(newValues, &Value{Value: v, ID: id})
+ }
+ }
+
+ // write all values to db.
+ err = p.writeValues(ctx, tx, newValues)
+ // log.Debugf("WROTE %d ATTRS", len(attrs))
+
+ tx.Commit()
+ tx = nil
+
+ return
+}
+
+// writeValues writes the values to db
+func (p *sqlHandler) writeValues(ctx context.Context, tx sq.BaseRunner, lis []*Value) (err error) {
+ ctx, span := lg.Span(ctx)
+ defer span.End()
+
+ if len(lis) == 0 {
+ return nil
+ }
+
+ newInsert := func() sq.InsertBuilder {
+ return sq.Insert("mercury_values").
+ RunWith(tx).
+ PlaceholderFormat(sq.Dollar).
+ Columns(
+ `"id"`,
+ `"seq"`,
+ `"name"`,
+ `"values"`,
+ `"notes"`,
+ `"tags"`,
+ )
+ }
+ chunk := int(65000 / 3)
+ insert := newInsert()
+ for i, s := range lis {
+ insert = insert.Values(
+ s.ID,
+ s.Seq,
+ s.Name,
+ listValue(s.Values, p.listFormat),
+ listValue(s.Notes, p.listFormat),
+ listValue(s.Tags, p.listFormat),
+ )
+ // log.Debug(s.Name)
+
+ if i > 0 && i%chunk == 0 {
+ // log.Debugf("inserting %v rows into %v", i%chunk, d.Table)
+ // log.Debug(insert.ToSql())
+ span.AddEvent(lg.LogQuery(insert.ToSql()))
+
+ _, err = insert.ExecContext(ctx)
+ if err != nil {
+ // log.Error(err)
+ return
+ }
+
+ insert = newInsert()
+ }
+ }
+ if len(lis)%chunk > 0 {
+ // log.Debugf("inserting %v rows into %v", len(lis)%chunk, d.Table)
+ // log.Debug(insert.ToSql())
+ span.AddEvent(lg.LogQuery(insert.ToSql()))
+
+ _, err = insert.ExecContext(ctx)
+ if err != nil {
+ // log.Error(err)
+ return
+ }
+ }
+
+ return
+}
+
+func getWhere(search mercury.NamespaceSearch, d *rsql.DbColumns) (sq.Sqlizer, error) {
+ var where sq.Or
+ space, err := d.Col("space")
+ if err != nil {
+ return nil, err
+ }
+ for _, m := range search {
+ switch m.(type) {
+ case mercury.NamespaceNode:
+ where = append(where, sq.Eq{space: m.Value()})
+ case mercury.NamespaceStar:
+ where = append(where, sq.Like{space: m.Value()})
+ case mercury.NamespaceTrace:
+ e := sq.Expr(`? LIKE `+space+` || '%'`, m.Value())
+ where = append(where, e)
+ }
+ }
+ return where, nil
+}
diff --git a/mux/httpmux.go b/mux/httpmux.go
index add065f..18982be 100644
--- a/mux/httpmux.go
+++ b/mux/httpmux.go
@@ -8,6 +8,7 @@ type mux struct {
*http.ServeMux
api *http.ServeMux
wellknown *http.ServeMux
+ handler http.Handler
}
func (mux *mux) Add(fns ...interface{ RegisterHTTP(*http.ServeMux) }) {
@@ -24,16 +25,30 @@ func (mux *mux) Add(fns ...interface{ RegisterHTTP(*http.ServeMux) }) {
// log.Printf("WellKnown: %T", fn)
fn.RegisterWellKnown(mux.wellknown)
}
+
+ if fn, ok := fn.(interface{ RegisterMiddleware(http.Handler) http.Handler }); ok {
+ hdlr := mux.handler
+ // log.Printf("WellKnown: %T", fn)
+ mux.handler = fn.RegisterMiddleware(hdlr)
+ }
}
}
+
+func (mux *mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ mux.handler.ServeHTTP(w, r)
+}
+
+
func New() *mux {
mux := &mux{
api: http.NewServeMux(),
wellknown: http.NewServeMux(),
ServeMux: http.NewServeMux(),
}
+ mux.Handle("/v1/", http.StripPrefix("/v1", mux.api))
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", mux.api))
mux.Handle("/.well-known/", http.StripPrefix("/.well-known", mux.wellknown))
+ mux.handler = mux.ServeMux
return mux
}
diff --git a/rsql/ast.go b/rsql/ast.go
new file mode 100644
index 0000000..ca441cd
--- /dev/null
+++ b/rsql/ast.go
@@ -0,0 +1,255 @@
+package rsql
+
+import (
+ "bytes"
+ "strings"
+)
+
+// Node is the smallest unit of ast
+type Node interface {
+ TokenLiteral() string
+ String() string
+}
+
+// Statement is a executable tree
+type Statement interface {
+ Node
+ statementNode()
+}
+
+// Expression is a portion of tree
+type Expression interface {
+ Node
+ expressionNode()
+}
+
+// Identifier is a variable name
+type Identifier struct {
+ Token Token
+ Value string
+}
+
+func (i *Identifier) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (i *Identifier) TokenLiteral() string { return i.Token.Literal }
+
+// String returns a string representation of value
+func (i *Identifier) String() string { return i.Value }
+
+// Integer is a numeric value
+type Integer struct {
+ Token Token
+ Value int64
+}
+
+func (i *Integer) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (i *Integer) TokenLiteral() string { return i.Token.Literal }
+
+// String returns a string representation of value
+func (i *Integer) String() string { return i.Token.Literal }
+
+// Float is a floating point value
+type Float struct {
+ Token Token
+ Value float64
+}
+
+func (i *Float) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (i *Float) TokenLiteral() string { return i.Token.Literal }
+
+// String returns a string representation of value
+func (i *Float) String() string { return i.Token.Literal }
+
+// Bool is a boolean value
+type Bool struct {
+ Token Token
+ Value bool
+}
+
+func (i *Bool) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (i *Bool) TokenLiteral() string { return i.Token.Literal }
+
+// String returns a string representation of value
+func (i *Bool) String() string { return i.Token.Literal }
+
+// Null is an empty value
+type Null struct {
+ Token Token
+}
+
+func (i *Null) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (i *Null) TokenLiteral() string { return i.Token.Literal }
+
+// String returns a string representation of value
+func (i *Null) String() string { return i.Token.Literal }
+
+// String is an array of codepoints
+type String struct {
+ Token Token
+ Value string
+}
+
+func (i *String) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (i *String) TokenLiteral() string { return i.Token.Literal }
+
+// String returns a string representation of value
+func (i *String) String() string {
+ var out bytes.Buffer
+
+ out.WriteRune('"')
+ out.WriteString(i.Value)
+ out.WriteRune('"')
+
+ return out.String()
+}
+
+// Array is an array of tokens
+type Array struct {
+ Token Token
+ Elements []Expression
+}
+
+func (a *Array) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (a *Array) TokenLiteral() string { return a.Token.Literal }
+
+// String returns a string representation of value
+func (a *Array) String() string {
+ var out bytes.Buffer
+
+ var elements []string
+ for _, el := range a.Elements {
+ elements = append(elements, el.String())
+ }
+
+ out.WriteRune('(')
+ out.WriteString(strings.Join(elements, ","))
+ out.WriteRune(')')
+
+ return out.String()
+}
+
+// Program is a collection of statements
+type Program struct {
+ Statements []Statement
+ Args map[string]string
+}
+
+func (p *Program) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (p *Program) TokenLiteral() string {
+ if len(p.Statements) > 0 {
+ return p.Statements[0].TokenLiteral()
+ }
+ return ""
+}
+
+// String returns a string representation of value
+func (p *Program) String() string {
+ var out bytes.Buffer
+
+ for _, s := range p.Statements {
+ out.WriteString(s.String())
+ }
+ if len(p.Args) > 0 {
+ out.WriteRune('|')
+ for a, b := range p.Args {
+ out.WriteRune(' ')
+ out.WriteString(a)
+ out.WriteRune(':')
+ out.WriteString(b)
+ }
+ }
+ return out.String()
+}
+
+// ExpressionStatement is a collection of expressions
+type ExpressionStatement struct {
+ Token Token
+ Expression Expression
+}
+
+func (ExpressionStatement) statementNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (e ExpressionStatement) TokenLiteral() string { return e.Token.Literal }
+
+// String returns a string representation of value
+func (e ExpressionStatement) String() string {
+ if e.Expression != nil {
+ return e.Expression.String()
+ }
+
+ return ""
+}
+
+// PrefixExpression is an expression with a preceeding operator
+type PrefixExpression struct {
+ Token Token
+ Operator string
+ Right Expression
+}
+
+func (p *PrefixExpression) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (p *PrefixExpression) TokenLiteral() string { return p.Token.Literal }
+
+// String returns a string representation of value
+func (p *PrefixExpression) String() string {
+ var out bytes.Buffer
+
+ out.WriteRune('(')
+ out.WriteString(p.Operator)
+ out.WriteString(p.Right.String())
+ out.WriteRune(')')
+
+ return out.String()
+}
+
+// InfixExpression is two expressions with a infix operator
+type InfixExpression struct {
+ Token Token
+ Left Expression
+ Operator string
+ Right Expression
+}
+
+func (i *InfixExpression) expressionNode() {}
+
+// TokenLiteral returns the literal value of a token
+func (i *InfixExpression) TokenLiteral() string { return i.Token.Literal }
+
+// String returns a string representation of value
+func (i *InfixExpression) String() string {
+ var out bytes.Buffer
+
+ out.WriteRune('(')
+ if i.Left != nil {
+ out.WriteString(i.Left.String())
+ } else {
+ out.WriteString("nil")
+ }
+ out.WriteString(i.Operator)
+ if i.Right != nil {
+ out.WriteString(i.Right.String())
+ } else {
+ out.WriteString("nil")
+ }
+ out.WriteRune(')')
+
+ return out.String()
+}
diff --git a/rsql/ast_test.go b/rsql/ast_test.go
new file mode 100644
index 0000000..0277301
--- /dev/null
+++ b/rsql/ast_test.go
@@ -0,0 +1,21 @@
+package rsql
+
+import "testing"
+
+func TestString(t *testing.T) {
+ program := &Program{
+ Statements: []Statement {
+ ExpressionStatement{
+ Token: Token{TokEQ, "=="},
+ Expression: &InfixExpression{
+ Token: Token{TokEQ, "=="},
+ Left: &Identifier{Token{TokIdent,"foo"}, "foo"},
+ Operator: "==",
+ Right: &Integer{Token{TokInteger, "5"}, 5},
+ },
+ },
+ },
+ }
+
+ t.Log(program.String())
+}
\ No newline at end of file
diff --git a/rsql/dbcolumns.go b/rsql/dbcolumns.go
new file mode 100644
index 0000000..e5fc263
--- /dev/null
+++ b/rsql/dbcolumns.go
@@ -0,0 +1,90 @@
+package rsql
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "unicode"
+)
+
+// DbColumns database model metadata
+type DbColumns struct {
+ Cols []string
+ index map[string]int
+ Table string
+ View string
+}
+
+// Col returns the mapped column names
+func (d *DbColumns) Col(column string) (s string, err error) {
+ idx, ok := d.Index(column)
+ if !ok {
+ err = fmt.Errorf("column not found on table: %v", column)
+ return
+ }
+ return d.Cols[idx], err
+}
+
+// Index returns the column number
+func (d *DbColumns) Index(column string) (idx int, ok bool) {
+ idx, ok = d.index[column]
+ return
+}
+
+// GetDbColumns builds a metadata struct
+func GetDbColumns(o interface{}) *DbColumns {
+ d := DbColumns{}
+ t := reflect.TypeOf(o)
+
+ d.index = make(map[string]int)
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+
+ sp := append(strings.Split(field.Tag.Get("db"), ","), "")
+
+ tag := sp[0]
+
+ json := field.Tag.Get("json")
+ if tag == "" {
+ tag = json
+ }
+
+ graphql := field.Tag.Get("graphql")
+ if tag == "" {
+ tag = graphql
+ }
+
+ if tag == "-" {
+ continue
+ }
+
+ if tag == "" {
+ tag = field.Name
+ }
+
+ d.index[field.Name] = len(d.Cols)
+
+ if _, ok := d.index[tag]; !ok && tag != "" {
+ d.index[tag] = len(d.Cols)
+ }
+ if _, ok := d.index[json]; !ok && json != "" {
+ d.index[json] = len(d.Cols)
+ }
+ if _, ok := d.index[graphql]; !ok && graphql != "" {
+ d.index[graphql] = len(d.Cols)
+ } else if !ok && graphql == "" {
+ a := []rune(field.Name)
+ for i := 0; i < len(a); i++ {
+ if unicode.IsLower(a[i]) {
+ break
+ }
+ a[i] = unicode.ToLower(a[i])
+ }
+ graphql = string(a)
+ d.index[graphql] = len(d.Cols)
+ }
+
+ d.Cols = append(d.Cols, tag)
+ }
+ return &d
+}
diff --git a/rsql/lexer.go b/rsql/lexer.go
new file mode 100644
index 0000000..80e91e3
--- /dev/null
+++ b/rsql/lexer.go
@@ -0,0 +1,258 @@
+package rsql
+
+import (
+ "unicode"
+ "unicode/utf8"
+)
+
+type Lexer struct {
+ input string
+ position int
+ readPosition int
+ rune rune
+}
+
+// NewLexer returns a new lexing generator
+func NewLexer(in string) *Lexer {
+ l := &Lexer{input: in}
+ l.readRune()
+ return l
+}
+
+// NextToken returns the next token from lexer
+func (l *Lexer) NextToken() Token {
+ var tok Token
+
+ l.skipSpace()
+
+ switch l.rune {
+ case '-':
+ l.readRune()
+ if isNumber(l.rune) {
+ var isFloat bool
+ tok.Literal, isFloat = l.readNumber()
+ if isFloat {
+ tok.Type = TokFloat
+ } else {
+ tok.Type = TokInteger
+ }
+
+ } else if isLetter(l.rune) {
+ tok.Literal = l.readIdentifier()
+ tok.Type = lookupIdent(tok.Literal)
+
+ } else {
+ tok = newToken(TokIllegal, l.rune)
+ return tok
+ }
+
+ tok.Literal = "-" + tok.Literal
+ return tok
+ case '=':
+ r := l.peekRune()
+ if r == '=' {
+ r := l.rune
+ l.readRune()
+ tok.Type, tok.Literal = TokEQ, string(r)+string(l.rune)
+ } else if isLetter(r) {
+ tok = l.readFIQL()
+
+ return tok
+ } else {
+ tok = newToken(TokIllegal, l.rune)
+ }
+ case ';':
+ tok = newToken(TokAND, l.rune)
+ case ',':
+ tok = newToken(TokOR, l.rune)
+ case ')':
+ tok = newToken(TokRParen, l.rune)
+ case '(':
+ tok = newToken(TokLParen, l.rune)
+ case ']':
+ tok = newToken(TokRBracket, l.rune)
+ case '[':
+ tok = newToken(TokLBracket, l.rune)
+ case '~':
+ tok = newToken(TokLIKE, l.rune)
+ case '!':
+ if l.peekRune() == '=' {
+ r := l.rune
+ l.readRune()
+ tok.Type, tok.Literal = TokNEQ, string(r)+string(l.rune)
+ } else if l.peekRune() == '~' {
+ r := l.rune
+ l.readRune()
+ tok.Type, tok.Literal = TokNLIKE, string(r)+string(l.rune)
+ } else {
+ tok = newToken(TokIllegal, l.rune)
+ return tok
+ }
+ case '<':
+ if l.peekRune() == '=' {
+ r := l.rune
+ l.readRune()
+ tok.Type, tok.Literal = TokLE, string(r)+string(l.rune)
+ } else {
+ tok = newToken(TokLT, l.rune)
+ }
+ case '>':
+ if l.peekRune() == '=' {
+ r := l.rune
+ l.readRune()
+ tok.Type, tok.Literal = TokGE, string(r)+string(l.rune)
+ } else {
+ tok = newToken(TokGT, l.rune)
+ }
+ case '"', '\'':
+ tok.Type = TokString
+ tok.Literal = l.readString(l.rune)
+ case 0:
+ tok.Type, tok.Literal = TokEOF, ""
+ default:
+ if isNumber(l.rune) {
+ var isFloat bool
+ tok.Literal, isFloat = l.readNumber()
+ if isFloat {
+ tok.Type = TokFloat
+ } else {
+ tok.Type = TokInteger
+ }
+
+ } else if isLetter(l.rune) {
+ tok.Literal = l.readIdentifier()
+ tok.Type = lookupIdent(tok.Literal)
+
+ } else {
+ tok = newToken(TokIllegal, l.rune)
+ return tok
+ }
+
+ return tok
+ }
+
+ l.readRune()
+ return tok
+}
+
+func (l *Lexer) readRune() {
+ var size int
+ if l.readPosition >= len(l.input) {
+ l.rune = 0
+ } else {
+ l.rune, size = utf8.DecodeRuneInString(l.input[l.readPosition:])
+ }
+
+ l.position = l.readPosition
+ l.readPosition += size
+}
+func (l *Lexer) peekRune() rune {
+ if l.readPosition >= len(l.input) {
+ return 0
+ }
+ r, _ := utf8.DecodeRuneInString(l.input[l.readPosition:])
+ return r
+}
+
+func (l *Lexer) skipSpace() {
+ for unicode.IsSpace(l.rune) {
+ l.readRune()
+ }
+}
+
+func (l *Lexer) readIdentifier() string {
+ position := l.position
+ if isLetter(l.rune) {
+ l.readRune()
+ }
+
+ for isLetter(l.rune) || isNumber(l.rune) {
+ l.readRune()
+ }
+
+ return l.input[position:l.position]
+}
+
+func (l *Lexer) readNumber() (string, bool) {
+ isFloat := false
+
+ position := l.position
+ for isNumber(l.rune) {
+ if l.rune == '.' {
+ isFloat = true
+ }
+
+ l.readRune()
+ }
+
+ return l.input[position:l.position], isFloat
+}
+func (l *Lexer) readString(st rune) string {
+ position := l.position + 1
+ escape := false
+ for {
+ l.readRune()
+
+ if l.rune == '\\' {
+ escape = true
+
+ continue
+ }
+ if escape {
+ escape = false
+ continue
+ }
+ if l.rune == st || l.rune == 0 {
+ break
+ }
+ }
+
+ return l.input[position:l.position]
+
+}
+func (l *Lexer) readFIQL() Token {
+ l.readRune()
+ s := l.readIdentifier()
+ if l.rune != '=' {
+ return Token{TokIllegal, "=" + s}
+ }
+ l.readRune()
+
+ switch s {
+ case "eq":
+ return Token{TokEQ, "=" + s + "="}
+ case "neq":
+ return Token{TokNEQ, "=" + s + "="}
+ case "gt":
+ return Token{TokGT, "=" + s + "="}
+ case "ge":
+ return Token{TokGE, "=" + s + "="}
+ case "lt":
+ return Token{TokLT, "=" + s + "="}
+ case "le":
+ return Token{TokLE, "=" + s + "="}
+ default:
+ return Token{TokExtend, "=" + s + "="}
+ }
+}
+
+func isLetter(r rune) bool {
+ if unicode.IsSpace(r) {
+ return false
+ }
+ switch r {
+ case '"', '\'', '(', ')', ';', ',', '=', '!', '~', '<', '>', '[', ']':
+ return false
+ }
+ if '0' < r && r < '9' || r == '.' {
+ return false
+ }
+
+ return unicode.IsPrint(r)
+}
+func isNumber(r rune) bool {
+ if '0' <= r && r <= '9' || r == '.' {
+ return true
+ }
+ return false
+}
diff --git a/rsql/lexer_test.go b/rsql/lexer_test.go
new file mode 100644
index 0000000..02710b4
--- /dev/null
+++ b/rsql/lexer_test.go
@@ -0,0 +1,105 @@
+package rsql
+
+import (
+ "testing"
+
+ "github.com/matryer/is"
+)
+
+func TestReservedToken(t *testing.T) {
+ input := `( ) ; , == != ~ < > <= >= [ ]`
+ tests := []struct {
+ expectedType TokenType
+ expectedLiteral string
+ }{
+ {TokLParen, "("},
+ {TokRParen, ")"},
+ {TokAND, ";"},
+ {TokOR, ","},
+ {TokEQ, "=="},
+ {TokNEQ, "!="},
+ {TokLIKE, "~"},
+ {TokLT, "<"},
+ {TokGT, ">"},
+ {TokLE, "<="},
+ {TokGE, ">="},
+ {TokLBracket, "["},
+ {TokRBracket, "]"},
+ }
+
+ t.Run("Reserved Tokens", func(t *testing.T) {
+ is := is.New(t)
+
+ l := NewLexer(input)
+
+ for _, tt := range tests {
+ tok := l.NextToken()
+ is.Equal(tt.expectedType, tok.Type)
+ is.Equal(tt.expectedLiteral, tok.Literal)
+ }
+ })
+}
+func TestNextToken(t *testing.T) {
+ input := `director=='name\'s';actor=eq="name's";Year=le=2000,Year>=2010;(one <= -1.0, two != true),three=in=(1,2,3);c4==5`
+ tests := []struct {
+ expectedType TokenType
+ expectedLiteral string
+ }{
+ {TokIdent, `director`},
+ {TokEQ, `==`},
+ {TokString, `name\'s`},
+ {TokAND, `;`},
+ {TokIdent, `actor`},
+ {TokEQ, `=eq=`},
+ {TokString, `name's`},
+ {TokAND, `;`},
+ {TokIdent, "Year"},
+ {TokLE, "=le="},
+ {TokInteger, "2000"},
+ {TokOR, ","},
+ {TokIdent, "Year"},
+ {TokGE, ">="},
+ {TokInteger, "2010"},
+ {TokAND, ";"},
+ {TokLParen, "("},
+ {TokIdent, "one"},
+ {TokLE, "<="},
+ {TokFloat, "-1.0"},
+ {TokOR, ","},
+ {TokIdent, "two"},
+ {TokNEQ, "!="},
+ {TokTRUE, "true"},
+ {TokRParen, ")"},
+ {TokOR, ","},
+ {TokIdent, "three"},
+ {TokExtend, "=in="},
+ {TokLParen, "("},
+ {TokInteger, "1"},
+ {TokOR, ","},
+ {TokInteger, "2"},
+ {TokOR, ","},
+ {TokInteger, "3"},
+ {TokRParen, ")"},
+ {TokAND, ";"},
+ {TokIdent, "c4"},
+ {TokEQ, "=="},
+ {TokInteger, "5"},
+ }
+
+ t.Run("Next Token Parsing", func(t *testing.T) {
+ is := is.New(t)
+
+ l := NewLexer(input)
+
+ c := 0
+ for _, tt := range tests {
+ c++
+ tok := l.NextToken()
+
+ is.Equal(tt.expectedType, tok.Type)
+ is.Equal(tt.expectedLiteral, tok.Literal)
+
+ }
+ is.Equal(c, len(tests))
+ })
+}
diff --git a/rsql/parser.go b/rsql/parser.go
new file mode 100644
index 0000000..0da0fc8
--- /dev/null
+++ b/rsql/parser.go
@@ -0,0 +1,285 @@
+package rsql
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// Precidence enumerations
+const (
+ _ = iota
+ PrecedenceLowest
+ PrecedenceAND
+ PrecedenceOR
+ PrecedenceCompare
+ PrecedenceHighest
+)
+
+var precidences = map[TokenType]int{
+ TokEQ: PrecedenceCompare,
+ TokNEQ: PrecedenceCompare,
+ TokLT: PrecedenceCompare,
+ TokLE: PrecedenceCompare,
+ TokGT: PrecedenceCompare,
+ TokGE: PrecedenceCompare,
+ TokLIKE: PrecedenceCompare,
+ TokOR: PrecedenceOR,
+ TokAND: PrecedenceAND,
+}
+
+type (
+ prefixParseFn func() Expression
+ infixParseFn func(expression Expression) Expression
+)
+
+// Parser reads lexed values and builds an AST
+type Parser struct {
+ l *Lexer
+ errors []string
+
+ curToken Token
+ peekToken Token
+
+ prefixParseFns map[TokenType]prefixParseFn
+ infixParseFns map[TokenType]infixParseFn
+}
+
+// NewParser returns a parser for a given lexer
+func NewParser(l *Lexer) *Parser {
+ p := &Parser{l: l}
+
+ p.prefixParseFns = make(map[TokenType]prefixParseFn)
+ p.registerPrefix(TokIdent, p.parseIdentifier)
+ p.registerPrefix(TokInteger, p.parseInteger)
+ p.registerPrefix(TokFloat, p.parseFloat)
+ p.registerPrefix(TokTRUE, p.parseBool)
+ p.registerPrefix(TokFALSE, p.parseBool)
+ p.registerPrefix(TokNULL, p.parseNull)
+ p.registerPrefix(TokString, p.parseString)
+ p.registerPrefix(TokLParen, p.parseGroupedExpression)
+ p.registerPrefix(TokLBracket, p.parseArray)
+
+ p.infixParseFns = make(map[TokenType]infixParseFn)
+ p.registerInfix(TokEQ, p.parseInfixExpression)
+ p.registerInfix(TokNEQ, p.parseInfixExpression)
+ p.registerInfix(TokLT, p.parseInfixExpression)
+ p.registerInfix(TokLE, p.parseInfixExpression)
+ p.registerInfix(TokGT, p.parseInfixExpression)
+ p.registerInfix(TokGE, p.parseInfixExpression)
+ p.registerInfix(TokLIKE, p.parseInfixExpression)
+ p.registerInfix(TokAND, p.parseInfixExpression)
+ p.registerInfix(TokOR, p.parseInfixExpression)
+
+ p.nextToken()
+ p.nextToken()
+
+ return p
+}
+
+// DefaultParse sets up a default lex/parse and returns the program
+func DefaultParse(in string) *Program {
+ args := make(map[string]string)
+ in, argstr, ok := strings.Cut(in, "|")
+ if ok {
+ for _, fd := range strings.Fields(argstr) {
+ a, b, _ := strings.Cut(fd, ":")
+ args[a] = b
+ }
+ }
+
+ l := NewLexer(in)
+ p := NewParser(l)
+ return p.ParseProgram(args)
+}
+
+func (p *Parser) registerPrefix(tokenType TokenType, fn prefixParseFn) {
+ p.prefixParseFns[tokenType] = fn
+}
+func (p *Parser) registerInfix(tokenType TokenType, fn infixParseFn) {
+ p.infixParseFns[tokenType] = fn
+}
+
+// Errors returns a list of errors while parsing
+func (p *Parser) Errors() []string {
+ return p.errors
+}
+func (p *Parser) peekError(t TokenType) {
+ msg := fmt.Sprintf("expected next token to be %s, got %s instad",
+ t, p.peekToken.Type)
+ p.errors = append(p.errors, msg)
+}
+
+func (p *Parser) nextToken() {
+ p.curToken = p.peekToken
+ p.peekToken = p.l.NextToken()
+}
+func (p *Parser) curTokenIs(t TokenType) bool {
+ return p.curToken.Type == t
+}
+func (p *Parser) peekTokenIs(t TokenType) bool {
+ return p.peekToken.Type == t
+}
+func (p *Parser) expectPeek(t TokenType) bool {
+ if p.peekTokenIs(t) {
+ p.nextToken()
+ return true
+ }
+ p.peekError(t)
+ return false
+}
+func (p *Parser) peekPrecedence() int {
+ if p, ok := precidences[p.peekToken.Type]; ok {
+ return p
+ }
+ return PrecedenceLowest
+}
+func (p *Parser) curPrecedence() int {
+ if p, ok := precidences[p.curToken.Type]; ok {
+ return p
+ }
+ return PrecedenceLowest
+}
+
+// ParseProgram builds a program AST from lexer
+func (p *Parser) ParseProgram(args map[string]string) *Program {
+ program := &Program{Args: args}
+ program.Statements = []Statement{}
+
+ for p.curToken.Type != TokEOF {
+ stmt := p.parseStatement()
+ if stmt != nil {
+ program.Statements = append(program.Statements, stmt)
+ }
+ p.nextToken()
+ }
+
+ return program
+}
+func (p *Parser) parseStatement() Statement {
+ switch p.curToken.Type {
+ default:
+ return p.parseExpressionStatement()
+ }
+}
+func (p *Parser) parseExpressionStatement() *ExpressionStatement {
+ stmt := &ExpressionStatement{Token: p.curToken}
+ stmt.Expression = p.parseExpression(PrecedenceLowest)
+
+ return stmt
+}
+func (p *Parser) parseExpression(precedence int) Expression {
+ prefix := p.prefixParseFns[p.curToken.Type]
+ if prefix == nil {
+ msg := fmt.Sprintf("no prefix parse function for %s found", p.curToken.Type)
+ p.errors = append(p.errors, msg)
+ return nil
+ }
+ leftExp := prefix()
+
+ for !p.peekTokenIs(TokEOF) && precedence < p.peekPrecedence() {
+ infix := p.infixParseFns[p.peekToken.Type]
+ if infix == nil {
+ return leftExp
+ }
+
+ p.nextToken()
+
+ leftExp = infix(leftExp)
+ }
+
+ return leftExp
+}
+func (p *Parser) parseIdentifier() Expression {
+ return &Identifier{Token: p.curToken, Value: p.curToken.Literal}
+}
+func (p *Parser) parseInteger() Expression {
+ lit := &Integer{Token: p.curToken}
+
+ value, err := strconv.ParseInt(p.curToken.Literal, 0, 64)
+ if err != nil {
+ msg := fmt.Sprintf("could not parse %q as integer", p.curToken.Literal)
+ p.errors = append(p.errors, msg)
+ return nil
+ }
+
+ lit.Value = value
+ return lit
+}
+func (p *Parser) parseFloat() Expression {
+ lit := &Float{Token: p.curToken}
+
+ value, err := strconv.ParseFloat(p.curToken.Literal, 64)
+ if err != nil {
+ msg := fmt.Sprintf("could not parse %q as float", p.curToken.Literal)
+ p.errors = append(p.errors, msg)
+ return nil
+ }
+
+ lit.Value = value
+ return lit
+}
+func (p *Parser) parseBool() Expression {
+ return &Bool{Token: p.curToken, Value: p.curTokenIs(TokTRUE)}
+}
+func (p *Parser) parseString() Expression {
+ s := p.curToken.Literal
+ s = strings.Replace(s, `\'`, `'`, -1)
+ s = strings.Replace(s, `\"`, `"`, -1)
+
+ return &String{Token: p.curToken, Value: s}
+}
+func (p *Parser) parseNull() Expression {
+ return &Null{Token: p.curToken}
+}
+func (p *Parser) parseArray() Expression {
+ array := &Array{Token: p.curToken}
+ array.Elements = p.parseExpressionList(TokRBracket)
+ return array
+}
+func (p *Parser) parseExpressionList(end TokenType) []Expression {
+ var list []Expression
+
+ if p.peekTokenIs(end) {
+ p.nextToken()
+ return list
+ }
+
+ p.nextToken()
+ list = append(list, p.parseExpression(PrecedenceHighest))
+ for p.peekTokenIs(TokOR) {
+ p.nextToken()
+ p.nextToken()
+ list = append(list, p.parseExpression(PrecedenceHighest))
+ }
+
+ if !p.expectPeek(end) {
+ return nil
+ }
+
+ return list
+}
+func (p *Parser) parseInfixExpression(left Expression) Expression {
+ expression := &InfixExpression{
+ Token: p.curToken,
+ Left: left,
+ Operator: p.curToken.Literal,
+ }
+
+ precidence := p.curPrecedence()
+ p.nextToken()
+ expression.Right = p.parseExpression(precidence)
+
+ return expression
+}
+func (p *Parser) parseGroupedExpression() Expression {
+ p.nextToken()
+
+ exp := p.parseExpression(PrecedenceLowest)
+
+ if !p.expectPeek(TokRParen) {
+ return nil
+ }
+
+ return exp
+}
diff --git a/rsql/parser_test.go b/rsql/parser_test.go
new file mode 100644
index 0000000..14b3153
--- /dev/null
+++ b/rsql/parser_test.go
@@ -0,0 +1,309 @@
+package rsql
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/matryer/is"
+)
+
+func typeOf(t any) string { return fmt.Sprintf("%T", t) }
+
+func TestIdentifierExpression(t *testing.T) {
+ input := `foobar`
+
+ t.Run("Identifier Expressions", func(t *testing.T) {
+ is := is.New(t)
+
+ l := NewLexer(input)
+ p := NewParser(l)
+
+ program := p.ParseProgram(nil)
+ checkParserErrors(t, p)
+
+ is.Equal(len(program.Statements), 1)
+ // if len(program.Statements) != 1 {
+ // t.Fatalf("program has not envough statements. got=%d", len(program.Statements))
+ // }
+ })
+}
+
+func TestIntegerExpression(t *testing.T) {
+ input := `5`
+
+ t.Run("IntegerExpression", func(t *testing.T) {
+ is := is.New(t)
+
+ l := NewLexer(input)
+ p := NewParser(l)
+
+ program := p.ParseProgram(nil)
+ checkParserErrors(t, p)
+
+ is.Equal(len(program.Statements), 1)
+ // if len(program.Statements) != 1 {
+ // t.Fatalf("program has not enough statements. got=%d", len(program.Statements))
+ // }
+
+ stmt, ok := program.Statements[0].(*ExpressionStatement)
+ is.Equal(typeOf(program.Statements[0]), typeOf(&ExpressionStatement{}))
+ is.True(ok)
+ // if !ok {
+ // t.Fatalf("program.Statements[0] is not ExpressionStatement got=%T",
+ // program.Statements[0])
+ // }
+
+ literal, ok := stmt.Expression.(*Integer)
+ is.Equal(typeOf(literal), typeOf(&Integer{}))
+ is.True(ok)
+ // if !ok {
+ // t.Fatalf("stmt.Expression is not Integer got=%T",
+ // stmt.Expression)
+ // }
+
+ is.Equal(literal.Value, int64(5))
+ // if literal.Value != 5 {
+ // t.Errorf("literal.Value not %d. got=%d", 5, literal.Value)
+ // }
+
+ is.Equal(literal.TokenLiteral(), "5")
+ // if literal.TokenLiteral() != "5" {
+ // t.Errorf("literal.TokenLiteral not %v. got=%v", "5", literal.TokenLiteral())
+ // }
+ })
+}
+
+func TestInfixExpression(t *testing.T) {
+ tests := []struct {
+ input string
+ left string
+ operator string
+ right int64
+ }{
+ {"foo == 1", "foo", "==", 1},
+ {"bar > 2", "bar", ">", 2},
+ {"bin < 3", "bin", "<", 3},
+ {"baz != 4", "baz", "!=", 4},
+ {"buf >= 5", "buf", ">=", 5},
+ {"goz <= 6", "goz", "<=", 6},
+ }
+ t.Run("Infix Expressions", func(t *testing.T) {
+ is := is.New(t)
+
+ for _, tt := range tests {
+ l := NewLexer(tt.input)
+ p := NewParser(l)
+ program := p.ParseProgram(nil)
+ checkParserErrors(t, p)
+
+ is.Equal(len(program.Statements), 1)
+ // if len(program.Statements) != 1 {
+ // t.Fatalf("program has not envough statements. got=%d", len(program.Statements))
+ // }
+
+ stmt, ok := program.Statements[0].(*ExpressionStatement)
+ is.Equal(typeOf(stmt), typeOf(&ExpressionStatement{}))
+ is.True(ok)
+ // if !ok {
+ // t.Fatalf("program.Statements[0] is not ExpressionStatement got=%T",
+ // program.Statements[0])
+ // }
+
+ exp, ok := stmt.Expression.(*InfixExpression)
+ is.Equal(typeOf(exp), typeOf(&InfixExpression{}))
+ is.True(ok)
+ // if !ok {
+ // t.Fatalf("stmt.Expression is not InfixExpression got=%T",
+ // stmt.Expression)
+ // }
+
+ if !testIdentifier(t, exp.Left, tt.left) {
+ return
+ }
+
+ is.Equal(exp.Operator, tt.operator)
+ // if exp.Operator != tt.operator {
+ // t.Fatalf("exp.Operator is not '%v'. got '%v'", tt.operator, exp.Operator)
+ // }
+
+ if testInteger(t, exp.Right, tt.right) {
+ return
+ }
+ }
+ })
+}
+
+func TestOperatorPrecedenceParsing(t *testing.T) {
+ tests := []struct {
+ input string
+ expect string
+ }{
+ {
+ "foo == 1; bar == 2.0",
+ "((foo==1);(bar==2.0))",
+ },
+ {
+ `director=='name\'s';actor=eq="name\'s";Year=le=2000,Year>=2010;one <= -1.0, two != true`,
+ `((((director=="name's");(actor=eq="name's"));((Year=le=2000),(Year>=2010)));((one<=-1.0),(two!=true)))`,
+ },
+ }
+ t.Run("Operator Precidence Parsing", func(t *testing.T) {
+ is := is.New(t)
+
+ for _, tt := range tests {
+ l := NewLexer(tt.input)
+ p := NewParser(l)
+ program := p.ParseProgram(nil)
+ checkParserErrors(t, p)
+
+ actual := program.String()
+ is.Equal(actual, tt.expect)
+ // if actual != tt.expect {
+ // t.Errorf("expcected=%q, got=%q", tt.expect, actual)
+ // }
+ }
+ })
+}
+
+func TestParsingArray(t *testing.T) {
+ input := "[1, 2.1, true, null]"
+
+ l := NewLexer(input)
+ p := NewParser(l)
+ program := p.ParseProgram(nil)
+ checkParserErrors(t, p)
+
+ if len(program.Statements) != 1 {
+ t.Fatalf("program has not enough statements. got=%d", len(program.Statements))
+ }
+
+ stmt, ok := program.Statements[0].(*ExpressionStatement)
+ if !ok {
+ t.Fatalf("program.Statements[0] is not ExpressionStatement got=%T",
+ program.Statements[0])
+ }
+
+ array, ok := stmt.Expression.(*Array)
+ if !ok {
+ t.Fatalf("stmt.Expression is not Array got=%T",
+ stmt.Expression)
+ }
+
+ if len(array.Elements) != 4 {
+ t.Fatalf("len(array.Elements) not 4. got=%v", len(array.Elements))
+ }
+
+ testInteger(t, array.Elements[0], 1)
+ testFloat(t, array.Elements[1], 2.1)
+ testBool(t, array.Elements[2], true)
+ testNull(t, array.Elements[3])
+}
+
+func checkParserErrors(t *testing.T, p *Parser) {
+ errors := p.Errors()
+ if len(errors) == 0 {
+ return
+ }
+
+ t.Errorf("parser has %d errors", len(errors))
+ for _, msg := range errors {
+ t.Errorf("parser error: %q", msg)
+ }
+ t.FailNow()
+}
+
+func testInteger(t *testing.T, e Expression, value int64) bool {
+ literal, ok := e.(*Integer)
+ if !ok {
+ t.Errorf("stmt.Expression is not Integer got=%T", e)
+ return false
+ }
+
+ if literal.Value != value {
+ t.Errorf("literal.Value not %d. got=%d", value, literal.Value)
+ return false
+ }
+
+ if literal.TokenLiteral() != fmt.Sprintf("%v", value) {
+ t.Errorf("literal.TokenLiteral not %v. got=%v", value, literal.TokenLiteral())
+ return false
+ }
+
+ return true
+}
+func testFloat(t *testing.T, e Expression, value float64) bool {
+ literal, ok := e.(*Float)
+ if !ok {
+ t.Errorf("stmt.Expression is not Float got=%T", e)
+ return false
+ }
+
+ if literal.Value != value {
+ t.Errorf("literal.Value not %f. got=%f", value, literal.Value)
+ return false
+ }
+
+ if literal.TokenLiteral() != fmt.Sprintf("%v", value) {
+ t.Errorf("literal.TokenLiteral not %q. got=%q", fmt.Sprintf("%v", value), literal.TokenLiteral())
+ return false
+ }
+
+ return true
+}
+func testBool(t *testing.T, e Expression, value bool) bool {
+ literal, ok := e.(*Bool)
+ if !ok {
+ t.Errorf("stmt.Expression is not Float got=%T", e)
+ return false
+ }
+
+ if literal.Value != value {
+ t.Errorf("literal.Value not %t. got=%t", value, literal.Value)
+ return false
+ }
+
+ if literal.TokenLiteral() != fmt.Sprintf("%v", value) {
+ t.Errorf("literal.TokenLiteral not %v. got=%v", value, literal.TokenLiteral())
+ return false
+ }
+
+ return true
+}
+func testNull(t *testing.T, e Expression) bool {
+ literal, ok := e.(*Null)
+ if !ok {
+ t.Errorf("stmt.Expression is not Null got=%T", e)
+ return false
+ }
+ if literal.Token.Type != TokNULL {
+ t.Errorf("liternal.Token is not TokNULL got=%T", e)
+ return false
+ }
+
+ return true
+}
+func testIdentifier(t *testing.T, e Expression, value string) bool {
+ literal, ok := e.(*Identifier)
+ if !ok {
+ t.Errorf("stmt.Expression is not Integer got=%T", e)
+ return false
+ }
+
+ if literal.Value != value {
+ t.Errorf("literal.Value not %s. got=%s", value, literal.Value)
+ return false
+ }
+
+ if literal.TokenLiteral() != value {
+ t.Errorf("literal.TokenLiteral not %v. got=%v", value, literal.TokenLiteral())
+ return false
+ }
+
+ return true
+}
+
+func TestReplace(t *testing.T) {
+ is := is.New(t)
+ is.Equal(strings.Replace(`name\'s`, `\'`, `'`, -1), `name's`)
+}
diff --git a/rsql/squirrel/sqlizer.go b/rsql/squirrel/sqlizer.go
new file mode 100644
index 0000000..49c0892
--- /dev/null
+++ b/rsql/squirrel/sqlizer.go
@@ -0,0 +1,300 @@
+package squirrel
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/Masterminds/squirrel"
+ "go.sour.is/pkg/rsql"
+)
+
+type dbInfo interface {
+ Col(string) (string, error)
+}
+
+type args map[string]string
+
+func (d *decoder) mkArgs(a args) args {
+ m := make(args, len(a))
+ for k, v := range a {
+ if k == "limit" || k == "offset" {
+ m[k] = v
+ continue
+ }
+
+ var err error
+ if k, err = d.dbInfo.Col(k); err == nil {
+ m[k] = v
+
+ }
+ }
+
+ return m
+}
+
+func (a args) Limit() (uint64, bool) {
+ if a == nil {
+ return 0, false
+ }
+ if v, ok := a["limit"]; ok {
+ i, err := strconv.ParseUint(v, 10, 64)
+ return i, err == nil
+ }
+
+ return 0, false
+}
+func (a args) Offset() (uint64, bool) {
+ if a == nil {
+ return 0, false
+ }
+ if v, ok := a["offset"]; ok {
+ i, err := strconv.ParseUint(v, 10, 64)
+ return i, err == nil
+ }
+
+ return 0, false
+}
+func (a args) Order() []string {
+ var lis []string
+
+ for k, v := range a {
+ if k == "limit" || k == "offset" {
+ continue
+ }
+ lis = append(lis, k+" "+v)
+ }
+
+ return lis
+}
+
+func Query(in string, db dbInfo) (squirrel.Sqlizer, args, error) {
+ d := decoder{dbInfo: db}
+ program := rsql.DefaultParse(in)
+ sql, err := d.decode(program)
+ return sql, d.mkArgs(program.Args), err
+}
+
+type decoder struct {
+ dbInfo dbInfo
+}
+
+func (db *decoder) decode(in *rsql.Program) (squirrel.Sqlizer, error) {
+ switch len(in.Statements) {
+ case 0:
+ return nil, nil
+ case 1:
+ return db.decodeStatement(in.Statements[0])
+ default:
+ a := squirrel.And{}
+ for _, stmt := range in.Statements {
+ d, err := db.decodeStatement(stmt)
+ if d == nil {
+ return nil, err
+ }
+
+ a = append(a, d)
+ }
+ return a, nil
+ }
+}
+func (db *decoder) decodeStatement(in rsql.Statement) (squirrel.Sqlizer, error) {
+ switch s := in.(type) {
+ case *rsql.ExpressionStatement:
+ return db.decodeExpression(s.Expression)
+ }
+ return nil, nil
+}
+func (db *decoder) decodeExpression(in rsql.Expression) (squirrel.Sqlizer, error) {
+ switch e := in.(type) {
+ case *rsql.InfixExpression:
+ return db.decodeInfix(e)
+ }
+ return nil, nil
+}
+func (db *decoder) decodeInfix(in *rsql.InfixExpression) (squirrel.Sqlizer, error) {
+
+ switch in.Token.Type {
+ case rsql.TokAND:
+ a := squirrel.And{}
+ left, err := db.decodeExpression(in.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ switch v := left.(type) {
+ case squirrel.And:
+ for _, el := range v {
+ if el != nil {
+ a = append(a, el)
+ }
+ }
+
+ default:
+ if v != nil {
+ a = append(a, v)
+ }
+ }
+
+ right, err := db.decodeExpression(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ switch v := right.(type) {
+ case squirrel.And:
+ for _, el := range v {
+ if el != nil {
+ a = append(a, el)
+ }
+ }
+
+ default:
+ if v != nil {
+ a = append(a, v)
+ }
+ }
+
+ return a, nil
+ case rsql.TokOR:
+ left, err := db.decodeExpression(in.Left)
+ if err != nil {
+ return nil, err
+ }
+ right, err := db.decodeExpression(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ return squirrel.Or{left, right}, nil
+ case rsql.TokEQ:
+ col, err := db.dbInfo.Col(in.Left.String())
+ if err != nil {
+ return nil, err
+ }
+ v, err := db.decodeValue(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ return squirrel.Eq{col: v}, nil
+ case rsql.TokLIKE:
+ col, err := db.dbInfo.Col(in.Left.String())
+ if err != nil {
+ return nil, err
+ }
+
+ v, err := db.decodeValue(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ switch value := v.(type) {
+ case string:
+ return Like{col, strings.Replace(value, "*", "%", -1)}, nil
+ default:
+ return nil, fmt.Errorf("LIKE requires a string value")
+ }
+
+ case rsql.TokNEQ:
+ col, err := db.dbInfo.Col(in.Left.String())
+ if err != nil {
+ return nil, err
+ }
+ v, err := db.decodeValue(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ return squirrel.NotEq{col: v}, nil
+ case rsql.TokGT:
+ col, err := db.dbInfo.Col(in.Left.String())
+ if err != nil {
+ return nil, err
+ }
+ v, err := db.decodeValue(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ return squirrel.Gt{col: v}, nil
+ case rsql.TokGE:
+ col, err := db.dbInfo.Col(in.Left.String())
+ if err != nil {
+ return nil, err
+ }
+ v, err := db.decodeValue(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ return squirrel.GtOrEq{col: v}, nil
+ case rsql.TokLT:
+ col, err := db.dbInfo.Col(in.Left.String())
+ if err != nil {
+ return nil, err
+ }
+ v, err := db.decodeValue(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ return squirrel.Lt{col: v}, nil
+ case rsql.TokLE:
+ col, err := db.dbInfo.Col(in.Left.String())
+ if err != nil {
+ return nil, err
+ }
+ v, err := db.decodeValue(in.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ return squirrel.LtOrEq{col: v}, nil
+ default:
+ return nil, nil
+ }
+}
+func (db *decoder) decodeValue(in rsql.Expression) (interface{}, error) {
+ switch v := in.(type) {
+ case *rsql.Array:
+ var values []interface{}
+ for _, el := range v.Elements {
+ v, err := db.decodeValue(el)
+ if err != nil {
+ return nil, err
+ }
+
+ values = append(values, v)
+ }
+
+ return values, nil
+ case *rsql.InfixExpression:
+ return db.decodeInfix(v)
+ case *rsql.Identifier:
+ return v.Value, nil
+ case *rsql.Integer:
+ return v.Value, nil
+ case *rsql.Float:
+ return v.Value, nil
+ case *rsql.String:
+ return v.Value, nil
+ case *rsql.Bool:
+ return v.Value, nil
+ case *rsql.Null:
+ return nil, nil
+ }
+
+ return nil, nil
+}
+
+type Like struct {
+ column string
+ value string
+}
+
+func (l Like) ToSql() (sql string, args []interface{}, err error) {
+ sql = fmt.Sprintf("%s LIKE(?)", l.column)
+ args = append(args, l.value)
+ return
+}
diff --git a/rsql/squirrel/sqlizer_test.go b/rsql/squirrel/sqlizer_test.go
new file mode 100644
index 0000000..f7bb292
--- /dev/null
+++ b/rsql/squirrel/sqlizer_test.go
@@ -0,0 +1,141 @@
+package squirrel
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/matryer/is"
+ "go.sour.is/pkg/rsql"
+)
+
+type testTable struct {
+ Foo string `json:"foo"`
+ Bar string `json:"bar"`
+ Baz string `json:"baz"`
+ Director string `json:"director"`
+ Actor string `json:"actor"`
+ Year string `json:"year"`
+ Genres string `json:"genres"`
+ One string `json:"one"`
+ Two string `json:"two"`
+ Family string `json:"family_name"`
+}
+
+func TestQuery(t *testing.T) {
+ d := rsql.GetDbColumns(testTable{})
+ is := is.New(t)
+
+ tests := []struct {
+ input string
+ expect squirrel.Sqlizer
+ expectLimit *uint64
+ expectOffset *uint64
+ expectOrder []string
+ fail bool
+ }{
+ {input: "foo==[1, 2, 3]", expect: squirrel.Eq{"foo": []interface{}{1, 2, 3}}},
+ {input: "foo==1,(bar==2;baz==3)", expect: squirrel.Or{squirrel.Eq{"foo": 1}, squirrel.And{squirrel.Eq{"bar": 2}, squirrel.Eq{"baz": 3}}}},
+
+ {input: "foo==1", expect: squirrel.Eq{"foo": 1}},
+ {input: "foo!=1.1", expect: squirrel.NotEq{"foo": 1.1}},
+ {input: "foo==true", expect: squirrel.Eq{"foo": true}},
+ {input: "foo==null", expect: squirrel.Eq{"foo": nil}},
+ {input: "foo>2", expect: squirrel.Gt{"foo": 2}},
+ {input: "foo>=2.1", expect: squirrel.GtOrEq{"foo": 2.1}},
+ {input: "foo<3", expect: squirrel.Lt{"foo": 3}},
+ {input: "foo<=3.1", expect: squirrel.LtOrEq{"foo": 3.1}},
+
+ {input: "foo=eq=1", expect: squirrel.Eq{"foo": 1}},
+ {input: "foo=neq=1.1", expect: squirrel.NotEq{"foo": 1.1}},
+ {input: "foo=gt=2", expect: squirrel.Gt{"foo": 2}},
+ {input: "foo=ge=2.1", expect: squirrel.GtOrEq{"foo": 2.1}},
+ {input: "foo=lt=3", expect: squirrel.Lt{"foo": 3}},
+ {input: "foo=le=3.1", expect: squirrel.LtOrEq{"foo": 3.1}},
+
+ {input: "foo==1;bar==2", expect: squirrel.And{squirrel.Eq{"foo": 1}, squirrel.Eq{"bar": 2}}},
+ {input: "foo==1,bar==2", expect: squirrel.Or{squirrel.Eq{"foo": 1}, squirrel.Eq{"bar": 2}}},
+ {input: "foo==1,bar==2;baz=3", expect: nil},
+ {
+ input: `director=='name\'s';actor=eq="name\'s";Year=le=2000,Year>=2010;one <= -1.0, two != true`,
+ expect: squirrel.And{
+ squirrel.Eq{"director": "name's"},
+ squirrel.Eq{"actor": "name's"},
+ squirrel.Or{
+ squirrel.LtOrEq{"year": 2000},
+ squirrel.GtOrEq{"year": 2010},
+ },
+ squirrel.Or{
+ squirrel.LtOrEq{"one": -1.0},
+ squirrel.NotEq{"two": true},
+ },
+ },
+ },
+ {
+ input: `genres==[sci-fi,action] ; genres==[romance,animated,horror] , director~Que*Tarantino`,
+ expect: squirrel.And{
+ squirrel.Eq{"genres": []interface{}{"sci-fi", "action"}},
+ squirrel.Or{
+ squirrel.Eq{"genres": []interface{}{"romance", "animated", "horror"}},
+ Like{"director", "Que%Tarantino"},
+ },
+ },
+ },
+ {input: "", expect: nil},
+ {input: "family_name==LUNDY", expect: squirrel.Eq{"family_name": "LUNDY"}},
+ {input: "family_name==[1,2,null]", expect: squirrel.Eq{"family_name": []interface{}{1, 2, nil}}},
+ {input: "family_name=LUNDY", expect: nil},
+ {input: "family_name==LUNDY and family_name==SMITH", expect: squirrel.And{squirrel.Eq{"family_name": "LUNDY"}, squirrel.Eq{"family_name": "SMITH"}}},
+ {input: "family_name==LUNDY or family_name==SMITH", expect: squirrel.Or{squirrel.Eq{"family_name": "LUNDY"}, squirrel.Eq{"family_name": "SMITH"}}},
+ {input: "foo==1,family_name=LUNDY;baz==2", expect: nil},
+ {input: "foo ~ bar*", expect: Like{"foo", "bar%"}},
+ {input: "foo ~ [bar*,bin*]", expect: nil, fail: true},
+ {input: "foo==1|limit:10", expect: squirrel.Eq{"foo": 1}, expectLimit: ptr(uint64(10))},
+ {input: "foo==1|offset:2", expect: squirrel.Eq{"foo": 1}, expectOffset: ptr(uint64(2))},
+ {
+ input: "foo>=1|limit:10 offset:2 foo:desc",
+ expect: squirrel.GtOrEq{"foo": 1},
+ expectLimit: ptr(uint64(10)),
+ expectOffset: ptr(uint64(2)),
+ expectOrder: []string{"foo desc"},
+ },
+ }
+
+ for i, tt := range tests {
+ q, a, err := Query(tt.input, d)
+ if !tt.fail && err != nil {
+ t.Error(err)
+ }
+ if q != nil {
+ t.Log(q.ToSql())
+ }
+
+ actual := fmt.Sprintf("%#v", q)
+ expect := fmt.Sprintf("%#v", tt.expect)
+ if expect != actual {
+ t.Errorf("test[%d]: %v\n\tinput and expected are not the same. wanted=%s got=%s", i, tt.input, expect, actual)
+ }
+
+ if limit, ok := a.Limit(); tt.expectLimit != nil {
+ is.True(ok)
+ is.Equal(limit, *tt.expectLimit)
+ } else {
+ is.True(!ok)
+ }
+
+ if offset, ok := a.Offset(); tt.expectOffset != nil {
+ is.True(ok)
+ is.Equal(offset, *tt.expectOffset)
+ } else {
+ is.True(!ok)
+ }
+
+ if order := a.Order(); tt.expectOrder != nil {
+ is.Equal(order, tt.expectOrder)
+ } else {
+ is.Equal(len(order), 0)
+ }
+ }
+}
+
+func ptr[T any](t T) *T { return &t }
diff --git a/rsql/token.go b/rsql/token.go
new file mode 100644
index 0000000..28de2a7
--- /dev/null
+++ b/rsql/token.go
@@ -0,0 +1,62 @@
+package rsql
+
+// Tokens for RSQL FIQL
+const (
+ TokIllegal = "TokIllegal"
+ TokEOF = "TokEOF"
+
+ TokIdent = "TokIdent"
+ TokInteger = "TokInteger"
+ TokString = "TokString"
+ TokFloat = "TokFloat"
+ TokExtend = "TokExtend"
+
+ TokLParen = "("
+ TokRParen = ")"
+
+ TokLBracket = "["
+ TokRBracket = "]"
+
+ TokLIKE = "~"
+ TokNLIKE= "!~"
+ TokNOT = "!"
+ TokLT = "<"
+ TokGT = ">"
+ TokLE = "<="
+ TokGE = ">="
+ TokEQ = "=="
+ TokNEQ = "!="
+ TokAND = ";"
+ TokOR = ","
+
+ TokTRUE = "true"
+ TokFALSE = "false"
+ TokNULL = "null"
+)
+
+var keywords = map[string]TokenType {
+ "true": TokTRUE,
+ "false": TokFALSE,
+ "null": TokNULL,
+ "and": TokAND,
+ "or": TokOR,
+}
+
+// TokenType is a token enumeration
+type TokenType string
+// Token is a type and literal pair
+type Token struct {
+ Type TokenType
+ Literal string
+}
+
+func newToken(tokenType TokenType, ch rune) Token {
+ return Token{Type: tokenType, Literal: string(ch)}
+}
+
+func lookupIdent(ident string) TokenType {
+ if tok, ok := keywords[ident]; ok {
+ return tok
+ }
+ return TokIdent
+}
\ No newline at end of file
diff --git a/service/service.go b/service/service.go
index b891b82..fd085d6 100644
--- a/service/service.go
+++ b/service/service.go
@@ -35,10 +35,11 @@ func (s *Harness) Setup(ctx context.Context, apps ...application) error {
ctx, span := lg.Span(ctx)
defer span.End()
+ s.onRunning = make(chan struct{})
+
// setup crontab
c := cron.New(cron.DefaultGranularity)
s.OnStart(c.Run)
- s.onRunning = make(chan struct{})
s.crontab = c
var err error
diff --git a/set/set.go b/set/set.go
index 4567d9d..21f358a 100644
--- a/set/set.go
+++ b/set/set.go
@@ -6,6 +6,7 @@ import (
"strings"
"go.sour.is/pkg/math"
+ "golang.org/x/exp/maps"
)
type Set[T comparable] map[T]struct{}
@@ -33,6 +34,9 @@ func (s Set[T]) Delete(items ...T) Set[T] {
}
return s
}
+func (s Set[T]) Values() []T {
+ return maps.Keys(s)
+}
func (s Set[T]) Equal(e Set[T]) bool {
for k := range s {
diff --git a/sour.is-mercury b/sour.is-mercury
new file mode 100755
index 0000000..54b6eb9
Binary files /dev/null and b/sour.is-mercury differ
diff --git a/xdg/xdg.go b/xdg/xdg.go
index 70a27af..0c37931 100644
--- a/xdg/xdg.go
+++ b/xdg/xdg.go
@@ -4,6 +4,7 @@
package xdg
import (
+ "errors"
"os"
"path/filepath"
"strings"
@@ -36,6 +37,9 @@ func setENV(name, value string) string {
return literal(name)
}
func Get(base, suffix string) string {
+ return strings.Join(paths(base, suffix), string(os.PathListSeparator))
+}
+func paths(base, suffix string) []string {
paths := strings.Split(os.ExpandEnv(base), string(os.PathListSeparator))
for i, path := range paths {
if strings.HasPrefix(path, "~") {
@@ -43,7 +47,17 @@ func Get(base, suffix string) string {
}
paths[i] = os.ExpandEnv(filepath.Join(path, suffix))
}
- return strings.Join(paths, string(os.PathListSeparator))
+ return paths
+}
+func Find(base, filename string) []string {
+ var files []string
+ for _, f := range paths(base, filename) {
+ if ok, _ := exists(f); !ok {
+ continue
+ }
+ files = append(files, f)
+ }
+ return files
}
func getHome() string {
@@ -53,3 +67,17 @@ func getHome() string {
}
return home
}
+
+func exists(name string) (bool, error) {
+ s, err := os.Stat(name)
+ if err == nil {
+ return true, nil
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ if s.IsDir() {
+ return false, nil
+ }
+ return false, err
+}