0

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

  1. Start the Go application.
  2. Send a POST request to http://localhost:8081/service/ with a Content-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).
  3. 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 in main.go) works correctly. This route also uses c.ShouldBind to parse the form data, which implicitly relies on r.MaxMultipartMemory set on the Gin engine.
  • Size Limits:
    • In main.go, a global http.MaxBytesReader middleware 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 MB is set on the Gin router.
    • In services_controller.go, c.Request.ParseMultipartForm(100 << 20) // 100 MB is explicitly called.
  • Hypothesis:
    • The http.MaxBytesReader middleware (set to 32MB) applied in main.go wraps all incoming request bodies.
    • When the request hits ServiceController.CreateService, the c.Request.Body has already been wrapped by http.MaxBytesReader.
    • If the total request body size (including multipart overhead) exceeds 32MB, the http.MaxBytesReader will stop reading, effectively returning an EOF (or ErrTooLarge if it were to explicitly check the size) to the downstream ParseMultipartForm call in the controller, regardless of the 100MB limit set there.
    • The test route in main.go uses c.ShouldBind(&req), which internally uses Gin's parsing logic, respecting r.MaxMultipartMemory. It's possible c.ShouldBind handles the http.MaxBytesReader interaction more gracefully or processes the body differently, allowing it to work up to the 32MB limit.
  • The 100MB limit in ParseMultipartForm seems ineffective because the http.MaxBytesReader is limiting the raw input stream earlier in the middleware chain.

Proposed Solution / Next Steps (for investigation)

  1. Harmonize Request Body Limits:

    • Option A (Recommended): Set the http.MaxBytesReader middleware limit in main.go to the desired absolute maximum request size (e.g., 100MB or higher). Then, rely on r.MaxMultipartMemory for in-memory buffering (e.g., 32MB), and potentially remove the explicit c.Request.ParseMultipartForm call in the controller, letting c.ShouldBind handle it.
    • Option B: If c.Request.ParseMultipartForm must be called explicitly, ensure its argument is consistent with or larger than http.MaxBytesReader's limit.
  2. Verify Upstream Proxies/Load Balancers: Confirm that no intermediate proxy or load balancer has a lower client_max_body_size or similar limit that could be cutting off the request before it reaches the Go application.

  3. 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.")
}```

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.