├── front
├── webpack.config.dev.js
├── src
│ ├── react-app-env.d.ts
│ ├── setupTests.ts
│ ├── reportWebVitals.ts
│ ├── index.tsx
│ ├── components
│ │ ├── Content.tsx
│ │ ├── Header.tsx
│ │ └── Navigator.tsx
│ └── pages
│ │ └── room
│ │ └── Index.tsx
├── public
│ └── index.html
├── .gitignore
├── tsconfig.json
├── .eslintcache
├── package.json
└── README.md
├── .gitignore
├── doc
├── images
│ ├── 依存関係.png
│ ├── クリーンアーキテクチャ.png
│ ├── 典型的なシナリオ.jpeg
│ ├── Controller拡大図.png
│ ├── DBHandlerの拡大図.png
│ └── HttpHandlerの拡大図.png
└── 依存関係図.drawio
├── worker
└── Dockerfile
├── app
├── interface
│ ├── adapter
│ │ ├── http_context.go
│ │ ├── db_handler.go
│ │ └── http_error.go
│ ├── repository
│ │ ├── room_repository.go
│ │ └── room_repository_test.go
│ └── controller
│ │ ├── room_controller.go
│ │ └── room_controller_test.go
├── usecase
│ ├── dao
│ │ └── room_dao.go
│ ├── input
│ │ └── room_input.go
│ └── service
│ │ ├── room_service.go
│ │ └── room_service_test.go
├── domain
│ └── room.go
├── go.mod
├── Dockerfile
├── main.go
├── testdata
│ └── mocks
│ │ ├── http_context_mock.go
│ │ └── db_handler_mock.go
├── infra
│ ├── http_handler.go
│ ├── router.go
│ ├── http_context.go
│ ├── db_handler.go
│ ├── http_handler_test.go
│ ├── http_context_test.go
│ └── db_handler_test.go
└── di
│ └── container.go
├── bin
└── migrate.sh
├── db
└── mysql
│ ├── dbconfig.yml
│ ├── init
│ └── setup.sql
│ └── migrations
│ └── 20210110062359-create_rooms.sql
├── env.development
├── .github
└── workflows
│ ├── lint.yml
│ └── test.yml
├── docker-compose.yml
├── LICENSE
└── README.md
/front/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/front/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.exe
3 | .env
4 | /app/coverage.out
5 | /app/app
6 |
--------------------------------------------------------------------------------
/doc/images/依存関係.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/依存関係.png
--------------------------------------------------------------------------------
/doc/images/クリーンアーキテクチャ.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/クリーンアーキテクチャ.png
--------------------------------------------------------------------------------
/doc/images/典型的なシナリオ.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/典型的なシナリオ.jpeg
--------------------------------------------------------------------------------
/doc/images/Controller拡大図.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/Controller拡大図.png
--------------------------------------------------------------------------------
/doc/images/DBHandlerの拡大図.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/DBHandlerの拡大図.png
--------------------------------------------------------------------------------
/doc/images/HttpHandlerの拡大図.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ryoutaku/simple-chat/HEAD/doc/images/HttpHandlerの拡大図.png
--------------------------------------------------------------------------------
/worker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.15.3
2 | COPY . /simple-chat
3 | WORKDIR /simple-chat
4 | RUN go get -v github.com/rubenv/sql-migrate/...
5 |
--------------------------------------------------------------------------------
/app/interface/adapter/http_context.go:
--------------------------------------------------------------------------------
1 | package adapter
2 |
3 | type HttpContext interface {
4 | Bind(i interface{}) error
5 | JSON(code int, i interface{}) error
6 | }
7 |
--------------------------------------------------------------------------------
/bin/migrate.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | cd `dirname $0`
4 | export $(cat ../.env | grep -v ^# | xargs);
5 |
6 | OPTIONS="-config=../db/mysql/dbconfig.yml -env=$GO_ENV"
7 | sql-migrate ${@} $OPTIONS
8 |
--------------------------------------------------------------------------------
/app/interface/adapter/db_handler.go:
--------------------------------------------------------------------------------
1 | package adapter
2 |
3 | type DBHandler interface {
4 | Find(dest interface{}, conds ...interface{}) (err error)
5 | Create(value interface{}) (err error)
6 | }
7 |
--------------------------------------------------------------------------------
/db/mysql/dbconfig.yml:
--------------------------------------------------------------------------------
1 | development:
2 | dialect: mysql
3 | datasource: ${MYSQL_USER}:${MYSQL_PASSWORD}@tcp(${MYSQL_HOST}:${MYSQL_PORT})/${DATABASE_NAME}?parseTime=true
4 | dir: ../db/mysql/migrations
5 |
--------------------------------------------------------------------------------
/env.development:
--------------------------------------------------------------------------------
1 | GO_ENV=development
2 | HTTP_PORT=5050
3 | MYSQL_USER=simple_chat_dev
4 | MYSQL_PASSWORD=simple_chat_pw
5 | MYSQL_HOST=simple_chat_mysql
6 | MYSQL_PORT=3306
7 | DATABASE_NAME=simple_chat_dev
8 |
--------------------------------------------------------------------------------
/app/usecase/dao/room_dao.go:
--------------------------------------------------------------------------------
1 | package dao
2 |
3 | import "github.com/ryoutaku/simple-chat/app/domain"
4 |
5 | type RoomRepository interface {
6 | All() (rooms domain.Rooms, err error)
7 | Create(room *domain.Room) (err error)
8 | }
9 |
--------------------------------------------------------------------------------
/app/domain/room.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type Room struct {
8 | ID int
9 | Name string
10 | CreatedAt time.Time
11 | UpdatedAt time.Time
12 | }
13 |
14 | type Rooms []Room
15 |
--------------------------------------------------------------------------------
/app/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ryoutaku/simple-chat/app
2 |
3 | go 1.15
4 |
5 | require (
6 | github.com/DATA-DOG/go-sqlmock v1.5.0
7 | github.com/gorilla/mux v1.8.0
8 | gorm.io/driver/mysql v1.0.3
9 | gorm.io/gorm v1.20.11
10 | )
11 |
--------------------------------------------------------------------------------
/db/mysql/init/setup.sql:
--------------------------------------------------------------------------------
1 | CREATE DATABASE IF NOT EXISTS simple_chat_dev;
2 | CREATE USER IF NOT EXISTS 'simple_chat_dev'@'%' IDENTIFIED BY 'simple_chat_pw';
3 | GRANT ALL PRIVILEGES ON simple_chat_dev.* TO 'simple_chat_dev'@'%';
4 | FLUSH PRIVILEGES;
--------------------------------------------------------------------------------
/front/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | simple chat
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/front/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.15.3 AS builder
2 | COPY . /app
3 | WORKDIR /app
4 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
5 | go build -o /go/bin/app -ldflags '-s -w'
6 |
7 | FROM scratch
8 | COPY --from=builder /go/bin/app /go/bin/app
9 | ENTRYPOINT ["/go/bin/app"]
10 |
--------------------------------------------------------------------------------
/app/interface/adapter/http_error.go:
--------------------------------------------------------------------------------
1 | package adapter
2 |
3 | type HttpError struct {
4 | Message string
5 | Code int
6 | }
7 |
8 | func NewHttpError(str string, code int) *HttpError {
9 | return &HttpError{
10 | Message: str,
11 | Code: code,
12 | }
13 | }
14 | func (e *HttpError) Error() string {
15 | return e.Message
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Run Lint
2 |
3 | on: push
4 |
5 | jobs:
6 | build:
7 | name: Run Test
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: golangci-lint
13 | uses: golangci/golangci-lint-action@v2
14 | with:
15 | version: v1.29
16 | working-directory: ./app
--------------------------------------------------------------------------------
/app/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/ryoutaku/simple-chat/app/di"
7 |
8 | "github.com/ryoutaku/simple-chat/app/infra"
9 | )
10 |
11 | func main() {
12 | db := infra.NewDBHandler()
13 | container := di.NewContainer(db)
14 | router := infra.NewRouter(container)
15 |
16 | port := ":" + os.Getenv("HTTP_PORT")
17 | router.Start(port)
18 | }
19 |
--------------------------------------------------------------------------------
/db/mysql/migrations/20210110062359-create_rooms.sql:
--------------------------------------------------------------------------------
1 |
2 | -- +migrate Up
3 | CREATE TABLE rooms (
4 | id INT AUTO_INCREMENT NOT NULL,
5 | name VARCHAR(255) NOT NULL,
6 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
7 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
8 | PRIMARY KEY (id)
9 | );
10 |
11 | -- +migrate Down
12 | DROP TABLE rooms;
--------------------------------------------------------------------------------
/front/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.tc.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/app/usecase/input/room_input.go:
--------------------------------------------------------------------------------
1 | package input
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type RoomService interface {
8 | All() (outData RoomsOutputData, err error)
9 | Create(inData RoomInputData) (outData RoomOutputData, err error)
10 | }
11 |
12 | type RoomInputData struct {
13 | ID int
14 | Name string
15 | }
16 |
17 | type RoomOutputData struct {
18 | ID int
19 | Name string
20 | CreatedAt time.Time
21 | UpdatedAt time.Time
22 | }
23 |
24 | type RoomsOutputData []RoomOutputData
25 |
--------------------------------------------------------------------------------
/front/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/app/testdata/mocks/http_context_mock.go:
--------------------------------------------------------------------------------
1 | package mocks
2 |
3 | import (
4 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
5 | )
6 |
7 | type FakeHttpContext struct {
8 | adapter.HttpContext
9 | FakeBind func(i interface{}) error
10 | FakeJSON func(code int, i interface{}) error
11 | }
12 |
13 | func (port FakeHttpContext) Bind(i interface{}) error {
14 | return port.FakeBind(i)
15 | }
16 |
17 | func (port FakeHttpContext) JSON(code int, i interface{}) error {
18 | return port.FakeJSON(code, i)
19 | }
20 |
--------------------------------------------------------------------------------
/app/testdata/mocks/db_handler_mock.go:
--------------------------------------------------------------------------------
1 | package mocks
2 |
3 | import (
4 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
5 | )
6 |
7 | type FakeDBHandler struct {
8 | adapter.DBHandler
9 | FakeFind func(dest interface{}, conds ...interface{}) (err error)
10 | FakeCreate func(value interface{}) (err error)
11 | }
12 |
13 | func (h FakeDBHandler) Find(dest interface{}, conds ...interface{}) (err error) {
14 | return h.FakeFind(dest, conds)
15 | }
16 |
17 | func (h FakeDBHandler) Create(value interface{}) (err error) {
18 | return h.FakeCreate(value)
19 | }
20 |
--------------------------------------------------------------------------------
/app/infra/http_handler.go:
--------------------------------------------------------------------------------
1 | package infra
2 |
3 | import (
4 | "log"
5 | "net/http"
6 |
7 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
8 | )
9 |
10 | type httpHandler func(adapter.HttpContext) *adapter.HttpError
11 |
12 | func (fn httpHandler) run(w http.ResponseWriter, r *http.Request) {
13 | context := NewHttpContext(w, r)
14 | defer func() {
15 | if err := recover(); err != nil {
16 | log.Println(err)
17 | http.Error(w, "Internal Server Error", http.StatusInternalServerError)
18 | }
19 | }()
20 |
21 | if err := fn(context); err != nil {
22 | http.Error(w, err.Error(), err.Code)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/front/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run Test
2 |
3 | on: push
4 |
5 | jobs:
6 | build:
7 | name: Run Test
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - uses: actions/checkout@v2
12 |
13 | - name: Set up Go
14 | uses: actions/setup-go@v2
15 | with:
16 | go-version: 1.15
17 |
18 | - name: Build
19 | working-directory: ./app
20 | run: go build -v ./...
21 |
22 | - name: Test
23 | working-directory: ./app
24 | run: go test -v -race -cover -coverprofile=coverage.out ./...
25 |
26 | - name: Upload coverage report
27 | uses: codecov/codecov-action@v1
28 | with:
29 | file: ./app/coverage.out
30 |
--------------------------------------------------------------------------------
/app/infra/router.go:
--------------------------------------------------------------------------------
1 | package infra
2 |
3 | import (
4 | "log"
5 | "net/http"
6 |
7 | "github.com/ryoutaku/simple-chat/app/di"
8 |
9 | "github.com/gorilla/mux"
10 | )
11 |
12 | type Router struct {
13 | Engine *mux.Router
14 | }
15 |
16 | func NewRouter(c *di.Container) *Router {
17 | r := mux.NewRouter()
18 |
19 | r.HandleFunc("/", httpHandler(c.Room.Index).run).Methods("GET")
20 | r.HandleFunc("/rooms", httpHandler(c.Room.Index).run).Methods("GET")
21 | r.HandleFunc("/rooms", httpHandler(c.Room.Create).run).Methods("POST")
22 |
23 | return &Router{Engine: r}
24 | }
25 |
26 | func (r *Router) Start(port string) error {
27 | log.Println("HTTPサーバーを起動します。ポート", port)
28 | return http.ListenAndServe(port, r.Engine)
29 | }
30 |
--------------------------------------------------------------------------------
/app/di/container.go:
--------------------------------------------------------------------------------
1 | package di
2 |
3 | import (
4 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
5 | "github.com/ryoutaku/simple-chat/app/interface/controller"
6 | "github.com/ryoutaku/simple-chat/app/interface/repository"
7 | "github.com/ryoutaku/simple-chat/app/usecase/service"
8 | )
9 |
10 | type Container struct {
11 | Room *controller.RoomController
12 | }
13 |
14 | func NewContainer(db adapter.DBHandler) *Container {
15 | return &Container{
16 | Room: newRoomController(db),
17 | }
18 | }
19 |
20 | func newRoomController(db adapter.DBHandler) *controller.RoomController {
21 | r := repository.NewRoomRepository(db)
22 | s := service.NewRoomService(r)
23 | c := controller.NewRoomController(s)
24 | return c
25 | }
26 |
--------------------------------------------------------------------------------
/app/interface/repository/room_repository.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
7 |
8 | "github.com/ryoutaku/simple-chat/app/domain"
9 | )
10 |
11 | type RoomRepository struct {
12 | DBHandler adapter.DBHandler
13 | }
14 |
15 | func NewRoomRepository(db adapter.DBHandler) *RoomRepository {
16 | return &RoomRepository{DBHandler: db}
17 | }
18 |
19 | func (r *RoomRepository) All() (rooms domain.Rooms, err error) {
20 | err = r.DBHandler.Find(&rooms)
21 | if err != nil {
22 | err = errors.New("not found")
23 | }
24 | return
25 | }
26 |
27 | func (r *RoomRepository) Create(room *domain.Room) (err error) {
28 | err = r.DBHandler.Create(room)
29 | return
30 | }
31 |
--------------------------------------------------------------------------------
/front/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import RoomIndex from './pages/room/Index';
4 | import reportWebVitals from './reportWebVitals';
5 | import { BrowserRouter as Router, Route } from "react-router-dom";
6 |
7 | ReactDOM.render(
8 |
9 |
10 |
11 |
12 |
13 | ,
14 | document.getElementById('root')
15 | );
16 |
17 | // If you want to start measuring performance in your app, pass a function
18 | // to log results (for example: reportWebVitals(console.log))
19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
20 | reportWebVitals();
21 |
--------------------------------------------------------------------------------
/app/infra/http_context.go:
--------------------------------------------------------------------------------
1 | package infra
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "net/http"
7 | )
8 |
9 | type HttpContext struct {
10 | Writer http.ResponseWriter
11 | Request *http.Request
12 | }
13 |
14 | func NewHttpContext(w http.ResponseWriter, r *http.Request) *HttpContext {
15 | return &HttpContext{
16 | Writer: w,
17 | Request: r,
18 | }
19 | }
20 |
21 | func (c *HttpContext) Bind(i interface{}) (err error) {
22 | err = json.NewDecoder(c.Request.Body).Decode(i)
23 | if err != nil {
24 | return errors.New("invalid request")
25 | }
26 | return err
27 | }
28 |
29 | func (c *HttpContext) JSON(code int, i interface{}) (err error) {
30 | res, err := json.Marshal(i)
31 | if err != nil {
32 | return errors.New("internal server error")
33 | }
34 |
35 | c.Writer.Header().Set("Content-Type", "application/json")
36 | c.Writer.WriteHeader(code)
37 | c.Writer.Write(res)
38 | return err
39 | }
40 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | app:
5 | build:
6 | context: app
7 | env_file:
8 | - .env
9 | container_name: simple_chat_app
10 | volumes:
11 | - .:/simple_chat_app:cached
12 | tty: true
13 | stdin_open: true
14 | ports:
15 | - 5050:5050
16 | depends_on:
17 | - mysql
18 | mysql:
19 | image: mysql:5.7
20 | container_name: simple_chat_mysql
21 | restart: always
22 | command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
23 | environment:
24 | MYSQL_ROOT_PASSWORD: password
25 | volumes:
26 | - mysql_data:/var/lib/mysql
27 | - ./db/mysql/init:/docker-entrypoint-initdb.d
28 | ports:
29 | - 3306:3306
30 | - 13306:3306
31 | woker:
32 | build:
33 | context: .
34 | dockerfile: worker/Dockerfile
35 | container_name: simple_chat_woker
36 | tty: true
37 | stdin_open: true
38 |
39 | volumes:
40 | mysql_data:
41 | driver: local
42 |
--------------------------------------------------------------------------------
/front/.eslintcache:
--------------------------------------------------------------------------------
1 | [{"/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/pages/room/Index.tsx":"1","/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/components/Header.tsx":"2","/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/components/Navigator.tsx":"3"},{"size":4398,"mtime":1610582547280,"results":"4","hashOfConfig":"5"},{"size":5182,"mtime":1610485101531,"results":"6","hashOfConfig":"5"},{"size":5467,"mtime":1610489288160,"results":"7","hashOfConfig":"5"},{"filePath":"8","messages":"9","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"6v7j9e",{"filePath":"10","messages":"11","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"12","messages":"13","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/pages/room/Index.tsx",[],"/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/components/Header.tsx",[],"/Users/nakagawaryoutaku/projects/study/simple-chat/front/src/components/Navigator.tsx",[]]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 ryotaku nakagawa
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/infra/db_handler.go:
--------------------------------------------------------------------------------
1 | package infra
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 |
8 | _ "time/tzdata"
9 |
10 | "gorm.io/driver/mysql"
11 | "gorm.io/gorm"
12 | )
13 |
14 | type DBHandler struct {
15 | DB *gorm.DB
16 | }
17 |
18 | func NewDBHandler() *DBHandler {
19 | user := os.Getenv("MYSQL_USER")
20 | password := os.Getenv("MYSQL_PASSWORD")
21 | host := os.Getenv("MYSQL_HOST")
22 | port := os.Getenv("MYSQL_PORT")
23 | dbName := os.Getenv("DATABASE_NAME")
24 |
25 | dsn := fmt.Sprintf(
26 | "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=%s",
27 | user, password, host, port, dbName, "Asia%2FTokyo",
28 | )
29 | db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
30 | if err != nil {
31 | log.Panic(err)
32 | }
33 | return &DBHandler{DB: db}
34 | }
35 |
36 | func (h *DBHandler) Find(dest interface{}, conds ...interface{}) (err error) {
37 | result := new(gorm.DB)
38 | if len(conds) == 0 {
39 | result = h.DB.Find(dest)
40 | } else {
41 | result = h.DB.Find(dest, conds)
42 | }
43 | err = result.Error
44 | return
45 | }
46 |
47 | func (h *DBHandler) Create(value interface{}) (err error) {
48 | result := h.DB.Create(value)
49 | err = result.Error
50 | return
51 | }
52 |
--------------------------------------------------------------------------------
/front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "front",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@material-ui/core": "^4.11.2",
7 | "@material-ui/icons": "^4.11.2",
8 | "@testing-library/jest-dom": "^5.11.4",
9 | "@testing-library/react": "^11.1.0",
10 | "@testing-library/user-event": "^12.1.10",
11 | "@types/jest": "^26.0.15",
12 | "@types/node": "^12.0.0",
13 | "@types/react": "^16.9.53",
14 | "@types/react-dom": "^16.9.8",
15 | "@types/react-router": "^5.1.11",
16 | "@types/react-router-dom": "^5.1.7",
17 | "axios": "^0.21.1",
18 | "react": "^17.0.1",
19 | "react-dom": "^17.0.1",
20 | "react-router": "^5.2.0",
21 | "react-router-dom": "^5.2.0",
22 | "react-scripts": "4.0.1",
23 | "typescript": "^4.0.3",
24 | "web-vitals": "^0.2.4"
25 | },
26 | "scripts": {
27 | "start": "react-scripts start",
28 | "build": "react-scripts build",
29 | "test": "react-scripts test",
30 | "eject": "react-scripts eject"
31 | },
32 | "eslintConfig": {
33 | "extends": [
34 | "react-app",
35 | "react-app/jest"
36 | ]
37 | },
38 | "browserslist": {
39 | "production": [
40 | ">0.2%",
41 | "not dead",
42 | "not op_mini all"
43 | ],
44 | "development": [
45 | "last 1 chrome version",
46 | "last 1 firefox version",
47 | "last 1 safari version"
48 | ]
49 | },
50 | "devDependencies": {
51 | "webpack-dev-server": "^3.11.2"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/usecase/service/room_service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "github.com/ryoutaku/simple-chat/app/domain"
5 | "github.com/ryoutaku/simple-chat/app/usecase/dao"
6 | "github.com/ryoutaku/simple-chat/app/usecase/input"
7 | )
8 |
9 | type RoomService struct {
10 | Repository dao.RoomRepository
11 | }
12 |
13 | func NewRoomService(repo dao.RoomRepository) *RoomService {
14 | return &RoomService{Repository: repo}
15 | }
16 |
17 | func (s *RoomService) All() (outData input.RoomsOutputData, err error) {
18 | rooms, err := s.Repository.All()
19 | if err != nil {
20 | return
21 | }
22 |
23 | outData = convertToRoomsOutputData(&rooms)
24 | return
25 | }
26 |
27 | func (s *RoomService) Create(inData input.RoomInputData) (outData input.RoomOutputData, err error) {
28 | room := convertToRoomDomain(&inData)
29 | err = s.Repository.Create(&room)
30 | if err != nil {
31 | return
32 | }
33 |
34 | outData = convertToRoomOutputData(&room)
35 | return
36 | }
37 |
38 | func convertToRoomDomain(inData *input.RoomInputData) (room domain.Room) {
39 | room.ID = inData.ID
40 | room.Name = inData.Name
41 | return
42 | }
43 |
44 | func convertToRoomOutputData(room *domain.Room) (outData input.RoomOutputData) {
45 | outData.ID = room.ID
46 | outData.Name = room.Name
47 | outData.CreatedAt = room.CreatedAt
48 | outData.UpdatedAt = room.UpdatedAt
49 | return
50 | }
51 |
52 | func convertToRoomsOutputData(rooms *domain.Rooms) (outData input.RoomsOutputData) {
53 | for _, room := range *rooms {
54 | var data input.RoomOutputData
55 | data = convertToRoomOutputData(&room)
56 | outData = append(outData, data)
57 | }
58 | return
59 | }
60 |
--------------------------------------------------------------------------------
/app/interface/controller/room_controller.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
8 |
9 | "github.com/ryoutaku/simple-chat/app/usecase/input"
10 | )
11 |
12 | type roomResponse struct {
13 | ID int `json:"id"`
14 | Name string `json:"name"`
15 | CreatedAt time.Time `json:"createdAt"`
16 | UpdatedAt time.Time `json:"updatedAt"`
17 | }
18 |
19 | type roomsResponse []roomResponse
20 |
21 | type RoomController struct {
22 | Service input.RoomService
23 | }
24 |
25 | func NewRoomController(s input.RoomService) *RoomController {
26 | return &RoomController{Service: s}
27 | }
28 |
29 | func (c *RoomController) Index(hc adapter.HttpContext) *adapter.HttpError {
30 | rooms, err := c.Service.All()
31 | if err != nil {
32 | return adapter.NewHttpError(err.Error(), http.StatusBadRequest)
33 | }
34 |
35 | respBody := convertRoomsResponse(&rooms)
36 | if err := hc.JSON(http.StatusCreated, respBody); err != nil {
37 | return adapter.NewHttpError(err.Error(), http.StatusInternalServerError)
38 | }
39 | return nil
40 | }
41 |
42 | func (c *RoomController) Create(context adapter.HttpContext) *adapter.HttpError {
43 | var inputData input.RoomInputData
44 | if err := context.Bind(&inputData); err != nil {
45 | return adapter.NewHttpError(err.Error(), http.StatusBadRequest)
46 | }
47 |
48 | room, err := c.Service.Create(inputData)
49 | if err != nil {
50 | return adapter.NewHttpError(err.Error(), http.StatusBadRequest)
51 | }
52 |
53 | respBody := convertRoomResponse(&room)
54 | if err = context.JSON(http.StatusCreated, respBody); err != nil {
55 | return adapter.NewHttpError(err.Error(), http.StatusInternalServerError)
56 | }
57 | return nil
58 | }
59 |
60 | func convertRoomResponse(room *input.RoomOutputData) (resp roomResponse) {
61 | resp.ID = room.ID
62 | resp.Name = room.Name
63 | resp.CreatedAt = room.CreatedAt
64 | resp.UpdatedAt = room.UpdatedAt
65 | return
66 | }
67 |
68 | func convertRoomsResponse(rooms *input.RoomsOutputData) (resp roomsResponse) {
69 | for _, room := range *rooms {
70 | var r roomResponse
71 | r = convertRoomResponse(&room)
72 | resp = append(resp, r)
73 | }
74 | return
75 | }
76 |
--------------------------------------------------------------------------------
/front/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React Index
2 |
3 | This project was bootstrapped with [Create React Index](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `yarn start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `yarn test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `yarn build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `yarn eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React Index documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/app/infra/http_handler_test.go:
--------------------------------------------------------------------------------
1 | package infra
2 |
3 | import (
4 | "io/ioutil"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
9 | )
10 |
11 | func newFakeHttpHandler(code int, message string) httpHandler {
12 | return func(c adapter.HttpContext) *adapter.HttpError {
13 | switch code {
14 | case 500:
15 | panic("panic test")
16 | case 400, 403, 404:
17 | return adapter.NewHttpError(message, code)
18 | default:
19 | return nil
20 | }
21 | }
22 | }
23 |
24 | func TestRun(t *testing.T) {
25 | testCases := []struct {
26 | name string
27 | handler httpHandler
28 | expectedBody string
29 | expectedCode int
30 | }{
31 | {
32 | name: "normal",
33 | handler: newFakeHttpHandler(200, ""),
34 | expectedBody: "",
35 | expectedCode: 200,
36 | },
37 | {
38 | name: "recover panic",
39 | handler: newFakeHttpHandler(500, ""),
40 | expectedBody: "Internal Server Error\n",
41 | expectedCode: 500,
42 | },
43 | {
44 | name: "error handling 400",
45 | handler: newFakeHttpHandler(400, "test error 400"),
46 | expectedBody: "test error 400\n",
47 | expectedCode: 400,
48 | },
49 | {
50 | name: "error handling 403",
51 | handler: newFakeHttpHandler(403, "test error 403"),
52 | expectedBody: "test error 403\n",
53 | expectedCode: 403,
54 | },
55 | {
56 | name: "error handling 404",
57 | handler: newFakeHttpHandler(404, "test error 404"),
58 | expectedBody: "test error 404\n",
59 | expectedCode: 404,
60 | },
61 | }
62 |
63 | for _, tc := range testCases {
64 | tc := tc
65 | t.Run(tc.name, func(t *testing.T) {
66 | t.Parallel()
67 | r := httptest.NewRequest("Post", "/dummy", nil)
68 | w := httptest.NewRecorder()
69 | tc.handler.run(w, r)
70 |
71 | resp := w.Result()
72 | if resp.StatusCode != tc.expectedCode {
73 | t.Errorf("%v: context.JSON StatusCode expected = %v, got = %v", tc.name, tc.expectedCode, resp.StatusCode)
74 | }
75 |
76 | body, _ := ioutil.ReadAll(resp.Body)
77 | if string(body) != tc.expectedBody {
78 | t.Errorf("%v: context.JSON ResponseBody expected = %v, got = %v",
79 | tc.name, tc.expectedBody, string(body))
80 | }
81 | })
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/app/infra/http_context_test.go:
--------------------------------------------------------------------------------
1 | package infra
2 |
3 | import (
4 | "errors"
5 | "io/ioutil"
6 | "math"
7 | "net/http"
8 | "net/http/httptest"
9 | "reflect"
10 | "strings"
11 | "testing"
12 | )
13 |
14 | type dummyInputData struct {
15 | ID int
16 | Name string
17 | }
18 |
19 | type dummyResponseBody struct {
20 | ID int `json:"id"`
21 | Name string `json:"name"`
22 | }
23 |
24 | func TestBind(t *testing.T) {
25 | testCases := []struct {
26 | name string
27 | body string
28 | expectedErr error
29 | expectedData *dummyInputData
30 | }{
31 | {
32 | name: "normal",
33 | body: `{"id":1,"name":"test"}`,
34 | expectedErr: nil,
35 | expectedData: &dummyInputData{ID: 1, Name: "test"},
36 | },
37 | {
38 | name: "invalid request",
39 | body: `{"id":"test","name":1}`,
40 | expectedErr: errors.New("invalid request"),
41 | expectedData: &dummyInputData{},
42 | },
43 | }
44 |
45 | for _, tc := range testCases {
46 | tc := tc
47 | t.Run(tc.name, func(t *testing.T) {
48 | t.Parallel()
49 | body := strings.NewReader(tc.body)
50 | r := httptest.NewRequest("Post", "/dummies", body)
51 | w := httptest.NewRecorder()
52 |
53 | context := NewHttpContext(w, r)
54 | data := new(dummyInputData)
55 |
56 | err := context.Bind(&data)
57 | if !reflect.DeepEqual(tc.expectedErr, err) {
58 | t.Errorf("context.Bind expected = %v, got = %v", tc.expectedErr, err)
59 | }
60 | if !reflect.DeepEqual(tc.expectedData, data) {
61 | t.Errorf("context.Bind expected = %v, got = %v", tc.expectedData, data)
62 | }
63 | })
64 | }
65 | }
66 |
67 | func TestJSON(t *testing.T) {
68 | testCases := []struct {
69 | name string
70 | body interface{}
71 | code int
72 | expectedErr error
73 | expectedBody string
74 | }{
75 | {
76 | name: "normal",
77 | body: dummyResponseBody{ID: 1, Name: "test"},
78 | code: http.StatusOK,
79 | expectedErr: nil,
80 | expectedBody: "{\"id\":1,\"name\":\"test\"}",
81 | },
82 | {
83 | name: "json.Marshal Error",
84 | body: math.Inf(0),
85 | code: http.StatusOK,
86 | expectedErr: errors.New("internal server error"),
87 | expectedBody: "",
88 | },
89 | }
90 |
91 | for _, tc := range testCases {
92 | tc := tc
93 | t.Run(tc.name, func(t *testing.T) {
94 | t.Parallel()
95 | r := httptest.NewRequest("Get", "/dummy", nil)
96 | w := httptest.NewRecorder()
97 | context := NewHttpContext(w, r)
98 |
99 | err := context.JSON(tc.code, tc.body)
100 | if !reflect.DeepEqual(tc.expectedErr, err) {
101 | t.Errorf("context.JSON expected = %v, got = %v", tc.expectedErr, err)
102 | }
103 |
104 | resp := w.Result()
105 | if resp.StatusCode != tc.code {
106 | t.Errorf("context.JSON StatusCode expected = %v, got = %v", tc.code, resp.StatusCode)
107 | }
108 |
109 | body, _ := ioutil.ReadAll(resp.Body)
110 | if string(body) != tc.expectedBody {
111 | t.Errorf("context.JSON ResponseBody expected = %v, got = %v", tc.expectedBody, string(body))
112 | }
113 | })
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/front/src/components/Content.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import AppBar from '@material-ui/core/AppBar';
3 | import Toolbar from '@material-ui/core/Toolbar';
4 | import Typography from '@material-ui/core/Typography';
5 | import Paper from '@material-ui/core/Paper';
6 | import Grid from '@material-ui/core/Grid';
7 | import Button from '@material-ui/core/Button';
8 | import TextField from '@material-ui/core/TextField';
9 | import Tooltip from '@material-ui/core/Tooltip';
10 | import IconButton from '@material-ui/core/IconButton';
11 | import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles';
12 | import SearchIcon from '@material-ui/icons/Search';
13 | import RefreshIcon from '@material-ui/icons/Refresh';
14 |
15 | const styles = (theme: Theme) =>
16 | createStyles({
17 | paper: {
18 | maxWidth: 936,
19 | margin: 'auto',
20 | overflow: 'hidden',
21 | },
22 | searchBar: {
23 | borderBottom: '1px solid rgba(0, 0, 0, 0.12)',
24 | },
25 | searchInput: {
26 | fontSize: theme.typography.fontSize,
27 | },
28 | block: {
29 | display: 'block',
30 | },
31 | addUser: {
32 | marginRight: theme.spacing(1),
33 | },
34 | contentWrapper: {
35 | margin: '40px 16px',
36 | },
37 | });
38 |
39 | export interface ContentProps extends WithStyles {}
40 |
41 | function Content(props: ContentProps) {
42 | const { classes } = props;
43 |
44 | return (
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
61 |
62 |
63 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | No users for this project yet
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | export default withStyles(styles)(Content);
85 |
--------------------------------------------------------------------------------
/app/interface/repository/room_repository_test.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "reflect"
7 | "testing"
8 | "time"
9 |
10 | "github.com/ryoutaku/simple-chat/app/testdata/mocks"
11 |
12 | "github.com/ryoutaku/simple-chat/app/domain"
13 | )
14 |
15 | var dummyRoom = domain.Room{
16 | ID: 1,
17 | Name: "テストルーム1",
18 | }
19 |
20 | var expectedRoom = domain.Room{
21 | ID: 1,
22 | Name: "テストルーム1",
23 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
24 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
25 | }
26 |
27 | var dummyRooms = domain.Rooms{
28 | domain.Room{
29 | ID: 1,
30 | Name: "テストルーム1",
31 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
32 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
33 | },
34 | }
35 |
36 | func TestAll(t *testing.T) {
37 | testCases := []struct {
38 | name string
39 | dbHandlerErr error
40 | expectedReturn domain.Rooms
41 | expectedError error
42 | }{
43 | {
44 | name: "normal",
45 | dbHandlerErr: nil,
46 | expectedReturn: dummyRooms,
47 | expectedError: nil,
48 | },
49 | {
50 | name: "dbHandler.Find Error",
51 | dbHandlerErr: errors.New("dbHandler.Find Error"),
52 | expectedReturn: domain.Rooms{},
53 | expectedError: errors.New("not found"),
54 | },
55 | }
56 |
57 | for _, tc := range testCases {
58 | tc := tc
59 | t.Run(tc.name, func(t *testing.T) {
60 | t.Parallel()
61 | fakeDBHandler := mocks.FakeDBHandler{
62 | FakeFind: func(dest interface{}, conds ...interface{}) error {
63 | if tc.dbHandlerErr == nil {
64 | pv := reflect.ValueOf(dest)
65 | vv := reflect.ValueOf(dummyRooms)
66 | pv.Elem().Set(vv)
67 | }
68 | return tc.dbHandlerErr
69 | },
70 | }
71 | repository := NewRoomRepository(fakeDBHandler)
72 |
73 | rooms, err := repository.All()
74 | if !reflect.DeepEqual(tc.expectedError, err) {
75 | t.Errorf("%v: repository.All error expected = %v, got = %v", tc.name, tc.expectedError, err)
76 | }
77 | if err == nil && !reflect.DeepEqual(tc.expectedReturn, rooms) {
78 | t.Errorf("%v: repository.All return expected = %v, got = %v", tc.name, tc.expectedReturn, rooms)
79 | }
80 | })
81 | }
82 | }
83 |
84 | func TestCreate(t *testing.T) {
85 | testCases := []struct {
86 | name string
87 | room domain.Room
88 | dbHandlerErr error
89 | expectedError error
90 | }{
91 | {
92 | name: "normal",
93 | room: dummyRoom,
94 | dbHandlerErr: nil,
95 | expectedError: nil,
96 | },
97 | {
98 | name: "dbHandler.Find Error",
99 | room: dummyRoom,
100 | dbHandlerErr: errors.New("dbHandler.Create Error"),
101 | expectedError: errors.New("dbHandler.Create Error"),
102 | },
103 | }
104 |
105 | for _, tc := range testCases {
106 | tc := tc
107 | t.Run(tc.name, func(t *testing.T) {
108 | t.Parallel()
109 | fakeDBHandler := mocks.FakeDBHandler{
110 | FakeCreate: func(value interface{}) error {
111 | if tc.dbHandlerErr == nil {
112 | pv := reflect.ValueOf(value)
113 | vv := reflect.ValueOf(expectedRoom)
114 | pv.Elem().Set(vv)
115 | }
116 | return tc.dbHandlerErr
117 | },
118 | }
119 | repository := NewRoomRepository(fakeDBHandler)
120 |
121 | err := repository.Create(&tc.room)
122 | if !reflect.DeepEqual(tc.expectedError, err) {
123 | fmt.Println(tc.expectedError)
124 | fmt.Println(err)
125 | t.Errorf("repository.All error expected = %v, got = %v", tc.expectedError, err)
126 | }
127 | if err == nil && !reflect.DeepEqual(expectedRoom, tc.room) {
128 | t.Errorf("repository.All return expected = %v, got = %v", expectedRoom, tc.room)
129 | }
130 | })
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/app/infra/db_handler_test.go:
--------------------------------------------------------------------------------
1 | package infra
2 |
3 | import (
4 | "database/sql"
5 | "reflect"
6 | "regexp"
7 | "testing"
8 | "time"
9 |
10 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
11 |
12 | "github.com/DATA-DOG/go-sqlmock"
13 | "gorm.io/driver/mysql"
14 | "gorm.io/gorm"
15 |
16 | _ "time/tzdata"
17 | )
18 |
19 | type dummy struct {
20 | ID int
21 | Name string
22 | CreatedAt time.Time
23 | UpdatedAt time.Time
24 | }
25 |
26 | func newDummyDBHandler() (adapter.DBHandler, sqlmock.Sqlmock, *sql.DB) {
27 | db, mock, _ := sqlmock.New()
28 | gdb, _ := gorm.Open(mysql.Dialector{
29 | Config: &mysql.Config{
30 | DriverName: "mysql", Conn: db, SkipInitializeWithVersion: true,
31 | },
32 | }, &gorm.Config{})
33 | dbHandler := DBHandler{DB: gdb}
34 | return &dbHandler, mock, db
35 | }
36 |
37 | func TestFind(t *testing.T) {
38 | testCases := []struct {
39 | name string
40 | targetID int
41 | targetName string
42 | conditions interface{}
43 | expectedTime time.Time
44 | }{
45 | {
46 | name: "normal",
47 | targetID: 1,
48 | targetName: "test",
49 | conditions: nil,
50 | expectedTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
51 | },
52 | {
53 | name: "normal conditions",
54 | targetID: 1,
55 | targetName: "test",
56 | conditions: 10,
57 | expectedTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
58 | },
59 | }
60 |
61 | for _, tc := range testCases {
62 | tc := tc
63 | t.Run(tc.name, func(t *testing.T) {
64 | t.Parallel()
65 | handler, mock, db := newDummyDBHandler()
66 | defer db.Close()
67 |
68 | mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `dummies`")).
69 | WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}).
70 | AddRow(tc.targetID, tc.targetName, tc.expectedTime, tc.expectedTime))
71 |
72 | dummyData := &dummy{
73 | ID: tc.targetID,
74 | Name: tc.targetName,
75 | }
76 | expectedDummy := &dummy{
77 | ID: tc.targetID,
78 | Name: tc.targetName,
79 | CreatedAt: tc.expectedTime,
80 | UpdatedAt: tc.expectedTime,
81 | }
82 |
83 | var err error
84 | if tc.conditions != nil {
85 | err = handler.Find(&dummyData, tc.conditions)
86 | } else {
87 | err = handler.Find(&dummyData)
88 | }
89 | if err != nil {
90 | t.Errorf("error '%s' was not expected", err)
91 | }
92 | if !reflect.DeepEqual(dummyData, expectedDummy) {
93 | t.Errorf("dummyData expected = %v, got = %v", dummyData, expectedDummy)
94 | }
95 | })
96 | }
97 | }
98 |
99 | func TestCreate(t *testing.T) {
100 | testCases := []struct {
101 | name string
102 | id int
103 | targetName string
104 | }{
105 | {
106 | name: "normal",
107 | id: 10,
108 | targetName: "AAAA",
109 | },
110 | }
111 |
112 | for _, tc := range testCases {
113 | tc := tc
114 | t.Run(tc.name, func(t *testing.T) {
115 | t.Parallel()
116 | handler, mock, db := newDummyDBHandler()
117 | defer db.Close()
118 |
119 | mock.ExpectExec(regexp.QuoteMeta(
120 | "INSERT INTO `dummies` (`name`,`created_at`,`updated_at`)")).
121 | WillReturnResult(sqlmock.NewResult(int64(tc.id), 1))
122 |
123 | dummyData := &dummy{Name: tc.targetName}
124 | beforeTime := time.Now()
125 |
126 | err := handler.Create(dummyData)
127 | if err != nil {
128 | t.Errorf("error '%s' was not expected", err)
129 | }
130 | if dummyData.CreatedAt.Before(beforeTime) {
131 | t.Errorf("dummyData.CreatedAt expected higher than beforeTime: %v, CreatedAt = %v", beforeTime, dummyData.CreatedAt)
132 | }
133 | if dummyData.UpdatedAt.Before(beforeTime) {
134 | t.Errorf("dummyData.UpdatedAt expected higher than beforeTime: %v, UpdatedAt = %v", beforeTime, dummyData.UpdatedAt)
135 | }
136 | })
137 | }
138 |
139 | }
140 |
--------------------------------------------------------------------------------
/app/usecase/service/room_service_test.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "errors"
5 | "reflect"
6 | "testing"
7 | "time"
8 |
9 | "github.com/ryoutaku/simple-chat/app/usecase/dao"
10 |
11 | "github.com/ryoutaku/simple-chat/app/domain"
12 | "github.com/ryoutaku/simple-chat/app/usecase/input"
13 | )
14 |
15 | type fakeRoomRepository struct {
16 | dao.RoomRepository
17 | fakeAll func() (rooms domain.Rooms, err error)
18 | fakeCreate func(room *domain.Room) (err error)
19 | }
20 |
21 | func (r fakeRoomRepository) All() (rooms domain.Rooms, err error) {
22 | return r.fakeAll()
23 | }
24 |
25 | func (r fakeRoomRepository) Create(room *domain.Room) (err error) {
26 | return r.fakeCreate(room)
27 | }
28 |
29 | var roomDomain = domain.Room{
30 | ID: 1,
31 | Name: "テストルーム1",
32 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
33 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
34 | }
35 |
36 | var roomsDomain = domain.Rooms{
37 | roomDomain,
38 | }
39 |
40 | var roomOutputData = input.RoomOutputData{
41 | ID: 1,
42 | Name: "テストルーム1",
43 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
44 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),
45 | }
46 |
47 | var roomsOutputData = input.RoomsOutputData{
48 | roomOutputData,
49 | }
50 |
51 | var roomInputData = input.RoomInputData{
52 | Name: "テストルーム1",
53 | }
54 |
55 | func TestAll(t *testing.T) {
56 | testCases := []struct {
57 | name string
58 | repositoryReturn domain.Rooms
59 | repositoryErr error
60 | expectedReturn input.RoomsOutputData
61 | expectedError error
62 | }{
63 | {
64 | name: "normal",
65 | repositoryReturn: roomsDomain,
66 | repositoryErr: nil,
67 | expectedReturn: roomsOutputData,
68 | expectedError: nil,
69 | },
70 | {
71 | name: "Repository Error",
72 | repositoryReturn: domain.Rooms{},
73 | repositoryErr: errors.New("repository error"),
74 | expectedReturn: input.RoomsOutputData{},
75 | expectedError: errors.New("repository error"),
76 | },
77 | }
78 |
79 | for _, tc := range testCases {
80 | tc := tc
81 | t.Run(tc.name, func(t *testing.T) {
82 | t.Parallel()
83 | fakeRepository := fakeRoomRepository{
84 | fakeAll: func() (rooms domain.Rooms, err error) {
85 | return tc.repositoryReturn, tc.repositoryErr
86 | },
87 | }
88 | service := NewRoomService(fakeRepository)
89 |
90 | outputData, err := service.All()
91 | if !reflect.DeepEqual(tc.expectedError, err) {
92 | t.Errorf("service.All error expected = %v, got = %v", tc.expectedError, err)
93 | }
94 | if err == nil && !reflect.DeepEqual(tc.expectedReturn, outputData) {
95 | t.Errorf("service.All return expected = %v, got = %v", tc.expectedReturn, outputData)
96 | }
97 | })
98 | }
99 | }
100 |
101 | func TestCreate(t *testing.T) {
102 | testCases := []struct {
103 | name string
104 | repositoryErr error
105 | expectedReturn input.RoomOutputData
106 | expectedError error
107 | }{
108 | {
109 | name: "normal",
110 | repositoryErr: nil,
111 | expectedReturn: roomOutputData,
112 | expectedError: nil,
113 | },
114 | {
115 | name: "Repository Error",
116 | repositoryErr: errors.New("repository error"),
117 | expectedReturn: input.RoomOutputData{Name: "テストルーム1"},
118 | expectedError: errors.New("repository error"),
119 | },
120 | }
121 |
122 | for _, tc := range testCases {
123 | tc := tc
124 | t.Run(tc.name, func(t *testing.T) {
125 | t.Parallel()
126 | fakeRepository := fakeRoomRepository{
127 | fakeCreate: func(room *domain.Room) (err error) {
128 | if tc.repositoryErr == nil {
129 | room.ID = 1
130 | room.CreatedAt = time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local)
131 | room.UpdatedAt = time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local)
132 | }
133 | return tc.repositoryErr
134 | },
135 | }
136 | service := NewRoomService(fakeRepository)
137 |
138 | outData, err := service.Create(roomInputData)
139 | if !reflect.DeepEqual(tc.expectedError, err) {
140 | t.Errorf("service.Create error expected = %v, got = %v", tc.expectedError, err)
141 | }
142 | if err == nil && !reflect.DeepEqual(tc.expectedReturn, outData) {
143 | t.Errorf("service.Create return expected = %v, got = %v", tc.expectedReturn, outData)
144 | }
145 | })
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/app/interface/controller/room_controller_test.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "errors"
5 | "reflect"
6 | "testing"
7 | "time"
8 |
9 | "github.com/ryoutaku/simple-chat/app/interface/adapter"
10 |
11 | "github.com/ryoutaku/simple-chat/app/testdata/mocks"
12 |
13 | "github.com/ryoutaku/simple-chat/app/usecase/input"
14 | )
15 |
16 | type fakeRoomService struct {
17 | input.RoomService
18 | fakeAll func() (outData input.RoomsOutputData, err error)
19 | fakeCreate func(inData input.RoomInputData) (outData input.RoomOutputData, err error)
20 | }
21 |
22 | func (i fakeRoomService) All() (outData input.RoomsOutputData, err error) {
23 | return i.fakeAll()
24 | }
25 |
26 | func (i fakeRoomService) Create(inData input.RoomInputData) (outData input.RoomOutputData, err error) {
27 | return i.fakeCreate(inData)
28 | }
29 |
30 | var roomOutputData = input.RoomOutputData{
31 | ID: 1,
32 | Name: "テストルーム1",
33 | CreatedAt: time.Now(),
34 | UpdatedAt: time.Now(),
35 | }
36 |
37 | var roomsOutputData = input.RoomsOutputData{
38 | roomOutputData,
39 | }
40 |
41 | func TestRoomControllerIndex(t *testing.T) {
42 | testCases := []struct {
43 | name string
44 | serviceReturn input.RoomsOutputData
45 | serviceErr error
46 | jsonErr error
47 | expected *adapter.HttpError
48 | }{
49 | {
50 | name: "normal",
51 | serviceReturn: roomsOutputData,
52 | serviceErr: nil,
53 | jsonErr: nil,
54 | expected: nil,
55 | },
56 | {
57 | name: "serviceError",
58 | serviceReturn: input.RoomsOutputData{},
59 | serviceErr: errors.New("serviceError"),
60 | jsonErr: nil,
61 | expected: adapter.NewHttpError("serviceError", 400),
62 | },
63 | {
64 | name: "jsonError",
65 | serviceReturn: roomsOutputData,
66 | serviceErr: nil,
67 | jsonErr: errors.New("jsonError"),
68 | expected: adapter.NewHttpError("jsonError", 500),
69 | },
70 | }
71 |
72 | for _, tc := range testCases {
73 | tc := tc
74 | t.Run(tc.name, func(t *testing.T) {
75 | t.Parallel()
76 | context := mocks.FakeHttpContext{
77 | FakeJSON: func(int, interface{}) error { return tc.jsonErr },
78 | }
79 |
80 | service := fakeRoomService{
81 | fakeAll: func() (outData input.RoomsOutputData, err error) {
82 | return tc.serviceReturn, tc.serviceErr
83 | },
84 | }
85 | controller := NewRoomController(service)
86 |
87 | if err := controller.Index(context); !reflect.DeepEqual(tc.expected, err) {
88 | t.Errorf("%v: controller.Index expected = %v, got = %v", tc.name, tc.expected, err)
89 | }
90 | })
91 | }
92 | }
93 |
94 | func TestRoomControllerCreate(t *testing.T) {
95 | testCases := []struct {
96 | name string
97 | bindErr error
98 | serviceReturn input.RoomOutputData
99 | serviceErr error
100 | jsonErr error
101 | expected *adapter.HttpError
102 | }{
103 | {
104 | name: "normal",
105 | bindErr: nil,
106 | serviceReturn: roomOutputData,
107 | serviceErr: nil,
108 | jsonErr: nil,
109 | expected: nil,
110 | },
111 | {
112 | name: "bindError",
113 | bindErr: errors.New("bindError"),
114 | serviceReturn: input.RoomOutputData{},
115 | serviceErr: nil,
116 | jsonErr: nil,
117 | expected: adapter.NewHttpError("bindError", 400),
118 | },
119 | {
120 | name: "serviceError",
121 | bindErr: nil,
122 | serviceReturn: input.RoomOutputData{},
123 | serviceErr: errors.New("serviceError"),
124 | jsonErr: nil,
125 | expected: adapter.NewHttpError("serviceError", 400),
126 | },
127 | {
128 | name: "JSONError",
129 | bindErr: nil,
130 | serviceReturn: roomOutputData,
131 | serviceErr: nil,
132 | jsonErr: errors.New("JSONError"),
133 | expected: adapter.NewHttpError("JSONError", 500),
134 | },
135 | }
136 |
137 | for _, tc := range testCases {
138 | tc := tc
139 | t.Run(tc.name, func(t *testing.T) {
140 | t.Parallel()
141 | context := mocks.FakeHttpContext{
142 | FakeBind: func(i interface{}) error { return tc.bindErr },
143 | FakeJSON: func(int, interface{}) error { return tc.jsonErr },
144 | }
145 |
146 | service := fakeRoomService{
147 | fakeCreate: func(input.RoomInputData) (input.RoomOutputData, error) {
148 | return tc.serviceReturn, tc.serviceErr
149 | },
150 | }
151 | controller := NewRoomController(service)
152 |
153 | if err := controller.Create(context); !reflect.DeepEqual(tc.expected, err) {
154 | t.Errorf("%v: controller.Index expected = %v, got = %v", tc.name, tc.expected, err)
155 | }
156 | })
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/front/src/pages/room/Index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | createMuiTheme,
4 | createStyles,
5 | ThemeProvider,
6 | withStyles,
7 | WithStyles,
8 | } from '@material-ui/core/styles';
9 | import CssBaseline from '@material-ui/core/CssBaseline';
10 | import Hidden from '@material-ui/core/Hidden';
11 | import Typography from '@material-ui/core/Typography';
12 | import Link from '@material-ui/core/Link';
13 | import Navigator from '../../components/Navigator';
14 | import Content from '../../components/Content';
15 | import Header from '../../components/Header';
16 |
17 | function Copyright() {
18 | return (
19 |
20 | {'Copyright © '}
21 |
22 | Your Website
23 | {' '}
24 | {new Date().getFullYear()}
25 | {'.'}
26 |
27 | );
28 | }
29 |
30 | let theme = createMuiTheme({
31 | palette: {
32 | primary: {
33 | light: '#63ccff',
34 | main: '#009be5',
35 | dark: '#006db3',
36 | },
37 | },
38 | typography: {
39 | h5: {
40 | fontWeight: 500,
41 | fontSize: 26,
42 | letterSpacing: 0.5,
43 | },
44 | },
45 | shape: {
46 | borderRadius: 8,
47 | },
48 | props: {
49 | MuiTab: {
50 | disableRipple: true,
51 | },
52 | },
53 | mixins: {
54 | toolbar: {
55 | minHeight: 48,
56 | },
57 | },
58 | });
59 |
60 | theme = {
61 | ...theme,
62 | overrides: {
63 | MuiDrawer: {
64 | paper: {
65 | backgroundColor: '#18202c',
66 | },
67 | },
68 | MuiButton: {
69 | label: {
70 | textTransform: 'none',
71 | },
72 | contained: {
73 | boxShadow: 'none',
74 | '&:active': {
75 | boxShadow: 'none',
76 | },
77 | },
78 | },
79 | MuiTabs: {
80 | root: {
81 | marginLeft: theme.spacing(1),
82 | },
83 | indicator: {
84 | height: 3,
85 | borderTopLeftRadius: 3,
86 | borderTopRightRadius: 3,
87 | backgroundColor: theme.palette.common.white,
88 | },
89 | },
90 | MuiTab: {
91 | root: {
92 | textTransform: 'none',
93 | margin: '0 16px',
94 | minWidth: 0,
95 | padding: 0,
96 | [theme.breakpoints.up('md')]: {
97 | padding: 0,
98 | minWidth: 0,
99 | },
100 | },
101 | },
102 | MuiIconButton: {
103 | root: {
104 | padding: theme.spacing(1),
105 | },
106 | },
107 | MuiTooltip: {
108 | tooltip: {
109 | borderRadius: 4,
110 | },
111 | },
112 | MuiDivider: {
113 | root: {
114 | backgroundColor: '#404854',
115 | },
116 | },
117 | MuiListItemText: {
118 | primary: {
119 | fontWeight: theme.typography.fontWeightMedium,
120 | },
121 | },
122 | MuiListItemIcon: {
123 | root: {
124 | color: 'inherit',
125 | marginRight: 0,
126 | '& svg': {
127 | fontSize: 20,
128 | },
129 | },
130 | },
131 | MuiAvatar: {
132 | root: {
133 | width: 32,
134 | height: 32,
135 | },
136 | },
137 | },
138 | };
139 |
140 | const drawerWidth = 256;
141 |
142 | const styles = createStyles({
143 | root: {
144 | display: 'flex',
145 | minHeight: '100vh',
146 | },
147 | drawer: {
148 | [theme.breakpoints.up('sm')]: {
149 | width: drawerWidth,
150 | flexShrink: 0,
151 | },
152 | },
153 | app: {
154 | flex: 1,
155 | display: 'flex',
156 | flexDirection: 'column',
157 | },
158 | main: {
159 | flex: 1,
160 | padding: theme.spacing(6, 4),
161 | background: '#eaeff1',
162 | },
163 | footer: {
164 | padding: theme.spacing(2),
165 | background: '#eaeff1',
166 | },
167 | });
168 |
169 | export interface PaperbaseProps extends WithStyles {}
170 |
171 | function RoomIndex(props: PaperbaseProps) {
172 | const { classes } = props;
173 | const [mobileOpen, setMobileOpen] = React.useState(false);
174 |
175 | const handleDrawerToggle = () => {
176 | setMobileOpen(!mobileOpen);
177 | };
178 |
179 | return (
180 |
181 |
182 |
183 |
196 |
197 |
198 |
199 |
200 |
201 |
204 |
205 |
206 |
207 | );
208 | }
209 |
210 | export default withStyles(styles)(RoomIndex);
211 |
--------------------------------------------------------------------------------
/front/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import AppBar from '@material-ui/core/AppBar';
3 | import Avatar from '@material-ui/core/Avatar';
4 | import Button from '@material-ui/core/Button';
5 | import Grid from '@material-ui/core/Grid';
6 | import HelpIcon from '@material-ui/icons/Help';
7 | import Hidden from '@material-ui/core/Hidden';
8 | import IconButton from '@material-ui/core/IconButton';
9 | import Link from '@material-ui/core/Link';
10 | import MenuIcon from '@material-ui/icons/Menu';
11 | import NotificationsIcon from '@material-ui/icons/Notifications';
12 | import Tab from '@material-ui/core/Tab';
13 | import Tabs from '@material-ui/core/Tabs';
14 | import Toolbar from '@material-ui/core/Toolbar';
15 | import Tooltip from '@material-ui/core/Tooltip';
16 | import Typography from '@material-ui/core/Typography';
17 | import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles';
18 |
19 | const lightColor = 'rgba(255, 255, 255, 0.7)';
20 |
21 | const styles = (theme: Theme) =>
22 | createStyles({
23 | secondaryBar: {
24 | zIndex: 0,
25 | },
26 | menuButton: {
27 | marginLeft: -theme.spacing(1),
28 | },
29 | iconButtonAvatar: {
30 | padding: 4,
31 | },
32 | link: {
33 | textDecoration: 'none',
34 | color: lightColor,
35 | '&:hover': {
36 | color: theme.palette.common.white,
37 | },
38 | },
39 | button: {
40 | borderColor: lightColor,
41 | },
42 | });
43 |
44 | interface HeaderProps extends WithStyles {
45 | onDrawerToggle: () => void;
46 | }
47 |
48 | function Header(props: HeaderProps) {
49 | const { classes, onDrawerToggle } = props;
50 |
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | Go to docs
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
96 |
97 |
98 |
99 |
100 | Authentication
101 |
102 |
103 |
104 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | );
134 | }
135 |
136 | export default withStyles(styles)(Header);
137 |
--------------------------------------------------------------------------------
/front/src/components/Navigator.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles';
4 | import Divider from '@material-ui/core/Divider';
5 | import Drawer, { DrawerProps } from '@material-ui/core/Drawer';
6 | import List from '@material-ui/core/List';
7 | import ListItem from '@material-ui/core/ListItem';
8 | import ListItemIcon from '@material-ui/core/ListItemIcon';
9 | import ListItemText from '@material-ui/core/ListItemText';
10 | import HomeIcon from '@material-ui/icons/Home';
11 | import PeopleIcon from '@material-ui/icons/People';
12 | import DnsRoundedIcon from '@material-ui/icons/DnsRounded';
13 | import PermMediaOutlinedIcon from '@material-ui/icons/PhotoSizeSelectActual';
14 | import PublicIcon from '@material-ui/icons/Public';
15 | import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet';
16 | import SettingsInputComponentIcon from '@material-ui/icons/SettingsInputComponent';
17 | import TimerIcon from '@material-ui/icons/Timer';
18 | import SettingsIcon from '@material-ui/icons/Settings';
19 | import PhonelinkSetupIcon from '@material-ui/icons/PhonelinkSetup';
20 | import { Omit } from '@material-ui/types';
21 |
22 | const categories = [
23 | {
24 | id: 'Develop',
25 | children: [
26 | { id: 'Authentication', icon: , active: true },
27 | { id: 'Database', icon: },
28 | { id: 'Storage', icon: },
29 | { id: 'Hosting', icon: },
30 | { id: 'Functions', icon: },
31 | { id: 'ML Kit', icon: },
32 | ],
33 | },
34 | {
35 | id: 'Quality',
36 | children: [
37 | { id: 'Analytics', icon: },
38 | { id: 'Performance', icon: },
39 | { id: 'Test Lab', icon: },
40 | ],
41 | },
42 | ];
43 |
44 | const styles = (theme: Theme) =>
45 | createStyles({
46 | categoryHeader: {
47 | paddingTop: theme.spacing(2),
48 | paddingBottom: theme.spacing(2),
49 | },
50 | categoryHeaderPrimary: {
51 | color: theme.palette.common.white,
52 | },
53 | item: {
54 | paddingTop: 1,
55 | paddingBottom: 1,
56 | color: 'rgba(255, 255, 255, 0.7)',
57 | '&:hover,&:focus': {
58 | backgroundColor: 'rgba(255, 255, 255, 0.08)',
59 | },
60 | },
61 | itemCategory: {
62 | backgroundColor: '#232f3e',
63 | boxShadow: '0 -1px 0 #404854 inset',
64 | paddingTop: theme.spacing(2),
65 | paddingBottom: theme.spacing(2),
66 | },
67 | firebase: {
68 | fontSize: 24,
69 | color: theme.palette.common.white,
70 | },
71 | itemActiveItem: {
72 | color: '#4fc3f7',
73 | },
74 | itemPrimary: {
75 | fontSize: 'inherit',
76 | },
77 | itemIcon: {
78 | minWidth: 'auto',
79 | marginRight: theme.spacing(2),
80 | },
81 | divider: {
82 | marginTop: theme.spacing(2),
83 | },
84 | });
85 |
86 | export interface NavigatorProps extends Omit, WithStyles {}
87 |
88 | function Navigator(props: NavigatorProps) {
89 | const { classes, ...other } = props;
90 |
91 | return (
92 |
93 |
94 |
95 | Simple Chat
96 |
97 |
98 |
99 |
100 |
101 |
106 | ホーム
107 |
108 |
109 | {categories.map(({ id, children }) => (
110 |
111 |
112 |
117 | {id}
118 |
119 |
120 | {children.map(({ id: childId, icon, active }) => (
121 |
126 | {icon}
127 |
132 | {childId}
133 |
134 |
135 | ))}
136 |
137 |
138 | ))}
139 |
140 |
141 | );
142 | }
143 |
144 | export default withStyles(styles)(Navigator);
145 |
--------------------------------------------------------------------------------
/doc/依存関係図.drawio:
--------------------------------------------------------------------------------
1 | 7V1bc6M2FP41nmkfmgHEzY+J4zSZzXbbpNvdzcuOYohhg8HFcpz011eYiwHJIDAS2Fm/xAihGJ2rvnOONAKTxevvIVw6HwPL9kaKZL2OwOVIUWTZ1PGfqOUtbjHAOG6Yh64VN0m7hnv3Pzt5Mm1du5a9StriJhQEHnKXxcZZ4Pv2DBXaYBgGm2K3p8CzCg1LOLeJhvsZ9MjWL66FnPS9JGl349p2507yr00tufEIZ8/zMFj7yf8bKeBp+4lvL2A6VtJ/5UAr2OSawHQEJmEQoPjb4nVie9HcptP2/fMnaxNcGX999z8s4ep+czvxf4sHu2rySPaGoe2jbodW4qFfoLe201nYvit6S+fXtvB0J5dBiJxgHvjQm+5aL7ZzaEf/RsJXuz63QbDEjTJu/GEj9JbwDlyjADc5aOEld/FrhW9fk+e3F9+iizMtvbx8zd+8fEuvXl30NR0Df4+fMrTkcvdUdJE95KdMLEeXKwRDlG9gnOyEKKtgHc7sin4gEQkYzu2q8RIpjGY7x9cJKX+3g4WN3xx3CG0PIvelyPwwkaF51i959DwM4VuuwzJwfbTKjfxn1IA7JOpAB9KZlPyURB/IQCvyVf0jilRgRfwl/h3pVe6Fdk1bdm3AujqFdXW4wBx34eGJvriECN6jcD1D69DObs3RlsT6tstjiL9tW2785RpFT5C3flk+z/H/GYHz6BdF/X4lZGQnARErbhwX2fdLuOWLDda6RW5vxmAvdojs10qWSAmhkbQzEt21yWnGVJ85OaWoS/sZKUfG5lQyB6FgtkJ+Hhkb3OAHvp22XbnR66R6Ie0x8+Bq5c7ixqRLPEwYPGdW5iDNVaGEmvNIrRJKbXWtFkp5ozs1dBD3pL87xz73dvjizuwaOV3FvaokVepPUk2ZkNRx34Iqy8RU9+oKyA0FKnEF0u8HuwJ9SiEfX4Cw3KZOGowyf8UvlTxY4QWM1fqx4vcmxurKIZC1QXBwwdbsLEmFuUkM0s7WSHtsTbXj2lZ0uuZyndXWKMOyNXUOZXblP66WW58R2eETnNU6l3f2Mli5KIh+ZKXVsmAwVN9SNQuyrZlq3/bKYJB2QsYKcloWMAuunGyim81kLQ/n5kmjTFPadqBGB9K4QCa9rTrHA5XVuSpYm7OsHN4bffVUxFKsTgXt6IsH6pu+45/0Jb0orUzfcUt3jMQCBNNXIZdul8ECun6dDdx2OnDhNo/crlbuDbtJHBvDW8Qpw1jEcXOBnwI/c3nNvau+ukVfyxWmWNxYSSQ/70ZbpoyeAucaXTzcTxfzMTBvnn7rHLHZ4xGYRQ/QkMDZOPcxxWoXhWB0Zi87zDoOFB5SjaE52woglfkFMXkrBy6jr7M3z8WzGNbP4GM83bePWUMWk/u0RngUO2kv0oXXxGvFedcNnZh3vcL4dz/tKjHtUx+56I2YevyCqDi1T1iPTgIvCHfaFXru3I/oY0crV9wQzYw7g955cmPhWtbWAtDIViRBwdcqq+XtdfLjeAqKLJFAqkbaYJVCM4UbzUgU6vPKnsGV/ZNoseEgUcfeaUZCPzlw5yfVInoAaVgkI+Ef138K4U9yxXfVgZGLttrvBFy9vLiGPqZNWOP1QQsu8ZADxVeBMjSXD5Drd2Lqjjly/56Wkgnl6iMyiZRyX0uOy462WhyCGaYuDWTIpYE4L0OB3IdMZKyasee33B06q/KTI9Ka5kVL6R6KPZD5ZNk4G6sFtlHHLWFWlrF4syCJhBypnt4D+VXzV6ZqO0sfbZQy0rGmVlk1NVDFCEsGaafcXY4isEpKeSAFiA1HABLBugvWqNZt3C4qBooTZtmd6ZxSvHzBTiMJWHXk5l8jtJxguY9Wc0ft6JPZ0r1HjcDRJE7JRSvCGjViMCGtTEH3ub88ChBUVpsiyKSQqQdEbJo9TaV+LN6GhSUP6Sj8L6YcwxZLj52c7EQje0pcXiL7KlhQRFUjU2ZVs6UgyBIll7ccl9ojCU1rhLQStqirauVvI/on4c397wIqH+BTTgRoCOXRyjEle7hDyZVqJFewhUtzU+slW5CJI7NlW+ILulwzEGfjpp4UCNwO4q0uERmIcQOsZaaqKC+vnMloaO1EYOstSrmPUjRyY7HuXorQ5CSCfWl6TGBC76tSlURsvkzx60tXIVzYmyB8JlWR57nLlV0/icUZzwr7txfPNpo56UVl2Kg5ts1OjlKi91hjowa3VCSVhuy8M0vQSbCvGcMMRp+X1YNcThVgXqloNQPxVuAk2MWQld5BTjovTQHIEJQ+7lt302rp3qe2KIGDRlN0MG+bOnYaU1GoRwZFKZmSbtBNtSrHnFnlGI2G7QgmKVnwNC68388tdE8tPlfMQz0t7PIgDP9MOQTiECCZfRZ/yDphZEzF7EI28coQj5xb3mklUyY2oUclq06jQgfJCvHkhZE7gP+jIn26+0gKygktPswzqfApkj5DofpbjbwXsLYjpOqQ9Ut71ValseoXNqYY1aaVsFpdqaxrY9VrxLB6n+VyGomlkPJCY9TKemuG1L8cU3LIBBRUlG1gE2UUqTk2z1R5px/ldkwiS3LToXkzCgXmmaqji+lofDmaaqPzy1HKun3WWOw3mmUGjLk2/SmjKGMg+vAEUiWAzScoEpViMYXWX2gsKT6cFEDRGJ6iOjAUbuqgZmje6oCC40RaAKuDq9HUGJlXkWr4qQ4aqgND7lkd6DR/oPXmqXf2ahn4q5o9GYuJnzNMmDDwvMEmapYLHvtPrtUHmKZ5SKZZy3BKqzyW6qVRcdnNEXFl3gFSFrUFpFoMr5cCCUArAAFKO7tmmJSKbxEQrFkGfuVCphofUFUn/eOB1oS1Te/Ky4/JHQtl3kBdUJBCo+QvSy2DoRolf1lQ1qaiVG/rrpYLdpRuAxLwzllb5z5a3G18BZiLzdqGKThUSq5hK/A+puQaofuyU2e6l8LVPotQS0qqbd4eXc9KlXq2a4VI0YhV0sQ91Y9yAITRViGSylXWS5mD3a15qbN2zE71+5GMKoavjy0ogiQj8q7zn46kpG5cfiVQVSGdQsz0BGw2GfMWuivLy+wPwwkkY/7Ph+9vUxu8PHxbUM9r6qS+lu1YjSM7/kZkDjOVXCxht0EU1pbsS9cnEpSS4HhszNDelFQJWj1QI8jJ0im75rVNwdUpuybyy8KlTu8wssl73DiE91kzlDVCFZ9zxxkpfr3asiKIAicq/KqALEfVrz7LtxcbE0HwuH7Vz2k4xQHhk09rdJKHzwmNmFDJNIzd0A+OdBS0zPCPtKqSmGPXM7JYPUNz94/dTA6LgSl28pT5l9FO9nRsbLL7RVchBuc50G+g9+/tzd9zGD5MH77Kk25NdxSdmOI1Ux3OcXR7UgkNUFDp1PPOmspxbK25D7sVluPNqlSrZJH7drNSNVxaNup7FGzjiCu5zK48GJsMwZI+NWCMDnelLfvxN1osr4cohA3dpfZCyBoM6VMIVUmrTXXoSPAo9qwkeC0eqZFVYBCvp/KT1R9/fnu8/eg8qOfWv47237O/DumgRM9gM/8NrKrji+wAda+799DKPakkFrRFtllZk6nrBpPYkpnzpc2s0jCsIPkg0aBo1x7o+rVhSssdaIyS3PsBpHVnIhx36jQPA7PoeuuHNmmVJSdc4bpTTKu0oT6VjExGtFTQdpOw6oQIwC+6Nblf//jjbg6vbz6o908vt9KXv/45LUvcEsXLDKvRz74KVaw9mG0VtPL2ji0ta2wI9ltsmZulpfL/MOIuA+H/YbN/5VbJovlfHbflf31Q/H+SLlBb/leaZZEOQgBEZfVQfCDQdqNUSuq0yi8tgsr3A0x3G4rfcxx8L2qftw7Z3iRBcrm8tu0wme0yHOsb/W/zYjqf65PrDz+UmxNz90/3BIi0qHY4J0CQvKu1PwGCPE6CVRCaAuxGah9T/6rmBAii/zBPgCDEeJJtUlADELLtZsAAFBYq0eeRiCbfD6hKb7DnAZkxIjSFnqZcT9KnKKb0HdsJBIfVXonyMcyiClHaOhilOmhZ66ocEV+GQYDy3bE+cD4Glh31+B8=7Vxbe6I8EP41XLYP58NlW3V3nx52n/rs193LKFHZIrEQa91f/wVM5JAIioi0XS9aGULAmfedTCYTJO1m/vYlBIvZPXKhL6my+yZpPUlVFcU2yb9Yst5ILM3ZCKah59JGqWDo/YVUKFPp0nNhlGuIEfKxt8gLxygI4BjnZCAM0SrfbIL8/F0XYAo5wXAMfF765Ll4xn6XLKcnvkJvOqO3tg16YgTGz9MQLQN6P0nVJslnc3oOWF+0fTQDLlplRFpf0m5ChPDm2/ztBvqxbpnaNtcNdpzdPncIA7zPBb+vh44yAO7raLm4DaLQtB8eLpgVXoG/hOx3JE+L10xD0CUKo4coxDM0RQHw+6n0OtECjO8jk6O0zR1CCyJUiPAPxHhNrQ+WGBHRDM99epb8hHD9K77+0mCHv2l3yUHvLXe0ZkdvHv7F+iDfk2suLYMeplfFB9uLAgZDJT6MMAhxVsArluo6QstwDEu0aVD8gnAKcZnWaYexZjN3oHb7AtEckl9JGoTQB9h7zUMVUMRPt+22l/5AHnlmVabs1Ez5Uqb8pPRUGBxZL5uHpRem8CFfMk+SihJQHQAwg8PXDSImRL4PQw5peRytZh6GwwVIVL4i3iePmZ1meoUhhm+lemVnHU4/DlXPKuMLmMpmGTdgyruNkdPhoQpTtU4wMmHFVexfiSBAAWSygRf/HkYk1mLsgyjyxhshbXIMsTlSFkjLiE+7pdRXSnlfn9eMr5XEZrY6NbENhQOuphcQuflVHLH5vuy4L3n70XPdqo5xKn/huao2vJr4aPl83/vx+KINbl7IgGSfZQASDiRV4wjDdg7ZKdDLsH08tSLiRZ+3MUssmZCTN8hHYdrvhDhbxhu1Yuwrdaq1OCK0sOU0zZGj/C177oy//RZgGIIxRucfoTSDH8HNcw9Rmt29ISplSQmVKClSHslCHp0uQGSKqhxINKtTJGHPnbO4CeYx4H2cqIQeBaMo/pdQaBITg52Y0mZJ8xFhlplIHuECRR6h2rqLXFONs3ONd0881ziE51hShLcLolmi1lK9VSIvoxJDoBEmOzLSUSzOKLpRM9IR9KW1OxvSRDz6RNbUtObiVlFfLVtzn6naB7amzk+fa3NT0Ffb1jQ/tzVNfvgj3LxUrOboSbqTsx+zXQNbnIG7HX1oZ4/09X0i/c/FiSajD66vEzNAdzhz9q45g0YzsIi/jte+RygQVsN/tOHK3Wgr2K6NfF9i0guk8jypGmCNofNKtfRLXXbSj8WRyCxBTOMcMnRO6f0Ae5h3OeQ347xiBRkd4HvTILYOjKdYRBAryxsD/4qemHuum0ywRUbLGyDHxWyqyGbH9OEa8nEGTyfBBEsXWKe4atGcdfgFip8RHIMIfj7zaHwMdnbz8EFZJrXw2Qyk8jHV2Q3EB1VeMAnB5zOOJXfKMIqi86FaewsphyzMnXNBpH4EemRoacWRi50JU/JBjOkIg5hDg87j7nLytT7OdzyiJe5AYYCW57JiOZeOw+upjXmZWHMdLBIQr8AotVZgKohrSO2u2DayICQ0pLFvyZDRjlfS+MICrmJo7wkvX31ktFtMwFjRJZocUUtzFOLz9TdKG4gvzcKdoU5gR45HKTh726iHd0uwvKScLMEjBrwowfMVBG5Hq+12BSBnG1j3WtJ5Nx5DUNrwIUZNU+BDShOeJ/chAmhr4jTkoV7F5EfRqq5P7GQE6yjvmCPiCau8JyOaG1X3rr4rnUpVjqpGS4GkUhhVjbqjaqEfXdFzUzG7Vehb5jmQXsdpd6jINMMW9SC6NEuHFqdVRKtgnWmwiPEY7SaLqvKVZ4pcAPCm00bhLFj8eOoTtcuDEMzhCoXPPNx931tEuzKwGdDmUb/d/JQcPEM8nm2ByiFs3xRd7XwOq2mvCjObWBMUK56P0+fAC84eohf01Lnw3OQzhv9CjzqhR33XyqBbGWkwH3zySKOAWac0QNi7fOmgXk8cdpj/8ljNzEFbgL3a0pRTANCsq7b0muG2zU83nbOuEpn8dDMuWpLdkCgvlOLxZ0D+fn+8/yjBiqDorLqmqd0AxuzgFqBT5Mnkg3xUeb6r7l7YvZzWcbn2tpyWLihgMQqOq57fEvVcdImtui2LD/HzW6Z6AIMhDpdjvAwrt0n1UCfmB4J9x61uRxRq2hZpuuiLRE6gtE75sDzHcaUkbRYzp9X3amGIsS9tNVOcXzNft0mF173LiUnpCOaPfV267ktOT+ob0lVPcvj9562Xru0OQIoQ3OCWPYpEosLk0wzbLcJ2JWMsJW/K6uTAqQrexLblk1mtuYF8uPFxnIIht+EUqu5yaqcg2PgV+wLiFAZS35LsQewg/jmFvZzCjrliC04huv0+gC+BFQQDZP75e2c9DN0Lfu/FEUHYI4wWKBDsDOhAJUWrO9WhfruaXt3frl7DB+fb82D0Vf150Y0cVbNvElM79CaxsxZJafwKlVLcn7v3VE3wThOr0FdzLl+I1W5U+WSwatUCa+ZVd9VV9q2CtcwZnwOres3ARRf0dTqsvjnDxfzu533v8eVaf1rNJuC/p4t9XgDybjJt7zL3r+6b+zcbf2vPjiS9Kd5Iv42n80FY3dIbEuXx283Ku67NBXKYvgp10zx936zW/x8=
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/ryoutaku/simple-chat/actions/workflows/test.yml)
2 | [](https://codecov.io/gh/ryoutaku/simple-chat)
3 |
4 | # Go + CleanArchitectureでのREST API設計
5 |
6 | ## 概要
7 | CleanArchitectureに基づいて設計されたGoによるREST API
8 |
9 | ### CleanArchitectureとは
10 |
11 | [【書籍】Clean Architecture 達人に学ぶソフトウェアの構造と設計](https://tatsu-zine.com/books/clean-architecture)
12 | で提唱されたソフトウェアアーキテクチャの実装例です。 \
13 | 以下の図のようにソースコードをいくつかの領域に分割し、依存性が内側(上位レベルの方針)にだけ向くように設計します。
14 |
15 | 
16 | > Clean Architecture 達人に学ぶソフトウェアの構造と設計(p.200)より抜粋
17 |
18 | このアーキテクチャは以下の特性を有しています。
19 |
20 | > ・フレームワーク非依存:アーキテクチャは,機能満載のソフトウェアのライブラリに依存していない.これにより,システムをフレームワークの制約で縛るのではなく,フレームワークをツールとして使用できる。 \
21 | > ・テスト可能:ビジネスルールは,UI,データベース,ウェブサーバー,その他の外部要素がなくてもテストできる。 \
22 | > ・UI非依存:UIは,システムのほかの部分を変更することなく,簡単に変更できる.たとえば,ビジネスルールを変更することなく,ウェブUIはコンソールUIに置き換えることができる。 \
23 | > ・データベース非依存:OracleやSQL ServerをMongo,BigTable,CouchDBなどに置き換えることができる.ビジネスルールはデータベースに束縛されていない。 \
24 | > ・外部エージェント非依存:ビジネスルールは外界のインターフェースについて何も知らない。
25 | >
26 | > Clean Architecture 達人に学ぶソフトウェアの構造と設計(p.200)より抜粋
27 |
28 | 以下の図は、データベースを使ったウェブベースのJavaシステムをクリーンアーキテクチャで実装した典型的な例として紹介されていたものです。\
29 | 今回は、この図を参考に実装を行っています。
30 |
31 | 
32 | > Clean Architecture 達人に学ぶソフトウェアの構造と設計(p.204)より抜粋
33 |
34 | ## 詳細
35 |
36 | ### API仕様
37 |
38 | - GET /rooms:Roomの全件取得
39 | - responseBody:
40 | ```json
41 | [
42 | {
43 | "id": 1,
44 | "name": "room_name1",
45 | "createdAt": "2021-02-13T18:38:51+09:00",
46 | "updatedAt": "2021-02-13T18:38:51+09:00"
47 | },
48 | {
49 | "id": 2,
50 | "name": "room_name2",
51 | "createdAt": "2021-02-13T18:38:51+09:00",
52 | "updatedAt": "2021-02-13T18:38:51+09:00"
53 | }
54 | ]
55 | - POST /rooms:Roomの作成
56 | - requestBody:
57 | ```json
58 | {"name": "room_name"}
59 | - responseBody:
60 | ```json
61 | {
62 | "id": 1,
63 | "name": "room_name",
64 | "createdAt": "2021-02-13T18:38:51+09:00",
65 | "updatedAt": "2021-02-13T18:38:51+09:00"
66 | }
67 |
68 | ※アーキテクチャの構成を明確に示すためにAPI自体はシンプルな機能のみにしています。
69 |
70 | ### 開発環境
71 | - アプリケーション
72 | - Go v1.15.3
73 | - gorilla/mux v1.8.0(ルーティング)
74 | - gorm.io/driver/mysql v1.0.3(MySQLドライバ)
75 | - gorm.io/gorm v1.20.11(ORM)
76 | - DATA-DOG/go-sqlmock v1.5.0(データベースのモック生成)
77 | - データベース
78 | - MySQL v5.7(マイグレーション:sql-migrate)
79 | - コンテナ
80 | - Docker
81 | - docker-compose
82 |
83 | ### ディレクトリ構成と各ファイルの役割
84 | ※テストコードなどアーキテクチャと直接関係ないファイルは省略しています
85 | ```
86 | app
87 | ├── di
88 | │ └── container.go ・ ・ ・ 「DIコンテナ(依存性の注入)」
89 | │
90 | ├── domain(エンティティ)
91 | │ └── room.go ・ ・ ・ 「テーブル設計と一致させたデータ構造体」
92 | │
93 | ├── infra(フレームワーク/ドライバ)
94 | │ ├── db_handler.go ・ ・ ・ 「DBへの接続、クエリ実行、ORMの呼び出し」
95 | │ ├── http_context.go ・ ・ ・ 「リクエストボディのパース処理、レスポンスの生成」
96 | │ ├── http_handler.go ・ ・ ・ 「Controllerのラッパー、HttpContextの初期化、エラーハンドリング」
97 | │ └── router.go ・ ・ ・ 「ルーティング」
98 | │
99 | ├── interface(インターフェイスアダプター)
100 | │ ├── adapter ・ ・ ・ 「infra ↔︎ interfaceの境界線」
101 | │ │ ├── db_handler.go ・ ・ ・ 「DBHandlerのインターフェイス」
102 | │ │ ├── http_context.go ・ ・ ・ 「HttpContextのインターフェイス」
103 | │ │ ├── http_handler.go ・ ・ ・ 「HttpHandlerのインターフェイス」
104 | │ │ └── http_error.go ・ ・ ・ 「HttpHandlerへエラー内容を伝える際のデータ型」
105 | │ ├── controller ・ ・ ・ 「リクエストをBindしてServiceへ処理を依頼し、処理結果をもとにレスポンスを生成」
106 | │ │ └── room_controller.go
107 | │ └── repository ・ ・ ・ 「永続層への操作を組み立てる」
108 | │ └── room_repository.go
109 | │
110 | ├── usecase(ユースケース)
111 | │ ├── input ・ ・ ・ 「controller ↔︎ serviceの境界線」
112 | │ │ └── room_input.go ・ ・ ・ 「Serviceのインターフェイス、Controllerとやり取りする際のデータ型」
113 | │ ├── service ・ ・ ・ 「domainやrepositoryを適宜呼び出してユースケースを実現」
114 | │ │ └── room_controller.go
115 | │ └── dao ・ ・ ・ 「repository ↔︎ serviceの境界線」
116 | │ └── room_dao.go ・ ・ ・ 「Repositoryのインターフェイス」
117 | │
118 | └── main.go ・ ・ ・ 「各コンポーネントの初期化、サーバー起動」
119 | ```
120 |
121 | ### コードの依存関係を表した図
122 |
123 | 
124 |
125 | 実際に実装したコードの依存関係を簡易的に表した図です。 \
126 | 右側が円の内側に相当し、各レイヤーの境界を越える際は、依存性の向きが内側に向くようになっています。\
127 | 円の外側に向かう制御が必要な箇所(Usecase層からのDBアクセス)は、依存性の注入(DI)を使って対処しています。
128 |
129 | なお、[Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments#interfaces) では構造体を利用する側のパッケージにインターフェースを定義することが推奨されています。\
130 | 但し、本実装では以下のインターフェイスに関しては、例外的に別パッケージに分けています。
131 |
132 | - `Service`のインターフェイス(`定義場所:input`パッケージ)→パッケージ単位で依存性の向きが内側に向くようにしたかったため
133 | - `DBHandler`と`HttpContext`インターフェイス(定義場所:`adapter`パッケージ)→ファイルの見通しを良くするため
134 | - `Repository`のインターフェイス(定義場所:`dao`パッケージ)→ 一つのServiceから複数のRepositoryを呼び出すケースも想定されるため
135 |
136 | インターフェイスの命名については、実装と一対一の関係(同じ役割)なので、実装と同じ名前にしています。
137 | [Effective Go #interface-names](https://golang.org/doc/effective_go#interface-names)
138 |
139 | DIコンテナ(依存性の注入)については、Wireやdigなどのツールを試しましたが、現状の構成では学習コストや外部ツール依存のリスクを上回るメリットを感じられなかったので自作しています。
140 |
141 | ### 設計のポイント
142 |
143 | 本実装において、設計をどうするかで苦慮した箇所を3点ピックアップしています。
144 | 1. infra層の改修のみでWEBフレームワークやORMなどの置換が可能
145 | 2. 各層をインターフェイスで結ぶことでモックを利用した単体テストを容易化
146 | 3. レイヤー間のデータのやり取りに専用のデータ構造体を利用して責務を明確化
147 |
148 | #### 1. infra層の改修のみでWEBフレームワークやORMなどの置換が可能
149 |
150 | ルーティングで設定するハンドラ関数の定義は、以下のように利用するWEBフレームワーク・ライブラリによって異なります。
151 |
152 | - net/http:`func(http.ResponseWriter, *http.Request)`
153 | - Gin:`func(*gin.Context)`
154 | - Echo:`func(echo.Context) error`
155 |
156 | ハンドラ関数をこれらの定義に合わせて実装すると、もしWEBフレームワークを別のものに切り替えたい場合に、全てのハンドラ関数の修正が必要になります。 \
157 | (WEBフレームワークのメソッドをハンドラ関数内で呼び出していて、そのメソッドのシグネチャがフレームワーク変更で変わる場合、その箇所の修正も必要)
158 |
159 | そこで、本実装ではハンドラ関数(controller)をラップする`HttpHandler`を用意し、そこでフレームワークの差異を吸収することで、修正箇所を1箇所に集約しています。 \
160 | WEBフレームワークのメソッド呼び出しに関しては、`HttpContext`のインターフェイスを介すことで、メソッド呼び出しの修正箇所も集約しています。 \
161 | `HttpContext`は、ルーティング時にフレームワークから引数で渡される`http.ResponseWriter`や`*http.Request`などをラップして、controllerから隠蔽します。
162 |
163 | 
164 |
165 | 本実装では`net/http`とルーティングに`gorilla/mux`を利用しています。
166 |
167 | ```go
168 | // app/infra/router.go
169 | func NewRouter(c *di.Container) *Router {
170 | r := mux.NewRouter()
171 |
172 | r.HandleFunc("/", httpHandler(c.Room.Index).run).Methods("GET")
173 | r.HandleFunc("/rooms", httpHandler(c.Room.Index).run).Methods("GET")
174 | r.HandleFunc("/rooms", httpHandler(c.Room.Create).run).Methods("POST")
175 |
176 | return &Router{Engine: r}
177 | }
178 | ```
179 | `httpHandler`では他にも、controllerからの戻り値`HttpError`を用いたエラーハンドリングの共通化と、予期せぬpanicからの復旧も担っています。 \
180 | このあたりは、フレームワークに備わっているミドルウェアを利用した方が良いかもしれないと考えています。(実装が容易、責務の分割)
181 |
182 | ```go
183 | // app/infra/http_handler.go
184 | type httpHandler func(adapter.HttpContext) *adapter.HttpError
185 |
186 | func (fn httpHandler) run(w http.ResponseWriter, r *http.Request) {
187 | context := NewHttpContext(w, r)
188 | defer func() {
189 | if err := recover(); err != nil {
190 | log.Println(err)
191 | http.Error(w, "Internal Server Error", http.StatusInternalServerError)
192 | }
193 | }()
194 |
195 | if err := fn(context); err != nil {
196 | http.Error(w, err.Error(), err.Code)
197 | }
198 | }
199 | ```
200 | 以下は、各フレームワークのメソッド(リクエストのBind等)を呼び出す際の`HttpContext`のインターフェイスと実装部分です。
201 |
202 | ```go
203 | // app/interface/adapter/http_context.go
204 | package adapter
205 |
206 | type HttpContext interface {
207 | Bind(i interface{}) error
208 | JSON(code int, i interface{}) error
209 | }
210 | ```
211 | ```go
212 | // app/infra/http_context.go
213 | func NewHttpContext(w http.ResponseWriter, r *http.Request) adapter.HttpContext {
214 | return &httpContext{
215 | Writer: w,
216 | Request: r,
217 | }
218 | }
219 |
220 | func (c *httpContext) Bind(i interface{}) (err error) {
221 | err = json.NewDecoder(c.Request.Body).Decode(i)
222 | if err != nil {
223 | return errors.New("invalid request")
224 | }
225 | return err
226 | }
227 |
228 | func (c *httpContext) JSON(code int, i interface{}) (err error) {
229 | res, err := json.Marshal(i)
230 | if err != nil {
231 | return errors.New("internal server error")
232 | }
233 |
234 | c.Writer.Header().Set("Content-Type", "application/json")
235 | c.Writer.WriteHeader(code)
236 | c.Writer.Write(res)
237 | return err
238 | }
239 | ```
240 |
241 | ORMについても、interface層から利用する際は`DBHandler`を経由することで、依存関係の逆転と修正箇所を集約しています。
242 |
243 | 
244 |
245 | これら実装のメリットは、interface層以下がフレームワークについて何も知らなくて良い様にすることで、infra層に修正箇所を集約できることです。
246 |
247 | デメリットは、インターフェイスを含めてファイル数が多くなり、管理が煩雑になることが懸念されます。 \
248 | また、フレームワークの機能を呼び出す為に、わざわざインターフェイスの実装を用意するのも冗長なように感じます。
249 |
250 | 今回はクリーンアーキテクチャの原則に則ることを重視して、infraとinterfaceの依存関係逆転が達成できる本実装を採用しましたが、interfaceが直接フレームワークを使う構成でも良いかもしれません。 \
251 | (ビジネスロジックであるUsecase層に、WEBの都合が侵食しなければ良いという方針)
252 |
253 | なお、フルスタックなフレームワーク(Beegoなど)やコード生成フレームワーク(goaなど)などは、今回の実装では置換出来ません。
254 |
255 | #### 2. 各層をインターフェイスで結ぶことでモックを利用した単体テストを容易化
256 |
257 | インターフェイスを用いることでgomockなどの外部ツールに頼らなくても、実装をモックに置き換えることができます。\
258 | controllerの単体テストを例に説明します。
259 |
260 | 
261 |
262 | テスト対象のメソッドは、Roomを全件取得するためのハンドラ関数`Index`です。 \
263 | 今回は、内部で呼び出している`Service.All()`をモックに置き換えます。
264 |
265 | ```go
266 | // app/interface/controller/room_controller.go
267 | type RoomController struct {
268 | Service input.RoomService
269 | }
270 |
271 | func (c *RoomController) Index(hc adapter.HttpContext) *adapter.HttpError {
272 | rooms, err := c.Service.All()
273 | if err != nil {
274 | return adapter.NewHttpError(err.Error(), http.StatusBadRequest)
275 | }
276 |
277 | respBody := convertRoomsResponse(&rooms)
278 | if err := hc.JSON(http.StatusCreated, respBody); err != nil {
279 | return adapter.NewHttpError(err.Error(), http.StatusInternalServerError)
280 | }
281 | return nil
282 | }
283 | ```
284 |
285 | controllerでは、`input`パッケージ内で定義されている`Service`のインターフェイスを利用しています。
286 | ```go
287 | // app/usecase/input/room_input.go
288 | package input
289 |
290 | type RoomService interface {
291 | All() (outData RoomsOutputData, err error)
292 | Create(inData RoomInputData) (outData RoomOutputData, err error)
293 | }
294 | ```
295 |
296 | `Service`の実装は、`service`パッケージ内で定義されています。 \
297 | ここで定義されたコンストラクタはDI(依存性の注入)の際に呼び出されて、controllerに渡されます。\
298 | コンストラクタは「Accept interfaces, Return structs」という広く浸透しているGoのInterfaceのお作法に従って、引数はインターフェイスで受け取って具象型(ポインタ)を返しています。
299 |
300 | ```go
301 | // app/usecase/service/room_service.go
302 | type RoomService struct {
303 | Repository dao.RoomRepository
304 | }
305 |
306 | func NewRoomService(repo dao.RoomRepository) *RoomService {
307 | return &RoomService{Repository: repo}
308 | }
309 |
310 | func (s *RoomService) All() (outData input.RoomsOutputData, err error) {
311 | rooms, err := s.Repository.All()
312 | if err != nil {
313 | return
314 | }
315 |
316 | outData = convertToRoomsOutputData(&rooms)
317 | return
318 | }
319 | ```
320 |
321 | ```go
322 | // app/usecase/dao/room_dao.go
323 | package dao
324 |
325 | type RoomRepository interface {
326 | All() (rooms domain.Rooms, err error)
327 | Create(room *domain.Room) (err error)
328 | }
329 | ```
330 |
331 | Serviceのモック`fakeRoomService`には、`input.RoomService`(インターフェイス)を埋め込んでいます。 \
332 | これにより、まだテストコードを実装していないメソッドをわざわざ定義しなくても、`fakeRoomService`はインターフェイスを満たすことが出来る。 \
333 | (例えば、RoomServiceには`Create`メソッドも定義されていますが、`Create`メソッドを`fakeRoomService`に定義しなくてもインターフェイスを満たせる)
334 |
335 | モックするメソッド`All()`を定義し直して、実装内部で同じシグネチャのモック用のメソッド`fakeAll()`を返します。\
336 | `fakeAll()`は、構造体で定義された関数で、構造体を初期化する際に関数の振る舞いを変更することで、モックの挙動を制御します。
337 |
338 | ```go
339 | // app/interface/controller/room_controller_test.go
340 | type fakeRoomService struct {
341 | input.RoomService
342 | fakeAll func() (outData input.RoomsOutputData, err error)
343 | }
344 |
345 | func (i fakeRoomService) All() (outData input.RoomsOutputData, err error) {
346 | return i.fakeAll()
347 | }
348 | ```
349 |
350 | 実際のテストコードではテーブル駆動テストで指定した戻り値でテストケースごとにモック生成しています。\
351 | これにより、様々なテストケースに対応できるようにしています。
352 |
353 | ```go
354 |
355 | func TestRoomControllerIndex(t *testing.T) {
356 | testCases := []struct {
357 | name string
358 | serviceReturn input.RoomsOutputData
359 | serviceErr error
360 | jsonErr error
361 | expected *adapter.HttpError
362 | }{
363 | {
364 | name: "normal",
365 | serviceReturn: roomsOutputData,
366 | serviceErr: nil,
367 | jsonErr: nil,
368 | expected: nil,
369 | },
370 | {
371 | name: "serviceError",
372 | serviceReturn: input.RoomsOutputData{},
373 | serviceErr: errors.New("serviceError"),
374 | jsonErr: nil,
375 | expected: adapter.NewHttpError("serviceError", 400),
376 | },
377 | {
378 | name: "jsonError",
379 | serviceReturn: roomsOutputData,
380 | serviceErr: nil,
381 | jsonErr: errors.New("jsonError"),
382 | expected: adapter.NewHttpError("jsonError", 500),
383 | },
384 | }
385 |
386 | for _, tc := range testCases {
387 | tc := tc
388 | t.Run(tc.name, func(t *testing.T) {
389 | t.Parallel()
390 | context := mocks.FakeHttpContext{
391 | FakeJSON: func(int, interface{}) error { return tc.jsonErr },
392 | }
393 |
394 | service := fakeRoomService{
395 | fakeAll: func() (outData input.RoomsOutputData, err error) {
396 | return tc.serviceReturn, tc.serviceErr
397 | },
398 | }
399 | controller := NewRoomController(service)
400 |
401 | if err := controller.Index(context); !reflect.DeepEqual(tc.expected, err) {
402 | t.Errorf("%v: controller.Index expected = %v, got = %v", tc.name, tc.expected, err)
403 | }
404 | })
405 | }
406 | }
407 | ```
408 |
409 | #### 3. レイヤー間のデータのやり取りに専用のデータ構造体を利用して責務を明確化
410 |
411 | 本実装では、リクエストボディを直接Domain(Entity層)にバインドせず、`input`パッケージのデータ構造体`InputData`にバインドしてServiceに渡しています。 \
412 | 更に、Service層からの戻り値は`input`パッケージのデータ構造体`OutputData`を指定し、レスポンスを生成する際も`controller`のデータ構造体`Response`に詰め替えています。
413 |
414 | リクエストボディを直接Domain(Entity)にバインドして、全ての処理で使い回し、レスポンスもDomain(Entity)の構造体で返した方が構造体の変換処理が省けてシンプルになります。 \
415 | 但し、レイヤーを超えた構造体の引渡しを行えば、構造体の責務が曖昧になり、以下のような問題の発生が懸念されます。
416 |
417 | - どのレイヤーにレスポンスのフォーマットとなるデータ構造体が定義されているか判断しづらい
418 | - レスポンスのフォーマットを変更するのに、Entity層の修正が必要になる \
419 | (Entity層がレスポンスの形式を知っている)
420 | - Entityのデータをそのまま返すので、意図せぬ情報漏えいが発生するリスクがある \
421 | (例えば、User構造体に外部に漏れてはいけないユーザーのIPアドレスが含まれており、その構造体でレスポンスを生成したことでIPアドレスが漏えいした等)
422 |
423 | その為、各レイヤーにはそれぞれの関心のある項目だけで定義した構造体を利用しています。
424 |
425 | なお、Domain(Entity層)をテーブル設計と同じデータ構造にしているので、`Repository`だけ例外的にレイヤーを飛び越えてDomain(Entity層)のデータ構造体を利用しています。
426 |
427 | ### 全体的な所感
428 |
429 | #### 単体テストの実装のしやすさ
430 | 実際に全てのコードの単体テストを実装してみましたが、モックを作りやすいことはメリットとして大きいと感じました。 \
431 | モックを使えばテストのスコープを狭められて、テストが壊れにくく、TDDも実践しやすくなるので、しっかりテストを書いて開発を進める場合には有効な構成だと考えています。
432 |
433 | 一方で、単体テストを書かないのであれば、インターフェイスは冗長なように感じます。 \
434 | (Usecase層からのDBアクセスなど円の外側に向かう制御は依存性注入で解消できているため)
435 |
436 | #### Usecase層の肥大化が懸念
437 | MVCではファットモデルが問題になるケースが多いですが、MVCのモデルに相当する部分がService(Usecase層)とRepository(interface層)に分かれるので、MVCよりはコード肥大化が緩和されるのではないかと感じます。\
438 | 但し複数テーブルにまたがる様な処理や複雑なデータ加工を必要とする処理が増えると、処理を組み立てるUsecase層の肥大化が問題なって来ると思われます。
439 |
440 | Domain(Entity層)にビジネスロジックを置くことで緩和できるでしょうが、Usecase層に置くアプリケーション固有のロジックとどの様に線引きするかは判断が非常に難しく、結局Usecase層にロジックが集中してしまいそうな印象を受けます。 \
441 | シンプルに永続層のデータを返すだけのAPIであれば、この心配は無さそうですが、そうなるとクリーンアーキテクチャをわざわざ採用する必要も無いと考えています。
442 |
443 | #### DDDを採用する場合の構成見直し
444 | `Repository`・`Service`・`Domain`といったDDDの構成要素と同じ名称を採用していますが、DDDを本格的に導入する場合は、大きな構成の見直しが必要になると考えています。 \
445 | 理由としては、現状Domain(Entity層)とテーブルが一対一の関係になっている点や、Usecase層が手続き型な処理でユースケースを実現している点などがあげられます。 \
446 | (DDDに関しては「ドメイン駆動設計入門」を一読した程度の理解度なので、どの様にすることが最適なのか現状不明確です)
447 |
448 | DDDもそうですがGoの言語仕様やお作法も含めて、まだまだ全体的に理解が浅いと実感します。\
449 | ただ設計を見直す度に新しい気づきがあるので、今後もより良い設計を検討して行きたいです。
450 |
451 | ## 参考情報
452 | - [【書籍】Clean Architecture 達人に学ぶソフトウェアの構造と設計](https://tatsu-zine.com/books/clean-architecture)
453 | - [世界一わかりやすいClean Architecture](https://www.nuits.jp/entry/easiest-clean-architecture-2019-09)
454 | - [実践クリーンアーキテクチャ](https://nrslib.com/clean-architecture/#outline__7)
455 | - [Goのサーバサイド実装におけるレイヤ設計とレイヤ内実装について考える](https://www.slideshare.net/pospome/go-80591000)
456 | - [マイクロサービスにクリーンアーキテクチャを採用する上で考えたこと](https://engineering.mercari.com/blog/entry/2019-12-19-100000/)
457 | - [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments#interfaces)
458 |
459 | ## 開発ドキュメント
460 |
461 | ### コマンドリファレンス
462 |
463 | #### セットアップ
464 | ```
465 | # リポジトリクローン
466 | git clone git@github.com:ryoutaku/simple-chat.git
467 | cd simple-chat
468 |
469 | # envコピー
470 | cp env.development .env
471 |
472 | # コンテナ起動
473 | docker-compose up -d --build
474 |
475 | # 作業用コンテナへbashで入る(マイグレーション実行時など)
476 | docker exec -i -t simple_chat_woker bash
477 | ```
478 |
479 | #### マイグレーション
480 | ```
481 | # マイグレーションファイル生成
482 | bin/migrate.sh new create_rooms
483 |
484 | # マイグレーション実行
485 | bin/migrate.sh up [-limit=0] [-dryrun]
486 |
487 | # ロールバック実行
488 | bin/migrate.sh down [-limit=0] [-dryrun]
489 |
490 | # マイグレーションの適用状況を確認
491 | bin/migrate.sh status
492 |
493 | # 直近のマイグレーションを再適用
494 | bin/migrate.sh redo
495 | ```
496 | ※sql-migrateを利用 https://github.com/rubenv/sql-migrate
497 |
498 | #### APIリクエスト
499 | ```
500 | # Roomの全件取得
501 | curl -H "Content-Type: application/json" localhost:5050/rooms
502 |
503 | # Roomの作成
504 | curl -X POST -H "Content-Type: application/json" -d '{"name":"テストルーム"}' localhost:5050/rooms
505 | ```
506 |
507 | ### 公式ドキュメント
508 | - [Golang](https://golang.org/doc/)
509 | - [GORM](https://gorm.io/ja_JP/)
510 | - [sql-migrate](https://github.com/rubenv/sql-migrate)
511 |
--------------------------------------------------------------------------------