diff --git a/boost/app.go b/boost/app.go index 19ce435a..34f760c9 100644 --- a/boost/app.go +++ b/boost/app.go @@ -71,4 +71,5 @@ func (a *App) Boot() { router.Education() router.Recommendations() router.Posts() + router.Categories() } diff --git a/boost/router.go b/boost/router.go index be4921ee..95c88a91 100644 --- a/boost/router.go +++ b/boost/router.go @@ -38,10 +38,19 @@ func (r *Router) Posts() { index := r.PipelineFor(abstract.Index) show := r.PipelineFor(abstract.Show) - r.Mux.HandleFunc("GET /posts", index) + r.Mux.HandleFunc("POST /posts", index) r.Mux.HandleFunc("GET /posts/{slug}", show) } +func (r *Router) Categories() { + repo := repository.Categories{DB: r.Db} + abstract := handler.MakeCategoriesHandler(&repo) + + index := r.PipelineFor(abstract.Index) + + r.Mux.HandleFunc("GET /categories", index) +} + func (r *Router) Profile() { abstract := handler.MakeProfileHandler("./storage/fixture/profile.json") diff --git a/database/repository/categories.go b/database/repository/categories.go index b7e65d2c..17a2f92d 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/google/uuid" "github.com/oullin/database" + "github.com/oullin/database/repository/pagination" "github.com/oullin/pkg/gorm" "strings" ) @@ -12,6 +13,44 @@ type Categories struct { DB *database.Connection } +func (c Categories) GetAll(paginate pagination.Paginate) (*pagination.Pagination[database.Category], error) { + var numItems int64 + var categories []database.Category + + query := c.DB.Sql(). + Model(&database.Category{}). + Joins("JOIN post_categories ON post_categories.category_id = categories.id"). + Joins("JOIN posts ON posts.id = post_categories.post_id"). + Where("categories.deleted_at is null"). + Where("posts.deleted_at is null"). + Where("posts.published_at is not null") + + group := "categories.id, categories.slug" + + if err := pagination.Count[*int64](&numItems, query, c.DB.GetSession(), group); err != nil { + return nil, err + } + + offset := (paginate.Page - 1) * paginate.Limit + + err := query. + Preload("Posts", "posts.deleted_at IS NULL AND posts.published_at IS NOT NULL"). + Offset(offset). + Limit(paginate.Limit). + Order("categories.name asc"). + Group(group). + Find(&categories).Error + + if err != nil { + return nil, err + } + + paginate.SetNumItems(numItems) + result := pagination.MakePagination[database.Category](categories, paginate) + + return result, nil +} + func (c Categories) FindBy(slug string) *database.Category { category := database.Category{} diff --git a/database/repository/pagination/pagination.go b/database/repository/pagination/pagination.go index 0e957808..c9600c7b 100644 --- a/database/repository/pagination/pagination.go +++ b/database/repository/pagination/pagination.go @@ -3,7 +3,8 @@ package pagination import "math" const MinPage = 1 -const MaxLimit = 100 +const PostsMaxLimit = 10 +const CategoriesMaxLimit = 5 // Pagination holds the data for a single page along with all pagination metadata. // It's generic and can be used for any data type. diff --git a/database/repository/pagination/support.go b/database/repository/pagination/support.go new file mode 100644 index 00000000..f8c99ba0 --- /dev/null +++ b/database/repository/pagination/support.go @@ -0,0 +1,15 @@ +package pagination + +import "gorm.io/gorm" + +func Count[T *int64](numItems T, query *gorm.DB, session *gorm.Session, distinct string) error { + sql := query. + Session(session). // clone the based query. + Distinct(distinct) // remove duplicated; if any to get the actual count. + + if err := sql.Count(numItems).Error; err != nil { + return err + } + + return nil +} diff --git a/database/repository/posts.go b/database/repository/posts.go index 4b224e9f..0c6e5220 100644 --- a/database/repository/posts.go +++ b/database/repository/posts.go @@ -15,7 +15,7 @@ type Posts struct { Tags *Tags } -func (p Posts) GetPosts(filters queries.PostFilters, paginate pagination.Paginate) (*pagination.Pagination[database.Post], error) { +func (p Posts) GetAll(filters queries.PostFilters, paginate pagination.Paginate) (*pagination.Pagination[database.Post], error) { var numItems int64 var posts []database.Post @@ -26,11 +26,7 @@ func (p Posts) GetPosts(filters queries.PostFilters, paginate pagination.Paginat 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. - - if err := countQuery.Count(&numItems).Error; err != nil { + if err := pagination.Count[*int64](&numItems, query, p.DB.GetSession(), "posts.id"); err != nil { return nil, err } diff --git a/handler/categories.go b/handler/categories.go new file mode 100644 index 00000000..1ef2c451 --- /dev/null +++ b/handler/categories.go @@ -0,0 +1,54 @@ +package handler + +import ( + "encoding/json" + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/database/repository/pagination" + "github.com/oullin/handler/paginate" + "github.com/oullin/handler/payload" + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" +) + +type CategoriesHandler struct { + Categories *repository.Categories +} + +func MakeCategoriesHandler(categories *repository.Categories) CategoriesHandler { + return CategoriesHandler{ + Categories: categories, + } +} + +func (h *CategoriesHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + result, err := h.Categories.GetAll( + paginate.MakeFrom(r.URL, 5), + ) + + if err != nil { + slog.Error("Error getting categories", "err", err) + return http.InternalError("Error getting categories") + } + + items := pagination.HydratePagination( + result, + func(s database.Category) payload.CategoryResponse { + return payload.CategoryResponse{ + UUID: s.UUID, + Name: s.Name, + Slug: s.Slug, + Description: s.Description, + } + }, + ) + + if err := json.NewEncoder(w).Encode(items); err != nil { + slog.Error("failed to encode response", "err", err) + + return http.InternalError("There was an issue processing the response. Please, try later.") + } + + return nil +} diff --git a/handler/paginate/paginate.go b/handler/paginate/paginate.go new file mode 100644 index 00000000..5251b9a6 --- /dev/null +++ b/handler/paginate/paginate.go @@ -0,0 +1,43 @@ +package paginate + +import ( + "github.com/oullin/database/repository/pagination" + "net/url" + "strconv" + "strings" +) + +func MakeFrom(url *url.URL, pageSize int) pagination.Paginate { + page := pagination.MinPage + values := url.Query() + path := strings.TrimSpace((*url).Path) + + if values.Get("page") != "" { + if tPage, err := strconv.Atoi(values.Get("page")); err == nil { + page = tPage + } + } + + if values.Get("limit") != "" { + if limit, err := strconv.Atoi(values.Get("limit")); err == nil { + pageSize = limit + } + } + + if strings.Contains(path, "categories") && pageSize > pagination.CategoriesMaxLimit { + pageSize = pagination.CategoriesMaxLimit + } + + if strings.Contains(path, "posts") && pageSize > pagination.PostsMaxLimit { + pageSize = pagination.PostsMaxLimit + } + + if page < pagination.MinPage { + page = pagination.MinPage + } + + return pagination.Paginate{ + Page: page, + Limit: pageSize, + } +} diff --git a/handler/payload/categories.go b/handler/payload/categories.go new file mode 100644 index 00000000..fedb7380 --- /dev/null +++ b/handler/payload/categories.go @@ -0,0 +1,25 @@ +package payload + +import "github.com/oullin/database" + +type CategoryResponse struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` +} + +func GetCategoriesResponse(categories []database.Category) []CategoryResponse { + var data []CategoryResponse + + for _, category := range categories { + data = append(data, CategoryResponse{ + UUID: category.UUID, + Name: category.Name, + Slug: category.Slug, + Description: category.Description, + }) + } + + return data +} diff --git a/handler/payload/posts.go b/handler/payload/posts.go new file mode 100644 index 00000000..29381850 --- /dev/null +++ b/handler/payload/posts.go @@ -0,0 +1,78 @@ +package payload + +import ( + "github.com/oullin/database" + "github.com/oullin/database/repository/queries" + "github.com/oullin/pkg" + baseHttp "net/http" + "strings" + "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 UserResponse `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 []CategoryResponse `json:"categories"` + Tags []TagResponse `json:"tags"` +} + +func GetPostsFiltersFrom(request IndexRequestBody) queries.PostFilters { + return queries.PostFilters{ + Title: request.Title, + Author: request.Author, + Category: request.Category, + Tag: request.Tag, + Text: request.Text, + } +} + +func GetSlugFrom(r *baseHttp.Request) string { + str := pkg.MakeStringable(r.PathValue("slug")) + + return strings.TrimSpace(str.ToLower()) +} + +func GetPostsResponse(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: GetCategoriesResponse(p.Categories), + Tags: GetTagsResponse(p.Tags), + Author: UserResponse{ + 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, + }, + } +} diff --git a/handler/payload/tags.go b/handler/payload/tags.go new file mode 100644 index 00000000..6a4c2472 --- /dev/null +++ b/handler/payload/tags.go @@ -0,0 +1,25 @@ +package payload + +import "github.com/oullin/database" + +type TagResponse struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` +} + +func GetTagsResponse(tags []database.Tag) []TagResponse { + var data []TagResponse + + for _, tag := range tags { + data = append(data, TagResponse{ + UUID: tag.UUID, + Name: tag.Name, + Slug: tag.Slug, + Description: tag.Description, + }) + } + + return data +} diff --git a/handler/payload/users.go b/handler/payload/users.go new file mode 100644 index 00000000..ddb0a3d2 --- /dev/null +++ b/handler/payload/users.go @@ -0,0 +1,13 @@ +package payload + +type UserResponse 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"` +} diff --git a/handler/posts.go b/handler/posts.go index b7aa2157..efd75816 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -5,7 +5,8 @@ import ( "fmt" "github.com/oullin/database/repository" "github.com/oullin/database/repository/pagination" - "github.com/oullin/handler/posts" + "github.com/oullin/handler/paginate" + "github.com/oullin/handler/payload" "github.com/oullin/pkg" "github.com/oullin/pkg/http" "log/slog" @@ -25,7 +26,7 @@ func MakePostsHandler(posts *repository.Posts) PostsHandler { func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { defer pkg.CloseWithLog(r.Body) - payload, err := http.ParseRequestBody[posts.IndexRequestBody](r) + requestBody, err := http.ParseRequestBody[payload.IndexRequestBody](r) if err != nil { slog.Error("failed to parse request body", "err", err) @@ -33,9 +34,9 @@ func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *ht return http.InternalError("There was an issue reading the request. Please, try again later.") } - result, err := h.Posts.GetPosts( - posts.GetFiltersFrom(payload), - posts.GetPaginateFrom(r.URL.Query()), + result, err := h.Posts.GetAll( + payload.GetPostsFiltersFrom(requestBody), + paginate.MakeFrom(r.URL, 10), ) if err != nil { @@ -46,7 +47,7 @@ func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *ht items := pagination.HydratePagination( result, - posts.GetPostsResponse, + payload.GetPostsResponse, ) if err := json.NewEncoder(w).Encode(items); err != nil { @@ -59,7 +60,7 @@ func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *ht } func (h *PostsHandler) Show(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - slug := posts.GetSlugFrom(r) + slug := payload.GetSlugFrom(r) if slug == "" { return http.BadRequestError("Slugs are required to show posts content") @@ -70,7 +71,7 @@ func (h *PostsHandler) Show(w baseHttp.ResponseWriter, r *baseHttp.Request) *htt return http.NotFound(fmt.Sprintf("The given post '%s' was not found", slug)) } - items := posts.GetPostsResponse(*post) + items := payload.GetPostsResponse(*post) if err := json.NewEncoder(w).Encode(items); err != nil { slog.Error(err.Error()) diff --git a/handler/posts/response.go b/handler/posts/response.go deleted file mode 100644 index ea07333c..00000000 --- a/handler/posts/response.go +++ /dev/null @@ -1,62 +0,0 @@ -package posts - -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"` - 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"` -} - -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"` -} - -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"` -} diff --git a/handler/posts/transformer.go b/handler/posts/transformer.go deleted file mode 100644 index b87177e8..00000000 --- a/handler/posts/transformer.go +++ /dev/null @@ -1,120 +0,0 @@ -package posts - -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 { - 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: GetCategoriesResponse(p.Categories), - Tags: GetTagsResponse(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.Author.UpdatedAt, - }, - } -} - -func GetCategoriesResponse(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 GetTagsResponse(tags []database.Tag) []TagData { - var data []TagData - - for _, tag := range tags { - data = append(data, TagData{ - UUID: tag.UUID, - Name: tag.Name, - Slug: tag.Slug, - Description: tag.Description, - CreatedAt: tag.CreatedAt, - UpdatedAt: tag.UpdatedAt, - }) - } - - return data -} - -func GetPaginateFrom(url url.Values) pagination.Paginate { - page := pagination.MinPage - pageSize := pagination.MaxLimit - - 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 page < pagination.MinPage { - page = pagination.MinPage - } - - if pageSize > pagination.MaxLimit || pageSize < 1 { - pageSize = pagination.MaxLimit - } - - return pagination.Paginate{ - Page: page, - Limit: pageSize, - } -} - -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 { - str := pkg.MakeStringable(r.PathValue("slug")) - - return strings.TrimSpace(str.ToLower()) -}