Files
silo/internal/api/handlers.go
2026-01-24 15:03:17 -06:00

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)
}