diff --git a/go.mod b/go.mod
index 19c11f0ea..fcbd1457b 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
github.com/Masterminds/squirrel v1.1.0
github.com/astaxie/beego v1.12.0
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
+ github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4
github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131
github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a // indirect
github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8
@@ -14,6 +15,7 @@ require (
github.com/fatih/structs v1.0.0 // indirect
github.com/go-chi/chi v4.0.3+incompatible
github.com/go-chi/cors v1.0.0
+ github.com/google/uuid v1.1.1 // indirect
github.com/google/wire v0.4.0
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a
diff --git a/go.sum b/go.sum
index 4b3823f1b..e272c1503 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao=
+github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131 h1:siEGb+iB1Ea75U7BnkYVSqSRzE6QHlXCbqEXenxRmhQ=
github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131/go.mod h1:eVWQJVQ67aMvYhpkDwaH2Goy2vo6v8JCMfGXfQ9sPtw=
github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a h1:7MucP9rMAsQRcRE1sGpvMZoTxFYZlDmfDvCH+z7H+90=
@@ -54,6 +56,8 @@ github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8l
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.4.0 h1:kXcsA/rIGzJImVqPdhfnr6q0xsS9gU0515q1EPpJ9fE=
github.com/google/wire v0.4.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
diff --git a/model/model.go b/model/model.go
index 535027405..a160ca580 100644
--- a/model/model.go
+++ b/model/model.go
@@ -2,6 +2,8 @@ package model
import (
"errors"
+
+ "github.com/deluan/rest"
)
var (
@@ -22,6 +24,11 @@ type QueryOptions struct {
Filters Filters
}
+type ResourceRepository interface {
+ rest.Repository
+ rest.Persistable
+}
+
type DataStore interface {
Album() AlbumRepository
Artist() ArtistRepository
@@ -31,5 +38,7 @@ type DataStore interface {
Playlist() PlaylistRepository
Property() PropertyRepository
+ Resource(model interface{}) ResourceRepository
+
WithTx(func(tx DataStore) error) error
}
diff --git a/model/user.go b/model/user.go
index ea78152ae..88422b2f2 100644
--- a/model/user.go
+++ b/model/user.go
@@ -3,9 +3,12 @@ package model
import "time"
type User struct {
- ID string
- Name string
- Password string
- IsAdmin bool
- CreatedAt time.Time
+ ID string
+ Name string
+ Password string
+ IsAdmin bool
+ LastLoginAt time.Time
+ LastAccessAt time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
diff --git a/persistence/mock_persistence.go b/persistence/mock_persistence.go
index 71738165e..afb777f9b 100644
--- a/persistence/mock_persistence.go
+++ b/persistence/mock_persistence.go
@@ -52,3 +52,7 @@ func (db *MockDataStore) Property() model.PropertyRepository {
func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error {
return block(db)
}
+
+func (db *MockDataStore) Resource(m interface{}) model.ResourceRepository {
+ return struct{ model.ResourceRepository }{}
+}
diff --git a/persistence/persistence.go b/persistence/persistence.go
index 62c502c81..63aa6f48a 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -16,8 +16,9 @@ import (
const batchSize = 100
var (
- once sync.Once
- driver = "sqlite3"
+ once sync.Once
+ driver = "sqlite3"
+ mappedModels map[interface{}]interface{}
)
type SQLStore struct {
@@ -30,11 +31,12 @@ func New() model.DataStore {
if dbPath == ":memory:" {
dbPath = "file::memory:?cache=shared"
}
+ log.Debug("Opening DB from: "+dbPath, "driver", driver)
+
err := initORM(dbPath)
if err != nil {
panic(err)
}
- log.Debug("Opening DB from: "+dbPath, "driver", driver)
})
return &SQLStore{}
}
@@ -67,6 +69,10 @@ func (db *SQLStore) Property() model.PropertyRepository {
return NewPropertyRepository(db.getOrmer())
}
+func (db *SQLStore) Resource(model interface{}) model.ResourceRepository {
+ return NewResource(db.getOrmer(), model, mappedModels[model])
+}
+
func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
o := orm.NewOrm()
err := o.Begin()
@@ -102,13 +108,6 @@ func (db *SQLStore) getOrmer() orm.Ormer {
func initORM(dbPath string) error {
verbose := conf.Sonic.LogLevel == "trace"
orm.Debug = verbose
- orm.RegisterModel(new(artist))
- orm.RegisterModel(new(album))
- orm.RegisterModel(new(mediaFile))
- orm.RegisterModel(new(checksum))
- orm.RegisterModel(new(property))
- orm.RegisterModel(new(playlist))
- orm.RegisterModel(new(Search))
if strings.Contains(dbPath, "postgres") {
driver = "postgres"
}
@@ -129,3 +128,22 @@ func collectField(collection interface{}, getValue func(item interface{}) string
return result
}
+
+func registerModel(model interface{}, mappedModel interface{}) {
+ mappedModels[model] = mappedModel
+ orm.RegisterModel(mappedModel)
+}
+
+func init() {
+ mappedModels = map[interface{}]interface{}{}
+
+ registerModel(new(model.Artist), new(artist))
+ registerModel(new(model.Album), new(album))
+ registerModel(new(model.MediaFile), new(mediaFile))
+ registerModel(new(model.Property), new(property))
+ registerModel(new(model.Playlist), new(playlist))
+ registerModel(model.User{}, new(user))
+
+ orm.RegisterModel(new(checksum))
+ orm.RegisterModel(new(search))
+}
diff --git a/persistence/resource_repository.go b/persistence/resource_repository.go
new file mode 100644
index 000000000..00778efe1
--- /dev/null
+++ b/persistence/resource_repository.go
@@ -0,0 +1,89 @@
+package persistence
+
+import (
+ "reflect"
+ "strings"
+
+ "github.com/astaxie/beego/orm"
+ "github.com/cloudsonic/sonic-server/model"
+ "github.com/deluan/rest"
+)
+
+type resourceRepository struct {
+ model.ResourceRepository
+ model interface{}
+ mappedModel interface{}
+ ormer orm.Ormer
+ instanceType reflect.Type
+ sliceType reflect.Type
+}
+
+func NewResource(o orm.Ormer, model interface{}, mappedModel interface{}) model.ResourceRepository {
+ r := &resourceRepository{model: model, mappedModel: mappedModel, ormer: o}
+ r.instanceType = reflect.TypeOf(mappedModel)
+ r.sliceType = reflect.SliceOf(r.instanceType)
+ return r
+}
+
+func (r *resourceRepository) newQuery() orm.QuerySeter {
+ return r.ormer.QueryTable(r.mappedModel)
+}
+
+func (r *resourceRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
+ qs := r.newQuery()
+ qs = r.addOptions(qs, options)
+ //qs = r.addFilters(qs, r.buildFilters(qs, options), r.getRestriction())
+ dataSet := r.NewSlice()
+ _, err := qs.All(dataSet)
+ if err == orm.ErrNoRows {
+ return dataSet, rest.ErrNotFound
+ }
+ return dataSet, err
+}
+
+func (r *resourceRepository) Count(options ...rest.QueryOptions) (int64, error) {
+ qs := r.newQuery()
+ //qs = r.addFilters(qs, r.buildFilters(qs, options), r.getRestriction())
+ count, err := qs.Count()
+ if err == orm.ErrNoRows {
+ err = rest.ErrNotFound
+ }
+ return count, err
+}
+
+func (r *resourceRepository) NewSlice() interface{} {
+ slice := reflect.MakeSlice(r.sliceType, 0, 0)
+ x := reflect.New(slice.Type())
+ x.Elem().Set(slice)
+ return x.Interface()
+}
+
+func (r *resourceRepository) addOptions(qs orm.QuerySeter, options []rest.QueryOptions) orm.QuerySeter {
+ if len(options) == 0 {
+ return qs
+ }
+ opt := options[0]
+ sort := strings.Split(opt.Sort, ",")
+ reverse := strings.ToLower(opt.Order) == "desc"
+ for i, s := range sort {
+ s = strings.TrimSpace(s)
+ if reverse {
+ if s[0] == '-' {
+ s = strings.TrimPrefix(s, "-")
+ } else {
+ s = "-" + s
+ }
+ }
+ sort[i] = strings.Replace(s, ".", "__", -1)
+ }
+ if opt.Sort != "" {
+ qs = qs.OrderBy(sort...)
+ }
+ if opt.Max > 0 {
+ qs = qs.Limit(opt.Max)
+ }
+ if opt.Offset > 0 {
+ qs = qs.Offset(opt.Offset)
+ }
+ return qs
+}
diff --git a/persistence/searchable_repository.go b/persistence/searchable_repository.go
index fb12cbc95..245ffbcff 100644
--- a/persistence/searchable_repository.go
+++ b/persistence/searchable_repository.go
@@ -9,7 +9,7 @@ import (
"github.com/kennygrant/sanitize"
)
-type Search struct {
+type search struct {
ID string `orm:"pk;column(id)"`
Table string `orm:"index"`
FullText string `orm:"index"`
@@ -55,13 +55,13 @@ func (r *searchableRepository) purgeInactive(activeList interface{}, getId func(
}
func (r *searchableRepository) addToIndex(table, id, text string) error {
- item := Search{ID: id, Table: table}
+ item := search{ID: id, Table: table}
err := r.ormer.Read(&item)
if err != nil && err != orm.ErrNoRows {
return err
}
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
- item = Search{ID: id, Table: table, FullText: sanitizedText}
+ item = search{ID: id, Table: table, FullText: sanitizedText}
if err == orm.ErrNoRows {
err = r.insert(&item)
} else {
@@ -79,7 +79,7 @@ func (r *searchableRepository) removeFromIndex(table string, ids []string) error
}
log.Trace("Deleting searchable items", "table", table, "num", len(subset), "from", offset)
offset += len(subset)
- _, err := r.ormer.QueryTable(&Search{}).Filter("table", table).Filter("id__in", subset).Delete()
+ _, err := r.ormer.QueryTable(&search{}).Filter("table", table).Filter("id__in", subset).Delete()
if err != nil {
return err
}
@@ -88,7 +88,7 @@ func (r *searchableRepository) removeFromIndex(table string, ids []string) error
}
func (r *searchableRepository) removeAllFromIndex(o orm.Ormer, table string) error {
- _, err := o.QueryTable(&Search{}).Filter("table", table).Delete()
+ _, err := o.QueryTable(&search{}).Filter("table", table).Delete()
return err
}
diff --git a/persistence/user_repository.go b/persistence/user_repository.go
new file mode 100644
index 000000000..2c88c23c0
--- /dev/null
+++ b/persistence/user_repository.go
@@ -0,0 +1,20 @@
+package persistence
+
+import (
+ "time"
+
+ "github.com/cloudsonic/sonic-server/model"
+)
+
+type user struct {
+ ID string `json:"id" orm:"pk;column(id)"`
+ Name string `json:"name" orm:"index"`
+ Password string `json:"-"`
+ IsAdmin bool `json:"isAdmin"`
+ LastLoginAt time.Time `json:"lastLoginAt"`
+ LastAccessAt time.Time `json:"lastAccessAt"`
+ CreatedAt time.Time `json:"createdAt" orm:"auto_now_add;type(datetime)"`
+ UpdatedAt time.Time `json:"updatedAt" orm:"auto_now;type(datetime)"`
+}
+
+var _ = model.User(user{})
diff --git a/server/app/app.go b/server/app/app.go
index a745b1b24..db03ef196 100644
--- a/server/app/app.go
+++ b/server/app/app.go
@@ -1,10 +1,14 @@
package app
import (
+ "context"
"net/http"
+ "net/url"
+ "strings"
"github.com/cloudsonic/sonic-server/model"
"github.com/cloudsonic/sonic-server/server"
+ "github.com/deluan/rest"
"github.com/go-chi/chi"
)
@@ -26,8 +30,54 @@ func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (app *Router) routes() http.Handler {
r := chi.NewRouter()
+
+ // Serve UI app assets
server.FileServer(r, app.path, "/", http.Dir("ui/build"))
+
+ // Basic unauthenticated ping
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"response":"pong"}`)) })
+ r.Route("/api", func(r chi.Router) {
+ // Add User resource
+ R(r, "/user", func(ctx context.Context) rest.Repository {
+ return app.ds.Resource(model.User{})
+ })
+ })
return r
}
+
+func R(r chi.Router, pathPrefix string, newRepository rest.RepositoryConstructor) {
+ r.Route(pathPrefix, func(r chi.Router) {
+ r.Get("/", rest.GetAll(newRepository))
+ r.Post("/", rest.Post(newRepository))
+ r.Route("/{id:[0-9a-f\\-]+}", func(r chi.Router) {
+ r.Use(UrlParams)
+ r.Get("/", rest.Get(newRepository))
+ r.Put("/", rest.Put(newRepository))
+ r.Delete("/", rest.Delete(newRepository))
+ })
+ })
+}
+
+// Middleware to convert Chi URL params (from Context) to query params, as expected by our REST package
+func UrlParams(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := chi.RouteContext(r.Context())
+ parts := make([]string, 0)
+ for i, key := range ctx.URLParams.Keys {
+ value := ctx.URLParams.Values[i]
+ if key == "*" {
+ continue
+ }
+ parts = append(parts, url.QueryEscape(":"+key)+"="+url.QueryEscape(value))
+ }
+ q := strings.Join(parts, "&")
+ if r.URL.RawQuery == "" {
+ r.URL.RawQuery = q
+ } else {
+ r.URL.RawQuery += "&" + q
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/ui/src/App.js b/ui/src/App.js
index 8d1aceb49..8c70c6f35 100644
--- a/ui/src/App.js
+++ b/ui/src/App.js
@@ -1,12 +1,13 @@
// in src/App.js
import React from 'react'
-import { Admin, ListGuesser, Resource } from 'react-admin'
+import { Admin, Resource } from 'react-admin'
import jsonServerProvider from 'ra-data-json-server'
+import user from './user'
-const dataProvider = jsonServerProvider('http://jsonplaceholder.typicode.com')
+const dataProvider = jsonServerProvider('/app/api')
const App = () => (
-
+
)
export default App
diff --git a/ui/src/user/UserList.js b/ui/src/user/UserList.js
new file mode 100644
index 000000000..bb38824a5
--- /dev/null
+++ b/ui/src/user/UserList.js
@@ -0,0 +1,48 @@
+import React from 'react'
+import {
+ BooleanField,
+ Datagrid,
+ DateField,
+ Filter,
+ List,
+ SearchInput,
+ SimpleList,
+ TextField
+} from 'react-admin'
+import { useMediaQuery } from '@material-ui/core'
+
+const UserFilter = (props) => (
+
+
+
+)
+
+const UserList = (props) => {
+ const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
+
+ return (
+
}
+ >
+ {isXsmall ? (
+ record.name}
+ secondaryText={(record) => record.email}
+ />
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default UserList
diff --git a/ui/src/user/index.js b/ui/src/user/index.js
new file mode 100644
index 000000000..6e13af8cc
--- /dev/null
+++ b/ui/src/user/index.js
@@ -0,0 +1,11 @@
+import SupervisedUserCircleIcon from '@material-ui/icons/SupervisedUserCircle'
+import UserList from './UserList'
+// import UserEdit from './UserEdit'
+// import UserCreate from './UserCreate'
+
+export default {
+ list: UserList,
+ // edit: UserEdit,
+ // create: UserCreate,
+ icon: SupervisedUserCircleIcon
+}