Skip to content

Commit

Permalink
Add Arbitrary Page Creation Support (#92)
Browse files Browse the repository at this point in the history
TODOS:

- Need the get page endpoints
- Some more unit tests for this
  • Loading branch information
matheusgomes28 authored Oct 5, 2024
1 parent ad4fa36 commit 8e125a9
Show file tree
Hide file tree
Showing 14 changed files with 370 additions and 3 deletions.
6 changes: 6 additions & 0 deletions admin-app/admin_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions admin-app/admin_responses.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Expand Down
2 changes: 2 additions & 0 deletions admin-app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
158 changes: 158 additions & 0 deletions admin-app/pages.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
}

Expand All @@ -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
}
Expand Down
43 changes: 43 additions & 0 deletions app/page.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions common/bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
8 changes: 8 additions & 0 deletions common/page.go
Original file line number Diff line number Diff line change
@@ -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"`
}
38 changes: 38 additions & 0 deletions database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions migrations/20240708192408_add_page_table.sql
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions tests/admin_app_tests/endpoint_tests/page_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 8e125a9

Please sign in to comment.