├── .github ├── pull_request_template.md └── workflows │ └── go.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── barter │ ├── http.go │ └── main.go ├── docker-compose.yaml ├── docs ├── architectural-design-and-decision.md ├── clean-architecture-overview.png ├── dependency-rules.png └── development-guideline.md ├── go.mod ├── go.sum ├── internal ├── adapter │ ├── repository │ │ └── postgres │ │ │ ├── good_repository.go │ │ │ ├── good_repository_test.go │ │ │ ├── postgres_repository.go │ │ │ ├── postgres_repository_test.go │ │ │ ├── trader_repository.go │ │ │ └── trader_repository_test.go │ └── server │ │ └── auth_server_dummy.go ├── app │ ├── application.go │ └── service │ │ ├── auth │ │ ├── automock │ │ │ ├── auth_server.go │ │ │ └── trader_repository.go │ │ ├── interface.go │ │ ├── service.go │ │ ├── service_test.go │ │ ├── token_service.go │ │ ├── trader_service.go │ │ └── trader_service_test.go │ │ └── barter │ │ ├── automock │ │ └── good_repository.go │ │ ├── exchange_service.go │ │ ├── exchange_service_test.go │ │ ├── good_service.go │ │ ├── good_service_test.go │ │ ├── interface.go │ │ ├── service.go │ │ └── service_test.go ├── domain │ ├── barter │ │ ├── exchange.go │ │ ├── exchange_test.go │ │ ├── good.go │ │ ├── good_test.go │ │ ├── trader.go │ │ └── trader_test.go │ └── common │ │ ├── error.go │ │ ├── error_code.go │ │ └── error_test.go └── router │ ├── handler.go │ ├── handler_auth_trader.go │ ├── handler_barter_exchange.go │ ├── handler_barter_good.go │ ├── handler_healthcheck.go │ ├── middleware.go │ ├── middleware_auth.go │ ├── middleware_cors.go │ ├── middleware_logger.go │ ├── param_util.go │ └── response.go ├── migrations ├── 1_create_trader.up.sql └── 2_create_good.up.sql └── testdata ├── good.yml ├── init_local_dev.sql ├── testdata.go └── trader.yml /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 🤔 Why 2 | 3 | 6 | 7 | ## 💡 How 8 | 9 | 14 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | paths: 7 | - "go.sum" 8 | - "go.mod" 9 | - "**.go" 10 | - ".golangci.yml" 11 | - "Makefile" 12 | 13 | env: 14 | GO_VERSION: 1.17.5 15 | GOLANGCI_LINT_VERSION: v1.42.1 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | go-build: ${{ steps.go-cache-paths.outputs.go-build }} 22 | go-mod: ${{ steps.go-cache-paths.outputs.go-mod }} 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@v3 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | 31 | - id: go-cache-paths 32 | run: | 33 | echo "::set-output name=go-build::$(go env GOCACHE)" 34 | echo "::set-output name=go-mod::$(go env GOMODCACHE)" 35 | 36 | - name: Go Build Cache 37 | uses: actions/cache@v2 38 | with: 39 | path: ${{ steps.go-cache-paths.outputs.go-build }} 40 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} 41 | 42 | - name: Go Mod Cache 43 | uses: actions/cache@v2 44 | with: 45 | path: ${{ steps.go-cache-paths.outputs.go-mod }} 46 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 47 | 48 | - name: Build 49 | run: make build 50 | 51 | lint-internal: 52 | runs-on: ubuntu-latest 53 | needs: [ build ] 54 | steps: 55 | - uses: actions/checkout@v3 56 | 57 | - name: Set up Go 58 | uses: actions/setup-go@v3 59 | with: 60 | go-version: ${{ env.GO_VERSION }} 61 | 62 | - name: Run golangci-lint in internal 63 | uses: golangci/golangci-lint-action@v3 64 | with: 65 | version: ${{ env.GOLANGCI_LINT_VERSION }} 66 | working-directory: internal 67 | 68 | lint-cmd: 69 | runs-on: ubuntu-latest 70 | needs: [ build ] 71 | steps: 72 | - uses: actions/checkout@v3 73 | - name: Set up Go 74 | uses: actions/setup-go@v3 75 | with: 76 | go-version: ${{ env.GO_VERSION }} 77 | - name: Run golangci-lint in cmd 78 | uses: golangci/golangci-lint-action@v3 79 | with: 80 | version: ${{ env.GOLANGCI_LINT_VERSION }} 81 | working-directory: cmd 82 | 83 | tests: 84 | runs-on: ubuntu-latest 85 | needs: [ build ] 86 | steps: 87 | - uses: actions/checkout@v3 88 | 89 | - name: Set up Go 90 | uses: actions/setup-go@v3 91 | with: 92 | go-version: ${{ env.GO_VERSION }} 93 | 94 | - name: Go Build Cache 95 | uses: actions/cache@v2 96 | with: 97 | path: ${{ needs.build.outputs.go-build }} 98 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} 99 | 100 | - name: Go Mod Cache 101 | uses: actions/cache@v2 102 | with: 103 | path: ${{ needs.build.outputs.go-mod }} 104 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 105 | 106 | - name: Run tests 107 | run: make test 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin/ 8 | data/ 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | # vendor/ 18 | 19 | # Goland settings 20 | .idea/ 21 | 22 | # System cache 23 | .DS_Store -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 5m 3 | 4 | output: 5 | sort-results: true 6 | 7 | linters: 8 | enable: 9 | - goimports 10 | - misspell 11 | - gosec 12 | - goimports 13 | 14 | linters-settings: 15 | goimports: 16 | local-prefixes: github.com/chatbotgang/barter -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Crescendo Lab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: build 2 | 3 | GIT_BRANCH=$(shell git branch | grep \* | cut -d ' ' -f2) 4 | GIT_REV=$(shell git rev-parse HEAD | cut -c1-7) 5 | BUILD_DATE=$(shell date +%Y-%m-%d.%H:%M:%S) 6 | EXTRA_LD_FLAGS=-X main.AppVersion=${GIT_BRANCH}-${GIT_REV} -X main.AppBuild=${BUILD_DATE} 7 | 8 | GOLANGCI_LINT_VERSION="v1.42.1" 9 | DATABASE_DSN="postgresql://cb_test:cb_test@localhost:5432/cb_test?sslmode=disable" 10 | TEST_PACKAGES = ./internal/... 11 | 12 | build: 13 | go build -ldflags '${EXTRA_LD_FLAGS}' -o bin/barter ./cmd/barter 14 | 15 | run: build 16 | ./bin/barter \ 17 | --database_dsn=$(DATABASE_DSN) \ 18 | | jq 19 | 20 | 21 | 22 | test: 23 | go vet $(TEST_PACKAGES) 24 | go test -race -cover -coverprofile cover.out $(TEST_PACKAGES) 25 | go tool cover -func=cover.out | tail -n 1 26 | 27 | mock: 28 | @which mockgen > /dev/null || (echo "No mockgen installed. Try: go install github.com/golang/mock/mockgen@v1.6.0"; exit 1) 29 | @echo "generating mocks..." 30 | @go generate ./... 31 | 32 | clean: 33 | rm -rf bin/ cover.out 34 | 35 | # Migrate db up to date 36 | migrate-db-up: 37 | docker run --rm -v $(shell pwd)/migrations:/migrations --network host migrate/migrate -verbose -path=/migrations/ -database=$(DATABASE_DSN) up 38 | 39 | # Revert db migration once a step 40 | migrate-db-down: 41 | docker run --rm -v $(shell pwd)/migrations:/migrations --network host migrate/migrate -verbose -path=/migrations/ -database=$(DATABASE_DSN) down 1 42 | 43 | # Force the current version to the given number. It is used for manually resolving dirty migration flag. 44 | # Ref: https://github.com/golang-migrate/migrate/blob/master/GETTING_STARTED.md#forcing-your-database-version 45 | migrate-db-force-%: 46 | docker run --rm -v $(shell pwd)/migrations:/migrations --network host migrate/migrate -verbose -path=/migrations/ -database=$(DATABASE_DSN) force $* 47 | 48 | # Only used for local dev 49 | init-local-db: 50 | docker exec cantata-postgres bash -c "psql -U cb_test -d cb_test -f /testdata/init_local_dev.sql" 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go-Clean-Arch 2 | 3 | **Go-Clean-Arch** gives a Clean Architecture template that is commonly used in Crescendo's Go projects. We will introduce the proposed architecture and related designs through a tutorial on building a sample application - [Crescendo Barter](#crescendo-barter). 4 | 5 | ## Overview 6 | 7 | The proposed clean architecture is inspired by DDD (Domain-Driven Design), Uncle Bob's [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html), and a Go Clean Architecture project [Wild Workouts](https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example), trying to balance feature development speed and maintenance efforts. 8 | 9 | ## Features 10 | 11 | Our proposed clean architecture tries to provide the following features: 12 | - **Testable**. Critical logic is well protected and validated. 13 | - **Ubiquitous language**. No communication barrier between business and engineering people. 14 | - **First-class-citizen errors**. Handle errors throughout the application in handy. 15 | - **Traceable requests**. Internal behaviors of a request could be observed through API and system logs. 16 | - **Product-ready**. Teams could use the architecture template in their new projects directly. 17 | - **Simple and straight**. Any new member could pick up the architecture within days. 18 | 19 | ## Architecture 20 | 21 | ![](./docs/clean-architecture-overview.png "architecture overview") 22 | 23 | The proposed architecture can be separated into 4 layers, including `Domain`, `Application`, `Router`, and `Adapter`. 24 | - `Domain` handles domain models and critical business logic. 25 | - `Application` handles use cases (orchestration of business rules), compositing functionalities of `Domain` and `Adapter`. 26 | - `Router` handles input request things, such as HTTP request routing, authentication, access control, and parameter validation. 27 | - `Adapter` handle output requests, such as accessing DB, communicate with other services, emit events. 28 | 29 | Its dependency rules are: 30 | 31 | ![](./docs/dependency-rules.png "dependency rules") 32 | 33 | More at [https://slides.com/jalex-chang/go-clean-arch-cresclab](https://slides.com/jalex-chang/go-clean-arch-cresclab). 34 | 35 | ## Crescendo Barter 36 | 37 | Crescendo Barter is a second-hand goods exchange application in which people can post their old goods and exchange them with others. 38 | 39 | ### User Stories 40 | 41 | Account management: 42 | - As a client, I want to register a trader account. 43 | - As a client, I want to log in to the application through the registered trader account. 44 | 45 | Second-hand Goods: 46 | - As a trader, I want to post my old goods to the application so that others can see what I have. 47 | - As a trader, I want to see all my posted goods. 48 | - As a trader, I want to see others’ posted goods. 49 | - As a trader, I want to remove some of my goods from the application. 50 | 51 | Goods Exchange: 52 | - As a trader, I want to exchange my own goods with others. 53 | 54 | ### Project Dependencies 55 | 56 |
Main application 57 | 58 | - [Golang](https://go.dev): ^1.17 59 | - [gin](https://github.com/gin-gonic/gin): ~1.7.7 60 | - [zerolog](https://github.com/rs/zerolog): ~1.26.1 61 | - [sqlx](https://github.com/jmoiron/sqlx): ~1.3.4 62 | - [PostgreSQL](https://www.postgresql.org/docs/13/index.html): ^13 63 | 64 |
65 | 66 |
Test usage 67 | 68 | - [testify](https://github.com/stretchr/testify): ^1.8.0 69 | - [mockgen](https://github.com/golang/mock): ~1.6.0 70 | - [testfixtures](https://github.com/go-testfixtures/testfixtures): ^3.8.0 71 | - [migrate](https://github.com/golang-migrate/migrate): ^4.15.0 72 | - [dockertest](https://github.com/ory/dockertest): ^3.9.0 73 | 74 |
75 | 76 | ### Development Guideline 77 | 78 | See [development guideline](./docs/development-guideline.md). 79 | -------------------------------------------------------------------------------- /cmd/barter/http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "sync" 8 | "time" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/rs/zerolog" 12 | 13 | "github.com/chatbotgang/go-clean-architecture-template/internal/app" 14 | "github.com/chatbotgang/go-clean-architecture-template/internal/router" 15 | ) 16 | 17 | func runHTTPServer(rootCtx context.Context, wg *sync.WaitGroup, port int, app *app.Application) { 18 | // Set to release mode to disable Gin logger 19 | gin.SetMode(gin.ReleaseMode) 20 | 21 | // Create gin router 22 | ginRouter := gin.New() 23 | 24 | // Set general middleware 25 | router.SetGeneralMiddlewares(rootCtx, ginRouter) 26 | 27 | // Register all handlers 28 | router.RegisterHandlers(ginRouter, app) 29 | 30 | // Build HTTP server 31 | httpAddr := fmt.Sprintf("0.0.0.0:%d", port) 32 | server := &http.Server{ 33 | Addr: httpAddr, 34 | Handler: ginRouter, 35 | } 36 | 37 | // Run the server in a goroutine 38 | go func() { 39 | zerolog.Ctx(rootCtx).Info().Msgf("HTTP server is on http://%s", httpAddr) 40 | err := server.ListenAndServe() 41 | if err != nil && err != http.ErrServerClosed { 42 | zerolog.Ctx(rootCtx).Panic().Err(err).Str("addr", httpAddr).Msg("fail to start HTTP server") 43 | } 44 | }() 45 | 46 | // Wait for rootCtx done 47 | go func() { 48 | <-rootCtx.Done() 49 | 50 | // Graceful shutdown http server with a timeout 51 | zerolog.Ctx(rootCtx).Info().Msgf("HTTP server is closing") 52 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 53 | defer cancel() 54 | if err := server.Shutdown(ctx); err != nil { 55 | zerolog.Ctx(ctx).Error().Err(err).Msg("fail to shutdown HTTP server") 56 | } 57 | 58 | // Notify when server is closed 59 | zerolog.Ctx(rootCtx).Info().Msgf("HTTP server is closed") 60 | wg.Done() 61 | }() 62 | } 63 | -------------------------------------------------------------------------------- /cmd/barter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/rs/zerolog" 13 | "gopkg.in/alecthomas/kingpin.v2" 14 | 15 | "github.com/chatbotgang/go-clean-architecture-template/internal/app" 16 | ) 17 | 18 | var ( 19 | AppName = "crescendo-barter" 20 | AppVersion = "unknown_version" 21 | AppBuild = "unknown_build" 22 | ) 23 | 24 | const ( 25 | defaultEnv = "staging" 26 | defaultLogLevel = "info" 27 | defaultPort = "9000" 28 | defaultTokenSigningKey = "cb-signing-key" // nolint 29 | defaultTokenExpiryDurationHour = "8" 30 | defaultTokenTokenIssuer = "crescendo-barter" 31 | ) 32 | 33 | type AppConfig struct { 34 | // General configuration 35 | Env *string 36 | LogLevel *string 37 | 38 | // Database configuration 39 | DatabaseDSN *string 40 | 41 | // HTTP configuration 42 | Port *int 43 | 44 | // Token configuration 45 | TokenSigningKey *string 46 | TokenExpiryDurationHour *int 47 | TokenIssuer *string 48 | } 49 | 50 | func initAppConfig() AppConfig { 51 | // Setup basic application information 52 | app := kingpin.New(AppName, "The HTTP server"). 53 | Version(fmt.Sprintf("version: %s, build: %s", AppVersion, AppBuild)) 54 | 55 | var config AppConfig 56 | 57 | config.Env = app. 58 | Flag("env", "The running environment"). 59 | Envar("CB_ENV").Default(defaultEnv).Enum("staging", "production") 60 | 61 | config.LogLevel = app. 62 | Flag("log_level", "Log filtering level"). 63 | Envar("CB_LOG_LEVEL").Default(defaultLogLevel).Enum("error", "warn", "info", "debug", "disabled") 64 | 65 | config.Port = app. 66 | Flag("port", "The HTTP server port"). 67 | Envar("CB_PORT").Default(defaultPort).Int() 68 | 69 | config.DatabaseDSN = app. 70 | Flag("database_dsn", "The database DSN"). 71 | Envar("CB_DATABASE_DSN").Required().String() 72 | 73 | config.TokenSigningKey = app. 74 | Flag("token_signing_key", "Token signing key"). 75 | Envar("CB_TOKEN_SIGNING_KEY").Default(defaultTokenSigningKey).String() 76 | config.TokenExpiryDurationHour = app. 77 | Flag("token_expiry_duration_hour", "Token expiry time"). 78 | Envar("CB_TOKEN_EXPIRY_DURATION_HOUR").Default(defaultTokenExpiryDurationHour).Int() 79 | config.TokenIssuer = app. 80 | Flag("token_issuer", "Token issuer"). 81 | Envar("CB_TOKEN_ISSUER").Default(defaultTokenTokenIssuer).String() 82 | 83 | kingpin.MustParse(app.Parse(os.Args[1:])) 84 | 85 | return config 86 | } 87 | 88 | func initRootLogger(levelStr, env string) zerolog.Logger { 89 | // Set global log level 90 | level, err := zerolog.ParseLevel(levelStr) 91 | if err != nil { 92 | level = zerolog.InfoLevel 93 | } 94 | zerolog.SetGlobalLevel(level) 95 | 96 | // Set logger time format 97 | const rfc3339Micro = "2006-01-02T15:04:05.000000Z07:00" 98 | zerolog.TimeFieldFormat = rfc3339Micro 99 | 100 | serviceName := fmt.Sprintf("%s-%s", AppName, env) 101 | rootLogger := zerolog.New(os.Stdout).With(). 102 | Timestamp(). 103 | Str("service", serviceName). 104 | Logger() 105 | 106 | return rootLogger 107 | } 108 | 109 | func main() { 110 | // Setup app configuration 111 | cfg := initAppConfig() 112 | 113 | // Create root logger 114 | rootLogger := initRootLogger(*cfg.LogLevel, *cfg.Env) 115 | 116 | // Create root context 117 | rootCtx, rootCtxCancelFunc := context.WithCancel(context.Background()) 118 | rootCtx = rootLogger.WithContext(rootCtx) 119 | 120 | rootLogger.Info(). 121 | Str("version", AppVersion). 122 | Str("build", AppBuild). 123 | Msgf("Launching %s", AppName) 124 | 125 | wg := sync.WaitGroup{} 126 | // Create application 127 | app := app.MustNewApplication(rootCtx, &wg, app.ApplicationParams{ 128 | Env: *cfg.Env, 129 | DatabaseDSN: *cfg.DatabaseDSN, 130 | TokenSigningKey: []byte(*cfg.TokenSigningKey), 131 | TokenExpiryDuration: time.Duration(*cfg.TokenExpiryDurationHour) * time.Hour, 132 | TokenIssuer: *cfg.TokenIssuer, 133 | }) 134 | 135 | // Run server 136 | wg.Add(1) 137 | runHTTPServer(rootCtx, &wg, *cfg.Port, app) 138 | 139 | // Listen to SIGTERM/SIGINT to close 140 | var gracefulStop = make(chan os.Signal, 1) 141 | signal.Notify(gracefulStop, syscall.SIGTERM, syscall.SIGINT) 142 | <-gracefulStop 143 | rootCtxCancelFunc() 144 | 145 | // Wait for all services to close with a specific timeout 146 | var waitUntilDone = make(chan struct{}) 147 | go func() { 148 | wg.Wait() 149 | close(waitUntilDone) 150 | }() 151 | select { 152 | case <-waitUntilDone: 153 | rootLogger.Info().Msg("success to close all services") 154 | case <-time.After(10 * time.Second): 155 | rootLogger.Err(context.DeadlineExceeded).Msg("fail to close all services") 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | container_name: cb-postgres 6 | image: postgres:13.1-alpine 7 | environment: 8 | - POSTGRES_USER=cb_test 9 | - POSTGRES_PASSWORD=cb_test 10 | - POSTGRES_DB=cb_test 11 | - PGDATA=/var/lib/postgresql/data 12 | ports: 13 | - "5432:5432" 14 | volumes: 15 | - './data:/var/lib/postgresql/data' 16 | - './testdata:/testdata' 17 | -------------------------------------------------------------------------------- /docs/architectural-design-and-decision.md: -------------------------------------------------------------------------------- 1 | # Architectural Designs & Decisions 2 | 3 | This document records all designs and decisions regarding the proposed clean architecture. To explain the architecture well, we will separate the doc into two topics: Layer Designs and Advanced Designs. 4 | 5 | Table of contents: 6 | - [Layer Designs](#layer-designs) 7 | - [Domain Layer](#domain-layer) 8 | - [Service Layer](#service-layer) 9 | - [Router Layer](#router-layer) 10 | - [Adapter Layer](#adapter-layer) 11 | - [Advanced Designs](#advanced-designs) 12 | - [Error Handling](#error-handling) 13 | - [Logs](#logs) 14 | - [Test](#test) 15 | 16 | ## Layer Designs 17 | 18 | ### Domain Layer 19 | 20 | ### Service Layer 21 | 22 | ### Router Layer 23 | 24 | ### Adapter Layer 25 | 26 | ## Advanced Designs 27 | 28 | ### Error Handling 29 | 30 | ### Logs 31 | 32 | ### Test 33 | -------------------------------------------------------------------------------- /docs/clean-architecture-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatbotgang/go-clean-arch/0e0a2a2993bd88f50d2ae41fb92ac1618ce5f775/docs/clean-architecture-overview.png -------------------------------------------------------------------------------- /docs/dependency-rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatbotgang/go-clean-arch/0e0a2a2993bd88f50d2ae41fb92ac1618ce5f775/docs/dependency-rules.png -------------------------------------------------------------------------------- /docs/development-guideline.md: -------------------------------------------------------------------------------- 1 | # Development Guidelines 2 | 3 | ## Unit Test 4 | 5 | ```shell 6 | make test 7 | ``` 8 | 9 | ## Run Crescendo Barter on Local 10 | 11 | ### 1. Start Dependent Services 12 | 13 | ```shell 14 | docker compose up -d 15 | ``` 16 | 17 | ### 2. Init Local DB 18 | 19 | ```shell 20 | make migrate-db-up 21 | make init-local-db 22 | ``` 23 | 24 | ### 3. Setup Application Configs 25 | 26 | Some application configs have already configured in `Makefile` for running Crescendo Barter on local successfully. 27 | These pre-configured configs provide basic functions, such as accessing DB. 28 | 29 | For testing advanced features, we need assign related configs by ourselves, and the application configs can be assigned through: 30 | 31 | 1. Environment Variables 32 | ```shell 33 | ENV=staging ./bin/applicatopn 34 | ``` 35 | 36 | 2. Command-Line Flags 37 | ```shell 38 | ./bin/applicatopn --env="staging" 39 | ``` 40 | 41 | Here lists the configurable application configs: 42 |
43 | Common configs 44 | 45 | | Env Var / Flag Var | Description | Type | Required | Default | 46 | |---------------------------------|-------------------------------------------------------------------------|---------|----------|---------| 47 | | `CB_ENV`
`env` | The running environment. | string | | staging | 48 | | `CB_LOG_LEVEL`
`log_level` | Log filtering level.
Support error, warn, info, debug, and disabled. | string | | info | 49 | | `CB_PORT`
`port` | The HTTP server port. | integer | | 9000 | 50 | 51 |
52 | 53 |
54 | Data systems 55 | 56 | | Env Var / Flag Var | Description | Type | Required | Default | 57 | |---------------------------------------|-------------------------------------------------------------|---------|----------|---------| 58 | | `CB_DATABASE_DSN`
`database_dsn` | The used Postgres DSN. | string | v | | | string | | | 59 | 60 |
61 | 62 |
63 | Application Features 64 | 65 | | Env Var / Flag Var | Description | Type | Required | Default | 66 | |-------------------------------------------------------------------|-----------------------------------------------------------|---------|----------|-------------------| 67 | | `CB_TOKEN_SIGNING_KEY`
`token_signing_key` | JWT Token signing key. | string | | cb-signing-key | 68 | | `CB_TOKEN_ISSUER`
`token_issuer` | JWT Token issuer. | string | | crescendo-barter | 69 | | `CB_TOKEN_EXPIRY_DURATION_HOUR`
`token_expiry_duration_hour` | JWT Token expiry hours used for customer-facing APIs. | integer | | 8 (8h) | 70 | 71 |
72 | 73 | ### 4. Start Cantata on Local 74 | 75 | ```shell 76 | make run 77 | ``` 78 | 79 | ## Migrate DB Schema 80 | 81 | 1. Add migration script in `migrations/` 82 | * `{no.}_{description}.up.sql`: migration script 83 | * `{no.}_{description}.down.sql`: rollback script 84 | 2. Run `make migrate-db-up` 85 | 3. If the migration result is unexpected, run `make migrate-db-down` -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chatbotgang/go-clean-architecture-template 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/Masterminds/squirrel v1.5.3 7 | github.com/bxcodec/faker/v3 v3.8.0 8 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 9 | github.com/gin-contrib/cors v1.4.0 10 | github.com/gin-contrib/requestid v0.0.5 11 | github.com/gin-gonic/gin v1.8.1 12 | github.com/go-testfixtures/testfixtures/v3 v3.8.0 13 | github.com/golang-migrate/migrate/v4 v4.15.2 14 | github.com/golang/mock v1.6.0 15 | github.com/google/uuid v1.3.0 16 | github.com/jmoiron/sqlx v1.3.5 17 | github.com/lib/pq v1.10.6 18 | github.com/ory/dockertest/v3 v3.9.1 19 | github.com/pkg/errors v0.9.1 20 | github.com/rs/zerolog v1.27.0 21 | github.com/stretchr/testify v1.8.0 22 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 23 | gotest.tools v2.2.0+incompatible 24 | ) 25 | 26 | require ( 27 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 28 | github.com/Microsoft/go-winio v0.5.2 // indirect 29 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 30 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 31 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect 32 | github.com/cenkalti/backoff/v4 v4.1.3 // indirect 33 | github.com/containerd/continuity v0.3.0 // indirect 34 | github.com/davecgh/go-spew v1.1.1 // indirect 35 | github.com/docker/cli v20.10.14+incompatible // indirect 36 | github.com/docker/docker v20.10.13+incompatible // indirect 37 | github.com/docker/go-connections v0.4.0 // indirect 38 | github.com/docker/go-units v0.4.0 // indirect 39 | github.com/gin-contrib/sse v0.1.0 // indirect 40 | github.com/go-playground/locales v0.14.0 // indirect 41 | github.com/go-playground/universal-translator v0.18.0 // indirect 42 | github.com/go-playground/validator/v10 v10.10.0 // indirect 43 | github.com/goccy/go-json v0.9.7 // indirect 44 | github.com/gogo/protobuf v1.3.2 // indirect 45 | github.com/google/go-cmp v0.5.6 // indirect 46 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 47 | github.com/hashicorp/errwrap v1.1.0 // indirect 48 | github.com/hashicorp/go-multierror v1.1.1 // indirect 49 | github.com/imdario/mergo v0.3.12 // indirect 50 | github.com/json-iterator/go v1.1.12 // indirect 51 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 52 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 53 | github.com/leodido/go-urn v1.2.1 // indirect 54 | github.com/mattn/go-colorable v0.1.12 // indirect 55 | github.com/mattn/go-isatty v0.0.14 // indirect 56 | github.com/mitchellh/mapstructure v1.4.1 // indirect 57 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect 58 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 59 | github.com/modern-go/reflect2 v1.0.2 // indirect 60 | github.com/opencontainers/go-digest v1.0.0 // indirect 61 | github.com/opencontainers/image-spec v1.0.2 // indirect 62 | github.com/opencontainers/runc v1.1.2 // indirect 63 | github.com/pelletier/go-toml/v2 v2.0.1 // indirect 64 | github.com/pmezard/go-difflib v1.0.0 // indirect 65 | github.com/sirupsen/logrus v1.8.1 // indirect 66 | github.com/ugorji/go/codec v1.2.7 // indirect 67 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 68 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 69 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 70 | go.uber.org/atomic v1.7.0 // indirect 71 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect 72 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect 73 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect 74 | golang.org/x/text v0.3.7 // indirect 75 | google.golang.org/protobuf v1.28.0 // indirect 76 | gopkg.in/yaml.v2 v2.4.0 // indirect 77 | gopkg.in/yaml.v3 v3.0.1 // indirect 78 | ) 79 | -------------------------------------------------------------------------------- /internal/adapter/repository/postgres/good_repository.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | sq "github.com/Masterminds/squirrel" 11 | "github.com/pkg/errors" 12 | 13 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 14 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 15 | ) 16 | 17 | type repoGood struct { 18 | ID int `db:"id"` 19 | Name string `db:"name"` 20 | OwnerID int `db:"owner_id"` 21 | CreatedAt time.Time `db:"created_at"` 22 | UpdatedAt time.Time `db:"updated_at"` 23 | } 24 | 25 | type repoColumnPatternGood struct { 26 | ID string 27 | Name string 28 | OwnerID string 29 | CreatedAt string 30 | UpdatedAt string 31 | } 32 | 33 | const repoTableGood = "good" 34 | 35 | var repoColumnGood = repoColumnPatternGood{ 36 | ID: "id", 37 | Name: "name", 38 | OwnerID: "owner_id", 39 | CreatedAt: "created_at", 40 | UpdatedAt: "updated_at", 41 | } 42 | 43 | func (c *repoColumnPatternGood) columns() string { 44 | return strings.Join([]string{ 45 | c.ID, 46 | c.Name, 47 | c.OwnerID, 48 | c.CreatedAt, 49 | c.UpdatedAt, 50 | }, ", ") 51 | } 52 | 53 | func (r *PostgresRepository) CreateGood(ctx context.Context, param barter.Good) (*barter.Good, common.Error) { 54 | insert := map[string]interface{}{ 55 | repoColumnGood.Name: param.Name, 56 | repoColumnGood.OwnerID: param.OwnerID, 57 | } 58 | 59 | // build SQL query 60 | query, args, err := r.pgsq.Insert(repoTableGood). 61 | SetMap(insert). 62 | Suffix(fmt.Sprintf("returning %s", repoColumnGood.columns())). 63 | ToSql() 64 | if err != nil { 65 | return nil, common.NewError(common.ErrorCodeInternalProcess, err) 66 | } 67 | 68 | // execute SQL query 69 | var row repoGood 70 | if err = r.db.GetContext(ctx, &row, query, args...); err != nil { 71 | return nil, common.NewError(common.ErrorCodeRemoteProcess, err) 72 | } 73 | 74 | good := barter.Good(row) 75 | return &good, nil 76 | } 77 | 78 | func (r *PostgresRepository) GetGoodByID(ctx context.Context, id int) (*barter.Good, common.Error) { 79 | where := sq.And{ 80 | sq.Eq{repoColumnGood.ID: id}, 81 | } 82 | 83 | // build SQL query 84 | query, args, err := r.pgsq.Select(repoColumnGood.columns()). 85 | From(repoTableGood). 86 | Where(where). 87 | Limit(1). 88 | ToSql() 89 | if err != nil { 90 | if errors.Is(err, sql.ErrNoRows) { 91 | return nil, common.NewError(common.ErrorCodeResourceNotFound, err) 92 | } 93 | return nil, common.NewError(common.ErrorCodeInternalProcess, err) 94 | } 95 | 96 | // execute SQL query 97 | var row repoGood 98 | if err = r.db.GetContext(ctx, &row, query, args...); err != nil { 99 | return nil, common.NewError(common.ErrorCodeRemoteProcess, err) 100 | } 101 | 102 | good := barter.Good(row) 103 | return &good, nil 104 | } 105 | 106 | func (r *PostgresRepository) ListGoods(ctx context.Context) ([]barter.Good, common.Error) { 107 | return r.listGoods(ctx, r.db, sq.And{}) 108 | } 109 | 110 | func (r *PostgresRepository) ListGoodsByOwner(ctx context.Context, ownerID int) ([]barter.Good, common.Error) { 111 | return r.listGoods(ctx, r.db, sq.And{ 112 | sq.Eq{repoColumnGood.OwnerID: ownerID}, 113 | }) 114 | } 115 | 116 | func (r *PostgresRepository) listGoods(ctx context.Context, db sqlContextGetter, where sq.And) ([]barter.Good, common.Error) { 117 | // build SQL query 118 | query, args, err := r.pgsq.Select(repoColumnGood.columns()). 119 | From(repoTableGood). 120 | Where(where). 121 | OrderBy(fmt.Sprintf("%s desc", repoColumnGood.CreatedAt)). 122 | ToSql() 123 | if err != nil { 124 | return nil, common.NewError(common.ErrorCodeInternalProcess, err) 125 | } 126 | 127 | // execute SQL query 128 | var rows []repoGood 129 | if err = db.SelectContext(ctx, &rows, query, args...); err != nil { 130 | return nil, common.NewError(common.ErrorCodeRemoteProcess, err) 131 | } 132 | 133 | var goods []barter.Good 134 | for _, row := range rows { 135 | good := barter.Good(row) 136 | goods = append(goods, good) 137 | } 138 | 139 | return goods, nil 140 | } 141 | 142 | func (r *PostgresRepository) UpdateGood(ctx context.Context, good barter.Good) (updatedGood *barter.Good, err common.Error) { 143 | // When using beginTx(), we need to make sure we have used named return values 'err'. 144 | // Otherwise, the defer function finishTx() won't work. 145 | tx, err := r.beginTx() 146 | if err != nil { 147 | return nil, err 148 | } 149 | defer func() { 150 | err = r.finishTx(err, tx) 151 | }() 152 | return r.updateGood(ctx, tx, good) 153 | } 154 | 155 | func (r *PostgresRepository) UpdateGoods(ctx context.Context, goods []barter.Good) (updatedGoods []barter.Good, err common.Error) { 156 | // When using beginTx(), we need to make sure we have used named return values 'err'. 157 | // Otherwise, the defer function finishTx() won't work. 158 | tx, err := r.beginTx() 159 | if err != nil { 160 | return nil, err 161 | } 162 | defer func() { 163 | err = r.finishTx(err, tx) 164 | }() 165 | 166 | for i := range goods { 167 | updatedGood, err := r.updateGood(ctx, tx, goods[i]) 168 | if err != nil { 169 | return nil, err 170 | } 171 | updatedGoods = append(updatedGoods, *updatedGood) 172 | } 173 | 174 | return updatedGoods, nil 175 | } 176 | 177 | func (r *PostgresRepository) updateGood(ctx context.Context, db sqlContextGetter, good barter.Good) (*barter.Good, common.Error) { 178 | where := sq.And{ 179 | sq.Eq{repoColumnGood.ID: good.ID}, 180 | } 181 | 182 | update := map[string]interface{}{ 183 | repoColumnGood.Name: good.Name, 184 | repoColumnGood.OwnerID: good.OwnerID, 185 | repoColumnGood.UpdatedAt: time.Now(), 186 | } 187 | 188 | // build SQL query 189 | query, args, err := r.pgsq.Update(repoTableGood). 190 | SetMap(update). 191 | Where(where). 192 | Suffix(fmt.Sprintf("returning %s", repoColumnGood.columns())). 193 | ToSql() 194 | if err != nil { 195 | return nil, common.NewError(common.ErrorCodeInternalProcess, err) 196 | } 197 | 198 | // execute SQL query 199 | var row repoGood 200 | if err = db.GetContext(ctx, &row, query, args...); err != nil { 201 | return nil, common.NewError(common.ErrorCodeRemoteProcess, err) 202 | } 203 | 204 | updatedGood := barter.Good(row) 205 | return &updatedGood, nil 206 | } 207 | 208 | func (r *PostgresRepository) DeleteGoodByID(ctx context.Context, id int) common.Error { 209 | where := sq.And{ 210 | sq.Eq{repoColumnGood.ID: id}, 211 | } 212 | 213 | // build SQL query 214 | query, args, err := r.pgsq.Delete(repoTableGood). 215 | Where(where). 216 | ToSql() 217 | if err != nil { 218 | return common.NewError(common.ErrorCodeInternalProcess, err) 219 | } 220 | 221 | // execute SQL query 222 | if _, err = r.db.ExecContext(ctx, query, args...); err != nil { 223 | return common.NewError(common.ErrorCodeRemoteProcess, err) 224 | } 225 | return nil 226 | } 227 | -------------------------------------------------------------------------------- /internal/adapter/repository/postgres/good_repository_test.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/bxcodec/faker/v3" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 12 | "github.com/chatbotgang/go-clean-architecture-template/testdata" 13 | ) 14 | 15 | func assertGood(t *testing.T, expected *barter.Good, actual *barter.Good) { 16 | require.NotNil(t, actual) 17 | assert.Equal(t, expected.Name, actual.Name) 18 | assert.Equal(t, expected.OwnerID, actual.OwnerID) 19 | } 20 | 21 | func TestPostgresRepository_CreateGood(t *testing.T) { 22 | db := getTestPostgresDB() 23 | repo := initRepository(t, db, testdata.Path(testdata.TestDataTrader)) 24 | 25 | // Args 26 | type Args struct { 27 | barter.Good 28 | } 29 | var args Args 30 | _ = faker.FakeData(&args) 31 | traderID := 1 32 | args.OwnerID = traderID 33 | 34 | good, err := repo.CreateGood(context.Background(), args.Good) 35 | require.NoError(t, err) 36 | assertGood(t, &args.Good, good) 37 | } 38 | 39 | func TestPostgresRepository_GetGoodByID(t *testing.T) { 40 | db := getTestPostgresDB() 41 | repo := initRepository(t, db, 42 | testdata.Path(testdata.TestDataTrader), 43 | testdata.Path(testdata.TestDataGood), 44 | ) 45 | goodID := 1 46 | 47 | _, err := repo.GetGoodByID(context.Background(), goodID) 48 | require.NoError(t, err) 49 | } 50 | 51 | func TestPostgresRepository_ListGoods(t *testing.T) { 52 | db := getTestPostgresDB() 53 | repo := initRepository(t, db, 54 | testdata.Path(testdata.TestDataTrader), 55 | testdata.Path(testdata.TestDataGood), 56 | ) 57 | 58 | goods, err := repo.ListGoods(context.Background()) 59 | require.NoError(t, err) 60 | assert.Len(t, goods, 2) 61 | } 62 | 63 | func TestPostgresRepository_ListGoodsByOwner(t *testing.T) { 64 | db := getTestPostgresDB() 65 | repo := initRepository(t, db, 66 | testdata.Path(testdata.TestDataTrader), 67 | testdata.Path(testdata.TestDataGood), 68 | ) 69 | traderID := 500 70 | 71 | goods, err := repo.ListGoodsByOwner(context.Background(), traderID) 72 | require.NoError(t, err) 73 | assert.Len(t, goods, 0) 74 | } 75 | 76 | func TestPostgresRepository_UpdateGood(t *testing.T) { 77 | db := getTestPostgresDB() 78 | repo := initRepository(t, db, 79 | testdata.Path(testdata.TestDataTrader), 80 | testdata.Path(testdata.TestDataGood), 81 | ) 82 | goodID := 1 83 | 84 | good, err := repo.GetGoodByID(context.Background(), goodID) 85 | require.NoError(t, err) 86 | 87 | newName := "good 123" 88 | good.Name = newName 89 | 90 | updatedGood, err := repo.UpdateGood(context.Background(), *good) 91 | require.NoError(t, err) 92 | assertGood(t, good, updatedGood) 93 | } 94 | 95 | func TestPostgresRepository_UpdateGoods(t *testing.T) { 96 | db := getTestPostgresDB() 97 | repo := initRepository(t, db, 98 | testdata.Path(testdata.TestDataTrader), 99 | testdata.Path(testdata.TestDataGood), 100 | ) 101 | 102 | good1, err := repo.GetGoodByID(context.Background(), 1) 103 | require.NoError(t, err) 104 | 105 | good2, err := repo.GetGoodByID(context.Background(), 2) 106 | require.NoError(t, err) 107 | 108 | newName := "good 123" 109 | good1.Name = newName 110 | good2.Name = newName 111 | 112 | updatedGoods, err := repo.UpdateGoods(context.Background(), []barter.Good{*good1, *good2}) 113 | require.NoError(t, err) 114 | assertGood(t, good1, &updatedGoods[0]) 115 | assertGood(t, good2, &updatedGoods[1]) 116 | } 117 | 118 | func TestPostgresRepository_DeleteGoodByID(t *testing.T) { 119 | db := getTestPostgresDB() 120 | repo := initRepository(t, db, 121 | testdata.Path(testdata.TestDataTrader), 122 | testdata.Path(testdata.TestDataGood), 123 | ) 124 | goodID := 1 125 | 126 | _, err := repo.GetGoodByID(context.Background(), goodID) 127 | require.NoError(t, err) 128 | 129 | err = repo.DeleteGoodByID(context.Background(), goodID) 130 | require.NoError(t, err) 131 | 132 | _, err = repo.GetGoodByID(context.Background(), goodID) 133 | require.Error(t, err) 134 | } 135 | -------------------------------------------------------------------------------- /internal/adapter/repository/postgres/postgres_repository.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | sq "github.com/Masterminds/squirrel" 8 | "github.com/hashicorp/go-multierror" 9 | "github.com/jmoiron/sqlx" 10 | 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 12 | ) 13 | 14 | type PostgresRepository struct { 15 | db *sqlx.DB 16 | pgsq sq.StatementBuilderType 17 | } 18 | 19 | func NewPostgresRepository(ctx context.Context, db *sqlx.DB) *PostgresRepository { 20 | return &PostgresRepository{ 21 | db: db, 22 | // set the default placeholder as $ instead of ? because postgres uses $ 23 | pgsq: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), 24 | } 25 | } 26 | 27 | // sqlContextGetter is an interface provided both by transaction and standard db connection 28 | type sqlContextGetter interface { 29 | GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error 30 | SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error 31 | ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) 32 | } 33 | 34 | func (r *PostgresRepository) beginTx() (*sqlx.Tx, common.Error) { 35 | tx, err := r.db.Beginx() 36 | if err != nil { 37 | return nil, common.NewError(common.ErrorCodeRemoteProcess, err) 38 | } 39 | return tx, nil 40 | } 41 | 42 | // finishTx close an open transaction 43 | // If error is provided, abort the transaction. 44 | // If err is nil, commit the transaction. 45 | func (r *PostgresRepository) finishTx(err common.Error, tx *sqlx.Tx) common.Error { 46 | if err != nil { 47 | if rollbackErr := tx.Rollback(); rollbackErr != nil { 48 | wrapError := multierror.Append(err, rollbackErr) 49 | return common.NewError(common.ErrorCodeRemoteProcess, wrapError) 50 | } 51 | 52 | return err 53 | } else { 54 | if commitErr := tx.Commit(); commitErr != nil { 55 | return common.NewError(common.ErrorCodeRemoteProcess, commitErr) 56 | } 57 | 58 | return nil 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/adapter/repository/postgres/postgres_repository_test.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log" 8 | "testing" 9 | "time" 10 | 11 | "github.com/bxcodec/faker/v3" 12 | "github.com/go-testfixtures/testfixtures/v3" 13 | "github.com/golang-migrate/migrate/v4" 14 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 15 | _ "github.com/golang-migrate/migrate/v4/source/file" 16 | "github.com/jmoiron/sqlx" 17 | _ "github.com/lib/pq" 18 | "github.com/ory/dockertest/v3" 19 | "github.com/ory/dockertest/v3/docker" 20 | "github.com/pkg/errors" 21 | "github.com/stretchr/testify/require" 22 | ) 23 | 24 | var testPostgresDB *sqlx.DB 25 | 26 | func getTestPostgresDB() *sqlx.DB { 27 | return testPostgresDB 28 | } 29 | 30 | // migrationSourcePath is a relative path to the collection storing all migration scripts 31 | const migrationSourcePath = "file://../../../../migrations" 32 | const testPostgresName = "repo_test" 33 | 34 | func buildTestPostgresDB() (*sqlx.DB, func(), error) { 35 | cb, pgdsn, err := startPostgresContainer() 36 | if err != nil { 37 | return nil, nil, errors.WithMessage(err, "failed to start postgres container") 38 | } 39 | 40 | // migrate postgres 41 | m, err := migrate.New(migrationSourcePath, pgdsn) 42 | if err != nil { 43 | return nil, nil, errors.WithMessage(err, "failed to start migration process") 44 | } 45 | 46 | err = m.Up() 47 | if err != nil { 48 | return nil, nil, errors.WithMessage(err, "failed to migrate postgres") 49 | } 50 | 51 | // connect to postgres 52 | db, err := sqlx.Open("postgres", pgdsn) 53 | if err != nil { 54 | return nil, nil, errors.WithMessage(err, "failed to connect postgres") 55 | } 56 | return db, cb, nil 57 | } 58 | 59 | func startPostgresContainer() (func(), string, error) { 60 | // new a docker pool 61 | pool, err := dockertest.NewPool("") 62 | if err != nil { 63 | return nil, "", fmt.Errorf("could not connect to docker: %s", err) 64 | } 65 | // start a PG container 66 | resource, err := pool.RunWithOptions(&dockertest.RunOptions{ 67 | Repository: "postgres", 68 | Tag: "13.1", 69 | Env: []string{ 70 | fmt.Sprintf("POSTGRES_PASSWORD=%s", testPostgresName), 71 | fmt.Sprintf("POSTGRES_USER=%s", testPostgresName), 72 | fmt.Sprintf("POSTGRES_DB=%s", testPostgresName), 73 | "listen_addresses = '*'", 74 | }, 75 | }, func(config *docker.HostConfig) { 76 | // set AutoRemove to true so that stopped container goes away by itself 77 | config.AutoRemove = true 78 | config.RestartPolicy = docker.RestartPolicy{Name: "no"} 79 | }) 80 | if err != nil { 81 | return nil, "", fmt.Errorf("could not start postgres: %s", err) 82 | } 83 | 84 | // Get host and port(random) info from the postgres container 85 | hostAndPort := resource.GetHostPort("5432/tcp") 86 | LocalhostPostgresDSN := fmt.Sprintf( 87 | "postgresql://repo_test:repo_test@%s/repo_test?sslmode=disable", 88 | hostAndPort, 89 | ) 90 | 91 | // build a call back function to destroy the docker pool 92 | cb := func() { 93 | if err := pool.Purge(resource); err != nil { 94 | log.Printf("Could not purge resource: %s", err) 95 | } 96 | } 97 | // exponential backoff-retry, because the application in the container might not be ready to accept connections yet 98 | pool.MaxWait = 120 * time.Second 99 | if err = pool.Retry(func() error { 100 | db, err := sql.Open("postgres", LocalhostPostgresDSN) 101 | if err != nil { 102 | return err 103 | } 104 | return db.Ping() 105 | }); err != nil { 106 | cb() 107 | return nil, "", fmt.Errorf("could not connect to postgres: %s", err) 108 | } 109 | 110 | return cb, LocalhostPostgresDSN, nil 111 | } 112 | 113 | func initRepository(t *testing.T, db *sqlx.DB, files ...string) (repo *PostgresRepository) { 114 | // Truncate existing records in all tables 115 | truncateAllData(t, db) 116 | 117 | // Setup DB again 118 | loader, err := testfixtures.New( 119 | testfixtures.Database(db.DB), 120 | testfixtures.Dialect("postgres"), 121 | testfixtures.Location(time.UTC), 122 | // Load predefined data 123 | testfixtures.Files(files...), 124 | ) 125 | require.NoError(t, err) 126 | 127 | err = loader.Load() 128 | require.NoError(t, err) 129 | 130 | return NewPostgresRepository(context.Background(), db) 131 | } 132 | 133 | func truncateAllData(t *testing.T, db *sqlx.DB) { 134 | template := `CREATE OR REPLACE FUNCTION truncate_all_tables() RETURNS void AS $$ 135 | DECLARE 136 | statements CURSOR FOR 137 | SELECT tablename FROM %s.pg_catalog.pg_tables 138 | WHERE schemaname = 'public' AND tablename !='schema_migrations'; 139 | BEGIN 140 | FOR stmt IN statements LOOP 141 | EXECUTE 'TRUNCATE TABLE ' || quote_ident(stmt.tablename) || ' RESTART IDENTITY CASCADE;'; 142 | END LOOP; 143 | END; 144 | $$ LANGUAGE plpgsql; 145 | SELECT truncate_all_tables();` 146 | 147 | // We add test postgres db name to avoid mis-using the script in production. 148 | script := fmt.Sprintf(template, testPostgresName) 149 | _, err := db.Exec(script) 150 | require.NoError(t, err) 151 | } 152 | 153 | // nolint 154 | func TestMain(m *testing.M) { 155 | // To avoid violating table constraints 156 | _ = faker.SetRandomStringLength(10) 157 | _ = faker.SetRandomMapAndSliceMinSize(10) 158 | 159 | db, closeDB, err := buildTestPostgresDB() 160 | if err != nil { 161 | fmt.Println(err) 162 | return 163 | } 164 | defer closeDB() 165 | testPostgresDB = db 166 | m.Run() 167 | } 168 | -------------------------------------------------------------------------------- /internal/adapter/repository/postgres/trader_repository.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | sq "github.com/Masterminds/squirrel" 11 | "github.com/pkg/errors" 12 | 13 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 14 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 15 | ) 16 | 17 | type repoTrader struct { 18 | ID int `db:"id"` 19 | UID string `db:"uid"` 20 | Email string `db:"email"` 21 | Name string `db:"name"` 22 | CreatedAt time.Time `db:"created_at"` 23 | UpdatedAt time.Time `db:"updated_at"` 24 | } 25 | 26 | const repoTableTrader = "trader" 27 | 28 | type repoColumnPatternTrader struct { 29 | ID string 30 | UID string 31 | Email string 32 | Name string 33 | CreatedAt string 34 | UpdatedAt string 35 | } 36 | 37 | var repoColumnTrader = repoColumnPatternTrader{ 38 | ID: "id", 39 | UID: "uid", 40 | Email: "email", 41 | Name: "name", 42 | CreatedAt: "created_at", 43 | UpdatedAt: "updated_at", 44 | } 45 | 46 | func (c *repoColumnPatternTrader) columns() string { 47 | return strings.Join([]string{ 48 | c.ID, 49 | c.UID, 50 | c.Email, 51 | c.Name, 52 | c.CreatedAt, 53 | c.UpdatedAt, 54 | }, ", ") 55 | } 56 | 57 | func (r *PostgresRepository) CreateTrader(ctx context.Context, param barter.Trader) (*barter.Trader, common.Error) { 58 | insert := map[string]interface{}{ 59 | repoColumnTrader.UID: param.UID, 60 | repoColumnTrader.Email: param.Email, 61 | repoColumnTrader.Name: param.Name, 62 | } 63 | // build SQL query 64 | query, args, err := r.pgsq.Insert(repoTableTrader). 65 | SetMap(insert). 66 | Suffix(fmt.Sprintf("returning %s", repoColumnTrader.columns())). 67 | ToSql() 68 | if err != nil { 69 | return nil, common.NewError(common.ErrorCodeInternalProcess, err) 70 | } 71 | 72 | // execute SQL query 73 | row := repoTrader{} 74 | if err = r.db.GetContext(ctx, &row, query, args...); err != nil { 75 | return nil, common.NewError(common.ErrorCodeRemoteProcess, err) 76 | } 77 | 78 | // map the query result back to domain model 79 | trader := barter.Trader(row) 80 | return &trader, nil 81 | } 82 | 83 | func (r *PostgresRepository) GetTraderByEmail(ctx context.Context, email string) (*barter.Trader, common.Error) { 84 | query, args, err := r.pgsq.Select(repoColumnTrader.columns()). 85 | From(repoTableTrader). 86 | Where(sq.Eq{repoColumnTrader.Email: email}). 87 | Limit(1). 88 | ToSql() 89 | if err != nil { 90 | return nil, common.NewError(common.ErrorCodeInternalProcess, err) 91 | } 92 | row := repoTrader{} 93 | 94 | // get one row from result 95 | if err = r.db.GetContext(ctx, &row, query, args...); err != nil { 96 | if errors.Is(err, sql.ErrNoRows) { 97 | return nil, common.NewError(common.ErrorCodeResourceNotFound, err, common.WithMsg("trader is not found")) 98 | } 99 | return nil, common.NewError(common.ErrorCodeRemoteProcess, err) 100 | } 101 | 102 | // map the query result back to domain model 103 | trader := barter.Trader(row) 104 | return &trader, nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/adapter/repository/postgres/trader_repository_test.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/bxcodec/faker/v3" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 12 | "github.com/chatbotgang/go-clean-architecture-template/testdata" 13 | ) 14 | 15 | func assertTrader(t *testing.T, expected *barter.Trader, actual *barter.Trader) { 16 | require.NotNil(t, actual) 17 | assert.Equal(t, expected.UID, actual.UID) 18 | assert.Equal(t, expected.Email, actual.Email) 19 | assert.Equal(t, expected.Name, actual.Name) 20 | } 21 | 22 | func TestPostgresRepository_CreateTrader(t *testing.T) { 23 | db := getTestPostgresDB() 24 | repo := initRepository(t, db) 25 | 26 | // Args 27 | type Args struct { 28 | Trader barter.Trader 29 | } 30 | var args Args 31 | _ = faker.FakeData(&args) 32 | 33 | trader, err := repo.CreateTrader(context.Background(), args.Trader) 34 | 35 | require.NoError(t, err) 36 | assertTrader(t, &args.Trader, trader) 37 | 38 | // No duplicate 39 | _, err = repo.CreateTrader(context.Background(), args.Trader) 40 | require.Error(t, err) 41 | } 42 | 43 | func TestPostgresRepository_GetTraderByEmail(t *testing.T) { 44 | db := getTestPostgresDB() 45 | repo := initRepository(t, db, testdata.Path(testdata.TestDataTrader)) 46 | 47 | email := "trader1@cresclab.com" 48 | 49 | _, err := repo.GetTraderByEmail(context.Background(), email) 50 | require.NoError(t, err) 51 | } 52 | -------------------------------------------------------------------------------- /internal/adapter/server/auth_server_dummy.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 9 | ) 10 | 11 | // We don't really implement the auth server, which communicates with the Crescendo's account management service, 12 | // because this application is only used for introduction to the clean architecture. 13 | 14 | type AuthServerParam struct{} 15 | 16 | type AuthServer struct{} 17 | 18 | func NewAuthServer(_ context.Context, param AuthServerParam) *AuthServer { 19 | return &AuthServer{} 20 | } 21 | 22 | func (s *AuthServer) RegisterAccount(ctx context.Context, email string, password string) (string, common.Error) { 23 | return uuid.NewString(), nil 24 | } 25 | 26 | func (s *AuthServer) AuthenticateAccount(ctx context.Context, email string, password string) common.Error { 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /internal/app/application.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "sync" 7 | "time" 8 | 9 | "github.com/jmoiron/sqlx" 10 | _ "github.com/lib/pq" 11 | 12 | "github.com/chatbotgang/go-clean-architecture-template/internal/adapter/repository/postgres" 13 | "github.com/chatbotgang/go-clean-architecture-template/internal/adapter/server" 14 | "github.com/chatbotgang/go-clean-architecture-template/internal/app/service/auth" 15 | "github.com/chatbotgang/go-clean-architecture-template/internal/app/service/barter" 16 | ) 17 | 18 | type Application struct { 19 | Params ApplicationParams 20 | AuthService *auth.AuthService 21 | BarterService *barter.BarterService 22 | } 23 | 24 | type ApplicationParams struct { 25 | // General configuration 26 | Env string 27 | 28 | // Database parameters 29 | DatabaseDSN string 30 | 31 | // Token parameter 32 | TokenSigningKey []byte 33 | TokenExpiryDuration time.Duration 34 | TokenIssuer string 35 | } 36 | 37 | func MustNewApplication(ctx context.Context, wg *sync.WaitGroup, params ApplicationParams) *Application { 38 | app, err := NewApplication(ctx, wg, params) 39 | if err != nil { 40 | log.Panicf("fail to new application, err: %s", err.Error()) 41 | } 42 | return app 43 | } 44 | 45 | func NewApplication(ctx context.Context, wg *sync.WaitGroup, params ApplicationParams) (*Application, error) { 46 | // Create repositories 47 | db := sqlx.MustOpen("postgres", params.DatabaseDSN) 48 | if err := db.Ping(); err != nil { 49 | return nil, err 50 | } 51 | pgRepo := postgres.NewPostgresRepository(ctx, db) 52 | 53 | // Create servers 54 | authServer := server.NewAuthServer(ctx, server.AuthServerParam{}) 55 | 56 | // Create application 57 | app := &Application{ 58 | Params: params, 59 | AuthService: auth.NewAuthService(ctx, auth.AuthServiceParam{ 60 | AuthServer: authServer, 61 | TraderRepo: pgRepo, 62 | SigningKey: params.TokenSigningKey, 63 | ExpiryDuration: params.TokenExpiryDuration, 64 | Issuer: params.TokenIssuer, 65 | }), 66 | BarterService: barter.NewBarterService(ctx, barter.BarterServiceParam{ 67 | GoodRepo: pgRepo, 68 | }), 69 | } 70 | 71 | return app, nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/app/service/auth/automock/auth_server.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/chatbotgang/go-clean-architecture-template/internal/app/service/auth (interfaces: AuthServer) 3 | 4 | // Package automock is a generated GoMock package. 5 | package automock 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | common "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 12 | gomock "github.com/golang/mock/gomock" 13 | ) 14 | 15 | // MockAuthServer is a mock of AuthServer interface. 16 | type MockAuthServer struct { 17 | ctrl *gomock.Controller 18 | recorder *MockAuthServerMockRecorder 19 | } 20 | 21 | // MockAuthServerMockRecorder is the mock recorder for MockAuthServer. 22 | type MockAuthServerMockRecorder struct { 23 | mock *MockAuthServer 24 | } 25 | 26 | // NewMockAuthServer creates a new mock instance. 27 | func NewMockAuthServer(ctrl *gomock.Controller) *MockAuthServer { 28 | mock := &MockAuthServer{ctrl: ctrl} 29 | mock.recorder = &MockAuthServerMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockAuthServer) EXPECT() *MockAuthServerMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // AuthenticateAccount mocks base method. 39 | func (m *MockAuthServer) AuthenticateAccount(arg0 context.Context, arg1, arg2 string) common.Error { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "AuthenticateAccount", arg0, arg1, arg2) 42 | ret0, _ := ret[0].(common.Error) 43 | return ret0 44 | } 45 | 46 | // AuthenticateAccount indicates an expected call of AuthenticateAccount. 47 | func (mr *MockAuthServerMockRecorder) AuthenticateAccount(arg0, arg1, arg2 interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthenticateAccount", reflect.TypeOf((*MockAuthServer)(nil).AuthenticateAccount), arg0, arg1, arg2) 50 | } 51 | 52 | // RegisterAccount mocks base method. 53 | func (m *MockAuthServer) RegisterAccount(arg0 context.Context, arg1, arg2 string) (string, common.Error) { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "RegisterAccount", arg0, arg1, arg2) 56 | ret0, _ := ret[0].(string) 57 | ret1, _ := ret[1].(common.Error) 58 | return ret0, ret1 59 | } 60 | 61 | // RegisterAccount indicates an expected call of RegisterAccount. 62 | func (mr *MockAuthServerMockRecorder) RegisterAccount(arg0, arg1, arg2 interface{}) *gomock.Call { 63 | mr.mock.ctrl.T.Helper() 64 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterAccount", reflect.TypeOf((*MockAuthServer)(nil).RegisterAccount), arg0, arg1, arg2) 65 | } 66 | -------------------------------------------------------------------------------- /internal/app/service/auth/automock/trader_repository.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/chatbotgang/go-clean-architecture-template/internal/app/service/auth (interfaces: TraderRepository) 3 | 4 | // Package automock is a generated GoMock package. 5 | package automock 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | barter "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 12 | common "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 13 | gomock "github.com/golang/mock/gomock" 14 | ) 15 | 16 | // MockTraderRepository is a mock of TraderRepository interface. 17 | type MockTraderRepository struct { 18 | ctrl *gomock.Controller 19 | recorder *MockTraderRepositoryMockRecorder 20 | } 21 | 22 | // MockTraderRepositoryMockRecorder is the mock recorder for MockTraderRepository. 23 | type MockTraderRepositoryMockRecorder struct { 24 | mock *MockTraderRepository 25 | } 26 | 27 | // NewMockTraderRepository creates a new mock instance. 28 | func NewMockTraderRepository(ctrl *gomock.Controller) *MockTraderRepository { 29 | mock := &MockTraderRepository{ctrl: ctrl} 30 | mock.recorder = &MockTraderRepositoryMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockTraderRepository) EXPECT() *MockTraderRepositoryMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // CreateTrader mocks base method. 40 | func (m *MockTraderRepository) CreateTrader(arg0 context.Context, arg1 barter.Trader) (*barter.Trader, common.Error) { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "CreateTrader", arg0, arg1) 43 | ret0, _ := ret[0].(*barter.Trader) 44 | ret1, _ := ret[1].(common.Error) 45 | return ret0, ret1 46 | } 47 | 48 | // CreateTrader indicates an expected call of CreateTrader. 49 | func (mr *MockTraderRepositoryMockRecorder) CreateTrader(arg0, arg1 interface{}) *gomock.Call { 50 | mr.mock.ctrl.T.Helper() 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTrader", reflect.TypeOf((*MockTraderRepository)(nil).CreateTrader), arg0, arg1) 52 | } 53 | 54 | // GetTraderByEmail mocks base method. 55 | func (m *MockTraderRepository) GetTraderByEmail(arg0 context.Context, arg1 string) (*barter.Trader, common.Error) { 56 | m.ctrl.T.Helper() 57 | ret := m.ctrl.Call(m, "GetTraderByEmail", arg0, arg1) 58 | ret0, _ := ret[0].(*barter.Trader) 59 | ret1, _ := ret[1].(common.Error) 60 | return ret0, ret1 61 | } 62 | 63 | // GetTraderByEmail indicates an expected call of GetTraderByEmail. 64 | func (mr *MockTraderRepositoryMockRecorder) GetTraderByEmail(arg0, arg1 interface{}) *gomock.Call { 65 | mr.mock.ctrl.T.Helper() 66 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTraderByEmail", reflect.TypeOf((*MockTraderRepository)(nil).GetTraderByEmail), arg0, arg1) 67 | } 68 | -------------------------------------------------------------------------------- /internal/app/service/auth/interface.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 7 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 8 | ) 9 | 10 | //go:generate mockgen -destination automock/auth_server.go -package=automock . AuthServer 11 | type AuthServer interface { 12 | RegisterAccount(ctx context.Context, email string, password string) (string, common.Error) 13 | AuthenticateAccount(ctx context.Context, email string, password string) common.Error 14 | } 15 | 16 | //go:generate mockgen -destination automock/trader_repository.go -package=automock . TraderRepository 17 | type TraderRepository interface { 18 | GetTraderByEmail(ctx context.Context, email string) (*barter.Trader, common.Error) 19 | CreateTrader(ctx context.Context, trader barter.Trader) (*barter.Trader, common.Error) 20 | } 21 | -------------------------------------------------------------------------------- /internal/app/service/auth/service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | type AuthService struct { 11 | authServer AuthServer 12 | traderRepo TraderRepository 13 | 14 | signingKey []byte 15 | expiryDuration time.Duration 16 | issuer string 17 | } 18 | 19 | type AuthServiceParam struct { 20 | AuthServer AuthServer 21 | TraderRepo TraderRepository 22 | 23 | SigningKey []byte 24 | ExpiryDuration time.Duration 25 | Issuer string 26 | } 27 | 28 | func NewAuthService(_ context.Context, param AuthServiceParam) *AuthService { 29 | return &AuthService{ 30 | authServer: param.AuthServer, 31 | traderRepo: param.TraderRepo, 32 | 33 | signingKey: param.SigningKey, 34 | expiryDuration: param.ExpiryDuration, 35 | issuer: param.Issuer, 36 | } 37 | } 38 | 39 | // logger wrap the execution context with component info 40 | func (s *AuthService) logger(ctx context.Context) *zerolog.Logger { 41 | l := zerolog.Ctx(ctx).With().Str("component", "auth-service").Logger() 42 | return &l 43 | } 44 | -------------------------------------------------------------------------------- /internal/app/service/auth/service_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/bxcodec/faker/v3" 8 | "github.com/golang/mock/gomock" 9 | 10 | "github.com/chatbotgang/go-clean-architecture-template/internal/app/service/auth/automock" 11 | ) 12 | 13 | type serviceMock struct { 14 | AuthServer *automock.MockAuthServer 15 | TraderRepo *automock.MockTraderRepository 16 | } 17 | 18 | func buildServiceMock(ctrl *gomock.Controller) serviceMock { 19 | return serviceMock{ 20 | AuthServer: automock.NewMockAuthServer(ctrl), 21 | TraderRepo: automock.NewMockTraderRepository(ctrl), 22 | } 23 | } 24 | func buildService(mock serviceMock) *AuthService { 25 | param := AuthServiceParam{ 26 | AuthServer: mock.AuthServer, 27 | TraderRepo: mock.TraderRepo, 28 | } 29 | return NewAuthService(context.Background(), param) 30 | } 31 | 32 | // nolint 33 | func TestMain(m *testing.M) { 34 | // To avoid getting an empty object slice 35 | _ = faker.SetRandomMapAndSliceMinSize(2) 36 | 37 | // To avoid getting a zero random number 38 | _ = faker.SetRandomNumberBoundaries(1, 100) 39 | 40 | m.Run() 41 | } 42 | -------------------------------------------------------------------------------- /internal/app/service/auth/token_service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 7 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 8 | ) 9 | 10 | func (s *AuthService) GenerateTraderToken(_ context.Context, trader barter.Trader) (string, common.Error) { 11 | signedToken, err := barter.GenerateTraderToken(trader, s.signingKey, s.expiryDuration, s.issuer) 12 | if err != nil { 13 | return "", common.NewError(common.ErrorCodeParameterInvalid, err, common.WithMsg(err.ClientMsg())) 14 | } 15 | return signedToken, nil 16 | } 17 | 18 | func (s *AuthService) ValidateTraderToken(_ context.Context, signedToken string) (*barter.Trader, common.Error) { 19 | trader, err := barter.ParseTraderFromToken(signedToken, s.signingKey) 20 | if err != nil { 21 | return nil, common.NewError(common.ErrorCodeAuthNotAuthenticated, err, common.WithMsg(err.ClientMsg())) 22 | } 23 | return trader, nil 24 | } 25 | -------------------------------------------------------------------------------- /internal/app/service/auth/trader_service.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 8 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 9 | ) 10 | 11 | type RegisterTraderParam struct { 12 | Email string 13 | Name string 14 | Password string 15 | } 16 | 17 | func (s *AuthService) RegisterTrader(ctx context.Context, param RegisterTraderParam) (*barter.Trader, common.Error) { 18 | // Check the given trader email exist or not 19 | _, err := s.traderRepo.GetTraderByEmail(ctx, param.Email) 20 | if err == nil { 21 | msg := "email exists" 22 | s.logger(ctx).Error().Msg(msg) 23 | return nil, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 24 | } 25 | 26 | // If not existed: 27 | // 1. Register a new account to Crescendo's auth server. 28 | // 2. Create a trader in the application. 29 | uid, err := s.authServer.RegisterAccount(ctx, param.Email, param.Password) 30 | if err != nil { 31 | s.logger(ctx).Error().Err(err).Msg("failed to register account in Crescendo") 32 | return nil, err 33 | } 34 | 35 | trader, err := s.traderRepo.CreateTrader(ctx, barter.NewTrader(uid, param.Email, param.Name)) 36 | if err != nil { 37 | s.logger(ctx).Error().Err(err).Msg("failed to register trader") 38 | return nil, err 39 | } 40 | 41 | return trader, nil 42 | } 43 | 44 | type LoginTraderParam struct { 45 | Email string 46 | Password string 47 | } 48 | 49 | func (s *AuthService) LoginTrader(ctx context.Context, param LoginTraderParam) (*barter.Trader, common.Error) { 50 | // Check the given trader email exist or not 51 | trader, err := s.traderRepo.GetTraderByEmail(ctx, param.Email) 52 | if err != nil { 53 | s.logger(ctx).Error().Err(err).Msg("failed to get trader") 54 | msg := "email does not exist" 55 | return nil, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 56 | } 57 | 58 | // Authenticate the account 59 | err = s.authServer.AuthenticateAccount(ctx, param.Email, param.Password) 60 | if err != nil { 61 | s.logger(ctx).Error().Err(err).Msg("failed to authenticate account") 62 | msg := "invalid password" 63 | s.logger(ctx).Error().Msg(msg) 64 | return nil, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 65 | } 66 | 67 | return trader, nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/app/service/auth/trader_service_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/bxcodec/faker/v3" 8 | "github.com/golang/mock/gomock" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 12 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 13 | ) 14 | 15 | func TestBarterService_RegisterTrader(t *testing.T) { 16 | t.Parallel() 17 | // Args 18 | type Args struct { 19 | Trader barter.Trader 20 | Password string 21 | } 22 | var args Args 23 | _ = faker.FakeData(&args) 24 | 25 | // Init 26 | ctrl := gomock.NewController(t) 27 | defer ctrl.Finish() 28 | 29 | // Test cases 30 | testCases := []struct { 31 | Name string 32 | SetupService func(t *testing.T) *AuthService 33 | ExpectError bool 34 | }{ 35 | { 36 | Name: "trader does not exist", 37 | SetupService: func(t *testing.T) *AuthService { 38 | mock := buildServiceMock(ctrl) 39 | 40 | mock.TraderRepo.EXPECT().GetTraderByEmail(gomock.Any(), args.Trader.Email).Return(nil, common.DomainError{}) 41 | mock.AuthServer.EXPECT().RegisterAccount(gomock.Any(), args.Trader.Email, args.Password).Return(args.Trader.UID, nil) 42 | mock.TraderRepo.EXPECT().CreateTrader(gomock.Any(), gomock.Any()).Return(&args.Trader, nil) 43 | 44 | service := buildService(mock) 45 | return service 46 | }, 47 | ExpectError: false, 48 | }, 49 | { 50 | Name: "failed to register trader", 51 | SetupService: func(t *testing.T) *AuthService { 52 | mock := buildServiceMock(ctrl) 53 | 54 | mock.TraderRepo.EXPECT().GetTraderByEmail(gomock.Any(), args.Trader.Email).Return(nil, common.DomainError{}) 55 | mock.AuthServer.EXPECT().RegisterAccount(gomock.Any(), args.Trader.Email, args.Password).Return(args.Trader.UID, nil) 56 | mock.TraderRepo.EXPECT().CreateTrader(gomock.Any(), gomock.Any()).Return(nil, common.DomainError{}) 57 | 58 | service := buildService(mock) 59 | return service 60 | }, 61 | ExpectError: true, 62 | }, 63 | { 64 | Name: "failed to register account", 65 | SetupService: func(t *testing.T) *AuthService { 66 | mock := buildServiceMock(ctrl) 67 | 68 | mock.TraderRepo.EXPECT().GetTraderByEmail(gomock.Any(), args.Trader.Email).Return(nil, common.DomainError{}) 69 | mock.AuthServer.EXPECT().RegisterAccount(gomock.Any(), args.Trader.Email, args.Password).Return("", common.DomainError{}) 70 | 71 | service := buildService(mock) 72 | return service 73 | }, 74 | ExpectError: true, 75 | }, 76 | { 77 | Name: "trader exist", 78 | SetupService: func(t *testing.T) *AuthService { 79 | mock := buildServiceMock(ctrl) 80 | 81 | mock.TraderRepo.EXPECT().GetTraderByEmail(gomock.Any(), args.Trader.Email).Return(&args.Trader, nil) 82 | 83 | service := buildService(mock) 84 | return service 85 | }, 86 | ExpectError: true, 87 | }, 88 | } 89 | 90 | for i := range testCases { 91 | c := testCases[i] 92 | t.Run(c.Name, func(t *testing.T) { 93 | service := c.SetupService(t) 94 | param := RegisterTraderParam{ 95 | Email: args.Trader.Email, 96 | Name: args.Trader.Name, 97 | Password: args.Password, 98 | } 99 | 100 | _, err := service.RegisterTrader(context.Background(), param) 101 | 102 | if c.ExpectError { 103 | require.Error(t, err) 104 | } else { 105 | require.NoError(t, err) 106 | } 107 | }) 108 | } 109 | } 110 | 111 | func TestBarterService_LoginTrader(t *testing.T) { 112 | t.Parallel() 113 | // Args 114 | type Args struct { 115 | Trader barter.Trader 116 | Password string 117 | } 118 | var args Args 119 | _ = faker.FakeData(&args) 120 | 121 | // Init 122 | ctrl := gomock.NewController(t) 123 | defer ctrl.Finish() 124 | 125 | // Test cases 126 | testCases := []struct { 127 | Name string 128 | SetupService func(t *testing.T) *AuthService 129 | ExpectError bool 130 | }{ 131 | { 132 | Name: "success", 133 | SetupService: func(t *testing.T) *AuthService { 134 | mock := buildServiceMock(ctrl) 135 | 136 | mock.TraderRepo.EXPECT().GetTraderByEmail(gomock.Any(), args.Trader.Email).Return(&args.Trader, nil) 137 | mock.AuthServer.EXPECT().AuthenticateAccount(gomock.Any(), args.Trader.Email, args.Password).Return(nil) 138 | 139 | service := buildService(mock) 140 | return service 141 | }, 142 | ExpectError: false, 143 | }, 144 | { 145 | Name: "invalid args.Password", 146 | SetupService: func(t *testing.T) *AuthService { 147 | mock := buildServiceMock(ctrl) 148 | 149 | mock.TraderRepo.EXPECT().GetTraderByEmail(gomock.Any(), args.Trader.Email).Return(&args.Trader, nil) 150 | mock.AuthServer.EXPECT().AuthenticateAccount(gomock.Any(), args.Trader.Email, args.Password).Return(common.DomainError{}) 151 | 152 | service := buildService(mock) 153 | return service 154 | }, 155 | ExpectError: true, 156 | }, 157 | { 158 | Name: "email does not exist", 159 | SetupService: func(t *testing.T) *AuthService { 160 | mock := buildServiceMock(ctrl) 161 | 162 | mock.TraderRepo.EXPECT().GetTraderByEmail(gomock.Any(), args.Trader.Email).Return(nil, common.DomainError{}) 163 | 164 | service := buildService(mock) 165 | return service 166 | }, 167 | ExpectError: true, 168 | }, 169 | } 170 | 171 | for i := range testCases { 172 | c := testCases[i] 173 | t.Run(c.Name, func(t *testing.T) { 174 | service := c.SetupService(t) 175 | param := LoginTraderParam{ 176 | Email: args.Trader.Email, 177 | Password: args.Password, 178 | } 179 | 180 | _, err := service.LoginTrader(context.Background(), param) 181 | 182 | if c.ExpectError { 183 | require.Error(t, err) 184 | } else { 185 | require.NoError(t, err) 186 | } 187 | }) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /internal/app/service/barter/automock/good_repository.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/chatbotgang/go-clean-architecture-template/internal/app/service/barter (interfaces: GoodRepository) 3 | 4 | // Package automock is a generated GoMock package. 5 | package automock 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | barter "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 12 | common "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 13 | gomock "github.com/golang/mock/gomock" 14 | ) 15 | 16 | // MockGoodRepository is a mock of GoodRepository interface. 17 | type MockGoodRepository struct { 18 | ctrl *gomock.Controller 19 | recorder *MockGoodRepositoryMockRecorder 20 | } 21 | 22 | // MockGoodRepositoryMockRecorder is the mock recorder for MockGoodRepository. 23 | type MockGoodRepositoryMockRecorder struct { 24 | mock *MockGoodRepository 25 | } 26 | 27 | // NewMockGoodRepository creates a new mock instance. 28 | func NewMockGoodRepository(ctrl *gomock.Controller) *MockGoodRepository { 29 | mock := &MockGoodRepository{ctrl: ctrl} 30 | mock.recorder = &MockGoodRepositoryMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockGoodRepository) EXPECT() *MockGoodRepositoryMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // CreateGood mocks base method. 40 | func (m *MockGoodRepository) CreateGood(arg0 context.Context, arg1 barter.Good) (*barter.Good, common.Error) { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "CreateGood", arg0, arg1) 43 | ret0, _ := ret[0].(*barter.Good) 44 | ret1, _ := ret[1].(common.Error) 45 | return ret0, ret1 46 | } 47 | 48 | // CreateGood indicates an expected call of CreateGood. 49 | func (mr *MockGoodRepositoryMockRecorder) CreateGood(arg0, arg1 interface{}) *gomock.Call { 50 | mr.mock.ctrl.T.Helper() 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGood", reflect.TypeOf((*MockGoodRepository)(nil).CreateGood), arg0, arg1) 52 | } 53 | 54 | // DeleteGoodByID mocks base method. 55 | func (m *MockGoodRepository) DeleteGoodByID(arg0 context.Context, arg1 int) common.Error { 56 | m.ctrl.T.Helper() 57 | ret := m.ctrl.Call(m, "DeleteGoodByID", arg0, arg1) 58 | ret0, _ := ret[0].(common.Error) 59 | return ret0 60 | } 61 | 62 | // DeleteGoodByID indicates an expected call of DeleteGoodByID. 63 | func (mr *MockGoodRepositoryMockRecorder) DeleteGoodByID(arg0, arg1 interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGoodByID", reflect.TypeOf((*MockGoodRepository)(nil).DeleteGoodByID), arg0, arg1) 66 | } 67 | 68 | // GetGoodByID mocks base method. 69 | func (m *MockGoodRepository) GetGoodByID(arg0 context.Context, arg1 int) (*barter.Good, common.Error) { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "GetGoodByID", arg0, arg1) 72 | ret0, _ := ret[0].(*barter.Good) 73 | ret1, _ := ret[1].(common.Error) 74 | return ret0, ret1 75 | } 76 | 77 | // GetGoodByID indicates an expected call of GetGoodByID. 78 | func (mr *MockGoodRepositoryMockRecorder) GetGoodByID(arg0, arg1 interface{}) *gomock.Call { 79 | mr.mock.ctrl.T.Helper() 80 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGoodByID", reflect.TypeOf((*MockGoodRepository)(nil).GetGoodByID), arg0, arg1) 81 | } 82 | 83 | // ListGoods mocks base method. 84 | func (m *MockGoodRepository) ListGoods(arg0 context.Context) ([]barter.Good, common.Error) { 85 | m.ctrl.T.Helper() 86 | ret := m.ctrl.Call(m, "ListGoods", arg0) 87 | ret0, _ := ret[0].([]barter.Good) 88 | ret1, _ := ret[1].(common.Error) 89 | return ret0, ret1 90 | } 91 | 92 | // ListGoods indicates an expected call of ListGoods. 93 | func (mr *MockGoodRepositoryMockRecorder) ListGoods(arg0 interface{}) *gomock.Call { 94 | mr.mock.ctrl.T.Helper() 95 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListGoods", reflect.TypeOf((*MockGoodRepository)(nil).ListGoods), arg0) 96 | } 97 | 98 | // ListGoodsByOwner mocks base method. 99 | func (m *MockGoodRepository) ListGoodsByOwner(arg0 context.Context, arg1 int) ([]barter.Good, common.Error) { 100 | m.ctrl.T.Helper() 101 | ret := m.ctrl.Call(m, "ListGoodsByOwner", arg0, arg1) 102 | ret0, _ := ret[0].([]barter.Good) 103 | ret1, _ := ret[1].(common.Error) 104 | return ret0, ret1 105 | } 106 | 107 | // ListGoodsByOwner indicates an expected call of ListGoodsByOwner. 108 | func (mr *MockGoodRepositoryMockRecorder) ListGoodsByOwner(arg0, arg1 interface{}) *gomock.Call { 109 | mr.mock.ctrl.T.Helper() 110 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListGoodsByOwner", reflect.TypeOf((*MockGoodRepository)(nil).ListGoodsByOwner), arg0, arg1) 111 | } 112 | 113 | // UpdateGood mocks base method. 114 | func (m *MockGoodRepository) UpdateGood(arg0 context.Context, arg1 barter.Good) (*barter.Good, common.Error) { 115 | m.ctrl.T.Helper() 116 | ret := m.ctrl.Call(m, "UpdateGood", arg0, arg1) 117 | ret0, _ := ret[0].(*barter.Good) 118 | ret1, _ := ret[1].(common.Error) 119 | return ret0, ret1 120 | } 121 | 122 | // UpdateGood indicates an expected call of UpdateGood. 123 | func (mr *MockGoodRepositoryMockRecorder) UpdateGood(arg0, arg1 interface{}) *gomock.Call { 124 | mr.mock.ctrl.T.Helper() 125 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGood", reflect.TypeOf((*MockGoodRepository)(nil).UpdateGood), arg0, arg1) 126 | } 127 | 128 | // UpdateGoods mocks base method. 129 | func (m *MockGoodRepository) UpdateGoods(arg0 context.Context, arg1 []barter.Good) ([]barter.Good, common.Error) { 130 | m.ctrl.T.Helper() 131 | ret := m.ctrl.Call(m, "UpdateGoods", arg0, arg1) 132 | ret0, _ := ret[0].([]barter.Good) 133 | ret1, _ := ret[1].(common.Error) 134 | return ret0, ret1 135 | } 136 | 137 | // UpdateGoods indicates an expected call of UpdateGoods. 138 | func (mr *MockGoodRepositoryMockRecorder) UpdateGoods(arg0, arg1 interface{}) *gomock.Call { 139 | mr.mock.ctrl.T.Helper() 140 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGoods", reflect.TypeOf((*MockGoodRepository)(nil).UpdateGoods), arg0, arg1) 141 | } 142 | -------------------------------------------------------------------------------- /internal/app/service/barter/exchange_service.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 7 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 8 | ) 9 | 10 | type ExchangeGoodsParam struct { 11 | Trader barter.Trader 12 | RequestGoodID int 13 | TargetGoodID int 14 | } 15 | 16 | func (s *BarterService) ExchangeGoods(ctx context.Context, param ExchangeGoodsParam) common.Error { 17 | // 1. Check ownership of request Good 18 | requestGood, err := s.goodRepo.GetGoodByID(ctx, param.RequestGoodID) 19 | if err != nil { 20 | s.logger(ctx).Error().Err(err).Msg("failed to get request good") 21 | return err 22 | } 23 | if !requestGood.IsMyGood(param.Trader) { 24 | s.logger(ctx).Error().Msg("not the owner of request good") 25 | return common.NewError(common.ErrorCodeAuthPermissionDenied, nil) 26 | } 27 | 28 | // 2. Check the target good exist or not 29 | targetGood, err := s.goodRepo.GetGoodByID(ctx, param.TargetGoodID) 30 | if err != nil { 31 | s.logger(ctx).Error().Err(err).Msg("failed to get target good") 32 | return err 33 | } 34 | 35 | // 3. Exchange ownerships of two goods 36 | _, err = s.goodRepo.UpdateGoods(ctx, barter.ExchangeGoods(*requestGood, *targetGood)) 37 | if err != nil { 38 | s.logger(ctx).Error().Err(err).Msg("failed to exchange goods") 39 | return err 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/app/service/barter/exchange_service_test.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/bxcodec/faker/v3" 8 | "github.com/golang/mock/gomock" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 12 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 13 | ) 14 | 15 | func TestBarterService_ExchangeGoods(t *testing.T) { 16 | t.Parallel() 17 | // Args 18 | type Args struct { 19 | Trader barter.Trader 20 | RequestGood barter.Good 21 | TargetGood barter.Good 22 | } 23 | var args Args 24 | _ = faker.FakeData(&args) 25 | args.RequestGood.OwnerID = args.Trader.ID 26 | 27 | // Init 28 | ctrl := gomock.NewController(t) 29 | defer ctrl.Finish() 30 | 31 | // Test cases 32 | testCases := []struct { 33 | Name string 34 | SetupService func(t *testing.T) *BarterService 35 | ExpectError bool 36 | }{ 37 | { 38 | Name: "success", 39 | SetupService: func(t *testing.T) *BarterService { 40 | mock := buildServiceMock(ctrl) 41 | 42 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.RequestGood.ID).Return(&args.RequestGood, nil) 43 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.TargetGood.ID).Return(&args.TargetGood, nil) 44 | mock.GoodRepo.EXPECT().UpdateGoods(gomock.Any(), gomock.Any()).Return([]barter.Good{args.RequestGood, args.TargetGood}, nil) 45 | 46 | service := buildService(mock) 47 | return service 48 | }, 49 | ExpectError: false, 50 | }, 51 | { 52 | Name: "failed to exchange goods", 53 | SetupService: func(t *testing.T) *BarterService { 54 | mock := buildServiceMock(ctrl) 55 | 56 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.RequestGood.ID).Return(&args.RequestGood, nil) 57 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.TargetGood.ID).Return(&args.TargetGood, nil) 58 | mock.GoodRepo.EXPECT().UpdateGoods(gomock.Any(), gomock.Any()).Return(nil, common.DomainError{}) 59 | 60 | service := buildService(mock) 61 | return service 62 | }, 63 | ExpectError: true, 64 | }, 65 | { 66 | Name: "failed to get target good", 67 | SetupService: func(t *testing.T) *BarterService { 68 | mock := buildServiceMock(ctrl) 69 | 70 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.RequestGood.ID).Return(&args.RequestGood, nil) 71 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.TargetGood.ID).Return(nil, common.DomainError{}) 72 | 73 | service := buildService(mock) 74 | return service 75 | }, 76 | ExpectError: true, 77 | }, 78 | { 79 | Name: "no ownership of request good", 80 | SetupService: func(t *testing.T) *BarterService { 81 | mock := buildServiceMock(ctrl) 82 | 83 | RequestGood := args.RequestGood 84 | RequestGood.OwnerID = args.RequestGood.OwnerID + 1 85 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.RequestGood.ID).Return(&RequestGood, nil) 86 | 87 | service := buildService(mock) 88 | return service 89 | }, 90 | ExpectError: true, 91 | }, 92 | { 93 | Name: "failed to get request good", 94 | SetupService: func(t *testing.T) *BarterService { 95 | mock := buildServiceMock(ctrl) 96 | 97 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.RequestGood.ID).Return(nil, common.DomainError{}) 98 | 99 | service := buildService(mock) 100 | return service 101 | }, 102 | ExpectError: true, 103 | }, 104 | } 105 | 106 | for i := range testCases { 107 | c := testCases[i] 108 | t.Run(c.Name, func(t *testing.T) { 109 | service := c.SetupService(t) 110 | param := ExchangeGoodsParam{ 111 | Trader: args.Trader, 112 | RequestGoodID: args.RequestGood.ID, 113 | TargetGoodID: args.TargetGood.ID, 114 | } 115 | 116 | err := service.ExchangeGoods(context.Background(), param) 117 | 118 | if c.ExpectError { 119 | require.Error(t, err) 120 | } else { 121 | require.NoError(t, err) 122 | } 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /internal/app/service/barter/good_service.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 7 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 8 | ) 9 | 10 | type PostGoodParam struct { 11 | Trader barter.Trader 12 | GoodName string 13 | } 14 | 15 | func (s *BarterService) PostGood(ctx context.Context, param PostGoodParam) (*barter.Good, common.Error) { 16 | return s.goodRepo.CreateGood(ctx, barter.NewGood(param.Trader, param.GoodName)) 17 | } 18 | 19 | type ListMyGoodsParam struct { 20 | Trader barter.Trader 21 | } 22 | 23 | func (s *BarterService) ListMyGoods(ctx context.Context, param ListMyGoodsParam) ([]barter.Good, common.Error) { 24 | goods, err := s.goodRepo.ListGoodsByOwner(ctx, param.Trader.ID) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return goods, nil 29 | } 30 | 31 | type ListOthersGoodsParam struct { 32 | Trader barter.Trader 33 | } 34 | 35 | func (s *BarterService) ListOthersGoods(ctx context.Context, param ListOthersGoodsParam) ([]barter.Good, common.Error) { 36 | goods, err := s.goodRepo.ListGoods(ctx) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | // Filter out goods of mine 42 | var filteredGoods []barter.Good 43 | for i := range goods { 44 | g := goods[i] 45 | if !g.IsMyGood(param.Trader) { 46 | filteredGoods = append(filteredGoods, g) 47 | } 48 | } 49 | return filteredGoods, nil 50 | } 51 | 52 | type RemoveGoodParam struct { 53 | Trader barter.Trader 54 | GoodID int 55 | } 56 | 57 | func (s *BarterService) RemoveMyGood(ctx context.Context, param RemoveGoodParam) common.Error { 58 | // Check the good exist otr not 59 | good, err := s.goodRepo.GetGoodByID(ctx, param.GoodID) 60 | if err != nil { 61 | s.logger(ctx).Error().Err(err).Msg("failed to get good") 62 | return err 63 | } 64 | 65 | // Check the ownership 66 | if !good.IsMyGood(param.Trader) { 67 | s.logger(ctx).Error(). 68 | Int("traderID", param.Trader.ID). 69 | Int("goodOwnerID", good.OwnerID). 70 | Msg("cannot remove others' good") 71 | return common.NewError(common.ErrorCodeAuthPermissionDenied, nil) 72 | } 73 | 74 | // Remove the good 75 | return s.goodRepo.DeleteGoodByID(ctx, good.ID) 76 | } 77 | -------------------------------------------------------------------------------- /internal/app/service/barter/good_service_test.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/bxcodec/faker/v3" 8 | "github.com/golang/mock/gomock" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 12 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 13 | ) 14 | 15 | func TestBarterService_RemoveMyGood(t *testing.T) { 16 | t.Parallel() 17 | // Args 18 | type Args struct { 19 | Trader barter.Trader 20 | Good barter.Good 21 | } 22 | var args Args 23 | _ = faker.FakeData(&args) 24 | args.Good.OwnerID = args.Trader.ID 25 | 26 | // Init 27 | ctrl := gomock.NewController(t) 28 | defer ctrl.Finish() 29 | 30 | // Test cases 31 | testCases := []struct { 32 | Name string 33 | SetupService func(t *testing.T) *BarterService 34 | ExpectError bool 35 | }{ 36 | { 37 | Name: "remove my good", 38 | SetupService: func(t *testing.T) *BarterService { 39 | mock := buildServiceMock(ctrl) 40 | 41 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.Good.ID).Return(&args.Good, nil) 42 | mock.GoodRepo.EXPECT().DeleteGoodByID(gomock.Any(), args.Good.ID).Return(nil) 43 | 44 | service := buildService(mock) 45 | return service 46 | }, 47 | ExpectError: false, 48 | }, 49 | { 50 | Name: "remove others' good", 51 | SetupService: func(t *testing.T) *BarterService { 52 | mock := buildServiceMock(ctrl) 53 | 54 | good := args.Good 55 | good.OwnerID++ 56 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), good.ID).Return(&good, nil) 57 | 58 | service := buildService(mock) 59 | return service 60 | }, 61 | ExpectError: true, 62 | }, 63 | { 64 | Name: "good not found", 65 | SetupService: func(t *testing.T) *BarterService { 66 | mock := buildServiceMock(ctrl) 67 | 68 | mock.GoodRepo.EXPECT().GetGoodByID(gomock.Any(), args.Good.ID).Return(nil, common.DomainError{}) 69 | 70 | service := buildService(mock) 71 | return service 72 | }, 73 | ExpectError: true, 74 | }, 75 | } 76 | 77 | for i := range testCases { 78 | c := testCases[i] 79 | t.Run(c.Name, func(t *testing.T) { 80 | service := c.SetupService(t) 81 | param := RemoveGoodParam{ 82 | Trader: args.Trader, 83 | GoodID: args.Good.ID, 84 | } 85 | 86 | err := service.RemoveMyGood(context.Background(), param) 87 | 88 | if c.ExpectError { 89 | require.Error(t, err) 90 | } else { 91 | require.NoError(t, err) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/app/service/barter/interface.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 7 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 8 | ) 9 | 10 | //go:generate mockgen -destination automock/good_repository.go -package=automock . GoodRepository 11 | type GoodRepository interface { 12 | CreateGood(ctx context.Context, param barter.Good) (*barter.Good, common.Error) 13 | GetGoodByID(ctx context.Context, id int) (*barter.Good, common.Error) 14 | ListGoods(ctx context.Context) ([]barter.Good, common.Error) 15 | ListGoodsByOwner(ctx context.Context, ownerID int) ([]barter.Good, common.Error) 16 | UpdateGoods(ctx context.Context, goods []barter.Good) ([]barter.Good, common.Error) 17 | DeleteGoodByID(ctx context.Context, id int) common.Error 18 | } 19 | -------------------------------------------------------------------------------- /internal/app/service/barter/service.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rs/zerolog" 7 | ) 8 | 9 | type BarterService struct { 10 | goodRepo GoodRepository 11 | } 12 | 13 | type BarterServiceParam struct { 14 | GoodRepo GoodRepository 15 | } 16 | 17 | func NewBarterService(_ context.Context, param BarterServiceParam) *BarterService { 18 | return &BarterService{ 19 | goodRepo: param.GoodRepo, 20 | } 21 | } 22 | 23 | // logger wrap the execution context with component info 24 | func (s *BarterService) logger(ctx context.Context) *zerolog.Logger { 25 | l := zerolog.Ctx(ctx).With().Str("component", "barter-service").Logger() 26 | return &l 27 | } 28 | -------------------------------------------------------------------------------- /internal/app/service/barter/service_test.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/bxcodec/faker/v3" 8 | "github.com/golang/mock/gomock" 9 | 10 | "github.com/chatbotgang/go-clean-architecture-template/internal/app/service/barter/automock" 11 | ) 12 | 13 | type serviceMock struct { 14 | GoodRepo *automock.MockGoodRepository 15 | } 16 | 17 | func buildServiceMock(ctrl *gomock.Controller) serviceMock { 18 | return serviceMock{ 19 | GoodRepo: automock.NewMockGoodRepository(ctrl), 20 | } 21 | } 22 | func buildService(mock serviceMock) *BarterService { 23 | param := BarterServiceParam{ 24 | GoodRepo: mock.GoodRepo, 25 | } 26 | return NewBarterService(context.Background(), param) 27 | } 28 | 29 | // nolint 30 | func TestMain(m *testing.M) { 31 | // To avoid getting an empty object slice 32 | _ = faker.SetRandomMapAndSliceMinSize(2) 33 | 34 | // To avoid getting a zero random number 35 | _ = faker.SetRandomNumberBoundaries(1, 100) 36 | 37 | m.Run() 38 | } 39 | -------------------------------------------------------------------------------- /internal/domain/barter/exchange.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | func ExchangeGoods(requestGood Good, targetGood Good) []Good { 4 | requestGood.OwnerID, targetGood.OwnerID = targetGood.OwnerID, requestGood.OwnerID 5 | return []Good{requestGood, targetGood} 6 | } 7 | -------------------------------------------------------------------------------- /internal/domain/barter/exchange_test.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bxcodec/faker/v3" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestExchangeGoods(t *testing.T) { 12 | t.Parallel() 13 | // Args 14 | type Args struct { 15 | Trader1 Trader 16 | Good1 Good 17 | Trader2 Trader 18 | Good2 Good 19 | } 20 | var args Args 21 | _ = faker.FakeData(&args) 22 | args.Good1.OwnerID = args.Trader1.ID 23 | args.Good2.OwnerID = args.Trader2.ID 24 | 25 | goods := ExchangeGoods(args.Good1, args.Good2) 26 | 27 | require.Len(t, goods, 2) 28 | assert.True(t, goods[0].IsMyGood(args.Trader2)) 29 | assert.True(t, goods[1].IsMyGood(args.Trader1)) 30 | } 31 | -------------------------------------------------------------------------------- /internal/domain/barter/good.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import "time" 4 | 5 | type Good struct { 6 | ID int 7 | Name string 8 | OwnerID int 9 | CreatedAt time.Time 10 | UpdatedAt time.Time 11 | } 12 | 13 | func NewGood(trader Trader, goodName string) Good { 14 | return Good{ 15 | Name: goodName, 16 | OwnerID: trader.ID, 17 | CreatedAt: time.Now(), 18 | } 19 | } 20 | 21 | func (d *Good) IsMyGood(trader Trader) bool { 22 | return d.OwnerID == trader.ID 23 | } 24 | -------------------------------------------------------------------------------- /internal/domain/barter/good_test.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bxcodec/faker/v3" 7 | "gotest.tools/assert" 8 | ) 9 | 10 | func TestGood_IsMyGood(t *testing.T) { 11 | t.Parallel() 12 | // Args 13 | type Args struct { 14 | Trader Trader 15 | Good Good 16 | } 17 | var args Args 18 | _ = faker.FakeData(&args) 19 | 20 | // Test cases 21 | testCases := []struct { 22 | Name string 23 | SetupArgs func(t *testing.T) Args 24 | ExpectResult bool 25 | }{ 26 | { 27 | Name: "my good", 28 | SetupArgs: func(t *testing.T) Args { 29 | a := args 30 | a.Good.OwnerID = a.Trader.ID 31 | return a 32 | }, 33 | ExpectResult: true, 34 | }, 35 | { 36 | Name: "others' good", 37 | SetupArgs: func(t *testing.T) Args { 38 | a := args 39 | a.Good.OwnerID = a.Trader.ID + 1 40 | return a 41 | }, 42 | ExpectResult: false, 43 | }, 44 | } 45 | 46 | for i := range testCases { 47 | c := testCases[i] 48 | t.Run(c.Name, func(t *testing.T) { 49 | a := c.SetupArgs(t) 50 | ok := a.Good.IsMyGood(a.Trader) 51 | 52 | assert.Equal(t, c.ExpectResult, ok) 53 | }) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /internal/domain/barter/trader.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/dgrijalva/jwt-go" 8 | 9 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 10 | ) 11 | 12 | type Trader struct { 13 | ID int 14 | UID string 15 | Email string 16 | Name string 17 | CreatedAt time.Time 18 | UpdatedAt time.Time 19 | } 20 | 21 | func NewTrader(uid string, email string, name string) Trader { 22 | return Trader{ 23 | UID: uid, 24 | Email: email, 25 | Name: name, 26 | } 27 | } 28 | 29 | type traderClaim struct { 30 | jwt.StandardClaims 31 | Trader 32 | } 33 | 34 | func GenerateTraderToken(trader Trader, signingKey []byte, expiryDuration time.Duration, issuer string) (string, common.Error) { 35 | claim := &traderClaim{ 36 | jwt.StandardClaims{ 37 | ExpiresAt: time.Now().Add(expiryDuration).Unix(), 38 | Issuer: issuer, 39 | IssuedAt: time.Now().Unix(), 40 | }, 41 | trader, 42 | } 43 | 44 | // Generate Signed JWT token 45 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim) 46 | signedToken, err := token.SignedString(signingKey) 47 | if err != nil { 48 | return "", common.NewError(common.ErrorCodeInternalProcess, err, common.WithMsg("failed to generate token")) 49 | } 50 | return signedToken, nil 51 | } 52 | 53 | func ParseTraderFromToken(signedToken string, signingKey []byte) (*Trader, common.Error) { 54 | token, err := jwt.ParseWithClaims(signedToken, &traderClaim{}, func(token *jwt.Token) (interface{}, error) { 55 | return signingKey, nil 56 | }) 57 | 58 | if err != nil { 59 | if e, ok := err.(*jwt.ValidationError); ok && e.Errors == jwt.ValidationErrorExpired { 60 | msg := "token is expired" 61 | return nil, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 62 | } else { 63 | return nil, common.NewError(common.ErrorCodeParameterInvalid, err, common.WithMsg("failed to parse token")) 64 | } 65 | } 66 | 67 | if !token.Valid { 68 | msg := "invalid token" 69 | return nil, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 70 | } 71 | 72 | claim, ok := token.Claims.(*traderClaim) 73 | if !ok { 74 | msg := "failed to parse claim" 75 | return nil, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 76 | } 77 | 78 | return &claim.Trader, nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/domain/barter/trader_test.go: -------------------------------------------------------------------------------- 1 | package barter 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/bxcodec/faker/v3" 8 | "github.com/stretchr/testify/require" 9 | "gotest.tools/assert" 10 | ) 11 | 12 | func TestTrader_Token(t *testing.T) { 13 | t.Parallel() 14 | // Args 15 | type Args struct { 16 | Trader Trader 17 | ExpiryDuration time.Duration 18 | SigningKey []byte 19 | Issuer string 20 | } 21 | var args Args 22 | _ = faker.FakeData(&args) 23 | 24 | // Test cases 25 | testCases := []struct { 26 | Name string 27 | SetupArgs func(t *testing.T) Args 28 | ExpectGenerateError bool 29 | ExpectParseError bool 30 | }{ 31 | { 32 | Name: "success", 33 | SetupArgs: func(t *testing.T) Args { 34 | a := args 35 | a.ExpiryDuration = time.Hour 36 | 37 | return a 38 | }, 39 | ExpectGenerateError: false, 40 | ExpectParseError: false, 41 | }, 42 | { 43 | Name: "expired token", 44 | SetupArgs: func(t *testing.T) Args { 45 | a := args 46 | a.ExpiryDuration = -1 * time.Hour 47 | 48 | return a 49 | }, 50 | ExpectGenerateError: false, 51 | ExpectParseError: true, 52 | }, 53 | } 54 | 55 | for i := range testCases { 56 | c := testCases[i] 57 | t.Run(c.Name, func(t *testing.T) { 58 | a := c.SetupArgs(t) 59 | token, err := GenerateTraderToken(a.Trader, a.SigningKey, a.ExpiryDuration, a.Issuer) 60 | 61 | if c.ExpectGenerateError { 62 | require.Error(t, err) 63 | } else { 64 | require.NoError(t, err) 65 | 66 | trader, err := ParseTraderFromToken(token, a.SigningKey) 67 | if c.ExpectParseError { 68 | require.Error(t, err) 69 | } else { 70 | require.NoError(t, err) 71 | assert.Equal(t, a.Trader.ID, trader.ID) 72 | } 73 | } 74 | }) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /internal/domain/common/error.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Error indicates a domain error 10 | type Error interface { 11 | Error() string 12 | ClientMsg() string 13 | } 14 | 15 | // DomainError used for expressing errors occurring in application. 16 | type DomainError struct { 17 | code ErrorCode // code indicates an ErrorCode customized for domain logic. 18 | err error // err contains a native error. It will be logged in system logs. 19 | clientMsg string // clientMsg contains a message that will return to clients 20 | remoteStatus int // remoteStatus contains proxy HTTP status code. It is used for remote process related errors. 21 | detail map[string]interface{} // detail contains some details that clients may need. It is business-driven. 22 | } 23 | 24 | func NewError(code ErrorCode, err error, opts ...ErrorOption) Error { 25 | if err, ok := err.(Error); ok { 26 | return err 27 | } 28 | 29 | e := DomainError{code: code, err: err} 30 | for _, o := range opts { 31 | o(&e) 32 | } 33 | return e 34 | } 35 | 36 | func (e DomainError) Error() string { 37 | var msgs []string 38 | if e.remoteStatus != 0 { 39 | msgs = append(msgs, strconv.Itoa(e.remoteStatus)) 40 | } 41 | if e.err != nil { 42 | msgs = append(msgs, e.err.Error()) 43 | } 44 | if e.clientMsg != "" { 45 | msgs = append(msgs, e.clientMsg) 46 | } 47 | 48 | return strings.Join(msgs, ": ") 49 | } 50 | 51 | func (e DomainError) Name() string { 52 | if e.code.Name == "" { 53 | return "UNKNOWN_ERROR" 54 | } 55 | return e.code.Name 56 | } 57 | 58 | func (e DomainError) ClientMsg() string { 59 | return e.clientMsg 60 | } 61 | 62 | func (e DomainError) HTTPStatus() int { 63 | if e.code.StatusCode == 0 { 64 | return http.StatusInternalServerError 65 | } 66 | return e.code.StatusCode 67 | } 68 | 69 | func (e DomainError) RemoteHTTPStatus() int { 70 | return e.remoteStatus 71 | } 72 | 73 | func (e DomainError) Detail() map[string]interface{} { 74 | return e.detail 75 | } 76 | 77 | type ErrorOption func(*DomainError) 78 | 79 | func WithMsg(msg string) ErrorOption { 80 | return func(e *DomainError) { 81 | e.clientMsg = msg 82 | } 83 | } 84 | 85 | func WithStatus(status int) ErrorOption { 86 | return func(e *DomainError) { 87 | e.remoteStatus = status 88 | } 89 | } 90 | 91 | func WithDetail(detail map[string]interface{}) ErrorOption { 92 | return func(e *DomainError) { 93 | e.detail = detail 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/domain/common/error_code.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "net/http" 4 | 5 | type ErrorCode struct { 6 | Name string 7 | StatusCode int 8 | } 9 | 10 | /* 11 | General error codes 12 | */ 13 | 14 | var ErrorCodeInternalProcess = ErrorCode{ 15 | Name: "INTERNAL_PROCESS", 16 | StatusCode: http.StatusInternalServerError, 17 | } 18 | 19 | /* 20 | Authentication and Authorization error codes 21 | */ 22 | 23 | var ErrorCodeAuthPermissionDenied = ErrorCode{ 24 | Name: "AUTH_PERMISSION_DENIED", 25 | StatusCode: http.StatusForbidden, 26 | } 27 | 28 | var ErrorCodeAuthNotAuthenticated = ErrorCode{ 29 | Name: "AUTH_NOT_AUTHENTICATED", 30 | StatusCode: http.StatusUnauthorized, 31 | } 32 | 33 | /* 34 | Resource-related error codes 35 | */ 36 | 37 | var ErrorCodeResourceNotFound = ErrorCode{ 38 | Name: "RESOURCE_NOT_FOUND", 39 | StatusCode: http.StatusNotFound, 40 | } 41 | 42 | /* 43 | Parameter-related error codes 44 | */ 45 | 46 | var ErrorCodeParameterInvalid = ErrorCode{ 47 | Name: "PARAMETER_INVALID", 48 | StatusCode: http.StatusBadRequest, 49 | } 50 | 51 | /* 52 | Remote server-related error codes 53 | */ 54 | 55 | var ErrorCodeRemoteProcess = ErrorCode{ 56 | Name: "REMOTE_PROCESS_ERROR", 57 | StatusCode: http.StatusBadGateway, 58 | } 59 | -------------------------------------------------------------------------------- /internal/domain/common/error_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDomainError_Option(t *testing.T) { 12 | t.Parallel() 13 | 14 | msg := "random client message" 15 | status := http.StatusBadRequest 16 | detail := map[string]interface{}{ 17 | "channel_id": 123, 18 | "member_name": "who am I?", 19 | "tag_id": []int{1, 2, 3, 4}, 20 | } 21 | 22 | // Test cases 23 | testCases := []struct { 24 | Name string 25 | TestError Error 26 | WithMsg bool 27 | WitStatus bool 28 | WitDeniedPermission bool 29 | WithDetail bool 30 | }{ 31 | { 32 | Name: "with client msg", 33 | TestError: NewError(ErrorCodeInternalProcess, nil, WithMsg(msg)), 34 | WithMsg: true, 35 | }, 36 | { 37 | Name: "with proxy HTTP status", 38 | TestError: NewError(ErrorCodeRemoteProcess, nil, WithStatus(status)), 39 | WitStatus: true, 40 | }, 41 | { 42 | Name: "with detail", 43 | TestError: NewError(ErrorCodeInternalProcess, nil, WithDetail(detail)), 44 | WithDetail: true, 45 | }, 46 | } 47 | 48 | for i := range testCases { 49 | c := testCases[i] 50 | t.Run(c.Name, func(t *testing.T) { 51 | err := c.TestError 52 | 53 | var domainError DomainError 54 | if errors.As(err, &domainError) { 55 | if c.WithMsg { 56 | assert.EqualValues(t, msg, domainError.ClientMsg()) 57 | } 58 | if c.WitStatus { 59 | assert.EqualValues(t, status, domainError.RemoteHTTPStatus()) 60 | } 61 | if c.WitDeniedPermission { 62 | assert.Contains(t, domainError.Error(), "no permission to") 63 | } 64 | if c.WithDetail { 65 | assert.Contains(t, domainError.Detail(), "channel_id") 66 | assert.Contains(t, domainError.Detail(), "member_name") 67 | assert.Equal(t, detail, domainError.Detail()) 68 | } 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func TestDomainError_ErrorMapping(t *testing.T) { 75 | t.Parallel() 76 | 77 | // Test cases 78 | testCases := []struct { 79 | Name string 80 | TestError Error 81 | ExpectErrorName string 82 | ExpectHTTPStatus int 83 | ExpectRemoteHTTPStatus int 84 | }{ 85 | { 86 | Name: "internal process", 87 | TestError: NewError(ErrorCodeInternalProcess, nil), 88 | ExpectErrorName: ErrorCodeInternalProcess.Name, 89 | ExpectHTTPStatus: http.StatusInternalServerError, 90 | }, 91 | { 92 | Name: "permission denied", 93 | TestError: NewError(ErrorCodeAuthPermissionDenied, nil), 94 | ExpectErrorName: ErrorCodeAuthPermissionDenied.Name, 95 | ExpectHTTPStatus: http.StatusForbidden, 96 | }, 97 | { 98 | Name: "not authenticated", 99 | TestError: NewError(ErrorCodeAuthNotAuthenticated, nil), 100 | ExpectErrorName: ErrorCodeAuthNotAuthenticated.Name, 101 | ExpectHTTPStatus: http.StatusUnauthorized, 102 | }, 103 | { 104 | Name: "invalid parameter", 105 | TestError: NewError(ErrorCodeParameterInvalid, nil), 106 | ExpectErrorName: ErrorCodeParameterInvalid.Name, 107 | ExpectHTTPStatus: http.StatusBadRequest, 108 | }, 109 | { 110 | Name: "resource not found", 111 | TestError: NewError(ErrorCodeResourceNotFound, nil), 112 | ExpectErrorName: ErrorCodeResourceNotFound.Name, 113 | ExpectHTTPStatus: http.StatusNotFound, 114 | }, 115 | { 116 | Name: "remote process", 117 | TestError: NewError(ErrorCodeRemoteProcess, nil, WithStatus(http.StatusBadRequest)), 118 | ExpectErrorName: ErrorCodeRemoteProcess.Name, 119 | ExpectHTTPStatus: http.StatusBadGateway, 120 | ExpectRemoteHTTPStatus: http.StatusBadRequest, 121 | }, 122 | { 123 | Name: "unknown error", 124 | TestError: NewError(ErrorCode{}, nil), 125 | ExpectErrorName: "UNKNOWN_ERROR", 126 | ExpectHTTPStatus: http.StatusInternalServerError, 127 | }, 128 | } 129 | 130 | for i := range testCases { 131 | c := testCases[i] 132 | t.Run(c.Name, func(t *testing.T) { 133 | err := c.TestError 134 | 135 | var domainError DomainError 136 | if errors.As(err, &domainError) { 137 | assert.Equal(t, c.ExpectErrorName, domainError.Name()) 138 | assert.Equal(t, c.ExpectHTTPStatus, domainError.HTTPStatus()) 139 | assert.Equal(t, c.ExpectRemoteHTTPStatus, domainError.RemoteHTTPStatus()) 140 | } else { 141 | t.Error("failed to match error type") 142 | } 143 | }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /internal/router/handler.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | 6 | "github.com/chatbotgang/go-clean-architecture-template/internal/app" 7 | ) 8 | 9 | func RegisterHandlers(router *gin.Engine, app *app.Application) { 10 | registerAPIHandlers(router, app) 11 | } 12 | 13 | func registerAPIHandlers(router *gin.Engine, app *app.Application) { 14 | // Build middlewares 15 | BearerToken := NewAuthMiddlewareBearer(app) 16 | 17 | // We mount all handlers under /api path 18 | r := router.Group("/api") 19 | v1 := r.Group("/v1") 20 | 21 | // Add health-check 22 | v1.GET("/health", handlerHealthCheck()) 23 | 24 | // Add auth namespace 25 | authGroup := v1.Group("/auth") 26 | { 27 | authGroup.POST("/traders", RegisterTrader(app)) 28 | authGroup.POST("/traders/login", LoginTrader(app)) 29 | } 30 | 31 | // Add barter namespace 32 | barterGroup := v1.Group("/barter", BearerToken.Required()) 33 | { 34 | barterGroup.POST("/goods", PostGood(app)) 35 | barterGroup.GET("/goods", ListMyGoods(app)) 36 | barterGroup.GET("/goods/traders", ListOthersGoods(app)) 37 | barterGroup.DELETE("/goods/:good_id", RemoveMyGood(app)) 38 | barterGroup.POST("/goods/exchange", ExchangeGoods(app)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/router/handler_auth_trader.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/chatbotgang/go-clean-architecture-template/internal/app" 10 | "github.com/chatbotgang/go-clean-architecture-template/internal/app/service/auth" 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 12 | ) 13 | 14 | func RegisterTrader(app *app.Application) gin.HandlerFunc { 15 | type Body struct { 16 | Email string `json:"email" binding:"required,email"` 17 | Name string `json:"name" binding:"required"` 18 | Password string `json:"password" binding:"required"` 19 | } 20 | 21 | type Response struct { 22 | ID int `json:"id"` 23 | UID string `json:"uid"` 24 | Email string `json:"email"` 25 | Name string `json:"name"` 26 | CreatedAt time.Time `json:"created_at"` 27 | } 28 | 29 | return func(c *gin.Context) { 30 | ctx := c.Request.Context() 31 | 32 | // Validate parameters 33 | var body Body 34 | err := c.ShouldBind(&body) 35 | if err != nil { 36 | respondWithError(c, common.NewError(common.ErrorCodeParameterInvalid, err, common.WithMsg("invalid parameter"))) 37 | return 38 | } 39 | 40 | // Invoke service 41 | trader, err := app.AuthService.RegisterTrader(ctx, auth.RegisterTraderParam{ 42 | Email: body.Email, 43 | Name: body.Name, 44 | Password: body.Password, 45 | }) 46 | if err != nil { 47 | respondWithError(c, err) 48 | return 49 | } 50 | 51 | resp := Response{ 52 | ID: trader.ID, 53 | UID: trader.UID, 54 | Email: trader.Email, 55 | Name: trader.Name, 56 | CreatedAt: trader.CreatedAt, 57 | } 58 | respondWithJSON(c, http.StatusCreated, resp) 59 | } 60 | } 61 | 62 | func LoginTrader(app *app.Application) gin.HandlerFunc { 63 | type Body struct { 64 | Email string `json:"email" binding:"required,email"` 65 | Password string `json:"password" binding:"required"` 66 | } 67 | 68 | type Response struct { 69 | TraderID int `json:"trader_id"` 70 | Token string `json:"token"` 71 | } 72 | 73 | return func(c *gin.Context) { 74 | ctx := c.Request.Context() 75 | 76 | // Validate parameters 77 | var body Body 78 | err := c.ShouldBind(&body) 79 | if err != nil { 80 | respondWithError(c, common.NewError(common.ErrorCodeParameterInvalid, err, common.WithMsg("invalid parameter"))) 81 | return 82 | } 83 | 84 | // Invoke service 85 | trader, err := app.AuthService.LoginTrader(ctx, auth.LoginTraderParam{ 86 | Email: body.Email, 87 | Password: body.Password, 88 | }) 89 | if err != nil { 90 | respondWithError(c, err) 91 | return 92 | } 93 | token, err := app.AuthService.GenerateTraderToken(ctx, *trader) 94 | if err != nil { 95 | respondWithError(c, err) 96 | return 97 | } 98 | 99 | resp := Response{ 100 | TraderID: trader.ID, 101 | Token: token, 102 | } 103 | respondWithJSON(c, http.StatusOK, resp) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /internal/router/handler_barter_exchange.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/chatbotgang/go-clean-architecture-template/internal/app" 10 | "github.com/chatbotgang/go-clean-architecture-template/internal/app/service/barter" 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 12 | ) 13 | 14 | func ExchangeGoods(app *app.Application) gin.HandlerFunc { 15 | type Body struct { 16 | RequestGoodID int `json:"request_good_id" binding:"required"` 17 | TargetGoodID int `json:"target_good_id" binding:"required"` 18 | } 19 | 20 | type Response struct { 21 | UUID string `json:"uuid"` 22 | } 23 | 24 | return func(c *gin.Context) { 25 | ctx := c.Request.Context() 26 | 27 | // Validate parameters 28 | var body Body 29 | err := c.ShouldBind(&body) 30 | if err != nil { 31 | respondWithError(c, common.NewError(common.ErrorCodeParameterInvalid, err, common.WithMsg("invalid parameter"))) 32 | return 33 | } 34 | 35 | uid := c.GetHeader("X-Idempotency-Key") 36 | if uid == "" { 37 | msg := "no idempotency key" 38 | respondWithError(c, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg))) 39 | return 40 | } 41 | 42 | // Get current trader 43 | trader, err := GetCurrentTrader(c) 44 | if err != nil { 45 | respondWithError(c, err) 46 | return 47 | } 48 | 49 | // Invoke service 50 | err = app.BarterService.ExchangeGoods(ctx, barter.ExchangeGoodsParam{ 51 | Trader: *trader, 52 | RequestGoodID: body.RequestGoodID, 53 | TargetGoodID: body.TargetGoodID, 54 | }) 55 | if err != nil { 56 | respondWithError(c, err) 57 | return 58 | } 59 | 60 | resp := Response{ 61 | UUID: uid, 62 | } 63 | respondWithJSON(c, http.StatusOK, resp) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/router/handler_barter_good.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/chatbotgang/go-clean-architecture-template/internal/app" 10 | "github.com/chatbotgang/go-clean-architecture-template/internal/app/service/barter" 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 12 | ) 13 | 14 | func PostGood(app *app.Application) gin.HandlerFunc { 15 | type Body struct { 16 | Name string `json:"name" binding:"required"` 17 | } 18 | 19 | type Response struct { 20 | ID int `json:"id"` 21 | Name string `json:"name"` 22 | OwnerID int `json:"owner_id"` 23 | CreatedAt time.Time `json:"created_at"` 24 | } 25 | 26 | return func(c *gin.Context) { 27 | ctx := c.Request.Context() 28 | 29 | // Validate parameters 30 | var body Body 31 | err := c.ShouldBind(&body) 32 | if err != nil { 33 | respondWithError(c, common.NewError(common.ErrorCodeParameterInvalid, err, common.WithMsg("invalid parameter"))) 34 | return 35 | } 36 | 37 | // Get current trader 38 | trader, err := GetCurrentTrader(c) 39 | if err != nil { 40 | respondWithError(c, err) 41 | return 42 | } 43 | 44 | // Invoke service 45 | good, err := app.BarterService.PostGood(ctx, barter.PostGoodParam{ 46 | Trader: *trader, 47 | GoodName: body.Name, 48 | }) 49 | if err != nil { 50 | respondWithError(c, err) 51 | return 52 | } 53 | 54 | resp := Response{ 55 | ID: good.ID, 56 | Name: good.Name, 57 | OwnerID: good.OwnerID, 58 | CreatedAt: trader.CreatedAt, 59 | } 60 | respondWithJSON(c, http.StatusCreated, resp) 61 | } 62 | } 63 | 64 | func ListMyGoods(app *app.Application) gin.HandlerFunc { 65 | 66 | type Good struct { 67 | ID int `json:"id"` 68 | Name string `json:"name"` 69 | OwnerID int `json:"owner_id"` 70 | CreatedAt time.Time `json:"created_at"` 71 | UpdatedAt time.Time `json:"updated_at"` 72 | } 73 | 74 | type Response struct { 75 | Goods []Good `json:"goods"` 76 | } 77 | 78 | return func(c *gin.Context) { 79 | ctx := c.Request.Context() 80 | 81 | // Get current trader 82 | trader, err := GetCurrentTrader(c) 83 | if err != nil { 84 | respondWithError(c, err) 85 | return 86 | } 87 | 88 | // Invoke service 89 | goods, err := app.BarterService.ListMyGoods(ctx, barter.ListMyGoodsParam{ 90 | Trader: *trader, 91 | }) 92 | if err != nil { 93 | respondWithError(c, err) 94 | return 95 | } 96 | 97 | resp := Response{ 98 | Goods: []Good{}, 99 | } 100 | for i := range goods { 101 | g := goods[i] 102 | resp.Goods = append(resp.Goods, Good(g)) 103 | } 104 | 105 | respondWithJSON(c, http.StatusOK, resp) 106 | } 107 | } 108 | 109 | func ListOthersGoods(app *app.Application) gin.HandlerFunc { 110 | 111 | type Good struct { 112 | ID int `json:"id"` 113 | Name string `json:"name"` 114 | OwnerID int `json:"owner_id"` 115 | CreatedAt time.Time `json:"created_at"` 116 | UpdatedAt time.Time `json:"updated_at"` 117 | } 118 | 119 | type Response struct { 120 | Goods []Good `json:"goods"` 121 | } 122 | 123 | return func(c *gin.Context) { 124 | ctx := c.Request.Context() 125 | 126 | // Get current trader 127 | trader, err := GetCurrentTrader(c) 128 | if err != nil { 129 | respondWithError(c, err) 130 | return 131 | } 132 | 133 | // Invoke service 134 | goods, err := app.BarterService.ListOthersGoods(ctx, barter.ListOthersGoodsParam{ 135 | Trader: *trader, 136 | }) 137 | if err != nil { 138 | respondWithError(c, err) 139 | return 140 | } 141 | 142 | resp := Response{ 143 | Goods: []Good{}, 144 | } 145 | for i := range goods { 146 | g := goods[i] 147 | resp.Goods = append(resp.Goods, Good(g)) 148 | } 149 | 150 | respondWithJSON(c, http.StatusOK, resp) 151 | } 152 | } 153 | 154 | func RemoveMyGood(app *app.Application) gin.HandlerFunc { 155 | return func(c *gin.Context) { 156 | ctx := c.Request.Context() 157 | 158 | // Validate parameters 159 | goodID, err := GetParamInt(c, "good_id") 160 | if err != nil { 161 | respondWithError(c, err) 162 | return 163 | } 164 | 165 | // Get current trader 166 | trader, err := GetCurrentTrader(c) 167 | if err != nil { 168 | respondWithError(c, err) 169 | return 170 | } 171 | 172 | // Invoke service 173 | err = app.BarterService.RemoveMyGood(ctx, barter.RemoveGoodParam{ 174 | Trader: *trader, 175 | GoodID: goodID, 176 | }) 177 | if err != nil { 178 | respondWithError(c, err) 179 | return 180 | } 181 | 182 | respondWithoutBody(c, http.StatusNoContent) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /internal/router/handler_healthcheck.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func handlerHealthCheck() gin.HandlerFunc { 10 | return func(c *gin.Context) { 11 | c.Status(http.StatusOK) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /internal/router/middleware.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gin-contrib/requestid" 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // SetGeneralMiddlewares add general-purpose middlewares 11 | func SetGeneralMiddlewares(ctx context.Context, ginRouter *gin.Engine) { 12 | ginRouter.Use(gin.Recovery()) 13 | ginRouter.Use(CORSMiddleware()) 14 | ginRouter.Use(requestid.New()) 15 | ginRouter.Use(LoggerMiddleware(ctx)) 16 | 17 | } 18 | -------------------------------------------------------------------------------- /internal/router/middleware_auth.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/chatbotgang/go-clean-architecture-template/internal/app" 10 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 11 | ) 12 | 13 | type AuthMiddlewareBearer struct { 14 | app *app.Application 15 | } 16 | 17 | func NewAuthMiddlewareBearer(app *app.Application) *AuthMiddlewareBearer { 18 | return &AuthMiddlewareBearer{ 19 | app: app, 20 | } 21 | } 22 | 23 | func (m *AuthMiddlewareBearer) Required() gin.HandlerFunc { 24 | return func(c *gin.Context) { 25 | ctx := c.Request.Context() 26 | 27 | // Get Bearer token 28 | token, err := GetAuthorizationToken(c) 29 | if err != nil { 30 | respondWithError(c, err) 31 | return 32 | } 33 | tokens := strings.Split(token, "Bearer ") 34 | if len(tokens) != 2 { 35 | msg := "bearer token is needed" 36 | respondWithError(c, common.NewError(common.ErrorCodeAuthNotAuthenticated, errors.New(msg), common.WithMsg(msg))) 37 | return 38 | } 39 | 40 | // Validate token 41 | trader, err := m.app.AuthService.ValidateTraderToken(ctx, tokens[1]) 42 | if err != nil { 43 | respondWithError(c, common.NewError(common.ErrorCodeAuthNotAuthenticated, errors.New(err.Error()), common.WithMsg(err.ClientMsg()))) 44 | return 45 | } 46 | 47 | // Set trader to context 48 | if err = SetTrader(c, *trader); err != nil { 49 | respondWithError(c, err) 50 | return 51 | } 52 | c.Next() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/router/middleware_cors.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-contrib/cors" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | func CORSMiddleware() gin.HandlerFunc { 9 | config := cors.DefaultConfig() 10 | config.AllowAllOrigins = true 11 | config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Idempotency-Key"} 12 | return cors.New(config) 13 | } 14 | -------------------------------------------------------------------------------- /internal/router/middleware_logger.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "time" 10 | 11 | "github.com/gin-contrib/requestid" 12 | "github.com/gin-gonic/gin" 13 | "github.com/rs/zerolog" 14 | ) 15 | 16 | var sensitiveAPIs = map[string]bool{} 17 | 18 | // filterSensitiveAPI only returns `email` field for sensitive APIs 19 | func filterSensitiveAPI(path string, data []byte) []byte { 20 | _, ok := sensitiveAPIs[path] 21 | if ok { 22 | return []byte{} 23 | } 24 | 25 | return data 26 | } 27 | 28 | // LoggerMiddleware is referenced from gin's logger implementation with additional capabilities: 29 | // 1. use zerolog to do structure log 30 | // 2. add requestID into context logger 31 | func LoggerMiddleware(rootCtx context.Context) gin.HandlerFunc { 32 | return func(c *gin.Context) { 33 | // Start timer 34 | start := time.Now() 35 | path := c.Request.URL.Path 36 | raw := c.Request.URL.RawQuery 37 | 38 | // Ignore health-check to avoid polluting API logs 39 | if path == "/api/v1/health" { 40 | c.Next() 41 | return 42 | } 43 | 44 | // Add RequestID into the logger of the request context 45 | requestID := requestid.Get(c) 46 | zlog := zerolog.Ctx(rootCtx).With(). 47 | Str("requestID", requestID). 48 | Str("path", c.FullPath()). 49 | Str("method", c.Request.Method). 50 | Logger() 51 | c.Request = c.Request.WithContext(zlog.WithContext(rootCtx)) 52 | 53 | // Use TeeReader to duplicate the request body to an internal buffer, so 54 | // that we could use it for logging 55 | var buf bytes.Buffer 56 | tee := io.TeeReader(c.Request.Body, &buf) 57 | c.Request.Body = io.NopCloser(tee) 58 | 59 | // Process request 60 | c.Next() 61 | 62 | // Build all information that we want to log 63 | end := time.Now() 64 | params := gin.LogFormatterParams{ 65 | TimeStamp: end, 66 | Latency: end.Sub(start), 67 | ClientIP: c.ClientIP(), 68 | StatusCode: c.Writer.Status(), 69 | BodySize: c.Writer.Size(), 70 | } 71 | if err := c.Errors.Last(); err != nil { 72 | params.ErrorMessage = err.Error() 73 | } 74 | if raw != "" { 75 | path = path + "?" + raw 76 | } 77 | params.Path = path 78 | 79 | // Build logger with proper severity 80 | var l *zerolog.Event 81 | if params.StatusCode >= 300 || len(params.ErrorMessage) != 0 { 82 | l = zerolog.Ctx(c.Request.Context()).Error() 83 | } else { 84 | l = zerolog.Ctx(c.Request.Context()).Info() 85 | } 86 | 87 | l = l.Time("callTime", params.TimeStamp). 88 | Int("status", params.StatusCode). 89 | Dur("latency", params.Latency). 90 | Str("clientIP", params.ClientIP). 91 | Str("fullPath", params.Path). 92 | Str("component", "router"). 93 | Str("userAgent", c.Request.Header.Get("User-Agent")) 94 | if params.ErrorMessage != "" { 95 | l = l.Err(errors.New(params.ErrorMessage)) 96 | } 97 | if buf.Len() > 0 { 98 | data := buf.Bytes() 99 | 100 | // Try to filter request body if it's a sensitive API 101 | data = filterSensitiveAPI(params.Path, data) 102 | 103 | var jsonBuf bytes.Buffer 104 | if err := json.Compact(&jsonBuf, data); err == nil { 105 | l = l.RawJSON("request", jsonBuf.Bytes()) 106 | } 107 | } 108 | l.Send() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/router/param_util.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/barter" 11 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 12 | ) 13 | 14 | const KeyAuth = "Authorization" 15 | const KeyTrader = "Trader" 16 | 17 | func GetAuthorizationToken(c *gin.Context) (string, common.Error) { 18 | token := c.GetHeader(KeyAuth) 19 | if token == "" { 20 | msg := "no Authorization" 21 | return "", common.NewError(common.ErrorCodeAuthNotAuthenticated, errors.New(msg), common.WithMsg(msg)) 22 | } 23 | return token, nil 24 | } 25 | 26 | func GetCurrentTrader(c *gin.Context) (*barter.Trader, common.Error) { 27 | data, ok := c.Get(KeyTrader) 28 | if !ok { 29 | return nil, common.NewError(common.ErrorCodeAuthNotAuthenticated, errors.New("no credential")) 30 | } 31 | 32 | cred, ok := data.(barter.Trader) 33 | if !ok { 34 | return nil, common.NewError(common.ErrorCodeAuthNotAuthenticated, errors.New("failed to assert credential")) 35 | } 36 | return &cred, nil 37 | } 38 | 39 | func SetTrader(c *gin.Context, trader barter.Trader) common.Error { 40 | c.Set(KeyTrader, trader) 41 | return nil 42 | } 43 | 44 | // GetParamInt gets a key's value from Gin's URL param and transform it to int. 45 | func GetParamInt(c *gin.Context, key string) (int, common.Error) { 46 | s := c.Param(key) 47 | if s == "" { 48 | msg := fmt.Sprintf("no %s", key) 49 | return 0, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 50 | } 51 | 52 | i, err := strconv.Atoi(s) 53 | if err != nil { 54 | msg := fmt.Sprintf("invalid %s", key) 55 | return 0, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 56 | } 57 | return i, nil 58 | } 59 | 60 | // GetQueryInt gets a key's value from Gin's URL query and transform it to int. 61 | func GetQueryInt(c *gin.Context, key string) (int, common.Error) { 62 | s := c.Query(key) 63 | if s == "" { 64 | msg := fmt.Sprintf("no %s", key) 65 | return 0, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 66 | } 67 | 68 | i, err := strconv.Atoi(s) 69 | if err != nil { 70 | msg := fmt.Sprintf("invalid %s", key) 71 | return 0, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 72 | } 73 | return i, nil 74 | } 75 | 76 | // GetQueryBool gets a key's value from Gin's URL query and transform it to bool. 77 | func GetQueryBool(c *gin.Context, key string) (bool, common.Error) { 78 | s := c.Query(key) 79 | if s == "" { 80 | msg := fmt.Sprintf("no %s", key) 81 | return false, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 82 | } 83 | 84 | b, err := strconv.ParseBool(s) 85 | if err != nil { 86 | msg := fmt.Sprintf("invalid %s", key) 87 | return false, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg)) 88 | } 89 | return b, nil 90 | } 91 | -------------------------------------------------------------------------------- /internal/router/response.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/rs/zerolog" 8 | 9 | "github.com/chatbotgang/go-clean-architecture-template/internal/domain/common" 10 | ) 11 | 12 | type ErrorMessage struct { 13 | Name string `json:"name"` 14 | Code int `json:"code"` 15 | Message string `json:"message,omitempty"` 16 | RemoteCode int `json:"remoteCode,omitempty"` 17 | Detail map[string]interface{} `json:"detail,omitempty"` 18 | } 19 | 20 | func respondWithJSON(c *gin.Context, code int, payload interface{}) { 21 | c.JSON(code, payload) 22 | } 23 | 24 | func respondWithoutBody(c *gin.Context, code int) { 25 | c.Status(code) 26 | } 27 | 28 | func respondWithError(c *gin.Context, err error) { 29 | errMessage := parseError(err) 30 | 31 | ctx := c.Request.Context() 32 | zerolog.Ctx(ctx).Error().Err(err).Str("component", "handler").Msg(errMessage.Message) 33 | _ = c.Error(err) 34 | c.AbortWithStatusJSON(errMessage.Code, errMessage) 35 | } 36 | 37 | func parseError(err error) ErrorMessage { 38 | var domainError common.DomainError 39 | // We don't check if errors.As is valid or not 40 | // because an empty common.DomainError would return default error data. 41 | _ = errors.As(err, &domainError) 42 | 43 | return ErrorMessage{ 44 | Name: domainError.Name(), 45 | Code: domainError.HTTPStatus(), 46 | Message: domainError.ClientMsg(), 47 | RemoteCode: domainError.RemoteHTTPStatus(), 48 | Detail: domainError.Detail(), 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /migrations/1_create_trader.up.sql: -------------------------------------------------------------------------------- 1 | create table trader 2 | ( 3 | id serial 4 | constraint trader_pk 5 | primary key, 6 | uid varchar(36) not null, 7 | email varchar(255) not null, 8 | name varchar(36) not null, 9 | created_at timestamp with time zone default now(), 10 | updated_at timestamp with time zone default now() 11 | ); 12 | 13 | create unique index trader_uid_uniq 14 | on trader (uid); 15 | -------------------------------------------------------------------------------- /migrations/2_create_good.up.sql: -------------------------------------------------------------------------------- 1 | create table good 2 | ( 3 | id serial 4 | constraint good_pk 5 | primary key, 6 | name varchar(255) not null, 7 | owner_id int not null 8 | constraint good_trader_id_fk 9 | references trader 10 | deferrable initially deferred, 11 | created_at timestamp with time zone default now(), 12 | updated_at timestamp with time zone default now() 13 | ); 14 | -------------------------------------------------------------------------------- /testdata/good.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | name: "good 1" 3 | owner_id: 1 4 | 5 | - id: 2 6 | name: "good 2" 7 | owner_id: 1 -------------------------------------------------------------------------------- /testdata/init_local_dev.sql: -------------------------------------------------------------------------------- 1 | begin; 2 | 3 | insert into trader ( uid, email, name) 4 | values ('c369b9e594fcd07c8e74cac1', 'trader1@cresclab.com', 'trader1'); 5 | 6 | insert into good (name, owner_id) 7 | values ('good 1', 1), ('good 2', 1); 8 | 9 | end; -------------------------------------------------------------------------------- /testdata/testdata.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | ) 7 | 8 | var basepath string 9 | 10 | const ( 11 | TestDataTrader = "trader.yml" 12 | TestDataGood = "good.yml" 13 | ) 14 | 15 | func init() { 16 | _, currentFile, _, _ := runtime.Caller(0) 17 | basepath = filepath.Dir(currentFile) 18 | } 19 | 20 | func Path(rel string) string { 21 | return filepath.Join(basepath, rel) 22 | } 23 | -------------------------------------------------------------------------------- /testdata/trader.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | uid: "c369b9e54f8fd07c8e74cac1" 3 | email: "trader1@cresclab.com" 4 | name: "trader1" 5 | 6 | - id: 2 7 | uid: "c369b9e594fcd07c8e74cac2" 8 | email: "trader2@cresclab.com" 9 | name: "trader2" --------------------------------------------------------------------------------