├── inventory.jpg ├── db ├── migrations │ ├── 000002_create_users_table.down.sql │ ├── 000001_create_products_table.down.sql │ ├── 000002_create_users_table.up.sql │ └── 000001_create_products_table.up.sql ├── metrics.go ├── usrrepo │ ├── mocks.go │ └── repo.go ├── mocks.go ├── db.go └── invrepo │ ├── mocks.go │ └── repo.go ├── core ├── user │ ├── model.go │ ├── mocks.go │ ├── service.go │ └── service_test.go ├── repo.go └── inventory │ ├── model.go │ ├── repository.go │ ├── mocks.go │ └── service.go ├── .gitignore ├── .github └── workflows │ ├── sec.yml │ ├── test.yml │ ├── validate.yml │ └── lint.yml ├── api ├── environmentdto.go ├── environmentapi.go ├── userdto.go ├── reservationdto.go ├── environmentapi_test.go ├── util.go ├── util_test.go ├── userapi.go ├── errordto.go ├── inventorydto.go ├── api_test.go ├── api.go ├── middleware.go ├── userapi_test.go ├── reservationapi.go ├── inventoryapi.go ├── reservationapi_test.go └── inventoryapi_test.go ├── config.yml ├── docker-compose.yml ├── config ├── config_test.go └── config.go ├── config_test.yml ├── Dockerfile ├── Makefile ├── queue ├── mocks.go └── queue.go ├── testutil ├── watcher.go └── http_util.go ├── scripts ├── setup-rmq.sh └── call-api.sh ├── go.mod ├── cmd ├── main.go └── integration_test.go └── README.md /inventory.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sksmith/go-micro-example/HEAD/inventory.jpg -------------------------------------------------------------------------------- /db/migrations/000002_create_users_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | 3 | COMMIT; -------------------------------------------------------------------------------- /db/migrations/000001_create_products_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS reservations; 2 | 3 | DROP TABLE IF EXISTS production_events; 4 | 5 | DROP TABLE IF EXISTS product_inventory; 6 | 7 | DROP TABLE IF EXISTS products; 8 | 9 | COMMIT; -------------------------------------------------------------------------------- /core/user/model.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "time" 4 | 5 | type CreateUserRequest struct { 6 | Username string `json:"username,omitempty"` 7 | IsAdmin bool `json:"isAdmin,omitempty"` 8 | PlainTextPassword string `json:"-"` 9 | } 10 | 11 | type User struct { 12 | Username string 13 | HashedPassword string 14 | IsAdmin bool 15 | Created time.Time 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.tmp 8 | bin/* 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 | # IDE Configurations 20 | .idea 21 | -------------------------------------------------------------------------------- /.github/workflows/sec.yml: -------------------------------------------------------------------------------- 1 | name: Sec 2 | on: 3 | workflow_call: 4 | secrets: 5 | webhook: 6 | required: true 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | env: 11 | GO111MODULE: on 12 | steps: 13 | - name: Checkout Source 14 | uses: actions/checkout@v2 15 | - name: Run Gosec Security Scanner 16 | uses: securego/gosec@master 17 | with: 18 | args: ./... -------------------------------------------------------------------------------- /db/migrations/000002_create_users_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users 2 | ( 3 | username VARCHAR(50) PRIMARY KEY, 4 | password VARCHAR(5000) NOT NULL, 5 | is_admin BOOLEAN DEFAULT FALSE, 6 | created_at TIMESTAMP WITH TIME ZONE 7 | ); 8 | 9 | INSERT INTO users (username, password, is_admin, created_at) 10 | VALUES ('admin', '$2a$10$v6K6OZgz.oUPSPGQfiarAOzD6JTz2.e5hdKCkq31NglPnAsT6j1GO', true, NOW()); 11 | 12 | COMMIT; -------------------------------------------------------------------------------- /api/environmentdto.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/sksmith/go-micro-example/config" 7 | ) 8 | 9 | type EnvResponse struct { 10 | config.Config 11 | } 12 | 13 | func NewEnvResponse(c config.Config) *EnvResponse { 14 | resp := &EnvResponse{Config: c} 15 | return resp 16 | } 17 | 18 | func (er *EnvResponse) Render(_ http.ResponseWriter, _ *http.Request) error { 19 | Scrub(er) 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /api/environmentapi.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi" 7 | "github.com/sksmith/go-micro-example/config" 8 | ) 9 | 10 | type EnvApi struct { 11 | cfg *config.Config 12 | } 13 | 14 | func NewEnvApi(cfg *config.Config) *EnvApi { 15 | return &EnvApi{cfg: cfg} 16 | } 17 | 18 | func (n *EnvApi) ConfigureRouter(r chi.Router) { 19 | r.Get("/", n.Get) 20 | } 21 | 22 | func (a *EnvApi) Get(w http.ResponseWriter, r *http.Request) { 23 | 24 | Render(w, r, NewEnvResponse(*a.cfg)) 25 | } 26 | -------------------------------------------------------------------------------- /api/userdto.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/sksmith/go-micro-example/core/user" 8 | ) 9 | 10 | type CreateUserRequestDto struct { 11 | *user.CreateUserRequest 12 | Password string `json:"password,omitempty"` 13 | } 14 | 15 | func (p *CreateUserRequestDto) Bind(_ *http.Request) error { 16 | if p.Username == "" || p.Password == "" { 17 | return errors.New("missing required field(s)") 18 | } 19 | 20 | p.CreateUserRequest.PlainTextPassword = p.Password 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | workflow_call: 4 | secrets: 5 | webhook: 6 | required: true 7 | jobs: 8 | test: 9 | strategy: 10 | matrix: 11 | go-version: [1.17.x] 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - name: Install Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: ${{ matrix.go-version }} 19 | - name: Checkout code 20 | uses: actions/checkout@v2 21 | - name: Test 22 | run: go test ./... -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | port: 8080 2 | profile: local 3 | 4 | config: 5 | source: local 6 | print: false 7 | 8 | log: 9 | level: debug 10 | structured: false 11 | 12 | db: 13 | name: go-micro-example-db 14 | host: localhost 15 | port: 5432 16 | user: postgres 17 | pass: postgres 18 | clean: false 19 | logLevel: warn 20 | pool: 21 | minSize: 1 22 | maxSize: 3 23 | 24 | rabbitmq: 25 | host: localhost 26 | port: 5672 27 | user: guest 28 | pass: guest 29 | inventory: 30 | exchange: inventory.exchange 31 | reservation: 32 | exchange: reservation.exchange 33 | product: 34 | queue: product.queue 35 | dlt: 36 | exchange: product.dlt.exchange -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: postgres 5 | hostname: postgres 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: postgres 11 | POSTGRES_DB: go-micro-example-db 12 | 13 | pgadmin: 14 | image: dpage/pgadmin4 15 | depends_on: 16 | - postgres 17 | ports: 18 | - "9090:80" 19 | environment: 20 | PGADMIN_DEFAULT_EMAIL: oz@oz.com 21 | PGADMIN_DEFAULT_PASSWORD: oz 22 | 23 | rabbitmq: 24 | image: rabbitmq:3.9-management-alpine 25 | container_name: 'rabbitmq' 26 | ports: 27 | - 5672:5672 28 | - 15672:15672 29 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/sksmith/go-micro-example/config" 8 | "github.com/sksmith/go-micro-example/testutil" 9 | ) 10 | 11 | func TestMain(m *testing.M) { 12 | testutil.ConfigLogging() 13 | os.Exit(m.Run()) 14 | } 15 | 16 | func TestLoadDefaults(t *testing.T) { 17 | cfg := config.LoadDefaults() 18 | 19 | if cfg.Profile.Value != cfg.Profile.Default { 20 | t.Errorf("profile got=%s want=%s", cfg.Profile.Value, cfg.Profile.Default) 21 | } 22 | } 23 | 24 | func TestLoad(t *testing.T) { 25 | cfg := config.Load("config_test") 26 | 27 | if cfg.Profile.Value != "test" { 28 | t.Errorf("profile got=%s want=%s", cfg.Profile.Value, "test") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /config_test.yml: -------------------------------------------------------------------------------- 1 | port: 8080 2 | profile: test 3 | 4 | config: 5 | source: local 6 | print: false 7 | 8 | log: 9 | level: debug 10 | structured: false 11 | 12 | db: 13 | name: go-micro-example-db 14 | host: localhost 15 | port: 5432 16 | user: postgres 17 | pass: postgres 18 | migrate: true 19 | migrationFolder: ../db/migrations 20 | clean: true 21 | logLevel: warn 22 | pool: 23 | minSize: 1 24 | maxSize: 3 25 | 26 | rabbitmq: 27 | host: localhost 28 | port: 5672 29 | user: guest 30 | pass: guest 31 | inventory: 32 | exchange: inventory.exchange 33 | reservation: 34 | exchange: reservation.exchange 35 | product: 36 | queue: product.queue 37 | dlt: 38 | exchange: product.dlt.exchange -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | lint: 8 | uses: sksmith/note-server/.github/workflows/lint.yml@master 9 | security: 10 | uses: sksmith/note-server/.github/workflows/sec.yml@master 11 | test: 12 | uses: sksmith/note-server/.github/workflows/test.yml@master 13 | notify: 14 | name: Notification 15 | needs: [lint, security, test] 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: sarisia/actions-status-discord@v1 19 | if: always() 20 | with: 21 | username: Dev Deployment 22 | description: A deployment to the development environment has been attempted 23 | webhook: ${{ secrets.DISCORD_WEBHOOK }} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as builder 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | ARG VER=NOT_SUPPLIED 8 | ARG SHA1=NOT_SUPPLIED 9 | ARG NOW=NOT_SUPPLIED 10 | 11 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ 12 | -ldflags "-X github.com/sksmith/go-micro-example/config.AppVersion=$VER \ 13 | -X github.com/sksmith/go-micro-example/config.Sha1Version=$SHA1 \ 14 | -X github.com/sksmith/go-micro-example/config.BuildTime=$NOW" \ 15 | -o ./go-micro-example ./cmd 16 | 17 | RUN apk add --update ca-certificates 18 | 19 | FROM scratch 20 | 21 | WORKDIR /app 22 | 23 | COPY --from=builder /app/go-micro-example /usr/bin/ 24 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 25 | COPY --from=builder /app/config.yml /app 26 | 27 | ENTRYPOINT ["go-micro-example"] 28 | -------------------------------------------------------------------------------- /api/reservationdto.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/sksmith/go-micro-example/core/inventory" 8 | ) 9 | 10 | type ReservationRequest struct { 11 | *inventory.ReservationRequest 12 | } 13 | 14 | func (r *ReservationRequest) Bind(_ *http.Request) error { 15 | if r.ReservationRequest == nil { 16 | return errors.New("missing required Reservation fields") 17 | } 18 | if r.Requester == "" { 19 | return errors.New("requester is required") 20 | } 21 | if r.Quantity < 1 { 22 | return errors.New("requested quantity must be greater than zero") 23 | } 24 | 25 | return nil 26 | } 27 | 28 | type ReservationResponse struct { 29 | inventory.Reservation 30 | } 31 | 32 | func (r *ReservationResponse) Render(_ http.ResponseWriter, _ *http.Request) error { 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /core/repo.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/jackc/pgconn" 8 | "github.com/jackc/pgx/v4" 9 | ) 10 | 11 | var ErrNotFound = errors.New("core: record not found") 12 | 13 | type Conn interface { 14 | Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) 15 | QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row 16 | Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) 17 | Begin(ctx context.Context) (pgx.Tx, error) 18 | } 19 | 20 | type Transaction interface { 21 | Conn 22 | Commit(ctx context.Context) error 23 | Rollback(ctx context.Context) error 24 | } 25 | 26 | type UpdateOptions struct { 27 | Tx Transaction 28 | } 29 | 30 | type QueryOptions struct { 31 | ForUpdate bool 32 | Tx Transaction 33 | } 34 | -------------------------------------------------------------------------------- /api/environmentapi_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/go-chi/chi" 9 | "github.com/sksmith/go-micro-example/api" 10 | "github.com/sksmith/go-micro-example/config" 11 | "github.com/sksmith/go-micro-example/testutil" 12 | ) 13 | 14 | func TestGetEnvironment(t *testing.T) { 15 | cfg := config.LoadDefaults() 16 | envApi := api.NewEnvApi(cfg) 17 | r := chi.NewRouter() 18 | envApi.ConfigureRouter(r) 19 | 20 | ts := httptest.NewServer(r) 21 | defer ts.Close() 22 | 23 | res, err := http.Get(ts.URL + "/") 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | got := &config.Config{} 29 | testutil.Unmarshal(res, got, t) 30 | 31 | if got.AppName != cfg.AppName { 32 | t.Errorf("unexpected app name got=[%v] want=[%v]", got, cfg.AppName) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VER := $(shell git describe --tag) 2 | SHA1 := $(shell git rev-parse HEAD) 3 | NOW := $(shell date -u +'%Y-%m-%d_%TZ') 4 | 5 | check: 6 | @echo Linting 7 | golangci-lint run 8 | 9 | @echo Security scanning 10 | gosec ./... 11 | 12 | @echo Testing 13 | go test ./... 14 | 15 | build: 16 | @echo Building the binary 17 | go build -ldflags "-X github.com/sksmith/go-micro-example/config.AppVersion=$(VER)\ 18 | -X github.com/sksmith/go-micro-example/config.Sha1Version=$(SHA1)\ 19 | -X github.com/sksmith/go-micro-example/config.BuildTime=$(NOW)"\ 20 | -o ./bin/go-micro-example ./cmd 21 | 22 | test: 23 | go test -cover ./... 24 | 25 | run: 26 | echo "executing the application" 27 | go run ./cmd/. 28 | 29 | docker: 30 | @echo Building the docker image 31 | docker build \ 32 | --build-arg VER=$(VER) \ 33 | --build-arg SHA1=$(SHA1) . 34 | 35 | tools: 36 | go install github.com/securego/gosec/v2/cmd/gosec 37 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.43.0 -------------------------------------------------------------------------------- /api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | func Scrub(o interface{}) { 10 | v := reflect.ValueOf(o).Elem() 11 | t := reflect.TypeOf(o).Elem() 12 | if v.Kind() != reflect.Struct { 13 | return 14 | } 15 | 16 | for i := 0; i < t.NumField(); i++ { 17 | f := v.Field(i) 18 | sf := t.Field(i) 19 | if f.Kind() == reflect.Struct { 20 | Scrub(v.Field(i).Addr().Interface()) 21 | } 22 | if sf.Tag.Get("sensitive") != "" { 23 | switch f.Kind() { 24 | case reflect.String: 25 | f.SetString("******") 26 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, 27 | reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 28 | f.SetInt(0) 29 | case reflect.Float32, reflect.Float64: 30 | f.SetFloat(0.00) 31 | default: 32 | log.Warn(). 33 | Str("fieldName", sf.Name). 34 | Str("type", f.Kind().String()). 35 | Msg("field marked sensitive but was an unrecognized type") 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | workflow_call: 4 | secrets: 5 | webhook: 6 | required: true 7 | jobs: 8 | golangci: 9 | name: lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: golangci-lint 14 | uses: golangci/golangci-lint-action@v2 15 | with: 16 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 17 | version: v1.29 18 | 19 | # Optional: show only new issues if it's a pull request. The default value is `false`. 20 | # only-new-issues: true 21 | 22 | # Optional: if set to true then the action will use pre-installed Go. 23 | # skip-go-installation: true 24 | 25 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 26 | # skip-pkg-cache: true 27 | 28 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 29 | # skip-build-cache: true -------------------------------------------------------------------------------- /api/util_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sksmith/go-micro-example/api" 7 | ) 8 | 9 | type TestObj struct { 10 | PlainText string 11 | SensText string `sensitive:"true"` 12 | PlainInt int 13 | SensInt int `sensitive:"true"` 14 | PlainFloat float32 15 | SensFloat float32 `sensitive:"true"` 16 | SensBool bool `sensitive:"true"` 17 | } 18 | 19 | func TestScrub(t *testing.T) { 20 | tests := []struct { 21 | input TestObj 22 | want TestObj 23 | }{ 24 | { 25 | input: TestObj{PlainText: "plaintext", SensText: "abc", PlainInt: 123, SensInt: 123, PlainFloat: 1.23, SensFloat: 1.23}, 26 | want: TestObj{PlainText: "plaintext", SensText: "******", PlainInt: 123, SensInt: 0, PlainFloat: 1.23, SensFloat: 0.00}, 27 | }, 28 | } 29 | 30 | for _, test := range tests { 31 | api.Scrub(&test.input) 32 | expect(test.input, test.want, t) 33 | } 34 | } 35 | 36 | func expect(got, want interface{}, t *testing.T) { 37 | if got != want { 38 | t.Errorf("\n got=[%+v]\nwant=[%+v]", got, want) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /queue/mocks.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sksmith/go-micro-example/core/inventory" 7 | "github.com/sksmith/go-micro-example/testutil" 8 | ) 9 | 10 | type MockQueue struct { 11 | PublishInventoryFunc func(ctx context.Context, productInventory inventory.ProductInventory) error 12 | PublishReservationFunc func(ctx context.Context, reservation inventory.Reservation) error 13 | testutil.CallWatcher 14 | } 15 | 16 | func NewMockQueue() *MockQueue { 17 | return &MockQueue{ 18 | PublishInventoryFunc: func(ctx context.Context, productInventory inventory.ProductInventory) error { 19 | return nil 20 | }, 21 | PublishReservationFunc: func(ctx context.Context, reservation inventory.Reservation) error { 22 | return nil 23 | }, 24 | CallWatcher: *testutil.NewCallWatcher(), 25 | } 26 | } 27 | 28 | func (m *MockQueue) PublishInventory(ctx context.Context, productInventory inventory.ProductInventory) error { 29 | m.AddCall(ctx, productInventory) 30 | return m.PublishInventoryFunc(ctx, productInventory) 31 | } 32 | 33 | func (m *MockQueue) PublishReservation(ctx context.Context, reservation inventory.Reservation) error { 34 | m.AddCall(ctx, reservation) 35 | return m.PublishReservationFunc(ctx, reservation) 36 | } 37 | -------------------------------------------------------------------------------- /api/userapi.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi" 8 | "github.com/go-chi/render" 9 | "github.com/rs/zerolog/log" 10 | "github.com/sksmith/go-micro-example/core/user" 11 | ) 12 | 13 | type UserService interface { 14 | Create(ctx context.Context, user user.CreateUserRequest) (user.User, error) 15 | Get(ctx context.Context, username string) (user.User, error) 16 | Delete(ctx context.Context, username string) error 17 | Login(ctx context.Context, username, password string) (user.User, error) 18 | } 19 | 20 | type UserApi struct { 21 | service UserService 22 | } 23 | 24 | func NewUserApi(service UserService) *UserApi { 25 | return &UserApi{service: service} 26 | } 27 | 28 | func (a *UserApi) ConfigureRouter(r chi.Router) { 29 | r.With(AdminOnly).Post("/", a.Create) 30 | } 31 | 32 | func (a *UserApi) Create(w http.ResponseWriter, r *http.Request) { 33 | data := &CreateUserRequestDto{} 34 | if err := render.Bind(r, data); err != nil { 35 | log.Err(err).Send() 36 | Render(w, r, ErrInvalidRequest(err)) 37 | return 38 | } 39 | 40 | _, err := a.service.Create(r.Context(), *data.CreateUserRequest) 41 | 42 | if err != nil { 43 | log.Err(err).Send() 44 | Render(w, r, ErrInternalServer) 45 | return 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /db/migrations/000001_create_products_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE products 2 | ( 3 | sku VARCHAR(50) PRIMARY KEY, 4 | upc VARCHAR(50) UNIQUE NOT NULL, 5 | name VARCHAR(100) NOT NULL 6 | ); 7 | 8 | CREATE TABLE product_inventory 9 | ( 10 | sku VARCHAR(50) PRIMARY KEY REFERENCES products(sku), 11 | available INTEGER 12 | ); 13 | 14 | CREATE TABLE production_events 15 | ( 16 | id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 17 | request_id VARCHAR(100) UNIQUE NOT NULL, 18 | sku VARCHAR(50) REFERENCES products(sku), 19 | quantity INTEGER, 20 | created TIMESTAMP WITH TIME ZONE 21 | ); 22 | 23 | CREATE 24 | INDEX prod_evt_req_idx ON production_events (request_id); 25 | 26 | CREATE TABLE reservations 27 | ( 28 | id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 29 | request_id VARCHAR(100) UNIQUE NOT NULL, 30 | requester VARCHAR(100), 31 | sku VARCHAR(50) REFERENCES products (sku), 32 | state VARCHAR(50), 33 | reserved_quantity INTEGER, 34 | requested_quantity INTEGER, 35 | created TIMESTAMP WITH TIME ZONE 36 | ); 37 | 38 | CREATE 39 | INDEX res_sku_state_idx ON reservations (sku, state); 40 | 41 | CREATE 42 | INDEX res_req_id_idx ON reservations (request_id); 43 | 44 | COMMIT; -------------------------------------------------------------------------------- /core/user/mocks.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import "context" 4 | 5 | type MockUserService struct { 6 | CreateFunc func(ctx context.Context, user CreateUserRequest) (User, error) 7 | GetFunc func(ctx context.Context, username string) (User, error) 8 | DeleteFunc func(ctx context.Context, username string) error 9 | LoginFunc func(ctx context.Context, username, password string) (User, error) 10 | } 11 | 12 | func NewMockUserService() *MockUserService { 13 | return &MockUserService{ 14 | CreateFunc: func(ctx context.Context, user CreateUserRequest) (User, error) { return User{}, nil }, 15 | GetFunc: func(ctx context.Context, username string) (User, error) { return User{}, nil }, 16 | DeleteFunc: func(ctx context.Context, username string) error { return nil }, 17 | LoginFunc: func(ctx context.Context, username, password string) (User, error) { return User{}, nil }, 18 | } 19 | } 20 | 21 | func (u *MockUserService) Create(ctx context.Context, user CreateUserRequest) (User, error) { 22 | return u.CreateFunc(ctx, user) 23 | } 24 | 25 | func (u *MockUserService) Get(ctx context.Context, username string) (User, error) { 26 | return u.GetFunc(ctx, username) 27 | } 28 | 29 | func (u *MockUserService) Delete(ctx context.Context, username string) error { 30 | return u.DeleteFunc(ctx, username) 31 | } 32 | 33 | func (u *MockUserService) Login(ctx context.Context, username, password string) (User, error) { 34 | return u.LoginFunc(ctx, username, password) 35 | } 36 | -------------------------------------------------------------------------------- /testutil/watcher.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/rs/zerolog" 10 | "github.com/rs/zerolog/log" 11 | "github.com/rs/zerolog/pkgerrors" 12 | ) 13 | 14 | type CallWatcher struct { 15 | functionCalls map[string][][]interface{} 16 | } 17 | 18 | func NewCallWatcher() *CallWatcher { 19 | return &CallWatcher{functionCalls: make(map[string][][]interface{})} 20 | } 21 | 22 | func (w *CallWatcher) VerifyCount(funcName string, want int, t *testing.T) { 23 | if w.GetCallCount(funcName) != want { 24 | t.Errorf("%s call count got=%d want=%d", funcName, w.GetCallCount(funcName), want) 25 | } 26 | } 27 | 28 | func (w *CallWatcher) GetCall(funcName string) [][]interface{} { 29 | return w.functionCalls[funcName] 30 | } 31 | 32 | func (w *CallWatcher) GetCallCount(funcName string) int { 33 | return len(w.functionCalls[funcName]) 34 | } 35 | 36 | func (w *CallWatcher) AddCall(args ...interface{}) { 37 | pc := make([]uintptr, 15) 38 | n := runtime.Callers(2, pc) 39 | frames := runtime.CallersFrames(pc[:n]) 40 | frame, _ := frames.Next() 41 | funcParts := strings.Split(frame.Function, ".") 42 | funcName := funcParts[len(funcParts)-1] 43 | 44 | calls := w.functionCalls[funcName] 45 | w.functionCalls[funcName] = append(calls, args) 46 | } 47 | 48 | func ConfigLogging() { 49 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 50 | zerolog.SetGlobalLevel(zerolog.TraceLevel) 51 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 52 | } 53 | -------------------------------------------------------------------------------- /db/metrics.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "time" 6 | ) 7 | 8 | var ( 9 | dbLatency = prometheus.NewSummaryVec( 10 | prometheus.SummaryOpts{ 11 | Name: "smfg_inventory_db_latency", 12 | Help: "The latency quantiles for the given database request", 13 | Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, 14 | }, 15 | []string{"func"}, 16 | ) 17 | 18 | dbVolume = prometheus.NewCounterVec( 19 | prometheus.CounterOpts{ 20 | Name: "smfg_inventory_db_volume", 21 | Help: "Number of times a given database request was made", 22 | }, 23 | []string{"func"}, 24 | ) 25 | 26 | dbErrors = prometheus.NewCounterVec( 27 | prometheus.CounterOpts{ 28 | Name: "smfg_inventory_db_errors", 29 | Help: "Number of times a given database request failed", 30 | }, 31 | []string{"func"}, 32 | ) 33 | ) 34 | 35 | type Metric struct { 36 | funcName string 37 | start time.Time 38 | } 39 | 40 | func StartMetric(funcName string) *Metric { 41 | dbVolume.With(prometheus.Labels{"func": funcName}).Inc() 42 | return &Metric{funcName: funcName, start: time.Now()} 43 | } 44 | 45 | func (m *Metric) Complete(err error) { 46 | if err != nil { 47 | dbErrors.With(prometheus.Labels{"func": m.funcName}).Inc() 48 | } 49 | dbLatency.WithLabelValues(m.funcName).Observe(float64(time.Since(m.start).Milliseconds())) 50 | } 51 | 52 | func init() { 53 | prometheus.MustRegister(dbVolume) 54 | prometheus.MustRegister(dbLatency) 55 | prometheus.MustRegister(dbErrors) 56 | } 57 | -------------------------------------------------------------------------------- /api/errordto.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/render" 7 | ) 8 | 9 | //-- 10 | // Error response payloads & renderers 11 | //-- 12 | 13 | // ErrResponse renderer type for handling all sorts of errors. 14 | // 15 | // In the best case scenario, the excellent github.com/pkg/errors package 16 | // helps reveal information on the error, setting it on Err, and in the Render() 17 | // method, using it to set the application-specific error code in AppCode. 18 | type ErrResponse struct { 19 | Err error `json:"-"` // low-level runtime error 20 | HTTPStatusCode int `json:"-"` // http response status code 21 | 22 | StatusText string `json:"status"` // user-level status message 23 | AppCode int64 `json:"code,omitempty"` // application-specific error code 24 | ErrorText string `json:"error,omitempty"` // application-level error message, for debugging 25 | } 26 | 27 | func (e *ErrResponse) Render(_ http.ResponseWriter, r *http.Request) error { 28 | render.Status(r, e.HTTPStatusCode) 29 | return nil 30 | } 31 | 32 | func ErrInvalidRequest(err error) *ErrResponse { 33 | return &ErrResponse{ 34 | Err: err, 35 | HTTPStatusCode: http.StatusBadRequest, 36 | StatusText: "Invalid request.", 37 | ErrorText: err.Error(), 38 | } 39 | } 40 | 41 | var ErrNotFound = &ErrResponse{ 42 | HTTPStatusCode: http.StatusNotFound, 43 | StatusText: "Resource not found.", 44 | } 45 | 46 | var ErrInternalServer = &ErrResponse{ 47 | Err: nil, 48 | HTTPStatusCode: http.StatusInternalServerError, 49 | StatusText: "Internal server error.", 50 | ErrorText: "An internal server error has occurred.", 51 | } 52 | -------------------------------------------------------------------------------- /scripts/setup-rmq.sh: -------------------------------------------------------------------------------- 1 | curl -i -u guest:guest -H "content-type:application/json" \ 2 | -XPUT -d'{"type":"direct","durable":true}' \ 3 | http://localhost:15672/api/exchanges/%2F/inventory.exchange 4 | 5 | curl -i -u guest:guest -H "content-type:application/json" \ 6 | -XPUT -d'{"auto_delete":false,"durable":true}' \ 7 | http://localhost:15672/api/queues/%2F/inventory.queue 8 | 9 | curl -i -u guest:guest -H "content-type:application/json" \ 10 | -XPOST -d'{}' \ 11 | http://localhost:15672/api/bindings/%2F/e/inventory.exchange/q/inventory.queue 12 | 13 | 14 | 15 | 16 | curl -i -u guest:guest -H "content-type:application/json" \ 17 | -XPUT -d'{"type":"direct","durable":true}' \ 18 | http://localhost:15672/api/exchanges/%2F/reservation.exchange 19 | 20 | curl -i -u guest:guest -H "content-type:application/json" \ 21 | -XPUT -d'{"auto_delete":false,"durable":true}' \ 22 | http://localhost:15672/api/queues/%2F/reservation.queue 23 | 24 | curl -i -u guest:guest -H "content-type:application/json" \ 25 | -XPOST -d'{}' \ 26 | http://localhost:15672/api/bindings/%2F/e/reservation.exchange/q/reservation.queue 27 | 28 | 29 | 30 | 31 | curl -i -u guest:guest -H "content-type:application/json" \ 32 | -XPUT -d'{"type":"direct","durable":true}' \ 33 | http://localhost:15672/api/exchanges/%2F/product.exchange 34 | 35 | curl -i -u guest:guest -H "content-type:application/json" \ 36 | -XPUT -d'{"auto_delete":false,"durable":true}' \ 37 | http://localhost:15672/api/queues/%2F/product.queue 38 | 39 | curl -i -u guest:guest -H "content-type:application/json" \ 40 | -XPOST -d'{}' \ 41 | http://localhost:15672/api/bindings/%2F/e/product.exchange/q/product.queue 42 | 43 | -------------------------------------------------------------------------------- /testutil/http_util.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/gobwas/ws/wsutil" 12 | ) 13 | 14 | func Unmarshal(res *http.Response, v interface{}, t *testing.T) { 15 | body, err := ioutil.ReadAll(res.Body) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | err = json.Unmarshal(body, v) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | } 24 | 25 | type RequestOptions struct { 26 | Username string 27 | Password string 28 | } 29 | 30 | func Put(url string, request interface{}, t *testing.T, op ...RequestOptions) *http.Response { 31 | return SendRequest(http.MethodPut, url, request, t, op...) 32 | } 33 | 34 | func Post(url string, request interface{}, t *testing.T, op ...RequestOptions) *http.Response { 35 | return SendRequest(http.MethodPost, url, request, t, op...) 36 | } 37 | 38 | func SendRequest(method, url string, request interface{}, t *testing.T, op ...RequestOptions) *http.Response { 39 | json, err := json.Marshal(request) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | req, err := http.NewRequest(method, url, bytes.NewBuffer(json)) 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | if len(op) > 0 { 50 | req.SetBasicAuth(op[0].Username, op[0].Password) 51 | } 52 | 53 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 54 | res, err := http.DefaultClient.Do(req) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | 59 | return res 60 | } 61 | 62 | func ReadWs(conn net.Conn, v interface{}, t *testing.T) { 63 | msg, _, err := wsutil.ReadServerData(conn) 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | 68 | err = json.Unmarshal(msg, v) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /db/usrrepo/mocks.go: -------------------------------------------------------------------------------- 1 | package usrrepo 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sksmith/go-micro-example/core" 7 | "github.com/sksmith/go-micro-example/core/user" 8 | "github.com/sksmith/go-micro-example/testutil" 9 | ) 10 | 11 | type MockRepo struct { 12 | CreateFunc func(ctx context.Context, user *user.User, options ...core.UpdateOptions) error 13 | GetFunc func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) 14 | UpdateFunc func(ctx context.Context, user *user.User, options ...core.UpdateOptions) error 15 | DeleteFunc func(ctx context.Context, username string, options ...core.UpdateOptions) error 16 | *testutil.CallWatcher 17 | } 18 | 19 | func NewMockRepo() *MockRepo { 20 | return &MockRepo{ 21 | CreateFunc: func(ctx context.Context, user *user.User, options ...core.UpdateOptions) error { return nil }, 22 | GetFunc: func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) { 23 | return user.User{}, nil 24 | }, 25 | UpdateFunc: func(ctx context.Context, user *user.User, options ...core.UpdateOptions) error { return nil }, 26 | DeleteFunc: func(ctx context.Context, username string, options ...core.UpdateOptions) error { return nil }, 27 | CallWatcher: testutil.NewCallWatcher(), 28 | } 29 | } 30 | 31 | func (r *MockRepo) Create(ctx context.Context, user *user.User, options ...core.UpdateOptions) error { 32 | r.AddCall(ctx, user, options) 33 | return r.CreateFunc(ctx, user, options...) 34 | } 35 | 36 | func (r *MockRepo) Get(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) { 37 | r.AddCall(ctx, username, options) 38 | return r.GetFunc(ctx, username, options...) 39 | } 40 | 41 | func (r *MockRepo) Update(ctx context.Context, user *user.User, options ...core.UpdateOptions) error { 42 | r.AddCall(ctx, user, options) 43 | return r.UpdateFunc(ctx, user, options...) 44 | } 45 | 46 | func (r *MockRepo) Delete(ctx context.Context, username string, options ...core.UpdateOptions) error { 47 | r.AddCall(ctx, username, options) 48 | return r.DeleteFunc(ctx, username, options...) 49 | } 50 | -------------------------------------------------------------------------------- /core/user/service.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/rs/zerolog/log" 9 | "github.com/sksmith/go-micro-example/core" 10 | "golang.org/x/crypto/bcrypt" 11 | ) 12 | 13 | func NewService(repo Repository) *service { 14 | log.Info().Msg("creating user service...") 15 | 16 | return &service{repo: repo} 17 | } 18 | 19 | type service struct { 20 | repo Repository 21 | } 22 | 23 | func (s *service) Get(ctx context.Context, username string) (User, error) { 24 | return s.repo.Get(ctx, username) 25 | } 26 | 27 | func (s *service) Create(ctx context.Context, req CreateUserRequest) (User, error) { 28 | if !usernameIsValid(req.Username) { 29 | return User{}, errors.New("invalid username") 30 | } 31 | if !passwordIsValid(req.PlainTextPassword) { 32 | return User{}, errors.New("invalid password") 33 | } 34 | hash, err := bcrypt.GenerateFromPassword([]byte(req.PlainTextPassword), bcrypt.DefaultCost) 35 | if err != nil { 36 | return User{}, err 37 | } 38 | user := &User{ 39 | Username: req.Username, 40 | HashedPassword: string(hash), 41 | Created: time.Now(), 42 | } 43 | err = s.repo.Create(ctx, user) 44 | if err != nil { 45 | return User{}, err 46 | } 47 | return *user, nil 48 | } 49 | 50 | func usernameIsValid(username string) bool { 51 | return true 52 | } 53 | 54 | func passwordIsValid(password string) bool { 55 | return true 56 | } 57 | 58 | func (s *service) Delete(ctx context.Context, username string) error { 59 | return s.repo.Delete(ctx, username) 60 | } 61 | 62 | func (s *service) Login(ctx context.Context, username, password string) (User, error) { 63 | u, err := s.repo.Get(ctx, username) 64 | if err != nil { 65 | return User{}, err 66 | } 67 | 68 | err = bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password)) 69 | if err != nil { 70 | return User{}, err 71 | } 72 | 73 | return u, nil 74 | } 75 | 76 | type Repository interface { 77 | Create(ctx context.Context, user *User, tx ...core.UpdateOptions) error 78 | Get(ctx context.Context, username string, tx ...core.QueryOptions) (User, error) 79 | Delete(ctx context.Context, username string, tx ...core.UpdateOptions) error 80 | } 81 | -------------------------------------------------------------------------------- /api/inventorydto.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/go-chi/render" 9 | "github.com/sksmith/go-micro-example/core/inventory" 10 | ) 11 | 12 | type ProductResponse struct { 13 | inventory.ProductInventory 14 | } 15 | 16 | func NewProductResponse(product inventory.ProductInventory) *ProductResponse { 17 | resp := &ProductResponse{ProductInventory: product} 18 | return resp 19 | } 20 | 21 | func (rd *ProductResponse) Render(_ http.ResponseWriter, _ *http.Request) error { 22 | // Pre-processing before a response is marshalled and sent across the wire 23 | return nil 24 | } 25 | 26 | func NewProductListResponse(products []inventory.ProductInventory) []render.Renderer { 27 | list := make([]render.Renderer, 0) 28 | for _, product := range products { 29 | list = append(list, NewProductResponse(product)) 30 | } 31 | return list 32 | } 33 | 34 | func NewReservationListResponse(reservations []inventory.Reservation) []render.Renderer { 35 | list := make([]render.Renderer, 0) 36 | for _, rsv := range reservations { 37 | list = append(list, &ReservationResponse{Reservation: rsv}) 38 | } 39 | 40 | return list 41 | } 42 | 43 | type CreateProductRequest struct { 44 | inventory.Product 45 | } 46 | 47 | func (p *CreateProductRequest) Bind(_ *http.Request) error { 48 | if p.Upc == "" || p.Name == "" || p.Sku == "" { 49 | return errors.New("missing required field(s)") 50 | } 51 | 52 | return nil 53 | } 54 | 55 | type CreateProductionEventRequest struct { 56 | *inventory.ProductionRequest 57 | 58 | ProtectedID uint64 `json:"id"` 59 | ProtectedCreated time.Time `json:"created"` 60 | } 61 | 62 | func (p *CreateProductionEventRequest) Bind(_ *http.Request) error { 63 | if p.ProductionRequest == nil { 64 | return errors.New("missing required ProductionRequest fields") 65 | } 66 | if p.RequestID == "" { 67 | return errors.New("requestId is required") 68 | } 69 | if p.Quantity < 1 { 70 | return errors.New("quantity must be greater than zero") 71 | } 72 | 73 | return nil 74 | } 75 | 76 | type ProductionEventResponse struct { 77 | } 78 | 79 | func (p *ProductionEventResponse) Render(_ http.ResponseWriter, _ *http.Request) error { 80 | return nil 81 | } 82 | 83 | func (p *ProductionEventResponse) Bind(_ *http.Request) error { 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "testing" 8 | 9 | "github.com/go-chi/chi" 10 | "github.com/sksmith/go-micro-example/api" 11 | "github.com/sksmith/go-micro-example/config" 12 | "github.com/sksmith/go-micro-example/core/inventory" 13 | "github.com/sksmith/go-micro-example/core/user" 14 | "github.com/sksmith/go-micro-example/testutil" 15 | ) 16 | 17 | func TestMain(m *testing.M) { 18 | testutil.ConfigLogging() 19 | os.Exit(m.Run()) 20 | } 21 | 22 | func TestCorsConfig(t *testing.T) { 23 | tests := []struct { 24 | origin string 25 | want string 26 | }{ 27 | {origin: "https://evilorigin.com", want: ""}, 28 | {origin: "http://evilorigin.com", want: ""}, 29 | {origin: "https://subdomain.seanksmith.me", want: "https://subdomain.seanksmith.me"}, 30 | {origin: "http://subdomain.seanksmith.me", want: "http://subdomain.seanksmith.me"}, 31 | {origin: "http://subdomain.seanksmith.evil.me", want: ""}, 32 | {origin: "http://localhost:8080", want: "http://localhost:8080"}, 33 | {origin: "http://localhost:3000", want: "http://localhost:3000"}, 34 | {origin: "https://localhost:8080", want: "https://localhost:8080"}, 35 | {origin: "https://localhost:3000", want: "https://localhost:3000"}, 36 | {origin: "https://localhostevil:3000", want: ""}, 37 | } 38 | 39 | r := getRouter() 40 | ts := httptest.NewServer(r) 41 | defer ts.Close() 42 | 43 | client := http.DefaultClient 44 | url := ts.URL + api.ApiPath + api.InventoryPath 45 | 46 | for _, test := range tests { 47 | req, err := http.NewRequest("GET", url, nil) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | req.Header.Add("Origin", test.origin) 52 | 53 | res, err := client.Do(req) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | got := res.Header.Get("Access-Control-Allow-Origin") 59 | if got != test.want { 60 | t.Errorf("failed cors test got=[%v] want=[%v]", got, test.want) 61 | } 62 | } 63 | } 64 | 65 | func getRouter() chi.Router { 66 | cfg := config.LoadDefaults() 67 | invSvc, resSvc, usrSvc := getMocks() 68 | return api.ConfigureRouter(cfg, invSvc, resSvc, usrSvc) 69 | } 70 | 71 | func getMocks() (*inventory.MockInventoryService, *inventory.MockReservationService, *user.MockUserService) { 72 | return inventory.NewMockInventoryService(), inventory.NewMockReservationService(), user.NewMockUserService() 73 | } 74 | -------------------------------------------------------------------------------- /scripts/call-api.sh: -------------------------------------------------------------------------------- 1 | curl -i -H "content-type:application/json" -u admin:admin \ 2 | -XPUT -d'{"sku":"sku123","upc":"someupc","name":"some product"}' \ 3 | "http://localhost:8080/api/v1/inventory" 4 | 5 | curl -i -H "content-type:application/json" -u admin:admin \ 6 | "http://localhost:8080/api/v1/inventory" 7 | 8 | curl -i -H "content-type:application/json" -u admin:admin \ 9 | -XPUT -d'{"requestID":"produceReq1","quantity":5}' \ 10 | "http://localhost:8080/api/v1/inventory/sku123/productionEvent" 11 | 12 | curl -i -H "content-type:application/json" -u admin:admin \ 13 | "http://localhost:8080/api/v1/inventory/sku123" 14 | 15 | curl -i -H "content-type:application/json" -u admin:admin \ 16 | -XPUT -d'{"requestID":"reserveReq1","sku":"sku123","requester":"someperson","quantity":2}' \ 17 | "http://localhost:8080/api/v1/reservation" 18 | 19 | curl -i -H "content-type:application/json" -u admin:admin \ 20 | -XPUT -d'{"requestID":"reserveReq2","requester":"someperson","quantity":2}' \ 21 | "http://localhost:8080/api/v1/reservation" 22 | 23 | curl -i -H "content-type:application/json" -u admin:admin \ 24 | -XPUT -d'{"requestID":"reserveReq3","sku":"sku123","requester":"someperson","quantity":2}' \ 25 | "http://localhost:8080/api/v1/reservation" 26 | 27 | curl -i -H "content-type:application/json" -u admin:admin \ 28 | -XPUT -d'{"requestID":"reserveReq4","sku":"sku123","requester":"someperson","quantity":2}' \ 29 | "http://localhost:8080/api/v1/reservation" 30 | 31 | curl -i -H "content-type:application/json" -u admin:admin \ 32 | "http://localhost:8080/api/v1/reservation?sku=sku123" 33 | 34 | curl -i -H "content-type:application/json" -u admin:admin \ 35 | "http://localhost:8080/api/v1/reservation?sku=sku123&state=Open" 36 | 37 | curl -i -H "content-type:application/json" -u admin:admin \ 38 | "http://localhost:8080/api/v1/reservation?sku=sku123&state=Closed" 39 | 40 | curl -i -H "content-type:application/json" -u admin:admin \ 41 | "http://localhost:8080/api/v1/reservation?sku=sku123&state=InvalidState" 42 | 43 | 44 | curl -i -H "content-type:application/json" -u admin:admin \ 45 | -XPUT -d'{"requestID":"reserveReq14","requester":"Sean Smith", "sku": "powerbat1", "quantity":4}' \ 46 | "http://localhost:8080/api/v1/reservation" 47 | 48 | curl -i -H "content-type:application/json" -u admin:admin \ 49 | -XPUT -d'{"requestID":"reserveReq16","requester":"Sean Smith", "sku": "sku123", "quantity":3}' \ 50 | "http://localhost:8080/api/v1/reservation" -------------------------------------------------------------------------------- /core/inventory/model.go: -------------------------------------------------------------------------------- 1 | // Package inventory is a rudimentary model that represents a fictional inventory tracking system for a factory. A real 2 | // factory would obviously need much more fine grained detail and would probably use a different ubiquitous language. 3 | package inventory 4 | 5 | import ( 6 | "time" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // ProductionRequest is a value object. A request to produce inventory. 12 | type ProductionRequest struct { 13 | RequestID string `json:"requestID"` 14 | Quantity int64 `json:"quantity"` 15 | } 16 | 17 | // ProductionEvent is an entity. An addition to inventory through production of a Product. 18 | type ProductionEvent struct { 19 | ID uint64 `json:"id"` 20 | RequestID string `json:"requestID"` 21 | Sku string `json:"sku"` 22 | Quantity int64 `json:"quantity"` 23 | Created time.Time `json:"created"` 24 | } 25 | 26 | // Product is a value object. A SKU able to be produced by the factory. 27 | type Product struct { 28 | Sku string `json:"sku"` 29 | Upc string `json:"upc"` 30 | Name string `json:"name"` 31 | } 32 | 33 | // ProductInventory is an entity. It represents current inventory levels for the associated product. 34 | type ProductInventory struct { 35 | Product 36 | Available int64 `json:"available"` 37 | } 38 | 39 | type ReserveState string 40 | 41 | const ( 42 | Open ReserveState = "Open" 43 | Closed ReserveState = "Closed" 44 | None ReserveState = "" 45 | ) 46 | 47 | func ParseReserveState(v string) (ReserveState, error) { 48 | switch v { 49 | case string(Open): 50 | return Open, nil 51 | case string(Closed): 52 | return Closed, nil 53 | case string(None): 54 | return None, nil 55 | default: 56 | return None, errors.New("invalid reserve state") 57 | } 58 | } 59 | 60 | type ReservationRequest struct { 61 | Sku string `json:"sku"` 62 | RequestID string `json:"requestId"` 63 | Requester string `json:"requester"` 64 | Quantity int64 `json:"quantity"` 65 | } 66 | 67 | // Reservation is an entity. An amount of inventory set aside for a given Customer. 68 | type Reservation struct { 69 | ID uint64 `json:"id"` 70 | RequestID string `json:"requestId"` 71 | Requester string `json:"requester"` 72 | Sku string `json:"sku"` 73 | State ReserveState `json:"state"` 74 | ReservedQuantity int64 `json:"reservedQuantity"` 75 | RequestedQuantity int64 `json:"requestedQuantity"` 76 | Created time.Time `json:"created"` 77 | } 78 | -------------------------------------------------------------------------------- /core/inventory/repository.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rs/zerolog/log" 7 | "github.com/sksmith/go-micro-example/core" 8 | ) 9 | 10 | func rollback(ctx context.Context, tx core.Transaction, err error) { 11 | if tx == nil { 12 | return 13 | } 14 | e := tx.Rollback(ctx) 15 | if e != nil { 16 | log.Warn().Err(err).Msg("failed to rollback") 17 | } 18 | } 19 | 20 | type Transactional interface { 21 | BeginTransaction(ctx context.Context) (core.Transaction, error) 22 | } 23 | 24 | type Repository interface { 25 | ProductionEventRepository 26 | ReservationRepository 27 | InventoryRepository 28 | ProductRepository 29 | } 30 | 31 | type ProductionEventRepository interface { 32 | Transactional 33 | GetProductionEventByRequestID(ctx context.Context, requestID string, options ...core.QueryOptions) (pe ProductionEvent, err error) 34 | 35 | SaveProductionEvent(ctx context.Context, event *ProductionEvent, options ...core.UpdateOptions) error 36 | } 37 | 38 | type ReservationRepository interface { 39 | Transactional 40 | GetReservations(ctx context.Context, resOptions GetReservationsOptions, limit, offset int, options ...core.QueryOptions) ([]Reservation, error) 41 | GetReservationByRequestID(ctx context.Context, requestId string, options ...core.QueryOptions) (Reservation, error) 42 | GetReservation(ctx context.Context, ID uint64, options ...core.QueryOptions) (Reservation, error) 43 | 44 | SaveReservation(ctx context.Context, reservation *Reservation, options ...core.UpdateOptions) error 45 | UpdateReservation(ctx context.Context, ID uint64, state ReserveState, qty int64, options ...core.UpdateOptions) error 46 | } 47 | 48 | type InventoryRepository interface { 49 | Transactional 50 | GetProductInventory(ctx context.Context, sku string, options ...core.QueryOptions) (pi ProductInventory, err error) 51 | GetAllProductInventory(ctx context.Context, limit int, offset int, options ...core.QueryOptions) ([]ProductInventory, error) 52 | 53 | SaveProductInventory(ctx context.Context, productInventory ProductInventory, options ...core.UpdateOptions) error 54 | } 55 | 56 | type ProductRepository interface { 57 | Transactional 58 | GetProduct(ctx context.Context, sku string, options ...core.QueryOptions) (Product, error) 59 | 60 | SaveProduct(ctx context.Context, product Product, options ...core.UpdateOptions) error 61 | } 62 | 63 | type InventoryQueue interface { 64 | PublishInventory(ctx context.Context, productInventory ProductInventory) error 65 | PublishReservation(ctx context.Context, reservation Reservation) error 66 | } 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sksmith/go-micro-example 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be 7 | github.com/go-chi/chi v4.1.2+incompatible 8 | github.com/go-chi/docgen v1.0.5 9 | github.com/go-chi/render v1.0.1 10 | github.com/golang-migrate/migrate/v4 v4.13.0 11 | github.com/hashicorp/golang-lru v0.5.4 12 | github.com/jackc/pgconn v1.10.1 13 | github.com/jackc/pgx/v4 v4.14.1 14 | github.com/pkg/errors v0.9.1 15 | github.com/prometheus/client_golang v1.11.0 16 | github.com/rs/zerolog v1.20.0 17 | github.com/sksmith/bunnyq v0.2.2 18 | github.com/sksmith/go-spring-config v0.0.1 19 | github.com/streadway/amqp v1.0.0 20 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 21 | ) 22 | 23 | require ( 24 | github.com/fsnotify/fsnotify v1.5.1 // indirect 25 | github.com/go-chi/cors v1.2.0 // indirect 26 | github.com/gobwas/httphead v0.1.0 // indirect 27 | github.com/gobwas/pool v0.2.1 // indirect 28 | github.com/google/uuid v1.3.0 // indirect 29 | github.com/hashicorp/hcl v1.0.0 // indirect 30 | github.com/magiconair/properties v1.8.5 // indirect 31 | github.com/mitchellh/mapstructure v1.4.3 // indirect 32 | github.com/pelletier/go-toml v1.9.4 // indirect 33 | github.com/spf13/afero v1.6.0 // indirect 34 | github.com/spf13/cast v1.4.1 // indirect 35 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 36 | github.com/spf13/pflag v1.0.5 // indirect 37 | github.com/subosito/gotenv v1.2.0 // indirect 38 | gopkg.in/ini.v1 v1.66.2 // indirect 39 | ) 40 | 41 | require ( 42 | github.com/beorn7/perks v1.0.1 // indirect 43 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 44 | github.com/gobwas/ws v1.1.0 45 | github.com/golang/protobuf v1.5.2 // indirect 46 | github.com/hashicorp/errwrap v1.0.0 // indirect 47 | github.com/hashicorp/go-multierror v1.1.0 // indirect 48 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 49 | github.com/jackc/pgio v1.0.0 // indirect 50 | github.com/jackc/pgpassfile v1.0.0 // indirect 51 | github.com/jackc/pgproto3/v2 v2.2.0 // indirect 52 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect 53 | github.com/jackc/pgtype v1.9.1 // indirect 54 | github.com/jackc/puddle v1.2.0 // indirect 55 | github.com/lib/pq v1.10.2 // indirect 56 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 57 | github.com/prometheus/client_model v0.2.0 // indirect 58 | github.com/prometheus/common v0.26.0 // indirect 59 | github.com/prometheus/procfs v0.6.0 // indirect 60 | github.com/spf13/viper v1.10.1 61 | golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect 62 | golang.org/x/text v0.3.7 // indirect 63 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 64 | google.golang.org/protobuf v1.27.1 // indirect 65 | gopkg.in/yaml.v2 v2.4.0 // indirect 66 | ) 67 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | // The api package packages handles configuring routing for http and websocket requests into the 2 | // server. It validates those requests and sends those to the core through the provided ports. 3 | package api 4 | 5 | import ( 6 | "net/http" 7 | 8 | "github.com/go-chi/chi" 9 | "github.com/go-chi/chi/middleware" 10 | "github.com/go-chi/cors" 11 | "github.com/go-chi/render" 12 | "github.com/prometheus/client_golang/prometheus/promhttp" 13 | "github.com/rs/zerolog/log" 14 | "github.com/sksmith/go-micro-example/config" 15 | ) 16 | 17 | const ( 18 | HealthEndpoint = "/health" 19 | EnvEndpoint = "/env" 20 | MetricsEndpoint = "/metrics" 21 | 22 | ApiPath = "/api/v1" 23 | InventoryPath = "/inventory" 24 | ReservationPath = "/reservation" 25 | UserPath = "/user" 26 | ) 27 | 28 | // ConfigureRouter instantiates a go-chi router with middleware and routes for the server 29 | func ConfigureRouter(cfg *config.Config, invSvc InventoryService, resSvc ReservationService, userService UserService) chi.Router { 30 | log.Info().Msg("configuring router...") 31 | r := chi.NewRouter() 32 | 33 | r.Use(cors.Handler(cors.Options{ 34 | AllowedOrigins: []string{"https://*.seanksmith.me", "http://*.seanksmith.me", "http://localhost:*", "https://localhost:*"}, 35 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 36 | AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, 37 | ExposedHeaders: []string{"Link"}, 38 | AllowCredentials: true, 39 | MaxAge: 300, // Maximum value not ignored by any of major browsers 40 | })) 41 | r.Use(middleware.RequestID) 42 | r.Use(middleware.Recoverer) 43 | r.Use(Metrics) 44 | r.Use(render.SetContentType(render.ContentTypeJSON)) 45 | r.Use(Logging) 46 | 47 | r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 48 | w.WriteHeader(http.StatusOK) 49 | _, _ = w.Write([]byte("UP")) 50 | }) 51 | r.Handle("/metrics", promhttp.Handler()) 52 | r.Route("/env", NewEnvApi(cfg).ConfigureRouter) 53 | 54 | // TODO Enable authentication, how to handle this for websockets? 55 | // r.With(Authenticate(userService)).Route("/api/v1", func(r chi.Router) { 56 | 57 | r.Route(ApiPath, func(r chi.Router) { 58 | r.Route(InventoryPath, NewInventoryApi(invSvc).ConfigureRouter) 59 | r.Route(ReservationPath, NewReservationApi(resSvc).ConfigureRouter) 60 | r.Route(UserPath, NewUserApi(userService).ConfigureRouter) 61 | }) 62 | 63 | return r 64 | } 65 | 66 | func Render(w http.ResponseWriter, r *http.Request, rnd render.Renderer) { 67 | if err := render.Render(w, r, rnd); err != nil { 68 | log.Warn().Err(err).Msg("failed to render") 69 | } 70 | } 71 | 72 | func RenderList(w http.ResponseWriter, r *http.Request, l []render.Renderer) { 73 | if err := render.RenderList(w, r, l); err != nil { 74 | log.Warn().Err(err).Msg("failed to render") 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /db/usrrepo/repo.go: -------------------------------------------------------------------------------- 1 | package usrrepo 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jackc/pgx/v4" 7 | "github.com/pkg/errors" 8 | "github.com/rs/zerolog/log" 9 | "github.com/sksmith/go-micro-example/core" 10 | "github.com/sksmith/go-micro-example/core/user" 11 | "github.com/sksmith/go-micro-example/db" 12 | 13 | lru "github.com/hashicorp/golang-lru" 14 | ) 15 | 16 | type dbRepo struct { 17 | conn core.Conn 18 | c *lru.Cache 19 | } 20 | 21 | func NewPostgresRepo(conn core.Conn) user.Repository { 22 | log.Info().Msg("creating user repository...") 23 | 24 | l, err := lru.New(256) 25 | if err != nil { 26 | log.Warn().Err(err).Msg("unable to configure cache") 27 | } 28 | return &dbRepo{ 29 | conn: conn, 30 | c: l, 31 | } 32 | } 33 | 34 | func (r *dbRepo) Create(ctx context.Context, user *user.User, txs ...core.UpdateOptions) error { 35 | m := db.StartMetric("Create") 36 | tx := db.GetUpdateOptions(r.conn, txs...) 37 | 38 | _, err := tx.Exec(ctx, ` 39 | INSERT INTO users (username, password, is_admin, created_at) 40 | VALUES ($1, $2, $3, $4);`, 41 | user.Username, user.HashedPassword, user.IsAdmin, user.Created) 42 | if err != nil { 43 | m.Complete(err) 44 | return err 45 | } 46 | r.cache(*user) 47 | m.Complete(nil) 48 | return nil 49 | } 50 | 51 | func (r *dbRepo) Get(ctx context.Context, username string, txs ...core.QueryOptions) (user.User, error) { 52 | m := db.StartMetric("GetUser") 53 | tx, forUpdate := db.GetQueryOptions(r.conn, txs...) 54 | 55 | u, ok := r.getcache(username) 56 | if ok { 57 | return u, nil 58 | } 59 | 60 | query := `SELECT username, password, is_admin, created_at FROM users WHERE username = $1 ` + forUpdate 61 | 62 | log.Debug().Str("query", query).Str("username", username).Msg("getting user") 63 | 64 | err := tx.QueryRow(ctx, query, username). 65 | Scan(&u.Username, &u.HashedPassword, &u.IsAdmin, &u.Created) 66 | if err != nil { 67 | m.Complete(err) 68 | if err == pgx.ErrNoRows { 69 | return user.User{}, errors.WithStack(core.ErrNotFound) 70 | } 71 | return user.User{}, errors.WithStack(err) 72 | } 73 | 74 | r.cache(u) 75 | m.Complete(nil) 76 | return u, nil 77 | } 78 | 79 | func (r *dbRepo) Delete(ctx context.Context, username string, txs ...core.UpdateOptions) error { 80 | m := db.StartMetric("DeleteUser") 81 | tx := db.GetUpdateOptions(r.conn, txs...) 82 | 83 | _, err := tx.Exec(ctx, `DELETE FROM users WHERE username = $1`, username) 84 | 85 | if err != nil { 86 | m.Complete(err) 87 | if err == pgx.ErrNoRows { 88 | return errors.WithStack(core.ErrNotFound) 89 | } 90 | return errors.WithStack(err) 91 | } 92 | 93 | r.uncache(username) 94 | m.Complete(nil) 95 | return nil 96 | } 97 | 98 | func (r *dbRepo) cache(u user.User) { 99 | if r.c == nil { 100 | return 101 | } 102 | r.c.Add(u.Username, u) 103 | } 104 | 105 | func (r *dbRepo) uncache(username string) { 106 | if r.c == nil { 107 | return 108 | } 109 | r.c.Remove(username) 110 | } 111 | 112 | func (r *dbRepo) getcache(username string) (user.User, bool) { 113 | if r.c == nil { 114 | return user.User{}, false 115 | } 116 | 117 | v, ok := r.c.Get(username) 118 | if !ok { 119 | return user.User{}, false 120 | } 121 | u, ok := v.(user.User) 122 | return u, ok 123 | } 124 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/jackc/pgx/v4/pgxpool" 11 | "github.com/rs/zerolog" 12 | "github.com/rs/zerolog/log" 13 | "github.com/rs/zerolog/pkgerrors" 14 | "github.com/sksmith/go-micro-example/api" 15 | "github.com/sksmith/go-micro-example/config" 16 | "github.com/sksmith/go-micro-example/core/inventory" 17 | "github.com/sksmith/go-micro-example/core/user" 18 | "github.com/sksmith/go-micro-example/db" 19 | "github.com/sksmith/go-micro-example/db/invrepo" 20 | "github.com/sksmith/go-micro-example/db/usrrepo" 21 | "github.com/sksmith/go-micro-example/queue" 22 | 23 | "github.com/common-nighthawk/go-figure" 24 | ) 25 | 26 | func main() { 27 | start := time.Now() 28 | ctx := context.Background() 29 | cfg := config.Load("config") 30 | 31 | configLogging(cfg) 32 | printLogHeader(cfg) 33 | cfg.Print() 34 | 35 | dbPool := configDatabase(ctx, cfg) 36 | 37 | iq := queue.NewInventoryQueue(ctx, cfg) 38 | 39 | ir := invrepo.NewPostgresRepo(dbPool) 40 | 41 | invService := inventory.NewService(ir, iq) 42 | 43 | ur := usrrepo.NewPostgresRepo(dbPool) 44 | 45 | userService := user.NewService(ur) 46 | 47 | r := api.ConfigureRouter(cfg, invService, invService, userService) 48 | 49 | _ = queue.NewProductQueue(ctx, cfg, invService) 50 | 51 | log.Info().Str("port", cfg.Port.Value).Int64("startTimeMs", time.Since(start).Milliseconds()).Msg("listening") 52 | log.Fatal().Err(http.ListenAndServe(":"+cfg.Port.Value, r)) 53 | } 54 | 55 | func printLogHeader(cfg *config.Config) { 56 | if cfg.Log.Structured.Value { 57 | log.Info().Str("application", cfg.AppName.Value). 58 | Str("revision", cfg.Revision.Value). 59 | Str("version", cfg.AppVersion.Value). 60 | Str("sha1ver", cfg.Sha1Version.Value). 61 | Str("build-time", cfg.BuildTime.Value). 62 | Str("profile", cfg.Profile.Value). 63 | Str("config-source", cfg.Config.Source.Value). 64 | Str("config-branch", cfg.Config.Spring.Branch.Value). 65 | Send() 66 | } else { 67 | f := figure.NewFigure(cfg.AppName.Value, "", true) 68 | f.Print() 69 | 70 | log.Info().Msg("=============================================") 71 | log.Info().Msg(fmt.Sprintf(" Revision: %s", cfg.Revision.Value)) 72 | log.Info().Msg(fmt.Sprintf(" Profile: %s", cfg.Profile.Value)) 73 | log.Info().Msg(fmt.Sprintf(" Config Server: %s - %s", cfg.Config.Source.Value, cfg.Config.Spring.Branch.Value)) 74 | log.Info().Msg(fmt.Sprintf(" Tag Version: %s", cfg.AppVersion.Value)) 75 | log.Info().Msg(fmt.Sprintf(" Sha1 Version: %s", cfg.Sha1Version.Value)) 76 | log.Info().Msg(fmt.Sprintf(" Build Time: %s", cfg.BuildTime.Value)) 77 | log.Info().Msg("=============================================") 78 | } 79 | } 80 | 81 | func configDatabase(ctx context.Context, cfg *config.Config) *pgxpool.Pool { 82 | dbPool, err := db.ConnectDb(ctx, cfg) 83 | if err != nil { 84 | log.Fatal().Err(err).Msg("failed to connect to db") 85 | } 86 | 87 | return dbPool 88 | } 89 | 90 | func configLogging(cfg *config.Config) { 91 | log.Info().Msg("configuring logging...") 92 | 93 | if !cfg.Log.Structured.Value { 94 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 95 | } 96 | 97 | level, err := zerolog.ParseLevel(cfg.Log.Level.Value) 98 | if err != nil { 99 | log.Warn().Str("loglevel", cfg.Log.Level.Value).Err(err).Msg("defaulting to info") 100 | level = zerolog.InfoLevel 101 | } 102 | log.Info().Str("loglevel", level.String()).Msg("setting log level") 103 | zerolog.SetGlobalLevel(level) 104 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 105 | } 106 | -------------------------------------------------------------------------------- /api/middleware.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/go-chi/chi" 12 | "github.com/go-chi/chi/middleware" 13 | "github.com/prometheus/client_golang/prometheus" 14 | 15 | "github.com/rs/zerolog/log" 16 | "github.com/sksmith/go-micro-example/core" 17 | "github.com/sksmith/go-micro-example/core/user" 18 | ) 19 | 20 | const DefaultPageLimit = 50 21 | 22 | type CtxKey string 23 | 24 | const ( 25 | CtxKeyLimit CtxKey = "limit" 26 | CtxKeyOffset CtxKey = "offset" 27 | CtxKeyUser CtxKey = "user" 28 | ) 29 | 30 | func Paginate(next http.Handler) http.Handler { 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | limitStr := r.URL.Query().Get("limit") 33 | offsetStr := r.URL.Query().Get("offset") 34 | 35 | var err error 36 | limit := DefaultPageLimit 37 | if limitStr != "" { 38 | limit, err = strconv.Atoi(limitStr) 39 | if err != nil { 40 | limit = DefaultPageLimit 41 | } 42 | } 43 | 44 | offset := 0 45 | if offsetStr != "" { 46 | offset, err = strconv.Atoi(offsetStr) 47 | if err != nil { 48 | offset = 0 49 | } 50 | } 51 | 52 | ctx := context.WithValue(r.Context(), CtxKeyLimit, limit) 53 | ctx = context.WithValue(ctx, CtxKeyOffset, offset) 54 | 55 | next.ServeHTTP(w, r.WithContext(ctx)) 56 | }) 57 | } 58 | 59 | type UserAccess interface { 60 | Login(ctx context.Context, username, password string) (user.User, error) 61 | } 62 | 63 | func Authenticate(ua UserAccess) func(http.Handler) http.Handler { 64 | return func(next http.Handler) http.Handler { 65 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 66 | username, password, ok := r.BasicAuth() 67 | 68 | if !ok { 69 | authErr(w) 70 | return 71 | } 72 | 73 | u, err := ua.Login(r.Context(), username, password) 74 | if err != nil { 75 | if errors.Is(err, core.ErrNotFound) { 76 | authErr(w) 77 | } else { 78 | log.Error().Err(err).Str("username", username).Msg("error acquiring user") 79 | Render(w, r, ErrInternalServer) 80 | } 81 | return 82 | } 83 | 84 | ctx := context.WithValue(r.Context(), CtxKeyUser, u) 85 | next.ServeHTTP(w, r.WithContext(ctx)) 86 | }) 87 | } 88 | } 89 | 90 | func AdminOnly(next http.Handler) http.Handler { 91 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 92 | usr, ok := r.Context().Value(CtxKeyUser).(user.User) 93 | 94 | if !ok || !usr.IsAdmin { 95 | authErr(w) 96 | return 97 | } 98 | 99 | next.ServeHTTP(w, r) 100 | }) 101 | } 102 | 103 | func authErr(w http.ResponseWriter) { 104 | w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) 105 | http.Error(w, "Unauthorized", http.StatusUnauthorized) 106 | } 107 | 108 | func Logging(next http.Handler) http.Handler { 109 | fn := func(w http.ResponseWriter, r *http.Request) { 110 | start := time.Now() 111 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 112 | 113 | defer func() { 114 | dur := fmt.Sprintf("%dms", time.Duration(time.Since(start).Milliseconds())) 115 | 116 | log.Trace(). 117 | Str("method", r.Method). 118 | Str("host", r.Host). 119 | Str("uri", r.RequestURI). 120 | Str("proto", r.Proto). 121 | Str("origin", r.Header.Get("Origin")). 122 | Int("status", ww.Status()). 123 | Int("bytes", ww.BytesWritten()). 124 | Str("duration", dur).Send() 125 | }() 126 | next.ServeHTTP(ww, r) 127 | } 128 | 129 | return http.HandlerFunc(fn) 130 | } 131 | 132 | func Metrics(next http.Handler) http.Handler { 133 | urlHitCount := prometheus.NewCounterVec( 134 | prometheus.CounterOpts{ 135 | Name: "url_hit_count", 136 | Help: "Number of times the given url was hit", 137 | }, 138 | []string{"method", "url"}, 139 | ) 140 | urlLatency := prometheus.NewSummaryVec( 141 | prometheus.SummaryOpts{ 142 | Name: "url_latency", 143 | Help: "The latency quantiles for the given URL", 144 | Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, 145 | }, 146 | []string{"method", "url"}, 147 | ) 148 | 149 | prometheus.MustRegister(urlHitCount) 150 | prometheus.MustRegister(urlLatency) 151 | 152 | fn := func(w http.ResponseWriter, r *http.Request) { 153 | start := time.Now() 154 | 155 | defer func() { 156 | ctx := chi.RouteContext(r.Context()) 157 | 158 | if len(ctx.RoutePatterns) > 0 { 159 | dur := float64(time.Since(start).Milliseconds()) 160 | urlLatency.WithLabelValues(ctx.RouteMethod, ctx.RoutePatterns[0]).Observe(dur) 161 | urlHitCount.WithLabelValues(ctx.RouteMethod, ctx.RoutePatterns[0]).Inc() 162 | } 163 | }() 164 | 165 | next.ServeHTTP(w, r) 166 | } 167 | return http.HandlerFunc(fn) 168 | } 169 | -------------------------------------------------------------------------------- /db/mocks.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jackc/pgconn" 7 | "github.com/jackc/pgx/v4" 8 | "github.com/sksmith/go-micro-example/testutil" 9 | ) 10 | 11 | type MockConn struct { 12 | QueryFunc func(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) 13 | QueryRowFunc func(ctx context.Context, sql string, args ...interface{}) pgx.Row 14 | ExecFunc func(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) 15 | BeginFunc func(ctx context.Context) (pgx.Tx, error) 16 | *testutil.CallWatcher 17 | } 18 | 19 | func NewMockConn() MockConn { 20 | return MockConn{ 21 | QueryFunc: func(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) { return nil, nil }, 22 | QueryRowFunc: func(ctx context.Context, sql string, args ...interface{}) pgx.Row { return nil }, 23 | ExecFunc: func(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) { return nil, nil }, 24 | BeginFunc: func(ctx context.Context) (pgx.Tx, error) { return NewMockPgxTx(), nil }, 25 | CallWatcher: testutil.NewCallWatcher(), 26 | } 27 | } 28 | 29 | func (c *MockConn) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) { 30 | c.AddCall(ctx, sql, args) 31 | return c.QueryFunc(ctx, sql, args) 32 | } 33 | 34 | func (c *MockConn) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row { 35 | c.AddCall(ctx, sql, args) 36 | return c.QueryRowFunc(ctx, sql, args) 37 | } 38 | 39 | func (c *MockConn) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) { 40 | c.AddCall(ctx, sql, args) 41 | return c.ExecFunc(ctx, sql, args) 42 | } 43 | 44 | func (c *MockConn) Begin(ctx context.Context) (pgx.Tx, error) { 45 | c.AddCall(ctx) 46 | return c.BeginFunc(ctx) 47 | } 48 | 49 | type MockTransaction struct { 50 | CommitFunc func(ctx context.Context) error 51 | RollbackFunc func(ctx context.Context) error 52 | 53 | MockConn 54 | *testutil.CallWatcher 55 | } 56 | 57 | func NewMockTransaction() *MockTransaction { 58 | return &MockTransaction{ 59 | MockConn: NewMockConn(), 60 | CommitFunc: func(ctx context.Context) error { return nil }, 61 | RollbackFunc: func(ctx context.Context) error { return nil }, 62 | CallWatcher: testutil.NewCallWatcher(), 63 | } 64 | } 65 | 66 | func (t *MockTransaction) Commit(ctx context.Context) error { 67 | t.AddCall(ctx) 68 | return t.CommitFunc(ctx) 69 | } 70 | 71 | func (t *MockTransaction) Rollback(ctx context.Context) error { 72 | t.AddCall(ctx) 73 | return t.RollbackFunc(ctx) 74 | } 75 | 76 | type MockPgxTx struct { 77 | *testutil.CallWatcher 78 | } 79 | 80 | func NewMockPgxTx() *MockPgxTx { 81 | return &MockPgxTx{ 82 | CallWatcher: testutil.NewCallWatcher(), 83 | } 84 | } 85 | 86 | func (m *MockPgxTx) Begin(ctx context.Context) (pgx.Tx, error) { 87 | m.AddCall(ctx) 88 | return nil, nil 89 | } 90 | 91 | func (m *MockPgxTx) BeginFunc(ctx context.Context, f func(pgx.Tx) error) (err error) { 92 | m.AddCall(ctx, f) 93 | return nil 94 | } 95 | 96 | func (m *MockPgxTx) Commit(ctx context.Context) error { 97 | m.AddCall(ctx) 98 | return nil 99 | } 100 | 101 | func (m *MockPgxTx) Rollback(ctx context.Context) error { 102 | m.AddCall(ctx) 103 | return nil 104 | } 105 | 106 | func (m *MockPgxTx) CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) { 107 | m.AddCall(ctx, tableName, columnNames, rowSrc) 108 | return 0, nil 109 | } 110 | 111 | func (m *MockPgxTx) SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults { 112 | m.AddCall(ctx, b) 113 | return nil 114 | } 115 | 116 | func (m *MockPgxTx) LargeObjects() pgx.LargeObjects { 117 | m.AddCall() 118 | return pgx.LargeObjects{} 119 | } 120 | 121 | func (m *MockPgxTx) Prepare(ctx context.Context, name, sql string) (*pgconn.StatementDescription, error) { 122 | m.AddCall(ctx, name, sql) 123 | return nil, nil 124 | } 125 | 126 | func (m *MockPgxTx) Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error) { 127 | m.AddCall(ctx, sql, arguments) 128 | return nil, nil 129 | } 130 | 131 | func (m *MockPgxTx) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) { 132 | m.AddCall(ctx, sql, args) 133 | return nil, nil 134 | } 135 | 136 | func (m *MockPgxTx) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row { 137 | m.AddCall(ctx, sql, args) 138 | return nil 139 | } 140 | 141 | func (m *MockPgxTx) QueryFunc(ctx context.Context, sql string, args []interface{}, scans []interface{}, f func(pgx.QueryFuncRow) error) (pgconn.CommandTag, error) { 142 | m.AddCall(ctx, sql, args, scans, f) 143 | return nil, nil 144 | } 145 | 146 | func (m *MockPgxTx) Conn() *pgx.Conn { 147 | m.AddCall() 148 | return nil 149 | } 150 | -------------------------------------------------------------------------------- /cmd/integration_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/rs/zerolog" 11 | "github.com/rs/zerolog/log" 12 | "github.com/rs/zerolog/pkgerrors" 13 | "github.com/sksmith/go-micro-example/api" 14 | "github.com/sksmith/go-micro-example/config" 15 | "github.com/sksmith/go-micro-example/core/inventory" 16 | "github.com/sksmith/go-micro-example/core/user" 17 | "github.com/sksmith/go-micro-example/db" 18 | "github.com/sksmith/go-micro-example/db/invrepo" 19 | "github.com/sksmith/go-micro-example/db/usrrepo" 20 | "github.com/sksmith/go-micro-example/queue" 21 | "github.com/sksmith/go-micro-example/testutil" 22 | ) 23 | 24 | var cfg *config.Config 25 | 26 | func TestMain(m *testing.M) { 27 | 28 | log.Info().Msg("configuring logging...") 29 | 30 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 31 | 32 | ctx := context.Background() 33 | cfg = config.Load("config_test") 34 | 35 | level, err := zerolog.ParseLevel(cfg.Log.Level.Value) 36 | if err != nil { 37 | log.Fatal().Err(err) 38 | } 39 | zerolog.SetGlobalLevel(level) 40 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 41 | 42 | cfg.Print() 43 | 44 | dbPool, err := db.ConnectDb(ctx, cfg) 45 | if err != nil { 46 | log.Fatal().Err(err).Msg("failed to connect to db") 47 | } 48 | 49 | iq := queue.NewInventoryQueue(ctx, cfg) 50 | 51 | ir := invrepo.NewPostgresRepo(dbPool) 52 | 53 | invService := inventory.NewService(ir, iq) 54 | 55 | ur := usrrepo.NewPostgresRepo(dbPool) 56 | 57 | userService := user.NewService(ur) 58 | 59 | r := api.ConfigureRouter(cfg, invService, invService, userService) 60 | 61 | _ = queue.NewProductQueue(ctx, cfg, invService) 62 | 63 | go func() { 64 | log.Fatal().Err(http.ListenAndServe(":"+cfg.Port.Value, r)) 65 | }() 66 | 67 | waitForReady() 68 | os.Exit(m.Run()) 69 | } 70 | 71 | func waitForReady() { 72 | for { 73 | res, err := http.Get(host() + "/health") 74 | if err == nil && res.StatusCode == 200 { 75 | break 76 | } 77 | log.Info().Msg("application not ready, sleeping") 78 | time.Sleep(1 * time.Second) 79 | } 80 | } 81 | 82 | func TestCreateProduct(t *testing.T) { 83 | cases := []struct { 84 | name string 85 | product inventory.Product 86 | 87 | wantSku string 88 | wantStatusCode int 89 | }{ 90 | { 91 | name: "valid request", 92 | product: inventory.Product{Sku: "somesku", Upc: "someupc", Name: "somename"}, 93 | wantSku: "somesku", 94 | wantStatusCode: 201, 95 | }, 96 | { 97 | name: "valid request with a long name", 98 | product: inventory.Product{Sku: "someskuwithareallylongname", Upc: "longskuupc", Name: "somename"}, 99 | wantSku: "someskuwithareallylongname", 100 | wantStatusCode: 201, 101 | }, 102 | { 103 | name: "missing sku", 104 | product: inventory.Product{Sku: "", Upc: "skurequiredupc", Name: "skurequiredname"}, 105 | wantStatusCode: 400, 106 | }, 107 | { 108 | name: "missing upc", 109 | product: inventory.Product{Sku: "upcreqsku", Upc: "", Name: "upcreqname"}, 110 | wantStatusCode: 400, 111 | }, 112 | { 113 | name: "missing name", 114 | product: inventory.Product{Sku: "namereqsku", Upc: "namerequpc", Name: ""}, 115 | wantStatusCode: 400, 116 | }, 117 | } 118 | 119 | for _, test := range cases { 120 | t.Run(test.name, func(t *testing.T) { 121 | request := api.CreateProductRequest{Product: test.product} 122 | res := testutil.Put(host()+"/api/v1/inventory", request, t) 123 | 124 | if res.StatusCode != test.wantStatusCode { 125 | t.Errorf("unexpected status got=%d want=%d", res.StatusCode, test.wantStatusCode) 126 | } 127 | 128 | body := &api.ProductResponse{} 129 | testutil.Unmarshal(res, body, t) 130 | if test.wantSku != "" && body.Sku != test.wantSku { 131 | t.Errorf("unexpected response sku got=%s want=%s", body.Sku, test.wantSku) 132 | } 133 | }) 134 | } 135 | } 136 | 137 | func TestList(t *testing.T) { 138 | cases := []struct { 139 | name string 140 | url string 141 | 142 | wantMinRespLen int 143 | wantStatusCode int 144 | }{ 145 | { 146 | name: "valid request", 147 | url: "/api/v1/inventory", 148 | wantMinRespLen: 2, 149 | wantStatusCode: 200, 150 | }, 151 | } 152 | 153 | for _, test := range cases { 154 | t.Run(test.name, func(t *testing.T) { 155 | res, err := http.Get(host() + test.url) 156 | 157 | if err != nil { 158 | t.Errorf("unexpected error got=%s", err) 159 | } 160 | if res.StatusCode != test.wantStatusCode { 161 | t.Errorf("unexpected status got=%d want=%d", res.StatusCode, test.wantStatusCode) 162 | } 163 | 164 | body := []inventory.ProductInventory{} 165 | testutil.Unmarshal(res, &body, t) 166 | if len(body) < test.wantMinRespLen { 167 | t.Errorf("unexpected response len got=%d want=%d", len(body), test.wantMinRespLen) 168 | } 169 | }) 170 | } 171 | } 172 | 173 | func host() string { 174 | return "http://localhost:" + cfg.Port.Value 175 | } 176 | -------------------------------------------------------------------------------- /core/inventory/mocks.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sksmith/go-micro-example/testutil" 7 | ) 8 | 9 | type MockInventoryService struct { 10 | ProduceFunc func(ctx context.Context, product Product, event ProductionRequest) error 11 | CreateProductFunc func(ctx context.Context, product Product) error 12 | GetProductFunc func(ctx context.Context, sku string) (Product, error) 13 | GetAllProductInventoryFunc func(ctx context.Context, limit, offset int) ([]ProductInventory, error) 14 | GetProductInventoryFunc func(ctx context.Context, sku string) (ProductInventory, error) 15 | SubscribeInventoryFunc func(ch chan<- ProductInventory) (id InventorySubID) 16 | UnsubscribeInventoryFunc func(id InventorySubID) 17 | *testutil.CallWatcher 18 | } 19 | 20 | func NewMockInventoryService() *MockInventoryService { 21 | return &MockInventoryService{ 22 | ProduceFunc: func(ctx context.Context, product Product, event ProductionRequest) error { return nil }, 23 | CreateProductFunc: func(ctx context.Context, product Product) error { return nil }, 24 | GetProductFunc: func(ctx context.Context, sku string) (Product, error) { return Product{}, nil }, 25 | GetAllProductInventoryFunc: func(ctx context.Context, limit, offset int) ([]ProductInventory, error) { 26 | return []ProductInventory{}, nil 27 | }, 28 | GetProductInventoryFunc: func(ctx context.Context, sku string) (ProductInventory, error) { return ProductInventory{}, nil }, 29 | SubscribeInventoryFunc: func(ch chan<- ProductInventory) (id InventorySubID) { return "" }, 30 | UnsubscribeInventoryFunc: func(id InventorySubID) {}, 31 | CallWatcher: testutil.NewCallWatcher(), 32 | } 33 | } 34 | 35 | func (i *MockInventoryService) Produce(ctx context.Context, product Product, event ProductionRequest) error { 36 | i.AddCall(ctx, product, event) 37 | return i.ProduceFunc(ctx, product, event) 38 | } 39 | 40 | func (i *MockInventoryService) CreateProduct(ctx context.Context, product Product) error { 41 | i.AddCall(ctx, product) 42 | return i.CreateProductFunc(ctx, product) 43 | } 44 | 45 | func (i *MockInventoryService) GetProduct(ctx context.Context, sku string) (Product, error) { 46 | i.AddCall(ctx, sku) 47 | return i.GetProductFunc(ctx, sku) 48 | } 49 | 50 | func (i *MockInventoryService) GetAllProductInventory(ctx context.Context, limit, offset int) ([]ProductInventory, error) { 51 | i.AddCall(ctx, limit, offset) 52 | return i.GetAllProductInventoryFunc(ctx, limit, offset) 53 | } 54 | 55 | func (i *MockInventoryService) GetProductInventory(ctx context.Context, sku string) (ProductInventory, error) { 56 | i.AddCall(ctx, sku) 57 | return i.GetProductInventoryFunc(ctx, sku) 58 | } 59 | 60 | func (i *MockInventoryService) SubscribeInventory(ch chan<- ProductInventory) (id InventorySubID) { 61 | i.AddCall(ch) 62 | return i.SubscribeInventoryFunc(ch) 63 | } 64 | 65 | func (i *MockInventoryService) UnsubscribeInventory(id InventorySubID) { 66 | i.AddCall(id) 67 | i.UnsubscribeInventoryFunc(id) 68 | } 69 | 70 | type MockReservationService struct { 71 | ReserveFunc func(ctx context.Context, rr ReservationRequest) (Reservation, error) 72 | 73 | GetReservationsFunc func(ctx context.Context, options GetReservationsOptions, limit, offset int) ([]Reservation, error) 74 | GetReservationFunc func(ctx context.Context, ID uint64) (Reservation, error) 75 | 76 | SubscribeReservationsFunc func(ch chan<- Reservation) (id ReservationsSubID) 77 | UnsubscribeReservationsFunc func(id ReservationsSubID) 78 | *testutil.CallWatcher 79 | } 80 | 81 | func NewMockReservationService() *MockReservationService { 82 | return &MockReservationService{ 83 | ReserveFunc: func(ctx context.Context, rr ReservationRequest) (Reservation, error) { return Reservation{}, nil }, 84 | GetReservationsFunc: func(ctx context.Context, options GetReservationsOptions, limit, offset int) ([]Reservation, error) { 85 | return []Reservation{}, nil 86 | }, 87 | GetReservationFunc: func(ctx context.Context, ID uint64) (Reservation, error) { return Reservation{}, nil }, 88 | SubscribeReservationsFunc: func(ch chan<- Reservation) (id ReservationsSubID) { return "" }, 89 | UnsubscribeReservationsFunc: func(id ReservationsSubID) {}, 90 | CallWatcher: testutil.NewCallWatcher(), 91 | } 92 | } 93 | 94 | func (r *MockReservationService) Reserve(ctx context.Context, rr ReservationRequest) (Reservation, error) { 95 | r.CallWatcher.AddCall(ctx, rr) 96 | return r.ReserveFunc(ctx, rr) 97 | } 98 | 99 | func (r *MockReservationService) GetReservations(ctx context.Context, options GetReservationsOptions, limit, offset int) ([]Reservation, error) { 100 | r.CallWatcher.AddCall(ctx, options, limit, offset) 101 | return r.GetReservationsFunc(ctx, options, limit, offset) 102 | } 103 | 104 | func (r *MockReservationService) GetReservation(ctx context.Context, ID uint64) (Reservation, error) { 105 | r.CallWatcher.AddCall(ctx, ID) 106 | return r.GetReservationFunc(ctx, ID) 107 | } 108 | 109 | func (r *MockReservationService) SubscribeReservations(ch chan<- Reservation) (id ReservationsSubID) { 110 | r.CallWatcher.AddCall(ch) 111 | return r.SubscribeReservationsFunc(ch) 112 | } 113 | 114 | func (r *MockReservationService) UnsubscribeReservations(id ReservationsSubID) { 115 | r.CallWatcher.AddCall(id) 116 | r.UnsubscribeReservationsFunc(id) 117 | } 118 | -------------------------------------------------------------------------------- /api/userapi_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/go-chi/chi" 11 | "github.com/sksmith/go-micro-example/api" 12 | "github.com/sksmith/go-micro-example/core" 13 | "github.com/sksmith/go-micro-example/core/user" 14 | "github.com/sksmith/go-micro-example/testutil" 15 | ) 16 | 17 | func TestUserCreate(t *testing.T) { 18 | ts, mockSvc := setupUserTestServer() 19 | defer ts.Close() 20 | 21 | tests := []struct { 22 | name string 23 | loginFunc func(ctx context.Context, username, password string) (user.User, error) 24 | createFunc func(ctx context.Context, user user.CreateUserRequest) (user.User, error) 25 | url string 26 | request interface{} 27 | wantResponse interface{} 28 | wantStatusCode int 29 | }{ 30 | { 31 | name: "admin users can create valid user", 32 | loginFunc: func(ctx context.Context, username, password string) (user.User, error) { 33 | return createUser("someadmin", "", true), nil 34 | }, 35 | createFunc: func(ctx context.Context, usr user.CreateUserRequest) (user.User, error) { 36 | return createUser(usr.Username, "somepasswordhash", usr.IsAdmin), nil 37 | }, 38 | url: ts.URL, 39 | request: createUserReq("someuser", "somepass", false), 40 | wantResponse: nil, 41 | wantStatusCode: http.StatusOK, 42 | }, 43 | { 44 | name: "non-admin users are unable to create users", 45 | loginFunc: func(ctx context.Context, username, password string) (user.User, error) { 46 | return createUser("someadmin", "", false), nil 47 | }, 48 | createFunc: func(ctx context.Context, usr user.CreateUserRequest) (user.User, error) { 49 | return createUser(usr.Username, "somepasswordhash", usr.IsAdmin), nil 50 | }, 51 | url: ts.URL, 52 | request: createUserReq("someuser", "somepass", false), 53 | wantResponse: nil, 54 | wantStatusCode: http.StatusUnauthorized, 55 | }, 56 | { 57 | name: "when the creating user is not found, server returns unauthorized", 58 | loginFunc: func(ctx context.Context, username, password string) (user.User, error) { 59 | return user.User{}, core.ErrNotFound 60 | }, 61 | createFunc: func(ctx context.Context, usr user.CreateUserRequest) (user.User, error) { 62 | return createUser(usr.Username, "somepasswordhash", usr.IsAdmin), nil 63 | }, 64 | url: ts.URL, 65 | request: createUserReq("someuser", "somepass", false), 66 | wantResponse: nil, 67 | wantStatusCode: http.StatusUnauthorized, 68 | }, 69 | { 70 | name: "when an unexpected error occurs logging in, an internal server error is returned", 71 | loginFunc: func(ctx context.Context, username, password string) (user.User, error) { 72 | return user.User{}, errors.New("some unexpected error") 73 | }, 74 | createFunc: nil, 75 | url: ts.URL, 76 | request: createUserReq("someuser", "somepass", false), 77 | wantResponse: api.ErrInternalServer, 78 | wantStatusCode: http.StatusInternalServerError, 79 | }, 80 | { 81 | name: "when an error occurs creating the user, an internal server error is returned", 82 | loginFunc: func(ctx context.Context, username, password string) (user.User, error) { 83 | return createUser("someadmin", "", true), nil 84 | }, 85 | createFunc: func(ctx context.Context, usr user.CreateUserRequest) (user.User, error) { 86 | return user.User{}, errors.New("some unexpected error") 87 | }, 88 | url: ts.URL, 89 | request: createUserReq("someuser", "somepass", false), 90 | wantResponse: api.ErrInternalServer, 91 | wantStatusCode: http.StatusInternalServerError, 92 | }, 93 | } 94 | 95 | for _, test := range tests { 96 | t.Run(test.name, func(t *testing.T) { 97 | mockSvc.LoginFunc = test.loginFunc 98 | mockSvc.CreateFunc = test.createFunc 99 | 100 | res := testutil.Post(test.url, test.request, t, testutil.RequestOptions{Username: "someuser", Password: "somepass"}) 101 | 102 | if res.StatusCode != test.wantStatusCode { 103 | t.Errorf("status code got=%d want=%d", res.StatusCode, test.wantStatusCode) 104 | } 105 | 106 | if test.wantStatusCode == http.StatusBadRequest || 107 | test.wantStatusCode == http.StatusInternalServerError || 108 | test.wantStatusCode == http.StatusNotFound { 109 | 110 | want := test.wantResponse.(*api.ErrResponse) 111 | got := &api.ErrResponse{} 112 | testutil.Unmarshal(res, got, t) 113 | 114 | if got.StatusText != want.StatusText { 115 | t.Errorf("status text got=%s want=%s", got.StatusText, want.StatusText) 116 | } 117 | if got.ErrorText != want.ErrorText { 118 | t.Errorf("error text got=%s want=%s", got.ErrorText, want.ErrorText) 119 | } 120 | } 121 | }) 122 | } 123 | } 124 | 125 | func createUser(username, password string, isAdmin bool) user.User { 126 | return user.User{Username: username, HashedPassword: password, IsAdmin: isAdmin} 127 | } 128 | 129 | func createUserReq(username, password string, isAdmin bool) api.CreateUserRequestDto { 130 | return api.CreateUserRequestDto{CreateUserRequest: &user.CreateUserRequest{Username: username, IsAdmin: isAdmin}, Password: password} 131 | } 132 | 133 | func setupUserTestServer() (*httptest.Server, *user.MockUserService) { 134 | svc := user.NewMockUserService() 135 | usrApi := api.NewUserApi(svc) 136 | r := chi.NewRouter() 137 | r.With(api.Authenticate(svc)).Route("/", func(r chi.Router) { 138 | usrApi.ConfigureRouter(r) 139 | }) 140 | ts := httptest.NewServer(r) 141 | 142 | return ts, svc 143 | } 144 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/golang-migrate/migrate/v4" 9 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 10 | _ "github.com/golang-migrate/migrate/v4/source/file" 11 | "github.com/jackc/pgx/v4" 12 | "github.com/jackc/pgx/v4/pgxpool" 13 | "github.com/rs/zerolog" 14 | "github.com/rs/zerolog/log" 15 | "github.com/sksmith/go-micro-example/config" 16 | "github.com/sksmith/go-micro-example/core" 17 | ) 18 | 19 | type dbconfig struct { 20 | timeZone string 21 | sslMode string 22 | poolMaxConns int32 23 | poolMinConns int32 24 | poolMaxConnLifetime time.Duration 25 | poolMaxConnIdleTime time.Duration 26 | poolHealthCheckPeriod time.Duration 27 | } 28 | 29 | type configOption func(cn *dbconfig) 30 | 31 | func MinPoolConns(minConns int32) func(cn *dbconfig) { 32 | return func(c *dbconfig) { 33 | c.poolMinConns = minConns 34 | } 35 | } 36 | 37 | func MaxPoolConns(maxConns int32) func(cn *dbconfig) { 38 | return func(c *dbconfig) { 39 | c.poolMaxConns = maxConns 40 | } 41 | } 42 | 43 | func newDbConfig() dbconfig { 44 | return dbconfig{ 45 | sslMode: "disable", 46 | timeZone: "UTC", 47 | poolMaxConns: 4, 48 | poolMinConns: 0, 49 | poolMaxConnLifetime: time.Hour, 50 | poolMaxConnIdleTime: time.Minute * 30, 51 | poolHealthCheckPeriod: time.Minute, 52 | } 53 | } 54 | 55 | func formatOption(url, option string, value interface{}) string { 56 | return url + " " + option + "=" + fmt.Sprintf("%v", value) 57 | } 58 | 59 | func addOptionsToConnStr(connStr string, options ...configOption) string { 60 | config := newDbConfig() 61 | for _, option := range options { 62 | option(&config) 63 | } 64 | 65 | connStr = formatOption(connStr, "sslmode", config.sslMode) 66 | connStr = formatOption(connStr, "TimeZone", config.timeZone) 67 | connStr = formatOption(connStr, "pool_max_conns", config.poolMaxConns) 68 | connStr = formatOption(connStr, "pool_min_conns", config.poolMinConns) 69 | connStr = formatOption(connStr, "pool_max_conn_lifetime", config.poolMaxConnLifetime) 70 | connStr = formatOption(connStr, "pool_max_conn_idle_time", config.poolMaxConnIdleTime) 71 | connStr = formatOption(connStr, "pool_health_check_period", config.poolHealthCheckPeriod) 72 | 73 | return connStr 74 | } 75 | 76 | func ConnectDb(ctx context.Context, cfg *config.Config) (*pgxpool.Pool, error) { 77 | 78 | log.Info().Str("host", cfg.Db.Host.Value).Str("name", cfg.Db.Name.Value).Msg("connecting to the database...") 79 | var err error 80 | 81 | if cfg.Db.Migrate.Value { 82 | log.Info().Msg("executing migrations") 83 | 84 | if err = RunMigrations(cfg); err != nil { 85 | log.Warn().Err(err).Msg("error executing migrations") 86 | } 87 | } 88 | 89 | connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s", 90 | cfg.Db.Host.Value, cfg.Db.Port.Value, cfg.Db.User.Value, cfg.Db.Pass.Value, cfg.Db.Name.Value) 91 | 92 | var pool *pgxpool.Pool 93 | 94 | url := addOptionsToConnStr(connStr, MinPoolConns(int32(cfg.Db.Pool.MinSize.Value)), MaxPoolConns(int32(cfg.Db.Pool.MaxSize.Value))) 95 | poolConfig, err := pgxpool.ParseConfig(url) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | level, err := pgx.LogLevelFromString(cfg.Db.LogLevel.Value) 101 | if err != nil { 102 | return nil, err 103 | } 104 | poolConfig.ConnConfig.Logger = logger{level: level} 105 | 106 | for { 107 | pool, err = pgxpool.ConnectConfig(ctx, poolConfig) 108 | if err != nil { 109 | log.Error().Err(err).Msg("failed to create connection pool... retrying") 110 | time.Sleep(1 * time.Second) 111 | continue 112 | } 113 | break 114 | } 115 | 116 | return pool, nil 117 | } 118 | 119 | type logger struct { 120 | level pgx.LogLevel 121 | } 122 | 123 | func (l logger) Log(ctx context.Context, level pgx.LogLevel, msg string, data map[string]interface{}) { 124 | if l.level < level { 125 | return 126 | } 127 | var evt *zerolog.Event 128 | switch level { 129 | case pgx.LogLevelTrace: 130 | evt = log.Trace() 131 | case pgx.LogLevelDebug: 132 | evt = log.Debug() 133 | case pgx.LogLevelInfo: 134 | evt = log.Info() 135 | case pgx.LogLevelWarn: 136 | evt = log.Warn() 137 | case pgx.LogLevelError: 138 | evt = log.Error() 139 | case pgx.LogLevelNone: 140 | evt = log.Info() 141 | default: 142 | evt = log.Info() 143 | } 144 | 145 | for k, v := range data { 146 | evt.Interface(k, v) 147 | } 148 | 149 | evt.Msg(msg) 150 | } 151 | 152 | func RunMigrations(cfg *config.Config) error { 153 | connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", 154 | cfg.Db.User.Value, 155 | cfg.Db.Pass.Value, 156 | cfg.Db.Host.Value, 157 | cfg.Db.Port.Value, 158 | cfg.Db.Name.Value) 159 | 160 | m, err := migrate.New("file:"+cfg.Db.MigrationFolder.Value, connStr) 161 | if err != nil { 162 | return err 163 | } 164 | if cfg.Db.Clean.Value { 165 | if err := m.Down(); err != nil { 166 | if err != migrate.ErrNoChange { 167 | return err 168 | } 169 | } 170 | } 171 | if err := m.Up(); err != nil { 172 | if err != migrate.ErrNoChange { 173 | return err 174 | } 175 | log.Info().Msg("schema is up to date") 176 | } 177 | 178 | return nil 179 | } 180 | 181 | func GetQueryOptions(cn core.Conn, options ...core.QueryOptions) (conn core.Conn, forUpdate string) { 182 | conn = cn 183 | forUpdate = "" 184 | if len(options) > 0 { 185 | conn = options[0].Tx 186 | 187 | if options[0].ForUpdate { 188 | forUpdate = "FOR UPDATE" 189 | } 190 | } 191 | 192 | return conn, forUpdate 193 | } 194 | 195 | func GetUpdateOptions(cn core.Conn, options ...core.UpdateOptions) (conn core.Conn) { 196 | conn = cn 197 | if len(options) > 0 { 198 | conn = options[0].Tx 199 | } 200 | 201 | return conn 202 | } 203 | -------------------------------------------------------------------------------- /api/reservationapi.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/go-chi/chi" 11 | "github.com/go-chi/render" 12 | "github.com/gobwas/ws" 13 | "github.com/gobwas/ws/wsutil" 14 | "github.com/rs/zerolog/log" 15 | "github.com/sksmith/go-micro-example/core" 16 | "github.com/sksmith/go-micro-example/core/inventory" 17 | ) 18 | 19 | type ReservationService interface { 20 | Reserve(ctx context.Context, rr inventory.ReservationRequest) (inventory.Reservation, error) 21 | 22 | GetReservations(ctx context.Context, options inventory.GetReservationsOptions, limit, offset int) ([]inventory.Reservation, error) 23 | GetReservation(ctx context.Context, ID uint64) (inventory.Reservation, error) 24 | 25 | SubscribeReservations(ch chan<- inventory.Reservation) (id inventory.ReservationsSubID) 26 | UnsubscribeReservations(id inventory.ReservationsSubID) 27 | } 28 | 29 | type ReservationApi struct { 30 | service ReservationService 31 | } 32 | 33 | func NewReservationApi(service ReservationService) *ReservationApi { 34 | return &ReservationApi{service: service} 35 | } 36 | 37 | const ( 38 | CtxKeyReservation CtxKey = "reservation" 39 | ) 40 | 41 | func (ra *ReservationApi) ConfigureRouter(r chi.Router) { 42 | r.HandleFunc("/subscribe", ra.Subscribe) 43 | 44 | r.Route("/", func(r chi.Router) { 45 | r.With(Paginate).Get("/", ra.List) 46 | r.Put("/", ra.Create) 47 | 48 | r.Route("/{ID}", func(r chi.Router) { 49 | r.Use(ra.ReservationCtx) 50 | r.Get("/", ra.Get) 51 | r.Delete("/", ra.Cancel) 52 | }) 53 | }) 54 | } 55 | 56 | func (a *ReservationApi) Subscribe(w http.ResponseWriter, r *http.Request) { 57 | log.Info().Msg("client requesting subscription") 58 | 59 | conn, _, _, err := ws.UpgradeHTTP(r, w) 60 | if err != nil { 61 | log.Err(err).Msg("failed to establish inventory subscription connection") 62 | Render(w, r, ErrInternalServer) 63 | } 64 | go func() { 65 | defer conn.Close() 66 | 67 | ch := make(chan inventory.Reservation, 1) 68 | 69 | id := a.service.SubscribeReservations(ch) 70 | defer func() { 71 | a.service.UnsubscribeReservations(id) 72 | }() 73 | 74 | for res := range ch { 75 | resp := &ReservationResponse{Reservation: res} 76 | 77 | body, err := json.Marshal(resp) 78 | if err != nil { 79 | log.Err(err).Interface("clientId", id).Msg("failed to marshal product response") 80 | continue 81 | } 82 | 83 | log.Debug().Interface("clientId", id).Interface("reservationResponse", resp).Msg("sending reservation update to client") 84 | err = wsutil.WriteServerText(conn, body) 85 | if err != nil { 86 | log.Err(err).Interface("clientId", id).Msg("failed to write server message, disconnecting client") 87 | return 88 | } 89 | } 90 | }() 91 | } 92 | 93 | func (a *ReservationApi) Get(w http.ResponseWriter, r *http.Request) { 94 | res := r.Context().Value(CtxKeyReservation).(inventory.Reservation) 95 | 96 | resp := &ReservationResponse{Reservation: res} 97 | render.Status(r, http.StatusOK) 98 | Render(w, r, resp) 99 | } 100 | 101 | func (a *ReservationApi) Create(w http.ResponseWriter, r *http.Request) { 102 | data := &ReservationRequest{} 103 | if err := render.Bind(r, data); err != nil { 104 | Render(w, r, ErrInvalidRequest(err)) 105 | return 106 | } 107 | 108 | res, err := a.service.Reserve(r.Context(), *data.ReservationRequest) 109 | 110 | if err != nil { 111 | if errors.Is(err, core.ErrNotFound) { 112 | Render(w, r, ErrNotFound) 113 | } else { 114 | log.Error().Err(err).Interface("reservationRequest", data).Msg("failed to reserve") 115 | Render(w, r, ErrInternalServer) 116 | } 117 | return 118 | } 119 | 120 | resp := &ReservationResponse{Reservation: res} 121 | render.Status(r, http.StatusCreated) 122 | Render(w, r, resp) 123 | } 124 | 125 | func (a *ReservationApi) Cancel(_ http.ResponseWriter, _ *http.Request) { 126 | // TODO Not implemented 127 | } 128 | 129 | func (a *ReservationApi) ReservationCtx(next http.Handler) http.Handler { 130 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 131 | var err error 132 | 133 | IDStr := chi.URLParam(r, "ID") 134 | if IDStr == "" { 135 | Render(w, r, ErrInvalidRequest(errors.New("reservation id is required"))) 136 | return 137 | } 138 | 139 | ID, err := strconv.ParseUint(IDStr, 10, 64) 140 | if err != nil { 141 | log.Error().Err(err).Str("ID", IDStr).Msg("invalid reservation id") 142 | Render(w, r, ErrInvalidRequest(errors.New("invalid reservation id"))) 143 | } 144 | 145 | reservation, err := a.service.GetReservation(r.Context(), ID) 146 | 147 | if err != nil { 148 | if errors.Is(err, core.ErrNotFound) { 149 | Render(w, r, ErrNotFound) 150 | } else { 151 | log.Error().Err(err).Str("id", IDStr).Msg("error acquiring product") 152 | Render(w, r, ErrInternalServer) 153 | } 154 | return 155 | } 156 | 157 | ctx := context.WithValue(r.Context(), CtxKeyReservation, reservation) 158 | next.ServeHTTP(w, r.WithContext(ctx)) 159 | }) 160 | } 161 | 162 | func (a *ReservationApi) List(w http.ResponseWriter, r *http.Request) { 163 | limit := r.Context().Value(CtxKeyLimit).(int) 164 | offset := r.Context().Value(CtxKeyOffset).(int) 165 | 166 | sku := r.URL.Query().Get("sku") 167 | 168 | state, err := inventory.ParseReserveState(r.URL.Query().Get("state")) 169 | if err != nil { 170 | Render(w, r, ErrInvalidRequest(errors.New("invalid state"))) 171 | return 172 | } 173 | 174 | res, err := a.service.GetReservations(r.Context(), inventory.GetReservationsOptions{Sku: sku, State: state}, limit, offset) 175 | 176 | if err != nil { 177 | if errors.Is(err, core.ErrNotFound) { 178 | Render(w, r, ErrNotFound) 179 | } else { 180 | log.Err(err).Send() 181 | Render(w, r, ErrInternalServer) 182 | } 183 | return 184 | } 185 | 186 | resList := NewReservationListResponse(res) 187 | render.Status(r, http.StatusOK) 188 | RenderList(w, r, resList) 189 | } 190 | -------------------------------------------------------------------------------- /api/inventoryapi.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | 9 | "github.com/go-chi/chi" 10 | "github.com/go-chi/render" 11 | "github.com/gobwas/ws" 12 | "github.com/gobwas/ws/wsutil" 13 | "github.com/rs/zerolog/log" 14 | "github.com/sksmith/go-micro-example/core" 15 | "github.com/sksmith/go-micro-example/core/inventory" 16 | ) 17 | 18 | type InventoryService interface { 19 | Produce(ctx context.Context, product inventory.Product, event inventory.ProductionRequest) error 20 | CreateProduct(ctx context.Context, product inventory.Product) error 21 | 22 | GetProduct(ctx context.Context, sku string) (inventory.Product, error) 23 | GetAllProductInventory(ctx context.Context, limit, offset int) ([]inventory.ProductInventory, error) 24 | GetProductInventory(ctx context.Context, sku string) (inventory.ProductInventory, error) 25 | 26 | SubscribeInventory(ch chan<- inventory.ProductInventory) (id inventory.InventorySubID) 27 | UnsubscribeInventory(id inventory.InventorySubID) 28 | } 29 | 30 | type InventoryApi struct { 31 | service InventoryService 32 | } 33 | 34 | func NewInventoryApi(service InventoryService) *InventoryApi { 35 | return &InventoryApi{service: service} 36 | } 37 | 38 | const ( 39 | CtxKeyProduct CtxKey = "product" 40 | ) 41 | 42 | func (a *InventoryApi) ConfigureRouter(r chi.Router) { 43 | r.HandleFunc("/subscribe", a.Subscribe) 44 | 45 | r.Route("/", func(r chi.Router) { 46 | r.With(Paginate).Get("/", a.List) 47 | r.Put("/", a.CreateProduct) 48 | 49 | r.Route("/{sku}", func(r chi.Router) { 50 | r.Use(a.ProductCtx) 51 | r.Put("/productionEvent", a.CreateProductionEvent) 52 | r.Get("/", a.GetProductInventory) 53 | }) 54 | }) 55 | } 56 | 57 | // Subscribe provides consumes real-time inventory updates and sends them 58 | // to the client via websocket connection. 59 | // 60 | // Note: This isn't exactly realistic because in the real world, this application 61 | // would need to be able to scale. If it were scaled, clients would only get updates 62 | // that occurred in their connected instance. 63 | func (a *InventoryApi) Subscribe(w http.ResponseWriter, r *http.Request) { 64 | log.Info().Msg("client requesting subscription") 65 | 66 | conn, _, _, err := ws.UpgradeHTTP(r, w) 67 | if err != nil { 68 | log.Err(err).Msg("failed to establish inventory subscription connection") 69 | Render(w, r, ErrInternalServer) 70 | } 71 | go func() { 72 | defer conn.Close() 73 | 74 | ch := make(chan inventory.ProductInventory, 1) 75 | 76 | id := a.service.SubscribeInventory(ch) 77 | defer func() { 78 | a.service.UnsubscribeInventory(id) 79 | }() 80 | 81 | for inv := range ch { 82 | resp := &ProductResponse{ProductInventory: inv} 83 | body, err := json.Marshal(resp) 84 | if err != nil { 85 | log.Err(err).Interface("clientId", id).Msg("failed to marshal product response") 86 | continue 87 | } 88 | 89 | log.Debug().Interface("clientId", id).Interface("productResponse", resp).Msg("sending inventory update to client") 90 | err = wsutil.WriteServerText(conn, body) 91 | if err != nil { 92 | log.Err(err).Interface("clientId", id).Msg("failed to write server message, disconnecting client") 93 | return 94 | } 95 | } 96 | }() 97 | } 98 | 99 | func (a *InventoryApi) List(w http.ResponseWriter, r *http.Request) { 100 | limit := r.Context().Value(CtxKeyLimit).(int) 101 | offset := r.Context().Value(CtxKeyOffset).(int) 102 | 103 | products, err := a.service.GetAllProductInventory(r.Context(), limit, offset) 104 | if err != nil { 105 | log.Err(err).Send() 106 | Render(w, r, ErrInternalServer) 107 | return 108 | } 109 | 110 | RenderList(w, r, NewProductListResponse(products)) 111 | } 112 | 113 | func (a *InventoryApi) CreateProduct(w http.ResponseWriter, r *http.Request) { 114 | data := &CreateProductRequest{} 115 | if err := render.Bind(r, data); err != nil { 116 | Render(w, r, ErrInvalidRequest(err)) 117 | return 118 | } 119 | 120 | if err := a.service.CreateProduct(r.Context(), data.Product); err != nil { 121 | log.Err(err).Send() 122 | Render(w, r, ErrInternalServer) 123 | return 124 | } 125 | 126 | render.Status(r, http.StatusCreated) 127 | Render(w, r, NewProductResponse(inventory.ProductInventory{Product: data.Product})) 128 | } 129 | 130 | func (a *InventoryApi) ProductCtx(next http.Handler) http.Handler { 131 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 132 | var product inventory.Product 133 | var err error 134 | 135 | sku := chi.URLParam(r, "sku") 136 | if sku == "" { 137 | Render(w, r, ErrInvalidRequest(errors.New("sku is required"))) 138 | return 139 | } 140 | 141 | product, err = a.service.GetProduct(r.Context(), sku) 142 | 143 | if err != nil { 144 | if errors.Is(err, core.ErrNotFound) { 145 | Render(w, r, ErrNotFound) 146 | } else { 147 | log.Error().Err(err).Str("sku", sku).Msg("error acquiring product") 148 | Render(w, r, ErrInternalServer) 149 | } 150 | return 151 | } 152 | 153 | ctx := context.WithValue(r.Context(), CtxKeyProduct, product) 154 | next.ServeHTTP(w, r.WithContext(ctx)) 155 | }) 156 | } 157 | 158 | func (a *InventoryApi) CreateProductionEvent(w http.ResponseWriter, r *http.Request) { 159 | product := r.Context().Value(CtxKeyProduct).(inventory.Product) 160 | 161 | data := &CreateProductionEventRequest{} 162 | if err := render.Bind(r, data); err != nil { 163 | Render(w, r, ErrInvalidRequest(err)) 164 | return 165 | } 166 | 167 | if err := a.service.Produce(r.Context(), product, *data.ProductionRequest); err != nil { 168 | log.Err(err).Send() 169 | Render(w, r, ErrInternalServer) 170 | return 171 | } 172 | 173 | render.Status(r, http.StatusCreated) 174 | Render(w, r, &ProductionEventResponse{}) 175 | } 176 | 177 | func (a *InventoryApi) GetProductInventory(w http.ResponseWriter, r *http.Request) { 178 | product := r.Context().Value(CtxKeyProduct).(inventory.Product) 179 | 180 | res, err := a.service.GetProductInventory(r.Context(), product.Sku) 181 | 182 | if err != nil { 183 | if errors.Is(err, core.ErrNotFound) { 184 | Render(w, r, ErrNotFound) 185 | } else { 186 | log.Err(err).Send() 187 | Render(w, r, ErrInternalServer) 188 | } 189 | return 190 | } 191 | 192 | resp := &ProductResponse{ProductInventory: res} 193 | render.Status(r, http.StatusOK) 194 | Render(w, r, resp) 195 | } 196 | -------------------------------------------------------------------------------- /core/user/service_test.go: -------------------------------------------------------------------------------- 1 | package user_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "reflect" 7 | "testing" 8 | "time" 9 | 10 | "github.com/sksmith/go-micro-example/core" 11 | "github.com/sksmith/go-micro-example/core/user" 12 | "github.com/sksmith/go-micro-example/db/usrrepo" 13 | ) 14 | 15 | func TestGet(t *testing.T) { 16 | usr := user.User{Username: "someuser", HashedPassword: "somehashedpassword", IsAdmin: false, Created: time.Now()} 17 | tests := []struct { 18 | name string 19 | username string 20 | 21 | getFunc func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) 22 | 23 | wantUser user.User 24 | wantErr bool 25 | }{ 26 | { 27 | name: "user is returned", 28 | username: "someuser", 29 | 30 | getFunc: func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) { 31 | return usr, nil 32 | }, 33 | 34 | wantUser: usr, 35 | }, 36 | { 37 | name: "error is returned", 38 | username: "someuser", 39 | 40 | getFunc: func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) { 41 | return user.User{}, errors.New("some unexpected error") 42 | }, 43 | 44 | wantErr: true, 45 | wantUser: user.User{}, 46 | }, 47 | } 48 | 49 | for _, test := range tests { 50 | mockRepo := usrrepo.NewMockRepo() 51 | if test.getFunc != nil { 52 | mockRepo.GetFunc = test.getFunc 53 | } 54 | 55 | service := user.NewService(mockRepo) 56 | 57 | t.Run(test.name, func(t *testing.T) { 58 | got, err := service.Get(context.Background(), test.username) 59 | if test.wantErr && err == nil { 60 | t.Errorf("expected error, got none") 61 | } else if !test.wantErr && err != nil { 62 | t.Errorf("did not want error, got=%v", err) 63 | } 64 | 65 | if !reflect.DeepEqual(got, test.wantUser) { 66 | t.Errorf("unexpected user\n got=%+v\nwant=%+v", got, test.wantUser) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func TestCreate(t *testing.T) { 73 | tests := []struct { 74 | name string 75 | request user.CreateUserRequest 76 | 77 | createFunc func(ctx context.Context, user *user.User, tx ...core.UpdateOptions) error 78 | 79 | wantUsername string 80 | wantRepoCallCnt map[string]int 81 | wantErr bool 82 | }{ 83 | { 84 | name: "user is returned", 85 | request: user.CreateUserRequest{Username: "someuser", IsAdmin: false, PlainTextPassword: "plaintextpw"}, 86 | 87 | wantRepoCallCnt: map[string]int{"Create": 1}, 88 | wantUsername: "someuser", 89 | }, 90 | } 91 | 92 | for _, test := range tests { 93 | mockRepo := usrrepo.NewMockRepo() 94 | if test.createFunc != nil { 95 | mockRepo.CreateFunc = test.createFunc 96 | } 97 | 98 | service := user.NewService(mockRepo) 99 | 100 | t.Run(test.name, func(t *testing.T) { 101 | got, err := service.Create(context.Background(), test.request) 102 | if test.wantErr && err == nil { 103 | t.Errorf("expected error, got none") 104 | } else if !test.wantErr && err != nil { 105 | t.Errorf("did not want error, got=%v", err) 106 | } 107 | 108 | if got.Username != test.wantUsername { 109 | t.Errorf("unexpected username got=%+v want=%+v", got.Username, test.wantUsername) 110 | } 111 | 112 | for f, c := range test.wantRepoCallCnt { 113 | mockRepo.VerifyCount(f, c, t) 114 | } 115 | }) 116 | } 117 | } 118 | 119 | func TestDelete(t *testing.T) { 120 | tests := []struct { 121 | name string 122 | username string 123 | 124 | deleteFunc func(ctx context.Context, username string, tx ...core.UpdateOptions) error 125 | 126 | wantRepoCallCnt map[string]int 127 | wantErr bool 128 | }{ 129 | { 130 | name: "user is deleted", 131 | username: "someuser", 132 | wantRepoCallCnt: map[string]int{"Delete": 1}, 133 | }, 134 | { 135 | name: "error is returned", 136 | username: "someuser", 137 | 138 | deleteFunc: func(ctx context.Context, username string, tx ...core.UpdateOptions) error { 139 | return errors.New("some unexpected error") 140 | }, 141 | 142 | wantRepoCallCnt: map[string]int{"Delete": 1}, 143 | wantErr: true, 144 | }, 145 | } 146 | 147 | for _, test := range tests { 148 | mockRepo := usrrepo.NewMockRepo() 149 | if test.deleteFunc != nil { 150 | mockRepo.DeleteFunc = test.deleteFunc 151 | } 152 | 153 | service := user.NewService(mockRepo) 154 | 155 | t.Run(test.name, func(t *testing.T) { 156 | err := service.Delete(context.Background(), test.username) 157 | if test.wantErr && err == nil { 158 | t.Errorf("expected error, got none") 159 | } else if !test.wantErr && err != nil { 160 | t.Errorf("did not want error, got=%v", err) 161 | } 162 | 163 | for f, c := range test.wantRepoCallCnt { 164 | mockRepo.VerifyCount(f, c, t) 165 | } 166 | }) 167 | } 168 | } 169 | 170 | func TestLogin(t *testing.T) { 171 | usr := user.User{Username: "someuser", HashedPassword: "$2a$10$t67eB.bOkZGovKD8wqqppO7q.SqWwTS8FUrUx3GAW57GMhkD2Zcwy", IsAdmin: false, Created: time.Now()} 172 | tests := []struct { 173 | name string 174 | username string 175 | password string 176 | 177 | getFunc func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) 178 | 179 | wantUsername string 180 | wantErr bool 181 | }{ 182 | { 183 | name: "correct password", 184 | username: "someuser", 185 | password: "plaintextpw", 186 | 187 | getFunc: func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) { 188 | return usr, nil 189 | }, 190 | 191 | wantUsername: "someuser", 192 | }, 193 | { 194 | name: "wrong password", 195 | username: "someuser", 196 | password: "wrongpw", 197 | 198 | getFunc: func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) { 199 | return usr, nil 200 | }, 201 | 202 | wantErr: true, 203 | wantUsername: "", 204 | }, 205 | { 206 | name: "unexpected error getting user", 207 | username: "someuser", 208 | password: "wrongpw", 209 | 210 | getFunc: func(ctx context.Context, username string, options ...core.QueryOptions) (user.User, error) { 211 | return user.User{}, errors.New("some unexpected error") 212 | }, 213 | 214 | wantErr: true, 215 | wantUsername: "", 216 | }, 217 | } 218 | 219 | for _, test := range tests { 220 | mockRepo := usrrepo.NewMockRepo() 221 | if test.getFunc != nil { 222 | mockRepo.GetFunc = test.getFunc 223 | } 224 | 225 | service := user.NewService(mockRepo) 226 | 227 | t.Run(test.name, func(t *testing.T) { 228 | got, err := service.Login(context.Background(), test.username, test.password) 229 | if test.wantErr && err == nil { 230 | t.Errorf("expected error, got none") 231 | } else if !test.wantErr && err != nil { 232 | t.Errorf("did not want error, got=%v", err) 233 | } 234 | 235 | if got.Username != test.wantUsername { 236 | t.Errorf("unexpected username got=%v want=%v", got.Username, test.wantUsername) 237 | } 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /db/invrepo/mocks.go: -------------------------------------------------------------------------------- 1 | package invrepo 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sksmith/go-micro-example/core" 7 | "github.com/sksmith/go-micro-example/core/inventory" 8 | "github.com/sksmith/go-micro-example/db" 9 | "github.com/sksmith/go-micro-example/testutil" 10 | ) 11 | 12 | type MockRepo struct { 13 | GetProductionEventByRequestIDFunc func(ctx context.Context, requestID string, options ...core.QueryOptions) (pe inventory.ProductionEvent, err error) 14 | SaveProductionEventFunc func(ctx context.Context, event *inventory.ProductionEvent, options ...core.UpdateOptions) error 15 | 16 | GetReservationFunc func(ctx context.Context, ID uint64, options ...core.QueryOptions) (inventory.Reservation, error) 17 | GetReservationsFunc func(ctx context.Context, resOptions inventory.GetReservationsOptions, limit, offset int, options ...core.QueryOptions) ([]inventory.Reservation, error) 18 | GetReservationByRequestIDFunc func(ctx context.Context, requestId string, options ...core.QueryOptions) (inventory.Reservation, error) 19 | UpdateReservationFunc func(ctx context.Context, ID uint64, state inventory.ReserveState, qty int64, options ...core.UpdateOptions) error 20 | SaveReservationFunc func(ctx context.Context, reservation *inventory.Reservation, options ...core.UpdateOptions) error 21 | 22 | GetProductFunc func(ctx context.Context, sku string, options ...core.QueryOptions) (inventory.Product, error) 23 | SaveProductFunc func(ctx context.Context, product inventory.Product, options ...core.UpdateOptions) error 24 | 25 | GetProductInventoryFunc func(ctx context.Context, sku string, options ...core.QueryOptions) (inventory.ProductInventory, error) 26 | GetAllProductInventoryFunc func(ctx context.Context, limit int, offset int, options ...core.QueryOptions) ([]inventory.ProductInventory, error) 27 | SaveProductInventoryFunc func(ctx context.Context, productInventory inventory.ProductInventory, options ...core.UpdateOptions) error 28 | 29 | BeginTransactionFunc func(ctx context.Context) (core.Transaction, error) 30 | 31 | *testutil.CallWatcher 32 | } 33 | 34 | func (r *MockRepo) SaveProductionEvent(ctx context.Context, event *inventory.ProductionEvent, options ...core.UpdateOptions) error { 35 | r.AddCall(ctx, event, options) 36 | return r.SaveProductionEventFunc(ctx, event, options...) 37 | } 38 | 39 | func (r *MockRepo) UpdateReservation(ctx context.Context, ID uint64, state inventory.ReserveState, qty int64, options ...core.UpdateOptions) error { 40 | r.AddCall(ctx, ID, state, options) 41 | return r.UpdateReservationFunc(ctx, ID, state, qty, options...) 42 | } 43 | 44 | func (r *MockRepo) GetProductionEventByRequestID(ctx context.Context, requestID string, options ...core.QueryOptions) (pe inventory.ProductionEvent, err error) { 45 | r.AddCall(ctx, requestID, options) 46 | return r.GetProductionEventByRequestIDFunc(ctx, requestID, options...) 47 | } 48 | 49 | func (r *MockRepo) SaveReservation(ctx context.Context, reservation *inventory.Reservation, options ...core.UpdateOptions) error { 50 | r.AddCall(ctx, reservation, options) 51 | return r.SaveReservationFunc(ctx, reservation, options...) 52 | } 53 | 54 | func (r *MockRepo) GetReservation(ctx context.Context, ID uint64, options ...core.QueryOptions) (inventory.Reservation, error) { 55 | r.AddCall(ctx, ID, options) 56 | return r.GetReservationFunc(ctx, ID, options...) 57 | } 58 | 59 | func (r *MockRepo) GetReservations(ctx context.Context, resOptions inventory.GetReservationsOptions, limit, offset int, options ...core.QueryOptions) ([]inventory.Reservation, error) { 60 | r.AddCall(ctx, resOptions, limit, offset, options) 61 | return r.GetReservationsFunc(ctx, resOptions, limit, offset, options...) 62 | } 63 | 64 | func (r *MockRepo) SaveProduct(ctx context.Context, product inventory.Product, options ...core.UpdateOptions) error { 65 | r.AddCall(ctx, product, options) 66 | return r.SaveProductFunc(ctx, product, options...) 67 | } 68 | 69 | func (r *MockRepo) GetProduct(ctx context.Context, sku string, options ...core.QueryOptions) (inventory.Product, error) { 70 | r.AddCall(ctx, sku, options) 71 | return r.GetProductFunc(ctx, sku, options...) 72 | } 73 | 74 | func (r *MockRepo) GetProductInventory(ctx context.Context, sku string, options ...core.QueryOptions) (inventory.ProductInventory, error) { 75 | r.AddCall(ctx, sku, options) 76 | return r.GetProductInventoryFunc(ctx, sku, options...) 77 | } 78 | 79 | func (r *MockRepo) SaveProductInventory(ctx context.Context, productInventory inventory.ProductInventory, options ...core.UpdateOptions) error { 80 | r.AddCall(ctx, productInventory, options) 81 | return r.SaveProductInventoryFunc(ctx, productInventory, options...) 82 | } 83 | 84 | func (r *MockRepo) GetAllProductInventory(ctx context.Context, limit int, offset int, options ...core.QueryOptions) ([]inventory.ProductInventory, error) { 85 | r.AddCall(ctx, limit, offset, options) 86 | return r.GetAllProductInventoryFunc(ctx, limit, offset, options...) 87 | } 88 | 89 | func (r *MockRepo) BeginTransaction(ctx context.Context) (core.Transaction, error) { 90 | r.AddCall(ctx) 91 | return r.BeginTransactionFunc(ctx) 92 | } 93 | 94 | func (r *MockRepo) GetReservationByRequestID(ctx context.Context, requestId string, options ...core.QueryOptions) (inventory.Reservation, error) { 95 | r.AddCall(ctx, requestId, options) 96 | return r.GetReservationByRequestIDFunc(ctx, requestId, options...) 97 | } 98 | 99 | func NewMockRepo() *MockRepo { 100 | return &MockRepo{ 101 | SaveProductionEventFunc: func(ctx context.Context, event *inventory.ProductionEvent, options ...core.UpdateOptions) error { 102 | return nil 103 | }, 104 | GetProductionEventByRequestIDFunc: func(ctx context.Context, requestID string, options ...core.QueryOptions) (pe inventory.ProductionEvent, err error) { 105 | return inventory.ProductionEvent{}, nil 106 | }, 107 | SaveReservationFunc: func(ctx context.Context, reservation *inventory.Reservation, options ...core.UpdateOptions) error { 108 | return nil 109 | }, 110 | GetReservationFunc: func(ctx context.Context, ID uint64, options ...core.QueryOptions) (inventory.Reservation, error) { 111 | return inventory.Reservation{}, nil 112 | }, 113 | GetReservationsFunc: func(ctx context.Context, resOptions inventory.GetReservationsOptions, limit, offset int, options ...core.QueryOptions) ([]inventory.Reservation, error) { 114 | return nil, nil 115 | }, 116 | SaveProductFunc: func(ctx context.Context, product inventory.Product, options ...core.UpdateOptions) error { return nil }, 117 | GetProductFunc: func(ctx context.Context, sku string, options ...core.QueryOptions) (inventory.Product, error) { 118 | return inventory.Product{}, nil 119 | }, 120 | GetAllProductInventoryFunc: func(ctx context.Context, limit int, offset int, options ...core.QueryOptions) ([]inventory.ProductInventory, error) { 121 | return nil, nil 122 | }, 123 | BeginTransactionFunc: func(ctx context.Context) (core.Transaction, error) { return db.NewMockTransaction(), nil }, 124 | GetReservationByRequestIDFunc: func(ctx context.Context, requestId string, options ...core.QueryOptions) (inventory.Reservation, error) { 125 | return inventory.Reservation{}, nil 126 | }, 127 | UpdateReservationFunc: func(ctx context.Context, ID uint64, state inventory.ReserveState, qty int64, options ...core.UpdateOptions) error { 128 | return nil 129 | }, 130 | GetProductInventoryFunc: func(ctx context.Context, sku string, options ...core.QueryOptions) (inventory.ProductInventory, error) { 131 | return inventory.ProductInventory{}, nil 132 | }, 133 | SaveProductInventoryFunc: func(ctx context.Context, productInventory inventory.ProductInventory, options ...core.UpdateOptions) error { 134 | return nil 135 | }, 136 | CallWatcher: testutil.NewCallWatcher(), 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/rs/zerolog/log" 10 | "github.com/sksmith/go-micro-example/config" 11 | "github.com/sksmith/go-micro-example/core/inventory" 12 | "github.com/streadway/amqp" 13 | ) 14 | 15 | type InventoryQueue struct { 16 | cfg *config.Config 17 | inventory chan<- message 18 | reservation chan<- message 19 | } 20 | 21 | func NewInventoryQueue(ctx context.Context, cfg *config.Config) *InventoryQueue { 22 | invChan := make(chan message) 23 | resChan := make(chan message) 24 | 25 | iq := &InventoryQueue{ 26 | cfg: cfg, 27 | inventory: invChan, 28 | reservation: resChan, 29 | } 30 | 31 | url := getUrl(cfg) 32 | 33 | go func() { 34 | invExch := cfg.RabbitMQ.Inventory.Exchange.Value 35 | publish(redial(ctx, url), invExch, invChan) 36 | ctx.Done() 37 | }() 38 | 39 | go func() { 40 | resExch := cfg.RabbitMQ.Reservation.Exchange.Value 41 | publish(redial(ctx, url), resExch, resChan) 42 | ctx.Done() 43 | }() 44 | 45 | return iq 46 | } 47 | 48 | func getUrl(cfg *config.Config) string { 49 | rmq := cfg.RabbitMQ 50 | return fmt.Sprintf("amqp://%s:%s@%s:%s", rmq.User.Value, rmq.Pass.Value, rmq.Host.Value, rmq.Port.Value) 51 | } 52 | 53 | func (i *InventoryQueue) PublishInventory(ctx context.Context, productInventory inventory.ProductInventory) error { 54 | body, err := json.Marshal(productInventory) 55 | if err != nil { 56 | return errors.WithMessage(err, "failed to serialize message for queue") 57 | } 58 | i.inventory <- message(body) 59 | return nil 60 | } 61 | 62 | func (i *InventoryQueue) PublishReservation(ctx context.Context, reservation inventory.Reservation) error { 63 | body, err := json.Marshal(reservation) 64 | if err != nil { 65 | return errors.WithMessage(err, "error marshalling reservation to send to queue") 66 | } 67 | i.reservation <- message(body) 68 | return nil 69 | } 70 | 71 | type ProductQueue struct { 72 | cfg *config.Config 73 | product <-chan message 74 | productDlt chan<- message 75 | } 76 | 77 | func NewProductQueue(ctx context.Context, cfg *config.Config, handler ProductHandler) *ProductQueue { 78 | log.Info().Msg("creating product queue...") 79 | 80 | prodChan := make(chan message) 81 | prodDltChan := make(chan message) 82 | 83 | pq := &ProductQueue{ 84 | cfg: cfg, 85 | product: prodChan, 86 | productDlt: prodDltChan, 87 | } 88 | 89 | url := getUrl(cfg) 90 | 91 | go func() { 92 | for message := range prodChan { 93 | product := inventory.Product{} 94 | err := json.Unmarshal(message, &product) 95 | if err != nil { 96 | log.Error().Err(err).Msg("error unmarshalling product, writing to dlt") 97 | pq.sendToDlt(ctx, message) 98 | } 99 | 100 | err = handler.CreateProduct(ctx, product) 101 | if err != nil { 102 | log.Error().Err(err).Msg("failed to create product, sending to dlt") 103 | pq.productDlt <- message 104 | } 105 | } 106 | }() 107 | 108 | go func() { 109 | prodQueue := cfg.RabbitMQ.Product.Queue.Value 110 | subscribe(redial(ctx, url), prodQueue, prodChan) 111 | ctx.Done() 112 | }() 113 | 114 | go func() { 115 | dltExch := cfg.RabbitMQ.Product.Dlt.Exchange.Value 116 | publish(redial(ctx, url), dltExch, prodDltChan) 117 | ctx.Done() 118 | }() 119 | 120 | return pq 121 | } 122 | 123 | type ProductHandler interface { 124 | CreateProduct(ctx context.Context, product inventory.Product) error 125 | } 126 | 127 | func (p *ProductQueue) sendToDlt(ctx context.Context, body []byte) { 128 | p.productDlt <- message(body) 129 | } 130 | 131 | // TODO We should be using one exchange per domain object here. 132 | // exchange binds the publishers to the subscribers 133 | // const exchange = "pubsub" 134 | 135 | // message is the application type for a message. This can contain identity, 136 | // or a reference to the recevier chan for further demuxing. 137 | type message []byte 138 | 139 | // session composes an amqp.Connection with an amqp.Channel 140 | type session struct { 141 | *amqp.Connection 142 | *amqp.Channel 143 | } 144 | 145 | // Close tears the connection down, taking the channel with it. 146 | func (s session) Close() error { 147 | if s.Connection == nil { 148 | return nil 149 | } 150 | return s.Connection.Close() 151 | } 152 | 153 | // redial continually connects to the URL, exiting the program when no longer possible 154 | func redial(ctx context.Context, url string) chan chan session { 155 | sessions := make(chan chan session) 156 | 157 | go func() { 158 | sess := make(chan session) 159 | defer close(sessions) 160 | 161 | for { 162 | select { 163 | case sessions <- sess: 164 | case <-ctx.Done(): 165 | log.Fatal().Msg("shutting down session factory") 166 | return 167 | } 168 | 169 | conn, err := amqp.Dial(url) 170 | if err != nil { 171 | log.Fatal().Err(err).Str("url", url).Msg("cannot (re)dial") 172 | } 173 | 174 | ch, err := conn.Channel() 175 | if err != nil { 176 | log.Fatal().Err(err).Msg("cannot create channel") 177 | } 178 | 179 | select { 180 | case sess <- session{conn, ch}: 181 | case <-ctx.Done(): 182 | log.Info().Msg("shutting down new session") 183 | return 184 | } 185 | } 186 | }() 187 | 188 | return sessions 189 | } 190 | 191 | // publish publishes messages to a reconnecting session to a fanout exchange. 192 | // It receives from the application specific source of messages. 193 | func publish(sessions chan chan session, exchange string, messages <-chan message) { 194 | for session := range sessions { 195 | var ( 196 | running bool 197 | reading = messages 198 | pending = make(chan message, 1) 199 | confirm = make(chan amqp.Confirmation, 1) 200 | ) 201 | 202 | pub := <-session 203 | 204 | // publisher confirms for this channel/connection 205 | if err := pub.Confirm(false); err != nil { 206 | log.Info().Msg("publisher confirms not supported") 207 | close(confirm) // confirms not supported, simulate by always nacking 208 | } else { 209 | pub.NotifyPublish(confirm) 210 | } 211 | 212 | log.Debug().Str("exchange", exchange).Msg("ready to publish messages") 213 | 214 | Publish: 215 | for { 216 | var body message 217 | select { 218 | case confirmed, ok := <-confirm: 219 | if !ok { 220 | break Publish 221 | } 222 | if !confirmed.Ack { 223 | log.Info().Uint64("message", confirmed.DeliveryTag).Str("body", string(body)).Msg("nack") 224 | } 225 | reading = messages 226 | 227 | case body = <-pending: 228 | routingKey := "ignored for fanout exchanges, application dependent for other exchanges" 229 | err := pub.Publish(exchange, routingKey, false, false, amqp.Publishing{ 230 | Body: body, 231 | }) 232 | // Retry failed delivery on the next session 233 | if err != nil { 234 | pending <- body 235 | _ = pub.Close() 236 | break Publish 237 | } 238 | 239 | case body, running = <-reading: 240 | // all messages consumed 241 | if !running { 242 | return 243 | } 244 | // work on pending delivery until ack'd 245 | pending <- body 246 | reading = nil 247 | } 248 | } 249 | } 250 | } 251 | 252 | // subscribe consumes deliveries from an exclusive queue from a fanout exchange and sends to the application specific messages chan. 253 | func subscribe(sessions chan chan session, queue string, messages chan<- message) { 254 | for session := range sessions { 255 | sub := <-session 256 | 257 | deliveries, err := sub.Consume(queue, "", false, false, false, false, nil) 258 | if err != nil { 259 | log.Error().Str("queue", queue).Err(err).Msg("cannot consume from") 260 | return 261 | } 262 | 263 | log.Info().Str("queue", queue).Msg("listening for messages") 264 | 265 | for msg := range deliveries { 266 | messages <- message(msg.Body) 267 | err = sub.Ack(msg.DeliveryTag, false) 268 | if err != nil { 269 | log.Error().Err(err).Str("queue", queue).Msg("failed to acknowledge to queue") 270 | } 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Micro Example 2 | 3 | ![Linter](https://github.com/sksmith/note-server/actions/workflows/lint.yml/badge.svg) 4 | ![Security](https://github.com/sksmith/note-server/actions/workflows/sec.yml/badge.svg) 5 | ![Test](https://github.com/sksmith/note-server/actions/workflows/test.yml/badge.svg) 6 | 7 | This sample project was created as a collection of the various things I've learned about best 8 | practices building microservices using Go. I structured the project using a hexagonal style abstracting 9 | away business logic from dependencies like the RESTful API, the Postgres database, and RabbitMQ message queue. 10 | 11 | ## Structure 12 | 13 | The Go community generally likes application directory structures to be as simple as possible which is 14 | totally admirable and applicable for a small simple microservice. I could probably have kept everything 15 | for this project in a single directory and focused on making sure it met twelve factors. But I'm a big 16 | fan of [Domain Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html), and how it gels so 17 | nicely with [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) and I wanted 18 | to see how a Go microservice might look structured using them. 19 | 20 | The starting point of the application is under the [cmd](cmd/main.go) directory. The "domain" 21 | core of the application where all business logic should reside is under the [core](core) 22 | directory. The other directories listed there are each of the external dependencies for the project. 23 | 24 | ![structure diagram](inventory.jpg) 25 | 26 | ## Running the Application Locally 27 | 28 | This project requires that you have Docker, Go and Make installed locally. If you do, you can start 29 | the application first by starting the docker-compose file, then start the application using the 30 | supplied Makefile. 31 | 32 | ```shell 33 | docker-compose -f ./scripts/docker-compose.yml up -d 34 | make run 35 | ``` 36 | 37 | If you want to create a deployable executable and run it: 38 | 39 | ```shell 40 | make build 41 | ./bin/inventory 42 | ``` 43 | 44 | ### Run Docker Compose 45 | 46 | ```shell 47 | docker-compose up 48 | ``` 49 | 50 | ## Application Features 51 | 52 | ### RESTful API 53 | 54 | This application uses the wonderful [go-chi](https://github.com/go-chi/chi) for routing 55 | [beautifuly documentation](https://github.com/go-chi/chi/blob/master/_examples/rest/main.go) served as the main 56 | inspiration for how to structure the API. Seriously, I was so impressed. 57 | 58 | In Java I like to generate the controller layer using Open API so that the contract and implementation always match 59 | exactly. I couldn't quite find an equivalent solution I liked. 60 | 61 | Truth be told, if I were doing inter-microservice communication I would strongly consider using gRPC rather than a 62 | RESTful API. 63 | 64 | ### Authentication 65 | 66 | Many of the endpoints in this project are protected by using a [simple authentication middleware](api/middleware.go). If 67 | you're interested in hitting them you can use basic auth admin:admin. Users are stored in the database along with their 68 | hashed password. Users are locally cached using [golang-lru](https://github.com/hashicorp/golang-lru). In a production 69 | setting if I actually wanted caching I'd either use a remote cache like Redis, or a distributed local cache like 70 | groupcache to prevent stale or out of sync data. 71 | 72 | ### Metrics 73 | 74 | This application outputs prometheus metrics using middleware I plugged into the go-chi router. If you're running 75 | locally check them out at [http://localhost:8080/metrics](http://localhost:8080/metrics). Every URL automatically 76 | gets a hit count and a latency metric added. You can find the configurations [here](api/middleware.go). 77 | 78 | ### Logging 79 | 80 | I ended up going with [zerolog](https://github.com/rs/zerolog) for logging in this project. I really like its API and 81 | their benchmarks look really great too! You can get structured logging or nice human-readable logging by 82 | [changing some configs](config.yml#L10) 83 | 84 | ### Configuration 85 | 86 | This project uses [viper](https://github.com/spf13/viper) for handling externalized configurations. At the moment it only reads from the local config.yml but the plan is to make it compatible with [Spring Cloud Config](https://cloud.spring.io/spring-cloud-config), and [etcd](https://etcd.io). 87 | 88 | ### Testing 89 | 90 | I chose not to go with any of the test frameworks when putting this project together. I felt like using interfaces and 91 | injecting dependencies would be enough to allow me to mock what I need to. There's a fair bit of boilerplate code 92 | required to mock, say, the inventory repository but not having to pull in and learn yet another dependency for testing 93 | seemed like a fair tradeoff. 94 | 95 | The testing in this project is pretty bare-bones and mostly just proof-of-concept. If you want to see some tests, 96 | though, they're in [api](api). I personally prefer more integration tests that test an application front-to-back for 97 | features rather than tons and tons of tightly-coupled unit tests. 98 | 99 | ### Database Migrations 100 | 101 | I'm using the [migrate](https://github.com/golang-migrate/migrate) project to manage database migrations. 102 | 103 | ```shell 104 | migrate create -ext sql -dir db/migrations -seq create_products_table 105 | 106 | migrate -database postgres://postgres:postgres@localhost:5432/smfg-db?sslmode=disable -path db/migrations up 107 | 108 | migrate -source file://db/migrations -database postgres://localhost:5432/database down 109 | ``` 110 | 111 | ## 12 Factors 112 | 113 | One of the goals of this service was to ensure all [12 principals](https://12factor.net/) of a 12-factor app are adhered 114 | to. This was a nice way to make sure the app I built offered most of what you need out of a Spring Boot application. 115 | 116 | ### I. Codebase 117 | 118 | The application is stored in my git repository. 119 | 120 | ### II. Dependencies 121 | 122 | Go handles this for us through its dependency management system (yay!) 123 | 124 | ### III. Config 125 | 126 | See the [configuration section](#Configuration) section above. 127 | 128 | ### IV. Backing Services 129 | 130 | The application connects to all external dependencies (in this case, RabbitMQ, and Postgres) via URLs which it gets from 131 | remote configuration. 132 | 133 | ### V. Build, release, run 134 | 135 | The application can easily be plugged into any CI/CD pipeline. This is mostly thanks to Go making this easy through 136 | great command line tools. 137 | 138 | ### VI. Processes 139 | 140 | This app is not *strictly* stateless. There is a cache in the user repository. This was a design choice I made in the 141 | interest of seeing what setting up a local cache in go might look like. In a more real-world application you would 142 | probably want an external cache (like Redis), or a distributed cache (like 143 | [Group Cache](https://github.com/golang/groupcache) - which is really cool!) 144 | 145 | This app is otherwise stateless and threadsafe. 146 | 147 | ### VII. Port Binding 148 | 149 | The application binds to a supplied port on startup. 150 | 151 | ### VIII. Concurrency 152 | 153 | Other than maintaining an instance-based cache (see Process above), the application will scale horizontally without 154 | issue. The database dependency would need to scale vertically unless you started using sharding, or a distributed data 155 | store like [Cosmos DB](https://docs.microsoft.com/en-us/azure/cosmos-db/distribute-data-globally). 156 | 157 | ### IX. Disposability 158 | 159 | One of the wonderful things about Go is how *fast* it starts up. This application can start up and shut down in a 160 | fraction of the time that similar Spring Boot microservices. In addition, they use a much smaller footprint. This is 161 | perfect for services that need to be highly elastic on demand. 162 | 163 | ### X. Dev/Prod Parity 164 | 165 | Docker makes standing up a prod-like environment on your local environment a breeze. This application has 166 | [a docker-compose file](scripts/docker-compose.yml) that starts up a local instance of rabbit and postgres. This 167 | obviously doesn't account for ensuring your dev and stage environments are up to snuff but at least that's a good start 168 | for local development. 169 | 170 | ### XI. Logs 171 | 172 | Logs in the application are written to the stdout allowing for logscrapers like 173 | [logstash](https://www.elastic.co/logstash) to consume and parse the logs. Through configuration the logs can output as 174 | plain text for ease of reading during local development and then switched after deployment into json structured logs for 175 | automatic parsing. 176 | 177 | ### XII. Admin Processes 178 | 179 | Database migration is automated in the project using [migrate](https://github.com/golang-migrate/migrate). 180 | 181 | ## TODO 182 | 183 | - [ ] Recreate architecture diagram 184 | - [ ] Add godoc 185 | - [ ] Return 204 no content if data already exists 186 | - [ ] Cleanup TODOs -------------------------------------------------------------------------------- /db/invrepo/repo.go: -------------------------------------------------------------------------------- 1 | package invrepo 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/jackc/pgx/v4" 8 | "github.com/pkg/errors" 9 | "github.com/rs/zerolog/log" 10 | "github.com/sksmith/go-micro-example/core" 11 | "github.com/sksmith/go-micro-example/core/inventory" 12 | "github.com/sksmith/go-micro-example/db" 13 | ) 14 | 15 | type dbRepo struct { 16 | conn core.Conn 17 | } 18 | 19 | func NewPostgresRepo(conn core.Conn) *dbRepo { 20 | log.Info().Msg("creating inventory repository...") 21 | return &dbRepo{ 22 | conn: conn, 23 | } 24 | } 25 | 26 | func (d *dbRepo) SaveProduct(ctx context.Context, product inventory.Product, options ...core.UpdateOptions) error { 27 | m := db.StartMetric("SaveProduct") 28 | tx := db.GetUpdateOptions(d.conn, options...) 29 | 30 | ct, err := tx.Exec(ctx, ` 31 | UPDATE products 32 | SET upc = $2, name = $3 33 | WHERE sku = $1;`, 34 | product.Sku, product.Upc, product.Name) 35 | if err != nil { 36 | m.Complete(nil) 37 | return errors.WithStack(err) 38 | } 39 | if ct.RowsAffected() == 0 { 40 | _, err := tx.Exec(ctx, ` 41 | INSERT INTO products (sku, upc, name) 42 | VALUES ($1, $2, $3);`, 43 | product.Sku, product.Upc, product.Name) 44 | if err != nil { 45 | m.Complete(err) 46 | return err 47 | } 48 | } 49 | m.Complete(nil) 50 | return nil 51 | } 52 | 53 | func (d *dbRepo) SaveProductInventory(ctx context.Context, productInventory inventory.ProductInventory, options ...core.UpdateOptions) error { 54 | m := db.StartMetric("SaveProductInventory") 55 | tx := db.GetUpdateOptions(d.conn, options...) 56 | 57 | ct, err := tx.Exec(ctx, ` 58 | UPDATE product_inventory 59 | SET available = $2 60 | WHERE sku = $1;`, 61 | productInventory.Sku, productInventory.Available) 62 | if err != nil { 63 | m.Complete(nil) 64 | return errors.WithStack(err) 65 | } 66 | if ct.RowsAffected() == 0 { 67 | insert := `INSERT INTO product_inventory (sku, available) 68 | VALUES ($1, $2);` 69 | _, err := tx.Exec(ctx, insert, productInventory.Sku, productInventory.Available) 70 | m.Complete(err) 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | m.Complete(nil) 76 | return nil 77 | } 78 | 79 | func (d *dbRepo) GetProduct(ctx context.Context, sku string, options ...core.QueryOptions) (inventory.Product, error) { 80 | m := db.StartMetric("GetProduct") 81 | tx, forUpdate := db.GetQueryOptions(d.conn, options...) 82 | 83 | product := inventory.Product{} 84 | err := tx.QueryRow(ctx, `SELECT sku, upc, name FROM products WHERE sku = $1 `+forUpdate, sku). 85 | Scan(&product.Sku, &product.Upc, &product.Name) 86 | 87 | if err != nil { 88 | m.Complete(err) 89 | if err == pgx.ErrNoRows { 90 | return product, errors.WithStack(core.ErrNotFound) 91 | } 92 | return product, errors.WithStack(err) 93 | } 94 | 95 | m.Complete(nil) 96 | return product, nil 97 | } 98 | 99 | func (d *dbRepo) GetProductInventory(ctx context.Context, sku string, options ...core.QueryOptions) (inventory.ProductInventory, error) { 100 | m := db.StartMetric("GetProductInventory") 101 | tx, forUpdate := db.GetQueryOptions(d.conn, options...) 102 | 103 | productInventory := inventory.ProductInventory{} 104 | err := tx.QueryRow(ctx, `SELECT p.sku, p.upc, p.name, pi.available FROM products p, product_inventory pi WHERE p.sku = $1 AND p.sku = pi.sku `+forUpdate, sku). 105 | Scan(&productInventory.Sku, &productInventory.Upc, &productInventory.Name, &productInventory.Available) 106 | 107 | if err != nil { 108 | m.Complete(err) 109 | if err == pgx.ErrNoRows { 110 | return productInventory, errors.WithStack(core.ErrNotFound) 111 | } 112 | return productInventory, errors.WithStack(err) 113 | } 114 | 115 | m.Complete(nil) 116 | return productInventory, nil 117 | } 118 | 119 | func (d *dbRepo) GetAllProductInventory(ctx context.Context, limit int, offset int, options ...core.QueryOptions) ([]inventory.ProductInventory, error) { 120 | m := db.StartMetric("GetAllProducts") 121 | tx, forUpdate := db.GetQueryOptions(d.conn, options...) 122 | 123 | products := make([]inventory.ProductInventory, 0) 124 | rows, err := tx.Query(ctx, 125 | `SELECT p.sku, p.upc, p.name, pi.available FROM products p, product_inventory pi WHERE p.sku = pi.sku ORDER BY p.sku LIMIT $1 OFFSET $2 `+forUpdate, 126 | limit, offset) 127 | if err != nil { 128 | m.Complete(err) 129 | if err == pgx.ErrNoRows { 130 | return products, errors.WithStack(core.ErrNotFound) 131 | } 132 | return nil, errors.WithStack(err) 133 | } 134 | defer rows.Close() 135 | 136 | for rows.Next() { 137 | product := inventory.ProductInventory{} 138 | err = rows.Scan(&product.Sku, &product.Upc, &product.Name, &product.Available) 139 | if err != nil { 140 | m.Complete(err) 141 | if err == pgx.ErrNoRows { 142 | return nil, errors.WithStack(core.ErrNotFound) 143 | } 144 | return nil, errors.WithStack(err) 145 | } 146 | products = append(products, product) 147 | } 148 | 149 | m.Complete(nil) 150 | return products, nil 151 | } 152 | 153 | func (d *dbRepo) GetProductionEventByRequestID(ctx context.Context, requestID string, options ...core.QueryOptions) (pe inventory.ProductionEvent, err error) { 154 | m := db.StartMetric("GetProductionEventByRequestID") 155 | tx, forUpdate := db.GetQueryOptions(d.conn, options...) 156 | 157 | pe = inventory.ProductionEvent{} 158 | err = tx.QueryRow(ctx, `SELECT id, request_id, sku, quantity, created FROM production_events `+forUpdate+` WHERE request_id = $1 `+forUpdate, requestID). 159 | Scan(&pe.ID, &pe.RequestID, &pe.Sku, &pe.Quantity, &pe.Created) 160 | 161 | if err != nil { 162 | m.Complete(err) 163 | if err == pgx.ErrNoRows { 164 | return pe, errors.WithStack(core.ErrNotFound) 165 | } 166 | return pe, errors.WithStack(err) 167 | } 168 | 169 | m.Complete(nil) 170 | return pe, nil 171 | } 172 | 173 | func (d *dbRepo) SaveProductionEvent(ctx context.Context, event *inventory.ProductionEvent, options ...core.UpdateOptions) error { 174 | m := db.StartMetric("SaveProductionEvent") 175 | tx := db.GetUpdateOptions(d.conn, options...) 176 | 177 | insert := `INSERT INTO production_events (request_id, sku, quantity, created) 178 | VALUES ($1, $2, $3, $4) RETURNING id;` 179 | 180 | err := tx.QueryRow(ctx, insert, event.RequestID, event.Sku, event.Quantity, event.Created).Scan(&event.ID) 181 | if err != nil { 182 | m.Complete(err) 183 | if err == pgx.ErrNoRows { 184 | return errors.WithStack(core.ErrNotFound) 185 | } 186 | return errors.WithStack(err) 187 | } 188 | m.Complete(nil) 189 | return nil 190 | } 191 | 192 | func (d *dbRepo) SaveReservation(ctx context.Context, r *inventory.Reservation, options ...core.UpdateOptions) error { 193 | m := db.StartMetric("SaveReservation") 194 | tx := db.GetUpdateOptions(d.conn, options...) 195 | 196 | insert := `INSERT INTO reservations (request_id, requester, sku, state, reserved_quantity, requested_quantity, created) 197 | VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;` 198 | err := tx.QueryRow(ctx, insert, r.RequestID, r.Requester, r.Sku, r.State, r.ReservedQuantity, r.RequestedQuantity, r.Created).Scan(&r.ID) 199 | if err != nil { 200 | m.Complete(err) 201 | if err == pgx.ErrNoRows { 202 | return errors.WithStack(core.ErrNotFound) 203 | } 204 | return errors.WithStack(err) 205 | } 206 | m.Complete(nil) 207 | return nil 208 | } 209 | 210 | func (d *dbRepo) UpdateReservation(ctx context.Context, ID uint64, state inventory.ReserveState, qty int64, options ...core.UpdateOptions) error { 211 | m := db.StartMetric("UpdateReservation") 212 | tx := db.GetUpdateOptions(d.conn, options...) 213 | 214 | update := `UPDATE reservations SET state = $2, reserved_quantity = $3 WHERE id=$1;` 215 | _, err := tx.Exec(ctx, update, ID, state, qty) 216 | m.Complete(err) 217 | if err != nil { 218 | return errors.WithStack(err) 219 | } 220 | m.Complete(nil) 221 | return nil 222 | } 223 | 224 | const reservationFields = "id, request_id, requester, sku, state, reserved_quantity, requested_quantity, created" 225 | 226 | func (d *dbRepo) GetReservations(ctx context.Context, resOptions inventory.GetReservationsOptions, limit, offset int, options ...core.QueryOptions) ([]inventory.Reservation, error) { 227 | m := db.StartMetric("GetSkuOpenReserves") 228 | tx, forUpdate := db.GetQueryOptions(d.conn, options...) 229 | 230 | params := make([]interface{}, 0) 231 | params = append(params, limit) 232 | params = append(params, offset) 233 | 234 | whereClause := "" 235 | paramIdx := 2 236 | 237 | if resOptions.Sku != "" || resOptions.State != inventory.None { 238 | whereClause = " WHERE " 239 | } 240 | 241 | if resOptions.Sku != "" { 242 | if paramIdx > 2 { 243 | whereClause += " AND" 244 | } 245 | paramIdx++ 246 | whereClause += " sku = $" + strconv.Itoa(paramIdx) 247 | params = append(params, resOptions.Sku) 248 | } 249 | 250 | if resOptions.State != inventory.None { 251 | if paramIdx > 2 { 252 | whereClause += " AND" 253 | } 254 | paramIdx++ 255 | whereClause += " state = $" + strconv.Itoa(paramIdx) 256 | params = append(params, resOptions.State) 257 | } 258 | 259 | reservations := make([]inventory.Reservation, 0) 260 | rows, err := tx.Query(ctx, 261 | `SELECT `+reservationFields+` FROM reservations `+whereClause+` ORDER BY created ASC LIMIT $1 OFFSET $2 `+forUpdate, 262 | params...) 263 | if err != nil { 264 | m.Complete(err) 265 | if err == pgx.ErrNoRows { 266 | return reservations, errors.WithStack(core.ErrNotFound) 267 | } 268 | return nil, errors.WithStack(err) 269 | } 270 | defer rows.Close() 271 | 272 | for rows.Next() { 273 | r := inventory.Reservation{} 274 | err = rows.Scan(&r.ID, &r.RequestID, &r.Requester, &r.Sku, &r.State, &r.ReservedQuantity, &r.RequestedQuantity, &r.Created) 275 | if err != nil { 276 | m.Complete(err) 277 | return nil, err 278 | } 279 | reservations = append(reservations, r) 280 | } 281 | 282 | m.Complete(nil) 283 | return reservations, nil 284 | } 285 | 286 | func (d *dbRepo) GetReservationByRequestID(ctx context.Context, requestId string, options ...core.QueryOptions) (inventory.Reservation, error) { 287 | m := db.StartMetric("GetReservationByRequestID") 288 | tx, forUpdate := db.GetQueryOptions(d.conn, options...) 289 | 290 | r := inventory.Reservation{} 291 | err := tx.QueryRow(ctx, 292 | `SELECT `+reservationFields+` FROM reservations WHERE request_id = $1 `+forUpdate, 293 | requestId).Scan(&r.ID, &r.RequestID, &r.Requester, &r.Sku, &r.State, &r.ReservedQuantity, &r.RequestedQuantity, &r.Created) 294 | if err != nil { 295 | m.Complete(err) 296 | if err == pgx.ErrNoRows { 297 | return r, errors.WithStack(core.ErrNotFound) 298 | } 299 | return r, errors.WithStack(err) 300 | } 301 | 302 | m.Complete(nil) 303 | return r, nil 304 | } 305 | 306 | func (d *dbRepo) GetReservation(ctx context.Context, ID uint64, options ...core.QueryOptions) (inventory.Reservation, error) { 307 | m := db.StartMetric("GetReservation") 308 | tx, forUpdate := db.GetQueryOptions(d.conn, options...) 309 | 310 | r := inventory.Reservation{} 311 | err := tx.QueryRow(ctx, 312 | `SELECT `+reservationFields+` FROM reservations WHERE id = $1 `+forUpdate, ID). 313 | Scan(&r.ID, &r.RequestID, &r.Requester, &r.Sku, &r.State, &r.ReservedQuantity, &r.RequestedQuantity, &r.Created) 314 | if err != nil { 315 | m.Complete(err) 316 | if err == pgx.ErrNoRows { 317 | return r, errors.WithStack(core.ErrNotFound) 318 | } 319 | return r, errors.WithStack(err) 320 | } 321 | 322 | m.Complete(nil) 323 | return r, nil 324 | } 325 | 326 | func (d *dbRepo) BeginTransaction(ctx context.Context) (core.Transaction, error) { 327 | tx, err := d.conn.Begin(ctx) 328 | if err != nil { 329 | return nil, err 330 | } 331 | return tx, nil 332 | } 333 | -------------------------------------------------------------------------------- /api/reservationapi_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "reflect" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/go-chi/chi" 14 | "github.com/gobwas/ws" 15 | "github.com/sksmith/go-micro-example/api" 16 | "github.com/sksmith/go-micro-example/core" 17 | "github.com/sksmith/go-micro-example/core/inventory" 18 | "github.com/sksmith/go-micro-example/testutil" 19 | ) 20 | 21 | func TestReservationSubscribe(t *testing.T) { 22 | mockSvc := inventory.NewMockReservationService() 23 | 24 | subscribeCalled := false 25 | expectedSubId := inventory.ReservationsSubID("subid1") 26 | unsubscribeCalled := false 27 | 28 | mockSvc.SubscribeReservationsFunc = func(ch chan<- inventory.Reservation) (id inventory.ReservationsSubID) { 29 | subscribeCalled = true 30 | go func() { 31 | res := getTestReservations() 32 | for i := 0; i < 3; i++ { 33 | ch <- res[i] 34 | } 35 | close(ch) 36 | }() 37 | 38 | return expectedSubId 39 | } 40 | 41 | mockSvc.UnsubscribeReservationsFunc = func(id inventory.ReservationsSubID) { 42 | unsubscribeCalled = true 43 | } 44 | 45 | resApi := api.NewReservationApi(mockSvc) 46 | r := chi.NewRouter() 47 | resApi.ConfigureRouter(r) 48 | ts := httptest.NewServer(r) 49 | defer ts.Close() 50 | 51 | url := strings.Replace(ts.URL, "http", "ws", 1) + "/subscribe" 52 | 53 | conn, _, _, err := ws.DefaultDialer.Dial(context.Background(), url) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | curRes := getTestReservations() 59 | for i := 0; i < 3; i++ { 60 | got := &inventory.Reservation{} 61 | testutil.ReadWs(conn, got, t) 62 | 63 | reflect.DeepEqual(got, curRes[i]) 64 | } 65 | 66 | if !subscribeCalled { 67 | t.Errorf("subscribe never called") 68 | } 69 | 70 | if !unsubscribeCalled { 71 | t.Errorf("unsubscribe never called") 72 | } 73 | } 74 | 75 | func TestReservationGet(t *testing.T) { 76 | ts, mockResSvc := setupReservationTestServer() 77 | defer ts.Close() 78 | 79 | tests := []struct { 80 | getReservationFunc func(ctx context.Context, ID uint64) (inventory.Reservation, error) 81 | ID string 82 | wantResponse *api.ReservationResponse 83 | wantErr *api.ErrResponse 84 | wantStatusCode int 85 | }{ 86 | { 87 | getReservationFunc: func(ctx context.Context, ID uint64) (inventory.Reservation, error) { 88 | return getTestReservations()[0], nil 89 | }, 90 | ID: "1", 91 | wantResponse: &api.ReservationResponse{Reservation: getTestReservations()[0]}, 92 | wantErr: nil, 93 | wantStatusCode: http.StatusOK, 94 | }, 95 | { 96 | getReservationFunc: func(ctx context.Context, ID uint64) (inventory.Reservation, error) { 97 | return inventory.Reservation{}, core.ErrNotFound 98 | }, 99 | ID: "1", 100 | wantResponse: nil, 101 | wantErr: api.ErrNotFound, 102 | wantStatusCode: http.StatusNotFound, 103 | }, 104 | { 105 | getReservationFunc: func(ctx context.Context, ID uint64) (inventory.Reservation, error) { 106 | return inventory.Reservation{}, errors.New("some unexpected error") 107 | }, 108 | ID: "1", 109 | wantResponse: nil, 110 | wantErr: api.ErrInternalServer, 111 | wantStatusCode: http.StatusInternalServerError, 112 | }, 113 | } 114 | 115 | for _, test := range tests { 116 | mockResSvc.GetReservationFunc = test.getReservationFunc 117 | 118 | url := ts.URL + "/" + test.ID 119 | res, err := http.Get(url) 120 | if err != nil { 121 | t.Fatal(err) 122 | } 123 | 124 | if res.StatusCode != test.wantStatusCode { 125 | t.Errorf("status code got=%d want=%d", res.StatusCode, test.wantStatusCode) 126 | } 127 | 128 | if test.wantErr == nil { 129 | got := api.ReservationResponse{} 130 | testutil.Unmarshal(res, &got, t) 131 | 132 | if !reflect.DeepEqual(got, *test.wantResponse) { 133 | t.Errorf("reservation\n got=%+v\nwant=%+v", got, *test.wantResponse) 134 | } 135 | } else { 136 | got := &api.ErrResponse{} 137 | testutil.Unmarshal(res, got, t) 138 | 139 | if got.StatusText != test.wantErr.StatusText { 140 | t.Errorf("status text got=%s want=%s", got.StatusText, test.wantErr.StatusText) 141 | } 142 | if got.ErrorText != test.wantErr.ErrorText { 143 | t.Errorf("error text got=%s want=%s", got.ErrorText, test.wantErr.ErrorText) 144 | } 145 | } 146 | } 147 | } 148 | 149 | func TestReservationCreate(t *testing.T) { 150 | ts, mockResSvc := setupReservationTestServer() 151 | defer ts.Close() 152 | 153 | tests := []struct { 154 | reserveFunc func(ctx context.Context, rr inventory.ReservationRequest) (inventory.Reservation, error) 155 | request *api.ReservationRequest 156 | wantResponse *api.ReservationResponse 157 | wantErr *api.ErrResponse 158 | wantStatusCode int 159 | }{ 160 | { 161 | reserveFunc: func(ctx context.Context, rr inventory.ReservationRequest) (inventory.Reservation, error) { 162 | return getTestReservations()[0], nil 163 | }, 164 | request: createReservationRequest("requestid1", "requester1", "sku1", 1), 165 | wantResponse: &api.ReservationResponse{Reservation: getTestReservations()[0]}, 166 | wantErr: nil, 167 | wantStatusCode: http.StatusCreated, 168 | }, 169 | { 170 | reserveFunc: func(ctx context.Context, rr inventory.ReservationRequest) (inventory.Reservation, error) { 171 | return inventory.Reservation{}, core.ErrNotFound 172 | }, 173 | request: createReservationRequest("requestid1", "requester1", "sku1", 1), 174 | wantResponse: nil, 175 | wantErr: api.ErrNotFound, 176 | wantStatusCode: http.StatusNotFound, 177 | }, 178 | { 179 | reserveFunc: func(ctx context.Context, rr inventory.ReservationRequest) (inventory.Reservation, error) { 180 | return inventory.Reservation{}, errors.New("some unexpected error") 181 | }, 182 | request: createReservationRequest("requestid1", "requester1", "sku1", 1), 183 | wantResponse: nil, 184 | wantErr: api.ErrInternalServer, 185 | wantStatusCode: http.StatusInternalServerError, 186 | }, 187 | } 188 | 189 | for _, test := range tests { 190 | mockResSvc.ReserveFunc = test.reserveFunc 191 | 192 | url := ts.URL 193 | res := testutil.Put(url, test.request, t) 194 | 195 | if res.StatusCode != test.wantStatusCode { 196 | t.Errorf("status code got=%d want=%d", res.StatusCode, test.wantStatusCode) 197 | } 198 | 199 | if test.wantErr == nil { 200 | got := api.ReservationResponse{} 201 | testutil.Unmarshal(res, &got, t) 202 | 203 | if !reflect.DeepEqual(got, *test.wantResponse) { 204 | t.Errorf("reservation\n got=%+v\nwant=%+v", got, *test.wantResponse) 205 | } 206 | } else { 207 | got := &api.ErrResponse{} 208 | testutil.Unmarshal(res, got, t) 209 | 210 | if got.StatusText != test.wantErr.StatusText { 211 | t.Errorf("status text got=%s want=%s", got.StatusText, test.wantErr.StatusText) 212 | } 213 | if got.ErrorText != test.wantErr.ErrorText { 214 | t.Errorf("error text got=%s want=%s", got.ErrorText, test.wantErr.ErrorText) 215 | } 216 | } 217 | } 218 | } 219 | 220 | func TestReservationList(t *testing.T) { 221 | ts, mockResSvc := setupReservationTestServer() 222 | defer ts.Close() 223 | 224 | tests := []struct { 225 | getReservationsFunc func(ctx context.Context, options inventory.GetReservationsOptions, limit int, offset int) ([]inventory.Reservation, error) 226 | url string 227 | wantResponse interface{} 228 | wantStatusCode int 229 | }{ 230 | { 231 | getReservationsFunc: func(ctx context.Context, options inventory.GetReservationsOptions, limit int, offset int) ([]inventory.Reservation, error) { 232 | if options.Sku != "" { 233 | t.Errorf("sku got=%s want=%s", options.Sku, "") 234 | } 235 | if options.State != inventory.None { 236 | t.Errorf("state got=%s want=%s", options.State, inventory.None) 237 | } 238 | if limit != 50 { 239 | t.Errorf("limit got=%d want=%d", limit, 50) 240 | } 241 | if offset != 0 { 242 | t.Errorf("offset got=%d want=%d", offset, 0) 243 | } 244 | return getTestReservations(), nil 245 | }, 246 | url: ts.URL, 247 | wantResponse: getTestReservationResponses(), 248 | wantStatusCode: http.StatusOK, 249 | }, 250 | { 251 | getReservationsFunc: func(ctx context.Context, options inventory.GetReservationsOptions, limit int, offset int) ([]inventory.Reservation, error) { 252 | if options.Sku != "somesku" { 253 | t.Errorf("sku got=%s want=%s", options.Sku, "somesku") 254 | } 255 | if options.State != inventory.Open { 256 | t.Errorf("state got=%s want=%s", options.State, inventory.Open) 257 | } 258 | if limit != 111 { 259 | t.Errorf("limit got=%d want=%d", limit, 111) 260 | } 261 | if offset != 222 { 262 | t.Errorf("offset got=%d want=%d", offset, 0) 263 | } 264 | return getTestReservations(), nil 265 | }, 266 | url: ts.URL + "?sku=somesku&state=Open&limit=111&offset=222", 267 | wantResponse: getTestReservationResponses(), 268 | wantStatusCode: http.StatusOK, 269 | }, 270 | { 271 | getReservationsFunc: func(ctx context.Context, options inventory.GetReservationsOptions, limit int, offset int) ([]inventory.Reservation, error) { 272 | if options.State != inventory.Closed { 273 | t.Errorf("state got=%s want=%s", options.State, inventory.Closed) 274 | } 275 | return getTestReservations(), nil 276 | }, 277 | url: ts.URL + "?state=Closed", 278 | wantResponse: getTestReservationResponses(), 279 | wantStatusCode: http.StatusOK, 280 | }, 281 | { 282 | getReservationsFunc: nil, 283 | url: ts.URL + "?state=SomeInvalidState", 284 | wantResponse: api.ErrInvalidRequest(errors.New("invalid state")), 285 | wantStatusCode: http.StatusBadRequest, 286 | }, 287 | { 288 | getReservationsFunc: func(ctx context.Context, options inventory.GetReservationsOptions, limit int, offset int) ([]inventory.Reservation, error) { 289 | return []inventory.Reservation{}, core.ErrNotFound 290 | }, 291 | url: ts.URL, 292 | wantResponse: api.ErrNotFound, 293 | wantStatusCode: http.StatusNotFound, 294 | }, 295 | { 296 | getReservationsFunc: func(ctx context.Context, options inventory.GetReservationsOptions, limit int, offset int) ([]inventory.Reservation, error) { 297 | return []inventory.Reservation{}, nil 298 | }, 299 | url: ts.URL + "?sku=someunknownsku", 300 | wantResponse: convertReservationsToResponse([]inventory.Reservation{}), 301 | wantStatusCode: http.StatusOK, 302 | }, 303 | { 304 | getReservationsFunc: func(ctx context.Context, options inventory.GetReservationsOptions, limit int, offset int) ([]inventory.Reservation, error) { 305 | return []inventory.Reservation{}, errors.New("some unexpected error") 306 | }, 307 | url: ts.URL, 308 | wantResponse: api.ErrInternalServer, 309 | wantStatusCode: http.StatusInternalServerError, 310 | }, 311 | } 312 | 313 | for _, test := range tests { 314 | mockResSvc.GetReservationsFunc = test.getReservationsFunc 315 | 316 | res, err := http.Get(test.url) 317 | if err != nil { 318 | t.Fatal(err) 319 | } 320 | 321 | if res.StatusCode != test.wantStatusCode { 322 | t.Errorf("status code got=%d want=%d", res.StatusCode, test.wantStatusCode) 323 | } 324 | 325 | if test.wantStatusCode == http.StatusBadRequest || 326 | test.wantStatusCode == http.StatusInternalServerError || 327 | test.wantStatusCode == http.StatusNotFound { 328 | 329 | want := test.wantResponse.(*api.ErrResponse) 330 | got := &api.ErrResponse{} 331 | testutil.Unmarshal(res, got, t) 332 | 333 | if got.StatusText != want.StatusText { 334 | t.Errorf("status text got=%s want=%s", got.StatusText, want.StatusText) 335 | } 336 | if got.ErrorText != want.ErrorText { 337 | t.Errorf("error text got=%s want=%s", got.ErrorText, want.ErrorText) 338 | } 339 | } else { 340 | want := test.wantResponse.([]api.ReservationResponse) 341 | got := []api.ReservationResponse{} 342 | testutil.Unmarshal(res, &got, t) 343 | 344 | if !reflect.DeepEqual(got, want) { 345 | t.Errorf("reservation\n got=%+v\nwant=%+v", got, want) 346 | } 347 | } 348 | } 349 | } 350 | 351 | func createReservationRequest(requestID, requester, sku string, quantity int64) *api.ReservationRequest { 352 | return &api.ReservationRequest{ReservationRequest: &inventory.ReservationRequest{ 353 | Sku: sku, RequestID: requestID, Requester: requester, Quantity: quantity}, 354 | } 355 | } 356 | 357 | func setupReservationTestServer() (*httptest.Server, *inventory.MockReservationService) { 358 | mockSvc := inventory.NewMockReservationService() 359 | invApi := api.NewReservationApi(mockSvc) 360 | r := chi.NewRouter() 361 | invApi.ConfigureRouter(r) 362 | ts := httptest.NewServer(r) 363 | 364 | return ts, mockSvc 365 | } 366 | 367 | var testReservations = []inventory.Reservation{ 368 | {ID: 1, RequestID: "requestID1", Requester: "requester1", Sku: "sku1", State: inventory.Closed, ReservedQuantity: 1, RequestedQuantity: 1, Created: getTime("2020-01-01T01:01:01Z")}, 369 | {ID: 2, RequestID: "requestID2", Requester: "requester2", Sku: "sku2", State: inventory.Open, ReservedQuantity: 1, RequestedQuantity: 2, Created: getTime("2020-01-01T01:01:01Z")}, 370 | {ID: 3, RequestID: "requestID3", Requester: "requester3", Sku: "sku3", State: inventory.None, ReservedQuantity: 0, RequestedQuantity: 3, Created: getTime("2020-01-01T01:01:01Z")}, 371 | } 372 | 373 | func getTestReservations() []inventory.Reservation { 374 | return testReservations 375 | } 376 | 377 | func getTestReservationResponses() []api.ReservationResponse { 378 | responses := []api.ReservationResponse{} 379 | 380 | for _, res := range testReservations { 381 | responses = append(responses, api.ReservationResponse{Reservation: res}) 382 | } 383 | 384 | return responses 385 | } 386 | 387 | func convertReservationsToResponse(reservations []inventory.Reservation) []api.ReservationResponse { 388 | responses := []api.ReservationResponse{} 389 | 390 | for _, res := range reservations { 391 | responses = append(responses, api.ReservationResponse{Reservation: res}) 392 | } 393 | 394 | return responses 395 | } 396 | 397 | func getTime(t string) time.Time { 398 | tm, err := time.Parse(time.RFC3339, t) 399 | if err != nil { 400 | panic(err) 401 | } 402 | return tm 403 | } 404 | -------------------------------------------------------------------------------- /core/inventory/service.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | "github.com/jackc/pgx/v4" 9 | "github.com/pkg/errors" 10 | "github.com/rs/zerolog/log" 11 | "github.com/sksmith/go-micro-example/core" 12 | ) 13 | 14 | func NewService(repo Repository, q InventoryQueue) *service { 15 | log.Info().Msg("creating inventory service...") 16 | return &service{ 17 | repo: repo, 18 | queue: q, 19 | inventorySubs: make(map[InventorySubID]chan<- ProductInventory), 20 | reservationSubs: make(map[ReservationsSubID]chan<- Reservation), 21 | } 22 | } 23 | 24 | type InventorySubID string 25 | type ReservationsSubID string 26 | 27 | type GetReservationsOptions struct { 28 | Sku string 29 | State ReserveState 30 | } 31 | 32 | type service struct { 33 | repo Repository 34 | queue InventoryQueue 35 | inventorySubs map[InventorySubID]chan<- ProductInventory 36 | reservationSubs map[ReservationsSubID]chan<- Reservation 37 | } 38 | 39 | func (s *service) CreateProduct(ctx context.Context, product Product) error { 40 | const funcName = "CreateProduct" 41 | 42 | dbProduct, err := s.repo.GetProduct(ctx, product.Sku) 43 | if err != nil != errors.Is(err, core.ErrNotFound) { 44 | return errors.WithStack(err) 45 | } 46 | if err == nil { 47 | log.Debug().Str("func", funcName).Str("sku", dbProduct.Sku).Msg("product already exists") 48 | return nil 49 | } 50 | 51 | tx, err := s.repo.BeginTransaction(ctx) 52 | if err != nil { 53 | return errors.WithStack(err) 54 | } 55 | defer func() { 56 | if err != nil { 57 | rollback(ctx, tx, err) 58 | } 59 | }() 60 | 61 | log.Debug().Str("func", funcName).Str("sku", product.Sku).Msg("creating product") 62 | if err = s.repo.SaveProduct(ctx, product, core.UpdateOptions{Tx: tx}); err != nil { 63 | return errors.WithStack(err) 64 | } 65 | 66 | log.Debug().Str("func", funcName).Str("sku", product.Sku).Msg("creating product inventory") 67 | pi := ProductInventory{Product: product} 68 | 69 | if err = s.repo.SaveProductInventory(ctx, pi, core.UpdateOptions{Tx: tx}); err != nil { 70 | return errors.WithStack(err) 71 | } 72 | 73 | if err = tx.Commit(ctx); err != nil { 74 | return errors.WithStack(err) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func (s *service) Produce(ctx context.Context, product Product, pr ProductionRequest) error { 81 | const funcName = "Produce" 82 | 83 | log.Debug(). 84 | Str("func", funcName). 85 | Str("sku", product.Sku). 86 | Str("requestId", pr.RequestID). 87 | Int64("quantity", pr.Quantity). 88 | Msg("producing inventory") 89 | 90 | if pr.RequestID == "" { 91 | return errors.New("request id is required") 92 | } 93 | if pr.Quantity < 1 { 94 | return errors.New("quantity must be greater than zero") 95 | } 96 | 97 | event, err := s.repo.GetProductionEventByRequestID(ctx, pr.RequestID) 98 | if err != nil && !errors.Is(err, core.ErrNotFound) { 99 | return errors.WithStack(err) 100 | } 101 | 102 | if event.RequestID != "" { 103 | log.Debug().Str("func", funcName).Str("requestId", pr.RequestID).Msg("production request already exists") 104 | return nil 105 | } 106 | 107 | event = ProductionEvent{ 108 | RequestID: pr.RequestID, 109 | Sku: product.Sku, 110 | Quantity: pr.Quantity, 111 | Created: time.Now(), 112 | } 113 | 114 | tx, err := s.repo.BeginTransaction(ctx) 115 | if err != nil { 116 | return errors.WithStack(err) 117 | } 118 | defer func() { 119 | if err != nil { 120 | rollback(ctx, tx, err) 121 | } 122 | }() 123 | 124 | if err = s.repo.SaveProductionEvent(ctx, &event, core.UpdateOptions{Tx: tx}); err != nil { 125 | return errors.WithMessage(err, "failed to save production event") 126 | } 127 | 128 | productInventory, err := s.repo.GetProductInventory(ctx, product.Sku, core.QueryOptions{Tx: tx, ForUpdate: true}) 129 | if err != nil { 130 | return errors.WithMessage(err, "failed to get product inventory") 131 | } 132 | 133 | productInventory.Available += event.Quantity 134 | if err = s.repo.SaveProductInventory(ctx, productInventory, core.UpdateOptions{Tx: tx}); err != nil { 135 | return errors.WithMessage(err, "failed to add production to product") 136 | } 137 | 138 | if err = tx.Commit(ctx); err != nil { 139 | return errors.WithMessage(err, "failed to commit production transaction") 140 | } 141 | 142 | err = s.publishInventory(ctx, productInventory) 143 | if err != nil { 144 | return errors.WithMessage(err, "failed to publish inventory") 145 | } 146 | 147 | if err = s.FillReserves(ctx, product); err != nil { 148 | return errors.WithMessage(err, "failed to fill reserves after production") 149 | } 150 | 151 | return nil 152 | } 153 | 154 | func (s *service) Reserve(ctx context.Context, rr ReservationRequest) (Reservation, error) { 155 | const funcName = "Reserve" 156 | 157 | log.Debug(). 158 | Str("func", funcName). 159 | Str("requestID", rr.RequestID). 160 | Str("sku", rr.Sku). 161 | Str("requester", rr.Requester). 162 | Int64("quantity", rr.Quantity). 163 | Msg("reserving inventory") 164 | 165 | if err := validateReservationRequest(rr); err != nil { 166 | return Reservation{}, err 167 | } 168 | 169 | tx, err := s.repo.BeginTransaction(ctx) 170 | defer func() { 171 | if err != nil { 172 | rollback(ctx, tx, err) 173 | } 174 | }() 175 | if err != nil { 176 | return Reservation{}, err 177 | } 178 | 179 | pr, err := s.repo.GetProduct(ctx, rr.Sku, core.QueryOptions{Tx: tx, ForUpdate: true}) 180 | if err != nil { 181 | log.Error().Err(err).Str("requestId", rr.RequestID).Msg("failed to get product") 182 | return Reservation{}, errors.WithStack(err) 183 | } 184 | 185 | res, err := s.repo.GetReservationByRequestID(ctx, rr.RequestID, core.QueryOptions{Tx: tx, ForUpdate: true}) 186 | if err != nil && !errors.Is(err, core.ErrNotFound) { 187 | log.Error().Err(err).Str("requestId", rr.RequestID).Msg("failed to get reservation request") 188 | return Reservation{}, errors.WithStack(err) 189 | } 190 | if res.RequestID != "" { 191 | log.Debug().Str("func", funcName).Str("requestId", rr.RequestID).Msg("reservation already exists, returning it") 192 | rollback(ctx, tx, err) 193 | return res, nil 194 | } 195 | 196 | res = Reservation{ 197 | RequestID: rr.RequestID, 198 | Requester: rr.Requester, 199 | Sku: rr.Sku, 200 | State: Open, 201 | RequestedQuantity: rr.Quantity, 202 | Created: time.Now(), 203 | } 204 | 205 | if err = s.repo.SaveReservation(ctx, &res, core.UpdateOptions{Tx: tx}); err != nil { 206 | return Reservation{}, errors.WithStack(err) 207 | } 208 | 209 | if err = tx.Commit(ctx); err != nil { 210 | return Reservation{}, errors.WithStack(err) 211 | } 212 | 213 | if err = s.FillReserves(ctx, pr); err != nil { 214 | return Reservation{}, errors.WithStack(err) 215 | } 216 | 217 | return res, nil 218 | } 219 | 220 | func validateReservationRequest(rr ReservationRequest) error { 221 | if rr.RequestID == "" { 222 | return errors.New("request id is required") 223 | } 224 | if rr.Requester == "" { 225 | return errors.New("requester is required") 226 | } 227 | if rr.Sku == "" { 228 | return errors.New("sku is requred") 229 | } 230 | if rr.Quantity < 1 { 231 | return errors.New("quantity is required") 232 | } 233 | return nil 234 | } 235 | 236 | func (s *service) GetAllProductInventory(ctx context.Context, limit, offset int) ([]ProductInventory, error) { 237 | return s.repo.GetAllProductInventory(ctx, limit, offset) 238 | } 239 | 240 | func (s *service) GetProduct(ctx context.Context, sku string) (Product, error) { 241 | const funcName = "GetProduct" 242 | 243 | log.Debug().Str("func", funcName).Str("sku", sku).Msg("getting product") 244 | 245 | product, err := s.repo.GetProduct(ctx, sku) 246 | if err != nil { 247 | return product, errors.WithStack(err) 248 | } 249 | return product, nil 250 | } 251 | 252 | func (s *service) GetProductInventory(ctx context.Context, sku string) (ProductInventory, error) { 253 | const funcName = "GetProductInventory" 254 | 255 | log.Debug().Str("func", funcName).Str("sku", sku).Msg("getting product inventory") 256 | 257 | product, err := s.repo.GetProductInventory(ctx, sku) 258 | if err != nil { 259 | return product, errors.WithStack(err) 260 | } 261 | return product, nil 262 | } 263 | 264 | func (s *service) GetReservation(ctx context.Context, ID uint64) (Reservation, error) { 265 | const funcName = "GetReservation" 266 | 267 | log.Debug().Str("func", funcName).Uint64("id", ID).Msg("getting reservation") 268 | 269 | rsv, err := s.repo.GetReservation(ctx, ID) 270 | if err != nil { 271 | return rsv, errors.WithStack(err) 272 | } 273 | return rsv, nil 274 | } 275 | 276 | func (s *service) GetReservations(ctx context.Context, options GetReservationsOptions, limit, offset int) ([]Reservation, error) { 277 | const funcName = "GetProductInventory" 278 | 279 | log.Debug(). 280 | Str("func", funcName). 281 | Str("sku", options.Sku). 282 | Str("state", string(options.State)). 283 | Msg("getting reservations") 284 | 285 | rsv, err := s.repo.GetReservations(ctx, options, limit, offset) 286 | if err != nil { 287 | return rsv, errors.WithStack(err) 288 | } 289 | return rsv, nil 290 | } 291 | 292 | func (s *service) SubscribeInventory(ch chan<- ProductInventory) (id InventorySubID) { 293 | id = InventorySubID(uuid.NewString()) 294 | s.inventorySubs[id] = ch 295 | log.Debug().Interface("clientId", id).Msg("subscribing to inventory") 296 | return id 297 | } 298 | 299 | func (s *service) UnsubscribeInventory(id InventorySubID) { 300 | log.Debug().Interface("clientId", id).Msg("unsubscribing from inventory") 301 | close(s.inventorySubs[id]) 302 | delete(s.inventorySubs, id) 303 | } 304 | 305 | func (s *service) SubscribeReservations(ch chan<- Reservation) (id ReservationsSubID) { 306 | id = ReservationsSubID(uuid.NewString()) 307 | s.reservationSubs[id] = ch 308 | log.Debug().Interface("clientId", id).Msg("subscribing to reservations") 309 | return id 310 | } 311 | 312 | func (s *service) UnsubscribeReservations(id ReservationsSubID) { 313 | log.Debug().Interface("clientId", id).Msg("unsubscribing from reservations") 314 | close(s.reservationSubs[id]) 315 | delete(s.reservationSubs, id) 316 | } 317 | 318 | func (s *service) FillReserves(ctx context.Context, product Product) error { 319 | const funcName = "fillReserves" 320 | 321 | tx, err := s.repo.BeginTransaction(ctx) 322 | defer func() { 323 | if err != nil { 324 | rollback(ctx, tx, err) 325 | } 326 | }() 327 | if err != nil { 328 | return errors.WithStack(err) 329 | } 330 | 331 | openReservations, err := s.repo.GetReservations(ctx, GetReservationsOptions{Sku: product.Sku, State: Open}, 100, 0, core.QueryOptions{Tx: tx, ForUpdate: true}) 332 | if err != nil { 333 | return errors.WithStack(err) 334 | } 335 | 336 | productInventory, err := s.repo.GetProductInventory(ctx, product.Sku, core.QueryOptions{Tx: tx, ForUpdate: true}) 337 | if err != nil { 338 | return errors.WithStack(err) 339 | } 340 | 341 | for _, reservation := range openReservations { 342 | var subtx pgx.Tx 343 | subtx, err = tx.Begin(ctx) 344 | if err != nil { 345 | return err 346 | } 347 | defer func() { 348 | if err != nil { 349 | rollback(ctx, subtx, err) 350 | } 351 | }() 352 | reservation := reservation 353 | 354 | log.Trace(). 355 | Str("func", funcName). 356 | Str("sku", product.Sku). 357 | Str("reservation.RequestID", reservation.RequestID). 358 | Int64("productInventory.Available", productInventory.Available). 359 | Msg("fulfilling reservation") 360 | 361 | if productInventory.Available == 0 { 362 | break 363 | } 364 | 365 | reserveAmount := reservation.RequestedQuantity - reservation.ReservedQuantity 366 | if reserveAmount > productInventory.Available { 367 | reserveAmount = productInventory.Available 368 | } 369 | productInventory.Available -= reserveAmount 370 | reservation.ReservedQuantity += reserveAmount 371 | 372 | if reservation.ReservedQuantity == reservation.RequestedQuantity { 373 | reservation.State = Closed 374 | } 375 | 376 | log.Debug(). 377 | Str("func", funcName). 378 | Str("sku", product.Sku). 379 | Str("reservation.RequestID", reservation.RequestID). 380 | Msg("saving product inventory") 381 | 382 | err = s.repo.SaveProductInventory(ctx, productInventory, core.UpdateOptions{Tx: tx}) 383 | if err != nil { 384 | return errors.WithStack(err) 385 | } 386 | 387 | log.Debug(). 388 | Str("func", funcName). 389 | Str("sku", product.Sku). 390 | Str("reservation.RequestID", reservation.RequestID). 391 | Str("state", string(reservation.State)). 392 | Msg("updating reservation") 393 | 394 | err = s.repo.UpdateReservation(ctx, reservation.ID, reservation.State, reservation.ReservedQuantity, core.UpdateOptions{Tx: tx}) 395 | if err != nil { 396 | return errors.WithStack(err) 397 | } 398 | 399 | if err = subtx.Commit(ctx); err != nil { 400 | return errors.WithStack(err) 401 | } 402 | 403 | err = s.publishInventory(ctx, productInventory) 404 | if err != nil { 405 | return errors.WithStack(err) 406 | } 407 | 408 | err = s.publishReservation(ctx, reservation) 409 | if err != nil { 410 | return errors.WithStack(err) 411 | } 412 | } 413 | 414 | if err = tx.Commit(ctx); err != nil { 415 | return errors.WithStack(err) 416 | } 417 | 418 | return nil 419 | } 420 | 421 | func (s *service) publishInventory(ctx context.Context, pi ProductInventory) error { 422 | err := s.queue.PublishInventory(ctx, pi) 423 | if err != nil { 424 | return errors.WithMessage(err, "failed to publish inventory to queue") 425 | } 426 | go s.notifyInventorySubscribers(pi) 427 | return nil 428 | } 429 | 430 | func (s *service) publishReservation(ctx context.Context, r Reservation) error { 431 | err := s.queue.PublishReservation(ctx, r) 432 | if err != nil { 433 | return errors.WithMessage(err, "failed to publish reservation to queue") 434 | } 435 | go s.notifyReservationSubscribers(r) 436 | return nil 437 | } 438 | 439 | func (s *service) notifyInventorySubscribers(pi ProductInventory) { 440 | for id, ch := range s.inventorySubs { 441 | log.Debug().Interface("clientId", id).Interface("productInventory", pi).Msg("notifying subscriber of inventory update") 442 | ch <- pi 443 | } 444 | } 445 | 446 | func (s *service) notifyReservationSubscribers(r Reservation) { 447 | for id, ch := range s.reservationSubs { 448 | log.Debug().Interface("clientId", id).Interface("productInventory", r).Msg("notifying subscriber of reservation update") 449 | ch <- r 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /api/inventoryapi_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/gobwas/ws" 14 | "github.com/sksmith/go-micro-example/api" 15 | "github.com/sksmith/go-micro-example/core" 16 | "github.com/sksmith/go-micro-example/core/inventory" 17 | "github.com/sksmith/go-micro-example/testutil" 18 | 19 | "github.com/go-chi/chi" 20 | ) 21 | 22 | func TestInventorySubscribe(t *testing.T) { 23 | mockSvc := inventory.NewMockInventoryService() 24 | 25 | subscribeCalled := false 26 | expectedSubId := inventory.InventorySubID("subid1") 27 | unsubscribeCalled := false 28 | 29 | mockSvc.SubscribeInventoryFunc = func(ch chan<- inventory.ProductInventory) (id inventory.InventorySubID) { 30 | subscribeCalled = true 31 | go func() { 32 | inv := getTestProductInventory() 33 | for i := 0; i < 3; i++ { 34 | ch <- inv[i] 35 | } 36 | close(ch) 37 | }() 38 | 39 | return expectedSubId 40 | } 41 | 42 | mockSvc.UnsubscribeInventoryFunc = func(id inventory.InventorySubID) { 43 | unsubscribeCalled = true 44 | } 45 | 46 | invApi := api.NewInventoryApi(mockSvc) 47 | r := chi.NewRouter() 48 | invApi.ConfigureRouter(r) 49 | ts := httptest.NewServer(r) 50 | defer ts.Close() 51 | 52 | url := strings.Replace(ts.URL, "http", "ws", 1) + "/subscribe" 53 | 54 | conn, _, _, err := ws.DefaultDialer.Dial(context.Background(), url) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | 59 | curInv := getTestProductInventory() 60 | for i := 0; i < 3; i++ { 61 | got := &inventory.ProductInventory{} 62 | testutil.ReadWs(conn, got, t) 63 | 64 | if got.Name != curInv[i].Name { 65 | t.Errorf("unexpected ws response[%d] got=[%s] want=[%s]", i, got.Name, curInv[i].Name) 66 | } 67 | } 68 | 69 | if !subscribeCalled { 70 | t.Errorf("subscribe never called") 71 | } 72 | 73 | if !unsubscribeCalled { 74 | t.Errorf("unsubscribe never called") 75 | } 76 | } 77 | 78 | func setupInventoryTestServer() (*httptest.Server, *inventory.MockInventoryService) { 79 | mockSvc := inventory.NewMockInventoryService() 80 | invApi := api.NewInventoryApi(mockSvc) 81 | r := chi.NewRouter() 82 | invApi.ConfigureRouter(r) 83 | ts := httptest.NewServer(r) 84 | 85 | return ts, mockSvc 86 | } 87 | 88 | func TestInventoryList(t *testing.T) { 89 | ts, mockInvSvc := setupInventoryTestServer() 90 | defer ts.Close() 91 | 92 | tests := []struct { 93 | limit int 94 | wantLimit int 95 | offset int 96 | wantOffset int 97 | inventory []inventory.ProductInventory 98 | serviceErr error 99 | wantInventory []inventory.ProductInventory 100 | wantErr *api.ErrResponse 101 | wantStatusCode int 102 | }{ 103 | { 104 | limit: -1, 105 | wantLimit: 50, 106 | offset: -1, 107 | wantOffset: 0, 108 | inventory: getTestProductInventory(), 109 | wantInventory: getTestProductInventory(), 110 | serviceErr: nil, 111 | wantErr: nil, 112 | wantStatusCode: http.StatusOK, 113 | }, 114 | { 115 | limit: 5, 116 | wantLimit: 5, 117 | offset: 7, 118 | wantOffset: 7, 119 | inventory: getTestProductInventory(), 120 | wantInventory: getTestProductInventory(), 121 | serviceErr: nil, 122 | wantErr: nil, 123 | wantStatusCode: http.StatusOK, 124 | }, 125 | { 126 | limit: -1, 127 | wantLimit: 50, 128 | offset: -1, 129 | wantOffset: 0, 130 | inventory: []inventory.ProductInventory{}, 131 | wantInventory: []inventory.ProductInventory{}, 132 | serviceErr: nil, 133 | wantErr: nil, 134 | wantStatusCode: http.StatusOK, 135 | }, 136 | { 137 | limit: -1, 138 | wantLimit: 50, 139 | offset: -1, 140 | wantOffset: 0, 141 | inventory: []inventory.ProductInventory{}, 142 | wantInventory: []inventory.ProductInventory{}, 143 | serviceErr: errors.New("something bad happened"), 144 | wantErr: api.ErrInternalServer, 145 | wantStatusCode: http.StatusInternalServerError, 146 | }, 147 | } 148 | 149 | for _, test := range tests { 150 | gotLimit := -1 151 | gotOffset := -1 152 | mockInvSvc.GetAllProductInventoryFunc = func(ctx context.Context, limit int, offset int) ([]inventory.ProductInventory, error) { 153 | gotLimit = limit 154 | gotOffset = offset 155 | return test.inventory, test.serviceErr 156 | } 157 | 158 | url := ts.URL 159 | if test.limit > -1 { 160 | url += fmt.Sprintf("?limit=%d&offset=%d", test.limit, test.offset) 161 | } 162 | 163 | res, err := http.Get(url) 164 | if err != nil { 165 | t.Fatal(err) 166 | } 167 | 168 | if test.wantErr == nil { 169 | got := []inventory.ProductInventory{} 170 | testutil.Unmarshal(res, &got, t) 171 | 172 | if !reflect.DeepEqual(got, test.wantInventory) { 173 | t.Errorf("inventory\n got:%+v\nwant:%+v\n", got, test.wantInventory) 174 | } 175 | } else { 176 | got := api.ErrResponse{} 177 | testutil.Unmarshal(res, &got, t) 178 | 179 | if got.StatusText != test.wantErr.StatusText { 180 | t.Errorf("errorResponse\n got:%v\nwant:%v\n", got.StatusText, test.wantErr.StatusText) 181 | } 182 | } 183 | 184 | if res.StatusCode != test.wantStatusCode { 185 | t.Errorf("status code got=[%d] want=[%d]", res.StatusCode, test.wantStatusCode) 186 | } 187 | 188 | if gotLimit != test.wantLimit { 189 | t.Errorf("limit got=[%d] want=[%d]", gotLimit, test.limit) 190 | } 191 | 192 | if gotOffset != test.wantOffset { 193 | t.Errorf("offset got=[%d] want=[%d]", gotOffset, test.offset) 194 | } 195 | } 196 | } 197 | 198 | func TestInventoryCreateProduct(t *testing.T) { 199 | ts, mockInvSvc := setupInventoryTestServer() 200 | defer ts.Close() 201 | 202 | tests := []struct { 203 | request api.CreateProductRequest 204 | serviceErr error 205 | wantProductResponse *api.ProductResponse 206 | wantErr *api.ErrResponse 207 | wantStatusCode int 208 | }{ 209 | { 210 | request: createProductRequest("name1", "sku1", "upc1"), 211 | serviceErr: nil, 212 | wantProductResponse: createProductResponse("name1", "sku1", "upc1", 0), 213 | wantErr: nil, 214 | wantStatusCode: http.StatusCreated, 215 | }, 216 | { 217 | request: createProductRequest("name1", "sku1", "upc1"), 218 | serviceErr: errors.New("some unexpected error"), 219 | wantProductResponse: nil, 220 | wantErr: api.ErrInternalServer, 221 | wantStatusCode: http.StatusInternalServerError, 222 | }, 223 | { 224 | request: createProductRequest("name1", "sku1", ""), 225 | serviceErr: nil, 226 | wantProductResponse: nil, 227 | wantErr: api.ErrInvalidRequest(errors.New("missing required field(s)")), 228 | wantStatusCode: http.StatusBadRequest, 229 | }, 230 | { 231 | request: createProductRequest("name1", "", "upc1"), 232 | serviceErr: nil, 233 | wantProductResponse: nil, 234 | wantErr: api.ErrInvalidRequest(errors.New("missing required field(s)")), 235 | wantStatusCode: http.StatusBadRequest, 236 | }, 237 | { 238 | request: createProductRequest("", "sku1", "upc1"), 239 | serviceErr: nil, 240 | wantProductResponse: nil, 241 | wantErr: api.ErrInvalidRequest(errors.New("missing required field(s)")), 242 | wantStatusCode: http.StatusBadRequest, 243 | }, 244 | } 245 | 246 | for _, test := range tests { 247 | mockInvSvc.CreateProductFunc = func(ctx context.Context, product inventory.Product) error { 248 | return test.serviceErr 249 | } 250 | 251 | res := testutil.Put(ts.URL, test.request, t) 252 | 253 | if res.StatusCode != test.wantStatusCode { 254 | t.Errorf("status code got=%d\nwant=%d", res.StatusCode, test.wantStatusCode) 255 | } 256 | 257 | if test.wantErr == nil { 258 | got := api.ProductResponse{} 259 | testutil.Unmarshal(res, &got, t) 260 | 261 | if !reflect.DeepEqual(got, *test.wantProductResponse) { 262 | t.Errorf("product\n got=%+v\nwant=%+v", got, *test.wantProductResponse) 263 | } 264 | } else { 265 | got := &api.ErrResponse{} 266 | testutil.Unmarshal(res, got, t) 267 | 268 | if got.StatusText != test.wantErr.StatusText { 269 | t.Errorf("status text got=%s want=%s", got.StatusText, test.wantErr.StatusText) 270 | } 271 | if got.ErrorText != test.wantErr.ErrorText { 272 | t.Errorf("error text got=%s want=%s", got.ErrorText, test.wantErr.ErrorText) 273 | } 274 | } 275 | } 276 | } 277 | 278 | func TestInventoryCreateProductionEvent(t *testing.T) { 279 | ts, mockInvSvc := setupInventoryTestServer() 280 | defer ts.Close() 281 | 282 | tests := []struct { 283 | getProductFunc func(ctx context.Context, sku string) (inventory.Product, error) 284 | produceFunc func(ctx context.Context, product inventory.Product, event inventory.ProductionRequest) error 285 | sku string 286 | request *api.CreateProductionEventRequest 287 | wantProductionEventResponse *api.ProductionEventResponse 288 | wantErr *api.ErrResponse 289 | wantStatusCode int 290 | }{ 291 | { 292 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 293 | return getTestProductInventory()[0].Product, nil 294 | }, 295 | produceFunc: func(ctx context.Context, product inventory.Product, event inventory.ProductionRequest) error { 296 | return nil 297 | }, 298 | sku: "testsku1", 299 | request: createProductionEventRequest("abc123", 1), 300 | wantProductionEventResponse: &api.ProductionEventResponse{}, 301 | wantErr: nil, 302 | wantStatusCode: http.StatusCreated, 303 | }, 304 | { 305 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 306 | return inventory.Product{}, core.ErrNotFound 307 | }, 308 | produceFunc: nil, 309 | sku: "testsku1", 310 | request: createProductionEventRequest("abc123", 1), 311 | wantProductionEventResponse: nil, 312 | wantErr: api.ErrNotFound, 313 | wantStatusCode: http.StatusNotFound, 314 | }, 315 | { 316 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 317 | return inventory.Product{}, errors.New("some unexpected error") 318 | }, 319 | produceFunc: nil, 320 | sku: "testsku1", 321 | request: createProductionEventRequest("abc123", 1), 322 | wantProductionEventResponse: nil, 323 | wantErr: api.ErrInternalServer, 324 | wantStatusCode: http.StatusInternalServerError, 325 | }, 326 | { 327 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 328 | return getTestProductInventory()[0].Product, nil 329 | }, 330 | produceFunc: func(ctx context.Context, product inventory.Product, event inventory.ProductionRequest) error { 331 | return errors.New("some unexpected error") 332 | }, 333 | sku: "testsku1", 334 | request: createProductionEventRequest("abc123", 1), 335 | wantProductionEventResponse: nil, 336 | wantErr: api.ErrInternalServer, 337 | wantStatusCode: http.StatusInternalServerError, 338 | }, 339 | } 340 | 341 | for _, test := range tests { 342 | mockInvSvc.GetProductFunc = test.getProductFunc 343 | mockInvSvc.ProduceFunc = test.produceFunc 344 | 345 | url := ts.URL + "/" + test.sku + "/productionEvent" 346 | res := testutil.Put(url, test.request, t) 347 | 348 | if res.StatusCode != test.wantStatusCode { 349 | t.Errorf("status code got=%d want=%d", res.StatusCode, test.wantStatusCode) 350 | } 351 | 352 | if test.wantErr == nil { 353 | got := api.ProductionEventResponse{} 354 | testutil.Unmarshal(res, &got, t) 355 | 356 | if !reflect.DeepEqual(got, *test.wantProductionEventResponse) { 357 | t.Errorf("product\n got=%+v\nwant=%+v", got, *test.wantProductionEventResponse) 358 | } 359 | } else { 360 | got := &api.ErrResponse{} 361 | testutil.Unmarshal(res, got, t) 362 | 363 | if got.StatusText != test.wantErr.StatusText { 364 | t.Errorf("status text got=%s want=%s", got.StatusText, test.wantErr.StatusText) 365 | } 366 | if got.ErrorText != test.wantErr.ErrorText { 367 | t.Errorf("error text got=%s want=%s", got.ErrorText, test.wantErr.ErrorText) 368 | } 369 | } 370 | } 371 | } 372 | 373 | func TestInventoryGetProductInventory(t *testing.T) { 374 | ts, mockInvSvc := setupInventoryTestServer() 375 | defer ts.Close() 376 | 377 | tests := []struct { 378 | sku string 379 | getProductFunc func(ctx context.Context, sku string) (inventory.Product, error) 380 | getProductInventoryFunc func(ctx context.Context, sku string) (inventory.ProductInventory, error) 381 | wantProductResponse *api.ProductResponse 382 | wantErr *api.ErrResponse 383 | wantStatusCode int 384 | }{ 385 | { 386 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 387 | return getTestProductInventory()[0].Product, nil 388 | }, 389 | getProductInventoryFunc: func(ctx context.Context, sku string) (inventory.ProductInventory, error) { 390 | return getTestProductInventory()[0], nil 391 | }, 392 | sku: "test1sku", 393 | wantProductResponse: createProductResponse("test1name", "test1sku", "test1upc", 1), 394 | wantErr: nil, 395 | wantStatusCode: http.StatusOK, 396 | }, 397 | { 398 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 399 | return inventory.Product{}, core.ErrNotFound 400 | }, 401 | getProductInventoryFunc: nil, 402 | sku: "test1sku", 403 | wantProductResponse: nil, 404 | wantErr: api.ErrNotFound, 405 | wantStatusCode: http.StatusNotFound, 406 | }, 407 | { 408 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 409 | return getTestProductInventory()[0].Product, nil 410 | }, 411 | getProductInventoryFunc: func(ctx context.Context, sku string) (inventory.ProductInventory, error) { 412 | return inventory.ProductInventory{}, core.ErrNotFound 413 | }, 414 | sku: "test1sku", 415 | wantProductResponse: nil, 416 | wantErr: api.ErrNotFound, 417 | wantStatusCode: http.StatusNotFound, 418 | }, 419 | { 420 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 421 | return inventory.Product{}, errors.New("some unexpected error") 422 | }, 423 | getProductInventoryFunc: nil, 424 | sku: "test1sku", 425 | wantProductResponse: nil, 426 | wantErr: api.ErrInternalServer, 427 | wantStatusCode: http.StatusInternalServerError, 428 | }, 429 | { 430 | getProductFunc: func(ctx context.Context, sku string) (inventory.Product, error) { 431 | return getTestProductInventory()[0].Product, nil 432 | }, 433 | getProductInventoryFunc: func(ctx context.Context, sku string) (inventory.ProductInventory, error) { 434 | return inventory.ProductInventory{}, errors.New("some unexpected error") 435 | }, 436 | sku: "test1sku", 437 | wantProductResponse: nil, 438 | wantErr: api.ErrInternalServer, 439 | wantStatusCode: http.StatusInternalServerError, 440 | }, 441 | } 442 | 443 | for _, test := range tests { 444 | mockInvSvc.GetProductFunc = test.getProductFunc 445 | mockInvSvc.GetProductInventoryFunc = test.getProductInventoryFunc 446 | 447 | res, err := http.Get(ts.URL + "/" + test.sku) 448 | if err != nil { 449 | t.Fatal(err) 450 | } 451 | 452 | if res.StatusCode != test.wantStatusCode { 453 | t.Errorf("status code got=%d want=%d", res.StatusCode, test.wantStatusCode) 454 | } 455 | 456 | if test.wantErr == nil { 457 | got := api.ProductResponse{} 458 | testutil.Unmarshal(res, &got, t) 459 | 460 | if !reflect.DeepEqual(got, *test.wantProductResponse) { 461 | t.Errorf("product\n got=%+v\nwant=%+v", got, *test.wantProductResponse) 462 | } 463 | } else { 464 | got := &api.ErrResponse{} 465 | testutil.Unmarshal(res, got, t) 466 | 467 | if got.StatusText != test.wantErr.StatusText { 468 | t.Errorf("status text got=%s want=%s", got.StatusText, test.wantErr.StatusText) 469 | } 470 | if got.ErrorText != test.wantErr.ErrorText { 471 | t.Errorf("error text got=%s want=%s", got.ErrorText, test.wantErr.ErrorText) 472 | } 473 | } 474 | } 475 | } 476 | 477 | func createProductionEventRequest(requestID string, quantity int64) *api.CreateProductionEventRequest { 478 | return &api.CreateProductionEventRequest{ 479 | ProductionRequest: &inventory.ProductionRequest{RequestID: requestID, Quantity: quantity}, 480 | } 481 | } 482 | 483 | func createProductRequest(name, sku, upc string) api.CreateProductRequest { 484 | return api.CreateProductRequest{Product: inventory.Product{Name: name, Sku: sku, Upc: upc}} 485 | } 486 | 487 | func createProductResponse(name, sku, upc string, available int64) *api.ProductResponse { 488 | return &api.ProductResponse{ 489 | ProductInventory: inventory.ProductInventory{ 490 | Available: available, 491 | Product: inventory.Product{Name: name, Sku: sku, Upc: upc}, 492 | }, 493 | } 494 | } 495 | 496 | func getTestProductInventory() []inventory.ProductInventory { 497 | return []inventory.ProductInventory{ 498 | {Available: 1, Product: inventory.Product{Sku: "test1sku", Upc: "test1upc", Name: "test1name"}}, 499 | {Available: 2, Product: inventory.Product{Sku: "test2sku", Upc: "test2upc", Name: "test2name"}}, 500 | {Available: 3, Product: inventory.Product{Sku: "test3sku", Upc: "test3upc", Name: "test3name"}}, 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "reflect" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/mitchellh/mapstructure" 11 | "github.com/rs/zerolog/log" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | const ( 16 | AppName = "Go Micro Example" 17 | Revision = "1" 18 | ) 19 | 20 | var ( 21 | // Build time arguments 22 | AppVersion string 23 | Sha1Version string 24 | BuildTime string 25 | 26 | // Runtime flags 27 | profile *string 28 | port *string 29 | configSource *string 30 | configUrl *string 31 | configBranch *string 32 | configUser *string 33 | configPass *string 34 | ) 35 | 36 | type StringConfig struct { 37 | Value string `json:"value" yaml:"value"` 38 | Default string `json:"default" yaml:"default"` 39 | Description string `json:"description" yaml:"description"` 40 | } 41 | 42 | type BoolConfig struct { 43 | Value bool `json:"value" yaml:"value"` 44 | Default bool `json:"default" yaml:"default"` 45 | Description string `json:"description" yaml:"description"` 46 | } 47 | 48 | type IntConfig struct { 49 | Value int64 `json:"value" yaml:"value"` 50 | Default int64 `json:"default" yaml:"default"` 51 | Description string `json:"description" yaml:"description"` 52 | } 53 | 54 | type FloatConfig struct { 55 | Value float64 `json:"value" yaml:"value"` 56 | Default float64 `json:"default" yaml:"default"` 57 | Description string `json:"description" yaml:"description"` 58 | } 59 | 60 | type Config struct { 61 | AppName StringConfig `json:"appName" yaml:"appName"` 62 | AppVersion StringConfig `json:"appVersion" yaml:"appVersion"` 63 | Sha1Version StringConfig `json:"sha1Version" yaml:"sha1Version"` 64 | BuildTime StringConfig `json:"buildTime" yaml:"buildTime"` 65 | Profile StringConfig `json:"profile" yaml:"profile"` 66 | Revision StringConfig `json:"revision" yaml:"revision"` 67 | Port StringConfig `json:"port" yaml:"port"` 68 | Config ConfigSource `json:"config" yaml:"config"` 69 | Log LogConfig `json:"log" yaml:"log"` 70 | Db DbConfig `json:"db" yaml:"db"` 71 | RabbitMQ QueueConfig `json:"rabbitmq" yaml:"rabbitmq"` 72 | } 73 | 74 | type ConfigSource struct { 75 | Print BoolConfig `json:"print" yaml:"print"` 76 | Source StringConfig `json:"source" yaml:"source"` 77 | Spring SpringConfig `json:"spring" yaml:"spring"` 78 | Description string `json:"description" yaml:"description"` 79 | } 80 | 81 | type SpringConfig struct { 82 | Url StringConfig `json:"url" yaml:"url"` 83 | Branch StringConfig `json:"branch" yaml:"branch"` 84 | User StringConfig `json:"user" yaml:"user"` 85 | Pass StringConfig `json:"pass" yaml:"pass"` 86 | Description string `json:"description" yaml:"description"` 87 | } 88 | 89 | type LogConfig struct { 90 | Level StringConfig `json:"level" yaml:"level"` 91 | Structured BoolConfig `json:"structured" yaml:"structured"` 92 | Description string `json:"description" yaml:"description"` 93 | } 94 | 95 | type DbConfig struct { 96 | Name StringConfig `json:"name" yaml:"name"` 97 | Host StringConfig `json:"host" yaml:"host"` 98 | Port StringConfig `json:"port" yaml:"port"` 99 | Migrate BoolConfig `json:"migrate" yaml:"migrate"` 100 | MigrationFolder StringConfig `json:"migrationFolder" yaml:"migrationFolder"` 101 | Clean BoolConfig `json:"clean" yaml:"clean"` 102 | User StringConfig `json:"user" yaml:"user"` 103 | Pass StringConfig `json:"pass" yaml:"pass"` 104 | Pool DbPoolConfig `json:"pool" yaml:"pool"` 105 | LogLevel StringConfig `json:"logLevel" yaml:"logLevel"` 106 | Description string `json:"description" yaml:"description"` 107 | } 108 | 109 | type DbPoolConfig struct { 110 | MinSize IntConfig `json:"minPoolSize" yaml:"minPoolSize"` 111 | MaxSize IntConfig `json:"maxPoolSize" yaml:"maxPoolSize"` 112 | MaxConnLife IntConfig `json:"maxConnLife" yaml:"maxConnLife"` 113 | MaxConnIdle IntConfig `json:"maxConnIdle" yaml:"maxConnIdle"` 114 | HealthCheckPeriod IntConfig `json:"healthCheckPeriod" yaml:"healthCheckPeriod"` 115 | Description string `json:"description" yaml:"description"` 116 | } 117 | 118 | type QueueConfig struct { 119 | Host StringConfig `json:"host" yaml:"host"` 120 | Port StringConfig `json:"port" yaml:"port"` 121 | User StringConfig `json:"user" yaml:"user"` 122 | Pass StringConfig `json:"pass" yaml:"pass"` 123 | Inventory InventoryQueueConfig `json:"inventory" yaml:"inventory"` 124 | Reservation ReservationQueueConfig `json:"reservation" yaml:"reservation"` 125 | Product ProductQueueConfig `json:"product" yaml:"product"` 126 | Description string `json:"description" yaml:"description"` 127 | } 128 | 129 | type InventoryQueueConfig struct { 130 | Exchange StringConfig `json:"exchange" yaml:"exchange"` 131 | Description string `json:"description" yaml:"description"` 132 | } 133 | 134 | type ReservationQueueConfig struct { 135 | Exchange StringConfig `json:"exchange" yaml:"exchange"` 136 | Description string `json:"description" yaml:"description"` 137 | } 138 | 139 | type ProductQueueConfig struct { 140 | Queue StringConfig `json:"queue" yaml:"queue"` 141 | Dlt ProductQueueDltConfig `json:"dlt" yaml:"dlt"` 142 | Description string `json:"description" yaml:"description"` 143 | } 144 | 145 | type ProductQueueDltConfig struct { 146 | Exchange StringConfig `json:"exchange" yaml:"exchange"` 147 | Description string `json:"description" yaml:"description"` 148 | } 149 | 150 | func (c *Config) Print() { 151 | if c.Config.Print.Value { 152 | log.Info().Interface("config", c).Msg("the following configurations have successfully loaded") 153 | } 154 | } 155 | 156 | func init() { 157 | def := &Config{} 158 | setupDefaults(def) 159 | 160 | profile = flag.String("p", def.Profile.Default, def.Profile.Description) 161 | port = flag.String("port", def.Port.Default, def.Port.Description) 162 | configSource = flag.String("s", def.Config.Source.Default, def.Config.Source.Description) 163 | configUrl = flag.String("cfgUrl", def.Config.Spring.Url.Default, def.Config.Spring.Url.Description) 164 | configBranch = flag.String("cfgBranch", def.Config.Spring.Branch.Default, def.Config.Spring.Branch.Description) 165 | configUser = flag.String("cfgUser", def.Config.Spring.User.Default, def.Config.Spring.User.Description) 166 | configPass = flag.String("cfgPass", def.Config.Spring.Pass.Default, def.Config.Spring.Pass.Description) 167 | 168 | viper.SetDefault("port", def.Port.Default) 169 | viper.SetDefault("profile", def.Profile.Default) 170 | 171 | viper.SetDefault("config.print", def.Config.Print.Default) 172 | viper.SetDefault("config.source", def.Config.Source.Default) 173 | 174 | viper.SetDefault("log.level", def.Log.Level.Default) 175 | viper.SetDefault("log.structured", def.Log.Structured.Default) 176 | 177 | viper.SetDefault("db.name", def.Db.Name.Default) 178 | viper.SetDefault("db.host", def.Db.Host.Default) 179 | viper.SetDefault("db.port", def.Db.Port.Default) 180 | viper.SetDefault("db.user", def.Db.User.Default) 181 | viper.SetDefault("db.pass", def.Db.Pass.Default) 182 | viper.SetDefault("db.clean", def.Db.Clean.Default) 183 | viper.SetDefault("db.migrate", def.Db.Migrate.Default) 184 | viper.SetDefault("db.migrationFile", def.Db.MigrationFolder.Default) 185 | viper.SetDefault("db.pool.minSize", def.Db.Pool.MinSize.Default) 186 | viper.SetDefault("db.pool.maxSize", def.Db.Pool.MaxSize.Default) 187 | 188 | viper.SetDefault("rabbitmq.host", def.RabbitMQ.Host.Default) 189 | viper.SetDefault("rabbitmq.port", def.RabbitMQ.Port.Default) 190 | viper.SetDefault("rabbitmq.user", def.RabbitMQ.User.Default) 191 | viper.SetDefault("rabbitmq.pass", def.RabbitMQ.Pass.Default) 192 | viper.SetDefault("rabbitmq.inventory.exchange", def.RabbitMQ.Inventory.Exchange.Default) 193 | viper.SetDefault("rabbitmq.reservation.exchange", def.RabbitMQ.Reservation.Exchange.Default) 194 | viper.SetDefault("rabbitmq.product.queue", def.RabbitMQ.Product.Queue.Default) 195 | viper.SetDefault("rabbitmq.product.dlt.exchange", def.RabbitMQ.Product.Dlt.Exchange.Default) 196 | } 197 | 198 | func LoadDefaults() *Config { 199 | config := &Config{} 200 | setupDefaults(config) 201 | return config 202 | } 203 | 204 | func Load(filename string) *Config { 205 | config := &Config{} 206 | setupDefaults(config) 207 | 208 | var err error 209 | switch config.Config.Source.Value { 210 | case "local": 211 | err = loadLocalConfigs(filename, config) 212 | case "etcd": 213 | err = loadRemoteConfigs(config) 214 | default: 215 | log.Warn(). 216 | Str("configSource", config.Config.Source.Value). 217 | Msg("unrecognized configuration source, using local") 218 | 219 | err = loadLocalConfigs(filename, config) 220 | } 221 | if err != nil { 222 | log.Fatal().Err(err).Msg("failed to load configurations") 223 | } 224 | 225 | err = loadCommandLineOverrides(config) 226 | if err != nil { 227 | log.Fatal().Err(err).Msg("failed to load configurations") 228 | } 229 | 230 | return config 231 | } 232 | 233 | func loadLocalConfigs(filename string, config *Config) error { 234 | log.Info().Msg("loading local configurations...") 235 | 236 | viper.SetConfigName(filename) 237 | viper.SetConfigType("yaml") 238 | viper.AddConfigPath(".") 239 | viper.AddConfigPath("../.") 240 | 241 | err := viper.ReadInConfig() 242 | if err != nil { 243 | return err 244 | } 245 | 246 | err = viper.Unmarshal(config, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( 247 | ValueToConfigValue(), 248 | mapstructure.StringToTimeDurationHookFunc(), 249 | mapstructure.StringToSliceHookFunc(","), 250 | ))) 251 | if err != nil { 252 | return err 253 | } 254 | 255 | return nil 256 | } 257 | 258 | func ValueToConfigValue() mapstructure.DecodeHookFunc { 259 | return func(f reflect.Value, t reflect.Value) (interface{}, error) { 260 | 261 | if t.Kind() != reflect.Struct { 262 | return f.Interface(), nil 263 | } 264 | 265 | to := t.Interface() 266 | switch t := to.(type) { 267 | case IntConfig: 268 | v, err := getInt(f) 269 | if err != nil { 270 | return nil, err 271 | } 272 | t.Value = v 273 | return t, nil 274 | case StringConfig: 275 | v, err := getString(f) 276 | if err != nil { 277 | return nil, err 278 | } 279 | t.Value = v 280 | return t, nil 281 | case BoolConfig: 282 | v, err := getBool(f) 283 | if err != nil { 284 | return nil, err 285 | } 286 | t.Value = v 287 | return t, nil 288 | case FloatConfig: 289 | v, err := getFloat(f) 290 | if err != nil { 291 | return nil, err 292 | } 293 | t.Value = v 294 | return t, nil 295 | } 296 | 297 | return f.Interface(), nil 298 | } 299 | } 300 | 301 | func getString(f reflect.Value) (string, error) { 302 | data := f.Interface() 303 | 304 | switch f.Kind() { 305 | case reflect.Int64: 306 | raw := data.(int64) 307 | return strconv.FormatInt(raw, 10), nil 308 | case reflect.Int: 309 | raw := data.(int) 310 | return strconv.Itoa(raw), nil 311 | case reflect.String: 312 | raw := data.(string) 313 | return raw, nil 314 | case reflect.Bool: 315 | raw := data.(bool) 316 | return strconv.FormatBool(raw), nil 317 | case reflect.Float64: 318 | raw := data.(float64) 319 | return strconv.FormatFloat(raw, 'f', 3, 64), nil 320 | } 321 | 322 | return "", errors.New("unrecognized type") 323 | } 324 | 325 | func getBool(f reflect.Value) (bool, error) { 326 | data := f.Interface() 327 | 328 | switch f.Kind() { 329 | case reflect.Int64: 330 | raw := data.(int64) 331 | return raw > 0, nil 332 | case reflect.Int: 333 | raw := data.(int) 334 | return raw > 0, nil 335 | case reflect.String: 336 | raw := data.(string) 337 | return raw == "true", nil 338 | case reflect.Bool: 339 | return data.(bool), nil 340 | case reflect.Float64: 341 | raw := data.(float64) 342 | return raw > 0, nil 343 | } 344 | 345 | return false, errors.New("unrecognized type") 346 | } 347 | 348 | func getFloat(f reflect.Value) (float64, error) { 349 | data := f.Interface() 350 | 351 | switch f.Kind() { 352 | case reflect.Int64: 353 | raw := data.(int64) 354 | return float64(raw), nil 355 | case reflect.Int: 356 | raw := data.(int) 357 | return float64(raw), nil 358 | case reflect.String: 359 | raw := data.(string) 360 | return strconv.ParseFloat(raw, 64) 361 | case reflect.Bool: 362 | raw := data.(bool) 363 | if raw { 364 | return 1, nil 365 | } else { 366 | return 0, nil 367 | } 368 | case reflect.Float64: 369 | raw := data.(float64) 370 | return raw, nil 371 | } 372 | 373 | return -1, errors.New("unrecognized type") 374 | } 375 | 376 | func getInt(f reflect.Value) (int64, error) { 377 | data := f.Interface() 378 | 379 | switch f.Kind() { 380 | case reflect.Int64: 381 | raw := data.(int64) 382 | return raw, nil 383 | case reflect.Int: 384 | raw := data.(int) 385 | return int64(raw), nil 386 | case reflect.String: 387 | raw := data.(string) 388 | return strconv.ParseInt(raw, 10, 64) 389 | case reflect.Bool: 390 | raw := data.(bool) 391 | if raw { 392 | return 1, nil 393 | } else { 394 | return 0, nil 395 | } 396 | case reflect.Float64: 397 | raw := data.(float64) 398 | return int64(raw), nil 399 | } 400 | 401 | return -1, errors.New("unrecognized type") 402 | } 403 | 404 | func loadRemoteConfigs(config *Config) error { 405 | 406 | return nil 407 | } 408 | 409 | func loadCommandLineOverrides(config *Config) error { 410 | flag.Parse() 411 | if *profile != config.Profile.Default { 412 | log.Debug().Str("profile", *profile).Str("config.Profile", config.Profile.Value).Str("config.Profile.Default", config.Profile.Default).Msg("overriding profile") 413 | config.Profile.Value = *profile 414 | } 415 | if *port != config.Port.Default { 416 | log.Debug().Str("port", *port).Str("config.port", config.Port.Value).Msg("overriding port") 417 | config.Port.Value = *port 418 | } 419 | if *configSource != config.Config.Source.Default { 420 | log.Debug().Str("configSource", *configSource).Str("config.Config.Source", config.Config.Source.Value).Msg("overriding config source") 421 | config.Config.Source.Value = *configSource 422 | } 423 | if *configUrl != config.Config.Spring.Url.Default { 424 | log.Debug().Str("configUrl", *profile).Str("config.Config.Spring.Url", config.Config.Spring.Url.Value).Msg("overriding config url") 425 | config.Config.Spring.Url.Value = *configUrl 426 | } 427 | if *configBranch != config.Config.Spring.Branch.Default { 428 | log.Debug().Str("configUrl", *configBranch).Str("config.Config.Spring.Branch", config.Config.Spring.Branch.Value).Msg("overriding config branch") 429 | config.Config.Spring.Branch.Value = *configBranch 430 | } 431 | if *configUser != config.Config.Spring.User.Default { 432 | log.Debug().Str("configUser", *configBranch).Str("config.Config.Spring.User", config.Config.Spring.User.Value).Msg("overriding config user") 433 | config.Config.Spring.User.Value = *configUser 434 | } 435 | if *configPass != config.Config.Spring.Pass.Default { 436 | log.Debug().Msg("overriding password") 437 | config.Config.Spring.Pass.Value = *configPass 438 | } 439 | return nil 440 | } 441 | 442 | func setupDefaults(config *Config) { 443 | config.AppName = StringConfig{Value: AppName, Default: AppName, Description: "Name of the application in a human readable format. Example: Go Micro Example"} 444 | 445 | config.AppVersion = StringConfig{Value: AppVersion, Default: "", Description: "Semantic version of the application. Example: v1.2.3"} 446 | config.Sha1Version = StringConfig{Value: Sha1Version, Default: "", Description: "Git sha1 hash of the application version."} 447 | config.BuildTime = StringConfig{Value: BuildTime, Default: "", Description: "When this version of the application was compiled."} 448 | config.Profile = StringConfig{Value: "local", Default: "local", Description: "Running profile of the application, can assist with sensible defaults or change behavior. Examples: local, dev, prod"} 449 | config.Revision = StringConfig{Value: Revision, Default: Revision, Description: "A hard coded revision handy for quickly determining if local changes are running. Examples: 1, Two, 9999"} 450 | config.Port = StringConfig{Value: "8080", Default: "8080", Description: "Port that the application will bind to on startup. Examples: 8080, 3000"} 451 | 452 | config.Config.Description = "Settings for where and how the application should get its configurations." 453 | config.Config.Print = BoolConfig{Value: false, Default: false, Description: "Print configurations on startup."} 454 | config.Config.Source = StringConfig{Value: "", Default: "", Description: "Where the application should go for configurations. Examples: local, etcd"} 455 | 456 | config.Config.Spring.Description = "Configuration settings for Spring Cloud Config. These are only used if config.source is spring." 457 | config.Config.Spring.Url = StringConfig{Value: "", Default: "", Description: "The url of the Spring Cloud Config server."} 458 | config.Config.Spring.Branch = StringConfig{Value: "", Default: "", Description: "The git branch to use to pull configurations from. Examples: main, master, development"} 459 | config.Config.Spring.User = StringConfig{Value: "", Default: "", Description: "User to use when connecting to the Spring Cloud Config server."} 460 | config.Config.Spring.Pass = StringConfig{Value: "", Default: "", Description: "Password to use when connecting to the Spring Cloud Config server."} 461 | 462 | config.Log.Description = "Settings for applicaton logging." 463 | config.Log.Level = StringConfig{Value: "trace", Default: "trace", Description: "The lowest level that the application should log at. Examples: info, warn, error."} 464 | config.Log.Structured = BoolConfig{Value: false, Default: false, Description: "Whether the application should output structured (json) logging, or human friendly plain text."} 465 | 466 | config.Db.Description = "Database configurations." 467 | config.Db.Name = StringConfig{Value: "micro-ex-db", Default: "micro-ex-db", Description: "The name of the database to connect to."} 468 | config.Db.Host = StringConfig{Value: "5432", Default: "5432", Description: "Port of the database."} 469 | config.Db.Migrate = BoolConfig{Value: true, Default: true, Description: "Whether or not database migrations should be executed on startup."} 470 | config.Db.MigrationFolder = StringConfig{Value: "db/migrations", Default: "db/migrations", Description: "Location of migration files to be executed on startup."} 471 | config.Db.Clean = BoolConfig{Value: false, Default: false, Description: "WARNING: THIS WILL DELETE ALL DATA FROM THE DB. Used only during migration. If clean is true, all 'down' migrations are executed."} 472 | config.Db.User = StringConfig{Value: "postgres", Default: "postgres", Description: "User the application will use to connect to the database."} 473 | config.Db.Pass = StringConfig{Value: "postgres", Default: "postgres", Description: "Password the application will use for connecting to the database."} 474 | config.Db.Pool.MinSize = IntConfig{Value: 1, Default: 1, Description: "The minimum size of the pool."} 475 | config.Db.Pool.MaxSize = IntConfig{Value: 3, Default: 3, Description: "The maximum size of the pool."} 476 | config.Db.Pool.MaxConnLife = IntConfig{Value: time.Hour.Milliseconds(), Default: time.Hour.Milliseconds(), Description: "The maximum time a connection can live in the pool in milliseconds."} 477 | config.Db.Pool.MaxConnIdle = IntConfig{Value: time.Minute.Milliseconds() * 30, Default: time.Minute.Milliseconds() * 30, Description: "The maximum time a connection can idle in the pool in milliseconds."} 478 | config.Db.LogLevel = StringConfig{Value: "trace", Default: "trace", Description: "The logging level for database interactions. See: log.level"} 479 | 480 | config.RabbitMQ.Description = "Rabbit MQ congfigurations." 481 | config.RabbitMQ.Host = StringConfig{Value: "localhost", Default: "localhost", Description: "RabbitMQ's broker host."} 482 | config.RabbitMQ.Port = StringConfig{Value: "5432", Default: "5432", Description: "RabbitMQ's broker host port."} 483 | config.RabbitMQ.User = StringConfig{Value: "guest", Default: "guest", Description: "User the application will use to connect to RabbitMQ."} 484 | config.RabbitMQ.Pass = StringConfig{Value: "guest", Default: "guest", Description: "Password the application will use to connect to RabbitMQ."} 485 | 486 | config.RabbitMQ.Inventory.Description = "RabbitMQ settings for inventory related updates." 487 | config.RabbitMQ.Inventory.Exchange = StringConfig{Value: "inventory.exchange", Default: "inventory.exchange", Description: "RabbitMQ exchang}}e to use for posting inventory updates."} 488 | 489 | config.RabbitMQ.Reservation.Description = "RabbitMQ settings for reservation related updates." 490 | config.RabbitMQ.Reservation.Exchange = StringConfig{Value: "reservation.exchange", Default: "reservation.exchange", Description: "RabbitMQ exchange to use for posting reservation updates."} 491 | 492 | config.RabbitMQ.Product.Description = "RabbitMQ settings for product related updates." 493 | config.RabbitMQ.Product.Queue = StringConfig{Value: "product.queue", Default: "product.queue", Description: "Queue used for listening to product updates coming from a theoretical product management system."} 494 | 495 | config.RabbitMQ.Product.Dlt.Description = "Configurations for the product dead letter topic, where messages that fail to be read from the queue are written." 496 | config.RabbitMQ.Product.Dlt.Exchange = StringConfig{Value: "product.dlt.exchange", Default: "product.dlt.exchange", Description: "Exchange used for posting messages to the dead letter topic."} 497 | } 498 | --------------------------------------------------------------------------------