├── .dockerignore
├── .github
├── FUNDING.yml
└── workflows
│ └── gotest.yml
├── clean-arch.png
├── .gitignore
├── config.json
├── domain
├── author.go
├── errors.go
├── mocks
│ ├── AuthorRepository.go
│ ├── ArticleUsecase.go
│ └── ArticleRepository.go
└── article.go
├── Dockerfile
├── article
├── delivery
│ └── http
│ │ ├── middleware
│ │ ├── middleware.go
│ │ └── middleware_test.go
│ │ ├── article_handler.go
│ │ └── article_test.go
├── repository
│ ├── helper.go
│ └── mysql
│ │ ├── mysql_article.go
│ │ └── mysqlarticle_test.go
└── usecase
│ ├── article_ucase.go
│ └── article_ucase_test.go
├── compose.yaml
├── author
└── repository
│ └── mysql
│ ├── mysql_test.go
│ └── mysql_repository.go
├── LICENSE
├── .air.toml
├── misc
└── make
│ ├── help.Makefile
│ └── tools.Makefile
├── go.mod
├── app
└── main.go
├── .golangci.yaml
├── README.md
├── Makefile
├── go.sum
└── article.sql
/.dockerignore:
--------------------------------------------------------------------------------
1 | engine
2 | *.out
3 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [bxcodec]
2 |
--------------------------------------------------------------------------------
/clean-arch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/spider-yamet/go-clean-arch/master/clean-arch.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | vendor/
3 | article_clean
4 | _*
5 | *.test
6 | .DS_Store
7 | engine
8 | bin/
9 | *.out
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "debug": true,
3 | "server": {
4 | "address": ":9090"
5 | },
6 | "context":{
7 | "timeout":2
8 | },
9 | "database": {
10 | "host": "localhost",
11 | "port": "3306",
12 | "user": "user",
13 | "pass": "password",
14 | "name": "article"
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/domain/author.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import "context"
4 |
5 | // Author representing the Author data struct
6 | type Author struct {
7 | ID int64 `json:"id"`
8 | Name string `json:"name"`
9 | CreatedAt string `json:"created_at"`
10 | UpdatedAt string `json:"updated_at"`
11 | }
12 |
13 | // AuthorRepository represent the author's repository contract
14 | type AuthorRepository interface {
15 | GetByID(ctx context.Context, id int64) (Author, error)
16 | }
17 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Builder
2 | FROM golang:1.19.4-alpine3.17 as builder
3 |
4 | RUN apk update && apk upgrade && \
5 | apk --update add git make bash build-base
6 |
7 | WORKDIR /app
8 |
9 | COPY . .
10 |
11 | RUN make build
12 |
13 | # Distribution
14 | FROM alpine:latest
15 |
16 | RUN apk update && apk upgrade && \
17 | apk --update --no-cache add tzdata && \
18 | mkdir /app
19 |
20 | WORKDIR /app
21 |
22 | EXPOSE 9090
23 |
24 | COPY --from=builder /app/engine /app/
25 |
26 | CMD /app/engine
--------------------------------------------------------------------------------
/.github/workflows/gotest.yml:
--------------------------------------------------------------------------------
1 | name: Go Test
2 |
3 | on:
4 | push:
5 | branches: ["main", "chore/upgrade-linter"]
6 | pull_request:
7 | branches: ["main"]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Set up Go
16 | uses: actions/setup-go@v3
17 | with:
18 | go-version: 1.19
19 |
20 | - name: Linter
21 | run: make lint
22 |
23 | - name: Test
24 | run: make tests
25 |
--------------------------------------------------------------------------------
/domain/errors.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import "errors"
4 |
5 | var (
6 | // ErrInternalServerError will throw if any the Internal Server Error happen
7 | ErrInternalServerError = errors.New("internal Server Error")
8 | // ErrNotFound will throw if the requested item is not exists
9 | ErrNotFound = errors.New("your requested Item is not found")
10 | // ErrConflict will throw if the current action already exists
11 | ErrConflict = errors.New("your Item already exist")
12 | // ErrBadParamInput will throw if the given request-body or params is not valid
13 | ErrBadParamInput = errors.New("given Param is not valid")
14 | )
15 |
--------------------------------------------------------------------------------
/article/delivery/http/middleware/middleware.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import "github.com/labstack/echo"
4 |
5 | // GoMiddleware represent the data-struct for middleware
6 | type GoMiddleware struct {
7 | // another stuff , may be needed by middleware
8 | }
9 |
10 | // CORS will handle the CORS middleware
11 | func (m *GoMiddleware) CORS(next echo.HandlerFunc) echo.HandlerFunc {
12 | return func(c echo.Context) error {
13 | c.Response().Header().Set("Access-Control-Allow-Origin", "*")
14 | return next(c)
15 | }
16 | }
17 |
18 | // InitMiddleware initialize the middleware
19 | func InitMiddleware() *GoMiddleware {
20 | return &GoMiddleware{}
21 | }
22 |
--------------------------------------------------------------------------------
/article/delivery/http/middleware/middleware_test.go:
--------------------------------------------------------------------------------
1 | package middleware_test
2 |
3 | import (
4 | "net/http"
5 | test "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/labstack/echo"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 |
12 | "github.com/bxcodec/go-clean-arch/article/delivery/http/middleware"
13 | )
14 |
15 | func TestCORS(t *testing.T) {
16 | e := echo.New()
17 | req := test.NewRequest(echo.GET, "/", nil)
18 | res := test.NewRecorder()
19 | c := e.NewContext(req, res)
20 | m := middleware.InitMiddleware()
21 |
22 | h := m.CORS(echo.HandlerFunc(func(c echo.Context) error {
23 | return c.NoContent(http.StatusOK)
24 | }))
25 |
26 | err := h(c)
27 | require.NoError(t, err)
28 | assert.Equal(t, "*", res.Header().Get("Access-Control-Allow-Origin"))
29 | }
30 |
--------------------------------------------------------------------------------
/article/repository/helper.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "encoding/base64"
5 | "time"
6 | )
7 |
8 | const (
9 | timeFormat = "2006-01-02T15:04:05.999Z07:00" // reduce precision from RFC3339Nano as date format
10 | )
11 |
12 | // DecodeCursor will decode cursor from user for mysql
13 | func DecodeCursor(encodedTime string) (time.Time, error) {
14 | byt, err := base64.StdEncoding.DecodeString(encodedTime)
15 | if err != nil {
16 | return time.Time{}, err
17 | }
18 |
19 | timeString := string(byt)
20 | t, err := time.Parse(timeFormat, timeString)
21 |
22 | return t, err
23 | }
24 |
25 | // EncodeCursor will encode cursor from mysql to user
26 | func EncodeCursor(t time.Time) string {
27 | timeString := t.Format(timeFormat)
28 |
29 | return base64.StdEncoding.EncodeToString([]byte(timeString))
30 | }
31 |
--------------------------------------------------------------------------------
/compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 | services:
3 | web:
4 | image: go-clean-arch
5 | container_name: article_management_api
6 | ports:
7 | - 9090:9090
8 | depends_on:
9 | mysql:
10 | condition: service_healthy
11 | volumes:
12 | - ./config.json:/app/config.json
13 |
14 | mysql:
15 | image: mysql:5.7
16 | container_name: go_clean_arch_mysql
17 | command: mysqld --user=root
18 | volumes:
19 | - ./article.sql:/docker-entrypoint-initdb.d/init.sql
20 | ports:
21 | - 3306:3306
22 | environment:
23 | - MYSQL_DATABASE=article
24 | - MYSQL_USER=user
25 | - MYSQL_PASSWORD=password
26 | - MYSQL_ROOT_PASSWORD=root
27 | healthcheck:
28 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
29 | timeout: 5s
30 | retries: 10
31 |
--------------------------------------------------------------------------------
/domain/mocks/AuthorRepository.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v1.0.0. DO NOT EDIT.
2 | package mocks
3 |
4 | import context "context"
5 | import domain "github.com/bxcodec/go-clean-arch/domain"
6 | import mock "github.com/stretchr/testify/mock"
7 |
8 | // AuthorRepository is an autogenerated mock type for the AuthorRepository type
9 | type AuthorRepository struct {
10 | mock.Mock
11 | }
12 |
13 | // GetByID provides a mock function with given fields: ctx, id
14 | func (_m *AuthorRepository) GetByID(ctx context.Context, id int64) (domain.Author, error) {
15 | ret := _m.Called(ctx, id)
16 |
17 | var r0 domain.Author
18 | if rf, ok := ret.Get(0).(func(context.Context, int64) domain.Author); ok {
19 | r0 = rf(ctx, id)
20 | } else {
21 | r0 = ret.Get(0).(domain.Author)
22 | }
23 |
24 | var r1 error
25 | if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
26 | r1 = rf(ctx, id)
27 | } else {
28 | r1 = ret.Error(1)
29 | }
30 |
31 | return r0, r1
32 | }
33 |
--------------------------------------------------------------------------------
/author/repository/mysql/mysql_test.go:
--------------------------------------------------------------------------------
1 | package mysql_test
2 |
3 | import (
4 | "context"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 | sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1"
10 |
11 | repository "github.com/bxcodec/go-clean-arch/author/repository/mysql"
12 | )
13 |
14 | func TestGetByID(t *testing.T) {
15 | db, mock, err := sqlmock.New()
16 | if err != nil {
17 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
18 | }
19 |
20 | rows := sqlmock.NewRows([]string{"id", "name", "updated_at", "created_at"}).
21 | AddRow(1, "Iman Tumorang", time.Now(), time.Now())
22 |
23 | query := "SELECT id, name, created_at, updated_at FROM author WHERE id=\\?"
24 |
25 | prep := mock.ExpectPrepare(query)
26 | userID := int64(1)
27 | prep.ExpectQuery().WithArgs(userID).WillReturnRows(rows)
28 |
29 | a := repository.NewMysqlAuthorRepository(db)
30 |
31 | anArticle, err := a.GetByID(context.TODO(), userID)
32 | assert.NoError(t, err)
33 | assert.NotNil(t, anArticle)
34 | }
35 |
--------------------------------------------------------------------------------
/author/repository/mysql/mysql_repository.go:
--------------------------------------------------------------------------------
1 | package mysql
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 |
7 | "github.com/bxcodec/go-clean-arch/domain"
8 | )
9 |
10 | type mysqlAuthorRepo struct {
11 | DB *sql.DB
12 | }
13 |
14 | // NewMysqlAuthorRepository will create an implementation of author.Repository
15 | func NewMysqlAuthorRepository(db *sql.DB) domain.AuthorRepository {
16 | return &mysqlAuthorRepo{
17 | DB: db,
18 | }
19 | }
20 |
21 | func (m *mysqlAuthorRepo) getOne(ctx context.Context, query string, args ...interface{}) (res domain.Author, err error) {
22 | stmt, err := m.DB.PrepareContext(ctx, query)
23 | if err != nil {
24 | return domain.Author{}, err
25 | }
26 | row := stmt.QueryRowContext(ctx, args...)
27 | res = domain.Author{}
28 |
29 | err = row.Scan(
30 | &res.ID,
31 | &res.Name,
32 | &res.CreatedAt,
33 | &res.UpdatedAt,
34 | )
35 | return
36 | }
37 |
38 | func (m *mysqlAuthorRepo) GetByID(ctx context.Context, id int64) (domain.Author, error) {
39 | query := `SELECT id, name, created_at, updated_at FROM author WHERE id=?`
40 | return m.getOne(ctx, query, id)
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Iman Tumorang
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.air.toml:
--------------------------------------------------------------------------------
1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format
2 |
3 | # Working directory
4 | # . or absolute path, please note that the directories following must be under root
5 | root = "."
6 | tmp_dir = "tmp_app"
7 |
8 | [build]
9 | # Just plain old shell command. You could use `make` as well.
10 | cmd = "go build -o ./tmp_app/app/engine ./app/."
11 | # Binary file yields from `cmd`.
12 | bin = "tmp_app/app"
13 | # Customize binary.
14 | full_bin = "./tmp_app/app/engine"
15 | # This log file places in your tmp_dir.
16 | log = "air_errors.log"
17 | # Watch these filename extensions.
18 | include_ext = ["go", "yaml", "toml"]
19 | # Ignore these filename extensions or directories.
20 | exclude_dir = ["tmp_app", "tmp"]
21 | # It's not necessary to trigger build each time file changes if it's too frequent.
22 | delay = 1000 # ms
23 |
24 | [log]
25 | # Show log time
26 | time = true
27 |
28 | [color]
29 | # Customize each part's color. If no color found, use the raw app log.
30 | main = "magenta"
31 | watcher = "cyan"
32 | build = "yellow"
33 | runner = "green"
34 |
35 | [misc]
36 | # Delete tmp directory on exit
37 | clean_on_exit = true
38 |
--------------------------------------------------------------------------------
/domain/article.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import (
4 | "context"
5 | "time"
6 | )
7 |
8 | // Article is representing the Article data struct
9 | type Article struct {
10 | ID int64 `json:"id"`
11 | Title string `json:"title" validate:"required"`
12 | Content string `json:"content" validate:"required"`
13 | Author Author `json:"author"`
14 | UpdatedAt time.Time `json:"updated_at"`
15 | CreatedAt time.Time `json:"created_at"`
16 | }
17 |
18 | // ArticleUsecase represent the article's usecases
19 | type ArticleUsecase interface {
20 | Fetch(ctx context.Context, cursor string, num int64) ([]Article, string, error)
21 | GetByID(ctx context.Context, id int64) (Article, error)
22 | Update(ctx context.Context, ar *Article) error
23 | GetByTitle(ctx context.Context, title string) (Article, error)
24 | Store(context.Context, *Article) error
25 | Delete(ctx context.Context, id int64) error
26 | }
27 |
28 | // ArticleRepository represent the article's repository contract
29 | type ArticleRepository interface {
30 | Fetch(ctx context.Context, cursor string, num int64) (res []Article, nextCursor string, err error)
31 | GetByID(ctx context.Context, id int64) (Article, error)
32 | GetByTitle(ctx context.Context, title string) (Article, error)
33 | Update(ctx context.Context, ar *Article) error
34 | Store(ctx context.Context, a *Article) error
35 | Delete(ctx context.Context, id int64) error
36 | }
37 |
--------------------------------------------------------------------------------
/misc/make/help.Makefile:
--------------------------------------------------------------------------------
1 | dep-gawk:
2 | @ if [ -z "$(shell command -v gawk)" ]; then \
3 | if [ -x /usr/local/bin/brew ]; then $(MAKE) _brew_gawk_install; exit 0; fi; \
4 | if [ -x /usr/bin/apt-get ]; then $(MAKE) _ubuntu_gawk_install; exit 0; fi; \
5 | if [ -x /usr/bin/yum ]; then $(MAKE) _centos_gawk_install; exit 0; fi; \
6 | if [ -x /sbin/apk ]; then $(MAKE) _alpine_gawk_install; exit 0; fi; \
7 | echo "GNU Awk Required, We cannot determine your OS or Package manager. Please install it yourself.";\
8 | exit 1; \
9 | fi
10 |
11 | _brew_gawk_install:
12 | @ echo "Instaling gawk using brew... "
13 | @ brew install gawk --quiet
14 | @ echo "done"
15 |
16 | _ubuntu_gawk_install:
17 | @ echo "Instaling gawk using apt-get... "
18 | @ apt-get -q install gawk -y
19 | @ echo "done"
20 |
21 | _alpine_gawk_install:
22 | @ echo "Instaling gawk using yum... "
23 | @ apk add --update --no-cache gawk
24 | @ echo "done"
25 |
26 | _centos_gawk_install:
27 | @ echo "Instaling gawk using yum... "
28 | @ yum install -q -y gawk;
29 | @ echo "done"
30 |
31 | help: dep-gawk
32 | @cat $(MAKEFILE_LIST) | \
33 | grep -E '^# ~~~ .*? [~]+$$|^[a-zA-Z0-9_-]+:.*?## .*$$' | \
34 | awk '{if ( $$1=="#" ) { \
35 | match($$0, /^# ~~~ (.+?) [~]+$$/, a);\
36 | {print "\n", a[1], ""}\
37 | } else { \
38 | match($$0, /^([0-9a-zA-Z_-]+):.*?## (.*)$$/, a); \
39 | {printf " - \033[32m%-20s\033[0m %s\n", a[1], a[2]} \
40 | }}'
41 | @echo ""
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/bxcodec/go-clean-arch
2 |
3 | go 1.12
4 |
5 | require (
6 | github.com/BurntSushi/toml v0.3.1 // indirect
7 | github.com/bxcodec/faker v1.4.2
8 | github.com/davecgh/go-spew v1.1.0 // indirect
9 | github.com/go-playground/locales v0.12.1 // indirect
10 | github.com/go-playground/universal-translator v0.16.0 // indirect
11 | github.com/go-sql-driver/mysql v1.3.0
12 | github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce // indirect
13 | github.com/labstack/echo v3.3.5+incompatible
14 | github.com/labstack/gommon v0.0.0-20180426014445-588f4e8bddc6 // indirect
15 | github.com/magiconair/properties v1.7.6 // indirect
16 | github.com/mattn/go-colorable v0.0.9 // indirect
17 | github.com/mattn/go-isatty v0.0.3 // indirect
18 | github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238 // indirect
19 | github.com/onsi/ginkgo v1.8.0 // indirect
20 | github.com/onsi/gomega v1.5.0 // indirect
21 | github.com/pelletier/go-toml v1.1.0 // indirect
22 | github.com/pmezard/go-difflib v1.0.0 // indirect
23 | github.com/sirupsen/logrus v1.0.5
24 | github.com/spf13/afero v1.1.0 // indirect
25 | github.com/spf13/cast v1.2.0 // indirect
26 | github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec // indirect
27 | github.com/spf13/pflag v1.0.1 // indirect
28 | github.com/spf13/viper v1.0.2
29 | github.com/stretchr/objx v0.1.0 // indirect
30 | github.com/stretchr/testify v1.2.1
31 | github.com/valyala/bytebufferpool v1.0.0 // indirect
32 | github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 // indirect
33 | golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94 // indirect
34 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f
35 | gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0
36 | gopkg.in/airbrake/gobrake.v2 v2.0.9 // indirect
37 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 // indirect
38 | gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
39 | gopkg.in/go-playground/validator.v9 v9.15.0
40 | )
41 |
--------------------------------------------------------------------------------
/app/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "log"
7 | "net/url"
8 | "time"
9 |
10 | _ "github.com/go-sql-driver/mysql"
11 | "github.com/labstack/echo"
12 | "github.com/spf13/viper"
13 |
14 | _articleHttpDelivery "github.com/bxcodec/go-clean-arch/article/delivery/http"
15 | _articleHttpDeliveryMiddleware "github.com/bxcodec/go-clean-arch/article/delivery/http/middleware"
16 | _articleRepo "github.com/bxcodec/go-clean-arch/article/repository/mysql"
17 | _articleUcase "github.com/bxcodec/go-clean-arch/article/usecase"
18 | _authorRepo "github.com/bxcodec/go-clean-arch/author/repository/mysql"
19 | )
20 |
21 | func init() {
22 | viper.SetConfigFile(`config.json`)
23 | err := viper.ReadInConfig()
24 | if err != nil {
25 | panic(err)
26 | }
27 |
28 | if viper.GetBool(`debug`) {
29 | log.Println("Service RUN on DEBUG mode")
30 | }
31 | }
32 |
33 | func main() {
34 | dbHost := viper.GetString(`database.host`)
35 | dbPort := viper.GetString(`database.port`)
36 | dbUser := viper.GetString(`database.user`)
37 | dbPass := viper.GetString(`database.pass`)
38 | dbName := viper.GetString(`database.name`)
39 | connection := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", dbUser, dbPass, dbHost, dbPort, dbName)
40 | val := url.Values{}
41 | val.Add("parseTime", "1")
42 | val.Add("loc", "Asia/Jakarta")
43 | dsn := fmt.Sprintf("%s?%s", connection, val.Encode())
44 | dbConn, err := sql.Open(`mysql`, dsn)
45 |
46 | if err != nil {
47 | log.Fatal(err)
48 | }
49 | err = dbConn.Ping()
50 | if err != nil {
51 | log.Fatal(err)
52 | }
53 |
54 | defer func() {
55 | err := dbConn.Close()
56 | if err != nil {
57 | log.Fatal(err)
58 | }
59 | }()
60 |
61 | e := echo.New()
62 | middL := _articleHttpDeliveryMiddleware.InitMiddleware()
63 | e.Use(middL.CORS)
64 | authorRepo := _authorRepo.NewMysqlAuthorRepository(dbConn)
65 | ar := _articleRepo.NewMysqlArticleRepository(dbConn)
66 |
67 | timeoutContext := time.Duration(viper.GetInt("context.timeout")) * time.Second
68 | au := _articleUcase.NewArticleUsecase(ar, authorRepo, timeoutContext)
69 | _articleHttpDelivery.NewArticleHandler(e, au)
70 |
71 | log.Fatal(e.Start(viper.GetString("server.address"))) //nolint
72 | }
73 |
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
1 | linters-settings:
2 | dupl:
3 | threshold: 300
4 | funlen:
5 | lines: 200
6 | statements: 55
7 | goconst:
8 | min-len: 2
9 | min-occurrences: 3
10 | gocritic:
11 | enabled-tags:
12 | - diagnostic
13 | - experimental
14 | - performance
15 | - style
16 | - opinionated
17 | disabled-checks:
18 | - dupImport # https://github.com/go-critic/go-critic/issues/845
19 | - ifElseChain
20 | - octalLiteral
21 | - whyNoLint
22 | - wrapperFunc
23 | - hugeParam
24 | gocyclo:
25 | min-complexity: 20
26 | gomnd:
27 | # don't include the "operation" and "assign"
28 | checks:
29 | - argument
30 | - case
31 | - condition
32 | - return
33 | ignored-numbers:
34 | - "0"
35 | - "1"
36 | - "2"
37 | - "3"
38 | ignored-functions:
39 | - strings.SplitN
40 | govet:
41 | check-shadowing: true
42 | settings:
43 | printf:
44 | funcs:
45 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof
46 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf
47 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf
48 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf
49 | lll:
50 | line-length: 160
51 | misspell:
52 | locale: US
53 | nolintlint:
54 | allow-leading-space: true # don't require machine-readable nolint directives (i.e. with no leading space)
55 | allow-unused: false # report any unused nolint directives
56 | require-explanation: false # don't require an explanation for nolint directives
57 | require-specific: false # don't require nolint directives to be specific about which linter is being skipped
58 |
59 | linters:
60 | disable-all: true
61 | enable:
62 | - asciicheck
63 | - bodyclose
64 | # - deadcode
65 | - depguard
66 | - dogsled
67 | - dupl
68 | - errcheck
69 | - exportloopref
70 | - funlen
71 | - gochecknoinits
72 | - goconst
73 | - gocritic
74 | - gocyclo
75 | - gofmt
76 | - goimports
77 | - gomnd
78 | - goprintffuncname
79 | - gosec
80 | - gosimple
81 | - ineffassign
82 | - lll
83 | - misspell
84 | - nakedret
85 | - noctx
86 | # - nolintlint
87 | - staticcheck
88 | # - structcheck
89 | - stylecheck
90 | - typecheck
91 | - unconvert
92 | - unparam
93 | - unused
94 | - revive
95 | # - varcheck
96 | - whitespace
97 |
98 | # don't enable:
99 | #
100 | # - scopelint
101 | # - gochecknoglobals
102 | # - gocognit
103 | # - godot
104 | # - godox
105 | # - goerr113
106 | # - interfacer
107 | # - maligned
108 | # - nestif
109 | # - prealloc
110 | # - testpackage
111 | # - wsl
112 |
113 | issues:
114 | # Excluding configuration per-path, per-linter, per-text and per-source
115 | exclude-rules:
116 | - path: _test\.go
117 | linters:
118 | - gomnd
119 | - dupl
120 | - goconst
121 |
122 | # - path: pkg/xerrors/xerrors.go
123 | # text: "SA1019: errCfg.Exclude is deprecated: use ExcludeFunctions instead"
124 | - path: app
125 | text: "don't use `init` function"
126 | run:
127 | timeout: 5m
128 | go: "1.17"
129 | skip-dirs:
130 | # - */mocks
131 |
--------------------------------------------------------------------------------
/domain/mocks/ArticleUsecase.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v1.0.0. DO NOT EDIT.
2 | package mocks
3 |
4 | import context "context"
5 | import domain "github.com/bxcodec/go-clean-arch/domain"
6 | import mock "github.com/stretchr/testify/mock"
7 |
8 | // ArticleUsecase is an autogenerated mock type for the ArticleUsecase type
9 | type ArticleUsecase struct {
10 | mock.Mock
11 | }
12 |
13 | // Delete provides a mock function with given fields: ctx, id
14 | func (_m *ArticleUsecase) Delete(ctx context.Context, id int64) error {
15 | ret := _m.Called(ctx, id)
16 |
17 | var r0 error
18 | if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
19 | r0 = rf(ctx, id)
20 | } else {
21 | r0 = ret.Error(0)
22 | }
23 |
24 | return r0
25 | }
26 |
27 | // Fetch provides a mock function with given fields: ctx, cursor, num
28 | func (_m *ArticleUsecase) Fetch(ctx context.Context, cursor string, num int64) ([]domain.Article, string, error) {
29 | ret := _m.Called(ctx, cursor, num)
30 |
31 | var r0 []domain.Article
32 | if rf, ok := ret.Get(0).(func(context.Context, string, int64) []domain.Article); ok {
33 | r0 = rf(ctx, cursor, num)
34 | } else {
35 | if ret.Get(0) != nil {
36 | r0 = ret.Get(0).([]domain.Article)
37 | }
38 | }
39 |
40 | var r1 string
41 | if rf, ok := ret.Get(1).(func(context.Context, string, int64) string); ok {
42 | r1 = rf(ctx, cursor, num)
43 | } else {
44 | r1 = ret.Get(1).(string)
45 | }
46 |
47 | var r2 error
48 | if rf, ok := ret.Get(2).(func(context.Context, string, int64) error); ok {
49 | r2 = rf(ctx, cursor, num)
50 | } else {
51 | r2 = ret.Error(2)
52 | }
53 |
54 | return r0, r1, r2
55 | }
56 |
57 | // GetByID provides a mock function with given fields: ctx, id
58 | func (_m *ArticleUsecase) GetByID(ctx context.Context, id int64) (domain.Article, error) {
59 | ret := _m.Called(ctx, id)
60 |
61 | var r0 domain.Article
62 | if rf, ok := ret.Get(0).(func(context.Context, int64) domain.Article); ok {
63 | r0 = rf(ctx, id)
64 | } else {
65 | r0 = ret.Get(0).(domain.Article)
66 | }
67 |
68 | var r1 error
69 | if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
70 | r1 = rf(ctx, id)
71 | } else {
72 | r1 = ret.Error(1)
73 | }
74 |
75 | return r0, r1
76 | }
77 |
78 | // GetByTitle provides a mock function with given fields: ctx, title
79 | func (_m *ArticleUsecase) GetByTitle(ctx context.Context, title string) (domain.Article, error) {
80 | ret := _m.Called(ctx, title)
81 |
82 | var r0 domain.Article
83 | if rf, ok := ret.Get(0).(func(context.Context, string) domain.Article); ok {
84 | r0 = rf(ctx, title)
85 | } else {
86 | r0 = ret.Get(0).(domain.Article)
87 | }
88 |
89 | var r1 error
90 | if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
91 | r1 = rf(ctx, title)
92 | } else {
93 | r1 = ret.Error(1)
94 | }
95 |
96 | return r0, r1
97 | }
98 |
99 | // Store provides a mock function with given fields: _a0, _a1
100 | func (_m *ArticleUsecase) Store(_a0 context.Context, _a1 *domain.Article) error {
101 | ret := _m.Called(_a0, _a1)
102 |
103 | var r0 error
104 | if rf, ok := ret.Get(0).(func(context.Context, *domain.Article) error); ok {
105 | r0 = rf(_a0, _a1)
106 | } else {
107 | r0 = ret.Error(0)
108 | }
109 |
110 | return r0
111 | }
112 |
113 | // Update provides a mock function with given fields: ctx, ar
114 | func (_m *ArticleUsecase) Update(ctx context.Context, ar *domain.Article) error {
115 | ret := _m.Called(ctx, ar)
116 |
117 | var r0 error
118 | if rf, ok := ret.Get(0).(func(context.Context, *domain.Article) error); ok {
119 | r0 = rf(ctx, ar)
120 | } else {
121 | r0 = ret.Error(0)
122 | }
123 |
124 | return r0
125 | }
126 |
--------------------------------------------------------------------------------
/domain/mocks/ArticleRepository.go:
--------------------------------------------------------------------------------
1 | // Code generated by mockery v1.0.0. DO NOT EDIT.
2 | package mocks
3 |
4 | import context "context"
5 | import domain "github.com/bxcodec/go-clean-arch/domain"
6 | import mock "github.com/stretchr/testify/mock"
7 |
8 | // ArticleRepository is an autogenerated mock type for the ArticleRepository type
9 | type ArticleRepository struct {
10 | mock.Mock
11 | }
12 |
13 | // Delete provides a mock function with given fields: ctx, id
14 | func (_m *ArticleRepository) Delete(ctx context.Context, id int64) error {
15 | ret := _m.Called(ctx, id)
16 |
17 | var r0 error
18 | if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
19 | r0 = rf(ctx, id)
20 | } else {
21 | r0 = ret.Error(0)
22 | }
23 |
24 | return r0
25 | }
26 |
27 | // Fetch provides a mock function with given fields: ctx, cursor, num
28 | func (_m *ArticleRepository) Fetch(ctx context.Context, cursor string, num int64) ([]domain.Article, string, error) {
29 | ret := _m.Called(ctx, cursor, num)
30 |
31 | var r0 []domain.Article
32 | if rf, ok := ret.Get(0).(func(context.Context, string, int64) []domain.Article); ok {
33 | r0 = rf(ctx, cursor, num)
34 | } else {
35 | if ret.Get(0) != nil {
36 | r0 = ret.Get(0).([]domain.Article)
37 | }
38 | }
39 |
40 | var r1 string
41 | if rf, ok := ret.Get(1).(func(context.Context, string, int64) string); ok {
42 | r1 = rf(ctx, cursor, num)
43 | } else {
44 | r1 = ret.Get(1).(string)
45 | }
46 |
47 | var r2 error
48 | if rf, ok := ret.Get(2).(func(context.Context, string, int64) error); ok {
49 | r2 = rf(ctx, cursor, num)
50 | } else {
51 | r2 = ret.Error(2)
52 | }
53 |
54 | return r0, r1, r2
55 | }
56 |
57 | // GetByID provides a mock function with given fields: ctx, id
58 | func (_m *ArticleRepository) GetByID(ctx context.Context, id int64) (domain.Article, error) {
59 | ret := _m.Called(ctx, id)
60 |
61 | var r0 domain.Article
62 | if rf, ok := ret.Get(0).(func(context.Context, int64) domain.Article); ok {
63 | r0 = rf(ctx, id)
64 | } else {
65 | r0 = ret.Get(0).(domain.Article)
66 | }
67 |
68 | var r1 error
69 | if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
70 | r1 = rf(ctx, id)
71 | } else {
72 | r1 = ret.Error(1)
73 | }
74 |
75 | return r0, r1
76 | }
77 |
78 | // GetByTitle provides a mock function with given fields: ctx, title
79 | func (_m *ArticleRepository) GetByTitle(ctx context.Context, title string) (domain.Article, error) {
80 | ret := _m.Called(ctx, title)
81 |
82 | var r0 domain.Article
83 | if rf, ok := ret.Get(0).(func(context.Context, string) domain.Article); ok {
84 | r0 = rf(ctx, title)
85 | } else {
86 | r0 = ret.Get(0).(domain.Article)
87 | }
88 |
89 | var r1 error
90 | if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
91 | r1 = rf(ctx, title)
92 | } else {
93 | r1 = ret.Error(1)
94 | }
95 |
96 | return r0, r1
97 | }
98 |
99 | // Store provides a mock function with given fields: _a0, _a1
100 | func (_m *ArticleRepository) Store(_a0 context.Context, _a1 *domain.Article) error {
101 | ret := _m.Called(_a0, _a1)
102 |
103 | var r0 error
104 | if rf, ok := ret.Get(0).(func(context.Context, *domain.Article) error); ok {
105 | r0 = rf(_a0, _a1)
106 | } else {
107 | r0 = ret.Error(0)
108 | }
109 |
110 | return r0
111 | }
112 |
113 | // Update provides a mock function with given fields: ctx, ar
114 | func (_m *ArticleRepository) Update(ctx context.Context, ar *domain.Article) error {
115 | ret := _m.Called(ctx, ar)
116 |
117 | var r0 error
118 | if rf, ok := ret.Get(0).(func(context.Context, *domain.Article) error); ok {
119 | r0 = rf(ctx, ar)
120 | } else {
121 | r0 = ret.Error(0)
122 | }
123 |
124 | return r0
125 | }
126 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # go-clean-arch
2 |
3 | ## Changelog
4 |
5 | - **v1**: checkout to the [v1 branch](https://github.com/bxcodec/go-clean-arch/tree/v1)
6 | Proposed on 2017, archived to v1 branch on 2018
7 | Desc: Initial proposal by me. The story can be read here: https://medium.com/@imantumorang/golang-clean-archithecture-efd6d7c43047
8 |
9 | - **v2**: checkout to the [v2 branch](https://github.com/bxcodec/go-clean-arch/tree/v2)
10 | Proposed on 2018, archived to v2 branch on 2020
11 | Desc: Improvement from v1. The story can be read here: https://medium.com/@imantumorang/trying-clean-architecture-on-golang-2-44d615bf8fdf
12 |
13 | - **v3**: master branch
14 | Proposed on 2019, merged to master on 2020.
15 | Desc: Introducing Domain package, the details can be seen on this PR [#21](https://github.com/bxcodec/go-clean-arch/pull/21)
16 |
17 | ## Description
18 |
19 | This is an example of implementation of Clean Architecture in Go (Golang) projects.
20 |
21 | Rule of Clean Architecture by Uncle Bob
22 |
23 | - Independent of Frameworks. The architecture does not depend on the existence of some library of feature laden software. This allows you to use such frameworks as tools, rather than having to cram your system into their limited constraints.
24 | - Testable. The business rules can be tested without the UI, Database, Web Server, or any other external element.
25 | - Independent of UI. The UI can change easily, without changing the rest of the system. A Web UI could be replaced with a console UI, for example, without changing the business rules.
26 | - Independent of Database. You can swap out Oracle or SQL Server, for Mongo, BigTable, CouchDB, or something else. Your business rules are not bound to the database.
27 | - Independent of any external agency. In fact your business rules simply don’t know anything at all about the outside world.
28 |
29 | More at https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html
30 |
31 | This project has 4 Domain layer :
32 |
33 | - Models Layer
34 | - Repository Layer
35 | - Usecase Layer
36 | - Delivery Layer
37 |
38 | #### The diagram:
39 |
40 | 
41 |
42 | The original explanation about this project's structure can read from this medium's post : https://medium.com/@imantumorang/golang-clean-archithecture-efd6d7c43047.
43 |
44 | It may different already, but the concept still the same in application level, also you can see the change log from v1 to current version in Master.
45 |
46 | ### How To Run This Project
47 |
48 | > Make Sure you have run the article.sql in your mysql
49 |
50 | Since the project already use Go Module, I recommend to put the source code in any folder but GOPATH.
51 |
52 | #### Run the Testing
53 |
54 | ```bash
55 | $ make tests
56 | ```
57 |
58 | #### Run the Applications
59 |
60 | Here is the steps to run it with `docker-compose`
61 |
62 | ```bash
63 | #move to directory
64 | $ cd workspace
65 |
66 | # Clone into your workspace
67 | $ git clone https://github.com/bxcodec/go-clean-arch.git
68 |
69 | #move to project
70 | $ cd go-clean-arch
71 |
72 | # Run the application
73 | $ make up
74 |
75 | # The hot reload will running
76 |
77 | # Execute the call in another terminal
78 | $ curl localhost:9090/articles
79 | ```
80 |
81 | ### Tools Used:
82 |
83 | In this project, I use some tools listed below. But you can use any simmilar library that have the same purposes. But, well, different library will have different implementation type. Just be creative and use anything that you really need.
84 |
85 | - All libraries listed in [`go.mod`](https://github.com/bxcodec/go-clean-arch/blob/master/go.mod)
86 | - ["github.com/vektra/mockery".](https://github.com/vektra/mockery) To Generate Mocks for testing needs.
87 |
--------------------------------------------------------------------------------
/misc/make/tools.Makefile:
--------------------------------------------------------------------------------
1 | # This makefile should be used to hold functions/variables
2 |
3 | define github_url
4 | https://github.com/$(GITHUB)/releases/download/v$(VERSION)/$(ARCHIVE)
5 | endef
6 |
7 | # creates a directory bin.
8 | bin:
9 | @ mkdir -p $@
10 |
11 | # ~~~ Tools ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
12 |
13 | # ~~ [migrate] ~~~ https://github.com/golang-migrate/migrate ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
14 |
15 | MIGRATE := $(shell command -v migrate || echo "bin/migrate")
16 | migrate: bin/migrate ## Install migrate (database migration)
17 |
18 | bin/migrate: VERSION := 4.14.1
19 | bin/migrate: GITHUB := golang-migrate/migrate
20 | bin/migrate: ARCHIVE := migrate.$(OSTYPE)-amd64.tar.gz
21 | bin/migrate: bin
22 | @ printf "Install migrate... "
23 | @ curl -Ls $(call github_url) | tar -zOxf - ./migrate.$(shell echo $(OSTYPE) | tr A-Z a-z)-amd64 > $@ && chmod +x $@
24 | @ echo "done."
25 |
26 | # ~~ [ air ] ~~~ https://github.com/cosmtrek/air ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
27 |
28 | AIR := $(shell command -v air || echo "bin/air")
29 | air: bin/air ## Installs air (go file watcher)
30 |
31 | bin/air: VERSION := 1.15.1
32 | bin/air: GITHUB := cosmtrek/air
33 | bin/air: ARCHIVE := air_$(VERSION)_$(OSTYPE)_amd64.tar.gz
34 | bin/air: bin
35 | @ printf "Install air... "
36 | @ curl -Ls $(shell echo $(call github_url) | tr A-Z a-z) | tar -zOxf - air > $@ && chmod +x $@
37 | @ echo "done."
38 |
39 |
40 | # ~~ [ gotestsum ] ~~~ https://github.com/gotestyourself/gotestsum ~~~~~~~~~~~~~~~~~~~~~~~
41 |
42 | GOTESTSUM := $(shell command -v gotestsum || echo "bin/gotestsum")
43 | gotestsum: bin/gotestsum ## Installs gotestsum (testing go code)
44 |
45 | bin/gotestsum: VERSION := 1.6.1
46 | bin/gotestsum: GITHUB := gotestyourself/gotestsum
47 | bin/gotestsum: ARCHIVE := gotestsum_$(VERSION)_$(OSTYPE)_amd64.tar.gz
48 | bin/gotestsum: bin
49 | @ printf "Install gotestsum... "
50 | @ curl -Ls $(shell echo $(call github_url) | tr A-Z a-z) | tar -zOxf - gotestsum > $@ && chmod +x $@
51 | @ echo "done."
52 |
53 | # ~~ [ tparse ] ~~~ https://github.com/mfridman/tparse ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
54 |
55 | TPARSE := $(shell command -v tparse || echo "bin/tparse")
56 | tparse: bin/tparse ## Installs tparse (testing go code)
57 |
58 | bin/tparse: VERSION := 0.8.3
59 | bin/tparse: GITHUB := mfridman/tparse
60 | bin/tparse: ARCHIVE := tparse_$(VERSION)_$(OSTYPE)_x86_64.tar.gz
61 | bin/tparse: bin
62 | @ printf "Install tparse... "
63 | @ curl -Ls $(call github_url) | tar -zOxf - tparse > $@ && chmod +x $@
64 | @ echo "done."
65 |
66 | # ~~ [ mockery ] ~~~ https://github.com/vektra/mockery ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
67 |
68 | MOCKERY := $(shell command -v mockery || echo "bin/mockery")
69 | mockery: bin/mockery ## Installs mockery (mocks generation)
70 |
71 | bin/mockery: VERSION := 2.5.1
72 | bin/mockery: GITHUB := vektra/mockery
73 | bin/mockery: ARCHIVE := mockery_$(VERSION)_$(OSTYPE)_x86_64.tar.gz
74 | bin/mockery: bin
75 | @ printf "Install mockery... "
76 | @ curl -Ls $(call github_url) | tar -zOxf - mockery > $@ && chmod +x $@
77 | @ echo "done."
78 |
79 | # ~~ [ golangci-lint ] ~~~ https://github.com/golangci/golangci-lint ~~~~~~~~~~~~~~~~~~~~~
80 |
81 | GOLANGCI := $(shell command -v golangci-lint || echo "bin/golangci-lint")
82 | golangci-lint: bin/golangci-lint ## Installs golangci-lint (linter)
83 |
84 | bin/golangci-lint: VERSION := 1.39.0
85 | bin/golangci-lint: GITHUB := golangci/golangci-lint
86 | bin/golangci-lint: ARCHIVE := golangci-lint-$(VERSION)-$(OSTYPE)-amd64.tar.gz
87 | bin/golangci-lint: bin
88 | @ printf "Install golangci-linter... "
89 | @ curl -Ls $(shell echo $(call github_url) | tr A-Z a-z) | tar -zOxf - $(shell printf golangci-lint-$(VERSION)-$(OSTYPE)-amd64/golangci-lint | tr A-Z a-z ) > $@ && chmod +x $@
90 | @ echo "done."
--------------------------------------------------------------------------------
/article/delivery/http/article_handler.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/labstack/echo"
8 | "github.com/sirupsen/logrus"
9 | validator "gopkg.in/go-playground/validator.v9"
10 |
11 | "github.com/bxcodec/go-clean-arch/domain"
12 | )
13 |
14 | // ResponseError represent the response error struct
15 | type ResponseError struct {
16 | Message string `json:"message"`
17 | }
18 |
19 | // ArticleHandler represent the httphandler for article
20 | type ArticleHandler struct {
21 | AUsecase domain.ArticleUsecase
22 | }
23 |
24 | // NewArticleHandler will initialize the articles/ resources endpoint
25 | func NewArticleHandler(e *echo.Echo, us domain.ArticleUsecase) {
26 | handler := &ArticleHandler{
27 | AUsecase: us,
28 | }
29 | e.GET("/articles", handler.FetchArticle)
30 | e.POST("/articles", handler.Store)
31 | e.GET("/articles/:id", handler.GetByID)
32 | e.DELETE("/articles/:id", handler.Delete)
33 | }
34 |
35 | // FetchArticle will fetch the article based on given params
36 | func (a *ArticleHandler) FetchArticle(c echo.Context) error {
37 | numS := c.QueryParam("num")
38 | num, _ := strconv.Atoi(numS)
39 | cursor := c.QueryParam("cursor")
40 | ctx := c.Request().Context()
41 |
42 | listAr, nextCursor, err := a.AUsecase.Fetch(ctx, cursor, int64(num))
43 | if err != nil {
44 | return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()})
45 | }
46 |
47 | c.Response().Header().Set(`X-Cursor`, nextCursor)
48 | return c.JSON(http.StatusOK, listAr)
49 | }
50 |
51 | // GetByID will get article by given id
52 | func (a *ArticleHandler) GetByID(c echo.Context) error {
53 | idP, err := strconv.Atoi(c.Param("id"))
54 | if err != nil {
55 | return c.JSON(http.StatusNotFound, domain.ErrNotFound.Error())
56 | }
57 |
58 | id := int64(idP)
59 | ctx := c.Request().Context()
60 |
61 | art, err := a.AUsecase.GetByID(ctx, id)
62 | if err != nil {
63 | return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()})
64 | }
65 |
66 | return c.JSON(http.StatusOK, art)
67 | }
68 |
69 | func isRequestValid(m *domain.Article) (bool, error) {
70 | validate := validator.New()
71 | err := validate.Struct(m)
72 | if err != nil {
73 | return false, err
74 | }
75 | return true, nil
76 | }
77 |
78 | // Store will store the article by given request body
79 | func (a *ArticleHandler) Store(c echo.Context) (err error) {
80 | var article domain.Article
81 | err = c.Bind(&article)
82 | if err != nil {
83 | return c.JSON(http.StatusUnprocessableEntity, err.Error())
84 | }
85 |
86 | var ok bool
87 | if ok, err = isRequestValid(&article); !ok {
88 | return c.JSON(http.StatusBadRequest, err.Error())
89 | }
90 |
91 | ctx := c.Request().Context()
92 | err = a.AUsecase.Store(ctx, &article)
93 | if err != nil {
94 | return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()})
95 | }
96 |
97 | return c.JSON(http.StatusCreated, article)
98 | }
99 |
100 | // Delete will delete article by given param
101 | func (a *ArticleHandler) Delete(c echo.Context) error {
102 | idP, err := strconv.Atoi(c.Param("id"))
103 | if err != nil {
104 | return c.JSON(http.StatusNotFound, domain.ErrNotFound.Error())
105 | }
106 |
107 | id := int64(idP)
108 | ctx := c.Request().Context()
109 |
110 | err = a.AUsecase.Delete(ctx, id)
111 | if err != nil {
112 | return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()})
113 | }
114 |
115 | return c.NoContent(http.StatusNoContent)
116 | }
117 |
118 | func getStatusCode(err error) int {
119 | if err == nil {
120 | return http.StatusOK
121 | }
122 |
123 | logrus.Error(err)
124 | switch err {
125 | case domain.ErrInternalServerError:
126 | return http.StatusInternalServerError
127 | case domain.ErrNotFound:
128 | return http.StatusNotFound
129 | case domain.ErrConflict:
130 | return http.StatusConflict
131 | default:
132 | return http.StatusInternalServerError
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/article/usecase/article_ucase.go:
--------------------------------------------------------------------------------
1 | package usecase
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/sirupsen/logrus"
8 | "golang.org/x/sync/errgroup"
9 |
10 | "github.com/bxcodec/go-clean-arch/domain"
11 | )
12 |
13 | type articleUsecase struct {
14 | articleRepo domain.ArticleRepository
15 | authorRepo domain.AuthorRepository
16 | contextTimeout time.Duration
17 | }
18 |
19 | // NewArticleUsecase will create new an articleUsecase object representation of domain.ArticleUsecase interface
20 | func NewArticleUsecase(a domain.ArticleRepository, ar domain.AuthorRepository, timeout time.Duration) domain.ArticleUsecase {
21 | return &articleUsecase{
22 | articleRepo: a,
23 | authorRepo: ar,
24 | contextTimeout: timeout,
25 | }
26 | }
27 |
28 | /*
29 | * In this function below, I'm using errgroup with the pipeline pattern
30 | * Look how this works in this package explanation
31 | * in godoc: https://godoc.org/golang.org/x/sync/errgroup#ex-Group--Pipeline
32 | */
33 | func (a *articleUsecase) fillAuthorDetails(c context.Context, data []domain.Article) ([]domain.Article, error) {
34 | g, ctx := errgroup.WithContext(c)
35 |
36 | // Get the author's id
37 | mapAuthors := map[int64]domain.Author{}
38 |
39 | for _, article := range data { //nolint
40 | mapAuthors[article.Author.ID] = domain.Author{}
41 | }
42 | // Using goroutine to fetch the author's detail
43 | chanAuthor := make(chan domain.Author)
44 | for authorID := range mapAuthors {
45 | authorID := authorID
46 | g.Go(func() error {
47 | res, err := a.authorRepo.GetByID(ctx, authorID)
48 | if err != nil {
49 | return err
50 | }
51 | chanAuthor <- res
52 | return nil
53 | })
54 | }
55 |
56 | go func() {
57 | err := g.Wait()
58 | if err != nil {
59 | logrus.Error(err)
60 | return
61 | }
62 | close(chanAuthor)
63 | }()
64 |
65 | for author := range chanAuthor {
66 | if author != (domain.Author{}) {
67 | mapAuthors[author.ID] = author
68 | }
69 | }
70 |
71 | if err := g.Wait(); err != nil {
72 | return nil, err
73 | }
74 |
75 | // merge the author's data
76 | for index, item := range data { //nolint
77 | if a, ok := mapAuthors[item.Author.ID]; ok {
78 | data[index].Author = a
79 | }
80 | }
81 | return data, nil
82 | }
83 |
84 | func (a *articleUsecase) Fetch(c context.Context, cursor string, num int64) (res []domain.Article, nextCursor string, err error) {
85 | if num == 0 {
86 | num = 10
87 | }
88 |
89 | ctx, cancel := context.WithTimeout(c, a.contextTimeout)
90 | defer cancel()
91 |
92 | res, nextCursor, err = a.articleRepo.Fetch(ctx, cursor, num)
93 | if err != nil {
94 | return nil, "", err
95 | }
96 |
97 | res, err = a.fillAuthorDetails(ctx, res)
98 | if err != nil {
99 | nextCursor = ""
100 | }
101 | return
102 | }
103 |
104 | func (a *articleUsecase) GetByID(c context.Context, id int64) (res domain.Article, err error) {
105 | ctx, cancel := context.WithTimeout(c, a.contextTimeout)
106 | defer cancel()
107 |
108 | res, err = a.articleRepo.GetByID(ctx, id)
109 | if err != nil {
110 | return
111 | }
112 |
113 | resAuthor, err := a.authorRepo.GetByID(ctx, res.Author.ID)
114 | if err != nil {
115 | return domain.Article{}, err
116 | }
117 | res.Author = resAuthor
118 | return
119 | }
120 |
121 | func (a *articleUsecase) Update(c context.Context, ar *domain.Article) (err error) {
122 | ctx, cancel := context.WithTimeout(c, a.contextTimeout)
123 | defer cancel()
124 |
125 | ar.UpdatedAt = time.Now()
126 | return a.articleRepo.Update(ctx, ar)
127 | }
128 |
129 | func (a *articleUsecase) GetByTitle(c context.Context, title string) (res domain.Article, err error) {
130 | ctx, cancel := context.WithTimeout(c, a.contextTimeout)
131 | defer cancel()
132 | res, err = a.articleRepo.GetByTitle(ctx, title)
133 | if err != nil {
134 | return
135 | }
136 |
137 | resAuthor, err := a.authorRepo.GetByID(ctx, res.Author.ID)
138 | if err != nil {
139 | return domain.Article{}, err
140 | }
141 |
142 | res.Author = resAuthor
143 | return
144 | }
145 |
146 | func (a *articleUsecase) Store(c context.Context, m *domain.Article) (err error) {
147 | ctx, cancel := context.WithTimeout(c, a.contextTimeout)
148 | defer cancel()
149 | existedArticle, _ := a.GetByTitle(ctx, m.Title)
150 | if existedArticle != (domain.Article{}) {
151 | return domain.ErrConflict
152 | }
153 |
154 | err = a.articleRepo.Store(ctx, m)
155 | return
156 | }
157 |
158 | func (a *articleUsecase) Delete(c context.Context, id int64) (err error) {
159 | ctx, cancel := context.WithTimeout(c, a.contextTimeout)
160 | defer cancel()
161 | existedArticle, err := a.articleRepo.GetByID(ctx, id)
162 | if err != nil {
163 | return
164 | }
165 | if existedArticle == (domain.Article{}) {
166 | return domain.ErrNotFound
167 | }
168 | return a.articleRepo.Delete(ctx, id)
169 | }
170 |
--------------------------------------------------------------------------------
/article/repository/mysql/mysql_article.go:
--------------------------------------------------------------------------------
1 | package mysql
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "fmt"
7 |
8 | "github.com/sirupsen/logrus"
9 |
10 | "github.com/bxcodec/go-clean-arch/article/repository"
11 | "github.com/bxcodec/go-clean-arch/domain"
12 | )
13 |
14 | type mysqlArticleRepository struct {
15 | Conn *sql.DB
16 | }
17 |
18 | // NewMysqlArticleRepository will create an object that represent the article.Repository interface
19 | func NewMysqlArticleRepository(conn *sql.DB) domain.ArticleRepository {
20 | return &mysqlArticleRepository{conn}
21 | }
22 |
23 | func (m *mysqlArticleRepository) fetch(ctx context.Context, query string, args ...interface{}) (result []domain.Article, err error) {
24 | rows, err := m.Conn.QueryContext(ctx, query, args...)
25 | if err != nil {
26 | logrus.Error(err)
27 | return nil, err
28 | }
29 |
30 | defer func() {
31 | errRow := rows.Close()
32 | if errRow != nil {
33 | logrus.Error(errRow)
34 | }
35 | }()
36 |
37 | result = make([]domain.Article, 0)
38 | for rows.Next() {
39 | t := domain.Article{}
40 | authorID := int64(0)
41 | err = rows.Scan(
42 | &t.ID,
43 | &t.Title,
44 | &t.Content,
45 | &authorID,
46 | &t.UpdatedAt,
47 | &t.CreatedAt,
48 | )
49 |
50 | if err != nil {
51 | logrus.Error(err)
52 | return nil, err
53 | }
54 | t.Author = domain.Author{
55 | ID: authorID,
56 | }
57 | result = append(result, t)
58 | }
59 |
60 | return result, nil
61 | }
62 |
63 | func (m *mysqlArticleRepository) Fetch(ctx context.Context, cursor string, num int64) (res []domain.Article, nextCursor string, err error) {
64 | query := `SELECT id,title,content, author_id, updated_at, created_at
65 | FROM article WHERE created_at > ? ORDER BY created_at LIMIT ? `
66 |
67 | decodedCursor, err := repository.DecodeCursor(cursor)
68 | if err != nil && cursor != "" {
69 | return nil, "", domain.ErrBadParamInput
70 | }
71 |
72 | res, err = m.fetch(ctx, query, decodedCursor, num)
73 | if err != nil {
74 | return nil, "", err
75 | }
76 |
77 | if len(res) == int(num) {
78 | nextCursor = repository.EncodeCursor(res[len(res)-1].CreatedAt)
79 | }
80 |
81 | return
82 | }
83 | func (m *mysqlArticleRepository) GetByID(ctx context.Context, id int64) (res domain.Article, err error) {
84 | query := `SELECT id,title,content, author_id, updated_at, created_at
85 | FROM article WHERE ID = ?`
86 |
87 | list, err := m.fetch(ctx, query, id)
88 | if err != nil {
89 | return domain.Article{}, err
90 | }
91 |
92 | if len(list) > 0 {
93 | res = list[0]
94 | } else {
95 | return res, domain.ErrNotFound
96 | }
97 |
98 | return
99 | }
100 |
101 | func (m *mysqlArticleRepository) GetByTitle(ctx context.Context, title string) (res domain.Article, err error) {
102 | query := `SELECT id,title,content, author_id, updated_at, created_at
103 | FROM article WHERE title = ?`
104 |
105 | list, err := m.fetch(ctx, query, title)
106 | if err != nil {
107 | return
108 | }
109 |
110 | if len(list) > 0 {
111 | res = list[0]
112 | } else {
113 | return res, domain.ErrNotFound
114 | }
115 | return
116 | }
117 |
118 | func (m *mysqlArticleRepository) Store(ctx context.Context, a *domain.Article) (err error) {
119 | query := `INSERT article SET title=? , content=? , author_id=?, updated_at=? , created_at=?`
120 | stmt, err := m.Conn.PrepareContext(ctx, query)
121 | if err != nil {
122 | return
123 | }
124 |
125 | res, err := stmt.ExecContext(ctx, a.Title, a.Content, a.Author.ID, a.UpdatedAt, a.CreatedAt)
126 | if err != nil {
127 | return
128 | }
129 | lastID, err := res.LastInsertId()
130 | if err != nil {
131 | return
132 | }
133 | a.ID = lastID
134 | return
135 | }
136 |
137 | func (m *mysqlArticleRepository) Delete(ctx context.Context, id int64) (err error) {
138 | query := "DELETE FROM article WHERE id = ?"
139 |
140 | stmt, err := m.Conn.PrepareContext(ctx, query)
141 | if err != nil {
142 | return
143 | }
144 |
145 | res, err := stmt.ExecContext(ctx, id)
146 | if err != nil {
147 | return
148 | }
149 |
150 | rowsAfected, err := res.RowsAffected()
151 | if err != nil {
152 | return
153 | }
154 |
155 | if rowsAfected != 1 {
156 | err = fmt.Errorf("weird Behavior. Total Affected: %d", rowsAfected)
157 | return
158 | }
159 |
160 | return
161 | }
162 | func (m *mysqlArticleRepository) Update(ctx context.Context, ar *domain.Article) (err error) {
163 | query := `UPDATE article set title=?, content=?, author_id=?, updated_at=? WHERE ID = ?`
164 |
165 | stmt, err := m.Conn.PrepareContext(ctx, query)
166 | if err != nil {
167 | return
168 | }
169 |
170 | res, err := stmt.ExecContext(ctx, ar.Title, ar.Content, ar.Author.ID, ar.UpdatedAt, ar.ID)
171 | if err != nil {
172 | return
173 | }
174 | affect, err := res.RowsAffected()
175 | if err != nil {
176 | return
177 | }
178 | if affect != 1 {
179 | err = fmt.Errorf("weird Behavior. Total Affected: %d", affect)
180 | return
181 | }
182 |
183 | return
184 | }
185 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Database
2 | MYSQL_USER ?= user
3 | MYSQL_PASSWORD ?= password
4 | MYSQL_ADDRESS ?= 127.0.0.1:3306
5 | MYSQL_DATABASE ?= article
6 |
7 | # Exporting bin folder to the path for makefile
8 | export PATH := $(PWD)/bin:$(PATH)
9 | # Default Shell
10 | export SHELL := bash
11 | # Type of OS: Linux or Darwin.
12 | export OSTYPE := $(shell uname -s)
13 |
14 |
15 | # --- Tooling & Variables ----------------------------------------------------------------
16 | include ./misc/make/tools.Makefile
17 | include ./misc/make/help.Makefile
18 |
19 | # ~~~ Development Environment ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
20 |
21 | up: dev-env dev-air ## Startup / Spinup Docker Compose and air
22 | down: docker-stop ## Stop Docker
23 | destroy: docker-teardown clean ## Teardown (removes volumes, tmp files, etc...)
24 |
25 | install-deps: migrate air gotestsum tparse mockery ## Install Development Dependencies (localy).
26 | deps: $(MIGRATE) $(AIR) $(GOTESTSUM) $(TPARSE) $(MOCKERY) ## Checks for Global Development Dependencies.
27 | deps:
28 | @echo "Required Tools Are Available"
29 |
30 | dev-env: ## Bootstrap Environment (with a Docker-Compose help).
31 | @ docker-compose up -d --build mysql
32 |
33 | dev-env-test: dev-env ## Run application (within a Docker-Compose help)
34 | @ $(MAKE) image-build
35 | docker-compose up web
36 |
37 | dev-air: $(AIR) ## Starts AIR ( Continuous Development app).
38 | air
39 |
40 | docker-stop:
41 | @ docker-compose down
42 |
43 | docker-teardown:
44 | @ docker-compose down --remove-orphans -v
45 |
46 | # ~~~ Code Actions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
47 |
48 | lint: $(GOLANGCI) ## Runs golangci-lint with predefined configuration
49 | @echo "Applying linter"
50 | golangci-lint version
51 | golangci-lint run -c .golangci.yaml ./...
52 |
53 | # -trimpath - will remove the filepathes from the reports, good to same money on network trafic,
54 | # focus on bug reports, and find issues fast.
55 | # - race - adds a racedetector, in case of racecondition, you can catch report with sentry.
56 | # https://golang.org/doc/articles/race_detector.html
57 | #
58 | # todo(butuzov): add additional flags to compiler to have an `version` flag.
59 | build: ## Builds binary
60 | @ printf "Building aplication... "
61 | @ go build \
62 | -trimpath \
63 | -o engine \
64 | ./app/
65 | @ echo "done"
66 |
67 |
68 | build-race: ## Builds binary (with -race flag)
69 | @ printf "Building aplication with race flag... "
70 | @ go build \
71 | -trimpath \
72 | -race \
73 | -o engine \
74 | ./app/
75 | @ echo "done"
76 |
77 |
78 | go-generate: $(MOCKERY) ## Runs go generte ./...
79 | go generate ./...
80 |
81 |
82 | TESTS_ARGS := --format testname --jsonfile gotestsum.json.out
83 | TESTS_ARGS += --max-fails 2
84 | TESTS_ARGS += -- ./...
85 | TESTS_ARGS += -test.parallel 2
86 | TESTS_ARGS += -test.count 1
87 | TESTS_ARGS += -test.failfast
88 | TESTS_ARGS += -test.coverprofile coverage.out
89 | TESTS_ARGS += -test.timeout 5s
90 | TESTS_ARGS += -race
91 |
92 | run-tests: $(GOTESTSUM)
93 | @ gotestsum $(TESTS_ARGS) -short
94 |
95 | tests: run-tests $(TPARSE) ## Run Tests & parse details
96 | @cat gotestsum.json.out | $(TPARSE) -all -top -notests
97 |
98 | # ~~~ Docker Build ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
99 |
100 | .ONESHELL:
101 | image-build:
102 | @ echo "Docker Build"
103 | @ DOCKER_BUILDKIT=0 docker build \
104 | --file Dockerfile \
105 | --tag go-clean-arch \
106 | .
107 |
108 | # ~~~ Database Migrations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
109 |
110 |
111 | MYSQL_DSN := "mysql://$(MYSQL_USER):$(MYSQL_PASSWORD)@tcp($(MYSQL_ADDRESS))/$(MYSQL_DATABASE)"
112 |
113 | migrate-up: $(MIGRATE) ## Apply all (or N up) migrations.
114 | @ read -p "How many migration you wants to perform (default value: [all]): " N; \
115 | migrate -database $(MYSQL_DSN) -path=misc/migrations up ${NN}
116 |
117 | .PHONY: migrate-down
118 | migrate-down: $(MIGRATE) ## Apply all (or N down) migrations.
119 | @ read -p "How many migration you wants to perform (default value: [all]): " N; \
120 | migrate -database $(MYSQL_DSN) -path=misc/migrations down ${NN}
121 |
122 | .PHONY: migrate-drop
123 | migrate-drop: $(MIGRATE) ## Drop everything inside the database.
124 | migrate -database $(MYSQL_DSN) -path=misc/migrations drop
125 |
126 | .PHONY: migrate-create
127 | migrate-create: $(MIGRATE) ## Create a set of up/down migrations with a specified name.
128 | @ read -p "Please provide name for the migration: " Name; \
129 | migrate create -ext sql -dir misc/migrations $${Name}
130 |
131 | # ~~~ Cleans ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
132 |
133 | clean: clean-artifacts clean-docker
134 |
135 | clean-artifacts: ## Removes Artifacts (*.out)
136 | @printf "Cleanning artifacts... "
137 | @rm -f *.out
138 | @echo "done."
139 |
140 |
141 | clean-docker: ## Removes dangling docker images
142 | @ docker image prune -f
143 |
--------------------------------------------------------------------------------
/article/delivery/http/article_test.go:
--------------------------------------------------------------------------------
1 | package http_test
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "net/http"
7 | "net/http/httptest"
8 | "strconv"
9 | "strings"
10 | "testing"
11 | "time"
12 |
13 | "github.com/bxcodec/faker"
14 | "github.com/labstack/echo"
15 | "github.com/stretchr/testify/assert"
16 | "github.com/stretchr/testify/mock"
17 | "github.com/stretchr/testify/require"
18 |
19 | articleHttp "github.com/bxcodec/go-clean-arch/article/delivery/http"
20 | "github.com/bxcodec/go-clean-arch/domain"
21 | "github.com/bxcodec/go-clean-arch/domain/mocks"
22 | )
23 |
24 | func TestFetch(t *testing.T) {
25 | var mockArticle domain.Article
26 | err := faker.FakeData(&mockArticle)
27 | assert.NoError(t, err)
28 | mockUCase := new(mocks.ArticleUsecase)
29 | mockListArticle := make([]domain.Article, 0)
30 | mockListArticle = append(mockListArticle, mockArticle)
31 | num := 1
32 | cursor := "2"
33 | mockUCase.On("Fetch", mock.Anything, cursor, int64(num)).Return(mockListArticle, "10", nil)
34 |
35 | e := echo.New()
36 | req, err := http.NewRequestWithContext(context.TODO(), echo.GET, "/article?num=1&cursor="+cursor, strings.NewReader(""))
37 | assert.NoError(t, err)
38 |
39 | rec := httptest.NewRecorder()
40 | c := e.NewContext(req, rec)
41 | handler := articleHttp.ArticleHandler{
42 | AUsecase: mockUCase,
43 | }
44 | err = handler.FetchArticle(c)
45 | require.NoError(t, err)
46 |
47 | responseCursor := rec.Header().Get("X-Cursor")
48 | assert.Equal(t, "10", responseCursor)
49 | assert.Equal(t, http.StatusOK, rec.Code)
50 | mockUCase.AssertExpectations(t)
51 | }
52 |
53 | func TestFetchError(t *testing.T) {
54 | mockUCase := new(mocks.ArticleUsecase)
55 | num := 1
56 | cursor := "2"
57 | mockUCase.On("Fetch", mock.Anything, cursor, int64(num)).Return(nil, "", domain.ErrInternalServerError)
58 |
59 | e := echo.New()
60 | req, err := http.NewRequestWithContext(context.TODO(), echo.GET, "/article?num=1&cursor="+cursor, strings.NewReader(""))
61 | assert.NoError(t, err)
62 |
63 | rec := httptest.NewRecorder()
64 | c := e.NewContext(req, rec)
65 | handler := articleHttp.ArticleHandler{
66 | AUsecase: mockUCase,
67 | }
68 | err = handler.FetchArticle(c)
69 | require.NoError(t, err)
70 |
71 | responseCursor := rec.Header().Get("X-Cursor")
72 | assert.Equal(t, "", responseCursor)
73 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
74 | mockUCase.AssertExpectations(t)
75 | }
76 |
77 | func TestGetByID(t *testing.T) {
78 | var mockArticle domain.Article
79 | err := faker.FakeData(&mockArticle)
80 | assert.NoError(t, err)
81 |
82 | mockUCase := new(mocks.ArticleUsecase)
83 |
84 | num := int(mockArticle.ID)
85 |
86 | mockUCase.On("GetByID", mock.Anything, int64(num)).Return(mockArticle, nil)
87 |
88 | e := echo.New()
89 | req, err := http.NewRequestWithContext(context.TODO(), echo.GET, "/article/"+strconv.Itoa(num), strings.NewReader(""))
90 | assert.NoError(t, err)
91 |
92 | rec := httptest.NewRecorder()
93 | c := e.NewContext(req, rec)
94 | c.SetPath("article/:id")
95 | c.SetParamNames("id")
96 | c.SetParamValues(strconv.Itoa(num))
97 | handler := articleHttp.ArticleHandler{
98 | AUsecase: mockUCase,
99 | }
100 | err = handler.GetByID(c)
101 | require.NoError(t, err)
102 |
103 | assert.Equal(t, http.StatusOK, rec.Code)
104 | mockUCase.AssertExpectations(t)
105 | }
106 |
107 | func TestStore(t *testing.T) {
108 | mockArticle := domain.Article{
109 | Title: "Title",
110 | Content: "Content",
111 | CreatedAt: time.Now(),
112 | UpdatedAt: time.Now(),
113 | }
114 |
115 | tempMockArticle := mockArticle
116 | tempMockArticle.ID = 0
117 | mockUCase := new(mocks.ArticleUsecase)
118 |
119 | j, err := json.Marshal(tempMockArticle)
120 | assert.NoError(t, err)
121 |
122 | mockUCase.On("Store", mock.Anything, mock.AnythingOfType("*domain.Article")).Return(nil)
123 |
124 | e := echo.New()
125 | req, err := http.NewRequestWithContext(context.TODO(), echo.POST, "/article", strings.NewReader(string(j)))
126 | assert.NoError(t, err)
127 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
128 |
129 | rec := httptest.NewRecorder()
130 | c := e.NewContext(req, rec)
131 | c.SetPath("/article")
132 |
133 | handler := articleHttp.ArticleHandler{
134 | AUsecase: mockUCase,
135 | }
136 | err = handler.Store(c)
137 | require.NoError(t, err)
138 |
139 | assert.Equal(t, http.StatusCreated, rec.Code)
140 | mockUCase.AssertExpectations(t)
141 | }
142 |
143 | func TestDelete(t *testing.T) {
144 | var mockArticle domain.Article
145 | err := faker.FakeData(&mockArticle)
146 | assert.NoError(t, err)
147 |
148 | mockUCase := new(mocks.ArticleUsecase)
149 |
150 | num := int(mockArticle.ID)
151 |
152 | mockUCase.On("Delete", mock.Anything, int64(num)).Return(nil)
153 |
154 | e := echo.New()
155 | req, err := http.NewRequestWithContext(context.TODO(), echo.DELETE, "/article/"+strconv.Itoa(num), strings.NewReader(""))
156 | assert.NoError(t, err)
157 |
158 | rec := httptest.NewRecorder()
159 | c := e.NewContext(req, rec)
160 | c.SetPath("article/:id")
161 | c.SetParamNames("id")
162 | c.SetParamValues(strconv.Itoa(num))
163 | handler := articleHttp.ArticleHandler{
164 | AUsecase: mockUCase,
165 | }
166 | err = handler.Delete(c)
167 | require.NoError(t, err)
168 |
169 | assert.Equal(t, http.StatusNoContent, rec.Code)
170 | mockUCase.AssertExpectations(t)
171 | }
172 |
--------------------------------------------------------------------------------
/article/repository/mysql/mysqlarticle_test.go:
--------------------------------------------------------------------------------
1 | package mysql_test
2 |
3 | import (
4 | "context"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 | sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1"
10 |
11 | "github.com/bxcodec/go-clean-arch/article/repository"
12 | articleMysqlRepo "github.com/bxcodec/go-clean-arch/article/repository/mysql"
13 | "github.com/bxcodec/go-clean-arch/domain"
14 | )
15 |
16 | func TestFetch(t *testing.T) {
17 | db, mock, err := sqlmock.New()
18 | if err != nil {
19 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
20 | }
21 |
22 | mockArticles := []domain.Article{
23 | {
24 | ID: 1, Title: "title 1", Content: "content 1",
25 | Author: domain.Author{ID: 1}, UpdatedAt: time.Now(), CreatedAt: time.Now(),
26 | },
27 | {
28 | ID: 2, Title: "title 2", Content: "content 2",
29 | Author: domain.Author{ID: 1}, UpdatedAt: time.Now(), CreatedAt: time.Now(),
30 | },
31 | }
32 |
33 | rows := sqlmock.NewRows([]string{"id", "title", "content", "author_id", "updated_at", "created_at"}).
34 | AddRow(mockArticles[0].ID, mockArticles[0].Title, mockArticles[0].Content,
35 | mockArticles[0].Author.ID, mockArticles[0].UpdatedAt, mockArticles[0].CreatedAt).
36 | AddRow(mockArticles[1].ID, mockArticles[1].Title, mockArticles[1].Content,
37 | mockArticles[1].Author.ID, mockArticles[1].UpdatedAt, mockArticles[1].CreatedAt)
38 |
39 | query := "SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE created_at > \\? ORDER BY created_at LIMIT \\?"
40 |
41 | mock.ExpectQuery(query).WillReturnRows(rows)
42 | a := articleMysqlRepo.NewMysqlArticleRepository(db)
43 | cursor := repository.EncodeCursor(mockArticles[1].CreatedAt)
44 | num := int64(2)
45 | list, nextCursor, err := a.Fetch(context.TODO(), cursor, num)
46 | assert.NotEmpty(t, nextCursor)
47 | assert.NoError(t, err)
48 | assert.Len(t, list, 2)
49 | }
50 |
51 | func TestGetByID(t *testing.T) {
52 | db, mock, err := sqlmock.New()
53 | if err != nil {
54 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
55 | }
56 |
57 | rows := sqlmock.NewRows([]string{"id", "title", "content", "author_id", "updated_at", "created_at"}).
58 | AddRow(1, "title 1", "Content 1", 1, time.Now(), time.Now())
59 |
60 | query := "SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE ID = \\?"
61 |
62 | mock.ExpectQuery(query).WillReturnRows(rows)
63 | a := articleMysqlRepo.NewMysqlArticleRepository(db)
64 |
65 | num := int64(5)
66 | anArticle, err := a.GetByID(context.TODO(), num)
67 | assert.NoError(t, err)
68 | assert.NotNil(t, anArticle)
69 | }
70 |
71 | func TestStore(t *testing.T) {
72 | now := time.Now()
73 | ar := &domain.Article{
74 | Title: "Judul",
75 | Content: "Content",
76 | CreatedAt: now,
77 | UpdatedAt: now,
78 | Author: domain.Author{
79 | ID: 1,
80 | Name: "Iman Tumorang",
81 | },
82 | }
83 | db, mock, err := sqlmock.New()
84 | if err != nil {
85 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
86 | }
87 |
88 | query := "INSERT article SET title=\\? , content=\\? , author_id=\\?, updated_at=\\? , created_at=\\?"
89 | prep := mock.ExpectPrepare(query)
90 | prep.ExpectExec().WithArgs(ar.Title, ar.Content, ar.Author.ID, ar.CreatedAt, ar.UpdatedAt).WillReturnResult(sqlmock.NewResult(12, 1))
91 |
92 | a := articleMysqlRepo.NewMysqlArticleRepository(db)
93 |
94 | err = a.Store(context.TODO(), ar)
95 | assert.NoError(t, err)
96 | assert.Equal(t, int64(12), ar.ID)
97 | }
98 |
99 | func TestGetByTitle(t *testing.T) {
100 | db, mock, err := sqlmock.New()
101 | if err != nil {
102 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
103 | }
104 |
105 | rows := sqlmock.NewRows([]string{"id", "title", "content", "author_id", "updated_at", "created_at"}).
106 | AddRow(1, "title 1", "Content 1", 1, time.Now(), time.Now())
107 |
108 | query := "SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE title = \\?"
109 |
110 | mock.ExpectQuery(query).WillReturnRows(rows)
111 | a := articleMysqlRepo.NewMysqlArticleRepository(db)
112 |
113 | title := "title 1"
114 | anArticle, err := a.GetByTitle(context.TODO(), title)
115 | assert.NoError(t, err)
116 | assert.NotNil(t, anArticle)
117 | }
118 |
119 | func TestDelete(t *testing.T) {
120 | db, mock, err := sqlmock.New()
121 | if err != nil {
122 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
123 | }
124 |
125 | query := "DELETE FROM article WHERE id = \\?"
126 |
127 | prep := mock.ExpectPrepare(query)
128 | prep.ExpectExec().WithArgs(12).WillReturnResult(sqlmock.NewResult(12, 1))
129 |
130 | a := articleMysqlRepo.NewMysqlArticleRepository(db)
131 |
132 | num := int64(12)
133 | err = a.Delete(context.TODO(), num)
134 | assert.NoError(t, err)
135 | }
136 |
137 | func TestUpdate(t *testing.T) {
138 | now := time.Now()
139 | ar := &domain.Article{
140 | ID: 12,
141 | Title: "Judul",
142 | Content: "Content",
143 | CreatedAt: now,
144 | UpdatedAt: now,
145 | Author: domain.Author{
146 | ID: 1,
147 | Name: "Iman Tumorang",
148 | },
149 | }
150 |
151 | db, mock, err := sqlmock.New()
152 | if err != nil {
153 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
154 | }
155 |
156 | query := "UPDATE article set title=\\?, content=\\?, author_id=\\?, updated_at=\\? WHERE ID = \\?"
157 |
158 | prep := mock.ExpectPrepare(query)
159 | prep.ExpectExec().WithArgs(ar.Title, ar.Content, ar.Author.ID, ar.UpdatedAt, ar.ID).WillReturnResult(sqlmock.NewResult(12, 1))
160 |
161 | a := articleMysqlRepo.NewMysqlArticleRepository(db)
162 |
163 | err = a.Update(context.TODO(), ar)
164 | assert.NoError(t, err)
165 | }
166 |
--------------------------------------------------------------------------------
/article/usecase/article_ucase_test.go:
--------------------------------------------------------------------------------
1 | package usecase_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/mock"
11 |
12 | ucase "github.com/bxcodec/go-clean-arch/article/usecase"
13 | "github.com/bxcodec/go-clean-arch/domain"
14 | "github.com/bxcodec/go-clean-arch/domain/mocks"
15 | )
16 |
17 | func TestFetch(t *testing.T) {
18 | mockArticleRepo := new(mocks.ArticleRepository)
19 | mockArticle := domain.Article{
20 | Title: "Hello",
21 | Content: "Content",
22 | }
23 |
24 | mockListArtilce := make([]domain.Article, 0)
25 | mockListArtilce = append(mockListArtilce, mockArticle)
26 |
27 | t.Run("success", func(t *testing.T) {
28 | mockArticleRepo.On("Fetch", mock.Anything, mock.AnythingOfType("string"),
29 | mock.AnythingOfType("int64")).Return(mockListArtilce, "next-cursor", nil).Once()
30 | mockAuthor := domain.Author{
31 | ID: 1,
32 | Name: "Iman Tumorang",
33 | }
34 | mockAuthorrepo := new(mocks.AuthorRepository)
35 | mockAuthorrepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockAuthor, nil)
36 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
37 | num := int64(1)
38 | cursor := "12"
39 | list, nextCursor, err := u.Fetch(context.TODO(), cursor, num)
40 | cursorExpected := "next-cursor"
41 | assert.Equal(t, cursorExpected, nextCursor)
42 | assert.NotEmpty(t, nextCursor)
43 | assert.NoError(t, err)
44 | assert.Len(t, list, len(mockListArtilce))
45 |
46 | mockArticleRepo.AssertExpectations(t)
47 | mockAuthorrepo.AssertExpectations(t)
48 | })
49 |
50 | t.Run("error-failed", func(t *testing.T) {
51 | mockArticleRepo.On("Fetch", mock.Anything, mock.AnythingOfType("string"),
52 | mock.AnythingOfType("int64")).Return(nil, "", errors.New("Unexpexted Error")).Once()
53 |
54 | mockAuthorrepo := new(mocks.AuthorRepository)
55 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
56 | num := int64(1)
57 | cursor := "12"
58 | list, nextCursor, err := u.Fetch(context.TODO(), cursor, num)
59 |
60 | assert.Empty(t, nextCursor)
61 | assert.Error(t, err)
62 | assert.Len(t, list, 0)
63 | mockArticleRepo.AssertExpectations(t)
64 | mockAuthorrepo.AssertExpectations(t)
65 | })
66 | }
67 |
68 | func TestGetByID(t *testing.T) {
69 | mockArticleRepo := new(mocks.ArticleRepository)
70 | mockArticle := domain.Article{
71 | Title: "Hello",
72 | Content: "Content",
73 | }
74 | mockAuthor := domain.Author{
75 | ID: 1,
76 | Name: "Iman Tumorang",
77 | }
78 |
79 | t.Run("success", func(t *testing.T) {
80 | mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockArticle, nil).Once()
81 | mockAuthorrepo := new(mocks.AuthorRepository)
82 | mockAuthorrepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockAuthor, nil)
83 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
84 |
85 | a, err := u.GetByID(context.TODO(), mockArticle.ID)
86 |
87 | assert.NoError(t, err)
88 | assert.NotNil(t, a)
89 |
90 | mockArticleRepo.AssertExpectations(t)
91 | mockAuthorrepo.AssertExpectations(t)
92 | })
93 | t.Run("error-failed", func(t *testing.T) {
94 | mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(domain.Article{}, errors.New("Unexpected")).Once()
95 |
96 | mockAuthorrepo := new(mocks.AuthorRepository)
97 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
98 |
99 | a, err := u.GetByID(context.TODO(), mockArticle.ID)
100 |
101 | assert.Error(t, err)
102 | assert.Equal(t, domain.Article{}, a)
103 |
104 | mockArticleRepo.AssertExpectations(t)
105 | mockAuthorrepo.AssertExpectations(t)
106 | })
107 | }
108 |
109 | func TestStore(t *testing.T) {
110 | mockArticleRepo := new(mocks.ArticleRepository)
111 | mockArticle := domain.Article{
112 | Title: "Hello",
113 | Content: "Content",
114 | }
115 |
116 | t.Run("success", func(t *testing.T) {
117 | tempMockArticle := mockArticle
118 | tempMockArticle.ID = 0
119 | mockArticleRepo.On("GetByTitle", mock.Anything, mock.AnythingOfType("string")).Return(domain.Article{}, domain.ErrNotFound).Once()
120 | mockArticleRepo.On("Store", mock.Anything, mock.AnythingOfType("*domain.Article")).Return(nil).Once()
121 |
122 | mockAuthorrepo := new(mocks.AuthorRepository)
123 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
124 |
125 | err := u.Store(context.TODO(), &tempMockArticle)
126 |
127 | assert.NoError(t, err)
128 | assert.Equal(t, mockArticle.Title, tempMockArticle.Title)
129 | mockArticleRepo.AssertExpectations(t)
130 | })
131 | t.Run("existing-title", func(t *testing.T) {
132 | existingArticle := mockArticle
133 | mockArticleRepo.On("GetByTitle", mock.Anything, mock.AnythingOfType("string")).Return(existingArticle, nil).Once()
134 | mockAuthor := domain.Author{
135 | ID: 1,
136 | Name: "Iman Tumorang",
137 | }
138 | mockAuthorrepo := new(mocks.AuthorRepository)
139 | mockAuthorrepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockAuthor, nil)
140 |
141 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
142 |
143 | err := u.Store(context.TODO(), &mockArticle)
144 |
145 | assert.Error(t, err)
146 | mockArticleRepo.AssertExpectations(t)
147 | mockAuthorrepo.AssertExpectations(t)
148 | })
149 | }
150 |
151 | func TestDelete(t *testing.T) {
152 | mockArticleRepo := new(mocks.ArticleRepository)
153 | mockArticle := domain.Article{
154 | Title: "Hello",
155 | Content: "Content",
156 | }
157 |
158 | t.Run("success", func(t *testing.T) {
159 | mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockArticle, nil).Once()
160 |
161 | mockArticleRepo.On("Delete", mock.Anything, mock.AnythingOfType("int64")).Return(nil).Once()
162 |
163 | mockAuthorrepo := new(mocks.AuthorRepository)
164 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
165 |
166 | err := u.Delete(context.TODO(), mockArticle.ID)
167 |
168 | assert.NoError(t, err)
169 | mockArticleRepo.AssertExpectations(t)
170 | mockAuthorrepo.AssertExpectations(t)
171 | })
172 | t.Run("article-is-not-exist", func(t *testing.T) {
173 | mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(domain.Article{}, nil).Once()
174 |
175 | mockAuthorrepo := new(mocks.AuthorRepository)
176 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
177 |
178 | err := u.Delete(context.TODO(), mockArticle.ID)
179 |
180 | assert.Error(t, err)
181 | mockArticleRepo.AssertExpectations(t)
182 | mockAuthorrepo.AssertExpectations(t)
183 | })
184 | t.Run("error-happens-in-db", func(t *testing.T) {
185 | mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(domain.Article{}, errors.New("Unexpected Error")).Once()
186 |
187 | mockAuthorrepo := new(mocks.AuthorRepository)
188 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
189 |
190 | err := u.Delete(context.TODO(), mockArticle.ID)
191 |
192 | assert.Error(t, err)
193 | mockArticleRepo.AssertExpectations(t)
194 | mockAuthorrepo.AssertExpectations(t)
195 | })
196 | }
197 |
198 | func TestUpdate(t *testing.T) {
199 | mockArticleRepo := new(mocks.ArticleRepository)
200 | mockArticle := domain.Article{
201 | Title: "Hello",
202 | Content: "Content",
203 | ID: 23,
204 | }
205 |
206 | t.Run("success", func(t *testing.T) {
207 | mockArticleRepo.On("Update", mock.Anything, &mockArticle).Once().Return(nil)
208 |
209 | mockAuthorrepo := new(mocks.AuthorRepository)
210 | u := ucase.NewArticleUsecase(mockArticleRepo, mockAuthorrepo, time.Second*2)
211 |
212 | err := u.Update(context.TODO(), &mockArticle)
213 | assert.NoError(t, err)
214 | mockArticleRepo.AssertExpectations(t)
215 | })
216 | }
217 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
3 | github.com/bxcodec/faker v1.4.2 h1:PlGLUcQ/yo/JUiwn3kUGnFkDbcv2o18oryc+ch+AkqY=
4 | github.com/bxcodec/faker v1.4.2/go.mod h1:BNzfpVdTwnFJ6GtfYTcQu6l6rHShT+veBxNCnjCx5XM=
5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
8 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
9 | github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
10 | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
11 | github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
12 | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
13 | github.com/go-sql-driver/mysql v1.3.0 h1:pgwjLi/dvffoP9aabwkT3AKpXQM93QARkjFhDDqC1UE=
14 | github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
15 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
16 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
17 | github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce h1:xdsDDbiBDQTKASoGEZ+pEmF1OnWuu8AQ9I8iNbHNeno=
18 | github.com/hashicorp/hcl v0.0.0-20180404174102-ef8a98b0bbce/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w=
19 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
20 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
21 | github.com/labstack/echo v3.3.5+incompatible h1:9PfxPUmasKzeJor9uQTaXLT6WUG/r+vSTmvXxvv3JO4=
22 | github.com/labstack/echo v3.3.5+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
23 | github.com/labstack/gommon v0.0.0-20180426014445-588f4e8bddc6 h1:Bhy+PiVd7K95/ZFdGLLT2t/irnSxJmmQi/aa6AHQ5UY=
24 | github.com/labstack/gommon v0.0.0-20180426014445-588f4e8bddc6/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
25 | github.com/magiconair/properties v1.7.6 h1:U+1DqNen04MdEPgFiIwdOUiqZ8qPa37xgogX/sd3+54=
26 | github.com/magiconair/properties v1.7.6/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
27 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
28 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
29 | github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI=
30 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
31 | github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238 h1:+MZW2uvHgN8kYvksEN3f7eFL2wpzk0GxmlFsMybWc7E=
32 | github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
33 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
34 | github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w=
35 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
36 | github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo=
37 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
38 | github.com/pelletier/go-toml v1.1.0 h1:cmiOvKzEunMsAxyhXSzpL5Q1CRKpVv0KQsnAIcSEVYM=
39 | github.com/pelletier/go-toml v1.1.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
40 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
42 | github.com/sirupsen/logrus v1.0.5 h1:8c8b5uO0zS4X6RPl/sd1ENwSkIc0/H2PaHxE3udaE8I=
43 | github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
44 | github.com/spf13/afero v1.1.0 h1:bopulORc2JeYaxfHLvJa5NzxviA9PoWhpiiJkru7Ji4=
45 | github.com/spf13/afero v1.1.0/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
46 | github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg=
47 | github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
48 | github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec h1:2ZXvIUGghLpdTVHR1UfvfrzoVlZaE/yOWC5LueIHZig=
49 | github.com/spf13/jwalterweatherman v0.0.0-20180109140146-7c0cea34c8ec/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
50 | github.com/spf13/pflag v1.0.1 h1:aCvUg6QPl3ibpQUxyLkrEkCHtPqYJL4x9AuhqVqFis4=
51 | github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
52 | github.com/spf13/viper v1.0.2 h1:Ncr3ZIuJn322w2k1qmzXDnkLAdQMlJqBa9kfAH+irso=
53 | github.com/spf13/viper v1.0.2/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
54 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
55 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
56 | github.com/stretchr/testify v1.2.1 h1:52QO5WkIUcHGIR7EnGagH88x1bUzqGXTC5/1bDTUQ7U=
57 | github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
58 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
59 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
60 | github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8=
61 | github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw=
62 | golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94 h1:m5xBqfQdnzv6XuV/pJizrLOwUoGzyn1J249cA0cKL4o=
63 | golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
64 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=
65 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
66 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
67 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=
68 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
69 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs=
70 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
71 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
72 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
73 | gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 h1:FVCohIoYO7IJoDDVpV2pdq7SgrMH6wHnuTyrdrxJNoY=
74 | gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw=
75 | gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo=
76 | gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
77 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
78 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
79 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
80 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
81 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 h1:OAj3g0cR6Dx/R07QgQe8wkA9RNjB2u4i700xBkIT4e0=
82 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
83 | gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
84 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
85 | gopkg.in/go-playground/validator.v9 v9.15.0 h1:N4HWJwF5Lu7S5Laom2wkFLkFrjBHtMBwIruWxFybsBI=
86 | gopkg.in/go-playground/validator.v9 v9.15.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
87 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
88 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
89 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
90 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
91 |
--------------------------------------------------------------------------------
/article.sql:
--------------------------------------------------------------------------------
1 | CREATE DATABASE IF NOT EXISTS `article` /*!40100 DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci */;
2 | USE `article`;
3 | -- MySQL dump 10.13 Distrib 5.7.17, for macos10.12 (x86_64)
4 | --
5 | -- Host: localhost Database: article
6 | -- ------------------------------------------------------
7 | -- Server version 5.7.18
8 |
9 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
10 | /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
11 | /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
12 | /*!40101 SET NAMES utf8 */;
13 | /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
14 | /*!40103 SET TIME_ZONE='+00:00' */;
15 | /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
16 | /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
17 | /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
18 | /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
19 |
20 | --
21 | -- Table structure for table `article`
22 | --
23 |
24 | DROP TABLE IF EXISTS `article`;
25 | /*!40101 SET @saved_cs_client = @@character_set_client */;
26 | /*!40101 SET character_set_client = utf8 */;
27 | CREATE TABLE `article` (
28 | `id` int(11) NOT NULL AUTO_INCREMENT,
29 | `title` varchar(45) COLLATE utf8_unicode_ci NOT NULL,
30 | `content` longtext COLLATE utf8_unicode_ci NOT NULL,
31 | `author_id` int(11) DEFAULT '0',
32 | `updated_at` datetime DEFAULT NULL,
33 | `created_at` datetime DEFAULT NULL,
34 | PRIMARY KEY (`id`)
35 | ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
36 | /*!40101 SET character_set_client = @saved_cs_client */;
37 |
38 | --
39 | -- Dumping data for table `article`
40 | --
41 |
42 | LOCK TABLES `article` WRITE;
43 | /*!40000 ALTER TABLE `article` DISABLE KEYS */;
44 | INSERT INTO `article` VALUES (1,'Makan Ayam','
But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful.
\n\nNor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?
\n\nOn the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire, that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish.
\n\nIn a free hour, when our power of choice is untrammelled and when nothing prevents our being able to do what we like best, every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business it will frequently occur that pleasures have to be repudiated and annoyances accepted. The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains.
\n\nBut I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness.But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure? On the
\n\n',1,'2017-05-18 13:50:19','2017-05-18 13:50:19'),(2,'Makan Ikan','Ut arcu tempor auctor pellentesque vitae lacinia potenti amet tellus sagittis molestie aliquam est mi facilisi amet, pretium torquent platea curabitur dolor pretium ultricies semper, phasellus commodo montes ut metus neque commodo platea a platea. Urna luctus cubilia faucibus class dolor nonummy orci dictumst amet ligula posuere hendrerit feugiat. Cursus dignissim ligula ultricies leo curae; nibh.
\n\nAuctor sodales non euismod eros sodales rhoncus justo sit. Tristique primis montes condimentum luctus sagittis pretium Fringilla ligula sociosqu nibh.
\n\nMus Hymenaeos ultricies primis lacus pretium id. Ullamcorper dapibus magnis tellus maecenas eget purus magna maecenas sollicitudin sagittis convallis senectus maecenas sociis purus orci mollis ridiculus velit tristique nulla enim sodales cubilia eleifend.
\n\nRisus quam lacus sociosqu Malesuada. Mattis pretium etiam egestas. Interdum ultrices luctus luctus rutrum pellentesque amet, tincidunt.
\n\nAccumsan at sociis dolor Fusce lacus lorem imperdiet tristique. Est sed. Sapien proin in vivamus sociosqu tempus. Risus. Feugiat. Et nam dapibus tristique donec id, mollis euismod. Lorem, nisi.
\n\nUt torquent curabitur blandit sociis nam sollicitudin tristique convallis aptent accumsan aliquam dictum imperdiet lacus imperdiet fermentum cum at urna neque sem curabitur facilisi hymenaeos dapibus. Diam vehicula. Urna hendrerit duis.
\n\nEget Convallis non senectus justo varius, sociis semper ullamcorper donec, molestie curae; metus ut sagittis. Mattis feugiat consectetuer inceptos ac.
\n\nNatoque libero egestas vitae egestas aenean viverra nostra ornare. Per. Aenean cum elit ridiculus per.
\n\nMassa hymenaeos Gravida parturient Cubilia laoreet, morbi duis interdum neque. Eu natoque elementum placerat sagittis Tincidunt facilisi sollicitudin tristique auctor donec arcu. Purus libero netus.
\n\nCurae; erat eget fames sociosqu, egestas auctor est orci luctus. Nibh elit non aenean pulvinar elementum rutrum eleifend habitasse dictum dapibus velit urna cras. Massa elit ac, nascetur. Ut vestibulum montes. Lorem a.
\n\nUltricies varius. Dapibus nam sagittis porta augue per. Hac velit. Elementum penatibus. Condimentum velit. Amet integer litora tempor mus eros curabitur Libero.
\n\nDapibus senectus magna. Arcu, dignissim tempor nascetur lobortis conubia ornare netus vivamus. Nascetur ad habitasse elementum rutrum parturient sapien pretium penatibus. Posuere etiam massa nisi. Imperdiet et sem habitasse.
\n\nLorem lectus natoque fames molestie fermentum at leo. Cubilia, fringilla nibh libero tempus. Hac platea, volutpat Pretium ultrices dictum. Malesuada ut integer senectus eros phasellus congue nam sociosqu Suspendisse a, a commodo commodo scelerisque.
\n\nConvallis sollicitudin non dui elit cubilia quis ullamcorper praesent tincidunt viverra mauris integer nostra gravida enim pellentesque faucibus sociosqu dapibus erat cursus.
\n\nInterdum id cras mauris class Cubilia sagittis faucibus consectetuer Per ante lacus. Eget donec nec phasellus. Eu metus tempor suscipit eleifend. Fames at.
\n\n Mattis bibendum faucibus nullam. Porta.\n\nPede neque mollis. Per netus interdum mus eleifend massa aliquet etiam feugiat eget penatibus dapibus cras penatibus ac. Dictum elementum fermentum fermentum. In netus dictumst.
\n\nLacus habitant lobortis. Potenti. Vulputate enim habitasse, tellus parturient litora a orci sociis tellus. Vel cursus nec dolor. Orci lectus tristique augue ad, aenean fringilla volutpat natoque ante. Pretium hymenaeos ridiculus penatibus nisi. Curae;.
\n\nMus. Aenean potenti sit nisi, dui. Consequat. Porta pellentesque lorem, dignissim nibh Diam in pretium venenatis. Quisque molestie.
\n\nVitae felis cum non torquent. Condimentum magna vitae erat diam. Sed duis pharetra dictum a facilisi euismod nullam, dis, risus tellus hac aliquam.
\n\nTellus. Nunc neque proin libero praesent nisl torquent integer torquent feugiat urna metus taciti montes enim. Torquent Laoreet, suscipit magna litora cras mattis suspendisse per.
\n\nDiam et. Dui purus congue a senectus arcu adipiscing netus hendrerit ridiculus cubilia non. Viverra morbi augue luctus ipsum scelerisque habitasse eleifend egestas tempor diam sociosqu imperdiet penatibus vehicula placerat eu.
\n\nFusce leo ligula scelerisque malesuada purus adipiscing vehicula praesent, lorem fames massa adipiscing condimentum magna rhoncus purus mattis sem, fringilla natoque potenti pharetra eu nisi est.
\n\nMetus mauris luctus sit fermentum cras facilisis. Dapibus augue lobortis sem fames sed quisque sollicitudin risus etiam. Lacus. Leo. Congue eros nam ultrices feugiat. Ante condimentum mus. Curabitur porttitor. Ante varius nullam ullamcorper gravida egestas.
\n\nIaculis hymenaeos Phasellus nulla at primis Dis commodo semper ornare turpis amet nulla. Morbi Consectetuer cum a facilisi metus quam interdum imperdiet netus ante urna.
',1,'2017-05-18 13:50:19','2017-05-18 13:50:19'),(3,'Makan Sayur','Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi id odio tortor. Pellentesque in efficitur velit. Aenean nec iaculis turpis. Ut eget lorem et velit lacinia mollis finibus vel felis. Sed ut elit leo. Curabitur eu ultrices ligula. Integer pulvinar nisl vitae lacinia porttitor. Maecenas mollis lacus quis turpis semper consequat.\n\nNullam sit amet augue non erat consectetur faucibus vitae eu nisi. Suspendisse non consectetur justo. Duis sed feugiat risus. Pellentesque euismod tellus pellentesque quam condimentum mollis. Phasellus est metus, tempus sit amet viverra tincidunt, lacinia at est. Aenean quis lacus nunc. Suspendisse accumsan nisl sit amet vestibulum molestie. Praesent quis justo congue, condimentum odio non, sollicitudin diam. Sed aliquam risus et urna pulvinar imperdiet. Praesent ac est velit. Sed sit amet volutpat enim, vehicula posuere diam.\n\nNunc sodales, arcu sed euismod sollicitudin, risus nisl fringilla nibh, nec venenatis dolor mi et lorem. Donec dapibus tempus porttitor. Suspendisse et tincidunt dolor. Suspendisse rhoncus faucibus tortor, in condimentum lacus gravida ac. Mauris eleifend blandit erat in interdum. Proin elementum nisi posuere quam scelerisque laoreet. Sed rutrum urna ante, vitae molestie diam lacinia a. In pretium mauris quam. Praesent vehicula odio dui, at sagittis orci bibendum quis.\n\nMauris a euismod ligula. Pellentesque sollicitudin vitae ante eget commodo. Etiam quis interdum lorem. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent a sapien eros. Nam varius quis lorem id ultrices. Etiam posuere tortor nec aliquam convallis. Praesent id tincidunt velit. Cras commodo ex a orci pellentesque bibendum. Duis at ex eu diam tincidunt placerat. Duis odio ante, rutrum ac laoreet eget, fringilla id metus. Vivamus non nisi vestibulum, lacinia elit in, consequat dui. Proin mattis felis metus, ut dignissim tellus finibus eget. Curabitur auctor leo mattis est blandit, eu consectetur sem maximus.\n\nClass aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Cras imperdiet magna lacus, vel luctus quam pulvinar a. In massa turpis, vestibulum vel tortor laoreet, malesuada porttitor nisi. Sed faucibus vulputate nunc, ac semper dui auctor in. Nunc convallis efficitur malesuada. Nulla facilisi. In et tristique est, vel aliquam massa. Donec iaculis, urna rhoncus pharetra tincidunt, arcu risus consequat lacus, sed dapibus nisi elit luctus tellus. You need a little dummy text for your mockup? How quaint.\n\nI bet you’re still using Bootstrap too…',1,'2017-05-18 13:50:19','2017-05-18 13:50:19'); 45 | /*!40000 ALTER TABLE `article` ENABLE KEYS */; 46 | UNLOCK TABLES; 47 | 48 | -- 49 | -- Table structure for table `article_category` 50 | -- 51 | 52 | DROP TABLE IF EXISTS `article_category`; 53 | /*!40101 SET @saved_cs_client = @@character_set_client */; 54 | /*!40101 SET character_set_client = utf8 */; 55 | CREATE TABLE `article_category` ( 56 | `id` int(11) NOT NULL AUTO_INCREMENT, 57 | `article_id` int(11) NOT NULL, 58 | `category_id` int(11) NOT NULL, 59 | PRIMARY KEY (`id`), 60 | UNIQUE KEY `composite` (`article_id`,`category_id`) 61 | ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 62 | /*!40101 SET character_set_client = @saved_cs_client */; 63 | 64 | -- 65 | -- Dumping data for table `article_category` 66 | -- 67 | 68 | LOCK TABLES `article_category` WRITE; 69 | /*!40000 ALTER TABLE `article_category` DISABLE KEYS */; 70 | INSERT INTO `article_category` VALUES (1,1,1),(2,1,2),(3,1,3),(4,2,1),(5,2,2),(6,2,3),(7,3,3),(8,4,3),(9,5,2),(11,6,1),(10,6,2); 71 | /*!40000 ALTER TABLE `article_category` ENABLE KEYS */; 72 | UNLOCK TABLES; 73 | 74 | -- 75 | -- Table structure for table `author` 76 | -- 77 | 78 | DROP TABLE IF EXISTS `author`; 79 | /*!40101 SET @saved_cs_client = @@character_set_client */; 80 | /*!40101 SET character_set_client = utf8 */; 81 | CREATE TABLE `author` ( 82 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 83 | `name` varchar(200) COLLATE utf8_unicode_ci DEFAULT '""', 84 | `created_at` datetime DEFAULT NULL, 85 | `updated_at` datetime DEFAULT NULL, 86 | PRIMARY KEY (`id`) 87 | ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 88 | /*!40101 SET character_set_client = @saved_cs_client */; 89 | 90 | -- 91 | -- Dumping data for table `author` 92 | -- 93 | 94 | LOCK TABLES `author` WRITE; 95 | /*!40000 ALTER TABLE `author` DISABLE KEYS */; 96 | INSERT INTO `author` VALUES (1,'Iman Tumorang','2017-05-18 13:50:19','2017-05-18 13:50:19'); 97 | /*!40000 ALTER TABLE `author` ENABLE KEYS */; 98 | UNLOCK TABLES; 99 | 100 | -- 101 | -- Table structure for table `category` 102 | -- 103 | 104 | DROP TABLE IF EXISTS `category`; 105 | /*!40101 SET @saved_cs_client = @@character_set_client */; 106 | /*!40101 SET character_set_client = utf8 */; 107 | CREATE TABLE `category` ( 108 | `id` int(11) NOT NULL AUTO_INCREMENT, 109 | `name` varchar(45) COLLATE utf8_unicode_ci NOT NULL, 110 | `tag` varchar(45) COLLATE utf8_unicode_ci NOT NULL, 111 | `created_at` datetime DEFAULT NULL, 112 | `updated_at` datetime DEFAULT NULL, 113 | PRIMARY KEY (`id`) 114 | ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 115 | /*!40101 SET character_set_client = @saved_cs_client */; 116 | 117 | -- 118 | -- Dumping data for table `category` 119 | -- 120 | 121 | LOCK TABLES `category` WRITE; 122 | /*!40000 ALTER TABLE `category` DISABLE KEYS */; 123 | INSERT INTO `category` VALUES (1,'Makanan','food','2017-05-18 13:50:19','2017-05-18 13:50:19'),(2,'Kehidupan','life','2017-05-18 13:50:19','2017-05-18 13:50:19'),(3,'Kasih Sayang','love','2017-05-18 13:50:19','2017-05-18 13:50:19'); 124 | /*!40000 ALTER TABLE `category` ENABLE KEYS */; 125 | UNLOCK TABLES; 126 | /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; 127 | 128 | /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; 129 | /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; 130 | /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; 131 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 132 | /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; 133 | /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; 134 | /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; 135 | 136 | -- Dump completed on 2017-12-13 17:17:00 137 | --------------------------------------------------------------------------------