feat: public API, SEO and existing surfaces
This commit is contained in:
1 parent
45fbbc33b1
commit
c390fcfe8d
103 files changed
+4476
-231
No files matched your search
@@ -58,6 +58,7 @@ type UseCases struct {
|
||||
ResolveArticleSEOUseCase *useCases.ResolveArticleSEOUseCase
|
||||
CheckArticleSEOUseCase *useCases.CheckArticleSEOUseCase
|
||||
ListSitemapUseCase *useCases.ListSitemapUseCase
|
||||
ResolvePublicArticleUseCase *useCases.ResolvePublicArticleUseCase
|
||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||
CreateTranslationUseCase *useCases.CreateTranslationUseCase
|
||||
DescribeArticleUseCase *useCases.DescribeArticleUseCase
|
||||
@@ -403,9 +404,10 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
|
||||
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo),
|
||||
MoveStoryUseCase: moveStory,
|
||||
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo),
|
||||
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo),
|
||||
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo),
|
||||
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo),
|
||||
ResolvePublicArticleUseCase: useCases.NewResolvePublicArticleUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo, renderer),
|
||||
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
|
||||
CreateAuthorUseCase: useCases.NewCreateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
|
||||
CreateMyAuthorUseCase: useCases.NewCreateMyAuthorUseCase(repos.AuthorRepo, repos.UserRepo, recorder),
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type PublicHandler struct {
|
||||
resolveUseCase *useCases.ResolvePublicArticleUseCase
|
||||
}
|
||||
|
||||
func NewPublicHandler(resolve *useCases.ResolvePublicArticleUseCase) *PublicHandler {
|
||||
return &PublicHandler{resolveUseCase: resolve}
|
||||
}
|
||||
|
||||
type publicLookupResponse struct {
|
||||
articleResponse
|
||||
Alternates []domain.Alternate `json:"alternates"`
|
||||
}
|
||||
|
||||
type publicStoryResponse struct {
|
||||
articleResponse
|
||||
Alternates []domain.Alternate `json:"alternates"`
|
||||
RequestedLang string `json:"requested_lang"`
|
||||
Fallback bool `json:"fallback"`
|
||||
}
|
||||
|
||||
type publicStoryQuery struct {
|
||||
Fallback *string `validate:"omitempty,eq=default"`
|
||||
}
|
||||
|
||||
var publicErrors = []struct {
|
||||
cause error
|
||||
message string
|
||||
status int
|
||||
}{
|
||||
{domain.ErrArticleNotFound, apperr.ArticleNotFoundCode, http.StatusNotFound},
|
||||
{domain.ErrLanguageNotAvailable, apperr.LanguageNotAvailableCode, http.StatusNotFound},
|
||||
{site.ErrSiteNotFound, apperr.SiteNotFoundCode, http.StatusNotFound},
|
||||
{language.ErrInvalidLanguage, apperr.InvalidLanguageCode, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
func writePublicError(w http.ResponseWriter, err error) {
|
||||
for _, known := range publicErrors {
|
||||
if errors.Is(err, known.cause) {
|
||||
http.Error(w, known.message, known.status)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *PublicHandler) Lookup(w http.ResponseWriter, r *http.Request) {
|
||||
format, ok := parseBodyFormat(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
found, err := h.resolveUseCase.ByLangSlug(useCases.PublicLookupQuery{
|
||||
SiteKey: chi.URLParam(r, "key"),
|
||||
Lang: chi.URLParam(r, "lang"),
|
||||
Slug: chi.URLParam(r, "slug"),
|
||||
Format: format,
|
||||
})
|
||||
if err != nil {
|
||||
writePublicError(w, err)
|
||||
return
|
||||
}
|
||||
h.write(w, found, format, func(response articleResponse) any {
|
||||
return publicLookupResponse{articleResponse: response, Alternates: found.Alternates}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PublicHandler) Story(w http.ResponseWriter, r *http.Request) {
|
||||
storyID, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
format, ok := parseBodyFormat(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
values := r.URL.Query()
|
||||
query := publicStoryQuery{}
|
||||
if values.Has("fallback") {
|
||||
fallback := values.Get("fallback")
|
||||
query.Fallback = &fallback
|
||||
}
|
||||
if validator.Validate.Struct(query) != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
command := useCases.PublicStoryQuery{
|
||||
SiteKey: chi.URLParam(r, "key"),
|
||||
StoryID: uint32(storyID),
|
||||
FallbackDefault: query.Fallback != nil,
|
||||
Format: format,
|
||||
}
|
||||
if values.Has("lang") {
|
||||
requested := values.Get("lang")
|
||||
command.Lang = &requested
|
||||
}
|
||||
found, err := h.resolveUseCase.ByStory(command)
|
||||
if err != nil {
|
||||
writePublicError(w, err)
|
||||
return
|
||||
}
|
||||
h.write(w, found, format, func(response articleResponse) any {
|
||||
return publicStoryResponse{articleResponse: response, Alternates: found.Alternates, RequestedLang: found.RequestedLang, Fallback: found.Fallback}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PublicHandler) write(w http.ResponseWriter, found useCases.PublicArticle, format domain.BodyFormat, shape func(articleResponse) any) {
|
||||
response, err := toArticleResponse(found.Article, format, found.Viewer, found.Context)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(shape(response))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,8 @@ type usageResponse struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Published *bool `json:"published,omitempty"`
|
||||
Fields []string `json:"fields"`
|
||||
StoryID *uint32 `json:"story_id,omitempty"`
|
||||
Lang *string `json:"lang,omitempty"`
|
||||
|
||||
ContentTypeKey string `json:"content_type_key,omitempty"`
|
||||
ContentTypeName string `json:"content_type_name,omitempty"`
|
||||
@@ -108,7 +110,7 @@ type usageFirstFieldBody struct {
|
||||
}
|
||||
|
||||
func toUsageResponse(usage useCases.MediaUsage) usageResponse {
|
||||
body := usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields, ContentTypeKey: usage.ContentTypeKey, ContentTypeName: usage.ContentTypeName}
|
||||
body := usageResponse{Type: usage.Type, ID: usage.ID, Title: usage.Title, Published: usage.Published, Fields: usage.Fields, StoryID: usage.StoryID, Lang: usage.Lang, ContentTypeKey: usage.ContentTypeKey, ContentTypeName: usage.ContentTypeName}
|
||||
if usage.FirstField != nil {
|
||||
body.FirstField = &usageFirstFieldBody{Key: usage.FirstField.Key, Type: string(usage.FirstField.Type), Value: usage.FirstField.Value}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type sitemapItem struct {
|
||||
URL string `json:"url"`
|
||||
ModifiedAt string `json:"modified_at"`
|
||||
Noindex bool `json:"noindex"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
func sitemapIntParam(values url.Values, name string, fallback int) (int, error) {
|
||||
@@ -73,7 +74,7 @@ func (h *SitemapHandler) Sitemap(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
items := make([]sitemapItem, 0, len(page.Entries))
|
||||
for _, entry := range page.Entries {
|
||||
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex})
|
||||
items = append(items, sitemapItem{URL: entry.URL, ModifiedAt: entry.ModifiedAt.UTC().Format(time.RFC3339), Noindex: entry.Noindex, Lang: entry.Lang})
|
||||
}
|
||||
body, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
|
||||
@@ -171,3 +171,8 @@ const SlugConflictErrorMessage = "slug_conflict"
|
||||
const ConflictingArticlesHeader = "X-Conflicting-Articles"
|
||||
const StoryMoveRequiredErrorMessage = "story_move_required"
|
||||
const StoryBusyErrorMessage = "story_busy"
|
||||
|
||||
const ArticleNotFoundCode = "article_not_found"
|
||||
const LanguageNotAvailableCode = "language_not_available"
|
||||
const SiteNotFoundCode = "site_not_found"
|
||||
const InvalidLanguageCode = "invalid_language"
|
||||
@@ -121,6 +121,16 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
|
||||
return r
|
||||
}
|
||||
|
||||
func NewPublicRouter(container *UseCases) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
publicHandler := article.NewPublicHandler(container.ResolvePublicArticleUseCase)
|
||||
|
||||
r.Get("/site/{key}/lang/{lang}/article/{slug}", publicHandler.Lookup)
|
||||
r.Get("/site/{key}/story/{story_id}", publicHandler.Story)
|
||||
return r
|
||||
}
|
||||
|
||||
func NewStoryRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(container.apiKeyAuth())
|
||||
@@ -627,6 +637,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies))
|
||||
})
|
||||
r.Mount("/public", NewPublicRouter(container))
|
||||
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
|
||||
Reference in new issue
Block a user