
api-endpoints
Create REST API endpoints with proper validation, error handling, and Swagger documentation. Use whe
API Endpoints
This skill guides you through creating REST API endpoints following this project's actual patterns from the codebase.
⚠️ CRITICAL PATTERNS
Use Case Return Pattern
// Use cases return (data, int, error) - int is HTTP status code!
func Create(ctx context.Context, req dto.Request) (*dto.Response, int, error)
Validation Pattern
// DTOs use ONLY json tags, NO binding tags
type Request struct {
Email string `json:"email"` // NO binding:"required,email"
}
// Validation via custom method
func (r *Request) Validate(lang constants.Lang) map[string][]string {
errors := make(map[string][]string)
if r.Email == "" {
errors["email"] = append(errors["email"], "email is required")
}
return errors
}
Handler Pattern
// 1. Bind JSON
if err := c.ShouldBindJSON(&req); err != nil {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang),
map[string][]string{"body": {err.Error()}})
return
}
// 2. Custom validation
if errors := req.Validate(lang); len(errors) > 0 {
response.NewResponse(c, status, nil, message, errors)
return
}
// 3. Call use case with context and use status from usecase
result, status, err := h.usecase.Create(c.Request.Context(), req)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
response.NewResponse(c, status, result, "success", nil)
Endpoint Checklist
- DTO with json tags only (no binding tags)
- Custom Validate(lang) method returning map[string][]string
- Handler binds JSON, then validates
- Handler passes c.Request.Context() to use case
- Use case returns (data, int, error) with HTTP status
- JWT claims type is *jwt.Claims with claims.UserID field
- Swagger comments complete
- Route registered in module.go
Creating an Endpoint
1. Define DTOs with Custom Validation
Create request/response structures in internal/features/{feature}/delivery/http/dto/:
package dto
import (
"app/internal/shared/constants"
"app/internal/shared/domain/entity"
"fmt"
)
// CreateProductRequest - NO binding tags, only json!
type CreateProductRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
CategoryID string `json:"category_id"`
}
// Custom validation method
func (r *CreateProductRequest) Validate(lang constants.Lang) map[string][]string {
errors := make(map[string][]string)
// Required field validation
if r.Name == "" {
errors["name"] = append(errors["name"],
fmt.Sprintf(constants.GetValidationMessage(constants.Required, lang), "name"))
}
// Min/Max length validation
if len(r.Name) < 3 {
errors["name"] = append(errors["name"], "name must be at least 3 characters")
}
if len(r.Name) > 255 {
errors["name"] = append(errors["name"], "name must be at most 255 characters")
}
// Numeric validation
if r.Price <= 0 {
errors["price"] = append(errors["price"], "price must be greater than 0")
}
// UUID validation (you can use validation helper if available)
if r.CategoryID == "" {
errors["category_id"] = append(errors["category_id"],
fmt.Sprintf(constants.GetValidationMessage(constants.Required, lang), "category_id"))
}
return errors
}
// UpdateProductRequest
type UpdateProductRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
}
func (r *UpdateProductRequest) Validate(lang constants.Lang) map[string][]string {
errors := make(map[string][]string)
if r.Name == "" {
errors["name"] = append(errors["name"],
fmt.Sprintf(constants.GetValidationMessage(constants.Required, lang), "name"))
}
if r.Price <= 0 {
errors["price"] = append(errors["price"], "price must be greater than 0")
}
return errors
}
// ProductResponse
type ProductResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
CategoryID string `json:"category_id"`
CreatedAt time.Time `json:"created_at"` // time.Time, not string!
}
// Mapper function
func ToProductResponse(product *entity.Product) *ProductResponse {
return &ProductResponse{
ID: product.ID,
Name: product.Name,
Description: product.Description,
Price: product.Price,
CategoryID: product.CategoryID,
CreatedAt: product.CreatedAt, // Direct assignment, no .Format()
}
}
// List response with pagination
type ProductListResponse struct {
Products []*ProductResponse `json:"products"`
Pagination *pkg.PaginationResponse `json:"pagination"`
}
IMPORTANT:
- ✅ Only
jsontags (NObindingtags!) - ✅ Custom
Validate(lang constants.Lang) map[string][]stringmethod - ✅ Mapper functions to convert entities to responses
2. Create Handler Method
Add handler in internal/features/{feature}/delivery/http/handler/{feature}_handler.go:
// CreateProduct godoc
// @Summary Create a new product
// @Description Create a new product with the provided details
// @Tags products
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateProductRequest true "Product details"
// @Success 201 {object} response.Response{data=dto.ProductResponse} "Product created"
// @Failure 400 {object} response.Response "Invalid input"
// @Failure 401 {object} response.Response "Unauthorized"
// @Failure 500 {object} response.Response "Internal server error"
// @Router /api/v1/products [post]
func (h *ProductHandler) CreateProduct(c *gin.Context) {
lang := middleware.GetLangFromGin(c)
// Get authenticated user from JWT claims
claimsVal, exists := c.Get("sess")
if !exists {
response.NewResponse(c, http.StatusUnauthorized, nil,
constants.GetErrorMessage(constants.Unauthorized, lang), nil)
return
}
claims, ok := claimsVal.(*jwt.Claims) // Type: *jwt.Claims (not *jwt.JWTClaims!)
if !ok {
response.NewResponse(c, http.StatusUnauthorized, nil,
constants.GetErrorMessage(constants.Unauthorized, lang), nil)
return
}
// 1. Bind JSON first
var req dto.CreateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang),
map[string][]string{"body": {err.Error()}}) // Structured error!
return
}
// 2. Then custom validation
if errors := req.Validate(lang); len(errors) > 0 {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang), errors)
return
}
// 3. Call use case with context - returns (data, status, error)
product, status, err := h.productUsecase.Create(c.Request.Context(), claims.UserID, req)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
response.NewResponse(c, status, product, "Product created successfully", nil)
}
CRITICAL HANDLER PATTERNS:
- ✅ Get lang from Gin:
middleware.GetLangFromGin(c) - ✅ Claims type:
*jwt.Claimswith fieldclaims.UserID(notclaims.ID) - ✅ Bind JSON first:
c.ShouldBindJSON(&req) - ✅ Then validate:
req.Validate(lang) - ✅ Errors as map:
map[string][]string{"body": {err.Error()}} - ✅ Pass context:
h.usecase.Create(c.Request.Context(), ...) - ✅ Get status from usecase:
product, status, err := ...
Swagger Annotations
Handler Comments Format
// MethodName godoc
// @Summary Short description
// @Description Detailed description
// @Tags tag-name
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param name location type required "description"
// @Success code {object} response.Response{data=dto.ResponseType} "description"
// @Failure code {object} response.Response "description"
// @Router /api/v1/path [method]
Common Annotations
Parameters:
// Path parameter
// @Param id path string true "Product ID"
// Query parameter
// @Param page query int false "Page number" default(1)
// @Param per_page query int false "Items per page" default(10)
// Body parameter
// @Param request body dto.CreateProductRequest true "Request body"
Success Response with Data:
// @Success 200 {object} response.Response{data=dto.ProductResponse} "Product retrieved"
// @Success 200 {object} response.Response{data=dto.ProductListResponse} "Product list"
Common Endpoint Patterns
GET - Single Item
// GetProduct godoc
// @Summary Get product by ID
// @Description Retrieve a single product by its ID
// @Tags products
// @Produce json
// @Security BearerAuth
// @Param id path string true "Product ID"
// @Success 200 {object} response.Response{data=dto.ProductResponse}
// @Failure 404 {object} response.Response "Product not found"
// @Router /api/v1/products/{id} [get]
func (h *ProductHandler) GetProduct(c *gin.Context) {
id := c.Param("id")
// Call use case with context - returns (data, status, error)
product, status, err := h.productUsecase.GetByID(c.Request.Context(), id)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
response.NewResponse(c, status, product, "Product retrieved successfully", nil)
}
GET - List with Pagination
// GetUsers godoc
// @Summary Get users list
// @Description Get a paginated and filtered list of users
// @Tags users
// @Produce json
// @Security BearerAuth
// @Param per_page query int false "Items per page" default(10)
// @Param page query int false "Page number" default(1)
// @Param email query string false "Filter by email"
// @Success 200 {object} response.Response{data=dto.UserListResponse}
// @Router /api/v1/users [get]
func (h *UserHandler) GetUsers(c *gin.Context) {
// Build queries map from query parameters
queries := map[string]string{}
err := c.BindQuery(&queries)
if err != nil {
lang := middleware.GetLangFromGin(c)
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang),
map[string][]string{"query": {err.Error()}})
return
}
// Call use case - returns (data, pagination, status, error)
users, pagination, status, err := h.userUsecase.GetUsers(c.Request.Context(), queries)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
// Build response with DTO
responseData := dto.UserListResponse{
Users: users,
Pagination: pagination,
}
response.NewResponse(c, status, responseData, "Users retrieved successfully", nil)
}
POST - Create
// CreateProduct godoc
// @Summary Create product
// @Description Create a new product
// @Tags products
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateProductRequest true "Product details"
// @Success 201 {object} response.Response{data=dto.ProductResponse}
// @Failure 400 {object} response.Response
// @Router /api/v1/products [post]
func (h *ProductHandler) CreateProduct(c *gin.Context) {
lang := middleware.GetLangFromGin(c)
claimsVal, exists := c.Get("sess")
if !exists {
response.NewResponse(c, http.StatusUnauthorized, nil,
constants.GetErrorMessage(constants.Unauthorized, lang), nil)
return
}
claims, ok := claimsVal.(*jwt.Claims)
if !ok {
response.NewResponse(c, http.StatusUnauthorized, nil,
constants.GetErrorMessage(constants.Unauthorized, lang), nil)
return
}
var req dto.CreateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang),
map[string][]string{"body": {err.Error()}})
return
}
if errors := req.Validate(lang); len(errors) > 0 {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang), errors)
return
}
product, status, err := h.productUsecase.Create(c.Request.Context(), claims.UserID, req)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
response.NewResponse(c, status, product, "Product created successfully", nil)
}
PUT - Update
// UpdateProduct godoc
// @Summary Update product
// @Description Update an existing product
// @Tags products
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Product ID"
// @Param request body dto.UpdateProductRequest true "Updated product details"
// @Success 200 {object} response.Response{data=dto.ProductResponse}
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Router /api/v1/products/{id} [put]
func (h *ProductHandler) UpdateProduct(c *gin.Context) {
lang := middleware.GetLangFromGin(c)
id := c.Param("id")
var req dto.UpdateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang),
map[string][]string{"body": {err.Error()}})
return
}
if errors := req.Validate(lang); len(errors) > 0 {
response.NewResponse(c, http.StatusBadRequest, nil,
constants.GetErrorMessage(constants.ValidationFailed, lang), errors)
return
}
product, status, err := h.productUsecase.Update(c.Request.Context(), id, req)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
response.NewResponse(c, status, product, "Product updated successfully", nil)
}
DELETE
// DeleteProduct godoc
// @Summary Delete product
// @Description Delete a product by ID
// @Tags products
// @Produce json
// @Security BearerAuth
// @Param id path string true "Product ID"
// @Success 200 {object} response.Response
// @Failure 404 {object} response.Response
// @Router /api/v1/products/{id} [delete]
func (h *ProductHandler) DeleteProduct(c *gin.Context) {
id := c.Param("id")
status, err := h.productUsecase.Delete(c.Request.Context(), id)
if err != nil {
response.NewResponse(c, status, nil, err.Error(), nil)
return
}
response.NewResponse(c, status, nil, "Product deleted successfully", nil)
}
Custom Validation Examples
Email Validation
func (r *Request) Validate(lang constants.Lang) map[string][]string {
errors := make(map[string][]string)
if r.Email == "" {
errors["email"] = append(errors["email"],
fmt.Sprintf(constants.GetValidationMessage(constants.Required, lang), "email"))
} else if !constants.IsValidEmail(r.Email) {
errors["email"] = append(errors["email"],
constants.GetValidationMessage(constants.InvalidEmail, lang))
}
return errors
}
String Length Validation
if r.Username == "" {
errors["username"] = append(errors["username"],
fmt.Sprintf(constants.GetValidationMessage(constants.Required, lang), "username"))
} else {
if !constants.MinLength(r.Username, 3) {
errors["username"] = append(errors["username"],
fmt.Sprintf(constants.GetValidationMessage(constants.UsernameTooShort, lang), 3))
}
if !constants.MaxLength(r.Username, 20) {
errors["username"] = append(errors["username"],
fmt.Sprintf(constants.GetValidationMessage(constants.UsernameTooLong, lang), 20))
}
}
Numeric Validation
if r.Price <= 0 {
errors["price"] = append(errors["price"], "price must be greater than 0")
}
if r.Quantity < 1 || r.Quantity > 1000 {
errors["quantity"] = append(errors["quantity"], "quantity must be between 1 and 1000")
}
Register Routes in Module
Add routes in module.go:
func (m *Module) RegisterRoutes(rg *gin.RouterGroup) {
// Public routes (no auth required)
productsPublic := rg.Group("/products")
{
productsPublic.GET("", m.handler.ListProducts)
productsPublic.GET("/:id", m.handler.GetProduct)
}
// Protected routes (auth required)
productsProtected := rg.Group("/products")
productsProtected.Use(middleware.AuthMiddleware())
{
productsProtected.POST("", m.handler.CreateProduct)
productsProtected.PUT("/:id", m.handler.UpdateProduct)
productsProtected.DELETE("/:id", m.handler.DeleteProduct)
}
}
Regenerate Swagger Docs
After adding or modifying endpoints:
make swag
This updates docs/docs.go, docs/swagger.json, and docs/swagger.yaml.
Access documentation at: http://localhost:8080/swagger/index.html
Testing Endpoints
Using curl
# Login first
TOKEN=$(curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password"}' \
| jq -r '.data.token')
# Use token in requests
curl -X GET http://localhost:8080/api/v1/products \
-H "Authorization: Bearer $TOKEN"
# Create resource
curl -X POST http://localhost:8080/api/v1/products \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Product","price":99.99}'
Error Handling Best Practices
-
Use language-specific errors from use case:
// Use case returns error with language support product, status, err := h.usecase.Create(c.Request.Context(), req) if err != nil { response.NewResponse(c, status, nil, err.Error(), nil) return } -
HTTP status codes from use case:
- Use case returns status code
- Handler uses it directly in response
-
Structured validation errors:
// Bind errors map[string][]string{"body": {err.Error()}} // Custom validation errors map[string][]string{ "email": {"email is required", "invalid email format"}, "price": {"price must be greater than 0"}, } -
Unified response format:
response.NewResponse(c, statusCode, data, message, errors)
Quick Reference
Critical Patterns:
- ✅ DTOs with json tags ONLY (no binding tags)
- ✅ Custom Validate(lang) method returning map[string][]string
- ✅ Handler: Bind → Validate → Call use case with context
- ✅ Use case returns (data, int, error) with HTTP status
- ✅ JWT claims:
*jwt.Claimstype withclaims.UserIDfield - ✅ Pass context:
h.usecase.Method(c.Request.Context(), ...) - ✅ Structured errors:
map[string][]string