├── .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 | ![golang clean architecture](https://github.com/bxcodec/go-clean-arch/raw/master/clean-arch.png) 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\n

Nor 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\n

On 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\n

In 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\n

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.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','

Odio Mollis Turpis Dictumst

\n\n

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\n

Auctor sodales non euismod eros sodales rhoncus justo sit. Tristique primis montes condimentum luctus sagittis pretium Fringilla ligula sociosqu nibh.

\n\n

Mus 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\n

Risus quam lacus sociosqu Malesuada. Mattis pretium etiam egestas. Interdum ultrices luctus luctus rutrum pellentesque amet, tincidunt.

\n\n

Accumsan 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\n

Ut 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\n

Eget Convallis non senectus justo varius, sociis semper ullamcorper donec, molestie curae; metus ut sagittis. Mattis feugiat consectetuer inceptos ac.

\n\n

Natoque libero egestas vitae egestas aenean viverra nostra ornare. Per. Aenean cum elit ridiculus per.

\n\n

Massa 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\n

Curae; 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\n

Ultricies varius. Dapibus nam sagittis porta augue per. Hac velit. Elementum penatibus. Condimentum velit. Amet integer litora tempor mus eros curabitur Libero.

\n\n

Dapibus 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\n

Lorem 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\n

Convallis sollicitudin non dui elit cubilia quis ullamcorper praesent tincidunt viverra mauris integer nostra gravida enim pellentesque faucibus sociosqu dapibus erat cursus.

\n\n

Interdum 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\n

Pede 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\n

Lacus 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\n

Mus. Aenean potenti sit nisi, dui. Consequat. Porta pellentesque lorem, dignissim nibh Diam in pretium venenatis. Quisque molestie.

\n\n

Vitae felis cum non torquent. Condimentum magna vitae erat diam. Sed duis pharetra dictum a facilisi euismod nullam, dis, risus tellus hac aliquam.

\n\n

Tellus. 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\n

Diam 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\n

Fusce 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\n

Metus 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\n

Iaculis 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 | --------------------------------------------------------------------------------