1077 lines
30 KiB
Go
1077 lines
30 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/kindredsystems/silo/internal/db"
|
|
"github.com/kindredsystems/silo/internal/partnum"
|
|
"github.com/kindredsystems/silo/internal/schema"
|
|
"github.com/kindredsystems/silo/internal/storage"
|
|
"github.com/rs/zerolog"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Server holds dependencies for HTTP handlers.
|
|
type Server struct {
|
|
logger zerolog.Logger
|
|
db *db.DB
|
|
items *db.ItemRepository
|
|
schemas map[string]*schema.Schema
|
|
schemasDir string
|
|
partgen *partnum.Generator
|
|
storage *storage.Storage
|
|
}
|
|
|
|
// NewServer creates a new API server.
|
|
func NewServer(
|
|
logger zerolog.Logger,
|
|
database *db.DB,
|
|
schemas map[string]*schema.Schema,
|
|
schemasDir string,
|
|
store *storage.Storage,
|
|
) *Server {
|
|
items := db.NewItemRepository(database)
|
|
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
|
partgen := partnum.NewGenerator(schemas, seqStore)
|
|
|
|
return &Server{
|
|
logger: logger,
|
|
db: database,
|
|
items: items,
|
|
schemas: schemas,
|
|
schemasDir: schemasDir,
|
|
partgen: partgen,
|
|
storage: store,
|
|
}
|
|
}
|
|
|
|
// dbSequenceStore implements partnum.SequenceStore using the database.
|
|
type dbSequenceStore struct {
|
|
db *db.DB
|
|
schemas map[string]*schema.Schema
|
|
}
|
|
|
|
func (s *dbSequenceStore) NextValue(ctx context.Context, schemaName string, scope string) (int, error) {
|
|
// For now, use schema name as ID. In production, you'd look up the schema UUID.
|
|
return s.db.NextSequenceValue(ctx, schemaName, scope)
|
|
}
|
|
|
|
// Error response structure.
|
|
type ErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// writeJSON writes a JSON response.
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// writeError writes an error JSON response.
|
|
func writeError(w http.ResponseWriter, status int, err string, message string) {
|
|
writeJSON(w, status, ErrorResponse{Error: err, Message: message})
|
|
}
|
|
|
|
// Health check handlers
|
|
|
|
// HandleHealth returns basic health status.
|
|
func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// HandleReady checks database and storage connectivity.
|
|
func (s *Server) HandleReady(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Check database
|
|
if err := s.db.Pool().Ping(ctx); err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "database_unavailable", err.Error())
|
|
return
|
|
}
|
|
|
|
// Storage check would go here if we had a ping method
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"status": "ready",
|
|
"database": "ok",
|
|
"storage": "ok",
|
|
})
|
|
}
|
|
|
|
// Schema handlers
|
|
|
|
// SchemaResponse represents a schema in API responses.
|
|
type SchemaResponse struct {
|
|
Name string `json:"name"`
|
|
Version int `json:"version"`
|
|
Description string `json:"description"`
|
|
Separator string `json:"separator"`
|
|
Format string `json:"format"`
|
|
Segments []SegmentResponse `json:"segments"`
|
|
Examples []string `json:"examples,omitempty"`
|
|
}
|
|
|
|
// SegmentResponse represents a schema segment.
|
|
type SegmentResponse struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required"`
|
|
Values map[string]string `json:"values,omitempty"`
|
|
Length int `json:"length,omitempty"`
|
|
}
|
|
|
|
// HandleListSchemas lists all available schemas.
|
|
func (s *Server) HandleListSchemas(w http.ResponseWriter, r *http.Request) {
|
|
schemas := make([]SchemaResponse, 0, len(s.schemas))
|
|
for _, sch := range s.schemas {
|
|
schemas = append(schemas, schemaToResponse(sch))
|
|
}
|
|
writeJSON(w, http.StatusOK, schemas)
|
|
}
|
|
|
|
// HandleGetSchema returns a specific schema.
|
|
func (s *Server) HandleGetSchema(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
sch, ok := s.schemas[name]
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, schemaToResponse(sch))
|
|
}
|
|
|
|
// HandleGetPropertySchema returns the property schema for a category.
|
|
func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request) {
|
|
schemaName := chi.URLParam(r, "name")
|
|
category := r.URL.Query().Get("category")
|
|
|
|
sch, ok := s.schemas[schemaName]
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
|
|
return
|
|
}
|
|
|
|
if sch.PropertySchemas == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"version": 0,
|
|
"properties": map[string]any{},
|
|
})
|
|
return
|
|
}
|
|
|
|
props := sch.PropertySchemas.GetPropertiesForCategory(category)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"version": sch.PropertySchemas.Version,
|
|
"properties": props,
|
|
})
|
|
}
|
|
|
|
func schemaToResponse(sch *schema.Schema) SchemaResponse {
|
|
segments := make([]SegmentResponse, len(sch.Segments))
|
|
for i, seg := range sch.Segments {
|
|
segments[i] = SegmentResponse{
|
|
Name: seg.Name,
|
|
Type: seg.Type,
|
|
Description: seg.Description,
|
|
Required: seg.Required,
|
|
Values: seg.Values,
|
|
Length: seg.Length,
|
|
}
|
|
}
|
|
return SchemaResponse{
|
|
Name: sch.Name,
|
|
Version: sch.Version,
|
|
Description: sch.Description,
|
|
Separator: sch.Separator,
|
|
Format: sch.Format,
|
|
Segments: segments,
|
|
Examples: sch.Examples,
|
|
}
|
|
}
|
|
|
|
// Item handlers
|
|
|
|
// ItemResponse represents an item in API responses.
|
|
type ItemResponse struct {
|
|
ID string `json:"id"`
|
|
PartNumber string `json:"part_number"`
|
|
ItemType string `json:"item_type"`
|
|
Description string `json:"description"`
|
|
CurrentRevision int `json:"current_revision"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Properties map[string]any `json:"properties,omitempty"`
|
|
}
|
|
|
|
// CreateItemRequest represents a request to create an item.
|
|
type CreateItemRequest struct {
|
|
Schema string `json:"schema"`
|
|
Project string `json:"project"`
|
|
Category string `json:"category"`
|
|
Description string `json:"description"`
|
|
Properties map[string]any `json:"properties,omitempty"`
|
|
}
|
|
|
|
// HandleListItems lists items with optional filtering.
|
|
func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
opts := db.ListOptions{
|
|
ItemType: r.URL.Query().Get("type"),
|
|
Search: r.URL.Query().Get("search"),
|
|
Project: r.URL.Query().Get("project"),
|
|
}
|
|
|
|
if limit := r.URL.Query().Get("limit"); limit != "" {
|
|
if l, err := strconv.Atoi(limit); err == nil {
|
|
opts.Limit = l
|
|
}
|
|
}
|
|
if offset := r.URL.Query().Get("offset"); offset != "" {
|
|
if o, err := strconv.Atoi(offset); err == nil {
|
|
opts.Offset = o
|
|
}
|
|
}
|
|
|
|
items, err := s.items.List(ctx, opts)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to list items")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list items")
|
|
return
|
|
}
|
|
|
|
response := make([]ItemResponse, len(items))
|
|
for i, item := range items {
|
|
response[i] = itemToResponse(item)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// HandleListProjects returns distinct project codes from existing items.
|
|
func (s *Server) HandleListProjects(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
projects, err := s.items.ListProjects(ctx)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to list projects")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list projects")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, projects)
|
|
}
|
|
|
|
// HandleCreateItem creates a new item with generated part number.
|
|
func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var req CreateItemRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
// Default schema
|
|
schemaName := req.Schema
|
|
if schemaName == "" {
|
|
schemaName = "kindred-rd"
|
|
}
|
|
|
|
// Generate part number
|
|
input := partnum.Input{
|
|
SchemaName: schemaName,
|
|
Values: map[string]string{
|
|
"project": req.Project,
|
|
"category": req.Category,
|
|
},
|
|
}
|
|
|
|
partNumber, err := s.partgen.Generate(ctx, input)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to generate part number")
|
|
writeError(w, http.StatusBadRequest, "generation_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
// Determine item type from category
|
|
itemType := "part"
|
|
if len(req.Category) > 0 {
|
|
switch req.Category[0] {
|
|
case 'A':
|
|
itemType = "assembly"
|
|
case 'D':
|
|
itemType = "document"
|
|
case 'T':
|
|
itemType = "tooling"
|
|
}
|
|
}
|
|
|
|
// Create item
|
|
item := &db.Item{
|
|
PartNumber: partNumber,
|
|
ItemType: itemType,
|
|
Description: req.Description,
|
|
}
|
|
|
|
properties := req.Properties
|
|
if properties == nil {
|
|
properties = make(map[string]any)
|
|
}
|
|
properties["project"] = req.Project
|
|
properties["category"] = req.Category
|
|
|
|
if err := s.items.Create(ctx, item, properties); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to create item")
|
|
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, itemToResponse(item))
|
|
}
|
|
|
|
// HandleGetItem retrieves an item by part number.
|
|
// Supports query param: ?include=properties to include current revision properties.
|
|
func (s *Server) HandleGetItem(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
|
|
}
|
|
|
|
response := itemToResponse(item)
|
|
|
|
// Include properties from current revision if requested
|
|
if r.URL.Query().Get("include") == "properties" {
|
|
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
|
if err == nil {
|
|
for _, rev := range revisions {
|
|
if rev.RevisionNumber == item.CurrentRevision {
|
|
response.Properties = rev.Properties
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// UpdateItemRequest represents a request to update an item.
|
|
type UpdateItemRequest struct {
|
|
PartNumber string `json:"part_number,omitempty"`
|
|
ItemType string `json:"item_type,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Properties map[string]any `json:"properties,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
}
|
|
|
|
// HandleUpdateItem updates an item's fields and/or creates a new revision.
|
|
func (s *Server) HandleUpdateItem(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 UpdateItemRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
// Update item fields if provided
|
|
newPartNumber := item.PartNumber
|
|
newItemType := item.ItemType
|
|
newDescription := item.Description
|
|
|
|
if req.PartNumber != "" {
|
|
newPartNumber = req.PartNumber
|
|
}
|
|
if req.ItemType != "" {
|
|
newItemType = req.ItemType
|
|
}
|
|
if req.Description != "" {
|
|
newDescription = req.Description
|
|
}
|
|
|
|
// Update the item record (UUID stays the same)
|
|
if err := s.items.Update(ctx, item.ID, newPartNumber, newItemType, newDescription); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to update item")
|
|
writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
// Create new revision if properties provided
|
|
if req.Properties != nil {
|
|
rev := &db.Revision{
|
|
ItemID: item.ID,
|
|
Properties: req.Properties,
|
|
Comment: &req.Comment,
|
|
}
|
|
|
|
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to create revision")
|
|
writeError(w, http.StatusInternalServerError, "revision_failed", err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get updated item (use new part number if changed)
|
|
item, _ = s.items.GetByPartNumber(ctx, newPartNumber)
|
|
writeJSON(w, http.StatusOK, itemToResponse(item))
|
|
}
|
|
|
|
// HandleDeleteItem permanently deletes an item.
|
|
// Use query param ?soft=true for soft delete (archive).
|
|
func (s *Server) HandleDeleteItem(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
soft := r.URL.Query().Get("soft") == "true"
|
|
|
|
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
|
|
}
|
|
|
|
if soft {
|
|
if err := s.items.Archive(ctx, item.ID); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to archive item")
|
|
writeError(w, http.StatusInternalServerError, "archive_failed", err.Error())
|
|
return
|
|
}
|
|
} else {
|
|
if err := s.items.Delete(ctx, item.ID); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to delete item")
|
|
writeError(w, http.StatusInternalServerError, "delete_failed", err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// Revision handlers
|
|
|
|
// RevisionResponse represents a revision in API responses.
|
|
type RevisionResponse struct {
|
|
ID string `json:"id"`
|
|
RevisionNumber int `json:"revision_number"`
|
|
Properties map[string]any `json:"properties"`
|
|
FileKey *string `json:"file_key,omitempty"`
|
|
FileChecksum *string `json:"file_checksum,omitempty"`
|
|
FileSize *int64 `json:"file_size,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
CreatedBy *string `json:"created_by,omitempty"`
|
|
Comment *string `json:"comment,omitempty"`
|
|
}
|
|
|
|
// HandleListRevisions lists revisions for an item.
|
|
func (s *Server) HandleListRevisions(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
|
|
}
|
|
|
|
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get revisions")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions")
|
|
return
|
|
}
|
|
|
|
response := make([]RevisionResponse, len(revisions))
|
|
for i, rev := range revisions {
|
|
response[i] = revisionToResponse(rev)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// HandleGetRevision retrieves a specific revision.
|
|
func (s *Server) HandleGetRevision(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
revStr := chi.URLParam(r, "revision")
|
|
|
|
revNum, err := strconv.Atoi(revStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number")
|
|
return
|
|
}
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil || item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions")
|
|
return
|
|
}
|
|
|
|
for _, rev := range revisions {
|
|
if rev.RevisionNumber == revNum {
|
|
writeJSON(w, http.StatusOK, revisionToResponse(rev))
|
|
return
|
|
}
|
|
}
|
|
|
|
writeError(w, http.StatusNotFound, "not_found", "Revision not found")
|
|
}
|
|
|
|
// Part number generation
|
|
|
|
// GeneratePartNumberRequest represents a request to generate a part number.
|
|
type GeneratePartNumberRequest struct {
|
|
Schema string `json:"schema"`
|
|
Project string `json:"project"`
|
|
Category string `json:"category"`
|
|
}
|
|
|
|
// HandleGeneratePartNumber generates a part number without creating an item.
|
|
func (s *Server) HandleGeneratePartNumber(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var req GeneratePartNumberRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
schemaName := req.Schema
|
|
if schemaName == "" {
|
|
schemaName = "kindred-rd"
|
|
}
|
|
|
|
input := partnum.Input{
|
|
SchemaName: schemaName,
|
|
Values: map[string]string{
|
|
"project": req.Project,
|
|
"category": req.Category,
|
|
},
|
|
}
|
|
|
|
partNumber, err := s.partgen.Generate(ctx, input)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "generation_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"part_number": partNumber})
|
|
}
|
|
|
|
// Schema value management handlers
|
|
|
|
// AddSchemaValueRequest represents a request to add a new enum value.
|
|
type AddSchemaValueRequest struct {
|
|
Code string `json:"code"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// UpdateSchemaValueRequest represents a request to update an enum value description.
|
|
type UpdateSchemaValueRequest struct {
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// HandleAddSchemaValue adds a new value to an enum segment.
|
|
func (s *Server) HandleAddSchemaValue(w http.ResponseWriter, r *http.Request) {
|
|
schemaName := chi.URLParam(r, "name")
|
|
segmentName := chi.URLParam(r, "segment")
|
|
|
|
sch, ok := s.schemas[schemaName]
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
|
|
return
|
|
}
|
|
|
|
// Find the segment
|
|
var segment *schema.Segment
|
|
for i := range sch.Segments {
|
|
if sch.Segments[i].Name == segmentName {
|
|
segment = &sch.Segments[i]
|
|
break
|
|
}
|
|
}
|
|
if segment == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Segment not found")
|
|
return
|
|
}
|
|
if segment.Type != "enum" {
|
|
writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type")
|
|
return
|
|
}
|
|
|
|
var req AddSchemaValueRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
if req.Code == "" || req.Description == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Code and description are required")
|
|
return
|
|
}
|
|
|
|
// Check if code already exists
|
|
if _, exists := segment.Values[req.Code]; exists {
|
|
writeError(w, http.StatusConflict, "already_exists", "Value code already exists")
|
|
return
|
|
}
|
|
|
|
// Add the new value
|
|
segment.Values[req.Code] = req.Description
|
|
|
|
// Save to file
|
|
if err := s.saveSchema(sch); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to save schema")
|
|
writeError(w, http.StatusInternalServerError, "save_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]string{"code": req.Code, "description": req.Description})
|
|
}
|
|
|
|
// HandleUpdateSchemaValue updates an enum value's description.
|
|
func (s *Server) HandleUpdateSchemaValue(w http.ResponseWriter, r *http.Request) {
|
|
schemaName := chi.URLParam(r, "name")
|
|
segmentName := chi.URLParam(r, "segment")
|
|
code := chi.URLParam(r, "code")
|
|
|
|
sch, ok := s.schemas[schemaName]
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
|
|
return
|
|
}
|
|
|
|
// Find the segment
|
|
var segment *schema.Segment
|
|
for i := range sch.Segments {
|
|
if sch.Segments[i].Name == segmentName {
|
|
segment = &sch.Segments[i]
|
|
break
|
|
}
|
|
}
|
|
if segment == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Segment not found")
|
|
return
|
|
}
|
|
if segment.Type != "enum" {
|
|
writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type")
|
|
return
|
|
}
|
|
|
|
// Check if code exists
|
|
if _, exists := segment.Values[code]; !exists {
|
|
writeError(w, http.StatusNotFound, "not_found", "Value code not found")
|
|
return
|
|
}
|
|
|
|
var req UpdateSchemaValueRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
if req.Description == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "Description is required")
|
|
return
|
|
}
|
|
|
|
// Update the value
|
|
segment.Values[code] = req.Description
|
|
|
|
// Save to file
|
|
if err := s.saveSchema(sch); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to save schema")
|
|
writeError(w, http.StatusInternalServerError, "save_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"code": code, "description": req.Description})
|
|
}
|
|
|
|
// HandleDeleteSchemaValue removes an enum value.
|
|
func (s *Server) HandleDeleteSchemaValue(w http.ResponseWriter, r *http.Request) {
|
|
schemaName := chi.URLParam(r, "name")
|
|
segmentName := chi.URLParam(r, "segment")
|
|
code := chi.URLParam(r, "code")
|
|
|
|
sch, ok := s.schemas[schemaName]
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "Schema not found")
|
|
return
|
|
}
|
|
|
|
// Find the segment
|
|
var segment *schema.Segment
|
|
for i := range sch.Segments {
|
|
if sch.Segments[i].Name == segmentName {
|
|
segment = &sch.Segments[i]
|
|
break
|
|
}
|
|
}
|
|
if segment == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Segment not found")
|
|
return
|
|
}
|
|
if segment.Type != "enum" {
|
|
writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type")
|
|
return
|
|
}
|
|
|
|
// Check if code exists
|
|
if _, exists := segment.Values[code]; !exists {
|
|
writeError(w, http.StatusNotFound, "not_found", "Value code not found")
|
|
return
|
|
}
|
|
|
|
// Delete the value
|
|
delete(segment.Values, code)
|
|
|
|
// Save to file
|
|
if err := s.saveSchema(sch); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to save schema")
|
|
writeError(w, http.StatusInternalServerError, "save_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// saveSchema writes the schema back to its YAML file.
|
|
func (s *Server) saveSchema(sch *schema.Schema) error {
|
|
// Build the schema file structure
|
|
schemaFile := schema.SchemaFile{
|
|
Schema: *sch,
|
|
}
|
|
|
|
data, err := yaml.Marshal(schemaFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
filename := filepath.Join(s.schemasDir, sch.Name+".yaml")
|
|
return os.WriteFile(filename, data, 0644)
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func itemToResponse(item *db.Item) ItemResponse {
|
|
return ItemResponse{
|
|
ID: item.ID,
|
|
PartNumber: item.PartNumber,
|
|
ItemType: item.ItemType,
|
|
Description: item.Description,
|
|
CurrentRevision: item.CurrentRevision,
|
|
CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}
|
|
}
|
|
|
|
func revisionToResponse(rev *db.Revision) RevisionResponse {
|
|
return RevisionResponse{
|
|
ID: rev.ID,
|
|
RevisionNumber: rev.RevisionNumber,
|
|
Properties: rev.Properties,
|
|
FileKey: rev.FileKey,
|
|
FileChecksum: rev.FileChecksum,
|
|
FileSize: rev.FileSize,
|
|
CreatedAt: rev.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
CreatedBy: rev.CreatedBy,
|
|
Comment: rev.Comment,
|
|
}
|
|
}
|
|
|
|
// File upload/download handlers
|
|
|
|
// CreateRevisionRequest represents a request to create a new revision.
|
|
type CreateRevisionRequest struct {
|
|
Properties map[string]any `json:"properties"`
|
|
Comment string `json:"comment"`
|
|
}
|
|
|
|
// HandleCreateRevision creates a new revision for an item (without file).
|
|
func (s *Server) HandleCreateRevision(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 CreateRevisionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
rev := &db.Revision{
|
|
ItemID: item.ID,
|
|
Properties: req.Properties,
|
|
Comment: &req.Comment,
|
|
}
|
|
|
|
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to create revision")
|
|
writeError(w, http.StatusInternalServerError, "revision_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, revisionToResponse(rev))
|
|
}
|
|
|
|
// HandleUploadFile uploads a file and creates a new revision.
|
|
func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
|
|
// Check storage is configured
|
|
if s.storage == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Parse multipart form (max 100MB)
|
|
if err := r.ParseMultipartForm(100 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_form", err.Error())
|
|
return
|
|
}
|
|
|
|
// Get the file
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "missing_file", "File is required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Get optional fields
|
|
comment := r.FormValue("comment")
|
|
propertiesJSON := r.FormValue("properties")
|
|
|
|
var properties map[string]any
|
|
if propertiesJSON != "" {
|
|
if err := json.Unmarshal([]byte(propertiesJSON), &properties); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_properties", "Properties must be valid JSON")
|
|
return
|
|
}
|
|
} else {
|
|
properties = make(map[string]any)
|
|
}
|
|
|
|
// Determine the next revision number
|
|
nextRevision := item.CurrentRevision + 1
|
|
|
|
// Generate storage key
|
|
fileKey := storage.FileKey(partNumber, nextRevision)
|
|
|
|
// Determine content type
|
|
contentType := header.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
// Upload to storage
|
|
result, err := s.storage.Put(ctx, fileKey, file, header.Size, contentType)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to upload file")
|
|
writeError(w, http.StatusInternalServerError, "upload_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
// Create revision with file metadata
|
|
rev := &db.Revision{
|
|
ItemID: item.ID,
|
|
Properties: properties,
|
|
FileKey: &result.Key,
|
|
FileVersion: &result.VersionID,
|
|
FileChecksum: &result.Checksum,
|
|
FileSize: &result.Size,
|
|
Comment: &comment,
|
|
}
|
|
|
|
if err := s.items.CreateRevision(ctx, rev); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to create revision")
|
|
writeError(w, http.StatusInternalServerError, "revision_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
s.logger.Info().
|
|
Str("part_number", partNumber).
|
|
Int("revision", rev.RevisionNumber).
|
|
Str("file_key", fileKey).
|
|
Int64("size", result.Size).
|
|
Msg("file uploaded")
|
|
|
|
writeJSON(w, http.StatusCreated, revisionToResponse(rev))
|
|
}
|
|
|
|
// HandleDownloadFile downloads the file for a specific revision.
|
|
func (s *Server) HandleDownloadFile(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
revStr := chi.URLParam(r, "revision")
|
|
|
|
// Check storage is configured
|
|
if s.storage == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
|
|
return
|
|
}
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil || item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
// Parse revision number (or use "latest")
|
|
var revNum int
|
|
if revStr == "latest" {
|
|
revNum = item.CurrentRevision
|
|
} else {
|
|
revNum, err = strconv.Atoi(revStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number or 'latest'")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get revision to find file key
|
|
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions")
|
|
return
|
|
}
|
|
|
|
var revision *db.Revision
|
|
for _, rev := range revisions {
|
|
if rev.RevisionNumber == revNum {
|
|
revision = rev
|
|
break
|
|
}
|
|
}
|
|
|
|
if revision == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Revision not found")
|
|
return
|
|
}
|
|
|
|
if revision.FileKey == nil {
|
|
writeError(w, http.StatusNotFound, "no_file", "Revision has no associated file")
|
|
return
|
|
}
|
|
|
|
// Get file from storage
|
|
var reader interface {
|
|
Read(p []byte) (n int, err error)
|
|
Close() error
|
|
}
|
|
|
|
if revision.FileVersion != nil && *revision.FileVersion != "" {
|
|
reader, err = s.storage.GetVersion(ctx, *revision.FileKey, *revision.FileVersion)
|
|
} else {
|
|
reader, err = s.storage.Get(ctx, *revision.FileKey)
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Str("key", *revision.FileKey).Msg("failed to get file")
|
|
writeError(w, http.StatusInternalServerError, "download_failed", err.Error())
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
// Set response headers
|
|
filename := partNumber + "_rev" + strconv.Itoa(revNum) + ".FCStd"
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
|
if revision.FileSize != nil {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(*revision.FileSize, 10))
|
|
}
|
|
|
|
// Stream file to response
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, readErr := reader.Read(buf)
|
|
if n > 0 {
|
|
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
|
|
s.logger.Error().Err(writeErr).Msg("failed to write response")
|
|
return
|
|
}
|
|
}
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleDownloadLatestFile downloads the file for the latest revision.
|
|
func (s *Server) HandleDownloadLatestFile(w http.ResponseWriter, r *http.Request) {
|
|
chi.URLParam(r, "partNumber") // ensure URL param is consumed
|
|
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.RouteContext(r.Context())))
|
|
|
|
// Add "latest" as the revision param and delegate
|
|
rctx := chi.RouteContext(r.Context())
|
|
rctx.URLParams.Add("revision", "latest")
|
|
s.HandleDownloadFile(w, r)
|
|
}
|