diff --git a/admin-app/app.go b/admin-app/app.go index a92947c..f2f5806 100644 --- a/admin-app/app.go +++ b/admin-app/app.go @@ -42,5 +42,8 @@ func SetupRoutes(app_settings common.AppSettings, database database.Database) *g r.POST("/images", postImageHandler(app_settings, database)) // r.DELETE("/images", deleteImageHandler(&database)) + // Card related endpoints + r.POST("/card", postCardHandler(database)) + return r } diff --git a/admin-app/cards.go b/admin-app/cards.go new file mode 100644 index 0000000..0cab0f1 --- /dev/null +++ b/admin-app/cards.go @@ -0,0 +1,169 @@ +package admin_app + +import ( + "encoding/json" + "net/http" + + "github.com/fossoreslp/go-uuid-v4" + "github.com/gin-gonic/gin" + "github.com/matheusgomes28/urchin/database" + "github.com/rs/zerolog/log" +) + +type AddCardRequest struct { + ImageLocation string `json:"image_location"` + JsonData string `json:"json_data"` + SchemaName string `json:"json_schema"` +} + +// func getCardHandler(database database.Database) func(*gin.Context) { +// return func(c *gin.Context) { +// // localhost:8080/post/{id} +// var post_binding PostBinding +// if err := c.ShouldBindUri(&post_binding); err != nil { +// c.JSON(http.StatusBadRequest, gin.H{ +// "error": "could not get post id", +// "msg": err.Error(), +// }) +// return +// } + +// post_id, err := strconv.Atoi(post_binding.Id) +// if err != nil { +// c.JSON(http.StatusBadRequest, gin.H{ +// "error": "invalid post id type", +// "msg": err.Error(), +// }) +// return +// } + +// post, err := database.GetPost(post_id) +// if err != nil { +// log.Warn().Msgf("could not get post from DB: %v", err) +// c.JSON(http.StatusNotFound, gin.H{ +// "error": "post id not found", +// "msg": err.Error(), +// }) +// return +// } + +// c.JSON(http.StatusOK, gin.H{ +// "id": post.Id, +// "title": post.Title, +// "excerpt": post.Excerpt, +// "content": post.Content, +// }) +// } +// } + +func postCardHandler(database database.Database) func(*gin.Context) { + return func(c *gin.Context) { + var add_card_request AddCardRequest + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + err := decoder.Decode(&add_card_request) + + if err != nil { + log.Warn().Msgf("invalid post card request: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid request body", + "msg": err.Error(), + }) + return + } + + card_uuid, err := uuid.New() + if err != nil { + log.Error().Msgf("could not create the UUID: %v", err) + return + } + + err = database.AddCard( + card_uuid.String(), + add_card_request.ImageLocation, + add_card_request.JsonData, + add_card_request.SchemaName, + ) + if err != nil { + log.Error().Msgf("failed to add post: %v", err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "could not add post", + "msg": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": card_uuid.String(), + }) + } +} + +// func putCardHandler(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, gin.H{ +// "error": "invalid request body", +// "msg": err.Error(), +// }) +// 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, gin.H{ +// "error": "could not change post", +// "msg": err.Error(), +// }) +// return +// } + +// c.JSON(http.StatusOK, gin.H{ +// "id": change_post_request.Id, +// }) +// } +// } + +// func deleteCardHandler(database database.Database) func(*gin.Context) { +// return func(c *gin.Context) { +// var delete_post_request DeletePostRequest +// decoder := json.NewDecoder(c.Request.Body) +// decoder.DisallowUnknownFields() + +// err := decoder.Decode(&delete_post_request) +// if err != nil { +// log.Warn().Msgf("could not delete post: %v", err) +// c.JSON(http.StatusBadRequest, gin.H{ +// "error": "invalid request body", +// "msg": err.Error(), +// }) +// return +// } + +// err = database.DeletePost(delete_post_request.Id) +// if err != nil { +// log.Error().Msgf("failed to delete post: %v", err) +// c.JSON(http.StatusBadRequest, gin.H{ +// "error": "could not delete post", +// "msg": err.Error(), +// }) +// return +// } + +// c.JSON(http.StatusOK, gin.H{ +// "id": delete_post_request.Id, +// }) +// } +// } diff --git a/cmd/urchin/index_test.go b/cmd/urchin/index_test.go new file mode 100644 index 0000000..ca79560 --- /dev/null +++ b/cmd/urchin/index_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/matheusgomes28/urchin/app" + "github.com/matheusgomes28/urchin/common" + + "github.com/stretchr/testify/assert" +) + +type DatabaseMock struct{} + +func (db DatabaseMock) GetPosts() ([]common.Post, error) { + return []common.Post{ + { + Title: "TestPost", + Content: "TestContent", + Excerpt: "TestExcerpt", + Id: 0, + }, + }, nil +} + +func (db DatabaseMock) GetPost(post_id int) (common.Post, error) { + return common.Post{}, fmt.Errorf("not implemented") +} + +func (db DatabaseMock) AddPost(title string, excerpt string, content string) (int, error) { + return 0, fmt.Errorf("not implemented") +} + +func (db DatabaseMock) ChangePost(id int, title string, excerpt string, content string) error { + return nil +} + +func (db DatabaseMock) DeletePost(id int) error { + return fmt.Errorf("not implemented") +} + +func (db DatabaseMock) AddImage(string, string, string) error { + return fmt.Errorf("not implemented") +} + +func (db DatabaseMock) AddCard(string, string, string, string) error { + return fmt.Errorf("not implemented") +} + +func TestIndexPing(t *testing.T) { + app_settings := common.AppSettings{ + DatabaseAddress: "localhost", + DatabasePort: 3006, + DatabaseUser: "root", + DatabasePassword: "root", + DatabaseName: "urchin", + WebserverPort: 8080, + } + + database_mock := DatabaseMock{} + + r := app.SetupRoutes(app_settings, database_mock) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Contains(t, w.Body.String(), "TestPost") + assert.Contains(t, w.Body.String(), "TestExcerpt") +} diff --git a/common/app_settings.go b/common/app_settings.go index 965e2f8..62a47a3 100644 --- a/common/app_settings.go +++ b/common/app_settings.go @@ -4,18 +4,24 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/BurntSushi/toml" ) +type CardSchema struct { + Name string `toml:"schema_name"` +} + type AppSettings struct { - DatabaseAddress string `toml:"database_address"` - DatabasePort int `toml:"database_port"` - DatabaseUser string `toml:"database_user"` - DatabasePassword string `toml:"database_password"` - DatabaseName string `toml:"database_name"` - WebserverPort int `toml:"webserver_port"` - ImageDirectory string `toml:"image_dir"` + DatabaseAddress string `toml:"database_address"` + DatabasePort int `toml:"database_port"` + DatabaseUser string `toml:"database_user"` + DatabasePassword string `toml:"database_password"` + DatabaseName string `toml:"database_name"` + WebserverPort int `toml:"webserver_port"` + ImageDirectory string `toml:"image_dir"` + CardSchemas []CardSchema `toml:"card_schema"` } func LoadSettings() (AppSettings, error) { @@ -62,7 +68,7 @@ func LoadSettings() (AppSettings, error) { image_directory := os.Getenv("URCHIN_IMAGE_DIRECTORY") if len(image_directory) == 0 { - return AppSettings{}, fmt.Errorf("URCHIN_IMAGE_DIRECTORY is not defined\n") + return AppSettings{}, fmt.Errorf("URCHIN_IMAGE_DIRECTORY is not defined") } return AppSettings{ @@ -78,10 +84,21 @@ func LoadSettings() (AppSettings, error) { func ReadConfigToml(filepath string) (AppSettings, error) { var config AppSettings - _, err := toml.DecodeFile(filepath, &config) + metadata, err := toml.DecodeFile(filepath, &config) if err != nil { return AppSettings{}, err } + if undecoded_keys := metadata.Undecoded(); len(undecoded_keys) > 0 { + err := fmt.Errorf("could not decode keys: ") + + for _, key := range undecoded_keys { + metadata.Keys() + err = fmt.Errorf("%v %v,", err, strings.Join(key, ".")) + } + + return AppSettings{}, err + } + return config, nil } diff --git a/common/post.go b/common/post.go index 7acd8d1..2dbd6ab 100644 --- a/common/post.go +++ b/common/post.go @@ -6,3 +6,10 @@ type Post struct { Excerpt string Id int } + +type Card struct { + Uuid string + ImageLocation string + JsonData string + SchemaName string +} diff --git a/database/database.go b/database/database.go index 59f03f4..cfc8361 100644 --- a/database/database.go +++ b/database/database.go @@ -4,19 +4,34 @@ import ( "database/sql" "errors" "fmt" + "os" + "path/filepath" "time" "github.com/matheusgomes28/urchin/common" "github.com/rs/zerolog/log" + "github.com/xeipuuv/gojsonschema" ) type Database interface { +<<<<<<< HEAD GetPosts(int, int) ([]common.Post, error) +======= + + // Post related stuff + GetPosts() ([]common.Post, error) +>>>>>>> 446f776 (Adding basic support for flexible cards) GetPost(post_id int) (common.Post, error) AddPost(title string, excerpt string, content string) (int, error) ChangePost(id int, title string, excerpt string, content string) error DeletePost(id int) error + + // Image related stuff AddImage(uuid string, name string, alt string) error + + // Card related stuff + AddCard(uuid string, image_location string, json_data string, schema_name string) error + GetCard(uuid int) (common.Card, error) } type SqlDatabase struct { @@ -170,6 +185,103 @@ func (db SqlDatabase) AddImage(uuid string, name string, alt string) (err error) return nil } +func (db SqlDatabase) AddCard(uuid string, image_location string, json_data string, schema_name string) (err error) { + // Check that the file exists and is a file + // not a directory. Ideally, check the ext + if image_location == "" { + return fmt.Errorf("cannot have image") + } + image_stat, err := os.Stat(image_location) + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("file does not exist: %s", image_location) + } + if err != nil { + return err + } + if image_stat.IsDir() { + return fmt.Errorf("given path is a directory: %s", image_location) + } + + // Load the schema + // TODO : probably pass the schema data instead + if json_data == "" { + return fmt.Errorf("cannot have empty data") + } + + if schema_name == "" { + return fmt.Errorf("cannot have an empty schema name") + } + + _, err = validateJson(json_data, schema_name) + if err != nil { + return err + } + + // Insert everything to the DB + tx, err := db.Connection.Begin() + if err != nil { + return err + } + defer func() { + if commit_err := tx.Commit(); commit_err != nil { + err = errors.Join(err, tx.Rollback(), commit_err) + } + }() + + query := "INSERT INTO cards(uuid, image_location, json_data, json_schema) VALUES(?, ?, ?, ?);" + _, err = tx.Exec(query, uuid, image_location, json_data, schema_name) + if err != nil { + return err + } + + return nil +} + +// / This function gets a post from the database +// / with the given ID. +func (db SqlDatabase) GetCard(uuid int) (card common.Card, err error) { + rows, err := db.Connection.Query("SELECT image_location, json_data, json_schema FROM cards WHERE uuid=?;", uuid) + if err != nil { + return common.Card{}, err + } + defer func() { + err = errors.Join(rows.Close()) + }() + + rows.Next() + if err = rows.Scan(&card.ImageLocation, &card.JsonData, &card.SchemaName); err != nil { + return common.Card{}, err + } + + // Validate the json + validateJson(card.JsonData, card.SchemaName) + + return card, nil +} + +func validateJson(json_data string, schema_name string) (bool, error) { + schema_data, err := os.ReadFile(filepath.Join("schemas", fmt.Sprintf("%s.json", schema_name))) + if err != nil { + return false, err + } + + schemaLoader := gojsonschema.NewBytesLoader(schema_data) + documentLoader := gojsonschema.NewStringLoader(json_data) + result, err := gojsonschema.Validate(schemaLoader, documentLoader) + if err != nil { + return false, fmt.Errorf("could not read json_data: %v", err) + } + if !result.Valid() { + json_err := fmt.Errorf("invalid card json: ") + for _, e := range result.Errors() { + json_err = fmt.Errorf("%v %s", json_err, e) + } + return false, json_err + } + + return true, nil +} + func MakeSqlConnection(user string, password string, address string, port int, database string) (SqlDatabase, error) { /// TODO : let user specify the DB diff --git a/go.mod b/go.mod index 4ee605d..b476a48 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/pressly/goose/v3 v3.19.2 github.com/rs/zerolog v1.31.0 github.com/stretchr/testify v1.8.4 + github.com/xeipuuv/gojsonschema v1.2.0 github.com/zutto/shardedmap v0.0.0-20180201164343-415202d0910e ) @@ -54,6 +55,8 @@ require ( github.com/tetratelabs/wazero v1.1.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect go.opentelemetry.io/otel v1.20.0 // indirect go.opentelemetry.io/otel/trace v1.20.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 5e7dbe2..805b66a 100644 --- a/go.sum +++ b/go.sum @@ -435,6 +435,7 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/schemas/person.json b/schemas/person.json new file mode 100644 index 0000000..1c81b51 --- /dev/null +++ b/schemas/person.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + }, + "height": { + "type": "number" + } + }, + "required": [ + "name", + "age", + "height" + ] +} diff --git a/urchin_config.toml b/urchin_config.toml index 2b68fe1..efdec08 100644 --- a/urchin_config.toml +++ b/urchin_config.toml @@ -1,5 +1,6 @@ # Address to the MariaDB database -database_address = "localhost" +# for local runs, change to "localhost" +database_address = "mariadb" # User to access datbaase database_user = "urchin" @@ -19,3 +20,9 @@ webserver_port = 8080 # Directory to use for storing uploaded images. image_dir = "./images" + +[[card_schema]] +schema_name = "schema1" + +[[card_schema]] +schema_name = "schema2"