mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
478a9e933d
Preserve tenant-scoped metadata through partial URL updates and project stable URLs across runtime identities. Use a safe adjacent launch control across overview tables with desktop and mobile regression coverage.
157 lines
4.4 KiB
Go
157 lines
4.4 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
func decodeBoundedMetadataPatch(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
target any,
|
|
) (map[string]json.RawMessage, bool) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return nil, false
|
|
}
|
|
|
|
var fields map[string]json.RawMessage
|
|
if len(body) == 0 || json.Unmarshal(body, &fields) != nil || fields == nil || json.Unmarshal(body, target) != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return nil, false
|
|
}
|
|
return fields, true
|
|
}
|
|
|
|
func metadataPatchHasField(fields map[string]json.RawMessage, field string) bool {
|
|
_, ok := fields[field]
|
|
return ok
|
|
}
|
|
|
|
// handleMetadataGetRequest serves the GET flow shared by the guest and docker
|
|
// metadata handlers: the bare route returns the full metadata map (an empty
|
|
// object instead of null), a suffixed route returns that resource's metadata
|
|
// (a zero record instead of a 404).
|
|
func handleMetadataGetRequest[M any](
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
routePrefix string,
|
|
getAll func(ctx context.Context) map[string]*M,
|
|
get func(ctx context.Context, id string) *M,
|
|
emptyRecord func(id string) *M,
|
|
) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
path := r.URL.Path
|
|
// Handle both the bare route and its trailing-slash form.
|
|
if path == routePrefix || path == routePrefix+"/" {
|
|
// Get all metadata
|
|
w.Header().Set("Content-Type", "application/json")
|
|
allMeta := getAll(r.Context())
|
|
if allMeta == nil {
|
|
// Return empty object instead of null
|
|
json.NewEncoder(w).Encode(make(map[string]*M))
|
|
} else {
|
|
json.NewEncoder(w).Encode(allMeta)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Get specific resource ID from path
|
|
resourceID := strings.TrimPrefix(path, routePrefix+"/")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if resourceID != "" {
|
|
meta := get(r.Context(), resourceID)
|
|
if meta == nil {
|
|
// Return empty metadata instead of 404
|
|
json.NewEncoder(w).Encode(emptyRecord(resourceID))
|
|
} else {
|
|
json.NewEncoder(w).Encode(meta)
|
|
}
|
|
} else {
|
|
// This shouldn't happen with current routing, but handle it anyway
|
|
http.Error(w, "Invalid request path", http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// handleMetadataUpdateRequest serves the PUT/POST flow shared by the guest
|
|
// and docker metadata handlers: decode a bounded body, validate the custom
|
|
// URL, persist, and echo the stored record.
|
|
func handleMetadataUpdateRequest[M any](
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
routePrefix string,
|
|
idRequiredMsg string,
|
|
idLogKey string,
|
|
saveErrLogMsg string,
|
|
updatedLogMsg string,
|
|
customURL func(*M) string,
|
|
get func(ctx context.Context, id string) *M,
|
|
merge func(id string, existing, incoming *M, fields map[string]json.RawMessage) *M,
|
|
set func(ctx context.Context, id string, meta *M) error,
|
|
) {
|
|
if r.Method != http.MethodPut && r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
resourceID := strings.TrimPrefix(r.URL.Path, routePrefix+"/")
|
|
if resourceID == "" || resourceID == "metadata" {
|
|
http.Error(w, idRequiredMsg, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var incoming M
|
|
fields, ok := decodeBoundedMetadataPatch(w, r, &incoming)
|
|
if !ok {
|
|
return
|
|
}
|
|
meta := merge(resourceID, get(r.Context(), resourceID), &incoming, fields)
|
|
|
|
if meta == nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Existing legacy metadata must remain editable. Validate the URL only
|
|
// when this patch actually authors customUrl.
|
|
if metadataPatchHasField(fields, "customUrl") {
|
|
if errMsg := validateCustomURL(customURL(meta)); errMsg != "" {
|
|
http.Error(w, errMsg, http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := set(r.Context(), resourceID, meta); err != nil {
|
|
log.Error().Err(err).Str(idLogKey, resourceID).Msg(saveErrLogMsg)
|
|
http.Error(w, metadataSaveErrorMessage(err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Info().Str(idLogKey, resourceID).Str("url", customURL(meta)).Msg(updatedLogMsg)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(meta); err != nil {
|
|
log.Debug().Err(err).Str(idLogKey, resourceID).Msg("Failed to encode metadata response")
|
|
}
|
|
}
|
|
|
|
func cloneStringSlice(values []string) []string {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
return append([]string(nil), values...)
|
|
}
|