From 24fb251258ec94af9e3635975d47be2f82a9888b Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Fri, 27 Mar 2026 14:17:23 +0900 Subject: [PATCH 1/3] Implement stub handlers --- cmd/server/main.go | 2 +- internal/handler/handler.go | 7 + internal/handler/handler_test.go | 91 +++++++++- internal/handler/stub.go | 275 +++++++++++++++++++++++++++-- internal/infrastructure/clients.go | 25 +++ 5 files changed, 385 insertions(+), 15 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 9d21aa4..038ab01 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -47,7 +47,7 @@ func main() { log.Fatalf("Failed to initialize external clients: %v", err) } - h := handler.NewHandler(clients.Academic, clients.Announcement) + h := handler.NewHandler(clients.Academic, clients.Announcement, clients.User) api.RegisterHandlers(router, h) addr := ":8080" diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 26c4929..37d0a9c 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -4,16 +4,19 @@ import ( api "github.com/fun-dotto/admin-bff-api/generated" "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" "github.com/fun-dotto/admin-bff-api/generated/external/announcement_api" + "github.com/fun-dotto/admin-bff-api/generated/external/user_api" ) type Handler struct { academicClient *academic_api.ClientWithResponses announcementClient *announcement_api.ClientWithResponses + userClient *user_api.ClientWithResponses } func NewHandler( academicClient *academic_api.ClientWithResponses, announcementClient *announcement_api.ClientWithResponses, + userClient *user_api.ClientWithResponses, ) *Handler { if academicClient == nil { panic("academicClient is required") @@ -21,9 +24,13 @@ func NewHandler( if announcementClient == nil { panic("announcementClient is required") } + if userClient == nil { + panic("userClient is required") + } return &Handler{ academicClient: academicClient, announcementClient: announcementClient, + userClient: userClient, } } diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 6ff28e9..a540e6e 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -1,6 +1,7 @@ package handler import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -12,6 +13,7 @@ import ( api "github.com/fun-dotto/admin-bff-api/generated" "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" "github.com/fun-dotto/admin-bff-api/generated/external/announcement_api" + "github.com/fun-dotto/admin-bff-api/generated/external/user_api" "github.com/fun-dotto/admin-bff-api/internal/middleware" "github.com/gin-gonic/gin" ) @@ -124,6 +126,89 @@ func TestPersonalCalendarItemsV1List_ProxiesAcademicAPI(t *testing.T) { } } +func TestCourseRegistrationsV1Create_ProxiesAcademicAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotMethod string + var gotPath string + var gotBody academic_api.CourseRegistrationRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"courseRegistration":{"id":"reg-1","userId":"user-1","subject":{"id":"subject-1","name":"Algorithms","faculties":[]}}}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest( + http.MethodPost, + "/v1/courseRegistrations", + bytes.NewBufferString(`{"subjectId":"subject-1","userId":"user-1"}`), + ) + c.Request.Header.Set("Content-Type", "application/json") + setAdminClaim(c) + + h.CourseRegistrationsV1Create(c) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated) + } + if gotMethod != http.MethodPost || gotPath != "/v1/courseRegistrations" { + t.Fatalf("method/path = %s %s", gotMethod, gotPath) + } + if gotBody.SubjectId != "subject-1" || gotBody.UserId != "user-1" { + t.Fatalf("unexpected upstream request body: %+v", gotBody) + } +} + +func TestUsersV1Detail_ProxiesUserAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"user":{"id":"user-1","name":"Jane Doe","email":"jane@example.com"}}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/users/user-1", nil) + setAdminClaim(c) + + h.UsersV1Detail(c, "user-1") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if gotPath != "/v1/users/user-1" { + t.Fatalf("path = %q, want %q", gotPath, "/v1/users/user-1") + } + + var body struct { + User struct { + Id string `json:"id"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.User.Id != "user-1" { + t.Fatalf("unexpected response body: %s", rec.Body.String()) + } +} + func newTestHandler(t *testing.T, baseURL string) *Handler { t.Helper() @@ -135,8 +220,12 @@ func newTestHandler(t *testing.T, baseURL string) *Handler { if err != nil { t.Fatalf("new announcement client: %v", err) } + userClient, err := user_api.NewClientWithResponses(baseURL) + if err != nil { + t.Fatalf("new user client: %v", err) + } - return NewHandler(academicClient, announcementClient) + return NewHandler(academicClient, announcementClient, userClient) } func setAdminClaim(c *gin.Context) { diff --git a/internal/handler/stub.go b/internal/handler/stub.go index ae96737..3bfeb78 100644 --- a/internal/handler/stub.go +++ b/internal/handler/stub.go @@ -6,69 +6,318 @@ import ( "github.com/gin-gonic/gin" api "github.com/fun-dotto/admin-bff-api/generated" + "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" + "github.com/fun-dotto/admin-bff-api/internal/middleware" ) // CourseRegistrationsV1List 履修情報を取得する func (h *Handler) CourseRegistrationsV1List(c *gin.Context, params api.CourseRegistrationsV1ListParams) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.CourseRegistrationsV1ListWithResponse(c.Request.Context(), &academic_api.CourseRegistrationsV1ListParams{ + UserId: params.UserId, + Year: params.Year, + Semesters: convertSlice[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), + }) + 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, gin.H{ + "registrations": response.JSON200.CourseRegistrations, + }) } // CourseRegistrationsV1Create 履修情報を作成する func (h *Handler) CourseRegistrationsV1Create(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.CourseRegistrationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.CourseRegistrationsV1CreateWithResponse(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, gin.H{ + "registration": response.JSON201.CourseRegistration, + }) } // CourseRegistrationsV1Delete 履修情報を削除する func (h *Handler) CourseRegistrationsV1Delete(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.CourseRegistrationsV1DeleteWithResponse(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) } // RoomsV1List 教室一覧を取得する func (h *Handler) RoomsV1List(c *gin.Context, params api.RoomsV1ListParams) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1ListWithResponse(c.Request.Context(), &academic_api.RoomsV1ListParams{ + Floor: convertSlicePtr[api.DottoFoundationV1Floor, academic_api.DottoFoundationV1Floor](params.Floor), + }) + 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) } // RoomsV1Create 教室を作成する func (h *Handler) RoomsV1Create(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.RoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.RoomsV1CreateWithResponse(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) } // RoomsV1Detail 教室を詳細取得する func (h *Handler) RoomsV1Detail(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1DetailWithResponse(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) } // RoomsV1Update 教室を更新する func (h *Handler) RoomsV1Update(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.RoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.RoomsV1UpdateWithResponse(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) } // RoomsV1Delete 教室を削除する func (h *Handler) RoomsV1Delete(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1DeleteWithResponse(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) } // ReservationsV1List 教室の予約一覧を取得する func (h *Handler) ReservationsV1List(c *gin.Context, id string, params api.ReservationsV1ListParams) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.ReservationsV1ListWithResponse(c.Request.Context(), id, &academic_api.ReservationsV1ListParams{ + From: params.From, + Until: params.Until, + }) + 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) } // TimetableItemsV1List 時間割を取得する func (h *Handler) TimetableItemsV1List(c *gin.Context, params api.TimetableItemsV1ListParams) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.TimetableItemsV1ListWithResponse(c.Request.Context(), &academic_api.TimetableItemsV1ListParams{ + Year: params.Year, + Semesters: convertSlice[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), + }) + 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, gin.H{ + "items": response.JSON200.TimetableItems, + }) } // TimetableItemsV1Create 時間割に追加する func (h *Handler) TimetableItemsV1Create(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.TimetableItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.TimetableItemsV1CreateWithResponse(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, gin.H{ + "item": response.JSON201.TimetableItem, + }) } // TimetableItemsV1Delete 時間割を削除する func (h *Handler) TimetableItemsV1Delete(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.TimetableItemsV1DeleteWithResponse(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) } // UsersV1Detail ユーザーを取得する func (h *Handler) UsersV1Detail(c *gin.Context, id string) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented"}) + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.userClient.UsersV1DetailWithResponse(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) +} + +func convertSlice[From, To ~string](src []From) []To { + result := make([]To, len(src)) + for i, v := range src { + result[i] = To(v) + } + return result } diff --git a/internal/infrastructure/clients.go b/internal/infrastructure/clients.go index ebd360b..cc6d884 100644 --- a/internal/infrastructure/clients.go +++ b/internal/infrastructure/clients.go @@ -9,6 +9,7 @@ import ( "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" "github.com/fun-dotto/admin-bff-api/generated/external/announcement_api" + "github.com/fun-dotto/admin-bff-api/generated/external/user_api" "google.golang.org/api/idtoken" ) @@ -18,6 +19,7 @@ const httpClientTimeout = 30 * time.Second type ExternalClients struct { Academic *academic_api.ClientWithResponses Announcement *announcement_api.ClientWithResponses + User *user_api.ClientWithResponses } // NewExternalClients 全ての外部APIクライアントを初期化 @@ -32,9 +34,15 @@ func NewExternalClients(ctx context.Context) (*ExternalClients, error) { return nil, fmt.Errorf("announcement client: %w", err) } + user, err := newUserClient(ctx) + if err != nil { + return nil, fmt.Errorf("user client: %w", err) + } + return &ExternalClients{ Academic: academic, Announcement: announcement, + User: user, }, nil } @@ -72,6 +80,23 @@ func newAnnouncementClient(ctx context.Context) (*announcement_api.ClientWithRes ) } +func newUserClient(ctx context.Context) (*user_api.ClientWithResponses, error) { + url := os.Getenv("USER_API_URL") + if url == "" { + return nil, fmt.Errorf("USER_API_URL is required") + } + + authClient, err := newAuthHTTPClient(ctx, url) + if err != nil { + return nil, err + } + + return user_api.NewClientWithResponses( + url, + user_api.WithHTTPClient(authClient), + ) +} + // newAuthHTTPClient Google Cloud認証付きHTTPクライアントを作成 func newAuthHTTPClient(ctx context.Context, targetURL string) (*http.Client, error) { client, err := idtoken.NewClient(ctx, targetURL) From 8390076916ef544c7c8732cf0bf3b7fb5eac7f52 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Fri, 27 Mar 2026 14:20:06 +0900 Subject: [PATCH 2/3] Split handler implementation files --- internal/handler/course_registration.go | 85 +++++ internal/handler/course_registration_test.go | 56 +++ internal/handler/handler_test.go | 237 ------------- .../handler/personal_calendar_item_test.go | 58 ++++ internal/handler/room.go | 148 ++++++++ internal/handler/stub.go | 323 ------------------ internal/handler/subject_test.go | 73 ++++ internal/handler/test_helper_test.go | 39 +++ internal/handler/timetable_item.go | 92 +++++ internal/handler/user.go | 29 ++ internal/handler/user_test.go | 49 +++ 11 files changed, 629 insertions(+), 560 deletions(-) create mode 100644 internal/handler/course_registration.go create mode 100644 internal/handler/course_registration_test.go delete mode 100644 internal/handler/handler_test.go create mode 100644 internal/handler/personal_calendar_item_test.go create mode 100644 internal/handler/room.go delete mode 100644 internal/handler/stub.go create mode 100644 internal/handler/subject_test.go create mode 100644 internal/handler/test_helper_test.go create mode 100644 internal/handler/timetable_item.go create mode 100644 internal/handler/user.go create mode 100644 internal/handler/user_test.go diff --git a/internal/handler/course_registration.go b/internal/handler/course_registration.go new file mode 100644 index 0000000..99ac97b --- /dev/null +++ b/internal/handler/course_registration.go @@ -0,0 +1,85 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + api "github.com/fun-dotto/admin-bff-api/generated" + "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" + "github.com/fun-dotto/admin-bff-api/internal/middleware" +) + +// CourseRegistrationsV1List 履修情報を取得する +func (h *Handler) CourseRegistrationsV1List(c *gin.Context, params api.CourseRegistrationsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.CourseRegistrationsV1ListWithResponse(c.Request.Context(), &academic_api.CourseRegistrationsV1ListParams{ + UserId: params.UserId, + Year: params.Year, + Semesters: convertSlice[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), + }) + 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, gin.H{ + "registrations": response.JSON200.CourseRegistrations, + }) +} + +// CourseRegistrationsV1Create 履修情報を作成する +func (h *Handler) CourseRegistrationsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.CourseRegistrationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.CourseRegistrationsV1CreateWithResponse(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, gin.H{ + "registration": response.JSON201.CourseRegistration, + }) +} + +// CourseRegistrationsV1Delete 履修情報を削除する +func (h *Handler) CourseRegistrationsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.CourseRegistrationsV1DeleteWithResponse(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/course_registration_test.go b/internal/handler/course_registration_test.go new file mode 100644 index 0000000..7c5b8f7 --- /dev/null +++ b/internal/handler/course_registration_test.go @@ -0,0 +1,56 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" + "github.com/gin-gonic/gin" +) + +func TestCourseRegistrationsV1Create_ProxiesAcademicAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotMethod string + var gotPath string + var gotBody academic_api.CourseRegistrationRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"courseRegistration":{"id":"reg-1","userId":"user-1","subject":{"id":"subject-1","name":"Algorithms","faculties":[]}}}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest( + http.MethodPost, + "/v1/courseRegistrations", + bytes.NewBufferString(`{"subjectId":"subject-1","userId":"user-1"}`), + ) + c.Request.Header.Set("Content-Type", "application/json") + setAdminClaim(c) + + h.CourseRegistrationsV1Create(c) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated) + } + if gotMethod != http.MethodPost || gotPath != "/v1/courseRegistrations" { + t.Fatalf("method/path = %s %s", gotMethod, gotPath) + } + if gotBody.SubjectId != "subject-1" || gotBody.UserId != "user-1" { + t.Fatalf("unexpected upstream request body: %+v", gotBody) + } +} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go deleted file mode 100644 index a540e6e..0000000 --- a/internal/handler/handler_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package handler - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - firebaseauth "firebase.google.com/go/v4/auth" - api "github.com/fun-dotto/admin-bff-api/generated" - "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" - "github.com/fun-dotto/admin-bff-api/generated/external/announcement_api" - "github.com/fun-dotto/admin-bff-api/generated/external/user_api" - "github.com/fun-dotto/admin-bff-api/internal/middleware" - "github.com/gin-gonic/gin" -) - -func TestSubjectsV1List_UsesRenamedQueryParameters(t *testing.T) { - gin.SetMode(gin.TestMode) - - var gotQuery url.Values - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.Query() - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"subjects":[]}`)) - })) - defer server.Close() - - h := newTestHandler(t, server.URL) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodGet, "/v1/subjects", nil) - setAdminClaim(c) - - q := "math" - year := 2026 - grades := []api.DottoFoundationV1Grade{api.B1} - courses := []api.DottoFoundationV1Course{api.InformationSystem} - classes := []api.DottoFoundationV1Class{api.A} - classifications := []api.DottoFoundationV1SubjectClassification{api.Cultural} - semesters := []api.DottoFoundationV1CourseSemester{api.Q1, api.Q2} - requirementTypes := []api.DottoFoundationV1SubjectRequirementType{api.Optional} - categories := []api.DottoFoundationV1CulturalSubjectCategory{api.Society} - - h.SubjectsV1List(c, api.SubjectsV1ListParams{ - Q: &q, - Grades: &grades, - Courses: &courses, - Classes: &classes, - Classifications: &classifications, - Year: &year, - Semesters: &semesters, - RequirementTypes: &requirementTypes, - CulturalSubjectCategories: &categories, - }) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - if gotQuery.Get("grades") == "" || gotQuery.Get("grade") != "" { - t.Fatalf("query grades = %q, grade = %q", gotQuery.Get("grades"), gotQuery.Get("grade")) - } - if gotQuery.Get("classes") == "" || gotQuery.Get("class") != "" { - t.Fatalf("query classes = %q, class = %q", gotQuery.Get("classes"), gotQuery.Get("class")) - } - if gotQuery.Get("classifications") == "" || gotQuery.Get("classification") != "" { - t.Fatalf("query classifications = %q, classification = %q", gotQuery.Get("classifications"), gotQuery.Get("classification")) - } - if gotQuery.Get("semesters") == "" || gotQuery.Get("semester") != "" { - t.Fatalf("query semesters = %q, semester = %q", gotQuery.Get("semesters"), gotQuery.Get("semester")) - } - if gotQuery.Get("requirementTypes") == "" || gotQuery.Get("requirementType") != "" { - t.Fatalf("query requirementTypes = %q, requirementType = %q", gotQuery.Get("requirementTypes"), gotQuery.Get("requirementType")) - } - if gotQuery.Get("culturalSubjectCategories") == "" || gotQuery.Get("culturalSubjectCategory") != "" { - t.Fatalf("query culturalSubjectCategories = %q, culturalSubjectCategory = %q", gotQuery.Get("culturalSubjectCategories"), gotQuery.Get("culturalSubjectCategory")) - } -} - -func TestPersonalCalendarItemsV1List_ProxiesAcademicAPI(t *testing.T) { - gin.SetMode(gin.TestMode) - - wantDate := time.Date(2026, 3, 26, 9, 0, 0, 0, time.UTC) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - if got := query.Get("userId"); got != "user-1" { - t.Fatalf("userId = %q, want %q", got, "user-1") - } - if got := query.Get("dates"); got == "" { - t.Fatal("dates query parameter is empty") - } - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"personalCalendarItems":[{"date":"2026-03-26T09:00:00Z","slot":{"dayOfWeek":"Thursday","period":"first"},"timetableItem":{"id":"item-1","subject":{"id":"subject-1","name":"Algorithms"},"slot":{"dayOfWeek":"Thursday","period":"first"},"rooms":[]}}]}`)) - })) - defer server.Close() - - h := newTestHandler(t, server.URL) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodGet, "/v1/personalCalendarItems", nil) - setAdminClaim(c) - - h.PersonalCalendarItemsV1List(c, api.PersonalCalendarItemsV1ListParams{ - UserId: "user-1", - Dates: []time.Time{wantDate}, - }) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - - var body struct { - PersonalCalendarItems []struct { - Date string `json:"date"` - } `json:"personalCalendarItems"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("unmarshal response: %v", err) - } - if len(body.PersonalCalendarItems) != 1 || body.PersonalCalendarItems[0].Date != "2026-03-26T09:00:00Z" { - t.Fatalf("unexpected response body: %s", rec.Body.String()) - } -} - -func TestCourseRegistrationsV1Create_ProxiesAcademicAPI(t *testing.T) { - gin.SetMode(gin.TestMode) - - var gotMethod string - var gotPath string - var gotBody academic_api.CourseRegistrationRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotPath = r.URL.Path - - if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { - t.Fatalf("decode request body: %v", err) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"courseRegistration":{"id":"reg-1","userId":"user-1","subject":{"id":"subject-1","name":"Algorithms","faculties":[]}}}`)) - })) - defer server.Close() - - h := newTestHandler(t, server.URL) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest( - http.MethodPost, - "/v1/courseRegistrations", - bytes.NewBufferString(`{"subjectId":"subject-1","userId":"user-1"}`), - ) - c.Request.Header.Set("Content-Type", "application/json") - setAdminClaim(c) - - h.CourseRegistrationsV1Create(c) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated) - } - if gotMethod != http.MethodPost || gotPath != "/v1/courseRegistrations" { - t.Fatalf("method/path = %s %s", gotMethod, gotPath) - } - if gotBody.SubjectId != "subject-1" || gotBody.UserId != "user-1" { - t.Fatalf("unexpected upstream request body: %+v", gotBody) - } -} - -func TestUsersV1Detail_ProxiesUserAPI(t *testing.T) { - gin.SetMode(gin.TestMode) - - var gotPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"user":{"id":"user-1","name":"Jane Doe","email":"jane@example.com"}}`)) - })) - defer server.Close() - - h := newTestHandler(t, server.URL) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodGet, "/v1/users/user-1", nil) - setAdminClaim(c) - - h.UsersV1Detail(c, "user-1") - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - if gotPath != "/v1/users/user-1" { - t.Fatalf("path = %q, want %q", gotPath, "/v1/users/user-1") - } - - var body struct { - User struct { - Id string `json:"id"` - } `json:"user"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("unmarshal response: %v", err) - } - if body.User.Id != "user-1" { - t.Fatalf("unexpected response body: %s", rec.Body.String()) - } -} - -func newTestHandler(t *testing.T, baseURL string) *Handler { - t.Helper() - - academicClient, err := academic_api.NewClientWithResponses(baseURL) - if err != nil { - t.Fatalf("new academic client: %v", err) - } - announcementClient, err := announcement_api.NewClientWithResponses(baseURL) - if err != nil { - t.Fatalf("new announcement client: %v", err) - } - userClient, err := user_api.NewClientWithResponses(baseURL) - if err != nil { - t.Fatalf("new user client: %v", err) - } - - return NewHandler(academicClient, announcementClient, userClient) -} - -func setAdminClaim(c *gin.Context) { - c.Set(middleware.FirebaseTokenContextKey, &firebaseauth.Token{ - Claims: map[string]interface{}{ - "admin": true, - }, - }) -} diff --git a/internal/handler/personal_calendar_item_test.go b/internal/handler/personal_calendar_item_test.go new file mode 100644 index 0000000..7309b50 --- /dev/null +++ b/internal/handler/personal_calendar_item_test.go @@ -0,0 +1,58 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + api "github.com/fun-dotto/admin-bff-api/generated" + "github.com/gin-gonic/gin" +) + +func TestPersonalCalendarItemsV1List_ProxiesAcademicAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + wantDate := time.Date(2026, 3, 26, 9, 0, 0, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + if got := query.Get("userId"); got != "user-1" { + t.Fatalf("userId = %q, want %q", got, "user-1") + } + if got := query.Get("dates"); got == "" { + t.Fatal("dates query parameter is empty") + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"personalCalendarItems":[{"date":"2026-03-26T09:00:00Z","slot":{"dayOfWeek":"Thursday","period":"first"},"timetableItem":{"id":"item-1","subject":{"id":"subject-1","name":"Algorithms"},"slot":{"dayOfWeek":"Thursday","period":"first"},"rooms":[]}}]}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/personalCalendarItems", nil) + setAdminClaim(c) + + h.PersonalCalendarItemsV1List(c, api.PersonalCalendarItemsV1ListParams{ + UserId: "user-1", + Dates: []time.Time{wantDate}, + }) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body struct { + PersonalCalendarItems []struct { + Date string `json:"date"` + } `json:"personalCalendarItems"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(body.PersonalCalendarItems) != 1 || body.PersonalCalendarItems[0].Date != "2026-03-26T09:00:00Z" { + t.Fatalf("unexpected response body: %s", rec.Body.String()) + } +} diff --git a/internal/handler/room.go b/internal/handler/room.go new file mode 100644 index 0000000..eeb3fa0 --- /dev/null +++ b/internal/handler/room.go @@ -0,0 +1,148 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + api "github.com/fun-dotto/admin-bff-api/generated" + "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" + "github.com/fun-dotto/admin-bff-api/internal/middleware" +) + +// RoomsV1List 教室一覧を取得する +func (h *Handler) RoomsV1List(c *gin.Context, params api.RoomsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1ListWithResponse(c.Request.Context(), &academic_api.RoomsV1ListParams{ + Floor: convertSlicePtr[api.DottoFoundationV1Floor, academic_api.DottoFoundationV1Floor](params.Floor), + }) + 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) +} + +// RoomsV1Create 教室を作成する +func (h *Handler) RoomsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.RoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.RoomsV1CreateWithResponse(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) +} + +// RoomsV1Detail 教室を詳細取得する +func (h *Handler) RoomsV1Detail(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1DetailWithResponse(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) +} + +// RoomsV1Update 教室を更新する +func (h *Handler) RoomsV1Update(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.RoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.RoomsV1UpdateWithResponse(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) +} + +// RoomsV1Delete 教室を削除する +func (h *Handler) RoomsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.RoomsV1DeleteWithResponse(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) +} + +// ReservationsV1List 教室の予約一覧を取得する +func (h *Handler) ReservationsV1List(c *gin.Context, id string, params api.ReservationsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.ReservationsV1ListWithResponse(c.Request.Context(), id, &academic_api.ReservationsV1ListParams{ + From: params.From, + Until: params.Until, + }) + 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) +} diff --git a/internal/handler/stub.go b/internal/handler/stub.go deleted file mode 100644 index 3bfeb78..0000000 --- a/internal/handler/stub.go +++ /dev/null @@ -1,323 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - api "github.com/fun-dotto/admin-bff-api/generated" - "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" - "github.com/fun-dotto/admin-bff-api/internal/middleware" -) - -// CourseRegistrationsV1List 履修情報を取得する -func (h *Handler) CourseRegistrationsV1List(c *gin.Context, params api.CourseRegistrationsV1ListParams) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.academicClient.CourseRegistrationsV1ListWithResponse(c.Request.Context(), &academic_api.CourseRegistrationsV1ListParams{ - UserId: params.UserId, - Year: params.Year, - Semesters: convertSlice[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), - }) - 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, gin.H{ - "registrations": response.JSON200.CourseRegistrations, - }) -} - -// CourseRegistrationsV1Create 履修情報を作成する -func (h *Handler) CourseRegistrationsV1Create(c *gin.Context) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - var req academic_api.CourseRegistrationRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - response, err := h.academicClient.CourseRegistrationsV1CreateWithResponse(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, gin.H{ - "registration": response.JSON201.CourseRegistration, - }) -} - -// CourseRegistrationsV1Delete 履修情報を削除する -func (h *Handler) CourseRegistrationsV1Delete(c *gin.Context, id string) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.academicClient.CourseRegistrationsV1DeleteWithResponse(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) -} - -// RoomsV1List 教室一覧を取得する -func (h *Handler) RoomsV1List(c *gin.Context, params api.RoomsV1ListParams) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.academicClient.RoomsV1ListWithResponse(c.Request.Context(), &academic_api.RoomsV1ListParams{ - Floor: convertSlicePtr[api.DottoFoundationV1Floor, academic_api.DottoFoundationV1Floor](params.Floor), - }) - 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) -} - -// RoomsV1Create 教室を作成する -func (h *Handler) RoomsV1Create(c *gin.Context) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - var req academic_api.RoomRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - response, err := h.academicClient.RoomsV1CreateWithResponse(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) -} - -// RoomsV1Detail 教室を詳細取得する -func (h *Handler) RoomsV1Detail(c *gin.Context, id string) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.academicClient.RoomsV1DetailWithResponse(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) -} - -// RoomsV1Update 教室を更新する -func (h *Handler) RoomsV1Update(c *gin.Context, id string) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - var req academic_api.RoomRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - response, err := h.academicClient.RoomsV1UpdateWithResponse(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) -} - -// RoomsV1Delete 教室を削除する -func (h *Handler) RoomsV1Delete(c *gin.Context, id string) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.academicClient.RoomsV1DeleteWithResponse(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) -} - -// ReservationsV1List 教室の予約一覧を取得する -func (h *Handler) ReservationsV1List(c *gin.Context, id string, params api.ReservationsV1ListParams) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.academicClient.ReservationsV1ListWithResponse(c.Request.Context(), id, &academic_api.ReservationsV1ListParams{ - From: params.From, - Until: params.Until, - }) - 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) -} - -// TimetableItemsV1List 時間割を取得する -func (h *Handler) TimetableItemsV1List(c *gin.Context, params api.TimetableItemsV1ListParams) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.academicClient.TimetableItemsV1ListWithResponse(c.Request.Context(), &academic_api.TimetableItemsV1ListParams{ - Year: params.Year, - Semesters: convertSlice[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), - }) - 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, gin.H{ - "items": response.JSON200.TimetableItems, - }) -} - -// TimetableItemsV1Create 時間割に追加する -func (h *Handler) TimetableItemsV1Create(c *gin.Context) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - var req academic_api.TimetableItemRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - response, err := h.academicClient.TimetableItemsV1CreateWithResponse(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, gin.H{ - "item": response.JSON201.TimetableItem, - }) -} - -// TimetableItemsV1Delete 時間割を削除する -func (h *Handler) TimetableItemsV1Delete(c *gin.Context, id string) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.academicClient.TimetableItemsV1DeleteWithResponse(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) -} - -// UsersV1Detail ユーザーを取得する -func (h *Handler) UsersV1Detail(c *gin.Context, id string) { - if !middleware.RequireAnyClaim(c, "admin", "developer") { - return - } - - response, err := h.userClient.UsersV1DetailWithResponse(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) -} - -func convertSlice[From, To ~string](src []From) []To { - result := make([]To, len(src)) - for i, v := range src { - result[i] = To(v) - } - return result -} diff --git a/internal/handler/subject_test.go b/internal/handler/subject_test.go new file mode 100644 index 0000000..c38ecec --- /dev/null +++ b/internal/handler/subject_test.go @@ -0,0 +1,73 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + api "github.com/fun-dotto/admin-bff-api/generated" + "github.com/gin-gonic/gin" +) + +func TestSubjectsV1List_UsesRenamedQueryParameters(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"subjects":[]}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/subjects", nil) + setAdminClaim(c) + + q := "math" + year := 2026 + grades := []api.DottoFoundationV1Grade{api.B1} + courses := []api.DottoFoundationV1Course{api.InformationSystem} + classes := []api.DottoFoundationV1Class{api.A} + classifications := []api.DottoFoundationV1SubjectClassification{api.Cultural} + semesters := []api.DottoFoundationV1CourseSemester{api.Q1, api.Q2} + requirementTypes := []api.DottoFoundationV1SubjectRequirementType{api.Optional} + categories := []api.DottoFoundationV1CulturalSubjectCategory{api.Society} + + h.SubjectsV1List(c, api.SubjectsV1ListParams{ + Q: &q, + Grades: &grades, + Courses: &courses, + Classes: &classes, + Classifications: &classifications, + Year: &year, + Semesters: &semesters, + RequirementTypes: &requirementTypes, + CulturalSubjectCategories: &categories, + }) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if gotQuery.Get("grades") == "" || gotQuery.Get("grade") != "" { + t.Fatalf("query grades = %q, grade = %q", gotQuery.Get("grades"), gotQuery.Get("grade")) + } + if gotQuery.Get("classes") == "" || gotQuery.Get("class") != "" { + t.Fatalf("query classes = %q, class = %q", gotQuery.Get("classes"), gotQuery.Get("class")) + } + if gotQuery.Get("classifications") == "" || gotQuery.Get("classification") != "" { + t.Fatalf("query classifications = %q, classification = %q", gotQuery.Get("classifications"), gotQuery.Get("classification")) + } + if gotQuery.Get("semesters") == "" || gotQuery.Get("semester") != "" { + t.Fatalf("query semesters = %q, semester = %q", gotQuery.Get("semesters"), gotQuery.Get("semester")) + } + if gotQuery.Get("requirementTypes") == "" || gotQuery.Get("requirementType") != "" { + t.Fatalf("query requirementTypes = %q, requirementType = %q", gotQuery.Get("requirementTypes"), gotQuery.Get("requirementType")) + } + if gotQuery.Get("culturalSubjectCategories") == "" || gotQuery.Get("culturalSubjectCategory") != "" { + t.Fatalf("query culturalSubjectCategories = %q, culturalSubjectCategory = %q", gotQuery.Get("culturalSubjectCategories"), gotQuery.Get("culturalSubjectCategory")) + } +} diff --git a/internal/handler/test_helper_test.go b/internal/handler/test_helper_test.go new file mode 100644 index 0000000..fb1f6c2 --- /dev/null +++ b/internal/handler/test_helper_test.go @@ -0,0 +1,39 @@ +package handler + +import ( + "testing" + + firebaseauth "firebase.google.com/go/v4/auth" + "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" + "github.com/fun-dotto/admin-bff-api/generated/external/announcement_api" + "github.com/fun-dotto/admin-bff-api/generated/external/user_api" + "github.com/fun-dotto/admin-bff-api/internal/middleware" + "github.com/gin-gonic/gin" +) + +func newTestHandler(t *testing.T, baseURL string) *Handler { + t.Helper() + + academicClient, err := academic_api.NewClientWithResponses(baseURL) + if err != nil { + t.Fatalf("new academic client: %v", err) + } + announcementClient, err := announcement_api.NewClientWithResponses(baseURL) + if err != nil { + t.Fatalf("new announcement client: %v", err) + } + userClient, err := user_api.NewClientWithResponses(baseURL) + if err != nil { + t.Fatalf("new user client: %v", err) + } + + return NewHandler(academicClient, announcementClient, userClient) +} + +func setAdminClaim(c *gin.Context) { + c.Set(middleware.FirebaseTokenContextKey, &firebaseauth.Token{ + Claims: map[string]interface{}{ + "admin": true, + }, + }) +} diff --git a/internal/handler/timetable_item.go b/internal/handler/timetable_item.go new file mode 100644 index 0000000..ee873d0 --- /dev/null +++ b/internal/handler/timetable_item.go @@ -0,0 +1,92 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + api "github.com/fun-dotto/admin-bff-api/generated" + "github.com/fun-dotto/admin-bff-api/generated/external/academic_api" + "github.com/fun-dotto/admin-bff-api/internal/middleware" +) + +// TimetableItemsV1List 時間割を取得する +func (h *Handler) TimetableItemsV1List(c *gin.Context, params api.TimetableItemsV1ListParams) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.TimetableItemsV1ListWithResponse(c.Request.Context(), &academic_api.TimetableItemsV1ListParams{ + Year: params.Year, + Semesters: convertSlice[api.DottoFoundationV1CourseSemester, academic_api.DottoFoundationV1CourseSemester](params.Semesters), + }) + 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, gin.H{ + "items": response.JSON200.TimetableItems, + }) +} + +// TimetableItemsV1Create 時間割に追加する +func (h *Handler) TimetableItemsV1Create(c *gin.Context) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + var req academic_api.TimetableItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.academicClient.TimetableItemsV1CreateWithResponse(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, gin.H{ + "item": response.JSON201.TimetableItem, + }) +} + +// TimetableItemsV1Delete 時間割を削除する +func (h *Handler) TimetableItemsV1Delete(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.academicClient.TimetableItemsV1DeleteWithResponse(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) +} + +func convertSlice[From, To ~string](src []From) []To { + result := make([]To, len(src)) + for i, v := range src { + result[i] = To(v) + } + return result +} diff --git a/internal/handler/user.go b/internal/handler/user.go new file mode 100644 index 0000000..6109ae1 --- /dev/null +++ b/internal/handler/user.go @@ -0,0 +1,29 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/fun-dotto/admin-bff-api/internal/middleware" +) + +// UsersV1Detail ユーザーを取得する +func (h *Handler) UsersV1Detail(c *gin.Context, id string) { + if !middleware.RequireAnyClaim(c, "admin", "developer") { + return + } + + response, err := h.userClient.UsersV1DetailWithResponse(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) +} diff --git a/internal/handler/user_test.go b/internal/handler/user_test.go new file mode 100644 index 0000000..231d8b9 --- /dev/null +++ b/internal/handler/user_test.go @@ -0,0 +1,49 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestUsersV1Detail_ProxiesUserAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"user":{"id":"user-1","name":"Jane Doe","email":"jane@example.com"}}`)) + })) + defer server.Close() + + h := newTestHandler(t, server.URL) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/users/user-1", nil) + setAdminClaim(c) + + h.UsersV1Detail(c, "user-1") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if gotPath != "/v1/users/user-1" { + t.Fatalf("path = %q, want %q", gotPath, "/v1/users/user-1") + } + + var body struct { + User struct { + Id string `json:"id"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.User.Id != "user-1" { + t.Fatalf("unexpected response body: %s", rec.Body.String()) + } +} From da9564b299a0ff60895eed38597e6320e7adfa30 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Fri, 27 Mar 2026 14:24:50 +0900 Subject: [PATCH 3/3] Move Firebase auth into OpenAPI validator --- cmd/server/main.go | 17 +++- internal/middleware/firebase_auth.go | 139 +++++++++++++++++++++------ 2 files changed, 126 insertions(+), 30 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 038ab01..e28d02a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,9 +10,10 @@ import ( "github.com/fun-dotto/admin-bff-api/internal/infrastructure" "github.com/fun-dotto/admin-bff-api/internal/middleware" "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" "github.com/gin-gonic/gin" "github.com/joho/godotenv" - oapimiddleware "github.com/oapi-codegen/gin-middleware" + "github.com/oapi-codegen/gin-middleware" ) func main() { @@ -39,8 +40,18 @@ func main() { router := gin.Default() - router.Use(oapimiddleware.OapiRequestValidator(spec)) - router.Use(middleware.FirebaseAuth(authClient)) + router.Use(ginmiddleware.OapiRequestValidatorWithOptions(spec, &ginmiddleware.Options{ + ErrorHandler: func(c *gin.Context, message string, statusCode int) { + if authStatusCode, authMessage, ok := middleware.GetAuthenticationError(c); ok { + c.AbortWithStatusJSON(authStatusCode, gin.H{"error": authMessage}) + return + } + c.AbortWithStatusJSON(statusCode, gin.H{"error": message}) + }, + Options: openapi3filter.Options{ + AuthenticationFunc: middleware.FirebaseAuthenticationFunc(authClient), + }, + })) clients, err := infrastructure.NewExternalClients(ctx) if err != nil { diff --git a/internal/middleware/firebase_auth.go b/internal/middleware/firebase_auth.go index 649c79b..929d335 100644 --- a/internal/middleware/firebase_auth.go +++ b/internal/middleware/firebase_auth.go @@ -2,60 +2,145 @@ package middleware import ( "context" + "errors" "net/http" "strings" "firebase.google.com/go/v4/auth" + "github.com/getkin/kin-openapi/openapi3filter" "github.com/gin-gonic/gin" + ginmiddleware "github.com/oapi-codegen/gin-middleware" ) type firebaseTokenKey struct{} +type authErrorStatusKey struct{} +type authErrorMessageKey struct{} // FirebaseTokenContextKey は Gin の context および context.Context に // Firebase ID トークンの検証結果を格納するキーです。 var FirebaseTokenContextKey = firebaseTokenKey{} +var ( + authenticationErrorStatusKey = authErrorStatusKey{} + authenticationErrorMessageKey = authErrorMessageKey{} +) + +type AuthenticationError struct { + StatusCode int + Message string +} + +func (e *AuthenticationError) Error() string { + return e.Message +} + +// FirebaseAuthenticationFunc は OpenAPI validator 向けの AuthenticationFunc を返します。 +// 認証に成功した場合は検証済みトークンを Gin / request context に格納します。 +func FirebaseAuthenticationFunc(authClient *auth.Client) openapi3filter.AuthenticationFunc { + return func(ctx context.Context, _ *openapi3filter.AuthenticationInput) error { + ginCtx := ginmiddleware.GetGinContext(ctx) + if ginCtx == nil { + return &AuthenticationError{ + StatusCode: http.StatusUnauthorized, + Message: "Authentication context is unavailable", + } + } + + token, err := verifyFirebaseToken(ginCtx.GetHeader("Authorization"), ginCtx.Request.Context(), authClient) + if err != nil { + var authErr *AuthenticationError + if errors.As(err, &authErr) { + ginCtx.Set(authenticationErrorStatusKey, authErr.StatusCode) + ginCtx.Set(authenticationErrorMessageKey, authErr.Message) + } + return err + } + + setFirebaseToken(ginCtx, token) + return nil + } +} + // FirebaseAuth は Authorization: Bearer を検証する Gin ミドルウェアです。 // 検証に成功すると、デコードされたトークン(*auth.Token)を context に格納して次のハンドラに渡します。 func FirebaseAuth(authClient *auth.Client) gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "Authorization header is required", - }) + token, err := verifyFirebaseToken(c.GetHeader("Authorization"), c.Request.Context(), authClient) + if err != nil { + var authErr *AuthenticationError + if errors.As(err, &authErr) { + c.AbortWithStatusJSON(authErr.StatusCode, gin.H{"error": authErr.Message}) + return + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) return } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "Authorization header must be Bearer ", - }) - return + setFirebaseToken(c, token) + c.Next() + } +} + +func verifyFirebaseToken(authHeader string, ctx context.Context, authClient *auth.Client) (*auth.Token, error) { + if authHeader == "" { + return nil, &AuthenticationError{ + StatusCode: http.StatusUnauthorized, + Message: "Authorization header is required", } + } - idToken := strings.TrimSpace(parts[1]) - if idToken == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "ID token is required", - }) - return + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return nil, &AuthenticationError{ + StatusCode: http.StatusUnauthorized, + Message: "Authorization header must be Bearer ", } + } - ctx := c.Request.Context() - token, err := authClient.VerifyIDToken(ctx, idToken) - if err != nil { - status, message := authErrorResponse(err) - c.AbortWithStatusJSON(status, gin.H{"error": message}) - return + idToken := strings.TrimSpace(parts[1]) + if idToken == "" { + return nil, &AuthenticationError{ + StatusCode: http.StatusUnauthorized, + Message: "ID token is required", } + } - c.Set(FirebaseTokenContextKey, token) - ctx = context.WithValue(ctx, FirebaseTokenContextKey, token) - c.Request = c.Request.WithContext(ctx) - c.Next() + token, err := authClient.VerifyIDToken(ctx, idToken) + if err != nil { + status, message := authErrorResponse(err) + return nil, &AuthenticationError{ + StatusCode: status, + Message: message, + } } + + return token, nil +} + +func setFirebaseToken(c *gin.Context, token *auth.Token) { + ctx := context.WithValue(c.Request.Context(), FirebaseTokenContextKey, token) + c.Set(FirebaseTokenContextKey, token) + c.Request = c.Request.WithContext(ctx) +} + +// GetAuthenticationError は validator の AuthenticationFunc が格納した認証失敗情報を返します。 +func GetAuthenticationError(c *gin.Context) (int, string, bool) { + status, statusExists := c.Get(authenticationErrorStatusKey) + message, messageExists := c.Get(authenticationErrorMessageKey) + if !statusExists || !messageExists { + return 0, "", false + } + + statusCode, ok := status.(int) + if !ok { + return 0, "", false + } + errorMessage, ok := message.(string) + if !ok { + return 0, "", false + } + + return statusCode, errorMessage, true } // authErrorResponse は Firebase Auth の検証エラーから HTTP ステータスとメッセージを返します。