- One shared localizer for ValidationError and ForbiddenError, tested in writeCRUDError's order; replaces the ForbiddenError-only one. - Applied on the CRUD and relation service transactions, the detached bulk branch, runAction, the record read (ShowRecord), and the reorder, singleton and relation-options routes, which localized neither type. - Only strings containing "::" are looked up, so translated text passes through unchanged and logs no missing-key warning. - Copy-on-write: the plugin's error value is never modified. - README and backend docs describe the localized ValidationError.
113 lines
3.4 KiB
Go
113 lines
3.4 KiB
Go
package cabana
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AdminSingletonResult is the data of the singleton route: the id of the one
|
|
// record a pact.AdminSingleton controller edits.
|
|
type AdminSingletonResult struct {
|
|
ID uint `json:"id"`
|
|
}
|
|
|
|
// singletonRefuses answers 404 for a list, create, bulk or delete request on
|
|
// a singleton controller (D-11) and reports whether it did.
|
|
func singletonRefuses(w http.ResponseWriter, r *http.Request, cc *CompiledController) bool {
|
|
if cc == nil || !cc.singleton {
|
|
return false
|
|
}
|
|
writeNotFound(w, r)
|
|
return true
|
|
}
|
|
|
|
// singletonRace is a SingletonRecord that lost a first-or-create race: a
|
|
// concurrent first request created the record between its read and its
|
|
// insert, so the insert hit a unique index.
|
|
type singletonRace struct{}
|
|
|
|
func (singletonRace) Error() string { return "cabana: singleton record created concurrently" }
|
|
|
|
// uniqueViolation reports a Postgres unique violation (SQLSTATE 23505).
|
|
func uniqueViolation(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
|
}
|
|
|
|
// singletonRoute serves GET {vendor}/{plugin}/{controller}/singleton: in one
|
|
// transaction it asks the controller's SingletonRecord for the record
|
|
// (created when absent) and answers its id, after checking that the form
|
|
// scope (FormExtendQuery) can load it. When two first requests race and the
|
|
// loser's insert hits the record's unique index, the transaction runs once
|
|
// more and finds the record the winner created. It is guarded like the form
|
|
// schema; a controller that is not a singleton answers 404.
|
|
func (s *service) singletonRoute(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
provider, ok := cc.Controller.(pact.AdminSingleton)
|
|
if !cc.singleton || !ok || provider == nil {
|
|
writeNotFound(w, r)
|
|
return
|
|
}
|
|
db, err := s.db()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
var result AdminSingletonResult
|
|
attempt := func(tx *gorm.DB) error {
|
|
txCtx := withTx(ctx, tx)
|
|
record, err := provider.SingletonRecord(txCtx, tx)
|
|
if err != nil {
|
|
if uniqueViolation(err) {
|
|
return singletonRace{}
|
|
}
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
var id uint
|
|
if record != nil {
|
|
id = pkUint(record)
|
|
}
|
|
if id == 0 {
|
|
return lifecycleFailure(cc, errors.New("cabana: SingletonRecord returned no record with an id"))
|
|
}
|
|
target, err := newWritableModel(cc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pk, err := coercePK(target, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := loadRecord(txCtx, tx, cc, target, pk); err != nil {
|
|
return err
|
|
}
|
|
result.ID = id
|
|
return nil
|
|
}
|
|
err = db.WithContext(ctx).Transaction(attempt)
|
|
var race singletonRace
|
|
if errors.As(err, &race) {
|
|
err = db.WithContext(ctx).Transaction(attempt)
|
|
if errors.As(err, &race) {
|
|
err = lifecycleFailure(cc, err)
|
|
}
|
|
}
|
|
if err != nil {
|
|
var missing recordNotFound
|
|
if !errors.As(err, &missing) {
|
|
slog.Error("cabana: singleton record failed", "controller", controllerID(cc), "error", err)
|
|
}
|
|
// A refusal from SingletonRecord carries plugin phrase keys.
|
|
writeCRUDError(w, localizeControllerError(ctx, s.translator(), err))
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|