Overview
Handlers in SFLUV follow a consistent pattern:- Extract user context (from JWT middleware)
- Parse request body/params
- Validate input
- Call database layer
- Return JSON response or error
backend/handlers/
Handler Pattern
Standard Handler Signature
All handlers implementhttp.HandlerFunc:
func (a *AppService) HandlerName(w http.ResponseWriter, r *http.Request) {
// Implementation
}
Example: Get User Profile
backend/handlers/app_user.go
func (a *AppService) GetUserAuthed(w http.ResponseWriter, r *http.Request) {
// 1. Extract authenticated user from context
userDid, ok := r.Context().Value("userDid").(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
// 2. Call database layer
user, err := a.db.GetUserByDid(r.Context(), userDid)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// 3. Fetch related data
wallets, _ := a.db.GetWalletsByUser(r.Context(), userDid)
locations, _ := a.db.GetLocationsByUser(r.Context(), userDid)
// 4. Build response struct
response := GetUserResponse{
User: user,
Wallets: wallets,
Locations: locations,
Proposer: a.db.GetProposerByUserId(r.Context(), userDid),
Improver: a.db.GetImproverByUserId(r.Context(), userDid),
}
// 5. Return JSON
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
Request Processing
1. Extracting User Context
Authenticated user DID is injected byAuthMiddleware:
userDid, ok := r.Context().Value("userDid").(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
Role checks (admin, proposer, etc.) happen in middleware. Handlers assume authorization is already validated.
2. Parsing JSON Body
var req CreateWorkflowRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
3. Parsing URL Parameters
Path params (chi):workflowId := chi.URLParam(r, "workflow_id")
stepId := chi.URLParam(r, "step_id")
address := r.URL.Query().Get("address")
page := r.URL.Query().Get("page")
4. Parsing Form Data
err := r.ParseForm()
if err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
wallet := r.FormValue("wallet")
email := r.FormValue("email")
Response Formatting
Success Response (JSON)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
Created Response (201)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"id": newWorkflowId,
})
No Content Response (204)
w.WriteHeader(http.StatusNoContent)
Plain Text Response
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "Success")
Error Handling
Standard HTTP Errors
// 400 Bad Request
http.Error(w, "Invalid workflow ID", http.StatusBadRequest)
// 403 Forbidden
http.Error(w, "Not authorized", http.StatusForbidden)
// 404 Not Found
http.Error(w, "Workflow not found", http.StatusNotFound)
// 500 Internal Server Error
http.Error(w, "Internal server error", http.StatusInternalServerError)
Logging Errors
user, err := a.db.GetUserByDid(r.Context(), userDid)
if err != nil {
a.logger.Logf("Error fetching user %s: %v", userDid, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
Never expose internal error details (database errors, stack traces) to clients. Log internally, return generic messages.
Handler Examples
Example 1: Create Resource (POST)
Route:POST /workflows
func (a *AppService) CreateWorkflow(w http.ResponseWriter, r *http.Request) {
userDid := r.Context().Value("userDid").(string)
// Parse request
var req CreateWorkflowRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Validate input
if req.Title == "" || req.Description == "" {
http.Error(w, "Title and description required", http.StatusBadRequest)
return
}
// Create workflow
workflowId := uuid.New().String()
workflow := &structs.Workflow{
Id: workflowId,
Title: req.Title,
Description: req.Description,
ProposerId: userDid,
Status: "pending",
}
// Save to database
err := a.db.CreateWorkflow(r.Context(), workflow)
if err != nil {
a.logger.Logf("Error creating workflow: %v", err)
http.Error(w, "Failed to create workflow", http.StatusInternalServerError)
return
}
// Send email notification to admin
a.notifyAdminNewWorkflow(workflow)
// Return workflow ID
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"id": workflowId,
})
}
Example 2: Update Resource (PUT)
Route:PUT /improvers/primary-rewards-account
func (a *AppService) UpdateImproverPrimaryRewardsAccount(w http.ResponseWriter, r *http.Request) {
userDid := r.Context().Value("userDid").(string)
// Parse request body
var req struct {
Address string `json:"address"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Validate wallet address
if !isValidAddress(req.Address) {
http.Error(w, "Invalid wallet address", http.StatusBadRequest)
return
}
// Update in database
err := a.db.UpdateImproverPrimaryRewardsAccount(r.Context(), userDid, req.Address)
if err != nil {
http.Error(w, "Failed to update", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
Example 3: Get Resource (GET)
Route:GET /workflows/{workflow_id}
func (a *AppService) GetWorkflow(w http.ResponseWriter, r *http.Request) {
workflowId := chi.URLParam(r, "workflow_id")
// Fetch workflow
workflow, err := a.db.GetWorkflowById(r.Context(), workflowId)
if err != nil {
http.Error(w, "Workflow not found", http.StatusNotFound)
return
}
// Fetch related data
steps, _ := a.db.GetWorkflowSteps(r.Context(), workflowId)
votes, _ := a.db.GetWorkflowVotes(r.Context(), workflowId)
// Build response
response := WorkflowDetailResponse{
Workflow: workflow,
Steps: steps,
Votes: votes,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
Example 4: Delete Resource (DELETE)
Route:DELETE /proposers/workflows/{workflow_id}
func (a *AppService) DeleteProposerWorkflow(w http.ResponseWriter, r *http.Request) {
userDid := r.Context().Value("userDid").(string)
workflowId := chi.URLParam(r, "workflow_id")
// Verify ownership
workflow, err := a.db.GetWorkflowById(r.Context(), workflowId)
if err != nil {
http.Error(w, "Workflow not found", http.StatusNotFound)
return
}
if workflow.ProposerId != userDid {
http.Error(w, "Not authorized", http.StatusForbidden)
return
}
// Only pending workflows can be deleted directly
if workflow.Status != "pending" {
http.Error(w, "Cannot delete approved workflow", http.StatusBadRequest)
return
}
// Delete from database
err = a.db.DeleteWorkflow(r.Context(), workflowId)
if err != nil {
http.Error(w, "Failed to delete", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
Email Notifications
Mailgun is used for transactional emails. HTML templates are constructed inline.Sending Email
func (a *AppService) sendEmail(to, subject, body string) error {
mg := mailgun.NewMailgun(
os.Getenv("MAILGUN_DOMAIN"),
os.Getenv("MAILGUN_API_KEY"),
)
message := mg.NewMessage(
"[email protected]",
subject,
"", // Plain text (optional)
to,
)
message.SetHtml(body)
_, _, err := mg.Send(context.Background(), message)
return err
}
Email Template Pattern
func (a *AppService) notifyAdminNewProposer(proposer *structs.Proposer) {
subject := "New Proposer Request"
body := fmt.Sprintf(`
<html>
<body style="font-family: Arial, sans-serif;">
<h2>New Proposer Request</h2>
<p><strong>User:</strong> %s</p>
<p><strong>Reason:</strong> %s</p>
<p><a href="%s/admin">Review in Admin Panel</a></p>
</body>
</html>
`, proposer.UserId, proposer.Reason, os.Getenv("APP_BASE_URL"))
adminEmail := os.Getenv("PROPOSER_ADMIN_EMAIL")
a.sendEmail(adminEmail, subject, body)
}
File Organization
Handlers grouped by feature/role:handlers/
├── app.go # AppService struct
├── app_user.go # User endpoints
├── app_workflow.go # Workflow CRUD, voting
├── app_wallet.go # Wallet management
├── app_location.go # Location CRUD
├── app_contact.go # Contact book
├── app_affiliate.go # Affiliate role requests
├── app_admin.go # Admin user/role management
├── app_ponder.go # Ponder subscriptions
├── app_verified_email.go # Email verification
├── bot.go # BotService struct
├── bot_workflow.go # Bot workflow helpers
├── w9.go # W9 compliance
├── unwrap.go # Token unwrapping
├── redeemer.go # Merchant redeemer sync
├── minter.go # Minter role sync
├── ponder.go # PonderService
├── ponder_transactions.go # Transaction history
└── affiliate_scheduler.go # Background payout scheduler
Common Patterns
Role-Specific Data Fetching
// Fetch role-specific data only if user has role
var improver *structs.Improver
if user.IsImprover {
improver, _ = a.db.GetImproverByUserId(ctx, userDid)
}
Pagination
page := r.URL.Query().Get("page")
limit := r.URL.Query().Get("limit")
pageInt, _ := strconv.Atoi(page)
limitInt, _ := strconv.Atoi(limit)
if limitInt == 0 {
limitInt = 20 // Default page size
}
workflows, total := a.db.GetWorkflowsPaginated(ctx, pageInt, limitInt)
response := PaginatedResponse{
Data: workflows,
Total: total,
Page: pageInt,
TotalPages: (total + limitInt - 1) / limitInt,
}
Webhook Validation
func (a *AppService) SubmitW9Webhook(w http.ResponseWriter, r *http.Request) {
// Verify shared secret
secret := r.Header.Get("X-W9-Secret")
if secret == "" {
secret = r.Header.Get("X-W9-Key")
}
expectedSecret := os.Getenv("W9_WEBHOOK_SECRET")
if expectedSecret != "" && secret != expectedSecret {
http.Error(w, "Invalid secret", http.StatusUnauthorized)
return
}
// Process webhook payload
// ...
}
Testing Handlers
Example Test
func TestCreateWorkflow(t *testing.T) {
// Setup: Mock database and service
mockDB := &MockAppDB{}
service := &AppService{db: mockDB}
// Create request
body := `{"title":"Test Workflow","description":"Test"}`
req := httptest.NewRequest("POST", "/workflows", strings.NewReader(body))
req = req.WithContext(context.WithValue(req.Context(), "userDid", "test-user"))
// Execute handler
w := httptest.NewRecorder()
service.CreateWorkflow(w, req)
// Assert response
assert.Equal(t, http.StatusCreated, w.Code)
var response map[string]string
json.NewDecoder(w.Body).Decode(&response)
assert.NotEmpty(t, response["id"])
}
Next Steps
Middleware
Authentication and role-based authorization
Database Layer
How handlers query the database