diff --git a/.env.example b/.env.example index 99b172b..1d561bc 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ ANNOUNCEMENT_API_URL= GOOGLE_APPLICATION_CREDENTIALS= GOOGLE_CLOUD_PROJECT= +SUBJECT_API_URL= diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 4395791..039a60a 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -125,5 +125,6 @@ jobs: region: ${{ vars._DEPLOY_REGION }} env_vars: | ANNOUNCEMENT_API_URL=${{ vars.ANNOUNCEMENT_API_URL }} + SUBJECT_API_URL=${{ vars.SUBJECT_API_URL }} env_vars_update_strategy: overwrite secrets_update_strategy: overwrite diff --git a/cmd/server/main.go b/cmd/server/main.go index d23276e..abac48f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,20 +3,16 @@ package main import ( "context" "log" - "os" firebase "firebase.google.com/go/v4" api "github.com/fun-dotto/api-template/generated" - "github.com/fun-dotto/api-template/generated/external/announcement_api" "github.com/fun-dotto/api-template/internal/handler" + "github.com/fun-dotto/api-template/internal/infrastructure" "github.com/fun-dotto/api-template/internal/middleware" - "github.com/fun-dotto/api-template/internal/repository" - "github.com/fun-dotto/api-template/internal/service" "github.com/getkin/kin-openapi/openapi3" "github.com/gin-gonic/gin" "github.com/joho/godotenv" oapimiddleware "github.com/oapi-codegen/gin-middleware" - "google.golang.org/api/idtoken" ) func main() { @@ -46,31 +42,12 @@ func main() { router.Use(oapimiddleware.OapiRequestValidator(spec)) router.Use(middleware.FirebaseAuth(authClient)) - announcementAPIURL := os.Getenv("ANNOUNCEMENT_API_URL") - if announcementAPIURL == "" { - log.Fatal("ANNOUNCEMENT_API_URL is required") - } - // 認証付きHTTPクライアントを作成 - announcementAPIAuthClient, err := idtoken.NewClient(ctx, announcementAPIURL) + clients, err := infrastructure.NewExternalClients(ctx) if err != nil { - log.Fatal("Failed to create auth client:", err) + log.Fatalf("Failed to initialize external clients: %v", err) } - // 生成されたクライアントに認証付きHTTPクライアントを注入 - apiClient, err := announcement_api.NewClientWithResponses( - announcementAPIURL, - announcement_api.WithHTTPClient(announcementAPIAuthClient), - ) - if err != nil { - log.Fatal("Failed to create API client:", err) - } - - // Initialize layers - announcementRepo := repository.NewAnnouncementRepository(apiClient) - announcementService := service.NewAnnouncementService(announcementRepo) - - // Register handlers - h := handler.NewHandler(announcementService) + h := handler.NewHandler(clients.Announcement, clients.Subject) api.RegisterHandlers(router, h) addr := ":8080" diff --git a/go.mod b/go.mod index 2189653..cb09af0 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/joho/godotenv v1.5.1 github.com/oapi-codegen/gin-middleware v1.0.2 github.com/oapi-codegen/runtime v1.1.2 - github.com/stretchr/testify v1.11.1 google.golang.org/api v0.231.0 ) @@ -35,7 +34,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect @@ -74,7 +72,6 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.55.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/internal/domain/announcement.go b/internal/domain/announcement.go deleted file mode 100644 index 542e377..0000000 --- a/internal/domain/announcement.go +++ /dev/null @@ -1,20 +0,0 @@ -package domain - -import "time" - -// Announcement お知らせのドメインモデル -type Announcement struct { - ID string `json:"id"` - Title string `json:"title"` - URL string `json:"url"` - AvailableFrom time.Time `json:"availableFrom"` - AvailableUntil *time.Time `json:"availableUntil,omitempty"` -} - -// AnnouncementRequest お知らせのリクエストモデル -type AnnouncementRequest struct { - Title string `json:"title"` - URL string `json:"url"` - AvailableFrom time.Time `json:"availableFrom"` - AvailableUntil *time.Time `json:"availableUntil,omitempty"` -} diff --git a/internal/external/announcement.go b/internal/external/announcement.go deleted file mode 100644 index 4bb8535..0000000 --- a/internal/external/announcement.go +++ /dev/null @@ -1,27 +0,0 @@ -package external - -import ( - "github.com/fun-dotto/api-template/generated/external/announcement_api" - "github.com/fun-dotto/api-template/internal/domain" -) - -// ToDomainAnnouncement 外部API形式からドメイン形式に変換する -func ToDomainAnnouncement(a announcement_api.Announcement) domain.Announcement { - return domain.Announcement{ - ID: a.Id, - Title: a.Title, - URL: a.Url, - AvailableFrom: a.AvailableFrom, - AvailableUntil: a.AvailableUntil, - } -} - -// ToExternalAnnouncementRequest ドメイン形式から外部API形式に変換する -func ToExternalAnnouncementRequest(req *domain.AnnouncementRequest) announcement_api.AnnouncementRequest { - return announcement_api.AnnouncementRequest{ - Title: req.Title, - Url: req.URL, - AvailableFrom: req.AvailableFrom, - AvailableUntil: req.AvailableUntil, - } -} diff --git a/internal/handler/announcement.go b/internal/handler/announcement.go index e867cc0..e93646e 100644 --- a/internal/handler/announcement.go +++ b/internal/handler/announcement.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" - api "github.com/fun-dotto/api-template/generated" + "github.com/fun-dotto/api-template/generated/external/announcement_api" "github.com/fun-dotto/api-template/internal/middleware" ) @@ -15,15 +15,18 @@ func (h *Handler) AnnouncementsV1List(c *gin.Context) { return } - announcements, err := h.announcementService.List(c.Request.Context()) + response, err := h.announcementClient.AnnouncementsV1ListWithResponse(c.Request.Context(), nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, api.AnnouncementsV1List200JSONResponse{ - Announcements: ToAPIAnnouncements(announcements), - }) + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // AnnouncementsV1Detail 詳細を取得する @@ -32,15 +35,18 @@ func (h *Handler) AnnouncementsV1Detail(c *gin.Context, id string) { return } - announcement, err := h.announcementService.Detail(c.Request.Context(), id) + response, err := h.announcementClient.AnnouncementsV1DetailWithResponse(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, api.AnnouncementsV1Detail200JSONResponse{ - Announcement: ToAPIAnnouncement(announcement), - }) + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // AnnouncementsV1Create 新規作成する @@ -49,21 +55,24 @@ func (h *Handler) AnnouncementsV1Create(c *gin.Context) { return } - var req api.AnnouncementServiceAnnouncementRequest + var req announcement_api.AnnouncementRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - announcement, err := h.announcementService.Create(c.Request.Context(), ToDomainAnnouncementRequest(&req)) + response, err := h.announcementClient.AnnouncementsV1CreateWithResponse(c.Request.Context(), req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusCreated, api.AnnouncementsV1Create201JSONResponse{ - Announcement: ToAPIAnnouncement(announcement), - }) + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) } // AnnouncementsV1Delete 削除する @@ -72,13 +81,18 @@ func (h *Handler) AnnouncementsV1Delete(c *gin.Context, id string) { return } - if err := h.announcementService.Delete(c.Request.Context(), id); err != nil { + response, err := h.announcementClient.AnnouncementsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + c.Status(http.StatusNoContent) - c.Writer.WriteHeaderNow() } // AnnouncementsV1Update 更新する @@ -87,19 +101,22 @@ func (h *Handler) AnnouncementsV1Update(c *gin.Context, id string) { return } - var req api.AnnouncementServiceAnnouncementRequest + var req announcement_api.AnnouncementRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - announcement, err := h.announcementService.Update(c.Request.Context(), id, ToDomainAnnouncementRequest(&req)) + response, err := h.announcementClient.AnnouncementsV1UpdateWithResponse(c.Request.Context(), id, req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, api.AnnouncementsV1Update200JSONResponse{ - Announcement: ToAPIAnnouncement(announcement), - }) + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } diff --git a/internal/handler/announcement_test.go b/internal/handler/announcement_test.go deleted file mode 100644 index 4ceefba..0000000 --- a/internal/handler/announcement_test.go +++ /dev/null @@ -1,557 +0,0 @@ -package handler_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "firebase.google.com/go/v4/auth" - api "github.com/fun-dotto/api-template/generated" - "github.com/fun-dotto/api-template/internal/handler" - "github.com/fun-dotto/api-template/internal/middleware" - "github.com/fun-dotto/api-template/internal/repository" - "github.com/fun-dotto/api-template/internal/service" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// setupTestContext Firebase認証をモックしたテストコンテキストを作成する -func setupTestContext(withAdminClaim bool) (*httptest.ResponseRecorder, *gin.Context) { - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - - // Requestを初期化 - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - - if withAdminClaim { - // Firebaseトークンをモック - token := &auth.Token{ - Claims: map[string]interface{}{ - "admin": true, - }, - } - c.Set(middleware.FirebaseTokenContextKey, token) - } - - return w, c -} - -// setupTestContextWithClaims 指定したクレームでFirebaseトークンをモックしたテストコンテキストを作成する(403など権限不足の検証用) -func setupTestContextWithClaims(claims map[string]interface{}) (*httptest.ResponseRecorder, *gin.Context) { - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - token := &auth.Token{Claims: claims} - c.Set(middleware.FirebaseTokenContextKey, token) - return w, c -} - -func TestAnnouncementsV1List(t *testing.T) { - tests := []struct { - name string - withAdminClaim bool - withDeveloperClaim bool - customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) - wantCode int - validate func(t *testing.T, w *httptest.ResponseRecorder) - }{ - { - name: "正常にお知らせ一覧が取得できる", - withAdminClaim: true, - wantCode: http.StatusOK, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err, "JSONのパースに失敗しました") - - announcements, ok := response["announcements"].([]interface{}) - assert.True(t, ok, "announcementsフィールドが配列ではありません") - assert.NotEmpty(t, announcements, "アナウンスメントが空です") - }, - }, - { - name: "developerクレームのみでも一覧が取得できる", - withDeveloperClaim: true, - wantCode: http.StatusOK, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err, "JSONのパースに失敗しました") - announcements, ok := response["announcements"].([]interface{}) - assert.True(t, ok, "announcementsフィールドが配列ではありません") - assert.NotEmpty(t, announcements, "アナウンスメントが空です") - assert.Len(t, announcements, 1, "MockRepositoryは1件返すはずです") - }, - }, - { - name: "Content-Typeがapplication/jsonである", - withAdminClaim: true, - wantCode: http.StatusOK, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) - }, - }, - { - name: "レスポンスが正しい構造である", - withAdminClaim: true, - wantCode: http.StatusOK, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - - announcements, ok := response["announcements"].([]interface{}) - assert.True(t, ok, "announcementsフィールドが配列ではありません") - assert.Len(t, announcements, 1, "MockRepositoryは1件返すはずです") - }, - }, - { - name: "お知らせのフィールドが正しく返される", - withAdminClaim: true, - wantCode: http.StatusOK, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response struct { - Announcements []api.AnnouncementServiceAnnouncement `json:"announcements"` - } - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Len(t, response.Announcements, 1, "MockRepositoryは1件返すはずです") - assert.Equal(t, "1", response.Announcements[0].Id) - assert.Equal(t, "お知らせ1", response.Announcements[0].Title) - assert.Equal(t, "https://example.com/1", response.Announcements[0].Url) - assert.False(t, response.Announcements[0].AvailableFrom.IsZero(), "AvailableFromが設定されていること") - assert.False(t, response.Announcements[0].AvailableUntil.IsZero(), "AvailableUntilが設定されていること") - }, - }, - { - name: "認証トークンがない場合は401エラー", - withAdminClaim: false, - wantCode: http.StatusUnauthorized, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Authentication required", response["error"]) - }, - }, - { - name: "admin/developer以外のクレームのみのトークンでは403エラー", - customClaims: map[string]interface{}{"user": true}, - wantCode: http.StatusForbidden, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Insufficient permissions", response["error"]) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockRepo := repository.NewMockAnnouncementRepository() - announcementService := service.NewAnnouncementService(mockRepo) - h := handler.NewHandler(announcementService) - var w *httptest.ResponseRecorder - var c *gin.Context - if tt.customClaims != nil { - w, c = setupTestContextWithClaims(tt.customClaims) - } else if tt.withDeveloperClaim { - w, c = setupTestContextWithClaims(map[string]interface{}{"developer": true}) - } else { - w, c = setupTestContext(tt.withAdminClaim) - } - - h.AnnouncementsV1List(c) - - assert.Equal(t, tt.wantCode, w.Code) - - if tt.validate != nil { - tt.validate(t, w) - } - }) - } -} - -func TestAnnouncementsV1Detail(t *testing.T) { - tests := []struct { - name string - id string - withAdminClaim bool - customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) - wantCode int - validate func(t *testing.T, w *httptest.ResponseRecorder) - }{ - { - name: "正常にお知らせ詳細が取得できる", - id: "1", - withAdminClaim: true, - wantCode: http.StatusOK, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response struct { - Announcement api.AnnouncementServiceAnnouncement `json:"announcement"` - } - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err, "JSONのパースに失敗しました") - assert.Equal(t, "1", response.Announcement.Id) - assert.Equal(t, "お知らせ1", response.Announcement.Title) - assert.Equal(t, "https://example.com/1", response.Announcement.Url) - }, - }, - { - name: "認証トークンがない場合は401エラー", - id: "1", - withAdminClaim: false, - wantCode: http.StatusUnauthorized, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Authentication required", response["error"]) - }, - }, - { - name: "admin/developer以外のクレームのみのトークンでは403エラー", - id: "1", - customClaims: map[string]interface{}{"user": true}, - wantCode: http.StatusForbidden, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Insufficient permissions", response["error"]) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockRepo := repository.NewMockAnnouncementRepository() - announcementService := service.NewAnnouncementService(mockRepo) - h := handler.NewHandler(announcementService) - var w *httptest.ResponseRecorder - var c *gin.Context - if tt.customClaims != nil { - w, c = setupTestContextWithClaims(tt.customClaims) - } else { - w, c = setupTestContext(tt.withAdminClaim) - } - - h.AnnouncementsV1Detail(c, tt.id) - - assert.Equal(t, tt.wantCode, w.Code) - - if tt.validate != nil { - tt.validate(t, w) - } - }) - } -} - -func TestAnnouncementsV1Create(t *testing.T) { - now := time.Now() - until := now.Add(24 * time.Hour) - - tests := []struct { - name string - request api.AnnouncementServiceAnnouncementRequest - withAdminClaim bool - withDeveloperClaim bool - customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) - wantCode int - validate func(t *testing.T, w *httptest.ResponseRecorder) - }{ - { - name: "正常にお知らせを作成できる", - request: api.AnnouncementServiceAnnouncementRequest{ - Title: "新しいお知らせ", - Url: "https://example.com/new", - AvailableFrom: now, - AvailableUntil: &until, - }, - withAdminClaim: true, - wantCode: http.StatusCreated, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response struct { - Announcement api.AnnouncementServiceAnnouncement `json:"announcement"` - } - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err, "JSONのパースに失敗しました") - assert.Equal(t, "created-id", response.Announcement.Id) - assert.Equal(t, "新しいお知らせ", response.Announcement.Title) - assert.Equal(t, "https://example.com/new", response.Announcement.Url) - }, - }, - { - name: "developerクレームのみでも作成できる", - request: api.AnnouncementServiceAnnouncementRequest{ - Title: "developer経由のお知らせ", - Url: "https://example.com/developer", - AvailableFrom: now, - AvailableUntil: &until, - }, - withDeveloperClaim: true, - wantCode: http.StatusCreated, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response struct { - Announcement api.AnnouncementServiceAnnouncement `json:"announcement"` - } - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err, "JSONのパースに失敗しました") - assert.Equal(t, "created-id", response.Announcement.Id) - assert.Equal(t, "developer経由のお知らせ", response.Announcement.Title) - assert.Equal(t, "https://example.com/developer", response.Announcement.Url) - }, - }, - { - name: "認証トークンがない場合は401エラー", - request: api.AnnouncementServiceAnnouncementRequest{ - Title: "新しいお知らせ", - Url: "https://example.com/new", - AvailableFrom: now, - AvailableUntil: &until, - }, - withAdminClaim: false, - wantCode: http.StatusUnauthorized, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Authentication required", response["error"]) - }, - }, - { - name: "admin/developer以外のクレームのみのトークンでは403エラー", - request: api.AnnouncementServiceAnnouncementRequest{ - Title: "新しいお知らせ", - Url: "https://example.com/new", - AvailableFrom: now, - AvailableUntil: &until, - }, - customClaims: map[string]interface{}{"user": true}, - wantCode: http.StatusForbidden, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Insufficient permissions", response["error"]) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockRepo := repository.NewMockAnnouncementRepository() - announcementService := service.NewAnnouncementService(mockRepo) - h := handler.NewHandler(announcementService) - var w *httptest.ResponseRecorder - var c *gin.Context - if tt.customClaims != nil { - w, c = setupTestContextWithClaims(tt.customClaims) - } else if tt.withDeveloperClaim { - w, c = setupTestContextWithClaims(map[string]interface{}{"developer": true}) - } else { - w, c = setupTestContext(tt.withAdminClaim) - } - - // リクエストボディを設定 - body, err := json.Marshal(tt.request) - require.NoError(t, err, "リクエストボディのJSONエンコードに失敗しました") - c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/announcements", bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - h.AnnouncementsV1Create(c) - - assert.Equal(t, tt.wantCode, w.Code) - - if tt.validate != nil { - tt.validate(t, w) - } - }) - } -} - -func TestAnnouncementsV1Update(t *testing.T) { - now := time.Now() - until := now.Add(24 * time.Hour) - - tests := []struct { - name string - id string - request api.AnnouncementServiceAnnouncementRequest - withAdminClaim bool - customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) - wantCode int - validate func(t *testing.T, w *httptest.ResponseRecorder) - }{ - { - name: "正常にお知らせを更新できる", - id: "1", - request: api.AnnouncementServiceAnnouncementRequest{ - Title: "更新されたお知らせ", - Url: "https://example.com/updated", - AvailableFrom: now, - AvailableUntil: &until, - }, - withAdminClaim: true, - wantCode: http.StatusOK, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response struct { - Announcement api.AnnouncementServiceAnnouncement `json:"announcement"` - } - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err, "JSONのパースに失敗しました") - assert.Equal(t, "1", response.Announcement.Id) - assert.Equal(t, "更新されたお知らせ", response.Announcement.Title) - assert.Equal(t, "https://example.com/updated", response.Announcement.Url) - }, - }, - { - name: "認証トークンがない場合は401エラー", - id: "1", - request: api.AnnouncementServiceAnnouncementRequest{ - Title: "更新されたお知らせ", - Url: "https://example.com/updated", - AvailableFrom: now, - AvailableUntil: &until, - }, - withAdminClaim: false, - wantCode: http.StatusUnauthorized, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Authentication required", response["error"]) - }, - }, - { - name: "admin/developer以外のクレームのみのトークンでは403エラー", - id: "1", - request: api.AnnouncementServiceAnnouncementRequest{ - Title: "更新されたお知らせ", - Url: "https://example.com/updated", - AvailableFrom: now, - AvailableUntil: &until, - }, - customClaims: map[string]interface{}{"user": true}, - wantCode: http.StatusForbidden, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Insufficient permissions", response["error"]) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockRepo := repository.NewMockAnnouncementRepository() - announcementService := service.NewAnnouncementService(mockRepo) - h := handler.NewHandler(announcementService) - var w *httptest.ResponseRecorder - var c *gin.Context - if tt.customClaims != nil { - w, c = setupTestContextWithClaims(tt.customClaims) - } else { - w, c = setupTestContext(tt.withAdminClaim) - } - - // リクエストボディを設定 - body, err := json.Marshal(tt.request) - require.NoError(t, err, "リクエストボディのJSONエンコードに失敗しました") - c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/announcements/"+tt.id, bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - h.AnnouncementsV1Update(c, tt.id) - - assert.Equal(t, tt.wantCode, w.Code) - - if tt.validate != nil { - tt.validate(t, w) - } - }) - } -} - -func TestAnnouncementsV1Delete(t *testing.T) { - tests := []struct { - name string - id string - withAdminClaim bool - withDeveloperClaim bool - customClaims map[string]interface{} // 指定時はこのクレームでトークンをセット(403検証用) - wantCode int - validate func(t *testing.T, w *httptest.ResponseRecorder) - }{ - { - name: "正常にお知らせを削除できる", - id: "1", - withAdminClaim: true, - wantCode: http.StatusNoContent, - validate: nil, - }, - { - name: "developerクレームのみでも削除できる", - id: "1", - withDeveloperClaim: true, - wantCode: http.StatusNoContent, - validate: nil, - }, - { - name: "認証トークンがない場合は401エラー", - id: "1", - withAdminClaim: false, - wantCode: http.StatusUnauthorized, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Authentication required", response["error"]) - }, - }, - { - name: "admin/developer以外のクレームのみのトークンでは403エラー", - id: "1", - customClaims: map[string]interface{}{"user": true}, - wantCode: http.StatusForbidden, - validate: func(t *testing.T, w *httptest.ResponseRecorder) { - var response map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Equal(t, "Insufficient permissions", response["error"]) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockRepo := repository.NewMockAnnouncementRepository() - announcementService := service.NewAnnouncementService(mockRepo) - h := handler.NewHandler(announcementService) - var w *httptest.ResponseRecorder - var c *gin.Context - if tt.customClaims != nil { - w, c = setupTestContextWithClaims(tt.customClaims) - } else if tt.withDeveloperClaim { - w, c = setupTestContextWithClaims(map[string]interface{}{"developer": true}) - } else { - w, c = setupTestContext(tt.withAdminClaim) - } - - h.AnnouncementsV1Delete(c, tt.id) - - assert.Equal(t, tt.wantCode, w.Code) - - if tt.validate != nil { - tt.validate(t, w) - } - }) - } -} diff --git a/internal/handler/converter.go b/internal/handler/converter.go deleted file mode 100644 index 72a5597..0000000 --- a/internal/handler/converter.go +++ /dev/null @@ -1,36 +0,0 @@ -package handler - -import ( - api "github.com/fun-dotto/api-template/generated" - "github.com/fun-dotto/api-template/internal/domain" -) - -// ToAPIAnnouncement ドメインモデルをAPIモデルに変換する -func ToAPIAnnouncement(a *domain.Announcement) api.AnnouncementServiceAnnouncement { - return api.AnnouncementServiceAnnouncement{ - Id: a.ID, - Title: a.Title, - Url: a.URL, - AvailableFrom: a.AvailableFrom, - AvailableUntil: a.AvailableUntil, - } -} - -// ToAPIAnnouncements ドメインモデルの配列をAPIモデルの配列に変換する -func ToAPIAnnouncements(announcements []domain.Announcement) []api.AnnouncementServiceAnnouncement { - result := make([]api.AnnouncementServiceAnnouncement, len(announcements)) - for i, a := range announcements { - result[i] = ToAPIAnnouncement(&a) - } - return result -} - -// ToDomainAnnouncementRequest APIモデルをドメインモデルに変換する -func ToDomainAnnouncementRequest(req *api.AnnouncementServiceAnnouncementRequest) *domain.AnnouncementRequest { - return &domain.AnnouncementRequest{ - Title: req.Title, - URL: req.Url, - AvailableFrom: req.AvailableFrom, - AvailableUntil: req.AvailableUntil, - } -} diff --git a/internal/handler/course.go b/internal/handler/course.go index a89a63f..b21fce0 100644 --- a/internal/handler/course.go +++ b/internal/handler/course.go @@ -4,29 +4,119 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/fun-dotto/api-template/generated/external/subject_api" + "github.com/fun-dotto/api-template/internal/middleware" ) // CoursesV1List コース一覧を取得する func (h *Handler) CoursesV1List(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.CoursesV1ListWithResponse(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // CoursesV1Detail コースを詳細取得する func (h *Handler) CoursesV1Detail(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.CoursesV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // CoursesV1Create コースを作成する func (h *Handler) CoursesV1Create(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.CourseRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.CoursesV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) } // CoursesV1Update コースを更新する func (h *Handler) CoursesV1Update(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.CourseRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.CoursesV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // CoursesV1Delete コースを削除する func (h *Handler) CoursesV1Delete(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.CoursesV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) } diff --git a/internal/handler/day_of_week_timetable_slot.go b/internal/handler/day_of_week_timetable_slot.go index da77d94..97047d7 100644 --- a/internal/handler/day_of_week_timetable_slot.go +++ b/internal/handler/day_of_week_timetable_slot.go @@ -4,29 +4,119 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/fun-dotto/api-template/generated/external/subject_api" + "github.com/fun-dotto/api-template/internal/middleware" ) // DayOfWeekTimetableSlotsV1List 曜日・時限一覧を取得する func (h *Handler) DayOfWeekTimetableSlotsV1List(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.DayOfWeekTimetableSlotsV1ListWithResponse(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // DayOfWeekTimetableSlotsV1Detail 曜日・時限を詳細取得する func (h *Handler) DayOfWeekTimetableSlotsV1Detail(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.DayOfWeekTimetableSlotsV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // DayOfWeekTimetableSlotsV1Create 曜日・時限を作成する func (h *Handler) DayOfWeekTimetableSlotsV1Create(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.DayOfWeekTimetableSlotRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.DayOfWeekTimetableSlotsV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) } // DayOfWeekTimetableSlotsV1Update 曜日・時限を更新する func (h *Handler) DayOfWeekTimetableSlotsV1Update(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.DayOfWeekTimetableSlotRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.DayOfWeekTimetableSlotsV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // DayOfWeekTimetableSlotsV1Delete 曜日・時限を削除する func (h *Handler) DayOfWeekTimetableSlotsV1Delete(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.DayOfWeekTimetableSlotsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) } diff --git a/internal/handler/faculty.go b/internal/handler/faculty.go index a85e4d2..3167886 100644 --- a/internal/handler/faculty.go +++ b/internal/handler/faculty.go @@ -4,29 +4,119 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/fun-dotto/api-template/generated/external/subject_api" + "github.com/fun-dotto/api-template/internal/middleware" ) // FacultiesV1List 教員一覧を取得する func (h *Handler) FacultiesV1List(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.FacultiesV1ListWithResponse(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // FacultiesV1Detail 教員を詳細取得する func (h *Handler) FacultiesV1Detail(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.FacultiesV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // FacultiesV1Create 教員を作成する func (h *Handler) FacultiesV1Create(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.FacultyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.FacultiesV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) } // FacultiesV1Update 教員を更新する func (h *Handler) FacultiesV1Update(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.FacultyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.FacultiesV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // FacultiesV1Delete 教員を削除する func (h *Handler) FacultiesV1Delete(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.FacultiesV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 2325d2a..2c1b1d9 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1,27 +1,29 @@ package handler import ( - "context" - api "github.com/fun-dotto/api-template/generated" - "github.com/fun-dotto/api-template/internal/domain" + "github.com/fun-dotto/api-template/generated/external/announcement_api" + "github.com/fun-dotto/api-template/generated/external/subject_api" ) -type AnnouncementService interface { - List(ctx context.Context) ([]domain.Announcement, error) - Detail(ctx context.Context, id string) (*domain.Announcement, error) - Create(ctx context.Context, req *domain.AnnouncementRequest) (*domain.Announcement, error) - Update(ctx context.Context, id string, req *domain.AnnouncementRequest) (*domain.Announcement, error) - Delete(ctx context.Context, id string) error -} - type Handler struct { - announcementService AnnouncementService + announcementClient *announcement_api.ClientWithResponses + subjectClient *subject_api.ClientWithResponses } -func NewHandler(announcementService AnnouncementService) *Handler { +func NewHandler( + announcementClient *announcement_api.ClientWithResponses, + subjectClient *subject_api.ClientWithResponses, +) *Handler { + if announcementClient == nil { + panic("announcementClient is required") + } + if subjectClient == nil { + panic("subjectClient is required") + } return &Handler{ - announcementService: announcementService, + announcementClient: announcementClient, + subjectClient: subjectClient, } } diff --git a/internal/handler/subject.go b/internal/handler/subject.go index 96519d5..f96071e 100644 --- a/internal/handler/subject.go +++ b/internal/handler/subject.go @@ -4,29 +4,119 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/fun-dotto/api-template/generated/external/subject_api" + "github.com/fun-dotto/api-template/internal/middleware" ) // SubjectsV1List 科目一覧を取得する func (h *Handler) SubjectsV1List(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.SubjectsV1ListWithResponse(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // SubjectsV1Detail 科目を詳細取得する func (h *Handler) SubjectsV1Detail(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.SubjectsV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // SubjectsV1Create 科目を作成する func (h *Handler) SubjectsV1Create(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.SubjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.SubjectsV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) } // SubjectsV1Update 科目を更新する func (h *Handler) SubjectsV1Update(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.SubjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.SubjectsV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // SubjectsV1Delete 科目を削除する func (h *Handler) SubjectsV1Delete(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.SubjectsV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.StatusCode() != http.StatusNoContent { + c.JSON(http.StatusInternalServerError, gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) } diff --git a/internal/handler/subject_category.go b/internal/handler/subject_category.go index a3723d8..559c146 100644 --- a/internal/handler/subject_category.go +++ b/internal/handler/subject_category.go @@ -4,29 +4,120 @@ import ( "net/http" "github.com/gin-gonic/gin" + + "github.com/fun-dotto/api-template/generated/external/subject_api" + "github.com/fun-dotto/api-template/internal/middleware" ) // SubjectCategoriesV1List 科目群・科目区分一覧を取得する func (h *Handler) SubjectCategoriesV1List(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.SubjectCategoriesV1ListWithResponse(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // SubjectCategoriesV1Detail 科目群・科目区分を詳細取得する func (h *Handler) SubjectCategoriesV1Detail(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.SubjectCategoriesV1DetailWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // SubjectCategoriesV1Create 科目群・科目区分を作成する func (h *Handler) SubjectCategoriesV1Create(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.SubjectCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.SubjectCategoriesV1CreateWithResponse(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON201 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusCreated, response.JSON201) } // SubjectCategoriesV1Update 科目群・科目区分を更新する func (h *Handler) SubjectCategoriesV1Update(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req subject_api.SubjectCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.subjectClient.SubjectCategoriesV1UpdateWithResponse(c.Request.Context(), id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if response.JSON200 == nil { + c.JSON(response.StatusCode(), gin.H{"error": "unexpected response from upstream"}) + return + } + + c.JSON(http.StatusOK, response.JSON200) } // SubjectCategoriesV1Delete 科目群・科目区分を削除する func (h *Handler) SubjectCategoriesV1Delete(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.subjectClient.SubjectCategoriesV1DeleteWithResponse(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + status := response.StatusCode() + if status != http.StatusNoContent { + c.JSON(status, gin.H{"error": "unexpected response from upstream"}) + return + } + + c.Status(http.StatusNoContent) } diff --git a/internal/infrastructure/clients.go b/internal/infrastructure/clients.go new file mode 100644 index 0000000..a4a57fd --- /dev/null +++ b/internal/infrastructure/clients.go @@ -0,0 +1,83 @@ +package infrastructure + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + "github.com/fun-dotto/api-template/generated/external/announcement_api" + "github.com/fun-dotto/api-template/generated/external/subject_api" + "google.golang.org/api/idtoken" +) + +const httpClientTimeout = 30 * time.Second + +// ExternalClients 外部APIクライアントをまとめて管理 +type ExternalClients struct { + Announcement *announcement_api.ClientWithResponses + Subject *subject_api.ClientWithResponses +} + +// NewExternalClients 全ての外部APIクライアントを初期化 +func NewExternalClients(ctx context.Context) (*ExternalClients, error) { + announcement, err := newAnnouncementClient(ctx) + if err != nil { + return nil, fmt.Errorf("announcement client: %w", err) + } + + subject, err := newSubjectClient(ctx) + if err != nil { + return nil, fmt.Errorf("subject client: %w", err) + } + + return &ExternalClients{ + Announcement: announcement, + Subject: subject, + }, nil +} + +func newAnnouncementClient(ctx context.Context) (*announcement_api.ClientWithResponses, error) { + url := os.Getenv("ANNOUNCEMENT_API_URL") + if url == "" { + return nil, fmt.Errorf("ANNOUNCEMENT_API_URL is required") + } + + authClient, err := newAuthHTTPClient(ctx, url) + if err != nil { + return nil, err + } + + return announcement_api.NewClientWithResponses( + url, + announcement_api.WithHTTPClient(authClient), + ) +} + +func newSubjectClient(ctx context.Context) (*subject_api.ClientWithResponses, error) { + url := os.Getenv("SUBJECT_API_URL") + if url == "" { + return nil, fmt.Errorf("SUBJECT_API_URL is required") + } + + authClient, err := newAuthHTTPClient(ctx, url) + if err != nil { + return nil, err + } + + return subject_api.NewClientWithResponses( + url, + subject_api.WithHTTPClient(authClient), + ) +} + +// newAuthHTTPClient Google Cloud認証付きHTTPクライアントを作成 +func newAuthHTTPClient(ctx context.Context, targetURL string) (*http.Client, error) { + client, err := idtoken.NewClient(ctx, targetURL) + if err != nil { + return nil, fmt.Errorf("failed to create auth client: %w", err) + } + client.Timeout = httpClientTimeout + return client, nil +} diff --git a/internal/repository/announcement.go b/internal/repository/announcement.go deleted file mode 100644 index b540df6..0000000 --- a/internal/repository/announcement.go +++ /dev/null @@ -1,102 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "github.com/fun-dotto/api-template/generated/external/announcement_api" - "github.com/fun-dotto/api-template/internal/domain" - "github.com/fun-dotto/api-template/internal/external" - "github.com/fun-dotto/api-template/internal/service" -) - -type announcementRepository struct { - client *announcement_api.ClientWithResponses -} - -func NewAnnouncementRepository(client *announcement_api.ClientWithResponses) service.AnnouncementRepository { - return &announcementRepository{client: client} -} - -// List 一覧を取得する -func (r *announcementRepository) List(ctx context.Context) ([]domain.Announcement, error) { - response, err := r.client.AnnouncementsV1ListWithResponse(ctx, nil) - if err != nil { - return nil, fmt.Errorf("failed to get announcements: %w", err) - } - - if response.JSON200 == nil { - return nil, fmt.Errorf("failed to get announcements: status %d", response.StatusCode()) - } - - // 外部API形式 → ドメイン形式に変換 - result := make([]domain.Announcement, len(response.JSON200.Announcements)) - for i, a := range response.JSON200.Announcements { - result[i] = external.ToDomainAnnouncement(a) - } - - return result, nil -} - -// Detail 詳細を取得する -func (r *announcementRepository) Detail(ctx context.Context, id string) (*domain.Announcement, error) { - response, err := r.client.AnnouncementsV1DetailWithResponse(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get announcement: %w", err) - } - - if response.JSON200 == nil { - return nil, fmt.Errorf("failed to get announcement: status %d", response.StatusCode()) - } - - result := external.ToDomainAnnouncement(response.JSON200.Announcement) - return &result, nil -} - -// Create 新規作成する -func (r *announcementRepository) Create(ctx context.Context, req *domain.AnnouncementRequest) (*domain.Announcement, error) { - body := external.ToExternalAnnouncementRequest(req) - - response, err := r.client.AnnouncementsV1CreateWithResponse(ctx, body) - if err != nil { - return nil, fmt.Errorf("failed to create announcement: %w", err) - } - - if response.JSON201 == nil { - return nil, fmt.Errorf("failed to create announcement: status %d", response.StatusCode()) - } - - result := external.ToDomainAnnouncement(response.JSON201.Announcement) - return &result, nil -} - -// Update 更新する -func (r *announcementRepository) Update(ctx context.Context, id string, req *domain.AnnouncementRequest) (*domain.Announcement, error) { - body := external.ToExternalAnnouncementRequest(req) - - response, err := r.client.AnnouncementsV1UpdateWithResponse(ctx, id, body) - if err != nil { - return nil, fmt.Errorf("failed to update announcement: %w", err) - } - - if response.JSON200 == nil { - return nil, fmt.Errorf("failed to update announcement: status %d", response.StatusCode()) - } - - result := external.ToDomainAnnouncement(response.JSON200.Announcement) - return &result, nil -} - -// Delete 削除する -func (r *announcementRepository) Delete(ctx context.Context, id string) error { - response, err := r.client.AnnouncementsV1DeleteWithResponse(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete announcement: %w", err) - } - - if response.StatusCode() != 204 { - return fmt.Errorf("failed to delete announcement: status %d", response.StatusCode()) - } - - return nil -} diff --git a/internal/repository/announcement_mock.go b/internal/repository/announcement_mock.go deleted file mode 100644 index 85bb253..0000000 --- a/internal/repository/announcement_mock.go +++ /dev/null @@ -1,71 +0,0 @@ -package repository - -import ( - "context" - "time" - - "github.com/fun-dotto/api-template/internal/domain" - "github.com/fun-dotto/api-template/internal/service" -) - -type mockAnnouncementRepository struct{} - -// NewMockAnnouncementRepository モックリポジトリを作成する -func NewMockAnnouncementRepository() service.AnnouncementRepository { - return &mockAnnouncementRepository{} -} - -// List 一覧を取得する(モック) -func (r *mockAnnouncementRepository) List(ctx context.Context) ([]domain.Announcement, error) { - now := time.Now() - until := now.Add(24 * time.Hour) - return []domain.Announcement{ - { - ID: "1", - Title: "お知らせ1", - URL: "https://example.com/1", - AvailableFrom: now, - AvailableUntil: &until, - }, - }, nil -} - -// Detail 詳細を取得する(モック) -func (r *mockAnnouncementRepository) Detail(ctx context.Context, id string) (*domain.Announcement, error) { - now := time.Now() - until := now.Add(24 * time.Hour) - return &domain.Announcement{ - ID: id, - Title: "お知らせ" + id, - URL: "https://example.com/" + id, - AvailableFrom: now, - AvailableUntil: &until, - }, nil -} - -// Create 新規作成する(モック) -func (r *mockAnnouncementRepository) Create(ctx context.Context, req *domain.AnnouncementRequest) (*domain.Announcement, error) { - return &domain.Announcement{ - ID: "created-id", - Title: req.Title, - URL: req.URL, - AvailableFrom: req.AvailableFrom, - AvailableUntil: req.AvailableUntil, - }, nil -} - -// Update 更新する(モック) -func (r *mockAnnouncementRepository) Update(ctx context.Context, id string, req *domain.AnnouncementRequest) (*domain.Announcement, error) { - return &domain.Announcement{ - ID: id, - Title: req.Title, - URL: req.URL, - AvailableFrom: req.AvailableFrom, - AvailableUntil: req.AvailableUntil, - }, nil -} - -// Delete 削除する(モック) -func (r *mockAnnouncementRepository) Delete(ctx context.Context, id string) error { - return nil -} diff --git a/internal/service/announcement.go b/internal/service/announcement.go deleted file mode 100644 index 4f27910..0000000 --- a/internal/service/announcement.go +++ /dev/null @@ -1,52 +0,0 @@ -package service - -import ( - "context" - - "github.com/fun-dotto/api-template/internal/domain" - "github.com/fun-dotto/api-template/internal/handler" -) - -type AnnouncementRepository interface { - List(ctx context.Context) ([]domain.Announcement, error) - Detail(ctx context.Context, id string) (*domain.Announcement, error) - Create(ctx context.Context, req *domain.AnnouncementRequest) (*domain.Announcement, error) - Update(ctx context.Context, id string, req *domain.AnnouncementRequest) (*domain.Announcement, error) - Delete(ctx context.Context, id string) error -} - -type announcementService struct { - repo AnnouncementRepository -} - -// NewAnnouncementService 新規作成する -func NewAnnouncementService(repo AnnouncementRepository) handler.AnnouncementService { - return &announcementService{ - repo: repo, - } -} - -// List 一覧を取得する -func (s *announcementService) List(ctx context.Context) ([]domain.Announcement, error) { - return s.repo.List(ctx) -} - -// Detail 詳細を取得する -func (s *announcementService) Detail(ctx context.Context, id string) (*domain.Announcement, error) { - return s.repo.Detail(ctx, id) -} - -// Create 新規作成する -func (s *announcementService) Create(ctx context.Context, req *domain.AnnouncementRequest) (*domain.Announcement, error) { - return s.repo.Create(ctx, req) -} - -// Update 更新する -func (s *announcementService) Update(ctx context.Context, id string, req *domain.AnnouncementRequest) (*domain.Announcement, error) { - return s.repo.Update(ctx, id, req) -} - -// Delete 削除する -func (s *announcementService) Delete(ctx context.Context, id string) error { - return s.repo.Delete(ctx, id) -}