package api import ( "net/http" "strings" "reanimator/internal/domain" historysvc "reanimator/internal/history" "reanimator/internal/repository/registry" ) type RegistryDependencies struct { Assets *registry.AssetRepository Components *registry.ComponentRepository History *historysvc.Service } type registryHandlers struct { deps RegistryDependencies } func RegisterRegistryRoutes(mux *http.ServeMux, deps RegistryDependencies) { h := registryHandlers{deps: deps} mux.HandleFunc("/assets", h.handleAssets) mux.HandleFunc("/registry/assets/", h.handleAssetByID) mux.HandleFunc("/components", h.handleComponents) mux.HandleFunc("/registry/components/", h.handleComponentByID) } func (h registryHandlers) handleAssets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := h.deps.Assets.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list assets failed") return } writeJSON(w, http.StatusOK, items) case http.MethodPost: var req struct { Name string `json:"name"` Vendor *string `json:"vendor"` Model *string `json:"model"` VendorSerial string `json:"vendor_serial"` AssetTag *string `json:"asset_tag"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if strings.TrimSpace(req.Name) == "" { writeError(w, http.StatusBadRequest, "name is required") return } if strings.TrimSpace(req.VendorSerial) == "" { writeError(w, http.StatusBadRequest, "vendor_serial is required") return } asset := domain.Asset{ Name: strings.TrimSpace(req.Name), Vendor: normalizeOptionalString(req.Vendor), Model: normalizeOptionalString(req.Model), VendorSerial: strings.TrimSpace(req.VendorSerial), MachineTag: normalizeOptionalString(req.AssetTag), } item, err := h.deps.Assets.Create(r.Context(), asset) if err != nil { switch err { case registry.ErrConflict: writeError(w, http.StatusConflict, "asset conflict") default: writeError(w, http.StatusInternalServerError, "create asset failed") } return } writeJSON(w, http.StatusCreated, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleAssetByID(w http.ResponseWriter, r *http.Request) { id, ok := parseID(r.URL.Path, "/registry/assets/") if !ok { writeError(w, http.StatusNotFound, "asset not found") return } switch r.Method { case http.MethodGet: item, err := h.deps.Assets.Get(r.Context(), id) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "asset not found") default: writeError(w, http.StatusInternalServerError, "get asset failed") } return } writeJSON(w, http.StatusOK, item) case http.MethodDelete: result, err := h.deps.Assets.DeleteWithDetails(r.Context(), id) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "asset not found") default: writeError(w, http.StatusInternalServerError, "delete asset failed") } return } writeJSON(w, http.StatusOK, map[string]any{ "deleted": true, "deleted_parts": result.DeletedParts, }) case http.MethodPut: var req struct { Name string `json:"name"` Vendor *string `json:"vendor"` Model *string `json:"model"` VendorSerial string `json:"vendor_serial"` AssetTag *string `json:"asset_tag"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if strings.TrimSpace(req.Name) == "" { writeError(w, http.StatusBadRequest, "name is required") return } if strings.TrimSpace(req.VendorSerial) == "" { writeError(w, http.StatusBadRequest, "vendor_serial is required") return } var item domain.Asset var err error if h.deps.History != nil { patch := []historysvc.PatchOp{ {Op: "replace", Path: "/identity/name", Value: strings.TrimSpace(req.Name)}, {Op: "replace", Path: "/identity/vendor_serial", Value: strings.TrimSpace(req.VendorSerial)}, {Op: "replace", Path: "/identity/vendor", Value: valueOrNil(normalizeOptionalString(req.Vendor))}, {Op: "replace", Path: "/identity/model", Value: valueOrNil(normalizeOptionalString(req.Model))}, {Op: "replace", Path: "/identity/machine_tag", Value: valueOrNil(normalizeOptionalString(req.AssetTag))}, } idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")) var idemPtr *string if idem != "" { idemPtr = &idem } _, err = h.deps.History.ApplyAssetPatch(r.Context(), historysvc.ApplyPatchCommand{ EntityID: id, ChangeType: "ASSET_REGISTRY_UPDATED", SourceType: "user", ActorType: "user", IdempotencyKey: idemPtr, Patch: patch, }) } else { item, err = h.deps.Assets.Update(r.Context(), domain.Asset{ ID: id, Name: strings.TrimSpace(req.Name), Vendor: normalizeOptionalString(req.Vendor), Model: normalizeOptionalString(req.Model), VendorSerial: strings.TrimSpace(req.VendorSerial), MachineTag: normalizeOptionalString(req.AssetTag), }) } if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "asset not found") case registry.ErrConflict: writeError(w, http.StatusConflict, "asset conflict") case historysvc.ErrNotFound: writeError(w, http.StatusNotFound, "asset not found") case historysvc.ErrConflict: writeError(w, http.StatusConflict, "history conflict") default: writeError(w, http.StatusInternalServerError, "update asset failed") } return } if h.deps.History != nil { item, err = h.deps.Assets.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "get asset failed") return } } writeJSON(w, http.StatusOK, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleComponents(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: items, err := h.deps.Components.List(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "list components failed") return } writeJSON(w, http.StatusOK, items) case http.MethodPost: var req struct { Vendor *string `json:"vendor"` Model *string `json:"model"` VendorSerial string `json:"vendor_serial"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if strings.TrimSpace(req.VendorSerial) == "" { writeError(w, http.StatusBadRequest, "vendor_serial is required") return } component := domain.Component{ Vendor: req.Vendor, Model: req.Model, VendorSerial: strings.TrimSpace(req.VendorSerial), } item, err := h.deps.Components.Create(r.Context(), component) if err != nil { switch err { case registry.ErrConflict: writeError(w, http.StatusConflict, "component conflict") default: writeError(w, http.StatusInternalServerError, "create component failed") } return } writeJSON(w, http.StatusCreated, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h registryHandlers) handleComponentByID(w http.ResponseWriter, r *http.Request) { id, ok := parseID(r.URL.Path, "/registry/components/") if !ok { writeError(w, http.StatusNotFound, "component not found") return } switch r.Method { case http.MethodGet: item, err := h.deps.Components.Get(r.Context(), id) if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "component not found") default: writeError(w, http.StatusInternalServerError, "get component failed") } return } writeJSON(w, http.StatusOK, item) case http.MethodPut: var req struct { Vendor *string `json:"vendor"` Model *string `json:"model"` VendorSerial string `json:"vendor_serial"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if strings.TrimSpace(req.VendorSerial) == "" { writeError(w, http.StatusBadRequest, "vendor_serial is required") return } var item domain.Component var err error if h.deps.History != nil { patch := []historysvc.PatchOp{ {Op: "replace", Path: "/identity/vendor_serial", Value: strings.TrimSpace(req.VendorSerial)}, {Op: "replace", Path: "/identity/vendor", Value: valueOrNil(normalizeOptionalString(req.Vendor))}, {Op: "replace", Path: "/identity/model", Value: valueOrNil(normalizeOptionalString(req.Model))}, } idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")) var idemPtr *string if idem != "" { idemPtr = &idem } _, err = h.deps.History.ApplyComponentPatch(r.Context(), historysvc.ApplyPatchCommand{ EntityID: id, ChangeType: "COMPONENT_REGISTRY_UPDATED", SourceType: "user", ActorType: "user", IdempotencyKey: idemPtr, Patch: patch, }) if err == nil { item, err = h.deps.Components.Get(r.Context(), id) } } else { item, err = h.deps.Components.Update(r.Context(), domain.Component{ ID: id, Vendor: normalizeOptionalString(req.Vendor), Model: normalizeOptionalString(req.Model), VendorSerial: strings.TrimSpace(req.VendorSerial), }) } if err != nil { switch err { case registry.ErrNotFound: writeError(w, http.StatusNotFound, "component not found") case registry.ErrConflict: writeError(w, http.StatusConflict, "component conflict") case historysvc.ErrNotFound: writeError(w, http.StatusNotFound, "component not found") case historysvc.ErrConflict: writeError(w, http.StatusConflict, "history conflict") default: writeError(w, http.StatusInternalServerError, "update component failed") } return } writeJSON(w, http.StatusOK, item) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func normalizeOptionalString(value *string) *string { if value == nil { return nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil } return &trimmed } func valueOrNil(value *string) any { if value == nil { return nil } return *value }