feat(10-02): relation field options and relation saves with labels

- FieldRelationContract/FieldRelationProvider bind every type: relation
  field to a belongsTo foreign key or a belongsToMany pivot; activation
  fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
  {value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
  the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
  revalidated through the same scoped query (422 and full rollback
  otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
  rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
  which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
This commit is contained in:
Jakub Zych
2026-09-27 16:00:52 +02:00
parent e9b48d4720
commit fe04dbc89e
14 changed files with 1969 additions and 102 deletions

View File

@@ -212,8 +212,11 @@ func (s *service) mount(r pact.Router) {
constrainController(g)
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
constrainRelation(g)
// Six-segment GET routes share one pattern: ServeMux rejects the
// relation list next to the field options route (neither is more
// specific), so nestedGet dispatches on the literal segments.
g.Get("/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}", s.nestedGet)
constrainNested(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
@@ -240,6 +243,32 @@ func constrainRelation(g pact.Router) {
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
}
func constrainNested(g pact.Router) {
constrainController(g)
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
}
// nestedGet serves the logical routes
//
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
//
// A numeric id never equals a literal segment, so the dispatch is unambiguous.
// Anything else is the D-10 not_found envelope, as an unmatched API path.
func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) {
id, segment, name := r.PathValue("id"), r.PathValue("segment"), r.PathValue("name")
switch {
case id == "fields" && name == "options":
r.SetPathValue("field", segment)
s.fieldOptions(w, r)
case segment == "relations":
s.relationLinked(w, r)
default:
writeNotFound(w, r)
}
}
func constrainSetting(g pact.Router) {
g.Where("code", "[A-Za-z_][A-Za-z0-9_-]*")
}
@@ -509,12 +538,12 @@ func (s *service) show(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.Show(r.Context(), cc, id)
rec, err := svc.ShowRecord(r.Context(), cc, id)
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, rec, nil)
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
})
}
@@ -530,12 +559,12 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.Create(r.Context(), cc, RecordInput{Body: body})
rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body})
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusCreated, rec, nil)
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
})
}
@@ -556,12 +585,12 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rec, err := svc.Update(r.Context(), cc, id, RecordInput{Body: body})
rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body})
if err != nil {
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, rec, nil)
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
})
}