├── docs └── todo.png ├── internal ├── adapters │ ├── repositories │ │ ├── libsql │ │ │ ├── sql │ │ │ │ ├── select.sql │ │ │ │ ├── select_all.sql │ │ │ │ ├── update.sql │ │ │ │ ├── insert_or_replace.sql │ │ │ │ └── create_table.sql │ │ │ └── todo_repository.go │ │ └── orm │ │ │ └── todo_repository.go │ └── handlers │ │ ├── htmx │ │ ├── templates │ │ │ ├── list.html │ │ │ ├── list_item_edit.html │ │ │ ├── list_item.html │ │ │ └── index.html │ │ ├── index.go │ │ ├── utils.go │ │ ├── list.go │ │ ├── delete.go │ │ ├── edit.go │ │ ├── done.go │ │ ├── add.go │ │ ├── htmx.go │ │ └── update.go │ │ └── fileserver │ │ └── file_server.go ├── web │ └── src │ │ └── input.css └── core │ ├── domain │ └── todo_item.go │ ├── ports │ └── ports.go │ └── services │ └── todosrv │ └── service.go ├── assets └── favicon.ico ├── .env ├── .dockerignore ├── pkg ├── loadenv │ └── loadenv.go ├── dirutil │ └── dir.go └── repo │ └── repo.go ├── tailwind.config.js ├── Makefile ├── population.json ├── docker-compose.yml ├── .gitignore ├── .air.toml ├── go.mod ├── .vscode └── launch.json ├── LICENSE ├── Dockerfile ├── cmd ├── populator │ └── main.go └── server │ └── main.go ├── README.md └── go.sum /docs/todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilycst/go-htmx-todo-list/HEAD/docs/todo.png -------------------------------------------------------------------------------- /internal/adapters/repositories/libsql/sql/select.sql: -------------------------------------------------------------------------------- 1 | SELECT * FROM todo_item WHERE id = ?; -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guilycst/go-htmx-todo-list/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | STORAGE="POSTGRESQL" 2 | CONN_STR="host=localhost user=admin password=admin dbname=todo port=5432 sslmode=disable" -------------------------------------------------------------------------------- /internal/adapters/repositories/libsql/sql/select_all.sql: -------------------------------------------------------------------------------- 1 | SELECT * FROM todo_item WHERE deleted_at IS NULL ORDER BY done ASC; -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .vscode 3 | dist 4 | docs 5 | tmp 6 | pg-data 7 | Makefile 8 | Dockerfile 9 | docker-compose.yml 10 | .gitignore 11 | *.toml 12 | *.md 13 | .env 14 | .turso.env -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/templates/list.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/adapters/repositories/libsql/sql/update.sql: -------------------------------------------------------------------------------- 1 | UPDATE todo_item 2 | SET 3 | updated_at = current_timestamp, 4 | deleted_at = ?, 5 | title = ?, 6 | description = ?, 7 | done = ? 8 | WHERE id = ?; -------------------------------------------------------------------------------- /pkg/loadenv/loadenv.go: -------------------------------------------------------------------------------- 1 | package loadenv 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/joho/godotenv" 7 | ) 8 | 9 | func LoadEnv(env *string) { 10 | flag.Parse() 11 | if env == nil || *env == "" { 12 | return 13 | } 14 | godotenv.Load(*env) 15 | } 16 | -------------------------------------------------------------------------------- /internal/adapters/repositories/libsql/sql/insert_or_replace.sql: -------------------------------------------------------------------------------- 1 | INSERT OR REPLACE INTO todo_item ( 2 | id, 3 | deleted_at, 4 | title, 5 | description, 6 | done 7 | ) 8 | VALUES 9 | ( 10 | ?, 11 | ?, 12 | ?, 13 | ?, 14 | ? 15 | ) 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './internal/adapters/handlers/htmx/templates/**/*.{html,js}', 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [ 10 | ], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | make src 3 | air -- --env $(env) 4 | populate: 5 | go run cmd/populator/main.go -file population.json -env $(env) 6 | build: 7 | make src 8 | go build -o ./tmp/main.exe ./cmd/server/ 9 | src: 10 | tailwind -i ./internal/web/src/input.css -o ./dist/output.css -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/index.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import "net/http" 4 | 5 | func (hx *HTMXHandler) IndexHandleFunc(w http.ResponseWriter, r *http.Request) { 6 | err := hx.tmpl.ExecuteTemplate(w, "index.html", nil) 7 | if err != nil { 8 | http.Error(w, err.Error(), http.StatusInternalServerError) 9 | return 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/web/src/input.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | 5 | .fade-me-out.htmx-swapping { 6 | opacity: 0; 7 | transition: opacity 0.25s ease-out; 8 | } 9 | 10 | .fade-me-in.htmx-added { 11 | opacity: 0; 12 | } 13 | 14 | .fade-me-in { 15 | opacity: 1; 16 | transition: opacity 0.25s ease-out; 17 | } -------------------------------------------------------------------------------- /internal/adapters/repositories/libsql/sql/create_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS todo_item 2 | ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | created_at DATETIME default current_timestamp, 5 | updated_at DATETIME default current_timestamp, 6 | deleted_at DATETIME default null, 7 | title TEXT, 8 | description TEXT, 9 | done INTEGER(1) 10 | ); -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/utils.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func getIdFromPath(r *http.Request) (string, uint, error) { 10 | pathSegments := strings.Split(r.URL.Path, "/") 11 | raw := pathSegments[2] 12 | id, err := strconv.ParseUint(raw, 10, 32) 13 | if err != nil { 14 | return raw, 0, err 15 | } 16 | 17 | return raw, uint(id), err 18 | } 19 | -------------------------------------------------------------------------------- /pkg/dirutil/dir.go: -------------------------------------------------------------------------------- 1 | package dirutil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func IsDir(dir string) error { 9 | file, err := os.Open(dir) 10 | if err != nil { 11 | return err 12 | } 13 | defer file.Close() 14 | 15 | fileInfo, err := file.Stat() 16 | if err != nil { 17 | return err 18 | } 19 | 20 | if !fileInfo.IsDir() { 21 | return fmt.Errorf("%s is not a dir", dir) 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/core/domain/todo_item.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type TodoItem struct { 8 | ID uint 9 | CreatedAt time.Time 10 | UpdatedAt time.Time 11 | DeletedAt *time.Time 12 | Title string 13 | Description string 14 | Done bool 15 | } 16 | 17 | func (t TodoItem) GetTitle() string { 18 | return t.Title 19 | } 20 | 21 | func (t *TodoItem) pGetTitle() string { 22 | return t.Title 23 | } 24 | -------------------------------------------------------------------------------- /population.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Title": "Go for a run", 4 | "Description": "3 miles", 5 | "Done": false 6 | }, 7 | { 8 | "Title": "Finish project proposal", 9 | "Description": "Due 16/07", 10 | "Done": false 11 | }, 12 | { 13 | "Title": "Buy groceries", 14 | "Description": "eggs, bacon and olive oil", 15 | "Done": false 16 | }, 17 | { 18 | "Title": "Change project", 19 | "Description": "Adhere to hexagonal architecture", 20 | "Done": true 21 | } 22 | ] -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | 5 | db: 6 | image: postgres 7 | restart: always 8 | volumes: 9 | - ./pg-data:/var/lib/postgresql/data 10 | ports: 11 | - 5432:5432 12 | environment: 13 | POSTGRES_PASSWORD: admin 14 | POSTGRES_USER: admin 15 | POSTGRES_DB: todo 16 | 17 | server: 18 | depends_on: 19 | - db 20 | build: ./ 21 | restart: always 22 | ports: 23 | - 8080:8080 24 | environment: 25 | STORAGE: "POSTGRESQL" 26 | CONN_STR: "host=db user=admin password=admin dbname=todo port=5432 sslmode=disable" -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/list.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func (hx *HTMXHandler) ListHandleFunc(w http.ResponseWriter, r *http.Request) { 9 | items, err := hx.srv.All() 10 | if err != nil { 11 | log.Println(err) 12 | http.Error(w, err.Error(), http.StatusInternalServerError) 13 | return 14 | } 15 | 16 | viewItems := []todoItemView{} 17 | for _, v := range items { 18 | viewItems = append(viewItems, ToView(v)) 19 | } 20 | 21 | err = hx.tmpl.ExecuteTemplate(w, "list.html", viewItems) 22 | if err != nil { 23 | http.Error(w, err.Error(), http.StatusInternalServerError) 24 | return 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/core/ports/ports.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import "github.com/guilycst/go-htmx/internal/core/domain" 4 | 5 | type TodoRepository interface { 6 | FindById(id any) (*domain.TodoItem, error) 7 | All() ([]domain.TodoItem, error) 8 | Save(data *domain.TodoItem) error 9 | Create(data *domain.TodoItem) error 10 | Delete(data *domain.TodoItem) error 11 | SaveBatch(data []*domain.TodoItem) error 12 | } 13 | 14 | type TodoService interface { 15 | FindById(id uint) (*domain.TodoItem, error) 16 | All() ([]domain.TodoItem, error) 17 | Save(item *domain.TodoItem) error 18 | Delete(item *domain.TodoItem) error 19 | Create(item *domain.TodoItem) error 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | #Distribution files folder 24 | dist 25 | 26 | #Temporary directory used by air 27 | tmp 28 | 29 | #postgres local volume 30 | pg-data 31 | 32 | #sqlite3 db files 33 | *.db 34 | 35 | #env files 36 | *.env 37 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | tmp_dir = "tmp" 3 | 4 | [build] 5 | args_bin = [] 6 | bin = "./tmp/main.exe" 7 | cmd = "make build" 8 | delay = 1000 9 | exclude_dir = ["dist", "tmp", "pg-data"] 10 | exclude_file = [] 11 | exclude_regex = ["_test.go"] 12 | exclude_unchanged = false 13 | follow_symlink = false 14 | full_bin = "" 15 | include_dir = [] 16 | include_ext = ["go", "tpl", "tmpl", "html", "css", "mod","js"] 17 | kill_delay = "0s" 18 | log = "build-errors.log" 19 | send_interrupt = false 20 | stop_on_error = true 21 | 22 | [color] 23 | app = "" 24 | build = "yellow" 25 | main = "magenta" 26 | runner = "green" 27 | watcher = "cyan" 28 | 29 | [log] 30 | time = false 31 | 32 | [misc] 33 | clean_on_exit = true 34 | 35 | [screen] 36 | clear_on_rebuild = true -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/delete.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func (hx *HTMXHandler) Delete(w http.ResponseWriter, r *http.Request) { 9 | raw, id, err := getIdFromPath(r) 10 | if err != nil { 11 | http.Error(w, fmt.Sprintf("id \"%s\" is invalid", raw), http.StatusBadRequest) 12 | return 13 | } 14 | 15 | found, err := hx.srv.FindById(id) 16 | if err != nil { 17 | http.Error(w, err.Error(), http.StatusInternalServerError) 18 | return 19 | } 20 | 21 | if found == nil { 22 | http.Error(w, fmt.Sprintf("id \"%d\" not found", id), http.StatusNotFound) 23 | return 24 | } 25 | 26 | if err = hx.srv.Delete(found); err != nil { 27 | http.Error(w, err.Error(), http.StatusInternalServerError) 28 | } 29 | 30 | w.WriteHeader(http.StatusAccepted) 31 | } 32 | -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/edit.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func (hx *HTMXHandler) Edit(w http.ResponseWriter, r *http.Request) { 9 | raw, id, err := getIdFromPath(r) 10 | if err != nil { 11 | http.Error(w, fmt.Sprintf("id \"%s\" is invalid", raw), http.StatusBadRequest) 12 | return 13 | } 14 | 15 | found, err := hx.srv.FindById(id) 16 | if err != nil { 17 | http.Error(w, err.Error(), http.StatusInternalServerError) 18 | return 19 | } 20 | 21 | if found == nil { 22 | http.Error(w, fmt.Sprintf("id \"%d\" not found", id), http.StatusNotFound) 23 | return 24 | } 25 | 26 | err = hx.tmpl.ExecuteTemplate(w, "list_item_edit.html", ToView(*found)) 27 | if err != nil { 28 | http.Error(w, err.Error(), http.StatusInternalServerError) 29 | return 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/templates/list_item_edit.html: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 |
    4 | 7 | 10 |
    11 | 14 |
    15 |
  • -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/guilycst/go-htmx 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/joho/godotenv v1.5.1 7 | github.com/libsql/libsql-client-go v0.0.0-20230710132643-6f49934b7fb3 8 | gorm.io/driver/postgres v1.5.2 9 | gorm.io/driver/sqlite v1.5.2 10 | gorm.io/gorm v1.25.2 11 | ) 12 | 13 | require ( 14 | github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 // indirect 15 | github.com/jackc/pgpassfile v1.0.0 // indirect 16 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 17 | github.com/jackc/pgx/v5 v5.3.1 // indirect 18 | github.com/jinzhu/inflection v1.0.0 // indirect 19 | github.com/jinzhu/now v1.1.5 // indirect 20 | github.com/klauspost/compress v1.15.15 // indirect 21 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20230512205400-b2348f0d1196 // indirect 22 | github.com/mattn/go-sqlite3 v1.14.17 // indirect 23 | golang.org/x/crypto v0.8.0 // indirect 24 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect 25 | golang.org/x/text v0.9.0 // indirect 26 | nhooyr.io/websocket v1.8.7 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/done.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func (hx *HTMXHandler) DoneHandleFunc(done bool) func(w http.ResponseWriter, r *http.Request) { 9 | return func(w http.ResponseWriter, r *http.Request) { 10 | raw, id, err := getIdFromPath(r) 11 | if err != nil { 12 | http.Error(w, fmt.Sprintf("id \"%s\" is invalid", raw), http.StatusBadRequest) 13 | return 14 | } 15 | 16 | found, err := hx.srv.FindById(id) 17 | if err != nil { 18 | http.Error(w, err.Error(), http.StatusInternalServerError) 19 | return 20 | } 21 | 22 | if found == nil { 23 | http.Error(w, fmt.Sprintf("id \"%d\" not found", id), http.StatusNotFound) 24 | return 25 | } 26 | 27 | found.Done = done 28 | err = hx.srv.Save(found) 29 | if err != nil { 30 | http.Error(w, err.Error(), http.StatusInternalServerError) 31 | } 32 | 33 | err = hx.tmpl.ExecuteTemplate(w, "list_item.html", ToView(*found)) 34 | if err != nil { 35 | http.Error(w, err.Error(), http.StatusInternalServerError) 36 | return 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch server", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "./cmd/server/main.go", 13 | "args": ["../../.env"], 14 | "env": { 15 | "DIST_DIR": "C:\\Users\\T-GAMER\\Documents\\repos\\go-htmx\\dist", 16 | "PUB_DIR": "C:\\Users\\T-GAMER\\Documents\\repos\\go-htmx\\internal\\web\\public" 17 | } 18 | }, 19 | { 20 | "name": "Launch populator", 21 | "type": "go", 22 | "request": "launch", 23 | "mode": "auto", 24 | "program": "./cmd/populator/main.go", 25 | "args": ["-file", "../../population.json", "../../.turso.env"], 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Guilherme de Castro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/add.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/guilycst/go-htmx/internal/core/domain" 7 | "github.com/guilycst/go-htmx/internal/core/services/todosrv" 8 | ) 9 | 10 | func (hx *HTMXHandler) AddHandleFunc(w http.ResponseWriter, r *http.Request) { 11 | if r.Method != "POST" { 12 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 13 | return 14 | } 15 | 16 | err := r.ParseForm() 17 | if err != nil { 18 | http.Error(w, "Failed to parse form", http.StatusInternalServerError) 19 | return 20 | } 21 | 22 | item := domain.TodoItem{ 23 | Title: r.Form.Get("title"), 24 | Description: r.Form.Get("description"), 25 | } 26 | 27 | err = hx.srv.Create(&item) 28 | if err != nil { 29 | if err == todosrv.ErrorTitleRequired { 30 | http.Error(w, err.Error(), http.StatusBadRequest) 31 | } else { 32 | http.Error(w, err.Error(), http.StatusInternalServerError) 33 | } 34 | } 35 | 36 | err = hx.tmpl.ExecuteTemplate(w, "list_item.html", ToView(item)) 37 | if err != nil { 38 | http.Error(w, err.Error(), http.StatusInternalServerError) 39 | return 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/htmx.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | 7 | "github.com/guilycst/go-htmx/internal/core/domain" 8 | "github.com/guilycst/go-htmx/internal/core/ports" 9 | ) 10 | 11 | //go:embed templates 12 | var tmplFs embed.FS 13 | 14 | type HTMXHandler struct { 15 | srv ports.TodoService 16 | tmpl *template.Template 17 | } 18 | 19 | type todoItemView struct { 20 | domain.TodoItem 21 | Order int64 22 | } 23 | 24 | func ToView(t domain.TodoItem) todoItemView { 25 | order := t.CreatedAt.Unix() 26 | if t.Done { 27 | order = t.UpdatedAt.Unix() 28 | } 29 | 30 | return todoItemView{ 31 | t, 32 | order, 33 | } 34 | } 35 | 36 | func NewHTMXHandler(srv ports.TodoService) (*HTMXHandler, error) { 37 | //Parse templates 38 | funcs := template.FuncMap(template.FuncMap{ 39 | "attr": func(s string) template.HTMLAttr { 40 | return template.HTMLAttr(s) 41 | }, 42 | "safe": func(s string) template.HTML { 43 | return template.HTML(s) 44 | }, 45 | }) 46 | 47 | tmpl, err := template.New("todo").Funcs(funcs).ParseFS(tmplFs, "templates/*.html") 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return &HTMXHandler{ 53 | srv: srv, 54 | tmpl: tmpl, 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/adapters/handlers/fileserver/file_server.go: -------------------------------------------------------------------------------- 1 | package fileserver 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/guilycst/go-htmx/pkg/dirutil" 9 | ) 10 | 11 | func NewFileServerHandler(dir string) (func(w http.ResponseWriter, r *http.Request), error) { 12 | 13 | err := dirutil.IsDir(dir) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | return func(w http.ResponseWriter, r *http.Request) { 19 | // Get the file name of the file requested by the client 20 | fileName := filepath.Base(r.URL.Path) 21 | // Get the path of the file requested by the client 22 | filePath := filepath.Join(dir, fileName) 23 | 24 | // Open the file 25 | file, err := os.Open(filePath) 26 | if err != nil { 27 | // Return a 404 Not Found status if the file doesn't exist 28 | http.NotFound(w, r) 29 | return 30 | } 31 | defer file.Close() 32 | 33 | // Get file information 34 | fileInfo, err := file.Stat() 35 | if err != nil { 36 | // Return a 500 Internal Server Error status if there's an error getting file info 37 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 38 | return 39 | } 40 | 41 | // Serve the file with its proper content type 42 | http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file) 43 | }, nil 44 | } 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Multi-stage build 2 | # build stage 3 | FROM golang:1.19-alpine AS build 4 | WORKDIR /app 5 | 6 | # Copy the Go module files to the working directory 7 | # Download and cache Go modules 8 | COPY go.mod go.sum ./ 9 | RUN go mod download 10 | 11 | # Copy the rest of the application source code to the working directory 12 | COPY . . 13 | 14 | #Build base 15 | RUN apk add build-base 16 | 17 | # Install curl 18 | # Dowload tailwind standalone client 19 | # Give permission to tailwind executable 20 | # Rename tailwind executable 21 | RUN apk --no-cache add curl 22 | RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v3.3.3/tailwindcss-linux-x64 23 | RUN chmod +x tailwindcss-linux-x64 24 | RUN mv tailwindcss-linux-x64 tailwindcss 25 | 26 | # Compile CSS 27 | RUN ./tailwindcss -i ./internal/web/src/input.css -o ./dist/output.css 28 | 29 | # Build the Go application 30 | RUN go build -o server ./cmd/server 31 | 32 | # run stage 33 | FROM golang:1.19-alpine AS server 34 | 35 | WORKDIR /app/ 36 | 37 | # Copy relevant files and folders from build stage 38 | COPY --from=build /app/server ./ 39 | COPY --from=build /app/dist ./dist 40 | COPY --from=build /app/assets ./assets 41 | 42 | #Set env 43 | ENV TEMPLATES_DIR=/app/templates 44 | ENV DIST_DIR=/app/dist 45 | ENV PUB_DIR=/app/public 46 | 47 | # Expose the port that the server listens on 48 | EXPOSE 8080 49 | 50 | # Set the entry point for the container 51 | CMD ["./server"] -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/update.go: -------------------------------------------------------------------------------- 1 | package htmx 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/guilycst/go-htmx/internal/core/services/todosrv" 8 | ) 9 | 10 | func (hx *HTMXHandler) Update(w http.ResponseWriter, r *http.Request) { 11 | raw, id, err := getIdFromPath(r) 12 | if err != nil { 13 | http.Error(w, fmt.Sprintf("id \"%s\" is invalid", raw), http.StatusBadRequest) 14 | return 15 | } 16 | 17 | found, err := hx.srv.FindById(id) 18 | if err != nil { 19 | http.Error(w, "Failed to parse form", http.StatusInternalServerError) 20 | return 21 | } 22 | 23 | if found == nil { 24 | http.Error(w, fmt.Sprintf("id \"%d\" not found", id), http.StatusNotFound) 25 | return 26 | } 27 | 28 | if r.Method != "PUT" { 29 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 30 | return 31 | } 32 | 33 | err = r.ParseForm() 34 | if err != nil { 35 | http.Error(w, "Failed to parse form", http.StatusInternalServerError) 36 | return 37 | } 38 | 39 | found.Title = r.Form.Get("title") 40 | found.Description = r.Form.Get("description") 41 | 42 | err = hx.srv.Save(found) 43 | if err != nil { 44 | if err == todosrv.ErrorTitleRequired { 45 | http.Error(w, err.Error(), http.StatusBadRequest) 46 | } else { 47 | http.Error(w, err.Error(), http.StatusInternalServerError) 48 | } 49 | } 50 | 51 | err = hx.tmpl.ExecuteTemplate(w, "list_item.html", ToView(*found)) 52 | if err != nil { 53 | http.Error(w, err.Error(), http.StatusInternalServerError) 54 | return 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /cmd/populator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "log" 7 | "os" 8 | 9 | "github.com/guilycst/go-htmx/internal/core/domain" 10 | "github.com/guilycst/go-htmx/internal/core/ports" 11 | "github.com/guilycst/go-htmx/pkg/loadenv" 12 | "github.com/guilycst/go-htmx/pkg/repo" 13 | ) 14 | 15 | var population = []*domain.TodoItem{} 16 | var repository ports.TodoRepository 17 | 18 | func init() { 19 | 20 | //Parse flags 21 | populationFile := flag.String("file", "", "JSON file containing population") 22 | env := flag.String("env", "", ".env file") 23 | flag.Parse() 24 | 25 | // Try to load .env file if any 26 | loadenv.LoadEnv(env) 27 | 28 | if populationFile == nil { 29 | log.Fatal("No population file provided (flag -file)") 30 | } 31 | 32 | //Read population file to memory 33 | data, err := os.ReadFile(*populationFile) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | 38 | json.Unmarshal(data, &population) 39 | 40 | if len(population) == 0 { 41 | log.Fatal("File is empty or in incorrect format") 42 | } 43 | 44 | //Create new repository 45 | var connStr string = os.Getenv("CONN_STR") 46 | var storage repo.Storage = repo.StorageFromString(os.Getenv("STORAGE")) 47 | //Create new repository 48 | pRepository, err := repo.GetRepo(storage, connStr) 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | repository = *pRepository 53 | } 54 | 55 | func main() { 56 | err := repository.SaveBatch(population) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | log.Print("💾✔️ - Database populated!") 61 | } 62 | -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/templates/list_item.html: -------------------------------------------------------------------------------- 1 | {{ $action := printf "/done/%d" .ID }} 2 | {{ $checked := "" }} 3 | {{ $lineThrough := "" }} 4 | {{ $prepend := false }} 5 | {{ $order := printf "style=\"order: %d;\"" .Order }} 6 | {{ if .Done}} 7 | {{ $action = printf "/undone/%d" .ID }} 8 | {{ $checked = "checked" }} 9 | {{ $lineThrough = "line-through" }} 10 | {{ $prepend = true }} 11 | {{end}} 12 | 13 |
  • 14 |
    15 | 19 | 23 |
    24 | 26 | 28 |
    29 |
    30 |
  • -------------------------------------------------------------------------------- /internal/core/services/todosrv/service.go: -------------------------------------------------------------------------------- 1 | package todosrv 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "time" 7 | 8 | "github.com/guilycst/go-htmx/internal/core/domain" 9 | "github.com/guilycst/go-htmx/internal/core/ports" 10 | ) 11 | 12 | var ErrorTitleRequired = errors.New("title is required") 13 | var ErrorInternal = errors.New("internal error") 14 | 15 | type service struct { 16 | repository ports.TodoRepository 17 | } 18 | 19 | func (s *service) FindById(id uint) (*domain.TodoItem, error) { 20 | item, err := s.repository.FindById(id) 21 | if err != nil { 22 | log.Println(err) 23 | return nil, ErrorInternal 24 | } 25 | return item, nil 26 | } 27 | 28 | func (s *service) All() ([]domain.TodoItem, error) { 29 | items, err := s.repository.All() 30 | if err != nil { 31 | log.Println(err) 32 | return nil, ErrorInternal 33 | } 34 | return items, nil 35 | } 36 | 37 | func (s *service) Save(item *domain.TodoItem) error { 38 | if item.Title == "" { 39 | return ErrorTitleRequired 40 | } 41 | 42 | if err := s.repository.Save(item); err != nil { 43 | log.Println(err) 44 | return ErrorInternal 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func (s *service) Create(item *domain.TodoItem) error { 51 | if item.Title == "" { 52 | return ErrorTitleRequired 53 | } 54 | 55 | if err := s.repository.Create(item); err != nil { 56 | log.Println(err) 57 | return ErrorInternal 58 | } 59 | 60 | return nil 61 | } 62 | 63 | func (s *service) Delete(item *domain.TodoItem) error { 64 | now := time.Now() 65 | item.DeletedAt = &now 66 | 67 | if err := s.repository.Save(item); err != nil { 68 | log.Println(err) 69 | return ErrorInternal 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func New(repository *ports.TodoRepository) *service { 76 | return &service{ 77 | repository: *repository, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/repo/repo.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "strings" 7 | 8 | "github.com/guilycst/go-htmx/internal/adapters/repositories/libsql" 9 | "github.com/guilycst/go-htmx/internal/adapters/repositories/orm" 10 | "github.com/guilycst/go-htmx/internal/core/ports" 11 | "gorm.io/driver/postgres" 12 | "gorm.io/driver/sqlite" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | type Storage string 17 | 18 | const ( 19 | POSTGRESQL Storage = "POSTGRESQL" 20 | LIBSQL Storage = "LIBSQL" 21 | SQLITE Storage = "SQLITE" 22 | UNKNOWN Storage = "UNKNOWN" 23 | ) 24 | 25 | func StorageFromString(str string) Storage { 26 | str = strings.ToUpper(str) 27 | switch str { 28 | case "POSTGRESQL": 29 | return POSTGRESQL 30 | case "LIBSQL": 31 | return LIBSQL 32 | case "SQLITE": 33 | return SQLITE 34 | default: 35 | log.Printf("No storage type %s!!!\n", str) 36 | return UNKNOWN 37 | } 38 | } 39 | 40 | var ( 41 | ErrNoDialector = errors.New("Storage type not supported") 42 | ) 43 | 44 | func GetRepo(stg Storage, connStr string) (*ports.TodoRepository, error) { 45 | switch stg { 46 | case LIBSQL: 47 | 48 | libsqlRepo, err := libsql.NewTodoDBRepository(connStr) 49 | if err != nil { 50 | return nil, err 51 | } 52 | return &libsqlRepo, nil 53 | default: 54 | dialector, err := getGormDialector(stg, connStr) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | ormRepo, err := orm.NewTodoDBRepository(dialector) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return &ormRepo, nil 64 | } 65 | } 66 | 67 | func getGormDialector(stg Storage, connStr string) (gorm.Dialector, error) { 68 | switch stg { 69 | case POSTGRESQL: 70 | return postgres.Open(connStr), nil 71 | case SQLITE: 72 | return sqlite.Open(connStr), nil 73 | default: 74 | return nil, ErrNoDialector 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/adapters/repositories/orm/todo_repository.go: -------------------------------------------------------------------------------- 1 | package orm 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/guilycst/go-htmx/internal/core/domain" 9 | "github.com/guilycst/go-htmx/internal/core/ports" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | ) 13 | 14 | type GormTodoDBRepository struct { 15 | db *gorm.DB 16 | } 17 | 18 | func (r *GormTodoDBRepository) FindById(id any) (*domain.TodoItem, error) { 19 | var data domain.TodoItem 20 | rs := r.db.First(&data, id) 21 | return &data, rs.Error 22 | } 23 | 24 | func (r *GormTodoDBRepository) All() ([]domain.TodoItem, error) { 25 | var data []domain.TodoItem 26 | rs := r.db.Where("deleted_at is null").Order("done asc").Find(&data) 27 | return data, rs.Error 28 | } 29 | 30 | func (r *GormTodoDBRepository) Save(data *domain.TodoItem) error { 31 | rs := r.db.Save(&data) 32 | return rs.Error 33 | } 34 | 35 | func (r *GormTodoDBRepository) Delete(data *domain.TodoItem) error { 36 | rs := r.db.Delete(&data) 37 | return rs.Error 38 | } 39 | 40 | func (r *GormTodoDBRepository) SaveBatch(data []*domain.TodoItem) error { 41 | rs := r.db.Create(data) 42 | return rs.Error 43 | } 44 | func (r *GormTodoDBRepository) Create(data *domain.TodoItem) error { 45 | return r.Save(data) 46 | } 47 | 48 | func NewTodoDBRepository(dialector gorm.Dialector) (ports.TodoRepository, error) { 49 | db, err := gorm.Open(dialector, &gorm.Config{ 50 | Logger: logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{ 51 | SlowThreshold: 200 * time.Millisecond, 52 | LogLevel: logger.Info, 53 | IgnoreRecordNotFoundError: false, 54 | Colorful: true, 55 | }), 56 | }) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | db.AutoMigrate(&domain.TodoItem{}) 62 | 63 | return &GormTodoDBRepository{ 64 | db: db, 65 | }, nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/adapters/handlers/htmx/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Go + HTMX To-Do list 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
    16 |
    17 |

    To-Do List

    18 |
    19 |
    20 |
    21 | 24 | 27 | 32 |
    33 |
    34 |
    35 |
    36 |
    37 | 38 | 39 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/guilycst/go-htmx/internal/adapters/handlers/fileserver" 10 | "github.com/guilycst/go-htmx/internal/adapters/handlers/htmx" 11 | 12 | _ "time/tzdata" 13 | 14 | "github.com/guilycst/go-htmx/internal/core/services/todosrv" 15 | "github.com/guilycst/go-htmx/pkg/loadenv" 16 | "github.com/guilycst/go-htmx/pkg/repo" 17 | ) 18 | 19 | func init() { 20 | 21 | //Parse flags 22 | env := flag.String("env", "", ".env file") 23 | flag.Parse() 24 | 25 | // Try to load .env file if any 26 | loadenv.LoadEnv(env) 27 | 28 | var ( 29 | storage repo.Storage = repo.StorageFromString(os.Getenv("STORAGE")) 30 | connStr string = os.Getenv("CONN_STR") 31 | ) 32 | 33 | //Create new repository 34 | repository, err := repo.GetRepo(storage, connStr) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | 39 | //Create service 40 | srv := todosrv.New(repository) 41 | 42 | //Initialize http handlers 43 | handler, err := htmx.NewHTMXHandler(srv) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | 48 | http.HandleFunc("/", handler.IndexHandleFunc) 49 | http.HandleFunc("/add", handler.AddHandleFunc) 50 | http.HandleFunc("/list", handler.ListHandleFunc) 51 | http.HandleFunc("/done/", handler.DoneHandleFunc(true)) 52 | http.HandleFunc("/undone/", handler.DoneHandleFunc(false)) 53 | http.HandleFunc("/delete/", handler.Delete) 54 | http.HandleFunc("/edit/", handler.Edit) 55 | http.HandleFunc("/update/", handler.Update) 56 | 57 | distFsh, err := fileserver.NewFileServerHandler("./dist") 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | http.HandleFunc("/dist/", distFsh) 62 | 63 | assetsFsh, err := fileserver.NewFileServerHandler("./assets") 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | http.HandleFunc("/assets/", assetsFsh) 68 | } 69 | 70 | func main() { 71 | // Start the HTTP server 72 | log.Println("Server started on http://localhost:8080") 73 | log.Fatal(http.ListenAndServe(":8080", nil)) 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go + HTMX To-Do List 2 | A modest todo list app built with Go, HTMX and Tailwind CSS. The primary objective behind creating this app was to explore the capabilities of HTMX and gain insights into its practical implementation. 3 | 4 | ![Todo app screenshot](./docs/todo.png) 5 | 6 | # What it can currently do 7 | 8 | - Create, Read, Update and Delete tasks 9 | - Complete tasks 10 | - Data is stored in PostgreSQL 11 | 12 | # Dependencies 13 | - Go (1.19) 14 | - Make 15 | - Tailwind CSS Standalone CLI 16 | - Air (live-reloading) 17 | - Docker 18 | 19 | # Implementation details 20 | 21 | The majority of Go code is dedicated to an HTTP server that manages requests from the HTMX library. When these requests are successful, they trigger the execution of an HTML template using Go's [html/template package](https://pkg.go.dev/html/template) and return an HTML document as a response. 22 | 23 | HTMX leverages these HTML responses to dynamically replace parts of the page without requiring a full page reload. This approach enables the application to provide interactivity comparable to popular JavaScript frameworks/libraries, but with reduced reliance on actual JavaScript code. 24 | 25 | The visual appearance of the page is managed using the Tailwind CSS framework. The Standalone CLI tool is responsible for compiling the *.html files found in the [./internal/web/templates/](./internal/web/templates/) directory and producing a CSS file [./dist/output.css](./dist/output.css). This generated CSS file is then served by the Go server. 26 | 27 | For storage, a PostgreSQL database is utilized within a container. 28 | 29 | # How to run 30 | 31 | ## Server 32 | Serves the HTMX app. 33 | 34 | In a browser visit ***http://localhost:8080*** 35 | 36 | ### Without Docker 37 | 38 | - Download the [Tailwind CSS Standalone CLI](https://tailwindcss.com/blog/standalone-cli) and setup in your PATH as **tailwind** 39 | - Setup a PosgreSQL server: The connection string is passed as a ENV variable named **PG_CONN_STR**, ENV variables are defined in the [.env](./.env), look it up for reference 40 | 41 | Then run the Make file: 42 | 43 | ```$ make run_l``` 44 | 45 | ### Docker Compose 46 | 47 | #### Source on host machine with live-reload, database on Docker 48 | 49 | ```$ make run``` 50 | 51 | #### Everything on Docker 52 | 53 | ```$ docker compose up``` 54 | 55 | ## Populator 56 | 57 | Inserts the contents of [population.json](./population.json) file into the database 58 | 59 | ### Without Docker 60 | 61 | - Setup a PosgreSQL server: The connection string is passed as a ENV variable named **PG_CONN_STR**, ENV variables are defined in the [.env](./.env), look it up for reference 62 | 63 | Then run the Make file: 64 | 65 | ```$ make populate_l``` 66 | 67 | 68 | ### Docker Compose 69 | 70 | ```$ make populate``` 71 | -------------------------------------------------------------------------------- /internal/adapters/repositories/libsql/todo_repository.go: -------------------------------------------------------------------------------- 1 | package libsql 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "github.com/guilycst/go-htmx/internal/core/domain" 11 | "github.com/guilycst/go-htmx/internal/core/ports" 12 | _ "github.com/libsql/libsql-client-go/libsql" 13 | ) 14 | 15 | //go:embed sql 16 | var sqlFiles embed.FS 17 | var loc, _ = time.LoadLocation("America/Sao_Paulo") 18 | 19 | type LibsqlTodoRepository struct { 20 | db *sql.DB 21 | } 22 | 23 | func (r *LibsqlTodoRepository) getSQL(fileName string) (string, error) { 24 | fb, err := sqlFiles.ReadFile(fmt.Sprintf("sql/%s", fileName)) 25 | if err != nil { 26 | return "", err 27 | } 28 | 29 | sql := string(fb) 30 | return sql, nil 31 | } 32 | 33 | func (r *LibsqlTodoRepository) FindById(id any) (*domain.TodoItem, error) { 34 | selc, err := r.getSQL("select.sql") 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | item := domain.TodoItem{} 40 | var ( 41 | createdAt sql.NullString 42 | updatedAt sql.NullString 43 | deletedAt sql.NullString 44 | ) 45 | if err := r.db.QueryRow(selc, id).Scan(&item.ID, &createdAt, &updatedAt, &deletedAt, &item.Title, &item.Description, &item.Done); err != nil { 46 | if err == sql.ErrNoRows { 47 | return nil, fmt.Errorf("id %d not found", id) 48 | } 49 | return nil, err 50 | } 51 | 52 | item.CreatedAt, _ = time.ParseInLocation("2006-01-02 15:04:05", createdAt.String, loc) 53 | item.UpdatedAt, _ = time.ParseInLocation("2006-01-02 15:04:05", updatedAt.String, loc) 54 | return &item, nil 55 | } 56 | 57 | func (r *LibsqlTodoRepository) All() ([]domain.TodoItem, error) { 58 | selc, err := r.getSQL("select_all.sql") 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | rows, err := r.db.Query(selc) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | items := []domain.TodoItem{} 69 | for rows.Next() { 70 | item := domain.TodoItem{} 71 | var ( 72 | createdAt sql.NullString 73 | updatedAt sql.NullString 74 | deletedAt sql.NullString 75 | ) 76 | if err := rows.Scan(&item.ID, &createdAt, &updatedAt, &deletedAt, &item.Title, &item.Description, &item.Done); err != nil { 77 | return nil, err 78 | } 79 | 80 | item.CreatedAt, _ = time.ParseInLocation("2006-01-02 15:04:05", createdAt.String, loc) 81 | item.UpdatedAt, _ = time.ParseInLocation("2006-01-02 15:04:05", updatedAt.String, loc) 82 | items = append(items, item) 83 | } 84 | 85 | return items, nil 86 | } 87 | 88 | func (r *LibsqlTodoRepository) Save(data *domain.TodoItem) error { 89 | save, err := r.getSQL("update.sql") 90 | if err != nil { 91 | return err 92 | } 93 | 94 | done := 0 95 | if data.Done { 96 | done = 1 97 | } 98 | 99 | var deletedAt = sql.NullString{ 100 | Valid: false, 101 | } 102 | if data.DeletedAt != nil { 103 | deletedAt = sql.NullString{ 104 | Valid: true, 105 | String: data.DeletedAt.Format("2006-01-02 15:04:05"), 106 | } 107 | } 108 | _, err = r.db.Exec(save, deletedAt, data.Title, data.Description, done, data.ID) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | item, err := r.FindById(data.ID) 114 | if err != nil { 115 | return err 116 | } 117 | *data = *item 118 | return err 119 | } 120 | 121 | func (r *LibsqlTodoRepository) Create(data *domain.TodoItem) error { 122 | save, err := r.getSQL("insert_or_replace.sql") 123 | if err != nil { 124 | return err 125 | } 126 | 127 | rs, err := r.db.Exec(save, nil, nil, data.Title, data.Description, 0) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | id, _ := rs.LastInsertId() 133 | item, err := r.FindById(id) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | *data = *item 139 | return err 140 | } 141 | 142 | func (r *LibsqlTodoRepository) Delete(data *domain.TodoItem) error { 143 | save, err := r.getSQL("insert_or_replace.sql") 144 | if err != nil { 145 | return err 146 | } 147 | 148 | done := 0 149 | if data.Done { 150 | done = 1 151 | } 152 | _, err = r.db.Exec(save, data.ID, time.Now(), data.Title, data.Description, done) 153 | return err 154 | } 155 | 156 | func (r *LibsqlTodoRepository) SaveBatch(data []*domain.TodoItem) error { 157 | save, err := r.getSQL("insert_or_replace.sql") 158 | if err != nil { 159 | return err 160 | } 161 | 162 | tx, err := r.db.Begin() 163 | if err != nil { 164 | return err 165 | } 166 | 167 | for _, item := range data { 168 | done := 0 169 | if item.Done { 170 | done = 1 171 | } 172 | 173 | _, err := tx.Exec(save, nil, nil, item.Title, item.Description, done) 174 | if err != nil { 175 | log.Println(err) 176 | log.Println("batch save transaction rollback") 177 | return tx.Rollback() 178 | } 179 | log.Printf("Inserted 1 row") 180 | } 181 | 182 | return tx.Commit() 183 | } 184 | 185 | func NewTodoDBRepository(connStr string) (ports.TodoRepository, error) { 186 | 187 | db, err := sql.Open("libsql", connStr) 188 | db.SetMaxOpenConns(5) 189 | db.SetMaxIdleConns(4) 190 | db.SetConnMaxIdleTime(time.Second * 3) 191 | db.SetConnMaxLifetime(time.Second * 3) 192 | if err != nil { 193 | return nil, err 194 | } 195 | 196 | err = db.Ping() 197 | if err != nil { 198 | return nil, err 199 | } 200 | 201 | log.Printf("Turso connection OK!") 202 | 203 | var repo = &LibsqlTodoRepository{ 204 | db: db, 205 | } 206 | 207 | create, err := repo.getSQL("create_table.sql") 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | _, err = db.Exec(create) 213 | if err != nil { 214 | return nil, err 215 | } 216 | 217 | return repo, nil 218 | } 219 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 h1:goHVqTbFX3AIo0tzGr14pgfAW2ZfPChKO21Z9MGf/gk= 2 | github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 7 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 8 | github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= 9 | github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= 10 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 11 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 12 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 13 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 14 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 15 | github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= 16 | github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= 17 | github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= 18 | github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= 19 | github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= 20 | github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 21 | github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= 22 | github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= 23 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 24 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 25 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 26 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 27 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 28 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 29 | github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= 30 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 31 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 32 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 33 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 34 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 35 | github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= 36 | github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= 37 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 38 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 39 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 40 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 41 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 42 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 43 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 44 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 45 | github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= 46 | github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw= 47 | github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4= 48 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 49 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 50 | github.com/libsql/libsql-client-go v0.0.0-20230710132643-6f49934b7fb3 h1:wK6okf6Lbye9TfPYSFW4o2L/jIUU8Z/k8/9HdB9tul0= 51 | github.com/libsql/libsql-client-go v0.0.0-20230710132643-6f49934b7fb3/go.mod h1:nSO3sfasvjfIpRObBYxdbEiY0CQdeWlExlAXBPDJItc= 52 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20230512205400-b2348f0d1196 h1:XBs+xh9/OM9kMvK7W9a3SEReC5x26z22cbYn0K6cUW4= 53 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20230512205400-b2348f0d1196/go.mod h1:20nXSmcf0nAscrzqsXeC2/tA3KkV2eCiJqYuyAgl+ss= 54 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 55 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 56 | github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= 57 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 58 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 59 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 60 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= 61 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 62 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 63 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 65 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 66 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 67 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 68 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 69 | github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= 70 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 71 | github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= 72 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 73 | golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= 74 | golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= 75 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= 76 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= 77 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 78 | golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= 79 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 80 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 81 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 82 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 83 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 84 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 85 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= 86 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 87 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 88 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 89 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 90 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 91 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 92 | gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= 93 | gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= 94 | gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc= 95 | gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= 96 | gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= 97 | gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 98 | nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= 99 | nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= 100 | --------------------------------------------------------------------------------