Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,937 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package httpapi serves the public JSON API under /api/volumen:
|
||||
// site metadata, paginated posts, tags, series, feeds, the sitemap,
|
||||
// and token-authenticated write endpoints.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
json "encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/feeds"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/preview"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
)
|
||||
|
||||
// Content is the content surface the API uses.
|
||||
type Content interface {
|
||||
All() []*post.Post
|
||||
Find(slug, lang string) *post.Post
|
||||
ResolveAlias(alias string) string
|
||||
Save(p *post.Post) (*post.Post, error)
|
||||
Delete(slug, lang string) (*post.Post, bool, error)
|
||||
CacheKey() string
|
||||
}
|
||||
|
||||
// Deps are the shared services the API needs.
|
||||
type Deps struct {
|
||||
Config *config.Config
|
||||
Store Content
|
||||
Tokens *tokens.Store
|
||||
OnEvent func(event string, payload map[string]any)
|
||||
|
||||
// PreviewKey verifies the shareable preview links: the session
|
||||
// secret the app layer resolved, from [admin].session_key or from
|
||||
// the secret.key file it generated, which is the same key the admin
|
||||
// signs the links with. Empty refuses every token.
|
||||
PreviewKey string
|
||||
}
|
||||
|
||||
// API routes /api/volumen requests.
|
||||
type API struct {
|
||||
deps Deps
|
||||
|
||||
sitemapMu sync.Mutex
|
||||
sitemapKey string
|
||||
sitemapXML string
|
||||
}
|
||||
|
||||
// New builds the API handler.
|
||||
func New(deps Deps) http.Handler {
|
||||
api := &API{deps: deps}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("OPTIONS /api/volumen/{rest...}", api.handleOptions)
|
||||
mux.HandleFunc("GET /api/volumen/site", api.handleSite)
|
||||
mux.HandleFunc("GET /api/volumen/posts", api.handlePosts)
|
||||
mux.HandleFunc("GET /api/volumen/posts/batch", api.handleBatch)
|
||||
mux.HandleFunc("GET /api/volumen/posts/{slug}", api.handleSingle)
|
||||
mux.HandleFunc("POST /api/volumen/posts", api.handleCreatePost)
|
||||
mux.HandleFunc("PUT /api/volumen/posts/{slug}", api.handleUpdatePost)
|
||||
mux.HandleFunc("DELETE /api/volumen/posts/{slug}", api.handleDeletePost)
|
||||
mux.HandleFunc("GET /api/volumen/tags", api.handleTags)
|
||||
mux.HandleFunc("GET /api/volumen/tags/{tag}", api.handleTagPosts)
|
||||
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.xml", api.handleTagRSS)
|
||||
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.atom", api.handleTagAtom)
|
||||
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.json", api.handleTagJSON)
|
||||
mux.HandleFunc("GET /api/volumen/series", api.handleSeries)
|
||||
mux.HandleFunc("GET /api/volumen/series/{name}", api.handleSeriesDetail)
|
||||
mux.HandleFunc("GET /api/volumen/series/{name}/feed.xml", api.handleSeriesRSS)
|
||||
mux.HandleFunc("GET /api/volumen/series/{name}/feed.atom", api.handleSeriesAtom)
|
||||
mux.HandleFunc("GET /api/volumen/series/{name}/feed.json", api.handleSeriesJSON)
|
||||
mux.HandleFunc("GET /api/volumen/feed.xml", api.handleRSS)
|
||||
mux.HandleFunc("GET /api/volumen/feed.atom", api.handleAtom)
|
||||
mux.HandleFunc("GET /api/volumen/feed.json", api.handleJSONFeed)
|
||||
mux.HandleFunc("GET /api/volumen/sitemap.xml", api.handleSitemap)
|
||||
return mux
|
||||
}
|
||||
|
||||
var corsHeaders = map[string]string{
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
}
|
||||
|
||||
const cacheHeader = "public, max-age=60, stale-while-revalidate=21600"
|
||||
|
||||
// pageSizeLimit bounds the page size: the documented limit of the list
|
||||
// endpoints, used both by the clamp and by the error message so the two
|
||||
// cannot drift.
|
||||
const pageSizeLimit = 100
|
||||
|
||||
// maxWriteBody bounds a write payload.
|
||||
const maxWriteBody = 10 << 20
|
||||
|
||||
// maxPage bounds the page number so offset arithmetic stays far inside
|
||||
// 32-bit int range.
|
||||
const maxPage = 1_000_000
|
||||
|
||||
func (a *API) fire(event string, payload map[string]any) {
|
||||
if a.deps.OnEvent != nil {
|
||||
a.deps.OnEvent(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCORS(w http.ResponseWriter) {
|
||||
for key, value := range corsHeaders {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
w.Header().Set("Cache-Control", cacheHeader)
|
||||
}
|
||||
|
||||
// writeJSON writes a JSON response with CORS and cache headers.
|
||||
func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
|
||||
writeJSONWithHeaders(w, r, status, v, nil)
|
||||
}
|
||||
|
||||
// writeJSONWithHeaders applies extra headers last, so write endpoints
|
||||
// can override the public CORS defaults.
|
||||
func writeJSONWithHeaders(w http.ResponseWriter, r *http.Request, status int, v any, extra map[string]string) {
|
||||
writeCORS(w)
|
||||
for key, value := range extra {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
writeJSONBody(w, r, v, "cannot encode response")
|
||||
}
|
||||
|
||||
// writeJSONBody writes one JSON document and the newline a line-oriented
|
||||
// client expects. encoding/json/v2 escapes only what JSON requires, so a
|
||||
// body keeps the characters the author wrote.
|
||||
func writeJSONBody(w io.Writer, r *http.Request, v any, what string) {
|
||||
if err := json.MarshalWrite(w, v, json.Deterministic(true)); err != nil {
|
||||
web.Logger(r.Context()).Warn("httpapi: "+what, "error", err)
|
||||
return
|
||||
}
|
||||
if _, err := io.WriteString(w, "\n"); err != nil {
|
||||
web.Logger(r.Context()).Warn("httpapi: "+what, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeError writes the uniform error envelope:
|
||||
//
|
||||
// {"error": "<code>", "message": "<human text>", …extras}
|
||||
//
|
||||
// Every failure, in the API and in the middleware, uses this shape with
|
||||
// CORS headers so cross-origin clients can read it. An error is never
|
||||
// publicly cacheable.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, status int, body map[string]any) {
|
||||
writeCORS(w)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
writeJSONBody(w, r, body, "cannot encode error")
|
||||
}
|
||||
|
||||
func writeXML(w http.ResponseWriter, r *http.Request, contentType, body string) {
|
||||
writeCORS(w)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// etagFor hashes the canonical serialisation of a payload, so two equal
|
||||
// payloads always produce one tag: without Deterministic a map inside the
|
||||
// payload could serialise in a different order on the next request and
|
||||
// change the tag for the same content.
|
||||
func etagFor(v any) string {
|
||||
raw, err := json.Marshal(v, json.Deterministic(true))
|
||||
if err != nil {
|
||||
return `""`
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return `"` + hex.EncodeToString(sum[:8]) + `"`
|
||||
}
|
||||
|
||||
func etagMatches(header, etag string) bool {
|
||||
opaque := func(tag string) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
tag = strings.TrimPrefix(tag, "W/")
|
||||
return strings.Trim(tag, `"`)
|
||||
}
|
||||
stored := opaque(etag)
|
||||
for tag := range strings.SplitSeq(header, ",") {
|
||||
if strings.TrimSpace(tag) == "*" || opaque(tag) == stored {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func maybeNotModified(w http.ResponseWriter, r *http.Request, etag string) bool {
|
||||
inm := r.Header.Get("If-None-Match")
|
||||
if inm == "" || !etagMatches(inm, etag) {
|
||||
return false
|
||||
}
|
||||
writeCORS(w)
|
||||
w.Header().Set("ETag", etag)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSONWithETag(w http.ResponseWriter, r *http.Request, v any) {
|
||||
etag := etagFor(v)
|
||||
if maybeNotModified(w, r, etag) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", etag)
|
||||
writeJSON(w, r, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func (a *API) baseURL() string {
|
||||
return strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
|
||||
}
|
||||
|
||||
func (a *API) handleOptions(w http.ResponseWriter, r *http.Request) {
|
||||
// The preflight answers for the whole subtree, so it advertises the
|
||||
// write methods as well; a browser preflight for a cross-origin POST
|
||||
// fails when the response lists only GET.
|
||||
for key, value := range writeCORSHeaders(r, a.deps.Config) {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *API) handleSite(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithETag(w, r, payloads.BuildSite(a.deps.Config))
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, def, lo, hi int) (int, bool) {
|
||||
raw := r.URL.Query().Get(name)
|
||||
if raw == "" {
|
||||
return def, true
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if n < lo || n > hi {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func writeQueryValidationError(w http.ResponseWriter, r *http.Request, name, msg string) {
|
||||
writeError(w, r, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": "validation",
|
||||
"message": msg,
|
||||
"field": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) handlePosts(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page, ok := queryInt(r, "page", 1, 1, maxPage)
|
||||
if !ok {
|
||||
writeQueryValidationError(w, r, "page", "Input should be between 1 and "+strconv.Itoa(maxPage))
|
||||
return
|
||||
}
|
||||
limit, ok := queryInt(r, "limit", 20, 1, pageSizeLimit)
|
||||
if !ok {
|
||||
writeQueryValidationError(w, r, "limit", "Input should be between 1 and "+strconv.Itoa(pageSizeLimit))
|
||||
return
|
||||
}
|
||||
payload := payloads.PostsPayload(
|
||||
a.deps.Store,
|
||||
q.Get("lang"), q.Get("tag"), q.Get("q"),
|
||||
page, limit, q.Get("cursor"),
|
||||
)
|
||||
writeJSONWithETag(w, r, payload)
|
||||
}
|
||||
|
||||
func (a *API) handleBatch(w http.ResponseWriter, r *http.Request) {
|
||||
slugsParam := r.URL.Query().Get("slugs")
|
||||
if slugsParam == "" {
|
||||
writeJSON(w, r, http.StatusOK, map[string]any{"posts": []any{}})
|
||||
return
|
||||
}
|
||||
var slugs []string
|
||||
for slug := range strings.SplitSeq(slugsParam, ",") {
|
||||
if trimmed := strings.TrimSpace(slug); trimmed != "" {
|
||||
slugs = append(slugs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(slugs) > pageSizeLimit {
|
||||
slugs = slugs[:pageSizeLimit]
|
||||
}
|
||||
// One listing serves the whole batch: Find re-walks the content
|
||||
// directory per call, so a hundred slugs would walk it a hundred
|
||||
// times. The map keeps Find(slug, "") semantics: the first post in
|
||||
// listing order that carries the slug.
|
||||
posts := a.deps.Store.All()
|
||||
first := make(map[string]*post.Post, len(posts))
|
||||
for _, p := range posts {
|
||||
if _, ok := first[p.Slug()]; !ok {
|
||||
first[p.Slug()] = p
|
||||
}
|
||||
}
|
||||
base := a.baseURL()
|
||||
results := make([]payloads.Detail, 0, len(slugs))
|
||||
for _, slug := range slugs {
|
||||
p := first[slug]
|
||||
if p == nil || !p.Published() {
|
||||
continue
|
||||
}
|
||||
detail, err := payloads.BuildDetail(p, base)
|
||||
if err != nil {
|
||||
web.Logger(r.Context()).Warn("httpapi: cannot render post", "slug", slug, "error", err)
|
||||
continue
|
||||
}
|
||||
results = append(results, detail)
|
||||
}
|
||||
etag := etagFor(results)
|
||||
if maybeNotModified(w, r, etag) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", etag)
|
||||
writeJSON(w, r, http.StatusOK, payloads.Batch{Posts: results})
|
||||
}
|
||||
|
||||
func (a *API) validPreviewToken(token, slug string) bool {
|
||||
return preview.Valid(token, slug, a.deps.PreviewKey, time.Now())
|
||||
}
|
||||
|
||||
func (a *API) handleSingle(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
lang := r.URL.Query().Get("lang")
|
||||
p := a.deps.Store.Find(slug, lang)
|
||||
if p == nil {
|
||||
if canonical := a.deps.Store.ResolveAlias(slug); canonical != "" {
|
||||
w.Header().Set("Location", "/api/volumen/posts/"+canonical)
|
||||
w.WriteHeader(http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
if !p.Published() && !a.validPreviewToken(r.URL.Query().Get("preview_token"), slug) {
|
||||
// A draft and a scheduled post are distinguishable on purpose:
|
||||
// the client knows the slug already, and the two states need
|
||||
// different handling on the other side.
|
||||
if p.Draft() {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "draft"})
|
||||
return
|
||||
}
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "scheduled"})
|
||||
return
|
||||
}
|
||||
detail, err := payloads.BuildDetail(p, a.baseURL())
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
|
||||
return
|
||||
}
|
||||
if !p.Published() {
|
||||
// The URL is only valid with the preview token, but a shared cache
|
||||
// would still be allowed to hold the unpublished content for the
|
||||
// header's lifetime; a draft or a scheduled post is not
|
||||
// publicly cacheable.
|
||||
writeJSONWithHeaders(w, r, http.StatusOK, detail,
|
||||
map[string]string{"Cache-Control": "no-store"})
|
||||
return
|
||||
}
|
||||
writeJSONWithETag(w, r, detail)
|
||||
}
|
||||
|
||||
func (a *API) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithETag(w, r, payloads.TagList{Tags: payloads.BuildTagCounts(a.publishedPosts())})
|
||||
}
|
||||
|
||||
func (a *API) handleTagPosts(w http.ResponseWriter, r *http.Request) {
|
||||
tag := r.PathValue("tag")
|
||||
q := r.URL.Query()
|
||||
page, ok := queryInt(r, "page", 1, 1, maxPage)
|
||||
if !ok {
|
||||
writeQueryValidationError(w, r, "page", "Input should be between 1 and "+strconv.Itoa(maxPage))
|
||||
return
|
||||
}
|
||||
limit, ok := queryInt(r, "limit", 20, 1, 100)
|
||||
if !ok {
|
||||
writeQueryValidationError(w, r, "limit", "Input should be between 1 and "+strconv.Itoa(pageSizeLimit))
|
||||
return
|
||||
}
|
||||
payload := payloads.PostsPayload(a.deps.Store, q.Get("lang"), tag, "", page, limit, q.Get("cursor"))
|
||||
if payloads.IsEmpty(payload) {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeJSONWithETag(w, r, payload)
|
||||
}
|
||||
|
||||
func (a *API) handleSeries(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, r, http.StatusOK, payloads.SeriesList{Series: payloads.BuildSeriesList(a.deps.Store)})
|
||||
}
|
||||
|
||||
func (a *API) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
posts := payloads.SeriesPosts(a.deps.Store, name)
|
||||
if len(posts) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
summaries := make([]payloads.Summary, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
summaries = append(summaries, payloads.BuildSummary(p))
|
||||
}
|
||||
writeJSON(w, r, http.StatusOK, payloads.SeriesDetail{
|
||||
Name: name,
|
||||
Count: len(posts),
|
||||
Posts: summaries,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) publishedPosts() []*post.Post {
|
||||
return payloads.PublishedPosts(a.deps.Store)
|
||||
}
|
||||
|
||||
func (a *API) postsWithTag(tag string) []*post.Post {
|
||||
var out []*post.Post
|
||||
for _, p := range a.publishedPosts() {
|
||||
if slices.Contains(p.Tags(), tag) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *API) handleTagRSS(w http.ResponseWriter, r *http.Request) {
|
||||
tag := r.PathValue("tag")
|
||||
posts := a.postsWithTag(tag)
|
||||
if len(posts) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeXML(w, r, "application/rss+xml",
|
||||
feeds.RenderRSSFeed(posts, a.deps.Config.Site, a.baseURL(), tagFeedPath(tag, "xml")))
|
||||
}
|
||||
|
||||
func (a *API) handleTagAtom(w http.ResponseWriter, r *http.Request) {
|
||||
tag := r.PathValue("tag")
|
||||
posts := a.postsWithTag(tag)
|
||||
if len(posts) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeXML(w, r, "application/atom+xml",
|
||||
feeds.RenderAtomFeed(posts, a.deps.Config.Site, a.baseURL(), tagFeedPath(tag, "atom")))
|
||||
}
|
||||
|
||||
func (a *API) handleTagJSON(w http.ResponseWriter, r *http.Request) {
|
||||
tag := r.PathValue("tag")
|
||||
posts := a.postsWithTag(tag)
|
||||
if len(posts) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
a.writeJSONFeed(w, r, posts, tagFeedPath(tag, "json"))
|
||||
}
|
||||
|
||||
func (a *API) handleSeriesRSS(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
posts := payloads.SeriesPosts(a.deps.Store, name)
|
||||
if len(posts) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeXML(w, r, "application/rss+xml",
|
||||
feeds.RenderRSSFeed(posts, a.deps.Config.Site, a.baseURL(), seriesFeedPath(name, "xml")))
|
||||
}
|
||||
|
||||
func (a *API) handleSeriesAtom(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
posts := payloads.SeriesPosts(a.deps.Store, name)
|
||||
if len(posts) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
writeXML(w, r, "application/atom+xml",
|
||||
feeds.RenderAtomFeed(posts, a.deps.Config.Site, a.baseURL(), seriesFeedPath(name, "atom")))
|
||||
}
|
||||
|
||||
func (a *API) handleSeriesJSON(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
posts := payloads.SeriesPosts(a.deps.Store, name)
|
||||
if len(posts) == 0 {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
a.writeJSONFeed(w, r, posts, seriesFeedPath(name, "json"))
|
||||
}
|
||||
|
||||
func (a *API) handleRSS(w http.ResponseWriter, r *http.Request) {
|
||||
writeXML(w, r, "application/rss+xml",
|
||||
feeds.RenderRSSFeed(a.publishedPosts(), a.deps.Config.Site, a.baseURL(), siteFeedPath("xml")))
|
||||
}
|
||||
|
||||
func (a *API) handleAtom(w http.ResponseWriter, r *http.Request) {
|
||||
writeXML(w, r, "application/atom+xml",
|
||||
feeds.RenderAtomFeed(a.publishedPosts(), a.deps.Config.Site, a.baseURL(), siteFeedPath("atom")))
|
||||
}
|
||||
|
||||
func (a *API) handleJSONFeed(w http.ResponseWriter, r *http.Request) {
|
||||
a.writeJSONFeed(w, r, a.publishedPosts(), siteFeedPath("json"))
|
||||
}
|
||||
|
||||
// Feed paths, used for the self link and feed_url of each document.
|
||||
func siteFeedPath(format string) string { return "/api/volumen/feed." + format }
|
||||
func tagFeedPath(tag, format string) string {
|
||||
return "/api/volumen/tags/" + url.PathEscape(tag) + "/feed." + format
|
||||
}
|
||||
func seriesFeedPath(name, format string) string {
|
||||
return "/api/volumen/series/" + url.PathEscape(name) + "/feed." + format
|
||||
}
|
||||
|
||||
func (a *API) writeJSONFeed(w http.ResponseWriter, r *http.Request, posts []*post.Post, selfPath string) {
|
||||
body, err := feeds.MarshalJSONFeed(feeds.RenderJSONFeed(posts, a.deps.Config.Site, a.baseURL(), selfPath))
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
|
||||
return
|
||||
}
|
||||
writeCORS(w)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(append(body, '\n'))
|
||||
}
|
||||
|
||||
func (a *API) handleSitemap(w http.ResponseWriter, r *http.Request) {
|
||||
// The key is the store's snapshot, which changes whenever a post file
|
||||
// is added, edited, touched or removed: the sitemap's lastmod comes
|
||||
// from the file's modification time, so keying on the path and date
|
||||
// alone would serve a stale lastmod for the life of the process.
|
||||
// The key is taken before the posts: content changing between the two
|
||||
// reads would pin XML rendered from the older snapshot under the newer
|
||||
// key, and the cache would serve it until the next change. Key-first,
|
||||
// the worst case is XML newer than its key, which recomputes.
|
||||
key := a.deps.Store.CacheKey()
|
||||
posts := a.publishedPosts()
|
||||
|
||||
a.sitemapMu.Lock()
|
||||
if a.sitemapXML != "" && a.sitemapKey == key {
|
||||
xml := a.sitemapXML
|
||||
a.sitemapMu.Unlock()
|
||||
writeXML(w, r, "application/xml", xml)
|
||||
return
|
||||
}
|
||||
a.sitemapMu.Unlock()
|
||||
|
||||
xml := feeds.RenderSitemap(posts, a.baseURL())
|
||||
a.sitemapMu.Lock()
|
||||
a.sitemapKey = key
|
||||
a.sitemapXML = xml
|
||||
a.sitemapMu.Unlock()
|
||||
writeXML(w, r, "application/xml", xml)
|
||||
}
|
||||
|
||||
// --- token-authenticated writes ---------------------------------------------
|
||||
|
||||
var writeFields = []string{
|
||||
"title", "slug", "lang", "author", "fediverse_creator",
|
||||
"excerpt", "cover", "cover_alt", "cover_caption", "series",
|
||||
}
|
||||
|
||||
func (a *API) requireToken(w http.ResponseWriter, r *http.Request, scope string) (*tokens.Token, bool) {
|
||||
header := r.Header.Get("Authorization")
|
||||
scheme, raw, found := strings.Cut(header, " ")
|
||||
if !found || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(raw) == "" {
|
||||
writeUnauthorized(w, r)
|
||||
return nil, false
|
||||
}
|
||||
token := a.deps.Tokens.Authenticate(strings.TrimSpace(raw))
|
||||
if token == nil {
|
||||
writeUnauthorized(w, r)
|
||||
return nil, false
|
||||
}
|
||||
a.deps.Tokens.Touch(token.Name)
|
||||
if !token.HasScope(scope) {
|
||||
writeError(w, r, http.StatusForbidden, map[string]any{
|
||||
"error": "forbidden",
|
||||
"message": fmt.Sprintf("Token lacks '%s' scope", scope),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
|
||||
// writeUnauthorized answers a missing or invalid token. The challenge
|
||||
// goes out with the status line: a header set after WriteHeader never
|
||||
// reaches the client.
|
||||
func writeUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSONWithHeaders(w, r, http.StatusUnauthorized,
|
||||
map[string]any{"error": "unauthorized"},
|
||||
map[string]string{"WWW-Authenticate": "Bearer", "Cache-Control": "no-store"})
|
||||
}
|
||||
|
||||
// writeCORSHeaders builds the restrictive CORS header set for
|
||||
// token-authenticated write endpoints; only the configured base_url is
|
||||
// allowed as an origin (with a wildcard fallback for setups without
|
||||
// one).
|
||||
func writeCORSHeaders(r *http.Request, cfg *config.Config) map[string]string {
|
||||
base := strings.TrimRight(cfg.Site.BaseURL, "/")
|
||||
origin := r.Header.Get("Origin")
|
||||
allowed := "*"
|
||||
if base != "" && origin != "" {
|
||||
allowed = base
|
||||
}
|
||||
return map[string]string{
|
||||
"Access-Control-Allow-Origin": allowed,
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Cache-Control": "no-store",
|
||||
}
|
||||
}
|
||||
|
||||
// readJSONBody decodes a write payload. The decoder rejects a duplicate
|
||||
// object member and invalid UTF-8, so a body that a JSON parser could
|
||||
// read two ways is a 400 rather than a silent choice. A body over the
|
||||
// limit is a 413, not a parse failure.
|
||||
func readJSONBody(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
|
||||
var data map[string]any
|
||||
if err := json.UnmarshalRead(http.MaxBytesReader(w, r.Body, maxWriteBody), &data); err != nil {
|
||||
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
||||
writeError(w, r, http.StatusRequestEntityTooLarge, map[string]any{"error": "payload_too_large"})
|
||||
return nil, false
|
||||
}
|
||||
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "invalid_json"})
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
// postFromJSON merges a JSON write payload into a post: omitted fields
|
||||
// keep their values on update, an explicit null or empty string clears a
|
||||
// field, and a malformed type is rejected with a validation message
|
||||
// rather than coerced.
|
||||
func postFromJSON(data map[string]any, existing *post.Post) (*post.Post, error) {
|
||||
meta := frontmatterMetaFrom(existing)
|
||||
body := ""
|
||||
if existing != nil {
|
||||
body = existing.Body
|
||||
}
|
||||
if rawBody, present := data["body"]; present {
|
||||
// An explicit null clears the body, as it does every metadata
|
||||
// field; keeping the stored text would contradict the merge
|
||||
// contract the comment above documents.
|
||||
if rawBody == nil {
|
||||
body = ""
|
||||
} else {
|
||||
text, ok := rawBody.(string)
|
||||
if !ok {
|
||||
return nil, &payloads.ValidationError{Message: "body must be a string"}
|
||||
}
|
||||
body = text
|
||||
}
|
||||
}
|
||||
|
||||
bad := func(message string) (*post.Post, error) { return nil, &payloads.ValidationError{Message: message} }
|
||||
|
||||
for _, field := range writeFields {
|
||||
value, present := data[field]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) != "" {
|
||||
meta.Set(field, strings.TrimSpace(v))
|
||||
} else {
|
||||
meta.Delete(field)
|
||||
}
|
||||
case nil:
|
||||
meta.Delete(field)
|
||||
default:
|
||||
return bad(fmt.Sprintf("%s must be a string", field))
|
||||
}
|
||||
}
|
||||
for _, dateField := range []string{"date", "publish_at"} {
|
||||
value, present := data[dateField]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if parsed, ok := payloads.ParseDate(value); ok {
|
||||
meta.Set(dateField, interpres.LocalDate{Time: parsed})
|
||||
} else if value == nil || value == "" {
|
||||
meta.Delete(dateField)
|
||||
} else {
|
||||
return bad(fmt.Sprintf("%s must be an ISO 8601 date", dateField))
|
||||
}
|
||||
}
|
||||
if value, present := data["tags"]; present {
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
var cleaned []string
|
||||
for _, item := range v {
|
||||
// A malformed item is rejected, not coerced: fmt.Sprintf
|
||||
// would turn null into the tag "<nil>".
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return bad("tags must be a list of strings")
|
||||
}
|
||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
||||
cleaned = append(cleaned, trimmed)
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
meta.Set("tags", cleaned)
|
||||
} else {
|
||||
meta.Delete("tags")
|
||||
}
|
||||
case string:
|
||||
if strings.TrimSpace(v) != "" {
|
||||
meta.Set("tags", payloads.ParseTags(v))
|
||||
} else {
|
||||
meta.Delete("tags")
|
||||
}
|
||||
case nil:
|
||||
meta.Delete("tags")
|
||||
default:
|
||||
return bad("tags must be a list of strings")
|
||||
}
|
||||
}
|
||||
for _, boolField := range []string{"draft", "all_langs"} {
|
||||
value, present := data[boolField]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
b, ok := value.(bool)
|
||||
if !ok {
|
||||
return bad(fmt.Sprintf("%s must be a boolean", boolField))
|
||||
}
|
||||
if b {
|
||||
meta.Set(boolField, true)
|
||||
} else {
|
||||
meta.Delete(boolField)
|
||||
}
|
||||
}
|
||||
if value, present := data["series_order"]; present {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
meta.Delete("series_order")
|
||||
case bool:
|
||||
return bad("series_order must be an integer")
|
||||
case float64:
|
||||
if v != float64(int64(v)) {
|
||||
return bad("series_order must be an integer")
|
||||
}
|
||||
meta.Set("series_order", int64(v))
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
meta.Delete("series_order")
|
||||
break
|
||||
}
|
||||
n, ok := payloads.ParseInt(v)
|
||||
if !ok {
|
||||
return bad("series_order must be an integer")
|
||||
}
|
||||
meta.Set("series_order", int64(n))
|
||||
default:
|
||||
return bad("series_order must be an integer")
|
||||
}
|
||||
}
|
||||
|
||||
p := post.New(meta, body)
|
||||
if existing != nil {
|
||||
p.Path = existing.Path
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func frontmatterMetaFrom(existing *post.Post) *frontmatter.Meta {
|
||||
meta := frontmatter.NewMeta()
|
||||
if existing != nil {
|
||||
for _, key := range existing.Metadata.Keys() {
|
||||
value, _ := existing.Metadata.Get(key)
|
||||
meta.Set(key, value)
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func (a *API) handleCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := a.requireToken(w, r, "write"); !ok {
|
||||
return
|
||||
}
|
||||
data, ok := readJSONBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, err := postFromJSON(data, nil)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
saved, err := a.deps.Store.Save(p)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "save_failed"})
|
||||
return
|
||||
}
|
||||
summary := payloads.BuildSummary(saved)
|
||||
a.fire("post.created", map[string]any{"post": summary})
|
||||
headers := writeCORSHeaders(r, a.deps.Config)
|
||||
// The ETag names the resource state the single-post GET serves, so a
|
||||
// client can chain the create straight into an If-Match write.
|
||||
if detail, err := payloads.BuildDetail(saved, a.baseURL()); err == nil {
|
||||
headers["ETag"] = etagFor(detail)
|
||||
}
|
||||
writeJSONWithHeaders(w, r, http.StatusCreated, summary, headers)
|
||||
}
|
||||
|
||||
// preconditionHolds checks the request's If-Match against the ETag the
|
||||
// single-post GET serves for the same resource, so a client that read
|
||||
// the post, edited it and writes it back fails instead of overwriting a
|
||||
// change it never saw. No header is unconditional; `*` demands the post
|
||||
// exists, which the caller has already established.
|
||||
func (a *API) preconditionHolds(w http.ResponseWriter, r *http.Request, existing *post.Post) bool {
|
||||
header := r.Header.Get("If-Match")
|
||||
if header == "" {
|
||||
return true
|
||||
}
|
||||
detail, err := payloads.BuildDetail(existing, a.baseURL())
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
|
||||
return false
|
||||
}
|
||||
if etagMatches(header, etagFor(detail)) {
|
||||
return true
|
||||
}
|
||||
writeError(w, r, http.StatusPreconditionFailed, map[string]any{"error": "precondition_failed"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *API) handleUpdatePost(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := a.requireToken(w, r, "write"); !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
existing := a.deps.Store.Find(slug, "")
|
||||
if existing == nil {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
if !a.preconditionHolds(w, r, existing) {
|
||||
return
|
||||
}
|
||||
data, ok := readJSONBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, err := postFromJSON(data, existing)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if p.Slug() == "" {
|
||||
p.Metadata.Set("slug", slug)
|
||||
}
|
||||
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
// A post whose language came from its directory (not the frontmatter)
|
||||
// keeps it through a rename: the payload set no lang, so the new
|
||||
// default path would otherwise drop the language subdirectory and
|
||||
// silently move the post into the default language.
|
||||
if p.Lang() == "" {
|
||||
p.SetFileLocation(existing.Slug(), existing.Lang())
|
||||
}
|
||||
// SavePost moves the file when the slug changed and archives the old
|
||||
// one under its own language, the same way the admin editor does, so
|
||||
// a rename behaves alike from either entry point.
|
||||
saved, err := payloads.SavePost(a.deps.Store, p, existing)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "save_failed"})
|
||||
return
|
||||
}
|
||||
summary := payloads.BuildSummary(saved)
|
||||
a.fire("post.updated", map[string]any{"post": summary})
|
||||
headers := writeCORSHeaders(r, a.deps.Config)
|
||||
if detail, err := payloads.BuildDetail(saved, a.baseURL()); err == nil {
|
||||
headers["ETag"] = etagFor(detail)
|
||||
}
|
||||
writeJSONWithHeaders(w, r, http.StatusOK, summary, headers)
|
||||
}
|
||||
|
||||
func (a *API) handleDeletePost(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := a.requireToken(w, r, "delete"); !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
existing := a.deps.Store.Find(slug, "")
|
||||
if existing == nil {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
if !a.preconditionHolds(w, r, existing) {
|
||||
return
|
||||
}
|
||||
deleted, _, err := a.deps.Store.Delete(slug, "")
|
||||
if err != nil {
|
||||
web.Logger(r.Context()).Error("httpapi: cannot delete post", "slug", slug, "error", err)
|
||||
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "delete_failed"})
|
||||
return
|
||||
}
|
||||
if deleted == nil {
|
||||
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
|
||||
return
|
||||
}
|
||||
a.fire("post.deleted", map[string]any{"post": map[string]any{
|
||||
"slug": deleted.Slug(), "title": deleted.Title(),
|
||||
}})
|
||||
for key, value := range writeCORSHeaders(r, a.deps.Config) {
|
||||
w.Header().Set(key, value)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/preview"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
handler http.Handler
|
||||
store *store.Store
|
||||
tokens *tokens.Store
|
||||
events []string
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, files map[string]string) *fixture {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
for name, body := range files {
|
||||
path := filepath.Join(content, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
|
||||
Host: "",
|
||||
Port: -1,
|
||||
ContentDir: content,
|
||||
UsersFile: filepath.Join(dir, "users.toml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
cfg.Site.BaseURL = "https://site.example"
|
||||
cfg.Admin.SessionKey = strings.Repeat("k", 64)
|
||||
|
||||
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
|
||||
f := &fixture{store: st, tokens: tokens.New(filepath.Join(dir, "tokens.toml"))}
|
||||
f.handler = New(Deps{
|
||||
Config: cfg,
|
||||
Store: st,
|
||||
Tokens: f.tokens,
|
||||
PreviewKey: cfg.Admin.SessionKey,
|
||||
OnEvent: func(event string, _ map[string]any) {
|
||||
f.events = append(f.events, event)
|
||||
},
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("invalid JSON %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const helloFile = `+++
|
||||
title = "Hello"
|
||||
slug = "hello"
|
||||
date = 2026-08-18
|
||||
lang = "cs"
|
||||
tags = ["go"]
|
||||
+++
|
||||
|
||||
Hello **body**.
|
||||
`
|
||||
|
||||
const draftFile = `+++
|
||||
title = "Draft"
|
||||
slug = "draft"
|
||||
draft = true
|
||||
+++
|
||||
|
||||
draft body
|
||||
`
|
||||
|
||||
const scheduledFile = `+++
|
||||
title = "Future"
|
||||
slug = "future"
|
||||
publish_at = 2999-01-01
|
||||
+++
|
||||
|
||||
future body
|
||||
`
|
||||
|
||||
func TestSiteAndETag(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := decodeJSON(t, rec)
|
||||
if body["title"] != config.DefaultSiteTitle || body["base_url"] != "https://site.example" {
|
||||
t.Fatalf("site = %v", body)
|
||||
}
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatal("CORS missing")
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Cache-Control"), "max-age=60") {
|
||||
t.Fatalf("cache-control = %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
etag := rec.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("ETag missing")
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
rec2 := f.do(t, req2)
|
||||
if rec2.Code != http.StatusNotModified {
|
||||
t.Fatalf("code = %d, want 304", rec2.Code)
|
||||
}
|
||||
if rec2.Header().Get("ETag") != etag {
|
||||
t.Fatal("ETag lost on 304")
|
||||
}
|
||||
|
||||
// Weak comparison: W/ prefix and lists still match.
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)
|
||||
req3.Header.Set("If-None-Match", "W/"+etag+", \"other\"")
|
||||
if rec3 := f.do(t, req3); rec3.Code != http.StatusNotModified {
|
||||
t.Fatalf("weak compare failed: %d", rec3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostsPaginationAndFilters(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{
|
||||
"hello.md": helloFile,
|
||||
"draft.md": draftFile,
|
||||
"scheduled.md": scheduledFile,
|
||||
})
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts", nil))
|
||||
body := decodeJSON(t, rec)
|
||||
if body["total"] != float64(1) {
|
||||
t.Fatalf("total = %v", body["total"])
|
||||
}
|
||||
posts := body["posts"].([]any)
|
||||
first := posts[0].(map[string]any)
|
||||
if first["slug"] != "hello" {
|
||||
t.Fatalf("post = %v", first)
|
||||
}
|
||||
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts?lang=cs&tag=go&q=hello", nil))
|
||||
if decodeJSON(t, rec)["total"] != float64(1) {
|
||||
t.Fatal("filters wrong")
|
||||
}
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts?lang=en", nil))
|
||||
if decodeJSON(t, rec)["total"] != float64(0) {
|
||||
t.Fatal("lang filter wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostsQueryValidation(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
for _, path := range []string{
|
||||
"/api/volumen/posts?page=0",
|
||||
"/api/volumen/posts?page=abc",
|
||||
"/api/volumen/posts?limit=0",
|
||||
"/api/volumen/posts?limit=101",
|
||||
} {
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("%s: code = %d, want 422", path, rec.Code)
|
||||
}
|
||||
body := decodeJSON(t, rec)
|
||||
if body["error"] != "validation" || body["field"] == nil {
|
||||
t.Fatalf("%s: body = %v", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatch(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"hello.md": helloFile, "draft.md": draftFile})
|
||||
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/batch", nil))
|
||||
if decodeJSON(t, rec)["posts"] == nil {
|
||||
t.Fatal("empty batch wrong")
|
||||
}
|
||||
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/batch?slugs=hello,draft,missing", nil))
|
||||
body := decodeJSON(t, rec)
|
||||
posts := body["posts"].([]any)
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("posts = %v", posts)
|
||||
}
|
||||
first := posts[0].(map[string]any)
|
||||
if first["slug"] != "hello" || first["html"] == nil || first["meta"] == nil {
|
||||
t.Fatalf("detail = %v", first)
|
||||
}
|
||||
if rec.Header().Get("ETag") == "" {
|
||||
t.Fatal("ETag missing on batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSinglePostAndAliases(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{
|
||||
"hello.md": "+++\ntitle = \"Hi\"\nslug = \"new\"\naliases = [\"old\"]\n+++\nbody\n",
|
||||
})
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/new", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if decodeJSON(t, rec)["title"] != "Hi" {
|
||||
t.Fatal("wrong post")
|
||||
}
|
||||
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/old", nil))
|
||||
if rec.Code != http.StatusMovedPermanently ||
|
||||
rec.Header().Get("Location") != "/api/volumen/posts/new" {
|
||||
t.Fatalf("alias redirect: %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/nope", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if decodeJSON(t, rec)["error"] != "not_found" {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftAndScheduledHiddenUnlessPreview(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"draft.md": draftFile, "scheduled.md": scheduledFile})
|
||||
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("draft visible: %d", rec.Code)
|
||||
}
|
||||
if decodeJSON(t, rec)["error"] != "draft" {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/future", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("scheduled visible: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Valid preview token reveals the draft.
|
||||
token := preview.Token("draft", strings.Repeat("k", 64), time.Now())
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token="+token, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("preview failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Garbage token does not.
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token=bogus", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("bogus token accepted: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagsAndSeries(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{
|
||||
"a.md": "+++\nslug = \"a\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 1\ndate = 2026-01-01\n+++\nx\n",
|
||||
"b.md": "+++\nslug = \"b\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 2\ndate = 2026-01-02\n+++\nx\n",
|
||||
})
|
||||
body := decodeJSON(t, f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags", nil)))
|
||||
tags := body["tags"].([]any)
|
||||
if len(tags) != 1 || tags[0].(map[string]any)["name"] != "go" {
|
||||
t.Fatalf("tags = %v", tags)
|
||||
}
|
||||
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go", nil))
|
||||
if decodeJSON(t, rec)["total"] != float64(2) {
|
||||
t.Fatal("tag posts wrong")
|
||||
}
|
||||
if rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/none", nil)); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown tag: %d", rec.Code)
|
||||
}
|
||||
|
||||
body = decodeJSON(t, f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series", nil)))
|
||||
series := body["series"].([]any)
|
||||
if len(series) != 1 || series[0].(map[string]any)["name"] != "S" {
|
||||
t.Fatalf("series = %v", series)
|
||||
}
|
||||
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series/S", nil))
|
||||
body = decodeJSON(t, rec)
|
||||
if body["count"] != float64(2) {
|
||||
t.Fatalf("series detail = %v", body)
|
||||
}
|
||||
if rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series/none", nil)); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown series: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
const seriesFile = `+++
|
||||
title = "Second"
|
||||
slug = "second"
|
||||
date = 2026-08-19
|
||||
series = "S"
|
||||
series_order = 1
|
||||
tags = ["go"]
|
||||
+++
|
||||
|
||||
Second body.`
|
||||
|
||||
func TestFeedsAndSitemap(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"hello.md": helloFile, "second.md": seriesFile})
|
||||
|
||||
cases := map[string]string{
|
||||
"/api/volumen/feed.xml": "application/rss+xml",
|
||||
"/api/volumen/feed.atom": "application/atom+xml",
|
||||
"/api/volumen/feed.json": "application/json",
|
||||
"/api/volumen/sitemap.xml": "application/xml",
|
||||
"/api/volumen/tags/go/feed.xml": "application/rss+xml",
|
||||
"/api/volumen/tags/go/feed.atom": "application/atom+xml",
|
||||
"/api/volumen/tags/go/feed.json": "application/json",
|
||||
}
|
||||
for path, contentType := range cases {
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: code = %d", path, rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != contentType {
|
||||
t.Fatalf("%s: content-type = %q, want %q", path, got, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// JSON feed keeps literal characters.
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/feed.json", nil))
|
||||
if strings.Contains(rec.Body.String(), `&`) {
|
||||
t.Fatal("JSON feed HTML-escaped")
|
||||
}
|
||||
|
||||
// Series feeds render the series, not the whole site, and name
|
||||
// themselves in the self link.
|
||||
for path, contentType := range map[string]string{
|
||||
"/api/volumen/series/S/feed.xml": "application/rss+xml",
|
||||
"/api/volumen/series/S/feed.atom": "application/atom+xml",
|
||||
"/api/volumen/series/S/feed.json": "application/json",
|
||||
} {
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: code = %d", path, rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != contentType {
|
||||
t.Fatalf("%s: content-type = %q, want %q", path, got, contentType)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "second") {
|
||||
t.Fatalf("%s does not carry the series post:\n%s", path, body)
|
||||
}
|
||||
if strings.Contains(body, "hello") {
|
||||
t.Fatalf("%s carries a post outside the series:\n%s", path, body)
|
||||
}
|
||||
if !strings.Contains(body, "/api/volumen/series/S/feed.") {
|
||||
t.Fatalf("%s does not name itself:\n%s", path, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A tag feed carries the tagged posts and names itself; a tag feed
|
||||
// for an unknown tag is a 404.
|
||||
tagFeed := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go/feed.json", nil))
|
||||
if body := tagFeed.Body.String(); !strings.Contains(body, "hello") ||
|
||||
!strings.Contains(body, "/api/volumen/tags/go/feed.json") {
|
||||
t.Fatalf("tag json feed = %s", body)
|
||||
}
|
||||
tagAtom := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go/feed.atom", nil))
|
||||
if body := tagAtom.Body.String(); !strings.Contains(body, "/api/volumen/tags/go/feed.atom") {
|
||||
t.Fatalf("tag atom feed does not name itself: %s", body)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/api/volumen/series/none/feed.xml",
|
||||
"/api/volumen/series/none/feed.atom",
|
||||
"/api/volumen/series/none/feed.json",
|
||||
"/api/volumen/tags/none/feed.json",
|
||||
"/api/volumen/tags/none/feed.atom",
|
||||
} {
|
||||
if rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil)); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s: code = %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsPreflight(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodOptions, "/api/volumen/posts", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") != "*" ||
|
||||
!strings.Contains(rec.Header().Get("Access-Control-Allow-Methods"), "GET") {
|
||||
t.Fatalf("headers = %v", rec.Header())
|
||||
}
|
||||
}
|
||||
|
||||
// An If-Match write is refused with 412 when the etag the client holds
|
||||
// no longer names the stored state, and accepted when it does. The
|
||||
// guard is opt-in: a write without the header stays unconditional.
|
||||
// Frontmatter keys the engine does not consume pass through to the
|
||||
// detail payload's fields object; a post without any omits the member.
|
||||
func TestCustomFieldsPassThrough(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"hello.md": helloFile,
|
||||
"custom.md": `+++
|
||||
title = "Custom"
|
||||
slug = "custom"
|
||||
date = 2026-08-18
|
||||
|
||||
[colour]
|
||||
accent = "#0f0"
|
||||
depths = [1, 2, 3]
|
||||
|
||||
[ratings]
|
||||
good = 5
|
||||
[[items]]
|
||||
n = 1
|
||||
+++`,
|
||||
}
|
||||
f := newFixture(t, files)
|
||||
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/custom", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := decodeJSON(t, rec)
|
||||
fields, ok := body["fields"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("fields missing: %s", rec.Body.String())
|
||||
}
|
||||
colour, ok := fields["colour"].(map[string]any)
|
||||
if !ok || colour["accent"] != "#0f0" {
|
||||
t.Fatalf("colour = %v", fields["colour"])
|
||||
}
|
||||
if depths, _ := colour["depths"].([]any); len(depths) != 3 {
|
||||
t.Fatalf("depths = %v", colour["depths"])
|
||||
}
|
||||
if ratings, _ := fields["ratings"].(map[string]any); ratings["good"] != float64(5) {
|
||||
t.Fatalf("ratings = %v", fields["ratings"])
|
||||
}
|
||||
if items, _ := fields["items"].([]any); len(items) != 1 {
|
||||
t.Fatalf("items = %v", fields["items"])
|
||||
}
|
||||
// A known key is never duplicated into fields.
|
||||
if _, present := fields["title"]; present {
|
||||
t.Fatalf("known key leaked into fields: %v", fields)
|
||||
}
|
||||
|
||||
// A post without custom frontmatter carries no fields member.
|
||||
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil))
|
||||
if strings.Contains(rec.Body.String(), `"fields"`) {
|
||||
t.Fatalf("empty fields leaked: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A search ranks by relevance: a title hit leads, a tag that contains
|
||||
// the query is now found at all, and a body-only mention trails; the
|
||||
// date order alone would read the other way round.
|
||||
func TestSearchRanksByRelevance(t *testing.T) {
|
||||
searchPost := func(slug, title, tags, date, body string) string {
|
||||
return fmt.Sprintf(`+++
|
||||
title = %q
|
||||
slug = %q
|
||||
lang = "en"
|
||||
date = %s
|
||||
tags = [%q]
|
||||
+++
|
||||
|
||||
%s
|
||||
`, title, slug, date, tags, body)
|
||||
}
|
||||
files := map[string]string{
|
||||
"title-hit.md": searchPost("title-hit", "WebP guide", "images", "2026-08-01", "nothing relevant here"),
|
||||
"tag-hit.md": searchPost("tag-hit", "Unrelated one", "webp", "2026-08-02", "nothing relevant here"),
|
||||
"body-hit.md": searchPost("body-hit", "Unrelated two", "images", "2026-08-03", "the webp format is lovely"),
|
||||
}
|
||||
f := newFixture(t, files)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts?q=webp", nil)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
posts, ok := decodeJSON(t, rec)["posts"].([]any)
|
||||
if !ok || len(posts) != 3 {
|
||||
t.Fatalf("posts = %v", posts)
|
||||
}
|
||||
want := []string{"title-hit", "tag-hit", "body-hit"}
|
||||
for i, slug := range want {
|
||||
if got := posts[i].(map[string]any)["slug"]; got != slug {
|
||||
t.Fatalf("rank %d = %v, want %q", i, got, slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfMatchGuardsWrites(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"hello.md": helloFile})
|
||||
_, raw, err := f.tokens.Create("full", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
auth := "Bearer " + raw
|
||||
|
||||
servedETag := func() string {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil)
|
||||
return f.do(t, req).Header().Get("ETag")
|
||||
}
|
||||
fresh := servedETag()
|
||||
if fresh == "" {
|
||||
t.Fatal("GET served no ETag")
|
||||
}
|
||||
|
||||
put := func(ifMatch string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/hello", strings.NewReader(`{"title":"Fresh"}`))
|
||||
req.Header.Set("Authorization", auth)
|
||||
if ifMatch != "" {
|
||||
req.Header.Set("If-Match", ifMatch)
|
||||
}
|
||||
return f.do(t, req)
|
||||
}
|
||||
|
||||
if rec := put(`"0000000000000000"`); rec.Code != http.StatusPreconditionFailed {
|
||||
t.Fatalf("stale etag: code = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := decodeJSON(t, put(`"0000000000000000"`)); body["error"] != "precondition_failed" {
|
||||
t.Fatalf("body = %v", body)
|
||||
}
|
||||
|
||||
rec := put(fresh)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("fresh etag: code = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
stored := servedETag()
|
||||
if rec.Header().Get("ETag") != stored {
|
||||
t.Fatalf("response ETag %q does not name the stored state %q", rec.Header().Get("ETag"), stored)
|
||||
}
|
||||
if rec.Header().Get("ETag") == fresh {
|
||||
t.Fatal("the etag survived an edit")
|
||||
}
|
||||
|
||||
if rec := put("*"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("star etag: code = %d", rec.Code)
|
||||
}
|
||||
if rec := put(""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("no header: code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfMatchGuardsDelete(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"hello.md": helloFile})
|
||||
_, raw, err := f.tokens.Create("full", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
auth := "Bearer " + raw
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/hello", nil)
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("If-Match", `"0000000000000000"`)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusPreconditionFailed {
|
||||
t.Fatalf("stale etag: code = %d", rec.Code)
|
||||
}
|
||||
|
||||
get := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil)
|
||||
fresh := f.do(t, get).Header().Get("ETag")
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/hello", nil)
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("If-Match", fresh)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("fresh etag: code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEndpointsRequireToken(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"hello.md": helloFile})
|
||||
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("WWW-Authenticate") != "Bearer" {
|
||||
t.Fatal("WWW-Authenticate missing")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer vol_bogus")
|
||||
if rec := f.do(t, req); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEndpointsScopeEnforced(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
// A token that carries only the delete scope may not write.
|
||||
_, raw, err := f.tokens.Create("scoped", []string{"delete"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := decodeJSON(t, rec)
|
||||
if message, _ := body["message"].(string); !strings.Contains(message, "write") {
|
||||
t.Fatalf("body = %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUpdateDeletePost(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
_, raw, err := f.tokens.Create("full", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// Invalid payload rejected.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
|
||||
strings.NewReader(`{"slug": "Upper"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Valid create.
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
|
||||
strings.NewReader(`{"slug": "created", "title": "Created", "body": "hello", "tags": ["go", "blog"]}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = f.do(t, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if decodeJSON(t, rec)["title"] != "Created" {
|
||||
t.Fatal("wrong payload")
|
||||
}
|
||||
if len(f.events) != 1 || f.events[0] != "post.created" {
|
||||
t.Fatalf("events = %v", f.events)
|
||||
}
|
||||
if f.store.Find("created", "") == nil {
|
||||
t.Fatal("post not saved")
|
||||
}
|
||||
|
||||
// Partial update keeps omitted fields.
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
|
||||
strings.NewReader(`{"title": "Updated"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
p := f.store.Find("created", "")
|
||||
// The body keeps the trailing newline the writer normalises, exactly
|
||||
// a second round-trip must produce the same document.
|
||||
if p.Title() != "Updated" || len(p.Tags()) != 2 || p.Body != "hello\n" {
|
||||
t.Fatalf("title=%q tags=%v body=%q", p.Title(), p.Tags(), p.Body)
|
||||
}
|
||||
|
||||
// Clearing a field with null.
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
|
||||
strings.NewReader(`{"title": null}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if f.store.Find("created", "").Title() != "" {
|
||||
t.Fatal("title not cleared")
|
||||
}
|
||||
|
||||
// Malformed types rejected.
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
|
||||
strings.NewReader(`{"draft": "yes"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
|
||||
// Unknown slug.
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/ghost",
|
||||
strings.NewReader(`{"title": "x"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
|
||||
// Invalid JSON body.
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`not json`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/created", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = f.do(t, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if f.store.Find("created", "") != nil {
|
||||
t.Fatal("post not deleted")
|
||||
}
|
||||
if f.events[len(f.events)-1] != "post.deleted" {
|
||||
t.Fatalf("events = %v", f.events)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/created", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRenameSoftDeletesOldFile(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{
|
||||
"old.md": "+++\ntitle = \"Old\"\nslug = \"old\"\n+++\nbody\n",
|
||||
})
|
||||
_, raw, err := f.tokens.Create("full", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/old",
|
||||
strings.NewReader(`{"slug": "new-name"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if f.store.Find("new-name", "") == nil {
|
||||
t.Fatal("renamed post missing")
|
||||
}
|
||||
if f.store.Find("old", "") != nil {
|
||||
t.Fatal("old slug still resolves")
|
||||
}
|
||||
if f.store.TombstonePath("old") == "" {
|
||||
t.Fatal("old file not soft-deleted")
|
||||
}
|
||||
if restored := f.store.Undelete("old"); restored == nil {
|
||||
t.Fatal("rename not undoable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewTokenShape(t *testing.T) {
|
||||
now := time.Now()
|
||||
// 32 hex characters plus an expiry stamp, stable for the same slug,
|
||||
// secret and day.
|
||||
token := preview.Token("hello", "secret", now)
|
||||
if len(token) != 32+1+10 {
|
||||
t.Fatalf("token = %q", token)
|
||||
}
|
||||
if token != preview.Token("hello", "secret", now) {
|
||||
t.Fatal("token not stable")
|
||||
}
|
||||
if token == preview.Token("other", "secret", now) {
|
||||
t.Fatal("token ignores slug")
|
||||
}
|
||||
if !preview.Valid(token, "hello", "secret", now) {
|
||||
t.Fatal("fresh token rejected")
|
||||
}
|
||||
if preview.Valid(token, "hello", "secret", now.Add(preview.TTL+time.Hour)) {
|
||||
t.Fatal("expired token accepted")
|
||||
}
|
||||
if preview.Valid(token, "hello", "other", now) {
|
||||
t.Fatal("token accepted with the wrong key")
|
||||
}
|
||||
if preview.Token("hello", "", now) != "" {
|
||||
t.Fatal("a token was minted without a session key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesOrderBooleanRejected(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"a.md": "+++\nslug = \"a\"\n+++\nx\n"})
|
||||
_, raw, err := f.tokens.Create("full", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/a",
|
||||
strings.NewReader(`{"series_order": true}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEndpointsKeepRestrictiveCORS(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
_, raw, err := f.tokens.Create("full", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
|
||||
strings.NewReader(`{"slug": "cors-check", "title": "T"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://site.example" {
|
||||
t.Fatalf("allow-origin = %q, want the configured base_url", got)
|
||||
}
|
||||
if methods := rec.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(methods, "POST") {
|
||||
t.Fatalf("allow-methods = %q", methods)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit null body clears the stored text, matching the merge
|
||||
// contract every other field follows.
|
||||
func TestUpdateClearsBodyWithNull(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
_, raw, _ := f.tokens.Create("full", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
|
||||
strings.NewReader(`{"slug": "body-test", "title": "B", "body": "text"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/body-test",
|
||||
strings.NewReader(`{"body": null}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusOK {
|
||||
t.Fatalf("update code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// The writer always ends the file with one newline, so an empty
|
||||
// body reads back as exactly that.
|
||||
if body := f.store.Find("body-test", "").Body; body != "\n" {
|
||||
t.Fatalf("body = %q, want cleared", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A tag list item that is not a string is rejected rather than coerced
|
||||
// into a made-up tag such as "<nil>".
|
||||
func TestUpdateRejectsNonStringTags(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
_, raw, _ := f.tokens.Create("full", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
|
||||
strings.NewReader(`{"slug": "tag-test", "title": "T", "body": "x"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, body := range []string{`{"tags": [null]}`, `{"tags": [3]}`, `{"tags": [true]}`} {
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/tag-test", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("body %s: code = %d", body, rec.Code)
|
||||
}
|
||||
if decodeJSON(t, rec)["error"] != "validation" {
|
||||
t.Fatalf("body %s: envelope = %s", body, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if tags := f.store.Find("tag-test", "").Tags(); len(tags) != 0 {
|
||||
t.Fatalf("tags = %v, want untouched", tags)
|
||||
}
|
||||
}
|
||||
|
||||
// A body over the limit is a 413, not a parse failure.
|
||||
func TestWriteBodyOverTheLimitIs413(t *testing.T) {
|
||||
f := newFixture(t, nil)
|
||||
_, raw, _ := f.tokens.Create("full", nil)
|
||||
big := strings.Repeat("x", maxWriteBody+1)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
|
||||
strings.NewReader(`{"slug": "big", "body": "`+big+`"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if decodeJSON(t, rec)["error"] != "payload_too_large" {
|
||||
t.Fatalf("envelope = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A preview response is not publicly cacheable: the URL is only valid
|
||||
// with the token, and a shared cache must not keep unpublished content.
|
||||
func TestPreviewResponseIsNotPubliclyCacheable(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"draft.md": draftFile})
|
||||
token := preview.Token("draft", strings.Repeat("k", 64), time.Now())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token="+token, nil)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("preview failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
// The published detail stays publicly cacheable.
|
||||
f2 := newFixture(t, map[string]string{"hello.md": helloFile})
|
||||
rec = f2.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil))
|
||||
if got := rec2CacheControl(rec); got == "no-store" {
|
||||
t.Fatalf("published detail carries %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func rec2CacheControl(rec *httptest.ResponseRecorder) string {
|
||||
return rec.Header().Get("Cache-Control")
|
||||
}
|
||||
|
||||
// Renaming through the API keeps a language that came from the file's
|
||||
// directory: the new file lands in the same language subtree rather
|
||||
// than in the content root.
|
||||
func TestRenameKeepsDirectoryLanguage(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{
|
||||
// No lang in the frontmatter: cs comes from the directory.
|
||||
"cs/hello.md": "+++\ntitle = \"Hello\"\nslug = \"hello\"\n+++\nbody\n",
|
||||
})
|
||||
_, raw, _ := f.tokens.Create("full", nil)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/hello",
|
||||
strings.NewReader(`{"slug": "hi"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("rename code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
renamed := f.store.Find("hi", "")
|
||||
if renamed == nil {
|
||||
t.Fatal("renamed post missing")
|
||||
}
|
||||
if renamed.Lang() != "cs" {
|
||||
t.Fatalf("lang = %q, want cs", renamed.Lang())
|
||||
}
|
||||
if want := filepath.Join(f.store.ContentDir, "cs", "hi.md"); renamed.Path != want {
|
||||
t.Fatalf("path = %q, want %q", renamed.Path, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var updateContract = flag.Bool("update-contract", false, "rewrite the API contract goldens")
|
||||
|
||||
// contractRequests pin the public JSON API contract. Any change to a
|
||||
// response body shows up as a golden diff, which is exactly the point:
|
||||
// the API serves the front-end, so its shape is a contract.
|
||||
var contractRequests = map[string]string{
|
||||
"site": "/api/volumen/site",
|
||||
"posts": "/api/volumen/posts",
|
||||
"posts_page2": "/api/volumen/posts?limit=1&page=2",
|
||||
"posts_cursor": "/api/volumen/posts?cursor=alpha&limit=1",
|
||||
"posts_filtered": "/api/volumen/posts?tag=go&q=alpha&lang=cs",
|
||||
"posts_batch": "/api/volumen/posts/batch?slugs=alpha,draft,missing",
|
||||
"post_detail": "/api/volumen/posts/alpha",
|
||||
"post_not_found": "/api/volumen/posts/ghost",
|
||||
"post_draft": "/api/volumen/posts/draft",
|
||||
"tags": "/api/volumen/tags",
|
||||
"tag_posts": "/api/volumen/tags/go",
|
||||
"series": "/api/volumen/series",
|
||||
"series_detail": "/api/volumen/series/Series",
|
||||
"feed_json": "/api/volumen/feed.json",
|
||||
}
|
||||
|
||||
const contractPost = `+++
|
||||
title = "Alpha"
|
||||
slug = "alpha"
|
||||
date = 2026-08-18
|
||||
lang = "cs"
|
||||
author = "Petr"
|
||||
tags = ["go", "research"]
|
||||
series = "Series"
|
||||
series_order = 1
|
||||
fediverse_creator = "@petr@social"
|
||||
cover = "/media/c.webp"
|
||||
cover_alt = "alt"
|
||||
cover_caption = "caption"
|
||||
aliases = ["old-alpha"]
|
||||
|
||||
[translations]
|
||||
en = "alpha-en"
|
||||
+++
|
||||
|
||||
Alpha **body** with a [link](https://example.com).
|
||||
`
|
||||
|
||||
const contractSecond = `+++
|
||||
title = "Beta"
|
||||
slug = "beta"
|
||||
date = 2026-07-01
|
||||
tags = ["go"]
|
||||
+++
|
||||
|
||||
Beta body.
|
||||
`
|
||||
|
||||
// TestAPIContract snapshots every public JSON response. Regenerate with
|
||||
// `go test ./internal/httpapi -update-contract` after an intentional
|
||||
// contract change, and record it in the CHANGELOG.
|
||||
func TestAPIContract(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{
|
||||
"alpha.md": contractPost,
|
||||
"beta.md": contractSecond,
|
||||
"draft.md": "+++\nslug = \"draft\"\ntitle = \"Draft\"\ndraft = true\n+++\nDraft body.\n",
|
||||
})
|
||||
|
||||
for name, path := range contractRequests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := f.do(t, req)
|
||||
got := rec.Body.Bytes()
|
||||
|
||||
golden := filepath.Join("testdata", "contract", name+".json")
|
||||
if *updateContract {
|
||||
if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(golden, got, 0o644); err != nil {
|
||||
t.Fatalf("write golden: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(golden)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden (run with -update-contract): %v", err)
|
||||
}
|
||||
// JSON objects are unordered, so compare parsed values
|
||||
// rather than bytes; keys and values must match exactly.
|
||||
var gotJSON, wantJSON any
|
||||
if err := json.Unmarshal(got, &gotJSON); err != nil {
|
||||
t.Fatalf("response is not JSON: %v\n%s", err, got)
|
||||
}
|
||||
if err := json.Unmarshal(want, &wantJSON); err != nil {
|
||||
t.Fatalf("golden is not JSON: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotJSON, wantJSON) {
|
||||
t.Fatalf("contract changed for %s:\n--- got ---\n%s\n--- want ---\n%s",
|
||||
path, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestContractStatusCodes pins the status codes that accompany the
|
||||
// bodies above.
|
||||
func TestContractStatusCodes(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{
|
||||
"alpha.md": contractPost,
|
||||
"draft.md": "+++\nslug = \"draft\"\ndraft = true\n+++\nx\n",
|
||||
})
|
||||
want := map[string]int{
|
||||
"/api/volumen/site": http.StatusOK,
|
||||
"/api/volumen/posts": http.StatusOK,
|
||||
"/api/volumen/posts/alpha": http.StatusOK,
|
||||
"/api/volumen/posts/ghost": http.StatusNotFound,
|
||||
"/api/volumen/posts/draft": http.StatusNotFound,
|
||||
"/api/volumen/posts/old-alpha": http.StatusMovedPermanently,
|
||||
"/api/volumen/tags/go": http.StatusOK,
|
||||
"/api/volumen/tags/none": http.StatusNotFound,
|
||||
"/api/volumen/series/None": http.StatusNotFound,
|
||||
"/api/volumen/posts?page=0": http.StatusUnprocessableEntity,
|
||||
"/api/volumen/posts?limit=101": http.StatusUnprocessableEntity,
|
||||
"/api/volumen/posts?page=1000001": http.StatusUnprocessableEntity,
|
||||
}
|
||||
for path, code := range want {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if rec := f.do(t, req); rec.Code != code {
|
||||
t.Fatalf("%s: code = %d, want %d", path, rec.Code, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":"https://jsonfeed.org/version/1.1","title":"Volumen","home_page_url":"https://site.example","feed_url":"https://site.example/api/volumen/feed.json","description":"Powered by Volumen.","language":"en","items":[{"id":"https://site.example/alpha","url":"https://site.example/alpha","title":"Alpha","content_html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","summary":"Alpha body with a [link](https://example.com).","date_published":"2026-08-18","tags":["go","research"],"authors":[{"name":"@petr@social"}]},{"id":"https://site.example/beta","url":"https://site.example/beta","title":"Beta","content_html":"<p>Beta body.</p>\n","summary":"Beta body.","date_published":"2026-07-01","tags":["go"]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha","body":"Alpha **body** with a [link](https://example.com).\n","html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","toc":"<div class=\"toc\">\n<ul></ul>\n</div>\n","meta":{"url":"https://site.example/alpha","json_ld":"{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://site.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://site.example/alpha\"}","og":{"article:author":"Petr","article:published_time":"2026-08-18","article:tag":["go","research"],"og:description":"Alpha body with a [link](https://example.com).","og:image":"/media/c.webp","og:locale":"cs","og:title":"Alpha","og:type":"article","og:url":"https://site.example/alpha"},"twitter":{"twitter:card":"summary_large_image","twitter:creator":"@petr@social","twitter:description":"Alpha body with a [link](https://example.com).","twitter:image":"/media/c.webp","twitter:title":"Alpha"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":"draft"}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":"not_found"}
|
||||
@@ -0,0 +1 @@
|
||||
{"page_size":20,"total":2,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"},{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":1,"has_next":false,"has_prev":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha","body":"Alpha **body** with a [link](https://example.com).\n","html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","toc":"<div class=\"toc\">\n<ul></ul>\n</div>\n","meta":{"url":"https://site.example/alpha","json_ld":"{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://site.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://site.example/alpha\"}","og":{"article:author":"Petr","article:published_time":"2026-08-18","article:tag":["go","research"],"og:description":"Alpha body with a [link](https://example.com).","og:image":"/media/c.webp","og:locale":"cs","og:title":"Alpha","og:type":"article","og:url":"https://site.example/alpha"},"twitter":{"twitter:card":"summary_large_image","twitter:creator":"@petr@social","twitter:description":"Alpha body with a [link](https://example.com).","twitter:image":"/media/c.webp","twitter:title":"Alpha"}}}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"page_size":1,"total":2,"posts":[{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"next_cursor":null}
|
||||
@@ -0,0 +1 @@
|
||||
{"page_size":20,"total":1,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"}],"page":1,"has_next":false,"has_prev":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"page_size":1,"total":2,"posts":[{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":2,"has_next":false,"has_prev":true}
|
||||
@@ -0,0 +1 @@
|
||||
{"series":[{"name":"Series","count":1}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"Series","count":1,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"title":"Volumen","description":"Powered by Volumen.","base_url":"https://site.example","language":"en","author":"Anonymous","fediverse_creator":""}
|
||||
@@ -0,0 +1 @@
|
||||
{"page_size":20,"total":2,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"},{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":1,"has_next":false,"has_prev":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"tags":[{"name":"go","count":2},{"name":"research","count":1}]}
|
||||
Reference in New Issue
Block a user