390 lines
12 KiB
Go
390 lines
12 KiB
Go
package author
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/apperr"
|
|
"RenewCMS/api/pkg/claims"
|
|
validator "RenewCMS/api/pkg/validator"
|
|
domain "RenewCMS/internal/domain/author"
|
|
"RenewCMS/internal/domain/image"
|
|
"RenewCMS/internal/domain/user"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
playground "github.com/go-playground/validator/v10"
|
|
)
|
|
|
|
const (
|
|
defaultListPage = 1
|
|
defaultListSize = 25
|
|
reassignParam = "reassign_to"
|
|
reassignNone = "none"
|
|
)
|
|
|
|
type Handler struct {
|
|
ListUseCase *useCases.ListAuthorsUseCase
|
|
CreateUseCase *useCases.CreateAuthorUseCase
|
|
CreateMyUseCase *useCases.CreateMyAuthorUseCase
|
|
UpdateUseCase *useCases.UpdateAuthorUseCase
|
|
DeleteUseCase *useCases.DeleteAuthorUseCase
|
|
GetUserUseCase *useCases.GetUserUseCase
|
|
}
|
|
|
|
func NewHandler(list *useCases.ListAuthorsUseCase, create *useCases.CreateAuthorUseCase, createMy *useCases.CreateMyAuthorUseCase, update *useCases.UpdateAuthorUseCase, deleteUseCase *useCases.DeleteAuthorUseCase, getUser *useCases.GetUserUseCase) *Handler {
|
|
return &Handler{ListUseCase: list, CreateUseCase: create, CreateMyUseCase: createMy, UpdateUseCase: update, DeleteUseCase: deleteUseCase, GetUserUseCase: getUser}
|
|
}
|
|
|
|
type socialLinkRequest struct {
|
|
Type string `json:"type" validate:"socialtype"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
func toLinks(requests []socialLinkRequest) []domain.SocialLink {
|
|
links := make([]domain.SocialLink, 0, len(requests))
|
|
for _, request := range requests {
|
|
links = append(links, domain.SocialLink{Type: request.Type, URL: request.URL})
|
|
}
|
|
return links
|
|
}
|
|
|
|
type PostAuthor struct {
|
|
Name string `json:"name" validate:"authorname"`
|
|
Slug string `json:"slug" validate:"omitempty,authorslug"`
|
|
Bio string `json:"bio"`
|
|
AvatarID *uint32 `json:"avatar_id" validate:"omitempty,gte=1"`
|
|
Website *string `json:"website"`
|
|
SocialLinks []socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
|
|
UserID *uint32 `json:"user_id" validate:"omitempty,gte=1"`
|
|
}
|
|
|
|
type optional[T any] struct {
|
|
Set bool
|
|
Value *T
|
|
}
|
|
|
|
func (o *optional[T]) UnmarshalJSON(data []byte) error {
|
|
o.Set = true
|
|
if string(data) == "null" {
|
|
o.Value = nil
|
|
return nil
|
|
}
|
|
var value T
|
|
if err := json.Unmarshal(data, &value); err != nil {
|
|
return err
|
|
}
|
|
o.Value = &value
|
|
return nil
|
|
}
|
|
|
|
type PatchAuthor struct {
|
|
Name *string `json:"name" validate:"authorname"`
|
|
Slug *string `json:"slug" validate:"authorslug"`
|
|
Bio *string `json:"bio"`
|
|
AvatarID optional[uint32] `json:"avatar_id"`
|
|
Website optional[string] `json:"website"`
|
|
SocialLinks *[]socialLinkRequest `json:"social_links" validate:"omitempty,dive"`
|
|
UserID optional[uint32] `json:"user_id"`
|
|
}
|
|
|
|
func (p PatchAuthor) toPatch() domain.Patch {
|
|
patch := domain.Patch{
|
|
Name: p.Name,
|
|
Slug: p.Slug,
|
|
Bio: p.Bio,
|
|
AvatarID: domain.Nullable[uint32]{Set: p.AvatarID.Set, Value: p.AvatarID.Value},
|
|
Website: domain.Nullable[string]{Set: p.Website.Set, Value: p.Website.Value},
|
|
UserID: domain.Nullable[uint32]{Set: p.UserID.Set, Value: p.UserID.Value},
|
|
}
|
|
if p.SocialLinks != nil {
|
|
links := toLinks(*p.SocialLinks)
|
|
patch.SocialLinks = &links
|
|
}
|
|
return patch
|
|
}
|
|
|
|
var requiredNonNullFields = []string{"name", "slug", "bio", "social_links"}
|
|
|
|
var nullableFields = []string{"avatar_id", "website", "user_id"}
|
|
|
|
func readPatchAuthor(r *http.Request) (PatchAuthor, bool) {
|
|
raw, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return PatchAuthor{}, false
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
return PatchAuthor{}, false
|
|
}
|
|
known := 0
|
|
for _, name := range requiredNonNullFields {
|
|
value, present := fields[name]
|
|
if !present {
|
|
continue
|
|
}
|
|
if string(value) == "null" {
|
|
return PatchAuthor{}, false
|
|
}
|
|
known++
|
|
}
|
|
for _, name := range nullableFields {
|
|
if _, present := fields[name]; present {
|
|
known++
|
|
}
|
|
}
|
|
if known == 0 {
|
|
return PatchAuthor{}, false
|
|
}
|
|
var request PatchAuthor
|
|
if err := json.Unmarshal(raw, &request); err != nil {
|
|
return PatchAuthor{}, false
|
|
}
|
|
return request, true
|
|
}
|
|
|
|
func parseAuthorId(r *http.Request) (uint32, error) {
|
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
|
return uint32(id), err
|
|
}
|
|
|
|
func presentAuthor(a domain.Author) domain.Author {
|
|
if a.SocialLinks == nil {
|
|
a.SocialLinks = []domain.SocialLink{}
|
|
}
|
|
return a
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
w.WriteHeader(status)
|
|
}
|
|
if _, err := w.Write(body); err != nil {
|
|
log.Printf("Failed to write response: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeAuthorError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, domain.ErrAuthorNotFound):
|
|
http.Error(w, apperr.AuthorNotFoundErrorMessage, http.StatusNotFound)
|
|
case errors.Is(err, domain.ErrSlugTaken):
|
|
http.Error(w, apperr.AuthorSlugTakenErrorMessage, http.StatusConflict)
|
|
case errors.Is(err, domain.ErrAuthorHasArticles):
|
|
http.Error(w, apperr.AuthorHasArticlesErrorMessage, http.StatusConflict)
|
|
case errors.Is(err, domain.ErrUserAlreadyLinked):
|
|
http.Error(w, apperr.UserAlreadyLinkedErrorMessage, http.StatusConflict)
|
|
case errors.Is(err, domain.ErrAuthorAlreadyLinked):
|
|
http.Error(w, apperr.AuthorAlreadyLinkedErrorMessage, http.StatusConflict)
|
|
case errors.Is(err, user.ErrUserNotFound):
|
|
http.Error(w, apperr.UserNotFoundErrorMessage, http.StatusNotFound)
|
|
case errors.Is(err, domain.ErrAuthorRights):
|
|
http.Error(w, apperr.AuthorRightsErrorMessage, http.StatusForbidden)
|
|
case errors.Is(err, domain.ErrBioTooLong):
|
|
http.Error(w, apperr.BioTooLongErrorMessage, http.StatusUnprocessableEntity)
|
|
case errors.Is(err, domain.ErrInvalidURL):
|
|
http.Error(w, apperr.InvalidURLErrorMessage, http.StatusUnprocessableEntity)
|
|
case errors.Is(err, domain.ErrTooManyLinks):
|
|
http.Error(w, apperr.TooManyLinksErrorMessage, http.StatusUnprocessableEntity)
|
|
case errors.Is(err, domain.ErrInvalidSocialType):
|
|
http.Error(w, apperr.InvalidSocialTypeErrorMessage, http.StatusBadRequest)
|
|
case errors.Is(err, domain.ErrInvalidSlug):
|
|
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
|
|
case errors.Is(err, domain.ErrInvalidReassign):
|
|
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
|
|
case errors.Is(err, image.ErrImageNotFound):
|
|
http.Error(w, apperr.AuthorImageNotFoundErrorMessage, http.StatusBadRequest)
|
|
default:
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func writeValidationError(w http.ResponseWriter, err error) {
|
|
var failures playground.ValidationErrors
|
|
if errors.As(err, &failures) {
|
|
for _, failure := range failures {
|
|
if failure.Tag() == validator.AuthorSlugTag {
|
|
http.Error(w, apperr.InvalidSlugErrorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
|
|
func (h *Handler) currentUser(r *http.Request) (user.User, bool) {
|
|
userId, ok := claims.UserId(r)
|
|
if !ok {
|
|
return user.User{}, false
|
|
}
|
|
current, err := h.GetUserUseCase.GetUser(userId)
|
|
if err != nil {
|
|
return user.User{}, false
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
type listQuery struct {
|
|
Page int `validate:"gte=1"`
|
|
Size int `validate:"oneof=5 25 50 100"`
|
|
Q string `validate:"max=200"`
|
|
}
|
|
|
|
type listResponse struct {
|
|
Items []domain.Author `json:"items"`
|
|
Total int `json:"total"`
|
|
Page int `json:"page"`
|
|
Size int `json:"size"`
|
|
}
|
|
|
|
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
|
|
if !values.Has(name) {
|
|
return fallback, nil
|
|
}
|
|
return strconv.Atoi(values.Get(name))
|
|
}
|
|
|
|
func readListQuery(values url.Values) (listQuery, error) {
|
|
page, err := intQueryParam(values, "page", defaultListPage)
|
|
if err != nil {
|
|
return listQuery{}, err
|
|
}
|
|
size, err := intQueryParam(values, "size", defaultListSize)
|
|
if err != nil {
|
|
return listQuery{}, err
|
|
}
|
|
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
|
|
}
|
|
|
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|
query, err := readListQuery(r.URL.Query())
|
|
if err != nil || validator.Validate.Struct(query) != nil {
|
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
current, _ := h.currentUser(r)
|
|
page, err := h.ListUseCase.List(useCases.ListAuthorsCommand{Q: query.Q, Page: query.Page, Size: query.Size, CallerRole: current.Role})
|
|
if err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
items := make([]domain.Author, 0, len(page.Items))
|
|
for _, item := range page.Items {
|
|
items = append(items, presentAuthor(item))
|
|
}
|
|
writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
|
|
}
|
|
|
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|
var request PostAuthor
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := validator.Validate.Struct(request); err != nil {
|
|
writeValidationError(w, err)
|
|
return
|
|
}
|
|
|
|
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateAuthorCommand{
|
|
Name: request.Name,
|
|
Slug: request.Slug,
|
|
Bio: request.Bio,
|
|
AvatarID: request.AvatarID,
|
|
Website: request.Website,
|
|
SocialLinks: toLinks(request.SocialLinks),
|
|
UserID: request.UserID,
|
|
})
|
|
if err != nil {
|
|
writeAuthorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, presentAuthor(created))
|
|
}
|
|
|
|
func (h *Handler) CreateMine(w http.ResponseWriter, r *http.Request) {
|
|
userId, ok := claims.UserId(r)
|
|
if !ok {
|
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
created, err := h.CreateMyUseCase.Create(r.Context(), userId)
|
|
if err != nil {
|
|
writeAuthorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, presentAuthor(created))
|
|
}
|
|
|
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseAuthorId(r)
|
|
if err != nil {
|
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
request, ok := readPatchAuthor(r)
|
|
if !ok {
|
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := validator.Validate.Struct(request); err != nil {
|
|
writeValidationError(w, err)
|
|
return
|
|
}
|
|
|
|
current, ok := h.currentUser(r)
|
|
if !ok {
|
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
updated, err := h.UpdateUseCase.Update(r.Context(), id, useCases.AuthorCaller{UserID: current.ID, Role: current.Role}, request.toPatch())
|
|
if err != nil {
|
|
writeAuthorError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, presentAuthor(updated))
|
|
}
|
|
|
|
func readReassignment(r *http.Request) useCases.Reassignment {
|
|
query := r.URL.Query()
|
|
if !query.Has(reassignParam) {
|
|
return useCases.Reassignment{Mode: useCases.ReassignAbsent}
|
|
}
|
|
value := query.Get(reassignParam)
|
|
if value == reassignNone {
|
|
return useCases.Reassignment{Mode: useCases.ReassignNone}
|
|
}
|
|
parsed, err := strconv.ParseUint(value, 10, 32)
|
|
if err != nil {
|
|
return useCases.Reassignment{Mode: useCases.ReassignMalformed}
|
|
}
|
|
return useCases.Reassignment{Mode: useCases.ReassignToAuthor, Destination: uint32(parsed)}
|
|
}
|
|
|
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseAuthorId(r)
|
|
if err != nil {
|
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.DeleteUseCase.Delete(r.Context(), id, readReassignment(r)); err != nil {
|
|
writeAuthorError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|