From 796070f25c334bc642c0aa5d3e4dc3a4c08b11cb Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 13:56:09 +0800 Subject: [PATCH 01/21] start working on query --- boost/app.go | 1 + boost/router.go | 11 +++ config/makefile/app.mk | 2 +- database/repository/pagination.go | 17 +++++ database/repository/posts.go | 121 +++++++++++++++++++++++++++++- handler/payload/posts.go | 17 +++++ handler/posts.go | 38 ++++++++++ 7 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 database/repository/pagination.go create mode 100644 handler/payload/posts.go create mode 100644 handler/posts.go diff --git a/boost/app.go b/boost/app.go index 485e40c8..e3904395 100644 --- a/boost/app.go +++ b/boost/app.go @@ -42,6 +42,7 @@ func MakeApp(env *env.Environment, validator *pkg.Validator) (*App, error) { router := Router{ Env: env, + Db: db, Mux: baseHttp.NewServeMux(), Pipeline: middleware.Pipeline{ Env: env, diff --git a/boost/router.go b/boost/router.go index 8433d7fb..8ef836b3 100644 --- a/boost/router.go +++ b/boost/router.go @@ -1,6 +1,8 @@ package boost import ( + "github.com/oullin/database" + "github.com/oullin/database/repository" "github.com/oullin/env" "github.com/oullin/handler" "github.com/oullin/pkg/http" @@ -12,6 +14,7 @@ type Router struct { Env *env.Environment Mux *baseHttp.ServeMux Pipeline middleware.Pipeline + Db *database.Connection } func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { @@ -28,6 +31,14 @@ func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { ) } +func (r *Router) Posts() { + repo := repository.Posts{DB: r.Db} + + abstract := handler.MakePostsHandler(&repo) + + r.Mux.HandleFunc("/posts", abstract.Handle) +} + func (r *Router) Profile() { abstract := handler.MakeProfileHandler("./storage/fixture/profile.json") diff --git a/config/makefile/app.mk b/config/makefile/app.mk index 44dcb571..e7c52b54 100644 --- a/config/makefile/app.mk +++ b/config/makefile/app.mk @@ -25,7 +25,7 @@ audit: watch: # --- Works with (air). # https://github.com/air-verse/air - cd $(APP_PATH) && air + cd $(APP_PATH) && air -d install-air: # --- Works with (air). diff --git a/database/repository/pagination.go b/database/repository/pagination.go new file mode 100644 index 00000000..99b225de --- /dev/null +++ b/database/repository/pagination.go @@ -0,0 +1,17 @@ +package repository + +// PaginatedResult holds the data for a single page along with all pagination metadata. +// It's generic and can be used for any data type. +// +// NextPage and PreviousPage are pointers (*int) so they can be nil (and omitted from JSON output) +// when there isn't a next or previous page. +type PaginatedResult[T any] struct { + Data []T `json:"data"` + Page int + TotalRecords int64 `json:"total_records"` + CurrentPage int `json:"current_page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` + NextPage *int `json:"next_page,omitempty"` + PreviousPage *int `json:"previous_page,omitempty"` +} diff --git a/database/repository/posts.go b/database/repository/posts.go index 12c2ef0c..b19233e8 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -4,7 +4,9 @@ import ( "fmt" "github.com/google/uuid" "github.com/oullin/database" - "github.com/oullin/pkg/gorm" + //"github.com/oullin/pkg/gorm" + "gorm.io/gorm" + "math" ) type Posts struct { @@ -13,6 +15,123 @@ type Posts struct { Tags *Tags } +type PostFilters struct { + UUID string + Slug string + Title string // Will perform a case-insensitive partial match + AuthorUsername string + CategorySlug string + TagSlug string + IsPublished *bool // Pointer to bool to allow three states: true, false, and not-set (nil) +} + +func (p Posts) GetPosts(filters *PostFilters, pagination *PaginatedResult[database.Post]) (*PaginatedResult[database.Post], error) { + var posts []database.Post + var totalRecords int64 + + query := p.DB.Sql().Model(&database.Post{}) + + if filters != nil { + // Filter by direct fields on the 'posts' table + if filters.UUID != "" { + query.Where("posts.uuid = ?", filters.UUID) + } + + if filters.Slug != "" { + query.Where("posts.slug = ?", filters.Slug) + } + + if filters.Title != "" { + // Use ILIKE for case-insensitive search (PostgreSQL specific). + // For MySQL, use: query.Where("LOWER(posts.title) LIKE LOWER(?)", "%"+filters.Title+"%") + query.Where("posts.title ILIKE ?", "%"+filters.Title+"%") + } + + // Filter by relations using JOINs + if filters.AuthorUsername != "" { + // GORM's Joins() uses the struct relation name ("Author"). + query.Joins("Author").Where("Author.username = ?", filters.AuthorUsername) + } + + if filters.CategorySlug != "" { + // For many-to-many, an explicit join is often clearer and safer. + query.Joins("JOIN post_categories ON post_categories.post_id = posts.id"). + Joins("JOIN categories ON categories.id = post_categories.category_id"). + Where("categories.slug = ?", filters.CategorySlug) + } + + if filters.TagSlug != "" { + query.Joins("JOIN post_tags ON post_tags.post_id = posts.id"). + Joins("JOIN tags ON tags.id = post_tags.tag_id"). + Where("tags.slug = ?", filters.TagSlug) + } + } + + // Count the total number of records matching the filters + if err := query.Distinct("posts.id").Count(&totalRecords).Error; err != nil { + return nil, err + } + + // Set default pagination values if none are provided + if pagination == nil { + pagination = &PaginatedResult[database.Post]{ + Page: 1, + PageSize: 10, + } + } + + if pagination.Page <= 0 { + pagination.Page = 1 + } + + if pagination.PageSize <= 0 { + pagination.PageSize = 10 + } + + // Calculate pagination metadata + totalPages := int(math.Ceil(float64(totalRecords) / float64(pagination.PageSize))) + + var nextPage *int + if pagination.Page < totalPages { + p := pagination.Page + 1 + nextPage = &p + } + + var prevPage *int + if pagination.Page > 1 && pagination.Page <= totalPages { + p := pagination.Page - 1 + prevPage = &p + } + + // Fetch the data for the current page + offset := (pagination.Page - 1) * pagination.PageSize + err := query.Preload("Author"). + Preload("Categories"). + Preload("Tags"). + Order("posts.published_at DESC, posts.created_at DESC"). + Limit(pagination.PageSize). + Offset(offset). + Distinct(). + Find(&posts).Error + + if err != nil { + return nil, err + } + + // Assemble the final result + result := &PaginatedResult[database.Post]{ + Data: posts, + TotalRecords: totalRecords, + CurrentPage: pagination.Page, + PageSize: pagination.PageSize, + TotalPages: totalPages, + NextPage: nextPage, + PreviousPage: prevPage, + } + + return result, nil +} + func (p Posts) FindCategoryBy(slug string) *database.Category { return p.Categories.FindBy(slug) } diff --git a/handler/payload/posts.go b/handler/payload/posts.go new file mode 100644 index 00000000..e29e26f9 --- /dev/null +++ b/handler/payload/posts.go @@ -0,0 +1,17 @@ +package payload + +import ( + "github.com/google/uuid" + "time" +) + +type Posts struct { + Uuid uuid.NullUUID `json:"uuid"` + Slug string `json:"slug"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Content string `json:"content"` + CoverImageURL string `json:"cover_image_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/handler/posts.go b/handler/posts.go new file mode 100644 index 00000000..e6d240b7 --- /dev/null +++ b/handler/posts.go @@ -0,0 +1,38 @@ +package handler + +import ( + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/pkg/http" + "log" + baseHttp "net/http" +) + +type PostsHandler struct { + Posts *repository.Posts +} + +type PostsResponse struct{} + +func MakePostsHandler(posts *repository.Posts) *PostsHandler { + return &PostsHandler{ + Posts: posts, + } +} + +func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) (*PostsResponse, *http.ApiError) { + + filters := repository.PostFilters{Title: ""} + pagination := repository.PaginatedResult[database.Post]{ + Page: 1, + PageSize: 1, + } + + posts, err := h.Posts.GetPosts(&filters, &pagination) + + if err != nil { + log.Fatalf("Failed to get posts: %v", err) + } + + return nil, nil +} From 72f529d2e2ad855b6284ced0d938756506eb777a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 14:16:25 +0800 Subject: [PATCH 02/21] fix query --- boost/app.go | 1 + boost/router.go | 7 +++++-- database/repository/posts.go | 7 +++---- go.mod | 2 +- handler/posts.go | 16 ++++++++++------ 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/boost/app.go b/boost/app.go index e3904395..19ce435a 100644 --- a/boost/app.go +++ b/boost/app.go @@ -70,4 +70,5 @@ func (a *App) Boot() { router.Talks() router.Education() router.Recommendations() + router.Posts() } diff --git a/boost/router.go b/boost/router.go index 8ef836b3..85699757 100644 --- a/boost/router.go +++ b/boost/router.go @@ -33,10 +33,13 @@ func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { func (r *Router) Posts() { repo := repository.Posts{DB: r.Db} - abstract := handler.MakePostsHandler(&repo) - r.Mux.HandleFunc("/posts", abstract.Handle) + resolver := r.PipelineFor( + abstract.Handle, + ) + + r.Mux.HandleFunc("/posts", resolver) } func (r *Router) Profile() { diff --git a/database/repository/posts.go b/database/repository/posts.go index b19233e8..676977b7 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -4,8 +4,7 @@ import ( "fmt" "github.com/google/uuid" "github.com/oullin/database" - //"github.com/oullin/pkg/gorm" - "gorm.io/gorm" + "github.com/oullin/pkg/gorm" "math" ) @@ -68,7 +67,7 @@ func (p Posts) GetPosts(filters *PostFilters, pagination *PaginatedResult[databa } // Count the total number of records matching the filters - if err := query.Distinct("posts.id").Count(&totalRecords).Error; err != nil { + if err := query.Distinct("posts.id, posts.published_at").Count(&totalRecords).Error; err != nil { return nil, err } @@ -108,7 +107,7 @@ func (p Posts) GetPosts(filters *PostFilters, pagination *PaginatedResult[databa err := query.Preload("Author"). Preload("Categories"). Preload("Tags"). - Order("posts.published_at DESC, posts.created_at DESC"). + Order("posts.published_at DESC"). Limit(pagination.PageSize). Offset(offset). Distinct(). diff --git a/go.mod b/go.mod index 1150f536..b68092ae 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/lib/pq v1.10.9 golang.org/x/crypto v0.39.0 golang.org/x/term v0.32.0 + golang.org/x/text v0.26.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.30.0 @@ -30,7 +31,6 @@ require ( golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect ) replace github.com/oullin/boost => ./boost diff --git a/handler/posts.go b/handler/posts.go index e6d240b7..36883b53 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -1,10 +1,10 @@ package handler import ( + "encoding/json" "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/pkg/http" - "log" baseHttp "net/http" ) @@ -14,13 +14,13 @@ type PostsHandler struct { type PostsResponse struct{} -func MakePostsHandler(posts *repository.Posts) *PostsHandler { - return &PostsHandler{ +func MakePostsHandler(posts *repository.Posts) PostsHandler { + return PostsHandler{ Posts: posts, } } -func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) (*PostsResponse, *http.ApiError) { +func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { filters := repository.PostFilters{Title: ""} pagination := repository.PaginatedResult[database.Post]{ @@ -31,8 +31,12 @@ func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) (* posts, err := h.Posts.GetPosts(&filters, &pagination) if err != nil { - log.Fatalf("Failed to get posts: %v", err) + return http.InternalError(err.Error()) } - return nil, nil + if err = json.NewEncoder(w).Encode(posts); err != nil { + return http.InternalError(err.Error()) + } + + return nil } From a2c4ecba0748854145e55463b720ed94486ba734 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 14:51:37 +0800 Subject: [PATCH 03/21] create transformer --- handler/payload/posts.go | 76 ++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/handler/payload/posts.go b/handler/payload/posts.go index e29e26f9..79668bcf 100644 --- a/handler/payload/posts.go +++ b/handler/payload/posts.go @@ -1,17 +1,73 @@ package payload import ( - "github.com/google/uuid" "time" ) -type Posts struct { - Uuid uuid.NullUUID `json:"uuid"` - Slug string `json:"slug"` - Title string `json:"title"` - Excerpt string `json:"excerpt"` - Content string `json:"content"` - CoverImageURL string `json:"cover_image_url"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +type PostResponse struct { + UUID string `json:"uuid"` + Author UserData `json:"author"` + Slug string `json:"slug"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Content string `json:"content"` + CoverImageURL string `json:"cover_image_url"` + PublishedAt *time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Associations + Categories []CategoryData `json:"categories"` + Tags []TagData `json:"tags"` + Comments []CommentData `json:"comments"` +} + +type UserData struct { + UUID string `json:"uuid"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Bio string `json:"bio"` + PictureFileName string `json:"picture_file_name"` + ProfilePictureURL string `json:"profile_picture_url"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CategoryData struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Associations + Posts []PostResponse `json:"posts"` +} + +type TagData struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Associations + Posts []PostResponse `json:"posts"` +} + +type CommentData struct { + UUID string `json:"uuid"` + Post PostResponse `json:"post"` + Author UserData `json:"author"` + Parent *CommentData `json:"parent"` + Replies []CommentData `json:"replies"` + Content string `json:"content"` + ApprovedAt *time.Time `json:"approved_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } From 2b47a17fbe1bcaa9e6b8f1fc7ce689d577b9333e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 15:03:47 +0800 Subject: [PATCH 04/21] map response --- database/repository/pagination.go | 20 +++++++++++++++++++ handler/posts.go | 32 ++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/database/repository/pagination.go b/database/repository/pagination.go index 99b225de..6e33e73b 100644 --- a/database/repository/pagination.go +++ b/database/repository/pagination.go @@ -15,3 +15,23 @@ type PaginatedResult[T any] struct { NextPage *int `json:"next_page,omitempty"` PreviousPage *int `json:"previous_page,omitempty"` } + +// MapPaginatedResult converts a paginated result of type S to a paginated result of type D. +func MapPaginatedResult[S any, D any](source *PaginatedResult[S], mapper func(S) D) *PaginatedResult[D] { + mappedData := make([]D, len(source.Data)) + + // Iterate over the source data and apply the mapper function + for i, item := range source.Data { + mappedData[i] = mapper(item) + } + + return &PaginatedResult[D]{ + Data: mappedData, + TotalRecords: source.TotalRecords, + CurrentPage: source.CurrentPage, + PageSize: source.PageSize, + TotalPages: source.TotalPages, + NextPage: source.NextPage, + PreviousPage: source.PreviousPage, + } +} diff --git a/handler/posts.go b/handler/posts.go index 36883b53..9945e09a 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/oullin/database" "github.com/oullin/database/repository" + "github.com/oullin/handler/payload" "github.com/oullin/pkg/http" baseHttp "net/http" ) @@ -12,8 +13,6 @@ type PostsHandler struct { Posts *repository.Posts } -type PostsResponse struct{} - func MakePostsHandler(posts *repository.Posts) PostsHandler { return PostsHandler{ Posts: posts, @@ -28,12 +27,39 @@ func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *h PageSize: 1, } - posts, err := h.Posts.GetPosts(&filters, &pagination) + items, err := h.Posts.GetPosts(&filters, &pagination) if err != nil { return http.InternalError(err.Error()) } + posts := repository.MapPaginatedResult(items, func(p database.Post) payload.PostResponse { + return payload.PostResponse{ + UUID: p.UUID, + Author: payload.UserData{ + UUID: p.Author.UUID, + FirstName: p.Author.FirstName, + LastName: p.Author.LastName, + Username: p.Author.Username, + DisplayName: p.Author.DisplayName, + Bio: p.Author.Bio, + PictureFileName: p.Author.PictureFileName, + ProfilePictureURL: p.Author.ProfilePictureURL, + IsAdmin: p.Author.IsAdmin, + CreatedAt: p.Author.CreatedAt, + UpdatedAt: p.UpdatedAt, + }, + Slug: p.Slug, + Title: p.Title, + Excerpt: p.Excerpt, + Content: p.Content, + CoverImageURL: p.CoverImageURL, + PublishedAt: p.PublishedAt, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + }) + if err = json.NewEncoder(w).Encode(posts); err != nil { return http.InternalError(err.Error()) } From 025b73bc92cf4a8ed3567a4957e2d83e9327ad6a Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 15:40:38 +0800 Subject: [PATCH 05/21] map posts --- database/connection.go | 4 ++ database/repository/pagination.go | 4 +- database/repository/posts.go | 4 +- handler/posts.go | 46 ++++--------- .../{payload/posts.go => posts/response.go} | 18 +---- handler/posts/transformer.go | 66 +++++++++++++++++++ 6 files changed, 89 insertions(+), 53 deletions(-) rename handler/{payload/posts.go => posts/response.go} (76%) create mode 100644 handler/posts/transformer.go diff --git a/database/connection.go b/database/connection.go index d78c2181..38d849e7 100644 --- a/database/connection.go +++ b/database/connection.go @@ -73,6 +73,10 @@ func (c *Connection) Sql() *gorm.DB { return c.driver } +func (c *Connection) Session() *gorm.Session { + return &gorm.Session{} +} + func (c *Connection) Transaction(callback func(db *gorm.DB) error) error { return c.driver.Transaction(callback) } diff --git a/database/repository/pagination.go b/database/repository/pagination.go index 6e33e73b..b75c0214 100644 --- a/database/repository/pagination.go +++ b/database/repository/pagination.go @@ -6,8 +6,8 @@ package repository // NextPage and PreviousPage are pointers (*int) so they can be nil (and omitted from JSON output) // when there isn't a next or previous page. type PaginatedResult[T any] struct { - Data []T `json:"data"` - Page int + Data []T `json:"data"` + Page int `json:"page"` TotalRecords int64 `json:"total_records"` CurrentPage int `json:"current_page"` PageSize int `json:"page_size"` diff --git a/database/repository/posts.go b/database/repository/posts.go index 676977b7..e3532440 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -29,6 +29,7 @@ func (p Posts) GetPosts(filters *PostFilters, pagination *PaginatedResult[databa var totalRecords int64 query := p.DB.Sql().Model(&database.Post{}) + countQuery := query.Session(p.DB.Session()) if filters != nil { // Filter by direct fields on the 'posts' table @@ -66,8 +67,7 @@ func (p Posts) GetPosts(filters *PostFilters, pagination *PaginatedResult[databa } } - // Count the total number of records matching the filters - if err := query.Distinct("posts.id, posts.published_at").Count(&totalRecords).Error; err != nil { + if err := countQuery.Distinct("posts.id, posts.published_at").Count(&totalRecords).Error; err != nil { return nil, err } diff --git a/handler/posts.go b/handler/posts.go index 9945e09a..f7966986 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -4,8 +4,9 @@ import ( "encoding/json" "github.com/oullin/database" "github.com/oullin/database/repository" - "github.com/oullin/handler/payload" + "github.com/oullin/handler/posts" "github.com/oullin/pkg/http" + "log/slog" baseHttp "net/http" ) @@ -20,47 +21,28 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { } func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - filters := repository.PostFilters{Title: ""} pagination := repository.PaginatedResult[database.Post]{ Page: 1, - PageSize: 1, + PageSize: 10, } - items, err := h.Posts.GetPosts(&filters, &pagination) + result, err := h.Posts.GetPosts(&filters, &pagination) if err != nil { - return http.InternalError(err.Error()) + slog.Error(err.Error()) + + return http.InternalError("The was an issue reading the posts. Please, try later.") } - posts := repository.MapPaginatedResult(items, func(p database.Post) payload.PostResponse { - return payload.PostResponse{ - UUID: p.UUID, - Author: payload.UserData{ - UUID: p.Author.UUID, - FirstName: p.Author.FirstName, - LastName: p.Author.LastName, - Username: p.Author.Username, - DisplayName: p.Author.DisplayName, - Bio: p.Author.Bio, - PictureFileName: p.Author.PictureFileName, - ProfilePictureURL: p.Author.ProfilePictureURL, - IsAdmin: p.Author.IsAdmin, - CreatedAt: p.Author.CreatedAt, - UpdatedAt: p.UpdatedAt, - }, - Slug: p.Slug, - Title: p.Title, - Excerpt: p.Excerpt, - Content: p.Content, - CoverImageURL: p.CoverImageURL, - PublishedAt: p.PublishedAt, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - } - }) + items := repository.MapPaginatedResult( + result, + posts.Collection, + ) + + if err = json.NewEncoder(w).Encode(items); err != nil { + slog.Error(err.Error()) - if err = json.NewEncoder(w).Encode(posts); err != nil { return http.InternalError(err.Error()) } diff --git a/handler/payload/posts.go b/handler/posts/response.go similarity index 76% rename from handler/payload/posts.go rename to handler/posts/response.go index 79668bcf..a4484faa 100644 --- a/handler/payload/posts.go +++ b/handler/posts/response.go @@ -1,4 +1,4 @@ -package payload +package posts import ( "time" @@ -19,7 +19,6 @@ type PostResponse struct { // Associations Categories []CategoryData `json:"categories"` Tags []TagData `json:"tags"` - Comments []CommentData `json:"comments"` } type UserData struct { @@ -43,9 +42,6 @@ type CategoryData struct { Description string `json:"description"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - - // Associations - Posts []PostResponse `json:"posts"` } type TagData struct { @@ -59,15 +55,3 @@ type TagData struct { // Associations Posts []PostResponse `json:"posts"` } - -type CommentData struct { - UUID string `json:"uuid"` - Post PostResponse `json:"post"` - Author UserData `json:"author"` - Parent *CommentData `json:"parent"` - Replies []CommentData `json:"replies"` - Content string `json:"content"` - ApprovedAt *time.Time `json:"approved_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go new file mode 100644 index 00000000..b922553c --- /dev/null +++ b/handler/posts/transformer.go @@ -0,0 +1,66 @@ +package posts + +import "github.com/oullin/database" + +func Collection(p database.Post) PostResponse { + return PostResponse{ + UUID: p.UUID, + Slug: p.Slug, + Title: p.Title, + Excerpt: p.Excerpt, + Content: p.Content, + CoverImageURL: p.CoverImageURL, + PublishedAt: p.PublishedAt, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + Categories: MapCategories(p.Categories), + Tags: MapTags(p.Tags), + Author: UserData{ + UUID: p.Author.UUID, + FirstName: p.Author.FirstName, + LastName: p.Author.LastName, + Username: p.Author.Username, + DisplayName: p.Author.DisplayName, + Bio: p.Author.Bio, + PictureFileName: p.Author.PictureFileName, + ProfilePictureURL: p.Author.ProfilePictureURL, + IsAdmin: p.Author.IsAdmin, + CreatedAt: p.Author.CreatedAt, + UpdatedAt: p.UpdatedAt, + }, + } +} + +func MapCategories(categories []database.Category) []CategoryData { + var data []CategoryData + + for _, category := range categories { + data = append(data, CategoryData{ + UUID: category.UUID, + Name: category.Name, + Slug: category.Slug, + Description: category.Description, + CreatedAt: category.CreatedAt, + UpdatedAt: category.UpdatedAt, + }) + } + + return data +} + +func MapTags(tags []database.Tag) []TagData { + var data []TagData + + for _, category := range tags { + data = append(data, TagData{ + UUID: category.UUID, + Name: category.Name, + Slug: category.Slug, + Description: category.Description, + CreatedAt: category.CreatedAt, + UpdatedAt: category.UpdatedAt, + }) + } + + return data +} From b2ae80dac6a3ee91aef5c9b9afd923b88b6bd886 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 16:31:22 +0800 Subject: [PATCH 06/21] extract filter logic --- database/repository/pagination.go | 13 ++++++- database/repository/posts.go | 49 ++------------------------ database/repository/queries/filters.go | 42 ++++++++++++++++++++++ database/repository/queries/posts.go | 49 ++++++++++++++++++++++++++ handler/posts.go | 3 +- 5 files changed, 108 insertions(+), 48 deletions(-) create mode 100644 database/repository/queries/filters.go create mode 100644 database/repository/queries/posts.go diff --git a/database/repository/pagination.go b/database/repository/pagination.go index b75c0214..e96211a8 100644 --- a/database/repository/pagination.go +++ b/database/repository/pagination.go @@ -16,7 +16,18 @@ type PaginatedResult[T any] struct { PreviousPage *int `json:"previous_page,omitempty"` } -// MapPaginatedResult converts a paginated result of type S to a paginated result of type D. +// MapPaginatedResult transforms a paginated result containing items of a source type (S) +// into a new result containing items of a destination type (D). +// +// It takes a source PaginatedResult and a mapper function that defines the conversion +// logic from an item of type S to an item of type D. +// +// Type Parameters: +// - S: The source type (e.g., a database model like database.Post). +// - D: The destination type (e.g., an API response DTO like PostResponse). +// +// The function returns a new PaginatedResult with the transformed data, while preserving +// all original pagination metadata (TotalRecords, CurrentPage, etc.). func MapPaginatedResult[S any, D any](source *PaginatedResult[S], mapper func(S) D) *PaginatedResult[D] { mappedData := make([]D, len(source.Data)) diff --git a/database/repository/posts.go b/database/repository/posts.go index e3532440..926ebb0a 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/google/uuid" "github.com/oullin/database" + "github.com/oullin/database/repository/queries" "github.com/oullin/pkg/gorm" "math" ) @@ -14,58 +15,14 @@ type Posts struct { Tags *Tags } -type PostFilters struct { - UUID string - Slug string - Title string // Will perform a case-insensitive partial match - AuthorUsername string - CategorySlug string - TagSlug string - IsPublished *bool // Pointer to bool to allow three states: true, false, and not-set (nil) -} - -func (p Posts) GetPosts(filters *PostFilters, pagination *PaginatedResult[database.Post]) (*PaginatedResult[database.Post], error) { +func (p Posts) GetPosts(filters *queries.PostFilters, pagination *PaginatedResult[database.Post]) (*PaginatedResult[database.Post], error) { var posts []database.Post var totalRecords int64 query := p.DB.Sql().Model(&database.Post{}) countQuery := query.Session(p.DB.Session()) - if filters != nil { - // Filter by direct fields on the 'posts' table - if filters.UUID != "" { - query.Where("posts.uuid = ?", filters.UUID) - } - - if filters.Slug != "" { - query.Where("posts.slug = ?", filters.Slug) - } - - if filters.Title != "" { - // Use ILIKE for case-insensitive search (PostgreSQL specific). - // For MySQL, use: query.Where("LOWER(posts.title) LIKE LOWER(?)", "%"+filters.Title+"%") - query.Where("posts.title ILIKE ?", "%"+filters.Title+"%") - } - - // Filter by relations using JOINs - if filters.AuthorUsername != "" { - // GORM's Joins() uses the struct relation name ("Author"). - query.Joins("Author").Where("Author.username = ?", filters.AuthorUsername) - } - - if filters.CategorySlug != "" { - // For many-to-many, an explicit join is often clearer and safer. - query.Joins("JOIN post_categories ON post_categories.post_id = posts.id"). - Joins("JOIN categories ON categories.id = post_categories.category_id"). - Where("categories.slug = ?", filters.CategorySlug) - } - - if filters.TagSlug != "" { - query.Joins("JOIN post_tags ON post_tags.post_id = posts.id"). - Joins("JOIN tags ON tags.id = post_tags.tag_id"). - Where("tags.slug = ?", filters.TagSlug) - } - } + queries.ApplyPostsFilters(filters, query) if err := countQuery.Distinct("posts.id, posts.published_at").Count(&totalRecords).Error; err != nil { return nil, err diff --git a/database/repository/queries/filters.go b/database/repository/queries/filters.go new file mode 100644 index 00000000..a9d303e5 --- /dev/null +++ b/database/repository/queries/filters.go @@ -0,0 +1,42 @@ +package queries + +import ( + "golang.org/x/text/cases" + "golang.org/x/text/language" + "strings" +) + +type PostFilters struct { + Text string + Title string // Will perform a case-insensitive partial match + Author string + Category string + Tag string + IsPublished *bool // Pointer to bool to allow three states: true, false, and not-set (nil) +} + +func (f PostFilters) GetText() string { + return f.sanitiseString(f.Text) +} + +func (f PostFilters) GetTitle() string { + return f.sanitiseString(f.Title) +} + +func (f PostFilters) GetAuthor() string { + return f.sanitiseString(f.Author) +} + +func (f PostFilters) GetCategory() string { + return f.sanitiseString(f.Category) +} + +func (f PostFilters) GetTag() string { + return f.sanitiseString(f.Tag) +} + +func (f PostFilters) sanitiseString(seed string) string { + caser := cases.Lower(language.English) + + return strings.TrimSpace(caser.String(seed)) +} diff --git a/database/repository/queries/posts.go b/database/repository/queries/posts.go new file mode 100644 index 00000000..b9508c41 --- /dev/null +++ b/database/repository/queries/posts.go @@ -0,0 +1,49 @@ +package queries + +import ( + "gorm.io/gorm" +) + +func ApplyPostsFilters(filters *PostFilters, query *gorm.DB) { + if filters == nil { + return + } + + if filters.GetTitle() != "" { + query.Where("LOWER(posts.title) ILIKE ?", "%"+filters.GetTitle()+"%") + } + + if filters.GetText() != "" { + query. + Or("LOWER(posts.slug) ILIKE ?", "%"+filters.GetText()+"%"). + Or("LOWER(posts.excerpt) ILIKE ?", "%"+filters.GetText()+"%"). + Or("LOWER(posts.content) ILIKE ?", "%"+filters.GetText()+"%") + } + + if filters.GetAuthor() != "" { + query. + Joins("Author"). + Where("LOWER(Author.username) = ?", filters.GetAuthor()). + Or("LOWER(Author.first_name) = ?", filters.GetAuthor()). + Or("LOWER(Author.last_name) = ?", filters.GetAuthor()). + Or("LOWER(Author.display_name) = ?", filters.GetAuthor()) + } + + if filters.GetCategory() != "" { + query. + Joins("JOIN post_categories ON post_categories.post_id = posts.id"). + Joins("JOIN categories ON categories.id = post_categories.category_id"). + Where("LOWER(categories.slug) = ?", filters.GetCategory()). + Or("LOWER(categories.name) = ?", filters.GetCategory()). + Or("LOWER(categories.description) = ?", "%"+filters.GetCategory()+"%") + } + + if filters.GetTag() != "" { + query. + Joins("JOIN post_tags ON post_tags.post_id = posts.id"). + Joins("JOIN tags ON tags.id = post_tags.tag_id"). + Where("LOWER(tags.slug) = ?", filters.GetTag()). + Or("LOWER(tags.name) = ?", filters.GetTag()). + Or("LOWER(tags.description) = ?", "%"+filters.GetTag()+"%") + } +} diff --git a/handler/posts.go b/handler/posts.go index f7966986..1710b5e9 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/oullin/database" "github.com/oullin/database/repository" + "github.com/oullin/database/repository/queries" "github.com/oullin/handler/posts" "github.com/oullin/pkg/http" "log/slog" @@ -21,7 +22,7 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { } func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - filters := repository.PostFilters{Title: ""} + filters := queries.PostFilters{Title: ""} pagination := repository.PaginatedResult[database.Post]{ Page: 1, PageSize: 10, From 6ae6566c7b08e8c789945b5e57ea3dcdc565e434 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 16:43:17 +0800 Subject: [PATCH 07/21] format --- database/repository/posts.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/database/repository/posts.go b/database/repository/posts.go index 926ebb0a..073678e8 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -19,15 +19,13 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination *PaginatedResul var posts []database.Post var totalRecords int64 - query := p.DB.Sql().Model(&database.Post{}) - countQuery := query.Session(p.DB.Session()) + query := p. + DB.Sql(). + Model(&database.Post{}). + Distinct("posts.id, posts.published_at") queries.ApplyPostsFilters(filters, query) - if err := countQuery.Distinct("posts.id, posts.published_at").Count(&totalRecords).Error; err != nil { - return nil, err - } - // Set default pagination values if none are provided if pagination == nil { pagination = &PaginatedResult[database.Post]{ @@ -44,6 +42,13 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination *PaginatedResul pagination.PageSize = 10 } + // ------------- + + countQuery := query.Session(p.DB.Session()) + if err := countQuery.Count(&totalRecords).Error; err != nil { + return nil, err + } + // Calculate pagination metadata totalPages := int(math.Ceil(float64(totalRecords) / float64(pagination.PageSize))) From 15c1a3b74c41efd67dc1791cedee61be60686a32 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 17:17:59 +0800 Subject: [PATCH 08/21] extract Pagination --- database/repository/pagination.go | 57 +++++++++++++++++++++++++----- database/repository/posts.go | 58 ++++++------------------------- handler/posts.go | 2 +- 3 files changed, 60 insertions(+), 57 deletions(-) diff --git a/database/repository/pagination.go b/database/repository/pagination.go index e96211a8..e6f63a90 100644 --- a/database/repository/pagination.go +++ b/database/repository/pagination.go @@ -1,14 +1,16 @@ package repository -// PaginatedResult holds the data for a single page along with all pagination metadata. +import "math" + +// Pagination holds the data for a single page along with all pagination metadata. // It's generic and can be used for any data type. // // NextPage and PreviousPage are pointers (*int) so they can be nil (and omitted from JSON output) // when there isn't a next or previous page. -type PaginatedResult[T any] struct { +type Pagination[T any] struct { Data []T `json:"data"` Page int `json:"page"` - TotalRecords int64 `json:"total_records"` + Total int64 `json:"total"` CurrentPage int `json:"current_page"` PageSize int `json:"page_size"` TotalPages int `json:"total_pages"` @@ -16,19 +18,56 @@ type PaginatedResult[T any] struct { PreviousPage *int `json:"previous_page,omitempty"` } +func MakePagination[T any](data []T, page, pageSize int, total int64) *Pagination[T] { + pSize := float64(pageSize) + if pSize <= 0 { + pSize = 10 + } + + totalPages := int(math.Ceil(float64(total) / pSize)) + + pagination := Pagination[T]{ + Data: data, + Page: page, + Total: total, + CurrentPage: page, + PageSize: pageSize, + TotalPages: totalPages, + NextPage: nil, + PreviousPage: nil, + } + + var nextPage *int + if pagination.Page < pagination.TotalPages { + p := pagination.Page + 1 + nextPage = &p + } + + var prevPage *int + if pagination.Page > 1 && pagination.Page <= pagination.TotalPages { + p := pagination.Page - 1 + prevPage = &p + } + + pagination.NextPage = nextPage + pagination.PreviousPage = prevPage + + return &pagination +} + // MapPaginatedResult transforms a paginated result containing items of a source type (S) // into a new result containing items of a destination type (D). // -// It takes a source PaginatedResult and a mapper function that defines the conversion +// It takes a source Pagination and a mapper function that defines the conversion // logic from an item of type S to an item of type D. // // Type Parameters: // - S: The source type (e.g., a database model like database.Post). // - D: The destination type (e.g., an API response DTO like PostResponse). // -// The function returns a new PaginatedResult with the transformed data, while preserving -// all original pagination metadata (TotalRecords, CurrentPage, etc.). -func MapPaginatedResult[S any, D any](source *PaginatedResult[S], mapper func(S) D) *PaginatedResult[D] { +// The function returns a new Pagination with the transformed data, while preserving +// all original pagination metadata (Total, CurrentPage, etc.). +func MapPaginatedResult[S any, D any](source *Pagination[S], mapper func(S) D) *Pagination[D] { mappedData := make([]D, len(source.Data)) // Iterate over the source data and apply the mapper function @@ -36,9 +75,9 @@ func MapPaginatedResult[S any, D any](source *PaginatedResult[S], mapper func(S) mappedData[i] = mapper(item) } - return &PaginatedResult[D]{ + return &Pagination[D]{ Data: mappedData, - TotalRecords: source.TotalRecords, + Total: source.Total, CurrentPage: source.CurrentPage, PageSize: source.PageSize, TotalPages: source.TotalPages, diff --git a/database/repository/posts.go b/database/repository/posts.go index 073678e8..590585bd 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -6,7 +6,6 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository/queries" "github.com/oullin/pkg/gorm" - "math" ) type Posts struct { @@ -15,9 +14,11 @@ type Posts struct { Tags *Tags } -func (p Posts) GetPosts(filters *queries.PostFilters, pagination *PaginatedResult[database.Post]) (*PaginatedResult[database.Post], error) { +func (p Posts) GetPosts(filters *queries.PostFilters, pagination *Pagination[database.Post]) (*Pagination[database.Post], error) { + page := 1 + pageSize := 10 + var total int64 var posts []database.Post - var totalRecords int64 query := p. DB.Sql(). @@ -26,51 +27,23 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination *PaginatedResul queries.ApplyPostsFilters(filters, query) - // Set default pagination values if none are provided - if pagination == nil { - pagination = &PaginatedResult[database.Post]{ - Page: 1, - PageSize: 10, - } - } - - if pagination.Page <= 0 { - pagination.Page = 1 + if pagination != nil { + page = pagination.Page + pageSize = pagination.PageSize } - if pagination.PageSize <= 0 { - pagination.PageSize = 10 - } - - // ------------- - countQuery := query.Session(p.DB.Session()) - if err := countQuery.Count(&totalRecords).Error; err != nil { + if err := countQuery.Count(&total).Error; err != nil { return nil, err } - // Calculate pagination metadata - totalPages := int(math.Ceil(float64(totalRecords) / float64(pagination.PageSize))) - - var nextPage *int - if pagination.Page < totalPages { - p := pagination.Page + 1 - nextPage = &p - } - - var prevPage *int - if pagination.Page > 1 && pagination.Page <= totalPages { - p := pagination.Page - 1 - prevPage = &p - } - // Fetch the data for the current page - offset := (pagination.Page - 1) * pagination.PageSize + offset := (page - 1) * pageSize err := query.Preload("Author"). Preload("Categories"). Preload("Tags"). Order("posts.published_at DESC"). - Limit(pagination.PageSize). + Limit(pageSize). Offset(offset). Distinct(). Find(&posts).Error @@ -79,16 +52,7 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination *PaginatedResul return nil, err } - // Assemble the final result - result := &PaginatedResult[database.Post]{ - Data: posts, - TotalRecords: totalRecords, - CurrentPage: pagination.Page, - PageSize: pagination.PageSize, - TotalPages: totalPages, - NextPage: nextPage, - PreviousPage: prevPage, - } + result := MakePagination[database.Post](posts, page, pageSize, total) return result, nil } diff --git a/handler/posts.go b/handler/posts.go index 1710b5e9..e2dca676 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -23,7 +23,7 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { filters := queries.PostFilters{Title: ""} - pagination := repository.PaginatedResult[database.Post]{ + pagination := repository.Pagination[database.Post]{ Page: 1, PageSize: 10, } From 1b5af9ece31da04336a5fd65c06369676e21d810 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 21 Jul 2025 17:40:15 +0800 Subject: [PATCH 09/21] use URL query --- database/repository/pagination.go | 4 +++- database/repository/posts.go | 2 +- handler/posts.go | 6 +----- handler/posts/transformer.go | 33 ++++++++++++++++++++++++++++++- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/database/repository/pagination.go b/database/repository/pagination.go index e6f63a90..89f12612 100644 --- a/database/repository/pagination.go +++ b/database/repository/pagination.go @@ -2,6 +2,8 @@ package repository import "math" +const MaxLimit = 100 + // Pagination holds the data for a single page along with all pagination metadata. // It's generic and can be used for any data type. // @@ -18,7 +20,7 @@ type Pagination[T any] struct { PreviousPage *int `json:"previous_page,omitempty"` } -func MakePagination[T any](data []T, page, pageSize int, total int64) *Pagination[T] { +func Paginate[T any](data []T, page, pageSize int, total int64) *Pagination[T] { pSize := float64(pageSize) if pSize <= 0 { pSize = 10 diff --git a/database/repository/posts.go b/database/repository/posts.go index 590585bd..a78eda9d 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -52,7 +52,7 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination *Pagination[dat return nil, err } - result := MakePagination[database.Post](posts, page, pageSize, total) + result := Paginate[database.Post](posts, page, pageSize, total) return result, nil } diff --git a/handler/posts.go b/handler/posts.go index e2dca676..dfd39331 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -2,7 +2,6 @@ package handler import ( "encoding/json" - "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/database/repository/queries" "github.com/oullin/handler/posts" @@ -23,10 +22,7 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { filters := queries.PostFilters{Title: ""} - pagination := repository.Pagination[database.Post]{ - Page: 1, - PageSize: 10, - } + pagination := posts.MapPagination(r.URL.Query()) result, err := h.Posts.GetPosts(&filters, &pagination) diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go index b922553c..6367c65a 100644 --- a/handler/posts/transformer.go +++ b/handler/posts/transformer.go @@ -1,6 +1,11 @@ package posts -import "github.com/oullin/database" +import ( + "github.com/oullin/database" + "github.com/oullin/database/repository" + "net/url" + "strconv" +) func Collection(p database.Post) PostResponse { return PostResponse{ @@ -64,3 +69,29 @@ func MapTags(tags []database.Tag) []TagData { return data } + +func MapPagination(url url.Values) repository.Pagination[database.Post] { + page := 1 + pageSize := 10 + + if url.Get("page") != "" { + if tPage, err := strconv.Atoi(url.Get("page")); err == nil { + page = tPage + } + } + + if url.Get("limit") != "" { + if limit, err := strconv.Atoi(url.Get("limit")); err == nil { + pageSize = limit + } + + if pageSize > repository.MaxLimit { + pageSize = repository.MaxLimit + } + } + + return repository.Pagination[database.Post]{ + Page: page, + PageSize: pageSize, + } +} From 879d7197fe97adbbc4f6b422b03909a152bf2a92 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 09:56:44 +0800 Subject: [PATCH 10/21] extract pagination attr --- database/repository/pagination.go | 36 +++++++++++++++++++++++++------ database/repository/posts.go | 22 +++++++------------ handler/posts.go | 2 +- handler/posts/transformer.go | 8 +++---- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/database/repository/pagination.go b/database/repository/pagination.go index 89f12612..04a4c464 100644 --- a/database/repository/pagination.go +++ b/database/repository/pagination.go @@ -4,6 +4,24 @@ import "math" const MaxLimit = 100 +type PaginationAttr struct { + Page int + Limit int + NumItems int64 +} + +func (a *PaginationAttr) SetNumItems(number int64) { + a.NumItems = number +} + +func (a *PaginationAttr) GetNumItemsAsInt() int64 { + return a.NumItems +} + +func (a *PaginationAttr) GetNumItemsAsFloat() float64 { + return float64(a.NumItems) +} + // Pagination holds the data for a single page along with all pagination metadata. // It's generic and can be used for any data type. // @@ -20,20 +38,24 @@ type Pagination[T any] struct { PreviousPage *int `json:"previous_page,omitempty"` } -func Paginate[T any](data []T, page, pageSize int, total int64) *Pagination[T] { - pSize := float64(pageSize) +//func Paginate[T any](data []T, page, pageSize int, total int64) *Pagination[T] { + +func Paginate[T any](data []T, attr PaginationAttr) *Pagination[T] { + pSize := float64(attr.Limit) if pSize <= 0 { pSize = 10 } - totalPages := int(math.Ceil(float64(total) / pSize)) + totalPages := int( + math.Ceil(attr.GetNumItemsAsFloat() / pSize), + ) pagination := Pagination[T]{ Data: data, - Page: page, - Total: total, - CurrentPage: page, - PageSize: pageSize, + Page: attr.Page, + Total: attr.GetNumItemsAsInt(), + CurrentPage: attr.Page, + PageSize: attr.Limit, TotalPages: totalPages, NextPage: nil, PreviousPage: nil, diff --git a/database/repository/posts.go b/database/repository/posts.go index a78eda9d..dfb12d5c 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -14,10 +14,8 @@ type Posts struct { Tags *Tags } -func (p Posts) GetPosts(filters *queries.PostFilters, pagination *Pagination[database.Post]) (*Pagination[database.Post], error) { - page := 1 - pageSize := 10 - var total int64 +func (p Posts) GetPosts(filters *queries.PostFilters, pagination PaginationAttr) (*Pagination[database.Post], error) { + var numItems int64 var posts []database.Post query := p. @@ -27,23 +25,18 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination *Pagination[dat queries.ApplyPostsFilters(filters, query) - if pagination != nil { - page = pagination.Page - pageSize = pagination.PageSize - } - countQuery := query.Session(p.DB.Session()) - if err := countQuery.Count(&total).Error; err != nil { + if err := countQuery.Count(&numItems).Error; err != nil { return nil, err } - // Fetch the data for the current page - offset := (page - 1) * pageSize + offset := (pagination.Page - 1) * pagination.Limit + err := query.Preload("Author"). Preload("Categories"). Preload("Tags"). Order("posts.published_at DESC"). - Limit(pageSize). + Limit(pagination.Limit). Offset(offset). Distinct(). Find(&posts).Error @@ -52,7 +45,8 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination *Pagination[dat return nil, err } - result := Paginate[database.Post](posts, page, pageSize, total) + pagination.SetNumItems(numItems) + result := Paginate[database.Post](posts, pagination) return result, nil } diff --git a/handler/posts.go b/handler/posts.go index dfd39331..6cd62bd7 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -24,7 +24,7 @@ func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *h filters := queries.PostFilters{Title: ""} pagination := posts.MapPagination(r.URL.Query()) - result, err := h.Posts.GetPosts(&filters, &pagination) + result, err := h.Posts.GetPosts(&filters, pagination) if err != nil { slog.Error(err.Error()) diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go index 6367c65a..6ec31d98 100644 --- a/handler/posts/transformer.go +++ b/handler/posts/transformer.go @@ -70,7 +70,7 @@ func MapTags(tags []database.Tag) []TagData { return data } -func MapPagination(url url.Values) repository.Pagination[database.Post] { +func MapPagination(url url.Values) repository.PaginationAttr { page := 1 pageSize := 10 @@ -90,8 +90,8 @@ func MapPagination(url url.Values) repository.Pagination[database.Post] { } } - return repository.Pagination[database.Post]{ - Page: page, - PageSize: pageSize, + return repository.PaginationAttr{ + Page: page, + Limit: pageSize, } } From af6a66360324d38ed1c6572871acbd83257ad7a3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 11:05:22 +0800 Subject: [PATCH 11/21] split extraction --- database/repository/pagination/paginate.go | 23 ++++++++++ .../repository/{ => pagination}/pagination.go | 42 +++++-------------- database/repository/posts.go | 11 ++--- handler/posts.go | 16 +++---- handler/posts/transformer.go | 26 +++++++----- 5 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 database/repository/pagination/paginate.go rename database/repository/{ => pagination}/pagination.go (68%) diff --git a/database/repository/pagination/paginate.go b/database/repository/pagination/paginate.go new file mode 100644 index 00000000..0c37abb2 --- /dev/null +++ b/database/repository/pagination/paginate.go @@ -0,0 +1,23 @@ +package pagination + +type Paginate struct { + Page int + Limit int + NumItems int64 +} + +func (a *Paginate) SetNumItems(number int64) { + a.NumItems = number +} + +func (a *Paginate) GetNumItemsAsInt() int64 { + return a.NumItems +} + +func (a *Paginate) GetNumItemsAsFloat() float64 { + return float64(a.NumItems) +} + +func (a *Paginate) GetLimit() int { + return a.Limit +} diff --git a/database/repository/pagination.go b/database/repository/pagination/pagination.go similarity index 68% rename from database/repository/pagination.go rename to database/repository/pagination/pagination.go index 04a4c464..359b8dad 100644 --- a/database/repository/pagination.go +++ b/database/repository/pagination/pagination.go @@ -1,27 +1,9 @@ -package repository +package pagination import "math" const MaxLimit = 100 -type PaginationAttr struct { - Page int - Limit int - NumItems int64 -} - -func (a *PaginationAttr) SetNumItems(number int64) { - a.NumItems = number -} - -func (a *PaginationAttr) GetNumItemsAsInt() int64 { - return a.NumItems -} - -func (a *PaginationAttr) GetNumItemsAsFloat() float64 { - return float64(a.NumItems) -} - // Pagination holds the data for a single page along with all pagination metadata. // It's generic and can be used for any data type. // @@ -31,31 +13,27 @@ type Pagination[T any] struct { Data []T `json:"data"` Page int `json:"page"` Total int64 `json:"total"` - CurrentPage int `json:"current_page"` PageSize int `json:"page_size"` TotalPages int `json:"total_pages"` NextPage *int `json:"next_page,omitempty"` PreviousPage *int `json:"previous_page,omitempty"` } -//func Paginate[T any](data []T, page, pageSize int, total int64) *Pagination[T] { - -func Paginate[T any](data []T, attr PaginationAttr) *Pagination[T] { - pSize := float64(attr.Limit) +func MakePagination[T any](data []T, paginate *Paginate) *Pagination[T] { + pSize := float64(paginate.Limit) if pSize <= 0 { pSize = 10 } totalPages := int( - math.Ceil(attr.GetNumItemsAsFloat() / pSize), + math.Ceil(paginate.GetNumItemsAsFloat() / pSize), ) pagination := Pagination[T]{ Data: data, - Page: attr.Page, - Total: attr.GetNumItemsAsInt(), - CurrentPage: attr.Page, - PageSize: attr.Limit, + Page: paginate.Page, + Total: paginate.GetNumItemsAsInt(), + PageSize: paginate.Limit, TotalPages: totalPages, NextPage: nil, PreviousPage: nil, @@ -79,7 +57,7 @@ func Paginate[T any](data []T, attr PaginationAttr) *Pagination[T] { return &pagination } -// MapPaginatedResult transforms a paginated result containing items of a source type (S) +// HydratePagination transforms a paginated result containing items of a source type (S) // into a new result containing items of a destination type (D). // // It takes a source Pagination and a mapper function that defines the conversion @@ -91,7 +69,7 @@ func Paginate[T any](data []T, attr PaginationAttr) *Pagination[T] { // // The function returns a new Pagination with the transformed data, while preserving // all original pagination metadata (Total, CurrentPage, etc.). -func MapPaginatedResult[S any, D any](source *Pagination[S], mapper func(S) D) *Pagination[D] { +func HydratePagination[S any, D any](source *Pagination[S], mapper func(S) D) *Pagination[D] { mappedData := make([]D, len(source.Data)) // Iterate over the source data and apply the mapper function @@ -102,7 +80,7 @@ func MapPaginatedResult[S any, D any](source *Pagination[S], mapper func(S) D) * return &Pagination[D]{ Data: mappedData, Total: source.Total, - CurrentPage: source.CurrentPage, + Page: source.Page, PageSize: source.PageSize, TotalPages: source.TotalPages, NextPage: source.NextPage, diff --git a/database/repository/posts.go b/database/repository/posts.go index dfb12d5c..844b2da9 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/google/uuid" "github.com/oullin/database" + "github.com/oullin/database/repository/pagination" "github.com/oullin/database/repository/queries" "github.com/oullin/pkg/gorm" ) @@ -14,7 +15,7 @@ type Posts struct { Tags *Tags } -func (p Posts) GetPosts(filters *queries.PostFilters, pagination PaginationAttr) (*Pagination[database.Post], error) { +func (p Posts) GetPosts(filters *queries.PostFilters, paginate *pagination.Paginate) (*pagination.Pagination[database.Post], error) { var numItems int64 var posts []database.Post @@ -30,13 +31,13 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination PaginationAttr) return nil, err } - offset := (pagination.Page - 1) * pagination.Limit + offset := (paginate.Page - 1) * paginate.Limit err := query.Preload("Author"). Preload("Categories"). Preload("Tags"). Order("posts.published_at DESC"). - Limit(pagination.Limit). + Limit(paginate.Limit). Offset(offset). Distinct(). Find(&posts).Error @@ -45,8 +46,8 @@ func (p Posts) GetPosts(filters *queries.PostFilters, pagination PaginationAttr) return nil, err } - pagination.SetNumItems(numItems) - result := Paginate[database.Post](posts, pagination) + paginate.SetNumItems(numItems) + result := pagination.MakePagination[database.Post](posts, paginate) return result, nil } diff --git a/handler/posts.go b/handler/posts.go index 6cd62bd7..80d269f2 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -3,7 +3,7 @@ package handler import ( "encoding/json" "github.com/oullin/database/repository" - "github.com/oullin/database/repository/queries" + "github.com/oullin/database/repository/pagination" "github.com/oullin/handler/posts" "github.com/oullin/pkg/http" "log/slog" @@ -21,20 +21,20 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { } func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - filters := queries.PostFilters{Title: ""} - pagination := posts.MapPagination(r.URL.Query()) - - result, err := h.Posts.GetPosts(&filters, pagination) + result, err := h.Posts.GetPosts( + posts.GetFiltersFrom(r), + posts.GetPaginateFrom(r.URL.Query()), + ) if err != nil { slog.Error(err.Error()) - return http.InternalError("The was an issue reading the posts. Please, try later.") + return http.InternalError("The was an issue reading the posts. Please, try again later.") } - items := repository.MapPaginatedResult( + items := pagination.HydratePagination( result, - posts.Collection, + posts.GetPostsResponse, ) if err = json.NewEncoder(w).Encode(items); err != nil { diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go index 6ec31d98..b231ebe7 100644 --- a/handler/posts/transformer.go +++ b/handler/posts/transformer.go @@ -2,12 +2,14 @@ package posts import ( "github.com/oullin/database" - "github.com/oullin/database/repository" + "github.com/oullin/database/repository/pagination" + "github.com/oullin/database/repository/queries" + baseHttp "net/http" "net/url" "strconv" ) -func Collection(p database.Post) PostResponse { +func GetPostsResponse(p database.Post) PostResponse { return PostResponse{ UUID: p.UUID, Slug: p.Slug, @@ -18,8 +20,8 @@ func Collection(p database.Post) PostResponse { PublishedAt: p.PublishedAt, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, - Categories: MapCategories(p.Categories), - Tags: MapTags(p.Tags), + Categories: GetCategoriesResponse(p.Categories), + Tags: GetTagsResponse(p.Tags), Author: UserData{ UUID: p.Author.UUID, FirstName: p.Author.FirstName, @@ -36,7 +38,7 @@ func Collection(p database.Post) PostResponse { } } -func MapCategories(categories []database.Category) []CategoryData { +func GetCategoriesResponse(categories []database.Category) []CategoryData { var data []CategoryData for _, category := range categories { @@ -53,7 +55,7 @@ func MapCategories(categories []database.Category) []CategoryData { return data } -func MapTags(tags []database.Tag) []TagData { +func GetTagsResponse(tags []database.Tag) []TagData { var data []TagData for _, category := range tags { @@ -70,7 +72,7 @@ func MapTags(tags []database.Tag) []TagData { return data } -func MapPagination(url url.Values) repository.PaginationAttr { +func GetPaginateFrom(url url.Values) *pagination.Paginate { page := 1 pageSize := 10 @@ -85,13 +87,17 @@ func MapPagination(url url.Values) repository.PaginationAttr { pageSize = limit } - if pageSize > repository.MaxLimit { - pageSize = repository.MaxLimit + if pageSize > pagination.MaxLimit { + pageSize = pagination.MaxLimit } } - return repository.PaginationAttr{ + return &pagination.Paginate{ Page: page, Limit: pageSize, } } + +func GetFiltersFrom(r *baseHttp.Request) *queries.PostFilters { + return nil +} From 971b2e1b39c3ac2de68b3c09db934bba1936c83f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 11:58:25 +0800 Subject: [PATCH 12/21] work on filters --- database/connection.go | 4 +- database/repository/pagination/pagination.go | 2 +- database/repository/posts.go | 11 +++-- database/repository/queries/posts.go | 46 +++++++++++++------ .../queries/{filters.go => posts_filters.go} | 0 handler/posts/transformer.go | 8 ++-- 6 files changed, 45 insertions(+), 26 deletions(-) rename database/repository/queries/{filters.go => posts_filters.go} (100%) diff --git a/database/connection.go b/database/connection.go index 38d849e7..2550f5d2 100644 --- a/database/connection.go +++ b/database/connection.go @@ -73,8 +73,8 @@ func (c *Connection) Sql() *gorm.DB { return c.driver } -func (c *Connection) Session() *gorm.Session { - return &gorm.Session{} +func (c *Connection) GetSession() *gorm.Session { + return &gorm.Session{QueryFields: true} } func (c *Connection) Transaction(callback func(db *gorm.DB) error) error { diff --git a/database/repository/pagination/pagination.go b/database/repository/pagination/pagination.go index 359b8dad..96fcf669 100644 --- a/database/repository/pagination/pagination.go +++ b/database/repository/pagination/pagination.go @@ -19,7 +19,7 @@ type Pagination[T any] struct { PreviousPage *int `json:"previous_page,omitempty"` } -func MakePagination[T any](data []T, paginate *Paginate) *Pagination[T] { +func MakePagination[T any](data []T, paginate Paginate) *Pagination[T] { pSize := float64(paginate.Limit) if pSize <= 0 { pSize = 10 diff --git a/database/repository/posts.go b/database/repository/posts.go index 844b2da9..6dbfac61 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -15,18 +15,20 @@ type Posts struct { Tags *Tags } -func (p Posts) GetPosts(filters *queries.PostFilters, paginate *pagination.Paginate) (*pagination.Pagination[database.Post], error) { +func (p Posts) GetPosts(filters queries.PostFilters, paginate pagination.Paginate) (*pagination.Pagination[database.Post], error) { var numItems int64 var posts []database.Post query := p. DB.Sql(). Model(&database.Post{}). - Distinct("posts.id, posts.published_at") + Distinct("posts.id, posts.published_at"). + Where("posts.published_at is not null"). + Where("posts.deleted_at is null") - queries.ApplyPostsFilters(filters, query) + queries.ApplyPostsFilters(&filters, query) - countQuery := query.Session(p.DB.Session()) + countQuery := query.Session(p.DB.GetSession()) if err := countQuery.Count(&numItems).Error; err != nil { return nil, err } @@ -39,7 +41,6 @@ func (p Posts) GetPosts(filters *queries.PostFilters, paginate *pagination.Pagin Order("posts.published_at DESC"). Limit(paginate.Limit). Offset(offset). - Distinct(). Find(&posts).Error if err != nil { diff --git a/database/repository/queries/posts.go b/database/repository/queries/posts.go index b9508c41..6658f131 100644 --- a/database/repository/queries/posts.go +++ b/database/repository/queries/posts.go @@ -4,6 +4,7 @@ import ( "gorm.io/gorm" ) +// ApplyPostsFilters The given query master table is "posts" func ApplyPostsFilters(filters *PostFilters, query *gorm.DB) { if filters == nil { return @@ -15,35 +16,52 @@ func ApplyPostsFilters(filters *PostFilters, query *gorm.DB) { if filters.GetText() != "" { query. - Or("LOWER(posts.slug) ILIKE ?", "%"+filters.GetText()+"%"). - Or("LOWER(posts.excerpt) ILIKE ?", "%"+filters.GetText()+"%"). - Or("LOWER(posts.content) ILIKE ?", "%"+filters.GetText()+"%") + Where("LOWER(posts.slug) ILIKE ? OR LOWER(posts.excerpt) ILIKE ? OR LOWER(posts.content) ILIKE ?", + "%"+filters.GetText()+"%", + "%"+filters.GetText()+"%", + "%"+filters.GetText()+"%", + ) } if filters.GetAuthor() != "" { query. - Joins("Author"). - Where("LOWER(Author.username) = ?", filters.GetAuthor()). - Or("LOWER(Author.first_name) = ?", filters.GetAuthor()). - Or("LOWER(Author.last_name) = ?", filters.GetAuthor()). - Or("LOWER(Author.display_name) = ?", filters.GetAuthor()) + Joins("JOIN users ON posts.author_id = users.id"). + Where("users.deleted_at IS NULL"). + Where("("+ + "LOWER(users.bio) ILIKE ? OR LOWER(users.first_name) ILIKE ? OR LOWER(users.last_name) ILIKE ? OR LOWER(users.display_name) ILIKE ?"+ + ")", + "%"+filters.GetAuthor()+"%", + "%"+filters.GetAuthor()+"%", + "%"+filters.GetAuthor()+"%", + "%"+filters.GetAuthor()+"%", + ) } if filters.GetCategory() != "" { query. Joins("JOIN post_categories ON post_categories.post_id = posts.id"). Joins("JOIN categories ON categories.id = post_categories.category_id"). - Where("LOWER(categories.slug) = ?", filters.GetCategory()). - Or("LOWER(categories.name) = ?", filters.GetCategory()). - Or("LOWER(categories.description) = ?", "%"+filters.GetCategory()+"%") + Where("categories.deleted_at IS NULL"). + Where("("+ + "LOWER(categories.slug) = ? OR LOWER(categories.name) = ? OR LOWER(categories.description) = ?"+ + ")", + "%"+filters.GetCategory()+"%", + "%"+filters.GetCategory()+"%", + "%"+filters.GetCategory()+"%", + ) } if filters.GetTag() != "" { query. Joins("JOIN post_tags ON post_tags.post_id = posts.id"). Joins("JOIN tags ON tags.id = post_tags.tag_id"). - Where("LOWER(tags.slug) = ?", filters.GetTag()). - Or("LOWER(tags.name) = ?", filters.GetTag()). - Or("LOWER(tags.description) = ?", "%"+filters.GetTag()+"%") + Where("tags.deleted_at IS NULL"). + Where("("+ + "LOWER(tags.slug) = ? OR LOWER(tags.name) = ? OR LOWER(tags.description) = ?"+ + ")", + "%"+filters.GetTag()+"%", + "%"+filters.GetTag()+"%", + "%"+filters.GetTag()+"%", + ) } } diff --git a/database/repository/queries/filters.go b/database/repository/queries/posts_filters.go similarity index 100% rename from database/repository/queries/filters.go rename to database/repository/queries/posts_filters.go diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go index b231ebe7..88101adb 100644 --- a/handler/posts/transformer.go +++ b/handler/posts/transformer.go @@ -72,7 +72,7 @@ func GetTagsResponse(tags []database.Tag) []TagData { return data } -func GetPaginateFrom(url url.Values) *pagination.Paginate { +func GetPaginateFrom(url url.Values) pagination.Paginate { page := 1 pageSize := 10 @@ -92,12 +92,12 @@ func GetPaginateFrom(url url.Values) *pagination.Paginate { } } - return &pagination.Paginate{ + return pagination.Paginate{ Page: page, Limit: pageSize, } } -func GetFiltersFrom(r *baseHttp.Request) *queries.PostFilters { - return nil +func GetFiltersFrom(r *baseHttp.Request) queries.PostFilters { + return queries.PostFilters{} } From 75691396067196992800362e016167c5ead9bec1 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 12:09:03 +0800 Subject: [PATCH 13/21] format --- database/repository/pagination/pagination.go | 1 + handler/posts.go | 2 +- handler/posts/response.go | 3 -- handler/posts/transformer.go | 30 +++++++++++--------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/database/repository/pagination/pagination.go b/database/repository/pagination/pagination.go index 96fcf669..0e957808 100644 --- a/database/repository/pagination/pagination.go +++ b/database/repository/pagination/pagination.go @@ -2,6 +2,7 @@ package pagination import "math" +const MinPage = 1 const MaxLimit = 100 // Pagination holds the data for a single page along with all pagination metadata. diff --git a/handler/posts.go b/handler/posts.go index 80d269f2..a963a153 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -40,7 +40,7 @@ func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *h if err = json.NewEncoder(w).Encode(items); err != nil { slog.Error(err.Error()) - return http.InternalError(err.Error()) + return http.InternalError("There was an issue processing the response. Please, try later.") } return nil diff --git a/handler/posts/response.go b/handler/posts/response.go index a4484faa..c801c94c 100644 --- a/handler/posts/response.go +++ b/handler/posts/response.go @@ -51,7 +51,4 @@ type TagData struct { Description string `json:"description"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - - // Associations - Posts []PostResponse `json:"posts"` } diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go index 88101adb..cfddb5aa 100644 --- a/handler/posts/transformer.go +++ b/handler/posts/transformer.go @@ -33,7 +33,7 @@ func GetPostsResponse(p database.Post) PostResponse { ProfilePictureURL: p.Author.ProfilePictureURL, IsAdmin: p.Author.IsAdmin, CreatedAt: p.Author.CreatedAt, - UpdatedAt: p.UpdatedAt, + UpdatedAt: p.Author.UpdatedAt, }, } } @@ -58,14 +58,14 @@ func GetCategoriesResponse(categories []database.Category) []CategoryData { func GetTagsResponse(tags []database.Tag) []TagData { var data []TagData - for _, category := range tags { + for _, tag := range tags { data = append(data, TagData{ - UUID: category.UUID, - Name: category.Name, - Slug: category.Slug, - Description: category.Description, - CreatedAt: category.CreatedAt, - UpdatedAt: category.UpdatedAt, + UUID: tag.UUID, + Name: tag.Name, + Slug: tag.Slug, + Description: tag.Description, + CreatedAt: tag.CreatedAt, + UpdatedAt: tag.UpdatedAt, }) } @@ -73,8 +73,8 @@ func GetTagsResponse(tags []database.Tag) []TagData { } func GetPaginateFrom(url url.Values) pagination.Paginate { - page := 1 - pageSize := 10 + page := pagination.MinPage + pageSize := pagination.MaxLimit if url.Get("page") != "" { if tPage, err := strconv.Atoi(url.Get("page")); err == nil { @@ -86,10 +86,14 @@ func GetPaginateFrom(url url.Values) pagination.Paginate { if limit, err := strconv.Atoi(url.Get("limit")); err == nil { pageSize = limit } + } - if pageSize > pagination.MaxLimit { - pageSize = pagination.MaxLimit - } + if page < pagination.MinPage { + page = pagination.MinPage + } + + if pageSize > pagination.MaxLimit { + pageSize = pagination.MaxLimit } return pagination.Paginate{ From 59f52453345a4850decdb0db3e6d67544da4a242 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 13:11:53 +0800 Subject: [PATCH 14/21] tweaks --- boost/router.go | 2 +- database/repository/queries/posts.go | 4 ++-- handler/posts.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/boost/router.go b/boost/router.go index 85699757..2d2e1a01 100644 --- a/boost/router.go +++ b/boost/router.go @@ -39,7 +39,7 @@ func (r *Router) Posts() { abstract.Handle, ) - r.Mux.HandleFunc("/posts", resolver) + r.Mux.HandleFunc("GET /posts", resolver) } func (r *Router) Profile() { diff --git a/database/repository/queries/posts.go b/database/repository/queries/posts.go index 6658f131..f0bac495 100644 --- a/database/repository/queries/posts.go +++ b/database/repository/queries/posts.go @@ -43,7 +43,7 @@ func ApplyPostsFilters(filters *PostFilters, query *gorm.DB) { Joins("JOIN categories ON categories.id = post_categories.category_id"). Where("categories.deleted_at IS NULL"). Where("("+ - "LOWER(categories.slug) = ? OR LOWER(categories.name) = ? OR LOWER(categories.description) = ?"+ + "LOWER(categories.slug) ILIKE ? OR LOWER(categories.name) ILIKE ? OR LOWER(categories.description) ILIKE ?"+ ")", "%"+filters.GetCategory()+"%", "%"+filters.GetCategory()+"%", @@ -57,7 +57,7 @@ func ApplyPostsFilters(filters *PostFilters, query *gorm.DB) { Joins("JOIN tags ON tags.id = post_tags.tag_id"). Where("tags.deleted_at IS NULL"). Where("("+ - "LOWER(tags.slug) = ? OR LOWER(tags.name) = ? OR LOWER(tags.description) = ?"+ + "LOWER(tags.slug) ILIKE ? OR LOWER(tags.name) ILIKE ? OR LOWER(tags.description) ILIKE ?"+ ")", "%"+filters.GetTag()+"%", "%"+filters.GetTag()+"%", diff --git a/handler/posts.go b/handler/posts.go index a963a153..b6e017eb 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -29,7 +29,7 @@ func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *h if err != nil { slog.Error(err.Error()) - return http.InternalError("The was an issue reading the posts. Please, try again later.") + return http.InternalError("There was an issue reading the posts. Please, try again later.") } items := pagination.HydratePagination( From 14f18ea1446986fbf3e83866cac3211c6d1009fa Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 13:43:36 +0800 Subject: [PATCH 15/21] fix query + naming --- boost/router.go | 6 +++--- database/repository/posts.go | 14 ++++++++------ handler/posts.go | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/boost/router.go b/boost/router.go index 2d2e1a01..6945abe4 100644 --- a/boost/router.go +++ b/boost/router.go @@ -35,11 +35,11 @@ func (r *Router) Posts() { repo := repository.Posts{DB: r.Db} abstract := handler.MakePostsHandler(&repo) - resolver := r.PipelineFor( - abstract.Handle, + index := r.PipelineFor( + abstract.Index, ) - r.Mux.HandleFunc("GET /posts", resolver) + r.Mux.HandleFunc("GET /posts", index) } func (r *Router) Profile() { diff --git a/database/repository/posts.go b/database/repository/posts.go index 6dbfac61..da3603b7 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -19,16 +19,17 @@ func (p Posts) GetPosts(filters queries.PostFilters, paginate pagination.Paginat var numItems int64 var posts []database.Post - query := p. - DB.Sql(). + query := p.DB.Sql(). Model(&database.Post{}). - Distinct("posts.id, posts.published_at"). - Where("posts.published_at is not null"). - Where("posts.deleted_at is null") + Where("posts.published_at is not null"). // only published posts will be selected. + Where("posts.deleted_at is null") // deleted posted will be discarded. queries.ApplyPostsFilters(&filters, query) - countQuery := query.Session(p.DB.GetSession()) + countQuery := query. + Session(p.DB.GetSession()). // clone the based query. + Distinct("posts.id") // remove duplicated posts to get the actual count. + if err := countQuery.Count(&numItems).Error; err != nil { return nil, err } @@ -41,6 +42,7 @@ func (p Posts) GetPosts(filters queries.PostFilters, paginate pagination.Paginat Order("posts.published_at DESC"). Limit(paginate.Limit). Offset(offset). + Distinct(). // remove duplications if any after applying JOINS Find(&posts).Error if err != nil { diff --git a/handler/posts.go b/handler/posts.go index b6e017eb..91c3e165 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -20,7 +20,7 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { } } -func (h *PostsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { +func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { result, err := h.Posts.GetPosts( posts.GetFiltersFrom(r), posts.GetPaginateFrom(r.URL.Query()), From 3cfe3d7afba4da8deb1d1858bd8c558a652c82c3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 13:45:00 +0800 Subject: [PATCH 16/21] format --- database/repository/posts.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/database/repository/posts.go b/database/repository/posts.go index da3603b7..096fdfbb 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -22,13 +22,13 @@ func (p Posts) GetPosts(filters queries.PostFilters, paginate pagination.Paginat query := p.DB.Sql(). Model(&database.Post{}). Where("posts.published_at is not null"). // only published posts will be selected. - Where("posts.deleted_at is null") // deleted posted will be discarded. + Where("posts.deleted_at is null") // deleted posted will be discarded. queries.ApplyPostsFilters(&filters, query) countQuery := query. Session(p.DB.GetSession()). // clone the based query. - Distinct("posts.id") // remove duplicated posts to get the actual count. + Distinct("posts.id") // remove duplicated posts to get the actual count. if err := countQuery.Count(&numItems).Error; err != nil { return nil, err From f7c7e7df27afd8e7a412ac5fdbda61664eed0b3c Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 14:25:45 +0800 Subject: [PATCH 17/21] add show endpoint --- boost/router.go | 6 ++--- database/repository/posts.go | 21 ++++++++++++++++++ database/repository/queries/posts_filters.go | 7 +++--- handler/posts.go | 23 ++++++++++++++++++++ handler/posts/transformer.go | 8 +++++++ pkg/http/response.go | 16 +++++++++++++- pkg/stringable.go | 8 +++++++ 7 files changed, 81 insertions(+), 8 deletions(-) diff --git a/boost/router.go b/boost/router.go index 6945abe4..be4921ee 100644 --- a/boost/router.go +++ b/boost/router.go @@ -35,11 +35,11 @@ func (r *Router) Posts() { repo := repository.Posts{DB: r.Db} abstract := handler.MakePostsHandler(&repo) - index := r.PipelineFor( - abstract.Index, - ) + index := r.PipelineFor(abstract.Index) + show := r.PipelineFor(abstract.Show) r.Mux.HandleFunc("GET /posts", index) + r.Mux.HandleFunc("GET /posts/{slug}", show) } func (r *Router) Profile() { diff --git a/database/repository/posts.go b/database/repository/posts.go index 096fdfbb..4b224e9f 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -55,6 +55,27 @@ func (p Posts) GetPosts(filters queries.PostFilters, paginate pagination.Paginat return result, nil } +func (p Posts) FindBy(slug string) *database.Post { + post := database.Post{} + + result := p.DB.Sql(). + Preload("Author"). + Preload("Categories"). + Preload("Tags"). + Where("LOWER(slug) = ?", slug). + First(&post) + + if gorm.HasDbIssues(result.Error) { + return nil + } + + if result.RowsAffected > 0 { + return &post + } + + return nil +} + func (p Posts) FindCategoryBy(slug string) *database.Category { return p.Categories.FindBy(slug) } diff --git a/database/repository/queries/posts_filters.go b/database/repository/queries/posts_filters.go index a9d303e5..2f335fa2 100644 --- a/database/repository/queries/posts_filters.go +++ b/database/repository/queries/posts_filters.go @@ -1,8 +1,7 @@ package queries import ( - "golang.org/x/text/cases" - "golang.org/x/text/language" + "github.com/oullin/pkg" "strings" ) @@ -36,7 +35,7 @@ func (f PostFilters) GetTag() string { } func (f PostFilters) sanitiseString(seed string) string { - caser := cases.Lower(language.English) + str := pkg.MakeStringable(seed) - return strings.TrimSpace(caser.String(seed)) + return strings.TrimSpace(str.ToLower()) } diff --git a/handler/posts.go b/handler/posts.go index 91c3e165..2fd197c5 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "fmt" "github.com/oullin/database/repository" "github.com/oullin/database/repository/pagination" "github.com/oullin/handler/posts" @@ -45,3 +46,25 @@ func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *ht return nil } + +func (h *PostsHandler) Show(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + slug := posts.GetSlugFrom(r) + + if slug == "" { + return http.BadRequestError("Slugs are required to show posts content") + } + + post := h.Posts.FindBy(slug) + if post == nil { + return http.NotFound(fmt.Sprintf("The given post '%s' was not found", slug)) + } + + items := posts.GetPostsResponse(*post) + if err := json.NewEncoder(w).Encode(items); err != nil { + slog.Error(err.Error()) + + return http.InternalError("There was an issue processing the response. Please, try later.") + } + + return nil +} diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go index cfddb5aa..3571216c 100644 --- a/handler/posts/transformer.go +++ b/handler/posts/transformer.go @@ -4,9 +4,11 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository/pagination" "github.com/oullin/database/repository/queries" + "github.com/oullin/pkg" baseHttp "net/http" "net/url" "strconv" + "strings" ) func GetPostsResponse(p database.Post) PostResponse { @@ -105,3 +107,9 @@ func GetPaginateFrom(url url.Values) pagination.Paginate { func GetFiltersFrom(r *baseHttp.Request) queries.PostFilters { return queries.PostFilters{} } + +func GetSlugFrom(r *baseHttp.Request) string { + str := pkg.MakeStringable(r.PathValue("slug")) + + return strings.TrimSpace(str.ToLower()) +} diff --git a/pkg/http/response.go b/pkg/http/response.go index 0eb8129d..d589a94f 100644 --- a/pkg/http/response.go +++ b/pkg/http/response.go @@ -67,7 +67,21 @@ func (r *Response) RespondWithNotModified() { func InternalError(msg string) *ApiError { return &ApiError{ - Message: fmt.Sprintf("Internal Server Error: %s", msg), + Message: fmt.Sprintf("Internal server error: %s", msg), Status: baseHttp.StatusInternalServerError, } } + +func BadRequestError(msg string) *ApiError { + return &ApiError{ + Message: fmt.Sprintf("Bad request error: %s", msg), + Status: baseHttp.StatusBadRequest, + } +} + +func NotFound(msg string) *ApiError { + return &ApiError{ + Message: fmt.Sprintf("Not found error: %s", msg), + Status: baseHttp.StatusNotFound, + } +} diff --git a/pkg/stringable.go b/pkg/stringable.go index 6ee000ac..429a4d52 100644 --- a/pkg/stringable.go +++ b/pkg/stringable.go @@ -2,6 +2,8 @@ package pkg import ( "fmt" + "golang.org/x/text/cases" + "golang.org/x/text/language" "strings" "time" "unicode" @@ -17,6 +19,12 @@ func MakeStringable(value string) *Stringable { } } +func (s Stringable) ToLower() string { + caser := cases.Lower(language.English) + + return strings.TrimSpace(caser.String(s.value)) +} + func (s Stringable) ToSnakeCase() string { var result strings.Builder From ffa800eff31849cc00d86873fcf6cef14e53afb7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 15:18:44 +0800 Subject: [PATCH 18/21] implement filtering --- database/repository/queries/posts.go | 2 +- database/repository/queries/posts_filters.go | 11 +++--- handler/posts.go | 13 +++++-- handler/posts/response.go | 8 +++++ handler/posts/transformer.go | 9 +++-- pkg/http/request.go | 37 ++++++++++++++++++++ 6 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 pkg/http/request.go diff --git a/database/repository/queries/posts.go b/database/repository/queries/posts.go index f0bac495..f26dc79e 100644 --- a/database/repository/queries/posts.go +++ b/database/repository/queries/posts.go @@ -28,7 +28,7 @@ func ApplyPostsFilters(filters *PostFilters, query *gorm.DB) { Joins("JOIN users ON posts.author_id = users.id"). Where("users.deleted_at IS NULL"). Where("("+ - "LOWER(users.bio) ILIKE ? OR LOWER(users.first_name) ILIKE ? OR LOWER(users.last_name) ILIKE ? OR LOWER(users.display_name) ILIKE ?"+ + "LOWER(users.bio) ILIKE ? OR LOWER(users.first_name) LIKE ? OR LOWER(users.last_name) LIKE ? OR LOWER(users.display_name) ILIKE ?"+ ")", "%"+filters.GetAuthor()+"%", "%"+filters.GetAuthor()+"%", diff --git a/database/repository/queries/posts_filters.go b/database/repository/queries/posts_filters.go index 2f335fa2..20ef2e9e 100644 --- a/database/repository/queries/posts_filters.go +++ b/database/repository/queries/posts_filters.go @@ -6,12 +6,11 @@ import ( ) type PostFilters struct { - Text string - Title string // Will perform a case-insensitive partial match - Author string - Category string - Tag string - IsPublished *bool // Pointer to bool to allow three states: true, false, and not-set (nil) + Text string + Title string // Will perform a case-insensitive partial match + Author string + Category string + Tag string } func (f PostFilters) GetText() string { diff --git a/handler/posts.go b/handler/posts.go index 2fd197c5..69abb174 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -22,8 +22,17 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { } func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + payload, closer, err := http.ParseRequestBody[posts.IndexRequestBody](r) + closer() //close the given request body. + + if err != nil { + slog.Error(err.Error()) + + return http.InternalError("There was an issue reading the request. Please, try again later." + err.Error()) + } + result, err := h.Posts.GetPosts( - posts.GetFiltersFrom(r), + posts.GetFiltersFrom(payload), posts.GetPaginateFrom(r.URL.Query()), ) @@ -38,7 +47,7 @@ func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *ht posts.GetPostsResponse, ) - if err = json.NewEncoder(w).Encode(items); err != nil { + if err := json.NewEncoder(w).Encode(items); err != nil { slog.Error(err.Error()) return http.InternalError("There was an issue processing the response. Please, try later.") diff --git a/handler/posts/response.go b/handler/posts/response.go index c801c94c..ea07333c 100644 --- a/handler/posts/response.go +++ b/handler/posts/response.go @@ -4,6 +4,14 @@ import ( "time" ) +type IndexRequestBody struct { + Title string `json:"title"` + Author string `json:"author"` + Category string `json:"category"` + Tag string `json:"tag"` + Text string `json:"text"` +} + type PostResponse struct { UUID string `json:"uuid"` Author UserData `json:"author"` diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go index 3571216c..252d3fa1 100644 --- a/handler/posts/transformer.go +++ b/handler/posts/transformer.go @@ -104,8 +104,13 @@ func GetPaginateFrom(url url.Values) pagination.Paginate { } } -func GetFiltersFrom(r *baseHttp.Request) queries.PostFilters { - return queries.PostFilters{} +func GetFiltersFrom(request IndexRequestBody) queries.PostFilters { + return queries.PostFilters{ + Title: request.Title, + Author: request.Author, + Category: request.Category, + Tag: request.Tag, + } } func GetSlugFrom(r *baseHttp.Request) string { diff --git a/pkg/http/request.go b/pkg/http/request.go new file mode 100644 index 00000000..3932e28f --- /dev/null +++ b/pkg/http/request.go @@ -0,0 +1,37 @@ +package http + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + baseHttp "net/http" +) + +func ParseRequestBody[T any](r *baseHttp.Request) (T, func(), error) { + var err error + var request T + var data []byte + + closer := func() { + defer func(Body io.ReadCloser) { + if issue := Body.Close(); issue != nil { + slog.Error("ParseRequestBody: " + issue.Error()) + } + }(r.Body) + } + + if data, err = io.ReadAll(r.Body); err != nil { + return request, closer, fmt.Errorf("failed to read the given request body: %w", err) + } + + if len(data) == 0 { + return request, closer, nil + } + + if err = json.Unmarshal(data, &request); err != nil { + return request, closer, fmt.Errorf("failed to unmarshal the given request body: %w", err) + } + + return request, closer, nil +} From dd68891c37cf8410f335ba7b038819330893dfe3 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 15:32:33 +0800 Subject: [PATCH 19/21] validations + format --- handler/posts.go | 2 +- handler/posts/transformer.go | 2 +- pkg/http/request.go | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/handler/posts.go b/handler/posts.go index 69abb174..6d3ff0f1 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -28,7 +28,7 @@ func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *ht if err != nil { slog.Error(err.Error()) - return http.InternalError("There was an issue reading the request. Please, try again later." + err.Error()) + return http.InternalError("There was an issue reading the request. Please, try again later.") } result, err := h.Posts.GetPosts( diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go index 252d3fa1..b87177e8 100644 --- a/handler/posts/transformer.go +++ b/handler/posts/transformer.go @@ -94,7 +94,7 @@ func GetPaginateFrom(url url.Values) pagination.Paginate { page = pagination.MinPage } - if pageSize > pagination.MaxLimit { + if pageSize > pagination.MaxLimit || pageSize < 1 { pageSize = pagination.MaxLimit } diff --git a/pkg/http/request.go b/pkg/http/request.go index 3932e28f..9e9ebdfe 100644 --- a/pkg/http/request.go +++ b/pkg/http/request.go @@ -8,6 +8,8 @@ import ( baseHttp "net/http" ) +const MaxRequestSize = 1 << 20 // 1MB limit + func ParseRequestBody[T any](r *baseHttp.Request) (T, func(), error) { var err error var request T @@ -21,7 +23,8 @@ func ParseRequestBody[T any](r *baseHttp.Request) (T, func(), error) { }(r.Body) } - if data, err = io.ReadAll(r.Body); err != nil { + limitedReader := io.LimitReader(r.Body, MaxRequestSize) + if data, err = io.ReadAll(limitedReader); err != nil { return request, closer, fmt.Errorf("failed to read the given request body: %w", err) } From 9a63dae37662750dbf87a9a2b5ae49bc5f6a0bd8 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 16:00:13 +0800 Subject: [PATCH 20/21] defear closer --- handler/posts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handler/posts.go b/handler/posts.go index 6d3ff0f1..ed84a838 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -23,7 +23,7 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { payload, closer, err := http.ParseRequestBody[posts.IndexRequestBody](r) - closer() //close the given request body. + defer closer() //close the given request body. if err != nil { slog.Error(err.Error()) From 021dc820c6c467f4b4c4b9f74632c680047a63dd Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 22 Jul 2025 16:12:29 +0800 Subject: [PATCH 21/21] close with logs instead --- handler/posts.go | 12 +++++++----- pkg/http/request.go | 19 +++++-------------- pkg/support.go | 12 ++++++++++++ 3 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 pkg/support.go diff --git a/handler/posts.go b/handler/posts.go index ed84a838..b7aa2157 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -6,6 +6,7 @@ import ( "github.com/oullin/database/repository" "github.com/oullin/database/repository/pagination" "github.com/oullin/handler/posts" + "github.com/oullin/pkg" "github.com/oullin/pkg/http" "log/slog" baseHttp "net/http" @@ -22,11 +23,12 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { } func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - payload, closer, err := http.ParseRequestBody[posts.IndexRequestBody](r) - defer closer() //close the given request body. + defer pkg.CloseWithLog(r.Body) + + payload, err := http.ParseRequestBody[posts.IndexRequestBody](r) if err != nil { - slog.Error(err.Error()) + slog.Error("failed to parse request body", "err", err) return http.InternalError("There was an issue reading the request. Please, try again later.") } @@ -37,7 +39,7 @@ func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *ht ) if err != nil { - slog.Error(err.Error()) + slog.Error("failed to fetch posts", "err", err) return http.InternalError("There was an issue reading the posts. Please, try again later.") } @@ -48,7 +50,7 @@ func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *ht ) if err := json.NewEncoder(w).Encode(items); err != nil { - slog.Error(err.Error()) + slog.Error("failed to encode response", "err", err) return http.InternalError("There was an issue processing the response. Please, try later.") } diff --git a/pkg/http/request.go b/pkg/http/request.go index 9e9ebdfe..5218ac04 100644 --- a/pkg/http/request.go +++ b/pkg/http/request.go @@ -4,37 +4,28 @@ import ( "encoding/json" "fmt" "io" - "log/slog" baseHttp "net/http" ) const MaxRequestSize = 1 << 20 // 1MB limit -func ParseRequestBody[T any](r *baseHttp.Request) (T, func(), error) { +func ParseRequestBody[T any](r *baseHttp.Request) (T, error) { var err error var request T var data []byte - closer := func() { - defer func(Body io.ReadCloser) { - if issue := Body.Close(); issue != nil { - slog.Error("ParseRequestBody: " + issue.Error()) - } - }(r.Body) - } - limitedReader := io.LimitReader(r.Body, MaxRequestSize) if data, err = io.ReadAll(limitedReader); err != nil { - return request, closer, fmt.Errorf("failed to read the given request body: %w", err) + return request, fmt.Errorf("failed to read the given request body: %w", err) } if len(data) == 0 { - return request, closer, nil + return request, nil } if err = json.Unmarshal(data, &request); err != nil { - return request, closer, fmt.Errorf("failed to unmarshal the given request body: %w", err) + return request, fmt.Errorf("failed to unmarshal the given request body: %w", err) } - return request, closer, nil + return request, nil } diff --git a/pkg/support.go b/pkg/support.go new file mode 100644 index 00000000..b1f1bf47 --- /dev/null +++ b/pkg/support.go @@ -0,0 +1,12 @@ +package pkg + +import ( + "io" + "log/slog" +) + +func CloseWithLog(c io.Closer) { + if err := c.Close(); err != nil { + slog.Error("failed to close resource", "err", err) + } +}