1
Fork 0
blog-go-mongo/posts.go
Leonardo Devai dda249c5bd Harmonize the blog engine contract across all eight backends
published_at is now the first-publish time (null for drafts, kept across edits
and unpublish/republish), slugs collide to -2/-3 and regenerate on retitle,
every error is {"error"} with 400/401/403/404/405/409 pinned, timestamps are
RFC 3339 UTC. GUIDELINES pins the contract; each backend passes the same 59-check
end-to-end script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01128fhuZbgivaSJvtMf4s1G
2026-09-27 21:25:37 +02:00

272 lines
6.9 KiB
Go

package main
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ObjectIDs marshal to their hex form in JSON, so ids appear as strings.
type post struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
Title string `bson:"title" json:"title"`
Slug string `bson:"slug" json:"slug"`
Body string `bson:"body" json:"body"`
Published bool `bson:"published" json:"published"`
PublishedAt *time.Time `bson:"published_at" json:"published_at"` // nil until first published
AuthorID bson.ObjectID `bson:"author_id" json:"author_id"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type postSummary struct {
ID bson.ObjectID `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
PublishedAt *time.Time `json:"published_at"`
}
func (a *app) listPosts(w http.ResponseWriter, r *http.Request) {
cur, err := a.posts.Find(r.Context(), bson.M{"published": true},
options.Find().SetSort(bson.D{{Key: "published_at", Value: -1}}))
if err != nil {
internalErr(w, err)
return
}
defer cur.Close(r.Context())
out := []postSummary{}
for cur.Next(r.Context()) {
var p post
if err := cur.Decode(&p); err != nil {
internalErr(w, err)
return
}
out = append(out, postSummary{p.ID, p.Title, p.Slug, excerpt(p.Body), p.PublishedAt})
}
if err := cur.Err(); err != nil {
internalErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (a *app) getPost(w http.ResponseWriter, r *http.Request) {
var p post
err := a.posts.FindOne(r.Context(),
bson.M{"slug": r.PathValue("slug"), "published": true}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
writeErr(w, http.StatusNotFound, "post not found")
return
}
if err != nil {
internalErr(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
func (a *app) createPost(w http.ResponseWriter, r *http.Request) {
uid, err := bson.ObjectIDFromHex(userID(r))
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid token subject")
return
}
var in struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := decode(w, r, &in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json body")
return
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" {
writeErr(w, http.StatusBadRequest, "title required")
return
}
now := mongoNow()
p := post{Title: in.Title, Body: in.Body, AuthorID: uid, CreatedAt: now, UpdatedAt: now}
insert := func(slug string) error {
p.Slug = slug
res, err := a.posts.InsertOne(r.Context(), p)
if err == nil {
p.ID = res.InsertedID.(bson.ObjectID)
}
return err
}
if err := withUniqueSlug(slugify(in.Title), insert); err != nil {
internalErr(w, err)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (a *app) updatePost(w http.ResponseWriter, r *http.Request) {
uid, err := bson.ObjectIDFromHex(userID(r))
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid token subject")
return
}
id, err := bson.ObjectIDFromHex(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusNotFound, "post not found")
return
}
var in struct {
Title *string `json:"title"`
Body *string `json:"body"`
Published *bool `json:"published"`
}
if err := decode(w, r, &in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json body")
return
}
var p post
err = a.posts.FindOne(r.Context(), bson.M{"_id": id}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
writeErr(w, http.StatusNotFound, "post not found")
return
}
if err != nil {
internalErr(w, err)
return
}
if p.AuthorID != uid {
writeErr(w, http.StatusForbidden, "not your post")
return
}
retitled := false
if in.Title != nil {
t := strings.TrimSpace(*in.Title)
if t == "" {
writeErr(w, http.StatusBadRequest, "title cannot be empty")
return
}
retitled = t != p.Title
p.Title = t
}
if in.Body != nil {
p.Body = *in.Body
}
if in.Published != nil {
p.Published = *in.Published
}
now := mongoNow()
p.UpdatedAt = now
if p.Published && p.PublishedAt == nil { // stamped on first publish, then kept
p.PublishedAt = &now
}
update := func(slug string) error {
p.Slug = slug
_, err := a.posts.UpdateByID(r.Context(), p.ID, bson.M{"$set": bson.M{
"title": p.Title,
"slug": slug,
"body": p.Body,
"published": p.Published,
"published_at": p.PublishedAt,
"updated_at": p.UpdatedAt,
}})
return err
}
if retitled { // the slug follows the title
err = withUniqueSlug(slugify(p.Title), update)
} else {
err = update(p.Slug)
}
if err != nil {
internalErr(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
func (a *app) deletePost(w http.ResponseWriter, r *http.Request) {
uid, err := bson.ObjectIDFromHex(userID(r))
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid token subject")
return
}
id, err := bson.ObjectIDFromHex(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusNotFound, "post not found")
return
}
var p post
err = a.posts.FindOne(r.Context(), bson.M{"_id": id}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
writeErr(w, http.StatusNotFound, "post not found")
return
}
if err != nil {
internalErr(w, err)
return
}
if p.AuthorID != uid {
writeErr(w, http.StatusForbidden, "not your post")
return
}
if _, err := a.posts.DeleteOne(r.Context(), bson.M{"_id": id}); err != nil {
internalErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// withUniqueSlug runs fn with base, then base-2, base-3, ... until the unique
// index on posts.slug stops complaining (capped, then the error escapes).
func withUniqueSlug(base string, fn func(slug string) error) error {
slug := base
for i := 2; ; i++ {
err := fn(slug)
if err == nil || !isDuplicate(err) || i > 50 {
return err
}
slug = fmt.Sprintf("%s-%d", base, i)
}
}
// slugify lowercases the title and collapses every non-alphanumeric run into
// a single dash: "Hello, World!" -> "hello-world".
func slugify(title string) string {
var b strings.Builder
pendingDash := false
for _, r := range strings.ToLower(title) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
if pendingDash && b.Len() > 0 {
b.WriteByte('-')
}
b.WriteRune(r)
pendingDash = false
} else {
pendingDash = true
}
}
if b.Len() == 0 {
return "post"
}
return b.String()
}
// excerpt returns the first 200 characters of the body, per the API contract.
func excerpt(body string) string {
runes := []rune(body)
if len(runes) <= 200 {
return body
}
return string(runes[:200])
}
// mongoNow is the current time at the millisecond precision MongoDB stores,
// so a response matches what later reads return.
func mongoNow() time.Time {
return time.Now().UTC().Truncate(time.Millisecond)
}