Handler Layer: HTTP Request Processing

HTTP API Entry Points

The handler package translates HTTP requests into domain objects, validates inputs, enforces authorization, and formats responses. Each handler delegates business logic to services.

📚 What is this & why does it exist?

Request lifecycle handlers for the internal RPC/HTTP boundary. Each handler validates inputs, builds a request context with tenant/user identity, dispatches to a service, and shapes the response — all wrapped in error-handling middleware that converts exceptions to typed error codes.

💡 Real-world analogy

Like a customs officer at an airport — every request gets stopped, papers checked (auth), bags inspected (validation), and then either waved through to the right terminal (service) or sent back with a stamped rejection (error response). The lifecycle is identical for every traveler.

Overview

Handlers are the outermost API boundary. They receive HTTP requests from Gin, parse and validate them, call services, and send back JSON responses. This separation keeps business logic testable and transport-agnostic.

Key principle: Handlers should be thin. If a handler exceeds ~50 lines, logic belongs in a service.

Request Processing Flow

HTTP Request
    |
    v
Gin Router (matches path)
    |
    v
Middleware (auth, CORS, logging)
    |
    v
Handler.Validate() (check JWT, unmarshal JSON)
    |
    v
Handler.Call Service (business logic)
    |
    v
Service returns (data, error)
    |
    v
Handler.Marshal Response (JSON)
    |
    v
Gin sends HTTP 200/4xx/5xx

Handler Types

HandlerEndpointsPurpose
ChatHandlerPOST /chat, GET /chat/:idConversational AI. Streams responses, manages context window.
SearchHandlerPOST /search, GET /search/statsFull-text + semantic search. Returns ranked results with snippets.
KBHandlerPOST /kb, DELETE /kb/:idKnowledge base CRUD. Manages documents, indexes.
DocumentHandlerPUT /doc/:id, GET /doc/:id/chunksDocument operations. Upload, delete, reprocess.
FileHandlerPOST /file/upload, GET /file/:idFile I/O wrapper. Delegates to storage backend.
LLMHandlerPOST /llm/chat, POST /llm/embedLLM API proxy. Routes to provider (OpenAI, local).
ChunkHandlerGET /chunk/:id, PATCH /chunk/:idText chunk operations. Inspect, edit, rerank.

Handler Anatomy

Input Validation

Every handler validates HTTP inputs before calling services:

type ChatRequest struct {
    KnowledgeBaseID string `json:"kb_id" binding:"required"`
    Question        string `json:"question" binding:"required,max=2000"`
    MaxTokens       int    `json:"max_tokens" binding:"gte=10,lte=4000"`
}

func (h *ChatHandler) Chat(c *gin.Context) {
    var req ChatRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    // Input is now type-safe and validated
}

Authorization

Middleware extracts JWT and injects user/tenant context. Handlers check scopes:

func (h *ChatHandler) Chat(c *gin.Context) {
    userID := c.GetString("user_id")     // from JWT
    tenantID := c.GetString("tenant_id")
    
    if userID == "" {
        c.JSON(401, gin.H{"error": "unauthorized"})
        return
    }
    
    // Ensure user owns the KB
    kb, err := h.service.GetKB(c.Context(), tenantID, kbID)
    if err != nil || kb.OwnerID != userID {
        c.JSON(403, gin.H{"error": "forbidden"})
        return
    }
}

Service Invocation

Delegate to service; let it handle retries, caching, transactions:

result, err := h.chatService.Chat(c.Context(), &ChatRequest{
    KnowledgeBaseID: req.KnowledgeBaseID,
    Question:        req.Question,
    UserID:          userID,
    TenantID:        tenantID,
})
if err != nil {
    h.logger.Error("chat error", "user_id", userID, "error", err)
    c.JSON(500, gin.H{"error": "internal error"})
    return
}

Response Formatting

Return consistent, typed responses:

type ChatResponse struct {
    ID        string    `json:"id"`
    Answer    string    `json:"answer"`
    Tokens    int       `json:"tokens"`
    Sources   []Source  `json:"sources"`
    Latency   float64   `json:"latency_ms"`
}

c.JSON(200, gin.H{
    "data": result,
    "meta": gin.H{
        "request_id": c.GetString("request_id"),
        "timestamp": time.Now().Unix(),
    },
})

Error Handling

StatusWhenExample
400Invalid input (missing field, wrong type)missing kb_id parameter
401Missing or invalid JWTexpired token
403User lacks permissionaccessing other tenant's KB
404Resource not foundKB not in database
429Rate limit exceededuser quota hit
500Unexpected service errordatabase connection lost

Submodules

Request Lifecycle Deep Dive

Trace a search query from client to response. End-to-end timing breakdown.

Middleware

CORS, JWT extraction, request logging, panic recovery.

Chat Handler Details

Streaming responses, context management, token accounting.

Search Handler Details

Query parsing, ranking algorithms, result pagination.

Authorization Patterns

JWT validation, tenant isolation, role-based access control.

Response Formats

Consistent JSON schema, error codes, timing metadata.

Key Concepts

Gin Context Injection

Handlers receive *gin.Context, which carries request state (user, tenant, request ID, headers). This allows middleware to inject data that handlers read.

Unmarshaling & Binding

c.ShouldBindJSON() combines unmarshaling and validation in one call. Errors are caught early.

Layering Discipline

Handlers must not access the database directly. They must call services. This maintains separation of concerns and makes unit tests faster.

Common Pitfalls

Common Patterns

Request ID Tracking

Every request gets a unique ID in middleware. This ID flows through logs, allowing you to trace a single request through all systems.

func LoggingMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        requestID := uuid.New().String()
        c.Set("request_id", requestID)
        c.Header("X-Request-ID", requestID)
        
        c.Next()
        
        latency := time.Since(start)
        logger.Info("request", "request_id", requestID, "latency_ms", latency.Milliseconds())
    }
}

Timeout & Cancellation

All handlers pass context.Context down to services and DAOs. Set a timeout in middleware so slow requests don't accumulate.

func TimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
        defer cancel()
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    }
}

Graceful Shutdown

When the server shuts down, drain in-flight requests before closing connections.

server := &http.Server{Addr: ":8080", Handler: router}

go func() {
    <-shutdown // signal channel
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    server.Shutdown(ctx)  // wait up to 5s for requests to finish
}()

server.ListenAndServe()

Testing Handlers

Use Gin's test utilities to mock HTTP requests:

func TestSearchHandler(t *testing.T) {
    router := gin.Default()
    mockService := &MockSearchService{}
    handler := &SearchHandler{searchService: mockService}
    
    router.POST("/search", handler.Search)
    
    body := `{"query": "test", "kb_id": "kb1", "top_k": 10}`
    req, _ := http.NewRequest("POST", "/search", strings.NewReader(body))
    w := httptest.NewRecorder()
    
    router.ServeHTTP(w, req)
    
    assert.Equal(t, 200, w.Code)
}

Response Formatting Best Practices

Standard Response Envelope

Wrap all responses in a consistent envelope for client-side consistency:

type APIResponse struct {
    Code    int         `json:"code"`      // 0 = success, non-zero = error
    Message string      `json:"message"`   // human-readable message
    Data    interface{} `json:"data"`      // actual response data
    Meta    struct {
        RequestID string `json:"request_id"`
        Timestamp int64  `json:"timestamp"`
    } `json:"meta"`
}

// Usage
c.JSON(200, APIResponse{
    Code: 0,
    Message: "OK",
    Data: searchResult,
    Meta: {
        RequestID: c.GetString("request_id"),
        Timestamp: time.Now().Unix(),
    },
})

Pagination Support

For list endpoints, support offset/limit pagination:

type ListRequest struct {
    Offset int `json:"offset" binding:"gte=0"`
    Limit  int `json:"limit" binding:"gte=1,lte=100"`
}

// Query
var items []Item
d.db.Offset(offset).Limit(limit).Find(&items)

// Response includes total count
type ListResponse struct {
    Items []Item `json:"items"`
    Total int    `json:"total"`
}

Error Response Standards

Provide detailed error information for debugging:

type ErrorResponse struct {
    Code      int    `json:"code"`
    Message   string `json:"message"`
    Details   string `json:"details,omitempty"`    // internal error details
    Timestamp int64  `json:"timestamp"`
    Path      string `json:"path"`
}

// Example: Missing required field
c.JSON(400, ErrorResponse{
    Code: 400,
    Message: "validation error",
    Details: "field 'kb_id' is required",
    Path: c.Request.URL.Path,
    Timestamp: time.Now().Unix(),
})

Further Reading