commit 80dd3452375146c93942f4bcafdc9c589161d9fe Author: Brandon Dyck Date: Fri Nov 18 19:19:28 2022 -0700 Create and list posts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81e8b4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.db +*.db-shm +*.db-wal diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7f13e96 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.codemonkeysoftware.net/b/sbsqlitessgcms + +go 1.18 + +require ( + crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c + gitlab.codemonkeysoftware.net/b/hatmill v0.0.6 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..99668d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= +crawshaw.io/sqlite v0.3.2 h1:N6IzTjkiw9FItHAa0jp+ZKC6tuLzXqAYIv+ccIWos1I= +crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= +crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c h1:wvzox0eLO6CKQAMcOqz7oH3UFqMpMmK7kwmwV+22HIs= +crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= +github.com/leanovate/gopter v0.2.4 h1:U4YLBggDFhJdqQsG4Na2zX7joVTky9vHaj/AGEwSuXU= +github.com/leanovate/gopter v0.2.4/go.mod h1:gNcbPWNEWRe4lm+bycKqxUYoH5uoVje5SkOJ3uoLer8= +gitlab.codemonkeysoftware.net/b/hatmill v0.0.6 h1:5Vs30ORHoujCYRvtbIwrusGBQgPzbKv011xKrTFa5ng= +gitlab.codemonkeysoftware.net/b/hatmill v0.0.6/go.mod h1:T19ms3BmsEzy5YTS4icMO1oLC53xWhOP9MFeBv4NcFQ= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..4847fed --- /dev/null +++ b/handler.go @@ -0,0 +1,142 @@ +package main + +import ( + "log" + "net/http" + "strconv" + + h "gitlab.codemonkeysoftware.net/b/hatmill" + ha "gitlab.codemonkeysoftware.net/b/hatmill/attribute" + he "gitlab.codemonkeysoftware.net/b/hatmill/element" +) + +const ( + pathRoot = "/" + pathCreatePost = "/create-post" + pathDoCreatePost = "/create-post/do" +) + +type handler struct { + mux *http.ServeMux + store *Store + baseURL string +} + +func NewHandler(store *Store, baseURL string) http.Handler { + h := &handler{ + store: store, + mux: http.NewServeMux(), + baseURL: baseURL, + } + + h.mux.HandleFunc(pathRoot, h.getPosts) + h.mux.HandleFunc(pathCreatePost, h.createPost) + h.mux.HandleFunc(pathDoCreatePost, h.doCreatePost) + + return h +} + +func (hnd *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + hnd.mux.ServeHTTP(w, r) +} + +func (hnd *handler) getPosts(w http.ResponseWriter, r *http.Request) { + result, err := hnd.store.GetPosts(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var rows h.Terms + for _, post := range result.Posts { + rows = append(rows, he.Tr()( + he.Td()(h.Text(strconv.FormatInt(post.Id, 10))), + he.Td()(h.Text(post.CreatedAt)), + he.Td()(h.Text(post.UpdatedAt)), + he.Td()(h.Text(post.Author)), + he.Td()(h.Text(post.Title)), + // he.Td()(h.Text(post.Body)), + )) + } + + html := he.Html()( + he.Head()(), + he.Body()( + he.H1()(h.Text("Posts")), + he.Table()( + he.Thead()( + he.Tr()( + he.Th()(h.Text("ID")), + he.Th()(h.Text("Created")), + he.Th()(h.Text("Updated")), + he.Th()(h.Text("Author")), + he.Th()(h.Text("Title")), + ), + ), + he.Tbody()( + rows..., + ), + ), + he.A(ha.Href(hnd.baseURL+pathCreatePost))(h.Text("Create post")), + ), + ) + + _, err = h.WriteDocument(w, html) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +const ( + fieldNamePostTitle = "post-title" + fieldNamePostBody = "post-body" + fieldNamePostAuthor = "post-author" +) + +func (hnd *handler) createPost(w http.ResponseWriter, r *http.Request) { + html := he.Html()( + he.Head()(), + he.Body()( + he.H1()(h.Text("Create post")), + he.Form(ha.Action(hnd.baseURL+pathDoCreatePost), ha.Method("POST"))( + he.Label(ha.For(fieldNamePostTitle))(h.Text("Title")), + he.Input(ha.Name(fieldNamePostTitle)), + he.Br(), + he.Label(ha.For(fieldNamePostAuthor))(h.Text("Author")), + he.Input(ha.Name(fieldNamePostAuthor)), + he.Br(), + he.Label(ha.For(fieldNamePostBody))(h.Text("Body")), + he.Textarea(ha.For(fieldNamePostBody))(), + he.Br(), + he.Input(ha.Type("submit")), + ), + ), + ) + _, err := h.WriteDocument(w, html) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (hnd *handler) doCreatePost(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + cmd := CreatePostCommand{ + Title: r.PostForm.Get(fieldNamePostTitle), + Author: r.PostForm.Get(fieldNamePostAuthor), + Body: r.PostForm.Get(fieldNamePostBody), + } + result, err := hnd.store.CreatePost(r.Context(), cmd) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Println("created post", result.PostId) + http.Redirect(w, r, pathRoot, http.StatusFound) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..234c223 --- /dev/null +++ b/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "errors" + "log" + "net/http" + + "crawshaw.io/sqlite/sqlitex" +) + +const addr = ":31337" +const dbPath = "./sbsqlitessgcms.db" +const baseURL = "http://localhost:31337" + +var dbPool *sqlitex.Pool + +func main() { + err := run() + if err != nil { + log.Fatal(err) + } +} + +func run() error { + store, err := NewStore(dbPath) + if err != nil { + return err + } + defer store.Close() + + log.Println("Listening on", addr) + err = http.ListenAndServe(addr, NewHandler(store, baseURL)) + if errors.Is(err, http.ErrServerClosed) { + return nil + } else { + return err + } +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..1457c5f --- /dev/null +++ b/store.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "errors" + "log" + "os" + + "crawshaw.io/sqlite" + "crawshaw.io/sqlite/sqlitex" +) + +var ErrNoConn = errors.New("could not get a database connection") + +type Store struct { + pool *sqlitex.Pool +} + +func NewStore(filename string) (*Store, error) { + var needCreate bool + if _, err := os.Stat(filename); os.IsNotExist(err) { + needCreate = true + } + // If the file exists, then assume it was created properly. + + pool, err := sqlitex.Open(filename, 0, 10) + if err != nil { + return nil, err + } + store := &Store{ + pool: pool, + } + if needCreate { + log.Println("creating schema") + err = store.createSchema() + if err != nil { + defer os.Remove(filename) + defer pool.Close() + return nil, err + } + } + + return store, nil +} + +func NewMemoryStore() (*Store, error) { + pool, err := sqlitex.Open("file::memory:?mode=memory&cache=shared", 0, 10) + if err != nil { + return nil, err + } + store := &Store{ + pool: pool, + } + + err = store.createSchema() + if err != nil { + defer pool.Close() + return nil, err + } + return store, nil +} + +func (s *Store) Close() error { + return s.pool.Close() +} + +func (s *Store) createSchema() error { + conn := s.pool.Get(context.Background()) + defer s.pool.Put(conn) + return sqlitex.ExecScript(conn, dbSchema) +} + +const dbSchema = ` + CREATE TABLE post ( + id INTEGER PRIMARY KEY, + author TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL, + body TEXT NOT NULL + ); + + CREATE TRIGGER update_post_updated_at + AFTER update ON post + BEGIN + UPDATE post SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id; + END; +` + +type Post struct { + Id int64 + Author string + CreatedAt string + UpdatedAt string + Title string + Body string +} + +type GetPostsResult struct { + Posts []Post +} + +func (s *Store) GetPosts(ctx context.Context) (GetPostsResult, error) { + conn := s.pool.Get(ctx) + if conn == nil { + return GetPostsResult{}, ErrNoConn + } + defer s.pool.Put(conn) + + const dbQuery = ` + SELECT id, author, created_at, updated_at, title, body + FROM post + ORDER BY created_at DESC` + var result GetPostsResult + err := sqlitex.Exec(conn, dbQuery, + func(stmt *sqlite.Stmt) error { + result.Posts = append(result.Posts, Post{ + Id: stmt.ColumnInt64(0), + Author: stmt.ColumnText(1), + CreatedAt: stmt.ColumnText(2), + UpdatedAt: stmt.ColumnText(3), + Title: stmt.ColumnText(4), + Body: stmt.ColumnText(5), + }) + return nil + }) + if err != nil { + return GetPostsResult{}, err + } + + return result, nil +} + +type CreatePostCommand struct { + Title string + Author string + Body string +} + +type CreatePostResult struct { + PostId int64 +} + +func (s *Store) CreatePost(ctx context.Context, cmd CreatePostCommand) (CreatePostResult, error) { + conn := s.pool.Get(ctx) + if conn == nil { + return CreatePostResult{}, ErrNoConn + } + defer s.pool.Put(conn) + + const dbQuery = ` + INSERT INTO post(title, author, body) + VALUES (?, ?, ?) + RETURNING id;` + var result CreatePostResult + err := sqlitex.Exec(conn, dbQuery, + func(stmt *sqlite.Stmt) error { + result.PostId = stmt.ColumnInt64(0) + return nil + }, cmd.Title, cmd.Author, cmd.Body) + return result, err +}