Implements issue #141 — .kc server-side metadata integration Phase 1. When a .kc file is uploaded, the server extracts silo/manifest.json and silo/metadata.json from the ZIP archive and indexes them into the item_metadata table. Plain .fcstd files continue to work unchanged. Extraction is best-effort: failures are logged but do not block the upload. New packages: - internal/kc: ZIP extraction library (Extract, Manifest, Metadata types) - internal/db: ItemMetadataRepository (Get, Upsert, UpdateFields, UpdateLifecycle, SetTags) New API endpoints under /api/items/{partNumber}: - GET /metadata — read indexed metadata (viewer) - PUT /metadata — merge fields into JSONB (editor) - PATCH /metadata/lifecycle — transition lifecycle state (editor) - PATCH /metadata/tags — add/remove tags (editor) SSE events: metadata.updated, metadata.lifecycle, metadata.tags Lifecycle transitions (Phase 1): draft→review→released→obsolete, review→draft (reject). Closes #141
420 lines
12 KiB
Go
420 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/kindredsystems/silo/internal/auth"
|
|
"github.com/kindredsystems/silo/internal/db"
|
|
"github.com/kindredsystems/silo/internal/kc"
|
|
)
|
|
|
|
// validTransitions defines allowed lifecycle state transitions for Phase 1.
|
|
var validTransitions = map[string][]string{
|
|
"draft": {"review"},
|
|
"review": {"draft", "released"},
|
|
"released": {"obsolete"},
|
|
"obsolete": {},
|
|
}
|
|
|
|
// MetadataResponse is the JSON representation returned by GET /metadata.
|
|
type MetadataResponse struct {
|
|
SchemaName *string `json:"schema_name"`
|
|
LifecycleState string `json:"lifecycle_state"`
|
|
Tags []string `json:"tags"`
|
|
Fields map[string]any `json:"fields"`
|
|
Manifest *ManifestInfo `json:"manifest,omitempty"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
UpdatedBy *string `json:"updated_by,omitempty"`
|
|
}
|
|
|
|
// ManifestInfo is the manifest subset included in MetadataResponse.
|
|
type ManifestInfo struct {
|
|
UUID *string `json:"uuid,omitempty"`
|
|
SiloInstance *string `json:"silo_instance,omitempty"`
|
|
RevisionHash *string `json:"revision_hash,omitempty"`
|
|
KCVersion *string `json:"kc_version,omitempty"`
|
|
}
|
|
|
|
func metadataToResponse(m *db.ItemMetadata) MetadataResponse {
|
|
resp := MetadataResponse{
|
|
SchemaName: m.SchemaName,
|
|
LifecycleState: m.LifecycleState,
|
|
Tags: m.Tags,
|
|
Fields: m.Fields,
|
|
UpdatedAt: m.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
UpdatedBy: m.UpdatedBy,
|
|
}
|
|
if m.ManifestUUID != nil || m.SiloInstance != nil || m.RevisionHash != nil || m.KCVersion != nil {
|
|
resp.Manifest = &ManifestInfo{
|
|
UUID: m.ManifestUUID,
|
|
SiloInstance: m.SiloInstance,
|
|
RevisionHash: m.RevisionHash,
|
|
KCVersion: m.KCVersion,
|
|
}
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// HandleGetMetadata returns indexed metadata for an item.
|
|
// GET /api/items/{partNumber}/metadata
|
|
func (s *Server) HandleGetMetadata(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
meta, err := s.metadata.Get(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get metadata")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
|
return
|
|
}
|
|
if meta == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, metadataToResponse(meta))
|
|
}
|
|
|
|
// HandleUpdateMetadata merges fields into the metadata JSONB.
|
|
// PUT /api/items/{partNumber}/metadata
|
|
func (s *Server) HandleUpdateMetadata(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Fields map[string]any `json:"fields"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
|
return
|
|
}
|
|
if len(req.Fields) == 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Fields must not be empty")
|
|
return
|
|
}
|
|
|
|
username := ""
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
username = user.Username
|
|
}
|
|
|
|
if err := s.metadata.UpdateFields(ctx, item.ID, req.Fields, username); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to update metadata fields")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
|
|
return
|
|
}
|
|
|
|
meta, err := s.metadata.Get(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to read back metadata")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read metadata")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("metadata.updated", mustMarshal(map[string]any{
|
|
"part_number": partNumber,
|
|
"changed_fields": fieldKeys(req.Fields),
|
|
"lifecycle_state": meta.LifecycleState,
|
|
"updated_by": username,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, metadataToResponse(meta))
|
|
}
|
|
|
|
// HandleUpdateLifecycle transitions the lifecycle state.
|
|
// PATCH /api/items/{partNumber}/metadata/lifecycle
|
|
func (s *Server) HandleUpdateLifecycle(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
State string `json:"state"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
|
return
|
|
}
|
|
if req.State == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "State is required")
|
|
return
|
|
}
|
|
|
|
meta, err := s.metadata.Get(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get metadata")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
|
return
|
|
}
|
|
if meta == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
|
return
|
|
}
|
|
|
|
// Validate transition
|
|
allowed := validTransitions[meta.LifecycleState]
|
|
valid := false
|
|
for _, s := range allowed {
|
|
if s == req.State {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
writeError(w, http.StatusUnprocessableEntity, "invalid_transition",
|
|
"Cannot transition from '"+meta.LifecycleState+"' to '"+req.State+"'")
|
|
return
|
|
}
|
|
|
|
username := ""
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
username = user.Username
|
|
}
|
|
|
|
fromState := meta.LifecycleState
|
|
if err := s.metadata.UpdateLifecycle(ctx, item.ID, req.State, username); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to update lifecycle")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update lifecycle")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("metadata.lifecycle", mustMarshal(map[string]any{
|
|
"part_number": partNumber,
|
|
"from_state": fromState,
|
|
"to_state": req.State,
|
|
"updated_by": username,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"lifecycle_state": req.State})
|
|
}
|
|
|
|
// HandleUpdateTags adds/removes tags.
|
|
// PATCH /api/items/{partNumber}/metadata/tags
|
|
func (s *Server) HandleUpdateTags(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Add []string `json:"add"`
|
|
Remove []string `json:"remove"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
|
return
|
|
}
|
|
if len(req.Add) == 0 && len(req.Remove) == 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Must provide 'add' or 'remove'")
|
|
return
|
|
}
|
|
|
|
meta, err := s.metadata.Get(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get metadata")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
|
return
|
|
}
|
|
if meta == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
|
return
|
|
}
|
|
|
|
// Compute new tag set: (existing + add) - remove
|
|
tagSet := make(map[string]struct{})
|
|
for _, t := range meta.Tags {
|
|
tagSet[t] = struct{}{}
|
|
}
|
|
for _, t := range req.Add {
|
|
tagSet[t] = struct{}{}
|
|
}
|
|
removeSet := make(map[string]struct{})
|
|
for _, t := range req.Remove {
|
|
removeSet[t] = struct{}{}
|
|
}
|
|
var newTags []string
|
|
for t := range tagSet {
|
|
if _, removed := removeSet[t]; !removed {
|
|
newTags = append(newTags, t)
|
|
}
|
|
}
|
|
if newTags == nil {
|
|
newTags = []string{}
|
|
}
|
|
|
|
username := ""
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
username = user.Username
|
|
}
|
|
|
|
if err := s.metadata.SetTags(ctx, item.ID, newTags, username); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to update tags")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update tags")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("metadata.tags", mustMarshal(map[string]any{
|
|
"part_number": partNumber,
|
|
"added": req.Add,
|
|
"removed": req.Remove,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"tags": newTags})
|
|
}
|
|
|
|
// extractKCMetadata attempts to extract and index silo/ metadata from an
|
|
// uploaded .kc file. Failures are logged but non-fatal for Phase 1.
|
|
func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey string, rev *db.Revision) {
|
|
if s.storage == nil {
|
|
return
|
|
}
|
|
|
|
reader, err := s.storage.Get(ctx, fileKey)
|
|
if err != nil {
|
|
s.logger.Warn().Err(err).Str("file_key", fileKey).Msg("kc: failed to read back file for extraction")
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
s.logger.Warn().Err(err).Msg("kc: failed to read file bytes")
|
|
return
|
|
}
|
|
|
|
result, err := kc.Extract(data)
|
|
if err != nil {
|
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: extraction failed")
|
|
return
|
|
}
|
|
if result == nil {
|
|
return // plain .fcstd, no silo/ directory
|
|
}
|
|
|
|
// Validate manifest UUID matches item
|
|
if result.Manifest != nil && result.Manifest.UUID != "" && result.Manifest.UUID != item.ID {
|
|
s.logger.Warn().
|
|
Str("manifest_uuid", result.Manifest.UUID).
|
|
Str("item_id", item.ID).
|
|
Msg("kc: manifest UUID does not match item, skipping indexing")
|
|
return
|
|
}
|
|
|
|
// Check for no-op (revision_hash unchanged)
|
|
if result.Manifest != nil && result.Manifest.RevisionHash != "" {
|
|
existing, _ := s.metadata.Get(ctx, item.ID)
|
|
if existing != nil && existing.RevisionHash != nil && *existing.RevisionHash == result.Manifest.RevisionHash {
|
|
s.logger.Debug().Str("part_number", item.PartNumber).Msg("kc: revision_hash unchanged, skipping")
|
|
return
|
|
}
|
|
}
|
|
|
|
username := ""
|
|
if rev.CreatedBy != nil {
|
|
username = *rev.CreatedBy
|
|
}
|
|
|
|
meta := &db.ItemMetadata{
|
|
ItemID: item.ID,
|
|
LifecycleState: "draft",
|
|
Fields: make(map[string]any),
|
|
Tags: []string{},
|
|
UpdatedBy: strPtr(username),
|
|
}
|
|
|
|
if result.Manifest != nil {
|
|
meta.KCVersion = strPtr(result.Manifest.KCVersion)
|
|
meta.ManifestUUID = strPtr(result.Manifest.UUID)
|
|
meta.SiloInstance = strPtr(result.Manifest.SiloInstance)
|
|
meta.RevisionHash = strPtr(result.Manifest.RevisionHash)
|
|
}
|
|
|
|
if result.Metadata != nil {
|
|
meta.SchemaName = strPtr(result.Metadata.SchemaName)
|
|
if result.Metadata.Tags != nil {
|
|
meta.Tags = result.Metadata.Tags
|
|
}
|
|
if result.Metadata.LifecycleState != "" {
|
|
meta.LifecycleState = result.Metadata.LifecycleState
|
|
}
|
|
if result.Metadata.Fields != nil {
|
|
meta.Fields = result.Metadata.Fields
|
|
}
|
|
}
|
|
|
|
if err := s.metadata.Upsert(ctx, meta); err != nil {
|
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to upsert metadata")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("metadata.updated", mustMarshal(map[string]any{
|
|
"part_number": item.PartNumber,
|
|
"lifecycle_state": meta.LifecycleState,
|
|
"updated_by": username,
|
|
}))
|
|
|
|
s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully")
|
|
}
|
|
|
|
// strPtr returns a pointer to s, or nil if s is empty.
|
|
func strPtr(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
// fieldKeys returns the keys from a map.
|
|
func fieldKeys(m map[string]any) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|