replace redis with eventstore
This commit is contained in:
parent
3669ea6eb7
commit
2262b2680f
10 changed files with 267 additions and 155 deletions
1
.gitginore
Normal file
1
.gitginore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.#*
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1 +1,4 @@
|
||||||
tmp
|
tmp
|
||||||
|
todo*.txt
|
||||||
|
\#*
|
||||||
|
\.#*
|
||||||
|
|
29
README.md
29
README.md
|
@ -1,16 +1,29 @@
|
||||||
# Todo App with GO and HTMX
|
# Todo App with GO and HTMX
|
||||||
|
|
||||||
## Plan
|
## Plan day 1
|
||||||
|
|
||||||
- [x] create Go webserver
|
- [x] create Go webserver
|
||||||
- [x] create landing page
|
- [x] create landing page
|
||||||
- [x] import HTMX code
|
- [x] import HTMX code
|
||||||
- [x] display todo list
|
- [x] display todo list
|
||||||
- [ ] add tailwind CSS
|
- [x] add tailwind CSS
|
||||||
- [ ] show todo list
|
- [x] show todo list
|
||||||
- [ ] let todos be marked completed
|
- [x] let todos be marked completed
|
||||||
- [ ] remove todos
|
- [x] remove todos
|
||||||
- [ ] add new todo with form
|
- [x] add new todo with form
|
||||||
- [ ] persist todos in redis
|
- [x] persist todos in redis
|
||||||
- [ ] spruce up
|
- [x] spruce up
|
||||||
|
|
||||||
|
## Plan day 2
|
||||||
|
|
||||||
|
make it use only battery included features,
|
||||||
|
|
||||||
|
i.e. replace redis with an event driven approach
|
||||||
|
|
||||||
|
- [x] modularize app
|
||||||
|
- [x] replace redis with json stream
|
||||||
|
- [x] use hashmap for fast individual access
|
||||||
|
- [ ] cache data to avoid reading each time
|
||||||
|
- [ ] create event store
|
||||||
|
- [ ] make events
|
||||||
|
- [ ] implement event store
|
||||||
|
|
8
internal/model.go
Normal file
8
internal/model.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type Todo struct {
|
||||||
|
Id int
|
||||||
|
Title string
|
||||||
|
Completed bool
|
||||||
|
}
|
||||||
|
var MaxId int = 0
|
10
internal/model/model.go
Normal file
10
internal/model/model.go
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type Todo struct {
|
||||||
|
Id int
|
||||||
|
Title string
|
||||||
|
Completed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Todos map[int]Todo
|
132
internal/persist/persist.go
Normal file
132
internal/persist/persist.go
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
package persist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
import "snamellit.com/play/todo/internal/model"
|
||||||
|
|
||||||
|
var EMPTY_TODO = model.Todo{Id: 0, Title: "", Completed: false}
|
||||||
|
|
||||||
|
var maxId int = 0
|
||||||
|
|
||||||
|
var todos model.Todos
|
||||||
|
|
||||||
|
type eventType string
|
||||||
|
const (
|
||||||
|
ADDED eventType = "ADDED"
|
||||||
|
DELETED eventType = "DELETED"
|
||||||
|
CHANGED eventType = "CHANGED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type event struct {
|
||||||
|
Kind eventType
|
||||||
|
Todo model.Todo
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTodos() (model.Todos, error) {
|
||||||
|
f, err := os.OpenFile("todo_events.txt", os.O_RDONLY|os.O_CREATE, 0644)
|
||||||
|
defer f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(f)
|
||||||
|
|
||||||
|
todos = make(model.Todos)
|
||||||
|
for {
|
||||||
|
var e event
|
||||||
|
err = dec.Decode(&e)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fmt.Println("Read event", e)
|
||||||
|
id := e.Todo.Id
|
||||||
|
if id > maxId {
|
||||||
|
maxId = id
|
||||||
|
}
|
||||||
|
applyEvent(e.Kind, e.Todo)
|
||||||
|
}
|
||||||
|
return todos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEvent(kind eventType, todo model.Todo) error {
|
||||||
|
id := todo.Id
|
||||||
|
switch t := kind; t {
|
||||||
|
case ADDED:
|
||||||
|
fmt.Println("apply add ", todo)
|
||||||
|
todos[id] = todo
|
||||||
|
case CHANGED:
|
||||||
|
fmt.Println("apply change ", todo)
|
||||||
|
todos[id] = todo
|
||||||
|
case DELETED:
|
||||||
|
fmt.Println("apply delete ", todo)
|
||||||
|
delete(todos, id)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("Unknown event type %s", t)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEvent(kind eventType, todo model.Todo) error {
|
||||||
|
err := applyEvent(kind, todo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile("todo_events.txt", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
|
||||||
|
defer f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Write todo", todo)
|
||||||
|
enc := json.NewEncoder(f)
|
||||||
|
e := event{Kind: kind, Todo: todo}
|
||||||
|
fmt.Println("Write event", e)
|
||||||
|
err = enc.Encode(e)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTodos() (model.Todos, error) {
|
||||||
|
if len(todos) == 0 {
|
||||||
|
return readTodos()
|
||||||
|
}
|
||||||
|
return todos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func GetTodoById(id int) (model.Todo, error) {
|
||||||
|
todo, present := todos[id]
|
||||||
|
if !present {
|
||||||
|
return EMPTY_TODO, fmt.Errorf("Todo with id %d not found", id)
|
||||||
|
}
|
||||||
|
return todo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetTodo(todo model.Todo) error {
|
||||||
|
fmt.Println("SetTodo", todo)
|
||||||
|
todos[todo.Id] = todo
|
||||||
|
return addEvent(CHANGED, todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddTodo(title string) error {
|
||||||
|
fmt.Println("AddTodo", title)
|
||||||
|
maxId++;
|
||||||
|
todo := model.Todo{Id: maxId, Title: title, Completed: false}
|
||||||
|
return addEvent(ADDED, todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteTodoById(id int) error {
|
||||||
|
delete(todos, id)
|
||||||
|
return addEvent(DELETED, model.Todo{Id: id})
|
||||||
|
}
|
||||||
|
|
75
internal/webserver/webserver.go
Normal file
75
internal/webserver/webserver.go
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
package webserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"snamellit.com/play/todo/internal/persist"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t, _ := template.ParseFiles("templates/index.html")
|
||||||
|
t.Execute(w, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func renderTodos(w http.ResponseWriter) {
|
||||||
|
fmt.Println("renderTodos")
|
||||||
|
todos,err := persist.GetTodos()
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprintf(w, "Error: %s", err)
|
||||||
|
}
|
||||||
|
t, _ := template.ParseFiles("templates/todos.html")
|
||||||
|
t.Execute(w, todos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TodosHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Println("TodosHandler")
|
||||||
|
if r.Method == "POST" {
|
||||||
|
r.ParseForm()
|
||||||
|
todoTitle := r.Form.Get("title")
|
||||||
|
persist.AddTodo(todoTitle)
|
||||||
|
}
|
||||||
|
renderTodos(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToggleTodoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Println("ToggleTodoHandler")
|
||||||
|
todoId, err := strconv.Atoi(r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprintf(w, "Error: %s", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Toggle Todo Id: %d", todoId)
|
||||||
|
todo, err := persist.GetTodoById(todoId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprintf(w, "Error: %s", err)
|
||||||
|
}
|
||||||
|
todo.Completed = !todo.Completed
|
||||||
|
err = persist.SetTodo(todo)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprintf(w, "Error: %s", err)
|
||||||
|
}
|
||||||
|
renderTodos(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TodoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
fmt.Fprintf(w, "Method %s not allowed", r.Method)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
todoId, err := strconv.Atoi(r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("Todo Id: %d", todoId)
|
||||||
|
persist.DeleteTodoById(todoId)
|
||||||
|
TodosHandler(w, r)
|
||||||
|
return
|
||||||
|
}
|
128
main.go
128
main.go
|
@ -1,141 +1,23 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"log"
|
"log"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"html/template"
|
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type todo struct {
|
import "snamellit.com/play/todo/internal/webserver"
|
||||||
Id int
|
|
||||||
Title string
|
|
||||||
Completed bool
|
|
||||||
}
|
|
||||||
var maxId int = 0
|
|
||||||
|
|
||||||
var ctx = context.Background()
|
|
||||||
var rdb = redis.NewClient(&redis.Options{
|
|
||||||
Addr: "localhost:6379",
|
|
||||||
Password: "", // no password set
|
|
||||||
DB: 0, // use default DB
|
|
||||||
})
|
|
||||||
|
|
||||||
func GetTodos() []todo {
|
|
||||||
var cursor uint64 = 0
|
|
||||||
todosJson, cursor, err := rdb.Scan(ctx, cursor, "todo:*", 20).Result()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
var todos []todo = make([]todo, len(todosJson))
|
|
||||||
for idx, todoJson := range todosJson {
|
|
||||||
todoJson := rdb.Get(ctx, todoJson).Val()
|
|
||||||
fmt.Println("Todo: %s", todoJson)
|
|
||||||
err = json.Unmarshal([]byte(todoJson), &todos[idx])
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if todos[idx].Id > maxId {
|
|
||||||
maxId = todos[idx].Id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return todos
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTodoById(id int) todo {
|
|
||||||
todoJson, err := rdb.Get(ctx, fmt.Sprintf("todo:%d", id)).Result()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var todo todo
|
|
||||||
err = json.Unmarshal([]byte(todoJson), &todo)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return todo
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetTodo(todo todo) {
|
|
||||||
todoJson, err := json.Marshal(todo)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
key := fmt.Sprintf("todo:%d", todo.Id)
|
|
||||||
err = rdb.Set(ctx, key, string(todoJson), 0).Err()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Println("Set todo: %s", todoJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteTodoById(id int) {
|
|
||||||
err := rdb.Del(ctx, fmt.Sprintf("todo:%d", id)).Err()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
t, _ := template.ParseFiles("templates/index.html")
|
|
||||||
t.Execute(w, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TodosHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == "POST" {
|
|
||||||
r.ParseForm()
|
|
||||||
todoTitle := r.Form.Get("title")
|
|
||||||
SetTodo(todo{Id: maxId + 1, Title: todoTitle, Completed: false})
|
|
||||||
}
|
|
||||||
todos := GetTodos()
|
|
||||||
t, _ := template.ParseFiles("templates/todos.html")
|
|
||||||
t.Execute(w, todos)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ToggleTodoHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
todoId, err := strconv.Atoi(r.URL.Path)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Println("Todo Id: %d", todoId)
|
|
||||||
todo := GetTodoById(todoId)
|
|
||||||
todo.Completed = !todo.Completed
|
|
||||||
SetTodo(todo)
|
|
||||||
TodosHandler(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func TodoHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "DELETE" {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
fmt.Fprintf(w, "Method %s not allowed", r.Method)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
todoId, err := strconv.Atoi(r.URL.Path)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Println("Todo Id: %d", todoId)
|
|
||||||
DeleteTodoById(todoId)
|
|
||||||
TodosHandler(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Println("Starting web server on port 8080")
|
fmt.Println("Starting web server on port 8080")
|
||||||
fs := http.FileServer(http.Dir("./static"))
|
fs := http.FileServer(http.Dir("./static"))
|
||||||
http.Handle("/static/", http.StripPrefix("/static/", fs))
|
http.Handle("/static/", http.StripPrefix("/static/", fs))
|
||||||
http.HandleFunc("/", IndexHandler)
|
http.HandleFunc("/", webserver.IndexHandler)
|
||||||
http.HandleFunc("/todos", TodosHandler)
|
http.HandleFunc("/todos", webserver.TodosHandler)
|
||||||
togglePath := "/toggle-todo/"
|
togglePath := "/toggle-todo/"
|
||||||
http.Handle(togglePath, http.StripPrefix(togglePath, http.HandlerFunc(ToggleTodoHandler)))
|
http.Handle(togglePath, http.StripPrefix(togglePath, http.HandlerFunc(webserver.ToggleTodoHandler)))
|
||||||
todoPath := "/todo/"
|
todoPath := "/todo/"
|
||||||
http.Handle(todoPath, http.StripPrefix(todoPath, http.HandlerFunc(TodoHandler)))
|
http.Handle(todoPath, http.StripPrefix(todoPath, http.HandlerFunc(webserver.TodoHandler)))
|
||||||
|
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
}
|
}
|
||||||
|
|
|
@ -534,10 +534,6 @@ video {
|
||||||
--tw-backdrop-sepia: ;
|
--tw-backdrop-sepia: ;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
@ -554,10 +550,6 @@ video {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-row {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gap-2 {
|
.gap-2 {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
@ -568,24 +560,10 @@ video {
|
||||||
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.rounded {
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rounded-xl {
|
.rounded-xl {
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-slate-100 {
|
|
||||||
--tw-bg-opacity: 1;
|
|
||||||
background-color: rgb(241 245 249 / var(--tw-bg-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-slate-200 {
|
|
||||||
--tw-bg-opacity: 1;
|
|
||||||
background-color: rgb(226 232 240 / var(--tw-bg-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-red-500 {
|
.bg-red-500 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||||
|
@ -596,6 +574,16 @@ video {
|
||||||
background-color: rgb(14 165 233 / var(--tw-bg-opacity));
|
background-color: rgb(14 165 233 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-slate-100 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(241 245 249 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-slate-200 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(226 232 240 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,10 @@
|
||||||
{{range $index, $task := . }}
|
{{range $index, $task := . }}
|
||||||
<li id={{$task.Id}} class="p-4 rounded-xl bg-slate-100">
|
<li id={{$task.Id}} class="p-4 rounded-xl bg-slate-100">
|
||||||
<input type="checkbox" {{if $task.Completed}}checked{{end}}
|
<input type="checkbox" {{if $task.Completed}}checked{{end}}
|
||||||
hx-post="/toggle-todo/{{$task.Id}}" hx-swap="outerHTML" hx-target="#todos">
|
hx-post="/toggle-todo/{{$index}}" hx-swap="outerHTML" hx-target="#todos">
|
||||||
{{$task.Title}}
|
{{$task.Title}}
|
||||||
<button class="bg-red-500"
|
<button class="bg-red-500"
|
||||||
hx-delete="/todo/{{$task.Id}}" hx-swap="outerHTML" hx-target="#todos">X</button>
|
hx-delete="/todo/{{$index}}" hx-swap="outerHTML" hx-target="#todos">X</button>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
<li>
|
<li>
|
||||||
|
|
Loading…
Reference in a new issue