From 3b1b05d5a680d8f47c0842b6ba52d6af663c814b Mon Sep 17 00:00:00 2001 From: Gabriel De Los Rios Date: Sun, 2 Nov 2025 22:03:10 -0300 Subject: [PATCH] feat: impl requests handllers for contacts and healthcheck --- internal/handler/base_handler.go | 47 ++++++++++ internal/handler/contact_handler.go | 135 ++++++++++++++++++++++++++++ internal/handler/errors.go | 88 ++++++++++++++++++ internal/handler/health_handler.go | 16 ++++ internal/handler/response.go | 56 ++++++++++++ 5 files changed, 342 insertions(+) create mode 100644 internal/handler/base_handler.go create mode 100644 internal/handler/contact_handler.go create mode 100644 internal/handler/errors.go create mode 100644 internal/handler/health_handler.go create mode 100644 internal/handler/response.go diff --git a/internal/handler/base_handler.go b/internal/handler/base_handler.go new file mode 100644 index 0000000..6f4e9ec --- /dev/null +++ b/internal/handler/base_handler.go @@ -0,0 +1,47 @@ +package handler + +import ( + "net/http" + "sync" +) + +type Route struct { + pattern string + handler func(w http.ResponseWriter, r *http.Request) +} + +type baseHandler struct { + mux *http.ServeMux + registeredRoutes map[string]bool + Routes []Route +} + +var ( + bh *baseHandler + bhOnce sync.Once + bhMu sync.RWMutex +) + +func NewBaseHandler(mux *http.ServeMux, routes []Route) *baseHandler { + bhOnce.Do(func() { + bh = &baseHandler{ + mux: mux, + registeredRoutes: make(map[string]bool), + Routes: routes, + } + }) + + bhMu.Lock() + defer bhMu.Unlock() + bh.registerRoutes(routes) + return bh +} + +func (b *baseHandler) registerRoutes(routes []Route) { + for _, route := range routes { + if !bh.registeredRoutes[route.pattern] { + b.mux.HandleFunc(route.pattern, route.handler) + bh.registeredRoutes[route.pattern] = true + } + } +} diff --git a/internal/handler/contact_handler.go b/internal/handler/contact_handler.go new file mode 100644 index 0000000..8e941d0 --- /dev/null +++ b/internal/handler/contact_handler.go @@ -0,0 +1,135 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "gitea.gabilandia.com/gabdlr/agenda-web-go/internal/models" + "gitea.gabilandia.com/gabdlr/agenda-web-go/internal/repository" +) + +type ContactHandler struct { + repository repository.Repository[models.Contact] +} + +const required_field = "is required" +const invalid_id = "Invalid ID" + +func HandleContacts(mux *http.ServeMux, repo repository.Repository[models.Contact]) { + ch := &ContactHandler{repository: repo} + routes := []Route{ + {"GET /contacts", ch.getAll}, + {"GET /contacts/{id}", ch.getByID}, + {"POST /contacts", ch.create}, + {"PUT /contacts/{id}", ch.update}, + {"DELETE /contacts/{id}", ch.delete}, + } + NewBaseHandler(mux, routes) +} + +func (h *ContactHandler) getAll(w http.ResponseWriter, r *http.Request) { + contacts, err := h.repository.GetAll() + if err != nil { + InternalError(w, err) + return + } + JSONSuccess(w, contacts, "Contact list retrieved successfully", http.StatusOK) +} + +func (h *ContactHandler) getByID(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + BadRequest(w, ErrInvalidFormat, invalid_id) + return + } + + contact, err := h.repository.GetByID(id) + if err != nil { + InternalError(w, err) + return + } + if contact == nil { + NotFound(w, ErrNotFound, "Contact not found") + return + } + JSONSuccess(w, contact, "Contact retrieved successfully", http.StatusOK) +} + +func (h *ContactHandler) create(w http.ResponseWriter, r *http.Request) { + + var contact models.Contact + if err := json.NewDecoder(r.Body).Decode(&contact); err != nil { + BadRequest(w, ErrInvalidJSON, "Error parsing the contact") + return + } + + if contact.Name == "" { + BadRequest(w, ErrMissingRequired, RequiredFieldErr("name")) + return + } + + if contact.Company == "" { + BadRequest(w, ErrMissingRequired, RequiredFieldErr("company")) + return + } + + if contact.Phone == "" { + BadRequest(w, ErrMissingRequired, RequiredFieldErr("phone")) + return + } + + id, err := h.repository.Create(&contact) + if err != nil { + InternalError(w, err) + return + } + + contact.ID = int(id) + JSONSuccess(w, contact, "Contact created successfully", http.StatusCreated) +} + +func (h *ContactHandler) update(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + BadRequest(w, ErrInvalidFormat, invalid_id) + return + } + + var contact models.Contact + if err := json.NewDecoder(r.Body).Decode(&contact); err != nil { + BadRequest(w, ErrInvalidJSON, "Error parsing the contact") + return + } + + contact.ID = id + + updateErr := h.repository.Update(&contact) + + if updateErr != nil { + InternalError(w, updateErr) + return + } + + JSONSuccess(w, nil, "Contact updated successfully", http.StatusOK) +} + +func (h *ContactHandler) delete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + + if err != nil { + BadRequest(w, ErrInvalidFormat, invalid_id) + return + } + rowsAffected, err := h.repository.Delete(id) + + if err != nil { + InternalError(w, err) + return + } + if rowsAffected == 0 { + BadRequest(w, ErrNotFound, "Contact not found") + return + } + JSONSuccess(w, nil, "Contact deleted successfully", http.StatusOK) +} diff --git a/internal/handler/errors.go b/internal/handler/errors.go new file mode 100644 index 0000000..d8b1902 --- /dev/null +++ b/internal/handler/errors.go @@ -0,0 +1,88 @@ +package handler + +import ( + "fmt" + "net/http" +) + +/* + Domain (First Digit) + + 1xxx - Request/Input Errors + + 2xxx - Authentication/Authorization + + 3xxx - Business Logic/Validation + + 4xxx - Data/Resource Errors + + 5xxx - System/External Service Errors + + Category (Second Digit) + + x0xx - General/Generic errors in that domain + + x1xx - Format/Structure errors + + x2xx - Validation/Constraint errors + + x3xx - State/Workflow errors + + x4xx - Permission/Access errors + + Specific Error (Last Two Digits) + + xx00-xx99 - Specific error instances +*/ + +// Domains +const ( + DomainRequest = 1000 + DomainAuth = 2000 + DomainData = 4000 + DomainSystem = 5000 +) + +const ( + CategoryGeneral = 0 + CategoryFormat = 100 + CategoryValidation = 200 + CategoryState = 300 + CategoryPermission = 400 +) + +// Err code Domain + Category + Specific +const ( + CodeInvalidJSON = DomainRequest + CategoryFormat + 1 + InvalidParamFormat = DomainRequest + CategoryFormat + 0 + CodeValidationFailed = DomainRequest + CategoryValidation + 0 + CodeMissingRequired = DomainRequest + CategoryValidation + 1 + CodeNotFound = DomainData + CategoryPermission + 0 + CodeInternalError = DomainSystem + CategoryGeneral + 0 +) + +var ( + ErrInvalidJSON = APIError{Code: CodeInvalidJSON, Message: "Invalid JSON in request body"} + ErrValidation = APIError{Code: CodeValidationFailed, Message: "Validation failed"} + ErrMissingRequired = APIError{Code: CodeMissingRequired, Message: "Required field is missing"} + ErrNotFound = APIError{Code: CodeNotFound, Message: "Resource not found"} + ErrInternalServer = APIError{Code: CodeInternalError, Message: "Internal server error"} + ErrInvalidFormat = APIError{Code: InvalidParamFormat, Message: "The given param doesn't match the format expectations"} +) + +// Helper functions for common errors +func BadRequest(w http.ResponseWriter, err APIError, details string) { + JSONError(w, err.Message, details, err.Code, http.StatusBadRequest) +} + +func NotFound(w http.ResponseWriter, err APIError, details string) { + JSONError(w, err.Message, details, err.Code, http.StatusNotFound) +} + +func InternalError(w http.ResponseWriter, err error) { + JSONError(w, ErrInternalServer.Message, err.Error(), ErrInternalServer.Code, http.StatusInternalServerError) +} + +func RequiredFieldErr(field string) string { + return (fmt.Sprintf("%s %s", field, required_field)) +} diff --git a/internal/handler/health_handler.go b/internal/handler/health_handler.go new file mode 100644 index 0000000..dc37210 --- /dev/null +++ b/internal/handler/health_handler.go @@ -0,0 +1,16 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +func HandlHealthChecks(mux *http.ServeMux) { + routes := []Route{{"GET /health", healthCheck}} + NewBaseHandler(mux, routes) +} + +func healthCheck(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/internal/handler/response.go b/internal/handler/response.go new file mode 100644 index 0000000..6f63c9c --- /dev/null +++ b/internal/handler/response.go @@ -0,0 +1,56 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +type APIResponse struct { + Success bool `json:"success"` + Data any `json:"data,omitempty"` + Errors []APIError `json:"errors,omitempty"` + Message string `json:"message,omitempty"` +} + +type APIError struct { + Code int `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} + +const content_type = "Content-Type" +const application_json = "application/json" + +func JSONSuccess(w http.ResponseWriter, data any, message string, statusCode int) { + w.Header().Set(content_type, application_json) + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(APIResponse{ + Success: true, + Data: data, + Message: message, + }) +} + +func JSONError(w http.ResponseWriter, message, details string, code, statusCode int) { + w.Header().Set(content_type, application_json) + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(APIResponse{ + Success: false, + Errors: []APIError{ + { + Code: code, + Message: message, + Details: details, + }, + }, + }) +} + +func JSONErrors(w http.ResponseWriter, errors []APIError, statusCode int) { + w.Header().Set(content_type, application_json) + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(APIResponse{ + Success: false, + Errors: errors, + }) +}