diff --git a/cabana/crud.go b/cabana/crud.go index 9da5dbd..693efc1 100644 --- a/cabana/crud.go +++ b/cabana/crud.go @@ -126,9 +126,77 @@ func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any, return s.save(ctx, cc, id, in, true) } -// Delete removes one scoped record. The lifecycle is added with the record routes. +// Delete removes one scoped record. A missing or out-of-scope id is a successful no-op. func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any) (BulkResult, error) { - return BulkResult{}, errors.New("cabana: delete is not implemented") + if s.DB == nil { + return BulkResult{}, errors.New("cabana: database is not configured") + } + if ctx == nil { + ctx = context.Background() + } + if _, err := newWritableModel(cc); err != nil { + return BulkResult{}, err + } + var result BulkResult + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + target, err := newWritableModel(cc) + if err != nil { + return err + } + pk, err := coercePK(target, id) + if err != nil { + return err + } + if err := loadRecord(ctx, tx, cc, target, pk); err != nil { + var missing recordNotFound + if errors.As(err, &missing) { + result.Deleted = 0 + return nil + } + return err + } + if err := deleteRecord(ctx, tx, cc, target); err != nil { + return err + } + result.Deleted = 1 + return nil + }) + if err != nil { + return BulkResult{}, err + } + return result, nil +} + +// Show loads one scoped record. Missing and out-of-scope ids are identical. +func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) { + if s.DB == nil { + return nil, errors.New("cabana: database is not configured") + } + if ctx == nil { + ctx = context.Background() + } + var result map[string]any + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + target, err := newWritableModel(cc) + if err != nil { + return err + } + pk, err := coercePK(target, id) + if err != nil { + return err + } + if err := loadRecord(ctx, tx, cc, target, pk); err != nil { + return err + } + result = projectRecord(cc, target) + return nil + }) + if err != nil { + return nil, err + } + return result, nil } func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (map[string]any, error) { @@ -177,10 +245,24 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i return &ValidationError{Details: validationDetails(msgs)} } if update { + if err := formBeforeUpdate(ctx, cc, target); err != nil { + return err + } err = tx.Save(target).Error } else { + if err := formBeforeCreate(ctx, cc, target); err != nil { + return err + } err = tx.Create(target).Error } + if err != nil { + return lifecycleFailure(cc, err) + } + if update { + err = formAfterUpdate(ctx, cc, target) + } else { + err = formAfterCreate(ctx, cc, target) + } if err != nil { return err } @@ -238,12 +320,125 @@ func controllerID(cc *CompiledController) string { func loadRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, dest any, pk any) error { col := primaryColumn(dest) - q := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}) - err := q.Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).Take(dest).Error + q := tx.WithContext(ctx) + if cc != nil { + if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil { + if next := ext.FormExtendQuery(ctx, q); next != nil { + q = next + } + } + } + err := q.Clauses(clause.Locking{Strength: "UPDATE"}). + Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}). + Take(dest).Error if errors.Is(err, gorm.ErrRecordNotFound) { return recordNotFound{} } - return err + if err != nil { + return lifecycleFailure(cc, err) + } + return nil +} + +type lifecycleError struct { + ControllerID string +} + +func (e *lifecycleError) Error() string { + if e == nil { + return "cabana: lifecycle failed" + } + return "cabana: controller " + e.ControllerID + " failed" +} + +func lifecycleFailure(cc *CompiledController, err error) error { + if err == nil { + return nil + } + var missing recordNotFound + if errors.As(err, &missing) { + return err + } + var invalid *ValidationError + if errors.As(err, &invalid) { + return err + } + var closed *CapabilityError + if errors.As(err, &closed) { + return err + } + return &lifecycleError{ControllerID: controllerID(cc)} +} + +func formBeforeCreate(ctx context.Context, cc *CompiledController, model any) error { + hook, ok := cc.Controller.(pact.FormBeforeCreate) + if !ok || hook == nil { + return nil + } + return lifecycleFailure(cc, hook.FormBeforeCreate(ctx, model)) +} + +func formAfterCreate(ctx context.Context, cc *CompiledController, model any) error { + hook, ok := cc.Controller.(pact.FormAfterCreate) + if !ok || hook == nil { + return nil + } + return lifecycleFailure(cc, hook.FormAfterCreate(ctx, model)) +} + +func formBeforeUpdate(ctx context.Context, cc *CompiledController, model any) error { + hook, ok := cc.Controller.(pact.FormBeforeUpdate) + if !ok || hook == nil { + return nil + } + return lifecycleFailure(cc, hook.FormBeforeUpdate(ctx, model)) +} + +func formAfterUpdate(ctx context.Context, cc *CompiledController, model any) error { + hook, ok := cc.Controller.(pact.FormAfterUpdate) + if !ok || hook == nil { + return nil + } + return lifecycleFailure(cc, hook.FormAfterUpdate(ctx, model)) +} + +func deleteRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) error { + if hook, ok := cc.Controller.(pact.FormBeforeDelete); ok && hook != nil { + if err := lifecycleFailure(cc, hook.FormBeforeDelete(ctx, model)); err != nil { + return err + } + } + if err := tx.WithContext(ctx).Delete(model).Error; err != nil { + return lifecycleFailure(cc, err) + } + if hook, ok := cc.Controller.(pact.FormAfterDelete); ok && hook != nil { + if err := lifecycleFailure(cc, hook.FormAfterDelete(ctx, model)); err != nil { + return err + } + } + return nil +} + +func decodeObject(r *http.Request) (map[string]any, error) { + dec := json.NewDecoder(r.Body) + dec.UseNumber() + var body map[string]any + if err := dec.Decode(&body); err != nil { + return nil, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}} + } + if body == nil { + body = map[string]any{} + } + return body, nil +} + +func pathID(r *http.Request) (uint, error) { + raw := strings.TrimSpace(r.PathValue("id")) + n, err := strconv.ParseUint(raw, 10, 64) + if raw == "" || err != nil { + return 0, &ValidationError{Details: map[string]any{"id": []string{"The id field must be an integer."}}} + } + return uint(n), nil } func projectOperation(cc *CompiledController, body map[string]any, op string) map[string]any { diff --git a/cabana/http.go b/cabana/http.go index d21d4ba..077ffe1 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -126,6 +126,14 @@ func (s *service) mount(r pact.Router) { constrainController(g) g.Get("/{vendor}/{plugin}/{controller}", s.list) constrainController(g) + g.Post("/{vendor}/{plugin}/{controller}", s.create) + constrainController(g) + g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show) + constrainController(g) + g.Put("/{vendor}/{plugin}/{controller}/{id}", s.update) + constrainController(g) + g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord) + constrainController(g) }) } @@ -166,19 +174,100 @@ func (s *service) translator() *phrasebook.Translator { } func (s *service) show(w http.ResponseWriter, r *http.Request) { - WriteError(w, http.StatusInternalServerError, "error", msgServerError) + s.protect(w, r, func(cc *CompiledController) { + id, err := pathID(r) + if err != nil { + writeCRUDError(w, err) + return + } + svc, err := s.crud() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + rec, err := svc.Show(r.Context(), cc, id) + if err != nil { + writeCRUDError(w, err) + return + } + WriteData(w, http.StatusOK, rec, nil) + }) } func (s *service) create(w http.ResponseWriter, r *http.Request) { - WriteError(w, http.StatusInternalServerError, "error", msgServerError) + s.protect(w, r, func(cc *CompiledController) { + body, err := decodeObject(r) + if err != nil { + writeCRUDError(w, err) + return + } + svc, err := s.crud() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + rec, err := svc.Create(r.Context(), cc, RecordInput{Body: body}) + if err != nil { + writeCRUDError(w, err) + return + } + WriteData(w, http.StatusCreated, rec, nil) + }) } func (s *service) update(w http.ResponseWriter, r *http.Request) { - WriteError(w, http.StatusInternalServerError, "error", msgServerError) + s.protect(w, r, func(cc *CompiledController) { + id, err := pathID(r) + if err != nil { + writeCRUDError(w, err) + return + } + body, err := decodeObject(r) + if err != nil { + writeCRUDError(w, err) + return + } + svc, err := s.crud() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + rec, err := svc.Update(r.Context(), cc, id, RecordInput{Body: body}) + if err != nil { + writeCRUDError(w, err) + return + } + WriteData(w, http.StatusOK, rec, nil) + }) } func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) { - WriteError(w, http.StatusInternalServerError, "error", msgServerError) + s.protect(w, r, func(cc *CompiledController) { + id, err := pathID(r) + if err != nil { + writeCRUDError(w, err) + return + } + svc, err := s.crud() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + result, err := svc.Delete(r.Context(), cc, id) + if err != nil { + writeCRUDError(w, err) + return + } + WriteData(w, http.StatusOK, result, nil) + }) +} + +func (s *service) crud() (CRUDService, error) { + db, err := s.db() + if err != nil { + return CRUDService{}, err + } + return CRUDService{DB: db}, nil } func (s *service) list(w http.ResponseWriter, r *http.Request) {