feat: public API, SEO and existing surfaces

This commit is contained in:
Florian Sylvain committed 2026-11-03 03:58:10 +01:00
1 parent 45fbbc33b1
commit c390fcfe8d
103 files changed
+4476 -231

No files matched your search

+138
View File
@@ -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)
}
}
+3 -1
View File
@@ -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}
}
+2 -1
View File
@@ -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 {