feat(09-10): add typed relation manager

This commit is contained in:
Jakub Zych
2026-09-26 21:33:43 +02:00
parent d7d48e2fbe
commit 12081c18d1
7 changed files with 1176 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
@@ -127,6 +128,8 @@ func (s *service) mount(r pact.Router) {
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema)
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/relation/{name}", s.relationSchema)
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}", s.list)
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}", s.create)
@@ -139,6 +142,14 @@ func (s *service) mount(r pact.Router) {
constrainController(g)
g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord)
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", s.relationLink)
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", s.relationUnlink)
constrainRelation(g)
})
}
@@ -148,6 +159,129 @@ func constrainController(g pact.Router) {
g.Where("controller", "[A-Za-z0-9_-]+")
}
func constrainRelation(g pact.Router) {
constrainController(g)
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
}
func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
cr, err := relationOf(cc, r.PathValue("name"))
if err != nil {
writeCRUDError(w, err)
return
}
tr := s.translator()
view := cr.Schema.Localize(r.Context(), tr)
meta := map[string]any{}
if locale := schemaLocale(r.Context(), tr); locale != "" {
meta["locale"] = locale
}
WriteData(w, http.StatusOK, view, meta)
})
}
func relationQueryFromRequest(r *http.Request) RelationQuery {
q := r.URL.Query()
return RelationQuery{Search: q.Get("search"), Sort: q.Get("sort"), Dir: q.Get("dir"), Page: q.Get("page"), PerPage: q.Get("per_page")}
}
func (s *service) relationLinked(w http.ResponseWriter, r *http.Request) {
s.relationList(w, r, false)
}
func (s *service) relationCandidates(w http.ResponseWriter, r *http.Request) {
s.relationList(w, r, true)
}
func (s *service) relationList(w http.ResponseWriter, r *http.Request, candidates bool) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var result *RelationResult
if candidates {
result, err = svc.Candidates(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r))
} else {
result, err = svc.Linked(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r))
}
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result.Data, map[string]any{"page": result.Meta.Page, "per_page": result.Meta.PerPage, "total": result.Meta.Total, "last_page": result.Meta.LastPage})
})
}
func (s *service) relationLink(w http.ResponseWriter, r *http.Request) {
s.relationMutation(w, r, true)
}
func (s *service) relationUnlink(w http.ResponseWriter, r *http.Request) {
s.relationMutation(w, r, false)
}
func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
in, err := decodeRelationMutation(r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.relations()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
var result RelationMutationResult
if link {
result, err = svc.Link(r.Context(), cc, r.PathValue("name"), id, in)
} else {
result, err = svc.Unlink(r.Context(), cc, r.PathValue("name"), id, in)
}
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}
func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) {
dec := json.NewDecoder(r.Body)
dec.UseNumber()
dec.DisallowUnknownFields()
var in RelationMutationInput
if err := dec.Decode(&in); err != nil {
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
}
return in, nil
}
func (s *service) relations() (RelationService, error) {
db, err := s.db()
if err != nil {
return RelationService{}, err
}
return RelationService{DB: db}, nil
}
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
if cc.Form == nil {
@@ -373,7 +507,7 @@ func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*Compi
fn(cc)
}
func projectRow(row any, cols []ListColumn) map[string]any {
func projectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any {
v := reflect.ValueOf(row)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
@@ -387,7 +521,7 @@ func projectRow(row any, cols []ListColumn) map[string]any {
}
for _, col := range cols {
if col.Relation != "" {
if value, ok := relatedSelect(v, col); ok {
if value, ok := relatedSelect(v, controller, col); ok {
out[col.Key] = value
}
continue