Here's a detailed description for your GitHub issue, incorporating the information we've discussed. This provides context, the problem statement, steps to reproduce, and your observations.
package services_controller
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/joy095/identity/config/db"
"github.com/joy095/identity/logger"
"github.com/joy095/identity/models/service_models"
)
type ServiceController struct{}
// NewServiceController creates and returns a new instance of ServiceController
func NewServiceController() *ServiceController {
return &ServiceController{}
}
type CreateServiceRequest struct {
BusinessID string `form:"businessId" binding:"required"`
Name string `form:"name" binding:"required"`
Description string `form:"description,omitempty"`
DurationMinutes int `form:"durationMinutes" binding:"required"`
Price float64 `form:"price" binding:"required"`
IsActive bool `form:"isActive,omitempty"`
}
type ImageUploadResponse struct {
ImageID uuid.UUID `json:"image_id"`
}
// CreateService handles service creation, including multipart form data and image upload.
func (sc *ServiceController) CreateService(c *gin.Context) {
logger.InfoLogger.Info("Received new request for /create-service")
var req CreateServiceRequest
if err := c.ShouldBind(&req); err != nil {
logger.ErrorLogger.Errorf("Failed to bind form data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid form data", "details": err.Error()})
return
}
logger.InfoLogger.Info("Step 1: Form data bound successfully.")
// Parse the BusinessID string into a UUID (This part is still early as it's a direct input requirement)
businessUUID, err := uuid.Parse(req.BusinessID)
if err != nil {
logger.ErrorLogger.Errorf("Invalid Business ID format: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format for businessId. Must be a valid UUID."})
return
}
if !req.IsActive {
req.IsActive = true
}
authHeader := c.GetHeader("Authorization")
logger.InfoLogger.Info("Step 2: Business ID parsed successfully.")
// **REQUIRED IMAGE CHECK** - Image file is mandatory
fileHeader, err := c.FormFile("image")
if err != nil {
if err == http.ErrMissingFile {
logger.ErrorLogger.Error("Form file 'image' is missing from the request.")
c.JSON(http.StatusBadRequest, gin.H{"error": "Image file is required for service creation"})
return
}
logger.ErrorLogger.Errorf("Could not get form file 'image': %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Could not process image file"})
return
}
logger.InfoLogger.Info("Step 3: Image file header retrieved successfully. Preparing to call Python service...")
// Process the required image upload
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open uploaded file"})
return
}
defer file.Close()
// Prepare a new request body buffer and multipart writer
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Get the original Content-Type from the file's header
originalContentType := fileHeader.Header.Get("Content-Type")
if originalContentType == "" {
originalContentType = "application/octet-stream"
}
// Create the new part's header
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "image", fileHeader.Filename))
h.Set("Content-Type", originalContentType)
// Create the part with the correct headers
part, err := writer.CreatePart(h)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create form part for proxying"})
return
}
// Copy the file content into the new part
_, err = io.Copy(part, file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write file to form"})
return
}
writer.Close()
// Send to Python image service
pythonServerURL := "http://localhost:8082/upload-image/"
httpReq, _ := http.NewRequest("POST", pythonServerURL, body)
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
httpReq.Header.Set("Authorization", authHeader)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
logger.ErrorLogger.Errorf("Failed to send request to image service or no response: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to send request to image service"})
return
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
logger.ErrorLogger.Errorf("Failed to read response body from image service: %v", readErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response from image service"})
return
}
// --- NEW LOGGING LINE TO CONFIRM RESPONSE AND SHOW CONTENT ---
logger.InfoLogger.Infof("Received response from Python image service (Status: %d, Body: %s)", resp.StatusCode, string(responseBody))
// --- END NEW LOGGING ---
if resp.StatusCode != http.StatusCreated {
logger.ErrorLogger.Errorf("Image service returned non-201 status: %d, response: %s", resp.StatusCode, string(responseBody))
c.JSON(http.StatusBadGateway, gin.H{
"error": "Image service returned an error",
"image_service_status": resp.StatusCode,
"image_service_response": string(responseBody),
})
return
}
fmt.Print("Response from image service:", string(responseBody))
var imgResp ImageUploadResponse
// Enhanced error handling for JSON unmarshaling
if err := json.Unmarshal(responseBody, &imgResp); err != nil {
logger.ErrorLogger.Errorf("Failed to parse JSON response from image service: %v, raw body: %s", err, string(responseBody))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse response from image service"})
return
}
// Validate that we got a valid UUID from the image service
if imgResp.ImageID == uuid.Nil {
logger.ErrorLogger.Errorf("Image service returned a nil/invalid image ID. Raw body: %s", string(responseBody))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Image service returned invalid image ID"})
return
}
fmt.Print("imgResp ", imgResp) // Debug print
fmt.Printf("Image ID: %s\n", imgResp.ImageID)
logger.InfoLogger.Info("Image ID: ", imgResp.ImageID)
// --- MOVED: Create the service object *here*, after getting the image ID ---
service := service_models.NewService(businessUUID, req.Name, req.Description, req.DurationMinutes, req.Price)
service.IsActive = req.IsActive
// Set the image ID as required (always valid since we validated it above)
service.ImageID = pgtype.UUID{Bytes: imgResp.ImageID, Valid: true}
// --- This part remains the same, as it's the database save ---
createdService, err := service_models.CreateServiceModel(db.DB, service)
if err != nil {
logger.ErrorLogger.Errorf("Failed to create service in database: %v", err)
if strings.Contains(err.Error(), "duplicate key value") {
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists for this business."})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service in the database."})
}
return
}
logger.InfoLogger.Infof("Service '%s' created successfully via test route with image ID: %s", createdService.Name, imgResp.ImageID)
c.JSON(http.StatusCreated, gin.H{
"message": "Service created successfully!",
"service": createdService,
})
}
Title: multipart: NextPart: EOF Error in ServiceController.CreateService, but Test Route in main.go Works
Problem Description
We are encountering a multipart: NextPart: EOF error when attempting to upload a file (image) as part of a multipart/form-data request to the /service/ endpoint, which is handled by ServiceController.CreateService. This error does not occur when the exact same request is made to the /create-service test endpoint defined directly in main.go.
This suggests an issue with how multipart form parsing or request body limits are being applied specifically within the ServiceController's execution flow, or how gin.Engine's middleware/settings interact with the controller.
Error Message
{"level":"error","message":"--- DIAGNOSTIC: FAILED at ParseMultipartForm: multipart: NextPart: EOF ---","service":"identity-service","timestamp":"2025-06-24T11:47:07+05:30"}
This log line originates from the services_controller.go file within the CreateService method:
// services_controller.go
err := c.Request.ParseMultipartForm(100 << 20) // 100 MB
if err != nil {
logger.ErrorLogger.Errorf("--- DIAGNOSTIC: FAILED at ParseMultipartForm: %v ---", err)
// ...
}
Steps to Reproduce
- Start the Go application.
- Send a
POSTrequest tohttp://localhost:8081/service/with aContent-Type: multipart/form-data.- Include form fields:
businessId,name,durationMinutes,price. - Include a file part named
image. - Crucially, use an image file size that is greater than 32MB (e.g., 40MB, 50MB, or larger).
- Include form fields:
- Observe the error logs from the Go service, specifically the
--- DIAGNOSTIC: FAILED at ParseMultipartForm: multipart: NextPart: EOF ---message.
Expected Behavior
The CreateService method in ServiceController should successfully parse the multipart form data, including the image file, up to the configured limits (100MB explicitly set for ParseMultipartForm or 32MB by MaxMultipartMemory if it handles it). The request should then proceed to proxy the image to the Python service and create the service entry in the database.
Actual Behavior
The c.Request.ParseMultipartForm call inside ServiceController.CreateService fails with multipart: NextPart: EOF, leading to a 400 Bad Request response.
Observations / Debugging Notes
- Discrepancy: The exact same request (same payload, same file size) sent to
http://localhost:8081/create-service(the test route inmain.go) works correctly. This route also usesc.ShouldBindto parse the form data, which implicitly relies onr.MaxMultipartMemoryset on the Gin engine. - Size Limits:
- In
main.go, a globalhttp.MaxBytesReadermiddleware is applied to the router:c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<20) // 32 MB. - Also in
main.go,r.MaxMultipartMemory = 32 << 20 // 32 MBis set on the Gin router. - In
services_controller.go,c.Request.ParseMultipartForm(100 << 20) // 100 MBis explicitly called.
- In
- Hypothesis:
- The
http.MaxBytesReadermiddleware (set to 32MB) applied inmain.gowraps all incoming request bodies. - When the request hits
ServiceController.CreateService, thec.Request.Bodyhas already been wrapped byhttp.MaxBytesReader. - If the total request body size (including multipart overhead) exceeds 32MB, the
http.MaxBytesReaderwill stop reading, effectively returning anEOF(orErrTooLargeif it were to explicitly check the size) to the downstreamParseMultipartFormcall in the controller, regardless of the 100MB limit set there. - The test route in
main.gousesc.ShouldBind(&req), which internally uses Gin's parsing logic, respectingr.MaxMultipartMemory. It's possiblec.ShouldBindhandles thehttp.MaxBytesReaderinteraction more gracefully or processes the body differently, allowing it to work up to the 32MB limit.
- The
- The 100MB limit in
ParseMultipartFormseems ineffective because thehttp.MaxBytesReaderis limiting the raw input stream earlier in the middleware chain.
Proposed Solution / Next Steps (for investigation)
Harmonize Request Body Limits:
- Option A (Recommended): Set the
http.MaxBytesReadermiddleware limit inmain.goto the desired absolute maximum request size (e.g., 100MB or higher). Then, rely onr.MaxMultipartMemoryfor in-memory buffering (e.g., 32MB), and potentially remove the explicitc.Request.ParseMultipartFormcall in the controller, lettingc.ShouldBindhandle it. - Option B: If
c.Request.ParseMultipartFormmust be called explicitly, ensure its argument is consistent with or larger thanhttp.MaxBytesReader's limit.
- Option A (Recommended): Set the
Verify Upstream Proxies/Load Balancers: Confirm that no intermediate proxy or load balancer has a lower
client_max_body_sizeor similar limit that could be cutting off the request before it reaches the Go application.Client-Side Verification: Double-check that the client sending the request is not prematurely closing the connection or has its own low upload limits.
package main
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/joy095/identity/badwords"
"github.com/joy095/identity/config"
"github.com/joy095/identity/config/db"
"github.com/joy095/identity/logger"
"github.com/joy095/identity/middlewares/cors"
"github.com/joy095/identity/models/service_models"
"github.com/joy095/identity/routes"
"github.com/joy095/identity/utils/mail"
)
//go:embed templates/email/*
var embeddedEmailTemplates embed.FS
func init() {
logger.InitLoggers()
config.LoadEnv()
}
// Updated request struct to make image required
type TestCreateServiceRequest struct {
BusinessID string `form:"businessId" binding:"required"`
Name string `form:"name" binding:"required"`
Description string `form:"description,omitempty"`
DurationMinutes int `form:"durationMinutes" binding:"required"`
Price float64 `form:"price" binding:"required"`
IsActive bool `form:"isActive,omitempty"`
}
// Corrected JSON tag to match Python's 'image_id'
type ImageUploadResponse struct {
ImageID uuid.UUID `json:"image_id"`
}
func main() {
db.Connect()
defer db.Close()
port := os.Getenv("PORT")
if port == "" {
port = "8081"
}
mail.InitTemplates(embeddedEmailTemplates)
logger.InfoLogger.Info("Application: Email templates initialized.")
badwords.LoadBadWords("badwords/en.txt")
logger.InfoLogger.Info("Bad words loaded successfully!")
r := gin.New()
r.Use(gin.Recovery())
r.Use(cors.CorsMiddleware())
r.MaxMultipartMemory = 32 << 20 // 32 MB
routes.RegisterUserRoutes(r)
routes.RegisterCustomerRoutes(r)
routes.RegisterBusinessRoutes(r)
routes.RegisterServicesRoutes(r)
routes.RegisterWorkingHoursRoutes(r, db.DB)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "ok from identity service"})
})
r.POST("/create-service", func(c *gin.Context) {
logger.InfoLogger.Info("Received new request for /create-service")
var req TestCreateServiceRequest
if err := c.ShouldBind(&req); err != nil {
logger.ErrorLogger.Errorf("Failed to bind form data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid form data", "details": err.Error()})
return
}
logger.InfoLogger.Info("Step 1: Form data bound successfully.")
// Parse the BusinessID string into a UUID (This part is still early as it's a direct input requirement)
businessUUID, err := uuid.Parse(req.BusinessID)
if err != nil {
logger.ErrorLogger.Errorf("Invalid Business ID format: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format for businessId. Must be a valid UUID."})
return
}
if !req.IsActive {
req.IsActive = true
}
authHeader := c.GetHeader("Authorization")
logger.InfoLogger.Info("Step 2: Business ID parsed successfully.")
// **REQUIRED IMAGE CHECK** - Image file is mandatory
fileHeader, err := c.FormFile("image")
if err != nil {
if err == http.ErrMissingFile {
logger.ErrorLogger.Error("Form file 'image' is missing from the request.")
c.JSON(http.StatusBadRequest, gin.H{"error": "Image file is required for service creation"})
return
}
logger.ErrorLogger.Errorf("Could not get form file 'image': %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Could not process image file"})
return
}
logger.InfoLogger.Info("Step 3: Image file header retrieved successfully. Preparing to call Python service...")
// Process the required image upload
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open uploaded file"})
return
}
defer file.Close()
// Prepare a new request body buffer and multipart writer
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Get the original Content-Type from the file's header
originalContentType := fileHeader.Header.Get("Content-Type")
if originalContentType == "" {
originalContentType = "application/octet-stream"
}
// Create the new part's header
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "image", fileHeader.Filename))
h.Set("Content-Type", originalContentType)
// Create the part with the correct headers
part, err := writer.CreatePart(h)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create form part for proxying"})
return
}
// Copy the file content into the new part
_, err = io.Copy(part, file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write file to form"})
return
}
writer.Close()
// Send to Python image service
pythonServerURL := "http://localhost:8082/upload-image/"
httpReq, _ := http.NewRequest("POST", pythonServerURL, body)
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
httpReq.Header.Set("Authorization", authHeader)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
logger.ErrorLogger.Errorf("Failed to send request to image service or no response: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to send request to image service"})
return
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
logger.ErrorLogger.Errorf("Failed to read response body from image service: %v", readErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response from image service"})
return
}
// --- NEW LOGGING LINE TO CONFIRM RESPONSE AND SHOW CONTENT ---
logger.InfoLogger.Infof("Received response from Python image service (Status: %d, Body: %s)", resp.StatusCode, string(responseBody))
// --- END NEW LOGGING ---
if resp.StatusCode != http.StatusCreated {
logger.ErrorLogger.Errorf("Image service returned non-201 status: %d, response: %s", resp.StatusCode, string(responseBody))
c.JSON(http.StatusBadGateway, gin.H{
"error": "Image service returned an error",
"image_service_status": resp.StatusCode,
"image_service_response": string(responseBody),
})
return
}
fmt.Print("Response from image service:", string(responseBody))
var imgResp ImageUploadResponse
// Enhanced error handling for JSON unmarshaling
if err := json.Unmarshal(responseBody, &imgResp); err != nil {
logger.ErrorLogger.Errorf("Failed to parse JSON response from image service: %v, raw body: %s", err, string(responseBody))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse response from image service"})
return
}
// Validate that we got a valid UUID from the image service
if imgResp.ImageID == uuid.Nil {
logger.ErrorLogger.Errorf("Image service returned a nil/invalid image ID. Raw body: %s", string(responseBody))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Image service returned invalid image ID"})
return
}
fmt.Print("imgResp ", imgResp) // Debug print
fmt.Printf("Image ID: %s\n", imgResp.ImageID)
logger.InfoLogger.Info("Image ID: ", imgResp.ImageID)
// --- MOVED: Create the service object *here*, after getting the image ID ---
service := service_models.NewService(businessUUID, req.Name, req.Description, req.DurationMinutes, req.Price)
service.IsActive = req.IsActive
// Set the image ID as required (always valid since we validated it above)
service.ImageID = pgtype.UUID{Bytes: imgResp.ImageID, Valid: true}
// --- This part remains the same, as it's the database save ---
createdService, err := service_models.CreateServiceModel(db.DB, service)
if err != nil {
logger.ErrorLogger.Errorf("Failed to create service in database: %v", err)
if strings.Contains(err.Error(), "duplicate key value") {
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists for this business."})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service in the database."})
}
return
}
logger.InfoLogger.Infof("Service '%s' created successfully via test route with image ID: %s", createdService.Name, imgResp.ImageID)
c.JSON(http.StatusCreated, gin.H{
"message": "Service created successfully!",
"service": createdService,
})
})
srv := &http.Server{
Addr: ":" + port,
Handler: r,
}
go func() {
fmt.Printf("Go Server listening on :%s\n", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Server failed to listen: %v\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("Shutting down Go server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Printf("Go Server forced to shutdown: %v\n", err)
}
fmt.Println("Go Server exited gracefully.")
}```