Files
silo/internal/api/metadata_handlers.go
Forbes cffcf56085 feat(api): item dependency extraction, indexing, and resolve endpoints
- Add Dependency type to internal/kc and extract silo/dependencies.json
  from .kc files on commit
- Create ItemDependencyRepository with ReplaceForRevision, ListByItem,
  and Resolve (LEFT JOIN against items table)
- Add GET /{partNumber}/dependencies and
  GET /{partNumber}/dependencies/resolve endpoints
- Index dependencies in extractKCMetadata with SSE broadcast
- Pack real dependency data into .kc files on checkout
- Update PackInput.Dependencies from []any to []Dependency

Closes #143
2026-02-18 18:53:40 -06:00

452 lines
13 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,
}))
// Index dependencies from silo/dependencies.json.
if result.Dependencies != nil {
dbDeps := make([]*db.ItemDependency, len(result.Dependencies))
for i, d := range result.Dependencies {
pn := d.PartNumber
rev := d.Revision
qty := d.Quantity
label := d.Label
rel := d.Relationship
if rel == "" {
rel = "component"
}
dbDeps[i] = &db.ItemDependency{
ParentItemID: item.ID,
ChildUUID: d.UUID,
ChildPartNumber: &pn,
ChildRevision: &rev,
Quantity: &qty,
Label: &label,
Relationship: rel,
}
}
if err := s.deps.ReplaceForRevision(ctx, item.ID, rev.RevisionNumber, dbDeps); err != nil {
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to index dependencies")
} else {
s.broker.Publish("dependencies.changed", mustMarshal(map[string]any{
"part_number": item.PartNumber,
"count": len(dbDeps),
}))
}
}
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
}