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