Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions cmd/academic-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"log"
"net/http"
"time"

api "github.com/fun-dotto/server/gen/academic"
Expand Down Expand Up @@ -63,6 +64,7 @@ func main() {
makeupClassRepo := repository.NewMakeupClassRepository(conn)
roomChangeRepo := repository.NewRoomChangeRepository(conn)
facultyRoomRepo := repository.NewFacultyRoomRepository(conn)
userRepo := repository.NewUserRepository(conn)
// Events
substituteDayMap, err := event.LoadSubstituteDayMap(assets.EventsJSON)
if err != nil {
Expand All @@ -85,15 +87,54 @@ func main() {
makeupClassSvc := service.NewMakeupClassService(makeupClassRepo)
roomChangeSvc := service.NewRoomChangeService(roomChangeRepo)
facultyRoomSvc := service.NewFacultyRoomService(facultyRoomRepo)
userSvc := service.NewUserService(userRepo)

// Handler + Router
h := handler.NewHandler(subjectSvc, facultySvc, roomSvc, timetableItemSvc, courseRegistrationSvc, personalCalendarItemSvc, cancelledClassSvc, makeupClassSvc, roomChangeSvc, facultyRoomSvc)
strictHandler := api.NewStrictHandler(h, []api.StrictMiddlewareFunc{
h := handler.NewHandler(subjectSvc, facultySvc, roomSvc, timetableItemSvc, courseRegistrationSvc, personalCalendarItemSvc, cancelledClassSvc, makeupClassSvc, roomChangeSvc, facultyRoomSvc, userSvc)
strictHandler := api.NewStrictHandlerWithOptions(h, []api.StrictMiddlewareFunc{
middleware.DeadlineErrorMapper(),
}, api.StrictGinServerOptions{
RequestErrorHandlerFunc: requestErrorHandler,
HandlerErrorFunc: handlerErrorHandler,
ResponseErrorHandlerFunc: responseErrorHandler,
})
api.RegisterHandlers(router, strictHandler)

if err := server.Run(router, ":8080"); err != nil {
log.Fatalf("Server exited with error: %v", err)
}
}

// oapi-codegen が生成する strict handler の既定のエラーハンドラは err.Error() を
// そのままレスポンス本文に載せるため、SQL 文やドライバのエラーメッセージといった
// 内部実装の詳細がクライアントへ露出し得る。本文は固定文言に差し替え、
// 詳細はサーバー側のログにのみ出力する。

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この変更は必要ないかも?

const (
badRequestMessage = "invalid request"
internalErrorMessage = "internal server error"
)

// requestErrorHandler はリクエストのパース・デコードに失敗した場合に 400 を返す。
func requestErrorHandler(c *gin.Context, err error) {
logStrictError(c, "request error", err)
c.JSON(http.StatusBadRequest, gin.H{"msg": badRequestMessage})
}

// handlerErrorHandler はハンドラ(および strict middleware)が non-nil error を
// 返した場合に 500 を返す。
func handlerErrorHandler(c *gin.Context, err error) {
logStrictError(c, "handler error", err)
c.JSON(http.StatusInternalServerError, gin.H{"msg": internalErrorMessage})
}

// responseErrorHandler はレスポンスのシリアライズに失敗した場合、あるいは想定外の
// レスポンス型が返された場合に 500 を返す。
func responseErrorHandler(c *gin.Context, err error) {
logStrictError(c, "response error", err)
c.JSON(http.StatusInternalServerError, gin.H{"msg": internalErrorMessage})
}

func logStrictError(c *gin.Context, kind string, err error) {
log.Printf("%s: %s %s: %v", kind, c.Request.Method, c.Request.URL.Path, err)
}
23 changes: 23 additions & 0 deletions gen/academic/api.gen.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

スキーマってTypeSpec側でアップデートしたんだっけ?

@kantacky kantacky Jul 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

そうか、APIの変更が必要なのか
してないと思う

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions internal/modules/academic/domain/subject.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ type SubjectListFilter struct {
Semester []CourseSemester
RequirementType []SubjectRequirementType
CulturalSubjectCategory []CulturalSubjectCategory

SortByUserAttribute bool

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

フィルターの型にソートのパラメータを付与するんじゃなくて、ソート用の型を作って分けてもいいんじゃないかな?

SortCourse *CourseType
SortGrade *Grade
}
7 changes: 7 additions & 0 deletions internal/modules/academic/domain/user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package domain

Comment thread
KurenNagata marked this conversation as resolved.
type User struct {
ID string
Course *CourseType
Grade *Grade
}
7 changes: 7 additions & 0 deletions internal/modules/academic/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ type facultyRoomService interface {
Delete(ctx context.Context, id string) error
}

type userService interface {
FindByID(ctx context.Context, id string) (domain.User, bool, error)
Comment thread
KurenNagata marked this conversation as resolved.
}

type Handler struct {
subjectSvc subjectService
facultySvc facultyService
Expand All @@ -87,6 +91,7 @@ type Handler struct {
makeupClassSvc makeupClassService
roomChangeSvc roomChangeService
facultyRoomSvc facultyRoomService
userSvc userService
}

func NewHandler(
Expand All @@ -100,6 +105,7 @@ func NewHandler(
makeupClassSvc makeupClassService,
roomChangeSvc roomChangeService,
facultyRoomSvc facultyRoomService,
userSvc userService,
) *Handler {
return &Handler{
subjectSvc: subjectSvc,
Expand All @@ -112,5 +118,6 @@ func NewHandler(
makeupClassSvc: makeupClassSvc,
roomChangeSvc: roomChangeSvc,
facultyRoomSvc: facultyRoomSvc,
userSvc: userSvc,
}
}
12 changes: 12 additions & 0 deletions internal/modules/academic/handler/subject_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import (
func (h *Handler) SubjectsV1List(ctx context.Context, request api.SubjectsV1ListRequestObject) (api.SubjectsV1ListResponseObject, error) {
filter := buildSubjectListFilter(request.Params)

if request.Params.UserId != nil {
user, found, err := h.userSvc.FindByID(ctx, *request.Params.UserId)
if err != nil {
return nil, err
}
if found {
filter.SortByUserAttribute = true
filter.SortCourse = user.Course
filter.SortGrade = user.Grade
}
}

subjects, err := h.subjectSvc.List(ctx, filter)
if err != nil {
return nil, err
Expand Down
167 changes: 167 additions & 0 deletions internal/modules/academic/handler/subject_list_test.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

テストは一旦なくてもいいかも

Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package handler

import (
"context"
"errors"
"testing"

api "github.com/fun-dotto/server/gen/academic"
"github.com/fun-dotto/server/internal/modules/academic/domain"
)

// fakeSubjectService は List に渡された filter を記録するだけのスタブ。
type fakeSubjectService struct {
gotFilter domain.SubjectListFilter
called bool
}

func (f *fakeSubjectService) List(_ context.Context, filter domain.SubjectListFilter) ([]domain.Subject, error) {
f.gotFilter = filter
f.called = true
return nil, nil
}

func (f *fakeSubjectService) GetByID(context.Context, string) (domain.Subject, error) {
return domain.Subject{}, nil
}

func (f *fakeSubjectService) Delete(context.Context, string) error { return nil }

func (f *fakeSubjectService) GetSyllabus(context.Context, string) (domain.Syllabus, error) {
return domain.Syllabus{}, nil
}

type fakeUserService struct {
user domain.User
found bool
err error
gotID string
called bool
}

func (f *fakeUserService) FindByID(_ context.Context, id string) (domain.User, bool, error) {
f.gotID = id
f.called = true
return f.user, f.found, f.err
}

func TestSubjectsV1List_UserAttributeSort(t *testing.T) {
course := domain.CourseTypeComplexSystem
grade := domain.GradeB3
userID := "firebase-uid-001"

tests := []struct {
name string
// 入力
paramUserID *string
userSvc *fakeUserService
// 期待値
wantUserSvcCalled bool
wantSortEnabled bool
wantSortCourse *domain.CourseType
wantSortGrade *domain.Grade
wantErr bool
}{
{
name: "userId 未指定ならソートせず UserService も呼ばない",
paramUserID: nil,
userSvc: &fakeUserService{},
wantUserSvcCalled: false,
wantSortEnabled: false,
},
{
name: "userId 指定かつユーザーが存在すればソート条件が立つ",
paramUserID: &userID,
userSvc: &fakeUserService{
user: domain.User{ID: userID, Course: &course, Grade: &grade},
found: true,
},
wantUserSvcCalled: true,
wantSortEnabled: true,
wantSortCourse: &course,
wantSortGrade: &grade,
},
{
name: "ユーザーが存在しなければエラーにせず現行順にフォールバックする",
paramUserID: &userID,
userSvc: &fakeUserService{found: false},
wantUserSvcCalled: true,
wantSortEnabled: false,
},
{
name: "コース・学年が未設定のユーザーでもソートは有効になる",
paramUserID: &userID,
userSvc: &fakeUserService{
user: domain.User{ID: userID},
found: true,
},
wantUserSvcCalled: true,
wantSortEnabled: true,
wantSortCourse: nil,
wantSortGrade: nil,
},
{
name: "UserService がエラーを返せばハンドラもエラーを返す",
paramUserID: &userID,
userSvc: &fakeUserService{err: errors.New("db is down")},
wantUserSvcCalled: true,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
subjectSvc := &fakeSubjectService{}
h := &Handler{subjectSvc: subjectSvc, userSvc: tt.userSvc}

_, err := h.SubjectsV1List(context.Background(), api.SubjectsV1ListRequestObject{
Params: api.SubjectsV1ListParams{UserId: tt.paramUserID},
})

if tt.wantErr {
if err == nil {
t.Fatal("エラーを期待したが nil だった")
}
if subjectSvc.called {
t.Error("エラー時は SubjectService.List を呼ぶべきではない")
}
return
}
if err != nil {
t.Fatalf("予期しないエラー: %v", err)
}

if tt.userSvc.called != tt.wantUserSvcCalled {
t.Errorf("UserService.FindByID called = %v, want %v", tt.userSvc.called, tt.wantUserSvcCalled)
}
if tt.wantUserSvcCalled && tt.userSvc.gotID != userID {
t.Errorf("FindByID に渡された id = %q, want %q", tt.userSvc.gotID, userID)
}

got := subjectSvc.gotFilter
if got.SortByUserAttribute != tt.wantSortEnabled {
t.Errorf("SortByUserAttribute = %v, want %v", got.SortByUserAttribute, tt.wantSortEnabled)
}
if !equalCourse(got.SortCourse, tt.wantSortCourse) {
t.Errorf("SortCourse = %v, want %v", got.SortCourse, tt.wantSortCourse)
}
if !equalGrade(got.SortGrade, tt.wantSortGrade) {
t.Errorf("SortGrade = %v, want %v", got.SortGrade, tt.wantSortGrade)
}
})
}
}

func equalCourse(a, b *domain.CourseType) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}

func equalGrade(a, b *domain.Grade) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
13 changes: 13 additions & 0 deletions internal/modules/academic/repository/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,16 @@ func timetableItemFromDomain(d domain.TimetableItem) model.TimetableItem {
}
return m
}

func userToDomain(m model.User) domain.User {
d := domain.User{ID: m.ID}
if m.Course != nil {
c := domain.CourseType(*m.Course)
d.Course = &c
}
if m.Grade != nil {
g := domain.Grade(*m.Grade)
d.Grade = &g
}
return d
}
Loading