├── .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 | 
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 | 
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"
--------------------------------------------------------------------------------