carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1package todo
2
3import (
4 "log/slog"
5 "net/http"
6 "strconv"
7
8 "github.com/go-chi/chi/v5"
9 "gorm.io/gorm"
10
11 "amadan.net/rastrillo/rastrillo"
12 "amadan.net/rastrillo/rastrillo/form"
13 "amadan.net/rastrillo/rastrillo/scope"
14 "amadan.net/rastrillo/rastrillo/sessions"
15)
16
17// notFoundTitle is the 404 page's heading: an item that isn't in your
18// list. (make bump edits this line.)
19const notFoundTitle = "That item is not in your list"
20
21// app holds what every handler needs.
22type app struct {
23 db *gorm.DB
24 sess *sessions.Sessions
25 logger *slog.Logger
26}
27
28// owned is the one seam every todo query goes through: reads and
29// writes alike carry WHERE user_id = <this visitor>.
30func (a *app) owned(r *http.Request) *gorm.DB {
31 uid, _ := sessions.UserID(r)
32 return scope.Owned(a.db.WithContext(r.Context()), uid)
33}
34
35// page serves one filter's view of the list, with ?edit=<id> opening
36// that item's editor.
37func (a *app) page(f filter) http.HandlerFunc {
38 return func(w http.ResponseWriter, r *http.Request) {
39 var todos []Todo
40 if err := a.owned(r).Order("id").Find(&todos).Error; err != nil {
41 a.fail(w, r, err)
42 return
43 }
44 editing, _ := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64)
45 w.Header().Set("Cache-Control", "no-store")
46 render(w, "index", newView(todos, f, editing))
47 }
48}
49
50func (a *app) create(w http.ResponseWriter, r *http.Request) {
51 p := form.Parse(r, form.Field{Name: "title"})
52 if title := p.String("title"); title != "" {
53 uid, _ := sessions.UserID(r)
54 if err := a.db.WithContext(r.Context()).Create(&Todo{UserID: uid, Title: title}).Error; err != nil {
55 a.fail(w, r, err)
56 return
57 }
58 }
59 a.done(w, r)
60}
61
62func (a *app) toggle(w http.ResponseWriter, r *http.Request) {
63 id, ok := itemID(r)
64 if !ok {
65 a.notFound(w, r)
66 return
67 }
68 res := a.owned(r).Model(&Todo{}).Where("id = ?", id).
69 Update("completed", gorm.Expr("NOT completed"))
70 a.afterItemWrite(w, r, res)
71}
72
73func (a *app) edit(w http.ResponseWriter, r *http.Request) {
74 id, ok := itemID(r)
75 if !ok {
76 a.notFound(w, r)
77 return
78 }
79 p := form.Parse(r, form.Field{Name: "title"})
80 title := p.String("title")
81 var res *gorm.DB
82 if title == "" {
83 res = a.owned(r).Where("id = ?", id).Delete(&Todo{})
84 } else {
85 res = a.owned(r).Model(&Todo{}).Where("id = ?", id).Update("title", title)
86 }
87 a.afterItemWrite(w, r, res)
88}
89
90func (a *app) destroy(w http.ResponseWriter, r *http.Request) {
91 id, ok := itemID(r)
92 if !ok {
93 a.notFound(w, r)
94 return
95 }
96 a.afterItemWrite(w, r, a.owned(r).Where("id = ?", id).Delete(&Todo{}))
97}
98
99// toggleAll completes every item, or reopens them all when every one
100// already was: one statement, so it can't race a concurrent toggle.
101func (a *app) toggleAll(w http.ResponseWriter, r *http.Request) {
102 uid, _ := sessions.UserID(r)
103 err := a.owned(r).Model(&Todo{}).Where("1 = 1").Update("completed",
104 gorm.Expr("EXISTS (SELECT 1 FROM todos WHERE user_id = ? AND NOT completed)", uid)).Error
105 if err != nil {
106 a.fail(w, r, err)
107 return
108 }
109 a.done(w, r)
110}
111
112func (a *app) clearCompleted(w http.ResponseWriter, r *http.Request) {
113 if err := a.owned(r).Where("completed").Delete(&Todo{}).Error; err != nil {
114 a.fail(w, r, err)
115 return
116 }
117 a.done(w, r)
118}
119
120// afterItemWrite finishes a write to one item: an error is a 500, no
121// row touched means the id isn't in this visitor's list (404).
122func (a *app) afterItemWrite(w http.ResponseWriter, r *http.Request, res *gorm.DB) {
123 switch {
124 case res.Error != nil:
125 a.fail(w, r, res.Error)
126 case res.RowsAffected == 0:
127 a.notFound(w, r)
128 default:
129 a.done(w, r)
130 }
131}
132
133// done answers a successful action: 303 back to the filter the form
134// came from, or, for the page's own script, the list's new markup.
135func (a *app) done(w http.ResponseWriter, r *http.Request) {
136 f := parseFilter(r.PostFormValue("filter"))
137 if !wantsFragment(r) {
138 http.Redirect(w, r, f.path(), http.StatusSeeOther)
139 return
140 }
141 var todos []Todo
142 if err := a.owned(r).Order("id").Find(&todos).Error; err != nil {
143 a.fail(w, r, err)
144 return
145 }
146 w.Header().Set("Cache-Control", "no-store")
147 render(w, "app", newView(todos, f, 0))
148}
149
150func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
151 errorPage(w, http.StatusNotFound, notFoundTitle)
152}
153
154// fail logs err under a fresh ref and renders the 500 page carrying it.
155func (a *app) fail(w http.ResponseWriter, r *http.Request, err error) {
156 ref := rastrillo.NewRef()
157 a.logger.Error("request failed", "ref", ref, "method", r.Method, "path", r.URL.Path, "err", err)
158 ErrorPage(w, r, http.StatusInternalServerError, ref)
159}
160
161// itemID reads {id}. A malformed id is a row that doesn't exist.
162func itemID(r *http.Request) (int64, bool) {
163 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
164 return id, err == nil && id > 0
165}
166
167// fragmentHeader is what the page's script sends to get the list's
168// markup back instead of a 303. Without it nothing changes.
169const fragmentHeader = "X-Todo-Fragment"
170
171func wantsFragment(r *http.Request) bool { return r.Header.Get(fragmentHeader) != "" }
172