Skip to content

Commit ff35b3e

Browse files
authored
Merge pull request #195 from LAA-Software-Engineering/fix/114-handler-service-repository
refactor: handler / service / repository split (closes #114)
2 parents f440cb0 + 55b6e28 commit ff35b3e

13 files changed

Lines changed: 855 additions & 249 deletions

pkg/api/books.go

Lines changed: 69 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,26 @@
11
package api
22

33
import (
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-
2418
const (
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).
5424
type 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+
6343
func 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]
125111
func (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]
219149
func (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]
249173
func (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]
281206
func (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]
323241
func (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

Comments
 (0)