From 8e125a997d0fe589f78c8bf5dc6af8d83cc30da7 Mon Sep 17 00:00:00 2001 From: matheusgomes28 Date: Sat, 5 Oct 2024 21:36:32 +0100 Subject: [PATCH] Add Arbitrary Page Creation Support (#92) TODOS: - Need the get page endpoints - Some more unit tests for this --- admin-app/admin_requests.go | 6 + admin-app/admin_responses.go | 5 + admin-app/app.go | 2 + admin-app/pages.go | 158 ++++++++++++++++++ app/app.go | 6 + app/page.go | 43 +++++ common/bindings.go | 4 + common/page.go | 8 + database/database.go | 38 +++++ migrations/20240708192408_add_page_table.sql | 14 ++ .../endpoint_tests/page_test.go | 44 +++++ tests/mocks/mocks.go | 10 ++ views/page.templ | 29 ++++ views/post.templ | 6 +- 14 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 admin-app/pages.go create mode 100644 app/page.go create mode 100644 common/page.go create mode 100644 migrations/20240708192408_add_page_table.sql create mode 100644 tests/admin_app_tests/endpoint_tests/page_test.go create mode 100644 views/page.templ diff --git a/admin-app/admin_requests.go b/admin-app/admin_requests.go index 0e8537f..36a2f2a 100644 --- a/admin-app/admin_requests.go +++ b/admin-app/admin_requests.go @@ -6,6 +6,12 @@ import "github.com/matheusgomes28/urchin/common" // organize the data in a simpler way. Every domain object supporting // CRUD endpoints has their own structures to handle the http methods. +type AddPageRequest struct { + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` +} + type DeletePostBinding struct { common.IntIdBinding } diff --git a/admin-app/admin_responses.go b/admin-app/admin_responses.go index c593f30..03695cc 100644 --- a/admin-app/admin_responses.go +++ b/admin-app/admin_responses.go @@ -1,5 +1,10 @@ package admin_app +type PageResponse struct { + Id int `json:"id"` + Link string `json:"link"` +} + type PostIdResponse struct { Id int `json:"id"` } diff --git a/admin-app/app.go b/admin-app/app.go index 978e84d..1fc7163 100644 --- a/admin-app/app.go +++ b/admin-app/app.go @@ -19,5 +19,7 @@ func SetupRoutes(app_settings common.AppSettings, database database.Database) *g r.POST("/images", postImageHandler(app_settings)) r.DELETE("/images/:name", deleteImageHandler(app_settings)) + r.POST("/pages", postPageHandler(database)) + return r } diff --git a/admin-app/pages.go b/admin-app/pages.go new file mode 100644 index 0000000..94d162f --- /dev/null +++ b/admin-app/pages.go @@ -0,0 +1,158 @@ +package admin_app + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/matheusgomes28/urchin/common" + "github.com/matheusgomes28/urchin/database" + "github.com/rs/zerolog/log" +) + +// func getPageHandler(database database.Database) func(*gin.Context) { +// return func(c *gin.Context) { +// } +// } + +// / postPageHandler is the function handling the endpoint for adding new pages +func postPageHandler(database database.Database) func(*gin.Context) { + return func(c *gin.Context) { + var add_page_request AddPageRequest + if c.Request.Body == nil { + c.JSON(http.StatusBadRequest, common.MsgErrorRes("no request body provided")) + return + } + + decoder := json.NewDecoder(c.Request.Body) + err := decoder.Decode(&add_page_request) + if err != nil { + log.Warn().Msgf("invalid page request: %v", err) + c.JSON(http.StatusBadRequest, common.ErrorRes("invalid request body", err)) + return + } + + err = checkRequiredPageData(add_page_request) + if err != nil { + c.JSON(http.StatusBadRequest, common.MsgErrorRes("invalid page data")) + return + } + + err = checkRequiredPageData(add_page_request) + if err != nil { + log.Error().Msgf("failed to add post required data is missing: %v", err) + c.JSON(http.StatusBadRequest, common.ErrorRes("missing required data", err)) + return + } + + id, err := database.AddPage( + add_page_request.Title, + add_page_request.Content, + add_page_request.Link, + ) + if err != nil { + log.Error().Msgf("failed to add post: %v", err) + c.JSON(http.StatusBadRequest, common.ErrorRes("could not add post", err)) + return + } + + c.JSON(http.StatusOK, PageResponse{ + Id: id, + Link: add_page_request.Link, + }) + } +} + +// func putPostHandler(database database.Database) func(*gin.Context) { +// return func(c *gin.Context) { +// var change_post_request ChangePostRequest +// decoder := json.NewDecoder(c.Request.Body) +// decoder.DisallowUnknownFields() + +// err := decoder.Decode(&change_post_request) +// if err != nil { +// log.Warn().Msgf("could not get post from DB: %v", err) +// c.JSON(http.StatusBadRequest, common.ErrorRes("invalid request body", err)) +// return +// } + +// err = database.ChangePost( +// change_post_request.Id, +// change_post_request.Title, +// change_post_request.Excerpt, +// change_post_request.Content, +// ) +// if err != nil { +// log.Error().Msgf("failed to change post: %v", err) +// c.JSON(http.StatusBadRequest, common.ErrorRes("could not change post", err)) +// return +// } + +// c.JSON(http.StatusOK, PostIdResponse{ +// change_post_request.Id, +// }) +// } +// } + +// func deletePostHandler(database database.Database) func(*gin.Context) { +// return func(c *gin.Context) { +// var delete_post_binding DeletePostBinding +// err := c.ShouldBindUri(&delete_post_binding) +// if err != nil { +// c.JSON(http.StatusBadRequest, common.ErrorRes("no id provided to delete post", err)) +// return +// } + +// rows_affected, err := database.DeletePost(delete_post_binding.Id) +// if err != nil { +// log.Error().Msgf("failed to delete post: %v", err) +// c.JSON(http.StatusBadRequest, common.ErrorRes("could not delete post", err)) +// return +// } + +// if rows_affected == 0 { +// log.Error().Msgf("no post found with id `%d`", delete_post_binding.Id) +// c.JSON(http.StatusNotFound, common.MsgErrorRes("no post found")) +// return +// } + +// c.JSON(http.StatusOK, PostIdResponse{ +// delete_post_binding.Id, +// }) +// } +// } + +func checkRequiredPageData(add_page_request AddPageRequest) error { + if strings.TrimSpace(add_page_request.Title) == "" { + return fmt.Errorf("missing required data 'Title'") + } + + if strings.TrimSpace(add_page_request.Content) == "" { + return fmt.Errorf("missing required data 'Content'") + } + + err := validateLink(add_page_request.Link) + if err != nil { + return err + } + + return nil +} + +func validateLink(link string) error { + for _, char := range link { + char_val := int(char) + is_uppercase := (char_val >= 65) && (char_val <= 90) + is_lowercase := (char_val >= 97) && (char_val <= 122) + is_sign := (char == '-') || (char == '_') + + if !(is_uppercase || is_lowercase || is_sign) { + // TODO : what is this conversion?! + return fmt.Errorf("invalid character in link %s", string(rune(char))) + } + } + + return nil +} diff --git a/app/app.go b/app/app.go index ec5219e..7b0413b 100644 --- a/app/app.go +++ b/app/app.go @@ -29,6 +29,10 @@ func SetupRoutes(app_settings common.AppSettings, database database.Database) *g addCachableHandler(r, "GET", "/images/:name", imageHandler, &cache, app_settings, database) addCachableHandler(r, "GET", "/images", imagesHandler, &cache, app_settings, database) + // Pages will be querying the page content from the unique + // link given at the creation of the page step + addCachableHandler(r, "GET", "/pages/:link", pageHandler, &cache, app_settings, database) + // Static endpoint for image serving r.Static("/images/data", app_settings.ImageDirectory) @@ -40,6 +44,7 @@ func SetupRoutes(app_settings common.AppSettings, database database.Database) *g // Where all the static files (css, js, etc) are served from r.Static("/static", "./static") + return r } @@ -50,6 +55,7 @@ func addCachableHandler(e *gin.Engine, method string, endpoint string, generator if app_settings.CacheEnabled { cached_endpoint, err := (*cache).Get(c.Request.RequestURI) if err == nil { + log.Info().Msgf("cache hit for page: %s", c.Request.RequestURI) c.Data(http.StatusOK, "text/html; charset=utf-8", cached_endpoint.Contents) return } diff --git a/app/page.go b/app/page.go new file mode 100644 index 0000000..31931ff --- /dev/null +++ b/app/page.go @@ -0,0 +1,43 @@ +package app + +import ( + "bytes" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/matheusgomes28/urchin/common" + "github.com/matheusgomes28/urchin/database" + "github.com/matheusgomes28/urchin/views" + "github.com/rs/zerolog/log" +) + +func pageHandler(c *gin.Context, app_settings common.AppSettings, database database.Database) ([]byte, error) { + var page_binding common.PageLinkBinding + err := c.ShouldBindUri(&page_binding) + + if err != nil || len(page_binding.Link) == 0 { + // TODO : we should be serving an error page + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page uri"}) + + return nil, err + } + + // Get the page with the ID + page, err := database.GetPage(page_binding.Link) + + if err != nil || page.Content == "" { + // TODO : serve the error page instead + c.JSON(http.StatusNotFound, gin.H{"error": "Page Not Found"}) + return nil, err + } + + // Generate HTML page + page.Content = string(mdToHTML([]byte(page.Content))) + post_view := views.MakePage(page.Title, page.Content, app_settings.AppNavbar.Links) + html_buffer := bytes.NewBuffer(nil) + if err = post_view.Render(c, html_buffer); err != nil { + log.Error().Msgf("could not render: %v", err) + } + + return html_buffer.Bytes(), nil +} diff --git a/common/bindings.go b/common/bindings.go index d86960b..117e6c9 100644 --- a/common/bindings.go +++ b/common/bindings.go @@ -12,6 +12,10 @@ type PostIdBinding struct { IntIdBinding } +type PageLinkBinding struct { + Link string `uri:"link" binding:"required"` +} + type ImageIdBinding struct { // This is the uuid of an image to be retrieved Filename string `uri:"name" binding:"required"` diff --git a/common/page.go b/common/page.go new file mode 100644 index 0000000..acc225b --- /dev/null +++ b/common/page.go @@ -0,0 +1,8 @@ +package common + +type Page struct { + Id int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` +} diff --git a/database/database.go b/database/database.go index 1eed06a..528bdbb 100644 --- a/database/database.go +++ b/database/database.go @@ -16,6 +16,8 @@ type Database interface { AddPost(title string, excerpt string, content string) (int, error) ChangePost(id int, title string, excerpt string, content string) error DeletePost(id int) (int, error) + AddPage(title string, content string, link string) (int, error) + GetPage(link string) (common.Page, error) } type SqlDatabase struct { @@ -141,6 +143,42 @@ func (db SqlDatabase) DeletePost(id int) (int, error) { return int(rows_affected), nil } +func (db SqlDatabase) AddPage(title string, content string, link string) (int, error) { + res, err := db.Connection.Exec("INSERT INTO pages(content, title, link) VALUES(?, ?, ?)", content, title, link) + if err != nil { + return -1, err + } + + id, err := res.LastInsertId() + if err != nil { + log.Warn().Msgf("could not get last ID: %v", err) + return -1, nil + } + + // TODO : possibly unsafe int conv, + // make sure all IDs are i64 in the + // future + return int(id), nil +} + +func (db SqlDatabase) GetPage(link string) (common.Page, error) { + rows, err := db.Connection.Query("SELECT id, title, content, link FROM pages WHERE link=?;", link) + if err != nil { + return common.Page{}, err + } + defer func() { + err = errors.Join(rows.Close()) + }() + + page := common.Page{} + rows.Next() + if err = rows.Scan(&page.Id, &page.Title, &page.Content, &page.Link); err != nil { + return common.Page{}, err + } + + return page, nil +} + func MakeSqlConnection(user string, password string, address string, port int, database string) (SqlDatabase, error) { /// TODO : let user specify the DB diff --git a/migrations/20240708192408_add_page_table.sql b/migrations/20240708192408_add_page_table.sql new file mode 100644 index 0000000..137f399 --- /dev/null +++ b/migrations/20240708192408_add_page_table.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE pages ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + content TEXT NOT NULL, + link TEXT NOT NULL, + title TEXT NOT NULL +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE pages; +-- +goose StatementEnd diff --git a/tests/admin_app_tests/endpoint_tests/page_test.go b/tests/admin_app_tests/endpoint_tests/page_test.go new file mode 100644 index 0000000..dea682d --- /dev/null +++ b/tests/admin_app_tests/endpoint_tests/page_test.go @@ -0,0 +1,44 @@ +package endpoint_tests + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + admin_app "github.com/matheusgomes28/urchin/admin-app" + "github.com/matheusgomes28/urchin/tests/mocks" + "github.com/stretchr/testify/assert" +) + +func TestAddPageHappyPath(t *testing.T) { + databaseMock := mocks.DatabaseMock{ + AddPageHandler: func(string, string, string) (int, error) { + return 0, nil + }, + } + + page_data := admin_app.AddPageRequest{ + Title: "Title", + Content: "Content", + Link: "Link", + } + + router := admin_app.SetupRoutes(app_settings, databaseMock) + responseRecorder := httptest.NewRecorder() + + body, _ := json.Marshal(page_data) + request, _ := http.NewRequest(http.MethodPost, "/pages", bytes.NewBuffer(body)) + + router.ServeHTTP(responseRecorder, request) + + assert.Equal(t, http.StatusOK, responseRecorder.Code) + var response admin_app.PageResponse + err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) + assert.Nil(t, err) + + assert.NotNil(t, response.Id) + assert.NotEmpty(t, response.Link) + assert.Equal(t, page_data.Link, response.Link) +} diff --git a/tests/mocks/mocks.go b/tests/mocks/mocks.go index 5e9f277..512dff6 100644 --- a/tests/mocks/mocks.go +++ b/tests/mocks/mocks.go @@ -9,6 +9,8 @@ type DatabaseMock struct { GetPostsHandler func(int, int) ([]common.Post, error) AddPostHandler func(string, string, string) (int, error) DeletePostHandler func(int) (int, error) + AddPageHandler func(string, string, string) (int, error) + GetPageHandler func(string) (common.Page, error) } func (db DatabaseMock) GetPosts(limit int, offset int) ([]common.Post, error) { @@ -30,3 +32,11 @@ func (db DatabaseMock) ChangePost(id int, title string, excerpt string, content func (db DatabaseMock) DeletePost(id int) (int, error) { return db.DeletePostHandler(id) } + +func (db DatabaseMock) AddPage(title string, content string, link string) (int, error) { + return db.AddPageHandler(title, content, link) +} + +func (db DatabaseMock) GetPage(link string) (common.Page, error) { + return db.GetPageHandler(link) +} diff --git a/views/page.templ b/views/page.templ new file mode 100644 index 0000000..247cd77 --- /dev/null +++ b/views/page.templ @@ -0,0 +1,29 @@ +package views + +import "github.com/matheusgomes28/urchin/common" + + +templ MakePage(title string, content string, links []common.Link) { + + + + + + { title } + + + + + + + + @MakeNavBar(links) +
+ @templ.Raw(content) +
+ + @MakeFooter() + + + +} diff --git a/views/post.templ b/views/post.templ index 4f7fd21..6131382 100644 --- a/views/post.templ +++ b/views/post.templ @@ -9,7 +9,7 @@ templ MakePostPage(title string, content string, links []common.Link) { - Menu and Contact Form + { title } @@ -20,8 +20,8 @@ templ MakePostPage(title string, content string, links []common.Link) { @MakeNavBar(links)
-

{ title }

-

@templ.Raw(content)

+

{ title }

+ @templ.Raw(content)