diff --git a/examples/10-production-boilerplate/README.md b/examples/10-production-boilerplate/README.md index f245ae1..191f3d4 100644 --- a/examples/10-production-boilerplate/README.md +++ b/examples/10-production-boilerplate/README.md @@ -63,12 +63,13 @@ This is a **reference implementation** of a production-ready REST API showcasing │ │ ├── entity.go # User entity (RICH MODEL) │ │ ├── repository.go # Data access interface + impl │ │ ├── service.go # Business logic / use cases -│ │ ├── api.go # HTTP handlers -│ │ └── validation.go # Request validation +│ │ ├── api.go # HTTP handlers (generic Box[Req, Res]) +│ │ └── dto.go # Request/response DTOs (validate tags) │ │ │ ├── notification/ # Notification bounded context │ │ ├── entity.go │ │ ├── service.go +│ │ ├── dto.go # Request/response DTOs │ │ └── api.go # SSE handler │ │ │ ├── chat/ # Chat bounded context diff --git a/examples/10-production-boilerplate/cmd/api/main.go b/examples/10-production-boilerplate/cmd/api/main.go index 7b59865..1f714f6 100644 --- a/examples/10-production-boilerplate/cmd/api/main.go +++ b/examples/10-production-boilerplate/cmd/api/main.go @@ -27,9 +27,9 @@ import ( "example.com/production-boilerplate/internal/shared/auth" "example.com/production-boilerplate/internal/shared/database" "example.com/production-boilerplate/internal/user" - "github.com/coregx/fursy" "github.com/coregx/fursy/middleware" + "github.com/coregx/fursy/plugins/validator" "github.com/coregx/stream/sse" "github.com/coregx/stream/websocket" ) @@ -80,6 +80,7 @@ func main() { // Create fursy router router := fursy.New() + router.SetValidator(validator.New()) // Global middleware router.Use(middleware.Logger()) @@ -96,12 +97,12 @@ func main() { // API documentation endpoint router.Handle("GET", "/", func(c *fursy.Context) error { - return c.JSON(http.StatusOK, map[string]interface{}{ + return c.JSON(http.StatusOK, map[string]any{ "name": "Production Boilerplate API", "version": "1.0.0", "docs": "/api/docs", "health": "/health", - "endpoints": map[string]interface{}{ + "endpoints": map[string]any{ "auth": map[string]string{ "register": "POST /api/auth/register", "login": "POST /api/auth/login", diff --git a/examples/10-production-boilerplate/go.mod b/examples/10-production-boilerplate/go.mod index 6cf2783..f505c70 100644 --- a/examples/10-production-boilerplate/go.mod +++ b/examples/10-production-boilerplate/go.mod @@ -4,20 +4,27 @@ go 1.27 require ( github.com/coregx/fursy v0.7.0 + github.com/coregx/fursy/plugins/validator v0.0.0 github.com/coregx/stream v0.1.4 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - golang.org/x/crypto v0.32.0 + golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.40.1 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.4 // indirect + github.com/leodido/go-urn v1.5.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.16.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect @@ -27,3 +34,5 @@ require ( replace github.com/coregx/fursy => ../.. replace github.com/coregx/fursy/plugins/database => ../../plugins/database + +replace github.com/coregx/fursy/plugins/validator => ../../plugins/validator diff --git a/examples/10-production-boilerplate/go.sum b/examples/10-production-boilerplate/go.sum index 6d59cdf..2495f40 100644 --- a/examples/10-production-boilerplate/go.sum +++ b/examples/10-production-boilerplate/go.sum @@ -1,34 +1,56 @@ github.com/coregx/stream v0.1.4 h1:MQE88pkvuFBlFkrtogk1X39CZJG8oE+avkx3+Y5GJq4= github.com/coregx/stream v0.1.4/go.mod h1:Nv8tdDu8yQ8jK7ctayRkXdmk3TMX1sS8En2qg0Izz7A= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= +github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= diff --git a/examples/10-production-boilerplate/internal/chat/api.go b/examples/10-production-boilerplate/internal/chat/api.go index 9301254..8eaad57 100644 --- a/examples/10-production-boilerplate/internal/chat/api.go +++ b/examples/10-production-boilerplate/internal/chat/api.go @@ -27,12 +27,12 @@ func (api *API) RegisterRoutes(r *fursy.Router, authMiddleware fursy.HandlerFunc protected := r.Group("/api") protected.Use(authMiddleware) { - protected.Handle("GET", "/chat/ws", api.websocket) + protected.GET("/chat/ws", api.websocket) } } // websocket handles WebSocket chat connection. -func (api *API) websocket(c *fursy.Context) error { +func (api *API) websocket(c *fursy.Box[fursy.Empty, fursy.Empty]) error { // Get user info from context userID := auth.GetUserID(c.Request.Context()) diff --git a/examples/10-production-boilerplate/internal/notification/api.go b/examples/10-production-boilerplate/internal/notification/api.go index 5794872..fc041a9 100644 --- a/examples/10-production-boilerplate/internal/notification/api.go +++ b/examples/10-production-boilerplate/internal/notification/api.go @@ -1,9 +1,6 @@ package notification import ( - "encoding/json" - "net/http" - "github.com/coregx/fursy" "github.com/coregx/stream/sse" ) @@ -27,13 +24,13 @@ func (api *API) RegisterRoutes(r *fursy.Router, authMiddleware fursy.HandlerFunc protected := r.Group("/api") protected.Use(authMiddleware) { - protected.Handle("GET", "/notifications/stream", api.stream) - protected.Handle("POST", "/notifications/broadcast", api.broadcast) + protected.GET("/notifications/stream", api.stream) + protected.POST("/notifications/broadcast", api.broadcast) } } // stream handles SSE notification stream. -func (api *API) stream(c *fursy.Context) error { +func (api *API) stream(c *fursy.Box[fursy.Empty, fursy.Empty]) error { // Upgrade to SSE connection conn, err := sse.Upgrade(c.Response, c.Request) if err != nil { @@ -50,29 +47,10 @@ func (api *API) stream(c *fursy.Context) error { } // broadcast broadcasts notification to all connected clients (admin only). -func (api *API) broadcast(c *fursy.Context) error { - var req struct { - Message string `json:"message"` - Type NotificationType `json:"type"` - } - - if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { - return c.Problem(fursy.BadRequest("Invalid request: " + err.Error())) - } - - if req.Message == "" { - return c.Problem(fursy.BadRequest("Message is required")) - } - - if req.Type == "" { - req.Type = NotificationTypeInfo - } - - if err := api.service.Broadcast(req.Message, req.Type); err != nil { +func (api *API) broadcast(c *fursy.Box[BroadcastRequest, MessageResponse]) error { + if err := api.service.Broadcast(c.ReqBody.Message, c.ReqBody.Type); err != nil { return c.Problem(fursy.InternalServerError("Broadcast failed: " + err.Error())) } - return c.JSON(http.StatusOK, map[string]string{ - "message": "Notification broadcasted successfully", - }) + return c.OK(MessageResponse{Message: "Notification broadcasted successfully"}) } diff --git a/examples/10-production-boilerplate/internal/notification/dto.go b/examples/10-production-boilerplate/internal/notification/dto.go new file mode 100644 index 0000000..0f6f0ac --- /dev/null +++ b/examples/10-production-boilerplate/internal/notification/dto.go @@ -0,0 +1,12 @@ +package notification + +// BroadcastRequest is the payload for broadcasting a notification. +type BroadcastRequest struct { + Message string `json:"message" validate:"required"` + Type NotificationType `json:"type"` +} + +// MessageResponse is a simple message response. +type MessageResponse struct { + Message string `json:"message"` +} diff --git a/examples/10-production-boilerplate/internal/notification/entity.go b/examples/10-production-boilerplate/internal/notification/entity.go index d2dda63..70fda6e 100644 --- a/examples/10-production-boilerplate/internal/notification/entity.go +++ b/examples/10-production-boilerplate/internal/notification/entity.go @@ -24,6 +24,10 @@ const ( // NewNotification creates a new notification. func NewNotification(userID, message string, notifType NotificationType) *Notification { + if notifType == "" { + notifType = NotificationTypeInfo // default value + } + return &Notification{ ID: generateID(), UserID: userID, diff --git a/examples/10-production-boilerplate/internal/user/api.go b/examples/10-production-boilerplate/internal/user/api.go index d38e32e..92f40eb 100644 --- a/examples/10-production-boilerplate/internal/user/api.go +++ b/examples/10-production-boilerplate/internal/user/api.go @@ -1,8 +1,6 @@ package user import ( - "encoding/json" - "net/http" "strconv" "example.com/production-boilerplate/internal/shared/auth" @@ -22,114 +20,88 @@ func NewAPI(service Service) *API { // RegisterRoutes registers user routes. func (api *API) RegisterRoutes(r *fursy.Router, authMiddleware fursy.HandlerFunc) { // Public routes - r.Handle("POST", "/api/auth/register", api.register) - r.Handle("POST", "/api/auth/login", api.login) + authGroup := r.Group("/api/auth") + authGroup.POST("/login", api.login) + authGroup.POST("/register", api.register) // Protected routes protected := r.Group("/api") protected.Use(authMiddleware) { - protected.Handle("GET", "/users/me", api.getProfile) - protected.Handle("PUT", "/users/me", api.updateProfile) - protected.Handle("POST", "/users/me/password", api.changePassword) + protected.GET("/users/me", api.getProfile) + protected.PUT("/users/me", api.updateProfile) + protected.POST("/users/me/password", api.changePassword) // Admin routes (with role middleware) admin := protected.Group("/users") admin.Use(auth.RequireRole("admin")) { - admin.Handle("GET", "", api.listUsers) - admin.Handle("POST", "/:id/ban", api.banUser) - admin.Handle("POST", "/:id/promote", api.promoteToAdmin) + admin.GET("", api.listUsers) + admin.POST("/:id/ban", api.banUser) + admin.POST("/:id/promote", api.promoteToAdmin) } } } // register handles user registration. -func (api *API) register(c *fursy.Context) error { - var req RegisterRequest - if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { - return c.Problem(fursy.BadRequest("Invalid Request: " + err.Error())) - } - - user, err := api.service.Register(c.Request.Context(), req) +func (api *API) register(c *fursy.Box[RegisterRequest, UserResponse]) error { + user, err := api.service.Register(c.Request.Context(), *c.ReqBody) if err != nil { - return MapError(c, err) + return MapError(c.Context, err) } - resp := ToResponse(user) - return c.JSON(http.StatusCreated, resp) + return c.Context.Created(ToResponse(user)) } // login handles user login. -func (api *API) login(c *fursy.Context) error { - var req LoginRequest - if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { - return c.Problem(fursy.BadRequest("Invalid Request: " + err.Error())) - } - - token, err := api.service.Login(c.Request.Context(), req) +func (api *API) login(c *fursy.Box[LoginRequest, LoginResponse]) error { + token, err := api.service.Login(c.Request.Context(), *c.ReqBody) if err != nil { - return MapError(c, err) + return MapError(c.Context, err) } - return c.JSON(http.StatusOK, map[string]string{ - "token": token, - }) + return c.OK(LoginResponse{Token: token}) } // getProfile retrieves current user profile. -func (api *API) getProfile(c *fursy.Context) error { +func (api *API) getProfile(c *fursy.Box[fursy.Empty, UserResponse]) error { // Get user ID from JWT claims (set by auth middleware) userID := auth.GetUserID(c.Request.Context()) user, err := api.service.GetProfile(c.Request.Context(), userID) if err != nil { - return MapError(c, err) + return MapError(c.Context, err) } - resp := ToResponse(user) - return c.JSON(http.StatusOK, resp) + return c.OK(ToResponse(user)) } // updateProfile updates current user profile. -func (api *API) updateProfile(c *fursy.Context) error { +func (api *API) updateProfile(c *fursy.Box[UpdateProfileRequest, UserResponse]) error { userID := auth.GetUserID(c.Request.Context()) - var req UpdateProfileRequest - if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { - return c.Problem(fursy.BadRequest("Invalid Request: " + err.Error())) - } - - user, err := api.service.UpdateProfile(c.Request.Context(), userID, req) + user, err := api.service.UpdateProfile(c.Request.Context(), userID, *c.ReqBody) if err != nil { - return MapError(c, err) + return MapError(c.Context, err) } - resp := ToResponse(user) - return c.JSON(http.StatusOK, resp) + return c.OK(ToResponse(user)) } // changePassword changes current user password. -func (api *API) changePassword(c *fursy.Context) error { +func (api *API) changePassword(c *fursy.Box[ChangePasswordRequest, MessageResponse]) error { userID := auth.GetUserID(c.Request.Context()) - var req ChangePasswordRequest - if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { - return c.Problem(fursy.BadRequest("Invalid Request: " + err.Error())) - } - - err := api.service.ChangePassword(c.Request.Context(), userID, req) + err := api.service.ChangePassword(c.Request.Context(), userID, *c.ReqBody) if err != nil { - return MapError(c, err) + return MapError(c.Context, err) } - return c.JSON(http.StatusOK, map[string]string{ - "message": "Password changed successfully", - }) + return c.OK(MessageResponse{Message: "Password changed successfully"}) } // listUsers lists all users (admin only). -func (api *API) listUsers(c *fursy.Context) error { +func (api *API) listUsers(c *fursy.Box[fursy.Empty, ListUsersResponse]) error { // Parse pagination from query params offset, _ := strconv.Atoi(c.Query("offset")) if offset == 0 { @@ -142,7 +114,7 @@ func (api *API) listUsers(c *fursy.Context) error { users, total, err := api.service.ListUsers(c.Request.Context(), offset, limit) if err != nil { - return MapError(c, err) + return MapError(c.Context, err) } // Convert to response DTOs @@ -151,38 +123,29 @@ func (api *API) listUsers(c *fursy.Context) error { resp[i] = ToResponse(user) } - return c.JSON(http.StatusOK, map[string]interface{}{ - "users": resp, - "total": total, - "offset": offset, - "limit": limit, - }) + return c.OK(ListUsersResponse{Limit: limit, Offset: offset, Total: total, Users: resp}) } // banUser bans a user (admin only). -func (api *API) banUser(c *fursy.Context) error { +func (api *API) banUser(c *fursy.Box[fursy.Empty, MessageResponse]) error { userID := c.Param("id") err := api.service.BanUser(c.Request.Context(), userID) if err != nil { - return MapError(c, err) + return MapError(c.Context, err) } - return c.JSON(http.StatusOK, map[string]string{ - "message": "User banned successfully", - }) + return c.OK(MessageResponse{Message: "User banned successfully"}) } // promoteToAdmin promotes a user to admin (admin only). -func (api *API) promoteToAdmin(c *fursy.Context) error { +func (api *API) promoteToAdmin(c *fursy.Box[fursy.Empty, MessageResponse]) error { userID := c.Param("id") err := api.service.PromoteToAdmin(c.Request.Context(), userID) if err != nil { - return MapError(c, err) + return MapError(c.Context, err) } - return c.JSON(http.StatusOK, map[string]string{ - "message": "User promoted to admin successfully", - }) + return c.OK(MessageResponse{Message: "User promoted to admin successfully"}) } diff --git a/examples/10-production-boilerplate/internal/user/api_test.go b/examples/10-production-boilerplate/internal/user/api_test.go index 08caaed..76d8aa8 100644 --- a/examples/10-production-boilerplate/internal/user/api_test.go +++ b/examples/10-production-boilerplate/internal/user/api_test.go @@ -6,11 +6,13 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" "example.com/production-boilerplate/internal/shared/auth" "github.com/coregx/fursy" + "github.com/coregx/fursy/plugins/validator" ) // mockService is a mock implementation of Service for API testing. @@ -84,6 +86,7 @@ func (m *mockService) PromoteToAdmin(ctx context.Context, userID string) error { // setupTestRouter creates a router with basic test routes (skip admin to avoid route conflicts in tests). func setupTestRouter(api *API) *fursy.Router { router := fursy.New() + router.SetValidator(validator.New()) jwtSvc := auth.NewJWTService([]byte("test-secret"), 24*time.Hour) // Auth middleware @@ -113,16 +116,16 @@ func setupTestRouter(api *API) *fursy.Router { } // Register only public and basic protected routes (skip admin routes due to route conflicts) - router.Handle("POST", "/api/auth/register", api.register) - router.Handle("POST", "/api/auth/login", api.login) + router.POST("/api/auth/register", api.register) + router.POST("/api/auth/login", api.login) // Protected routes protected := router.Group("/api") protected.Use(authMiddleware) { - protected.Handle("GET", "/users/me", api.getProfile) - protected.Handle("PUT", "/users/me", api.updateProfile) - protected.Handle("POST", "/users/me/password", api.changePassword) + protected.GET("/users/me", api.getProfile) + protected.PUT("/users/me", api.updateProfile) + protected.POST("/users/me/password", api.changePassword) } return router @@ -133,6 +136,7 @@ func TestAPI_Register(t *testing.T) { tests := []struct { name string payload interface{} + contentType string // defaults to application/json mockFunc func(ctx context.Context, req RegisterRequest) (*User, error) wantStatusCode int checkBody func(t *testing.T, body string) @@ -181,8 +185,50 @@ func TestAPI_Register(t *testing.T) { payload: "invalid json", wantStatusCode: http.StatusBadRequest, checkBody: func(t *testing.T, body string) { - if !contains(body, "Invalid Request") { - t.Errorf("Response should contain 'Invalid Request', got: %s", body) + if !contains(body, "Bad Request") { + t.Errorf("Response should contain 'Bad Request', got: %s", body) + } + }, + }, + { + name: "missing email", + payload: map[string]string{ + "password": "SecurePass123", + "name": "User", + }, + wantStatusCode: http.StatusUnprocessableEntity, + checkBody: func(t *testing.T, body string) { + if !contains(body, "Validation Failed") || !contains(body, `"Email"`) { + t.Errorf("Response should report Email validation error, got: %s", body) + } + }, + }, + { + name: "password longer than 72 characters", + payload: map[string]string{ + "email": "user@example.com", + "password": strings.Repeat("a", 73), + "name": "User", + }, + wantStatusCode: http.StatusUnprocessableEntity, + checkBody: func(t *testing.T, body string) { + if !contains(body, "Validation Failed") || !contains(body, `"Password"`) { + t.Errorf("Response should report Password validation error, got: %s", body) + } + }, + }, + { + name: "unsupported content type", + payload: map[string]string{ + "email": "user@example.com", + "password": "SecurePass123", + "name": "User", + }, + contentType: "text/plain", + wantStatusCode: http.StatusUnsupportedMediaType, + checkBody: func(t *testing.T, body string) { + if !contains(body, "Unsupported Media Type") { + t.Errorf("Response should contain 'Unsupported Media Type', got: %s", body) } }, }, @@ -204,8 +250,13 @@ func TestAPI_Register(t *testing.T) { body, _ = json.Marshal(tt.payload) } + contentType := tt.contentType + if contentType == "" { + contentType = "application/json" + } + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", contentType) w := httptest.NewRecorder() // Execute @@ -216,6 +267,11 @@ func TestAPI_Register(t *testing.T) { t.Errorf("Status code = %d, want %d", w.Code, tt.wantStatusCode) } + // Register must not advertise a Location (no GET /api/users/:id route). + if loc := w.Header().Get("Location"); loc != "" { + t.Errorf("Location header = %q, want empty", loc) + } + // Check body if tt.checkBody != nil { tt.checkBody(t, w.Body.String()) diff --git a/examples/10-production-boilerplate/internal/user/dto.go b/examples/10-production-boilerplate/internal/user/dto.go new file mode 100644 index 0000000..cde64c6 --- /dev/null +++ b/examples/10-production-boilerplate/internal/user/dto.go @@ -0,0 +1,67 @@ +package user + +import "time" + +// LoginRequest represents login request. +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +// LoginResponse is returned on successful login. +type LoginResponse struct { + Token string `json:"token"` +} + +// UpdateProfileRequest represents profile update request. +type UpdateProfileRequest struct { + Name string `json:"name" validate:"required"` +} + +// ChangePasswordRequest represents password change request. +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=8,max=72"` +} + +// RegisterRequest represents user registration request. +type RegisterRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=72"` + Name string `json:"name" validate:"required"` +} + +// UserResponse represents user in API response (separate from domain entity!). +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +// ToResponse converts User entity to UserResponse. +func ToResponse(user *User) UserResponse { + return UserResponse{ + ID: user.ID, + Email: user.Email.String(), + Name: user.Name, + Role: string(user.Role), + Status: string(user.Status), + CreatedAt: user.CreatedAt.Format(time.RFC3339), + } +} + +// ListUsersResponse is a paginated user list. +type ListUsersResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` + Users []UserResponse `json:"users"` +} + +// MessageResponse is a generic confirmation message. +type MessageResponse struct { + Message string `json:"message"` +} diff --git a/examples/10-production-boilerplate/internal/user/service.go b/examples/10-production-boilerplate/internal/user/service.go index 465f7be..407fb69 100644 --- a/examples/10-production-boilerplate/internal/user/service.go +++ b/examples/10-production-boilerplate/internal/user/service.go @@ -50,11 +50,6 @@ func NewService(repo Repository, jwtSvc *auth.JWTService) Service { // Register creates a new user account. func (s *serviceImpl) Register(ctx context.Context, req RegisterRequest) (*User, error) { - // Validate request - if err := req.Validate(); err != nil { - return nil, err - } - // Check if email already exists email, _ := NewEmail(req.Email) _, err := s.repo.GetByEmail(ctx, email) @@ -81,11 +76,6 @@ func (s *serviceImpl) Register(ctx context.Context, req RegisterRequest) (*User, // Login authenticates user and returns JWT token. func (s *serviceImpl) Login(ctx context.Context, req LoginRequest) (string, error) { - // Validate request - if err := req.Validate(); err != nil { - return "", err - } - // Get user by email email, _ := NewEmail(req.Email) user, err := s.repo.GetByEmail(ctx, email) @@ -122,11 +112,6 @@ func (s *serviceImpl) GetProfile(ctx context.Context, userID string) (*User, err // UpdateProfile updates user profile. func (s *serviceImpl) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*User, error) { - // Validate request - if err := req.Validate(); err != nil { - return nil, err - } - // Get user user, err := s.repo.Get(ctx, userID) if err != nil { @@ -148,11 +133,6 @@ func (s *serviceImpl) UpdateProfile(ctx context.Context, userID string, req Upda // ChangePassword changes user password. func (s *serviceImpl) ChangePassword(ctx context.Context, userID string, req ChangePasswordRequest) error { - // Validate request - if err := req.Validate(); err != nil { - return err - } - // Get user user, err := s.repo.Get(ctx, userID) if err != nil { diff --git a/examples/10-production-boilerplate/internal/user/service_test.go b/examples/10-production-boilerplate/internal/user/service_test.go index 0d88fe3..d828a29 100644 --- a/examples/10-production-boilerplate/internal/user/service_test.go +++ b/examples/10-production-boilerplate/internal/user/service_test.go @@ -161,7 +161,7 @@ func TestService_Register(t *testing.T) { Password: "SecurePass123", Name: "", }, - wantErr: errors.New("name is required"), + wantErr: errors.New("name cannot be empty"), }, } diff --git a/examples/10-production-boilerplate/internal/user/validation.go b/examples/10-production-boilerplate/internal/user/validation.go deleted file mode 100644 index 39781e8..0000000 --- a/examples/10-production-boilerplate/internal/user/validation.go +++ /dev/null @@ -1,102 +0,0 @@ -package user - -import ( - "errors" - "time" -) - -// RegisterRequest represents user registration request. -type RegisterRequest struct { - Email string `json:"email"` - Password string `json:"password"` - Name string `json:"name"` -} - -// Validate validates RegisterRequest. -func (r RegisterRequest) Validate() error { - if r.Email == "" { - return errors.New("email is required") - } - if r.Password == "" { - return errors.New("password is required") - } - if len(r.Password) < 8 { - return errors.New("password must be at least 8 characters") - } - if r.Name == "" { - return errors.New("name is required") - } - return nil -} - -// LoginRequest represents login request. -type LoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -// Validate validates LoginRequest. -func (r LoginRequest) Validate() error { - if r.Email == "" { - return errors.New("email is required") - } - if r.Password == "" { - return errors.New("password is required") - } - return nil -} - -// UpdateProfileRequest represents profile update request. -type UpdateProfileRequest struct { - Name string `json:"name"` -} - -// Validate validates UpdateProfileRequest. -func (r UpdateProfileRequest) Validate() error { - if r.Name == "" { - return errors.New("name is required") - } - return nil -} - -// ChangePasswordRequest represents password change request. -type ChangePasswordRequest struct { - OldPassword string `json:"old_password"` - NewPassword string `json:"new_password"` -} - -// Validate validates ChangePasswordRequest. -func (r ChangePasswordRequest) Validate() error { - if r.OldPassword == "" { - return errors.New("old password is required") - } - if r.NewPassword == "" { - return errors.New("new password is required") - } - if len(r.NewPassword) < 8 { - return errors.New("new password must be at least 8 characters") - } - return nil -} - -// UserResponse represents user in API response (separate from domain entity!). -type UserResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` -} - -// ToResponse converts User entity to UserResponse. -func ToResponse(user *User) UserResponse { - return UserResponse{ - ID: user.ID, - Email: user.Email.String(), - Name: user.Name, - Role: string(user.Role), - Status: string(user.Status), - CreatedAt: user.CreatedAt.Format(time.RFC3339), - } -}