Merge pull request #9 from aldy505/api/refactor

Refactor API and better SQL connection pooling
This commit is contained in:
Reinaldy Rafli 2021-11-08 23:46:24 +07:00 committed by GitHub
commit 9175fc2ea3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
92 changed files with 2982 additions and 1671 deletions

View File

@ -8,7 +8,8 @@ jobs:
api-build:
name: Build
runs-on: ubuntu-latest
container: golang:1.16.6
container: golang:1.17
timeout-minutes: 15
services:
postgres:
image: postgres:13-alpine
@ -54,7 +55,7 @@ jobs:
run: go build main.go
- name: Run test & coverage
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
run: go test -v -coverprofile=coverage.out -covermode=atomic ./...
env:
ENV: development
PORT: 5000

View File

@ -8,7 +8,7 @@ jobs:
client-build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ./client

View File

@ -8,7 +8,7 @@ jobs:
client-build:
name: Client
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ./client
@ -62,7 +62,8 @@ jobs:
api-build:
name: API
runs-on: ubuntu-latest
container: golang:1.16.6
container: golang:1.17
timeout-minutes: 15
services:
postgres:
image: postgres:13-alpine
@ -108,7 +109,7 @@ jobs:
run: go build main.go
- name: Run test & coverage
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
run: go test -v -coverprofile=coverage.out -covermode=atomic ./...
env:
ENV: development
PORT: 5000

View File

@ -24,10 +24,10 @@ This is some kind of [icanhazdadjokes](https://icanhazdadjoke.com/) but it's Ind
You can consume this API via a website (linked in the front facing web) with a few endpoints:
* `/v1/` - Random jokes bapak2
* `/v1/id/{number}` - Jokes bapak2 based on ID
* `/v1/today` - Jokes bapak2 of the day
* `/v1/total` - Total available jokes bapak2
* `/` - Random jokes bapak2
* `/id/{number}` - Jokes bapak2 based on ID
* `/today` - Jokes bapak2 of the day
* `/total` - Total available jokes bapak2
Currently I'm (still) searching for an alternative for AWS S3 that I can use for free.
@ -54,25 +54,26 @@ See [CONTRIBUTING](./CONTRIBUTING.md) or README files on each project directory
* [Ronny Gunawan](https://github.com/ronnygunawan) for the caching concept & ideas
* [artileda](https://github.com/artileda) for the jokes submission
* [elianiva](https://github.com/elianiva) for solving my SvelteKit problems
* [kokizzu](https://github.com/kokizzu) for the dependency injection concept & ideas
## License
Jokes Bapak2 API is licensed under [GNU GENERAL PUBLIC LICENSE v3 license](./LICENSE)
```
Jokes Bapak2 API is a free-to-use image API of Indonesian dad jokes.
Copyright (C) 2021-present Jokes Bapak2 Contributors
Jokes Bapak2 API is a free-to-use image API of Indonesian dad jokes.
Copyright (C) 2021-present Jokes Bapak2 Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
```

View File

@ -1,4 +1,4 @@
FROM golang:1.16.6-buster
FROM golang:1.17-buster
WORKDIR /app

View File

@ -1,73 +0,0 @@
package v1
import (
"jokes-bapak2-api/app/v1/core"
"jokes-bapak2-api/app/v1/platform/cache"
"jokes-bapak2-api/app/v1/platform/database"
"jokes-bapak2-api/app/v1/routes"
"log"
"os"
"time"
"github.com/getsentry/sentry-go"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/etag"
)
var memory = cache.InMemory()
var db = database.New()
func New() *fiber.App {
app := fiber.New(fiber.Config{
DisableKeepalive: true,
CaseSensitive: true,
ErrorHandler: errorHandler,
})
err := sentry.Init(sentry.ClientOptions{
Dsn: os.Getenv("SENTRY_DSN"),
Environment: os.Getenv("ENV"),
AttachStacktrace: true,
// Enable printing of SDK debug messages.
// Useful when getting started or trying to figure something out.
Debug: true,
})
if err != nil {
log.Fatal(err)
}
defer sentry.Flush(2 * time.Second)
err = database.Setup()
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
err = core.SetAllJSONJoke(db, memory)
if err != nil {
log.Fatalln(err)
}
err = core.SetTotalJoke(db, memory)
if err != nil {
log.Fatalln(err)
}
app.Use(cors.New())
app.Use(etag.New())
routes.Health(app)
routes.Joke(app)
routes.Submit(app)
return app
}
func errorHandler(c *fiber.Ctx, err error) error {
log.Println(err)
sentry.CaptureException(err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Something went wrong on our end",
})
}

View File

@ -1,55 +0,0 @@
package core
import (
"jokes-bapak2-api/app/v1/models"
"github.com/allegro/bigcache/v3"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pquerna/ffjson/ffjson"
)
// SetAllJSONJoke fetches jokes data from GetAllJSONJokes then set it to memory cache.
func SetAllJSONJoke(db *pgxpool.Pool, memory *bigcache.BigCache) error {
jokes, err := GetAllJSONJokes(db)
if err != nil {
return err
}
err = memory.Set("jokes", jokes)
if err != nil {
return err
}
return nil
}
func SetTotalJoke(db *pgxpool.Pool, memory *bigcache.BigCache) error {
check, err := CheckJokesCache(memory)
if err != nil {
return err
}
if !check {
err = SetAllJSONJoke(db, memory)
if err != nil {
return err
}
}
jokes, err := memory.Get("jokes")
if err != nil {
return err
}
var data []models.Joke
err = ffjson.Unmarshal(jokes, &data)
if err != nil {
return err
}
var total = []byte{byte(len(data))}
err = memory.Set("total", total)
if err != nil {
return err
}
return nil
}

View File

@ -1,16 +0,0 @@
package handler
import (
"jokes-bapak2-api/app/v1/platform/cache"
"jokes-bapak2-api/app/v1/platform/database"
"time"
"github.com/Masterminds/squirrel"
"github.com/gojek/heimdall/v7/httpclient"
)
var Psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
var Db = database.New()
var Redis = cache.New()
var Memory = cache.InMemory()
var Client = httpclient.NewClient(httpclient.WithHTTPTimeout(10 * time.Second))

View File

@ -1,31 +0,0 @@
package health
import (
"context"
"jokes-bapak2-api/app/v1/handler"
"jokes-bapak2-api/app/v1/models"
"github.com/gofiber/fiber/v2"
)
func Health(c *fiber.Ctx) error {
// Ping REDIS database
err := handler.Redis.Ping(context.Background()).Err()
if err != nil {
return c.
Status(fiber.StatusServiceUnavailable).
JSON(models.Error{
Error: "REDIS: " + err.Error(),
})
}
_, err = handler.Db.Query(context.Background(), "SELECT \"id\" FROM \"jokesbapak2\" LIMIT 1")
if err != nil {
return c.
Status(fiber.StatusServiceUnavailable).
JSON(models.Error{
Error: "POSTGRESQL: " + err.Error(),
})
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -1,72 +0,0 @@
package health_test
import (
"context"
"io/ioutil"
v1 "jokes-bapak2-api/app/v1"
"jokes-bapak2-api/app/v1/platform/database"
"net/http"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v4/pgxpool"
_ "github.com/joho/godotenv/autoload"
"github.com/stretchr/testify/assert"
)
var db *pgxpool.Pool = database.New()
var jokesData = []interface{}{1, "https://via.placeholder.com/300/06f/fff.png", 1, 2, "https://via.placeholder.com/300/07f/fff.png", 1, 3, "https://via.placeholder.com/300/08f/fff.png", 1}
var app *fiber.App = v1.New()
func cleanup() {
j, err := db.Query(context.Background(), "DROP TABLE \"jokesbapak2\"")
if err != nil {
panic(err)
}
a, err := db.Query(context.Background(), "DROP TABLE \"administrators\"")
if err != nil {
panic(err)
}
defer j.Close()
defer a.Close()
}
func setup() error {
err := database.Setup()
if err != nil {
return err
}
a, err := db.Query(context.Background(), "INSERT INTO \"administrators\" (id, key, token, last_used) VALUES ($1, $2, $3, $4);", 1, "very secure", "not the real one", time.Now().Format(time.RFC3339))
if err != nil {
return err
}
j, err := db.Query(context.Background(), "INSERT INTO \"jokesbapak2\" (id, link, creator) VALUES ($1, $2, $3), ($4, $5, $6), ($7, $8, $9);", jokesData...)
if err != nil {
return err
}
defer a.Close()
defer j.Close()
return nil
}
func TestHealth(t *testing.T) {
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
req, _ := http.NewRequest("GET", "/health", nil)
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "health")
assert.Equalf(t, 200, res.StatusCode, "health")
assert.NotEqualf(t, 0, res.ContentLength, "health")
_, err = ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "health")
}

View File

@ -1,65 +0,0 @@
package joke
import (
"context"
"jokes-bapak2-api/app/v1/core"
"jokes-bapak2-api/app/v1/handler"
"jokes-bapak2-api/app/v1/models"
"github.com/gofiber/fiber/v2"
)
func AddNewJoke(c *fiber.Ctx) error {
var body models.Joke
err := c.BodyParser(&body)
if err != nil {
return err
}
// Check link validity
valid, err := core.CheckImageValidity(handler.Client, body.Link)
if err != nil {
return err
}
if !valid {
return c.
Status(fiber.StatusBadRequest).
JSON(models.Error{
Error: "URL provided is not a valid image",
})
}
sql, args, err := handler.Psql.
Insert("jokesbapak2").
Columns("link", "creator").
Values(body.Link, c.Locals("userID")).
ToSql()
if err != nil {
return err
}
// TODO: Implement solution if the link provided already exists.
r, err := handler.Db.Query(context.Background(), sql, args...)
if err != nil {
return err
}
defer r.Close()
err = core.SetAllJSONJoke(handler.Db, handler.Memory)
if err != nil {
return err
}
err = core.SetTotalJoke(handler.Db, handler.Memory)
if err != nil {
return err
}
return c.
Status(fiber.StatusCreated).
JSON(models.ResponseJoke{
Link: body.Link,
})
}

View File

@ -1,57 +0,0 @@
package joke_test
import (
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAddNewJoke_201(t *testing.T) {
// TODO: Remove this line below, make this test works
t.SkipNow()
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
reqBody := strings.NewReader("{\"link\":\"https://via.placeholder.com/300/07f/ff0000.png\",\"key\":\"test\",\"token\":\"password\"}")
req, _ := http.NewRequest("PUT", "/", reqBody)
req.Header.Set("content-type", "application/json")
req.Header.Add("accept", "application/json")
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke add")
assert.Equalf(t, 201, res.StatusCode, "joke add")
assert.NotEqualf(t, 0, res.ContentLength, "joke add")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke add")
assert.Equalf(t, "{\"link\":\"https://via.placeholder.com/300/07f/ff0000.png\"}", string(body), "joke add")
}
func TestAddNewJoke_NotValidImage(t *testing.T) {
// TODO: Remove this line below, make this test works
t.SkipNow()
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
reqBody := strings.NewReader("{\"link\":\"https://google.com/\",\"key\":\"test\",\"token\":\"password\"}")
req, _ := http.NewRequest("PUT", "/", reqBody)
req.Header.Set("content-type", "application/json")
req.Header.Add("accept", "application/json")
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke add")
assert.Equalf(t, 400, res.StatusCode, "joke add")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke add")
assert.Equalf(t, "{\"error\":\"URL provided is not a valid image\"}", string(body), "joke add")
}

View File

@ -1,73 +0,0 @@
package joke
import (
"context"
"strconv"
"jokes-bapak2-api/app/v1/core"
"jokes-bapak2-api/app/v1/handler"
"jokes-bapak2-api/app/v1/models"
"github.com/Masterminds/squirrel"
"github.com/gofiber/fiber/v2"
)
func DeleteJoke(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return err
}
// Check if the joke exists
sql, args, err := handler.Psql.
Select("id").
From("jokesbapak2").
Where(squirrel.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
var jokeID int
err = handler.Db.QueryRow(context.Background(), sql, args...).Scan(&jokeID)
if err != nil {
return err
}
if jokeID == id {
sql, args, err = handler.Psql.
Delete("jokesbapak2").
Where(squirrel.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
r, err := handler.Db.Query(context.Background(), sql, args...)
if err != nil {
return err
}
defer r.Close()
err = core.SetAllJSONJoke(handler.Db, handler.Memory)
if err != nil {
return err
}
err = core.SetTotalJoke(handler.Db, handler.Memory)
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.Error{
Error: "specified joke id does not exists",
})
}

View File

@ -1,64 +0,0 @@
package joke_test
import (
"context"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDeleteJoke_200(t *testing.T) {
// TODO: Remove this line below, make this test works
t.SkipNow()
err := setup()
if err != nil {
t.Fatal(err)
}
j, err := db.Query(context.Background(), "INSERT INTO \"jokesbapak2\" (id, link, creator) VALUES ($1, $2, $3);", 100, "https://via.placeholder.com/300/01f/fff.png", 1)
if err != nil {
t.Fatal(err)
}
defer j.Close()
defer cleanup()
reqBody := strings.NewReader("{\"key\":\"very secure\",\"token\":\"password\"}")
req, _ := http.NewRequest("DELETE", "/id/100", reqBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke delete")
assert.Equalf(t, 200, res.StatusCode, "joke delete")
assert.NotEqualf(t, 0, res.ContentLength, "joke delete")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke delete")
assert.Equalf(t, "{\"message\":\"specified joke id has been deleted\"}", string(body), "joke delete")
}
func TestDeleteJoke_NotExists(t *testing.T) {
// TODO: Remove this line below, make this test works
t.SkipNow()
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
reqBody := strings.NewReader("{\"key\":\"very secure\",\"token\":\"password\"}")
req, _ := http.NewRequest("DELETE", "/id/100", reqBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke delete")
assert.Equalf(t, 406, res.StatusCode, "joke delete")
assert.NotEqualf(t, 0, res.ContentLength, "joke delete")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke delete")
assert.Equalf(t, "{\"message\":\"specified joke id does not exists\"}", string(body), "joke delete")
}

View File

@ -1,153 +0,0 @@
package joke
import (
"context"
"io/ioutil"
"strconv"
"time"
"jokes-bapak2-api/app/v1/core"
"jokes-bapak2-api/app/v1/handler"
"jokes-bapak2-api/app/v1/models"
"jokes-bapak2-api/app/v1/utils"
"github.com/gofiber/fiber/v2"
)
func TodayJoke(c *fiber.Ctx) error {
// check from handler.Redis if today's joke already exists
// send the joke if exists
// get a new joke if it's not, then send it.
var joke models.Today
err := handler.Redis.MGet(context.Background(), "today:link", "today:date", "today:image", "today:contentType").Scan(&joke)
if err != nil {
return err
}
eq, err := utils.IsToday(joke.Date)
if err != nil {
return err
}
if eq {
c.Set("Content-Type", joke.ContentType)
return c.Status(fiber.StatusOK).Send([]byte(joke.Image))
} else {
var link string
err := handler.Db.QueryRow(context.Background(), "SELECT link FROM jokesbapak2 ORDER BY random() LIMIT 1").Scan(&link)
if err != nil {
return err
}
response, err := handler.Client.Get(link, nil)
if err != nil {
return err
}
data, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
err = handler.Redis.MSet(context.Background(), map[string]interface{}{
"today:link": link,
"today:date": now,
"today:image": string(data),
"today:contentType": response.Header.Get("content-type"),
}).Err()
if err != nil {
return err
}
c.Set("Content-Type", response.Header.Get("content-type"))
return c.Status(fiber.StatusOK).Send(data)
}
}
func SingleJoke(c *fiber.Ctx) error {
checkCache, err := core.CheckJokesCache(handler.Memory)
if err != nil {
return err
}
if !checkCache {
jokes, err := core.GetAllJSONJokes(handler.Db)
if err != nil {
return err
}
err = handler.Memory.Set("jokes", jokes)
if err != nil {
return err
}
}
link, err := core.GetRandomJokeFromCache(handler.Memory)
if err != nil {
return err
}
// Get image data
response, err := handler.Client.Get(link, nil)
if err != nil {
return err
}
data, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
c.Set("Content-Type", response.Header.Get("content-type"))
return c.Status(fiber.StatusOK).Send(data)
}
func JokeByID(c *fiber.Ctx) error {
checkCache, err := core.CheckJokesCache(handler.Memory)
if err != nil {
return err
}
if !checkCache {
jokes, err := core.GetAllJSONJokes(handler.Db)
if err != nil {
return err
}
err = handler.Memory.Set("jokes", jokes)
if err != nil {
return err
}
}
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return err
}
link, err := core.GetCachedJokeByID(handler.Memory, id)
if err != nil {
return err
}
if link == "" {
return c.
Status(fiber.StatusNotFound).
Send([]byte("Requested ID was not found."))
}
// Get image data
response, err := handler.Client.Get(link, nil)
if err != nil {
return err
}
data, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
c.Set("Content-Type", response.Header.Get("content-type"))
return c.Status(fiber.StatusOK).Send(data)
}

View File

@ -1,131 +0,0 @@
package joke_test
import (
"context"
"io/ioutil"
"net/http"
"testing"
v1 "jokes-bapak2-api/app/v1"
"jokes-bapak2-api/app/v1/platform/database"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v4/pgxpool"
_ "github.com/joho/godotenv/autoload"
"github.com/stretchr/testify/assert"
)
var db *pgxpool.Pool = database.New()
var jokesData = []interface{}{1, "https://via.placeholder.com/300/06f/fff.png", 1, 2, "https://via.placeholder.com/300/07f/fff.png", 1, 3, "https://via.placeholder.com/300/08f/fff.png", 1}
var app *fiber.App = v1.New()
func cleanup() {
j, err := db.Query(context.Background(), "DROP TABLE \"jokesbapak2\"")
if err != nil {
panic(err)
}
a, err := db.Query(context.Background(), "DROP TABLE \"administrators\"")
if err != nil {
panic(err)
}
defer j.Close()
defer a.Close()
}
func setup() error {
err := database.Setup()
if err != nil {
return err
}
a, err := db.Query(context.Background(), "INSERT INTO \"administrators\" (\"id\", \"key\", \"token\", \"last_used\") VALUES (1, 'test', '$argon2id$v=19$m=65536,t=16,p=4$3a08c79fbf2222467a623df9a9ebf75802c65a4f9be36eb1df2f5d2052d53cb7$ce434bd38f7ba1fc1f2eb773afb8a1f7f2dad49140803ac6cb9d7256ce9826fb3b4afa1e2488da511c852fc6c33a76d5657eba6298a8e49d617b9972645b7106', '');")
if err != nil {
return err
}
defer a.Close()
j, err := db.Query(context.Background(), "INSERT INTO \"jokesbapak2\" (id, link, creator) VALUES ($1, $2, $3), ($4, $5, $6), ($7, $8, $9);", jokesData...)
if err != nil {
return err
}
defer j.Close()
return nil
}
/// Need to find some workaround for this test
func TestTodayJoke(t *testing.T) {
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
req, _ := http.NewRequest("GET", "/today", nil)
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "today joke")
assert.Equalf(t, 200, res.StatusCode, "today joke")
assert.NotEqualf(t, 0, res.ContentLength, "today joke")
_, err = ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "today joke")
}
func TestSingleJoke(t *testing.T) {
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
req, _ := http.NewRequest("GET", "/", nil)
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "single joke")
assert.Equalf(t, 200, res.StatusCode, "single joke")
assert.NotEqualf(t, 0, res.ContentLength, "single joke")
_, err = ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "single joke")
}
func TestJokeByID_200(t *testing.T) {
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
req, _ := http.NewRequest("GET", "/id/1", nil)
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke by id")
assert.Equalf(t, 200, res.StatusCode, "joke by id")
assert.NotEqualf(t, 0, res.ContentLength, "joke by id")
_, err = ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke by id")
}
func TestJokeByID_404(t *testing.T) {
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
req, _ := http.NewRequest("GET", "/id/300", nil)
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke by id")
assert.Equalf(t, 404, res.StatusCode, "joke by id")
assert.NotEqualf(t, 0, res.ContentLength, "joke by id")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke by id")
assert.Equalf(t, "Requested ID was not found.", string(body), "joke by id")
}

View File

@ -1,43 +0,0 @@
package joke
import (
"jokes-bapak2-api/app/v1/core"
"jokes-bapak2-api/app/v1/handler"
"jokes-bapak2-api/app/v1/models"
"strconv"
"github.com/gofiber/fiber/v2"
)
func TotalJokes(c *fiber.Ctx) error {
checkTotal, err := core.CheckTotalJokesCache(handler.Memory)
if err != nil {
return err
}
if !checkTotal {
err = core.SetTotalJoke(handler.Db, handler.Memory)
if err != nil {
return err
}
}
total, err := handler.Memory.Get("total")
if err != nil {
if err.Error() == "Entry not found" {
return c.
Status(fiber.StatusInternalServerError).
JSON(models.Error{
Error: "no data found",
})
}
return err
}
return c.
Status(fiber.StatusOK).
JSON(models.ResponseJoke{
Message: strconv.Itoa(int(total[0])),
})
}

View File

@ -1,30 +0,0 @@
package joke_test
import (
"io/ioutil"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTotalJokes(t *testing.T) {
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
req, _ := http.NewRequest("GET", "/total", nil)
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke total")
assert.Equalf(t, 200, res.StatusCode, "joke total")
assert.NotEqualf(t, 0, res.ContentLength, "joke total")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke total")
// FIXME: This should be "message": "3", not one. I don't know what's wrong as it's 1 AM.
assert.Equalf(t, "{\"message\":\"3\"}", string(body), "joke total")
}

View File

@ -1,91 +0,0 @@
package joke
import (
"context"
"jokes-bapak2-api/app/v1/core"
"jokes-bapak2-api/app/v1/handler"
"jokes-bapak2-api/app/v1/models"
"github.com/Masterminds/squirrel"
"github.com/gofiber/fiber/v2"
)
func UpdateJoke(c *fiber.Ctx) error {
id := c.Params("id")
// Check if the joke exists
sql, args, err := handler.Psql.
Select("id").
From("jokesbapak2").
Where(squirrel.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
var jokeID string
err = handler.Db.QueryRow(context.Background(), sql, args...).Scan(&jokeID)
if err != nil && err != models.ErrNoRows {
return err
}
if jokeID == id {
body := new(models.Joke)
err = c.BodyParser(&body)
if err != nil {
return err
}
// Check link validity
valid, err := core.CheckImageValidity(handler.Client, body.Link)
if err != nil {
return err
}
if !valid {
return c.
Status(fiber.StatusBadRequest).
JSON(models.Error{
Error: "URL provided is not a valid image",
})
}
sql, args, err = handler.Psql.
Update("jokesbapak2").
Set("link", body.Link).
Set("creator", c.Locals("userID")).
ToSql()
if err != nil {
return err
}
r, err := handler.Db.Query(context.Background(), sql, args...)
if err != nil {
return err
}
defer r.Close()
err = core.SetAllJSONJoke(handler.Db, handler.Memory)
if err != nil {
return err
}
err = core.SetTotalJoke(handler.Db, handler.Memory)
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.Error{
Error: "specified joke id does not exists",
})
}

View File

@ -1,55 +0,0 @@
package joke_test
import (
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUpdateJoke_200(t *testing.T) {
t.SkipNow()
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
reqBody := strings.NewReader("{\"link\":\"https://picsum.photos/id/9/200/300\",\"key\":\"test\",\"token\":\"password\"}")
req, _ := http.NewRequest("PATCH", "/id/1", reqBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke update")
assert.Equalf(t, 200, res.StatusCode, "joke update")
assert.NotEqualf(t, 0, res.ContentLength, "joke update")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke update")
assert.Equalf(t, "{\"message\":\"specified joke id has been deleted\"}", string(body), "joke update")
}
func TestUpdateJoke_NotExists(t *testing.T) {
// TODO: Remove this line below, make this test works
t.SkipNow()
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
reqBody := strings.NewReader("{\"link\":\"https://picsum.photos/id/9/200/300\",\"key\":\"test\",\"token\":\"password\"}")
req, _ := http.NewRequest("PATCH", "/id/100", reqBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "joke update")
assert.Equalf(t, 406, res.StatusCode, "joke update")
assert.NotEqualf(t, 0, res.ContentLength, "joke update")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "joke update")
assert.Equalf(t, "{\"message\":\"specified joke id does not exists\"}", string(body), "joke update")
}

View File

@ -1,101 +0,0 @@
package submit
import (
"context"
"jokes-bapak2-api/app/v1/core"
"jokes-bapak2-api/app/v1/handler"
"jokes-bapak2-api/app/v1/models"
"strings"
"time"
"github.com/georgysavva/scany/pgxscan"
"github.com/gofiber/fiber/v2"
)
func SubmitJoke(c *fiber.Ctx) error {
var body models.Submission
err := c.BodyParser(&body)
if err != nil {
return err
}
// Image and/or Link should not be empty
if body.Image == "" && body.Link == "" {
return c.Status(fiber.StatusBadRequest).JSON(models.Error{
Error: "a link or an image should be supplied in a form of multipart/form-data",
})
}
// Author should be supplied
if body.Author == "" {
return c.Status(fiber.StatusBadRequest).JSON(models.Error{
Error: "an author key consisting on the format \"yourname <youremail@mail>\" must be supplied",
})
} else {
// Validate format
valid := core.ValidateAuthor(body.Author)
if !valid {
return c.Status(fiber.StatusBadRequest).JSON(models.Error{
Error: "please stick to the format of \"yourname <youremail@mail>\" and within 200 characters",
})
}
}
var url string
// Check link validity if link was provided
if body.Link != "" {
valid, err := core.CheckImageValidity(handler.Client, body.Link)
if err != nil {
return err
}
if !valid {
return c.Status(fiber.StatusBadRequest).JSON(models.Error{
Error: "URL provided is not a valid image",
})
}
url = body.Link
}
// If image was provided
if body.Image != "" {
image := strings.NewReader(body.Image)
url, err = core.UploadImage(handler.Client, image)
if err != nil {
return err
}
}
now := time.Now().UTC().Format(time.RFC3339)
sql, args, err := handler.Psql.
Insert("submission").
Columns("link", "created_at", "author").
Values(url, now, body.Author).
Suffix("RETURNING id,created_at,link,author,status").
ToSql()
if err != nil {
return err
}
var submission []models.Submission
result, err := handler.Db.Query(context.Background(), sql, args...)
if err != nil {
return err
}
defer result.Close()
err = pgxscan.ScanAll(&submission, result)
if err != nil {
return err
}
return c.
Status(fiber.StatusCreated).
JSON(models.ResponseSubmission{
Message: "Joke submitted. Please wait for a few days for admin to approve your submission.",
Data: submission[0],
})
}

View File

@ -1,104 +0,0 @@
package submit
import (
"bytes"
"context"
"jokes-bapak2-api/app/v1/handler"
"jokes-bapak2-api/app/v1/models"
"log"
"strconv"
"github.com/aldy505/bob"
"github.com/georgysavva/scany/pgxscan"
"github.com/gofiber/fiber/v2"
)
func GetSubmission(c *fiber.Ctx) error {
query := new(models.SubmissionQuery)
err := c.QueryParser(query)
if err != nil {
return err
}
var limit int
var offset int
var approved bool
if query.Limit != "" {
limit, err = strconv.Atoi(query.Limit)
if err != nil {
return err
}
}
if query.Page != "" {
page, err := strconv.Atoi(query.Page)
if err != nil {
return err
}
offset = (page - 1) * 20
}
if query.Approved != "" {
approved, err = strconv.ParseBool(query.Approved)
if err != nil {
return err
}
}
var status int
if approved {
status = 1
} else {
status = 0
}
var sql string
var args []interface{}
var sqlQuery *bytes.Buffer = &bytes.Buffer{}
sqlQuery.WriteString("SELECT * FROM submission WHERE TRUE")
if query.Author != "" {
sqlQuery.WriteString(" AND author = ?")
args = append(args, query.Author)
}
if query.Approved != "" {
sqlQuery.WriteString(" AND status = ?")
args = append(args, status)
}
if limit > 0 {
sqlQuery.WriteString(" LIMIT " + strconv.Itoa(limit))
} else {
sqlQuery.WriteString(" LIMIT 20")
}
if query.Page != "" {
sqlQuery.WriteString(" OFFSET " + strconv.Itoa(offset))
}
sql = bob.ReplacePlaceholder(sqlQuery.String(), bob.Dollar)
var submissions []models.Submission
results, err := handler.Db.Query(context.Background(), sql, args...)
if err != nil {
log.Println(err)
return err
}
defer results.Close()
err = pgxscan.ScanAll(&submissions, results)
if err != nil {
return err
}
return c.
Status(fiber.StatusOK).
JSON(fiber.Map{
"count": len(submissions),
"jokes": submissions,
})
}

View File

@ -1,79 +0,0 @@
package submit_test
import (
"context"
"io/ioutil"
v1 "jokes-bapak2-api/app/v1"
"jokes-bapak2-api/app/v1/platform/database"
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v4/pgxpool"
_ "github.com/joho/godotenv/autoload"
"github.com/stretchr/testify/assert"
)
var db *pgxpool.Pool = database.New()
var submissionData = []interface{}{1, "https://via.placeholder.com/300/01f/fff.png", "2021-08-03T18:20:38Z", "Test <test@example.com>", 0, 2, "https://via.placeholder.com/300/02f/fff.png", "2021-08-04T18:20:38Z", "Test <test@example.com>", 1}
var app *fiber.App = v1.New()
func cleanup() {
s, err := db.Query(context.Background(), "DROP TABLE \"submission\"")
if err != nil {
panic(err)
}
defer s.Close()
}
func setup() error {
err := database.Setup()
if err != nil {
return err
}
s, err := db.Query(context.Background(), "INSERT INTO \"submission\" (id, link, created_at, author, status) VALUES ($1, $2, $3, $4, $5), ($6, $7, $8, $9, $10);", submissionData...)
if err != nil {
return err
}
defer s.Close()
return nil
}
func TestGetSubmission_200(t *testing.T) {
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
req, _ := http.NewRequest("GET", "/submit", nil)
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "get submission")
assert.Equalf(t, 200, res.StatusCode, "get submission")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "get submission")
assert.Equalf(t, "{\"count\":2,\"jokes\":[{\"id\":1,\"link\":\"https://via.placeholder.com/300/01f/fff.png\",\"created_at\":\"2021-08-03T18:20:38Z\",\"author\":\"Test \\u003ctest@example.com\\u003e\",\"status\":0},{\"id\":2,\"link\":\"https://via.placeholder.com/300/02f/fff.png\",\"created_at\":\"2021-08-04T18:20:38Z\",\"author\":\"Test \\u003ctest@example.com\\u003e\",\"status\":1}]}", string(body), "get submission")
}
func TestGetSubmission_Params(t *testing.T) {
err := setup()
if err != nil {
t.Fatal(err)
}
defer cleanup()
req, _ := http.NewRequest("GET", "/submit?page=1&limit=5&approved=true", nil)
res, err := app.Test(req, -1)
assert.Equalf(t, false, err != nil, "get submission")
assert.Equalf(t, 200, res.StatusCode, "get submission")
body, err := ioutil.ReadAll(res.Body)
assert.Nilf(t, err, "get submission")
assert.Equalf(t, "{\"count\":1,\"jokes\":[{\"id\":2,\"link\":\"https://via.placeholder.com/300/02f/fff.png\",\"created_at\":\"2021-08-04T18:20:38Z\",\"author\":\"Test \\u003ctest@example.com\\u003e\",\"status\":1}]}", string(body), "get submission")
}

View File

@ -1,97 +0,0 @@
package middleware
import (
"context"
"time"
"jokes-bapak2-api/app/v1/models"
"jokes-bapak2-api/app/v1/platform/database"
"github.com/Masterminds/squirrel"
phccrypto "github.com/aldy505/phc-crypto"
"github.com/gofiber/fiber/v2"
)
var psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
var db = database.New()
func RequireAuth() fiber.Handler {
return func(c *fiber.Ctx) error {
var auth models.Auth
err := c.BodyParser(&auth)
if err != nil {
return err
}
// Check if key exists
sql, args, err := psql.
Select("token").
From("administrators").
Where(squirrel.Eq{"key": auth.Key}).
ToSql()
if err != nil {
return err
}
var token string
err = db.QueryRow(context.Background(), sql, args...).Scan(&token)
if err != nil {
if err.Error() == "no rows in result set" {
return c.
Status(fiber.StatusForbidden).
JSON(models.Error{
Error: "Invalid key",
})
}
return err
}
crypto, err := phccrypto.Use(phccrypto.Argon2, phccrypto.Config{})
if err != nil {
return err
}
verify, err := crypto.Verify(token, auth.Token)
if err != nil {
return err
}
if verify {
sql, args, err = psql.
Update("administrators").
Set("last_used", time.Now().UTC().Format(time.RFC3339)).
ToSql()
if err != nil {
return err
}
_, err = db.Query(context.Background(), sql, args...)
if err != nil {
return err
}
sql, args, err = psql.
Select("id").
From("administrators").
Where(squirrel.Eq{"key": auth.Key}).
ToSql()
if err != nil {
return err
}
var id int
err = db.QueryRow(context.Background(), sql, args...).Scan(&id)
if err != nil {
return err
}
c.Locals("userID", id)
return c.Next()
}
return c.
Status(fiber.StatusForbidden).
JSON(models.Error{
Error: "Invalid key",
})
}
}

View File

@ -1,14 +0,0 @@
package models
import "errors"
var ErrNoRows = errors.New("no rows in result set")
var ErrConnDone = errors.New("connection is already closed")
var ErrTxDone = errors.New("transaction has already been committed or rolled back")
var ErrNotFound = errors.New("record not found")
var ErrEmpty = errors.New("record is empty")
type Error struct {
Error string `json:"error"`
}

View File

@ -1,16 +0,0 @@
package cache
import (
"log"
"time"
"github.com/allegro/bigcache/v3"
)
func InMemory() *bigcache.BigCache {
cache, err := bigcache.NewBigCache(bigcache.DefaultConfig(6 * time.Hour))
if err != nil {
log.Fatalln(err)
}
return cache
}

View File

@ -1,19 +0,0 @@
package cache
import (
"log"
"os"
"github.com/go-redis/redis/v8"
_ "github.com/joho/godotenv/autoload"
)
// Connect to the database
func New() *redis.Client {
opt, err := redis.ParseURL(os.Getenv("REDIS_URL"))
if err != nil {
log.Fatalln(err)
}
rdb := redis.NewClient(opt)
return rdb
}

View File

@ -1,21 +0,0 @@
-- Access the data from your HTTP Request software (Postman or Insomnia)
-- with this auth:
-- key: test
-- token: password
INSERT INTO "administrators" ("id", "key", "token", "last_used") VALUES
(1, 'test', '$argon2id$v=19$m=65536,t=16,p=4$3a08c79fbf2222467a623df9a9ebf75802c65a4f9be36eb1df2f5d2052d53cb7$ce434bd38f7ba1fc1f2eb773afb8a1f7f2dad49140803ac6cb9d7256ce9826fb3b4afa1e2488da511c852fc6c33a76d5657eba6298a8e49d617b9972645b7106', '');
-- 10 jokes is enough right?
INSERT INTO "jokesbapak2" ("id", "link", "creator") VALUES
(1, 'https://picsum.photos/id/1000/500/500', 1),
(2, 'https://picsum.photos/id/1001/500/500', 1),
(3, 'https://picsum.photos/id/1002/500/500', 1),
(4, 'https://picsum.photos/id/1003/500/500', 1),
(5, 'https://picsum.photos/id/1004/500/500', 1),
(6, 'https://picsum.photos/id/1005/500/500', 1),
(7, 'https://picsum.photos/id/1006/500/500', 1),
(8, 'https://picsum.photos/id/1010/500/500', 1),
(9, 'https://picsum.photos/id/1008/500/500', 1),
(10, 'https://picsum.photos/id/1009/500/500', 1);

View File

@ -1,27 +0,0 @@
package database
import (
"context"
"log"
"os"
"github.com/jackc/pgx/v4/pgxpool"
_ "github.com/joho/godotenv/autoload"
)
// Connect to the database
func New() *pgxpool.Pool {
poolConfig, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalln("Unable to create pool config", err)
}
poolConfig.MaxConns = 18
poolConfig.MinConns = 2
conn, err := pgxpool.ConnectConfig(context.Background(), poolConfig)
if err != nil {
log.Fatalln("Unable to create connection", err)
}
return conn
}

View File

@ -1,16 +0,0 @@
package routes
import (
"jokes-bapak2-api/app/v1/handler/health"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
)
func Health(app *fiber.App) *fiber.App {
// Health check
app.Get("/health", cache.New(cache.Config{Expiration: 30 * time.Minute}), health.Health)
return app
}

View File

@ -1,35 +0,0 @@
package routes
import (
"jokes-bapak2-api/app/v1/handler/joke"
"jokes-bapak2-api/app/v1/middleware"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
)
func Joke(app *fiber.App) *fiber.App {
// Single route
app.Get("/", joke.SingleJoke)
// Today's joke
app.Get("/today", cache.New(cache.Config{Expiration: 6 * time.Hour}), joke.TodayJoke)
// Joke by ID
app.Get("/id/:id", middleware.OnlyIntegerAsID(), joke.JokeByID)
// Count total jokes
app.Get("/total", cache.New(cache.Config{Expiration: 15 * time.Minute}), joke.TotalJokes)
// Add new joke
app.Put("/", middleware.RequireAuth(), joke.AddNewJoke)
// Update a joke
app.Patch("/id/:id", middleware.RequireAuth(), middleware.OnlyIntegerAsID(), joke.UpdateJoke)
// Delete a joke
app.Delete("/id/:id", middleware.RequireAuth(), middleware.OnlyIntegerAsID(), joke.DeleteJoke)
return app
}

View File

@ -0,0 +1,60 @@
package administrator
import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/jackc/pgx/v4/pgxpool"
)
func GetUserID(db *pgxpool.Pool, ctx context.Context, key string) (int, error) {
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
c, err := db.Acquire(ctx)
if err != nil {
return 0, err
}
defer c.Release()
tx, err := c.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
sql, args, err := query.
Update("administrators").
Set("last_used", time.Now().UTC().Format(time.RFC3339)).
ToSql()
if err != nil {
return 0, err
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return 0, err
}
sql, args, err = query.
Select("id").
From("administrators").
Where(squirrel.Eq{"key": key}).
ToSql()
if err != nil {
return 0, err
}
var id int
err = tx.QueryRow(ctx, sql, args...).Scan(&id)
if err != nil {
return 0, err
}
err = tx.Commit(ctx)
if err != nil {
return 0, err
}
return id, nil
}

View File

@ -0,0 +1,57 @@
package administrator_test
import (
"context"
"jokes-bapak2-api/core/administrator"
"testing"
"time"
)
func TestGetUserID_Success(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
c, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer c.Release()
_, err = c.Exec(
ctx,
`INSERT INTO administrators (id, key, token, last_used) VALUES ($1, $2, $3, $4)`,
administratorsData...,
)
if err != nil {
t.Error("an error was thrown:", err)
}
id, err := administrator.GetUserID(db, ctx, "very secure")
if err != nil {
t.Error("an error was thrown:", err)
}
if id != 1 {
t.Error("id is not correct, want: 1, got:", id)
}
}
func TestGetUserID_Failed(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
c, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer c.Release()
id, err := administrator.GetUserID(db, ctx, "very secure")
if err == nil {
t.Error("an error was expected, got:", id)
}
}

View File

@ -0,0 +1,124 @@
package administrator_test
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v4/pgxpool"
)
var db *pgxpool.Pool
var administratorsData = []interface{}{
1, "very secure", "not the real one", time.Now().Format(time.RFC3339),
}
func TestMain(m *testing.M) {
defer Teardown()
Setup()
os.Exit(m.Run())
}
func Setup() {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1*time.Minute))
defer cancel()
poolConfig, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
db, err = pgxpool.ConnectConfig(ctx, poolConfig)
if err != nil {
panic(err)
}
conn, err := db.Acquire(ctx)
if err != nil {
panic(err)
}
defer conn.Release()
tx, err := conn.Begin(ctx)
if err != nil {
panic(err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(
ctx,
`CREATE TABLE IF NOT EXISTS administrators (
id SERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
token TEXT,
last_used VARCHAR(255)
)`,
)
if err != nil {
panic(err)
}
err = tx.Commit(ctx)
if err != nil {
panic(err)
}
}
func Teardown() (err error) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1*time.Minute))
defer cancel()
defer db.Close()
c, err := db.Acquire(ctx)
if err != nil {
return err
}
defer c.Release()
tx, err := c.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, "TRUNCATE TABLE submission RESTART IDENTITY CASCADE")
if err != nil {
return err
}
_, err = tx.Exec(ctx, "TRUNCATE TABLE jokesbapak2 RESTART IDENTITY CASCADE")
if err != nil {
return err
}
_, err = tx.Exec(ctx, "TRUNCATE TABLE administrators RESTART IDENTITY CASCADE")
if err != nil {
return err
}
err = tx.Commit(ctx)
if err != nil {
return err
}
return
}
func Flush() error {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
_, err = conn.Exec(ctx, "TRUNCATE TABLE administrators RESTART IDENTITY CASCADE")
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,41 @@
package administrator
import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
func CheckKeyExists(db *pgxpool.Pool, ctx context.Context, key string) (string, error) {
conn, err := db.Acquire(ctx)
if err != nil {
return "", err
}
defer conn.Release()
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
// Check if key exists
sql, args, err := query.
Select("token").
From("administrators").
Where(squirrel.Eq{"key": key}).
ToSql()
if err != nil {
return "", err
}
var token string
err = conn.QueryRow(ctx, sql, args...).Scan(&token)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", err
}
return token, nil
}

View File

@ -0,0 +1,70 @@
package administrator_test
import (
"context"
"jokes-bapak2-api/core/administrator"
"testing"
"time"
)
func TestCheckKeyExists_Success(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
c, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer c.Release()
_, err = c.Exec(
ctx,
"INSERT INTO administrators (id, key, token, last_used) VALUES ($1, $2, $3, $4)",
administratorsData...,
)
if err != nil {
t.Error("an error was thrown:", err)
}
key, err := administrator.CheckKeyExists(db, ctx, "very secure")
if err != nil {
t.Error("an error was thrown:", err)
}
if key != "not the real one" {
t.Error("key isn't not the real one, got:", key)
}
}
func TestCheckKeyExists_Failing(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
c, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer c.Release()
_, err = c.Exec(
ctx,
"INSERT INTO administrators (id, key, token, last_used) VALUES ($1, $2, $3, $4)",
administratorsData...,
)
if err != nil {
t.Error("an error was thrown:", err)
}
key, err := administrator.CheckKeyExists(db, ctx, "others")
if err != nil {
t.Error("an error was thrown:", err)
}
if key != "" {
t.Error("key is not empty, got:", key)
}
}

View File

@ -1,25 +1,34 @@
package core
package joke
import (
"context"
"jokes-bapak2-api/app/v1/models"
"errors"
"jokes-bapak2-api/core/schema"
"math/rand"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/allegro/bigcache/v3"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgx"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pquerna/ffjson/ffjson"
)
// GetAllJSONJokes fetch the database for all the jokes then output it as a JSON []byte.
// Keep in mind, you will need to store it to memory yourself.
func GetAllJSONJokes(db *pgxpool.Pool) ([]byte, error) {
var jokes []models.Joke
results, err := db.Query(context.Background(), "SELECT \"id\",\"link\" FROM \"jokesbapak2\" ORDER BY \"id\"")
func GetAllJSONJokes(db *pgxpool.Pool, ctx context.Context) ([]byte, error) {
conn, err := db.Acquire(ctx)
if err != nil {
return []byte{}, err
}
defer conn.Release()
var jokes []schema.Joke
results, err := conn.Query(ctx, "SELECT \"id\",\"link\" FROM \"jokesbapak2\" ORDER BY \"id\"")
if err != nil {
return nil, err
}
defer results.Close()
err = pgxscan.ScanAll(&jokes, results)
@ -35,17 +44,34 @@ func GetAllJSONJokes(db *pgxpool.Pool) ([]byte, error) {
return data, nil
}
// Only return a link
func GetRandomJokeFromDB(db *pgxpool.Pool, ctx context.Context) (string, error) {
conn, err := db.Acquire(ctx)
if err != nil {
return "", err
}
defer conn.Release()
var link string
err = conn.QueryRow(ctx, "SELECT link FROM jokesbapak2 ORDER BY random() LIMIT 1").Scan(&link)
if err != nil {
return "", err
}
return link, nil
}
// GetRandomJokeFromCache returns a link string of a random joke from cache.
func GetRandomJokeFromCache(memory *bigcache.BigCache) (string, error) {
jokes, err := memory.Get("jokes")
if err != nil {
if err.Error() == "Entry not found" {
return "", models.ErrNotFound
if errors.Is(err, bigcache.ErrEntryNotFound) {
return "", schema.ErrNotFound
}
return "", err
}
var data []models.Joke
var data []schema.Joke
err = ffjson.Unmarshal(jokes, &data)
if err != nil {
return "", nil
@ -54,7 +80,7 @@ func GetRandomJokeFromCache(memory *bigcache.BigCache) (string, error) {
// Return an error if the database is empty
dataLength := len(data)
if dataLength == 0 {
return "", models.ErrEmpty
return "", schema.ErrEmpty
}
random := rand.Intn(dataLength)
@ -67,7 +93,7 @@ func GetRandomJokeFromCache(memory *bigcache.BigCache) (string, error) {
func CheckJokesCache(memory *bigcache.BigCache) (bool, error) {
_, err := memory.Get("jokes")
if err != nil {
if err.Error() == "Entry not found" {
if errors.Is(err, bigcache.ErrEntryNotFound) {
return false, nil
}
return false, err
@ -80,7 +106,7 @@ func CheckJokesCache(memory *bigcache.BigCache) (bool, error) {
func CheckTotalJokesCache(memory *bigcache.BigCache) (bool, error) {
_, err := memory.Get("total")
if err != nil {
if err.Error() == "Entry not found" {
if errors.Is(err, bigcache.ErrEntryNotFound) {
return false, nil
}
return false, err
@ -93,16 +119,16 @@ func CheckTotalJokesCache(memory *bigcache.BigCache) (bool, error) {
func GetCachedJokeByID(memory *bigcache.BigCache, id int) (string, error) {
jokes, err := memory.Get("jokes")
if err != nil {
if err.Error() == "Entry not found" {
return "", models.ErrNotFound
if errors.Is(err, bigcache.ErrEntryNotFound) {
return "", schema.ErrNotFound
}
return "", err
}
var data []models.Joke
var data []schema.Joke
err = ffjson.Unmarshal(jokes, &data)
if err != nil {
return "", nil
return "", err
}
// This is a simple solution, might convert it to goroutines and channels sometime soon.
@ -119,11 +145,42 @@ func GetCachedJokeByID(memory *bigcache.BigCache, id int) (string, error) {
func GetCachedTotalJokes(memory *bigcache.BigCache) (int, error) {
total, err := memory.Get("total")
if err != nil {
if err.Error() == "Entry not found" {
return 0, models.ErrNotFound
if errors.Is(err, bigcache.ErrEntryNotFound) {
return 0, schema.ErrNotFound
}
return 0, err
}
i, err := strconv.Atoi(string(total))
if err != nil {
return 0, err
}
return int(total[0]), nil
return i, nil
}
func CheckJokeExists(db *pgxpool.Pool, ctx context.Context, id string) (bool, error) {
conn, err := db.Acquire(ctx)
if err != nil {
return false, err
}
defer conn.Release()
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
sql, args, err := query.
Select("id").
From("jokesbapak2").
Where(squirrel.Eq{"id": id}).
ToSql()
if err != nil {
return false, err
}
var jokeID int
err = conn.QueryRow(ctx, sql, args...).Scan(&jokeID)
if err != nil && errors.Is(err, pgx.ErrNoRows) {
return false, err
}
return strconv.Itoa(jokeID) == id, nil
}

View File

@ -0,0 +1,341 @@
package joke_test
import (
"context"
"encoding/json"
"jokes-bapak2-api/core/joke"
"jokes-bapak2-api/core/schema"
"testing"
"time"
"github.com/jackc/pgx/v4"
)
func TestGetAllJSONJokes(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
conn, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer conn.Release()
err = conn.BeginFunc(ctx, func(t pgx.Tx) error {
_, err := t.Exec(
ctx,
`INSERT INTO "administrators"
(id, key, token, last_used)
VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
administratorsData...,
)
if err != nil {
return err
}
_, err = t.Exec(
ctx,
`INSERT INTO "jokesbapak2"
(id, link, creator)
VALUES
($1, $2, $3),
($4, $5, $6),
($7, $8, $9)`,
jokesData...,
)
if err != nil {
return err
}
return nil
})
if err != nil {
t.Error("an error was thrown:", err)
}
j, err := joke.GetAllJSONJokes(db, ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
if string(j) == "" {
t.Error("j should not be empty")
}
}
func TestGetRandomJokeFromDB(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
conn, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer conn.Release()
err = conn.BeginFunc(ctx, func(t pgx.Tx) error {
_, err := t.Exec(
ctx,
`INSERT INTO "administrators"
(id, key, token, last_used)
VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
administratorsData...,
)
if err != nil {
return err
}
_, err = t.Exec(
ctx,
`INSERT INTO "jokesbapak2"
(id, link, creator)
VALUES
($1, $2, $3),
($4, $5, $6),
($7, $8, $9)`,
jokesData...,
)
if err != nil {
return err
}
return nil
})
if err != nil {
t.Error("an error was thrown:", err)
}
j, err := joke.GetRandomJokeFromDB(db, ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
if j == "" {
t.Error("j should not be empty")
}
}
func TestGetRandomJokeFromCache(t *testing.T) {
defer Flush()
jokes := []schema.Joke{
{ID: 1, Link: "link1", Creator: 1},
{ID: 2, Link: "link2", Creator: 1},
{ID: 3, Link: "link3", Creator: 1},
}
data, err := json.Marshal(jokes)
if err != nil {
t.Error("an error was thrown:", err)
}
err = memory.Set("jokes", data)
if err != nil {
t.Error("an error was thrown:", err)
}
j, err := joke.GetRandomJokeFromCache(memory)
if err != nil {
t.Error("an error was thrown:", err)
}
if j == "" {
t.Error("j should not be empty")
}
}
func TestCheckJokesCache_True(t *testing.T) {
defer Flush()
jokes := []schema.Joke{
{ID: 1, Link: "link1", Creator: 1},
{ID: 2, Link: "link2", Creator: 1},
{ID: 3, Link: "link3", Creator: 1},
}
data, err := json.Marshal(jokes)
if err != nil {
t.Error("an error was thrown:", err)
}
err = memory.Set("jokes", data)
if err != nil {
t.Error("an error was thrown:", err)
}
j, err := joke.CheckJokesCache(memory)
if err != nil {
t.Error("an error was thrown:", err)
}
if j == false {
t.Error("j should not be false")
}
}
func TestCheckJokesCache_False(t *testing.T) {
defer Flush()
j, err := joke.CheckJokesCache(memory)
if err != nil {
t.Error("an error was thrown:", err)
}
if j == true {
t.Error("j should not be true")
}
}
func TestCheckTotalJokesCache_True(t *testing.T) {
defer Flush()
err := memory.Set("total", []byte("10"))
if err != nil {
t.Error("an error was thrown:", err)
}
j, err := joke.CheckTotalJokesCache(memory)
if err != nil {
t.Error("an error was thrown:", err)
}
if j == false {
t.Error("j should not be false")
}
}
func TestCheckTotalJokesCache_False(t *testing.T) {
defer Flush()
j, err := joke.CheckTotalJokesCache(memory)
if err != nil {
t.Error("an error was thrown:", err)
}
if j == true {
t.Error("j should not be true")
}
}
func TestGetCachedJokeByID(t *testing.T) {
defer Flush()
jokes := []schema.Joke{
{ID: 1, Link: "link1", Creator: 1},
{ID: 2, Link: "link2", Creator: 1},
{ID: 3, Link: "link3", Creator: 1},
}
data, err := json.Marshal(jokes)
if err != nil {
t.Error("an error was thrown:", err)
}
err = memory.Set("jokes", data)
if err != nil {
t.Error("an error was thrown:", err)
}
j, err := joke.GetCachedJokeByID(memory, 1)
if err != nil {
t.Error("an error was thrown:", err)
}
if j != "link1" {
t.Error("j should be link1, got:", j)
}
k, err := joke.GetCachedJokeByID(memory, 4)
if err != nil {
t.Error("an error was thrown:", err)
}
if k != "" {
t.Error("k should be empty, got:", k)
}
}
func TestGetCachedTotalJokes(t *testing.T) {
defer Flush()
err := memory.Set("total", []byte("10"))
if err != nil {
t.Error("an error was thrown:", err)
}
j, err := joke.GetCachedTotalJokes(memory)
if err != nil {
t.Error("an error was thrown:", err)
}
if j != 10 {
t.Error("j should be 10, got:", j)
}
}
func TestCheckJokeExists(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
conn, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer conn.Release()
err = conn.BeginFunc(ctx, func(t pgx.Tx) error {
_, err := t.Exec(
ctx,
`INSERT INTO "administrators"
(id, key, token, last_used)
VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
administratorsData...,
)
if err != nil {
return err
}
_, err = t.Exec(
ctx,
`INSERT INTO "jokesbapak2"
(id, link, creator)
VALUES
($1, $2, $3),
($4, $5, $6),
($7, $8, $9)`,
jokesData...,
)
if err != nil {
return err
}
return nil
})
if err != nil {
t.Error("an error was thrown:", err)
}
j, err := joke.CheckJokeExists(db, ctx, "1")
if err != nil {
t.Error("an error was thrown:", err)
}
if j == false {
t.Error("j should not be false")
}
k, err := joke.CheckJokeExists(db, ctx, "4")
if err != nil {
t.Error("an error was thrown:", err)
}
if k == true {
t.Error("k should not be true")
}
}

195
api/core/joke/init_test.go Normal file
View File

@ -0,0 +1,195 @@
package joke_test
import (
"context"
"os"
"testing"
"time"
"github.com/allegro/bigcache/v3"
"github.com/go-redis/redis/v8"
"github.com/jackc/pgx/v4/pgxpool"
)
var db *pgxpool.Pool
var cache *redis.Client
var memory *bigcache.BigCache
var jokesData = []interface{}{
1, "https://via.placeholder.com/300/06f/fff.png", 1,
2, "https://via.placeholder.com/300/07f/fff.png", 1,
3, "https://via.placeholder.com/300/08f/fff.png", 1,
}
var administratorsData = []interface{}{
1, "very secure", "not the real one", time.Now().Format(time.RFC3339), 2, "test", "$argon2id$v=19$m=65536,t=16,p=4$3a08c79fbf2222467a623df9a9ebf75802c65a4f9be36eb1df2f5d2052d53cb7$ce434bd38f7ba1fc1f2eb773afb8a1f7f2dad49140803ac6cb9d7256ce9826fb3b4afa1e2488da511c852fc6c33a76d5657eba6298a8e49d617b9972645b7106", "",
}
func TestMain(m *testing.M) {
defer Teardown()
Setup()
time.Sleep(3 * time.Second)
os.Exit(m.Run())
}
func Setup() {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1*time.Minute))
defer cancel()
poolConfig, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
db, err = pgxpool.ConnectConfig(ctx, poolConfig)
if err != nil {
panic(err)
}
opt, err := redis.ParseURL(os.Getenv("REDIS_URL"))
if err != nil {
panic(err)
}
cache = redis.NewClient(opt)
memory, err = bigcache.NewBigCache(bigcache.DefaultConfig(6 * time.Hour))
if err != nil {
panic(err)
}
conn, err := db.Acquire(ctx)
if err != nil {
panic(err)
}
defer conn.Release()
tx, err := conn.Begin(ctx)
if err != nil {
panic(err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(
ctx,
`CREATE TABLE IF NOT EXISTS administrators (
id SERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
token TEXT,
last_used VARCHAR(255)
)`,
)
if err != nil {
panic(err)
}
_, err = tx.Exec(
ctx,
`CREATE TABLE IF NOT EXISTS jokesbapak2 (
id SERIAL PRIMARY KEY,
link TEXT UNIQUE,
creator INT NOT NULL REFERENCES "administrators" ("id")
)`,
)
if err != nil {
panic(err)
}
_, err = tx.Exec(
ctx,
`CREATE TABLE IF NOT EXISTS submission (
id SERIAL PRIMARY KEY,
link VARCHAR(255) UNIQUE NOT NULL,
created_at VARCHAR(255),
author VARCHAR(255) NOT NULL,
status SMALLINT DEFAULT 0
)`,
)
if err != nil {
panic(err)
}
err = tx.Commit(ctx)
if err != nil {
panic(err)
}
}
func Teardown() (err error) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer db.Close()
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, "TRUNCATE TABLE submission RESTART IDENTITY CASCADE")
if err != nil {
return err
}
_, err = tx.Exec(ctx, "TRUNCATE TABLE jokesbapak2 RESTART IDENTITY CASCADE")
if err != nil {
return err
}
_, err = tx.Exec(ctx, "TRUNCATE TABLE administrators RESTART IDENTITY CASCADE")
if err != nil {
return err
}
err = tx.Commit(ctx)
if err != nil {
return err
}
err = cache.Close()
if err != nil {
return
}
err = memory.Close()
return
}
func Flush() error {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
_, err = conn.Exec(ctx, "TRUNCATE TABLE submission RESTART IDENTITY CASCADE")
if err != nil {
return err
}
_, err = conn.Exec(ctx, "TRUNCATE TABLE jokesbapak2 RESTART IDENTITY CASCADE")
if err != nil {
return err
}
_, err = conn.Exec(ctx, "TRUNCATE TABLE administrators RESTART IDENTITY CASCADE")
if err != nil {
return err
}
err = cache.FlushAll(ctx).Err()
if err != nil {
return err
}
err = memory.Reset()
if err != nil {
return err
}
return nil
}

134
api/core/joke/setter.go Normal file
View File

@ -0,0 +1,134 @@
package joke
import (
"context"
"jokes-bapak2-api/core/schema"
"github.com/Masterminds/squirrel"
"github.com/allegro/bigcache/v3"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pquerna/ffjson/ffjson"
)
// SetAllJSONJoke fetches jokes data from GetAllJSONJokes then set it to memory cache.
func SetAllJSONJoke(db *pgxpool.Pool, ctx context.Context, memory *bigcache.BigCache) error {
jokes, err := GetAllJSONJokes(db, ctx)
if err != nil {
return err
}
err = memory.Set("jokes", jokes)
if err != nil {
return err
}
return nil
}
func SetTotalJoke(db *pgxpool.Pool, ctx context.Context, memory *bigcache.BigCache) error {
check, err := CheckJokesCache(memory)
if err != nil {
return err
}
if !check {
err = SetAllJSONJoke(db, ctx, memory)
if err != nil {
return err
}
}
jokes, err := memory.Get("jokes")
if err != nil {
return err
}
var data []schema.Joke
err = ffjson.Unmarshal(jokes, &data)
if err != nil {
return err
}
var total = []byte{byte(len(data))}
err = memory.Set("total", total)
if err != nil {
return err
}
return nil
}
func InsertJokeIntoDB(db *pgxpool.Pool, ctx context.Context, joke schema.Joke) error {
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
sql, args, err := query.
Insert("jokesbapak2").
Columns("link", "creator").
Values(joke.Link, joke.Creator).
ToSql()
if err != nil {
return err
}
r, err := conn.Query(ctx, sql, args...)
if err != nil {
return err
}
defer r.Close()
return nil
}
func DeleteSingleJoke(db *pgxpool.Pool, ctx context.Context, id int) error {
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
sql, args, err := query.
Delete("jokesbapak2").
Where(squirrel.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
r, err := conn.Query(ctx, sql, args...)
if err != nil {
return err
}
defer r.Close()
return nil
}
func UpdateJoke(db *pgxpool.Pool, ctx context.Context, newJoke schema.Joke) error {
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
sql, args, err := query.
Update("jokesbapak2").
Set("link", newJoke.Link).
Set("creator", newJoke.Creator).
Where(squirrel.Eq{"id": newJoke.ID}).
ToSql()
if err != nil {
return err
}
r, err := conn.Query(ctx, sql, args...)
if err != nil {
return err
}
defer r.Close()
return nil
}

View File

@ -0,0 +1,238 @@
package joke_test
import (
"context"
"jokes-bapak2-api/core/joke"
"jokes-bapak2-api/core/schema"
"testing"
"time"
"github.com/jackc/pgx/v4"
)
func TestSetAllJSONJoke(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
conn, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer conn.Release()
err = conn.BeginFunc(ctx, func(t pgx.Tx) error {
_, err := t.Exec(
ctx,
`INSERT INTO "administrators"
(id, key, token, last_used)
VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
administratorsData...,
)
if err != nil {
return err
}
_, err = t.Exec(
ctx,
`INSERT INTO "jokesbapak2"
(id, link, creator)
VALUES
($1, $2, $3),
($4, $5, $6),
($7, $8, $9)`,
jokesData...,
)
if err != nil {
return err
}
return nil
})
if err != nil {
t.Error("an error was thrown:", err)
}
err = joke.SetAllJSONJoke(db, ctx, memory)
if err != nil {
t.Error("an error was thrown:", err)
}
}
func TestSetTotalJoke(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
conn, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer conn.Release()
err = conn.BeginFunc(ctx, func(t pgx.Tx) error {
_, err := t.Exec(
ctx,
`INSERT INTO "administrators"
(id, key, token, last_used)
VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
administratorsData...,
)
if err != nil {
return err
}
_, err = t.Exec(
ctx,
`INSERT INTO "jokesbapak2"
(id, link, creator)
VALUES
($1, $2, $3),
($4, $5, $6),
($7, $8, $9)`,
jokesData...,
)
if err != nil {
return err
}
return nil
})
if err != nil {
t.Error("an error was thrown:", err)
}
err = joke.SetTotalJoke(db, ctx, memory)
if err != nil {
t.Error("an error was thrown:", err)
}
}
func TestInsertJokeIntoDB(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
data := schema.Joke{
ID: 1,
Link: "link1",
Creator: 1,
}
err := joke.InsertJokeIntoDB(db, ctx, data)
if err != nil {
t.Error("an error was thrown:", err)
}
}
func TestDeleteSingleJoke(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
conn, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer conn.Release()
err = conn.BeginFunc(ctx, func(t pgx.Tx) error {
_, err := t.Exec(
ctx,
`INSERT INTO "administrators"
(id, key, token, last_used)
VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
administratorsData...,
)
if err != nil {
return err
}
_, err = t.Exec(
ctx,
`INSERT INTO "jokesbapak2"
(id, link, creator)
VALUES
($1, $2, $3),
($4, $5, $6),
($7, $8, $9)`,
jokesData...,
)
if err != nil {
return err
}
return nil
})
if err != nil {
t.Error("an error was thrown:", err)
}
err = joke.DeleteSingleJoke(db, ctx, 1)
if err != nil {
t.Error("an error was thrown:", err)
}
}
func TestUpdateJoke(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
conn, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer conn.Release()
err = conn.BeginFunc(ctx, func(t pgx.Tx) error {
_, err := t.Exec(
ctx,
`INSERT INTO "administrators"
(id, key, token, last_used)
VALUES
($1, $2, $3, $4),
($5, $6, $7, $8)`,
administratorsData...,
)
if err != nil {
return err
}
_, err = t.Exec(
ctx,
`INSERT INTO "jokesbapak2"
(id, link, creator)
VALUES
($1, $2, $3),
($4, $5, $6),
($7, $8, $9)`,
jokesData...,
)
if err != nil {
return err
}
return nil
})
if err != nil {
t.Error("an error was thrown:", err)
}
newJoke := schema.Joke{
ID: 1,
Link: "link1",
Creator: 1,
}
err = joke.UpdateJoke(db, ctx, newJoke)
if err != nil {
t.Error("an error was thrown:", err)
}
}

10
api/core/schema/err.go Normal file
View File

@ -0,0 +1,10 @@
package schema
import "errors"
var ErrNotFound = errors.New("record not found")
var ErrEmpty = errors.New("record is empty")
type Error struct {
Error string `json:"error"`
}

View File

@ -0,0 +1,15 @@
package schema
type ImageAPI struct {
Data ImageAPIData `json:"data"`
Success bool `json:"success"`
Status int `json:"status"`
}
type ImageAPIData struct {
ID string `json:"id"`
Title string `json:"title"`
URLViewer string `json:"url_viewer"`
URL string `json:"url"`
DisplayURL string `json:"display_url"`
}

7
api/core/schema/joke.go Normal file
View File

@ -0,0 +1,7 @@
package schema
type Joke struct {
ID int `json:"id" form:"id" db:"id"`
Link string `json:"link" form:"link" db:"link"`
Creator int `json:"creator" form:"creator" db:"creator"`
}

View File

@ -1,4 +1,4 @@
package models
package schema
type Submission struct {
ID int `json:"id,omitempty" db:"id"`
@ -17,21 +17,8 @@ type SubmissionQuery struct {
}
type ResponseSubmission struct {
ID string `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Data Submission `json:"data,omitempty"`
}
type ImageAPI struct {
Data ImageAPIData `json:"data"`
Success bool `json:"success"`
Status int `json:"status"`
}
type ImageAPIData struct {
ID string `json:"id"`
Title string `json:"title"`
URLViewer string `json:"url_viewer"`
URL string `json:"url"`
DisplayURL string `json:"display_url"`
ID string `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Submission Submission `json:"submission,omitempty"`
AuthorPage string `json:"author_page,omitempty"`
}

115
api/core/submit/getter.go Normal file
View File

@ -0,0 +1,115 @@
package submit
import (
"context"
"jokes-bapak2-api/core/schema"
"net/url"
"strconv"
"strings"
"github.com/aldy505/bob"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgx/v4/pgxpool"
)
func GetSubmittedItems(db *pgxpool.Pool, ctx context.Context, queries schema.SubmissionQuery) ([]schema.Submission, error) {
var err error
var limit int
var offset int
var approved bool
if queries.Limit != "" {
limit, err = strconv.Atoi(queries.Limit)
if err != nil {
return []schema.Submission{}, err
}
}
if queries.Page != "" {
page, err := strconv.Atoi(queries.Page)
if err != nil {
return []schema.Submission{}, err
}
offset = (page - 1) * 20
}
if queries.Approved != "" {
approved, err = strconv.ParseBool(queries.Approved)
if err != nil {
return []schema.Submission{}, err
}
}
var status int
if approved {
status = 1
} else {
status = 0
}
sql, args, err := GetterQueryBuilder(queries, status, limit, offset)
if err != nil {
return []schema.Submission{}, err
}
conn, err := db.Acquire(ctx)
if err != nil {
return []schema.Submission{}, err
}
defer conn.Release()
var submissions []schema.Submission
results, err := conn.Query(ctx, sql, args...)
if err != nil {
return []schema.Submission{}, err
}
defer results.Close()
err = pgxscan.ScanAll(&submissions, results)
if err != nil {
return []schema.Submission{}, err
}
return submissions, nil
}
func GetterQueryBuilder(queries schema.SubmissionQuery, status, limit, offset int) (string, []interface{}, error) {
var sql string
var args []interface{}
var sqlQuery strings.Builder
sqlQuery.WriteString("SELECT * FROM submission WHERE TRUE")
if queries.Author != "" {
sqlQuery.WriteString(" AND author = ?")
escapedAuthor, err := url.QueryUnescape(queries.Author)
if err != nil {
return sql, args, err
}
args = append(args, escapedAuthor)
}
if queries.Approved != "" {
sqlQuery.WriteString(" AND status = ?")
args = append(args, status)
}
if limit > 0 {
sqlQuery.WriteString(" LIMIT " + strconv.Itoa(limit))
} else {
sqlQuery.WriteString(" LIMIT 20")
}
if queries.Page != "" {
sqlQuery.WriteString(" OFFSET " + strconv.Itoa(offset))
}
sql = bob.ReplacePlaceholder(sqlQuery.String(), bob.Dollar)
return sql, args, nil
}

View File

@ -0,0 +1,68 @@
package submit_test
import (
"context"
"jokes-bapak2-api/core/schema"
"jokes-bapak2-api/core/submit"
"testing"
"time"
)
func TestGetSubmittedItems(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
c, err := db.Acquire(ctx)
if err != nil {
t.Error("an error was thrown:", err)
}
defer c.Release()
_, err = c.Exec(ctx, "INSERT INTO submission (id, link, created_at, author, status) VALUES ($1, $2, $3, $4, $5), ($6, $7, $8, $9, $10)", submissionData...)
if err != nil {
t.Error("an error was thrown:", err)
}
items, err := submit.GetSubmittedItems(db, ctx, schema.SubmissionQuery{})
if err != nil {
t.Error("an error was thrown:", err)
}
if len(items) != 2 {
t.Error("expected 2 items, got", len(items))
}
}
func TestGetterQueryBuilder(t *testing.T) {
s, _, err := submit.GetterQueryBuilder(schema.SubmissionQuery{}, 0, 0, 0)
if err != nil {
t.Error("an error was thrown:", err)
}
if s != "SELECT * FROM submission WHERE TRUE LIMIT 20" {
t.Error("expected query to be", "SELECT * FROM submission WHERE TRUE LIMIT 20", "got", s)
}
s, i, err := submit.GetterQueryBuilder(schema.SubmissionQuery{
Author: "Test <example@test.com>",
Approved: "true",
Page: "2",
}, 2, 15, 10)
if err != nil {
t.Error("an error was thrown:", err)
}
if s != "SELECT * FROM submission WHERE TRUE AND author = $1 AND status = $2 LIMIT 15 OFFSET 10" {
t.Error("expected query to be", "SELECT * FROM submission WHERE TRUE AND author = $1 AND status = $2 LIMIT 15 OFFSET 15", "got:", s)
}
if i[0].(string) != "Test <example@test.com>" {
t.Error("expected first arg to be Test <example@test.com>, got:", i[0].(string))
}
if i[1].(int) != 2 {
t.Error("expected second arg to be 1, got:", i[1].(int))
}
}

View File

@ -0,0 +1,128 @@
package submit_test
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v4/pgxpool"
)
var db *pgxpool.Pool
var submissionData = []interface{}{
1, "https://via.placeholder.com/300/01f/fff.png", "2021-08-03T18:20:38Z", "Test <test@example.com>", 0,
2, "https://via.placeholder.com/300/02f/fff.png", "2021-08-04T18:20:38Z", "Test <test@example.com>", 1,
}
func TestMain(m *testing.M) {
defer Teardown()
Setup()
time.Sleep(3 * time.Second)
os.Exit(m.Run())
}
func Setup() {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1*time.Minute))
defer cancel()
poolConfig, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
db, err = pgxpool.ConnectConfig(ctx, poolConfig)
if err != nil {
panic(err)
}
conn, err := db.Acquire(ctx)
if err != nil {
panic(err)
}
defer conn.Release()
tx, err := conn.Begin(ctx)
if err != nil {
panic(err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(
ctx,
`CREATE TABLE IF NOT EXISTS submission (
id SERIAL PRIMARY KEY,
link VARCHAR(255) UNIQUE NOT NULL,
created_at VARCHAR(255),
author VARCHAR(255) NOT NULL,
status SMALLINT DEFAULT 0
)`,
)
if err != nil {
panic(err)
}
err = tx.Commit(ctx)
if err != nil {
panic(err)
}
}
func Teardown() (err error) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer db.Close()
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, "DROP TABLE IF EXISTS submission CASCADE")
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DROP TABLE IF EXISTS jokesbapak2 CASCADE")
if err != nil {
return err
}
_, err = tx.Exec(ctx, "DROP TABLE IF EXISTS administrators CASCADE")
if err != nil {
return err
}
err = tx.Commit(ctx)
if err != nil {
return err
}
return
}
func Flush() error {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
_, err = conn.Exec(ctx, "TRUNCATE TABLE submission RESTART IDENTITY CASCADE")
if err != nil {
return err
}
return nil
}

View File

@ -1,17 +1,22 @@
package core
package submit
import (
"bytes"
"context"
"io"
"io/ioutil"
"jokes-bapak2-api/app/v1/models"
"jokes-bapak2-api/app/v1/utils"
"jokes-bapak2-api/core/schema"
"jokes-bapak2-api/utils"
"mime/multipart"
"net/http"
"net/url"
"os"
"time"
"github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/pgxscan"
"github.com/gojek/heimdall/v7/httpclient"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pquerna/ffjson/ffjson"
)
@ -71,7 +76,7 @@ func UploadImage(client *httpclient.Client, image io.Reader) (string, error) {
return "", err
}
var data models.ImageAPI
var data schema.ImageAPI
err = ffjson.Unmarshal(responseBody, &data)
if err != nil {
return "", err
@ -79,3 +84,39 @@ func UploadImage(client *httpclient.Client, image io.Reader) (string, error) {
return data.Data.URL, nil
}
func SubmitJoke(db *pgxpool.Pool, ctx context.Context, s schema.Submission, link string) (schema.Submission, error) {
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
conn, err := db.Acquire(ctx)
if err != nil {
return schema.Submission{}, err
}
defer conn.Release()
now := time.Now().UTC().Format(time.RFC3339)
sql, args, err := query.
Insert("submission").
Columns("link", "created_at", "author").
Values(link, now, s.Author).
Suffix("RETURNING id,created_at,link,author,status").
ToSql()
if err != nil {
return schema.Submission{}, err
}
var submission schema.Submission
result, err := conn.Query(ctx, sql, args...)
if err != nil {
return schema.Submission{}, err
}
defer result.Close()
err = pgxscan.ScanOne(&submission, result)
if err != nil {
return schema.Submission{}, err
}
return submission, nil
}

View File

@ -0,0 +1,25 @@
package submit_test
import (
"context"
"jokes-bapak2-api/core/schema"
"jokes-bapak2-api/core/submit"
"testing"
"time"
)
func TestSubmitJoke(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
defer cancel()
defer Flush()
s, err := submit.SubmitJoke(db, ctx, schema.Submission{Author: "Test <example@test.com>"}, "https://example.net/img.png")
if err != nil {
t.Error("an error was thrown:", err)
}
if s.Link != "https://example.net/img.png" {
t.Error("link is not correct, got:", s.Link)
}
}

View File

@ -1,4 +1,4 @@
package core
package validator
import (
"regexp"

View File

@ -0,0 +1,40 @@
package validator_test
import (
"jokes-bapak2-api/core/validator"
"testing"
)
func TestValidateAuthor_False(t *testing.T) {
v := validator.ValidateAuthor("Test Author")
if v != false {
t.Error("Expected false, got true")
}
v = validator.ValidateAuthor("Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec.")
if v != false {
t.Error("Expected false, got true")
}
v = validator.ValidateAuthor("")
if v != false {
t.Error("Expected false, got true")
}
v = validator.ValidateAuthor("Test <Author")
if v != false {
t.Error("Expected false, got true")
}
v = validator.ValidateAuthor("Test <Author>")
if v != false {
t.Error("Expected false, got true")
}
}
func TestValidateAuthor_True(t *testing.T) {
v := validator.ValidateAuthor("Test Author <author@mail.com>")
if v != true {
t.Error("Expected true, got false")
}
}

View File

@ -1,8 +1,8 @@
package core
package validator
import (
"errors"
"jokes-bapak2-api/app/v1/utils"
"jokes-bapak2-api/utils"
"net/http"
"strings"

View File

@ -0,0 +1,50 @@
package validator_test
import (
"jokes-bapak2-api/core/validator"
"testing"
"github.com/gojek/heimdall/v7/httpclient"
)
func TestCheckImageValidity_Error(t *testing.T) {
client := httpclient.NewClient()
b, err := validator.CheckImageValidity(client, "http://lorem-ipsum")
if err == nil {
t.Error("Expected error, got nil")
}
if b {
t.Error("Expected false, got true")
}
if err.Error() != "URL must use HTTPS protocol" {
t.Error("Expected error to be URL must use HTTPS protocol, got:", err)
}
}
func TestCheckImageValidity_False(t *testing.T) {
client := httpclient.NewClient()
b, err := validator.CheckImageValidity(client, "https://www.youtube.com/watch?v=yTJV6T37Reo")
if err != nil {
t.Error("Expected nil, got error")
}
if b {
t.Error("Expected false, got true")
}
}
func TestCheckImageValidity_True(t *testing.T) {
client := httpclient.NewClient()
b, err := validator.CheckImageValidity(client, "https://i.ytimg.com/vi/yTJV6T37Reo/maxresdefault.jpg")
if err != nil {
t.Error("Expected nil, got error")
}
if !b {
t.Error("Expected true, got false")
}
}

View File

@ -0,0 +1,69 @@
package validator
import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
// Validate if link already exists
func JokeLinkExists(db *pgxpool.Pool, ctx context.Context, link string) (bool, error) {
conn, err := db.Acquire(ctx)
if err != nil {
return false, err
}
defer conn.Release()
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
sql, args, err := query.
Select("link").
From("jokesbapak2").
Where(squirrel.Eq{"link": link}).
ToSql()
if err != nil {
return false, err
}
var validateLink string
err = conn.QueryRow(ctx, sql, args...).Scan(&validateLink)
if err != nil && err != pgx.ErrNoRows {
return false, err
}
return validateLink != "", nil
}
// Check if the joke exists
func JokeIDExists(db *pgxpool.Pool, ctx context.Context, id int) (bool, error) {
conn, err := db.Acquire(ctx)
if err != nil {
return false, err
}
defer conn.Release()
var query = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
sql, args, err := query.
Select("id").
From("jokesbapak2").
Where(squirrel.Eq{"id": id}).
ToSql()
if err != nil {
return false, err
}
var jokeID int
err = conn.QueryRow(ctx, sql, args...).Scan(&jokeID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return false, err
}
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return true, nil
}

View File

@ -0,0 +1,38 @@
package validator
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
func SubmitLinkExists(db *pgxpool.Pool, ctx context.Context, query squirrel.StatementBuilderType, link string) (bool, error) {
conn, err := db.Acquire(ctx)
if err != nil {
return false, err
}
defer conn.Release()
sql, args, err := query.
Select("link").
From("submission").
Where(squirrel.Eq{"link": link}).
ToSql()
if err != nil {
return false, err
}
var validateLink string
err = conn.QueryRow(ctx, sql, args...).Scan(&validateLink)
if err != nil && err != pgx.ErrNoRows {
return false, err
}
if err == nil && validateLink != "" {
return true, nil
}
return false, nil
}

View File

@ -19,6 +19,10 @@
"url": "https://jokesbapak2.herokuapp.com/v1",
"description": "Production"
},
{
"url": "https://jokesbapak2.herokuapp.com",
"description": "Production"
},
{
"url": "http://localhost:5000",
"description": "Development"

View File

@ -16,6 +16,8 @@ info:
servers:
- url: "https://jokesbapak2.herokuapp.com/v1"
description: Production
- url: "https://jokesbapak2.herokuapp.com"
description: Production
- url: "http://localhost:5000"
description: Development
paths:

View File

@ -1,6 +1,6 @@
module jokes-bapak2-api
go 1.16
go 1.17
require (
github.com/Masterminds/squirrel v1.5.0
@ -13,14 +13,44 @@ require (
github.com/go-redis/redis/v8 v8.11.0
github.com/gofiber/fiber/v2 v2.15.0
github.com/gojek/heimdall/v7 v7.0.2
github.com/jackc/pgx v3.6.2+incompatible
github.com/jackc/pgx/v4 v4.12.0
github.com/joho/godotenv v1.3.0
github.com/kr/text v0.2.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7
github.com/stretchr/testify v1.7.0
github.com/stretchr/testify v1.7.0 // indirect
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
require (
github.com/andybalholm/brotli v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/gojek/valkyrie v0.0.0-20180215180059-6aee720afcdf // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect
github.com/jackc/pgconn v1.9.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.1.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.8.0 // indirect
github.com/jackc/puddle v1.1.3 // indirect
github.com/klauspost/compress v1.12.2 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.3.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.26.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e // indirect
golang.org/x/text v0.3.6 // indirect
)

View File

@ -98,8 +98,9 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/georgysavva/scany v0.2.9 h1:Xt6rjYpHnMClTm/g+oZTnoSxUwiln5GqMNU+QeLNHQU=
github.com/georgysavva/scany v0.2.9/go.mod h1:yeOeC1BdIdl6hOwy8uefL2WNSlseFzbhlG/frrh65SA=
@ -214,6 +215,8 @@ github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc=
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=

View File

@ -0,0 +1,40 @@
package health
import (
"github.com/go-redis/redis/v8"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v4/pgxpool"
)
type Dependencies struct {
DB *pgxpool.Pool
Redis *redis.Client
}
func (d *Dependencies) Health(c *fiber.Ctx) error {
conn, err := d.DB.Acquire(c.Context())
if err != nil {
return err
}
defer conn.Release()
// Ping REDIS database
err = d.Redis.Ping(c.Context()).Err()
if err != nil {
return c.
Status(fiber.StatusServiceUnavailable).
JSON(Error{
Error: "REDIS: " + err.Error(),
})
}
_, err = conn.Query(c.Context(), "SELECT \"id\" FROM \"jokesbapak2\" LIMIT 1")
if err != nil {
return c.
Status(fiber.StatusServiceUnavailable).
JSON(Error{
Error: "POSTGRESQL: " + err.Error(),
})
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -0,0 +1,5 @@
package health
type Error struct {
Error string `json:"error"`
}

View File

@ -0,0 +1,17 @@
package joke
import (
"github.com/Masterminds/squirrel"
"github.com/allegro/bigcache/v3"
"github.com/go-redis/redis/v8"
"github.com/gojek/heimdall/v7/httpclient"
"github.com/jackc/pgx/v4/pgxpool"
)
type Dependencies struct {
DB *pgxpool.Pool
Redis *redis.Client
Memory *bigcache.BigCache
HTTP *httpclient.Client
Query squirrel.StatementBuilderType
}

View File

@ -0,0 +1,69 @@
package joke
import (
core "jokes-bapak2-api/core/joke"
"jokes-bapak2-api/core/schema"
"jokes-bapak2-api/core/validator"
"github.com/gofiber/fiber/v2"
)
func (d *Dependencies) AddNewJoke(c *fiber.Ctx) error {
var body schema.Joke
err := c.BodyParser(&body)
if err != nil {
return err
}
// Check link validity
valid, err := validator.CheckImageValidity(d.HTTP, body.Link)
if err != nil {
return err
}
if !valid {
return c.
Status(fiber.StatusBadRequest).
JSON(Error{
Error: "URL provided is not a valid image",
})
}
validateLink, err := validator.JokeLinkExists(d.DB, c.Context(), body.Link)
if err != nil {
return err
}
if !validateLink {
return c.Status(fiber.StatusConflict).JSON(Error{
Error: "Given link is already on the jokesbapak2 database",
})
}
err = core.InsertJokeIntoDB(
d.DB,
c.Context(),
schema.Joke{
Link: body.Link,
Creator: c.Locals("userID").(int),
},
)
if err != nil {
return err
}
err = core.SetAllJSONJoke(d.DB, c.Context(), d.Memory)
if err != nil {
return err
}
err = core.SetTotalJoke(d.DB, c.Context(), d.Memory)
if err != nil {
return err
}
return c.
Status(fiber.StatusCreated).
JSON(ResponseJoke{
Link: body.Link,
})
}

View File

@ -0,0 +1,51 @@
package joke
import (
core "jokes-bapak2-api/core/joke"
"jokes-bapak2-api/core/validator"
"strconv"
"github.com/gofiber/fiber/v2"
)
func (d *Dependencies) DeleteJoke(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return err
}
validate, err := validator.JokeIDExists(d.DB, c.Context(), id)
if err != nil {
return err
}
if validate {
return c.
Status(fiber.StatusNotAcceptable).
JSON(Error{
Error: "specified joke id does not exists",
})
}
err = core.DeleteSingleJoke(d.DB, c.Context(), id)
if err != nil {
return err
}
err = core.SetAllJSONJoke(d.DB, c.Context(), d.Memory)
if err != nil {
return err
}
err = core.SetTotalJoke(d.DB, c.Context(), d.Memory)
if err != nil {
return err
}
return c.
Status(fiber.StatusOK).
JSON(ResponseJoke{
Message: "specified joke id has been deleted",
})
}

View File

@ -0,0 +1,151 @@
package joke
import (
"errors"
"io/ioutil"
core "jokes-bapak2-api/core/joke"
"jokes-bapak2-api/core/schema"
"jokes-bapak2-api/utils"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
)
func (d *Dependencies) TodayJoke(c *fiber.Ctx) error {
// check from handler.Redis if today's joke already exists
// send the joke if exists
// get a new joke if it's not, then send it.
var joke Today
err := d.Redis.MGet(c.Context(), "today:link", "today:date", "today:image", "today:contentType").Scan(&joke)
if err != nil {
return err
}
eq, err := utils.IsToday(joke.Date)
if err != nil {
return err
}
if eq {
c.Set("Content-Type", joke.ContentType)
return c.Status(fiber.StatusOK).Send([]byte(joke.Image))
}
link, err := core.GetRandomJokeFromDB(d.DB, c.Context())
if err != nil {
return err
}
response, err := d.HTTP.Get(link, nil)
if err != nil {
return err
}
data, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
err = d.Redis.MSet(c.Context(), map[string]interface{}{
"today:link": link,
"today:date": now,
"today:image": string(data),
"today:contentType": response.Header.Get("content-type"),
}).Err()
if err != nil {
return err
}
c.Set("Content-Type", response.Header.Get("content-type"))
return c.Status(fiber.StatusOK).Send(data)
}
func (d *Dependencies) SingleJoke(c *fiber.Ctx) error {
checkCache, err := core.CheckJokesCache(d.Memory)
if err != nil {
return err
}
if !checkCache {
jokes, err := core.GetAllJSONJokes(d.DB, c.Context())
if err != nil {
return err
}
err = d.Memory.Set("jokes", jokes)
if err != nil {
return err
}
}
link, err := core.GetRandomJokeFromCache(d.Memory)
if err != nil && !errors.Is(err, schema.ErrEmpty) {
return err
}
// Get image data
response, err := d.HTTP.Get(link, nil)
if err != nil {
return err
}
data, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
c.Set("Content-Type", response.Header.Get("content-type"))
return c.Status(fiber.StatusOK).Send(data)
}
func (d *Dependencies) JokeByID(c *fiber.Ctx) error {
checkCache, err := core.CheckJokesCache(d.Memory)
if err != nil {
return err
}
if !checkCache {
jokes, err := core.GetAllJSONJokes(d.DB, c.Context())
if err != nil {
return err
}
err = d.Memory.Set("jokes", jokes)
if err != nil {
return err
}
}
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return err
}
link, err := core.GetCachedJokeByID(d.Memory, id)
if err != nil {
return err
}
if link == "" {
return c.
Status(fiber.StatusNotFound).
Send([]byte("Requested ID was not found."))
}
// Get image data
response, err := d.HTTP.Get(link, nil)
if err != nil {
return err
}
data, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
c.Set("Content-Type", response.Header.Get("content-type"))
return c.Status(fiber.StatusOK).Send(data)
}

View File

@ -0,0 +1,43 @@
package joke
import (
"errors"
core "jokes-bapak2-api/core/joke"
"strconv"
"github.com/allegro/bigcache/v3"
"github.com/gofiber/fiber/v2"
)
func (d *Dependencies) TotalJokes(c *fiber.Ctx) error {
checkTotal, err := core.CheckTotalJokesCache(d.Memory)
if err != nil {
return err
}
if !checkTotal {
err = core.SetTotalJoke(d.DB, c.Context(), d.Memory)
if err != nil {
return err
}
}
total, err := d.Memory.Get("total")
if err != nil {
if errors.Is(err, bigcache.ErrEntryNotFound) {
return c.
Status(fiber.StatusInternalServerError).
JSON(Error{
Error: "no data found",
})
}
return err
}
return c.
Status(fiber.StatusOK).
JSON(ResponseJoke{
Message: strconv.Itoa(int(total[0])),
})
}

View File

@ -0,0 +1,86 @@
package joke
import (
core "jokes-bapak2-api/core/joke"
"jokes-bapak2-api/core/schema"
"jokes-bapak2-api/core/validator"
"strconv"
"github.com/gofiber/fiber/v2"
)
func (d *Dependencies) UpdateJoke(c *fiber.Ctx) error {
id := c.Params("id")
// Check if the joke exists
jokeExists, err := core.CheckJokeExists(d.DB, c.Context(), id)
if err != nil {
return err
}
if !jokeExists {
return c.
Status(fiber.StatusNotAcceptable).
JSON(Error{
Error: "specified joke id does not exists",
})
}
body := new(schema.Joke)
err = c.BodyParser(&body)
if err != nil {
return err
}
// Check link validity
valid, err := validator.CheckImageValidity(d.HTTP, body.Link)
if err != nil {
return err
}
if !valid {
return c.
Status(fiber.StatusBadRequest).
JSON(Error{
Error: "URL provided is not a valid image",
})
}
newID, err := strconv.Atoi(id)
if err != nil {
return err
}
newCreator, err := strconv.Atoi(c.Locals("userID").(string))
if err != nil {
return err
}
updatedJoke := schema.Joke{
Link: body.Link,
Creator: newCreator,
ID: newID,
}
err = core.UpdateJoke(d.DB, c.Context(), updatedJoke)
if err != nil {
return err
}
err = core.SetAllJSONJoke(d.DB, c.Context(), d.Memory)
if err != nil {
return err
}
err = core.SetTotalJoke(d.DB, c.Context(), d.Memory)
if err != nil {
return err
}
return c.
Status(fiber.StatusOK).
JSON(ResponseJoke{
Message: "specified joke id has been updated",
Link: body.Link,
})
}

View File

@ -1,9 +1,8 @@
package models
package joke
type Joke struct {
ID int `json:"id" form:"id" db:"id"`
Link string `json:"link" form:"link" db:"link"`
Creator int `json:"creator" form:"creator" db:"creator"`
type ResponseJoke struct {
Link string `json:"link,omitempty"`
Message string `json:"message,omitempty"`
}
type Today struct {
@ -12,7 +11,6 @@ type Today struct {
ContentType string `redis:"today:contentType"`
}
type ResponseJoke struct {
Link string `json:"link,omitempty"`
Message string `json:"message,omitempty"`
type Error struct {
Error string `json:"error"`
}

View File

@ -0,0 +1,17 @@
package submit
import (
"github.com/Masterminds/squirrel"
"github.com/allegro/bigcache/v3"
"github.com/go-redis/redis/v8"
"github.com/gojek/heimdall/v7/httpclient"
"github.com/jackc/pgx/v4/pgxpool"
)
type Dependencies struct {
DB *pgxpool.Pool
Redis *redis.Client
Memory *bigcache.BigCache
HTTP *httpclient.Client
Query squirrel.StatementBuilderType
}

View File

@ -0,0 +1,99 @@
package submit
import (
"jokes-bapak2-api/core/schema"
core "jokes-bapak2-api/core/submit"
"jokes-bapak2-api/core/validator"
"net/url"
"strings"
"github.com/gofiber/fiber/v2"
)
func (d *Dependencies) SubmitJoke(c *fiber.Ctx) error {
conn, err := d.DB.Acquire(c.Context())
if err != nil {
return err
}
defer conn.Release()
var body schema.Submission
err = c.BodyParser(&body)
if err != nil {
return err
}
// Image and/or Link should not be empty
if body.Image == "" && body.Link == "" {
return c.Status(fiber.StatusBadRequest).JSON(schema.Error{
Error: "A link or an image should be supplied in a form of multipart/form-data",
})
}
// Author should be supplied
if body.Author == "" {
return c.Status(fiber.StatusBadRequest).JSON(schema.Error{
Error: "An author key consisting on the format \"yourname <youremail@mail>\" must be supplied",
})
} else {
// Validate format
valid := validator.ValidateAuthor(body.Author)
if !valid {
return c.Status(fiber.StatusBadRequest).JSON(schema.Error{
Error: "Please stick to the format of \"yourname <youremail@mail>\" and within 200 characters",
})
}
}
var link string
// Check link validity if link was provided
if body.Link != "" {
valid, err := validator.CheckImageValidity(d.HTTP, body.Link)
if err != nil {
return err
}
if !valid {
return c.Status(fiber.StatusBadRequest).JSON(schema.Error{
Error: "URL provided is not a valid image",
})
}
link = body.Link
}
// If image was provided
if body.Image != "" {
image := strings.NewReader(body.Image)
link, err = core.UploadImage(d.HTTP, image)
if err != nil {
return err
}
}
// Validate if link already exists
validateLink, err := validator.SubmitLinkExists(d.DB, c.Context(), d.Query, link)
if err != nil {
return err
}
if validateLink {
return c.Status(fiber.StatusConflict).JSON(schema.Error{
Error: "Given link is already on the submission queue.",
})
}
submission, err := core.SubmitJoke(d.DB, c.Context(), body, link)
if err != nil {
return err
}
return c.
Status(fiber.StatusCreated).
JSON(schema.ResponseSubmission{
Message: "Joke submitted. Please wait for a few days for admin to approve your submission.",
Submission: submission,
AuthorPage: "/submit?author=" + url.QueryEscape(body.Author),
})
}

View File

@ -0,0 +1,28 @@
package submit
import (
"jokes-bapak2-api/core/schema"
core "jokes-bapak2-api/core/submit"
"github.com/gofiber/fiber/v2"
)
func (d *Dependencies) GetSubmission(c *fiber.Ctx) error {
query := new(schema.SubmissionQuery)
err := c.QueryParser(query)
if err != nil {
return err
}
submissions, err := core.GetSubmittedItems(d.DB, c.Context(), *query)
if err != nil {
return err
}
return c.
Status(fiber.StatusOK).
JSON(fiber.Map{
"count": len(submissions),
"jokes": submissions,
})
}

View File

@ -4,22 +4,100 @@ import (
"log"
"os"
"os/signal"
"context"
"jokes-bapak2-api/core/joke"
"jokes-bapak2-api/platform/database"
"jokes-bapak2-api/routes"
"time"
v1 "jokes-bapak2-api/app/v1"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/favicon"
"github.com/gofiber/fiber/v2/middleware/limiter"
_ "github.com/joho/godotenv/autoload"
"github.com/Masterminds/squirrel"
"github.com/allegro/bigcache/v3"
"github.com/getsentry/sentry-go"
"github.com/go-redis/redis/v8"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/etag"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gojek/heimdall/v7/httpclient"
"github.com/jackc/pgx/v4/pgxpool"
)
func main() {
timeoutDefault, _ := time.ParseDuration("1m")
// Setup PostgreSQL
poolConfig, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
log.Panicln("Unable to create pool config", err)
}
poolConfig.MaxConnIdleTime = time.Minute * 3
poolConfig.MaxConnLifetime = time.Minute * 5
poolConfig.MaxConns = 15
poolConfig.MinConns = 4
db, err := pgxpool.ConnectConfig(context.Background(), poolConfig)
if err != nil {
log.Panicln("Unable to create connection", err)
}
defer db.Close()
// Setup Redis
opt, err := redis.ParseURL(os.Getenv("REDIS_URL"))
if err != nil {
log.Fatalln(err)
}
rdb := redis.NewClient(opt)
defer rdb.Close()
// Setup In Memory
memory, err := bigcache.NewBigCache(bigcache.DefaultConfig(6 * time.Hour))
if err != nil {
log.Panicln(err)
}
defer memory.Close()
// Setup Sentry
err = sentry.Init(sentry.ClientOptions{
Dsn: os.Getenv("SENTRY_DSN"),
Environment: os.Getenv("ENV"),
AttachStacktrace: true,
// Enable printing of SDK debug messages.
// Useful when getting started or trying to figure something out.
Debug: true,
})
if err != nil {
log.Panicln(err)
}
defer sentry.Flush(2 * time.Second)
setupCtx, setupCancel := context.WithDeadline(context.Background(), time.Now().Add(time.Minute*4))
defer setupCancel()
err = database.Populate(db, setupCtx)
if err != nil {
sentry.CaptureException(err)
log.Panicln(err)
}
err = joke.SetAllJSONJoke(db, setupCtx, memory)
if err != nil {
log.Panicln(err)
}
err = joke.SetTotalJoke(db, setupCtx, memory)
if err != nil {
log.Panicln(err)
}
timeoutDefault := time.Minute * 1
app := fiber.New(fiber.Config{
ReadTimeout: timeoutDefault,
WriteTimeout: timeoutDefault,
ReadTimeout: timeoutDefault,
WriteTimeout: timeoutDefault,
CaseSensitive: true,
DisableKeepalive: true,
ErrorHandler: errorHandler,
})
app.Use(limiter.New(limiter.Config{
@ -27,11 +105,21 @@ func main() {
Expiration: 1 * time.Minute,
LimitReached: limitHandler,
}))
app.Use(favicon.New(favicon.Config{
File: "./favicon.png",
}))
app.Mount("/v1", v1.New())
app.Use(cors.New())
app.Use(etag.New())
route := routes.Dependencies{
DB: db,
Redis: rdb,
Memory: memory,
HTTP: httpclient.NewClient(httpclient.WithHTTPTimeout(10 * time.Second)),
Query: squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar),
App: app,
}
route.Health()
route.Joke()
route.Submit()
// Start server (with or without graceful shutdown).
if os.Getenv("ENV") == "development" {
@ -47,6 +135,14 @@ func limitHandler(c *fiber.Ctx) error {
})
}
func errorHandler(c *fiber.Ctx, err error) error {
log.Println(err)
sentry.CaptureException(err)
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.

58
api/middleware/auth.go Normal file
View File

@ -0,0 +1,58 @@
package middleware
import (
"jokes-bapak2-api/core/administrator"
phccrypto "github.com/aldy505/phc-crypto"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v4/pgxpool"
)
func RequireAuth(db *pgxpool.Pool) fiber.Handler {
return func(c *fiber.Ctx) error {
var auth Auth
err := c.BodyParser(&auth)
if err != nil {
return err
}
token, err := administrator.CheckKeyExists(db, c.Context(), auth.Key)
if err != nil {
return err
}
if token == "" {
return c.
Status(fiber.StatusForbidden).
JSON(Error{
Error: "Invalid key",
})
}
crypto, err := phccrypto.Use(phccrypto.Argon2, phccrypto.Config{})
if err != nil {
return err
}
verify, err := crypto.Verify(token, auth.Token)
if err != nil {
return err
}
if verify {
id, err := administrator.GetUserID(db, c.Context(), auth.Key)
if err != nil {
return err
}
c.Locals("userID", id)
return c.Next()
}
return c.
Status(fiber.StatusForbidden).
JSON(Error{
Error: "Invalid key",
})
}
}

View File

@ -1,4 +1,4 @@
package models
package middleware
type Auth struct {
ID int `json:"id" form:"id" db:"id"`
@ -6,3 +6,7 @@ type Auth struct {
Token string `json:"token" form:"token" db:"token"`
LastUsed string `json:"last_used" form:"last_used" db:"last_used"`
}
type Error struct {
Error string `json:"error"`
}

View File

@ -1,7 +1,6 @@
package middleware
import (
"jokes-bapak2-api/app/v1/models"
"regexp"
"github.com/gofiber/fiber/v2"
@ -21,7 +20,7 @@ func OnlyIntegerAsID() fiber.Handler {
return c.
Status(fiber.StatusBadRequest).
JSON(models.Error{
JSON(Error{
Error: "only numbers are allowed as ID",
})
}

View File

@ -2,24 +2,46 @@ package database
import (
"context"
"log"
"github.com/aldy505/bob"
"github.com/jackc/pgx/v4/pgxpool"
)
// Setup the table connection, create table if not exists
func Setup() error {
db := New()
func Populate(db *pgxpool.Pool, ctx context.Context) error {
err := setupAuthTable(db, ctx)
if err != nil {
return err
}
// administrators table
err = setupJokesTable(db, ctx)
if err != nil {
return err
}
err = setupSubmissionTable(db, ctx)
if err != nil {
return err
}
return nil
}
func setupAuthTable(db *pgxpool.Pool, ctx context.Context) error {
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
// Check if table exists
var tableAuthExists bool
err := db.QueryRow(context.Background(), `SELECT EXISTS (
err = conn.QueryRow(ctx, `SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'administrators'
);`).Scan(&tableAuthExists)
if err != nil {
log.Fatalln("16 - failed on checking table: ", err)
return err
}
@ -27,33 +49,37 @@ func Setup() error {
sql, _, err := bob.
CreateTable("administrators").
AddColumn(bob.ColumnDef{Name: "id", Type: "SERIAL", Extras: []string{"PRIMARY KEY"}}).
StringColumn("key", "UNIQUE").
StringColumn("key", "NOT NULL", "UNIQUE").
TextColumn("token").
StringColumn("last_used").
ToSql()
if err != nil {
log.Fatalln("17 - failed on table creation: ", err)
return err
}
_, err = db.Query(context.Background(), sql)
_, err = conn.Exec(ctx, sql)
if err != nil {
log.Fatalln("18 - failed on table creation: ", err)
return err
}
}
return nil
}
// Jokesbapak2 table
func setupJokesTable(db *pgxpool.Pool, ctx context.Context) error {
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
// Check if table exists
var tableJokesExists bool
err = db.QueryRow(context.Background(), `SELECT EXISTS (
err = conn.QueryRow(ctx, `SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'jokesbapak2'
);`).Scan(&tableJokesExists)
if err != nil {
log.Fatalln("10 - failed on checking table: ", err)
return err
}
@ -65,28 +91,33 @@ func Setup() error {
AddColumn(bob.ColumnDef{Name: "creator", Type: "INT", Extras: []string{"NOT NULL", "REFERENCES \"administrators\" (\"id\")"}}).
ToSql()
if err != nil {
log.Fatalln("11 - failed on table creation: ", err)
return err
}
_, err = db.Query(context.Background(), sql)
_, err = conn.Exec(ctx, sql)
if err != nil {
log.Fatalln("12 - failed on table creation: ", err)
return err
}
}
// Submission table
return nil
}
func setupSubmissionTable(db *pgxpool.Pool, ctx context.Context) error {
conn, err := db.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
//Check if table exists
var tableSubmissionExists bool
err = db.QueryRow(context.Background(), `SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'submission'
);`).Scan(&tableJokesExists)
err = conn.QueryRow(ctx, `SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'submission'
);`).Scan(&tableSubmissionExists)
if err != nil {
log.Fatalln("13 - failed on checking table: ", err)
return err
}
@ -100,12 +131,12 @@ func Setup() error {
AddColumn(bob.ColumnDef{Name: "status", Type: "SMALLINT", Extras: []string{"DEFAULT 0"}}).
ToSql()
if err != nil {
log.Fatalln("14 - failed on table creation: ", err)
return err
}
_, err = db.Query(context.Background(), sql)
_, err = conn.Exec(ctx, sql)
if err != nil {
log.Fatalln("15 - failed on table creation: ", err)
return err
}
}

View File

@ -0,0 +1,21 @@
-- Access the data from your HTTP Request software (Postman or Insomnia)
-- with this auth:
-- key: test
-- token: password
INSERT INTO "administrators" ("id", "key", "token", "last_used") VALUES
(1, 'test', '$argon2id$v=19$m=65536,t=16,p=4$3a08c79fbf2222467a623df9a9ebf75802c65a4f9be36eb1df2f5d2052d53cb7$ce434bd38f7ba1fc1f2eb773afb8a1f7f2dad49140803ac6cb9d7256ce9826fb3b4afa1e2488da511c852fc6c33a76d5657eba6298a8e49d617b9972645b7106', '');
-- 10 jokes is enough right?
INSERT INTO "jokesbapak2" ("link", "creator") VALUES
('https://picsum.photos/id/1000/500/500', 1),
('https://picsum.photos/id/1001/500/500', 1),
('https://picsum.photos/id/1002/500/500', 1),
('https://picsum.photos/id/1003/500/500', 1),
('https://picsum.photos/id/1004/500/500', 1),
('https://picsum.photos/id/1005/500/500', 1),
('https://picsum.photos/id/1006/500/500', 1),
('https://picsum.photos/id/1010/500/500', 1),
('https://picsum.photos/id/1008/500/500', 1),
('https://picsum.photos/id/1009/500/500', 1);

View File

@ -0,0 +1,19 @@
package routes
import (
"github.com/Masterminds/squirrel"
"github.com/allegro/bigcache/v3"
"github.com/go-redis/redis/v8"
"github.com/gofiber/fiber/v2"
"github.com/gojek/heimdall/v7/httpclient"
"github.com/jackc/pgx/v4/pgxpool"
)
type Dependencies struct {
DB *pgxpool.Pool
Redis *redis.Client
Memory *bigcache.BigCache
HTTP *httpclient.Client
Query squirrel.StatementBuilderType
App *fiber.App
}

19
api/routes/health.go Normal file
View File

@ -0,0 +1,19 @@
package routes
import (
"jokes-bapak2-api/handler/health"
"time"
"github.com/gofiber/fiber/v2/middleware/cache"
)
func (d *Dependencies) Health() {
// Health check
deps := health.Dependencies{
DB: d.DB,
Redis: d.Redis,
}
d.App.Get("/health", cache.New(cache.Config{Expiration: 30 * time.Minute}), deps.Health)
d.App.Get("/v1/health", cache.New(cache.Config{Expiration: 30 * time.Minute}), deps.Health)
}

43
api/routes/joke.go Normal file
View File

@ -0,0 +1,43 @@
package routes
import (
"jokes-bapak2-api/handler/joke"
"jokes-bapak2-api/middleware"
"time"
"github.com/gofiber/fiber/v2/middleware/cache"
)
func (d *Dependencies) Joke() {
deps := joke.Dependencies{
DB: d.DB,
Redis: d.Redis,
Memory: d.Memory,
HTTP: d.HTTP,
Query: d.Query,
}
// Single route
d.App.Get("/", deps.SingleJoke)
d.App.Get("/v1", deps.SingleJoke)
// Today's joke
d.App.Get("/today", cache.New(cache.Config{Expiration: 6 * time.Hour}), deps.TodayJoke)
d.App.Get("/v1/today", cache.New(cache.Config{Expiration: 6 * time.Hour}), deps.TodayJoke)
// Joke by ID
d.App.Get("/id/:id", middleware.OnlyIntegerAsID(), deps.JokeByID)
d.App.Get("/v1/id/:id", middleware.OnlyIntegerAsID(), deps.JokeByID)
// Count total jokes
d.App.Get("/total", cache.New(cache.Config{Expiration: 15 * time.Minute}), deps.TotalJokes)
d.App.Get("/v1/total", cache.New(cache.Config{Expiration: 15 * time.Minute}), deps.TotalJokes)
// Add new joke
d.App.Put("/", middleware.RequireAuth(d.DB), deps.AddNewJoke)
// Update a joke
d.App.Patch("/id/:id", middleware.RequireAuth(d.DB), middleware.OnlyIntegerAsID(), deps.UpdateJoke)
// Delete a joke
d.App.Delete("/id/:id", middleware.RequireAuth(d.DB), middleware.OnlyIntegerAsID(), deps.DeleteJoke)
}

View File

@ -1,16 +1,24 @@
package routes
import (
"jokes-bapak2-api/app/v1/handler/submit"
"jokes-bapak2-api/handler/submit"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
)
func Submit(app *fiber.App) *fiber.App {
func (d *Dependencies) Submit() {
deps := submit.Dependencies{
DB: d.DB,
Redis: d.Redis,
Memory: d.Memory,
HTTP: d.HTTP,
Query: d.Query,
}
// Get pending submitted joke
app.Get(
d.App.Get(
"/submit",
cache.New(cache.Config{
Expiration: 5 * time.Minute,
@ -18,10 +26,8 @@ func Submit(app *fiber.App) *fiber.App {
return c.OriginalURL()
},
}),
submit.GetSubmission)
deps.GetSubmission)
// Add a joke
app.Post("/submit", submit.SubmitJoke)
return app
d.App.Post("/submit", deps.SubmitJoke)
}

View File

@ -1,7 +1,7 @@
package utils_test
import (
"jokes-bapak2-api/app/v1/utils"
"jokes-bapak2-api/utils"
"testing"
)

View File

@ -1,10 +1,9 @@
package utils_test
import (
"jokes-bapak2-api/utils"
"testing"
"time"
"jokes-bapak2-api/app/v1/utils"
)
func TestIsToday_Today(t *testing.T) {

View File

@ -1,10 +1,9 @@
package utils_test
import (
"jokes-bapak2-api/utils"
"strings"
"testing"
"jokes-bapak2-api/app/v1/utils"
)
func TestParseToJSONBody(t *testing.T) {

View File

@ -1,7 +1,7 @@
package utils_test
import (
"jokes-bapak2-api/app/v1/utils"
"jokes-bapak2-api/utils"
"testing"
)

View File

@ -1,10 +1,9 @@
package utils_test
import (
"jokes-bapak2-api/utils"
"net/http"
"testing"
"jokes-bapak2-api/app/v1/utils"
)
func TestRequest_Get(t *testing.T) {