11package api
22
33import (
4- "context"
5- "encoding/json"
64 "errors"
7- "fmt"
8- "golang-rest-api-template/pkg/cache"
9- "golang-rest-api-template/pkg/database"
10- "golang-rest-api-template/pkg/models"
115 "net/http"
126 "strconv"
137 "strings"
14- "time"
8+
9+ "golang-rest-api-template/pkg/cache"
10+ "golang-rest-api-template/pkg/database"
11+ "golang-rest-api-template/pkg/models"
12+ "golang-rest-api-template/pkg/repository"
13+ "golang-rest-api-template/pkg/service"
1514
1615 "github.com/gin-gonic/gin"
17- "golang.org/x/sync/singleflight"
1816)
1917
20- // booksListCacheGenKey is incremented on book writes so list pagination entries
21- // (scoped by generation) go stale without Redis KEYS.
22- const booksListCacheGenKey = "v1:books:list_cache_gen"
23-
2418const (
2519 findBooksMinLimit = 1
2620 findBooksMaxLimit = 100
2721)
2822
29- func booksListDataCacheKey (gen int64 , offset , limit int ) string {
30- return fmt .Sprintf ("books_g%d_offset_%d_limit_%d" , gen , offset , limit )
31- }
32-
33- func (r * bookRepository ) booksListCacheGeneration (ctx context.Context ) int64 {
34- if r == nil || r .RedisClient == nil {
35- return 0
36- }
37- n , err := r .RedisClient .Get (ctx , booksListCacheGenKey ).Int64 ()
38- if err != nil {
39- return 0
40- }
41- if n < 0 {
42- return 0
43- }
44- return n
45- }
46-
47- func (r * bookRepository ) bumpBooksListCacheGeneration (ctx context.Context ) {
48- if r == nil || r .RedisClient == nil {
49- return
50- }
51- _ , _ = r .RedisClient .Incr (ctx , booksListCacheGenKey ).Result ()
52- }
53-
23+ // BookRepository is the HTTP surface for book routes (Gin handlers).
5424type BookRepository interface {
5525 Healthcheck (c * gin.Context )
5626 FindBooks (c * gin.Context )
@@ -60,6 +30,16 @@ type BookRepository interface {
6030 DeleteBook (c * gin.Context )
6131}
6232
33+ type bookRepository struct {
34+ svc * service.BookService
35+ }
36+
37+ // NewBookRepository wires persistence and cache into book HTTP handlers.
38+ func NewBookRepository (db database.Database , redisClient cache.Cache ) * bookRepository {
39+ store := repository .NewGormBookStore (db )
40+ return & bookRepository {svc : service .NewBookService (store , redisClient )}
41+ }
42+
6343func parseIDParam (c * gin.Context ) (uint , bool ) {
6444 if c == nil {
6545 return 0 , false
@@ -73,26 +53,32 @@ func parseIDParam(c *gin.Context) (uint, bool) {
7353 return uint (id ), true
7454}
7555
76- // Sentinel errors for FindBooks singleflight so callers can map failures to HTTP responses.
77- var (
78- errListBooksSFDB = errors .New ("listBooks singleflight: database" )
79- errListBooksSFMarshal = errors .New ("listBooks singleflight: marshal" )
80- errListBooksSFRedis = errors .New ("listBooks singleflight: redis set" )
81- )
82-
83- // bookRepository holds shared resources like database and Redis client
84- type bookRepository struct {
85- DB database.Database
86- RedisClient cache.Cache
87- listBooksSF singleflight.Group
88- }
56+ func parseOffsetLimit (c * gin.Context ) (offset , limit int , ok bool ) {
57+ offsetQuery := strings .TrimSpace (c .DefaultQuery ("offset" , "0" ))
58+ limitQuery := strings .TrimSpace (c .DefaultQuery ("limit" , "10" ))
8959
90- // NewBookRepository returns a bookRepository backed by db and optional redisClient.
91- func NewBookRepository (db database.Database , redisClient cache.Cache ) * bookRepository {
92- return & bookRepository {
93- DB : db ,
94- RedisClient : redisClient ,
60+ o , err := strconv .Atoi (offsetQuery )
61+ if err != nil {
62+ c .JSON (http .StatusBadRequest , gin.H {"error" : "Invalid offset format" })
63+ return 0 , 0 , false
64+ }
65+ l , err := strconv .Atoi (limitQuery )
66+ if err != nil {
67+ c .JSON (http .StatusBadRequest , gin.H {"error" : "Invalid limit format" })
68+ return 0 , 0 , false
9569 }
70+ if o < 0 {
71+ c .JSON (http .StatusBadRequest , gin.H {"error" : "offset must be >= 0" })
72+ return 0 , 0 , false
73+ }
74+ if l < findBooksMinLimit {
75+ c .JSON (http .StatusBadRequest , gin.H {"error" : "limit must be at least 1" })
76+ return 0 , 0 , false
77+ }
78+ if l > findBooksMaxLimit {
79+ l = findBooksMaxLimit
80+ }
81+ return o , l , true
9682}
9783
9884// @BasePath /api/v1
@@ -123,82 +109,26 @@ func (r *bookRepository) Healthcheck(c *gin.Context) {
123109// @Failure 500 {string} string "Internal Server Error"
124110// @Router /books [get]
125111func (r * bookRepository ) FindBooks (c * gin.Context ) {
126- var books []models.Book
127-
128- // Query params trimmed so cache keys match parsed integers (no cache fragmentation from " 0 " vs "0").
129- offsetQuery := strings .TrimSpace (c .DefaultQuery ("offset" , "0" ))
130- limitQuery := strings .TrimSpace (c .DefaultQuery ("limit" , "10" ))
131-
132- offset , err := strconv .Atoi (offsetQuery )
133- if err != nil {
134- c .JSON (http .StatusBadRequest , gin.H {"error" : "Invalid offset format" })
135- return
136- }
137-
138- limit , err := strconv .Atoi (limitQuery )
139- if err != nil {
140- c .JSON (http .StatusBadRequest , gin.H {"error" : "Invalid limit format" })
141- return
142- }
143-
144- if offset < 0 {
145- c .JSON (http .StatusBadRequest , gin.H {"error" : "offset must be >= 0" })
146- return
147- }
148- if limit < findBooksMinLimit {
149- c .JSON (http .StatusBadRequest , gin.H {"error" : "limit must be at least 1" })
150- return
151- }
152- if limit > findBooksMaxLimit {
153- limit = findBooksMaxLimit
154- }
155-
156- reqCtx := c .Request .Context ()
157- gen := r .booksListCacheGeneration (reqCtx )
158- cacheKey := booksListDataCacheKey (gen , offset , limit )
159-
160- // Try fetching the data from Redis first
161- cachedBooks , err := r .RedisClient .Get (reqCtx , cacheKey ).Result ()
162- if err == nil {
163- err := json .Unmarshal ([]byte (cachedBooks ), & books )
164- if err != nil {
165- c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to unmarshal cached data" })
166- return
167- }
168- c .JSON (http .StatusOK , gin.H {"data" : books })
112+ offset , limit , ok := parseOffsetLimit (c )
113+ if ! ok {
169114 return
170115 }
171-
172- // If cache missed, coalesce concurrent loads on the same cache key (cache stampede protection).
173- out , err , _ := r .listBooksSF .Do (cacheKey , func () (interface {}, error ) {
174- var loaded []models.Book
175- if err := r .DB .Offset (offset ).Limit (limit ).Find (& loaded ).Error ; err != nil {
176- return nil , fmt .Errorf ("%w: %v" , errListBooksSFDB , err )
177- }
178- serializedBooks , err := json .Marshal (loaded )
179- if err != nil {
180- return nil , fmt .Errorf ("%w: %v" , errListBooksSFMarshal , err )
181- }
182- if err := r .RedisClient .Set (reqCtx , cacheKey , serializedBooks , time .Minute ).Err (); err != nil {
183- return nil , fmt .Errorf ("%w: %v" , errListBooksSFRedis , err )
184- }
185- return loaded , nil
186- })
116+ books , err := r .svc .ListBooks (c .Request .Context (), offset , limit )
187117 if err != nil {
188118 switch {
189- case errors .Is (err , errListBooksSFDB ):
119+ case errors .Is (err , service . ErrListBooksDB ):
190120 c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to list books" })
191- case errors .Is (err , errListBooksSFMarshal ):
121+ case errors .Is (err , service . ErrListBooksMarshal ):
192122 c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to marshal data" })
193- case errors .Is (err , errListBooksSFRedis ):
123+ case errors .Is (err , service . ErrListBooksRedis ):
194124 c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to set cache" })
125+ case errors .Is (err , service .ErrListBooksUnmarshal ):
126+ c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to unmarshal cached data" })
195127 default :
196128 c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to list books" })
197129 }
198130 return
199131 }
200-
201- books = out .([]models.Book )
202132 c .JSON (http .StatusOK , gin.H {"data" : books })
203133}
204134
@@ -218,21 +148,15 @@ func (r *bookRepository) FindBooks(c *gin.Context) {
218148// @Router /books [post]
219149func (r * bookRepository ) CreateBook (c * gin.Context ) {
220150 var input models.CreateBook
221-
222151 if err := c .ShouldBindJSON (& input ); err != nil {
223152 c .JSON (http .StatusBadRequest , gin.H {"error" : err .Error ()})
224153 return
225154 }
226-
227- book := models.Book {Title : input .Title , Author : input .Author }
228-
229- if err := r .DB .Create (& book ).Error ; err != nil {
155+ book , err := r .svc .CreateBook (c .Request .Context (), input .Title , input .Author )
156+ if err != nil {
230157 c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to create book" })
231158 return
232159 }
233-
234- r .bumpBooksListCacheGeneration (c .Request .Context ())
235-
236160 c .JSON (http .StatusCreated , gin.H {"data" : book })
237161}
238162
@@ -247,18 +171,19 @@ func (r *bookRepository) CreateBook(c *gin.Context) {
247171// @Failure 404 {string} string "Book not found"
248172// @Router /books/{id} [get]
249173func (r * bookRepository ) FindBook (c * gin.Context ) {
250- var book models.Book
251-
252174 id , ok := parseIDParam (c )
253175 if ! ok {
254176 return
255177 }
256-
257- if err := r .DB .FirstByID (& book , id ).Error (); err != nil {
258- c .JSON (http .StatusNotFound , gin.H {"error" : "book not found" })
178+ book , err := r .svc .GetBook (c .Request .Context (), id )
179+ if err != nil {
180+ if repository .IsBookNotFound (err ) {
181+ c .JSON (http .StatusNotFound , gin.H {"error" : "book not found" })
182+ return
183+ }
184+ c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to load book" })
259185 return
260186 }
261-
262187 c .JSON (http .StatusOK , gin.H {"data" : book })
263188}
264189
@@ -279,31 +204,24 @@ func (r *bookRepository) FindBook(c *gin.Context) {
279204// @Failure 500 {string} string "Internal Server Error"
280205// @Router /books/{id} [put]
281206func (r * bookRepository ) UpdateBook (c * gin.Context ) {
282- var book models.Book
283207 var input models.UpdateBook
284-
285208 id , ok := parseIDParam (c )
286209 if ! ok {
287210 return
288211 }
289-
290- if err := r .DB .FirstByID (& book , id ).Error (); err != nil {
291- c .JSON (http .StatusNotFound , gin.H {"error" : "book not found" })
292- return
293- }
294-
295212 if err := c .ShouldBindJSON (& input ); err != nil {
296213 c .JSON (http .StatusBadRequest , gin.H {"error" : err .Error ()})
297214 return
298215 }
299-
300- if err := r .DB .Model (& book ).Updates (models.Book {Title : input .Title , Author : input .Author }).Error ; err != nil {
216+ book , err := r .svc .UpdateBook (c .Request .Context (), id , input .Title , input .Author )
217+ if err != nil {
218+ if repository .IsBookNotFound (err ) {
219+ c .JSON (http .StatusNotFound , gin.H {"error" : "book not found" })
220+ return
221+ }
301222 c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to update book" })
302223 return
303224 }
304-
305- r .bumpBooksListCacheGeneration (c .Request .Context ())
306-
307225 c .JSON (http .StatusOK , gin.H {"data" : book })
308226}
309227
@@ -321,24 +239,17 @@ func (r *bookRepository) UpdateBook(c *gin.Context) {
321239// @Failure 500 {string} string "Internal Server Error"
322240// @Router /books/{id} [delete]
323241func (r * bookRepository ) DeleteBook (c * gin.Context ) {
324- var book models.Book
325-
326242 id , ok := parseIDParam (c )
327243 if ! ok {
328244 return
329245 }
330-
331- if err := r .DB .FirstByID (& book , id ).Error (); err != nil {
332- c .JSON (http .StatusNotFound , gin.H {"error" : "book not found" })
333- return
334- }
335-
336- if err := r .DB .Delete (& book ).Error ; err != nil {
246+ if err := r .svc .DeleteBook (c .Request .Context (), id ); err != nil {
247+ if repository .IsBookNotFound (err ) {
248+ c .JSON (http .StatusNotFound , gin.H {"error" : "book not found" })
249+ return
250+ }
337251 c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to delete book" })
338252 return
339253 }
340-
341- r .bumpBooksListCacheGeneration (c .Request .Context ())
342-
343254 c .Status (http .StatusNoContent )
344255}
0 commit comments