feat: nearly finished

This commit is contained in:
Reinaldy Rafli 2021-07-09 19:13:19 +07:00
parent 2e7e1c8749
commit 96931551aa
27 changed files with 490 additions and 136 deletions

6
api/.gitignore vendored
View File

@ -13,3 +13,9 @@
# Dependency directories (remove the comment below to include it)
vendor/
# Environment variable
.env
# Heroku bin directory
bin

View File

@ -6,20 +6,52 @@ Still work in progress
```bash
# Install modules
$ go install
$ go mod download
# or
$ go mod vendor
# run the local server
$ go run ./
$ go run main.go
# build everything
$ go build ./
$ go build main.go
```
## Modules
## Used packages
- https://github.com/Masterminds/squirrel
- https://github.com/jackc/pgx
- https://github.com/go-redis/redis/v8
- https://github.com/unrolled/secure
| Name | Version | Type |
| --- | --- | --- |
| gofiber/fiber | v2.14.0 | Framework |
| jackc/pgx | v4.11.0 | Database |
| go-redis/redis | v8.11.0 | Cache |
| joho/godotenv | v1.3.0 | Config |
| getsentry/sentry-go | v0.11.0 | Logging |
| aldy505/phc-crypto | v1.1.0 | Utils |
| Masterminds/squirrel | v1.5.0 | Utils |
| aldy505/bob | v0.0.1 | Utils |
## Directory structure
```
└-- /app
└---- /v1
└---- /handler
└---- /middleware folder for add middleware
└---- /models
└---- /platform
└--------- /cache folder with in-memory cache setup functions
└--------- /database folder with database setup functions
└---- /routes folder for describe routes
└---- /utils folder with utility functions
```
## `.env` configuration
```ini
ENV=development
PORT=5000
DATABASE_URL=postgres://postgres:password@localhost:5432/jokesbapak2
REDIS_URL=redis://@localhost:6379
SENTRY_DSN=
```

18
api/app/v1/app.go Normal file
View File

@ -0,0 +1,18 @@
package v1
import (
"github.com/aldy505/jokes-bapak2-api/api/app/v1/routes"
"github.com/gofiber/fiber/v2"
)
func New() *fiber.App {
app := fiber.New(fiber.Config{
DisableKeepalive: true,
CaseSensitive: true,
})
routes.Health(app)
routes.Joke(app)
return app
}

View File

@ -0,0 +1,7 @@
package handler
import "github.com/gofiber/fiber/v2"
func Health(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}

View File

@ -8,7 +8,7 @@ import (
)
func AddNewJoke(c *fiber.Ctx) error {
var body models.JokePost
var body models.RequestJokePost
err := c.BodyParser(&body)
if err != nil {
return err
@ -24,7 +24,7 @@ func AddNewJoke(c *fiber.Ctx) error {
return err
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"link": body.Link,
return c.Status(fiber.StatusCreated).JSON(models.ResponseJoke{
Link: body.Link,
})
}

View File

@ -1 +1,43 @@
package handler
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/aldy505/jokes-bapak2-api/api/app/v1/models"
"github.com/gofiber/fiber/v2"
)
func DeleteJoke(c *fiber.Ctx) error {
id := c.Params("id")
// Check if the joke exists
sql, args, err := psql.Select("id").From("jokesbapak2").Where(squirrel.Eq{"id": id}).ToSql()
if err != nil {
return err
}
var jokeID string
err = db.QueryRow(context.Background(), sql, args...).Scan(&jokeID)
if err != nil {
return err
}
if jokeID == id {
sql, args, err = psql.Delete("jokesbapak2").Where(squirrel.Eq{"id": id}).ToSql()
if err != nil {
return err
}
_, err = db.Query(context.Background(), sql, args...)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(models.ResponseJoke{
Message: "specified joke id has been deleted",
})
}
return c.Status(fiber.StatusNotAcceptable).JSON(models.ResponseError{
Error: "specified joke id does not exists",
})
}

View File

@ -2,7 +2,6 @@ package handler
import (
"context"
"strconv"
"time"
"github.com/Masterminds/squirrel"
@ -38,20 +37,20 @@ func TodayJoke(c *fiber.Ctx) error {
if eq {
c.Attachment(joke.link)
return c.SendStatus(200)
return c.SendStatus(fiber.StatusOK)
} else {
var link string
err := db.QueryRow(context.Background(), "SELECT link FROM jokesbapak2 WHERE random() < 0.01 LIMIT 1").Scan(&link)
if err != nil {
return err
}
now := strconv.Itoa(int(time.Now().Unix()))
now := time.Now().UTC().Format(time.RFC3339)
err = redis.MSet(context.Background(), "today:link", link, "today:date", now).Err()
if err != nil {
return err
}
c.Attachment(link)
return c.SendStatus(200)
return c.SendStatus(fiber.StatusOK)
}
}
@ -66,7 +65,7 @@ func SingleJoke(c *fiber.Ctx) error {
return err
}
c.Attachment(link)
return c.SendStatus(200)
return c.SendStatus(fiber.StatusOK)
}
func JokeByID(c *fiber.Ctx) error {
@ -79,8 +78,8 @@ func JokeByID(c *fiber.Ctx) error {
return err
}
if link == "" {
return c.Status(404).Send([]byte("Requested ID was not found."))
return c.Status(fiber.StatusNotFound).Send([]byte("Requested ID was not found."))
}
c.Attachment(link)
return c.SendStatus(200)
return c.SendStatus(fiber.StatusOK)
}

View File

@ -1 +1,51 @@
package handler
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/aldy505/jokes-bapak2-api/api/app/v1/models"
"github.com/gofiber/fiber/v2"
)
func UpdateJoke(c *fiber.Ctx) error {
id := c.Params("id")
// Check if the joke exists
sql, args, err := psql.Select("id").From("jokesbapak2").Where(squirrel.Eq{"id": id}).ToSql()
if err != nil {
return err
}
var jokeID string
err = db.QueryRow(context.Background(), sql, args...).Scan(&jokeID)
if err != nil {
return err
}
if jokeID == id {
body := new(models.RequestJokePost)
err = c.BodyParser(&body)
if err != nil {
return err
}
sql, args, err = psql.Update("jokesbapak2").Set("link", body.Link).Set("key", body.Key).ToSql()
if err != nil {
return err
}
_, err := db.Query(context.Background(), sql, args...)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(models.ResponseJoke{
Message: "specified joke id has been updated",
Link: body.Link,
})
}
return c.Status(fiber.StatusNotAcceptable).JSON(models.ResponseError{
Error: "specified joke id does not exists",
})
}

View File

@ -15,7 +15,7 @@ var db = database.New()
func RequireAuth() fiber.Handler {
return func(c *fiber.Ctx) error {
var auth models.Auth
var auth models.RequestAuth
err := c.BodyParser(&auth)
if err != nil {
return err
@ -33,8 +33,8 @@ func RequireAuth() fiber.Handler {
}
if token == "" {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Invalid token",
return c.Status(fiber.StatusForbidden).JSON(models.ResponseError{
Error: "Invalid token",
})
}
@ -61,8 +61,9 @@ func RequireAuth() fiber.Handler {
if verify {
c.Next()
}
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Invalid key",
return c.Status(fiber.StatusForbidden).JSON(models.ResponseError{
Error: "Invalid key",
})
}
}

View File

@ -1,6 +0,0 @@
package models
type Auth struct {
Key string `json:"key"`
Token string `json:"token"`
}

View File

@ -1,6 +0,0 @@
package models
type JokePost struct {
Key string `json:"string"`
Link string `json:"link"`
}

View File

@ -0,0 +1,11 @@
package models
type RequestJokePost struct {
Key string `json:"string"`
Link string `json:"link"`
}
type RequestAuth struct {
Key string `json:"key"`
Token string `json:"token"`
}

View File

@ -0,0 +1,10 @@
package models
type ResponseError struct {
Error string `json:"error"`
}
type ResponseJoke struct {
Link string `json:"link"`
Message string `json:"message"`
}

View File

@ -5,6 +5,7 @@ import (
"os"
"github.com/go-redis/redis/v8"
_ "github.com/joho/godotenv/autoload"
)
// Connect to the database

View File

@ -5,105 +5,55 @@ import (
"log"
"strings"
sq "github.com/Masterminds/squirrel"
"github.com/aldy505/bob"
)
// Set up the table connection, create table if not exists
func Setup() error {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
db := New()
// Jokesbapak2 table & data
// Check if table exists
sql, args, err := bob.HasTable("jokesbapak2").PlaceholderFormat(bob.Dollar).ToSQL()
sql, _, err := bob.CreateTableIfNotExists("jokesbapak2").
Columns("id", "link", "key").
Types("SERIAL", "TEXT", "VARCHAR(255)").
Primary("id").ToSql()
if err != nil {
log.Fatalln("failed on checking database table:", err)
log.Fatalln("10 - failed on table creation: ", err)
}
var hasTableJokes bool
err = db.QueryRow(context.Background(), sql, args...).Scan(&hasTableJokes)
if err != nil {
if err.Error() == "no rows in result set" {
hasTableJokes = false
} else {
log.Fatalln("failed on checking database table:", err)
}
}
if !hasTableJokes {
sql, _, err = bob.CreateTable("jokesbapak2").
Columns("id", "link").
Types("SERIAL", "VARCHAR(255)").
Primary("id").ToSQL()
splitSql := strings.Split(sql, ";")
for i := range splitSql {
_, err = db.Query(context.Background(), splitSql[i])
if err != nil {
log.Fatalln("failed on table creation:", err)
}
splitSql := strings.Split(sql, ";")
for i := range splitSql {
_, err = db.Query(context.Background(), splitSql[i])
if err != nil {
log.Println(sql)
log.Fatalln("Failed on table creation: ", err)
return err
}
}
insertQuery, args, err := psql.Insert("jokesbapak2").
Columns("link").
Values("https://i.ibb.co/19pntdQ/Ea-p8-BWU8-AAtbjp.jpg").
ToSql()
if err != nil {
log.Fatalln("Failed on query creation: ", err)
return err
}
_, err = db.Query(context.Background(), insertQuery, args...)
if err != nil {
log.Fatalln("Failed on table insertion: ", err)
log.Fatalln("11 - failed on table creation: ", err)
return err
}
}
// Authorization
// Check if table exists
sql, args, err = bob.HasTable("authorization").PlaceholderFormat(bob.Dollar).ToSQL()
sql, _, err = bob.CreateTableIfNotExists("authorization").
Columns("id", "token", "key").
Types("SERIAL", "TEXT", "VARCHAR(255)").
Primary("id").
Unique("token").
ToSql()
if err != nil {
log.Fatalln("failed on checking database table:", err)
log.Fatalln("14 - failed on table creation: ", err)
return err
}
var hasTableAuth bool
err = db.QueryRow(context.Background(), sql, args...).Scan(&hasTableAuth)
if err != nil {
if err.Error() == "no rows in result set" {
hasTableAuth = false
} else {
log.Fatalln("failed on checking database table:", err)
}
}
if !hasTableAuth {
sql, _, err = bob.CreateTable("authorization").
Columns("id", "token", "key").
Types("SERIAL", "VARCHAR(255)", "VARCHAR(255)").
Primary("id").
Unique("token").
ToSQL()
splitSql = strings.Split(sql, ";")
for i := range splitSql {
_, err = db.Query(context.Background(), splitSql[i])
if err != nil {
log.Fatalln("Failed on table creation: ", err)
log.Fatalln("15 - failed on table creation: ", err)
return err
}
splitSql := strings.Split(sql, ";")
for i := range splitSql {
_, err = db.Query(context.Background(), splitSql[i])
if err != nil {
log.Fatalln("Failed on table creation: ", err)
return err
}
}
}
_, err = db.Query(context.Background(), "ALTER TABLE jokesbapak2 ADD CONSTRAINT fk_jokesbapak2_key FOREIGN KEY (key) REFERENCES authorization (id)")
if err != nil {
log.Fatalln("16 - failed on foreign key iteration: ", err)
}
return nil
}

View File

@ -6,6 +6,7 @@ import (
"os"
"github.com/jackc/pgx/v4/pgxpool"
_ "github.com/joho/godotenv/autoload"
)
// Connect to the database

View File

@ -0,0 +1,12 @@
package routes
import (
"github.com/aldy505/jokes-bapak2-api/api/app/v1/handler"
"github.com/gofiber/fiber/v2"
)
func Health(app *fiber.App) *fiber.App {
// Health check
app.Get("/", handler.Health)
return app
}

View File

@ -12,31 +12,24 @@ import (
var db = database.New()
var redis = cache.New()
func New() *fiber.App {
app := fiber.New(fiber.Config{
ETag: true,
DisableKeepalive: true,
CaseSensitive: true,
})
v1 := app.Group("/v1")
func Joke(app *fiber.App) *fiber.App {
// Single route
v1.Get("/", handler.SingleJoke)
app.Get("/", handler.SingleJoke)
// Today's joke
v1.Get("/today", handler.TodayJoke)
app.Get("/today", handler.TodayJoke)
// Joke by ID
v1.Get("/:id", handler.JokeByID)
app.Get("/:id", handler.JokeByID)
// Add new joke
v1.Put("/", middleware.RequireAuth(), handler.AddNewJoke)
app.Put("/", middleware.RequireAuth(), handler.AddNewJoke)
// Update a joke
v1.Patch("/:id")
app.Patch("/:id", middleware.RequireAuth(), handler.UpdateJoke)
// Delete a joke
v1.Delete("/:id")
app.Delete("/:id", middleware.RequireAuth(), handler.DeleteJoke)
return app
}

View File

@ -4,15 +4,13 @@ import "time"
// IsToday checks if a date is in fact today or not.
func IsToday(date string) (bool, error) {
parse, err := time.Parse(time.UnixDate, date)
parse, err := time.Parse(time.RFC3339, date)
if err != nil {
return false, err
}
now := time.Now()
y1, m1, d1 := parse.Date()
y2, m2, d2 := time.Now().Date()
if parse.Equal(now) {
return true, nil
}
return false, nil
return y1 == y2 && m1 == m2 && d1 == d2, nil
}

View File

@ -0,0 +1,30 @@
package utils_test
import (
"testing"
"time"
"github.com/aldy505/jokes-bapak2-api/api/app/v1/utils"
)
func TestIsToday(t *testing.T) {
t.Run("should be able to tell if it's today", func(t *testing.T) {
today, err := utils.IsToday(time.Now().UTC().Format(time.RFC3339))
if err != nil {
t.Error(err.Error())
}
if today == false {
t.Error("today should be true:", today)
}
})
t.Run("should be able to tell if it's not today", func(t *testing.T) {
today, err := utils.IsToday("2021-01-01T11:48:24Z")
if err != nil {
t.Error(err.Error())
}
if today == true {
t.Error("today should be false:", today)
}
})
}

33
api/app/v1/utils/parse.go Normal file
View File

@ -0,0 +1,33 @@
package utils
import (
"encoding/json"
"strconv"
)
// ParseToFormBody converts a body to form data type
func ParseToFormBody(body map[string]interface{}) ([]byte, error) {
var form string
for key, value := range body {
form += key + "="
switch v := value.(type) {
case string:
form += v
case int:
form += strconv.Itoa(v)
case bool:
form += strconv.FormatBool(v)
}
form += "&"
}
return []byte(form), nil
}
// ParseToJSONBody converts a body to json data type
func ParseToJSONBody(body map[string]interface{}) ([]byte, error) {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
return b, nil
}

View File

@ -0,0 +1,43 @@
package utils_test
import (
"testing"
"github.com/aldy505/jokes-bapak2-api/api/app/v1/utils"
)
func TestParseToJSONBody(t *testing.T) {
t.Run("should be able to parse a json string", func(t *testing.T) {
body := map[string]interface{}{
"name": "Scott",
"age": 32,
"fat": true,
}
parsed, err := utils.ParseToJSONBody(body)
if err != nil {
t.Error(err.Error())
}
result := "{\"age\":32,\"fat\":true,\"name\":\"Scott\"}"
if string(parsed) != result {
t.Error("parsed string is not the same as result:", string(parsed))
}
})
}
func TestParseToFormBody(t *testing.T) {
t.Run("should be able to parse a form body", func(t *testing.T) {
body := map[string]interface{}{
"name": "Scott",
"age": 32,
"fat": true,
}
parsed, err := utils.ParseToFormBody(body)
if err != nil {
t.Error(err.Error())
}
result := "name=Scott&age=32&fat=true&"
if string(parsed) != result {
t.Error("parsed string is not the same as result:", string(parsed))
}
})
}

View File

@ -0,0 +1,55 @@
package utils
import (
"bytes"
"net/http"
)
type ContentType int
const (
JSON ContentType = iota
Form
)
type RequestConfig struct {
URL string
Method string
Headers map[string]interface{}
Body map[string]interface{}
ContentType ContentType
}
// Request is a simple wrapper around http.NewRequest
func Request(config RequestConfig) (response *http.Response, err error) {
client := &http.Client{}
var body []byte
if config.ContentType == JSON {
parsed, err := ParseToJSONBody(config.Body)
if err != nil {
return &http.Response{}, err
}
body = parsed
} else if config.ContentType == Form {
parsed, err := ParseToFormBody(config.Body)
if err != nil {
return &http.Response{}, err
}
body = parsed
}
request, err := http.NewRequest(config.Method, config.URL, bytes.NewReader(body))
if err != nil {
return
}
response, err = client.Do(request)
if err != nil {
return
}
return
}

View File

@ -0,0 +1,27 @@
package utils_test
import (
"net/http"
"testing"
"github.com/aldy505/jokes-bapak2-api/api/app/v1/utils"
)
func TestRequest(t *testing.T) {
t.Run("should be able to do a get request", func(t *testing.T) {
res, err := utils.Request(utils.RequestConfig{
URL: "https://jsonplaceholder.typicode.com/todos/1",
Method: http.MethodGet,
Headers: map[string]interface{}{
"User-Agent": "Jokesbapak2 Test API",
"Accept": "application/json",
},
})
if err != nil {
t.Error(err.Error())
}
if res.StatusCode != 200 {
t.Error("response does not have 200 status", res.Status)
}
})
}

View File

@ -4,10 +4,11 @@ go 1.16
require (
github.com/Masterminds/squirrel v1.5.0
github.com/aldy505/bob v0.0.0-20210630160113-75547d606a54
github.com/aldy505/bob v0.0.1
github.com/aldy505/phc-crypto v1.1.0
github.com/getsentry/sentry-go v0.11.0
github.com/go-redis/redis/v8 v8.11.0
github.com/gofiber/fiber/v2 v2.14.0
github.com/jackc/pgx/v4 v4.11.0
github.com/joho/godotenv v1.3.0
)

View File

@ -17,6 +17,8 @@ github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/aldy505/bob v0.0.0-20210630160113-75547d606a54 h1:U9GFaqa00Ft1kqmUxoNz+CugEmXGdX6bz2L+O/dbB/8=
github.com/aldy505/bob v0.0.0-20210630160113-75547d606a54/go.mod h1:/8HuD17XXgzuaFw5j4oDyB8O+NlW8mKWd0QCCbeoLVE=
github.com/aldy505/bob v0.0.1 h1:L/nvD9+ViLJaWbgbeBes/4xQfz7YtQLJtk8OjSa9L2k=
github.com/aldy505/bob v0.0.1/go.mod h1:/8HuD17XXgzuaFw5j4oDyB8O+NlW8mKWd0QCCbeoLVE=
github.com/aldy505/phc-crypto v1.1.0 h1:BagRKCrB7FOYy5vnuXR6xs6ml2gJD/CvSJkX/Ozo63w=
github.com/aldy505/phc-crypto v1.1.0/go.mod h1:LJugClOkOWKnpLrWhSaIDRN/5ftvZPD48S5oXsT7iTg=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@ -243,6 +245,8 @@ github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv
github.com/jackc/puddle v1.1.3 h1:JnPg/5Q9xVJGfjsO5CPUOjnJps1JaRUm8I9FXVCFK94=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=

View File

@ -3,16 +3,18 @@ package main
import (
"log"
"os"
"os/signal"
"time"
v1 "github.com/aldy505/jokes-bapak2-api/api/app/v1"
"github.com/aldy505/jokes-bapak2-api/api/app/v1/platform/database"
"github.com/aldy505/jokes-bapak2-api/api/app/v1/routes"
"github.com/getsentry/sentry-go"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/etag"
"github.com/gofiber/fiber/v2/middleware/limiter"
_ "github.com/joho/godotenv/autoload"
)
func main() {
@ -45,14 +47,54 @@ func main() {
app.Use(limiter.New())
app.Use(etag.New())
app.Mount("/", routes.New())
app.Mount("/v1", v1.New())
log.Fatal(app.Listen(":" + os.Getenv("PORT")))
// Start server (with or without graceful shutdown).
if os.Getenv("ENV") == "development" {
StartServer(app)
} else {
StartServerWithGracefulShutdown(app)
}
}
func errorHandler(c *fiber.Ctx, err error) error {
sentry.CaptureException(err)
return c.Status(500).JSON(fiber.Map{
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Something went wrong on our end",
})
}
// StartServerWithGracefulShutdown function for starting server with a graceful shutdown.
func StartServerWithGracefulShutdown(a *fiber.App) {
// Create channel for idle connections.
idleConnsClosed := make(chan struct{})
go func() {
sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt) // Catch OS signals.
<-sigint
// Received an interrupt signal, shutdown.
if err := a.Shutdown(); err != nil {
// Error from closing listeners, or context timeout:
log.Printf("Oops... Server is not shutting down! Reason: %v", err)
}
close(idleConnsClosed)
}()
// Run server.
if err := a.Listen(":" + os.Getenv("PORT")); err != nil {
log.Printf("Oops... Server is not running! Reason: %v", err)
}
<-idleConnsClosed
}
// StartServer func for starting a simple server.
func StartServer(a *fiber.App) {
// Run server.
if err := a.Listen(":" + os.Getenv("PORT")); err != nil {
log.Printf("Oops... Server is not running! Reason: %v", err)
}
}