-
Notifications
You must be signed in to change notification settings - Fork 24
HYPERFLEET-1328 - refactor: simplify resource handlers and add registry cycle detection #286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,14 +13,8 @@ import ( | |
| "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" | ||
| ) | ||
|
|
||
| // ResourceHandler serves both flat and owner-nested routes for a single entity | ||
| // kind. Every method branches on whether "parent_id" is present in mux.Vars(r) | ||
| // rather than dispatching statically per route. This is only correct because | ||
| // plugins/entities/plugin.go guarantees the invariant: a nested (ParentKind != "") | ||
| // descriptor is registered exclusively under a {parent_id} subrouter, and a flat | ||
| // descriptor never is. If that registration is ever bypassed — e.g. a nested kind | ||
| // wired to a flat route — these branches take the wrong path silently (Create | ||
| // would skip setting owner references instead of erroring). | ||
| // ResourceHandler serves both flat and owner-nested routes for one entity kind. | ||
| // Each verb branches on whether parent_id is present in the path. | ||
| type ResourceHandler struct { | ||
| service services.ResourceService | ||
| descriptor registry.EntityDescriptor | ||
|
|
@@ -48,16 +42,22 @@ func (h *ResourceHandler) Create(w http.ResponseWriter, r *http.Request) { | |
| Action: func() (interface{}, *errors.ServiceError) { | ||
| ctx := r.Context() | ||
|
|
||
| parentID, hasParent := mux.Vars(r)["parent_id"] | ||
| if !hasParent && h.descriptor.ParentKind != "" { | ||
| parent := registry.MustGet(h.descriptor.ParentKind) | ||
| return nil, errors.Validation( | ||
| "kind %q is a child kind; create it via /%s/{id}/%s", h.descriptor.Kind, parent.Plural, h.descriptor.Plural, | ||
| ) | ||
| } | ||
|
|
||
| var resource *api.Resource | ||
| var err error | ||
| if parentID, hasParent := mux.Vars(r)["parent_id"]; hasParent { | ||
| if hasParent { | ||
| parent, svcErr := h.service.Get(ctx, h.descriptor.ParentKind, parentID) | ||
| if svcErr != nil { | ||
| return nil, svcErr | ||
| } | ||
| resource, err = presenters.ConvertResourceWithOwner(&req, parent.ID, parent.Kind, parent.Href) | ||
| } else if h.descriptor.ParentKind != "" { | ||
| return nil, childCreateRejection(h.descriptor) | ||
| } else { | ||
| resource, err = presenters.ConvertResource(&req) | ||
| } | ||
|
|
@@ -79,15 +79,15 @@ func (h *ResourceHandler) Get(w http.ResponseWriter, r *http.Request) { | |
| cfg := &handlerConfig{ | ||
| Action: func() (interface{}, *errors.ServiceError) { | ||
| ctx := r.Context() | ||
| vars := mux.Vars(r) | ||
| id := vars["id"] | ||
| id := mux.Vars(r)["id"] | ||
|
|
||
| parentID, err := h.checkParentExists(r) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var resource *api.Resource | ||
| var err *errors.ServiceError | ||
| if parentID, hasParent := vars["parent_id"]; hasParent { | ||
| if _, err = h.service.Get(ctx, h.descriptor.ParentKind, parentID); err != nil { | ||
| return nil, err | ||
| } | ||
| if parentID != "" { | ||
|
Comment on lines
+84
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Remove parent pre-checks from These calls restore the old behavior: invalid-parent Proposed fix- parentID, err := h.checkParentExists(r)
- if err != nil {
- return nil, err
- }
+ parentID, hasParent := mux.Vars(r)["parent_id"]
var resource *api.Resource
- if parentID != "" {
+ var err *errors.ServiceError
+ if hasParent {
resource, err = h.service.GetByOwner(ctx, h.descriptor.Kind, id, parentID)
} else {
resource, err = h.service.Get(ctx, h.descriptor.Kind, id)
}- parentID, err := h.checkParentExists(r)
- if err != nil {
- return nil, err
- }
+ parentID, hasParent := mux.Vars(r)["parent_id"]
listArgs, err := services.NewListArguments(r.URL.Query())
if err != nil {
return nil, err
}
var resources api.ResourceList
var paging *api.PagingMeta
- if parentID != "" {
+ if hasParent {
resources, paging, err = h.service.ListByOwner(ctx, h.descriptor.Kind, parentID, listArgs)
} else {Also applies to: 110-122 🤖 Prompt for AI Agents |
||
| resource, err = h.service.GetByOwner(ctx, h.descriptor.Kind, id, parentID) | ||
| } else { | ||
| resource, err = h.service.Get(ctx, h.descriptor.Kind, id) | ||
|
|
@@ -107,17 +107,19 @@ func (h *ResourceHandler) List(w http.ResponseWriter, r *http.Request) { | |
| Action: func() (interface{}, *errors.ServiceError) { | ||
| ctx := r.Context() | ||
|
|
||
| parentID, err := h.checkParentExists(r) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| listArgs, err := services.NewListArguments(r.URL.Query()) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var resources api.ResourceList | ||
| var paging *api.PagingMeta | ||
| if parentID, hasParent := mux.Vars(r)["parent_id"]; hasParent { | ||
| if _, svcErr := h.service.Get(ctx, h.descriptor.ParentKind, parentID); svcErr != nil { | ||
| return nil, svcErr | ||
| } | ||
| if parentID != "" { | ||
| resources, paging, err = h.service.ListByOwner(ctx, h.descriptor.Kind, parentID, listArgs) | ||
| } else { | ||
| resources, paging, err = h.service.List(ctx, h.descriptor.Kind, listArgs) | ||
|
|
@@ -148,7 +150,7 @@ func (h *ResourceHandler) Patch(w http.ResponseWriter, r *http.Request) { | |
| Action: func() (interface{}, *errors.ServiceError) { | ||
| id := mux.Vars(r)["id"] | ||
|
|
||
| if err := h.verifyOwnership(r, id); err != nil { | ||
| if err := h.checkOwnership(r, id); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
|
|
@@ -168,24 +170,42 @@ func (h *ResourceHandler) Delete(w http.ResponseWriter, r *http.Request) { | |
| Action: func() (interface{}, *errors.ServiceError) { | ||
| id := mux.Vars(r)["id"] | ||
|
|
||
| if err := h.verifyOwnership(r, id); err != nil { | ||
| if err := h.checkOwnership(r, id); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| resource, svcErr := h.service.Delete(r.Context(), h.descriptor.Kind, id) | ||
| if svcErr != nil { | ||
| return nil, svcErr | ||
| resource, err := h.service.Delete(r.Context(), h.descriptor.Kind, id) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return presenters.PresentResource(resource), nil | ||
| }, | ||
| } | ||
| handleSoftDelete(w, r, cfg) | ||
| } | ||
|
|
||
| // verifyOwnership confirms id belongs to the parent named by parent_id in the | ||
| // request path. No-op for flat (non-nested) routes, where parent_id is absent. | ||
| func (h *ResourceHandler) verifyOwnership(r *http.Request, id string) *errors.ServiceError { | ||
| if parentID, hasParent := mux.Vars(r)["parent_id"]; hasParent { | ||
| // checkParentExists returns the parent_id if the parent exists, "" for flat | ||
| // routes, or a 404 if parent_id is present but the parent is missing. | ||
| func (h *ResourceHandler) checkParentExists(r *http.Request) (string, *errors.ServiceError) { | ||
| parentID, hasParent := mux.Vars(r)["parent_id"] | ||
| if !hasParent { | ||
| return "", nil | ||
| } | ||
| _, err := h.service.Get(r.Context(), h.descriptor.ParentKind, parentID) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return parentID, nil | ||
| } | ||
|
|
||
| // checkOwnership verifies id belongs to parent_id, checking the parent first so | ||
| // a missing parent reports "not found" against the parent, not the child. | ||
| func (h *ResourceHandler) checkOwnership(r *http.Request, id string) *errors.ServiceError { | ||
| parentID, err := h.checkParentExists(r) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if parentID != "" { | ||
| if _, err := h.service.GetByOwner(r.Context(), h.descriptor.Kind, id, parentID); err != nil { | ||
| return err | ||
| } | ||
|
|
@@ -218,7 +238,7 @@ func (h *ResourceHandler) ForceDelete(w http.ResponseWriter, r *http.Request) { | |
| Action: func() (interface{}, *errors.ServiceError) { | ||
| id := mux.Vars(r)["id"] | ||
|
|
||
| if err := h.verifyOwnership(r, id); err != nil { | ||
| if err := h.checkOwnership(r, id); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
|
|
@@ -230,13 +250,3 @@ func (h *ResourceHandler) ForceDelete(w http.ResponseWriter, r *http.Request) { | |
| } | ||
| handleForceDelete(w, r, cfg) | ||
| } | ||
|
|
||
| func childCreateRejection(descriptor registry.EntityDescriptor) *errors.ServiceError { | ||
| parent := registry.MustGet(descriptor.ParentKind) | ||
| svcErr := errors.Validation( | ||
| "Cannot create %s here. Use POST /%s/{%s_id}/%s", | ||
| descriptor.Kind, parent.Plural, parent.Kind, descriptor.Plural, | ||
| ) | ||
| svcErr.HTTPCode = http.StatusUnprocessableEntity | ||
| return svcErr | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -191,7 +191,9 @@ func shouldValidateRequest( | |
| } | ||
| } | ||
|
|
||
| if rootResourcePattern.MatchString(path) { | ||
| // Root /resources POST carries kind in body — resolve plural from body later. | ||
| // Root /resources PATCH has no kind; handler validates spec internally. | ||
| if method == http.MethodPost && rootResourcePattern.MatchString(path) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning Blocking Category: JIRA HYPERFLEET-1328 acceptance criteria #1, #2, and #3 are written for approach #1 (middleware DB lookup) but the PR implements approach #2 (exclude PATCH from middleware). Under approach #2:
The approach is valid per the ticket's technical notes, but the ACs should be updated to match approach #2 — or a follow-up ticket should capture the remaining PATCH validation gap. The ticket also notes approach #2 should "document this as the accepted layering for the root PATCH case."
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also updated ACs to match #2. |
||
| return true, "" | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.