├── 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 |
2 | {{range .}}
3 |
4 | {{template "list_item.html" .}}
5 |
6 | {{end}}
7 |
--------------------------------------------------------------------------------
/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 |
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 |
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 | 
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 |
--------------------------------------------------------------------------------