diff --git a/common/actions/generic.go b/common/actions/generic.go new file mode 100644 index 00000000..10c62ae7 --- /dev/null +++ b/common/actions/generic.go @@ -0,0 +1,294 @@ +package actions + +import ( + "errors" + "net/http" + "reflect" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/v2/jwtauth/user" + "github.com/go-admin-team/go-admin-core/v2/response" + "github.com/go-admin-team/go-admin-core/v2/sdk/api" + "github.com/go-admin-team/go-admin-core/v2/sdk/pkg" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go-admin/common/dto" +) + +// The generic CRUD actions. They serve the same five kinds of route as +// IndexAction and its siblings, and answer them the same way; what changes is +// what the caller has to get right. +// +// The older actions take instances at registration and serve every request +// from them, so each model and DTO has to implement a Generate that returns a +// copy - return the receiver and concurrent requests share one struct - and a +// list route takes a func() interface{} whose element type nothing checks. +// Here each request declares its own values of the types it was given, so +// neither convention exists, and a model paired with the wrong DTO does not +// compile. +// +// Three things differ from the older actions, all on paths they got wrong: +// +// - A request whose database connection cannot be found is answered with +// 500. The older actions logged it and wrote nothing, which reaches the +// client as an empty 200. +// - The key is matched against the model's primary-key column as a value. +// The older actions passed GetId() to Where, which GORM reads as a SQL +// condition when it is a string. +// - There is no GenerateM, so no error of its to drop. +// +// The type constraints are unexported: callers never name them, and an +// exported constraint would be a promise that could not be taken back. + +// record is a model the actions can create, read, change and delete. +type record[T any] interface { + *T + schema.Tabler + SetCreateBy(int) + SetUpdateBy(int) + GetId() any +} + +// searchReq is a list request: it binds itself, reports its page, and hands +// back the struct whose search tags MakeCondition turns into WHERE clauses. +type searchReq[S any] interface { + *S + Bind(*gin.Context) error + GetPageIndex() int + GetPageSize() int + GetNeedSearch() any +} + +// idReq is a request naming rows by key: one from the URI, or several from a +// DELETE body, as dto.ObjectById binds them. +type idReq[I any] interface { + *I + Bind(*gin.Context) error + GetId() any +} + +// controlReq is a create or update request: it binds itself and builds the +// model row it describes. +type controlReq[T, C any] interface { + *C + Bind(*gin.Context) error + ToModel() (*T, error) +} + +// Index lists T, filtered by S and by the caller's data permission. +func Index[T, S any, PT record[T], PS searchReq[S]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + msgID := pkg.GenerateMsgIDFromContext(c) + + var req S + if err := PS(&req).Bind(c); err != nil { + response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败") + return + } + var object T + list := make([]T, 0) + var count int64 + p := GetPermissionFromContext(c) + err := db.WithContext(c.Request.Context()).Model(&object). + Scopes( + dto.MakeCondition(PS(&req).GetNeedSearch()), + dto.Paginate(PS(&req).GetPageSize(), PS(&req).GetPageIndex()), + Permission(PT(&object).TableName(), p), + ). + Find(&list).Limit(-1).Offset(-1). + Count(&count).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + log.Errorf("MsgID[%s] Index error: %s", msgID, err) + response.Error(c, 500, err, "查询失败") + return + } + response.PageOK(c, &list, int(count), PS(&req).GetPageIndex(), PS(&req).GetPageSize(), "查询成功") + c.Next() + } +} + +// View answers one T, named by I's key, if the caller's data permission +// reaches it. +func View[T, I any, PT record[T], PI idReq[I]]() gin.HandlerFunc { + return ViewAs[T, I, T, PT, PI]() +} + +// ViewAs is View answering with R instead of T: the row is looked up as T and +// scanned into R, for a detail route that shows a different shape than the +// model stores. +func ViewAs[T, I, R any, PT record[T], PI idReq[I]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + msgID := pkg.GenerateMsgIDFromContext(c) + + var req I + if err := PI(&req).Bind(c); err != nil { + response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败") + return + } + var object T + var rsp R + p := GetPermissionFromContext(c) + err := db.Model(&object).WithContext(c.Request.Context()).Scopes( + Permission(PT(&object).TableName(), p), + ).Where(keyIs(PI(&req).GetId())).First(&rsp).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + response.Error(c, http.StatusNotFound, nil, "查看对象不存在或无权查看") + return + } + if err != nil { + log.Errorf("MsgID[%s] View error: %s", msgID, err) + response.Error(c, 500, err, "查看失败") + return + } + response.OK(c, &rsp, "查询成功") + c.Next() + } +} + +// Create inserts the T that C builds, recording the caller as its creator. +func Create[T, C any, PT record[T], PC controlReq[T, C]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + + var req C + if err := PC(&req).Bind(c); err != nil { + response.Error(c, http.StatusUnprocessableEntity, err, err.Error()) + return + } + object, err := PC(&req).ToModel() + if err != nil { + response.Error(c, 500, err, "模型生成失败") + return + } + PT(object).SetCreateBy(user.GetUserId(c)) + if err = db.WithContext(c.Request.Context()).Create(object).Error; err != nil { + log.Errorf("Create error: %s", err) + response.Error(c, 500, err, "创建失败") + return + } + response.OK(c, PT(object).GetId(), "创建成功") + c.Next() + } +} + +// Update writes the T that C builds over the row with its key, if the +// caller's data permission reaches that row. +func Update[T, C any, PT record[T], PC controlReq[T, C]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + msgID := pkg.GenerateMsgIDFromContext(c) + + var req C + if err := PC(&req).Bind(c); err != nil { + response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败") + return + } + object, err := PC(&req).ToModel() + if err != nil { + response.Error(c, 500, err, "模型生成失败") + return + } + PT(object).SetUpdateBy(user.GetUserId(c)) + + p := GetPermissionFromContext(c) + result := db.WithContext(c.Request.Context()).Scopes( + Permission(PT(object).TableName(), p), + ).Where(keyIs(PT(object).GetId())).Updates(object) + if err = result.Error; err != nil { + log.Errorf("MsgID[%s] Update error: %s", msgID, err) + response.Error(c, 500, err, "更新失败") + return + } + if result.RowsAffected == 0 { + response.Error(c, http.StatusForbidden, nil, "无权更新该数据") + return + } + response.OK(c, PT(object).GetId(), "更新成功") + c.Next() + } +} + +// Delete removes the rows I names, where the caller's data permission +// reaches them. +func Delete[T, I any, PT record[T], PI idReq[I]]() gin.HandlerFunc { + return func(c *gin.Context) { + log := api.GetRequestLogger(c) + db, ok := genericOrm(c) + if !ok { + return + } + msgID := pkg.GenerateMsgIDFromContext(c) + + var req I + if err := PI(&req).Bind(c); err != nil { + log.Errorf("MsgID[%s] Bind error: %s", msgID, err) + response.Error(c, http.StatusUnprocessableEntity, err, "参数验证失败") + return + } + var object T + PT(&object).SetUpdateBy(user.GetUserId(c)) + + p := GetPermissionFromContext(c) + result := db.WithContext(c.Request.Context()).Scopes( + Permission(PT(&object).TableName(), p), + ).Where(keyIs(PI(&req).GetId())).Delete(&object) + if err := result.Error; err != nil { + log.Errorf("MsgID[%s] Delete error: %s", msgID, err) + response.Error(c, 500, err, "删除失败") + return + } + if result.RowsAffected == 0 { + response.Error(c, http.StatusForbidden, nil, "无权删除该数据") + return + } + // The key of the empty model, as DeleteAction answers: the rows are + // named by the request, not by a model. + response.OK(c, PT(&object).GetId(), "删除成功") + c.Next() + } +} + +// genericOrm reads the request's database, answering 500 when there is none. +func genericOrm(c *gin.Context) (*gorm.DB, bool) { + db, err := pkg.GetOrm(c) + if err != nil { + api.GetRequestLogger(c).Error(err) + response.Error(c, 500, err, "数据库连接获取失败") + return nil, false + } + return db, true +} + +// keyIs matches the primary-key column against id as a value, or against +// each element when id is a slice or array. +func keyIs(id any) clause.Expression { + v := reflect.ValueOf(id) + if v.Kind() == reflect.Slice || v.Kind() == reflect.Array { + values := make([]any, v.Len()) + for i := range values { + values[i] = v.Index(i).Interface() + } + return clause.IN{Column: clause.PrimaryColumn, Values: values} + } + return clause.Eq{Column: clause.PrimaryColumn, Value: id} +} diff --git a/common/actions/generic_test.go b/common/actions/generic_test.go new file mode 100644 index 00000000..208c33f6 --- /dev/null +++ b/common/actions/generic_test.go @@ -0,0 +1,257 @@ +package actions_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/go-admin-team/go-admin-core/v2/jwtauth" + "github.com/go-admin-team/go-admin-core/v2/sdk/config" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "go-admin/common/actions" + "go-admin/common/dto" + "go-admin/common/models" +) + +// probeSearch is probeIndexReq with a search field, so a list request can +// be told apart from another in its response. +type probeSearch struct { + dto.Pagination `search:"-"` + Name string `form:"name" search:"type:exact;column:name;table:action_probe_row"` +} + +func (p *probeSearch) Generate() dto.Index { o := *p; return &o } +func (p *probeSearch) Bind(c *gin.Context) error { return c.ShouldBind(p) } +func (p *probeSearch) GetNeedSearch() interface{} { return *p } + +// probeById names rows the way every ById DTO here does. +type probeById struct { + dto.ObjectById +} + +func (s *probeById) Generate() dto.Control { o := *s; return &o } +func (s *probeById) GenerateM() (models.ActiveRecord, error) { return &probeRow{}, nil } + +// probeControl carries both the old actions' methods and ToModel, so one +// request type serves both generations of action. +type probeControl struct { + Id int `json:"id"` + Name string `json:"name"` +} + +func (s *probeControl) Bind(c *gin.Context) error { return c.ShouldBindJSON(s) } +func (s *probeControl) Generate() dto.Control { o := *s; return &o } +func (s *probeControl) GetId() interface{} { return s.Id } +func (s *probeControl) GenerateM() (models.ActiveRecord, error) { + return &probeRow{Model: models.Model{Id: s.Id}, Name: s.Name}, nil +} +func (s *probeControl) ToModel() (*probeRow, error) { + return &probeRow{Model: models.Model{Id: s.Id}, Name: s.Name}, nil +} + +// probeKey names a row by a string key, which the old actions handed to GORM +// as a SQL condition. +type probeKey struct { + Key string `uri:"id"` +} + +func (s *probeKey) Bind(c *gin.Context) error { return c.ShouldBindUri(s) } +func (s *probeKey) GetId() interface{} { return s.Key } + +func probeDB(t *testing.T, name string, l gormlogger.Interface) *gorm.DB { + t.Helper() + if l == nil { + l = gormlogger.Default.LogMode(gormlogger.Silent) + } + db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{Logger: l}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&probeRow{}); err != nil { + t.Fatal(err) + } + return db +} + +// probeEngine serves the five routes from db, as caller 7. +func probeEngine(db *gorm.DB, register func(r gin.IRoutes)) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + if db != nil { + c.Set("db", db) + } + c.Set(jwtauth.JwtPayloadKey, jwtauth.MapClaims{"identity": float64(7)}) + c.Next() + }) + register(r) + return r +} + +func oldRoutes(r gin.IRoutes) { + r.GET("/x", actions.IndexAction(&probeRow{}, &probeSearch{}, func() interface{} { l := make([]probeRow, 0); return &l })) + r.GET("/x/:id", actions.ViewAction(&probeById{}, func() interface{} { return &probeRow{} })) + r.POST("/x", actions.CreateAction(&probeControl{})) + r.PUT("/x", actions.UpdateAction(&probeControl{})) + r.DELETE("/x", actions.DeleteAction(&probeById{})) +} + +func newRoutes(r gin.IRoutes) { + r.GET("/x", actions.Index[probeRow, probeSearch]()) + r.GET("/x/:id", actions.View[probeRow, probeById]()) + r.POST("/x", actions.Create[probeRow, probeControl]()) + r.PUT("/x", actions.Update[probeRow, probeControl]()) + r.DELETE("/x", actions.Delete[probeRow, probeById]()) +} + +var requestIDField = regexp.MustCompile(`"requestId":"[^"]*"`) + +func serve(r *gin.Engine, method, path, body string) (int, string) { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + return w.Code, requestIDField.ReplaceAllString(w.Body.String(), `"requestId":""`) +} + +// The generic actions answer every request exactly as the actions they +// replace, on the same types and the same data, until the old ones go. +func TestGenericActionsAnswerAsTheOldOnesDo(t *testing.T) { + script := []struct{ method, path, body string }{ + {"POST", "/x", `{"name":"a"}`}, + {"POST", "/x", `{"name":"b"}`}, + {"POST", "/x", `not json`}, + {"GET", "/x?pageIndex=1&pageSize=10", ""}, + {"GET", "/x?pageIndex=1&pageSize=1&name=b", ""}, + {"GET", "/x?pageIndex=2&pageSize=1", ""}, + {"GET", "/x/1", ""}, + {"GET", "/x/99", ""}, + {"GET", "/x/abc", ""}, + {"PUT", "/x", `{"id":1,"name":"a2"}`}, + {"PUT", "/x", `{"id":99,"name":"ghost"}`}, + {"PUT", "/x", `{"name":"no-id"}`}, + {"GET", "/x/1", ""}, + {"DELETE", "/x", `{"ids":[2]}`}, + {"DELETE", "/x", `{"ids":[99]}`}, + {"GET", "/x?pageIndex=1&pageSize=10", ""}, + } + old := probeEngine(probeDB(t, t.Name()+"-old", nil), oldRoutes) + gen := probeEngine(probeDB(t, t.Name()+"-new", nil), newRoutes) + for i, s := range script { + oc, ob := serve(old, s.method, s.path, s.body) + nc, nb := serve(gen, s.method, s.path, s.body) + if oc != nc || ob != nb { + t.Errorf("step %d, %s %s %s:\nold %d %s\nnew %d %s", i+1, s.method, s.path, s.body, oc, ob, nc, nb) + } + } +} + +// Every action that reads or changes existing rows applies the caller's +// data permission: Index, View, Update and Delete. +func TestGenericActionsApplyDataPermission(t *testing.T) { + previous := config.ApplicationConfig.EnableDP + config.ApplicationConfig.EnableDP = true + t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous }) + + for _, req := range []struct{ method, path, body string }{ + {"GET", "/x?pageIndex=1&pageSize=10", ""}, + {"GET", "/x/1", ""}, + {"PUT", "/x", `{"id":1,"name":"a"}`}, + {"DELETE", "/x", `{"ids":[1]}`}, + } { + cl := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Silent)} + db := probeDB(t, strings.NewReplacer("/", "_", "?", "_").Replace(t.Name()+req.method+req.path), cl) + r := probeEngine(db, func(r gin.IRoutes) { + self := func(c *gin.Context) { + c.Set(actions.PermissionKey, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 7}) + } + r.GET("/x", self, actions.Index[probeRow, probeSearch]()) + r.GET("/x/:id", self, actions.View[probeRow, probeById]()) + r.PUT("/x", self, actions.Update[probeRow, probeControl]()) + r.DELETE("/x", self, actions.Delete[probeRow, probeById]()) + }) + serve(r, req.method, req.path, req.body) + if !strings.Contains(cl.all(), "action_probe_row.create_by = ") { + t.Errorf("%s %s ran without the data-permission scope:\n%s", req.method, req.path, cl.all()) + } + } +} + +// A string key is compared as a value. Handed to Where on its own, "1=1" +// is read as a SQL condition and matches every row. +func TestGenericViewMatchesAStringKeyAsAValue(t *testing.T) { + db := probeDB(t, t.Name(), nil) + if err := db.Create(&probeRow{Name: "a"}).Error; err != nil { + t.Fatal(err) + } + r := probeEngine(db, func(r gin.IRoutes) { r.GET("/x/:id", actions.View[probeRow, probeKey]()) }) + + _, body := serve(r, "GET", "/x/1=1", "") + var res struct{ Code int } + _ = json.Unmarshal([]byte(body), &res) + if res.Code != http.StatusNotFound { + t.Errorf("GET /x/1=1 answered %s; want the row not found", body) + } +} + +// Without a database in the request the old actions wrote nothing, which a +// client reads as an empty 200. +func TestGenericActionsAnswerAMissingDatabase(t *testing.T) { + r := probeEngine(nil, newRoutes) + for _, req := range []struct{ method, path, body string }{ + {"GET", "/x?pageIndex=1&pageSize=10", ""}, + {"GET", "/x/1", ""}, + {"POST", "/x", `{"name":"a"}`}, + {"PUT", "/x", `{"id":1}`}, + {"DELETE", "/x", `{"ids":[1]}`}, + } { + _, body := serve(r, req.method, req.path, req.body) + if !strings.Contains(body, `"code":500`) || !strings.Contains(body, "数据库连接获取失败") { + t.Errorf("%s %s answered %q; want a 500 naming the connection", req.method, req.path, body) + } + } +} + +// Concurrent requests to one route each see only what they asked for. The +// generic actions build every value per request, so there is nothing to +// share; this holds that shape. +func TestGenericIndexKeepsConcurrentRequestsApart(t *testing.T) { + db := probeDB(t, t.Name(), nil) + for i := 0; i < 20; i++ { + if err := db.Create(&probeRow{Name: fmt.Sprintf("n%d", i)}).Error; err != nil { + t.Fatal(err) + } + } + r := probeEngine(db, newRoutes) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + name := fmt.Sprintf("n%d", i) + wg.Go(func() { + for j := 0; j < 10; j++ { + _, body := serve(r, "GET", "/x?pageIndex=1&pageSize=10&name="+name, "") + var res struct { + Data struct{ List []probeRow } + } + if err := json.Unmarshal([]byte(body), &res); err != nil { + t.Errorf("decoding %q: %v", body, err) + return + } + if len(res.Data.List) != 1 || res.Data.List[0].Name != name { + t.Errorf("asked for %s, got %+v", name, res.Data.List) + return + } + } + }) + } + wg.Wait() +}