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