Repository: go-WebAPI-team/task-app
Pull request #21: Feature/tags crud
Author: login: sunny-side-app
Created: 2025-04-22T08:10:08Z | Merged: 2025-04-22T09:49:53Z

タグの一覧取得、タグ作成エンドポイントは完了しました。
タグについては下記が今後のToDoです。
   - タグ詳細取得/タグ削除
   - 認証機能実装後のユーザーID取得(handler/create_tag.go)
<!-- This is an auto-generated comment: release notes by OSS CodeRabbit -->
### Summary by CodeRabbit

```
- New Feature: MySQL設定ファイルが追加され、デフォルトの文字セットとして`utf8mb4`を指定
- New Feature: データベーススキーマに`users`、`tasks`、`tags`テーブルを追加
- New Feature: 時間取得の抽象化を提供する`clock`パッケージを導入
- New Feature: タグ作成と一覧取得のHTTPハンドラーを追加
- Chore: Goモジュールに新しい依存関係を追加
- Test: タグ作成機能のテストを実装し、モックリポジトリを使用
```
<!-- end of auto-generated comment: release notes by OSS CodeRabbit -->


Diff:
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2eea525
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.env
\ No newline at end of file
diff --git a/_tools/mysql/conf.d/mysql.cnf b/_tools/mysql/conf.d/mysql.cnf
new file mode 100644
index 0000000..4b87c1d
--- /dev/null
+++ b/_tools/mysql/conf.d/mysql.cnf
@@ -0,0 +1,2 @@
+[mysql]
+default-character-set = utf8mb4
\ No newline at end of file
diff --git a/_tools/mysql/conf.d/mysqld.cnf b/_tools/mysql/conf.d/mysqld.cnf
new file mode 100644
index 0000000..95a1054
--- /dev/null
+++ b/_tools/mysql/conf.d/mysqld.cnf
@@ -0,0 +1,13 @@
+[mysqld]
+# 文字コード系
+character-set-server = utf8mb4
+collation-server     = utf8mb4_bin
+
+# タイムゾーンを JST (+09:00) に固定
+default_time_zone = '+09:00'
+
+# SQL モード厳格化
+sql_mode = TRADITIONAL,NO_AUTO_VALUE_ON_ZERO,ONLY_FULL_GROUP_BY
+
+# 認証プラグインをレガシー互換にする（8.0 既定は caching_sha2 だが、Go ドライバ互換を優先）
+default_authentication_plugin = mysql_native_password
diff --git a/_tools/mysql/schema.sql b/_tools/mysql/schema.sql
new file mode 100644
index 0000000..8d0d1bc
--- /dev/null
+++ b/_tools/mysql/schema.sql
@@ -0,0 +1,65 @@
+-- 文字コード統一
+SET NAMES utf8mb4;
+SET time_zone = '+09:00';
+
+DROP TABLE IF EXISTS tasks_tags;
+DROP TABLE IF EXISTS tags;
+DROP TABLE IF EXISTS tasks;
+DROP TABLE IF EXISTS users;
+
+-- ユーザテーブル
+CREATE TABLE users (
+  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  name         VARCHAR(100)    NOT NULL UNIQUE,
+  password     CHAR(60)        NOT NULL COMMENT 'bcrypt 60 文字',
+  created_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+
+-- タスクテーブル
+CREATE TABLE tasks (
+  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  user_id      BIGINT UNSIGNED NOT NULL,
+  title        VARCHAR(255)    NOT NULL,
+  description  TEXT,
+  deadline     DATETIME,
+  priority     TINYINT         NOT NULL DEFAULT 2 COMMENT '1:Low 2:Normal 3:High',
+  is_done      TINYINT(1)      NOT NULL DEFAULT 0,
+  created_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  KEY idx_tasks_user (user_id),
+  CONSTRAINT fk_tasks_user      FOREIGN KEY (user_id)
+    REFERENCES users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+
+-- タグテーブル
+CREATE TABLE tags (
+  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  user_id      BIGINT UNSIGNED NOT NULL,
+  name         VARCHAR(100)    NOT NULL,
+  created_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_tags_user_name (user_id, name),
+  CONSTRAINT fk_tags_user      FOREIGN KEY (user_id)
+    REFERENCES users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+
+-- タスク✕タグ中間テーブル
+CREATE TABLE tasks_tags (
+  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  task_id      BIGINT UNSIGNED NOT NULL,
+  tag_id       BIGINT UNSIGNED NOT NULL,
+  created_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_tasks_tags (task_id, tag_id),
+  KEY idx_tasks_tags_task (task_id),
+  KEY idx_tasks_tags_tag  (tag_id),
+  CONSTRAINT fk_tasks_tags_task FOREIGN KEY (task_id)
+    REFERENCES tasks(id) ON DELETE CASCADE,
+  CONSTRAINT fk_tasks_tags_tag  FOREIGN KEY (tag_id)
+    REFERENCES tags(id)  ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
diff --git a/clock/clock.go b/clock/clock.go
new file mode 100644
index 0000000..1da3707
--- /dev/null
+++ b/clock/clock.go
@@ -0,0 +1,21 @@
+package clock
+
+import (
+	"time"
+)
+
+type Clocker interface {
+	Now() time.Time
+}
+
+type RealClocker struct{}
+
+func (r RealClocker) Now() time.Time {
+	return time.Now()
+}
+
+type FixedClocker struct{}
+
+func (fc FixedClocker) Now() time.Time {
+	return time.Date(2022, 5, 10, 12, 34, 56, 0, time.UTC)
+}
\ No newline at end of file
diff --git a/config/config.go b/config/config.go
index 39b4217..3808688 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,17 +1,27 @@
 package config
 
 import (
+	"fmt"
 	"github.com/caarlos0/env/v11"
 )
 
 type Config struct {
 	Port int    `env:"PORT" envDefault:"8080"`
+	DBHost string `env:"DB_HOST" envDefault:"127.0.0.1"`
+	DBPort int    `env:"DB_PORT" envDefault:"3306"`
+	DBUser string `env:"DB_USER" envDefault:"todo_user"`
+	DBPass string `env:"DB_PASS" envDefault:"pass1234"`
+	DBName string `env:"DB_NAME" envDefault:"todo_app"`
+	DBLoc  string `env:"DB_LOC"  envDefault:"Asia%2FTokyo"`
 }
 
-func New() (*Config, error) {
+func New() (*Config, string, error) {
 	cfg := &Config{}
 	if err := env.Parse(cfg); err != nil {
-		return nil, err
+		return nil, "", err
 	}
-	return cfg, nil
+	
+	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4&loc=%s",
+		cfg.DBUser, cfg.DBPass, cfg.DBHost, cfg.DBPort, cfg.DBName, cfg.DBLoc)
+	return cfg, dsn, nil
 }
\ No newline at end of file
diff --git a/config/config_test.go b/config/config_test.go
index b230416..2e3a714 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -9,7 +9,7 @@ func TestNew(t *testing.T) {
 	wantPort := 3333
 	t.Setenv("PORT", fmt.Sprint(wantPort))
 
-	got, err := New()
+	got, _, err := New()
 	if err != nil {
 		t.Fatalf("cannot create config: %v", err)
 	}
diff --git a/entity/tag.go b/entity/tag.go
new file mode 100644
index 0000000..4422480
--- /dev/null
+++ b/entity/tag.go
@@ -0,0 +1,16 @@
+package entity
+
+import "time"
+
+type TagID int64
+type UserID int64
+
+type Tag struct {
+	ID      TagID      `json:"id"`
+	Name    string     `json:"name"`
+	UserID  UserID 	   `json:"user_id"`
+	CreatedAt time.Time  `json:"created_at"`
+	UpdatedAt time.Time  `json:"updated_at"`
+}
+
+type Tags []*Tag
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 96f1da4..65ed7f0 100644
--- a/go.mod
+++ b/go.mod
@@ -1,10 +1,31 @@
-module github.com/go-WebAPI-team/task-app
+module github.com/go-webapi-team/task-app
 
 go 1.23.0
 
 toolchain go1.23.8
 
 require (
-	github.com/caarlos0/env/v11 v11.3.1 // indirect
-	golang.org/x/sync v0.13.0 // indirect
+	github.com/budougumi0617/go_todo_app v1.1.1
+	github.com/caarlos0/env/v11 v11.3.1
+	github.com/go-playground/validator/v10 v10.26.0
+	github.com/google/go-cmp v0.7.0
+	golang.org/x/sync v0.13.0
+)
+
+require (
+	github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
+	github.com/cespare/xxhash/v2 v2.1.2 // indirect
+	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+	github.com/go-chi/chi/v5 v5.2.1 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-redis/redis/v8 v8.11.5 // indirect
+	github.com/go-sql-driver/mysql v1.6.0 // indirect
+	github.com/jmoiron/sqlx v1.3.5 // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
+	golang.org/x/crypto v0.33.0 // indirect
+	golang.org/x/net v0.34.0 // indirect
+	golang.org/x/sys v0.30.0 // indirect
+	golang.org/x/text v0.22.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 2167100..8df374d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,67 @@
+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
+github.com/budougumi0617/go_todo_app v1.1.1 h1:oUPfSoCHeI+J4KNW7dfoOujGlE+U0Ufz17yWKIYp9fI=
+github.com/budougumi0617/go_todo_app v1.1.1/go.mod h1:QwKZ8Ou8WKkvUfe5uNabPlZ2WrncFMwlxFr9hUjXp0Q=
 github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
 github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
+github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
+github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
+github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
+github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
+github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
+github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
+github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
+github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
+github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
+github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
+github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
+github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
 golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
 golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/handler/create_tag.go b/handler/create_tag.go
new file mode 100644
index 0000000..eb3ddf3
--- /dev/null
+++ b/handler/create_tag.go
@@ -0,0 +1,61 @@
+package handler
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/go-webapi-team/task-app/entity"
+	"github.com/go-webapi-team/task-app/store"
+	"github.com/go-playground/validator/v10"
+)
+
+// ユースケース単位のインタフェース
+type TagCreator interface {
+	CreateTag(ctx context.Context, db store.Execer, t *entity.Tag) error
+}
+
+type CreateTag struct {
+	Repo TagCreator
+	DB store.Execer
+	Validator *validator.Validate
+}
+
+func (ct *CreateTag) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	var b struct {
+		Name string `json:"name" validate:"required"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
+		RespondJSON(ctx, w, &ErrResponse{
+			Message: err.Error(),
+		}, http.StatusBadRequest)
+		return
+	}
+	if err := ct.Validator.Struct(b); err != nil {
+		RespondJSON(ctx, w, &ErrResponse{
+			Message: err.Error(),
+		}, http.StatusBadRequest)
+		return
+	}
+	now := time.Now()
+	t := &entity.Tag{
+		Name:      b.Name,
+		UserID:    1, // TODO: 認証実装後にユーザIDを取得する
+		CreatedAt:  now,
+		UpdatedAt: now,
+	}
+
+	if err := ct.Repo.CreateTag(ctx, ct.DB, t); err !=  nil {
+		RespondJSON(ctx, w, &ErrResponse{
+			Message: err.Error(),
+		}, http.StatusInternalServerError)
+		return
+	}
+	// t.ID に自動設定される ※CreateTag が LastInsertId をセット
+    rsp := struct{ ID int `json:"id"` }{ID: int(t.ID)}
+	RespondJSON(ctx, w, rsp, http.StatusOK)
+
+}
\ No newline at end of file
diff --git a/handler/create_tag_test.go b/handler/create_tag_test.go
new file mode 100644
index 0000000..b06cfc6
--- /dev/null
+++ b/handler/create_tag_test.go
@@ -0,0 +1,82 @@
+package handler
+
+import (
+    "context"
+    "bytes"
+    "net/http"
+    "net/http/httptest"
+    "testing"
+
+    "github.com/go-playground/validator/v10"
+
+    "github.com/go-webapi-team/task-app/entity"
+    "github.com/go-webapi-team/task-app/store"
+
+    "github.com/budougumi0617/go_todo_app/testutil"
+)
+
+// fakeCreator は Repository.CreateTag のモック実装
+type fakeCreator struct{ nextID int64 }
+
+func (f *fakeCreator) CreateTag(_ context.Context, _ store.Execer, t *entity.Tag) error {
+	f.nextID++
+	t.ID = entity.TagID(f.nextID)
+	return nil
+}
+
+func TestCreateTag(t *testing.T) {
+    type want struct {
+        status  int
+        rspFile string
+    }
+    tests := map[string]struct {
+        reqFile string
+        want    want
+    }{
+        "ok": {
+            reqFile: "testdata/create_tag/ok_req.json.golden",
+            want: want{
+                status:  http.StatusOK,
+                rspFile: "testdata/create_tag/ok_rsp.json.golden",
+            },
+        },
+        "badRequest": {
+            reqFile: "testdata/create_tag/bad_req.json.golden",
+            want: want{
+                status:  http.StatusBadRequest,
+                rspFile: "testdata/create_tag/bad_rsp.json.golden",
+            },
+        },
+    }
+
+    for n, tt := range tests {
+        tt := tt
+        t.Run(n, func(t *testing.T) {
+            t.Parallel()
+
+            // テスト用のHTTPレスポンスレコーダとリクエスト作成
+            w := httptest.NewRecorder()
+            r := httptest.NewRequest(
+                http.MethodPost,
+                "/tags", 
+                bytes.NewReader(testutil.LoadFile(t, tt.reqFile)),
+            )
+
+            // テスト対象となるハンドラ(CreateTag)の用意
+            sut := CreateTag{
+                Repo: &fakeCreator{},     // モックリポジトリ
+                DB: nil,               // db 引数はモックで使わないので nil OK
+                Validator: validator.New(),
+            }
+
+            // 実行
+            sut.ServeHTTP(w, r)
+
+            // 結果を検証
+            resp := w.Result()
+            testutil.AssertResponse(t,
+                resp, tt.want.status, testutil.LoadFile(t, tt.want.rspFile),
+            )
+        })
+    }
+}
diff --git a/handler/list_tag.go b/handler/list_tag.go
new file mode 100644
index 0000000..9e2597f
--- /dev/null
+++ b/handler/list_tag.go
@@ -0,0 +1,55 @@
+package handler
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/go-webapi-team/task-app/entity"
+	"github.com/go-webapi-team/task-app/store"
+)
+
+// 一覧取得ユースケースのインタフェース
+type TagLister interface {
+	ListTags(ctx context.Context, db store.Queryer) (entity.Tags, error)
+}
+
+type ListTag struct {
+	Repo TagLister
+	DB   store.Queryer
+}
+
+// DTO（Data Transfer Object）
+// ドメインモデル（entity.Tag）と外部公開の（クライアントが読むレスポンス JSONの契約）を分離する
+type Tag struct {
+	ID   int    `json:"id"`
+	Name string `json:"name"`
+	// CreatedAt *は要件があるときだけ追加*
+    CreatedAt *time.Time `json:"created,omitempty"`
+}
+
+func (lt *ListTag) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	tags, err := lt.Repo.ListTags(ctx, lt.DB)
+	if err != nil {
+		RespondJSON(ctx, w, &ErrResponse{Message: err.Error()},
+			http.StatusInternalServerError)
+		return
+	}
+
+	rsp := make([]Tag, 0) // 要素 0 だが non‑nil → JSON では [] になる
+	for _, t := range tags {
+		var created *time.Time
+		if !t.CreatedAt.IsZero() {
+			c := t.CreatedAt
+			created = &c
+		}
+		rsp = append(rsp, Tag{
+			ID:   int(t.ID),
+			Name: t.Name,
+			CreatedAt: created,
+		})
+	}
+	RespondJSON(ctx, w, rsp, http.StatusOK)
+}
diff --git a/handler/list_tag_test.go b/handler/list_tag_test.go
new file mode 100644
index 0000000..004293a
--- /dev/null
+++ b/handler/list_tag_test.go
@@ -0,0 +1,85 @@
+package handler
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/go-webapi-team/task-app/entity"
+	"github.com/go-webapi-team/task-app/store"
+	"github.com/go-webapi-team/task-app/testutil"
+)
+
+type fakeLister struct{ ret entity.Tags }
+
+func (f *fakeLister) ListTags(_ context.Context, _ store.Queryer) (entity.Tags, error) {
+	return f.ret, nil
+}
+
+func TestListTag(t *testing.T) {
+	type want struct {
+		status  int
+		rspFile string
+	}
+	tests := map[string]struct {
+		tags entity.Tags
+		want want
+	}{
+		"ok": {
+			tags: entity.Tags{
+				{
+					ID:   1,
+					Name: "tag1",
+				},
+				{
+					ID:   2,
+					Name: "tag2",
+				},
+			},
+			want: want{
+				status:  http.StatusOK,
+				rspFile: "testdata/list_tag/ok_rsp.json.golden",
+			
+			},
+		},
+		"empty": {
+			tags: entity.Tags{},
+			want: want{
+				status:  http.StatusOK,
+				rspFile: "testdata/list_tag/empty_rsp.json.golden",
+			},
+		},
+	}
+	for n, tt := range tests {
+		tt := tt
+		t.Run(n, func(t *testing.T) {
+			t.Parallel()
+
+			// テスト用のHTTPレスポンスレコーダとリクエスト作成
+			w := httptest.NewRecorder()
+			r := httptest.NewRequest(
+				http.MethodGet,
+				"/tags",
+				nil,
+			)
+
+			// テスト対象となるハンドラ(ListTag)の用意
+			sut := ListTag{
+				Repo: &fakeLister{ret: tt.tags},
+				DB:   nil,
+			}
+
+			// リクエストをハンドラに渡す
+			sut.ServeHTTP(w, r)
+
+			resp := w.Result()
+			testutil.AssertResponse(t,
+				resp,
+				tt.want.status, 
+				testutil.LoadFile(t, tt.want.rspFile),
+			)
+		})
+	}
+}
+
diff --git a/handler/response.go b/handler/response.go
new file mode 100644
index 0000000..3fe5502
--- /dev/null
+++ b/handler/response.go
@@ -0,0 +1,35 @@
+package handler
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+)
+
+type ErrResponse struct {
+	Message string   `json:"message"`
+	Details []string `json:"details,omitempty"`
+}
+
+// RespondJSONは、各HTTPハンドラーの実装で必要になる、HTTPレスポンスを書き込む共通処理を実装する
+func RespondJSON(ctx context.Context, w http.ResponseWriter, body any, status int) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	bodyBytes, err := json.Marshal(body)
+	if err != nil {
+		fmt.Printf("encode response error: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		rsp := ErrResponse{
+			Message: http.StatusText(http.StatusInternalServerError),
+		}
+		if err := json.NewEncoder(w).Encode(rsp); err != nil {
+			fmt.Printf("write error response error: %v", err)
+		}
+		return
+	}
+
+	w.WriteHeader(status)
+	if _, err := fmt.Fprintf(w, "%s", bodyBytes); err != nil {
+		fmt.Printf("write response error: %v", err)
+	}
+}
\ No newline at end of file
diff --git a/handler/testdata/create_tag/bad_req.json.golden b/handler/testdata/create_tag/bad_req.json.golden
new file mode 100644
index 0000000..f4b202a
--- /dev/null
+++ b/handler/testdata/create_tag/bad_req.json.golden
@@ -0,0 +1,3 @@
+{
+  "nane": "Implement a handler"
+}
\ No newline at end of file
diff --git a/handler/testdata/create_tag/bad_rsp.json.golden b/handler/testdata/create_tag/bad_rsp.json.golden
new file mode 100644
index 0000000..7543b14
--- /dev/null
+++ b/handler/testdata/create_tag/bad_rsp.json.golden
@@ -0,0 +1,3 @@
+{
+  "message": "Key: 'Name' Error:Field validation for 'Name' failed on the 'required' tag"
+}
\ No newline at end of file
diff --git a/handler/testdata/create_tag/ok_req.json.golden b/handler/testdata/create_tag/ok_req.json.golden
new file mode 100644
index 0000000..1752926
--- /dev/null
+++ b/handler/testdata/create_tag/ok_req.json.golden
@@ -0,0 +1,3 @@
+{
+  "name": "Implement a handler"
+}
\ No newline at end of file
diff --git a/handler/testdata/create_tag/ok_rsp.json.golden b/handler/testdata/create_tag/ok_rsp.json.golden
new file mode 100644
index 0000000..95fc8f9
--- /dev/null
+++ b/handler/testdata/create_tag/ok_rsp.json.golden
@@ -0,0 +1,3 @@
+{
+  "id": 1
+}
\ No newline at end of file
diff --git a/handler/testdata/list_tag/empty_rsp.json.golden b/handler/testdata/list_tag/empty_rsp.json.golden
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/handler/testdata/list_tag/empty_rsp.json.golden
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/handler/testdata/list_tag/ok_rsp.json.golden b/handler/testdata/list_tag/ok_rsp.json.golden
new file mode 100644
index 0000000..f1fa19e
--- /dev/null
+++ b/handler/testdata/list_tag/ok_rsp.json.golden
@@ -0,0 +1,10 @@
+[
+  {
+    "id": 1,
+    "name": "tag1"
+  },
+  {
+    "id": 2,
+    "name": "tag2"
+  }
+]
\ No newline at end of file
diff --git a/main.go b/main.go
index a27ea67..5840c9d 100644
--- a/main.go
+++ b/main.go
@@ -7,7 +7,9 @@ import (
 	"net"
 	"os"
 
-	"github.com/go-WebAPI-team/task-app/config"
+	"github.com/go-webapi-team/task-app/clock"
+	"github.com/go-webapi-team/task-app/config"
+	"github.com/go-webapi-team/task-app/store"
 )
 
 // run(ctx context.Context) errorに処理を委譲
@@ -19,10 +21,21 @@ func main() {
 }
 
 func run(ctx context.Context) error {
-	cfg, err := config.New()
+	cfg, _, err := config.New()
 	if err != nil {
 		return err
 	}
+
+	// DB接続をここで作る
+    db, cleanup, err := store.New(ctx, cfg)
+    if err != nil { return err }
+    defer cleanup()             // ← アプリ終了時に Close
+
+    repo := &store.Repository{Clocker: clock.RealClocker{}}
+
+	mux := NewMux(db, repo)
+
+
 	l, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port))
 	if err != nil {
 		log.Fatalf("failed to listen port %d: %v", cfg.Port, err)
@@ -30,7 +43,6 @@ func run(ctx context.Context) error {
 	url := fmt.Sprintf("http://%s", l.Addr().String())
 	log.Printf("start with: %v", url)
 
-	mux := NewMux()
 	s := NewServer(l, mux)
 	
 	return s.Run(ctx)
diff --git a/mux.go b/mux.go
index b3f0ddd..c8bf54c 100644
--- a/mux.go
+++ b/mux.go
@@ -1,14 +1,41 @@
 package main
 
-import "net/http"
+import (
+	"database/sql"
+	"net/http"
+
+	"github.com/go-webapi-team/task-app/handler"
+	"github.com/go-webapi-team/task-app/store"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/go-playground/validator/v10"
+)
 
 // NewMuxは、どのようなハンドラーの実装をどんなURLパスで公開するかルーティングする
-func NewMux() http.Handler {
-	mux := http.NewServeMux()
-	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("Content-Type", "application/json; charset=utf-8")
+func NewMux(db *sql.DB, repo *store.Repository) http.Handler {
+	mux := chi.NewRouter()
+	v := validator.New()
+
+	// RDB 永続化版ハンドラ
+	ct := &handler.CreateTag{
+		Repo:      repo,
+		DB:        db,
+		Validator: v,
+	}
+	mux.Post("/tags", ct.ServeHTTP)
+
+	lt := &handler.ListTag{
+		Repo: repo,
+		DB:   db,
+	}
+	mux.Get("/tags", lt.ServeHTTP)
+
+	// 常に使うヘルスチェック
+    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+        w.Header().Set("Content-Type", "application/json; charset=utf-8")
 		// 静的解析(実装予定)のエラーを回避するため明示的に戻り値を捨てる
-		_, _ = w.Write([]byte(`{"status": "ok"}`))
-	})
+        _, _ = w.Write([]byte(`{"status": "ok"}`))
+    })
+
 	return mux
 }
\ No newline at end of file
diff --git a/mux_test.go b/mux_test.go
index d96f71d..905267d 100644
--- a/mux_test.go
+++ b/mux_test.go
@@ -10,13 +10,18 @@ import (
 func TestNewMux(t *testing.T) {
 	w := httptest.NewRecorder()
 	r := httptest.NewRequest(http.MethodGet, "/health", nil)
-	sut := NewMux()
+	// Health エンドポイントだけをテストするのでDB, Repository は nil で OK
+	sut := NewMux(nil, nil)
+
 	sut.ServeHTTP(w, r)
+	
 	resp := w.Result()
 	t.Cleanup(func() { _ = resp.Body.Close() })
+	
 	if resp.StatusCode != http.StatusOK {
 		t.Error("want status code 200, but", resp.StatusCode)
 	}
+	
 	got, err := io.ReadAll(resp.Body)
 	if err != nil {
 		t.Fatalf("failed to read body: %v", err)
diff --git a/store/repository.go b/store/repository.go
new file mode 100644
index 0000000..fcd7b3d
--- /dev/null
+++ b/store/repository.go
@@ -0,0 +1,57 @@
+package store
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"time"
+
+	_ "github.com/go-sql-driver/mysql" // ドライバの side‑effect import
+	"github.com/go-webapi-team/task-app/clock"
+	"github.com/go-webapi-team/task-app/config"
+)
+
+const (
+	pingTimeout = 2 * time.Second
+)
+
+// New は *sql.DB と cleanup 関数を返す。database/sql のみを使用。
+func New(ctx context.Context, cfg *config.Config) (*sql.DB, func() error, error) {
+	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4&loc=%s",
+		cfg.DBUser, cfg.DBPass,
+		cfg.DBHost, cfg.DBPort,
+		cfg.DBName,	
+		cfg.DBLoc,
+	)
+
+	// ドライバを指定して Open（ここでは接続しない）
+	db, err := sql.Open("mysql", dsn)
+	if err != nil {
+		return nil, func() error { return nil }, err
+	}
+
+	// Ping で実際に接続を確認
+	ctxPing, cancel := context.WithTimeout(ctx, pingTimeout)
+
+	defer cancel()
+	if err := db.PingContext(ctxPing); err != nil {
+		_ = db.Close()
+		return nil, func() error { return nil }, err
+	}
+
+	return db, db.Close, nil
+}
+
+type Repository struct {
+	Clocker clock.Clocker
+}
+
+type Execer interface {
+	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+}
+
+type Queryer interface {
+	Execer
+	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
+	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
+}
diff --git a/store/tag.go b/store/tag.go
new file mode 100644
index 0000000..cfd6944
--- /dev/null
+++ b/store/tag.go
@@ -0,0 +1,46 @@
+package store
+
+import (
+	"context"
+	"github.com/go-webapi-team/task-app/entity"
+)
+
+func (r *Repository) CreateTag(ctx context.Context, db Execer, t *entity.Tag) error {
+	const sqlStr = `INSERT INTO tags (user_id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`
+	
+	now := r.Clocker.Now()
+	result, err := db.ExecContext(ctx, sqlStr, t.UserID, t.Name, now, now)
+	
+	if err != nil {
+		return err
+	}
+	id, err := result.LastInsertId()
+	if err != nil {
+		return err
+	}
+	t.ID = entity.TagID(id)
+	return nil
+}
+
+func (r *Repository) ListTags(ctx context.Context, db Queryer) (entity.Tags, error) {
+	const sqlStr = `SELECT id, user_id, name, created_at, updated_at FROM tags`
+
+	rows, err := db.QueryContext(ctx, sqlStr)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var tags entity.Tags
+	for rows.Next() {
+		var t entity.Tag
+		if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.CreatedAt, &t.UpdatedAt); err != nil {
+			return nil, err
+		}
+		tags = append(tags, &t)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return tags, nil
+}
\ No newline at end of file
diff --git a/store/tag_test.go b/store/tag_test.go
new file mode 100644
index 0000000..55bc24f
--- /dev/null
+++ b/store/tag_test.go
@@ -0,0 +1,77 @@
+package store
+
+import (
+	"context"
+	"database/sql/driver"
+	"testing"
+
+	"github.com/DATA-DOG/go-sqlmock"
+	"github.com/go-webapi-team/task-app/clock"
+	"github.com/go-webapi-team/task-app/entity"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestTagRepository_ListTags(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	c := clock.FixedClocker{}
+
+	// --- モック DB & 期待行を用意（sqlmock は database/sql 互換） ---
+	db, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = db.Close() })
+
+	now := c.Now()
+	want := entity.Tags{
+		{ID: 1, UserID: 1, Name: "tag1", CreatedAt: now, UpdatedAt: now},
+		{ID: 2, UserID: 2, Name: "tag2", CreatedAt: now, UpdatedAt: now},
+	}
+
+	rows := sqlmock.NewRows([]string{"id", "user_id", "name", "created_at", "updated_at"}).
+		AddRow(want[0].ID, want[0].UserID, want[0].Name, want[0].CreatedAt, want[0].UpdatedAt).
+		AddRow(want[1].ID, want[1].UserID, want[1].Name, want[1].CreatedAt, want[1].UpdatedAt)
+
+	mock.ExpectQuery(`SELECT id, user_id, name, created_at, updated_at FROM tags`).
+		WillReturnRows(rows)
+
+	sut := &Repository{Clocker: c}
+	got, err := sut.ListTags(ctx, db)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("mismatch (-got +want):\n%s", diff)
+	}
+}
+
+func TestTagRepository_CreateTag(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	c := clock.FixedClocker{}
+	now := c.Now()
+
+	newTag := &entity.Tag{UserID: 99, Name: "new", CreatedAt: now, UpdatedAt: now}
+	wantID := int64(123)
+
+	db, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = db.Close() })
+
+	mock.ExpectExec(`INSERT INTO tags`).
+		WithArgs(newTag.UserID, newTag.Name, driver.Value(now), driver.Value(now)).
+		WillReturnResult(sqlmock.NewResult(wantID, 1))
+
+	r := &Repository{Clocker: c}
+	if err := r.CreateTag(ctx, db, newTag); err != nil {
+		t.Fatalf("error: %v", err)
+	}
+	if newTag.ID != entity.TagID(wantID) {
+		t.Errorf("ID not set, got %d want %d", newTag.ID, wantID)
+	}
+}
diff --git a/testutil/handler.go b/testutil/handler.go
new file mode 100644
index 0000000..738330d
--- /dev/null
+++ b/testutil/handler.go
@@ -0,0 +1,53 @@
+package testutil
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"os"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func AssertJSON(t *testing.T, want, got []byte) {
+	t.Helper()
+
+	var jw, jg any
+	if err := json.Unmarshal(want, &jw); err != nil {
+		t.Fatalf("cannot unmarshal want %q: %v", want, err)
+	}
+	if err := json.Unmarshal(got, &jg); err != nil {
+		t.Fatalf("cannot unmarshal got %q: %v", got, err)
+	}
+	if diff := cmp.Diff(jg, jw); diff != "" {
+		t.Errorf("got differs: (-got +want)\n%s", diff)
+	}
+}
+func AssertResponse(t *testing.T, got *http.Response, status int, body []byte) {
+	t.Helper()
+	t.Cleanup(func() { _ = got.Body.Close() })
+	gb, err := io.ReadAll(got.Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got.StatusCode != status {
+		t.Fatalf("want status %d, but got %d, body: %q", status, got.StatusCode, gb)
+	}
+
+	if len(gb) == 0 && len(body) == 0 {
+		// 期待としても実体としてもレスポンスボディがないのでAssertJSONを呼ぶ必要はない
+		return
+	}
+	AssertJSON(t, body, gb)
+}
+
+func LoadFile(t *testing.T, path string) []byte {
+	t.Helper()
+
+	bt, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("cannot read from %q: %v", path, err)
+	}
+	return bt
+}
\ No newline at end of file


Reviews:
user: login: github-actions[bot]
body: <img src="https://avatars.githubusercontent.com/in/347564?s=41" alt="Image description" width="20" height="20">   CodeRabbit

<details>
<summary>Commits</summary>
Files that changed from the base of the PR and between e8d2b047ca088f2c2e717686cc116a3baf4f9639 and 8e30811c69fc2fa72f7556837968b68690572119 commits.
</details>

<details>
<summary>Files selected (27)</summary>

* _tools/mysql/conf.d/mysql.cnf (1)
* _tools/mysql/conf.d/mysqld.cnf (1)
* _tools/mysql/schema.sql (1)
* clock/clock.go (1)
* config/config.go (1)
* config/config_test.go (1)
* entity/tag.go (1)
* go.mod (1)
* go.sum (1)
* handler/create_tag.go (1)
* handler/create_tag_test.go (1)
* handler/list_tag.go (1)
* handler/list_tag_test.go (1)
* handler/response.go (1)
* handler/testdata/create_tag/bad_req.json.golden (1)
* handler/testdata/create_tag/bad_rsp.json.golden (1)
* handler/testdata/create_tag/ok_req.json.golden (1)
* handler/testdata/create_tag/ok_rsp.json.golden (1)
* handler/testdata/list_tag/ok_rsp.json.golden (1)
* main.go (2)
* mux.go (1)
* mux_test.go (1)
* store/repository.go (1)
* store/store.go (1)
* store/tag.go (1)
* store/tag_test.go (1)
* testutil/handler.go (1)
</details>





<details>
<summary>Files not summarized due to errors (1)</summary>

* go.sum (diff tokens exceeds limit)

</details>



<details>
<summary>Files skipped from review due to trivial changes (8)</summary>

* _tools/mysql/conf.d/mysql.cnf
* _tools/mysql/conf.d/mysqld.cnf
* handler/testdata/create_tag/bad_req.json.golden
* handler/testdata/create_tag/ok_req.json.golden
* handler/testdata/create_tag/ok_rsp.json.golden
* handler/testdata/list_tag/ok_rsp.json.golden
* testutil/handler.go
* go.sum (diff too large)

</details>

<details>
<summary>Review comments generated (27)</summary>

* Review: 6
* LGTM: 21

</details>

---

<details>
<summary>Tips</summary>

### Chat with <img src="https://avatars.githubusercontent.com/in/347564?s=41&u=fad245b8b4c7254fe63dd4dcd4d662ace122757e&v=4" alt="Image description" width="20" height="20">  CodeRabbit Bot (`@coderabbitai`)
- Reply on review comments left by this bot to ask follow-up questions. A review comment is a comment on a diff or a file.
- Invite the bot into a review comment chain by tagging `@coderabbitai` in a reply.

### Code suggestions
- The bot may make code suggestions, but please review them carefully before committing since the line number ranges may be misaligned. 
- You can edit the comment made by the bot and manually tweak the suggestion if it is slightly off.

### Pausing incremental reviews
- Add `@coderabbitai: ignore` anywhere in the PR description to pause further reviews from the bot.

</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-04-22T08:12:54Z
user: login: sunny-side-app
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:42:24Z
user: login: sunny-side-app
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:43:14Z
user: login: sunny-side-app
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:43:44Z
user: login: sunny-side-app
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T09:44:16Z

Review comments:
diff_hunk: @@ -1,10 +1,31 @@
-module github.com/go-WebAPI-team/task-app
+module github.com/go-webapi-team/task-app
 
 go 1.23.0
path: go.mod
user: login: github-actions[bot]
body: <img src="https://avatars.githubusercontent.com/in/347564?s=41" alt="Image description" width="20" height="20">   CodeRabbit

Goのバージョン指定が不正です。`go 1.23.0`は存在しないバージョンです。正しいバージョンを指定してください。
```diff
- go 1.23.0
+ go 1.17
```


<!-- This is an auto-generated comment by OSS CodeRabbit -->
created_at: 2025-04-22T08:12:52Z
author_association: NONE
diff_hunk: @@ -0,0 +1,57 @@
+package store
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"time"
+
+	_ "github.com/go-sql-driver/mysql" // ドライバの side‑effect import
+	"github.com/go-webapi-team/task-app/clock"
+	"github.com/go-webapi-team/task-app/config"
+)
+
+const (
+	pingTimeout = 2 * time.Second
+)
+
+// New は *sql.DB と cleanup 関数を返す。database/sql のみを使用。
+func New(ctx context.Context, cfg *config.Config) (*sql.DB, func() error, error) {
+	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4&loc=%s",
+		cfg.DBUser, cfg.DBPass,
+		cfg.DBHost, cfg.DBPort,
+		cfg.DBName,	
+		cfg.DBLoc,
+	)
+
+	// ドライバを指定して Open（ここでは接続しない）
+	db, err := sql.Open("mysql", dsn)
+	if err != nil {
+		return nil, func() error { return nil }, err
+	}
+
+	// Ping で実際に接続を確認
+	ctxPing, cancel := context.WithTimeout(ctx, pingTimeout)
+
+	defer cancel()
+	if err := db.PingContext(ctxPing); err != nil {
+		_ = db.Close()
+		return nil, func() error { return nil }, err
+	}
+
+	return db, db.Close, nil
+}
+
+type Repository struct {
+	Clocker clock.Clocker
+}
path: store/repository.go
user: login: github-actions[bot]
body: <img src="https://avatars.githubusercontent.com/in/347564?s=41" alt="Image description" width="20" height="20">   CodeRabbit

`Repository`構造体に`Clocker`フィールドを追加することで、時間関連の操作を抽象化していますが、他のフィールドやメソッドがないため、現時点ではこの構造体の目的が不明瞭です。将来的に拡張される予定であれば問題ありませんが、現状では使用されていないフィールドがあることに注意してください。


<!-- This is an auto-generated comment by OSS CodeRabbit -->
created_at: 2025-04-22T08:12:52Z
author_association: NONE
diff_hunk: @@ -0,0 +1,61 @@
+package handler
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/go-webapi-team/task-app/entity"
+	"github.com/go-webapi-team/task-app/store"
+	"github.com/go-playground/validator/v10"
+)
+
+// ユースケース単位のインタフェース
+type TagCreator interface {
+	CreateTag(ctx context.Context, db store.Execer, t *entity.Tag) error
+}
+
+type CreateTag struct {
+	Repo TagCreator
+	DB store.Execer
+	Validator *validator.Validate
+}
+
+func (ct *CreateTag) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	var b struct {
+		Name string `json:"name" validate:"required"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
+		RespondJSON(ctx, w, &ErrResponse{
+			Message: err.Error(),
+		}, http.StatusInternalServerError)
+		return
path: handler/create_tag.go
user: login: github-actions[bot]
body: <img src="https://avatars.githubusercontent.com/in/347564?s=41" alt="Image description" width="20" height="20">   CodeRabbit

エラーレスポンスのステータスコードが`http.StatusInternalServerError`になっていますが、JSONデコードエラーの場合はクライアント側の問題であるため、`http.StatusBadRequest`を返すべきです。
```diff
- 		}, http.StatusInternalServerError)
+ 		}, http.StatusBadRequest)
```


<!-- This is an auto-generated comment by OSS CodeRabbit -->
created_at: 2025-04-22T08:12:53Z
author_association: NONE
diff_hunk: @@ -0,0 +1,39 @@
+package store
+
+import (
+	"errors"
+
+	"github.com/go-webapi-team/task-app/entity"
+)
+
+var (
+	Tags = &TagStore{Tags: map[entity.TagID]*entity.Tag{}}
+	ErrNotFound = errors.New("not found")
+)
+
+type TagStore struct {
+	LastID entity.TagID
+	Tags	map[entity.TagID]*entity.Tag
+}
+
+func (ts *TagStore) Create(t *entity.Tag) (*entity.TagID, error) {
+	ts.LastID++
+	t.ID = ts.LastID
+	ts.Tags[t.ID] = t
+	return &t.ID, nil
path: store/store.go
user: login: github-actions[bot]
body: <img src="https://avatars.githubusercontent.com/in/347564?s=41" alt="Image description" width="20" height="20">   CodeRabbit

`Create`メソッドでタグを作成する際に、データ競合が発生する可能性があります。特に、複数のゴルーチンから同時にアクセスされる場合に問題となります。この関数はスレッドセーフではないため、`sync.Mutex`を使用して排他制御を行うことを検討してください。

```go
type TagStore struct {
	LastID entity.TagID
	Tags   map[entity.TagID]*entity.Tag
	mu     sync.Mutex
}

func (ts *TagStore) Create(t *entity.Tag) (*entity.TagID, error) {
	ts.mu.Lock()
	defer ts.mu.Unlock()

	ts.LastID++
	t.ID = ts.LastID
	ts.Tags[t.ID] = t
	return &t.ID, nil
}
```



<!-- This is an auto-generated comment by OSS CodeRabbit -->
created_at: 2025-04-22T08:12:53Z
author_association: NONE
diff_hunk: @@ -0,0 +1,39 @@
+package store
+
+import (
+	"errors"
+
+	"github.com/go-webapi-team/task-app/entity"
+)
+
+var (
+	Tags = &TagStore{Tags: map[entity.TagID]*entity.Tag{}}
+	ErrNotFound = errors.New("not found")
+)
+
+type TagStore struct {
+	LastID entity.TagID
+	Tags	map[entity.TagID]*entity.Tag
+}
+
+func (ts *TagStore) Create(t *entity.Tag) (*entity.TagID, error) {
+	ts.LastID++
+	t.ID = ts.LastID
+	ts.Tags[t.ID] = t
+	return &t.ID, nil
+}
+
+func (ts *TagStore) Get(id entity.TagID) (*entity.Tag, error) {
+	if t, ok := ts.Tags[id]; ok {
+		return t, nil
+	}
+	return nil, ErrNotFound
path: store/store.go
user: login: github-actions[bot]
body: <img src="https://avatars.githubusercontent.com/in/347564?s=41" alt="Image description" width="20" height="20">   CodeRabbit

`Get`メソッドも同様にスレッドセーフではありません。`sync.Mutex`を使用して、タグの取得時にも排他制御を追加することをお勧めします。

```go
func (ts *TagStore) Get(id entity.TagID) (*entity.Tag, error) {
	ts.mu.Lock()
	defer ts.mu.Unlock()

	if t, ok := ts.Tags[id]; ok {
		return t, nil
	}
	return nil, ErrNotFound
}
```



<!-- This is an auto-generated comment by OSS CodeRabbit -->
created_at: 2025-04-22T08:12:53Z
author_association: NONE
diff_hunk: @@ -0,0 +1,39 @@
+package store
+
+import (
+	"errors"
+
+	"github.com/go-webapi-team/task-app/entity"
+)
+
+var (
+	Tags = &TagStore{Tags: map[entity.TagID]*entity.Tag{}}
+	ErrNotFound = errors.New("not found")
+)
+
+type TagStore struct {
+	LastID entity.TagID
+	Tags	map[entity.TagID]*entity.Tag
+}
+
+func (ts *TagStore) Create(t *entity.Tag) (*entity.TagID, error) {
+	ts.LastID++
+	t.ID = ts.LastID
+	ts.Tags[t.ID] = t
+	return &t.ID, nil
+}
+
+func (ts *TagStore) Get(id entity.TagID) (*entity.Tag, error) {
+	if t, ok := ts.Tags[id]; ok {
+		return t, nil
+	}
+	return nil, ErrNotFound
+}
+
+func (ts *TagStore) GetAll() entity.Tags {
+	var tags entity.Tags
+	for _, t := range ts.Tags {
+		tags = append(tags, t)
+	}
+	return tags
path: store/store.go
user: login: github-actions[bot]
body: <img src="https://avatars.githubusercontent.com/in/347564?s=41" alt="Image description" width="20" height="20">   CodeRabbit

`GetAll`メソッドもスレッドセーフではありません。`sync.Mutex`を使用して、全てのタグを取得する際にも排他制御を追加することをお勧めします。

```go
func (ts *TagStore) GetAll() entity.Tags {
	ts.mu.Lock()
	defer ts.mu.Unlock()

	var tags entity.Tags
	for _, t := range ts.Tags {
		tags = append(tags, t)
	}
	return tags
}
```


<!-- This is an auto-generated comment by OSS CodeRabbit -->
created_at: 2025-04-22T08:12:53Z
author_association: NONE
diff_hunk: @@ -1,10 +1,31 @@
-module github.com/go-WebAPI-team/task-app
+module github.com/go-webapi-team/task-app
 
 go 1.23.0
path: go.mod
user: login: sunny-side-app
body: 100% 妥当。存在しないバージョンは go vet/go get 時点で失敗。
対応 : go 1.22 など現行 LTS に即修正。

注：AIが上記のように評価しましたが、対応しません。
created_at: 2025-04-22T09:42:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+package store
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"time"
+
+	_ "github.com/go-sql-driver/mysql" // ドライバの side‑effect import
+	"github.com/go-webapi-team/task-app/clock"
+	"github.com/go-webapi-team/task-app/config"
+)
+
+const (
+	pingTimeout = 2 * time.Second
+)
+
+// New は *sql.DB と cleanup 関数を返す。database/sql のみを使用。
+func New(ctx context.Context, cfg *config.Config) (*sql.DB, func() error, error) {
+	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4&loc=%s",
+		cfg.DBUser, cfg.DBPass,
+		cfg.DBHost, cfg.DBPort,
+		cfg.DBName,	
+		cfg.DBLoc,
+	)
+
+	// ドライバを指定して Open（ここでは接続しない）
+	db, err := sql.Open("mysql", dsn)
+	if err != nil {
+		return nil, func() error { return nil }, err
+	}
+
+	// Ping で実際に接続を確認
+	ctxPing, cancel := context.WithTimeout(ctx, pingTimeout)
+
+	defer cancel()
+	if err := db.PingContext(ctxPing); err != nil {
+		_ = db.Close()
+		return nil, func() error { return nil }, err
+	}
+
+	return db, db.Close, nil
+}
+
+type Repository struct {
+	Clocker clock.Clocker
+}
path: store/repository.go
user: login: sunny-side-app
body: 今後 CreatedAt/UpdatedAt をテストで固定したい場合に使う DI フィールド。
現時点で未使用でも 害はゼロ。削除すると将来 Clock を注入し直す手間が増える。
対応不要（TODO コメントを残すなら可）。
created_at: 2025-04-22T09:43:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,61 @@
+package handler
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"github.com/go-webapi-team/task-app/entity"
+	"github.com/go-webapi-team/task-app/store"
+	"github.com/go-playground/validator/v10"
+)
+
+// ユースケース単位のインタフェース
+type TagCreator interface {
+	CreateTag(ctx context.Context, db store.Execer, t *entity.Tag) error
+}
+
+type CreateTag struct {
+	Repo TagCreator
+	DB store.Execer
+	Validator *validator.Validate
+}
+
+func (ct *CreateTag) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	var b struct {
+		Name string `json:"name" validate:"required"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
+		RespondJSON(ctx, w, &ErrResponse{
+			Message: err.Error(),
+		}, http.StatusInternalServerError)
+		return
path: handler/create_tag.go
user: login: sunny-side-app
body: クライアント送信 JSON が壊れている＝HTTP4xx。
API 利用者が原因を特定しやすくなる。
修正 1 行。行わない理由は無い。
created_at: 2025-04-22T09:43:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+package store
+
+import (
+	"errors"
+
+	"github.com/go-webapi-team/task-app/entity"
+)
+
+var (
+	Tags = &TagStore{Tags: map[entity.TagID]*entity.Tag{}}
+	ErrNotFound = errors.New("not found")
+)
+
+type TagStore struct {
+	LastID entity.TagID
+	Tags	map[entity.TagID]*entity.Tag
+}
+
+func (ts *TagStore) Create(t *entity.Tag) (*entity.TagID, error) {
+	ts.LastID++
+	t.ID = ts.LastID
+	ts.Tags[t.ID] = t
+	return &t.ID, nil
path: store/store.go
user: login: sunny-side-app
body: store/store.go は 旧メモリ実装。現在ルーティングから参照されておらず、テストでも fakeRepo を使っている。
本番コードから完全に切り離されているなら ファイルごと削除 が最善。
〝スレッドセーフ〟問題も同時に消える。
created_at: 2025-04-22T09:44:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-22T08:10:21Z
body: <img src="https://avatars.githubusercontent.com/in/347564?s=41" alt="Image description" width="20" height="20">   CodeRabbit

```
- **ウォークスルー**: この変更では、MySQL設定の追加、データベーススキーマの定義、時間取得の抽象化、設定管理の改善、タグ関連機能の実装が行われました。これにより、アプリケーションのデータベース操作とテストが強化され、タグ管理機能が拡充されました。

- **変更点**:

  | ファイル名 | 要約 |
  | --- | --- |
  | _tools/mysql/conf.d/mysql.cnf, _tools/mysql/conf.d/mysqld.cnf | MySQL設定ファイルが追加され、文字セットやタイムゾーンなどが設定されました。 |
  | _tools/mysql/schema.sql | データベーススキーマが定義され、4つのテーブルが追加されました。 |
  | clock/clock.go | 時間取得を抽象化する`Clocker`インターフェースとその実装が追加されました。 |
  | config/config.go, config/config_test.go, main.go | `Config`構造体にデータベース設定が追加され、接続初期化が改善されました。 |
  | entity/tag.go | `Tag`構造体が追加され、タグデータの基盤が整備されました。 |
  | go.mod | 新しい依存関係が追加され、モジュールパスが更新されました。 |
  | handler/create_tag.go, handler/create_tag_test.go, handler/list_tag.go, handler/list_tag_test.go, handler/response.go, mux.go, mux_test.go | タグ作成・一覧取得のHTTPハンドラーとテストが追加されました。 |
```
<!-- This is an auto-generated comment: raw summary by OSS CodeRabbit -->
<!--

```
_tools/mysql/conf.d/mysql.cnf, _tools/mysql/conf.d/mysqld.cnf: MySQLの設定ファイルが追加され、デフォルトの文字セットとして`utf8mb4`が指定されています。さらに、`mysqld.cnf`では、文字エンコーディング、タイムゾーン（JST）、SQLモード（厳密）、および認証プラグイン（`mysql_native_password`）が設定されています。
---
_tools/mysql/schema.sql: この差分は、データベーススキーマの作成に関するもので、`users`、`tasks`、`tags`、および中間テーブル`tasks_tags`を含む4つのテーブルが定義されています。各テーブルには、主キー、外部キー制約、およびインデックスが設定されており、ユーザーとタスク、タグの関連付けを管理します。特に、`tasks`と`tags`テーブルは、`user_id`を外部キーとして持ち、`tasks_tags`は`task_id`と`tag_id`の組み合わせでユニークなエントリを保証しています。
---
clock/clock.go: 新しいパッケージ`clock`が導入され、インターフェース`Clocker`と2つの実装：`RealClocker`と`FixedClocker`が追加されました。`RealClocker`は`time.Now()`を使用して現在時刻を返し、`FixedClocker`は固定された日時を返します。この追加は、時間取得の抽象化を提供し、テスト目的で有用です。
---
config/config.go, config/config_test.go, main.go: `Config`構造体に新しいデータベース設定フィールドが追加され、`New`関数がDSN文字列と`Config`オブジェクトを返すように変更されました。この変更により、`New`関数のシグネチャが変更され、テストコードでも追加の戻り値プレースホルダーが必要になりました。また、`run`関数内での設定取得とデータベース接続の初期化が追加され、`store.New()`を使用してデータベース接続を確立し、クリーンアップ処理を追加しています。
---
entity/tag.go: `entity`パッケージに新しい`Tag`構造体が導入され、`ID`、`Name`、`UserID`、`CreatedAt`、`UpdatedAt`のフィールドが含まれています。また、`TagID`と`UserID`を`int64`として定義し、複数の`Tag`インスタンス用のスライスタイプ`Tags`も定義されています。この追加は、アプリケーション内でタグ関連のデータを扱う基盤を整えます。
---
go.mod: Goモジュールパスが更新され、SQLモック、Redis、MySQLドライバなどの新しい依存関係が`go.mod`ファイルに追加されました。これらの変更は、プロジェクトのビルドやランタイムの動作に影響を与える可能性がありますが、直接的に関数シグネチャやグローバルデータ構造を変更するものではありません。
---
handler/create_tag.go, handler/create_tag_test.go, handler/list_tag.go, handler/list_tag_test.go, handler/response.go, mux.go, mux_test.go: 新しいタグを作成するためのHTTPハンドラー`CreateTag`が追加されました。`ServeHTTP`メソッドでは、リクエストボディからタグ名をデコードし、バリデーション後にデータベースに保存します。ユーザーIDは固定値で設定されていますが、将来的には認証機能実装後に取得される予定です。レスポンスとして新しく作成されたタグのIDを返します。また、タグ作成機能のテストが実装され、`fakeCreator`というモックリポジトリを使用して、正常系と異常系のテストケースが追加されています。さらに、新しいHTTPハンドラーがタグの一覧を取得するために追加され、`TagLister`インターフェースを実装しています。`RespondJSON`関数が追加され、JSONレスポンスの標準化が行われています。`mux`では、`chi`を使用した新しいル
-->
<!-- end of auto-generated comment: raw summary by OSS CodeRabbit -->
<!-- This is an auto-generated comment: short summary by OSS CodeRabbit -->
<!--

```
_tools/mysql/conf.d/mysql.cnf, _tools/mysql/conf.d/mysqld.cnf: MySQL設定ファイルが追加され、`utf8mb4`文字セット、JSTタイムゾーン、厳密なSQLモード、`mysql_native_password`認証プラグインが設定されています。

_tools/mysql/schema.sql: データベーススキーマに`users`、`tasks`、`tags`、`tasks_tags`の4つのテーブルが定義され、主キー、外部キー制約、インデックスが設定されています。

clock/clock.go: `Clocker`インターフェースとその実装である`RealClocker`と`FixedClocker`が追加され、時間取得の抽象化を提供します。

config/config.go, config/config_test.go, main.go: `Config`構造体にデータベース設定フィールドが追加され、`New`関数がDSN文字列と`Config`オブジェクトを返すように変更されました。設定取得とデータベース接続の初期化が追加されています。

entity/tag.go: `Tag`構造体が追加され、タグ関連のデータを扱う基盤が整えられています。

go.mod: Goモジュールパスが更新され、新しい依存関係が追加されました。

handler/create_tag.go, handler/create_tag_test.go, handler/list_tag.go, handler/list_tag_test.go, handler/response.go, mux.go, mux_test.go: 新しいタグを作成するHTTPハンドラー`CreateTag`が追加され、リクエストボディからタグ名をデコードし、バリデーション後にデータベースに保存します。ユーザーIDは固定値ですが、将来的には認証機能で取得予定です。レスポンスとして新しく作成されたタグのIDを返します。タグ作成機能のテストが実装され、`fakeCreator`モックリポジトリを使用したテストケースが追加されています。また、タグ一覧を取得するHTTPハンドラーが追加され、`TagLister`インターフェースを実装しています。`RespondJSON`関数が追加され、JSONレスポンスの標準化が行われています。`mux`では、`chi`を使用したルーティングが導入されています。
```
-->
<!-- end of auto-generated comment: short summary by OSS CodeRabbit -->

---

<details>
<summary>Uplevel your code reviews with CodeRabbit Pro</summary>

### CodeRabbit Pro

If you like this project, please support us by purchasing the [Pro version](https://coderabbit.ai). The Pro version has advanced context, superior noise reduction and several proprietary improvements compared to the open source version. Moreover, CodeRabbit Pro is free for open source projects.

</details>


<!-- commit_ids_reviewed_start -->
<!-- 8e30811c69fc2fa72f7556837968b68690572119 -->
<!-- commit_ids_reviewed_end -->

<!-- This is an auto-generated comment: summarize by OSS CodeRabbit -->
author_association: NONE
user: login: sunny-side-app
created_at: 2025-04-22T09:49:44Z
body: 指摘対応したのでマージします。
author_association: CONTRIBUTOR