Repository: bxcodec/go-clean-arch Branch: master Commit: e06c6d0cb370 Files: 40 Total size: 88.2 KB Directory structure: gitextract_qu10t1uh/ ├── .air.toml ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ └── gotest.yml ├── .gitignore ├── .golangci.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app/ │ └── main.go ├── article/ │ ├── mocks/ │ │ ├── ArticleRepository.go │ │ └── AuthorRepository.go │ ├── service.go │ └── service_test.go ├── article.sql ├── compose.yaml ├── domain/ │ ├── article.go │ ├── author.go │ └── errors.go ├── example.env ├── go.mod ├── go.sum ├── internal/ │ ├── README.md │ ├── repository/ │ │ ├── helper.go │ │ └── mysql/ │ │ ├── article.go │ │ ├── article_test.go │ │ ├── author.go │ │ └── author_test.go │ ├── rest/ │ │ ├── article.go │ │ ├── article_test.go │ │ ├── middleware/ │ │ │ ├── cors.go │ │ │ ├── cors_test.go │ │ │ └── timeout.go │ │ └── mocks/ │ │ └── ArticleService.go │ └── workers/ │ ├── .gitkeep │ └── README.md └── misc/ └── make/ ├── help.Makefile └── tools.Makefile ================================================ FILE CONTENTS ================================================ ================================================ FILE: .air.toml ================================================ # Config file for [Air](https://github.com/cosmtrek/air) in TOML format # Working directory # . or absolute path, please note that the directories following must be under root root = "." tmp_dir = "tmp_app" [build] # Just plain old shell command. You could use `make` as well. cmd = "go build -o ./tmp_app/app/engine ./app/." # Binary file yields from `cmd`. bin = "tmp_app/app" # Customize binary. full_bin = "./tmp_app/app/engine" # This log file places in your tmp_dir. log = "air_errors.log" # Watch these filename extensions. include_ext = ["go", "yaml", "toml"] # Exclude specific regular expressions. exclude_regex = ["_test\\.go"] # Ignore these filename extensions or directories. exclude_dir = ["tmp_app", "tmp"] # It's not necessary to trigger build each time file changes if it's too frequent. delay = 1000 # ms [log] # Show log time time = true [color] # Customize each part's color. If no color found, use the raw app log. main = "magenta" watcher = "cyan" build = "yellow" runner = "green" [misc] # Delete tmp directory on exit clean_on_exit = true ================================================ FILE: .dockerignore ================================================ engine *.out ================================================ FILE: .github/FUNDING.yml ================================================ github: [bxcodec] ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "gomod" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" ================================================ FILE: .github/workflows/gotest.yml ================================================ name: Go Test on: push: branches: ["main", "chore/upgrade-linter"] pull_request: branches: ["main"] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Go uses: actions/setup-go@v3 with: go-version: 1.19 - name: Linter run: make lint - name: Test run: make tests ================================================ FILE: .gitignore ================================================ vendor/ article_clean _* *.test .DS_Store engine bin/ *.out tmp_app/ .env ================================================ FILE: .golangci.yaml ================================================ linters-settings: govet: check-shadowing: true settings: printf: funcs: - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf gocyclo: min-complexity: 50 maligned: suggest-new: true dupl: threshold: 100 goconst: min-len: 2 min-occurrences: 2 misspell: locale: US revive: confidence: 0.8 lll: line-length: 160 # tab width in spaces. Default to 1. tab-width: 1 funlen: lines: 150 statements: 80 linters: # please, do not use `enable-all`: it's deprecated and will be removed soon. # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint disable-all: true enable: - errcheck - funlen - goconst - gocyclo - gosec - gosimple - govet - ineffassign - lll - misspell - revive - staticcheck - typecheck - unconvert - unparam - unused # don't enable: # - gochecknoglobals # - gocognit # - godox # - maligned # - prealloc run: skip-dirs: # - test/testdata_etc skip-files: - ".*_test\\.go$" issues: exclude-rules: # ================================================ FILE: Dockerfile ================================================ # Builder FROM golang:1.20.7-alpine3.17 as builder RUN apk update && apk upgrade && \ apk --update add git make bash build-base WORKDIR /app COPY . . RUN make build # Distribution FROM alpine:latest RUN apk update && apk upgrade && \ apk --update --no-cache add tzdata && \ mkdir /app WORKDIR /app EXPOSE 9090 COPY --from=builder /app/engine /app/ CMD /app/engine ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Iman Tumorang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Database MYSQL_USER ?= user MYSQL_PASSWORD ?= password MYSQL_ADDRESS ?= 127.0.0.1:3306 MYSQL_DATABASE ?= article # Exporting bin folder to the path for makefile export PATH := $(PWD)/bin:$(PATH) # Default Shell export SHELL := bash # Type of OS: Linux or Darwin. export OSTYPE := $(shell uname -s | tr A-Z a-z) export ARCH := $(shell uname -m) # --- Tooling & Variables ---------------------------------------------------------------- include ./misc/make/tools.Makefile include ./misc/make/help.Makefile # ~~~ Development Environment ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ up: dev-env dev-air ## Startup / Spinup Docker Compose and air down: docker-stop ## Stop Docker destroy: docker-teardown clean ## Teardown (removes volumes, tmp files, etc...) install-deps: migrate air gotestsum tparse mockery ## Install Development Dependencies (localy). deps: $(MIGRATE) $(AIR) $(GOTESTSUM) $(TPARSE) $(MOCKERY) $(GOLANGCI) ## Checks for Global Development Dependencies. deps: @echo "Required Tools Are Available" dev-env: ## Bootstrap Environment (with a Docker-Compose help). @ docker-compose up -d --build mysql dev-env-test: dev-env ## Run application (within a Docker-Compose help) @ $(MAKE) image-build docker-compose up web dev-air: $(AIR) ## Starts AIR ( Continuous Development app). air docker-stop: @ docker-compose down docker-teardown: @ docker-compose down --remove-orphans -v # ~~~ Code Actions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ lint: $(GOLANGCI) ## Runs golangci-lint with predefined configuration @echo "Applying linter" golangci-lint version golangci-lint run -c .golangci.yaml ./... # -trimpath - will remove the filepathes from the reports, good to same money on network trafic, # focus on bug reports, and find issues fast. # - race - adds a racedetector, in case of racecondition, you can catch report with sentry. # https://golang.org/doc/articles/race_detector.html # # todo(butuzov): add additional flags to compiler to have an `version` flag. build: ## Builds binary @ printf "Building aplication... " @ go build \ -trimpath \ -o engine \ ./app/ @ echo "done" build-race: ## Builds binary (with -race flag) @ printf "Building aplication with race flag... " @ go build \ -trimpath \ -race \ -o engine \ ./app/ @ echo "done" go-generate: $(MOCKERY) ## Runs go generte ./... go generate ./... TESTS_ARGS := --format testname --jsonfile gotestsum.json.out TESTS_ARGS += --max-fails 2 TESTS_ARGS += -- ./... TESTS_ARGS += -test.parallel 2 TESTS_ARGS += -test.count 1 TESTS_ARGS += -test.failfast TESTS_ARGS += -test.coverprofile coverage.out TESTS_ARGS += -test.timeout 5s TESTS_ARGS += -race tests: $(GOTESTSUM) @ gotestsum $(TESTS_ARGS) -short tests-complete: tests $(TPARSE) ## Run Tests & parse details @cat gotestsum.json.out | $(TPARSE) -all -notests # ~~~ Docker Build ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .ONESHELL: image-build: @ echo "Docker Build" @ DOCKER_BUILDKIT=0 docker build \ --file Dockerfile \ --tag go-clean-arch \ . # Commenting this as this not relevant for the project, we load the DB data from the SQL file. # please refer this when introducing the database schema migrations. # # ~~~ Database Migrations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # MYSQL_DSN := "mysql://$(MYSQL_USER):$(MYSQL_PASSWORD)@tcp($(MYSQL_ADDRESS))/$(MYSQL_DATABASE)" # migrate-up: $(MIGRATE) ## Apply all (or N up) migrations. # @ read -p "How many migration you wants to perform (default value: [all]): " N; \ # migrate -database $(MYSQL_DSN) -path=misc/migrations up ${NN} # .PHONY: migrate-down # migrate-down: $(MIGRATE) ## Apply all (or N down) migrations. # @ read -p "How many migration you wants to perform (default value: [all]): " N; \ # migrate -database $(MYSQL_DSN) -path=misc/migrations down ${NN} # .PHONY: migrate-drop # migrate-drop: $(MIGRATE) ## Drop everything inside the database. # migrate -database $(MYSQL_DSN) -path=misc/migrations drop # .PHONY: migrate-create # migrate-create: $(MIGRATE) ## Create a set of up/down migrations with a specified name. # @ read -p "Please provide name for the migration: " Name; \ # migrate create -ext sql -dir misc/migrations $${Name} # ~~~ Cleans ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ clean: clean-artifacts clean-docker clean-artifacts: ## Removes Artifacts (*.out) @printf "Cleanning artifacts... " @rm -f *.out @echo "done." clean-docker: ## Removes dangling docker images @ docker image prune -f ================================================ FILE: README.md ================================================ # go-clean-arch ## Changelog - **v1**: checkout to the [v1 branch](https://github.com/bxcodec/go-clean-arch/tree/v1)
Proposed on 2017, archived to v1 branch on 2018
Desc: Initial proposal by me. The story can be read here: https://medium.com/@imantumorang/golang-clean-archithecture-efd6d7c43047 - **v2**: checkout to the [v2 branch](https://github.com/bxcodec/go-clean-arch/tree/v2)
Proposed on 2018, archived to v2 branch on 2020
Desc: Improvement from v1. The story can be read here: https://medium.com/@imantumorang/trying-clean-architecture-on-golang-2-44d615bf8fdf - **v3**: checkout to the [v3 branch](https://github.com/bxcodec/go-clean-arch/tree/v3)
Proposed on 2019, merged to master on 2020.
Desc: Introducing Domain package, the details can be seen on this PR [#21](https://github.com/bxcodec/go-clean-arch/pull/21) - **v4**: master branch Proposed on 2024, merged to master on 2024.
Desc: - Declare Interfaces to the consuming side, - Introduce `internal` package - Introduce `Service-focused` package. Details can be seen in this PR [#88](https://github.com/bxcodec/go-clean-arch/pull/88).
> ### Author's Note > > You may notice it diverges from the structures seen in previous versions. I encourage you to explore the branches for each version to select the structure that appeals to you the most. In my recent projects, the code structure has progressed to version 4. However, I do not strictly advocate for one version over another. You may encounter alternative examples on the internet that align more closely with your preferences. Rest assured, the foundational concept will remain consistent or at least bear resemblance. The differences are primarily in the arrangement of directories or the integration of advanced tools directly into the setup. ## Description This is an example of implementation of Clean Architecture in Go (Golang) projects. Rule of Clean Architecture by Uncle Bob - 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. - Testable. The business rules can be tested without the UI, Database, Web Server, or any other external element. - 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. - 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. - Independent of any external agency. In fact your business rules simply don’t know anything at all about the outside world. More at https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html This project has 4 Domain layer : - Models Layer - Repository Layer - Usecase Layer - Delivery Layer #### The diagram: ![golang clean architecture](https://github.com/bxcodec/go-clean-arch/raw/master/clean-arch.png) The original explanation about this project's structure can read from this medium's post : https://medium.com/@imantumorang/golang-clean-archithecture-efd6d7c43047. It may be 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. ### How To Run This Project > Make Sure you have run the article.sql in your mysql Since the project is already use Go Module, I recommend to put the source code in any folder but GOPATH. #### Run the Testing ```bash $ make tests ``` #### Run the Applications Here is the steps to run it with `docker-compose` ```bash #move to directory $ cd workspace # Clone into your workspace $ git clone https://github.com/bxcodec/go-clean-arch.git #move to project $ cd go-clean-arch # copy the example.env to .env $ cp example.env .env # Run the application $ make up # The hot reload will running # Execute the call in another terminal $ curl localhost:9090/articles ``` ### Tools Used: In this project, I use some tools listed below. But you can use any similar 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. - All libraries listed in [`go.mod`](https://github.com/bxcodec/go-clean-arch/blob/master/go.mod) - ["github.com/vektra/mockery".](https://github.com/vektra/mockery) To Generate Mocks for testing needs. ================================================ FILE: app/main.go ================================================ package main import ( "database/sql" "fmt" "log" "net/url" "os" "strconv" "time" _ "github.com/go-sql-driver/mysql" "github.com/labstack/echo/v4" mysqlRepo "github.com/bxcodec/go-clean-arch/internal/repository/mysql" "github.com/bxcodec/go-clean-arch/article" "github.com/bxcodec/go-clean-arch/internal/rest" "github.com/bxcodec/go-clean-arch/internal/rest/middleware" "github.com/joho/godotenv" ) const ( defaultTimeout = 30 defaultAddress = ":9090" ) func init() { err := godotenv.Load() if err != nil { log.Fatal("Error loading .env file") } } func main() { //prepare database dbHost := os.Getenv("DATABASE_HOST") dbPort := os.Getenv("DATABASE_PORT") dbUser := os.Getenv("DATABASE_USER") dbPass := os.Getenv("DATABASE_PASS") dbName := os.Getenv("DATABASE_NAME") connection := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", dbUser, dbPass, dbHost, dbPort, dbName) val := url.Values{} val.Add("parseTime", "1") val.Add("loc", "Asia/Jakarta") dsn := fmt.Sprintf("%s?%s", connection, val.Encode()) dbConn, err := sql.Open(`mysql`, dsn) if err != nil { log.Fatal("failed to open connection to database", err) } err = dbConn.Ping() if err != nil { log.Fatal("failed to ping database ", err) } defer func() { err := dbConn.Close() if err != nil { log.Fatal("got error when closing the DB connection", err) } }() // prepare echo e := echo.New() e.Use(middleware.CORS) timeoutStr := os.Getenv("CONTEXT_TIMEOUT") timeout, err := strconv.Atoi(timeoutStr) if err != nil { log.Println("failed to parse timeout, using default timeout") timeout = defaultTimeout } timeoutContext := time.Duration(timeout) * time.Second e.Use(middleware.SetRequestContextWithTimeout(timeoutContext)) // Prepare Repository authorRepo := mysqlRepo.NewAuthorRepository(dbConn) articleRepo := mysqlRepo.NewArticleRepository(dbConn) // Build service Layer svc := article.NewService(articleRepo, authorRepo) rest.NewArticleHandler(e, svc) // Start Server address := os.Getenv("SERVER_ADDRESS") if address == "" { address = defaultAddress } log.Fatal(e.Start(address)) //nolint } ================================================ FILE: article/mocks/ArticleRepository.go ================================================ // Code generated by mockery v2.42.0. DO NOT EDIT. package mocks import ( context "context" domain "github.com/bxcodec/go-clean-arch/domain" mock "github.com/stretchr/testify/mock" ) // ArticleRepository is an autogenerated mock type for the ArticleRepository type type ArticleRepository struct { mock.Mock } // Delete provides a mock function with given fields: ctx, id func (_m *ArticleRepository) Delete(ctx context.Context, id int64) error { ret := _m.Called(ctx, id) if len(ret) == 0 { panic("no return value specified for Delete") } var r0 error if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Fetch provides a mock function with given fields: ctx, cursor, num func (_m *ArticleRepository) Fetch(ctx context.Context, cursor string, num int64) ([]domain.Article, string, error) { ret := _m.Called(ctx, cursor, num) if len(ret) == 0 { panic("no return value specified for Fetch") } var r0 []domain.Article var r1 string var r2 error if rf, ok := ret.Get(0).(func(context.Context, string, int64) ([]domain.Article, string, error)); ok { return rf(ctx, cursor, num) } if rf, ok := ret.Get(0).(func(context.Context, string, int64) []domain.Article); ok { r0 = rf(ctx, cursor, num) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]domain.Article) } } if rf, ok := ret.Get(1).(func(context.Context, string, int64) string); ok { r1 = rf(ctx, cursor, num) } else { r1 = ret.Get(1).(string) } if rf, ok := ret.Get(2).(func(context.Context, string, int64) error); ok { r2 = rf(ctx, cursor, num) } else { r2 = ret.Error(2) } return r0, r1, r2 } // GetByID provides a mock function with given fields: ctx, id func (_m *ArticleRepository) GetByID(ctx context.Context, id int64) (domain.Article, error) { ret := _m.Called(ctx, id) if len(ret) == 0 { panic("no return value specified for GetByID") } var r0 domain.Article var r1 error if rf, ok := ret.Get(0).(func(context.Context, int64) (domain.Article, error)); ok { return rf(ctx, id) } if rf, ok := ret.Get(0).(func(context.Context, int64) domain.Article); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(domain.Article) } if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetByTitle provides a mock function with given fields: ctx, title func (_m *ArticleRepository) GetByTitle(ctx context.Context, title string) (domain.Article, error) { ret := _m.Called(ctx, title) if len(ret) == 0 { panic("no return value specified for GetByTitle") } var r0 domain.Article var r1 error if rf, ok := ret.Get(0).(func(context.Context, string) (domain.Article, error)); ok { return rf(ctx, title) } if rf, ok := ret.Get(0).(func(context.Context, string) domain.Article); ok { r0 = rf(ctx, title) } else { r0 = ret.Get(0).(domain.Article) } if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, title) } else { r1 = ret.Error(1) } return r0, r1 } // Store provides a mock function with given fields: ctx, a func (_m *ArticleRepository) Store(ctx context.Context, a *domain.Article) error { ret := _m.Called(ctx, a) if len(ret) == 0 { panic("no return value specified for Store") } var r0 error if rf, ok := ret.Get(0).(func(context.Context, *domain.Article) error); ok { r0 = rf(ctx, a) } else { r0 = ret.Error(0) } return r0 } // Update provides a mock function with given fields: ctx, ar func (_m *ArticleRepository) Update(ctx context.Context, ar *domain.Article) error { ret := _m.Called(ctx, ar) if len(ret) == 0 { panic("no return value specified for Update") } var r0 error if rf, ok := ret.Get(0).(func(context.Context, *domain.Article) error); ok { r0 = rf(ctx, ar) } else { r0 = ret.Error(0) } return r0 } // NewArticleRepository creates a new instance of ArticleRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewArticleRepository(t interface { mock.TestingT Cleanup(func()) }) *ArticleRepository { mock := &ArticleRepository{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } ================================================ FILE: article/mocks/AuthorRepository.go ================================================ // Code generated by mockery v2.42.0. DO NOT EDIT. package mocks import ( context "context" domain "github.com/bxcodec/go-clean-arch/domain" mock "github.com/stretchr/testify/mock" ) // AuthorRepository is an autogenerated mock type for the AuthorRepository type type AuthorRepository struct { mock.Mock } // GetByID provides a mock function with given fields: ctx, id func (_m *AuthorRepository) GetByID(ctx context.Context, id int64) (domain.Author, error) { ret := _m.Called(ctx, id) if len(ret) == 0 { panic("no return value specified for GetByID") } var r0 domain.Author var r1 error if rf, ok := ret.Get(0).(func(context.Context, int64) (domain.Author, error)); ok { return rf(ctx, id) } if rf, ok := ret.Get(0).(func(context.Context, int64) domain.Author); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(domain.Author) } if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // NewAuthorRepository creates a new instance of AuthorRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewAuthorRepository(t interface { mock.TestingT Cleanup(func()) }) *AuthorRepository { mock := &AuthorRepository{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } ================================================ FILE: article/service.go ================================================ package article import ( "context" "time" "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "github.com/bxcodec/go-clean-arch/domain" ) // ArticleRepository represent the article's repository contract // //go:generate mockery --name ArticleRepository type ArticleRepository interface { Fetch(ctx context.Context, cursor string, num int64) (res []domain.Article, nextCursor string, err error) GetByID(ctx context.Context, id int64) (domain.Article, error) GetByTitle(ctx context.Context, title string) (domain.Article, error) Update(ctx context.Context, ar *domain.Article) error Store(ctx context.Context, a *domain.Article) error Delete(ctx context.Context, id int64) error } // AuthorRepository represent the author's repository contract // //go:generate mockery --name AuthorRepository type AuthorRepository interface { GetByID(ctx context.Context, id int64) (domain.Author, error) } type Service struct { articleRepo ArticleRepository authorRepo AuthorRepository } // NewService will create a new article service object func NewService(a ArticleRepository, ar AuthorRepository) *Service { return &Service{ articleRepo: a, authorRepo: ar, } } /* * In this function below, I'm using errgroup with the pipeline pattern * Look how this works in this package explanation * in godoc: https://godoc.org/golang.org/x/sync/errgroup#ex-Group--Pipeline */ func (a *Service) fillAuthorDetails(ctx context.Context, data []domain.Article) ([]domain.Article, error) { g, ctx := errgroup.WithContext(ctx) // Get the author's id mapAuthors := map[int64]domain.Author{} for _, article := range data { //nolint mapAuthors[article.Author.ID] = domain.Author{} } // Using goroutine to fetch the author's detail chanAuthor := make(chan domain.Author) for authorID := range mapAuthors { authorID := authorID g.Go(func() error { res, err := a.authorRepo.GetByID(ctx, authorID) if err != nil { return err } chanAuthor <- res return nil }) } go func() { defer close(chanAuthor) err := g.Wait() if err != nil { logrus.Error(err) return } }() for author := range chanAuthor { if author != (domain.Author{}) { mapAuthors[author.ID] = author } } if err := g.Wait(); err != nil { return nil, err } // merge the author's data for index, item := range data { //nolint if a, ok := mapAuthors[item.Author.ID]; ok { data[index].Author = a } } return data, nil } func (a *Service) Fetch(ctx context.Context, cursor string, num int64) (res []domain.Article, nextCursor string, err error) { res, nextCursor, err = a.articleRepo.Fetch(ctx, cursor, num) if err != nil { return nil, "", err } res, err = a.fillAuthorDetails(ctx, res) if err != nil { nextCursor = "" } return } func (a *Service) GetByID(ctx context.Context, id int64) (res domain.Article, err error) { res, err = a.articleRepo.GetByID(ctx, id) if err != nil { return } resAuthor, err := a.authorRepo.GetByID(ctx, res.Author.ID) if err != nil { return domain.Article{}, err } res.Author = resAuthor return } func (a *Service) Update(ctx context.Context, ar *domain.Article) (err error) { ar.UpdatedAt = time.Now() return a.articleRepo.Update(ctx, ar) } func (a *Service) GetByTitle(ctx context.Context, title string) (res domain.Article, err error) { res, err = a.articleRepo.GetByTitle(ctx, title) if err != nil { return } resAuthor, err := a.authorRepo.GetByID(ctx, res.Author.ID) if err != nil { return domain.Article{}, err } res.Author = resAuthor return } func (a *Service) Store(ctx context.Context, m *domain.Article) (err error) { existedArticle, _ := a.GetByTitle(ctx, m.Title) // ignore if any error if existedArticle != (domain.Article{}) { return domain.ErrConflict } err = a.articleRepo.Store(ctx, m) return } func (a *Service) Delete(ctx context.Context, id int64) (err error) { existedArticle, err := a.articleRepo.GetByID(ctx, id) if err != nil { return } if existedArticle == (domain.Article{}) { return domain.ErrNotFound } return a.articleRepo.Delete(ctx, id) } ================================================ FILE: article/service_test.go ================================================ package article_test import ( "context" "errors" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/bxcodec/go-clean-arch/article" "github.com/bxcodec/go-clean-arch/article/mocks" "github.com/bxcodec/go-clean-arch/domain" ) func TestFetchArticle(t *testing.T) { mockArticleRepo := new(mocks.ArticleRepository) mockArticle := domain.Article{ Title: "Hello", Content: "Content", } mockListArtilce := make([]domain.Article, 0) mockListArtilce = append(mockListArtilce, mockArticle) t.Run("success", func(t *testing.T) { mockArticleRepo.On("Fetch", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("int64")).Return(mockListArtilce, "next-cursor", nil).Once() mockAuthor := domain.Author{ ID: 1, Name: "Iman Tumorang", } mockAuthorrepo := new(mocks.AuthorRepository) mockAuthorrepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockAuthor, nil) u := article.NewService(mockArticleRepo, mockAuthorrepo) num := int64(1) cursor := "12" list, nextCursor, err := u.Fetch(context.TODO(), cursor, num) cursorExpected := "next-cursor" assert.Equal(t, cursorExpected, nextCursor) assert.NotEmpty(t, nextCursor) assert.NoError(t, err) assert.Len(t, list, len(mockListArtilce)) mockArticleRepo.AssertExpectations(t) mockAuthorrepo.AssertExpectations(t) }) t.Run("error-failed", func(t *testing.T) { mockArticleRepo.On("Fetch", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("int64")).Return(nil, "", errors.New("Unexpexted Error")).Once() mockAuthorrepo := new(mocks.AuthorRepository) u := article.NewService(mockArticleRepo, mockAuthorrepo) num := int64(1) cursor := "12" list, nextCursor, err := u.Fetch(context.TODO(), cursor, num) assert.Empty(t, nextCursor) assert.Error(t, err) assert.Len(t, list, 0) mockArticleRepo.AssertExpectations(t) mockAuthorrepo.AssertExpectations(t) }) } func TestGetByID(t *testing.T) { mockArticleRepo := new(mocks.ArticleRepository) mockArticle := domain.Article{ Title: "Hello", Content: "Content", } mockAuthor := domain.Author{ ID: 1, Name: "Iman Tumorang", } t.Run("success", func(t *testing.T) { mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockArticle, nil).Once() mockAuthorrepo := new(mocks.AuthorRepository) mockAuthorrepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockAuthor, nil) u := article.NewService(mockArticleRepo, mockAuthorrepo) a, err := u.GetByID(context.TODO(), mockArticle.ID) assert.NoError(t, err) assert.NotNil(t, a) mockArticleRepo.AssertExpectations(t) mockAuthorrepo.AssertExpectations(t) }) t.Run("error-failed", func(t *testing.T) { mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(domain.Article{}, errors.New("Unexpected")).Once() mockAuthorrepo := new(mocks.AuthorRepository) u := article.NewService(mockArticleRepo, mockAuthorrepo) a, err := u.GetByID(context.TODO(), mockArticle.ID) assert.Error(t, err) assert.Equal(t, domain.Article{}, a) mockArticleRepo.AssertExpectations(t) mockAuthorrepo.AssertExpectations(t) }) } func TestStore(t *testing.T) { mockArticleRepo := new(mocks.ArticleRepository) mockArticle := domain.Article{ Title: "Hello", Content: "Content", } t.Run("success", func(t *testing.T) { tempMockArticle := mockArticle tempMockArticle.ID = 0 mockArticleRepo.On("GetByTitle", mock.Anything, mock.AnythingOfType("string")).Return(domain.Article{}, domain.ErrNotFound).Once() mockArticleRepo.On("Store", mock.Anything, mock.AnythingOfType("*domain.Article")).Return(nil).Once() mockAuthorrepo := new(mocks.AuthorRepository) u := article.NewService(mockArticleRepo, mockAuthorrepo) err := u.Store(context.TODO(), &tempMockArticle) assert.NoError(t, err) assert.Equal(t, mockArticle.Title, tempMockArticle.Title) mockArticleRepo.AssertExpectations(t) }) t.Run("existing-title", func(t *testing.T) { existingArticle := mockArticle mockArticleRepo.On("GetByTitle", mock.Anything, mock.AnythingOfType("string")).Return(existingArticle, nil).Once() mockAuthor := domain.Author{ ID: 1, Name: "Iman Tumorang", } mockAuthorrepo := new(mocks.AuthorRepository) mockAuthorrepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockAuthor, nil) u := article.NewService(mockArticleRepo, mockAuthorrepo) err := u.Store(context.TODO(), &mockArticle) assert.Error(t, err) mockArticleRepo.AssertExpectations(t) mockAuthorrepo.AssertExpectations(t) }) } func TestDelete(t *testing.T) { mockArticleRepo := new(mocks.ArticleRepository) mockArticle := domain.Article{ Title: "Hello", Content: "Content", } t.Run("success", func(t *testing.T) { mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(mockArticle, nil).Once() mockArticleRepo.On("Delete", mock.Anything, mock.AnythingOfType("int64")).Return(nil).Once() mockAuthorrepo := new(mocks.AuthorRepository) u := article.NewService(mockArticleRepo, mockAuthorrepo) err := u.Delete(context.TODO(), mockArticle.ID) assert.NoError(t, err) mockArticleRepo.AssertExpectations(t) mockAuthorrepo.AssertExpectations(t) }) t.Run("article-is-not-exist", func(t *testing.T) { mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(domain.Article{}, nil).Once() mockAuthorrepo := new(mocks.AuthorRepository) u := article.NewService(mockArticleRepo, mockAuthorrepo) err := u.Delete(context.TODO(), mockArticle.ID) assert.Error(t, err) mockArticleRepo.AssertExpectations(t) mockAuthorrepo.AssertExpectations(t) }) t.Run("error-happens-in-db", func(t *testing.T) { mockArticleRepo.On("GetByID", mock.Anything, mock.AnythingOfType("int64")).Return(domain.Article{}, errors.New("Unexpected Error")).Once() mockAuthorrepo := new(mocks.AuthorRepository) u := article.NewService(mockArticleRepo, mockAuthorrepo) err := u.Delete(context.TODO(), mockArticle.ID) assert.Error(t, err) mockArticleRepo.AssertExpectations(t) mockAuthorrepo.AssertExpectations(t) }) } func TestUpdate(t *testing.T) { mockArticleRepo := new(mocks.ArticleRepository) mockArticle := domain.Article{ Title: "Hello", Content: "Content", ID: 23, } t.Run("success", func(t *testing.T) { mockArticleRepo.On("Update", mock.Anything, &mockArticle).Once().Return(nil) mockAuthorrepo := new(mocks.AuthorRepository) u := article.NewService(mockArticleRepo, mockAuthorrepo) err := u.Update(context.TODO(), &mockArticle) assert.NoError(t, err) mockArticleRepo.AssertExpectations(t) }) } ================================================ FILE: article.sql ================================================ CREATE DATABASE IF NOT EXISTS `article` /*!40100 DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci */; USE `article`; -- MySQL dump 10.13 Distrib 5.7.17, for macos10.12 (x86_64) -- -- Host: localhost Database: article -- ------------------------------------------------------ -- Server version 5.7.18 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; /*!40101 SET NAMES utf8 */; /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; /*!40103 SET TIME_ZONE='+00:00' */; /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; -- -- Table structure for table `article` -- DROP TABLE IF EXISTS `article`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `article` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(45) COLLATE utf8_unicode_ci NOT NULL, `content` longtext COLLATE utf8_unicode_ci NOT NULL, `author_id` int(11) DEFAULT '0', `updated_at` datetime DEFAULT NULL, `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Dumping data for table `article` -- LOCK TABLES `article` WRITE; /*!40000 ALTER TABLE `article` DISABLE KEYS */; 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'); /*!40000 ALTER TABLE `article` ENABLE KEYS */; UNLOCK TABLES; -- -- Table structure for table `article_category` -- DROP TABLE IF EXISTS `article_category`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `article_category` ( `id` int(11) NOT NULL AUTO_INCREMENT, `article_id` int(11) NOT NULL, `category_id` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `composite` (`article_id`,`category_id`) ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Dumping data for table `article_category` -- LOCK TABLES `article_category` WRITE; /*!40000 ALTER TABLE `article_category` DISABLE KEYS */; 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); /*!40000 ALTER TABLE `article_category` ENABLE KEYS */; UNLOCK TABLES; -- -- Table structure for table `author` -- DROP TABLE IF EXISTS `author`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `author` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(200) COLLATE utf8_unicode_ci DEFAULT '""', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Dumping data for table `author` -- LOCK TABLES `author` WRITE; /*!40000 ALTER TABLE `author` DISABLE KEYS */; INSERT INTO `author` VALUES (1,'Iman Tumorang','2017-05-18 13:50:19','2017-05-18 13:50:19'); /*!40000 ALTER TABLE `author` ENABLE KEYS */; UNLOCK TABLES; -- -- Table structure for table `category` -- DROP TABLE IF EXISTS `category`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `category` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(45) COLLATE utf8_unicode_ci NOT NULL, `tag` varchar(45) COLLATE utf8_unicode_ci NOT NULL, `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- -- Dumping data for table `category` -- LOCK TABLES `category` WRITE; /*!40000 ALTER TABLE `category` DISABLE KEYS */; 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'); /*!40000 ALTER TABLE `category` ENABLE KEYS */; UNLOCK TABLES; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; -- Dump completed on 2017-12-13 17:17:00 ================================================ FILE: compose.yaml ================================================ version: "3.7" services: web: image: go-clean-arch container_name: article_management_api ports: - 9090:9090 depends_on: mysql: condition: service_healthy volumes: - ./config.json:/app/config.json mysql: image: mysql:8.3 container_name: go_clean_arch_mysql command: mysqld --user=root volumes: - ./article.sql:/docker-entrypoint-initdb.d/init.sql ports: - 3306:3306 environment: - MYSQL_DATABASE=article - MYSQL_USER=user - MYSQL_PASSWORD=password - MYSQL_ROOT_PASSWORD=root healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 5s retries: 10 ================================================ FILE: domain/article.go ================================================ package domain import ( "time" ) // Article is representing the Article data struct type Article struct { ID int64 `json:"id"` Title string `json:"title" validate:"required"` Content string `json:"content" validate:"required"` Author Author `json:"author"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } ================================================ FILE: domain/author.go ================================================ package domain // Author representing the Author data struct type Author struct { ID int64 `json:"id"` Name string `json:"name"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } ================================================ FILE: domain/errors.go ================================================ package domain import "errors" var ( // ErrInternalServerError will throw if any the Internal Server Error happen ErrInternalServerError = errors.New("internal Server Error") // ErrNotFound will throw if the requested item is not exists ErrNotFound = errors.New("your requested Item is not found") // ErrConflict will throw if the current action already exists ErrConflict = errors.New("your Item already exist") // ErrBadParamInput will throw if the given request-body or params is not valid ErrBadParamInput = errors.New("given Param is not valid") ) ================================================ FILE: example.env ================================================ DEBUG = True SERVER_ADDRESS = ":9090" CONTEXT_TIMEOUT = 2 DATABASE_HOST = "localhost" DATABASE_PORT = "3306" DATABASE_USER = "user" DATABASE_PASS = "password" DATABASE_NAME = "article" ================================================ FILE: go.mod ================================================ module github.com/bxcodec/go-clean-arch go 1.20 require ( github.com/go-faker/faker/v4 v4.3.0 github.com/go-sql-driver/mysql v1.7.1 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.11.4 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 golang.org/x/sync v0.6.0 gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 gopkg.in/go-playground/validator.v9 v9.31.0 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.19.0 // indirect golang.org/x/net v0.21.0 // indirect golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-faker/faker/v4 v4.3.0 h1:UXOW7kn/Mwd0u6MR30JjUKVzguT20EB/hBOddAAO+DY= github.com/go-faker/faker/v4 v4.3.0/go.mod h1:F/bBy8GH9NxOxMInug5Gx4WYeG6fHJZ8Ol/dhcpRub4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 h1:FVCohIoYO7IJoDDVpV2pdq7SgrMH6wHnuTyrdrxJNoY= gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M= gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: internal/README.md ================================================ # Internal Directory Guidelines This directory is designated for internal processes. Golang operates on a package system, where each directory is effectively treated as a package. These packages can be imported into other projects as libraries. To prevent the exposure of detailed implementations, such as database handling or cache management, to the public, these are encapsulated within the internal package. The concept of the internal folder is inspired directly by the Golang compiler. This approach is detailed in the [Release Notes Go 1.4](https://golang.org/doc/go1.4#internalpackages). Consequently, functions, structures, or interfaces within this directory are inaccessible for importation by external projects but remain available for use within this project. An "external project" refers to any project distinct from the current one. For instance, if there is an authentication service written in Go and this project, the authentication service can incorporate this project as a module/library. However, it will not have visibility into the specific implementations housed within the /internal directory. ================================================ FILE: internal/repository/helper.go ================================================ package repository import ( "encoding/base64" "time" ) const ( timeFormat = "2006-01-02T15:04:05.999Z07:00" // reduce precision from RFC3339Nano as date format ) // DecodeCursor will decode cursor from user for mysql func DecodeCursor(encodedTime string) (time.Time, error) { byt, err := base64.StdEncoding.DecodeString(encodedTime) if err != nil { return time.Time{}, err } timeString := string(byt) t, err := time.Parse(timeFormat, timeString) return t, err } // EncodeCursor will encode cursor from mysql to user func EncodeCursor(t time.Time) string { timeString := t.Format(timeFormat) return base64.StdEncoding.EncodeToString([]byte(timeString)) } ================================================ FILE: internal/repository/mysql/article.go ================================================ package mysql import ( "context" "database/sql" "fmt" "github.com/sirupsen/logrus" "github.com/bxcodec/go-clean-arch/domain" "github.com/bxcodec/go-clean-arch/internal/repository" ) type ArticleRepository struct { Conn *sql.DB } // NewArticleRepository will create an object that represent the article.Repository interface func NewArticleRepository(conn *sql.DB) *ArticleRepository { return &ArticleRepository{conn} } func (m *ArticleRepository) fetch(ctx context.Context, query string, args ...interface{}) (result []domain.Article, err error) { rows, err := m.Conn.QueryContext(ctx, query, args...) if err != nil { logrus.Error(err) return nil, err } defer func() { errRow := rows.Close() if errRow != nil { logrus.Error(errRow) } }() result = make([]domain.Article, 0) for rows.Next() { t := domain.Article{} authorID := int64(0) err = rows.Scan( &t.ID, &t.Title, &t.Content, &authorID, &t.UpdatedAt, &t.CreatedAt, ) if err != nil { logrus.Error(err) return nil, err } t.Author = domain.Author{ ID: authorID, } result = append(result, t) } return result, nil } func (m *ArticleRepository) Fetch(ctx context.Context, cursor string, num int64) (res []domain.Article, nextCursor string, err error) { query := `SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE created_at > ? ORDER BY created_at LIMIT ? ` decodedCursor, err := repository.DecodeCursor(cursor) if err != nil && cursor != "" { return nil, "", domain.ErrBadParamInput } res, err = m.fetch(ctx, query, decodedCursor, num) if err != nil { return nil, "", err } if len(res) == int(num) { nextCursor = repository.EncodeCursor(res[len(res)-1].CreatedAt) } return } func (m *ArticleRepository) GetByID(ctx context.Context, id int64) (res domain.Article, err error) { query := `SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE ID = ?` list, err := m.fetch(ctx, query, id) if err != nil { return domain.Article{}, err } if len(list) > 0 { res = list[0] } else { return res, domain.ErrNotFound } return } func (m *ArticleRepository) GetByTitle(ctx context.Context, title string) (res domain.Article, err error) { query := `SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE title = ?` list, err := m.fetch(ctx, query, title) if err != nil { return } if len(list) > 0 { res = list[0] } else { return res, domain.ErrNotFound } return } func (m *ArticleRepository) Store(ctx context.Context, a *domain.Article) (err error) { query := `INSERT article SET title=? , content=? , author_id=?, updated_at=? , created_at=?` stmt, err := m.Conn.PrepareContext(ctx, query) if err != nil { return } res, err := stmt.ExecContext(ctx, a.Title, a.Content, a.Author.ID, a.UpdatedAt, a.CreatedAt) if err != nil { return } lastID, err := res.LastInsertId() if err != nil { return } a.ID = lastID return } func (m *ArticleRepository) Delete(ctx context.Context, id int64) (err error) { query := "DELETE FROM article WHERE id = ?" stmt, err := m.Conn.PrepareContext(ctx, query) if err != nil { return } res, err := stmt.ExecContext(ctx, id) if err != nil { return } rowsAfected, err := res.RowsAffected() if err != nil { return } if rowsAfected != 1 { err = fmt.Errorf("weird Behavior. Total Affected: %d", rowsAfected) return } return } func (m *ArticleRepository) Update(ctx context.Context, ar *domain.Article) (err error) { query := `UPDATE article set title=?, content=?, author_id=?, updated_at=? WHERE ID = ?` stmt, err := m.Conn.PrepareContext(ctx, query) if err != nil { return } res, err := stmt.ExecContext(ctx, ar.Title, ar.Content, ar.Author.ID, ar.UpdatedAt, ar.ID) if err != nil { return } affect, err := res.RowsAffected() if err != nil { return } if affect != 1 { err = fmt.Errorf("weird Behavior. Total Affected: %d", affect) return } return } ================================================ FILE: internal/repository/mysql/article_test.go ================================================ package mysql_test import ( "context" "testing" "time" "github.com/stretchr/testify/assert" sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1" "github.com/bxcodec/go-clean-arch/domain" "github.com/bxcodec/go-clean-arch/internal/repository" articleMysqlRepo "github.com/bxcodec/go-clean-arch/internal/repository/mysql" ) func TestFetchArticle(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } mockArticles := []domain.Article{ { ID: 1, Title: "title 1", Content: "content 1", Author: domain.Author{ID: 1}, UpdatedAt: time.Now(), CreatedAt: time.Now(), }, { ID: 2, Title: "title 2", Content: "content 2", Author: domain.Author{ID: 1}, UpdatedAt: time.Now(), CreatedAt: time.Now(), }, } rows := sqlmock.NewRows([]string{"id", "title", "content", "author_id", "updated_at", "created_at"}). AddRow(mockArticles[0].ID, mockArticles[0].Title, mockArticles[0].Content, mockArticles[0].Author.ID, mockArticles[0].UpdatedAt, mockArticles[0].CreatedAt). AddRow(mockArticles[1].ID, mockArticles[1].Title, mockArticles[1].Content, mockArticles[1].Author.ID, mockArticles[1].UpdatedAt, mockArticles[1].CreatedAt) query := "SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE created_at > \\? ORDER BY created_at LIMIT \\?" mock.ExpectQuery(query).WillReturnRows(rows) a := articleMysqlRepo.NewArticleRepository(db) cursor := repository.EncodeCursor(mockArticles[1].CreatedAt) num := int64(2) list, nextCursor, err := a.Fetch(context.TODO(), cursor, num) assert.NotEmpty(t, nextCursor) assert.NoError(t, err) assert.Len(t, list, 2) } func TestGetArticleByID(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } rows := sqlmock.NewRows([]string{"id", "title", "content", "author_id", "updated_at", "created_at"}). AddRow(1, "title 1", "Content 1", 1, time.Now(), time.Now()) query := "SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE ID = \\?" mock.ExpectQuery(query).WillReturnRows(rows) a := articleMysqlRepo.NewArticleRepository(db) num := int64(5) anArticle, err := a.GetByID(context.TODO(), num) assert.NoError(t, err) assert.NotNil(t, anArticle) } func TestStoreArticle(t *testing.T) { now := time.Now() ar := &domain.Article{ Title: "Judul", Content: "Content", CreatedAt: now, UpdatedAt: now, Author: domain.Author{ ID: 1, Name: "Iman Tumorang", }, } db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } query := "INSERT article SET title=\\? , content=\\? , author_id=\\?, updated_at=\\? , created_at=\\?" prep := mock.ExpectPrepare(query) prep.ExpectExec().WithArgs(ar.Title, ar.Content, ar.Author.ID, ar.CreatedAt, ar.UpdatedAt).WillReturnResult(sqlmock.NewResult(12, 1)) a := articleMysqlRepo.NewArticleRepository(db) err = a.Store(context.TODO(), ar) assert.NoError(t, err) assert.Equal(t, int64(12), ar.ID) } func TestGetArticleByTitle(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } rows := sqlmock.NewRows([]string{"id", "title", "content", "author_id", "updated_at", "created_at"}). AddRow(1, "title 1", "Content 1", 1, time.Now(), time.Now()) query := "SELECT id,title,content, author_id, updated_at, created_at FROM article WHERE title = \\?" mock.ExpectQuery(query).WillReturnRows(rows) a := articleMysqlRepo.NewArticleRepository(db) title := "title 1" anArticle, err := a.GetByTitle(context.TODO(), title) assert.NoError(t, err) assert.NotNil(t, anArticle) } func TestDeleteArticle(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } query := "DELETE FROM article WHERE id = \\?" prep := mock.ExpectPrepare(query) prep.ExpectExec().WithArgs(12).WillReturnResult(sqlmock.NewResult(12, 1)) a := articleMysqlRepo.NewArticleRepository(db) num := int64(12) err = a.Delete(context.TODO(), num) assert.NoError(t, err) } func TestUpdateArticle(t *testing.T) { now := time.Now() ar := &domain.Article{ ID: 12, Title: "Judul", Content: "Content", CreatedAt: now, UpdatedAt: now, Author: domain.Author{ ID: 1, Name: "Iman Tumorang", }, } db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } query := "UPDATE article set title=\\?, content=\\?, author_id=\\?, updated_at=\\? WHERE ID = \\?" prep := mock.ExpectPrepare(query) prep.ExpectExec().WithArgs(ar.Title, ar.Content, ar.Author.ID, ar.UpdatedAt, ar.ID).WillReturnResult(sqlmock.NewResult(12, 1)) a := articleMysqlRepo.NewArticleRepository(db) err = a.Update(context.TODO(), ar) assert.NoError(t, err) } ================================================ FILE: internal/repository/mysql/author.go ================================================ package mysql import ( "context" "database/sql" "github.com/bxcodec/go-clean-arch/domain" ) type AuthorRepository struct { DB *sql.DB } // NewMysqlAuthorRepository will create an implementation of author.Repository func NewAuthorRepository(db *sql.DB) *AuthorRepository { return &AuthorRepository{ DB: db, } } func (m *AuthorRepository) getOne(ctx context.Context, query string, args ...interface{}) (res domain.Author, err error) { stmt, err := m.DB.PrepareContext(ctx, query) if err != nil { return domain.Author{}, err } row := stmt.QueryRowContext(ctx, args...) res = domain.Author{} err = row.Scan( &res.ID, &res.Name, &res.CreatedAt, &res.UpdatedAt, ) return } func (m *AuthorRepository) GetByID(ctx context.Context, id int64) (domain.Author, error) { query := `SELECT id, name, created_at, updated_at FROM author WHERE id=?` return m.getOne(ctx, query, id) } ================================================ FILE: internal/repository/mysql/author_test.go ================================================ package mysql_test import ( "context" "testing" "time" "github.com/stretchr/testify/assert" sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1" repository "github.com/bxcodec/go-clean-arch/internal/repository/mysql" ) func TestGetAuthorByID(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } rows := sqlmock.NewRows([]string{"id", "name", "updated_at", "created_at"}). AddRow(1, "Iman Tumorang", time.Now(), time.Now()) query := "SELECT id, name, created_at, updated_at FROM author WHERE id=\\?" prep := mock.ExpectPrepare(query) userID := int64(1) prep.ExpectQuery().WithArgs(userID).WillReturnRows(rows) a := repository.NewAuthorRepository(db) anArticle, err := a.GetByID(context.TODO(), userID) assert.NoError(t, err) assert.NotNil(t, anArticle) } ================================================ FILE: internal/rest/article.go ================================================ package rest import ( "context" "net/http" "strconv" "github.com/labstack/echo/v4" "github.com/sirupsen/logrus" validator "gopkg.in/go-playground/validator.v9" "github.com/bxcodec/go-clean-arch/domain" ) // ResponseError represent the response error struct type ResponseError struct { Message string `json:"message"` } // ArticleService represent the article's usecases // //go:generate mockery --name ArticleService type ArticleService interface { Fetch(ctx context.Context, cursor string, num int64) ([]domain.Article, string, error) GetByID(ctx context.Context, id int64) (domain.Article, error) Update(ctx context.Context, ar *domain.Article) error GetByTitle(ctx context.Context, title string) (domain.Article, error) Store(context.Context, *domain.Article) error Delete(ctx context.Context, id int64) error } // ArticleHandler represent the httphandler for article type ArticleHandler struct { Service ArticleService } const defaultNum = 10 // NewArticleHandler will initialize the articles/ resources endpoint func NewArticleHandler(e *echo.Echo, svc ArticleService) { handler := &ArticleHandler{ Service: svc, } e.GET("/articles", handler.FetchArticle) e.POST("/articles", handler.Store) e.GET("/articles/:id", handler.GetByID) e.DELETE("/articles/:id", handler.Delete) } // FetchArticle will fetch the article based on given params func (a *ArticleHandler) FetchArticle(c echo.Context) error { numS := c.QueryParam("num") num, err := strconv.Atoi(numS) if err != nil || num == 0 { num = defaultNum } cursor := c.QueryParam("cursor") ctx := c.Request().Context() listAr, nextCursor, err := a.Service.Fetch(ctx, cursor, int64(num)) if err != nil { return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) } c.Response().Header().Set(`X-Cursor`, nextCursor) return c.JSON(http.StatusOK, listAr) } // GetByID will get article by given id func (a *ArticleHandler) GetByID(c echo.Context) error { idP, err := strconv.Atoi(c.Param("id")) if err != nil { return c.JSON(http.StatusNotFound, domain.ErrNotFound.Error()) } id := int64(idP) ctx := c.Request().Context() art, err := a.Service.GetByID(ctx, id) if err != nil { return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) } return c.JSON(http.StatusOK, art) } func isRequestValid(m *domain.Article) (bool, error) { validate := validator.New() err := validate.Struct(m) if err != nil { return false, err } return true, nil } // Store will store the article by given request body func (a *ArticleHandler) Store(c echo.Context) (err error) { var article domain.Article err = c.Bind(&article) if err != nil { return c.JSON(http.StatusUnprocessableEntity, err.Error()) } var ok bool if ok, err = isRequestValid(&article); !ok { return c.JSON(http.StatusBadRequest, err.Error()) } ctx := c.Request().Context() err = a.Service.Store(ctx, &article) if err != nil { return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) } return c.JSON(http.StatusCreated, article) } // Delete will delete article by given param func (a *ArticleHandler) Delete(c echo.Context) error { idP, err := strconv.Atoi(c.Param("id")) if err != nil { return c.JSON(http.StatusNotFound, domain.ErrNotFound.Error()) } id := int64(idP) ctx := c.Request().Context() err = a.Service.Delete(ctx, id) if err != nil { return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) } return c.NoContent(http.StatusNoContent) } func getStatusCode(err error) int { if err == nil { return http.StatusOK } logrus.Error(err) switch err { case domain.ErrInternalServerError: return http.StatusInternalServerError case domain.ErrNotFound: return http.StatusNotFound case domain.ErrConflict: return http.StatusConflict default: return http.StatusInternalServerError } } ================================================ FILE: internal/rest/article_test.go ================================================ package rest_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" faker "github.com/go-faker/faker/v4" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/bxcodec/go-clean-arch/domain" "github.com/bxcodec/go-clean-arch/internal/rest" "github.com/bxcodec/go-clean-arch/internal/rest/mocks" ) func TestFetch(t *testing.T) { var mockArticle domain.Article err := faker.FakeData(&mockArticle) assert.NoError(t, err) mockUCase := new(mocks.ArticleService) mockListArticle := make([]domain.Article, 0) mockListArticle = append(mockListArticle, mockArticle) num := 1 cursor := "2" mockUCase.On("Fetch", mock.Anything, cursor, int64(num)).Return(mockListArticle, "10", nil) e := echo.New() req, err := http.NewRequestWithContext(context.TODO(), echo.GET, "/article?num=1&cursor="+cursor, strings.NewReader("")) assert.NoError(t, err) rec := httptest.NewRecorder() c := e.NewContext(req, rec) handler := rest.ArticleHandler{ Service: mockUCase, } err = handler.FetchArticle(c) require.NoError(t, err) responseCursor := rec.Header().Get("X-Cursor") assert.Equal(t, "10", responseCursor) assert.Equal(t, http.StatusOK, rec.Code) mockUCase.AssertExpectations(t) } func TestFetchError(t *testing.T) { mockUCase := new(mocks.ArticleService) num := 1 cursor := "2" mockUCase.On("Fetch", mock.Anything, cursor, int64(num)).Return(nil, "", domain.ErrInternalServerError) e := echo.New() req, err := http.NewRequestWithContext(context.TODO(), echo.GET, "/article?num=1&cursor="+cursor, strings.NewReader("")) assert.NoError(t, err) rec := httptest.NewRecorder() c := e.NewContext(req, rec) handler := rest.ArticleHandler{ Service: mockUCase, } err = handler.FetchArticle(c) require.NoError(t, err) responseCursor := rec.Header().Get("X-Cursor") assert.Equal(t, "", responseCursor) assert.Equal(t, http.StatusInternalServerError, rec.Code) mockUCase.AssertExpectations(t) } func TestGetByID(t *testing.T) { var mockArticle domain.Article err := faker.FakeData(&mockArticle) assert.NoError(t, err) mockUCase := new(mocks.ArticleService) num := int(mockArticle.ID) mockUCase.On("GetByID", mock.Anything, int64(num)).Return(mockArticle, nil) e := echo.New() req, err := http.NewRequestWithContext(context.TODO(), echo.GET, "/article/"+strconv.Itoa(num), strings.NewReader("")) assert.NoError(t, err) rec := httptest.NewRecorder() c := e.NewContext(req, rec) c.SetPath("article/:id") c.SetParamNames("id") c.SetParamValues(strconv.Itoa(num)) handler := rest.ArticleHandler{ Service: mockUCase, } err = handler.GetByID(c) require.NoError(t, err) assert.Equal(t, http.StatusOK, rec.Code) mockUCase.AssertExpectations(t) } func TestStore(t *testing.T) { mockArticle := domain.Article{ Title: "Title", Content: "Content", CreatedAt: time.Now(), UpdatedAt: time.Now(), } tempMockArticle := mockArticle tempMockArticle.ID = 0 mockUCase := new(mocks.ArticleService) j, err := json.Marshal(tempMockArticle) assert.NoError(t, err) mockUCase.On("Store", mock.Anything, mock.AnythingOfType("*domain.Article")).Return(nil) e := echo.New() req, err := http.NewRequestWithContext(context.TODO(), echo.POST, "/article", strings.NewReader(string(j))) assert.NoError(t, err) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() c := e.NewContext(req, rec) c.SetPath("/article") handler := rest.ArticleHandler{ Service: mockUCase, } err = handler.Store(c) require.NoError(t, err) assert.Equal(t, http.StatusCreated, rec.Code) mockUCase.AssertExpectations(t) } func TestDelete(t *testing.T) { var mockArticle domain.Article err := faker.FakeData(&mockArticle) assert.NoError(t, err) mockUCase := new(mocks.ArticleService) num := int(mockArticle.ID) mockUCase.On("Delete", mock.Anything, int64(num)).Return(nil) e := echo.New() req, err := http.NewRequestWithContext(context.TODO(), echo.DELETE, "/article/"+strconv.Itoa(num), strings.NewReader("")) assert.NoError(t, err) rec := httptest.NewRecorder() c := e.NewContext(req, rec) c.SetPath("article/:id") c.SetParamNames("id") c.SetParamValues(strconv.Itoa(num)) handler := rest.ArticleHandler{ Service: mockUCase, } err = handler.Delete(c) require.NoError(t, err) assert.Equal(t, http.StatusNoContent, rec.Code) mockUCase.AssertExpectations(t) } ================================================ FILE: internal/rest/middleware/cors.go ================================================ package middleware import "github.com/labstack/echo/v4" // CORS will handle the CORS middleware func CORS(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { c.Response().Header().Set("Access-Control-Allow-Origin", "*") return next(c) } } ================================================ FILE: internal/rest/middleware/cors_test.go ================================================ package middleware_test import ( "net/http" test "net/http/httptest" "testing" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/bxcodec/go-clean-arch/internal/rest/middleware" ) func TestCORS(t *testing.T) { e := echo.New() req := test.NewRequest(echo.GET, "/", nil) res := test.NewRecorder() c := e.NewContext(req, res) h := middleware.CORS(echo.HandlerFunc(func(c echo.Context) error { return c.NoContent(http.StatusOK) })) err := h(c) require.NoError(t, err) assert.Equal(t, "*", res.Header().Get("Access-Control-Allow-Origin")) } ================================================ FILE: internal/rest/middleware/timeout.go ================================================ package middleware import ( "context" "time" echo "github.com/labstack/echo/v4" ) // SetRequestContextWithTimeout will set the request context with timeout for every incoming HTTP Request func SetRequestContextWithTimeout(d time.Duration) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { ctx, cancel := context.WithTimeout(c.Request().Context(), d) defer cancel() newRequest := c.Request().WithContext(ctx) c.SetRequest(newRequest) return next(c) } } } ================================================ FILE: internal/rest/mocks/ArticleService.go ================================================ // Code generated by mockery v2.42.0. DO NOT EDIT. package mocks import ( context "context" domain "github.com/bxcodec/go-clean-arch/domain" mock "github.com/stretchr/testify/mock" ) // ArticleService is an autogenerated mock type for the ArticleService type type ArticleService struct { mock.Mock } // Delete provides a mock function with given fields: ctx, id func (_m *ArticleService) Delete(ctx context.Context, id int64) error { ret := _m.Called(ctx, id) if len(ret) == 0 { panic("no return value specified for Delete") } var r0 error if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { r0 = rf(ctx, id) } else { r0 = ret.Error(0) } return r0 } // Fetch provides a mock function with given fields: ctx, cursor, num func (_m *ArticleService) Fetch(ctx context.Context, cursor string, num int64) ([]domain.Article, string, error) { ret := _m.Called(ctx, cursor, num) if len(ret) == 0 { panic("no return value specified for Fetch") } var r0 []domain.Article var r1 string var r2 error if rf, ok := ret.Get(0).(func(context.Context, string, int64) ([]domain.Article, string, error)); ok { return rf(ctx, cursor, num) } if rf, ok := ret.Get(0).(func(context.Context, string, int64) []domain.Article); ok { r0 = rf(ctx, cursor, num) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]domain.Article) } } if rf, ok := ret.Get(1).(func(context.Context, string, int64) string); ok { r1 = rf(ctx, cursor, num) } else { r1 = ret.Get(1).(string) } if rf, ok := ret.Get(2).(func(context.Context, string, int64) error); ok { r2 = rf(ctx, cursor, num) } else { r2 = ret.Error(2) } return r0, r1, r2 } // GetByID provides a mock function with given fields: ctx, id func (_m *ArticleService) GetByID(ctx context.Context, id int64) (domain.Article, error) { ret := _m.Called(ctx, id) if len(ret) == 0 { panic("no return value specified for GetByID") } var r0 domain.Article var r1 error if rf, ok := ret.Get(0).(func(context.Context, int64) (domain.Article, error)); ok { return rf(ctx, id) } if rf, ok := ret.Get(0).(func(context.Context, int64) domain.Article); ok { r0 = rf(ctx, id) } else { r0 = ret.Get(0).(domain.Article) } if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { r1 = rf(ctx, id) } else { r1 = ret.Error(1) } return r0, r1 } // GetByTitle provides a mock function with given fields: ctx, title func (_m *ArticleService) GetByTitle(ctx context.Context, title string) (domain.Article, error) { ret := _m.Called(ctx, title) if len(ret) == 0 { panic("no return value specified for GetByTitle") } var r0 domain.Article var r1 error if rf, ok := ret.Get(0).(func(context.Context, string) (domain.Article, error)); ok { return rf(ctx, title) } if rf, ok := ret.Get(0).(func(context.Context, string) domain.Article); ok { r0 = rf(ctx, title) } else { r0 = ret.Get(0).(domain.Article) } if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = rf(ctx, title) } else { r1 = ret.Error(1) } return r0, r1 } // Store provides a mock function with given fields: _a0, _a1 func (_m *ArticleService) Store(_a0 context.Context, _a1 *domain.Article) error { ret := _m.Called(_a0, _a1) if len(ret) == 0 { panic("no return value specified for Store") } var r0 error if rf, ok := ret.Get(0).(func(context.Context, *domain.Article) error); ok { r0 = rf(_a0, _a1) } else { r0 = ret.Error(0) } return r0 } // Update provides a mock function with given fields: ctx, ar func (_m *ArticleService) Update(ctx context.Context, ar *domain.Article) error { ret := _m.Called(ctx, ar) if len(ret) == 0 { panic("no return value specified for Update") } var r0 error if rf, ok := ret.Get(0).(func(context.Context, *domain.Article) error); ok { r0 = rf(ctx, ar) } else { r0 = ret.Error(0) } return r0 } // NewArticleService creates a new instance of ArticleService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewArticleService(t interface { mock.TestingT Cleanup(func()) }) *ArticleService { mock := &ArticleService{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } ================================================ FILE: internal/workers/.gitkeep ================================================ ================================================ FILE: internal/workers/README.md ================================================ If need queue workers/consumer can put it under this directory. See how repository looks like for reference. ================================================ FILE: misc/make/help.Makefile ================================================ dep-gawk: @ if [ -z "$(shell command -v gawk)" ]; then \ if [ -x /usr/local/bin/brew ]; then $(MAKE) _brew_gawk_install; exit 0; fi; \ if [ -x /usr/bin/apt-get ]; then $(MAKE) _ubuntu_gawk_install; exit 0; fi; \ if [ -x /usr/bin/yum ]; then $(MAKE) _centos_gawk_install; exit 0; fi; \ if [ -x /sbin/apk ]; then $(MAKE) _alpine_gawk_install; exit 0; fi; \ echo "GNU Awk Required, We cannot determine your OS or Package manager. Please install it yourself.";\ exit 1; \ fi _brew_gawk_install: @ echo "Instaling gawk using brew... " @ brew install gawk --quiet @ echo "done" _ubuntu_gawk_install: @ echo "Instaling gawk using apt-get... " @ apt-get -q install gawk -y @ echo "done" _alpine_gawk_install: @ echo "Instaling gawk using yum... " @ apk add --update --no-cache gawk @ echo "done" _centos_gawk_install: @ echo "Instaling gawk using yum... " @ yum install -q -y gawk; @ echo "done" help: dep-gawk @cat $(MAKEFILE_LIST) | \ grep -E '^# ~~~ .*? [~]+$$|^[a-zA-Z0-9_-]+:.*?## .*$$' | \ awk '{if ( $$1=="#" ) { \ match($$0, /^# ~~~ (.+?) [~]+$$/, a);\ {print "\n", a[1], ""}\ } else { \ match($$0, /^([0-9a-zA-Z_-]+):.*?## (.*)$$/, a); \ {printf " - \033[32m%-20s\033[0m %s\n", a[1], a[2]} \ }}' @echo "" ================================================ FILE: misc/make/tools.Makefile ================================================ # This makefile should be used to hold functions/variables ifeq ($(ARCH),x86_64) ARCH := amd64 else ifeq ($(ARCH),aarch64) ARCH := arm64 endif define github_url https://github.com/$(GITHUB)/releases/download/v$(VERSION)/$(ARCHIVE) endef # creates a directory bin. bin: @ mkdir -p $@ # ~~~ Tools ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # ~~ [migrate] ~~~ https://github.com/golang-migrate/migrate ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MIGRATE := $(shell command -v migrate || echo "bin/migrate") migrate: bin/migrate ## Install migrate (database migration) bin/migrate: VERSION := 4.17.0 bin/migrate: GITHUB := golang-migrate/migrate bin/migrate: ARCHIVE := migrate.$(OSTYPE)-$(ARCH).tar.gz bin/migrate: bin @ printf "Install migrate... " @ curl -Ls $(shell echo $(call github_url) | tr A-Z a-z) | tar -zOxf - ./migrate > $@ && chmod +x $@ @ echo "done." # ~~ [ air ] ~~~ https://github.com/cosmtrek/air ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ AIR := $(shell command -v air || echo "bin/air") air: bin/air ## Installs air (go file watcher) bin/air: VERSION := 1.49.0 bin/air: GITHUB := cosmtrek/air bin/air: ARCHIVE := air_$(VERSION)_$(OSTYPE)_$(ARCH).tar.gz bin/air: bin @ printf "Install air... " @ curl -Ls $(shell echo $(call github_url) | tr A-Z a-z) | tar -zOxf - air > $@ && chmod +x $@ @ echo "done." # ~~ [ gotestsum ] ~~~ https://github.com/gotestyourself/gotestsum ~~~~~~~~~~~~~~~~~~~~~~~ GOTESTSUM := $(shell command -v gotestsum || echo "bin/gotestsum") gotestsum: bin/gotestsum ## Installs gotestsum (testing go code) bin/gotestsum: VERSION := 1.11.0 bin/gotestsum: GITHUB := gotestyourself/gotestsum bin/gotestsum: ARCHIVE := gotestsum_$(VERSION)_$(OSTYPE)_$(ARCH).tar.gz bin/gotestsum: bin @ printf "Install gotestsum... " @ curl -Ls $(shell echo $(call github_url) | tr A-Z a-z) | tar -zOxf - gotestsum > $@ && chmod +x $@ @ echo "done." # ~~ [ tparse ] ~~~ https://github.com/mfridman/tparse ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ TPARSE := $(shell command -v tparse || echo "bin/tparse") tparse: bin/tparse ## Installs tparse (testing go code) # eg https://github.com/mfridman/tparse/releases/download/v0.13.2/tparse_darwin_arm64 bin/tparse: VERSION := 0.13.2 bin/tparse: GITHUB := mfridman/tparse bin/tparse: ARCHIVE := tparse_$(OSTYPE)_$(ARCH) bin/tparse: bin @ printf "Install tparse... " @ curl -Ls $(call github_url) > $@ && chmod +x $@ @ echo "done." # ~~ [ mockery ] ~~~ https://github.com/vektra/mockery ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MOCKERY := $(shell command -v mockery || echo "bin/mockery") mockery: bin/mockery ## Installs mockery (mocks generation) bin/mockery: VERSION := 2.42.0 bin/mockery: GITHUB := vektra/mockery bin/mockery: ARCHIVE := mockery_$(VERSION)_$(OSTYPE)_$(ARCH).tar.gz bin/mockery: bin @ printf "Install mockery... " @ curl -Ls $(call github_url) | tar -zOxf - mockery > $@ && chmod +x $@ @ echo "done." # ~~ [ golangci-lint ] ~~~ https://github.com/golangci/golangci-lint ~~~~~~~~~~~~~~~~~~~~~ GOLANGCI := $(shell command -v golangci-lint || echo "bin/golangci-lint") golangci-lint: bin/golangci-lint ## Installs golangci-lint (linter) bin/golangci-lint: VERSION := 1.56.2 bin/golangci-lint: GITHUB := golangci/golangci-lint bin/golangci-lint: ARCHIVE := golangci-lint-$(VERSION)-$(OSTYPE)-$(ARCH).tar.gz bin/golangci-lint: bin @ printf "Install golangci-linter... " @ curl -Ls $(shell echo $(call github_url) | tr A-Z a-z) | tar -zOxf - $(shell printf golangci-lint-$(VERSION)-$(OSTYPE)-$(ARCH)/golangci-lint | tr A-Z a-z ) > $@ && chmod +x $@ @ echo "done."