Move template rendering logic into api package

This commit is contained in:
Brian Picciano 2022-05-14 16:14:11 -06:00
parent dd354bc323
commit 4c04177c05
14 changed files with 111 additions and 261 deletions

View File

@ -3,8 +3,10 @@ package api
import (
"context"
"embed"
"errors"
"fmt"
"html/template"
"net"
"net/http"
"net/http/httputil"
@ -20,6 +22,11 @@ import (
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
)
//go:embed tpl
var fs embed.FS
var tpls = template.Must(template.ParseFS(fs, "tpl/*"))
// Params are used to instantiate a new API instance. All fields are required
// unless otherwise noted.
type Params struct {
@ -27,7 +34,6 @@ type Params struct {
PowManager pow.Manager
PostStore post.Store
PostHTTPRenderer post.Renderer
MailingList mailinglist.MailingList
@ -190,7 +196,7 @@ func (a *api) handler() http.Handler {
mux.Handle("/api/", http.StripPrefix("/api", apiHandler))
mux.Handle("/posts/", a.postHandler())
mux.Handle("/v2/posts/", a.postHandler())
return mux
}

View File

@ -1,6 +1,6 @@
// Package apiutils contains utilities which are useful for implementing api
// Package apiutil contains utilities which are useful for implementing api
// endpoints.
package apiutils
package apiutil
import (
"context"

View File

@ -9,7 +9,7 @@ import (
"unicode"
"github.com/gorilla/websocket"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
"github.com/mediocregopher/blog.mediocregopher.com/srv/chat"
)
@ -44,9 +44,9 @@ func newChatHandler(
func (c *chatHandler) historyHandler() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
limit, err := apiutils.StrToInt(r.PostFormValue("limit"), 0)
limit, err := apiutil.StrToInt(r.PostFormValue("limit"), 0)
if err != nil {
apiutils.BadRequest(rw, r, fmt.Errorf("invalid limit parameter: %w", err))
apiutil.BadRequest(rw, r, fmt.Errorf("invalid limit parameter: %w", err))
return
}
@ -58,13 +58,13 @@ func (c *chatHandler) historyHandler() http.Handler {
})
if argErr := (chat.ErrInvalidArg{}); errors.As(err, &argErr) {
apiutils.BadRequest(rw, r, argErr.Err)
apiutil.BadRequest(rw, r, argErr.Err)
return
} else if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
}
apiutils.JSONResult(rw, r, struct {
apiutil.JSONResult(rw, r, struct {
Cursor string `json:"cursor"`
Messages []chat.Message `json:"messages"`
}{
@ -107,11 +107,11 @@ func (c *chatHandler) userIDHandler() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
userID, err := c.userID(r)
if err != nil {
apiutils.BadRequest(rw, r, err)
apiutil.BadRequest(rw, r, err)
return
}
apiutils.JSONResult(rw, r, struct {
apiutil.JSONResult(rw, r, struct {
UserID chat.UserID `json:"userID"`
}{
UserID: userID,
@ -123,18 +123,18 @@ func (c *chatHandler) appendHandler() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
userID, err := c.userID(r)
if err != nil {
apiutils.BadRequest(rw, r, err)
apiutil.BadRequest(rw, r, err)
return
}
body := r.PostFormValue("body")
if l := len(body); l == 0 {
apiutils.BadRequest(rw, r, errors.New("body is required"))
apiutil.BadRequest(rw, r, errors.New("body is required"))
return
} else if l > 300 {
apiutils.BadRequest(rw, r, errors.New("body too long"))
apiutil.BadRequest(rw, r, errors.New("body too long"))
return
}
@ -144,11 +144,11 @@ func (c *chatHandler) appendHandler() http.Handler {
})
if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
return
}
apiutils.JSONResult(rw, r, struct {
apiutil.JSONResult(rw, r, struct {
MessageID string `json:"messageID"`
}{
MessageID: msg.ID,
@ -164,7 +164,7 @@ func (c *chatHandler) listenHandler() http.Handler {
conn, err := c.wsUpgrader.Upgrade(rw, r, nil)
if err != nil {
apiutils.BadRequest(rw, r, err)
apiutil.BadRequest(rw, r, err)
return
}
defer conn.Close()
@ -172,14 +172,14 @@ func (c *chatHandler) listenHandler() http.Handler {
it, err := c.room.Listen(ctx, sinceID)
if errors.As(err, new(chat.ErrInvalidArg)) {
apiutils.BadRequest(rw, r, err)
apiutil.BadRequest(rw, r, err)
return
} else if errors.Is(err, context.Canceled) {
return
} else if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
return
}
@ -192,7 +192,7 @@ func (c *chatHandler) listenHandler() http.Handler {
return
} else if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
return
}
@ -203,7 +203,7 @@ func (c *chatHandler) listenHandler() http.Handler {
})
if err != nil {
apiutils.GetRequestLogger(r).Error(ctx, "couldn't write message", err)
apiutil.GetRequestLogger(r).Error(ctx, "couldn't write message", err)
return
}
}

View File

@ -4,7 +4,7 @@ import (
"errors"
"net/http"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
)
const (
@ -15,16 +15,16 @@ const (
func setCSRFMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
csrfTok, err := apiutils.GetCookie(r, csrfTokenCookieName, "")
csrfTok, err := apiutil.GetCookie(r, csrfTokenCookieName, "")
if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
return
} else if csrfTok == "" {
http.SetCookie(rw, &http.Cookie{
Name: csrfTokenCookieName,
Value: apiutils.RandStr(32),
Value: apiutil.RandStr(32),
Secure: true,
})
}
@ -36,10 +36,10 @@ func setCSRFMiddleware(h http.Handler) http.Handler {
func checkCSRFMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
csrfTok, err := apiutils.GetCookie(r, csrfTokenCookieName, "")
csrfTok, err := apiutil.GetCookie(r, csrfTokenCookieName, "")
if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
return
}
@ -49,7 +49,7 @@ func checkCSRFMiddleware(h http.Handler) http.Handler {
}
if csrfTok == "" || givenCSRFTok != csrfTok {
apiutils.BadRequest(rw, r, errors.New("invalid CSRF token"))
apiutil.BadRequest(rw, r, errors.New("invalid CSRF token"))
return
}

View File

@ -5,7 +5,7 @@ import (
"net/http"
"strings"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
"github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
)
@ -16,7 +16,7 @@ func (a *api) mailingListSubscribeHandler() http.Handler {
parts[0] == "" ||
parts[1] == "" ||
len(email) >= 512 {
apiutils.BadRequest(rw, r, errors.New("invalid email"))
apiutil.BadRequest(rw, r, errors.New("invalid email"))
return
}
@ -26,11 +26,11 @@ func (a *api) mailingListSubscribeHandler() http.Handler {
// just eat the error, make it look to the user like the
// verification email was sent.
} else if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
return
}
apiutils.JSONResult(rw, r, struct{}{})
apiutil.JSONResult(rw, r, struct{}{})
})
}
@ -40,25 +40,25 @@ func (a *api) mailingListFinalizeHandler() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
subToken := r.PostFormValue("subToken")
if l := len(subToken); l == 0 || l > 128 {
apiutils.BadRequest(rw, r, errInvalidSubToken)
apiutil.BadRequest(rw, r, errInvalidSubToken)
return
}
err := a.params.MailingList.FinalizeSubscription(subToken)
if errors.Is(err, mailinglist.ErrNotFound) {
apiutils.BadRequest(rw, r, errInvalidSubToken)
apiutil.BadRequest(rw, r, errInvalidSubToken)
return
} else if errors.Is(err, mailinglist.ErrAlreadyVerified) {
// no problem
} else if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
return
}
apiutils.JSONResult(rw, r, struct{}{})
apiutil.JSONResult(rw, r, struct{}{})
})
}
@ -68,21 +68,21 @@ func (a *api) mailingListUnsubscribeHandler() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
unsubToken := r.PostFormValue("unsubToken")
if l := len(unsubToken); l == 0 || l > 128 {
apiutils.BadRequest(rw, r, errInvalidUnsubToken)
apiutil.BadRequest(rw, r, errInvalidUnsubToken)
return
}
err := a.params.MailingList.Unsubscribe(unsubToken)
if errors.Is(err, mailinglist.ErrNotFound) {
apiutils.BadRequest(rw, r, errInvalidUnsubToken)
apiutil.BadRequest(rw, r, errInvalidUnsubToken)
return
} else if err != nil {
apiutils.InternalServerError(rw, r, err)
apiutil.InternalServerError(rw, r, err)
return
}
apiutils.JSONResult(rw, r, struct{}{})
apiutil.JSONResult(rw, r, struct{}{})
})
}

View File

@ -5,7 +5,7 @@ import (
"net/http"
"time"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
"github.com/mediocregopher/mediocre-go-lib/v2/mctx"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
)
@ -61,7 +61,7 @@ func (lrw *logResponseWriter) WriteHeader(statusCode int) {
func logMiddleware(logger *mlog.Logger, h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
r = apiutils.SetRequestLogger(r, logger)
r = apiutil.SetRequestLogger(r, logger)
lrw := newLogResponseWriter(rw)
@ -90,7 +90,7 @@ func postOnlyMiddleware(h http.Handler) http.Handler {
return
}
apiutils.GetRequestLogger(r).WarnString(r.Context(), "method not allowed")
apiutil.GetRequestLogger(r).WarnString(r.Context(), "method not allowed")
rw.WriteHeader(405)
})
}

View File

@ -3,11 +3,15 @@ package api
import (
"errors"
"fmt"
"html/template"
"net/http"
"path/filepath"
"strings"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post"
)
@ -22,22 +26,63 @@ func (a *api) postHandler() http.Handler {
http.Error(rw, "Post not found", 404)
return
} else if err != nil {
apiutils.InternalServerError(
apiutil.InternalServerError(
rw, r, fmt.Errorf("fetching post with id %q: %w", id, err),
)
return
}
renderablePost, err := post.NewRenderablePost(a.params.PostStore, storedPost)
parserExt := parser.CommonExtensions | parser.AutoHeadingIDs
parser := parser.NewWithExtensions(parserExt)
htmlFlags := html.CommonFlags | html.HrefTargetBlank
htmlRenderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags})
renderedBody := markdown.ToHTML([]byte(storedPost.Body), parser, htmlRenderer)
tplData := struct {
post.StoredPost
SeriesPrevious, SeriesNext *post.StoredPost
Body template.HTML
}{
StoredPost: storedPost,
Body: template.HTML(renderedBody),
}
if series := storedPost.Series; series != "" {
seriesPosts, err := a.params.PostStore.GetBySeries(series)
if err != nil {
apiutils.InternalServerError(
rw, r, fmt.Errorf("constructing renderable post with id %q: %w", id, err),
apiutil.InternalServerError(
rw, r,
fmt.Errorf("fetching posts for series %q: %w", series, err),
)
return
}
if err := a.params.PostHTTPRenderer.Render(rw, renderablePost); err != nil {
apiutils.InternalServerError(
var foundThis bool
for i := range seriesPosts {
seriesPost := seriesPosts[i]
if seriesPost.ID == storedPost.ID {
foundThis = true
continue
}
if !foundThis {
tplData.SeriesPrevious = &seriesPost
continue
}
tplData.SeriesNext = &seriesPost
break
}
}
if err := tpls.ExecuteTemplate(rw, "post.html", tplData); err != nil {
apiutil.InternalServerError(
rw, r, fmt.Errorf("rendering post with id %q: %w", id, err),
)
return

View File

@ -6,7 +6,7 @@ import (
"fmt"
"net/http"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
)
func (a *api) newPowChallengeHandler() http.Handler {
@ -14,7 +14,7 @@ func (a *api) newPowChallengeHandler() http.Handler {
challenge := a.params.PowManager.NewChallenge()
apiutils.JSONResult(rw, r, struct {
apiutil.JSONResult(rw, r, struct {
Seed string `json:"seed"`
Target uint32 `json:"target"`
}{
@ -30,21 +30,21 @@ func (a *api) requirePowMiddleware(h http.Handler) http.Handler {
seedHex := r.FormValue("powSeed")
seed, err := hex.DecodeString(seedHex)
if err != nil || len(seed) == 0 {
apiutils.BadRequest(rw, r, errors.New("invalid powSeed"))
apiutil.BadRequest(rw, r, errors.New("invalid powSeed"))
return
}
solutionHex := r.FormValue("powSolution")
solution, err := hex.DecodeString(solutionHex)
if err != nil || len(seed) == 0 {
apiutils.BadRequest(rw, r, errors.New("invalid powSolution"))
apiutil.BadRequest(rw, r, errors.New("invalid powSolution"))
return
}
err = a.params.PowManager.CheckSolution(seed, solution)
if err != nil {
apiutils.BadRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err))
apiutil.BadRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err))
return
}

View File

@ -124,7 +124,6 @@ func main() {
apiParams.Logger = logger.WithNamespace("api")
apiParams.PowManager = powMgr
apiParams.PostStore = postStore
apiParams.PostHTTPRenderer = post.NewMarkdownToHTMLRenderer()
apiParams.MailingList = ml
apiParams.GlobalRoom = chatGlobalRoom
apiParams.UserIDCalculator = chatUserIDCalc

View File

@ -1,96 +0,0 @@
package post
import (
_ "embed"
"fmt"
"html/template"
"io"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/mediocregopher/blog.mediocregopher.com/srv/tpl"
)
// RenderablePost is a Post wrapped with extra information necessary for
// rendering.
type RenderablePost struct {
StoredPost
SeriesPrevious, SeriesNext *StoredPost
}
// NewRenderablePost wraps an existing Post such that it can be rendered.
func NewRenderablePost(store Store, post StoredPost) (RenderablePost, error) {
renderablePost := RenderablePost{
StoredPost: post,
}
if post.Series != "" {
seriesPosts, err := store.GetBySeries(post.Series)
if err != nil {
return RenderablePost{}, fmt.Errorf(
"fetching posts for series %q: %w",
post.Series, err,
)
}
var foundThis bool
for i := range seriesPosts {
seriesPost := seriesPosts[i]
if seriesPost.ID == post.ID {
foundThis = true
continue
}
if !foundThis {
renderablePost.SeriesPrevious = &seriesPost
continue
}
renderablePost.SeriesNext = &seriesPost
break
}
}
return renderablePost, nil
}
// Renderer takes a Post and renders it to some encoding.
type Renderer interface {
Render(io.Writer, RenderablePost) error
}
func mdBodyToHTML(body []byte) []byte {
parserExt := parser.CommonExtensions | parser.AutoHeadingIDs
parser := parser.NewWithExtensions(parserExt)
htmlFlags := html.CommonFlags | html.HrefTargetBlank
htmlRenderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags})
return markdown.ToHTML(body, parser, htmlRenderer)
}
type mdHTMLRenderer struct{}
// NewMarkdownToHTMLRenderer renders Posts from markdown to HTML.
func NewMarkdownToHTMLRenderer() Renderer {
return mdHTMLRenderer{}
}
func (r mdHTMLRenderer) Render(into io.Writer, post RenderablePost) error {
data := struct {
RenderablePost
Body template.HTML
}{
RenderablePost: post,
Body: template.HTML(mdBodyToHTML([]byte(post.Body))),
}
return tpl.HTML.ExecuteTemplate(into, "post.html", data)
}

View File

@ -1,92 +0,0 @@
package post
import (
"bytes"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestMarkdownBodyToHTML(t *testing.T) {
tests := []struct {
body string
exp string
}{
{
body: `
# Foo
`,
exp: `<h1 id="foo">Foo</h1>`,
},
{
body: `
this is a body
this is another
`,
exp: `
<p>this is a body</p>
<p>this is another</p>`,
},
{
body: `this is a [link](somewhere.html)`,
exp: `<p>this is a <a href="somewhere.html" target="_blank">link</a></p>`,
},
}
for i, test := range tests {
t.Run(strconv.Itoa(i), func(t *testing.T) {
outB := mdBodyToHTML([]byte(test.body))
out := string(outB)
// just to make the tests nicer
out = strings.TrimSpace(out)
test.exp = strings.TrimSpace(test.exp)
assert.Equal(t, test.exp, out)
})
}
}
func TestMarkdownToHTMLRenderer(t *testing.T) {
r := NewMarkdownToHTMLRenderer()
post := RenderablePost{
StoredPost: StoredPost{
Post: Post{
ID: "foo",
Title: "Foo",
Description: "Bar.",
Body: "This is the body.",
Series: "baz",
},
PublishedAt: time.Now(),
},
SeriesPrevious: &StoredPost{
Post: Post{
ID: "foo-prev",
Title: "Foo Prev",
},
},
SeriesNext: &StoredPost{
Post: Post{
ID: "foo-next",
Title: "Foo Next",
},
},
}
buf := new(bytes.Buffer)
err := r.Render(buf, post)
assert.NoError(t, err)
t.Log(buf.String())
}

View File

@ -1,12 +0,0 @@
// Package tpl contains template files which are used to render the blog.
package tpl
import (
"embed"
html_tpl "html/template"
)
//go:embed *
var fs embed.FS
var HTML = html_tpl.Must(html_tpl.ParseFS(fs, "html/*"))