diff --git a/cmd/kuriweb/http.go b/cmd/kuriweb/http.go index 913cf7f..8e8b2eb 100644 --- a/cmd/kuriweb/http.go +++ b/cmd/kuriweb/http.go @@ -3,6 +3,7 @@ package main import ( "log/slog" "net/http" + "strings" "time" "git.loyso.art/frx/kurious/assets/kurious" @@ -13,6 +14,23 @@ import ( "github.com/gorilla/mux" ) +const ( + pathParamLearningType = "learning_type" + pathParamThematicType = "thematic_type" +) + +func makePathTemplate(params ...string) string { + var sb strings.Builder + for _, param := range params { + sb.WriteRune('/') + sb.WriteRune('{') + sb.WriteString(param) + sb.WriteRune('}') + } + + return sb.String() +} + func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server { router := mux.NewRouter() @@ -23,8 +41,12 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server router.HandleFunc("/updatedesc", coursesAPI.UdpateDescription).Methods(http.MethodPost) coursesRouter := router.PathPrefix("/courses").Subrouter() coursesRouter.HandleFunc("/", coursesAPI.List).Methods(http.MethodGet) + coursesListLearningOnlyPath := makePathTemplate(pathParamLearningType) + coursesRouter.HandleFunc(coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet) + coursesListFullPath := makePathTemplate(pathParamLearningType, pathParamThematicType) + coursesRouter.HandleFunc(coursesListFullPath, coursesAPI.List).Methods(http.MethodGet) - courseRouter := coursesRouter.PathPrefix("/{course_id}").Subrouter() + courseRouter := router.PathPrefix("/course").PathPrefix("/{course_id}").Subrouter() courseRouter.HandleFunc("/", coursesAPI.Get).Methods(http.MethodGet) courseRouter.HandleFunc("/short", coursesAPI.GetShort).Methods(http.MethodGet) courseRouter.HandleFunc("/editdesc", coursesAPI.RenderEditDescription).Methods(http.MethodGet) diff --git a/cmd/kuriweb/main.go b/cmd/kuriweb/main.go index 9491caf..51b1cac 100644 --- a/cmd/kuriweb/main.go +++ b/cmd/kuriweb/main.go @@ -16,6 +16,7 @@ import ( "git.loyso.art/frx/kurious/internal/kurious/adapters" xhttp "git.loyso.art/frx/kurious/internal/kurious/ports/http" "git.loyso.art/frx/kurious/internal/kurious/service" + "golang.org/x/sync/errgroup" ) @@ -88,26 +89,30 @@ func app(ctx context.Context) error { slog.String("addr", httpServer.Addr), ) - err := httpServer.ListenAndServe() - if err != nil { + if err := httpServer.ListenAndServe(); err != nil { if !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("listening http: %w", err) } } + return nil }) + eg.Go(func() error { <-egctx.Done() xcontext.LogInfo(ctx, log, "trying to shutdown http") + sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*10) defer sdcancel() + err := httpServer.Shutdown(sdctx) if err != nil { return fmt.Errorf("shutting down the server: %w", err) } xcontext.LogInfo(ctx, log, "server closed successfuly") + return nil }) diff --git a/internal/kurious/adapters/ydb_course_repository.go b/internal/kurious/adapters/ydb_course_repository.go index 3d0254e..51dae0a 100644 --- a/internal/kurious/adapters/ydb_course_repository.go +++ b/internal/kurious/adapters/ydb_course_repository.go @@ -179,6 +179,8 @@ func (r *ydbCourseRepository) List( return result, fmt.Errorf("rendering query params: %w", err) } + xcontext.LogInfo(ctx, r.log, "query prepared", slog.String("query", query), slog.String("args", tableParamOptsToString(opts...))) + courses := make([]domain.Course, 0, 1_000) readTx := table.TxControl( table.BeginTx( @@ -192,7 +194,7 @@ func (r *ydbCourseRepository) List( func(ctx context.Context, s table.Session) error { start := time.Now() defer func() { - since := time.Since(start) + since := time.Since(start).Truncate(time.Millisecond) xcontext.LogInfo( ctx, r.log, "executed query", @@ -242,6 +244,156 @@ func (r *ydbCourseRepository) List( return result, err } +func (r *ydbCourseRepository) ListLearningTypes( + ctx context.Context, +) (result domain.ListLearningTypeResult, err error) { + const queryName = "list_learning_type" + const querySelect = `SELECT DISTINCT learning_type FROM courses;` + + readTx := table.TxControl( + table.BeginTx( + table.WithOnlineReadOnly(), + ), + table.CommitTx(), + ) + + err = r.db.Table().Do( + ctx, + func(ctx context.Context, s table.Session) error { + start := time.Now() + defer func() { + since := time.Since(start).Truncate(time.Millisecond) + xcontext.LogInfo( + ctx, r.log, + "executed query", + slog.String("name", queryName), + slog.Duration("elapsed", since), + ) + }() + + _, res, err := s.Execute( + ctx, readTx, querySelect, table.NewQueryParameters(), + options.WithCollectStatsModeNone(), + ) + if err != nil { + return fmt.Errorf("executing query: %w", err) + } + if !res.NextResultSet(ctx) || !res.HasNextRow() { + return nil + } + + for res.NextRow() { + var learningTypeID string + if err = res.Scan(&learningTypeID); err != nil { + return fmt.Errorf("scanning row: %w", err) + } + + result.LearningTypeIDs = append(result.LearningTypeIDs, learningTypeID) + } + if err = res.Err(); err != nil { + return err + } + + xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(result.LearningTypeIDs))) + + return nil + }, + table.WithIdempotent(), + ) + if err != nil { + return result, err + } + + return result, nil +} + +func (r *ydbCourseRepository) ListCourseThematics( + ctx context.Context, + params domain.ListCourseThematicsParams, +) (result domain.ListCourseThematicsResult, err error) { + const queryName = "list_course_thematics" + + qtParams := queryTemplateParams{ + Fields: "DISTINCT course_thematic", + Table: "courses", + Declares: []queryTemplateDeclaration{}, + Conditions: []string{}, + } + + learningTypeValue := types.TextValue(params.LearningTypeID) + d := queryTemplateDeclaration{ + Name: "course_thematic", + Type: learningTypeValue.Type().String(), + } + qtParams.Declares = append(qtParams.Declares, d) + qtParams.Conditions = append(qtParams.Conditions, d.Name+"="+d.Arg()) + + opts := []table.ParameterOption{ + table.ValueParam(d.Arg(), learningTypeValue), + } + + query, err := qtParams.render() + if err != nil { + return result, fmt.Errorf("rendering query params: %w", err) + } + + readTx := table.TxControl( + table.BeginTx( + table.WithOnlineReadOnly(), + ), + table.CommitTx(), + ) + + err = r.db.Table().Do( + ctx, + func(ctx context.Context, s table.Session) error { + start := time.Now() + defer func() { + since := time.Since(start).Truncate(time.Millisecond) + xcontext.LogInfo( + ctx, r.log, + "executed query", + slog.String("name", queryName), + slog.Duration("elapsed", since), + ) + }() + + _, res, err := s.Execute( + ctx, readTx, query, table.NewQueryParameters(opts...), + options.WithCollectStatsModeNone(), + ) + if err != nil { + return fmt.Errorf("executing query: %w", err) + } + if !res.NextResultSet(ctx) || !res.HasNextRow() { + return nil + } + + for res.NextRow() { + var courseThematicID string + if err = res.Scan(&courseThematicID); err != nil { + return fmt.Errorf("scanning row: %w", err) + } + + result.CourseThematicIDs = append(result.CourseThematicIDs, courseThematicID) + } + if err = res.Err(); err != nil { + return err + } + + xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(result.CourseThematicIDs))) + + return nil + }, + table.WithIdempotent(), + ) + if err != nil { + return result, err + } + + return result, nil +} + func (r *ydbCourseRepository) Get( ctx context.Context, id string, @@ -735,15 +887,16 @@ func (p queryTemplateParams) render() (string, error) { const queryTemplateSelect = `{{ range .Declares }}DECLARE ${{.Name}} AS {{.Type}};{{end}} SELECT {{.Fields}} FROM {{.Table}} -WHERE {{ range .Conditions }}{{.}}{{end}} +WHERE 1=1 {{ range .Conditions }} AND {{.}} {{ end }} {{.Suffix}}` var querySelect = template.Must(template.New("").Parse(queryTemplateSelect)) -// func tableParamOptsToString(in ...table.ParameterOption) string { -// var sb strings.Builder -// for _, opt := range in { -// sb.WriteString(opt.Name() + "(" + opt.Value().Type().String() + ");") -// } -// return sb.String() -// } +func tableParamOptsToString(in ...table.ParameterOption) string { + var sb strings.Builder + for _, opt := range in { + sb.WriteString(opt.Name() + ":" + opt.Value().Yql() + ";") + // sb.WriteString(opt.Name() + " (" + opt.Value().Type().String() + "); ") + } + return sb.String() +} diff --git a/internal/kurious/app/app.go b/internal/kurious/app/app.go index bd44758..4a0941e 100644 --- a/internal/kurious/app/app.go +++ b/internal/kurious/app/app.go @@ -13,8 +13,10 @@ type Commands struct { } type Queries struct { - GetCourse query.GetCourseHandler - ListCourses query.ListCourseHandler + GetCourse query.GetCourseHandler + ListCourses query.ListCourseHandler + ListLearningTypes query.ListLearningTypesHandler + ListCourseThematics query.ListCourseThematicsHandler } type Application struct { diff --git a/internal/kurious/app/query/getcourse.go b/internal/kurious/app/query/getcourse.go index 0a5d320..b07a031 100644 --- a/internal/kurious/app/query/getcourse.go +++ b/internal/kurious/app/query/getcourse.go @@ -16,15 +16,18 @@ type GetCourse struct { type GetCourseHandler decorator.QueryHandler[GetCourse, domain.Course] type getCourseHandler struct { - repo domain.CourseRepository + repo domain.CourseRepository + mapper domain.CourseMapper } func NewGetCourseHandler( repo domain.CourseRepository, + mapper domain.CourseMapper, log *slog.Logger, ) GetCourseHandler { h := getCourseHandler{ - repo: repo, + repo: repo, + mapper: mapper, } return decorator.AddQueryDecorators(h, log) } @@ -35,5 +38,8 @@ func (h getCourseHandler) Handle(ctx context.Context, query GetCourse) (domain.C return domain.Course{}, fmt.Errorf("getting course: %w", err) } + course.LearningType = h.mapper.LearningTypeNameByID(course.LearningTypeID) + course.Thematic = h.mapper.CourseThematicNameByID(course.ThematicID) + return course, nil } diff --git a/internal/kurious/app/query/listcoursethematics.go b/internal/kurious/app/query/listcoursethematics.go new file mode 100644 index 0000000..deb83c7 --- /dev/null +++ b/internal/kurious/app/query/listcoursethematics.go @@ -0,0 +1,62 @@ +package query + +import ( + "context" + "fmt" + "log/slog" + + "git.loyso.art/frx/kurious/internal/common/decorator" + "git.loyso.art/frx/kurious/internal/kurious/domain" +) + +type ListCourseThematics struct { + LearningTypeID string +} + +type CourseThematic struct { + ID string + Name string +} + +type ListCourseThematicsResult struct { + CourseThematics []CourseThematic +} + +type ListCourseThematicsHandler decorator.QueryHandler[ListCourseThematics, ListCourseThematicsResult] + +type listCourseThematicsHandler struct { + repo domain.CourseRepository + mapper domain.CourseMapper +} + +func NewListCourseThematicsHandler( + repo domain.CourseRepository, + mapper domain.CourseMapper, + log *slog.Logger, +) ListCourseThematicsHandler { + h := listCourseThematicsHandler{ + repo: repo, + mapper: mapper, + } + + return decorator.AddQueryDecorators(h, log) +} + +func (h listCourseThematicsHandler) Handle(ctx context.Context, query ListCourseThematics) (out ListCourseThematicsResult, err error) { + result, err := h.repo.ListCourseThematics(ctx, domain.ListCourseThematicsParams{ + LearningTypeID: query.LearningTypeID, + }) + if err != nil { + return out, fmt.Errorf("listing course thematics from repo: %w", err) + } + + out.CourseThematics = make([]CourseThematic, 0, len(result.CourseThematicIDs)) + for _, ct := range result.CourseThematicIDs { + var item CourseThematic + item.ID = ct + item.Name = h.mapper.CourseThematicNameByID(ct) + out.CourseThematics = append(out.CourseThematics, item) + } + + return out, nil +} diff --git a/internal/kurious/app/query/listlearningtypes.go b/internal/kurious/app/query/listlearningtypes.go new file mode 100644 index 0000000..0d378d2 --- /dev/null +++ b/internal/kurious/app/query/listlearningtypes.go @@ -0,0 +1,58 @@ +package query + +import ( + "context" + "fmt" + "log/slog" + + "git.loyso.art/frx/kurious/internal/common/decorator" + "git.loyso.art/frx/kurious/internal/kurious/domain" +) + +type ListLearningTypes struct{} + +type LearningType struct { + ID string + Name string +} + +type ListLearningTypesResult struct { + LearningTypes []LearningType +} + +type ListLearningTypesHandler decorator.QueryHandler[ListLearningTypes, ListLearningTypesResult] + +type listLearningTypesHandler struct { + repo domain.CourseRepository + mapper domain.CourseMapper +} + +func NewListLearningTypesHandler( + repo domain.CourseRepository, + mapper domain.CourseMapper, + log *slog.Logger, +) ListLearningTypesHandler { + h := listLearningTypesHandler{ + repo: repo, + mapper: mapper, + } + + return decorator.AddQueryDecorators(h, log) +} + +func (h listLearningTypesHandler) Handle(ctx context.Context, query ListLearningTypes) (out ListLearningTypesResult, err error) { + result, err := h.repo.ListLearningTypes(ctx) + if err != nil { + return out, fmt.Errorf("listing learning types from repo: %w", err) + } + + out.LearningTypes = make([]LearningType, 0, len(result.LearningTypeIDs)) + for _, lt := range result.LearningTypeIDs { + var item LearningType + item.ID = lt + item.Name = h.mapper.LearningTypeNameByID(lt) + out.LearningTypes = append(out.LearningTypes, item) + } + + return out, nil +} diff --git a/internal/kurious/domain/repository.go b/internal/kurious/domain/repository.go index 30a3fd8..bdd72df 100644 --- a/internal/kurious/domain/repository.go +++ b/internal/kurious/domain/repository.go @@ -39,10 +39,24 @@ type ListCoursesResult struct { NextPageToken string } +type ListLearningTypeResult struct { + LearningTypeIDs []string +} + +type ListCourseThematicsParams struct { + LearningTypeID string +} + +type ListCourseThematicsResult struct { + CourseThematicIDs []string +} + //go:generate mockery --name CourseRepository type CourseRepository interface { // List courses by specifid parameters. - List(ctx context.Context, params ListCoursesParams) (ListCoursesResult, error) + List(context.Context, ListCoursesParams) (ListCoursesResult, error) + ListLearningTypes(context.Context) (ListLearningTypeResult, error) + ListCourseThematics(context.Context, ListCourseThematicsParams) (ListCourseThematicsResult, error) // Get course by id. // Should return ErrNotFound in case course not found. Get(ctx context.Context, id string) (Course, error) diff --git a/internal/kurious/ports/http/course.go b/internal/kurious/ports/http/course.go index f013a1e..148434f 100644 --- a/internal/kurious/ports/http/course.go +++ b/internal/kurious/ports/http/course.go @@ -50,9 +50,9 @@ func parseListCoursesParams(r *http.Request) (out listCoursesParams, err error) return out, err } - query := r.URL.Query() - out.learningType = query.Get("category") - out.courseThematic = query.Get("type") + vars := mux.Vars(r) + out.learningType = vars["learning_type"] + out.courseThematic = vars["thematic_type"] return out, nil } diff --git a/internal/kurious/service/service.go b/internal/kurious/service/service.go index e987313..e2f5087 100644 --- a/internal/kurious/service/service.go +++ b/internal/kurious/service/service.go @@ -43,8 +43,10 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig, mapper domain.Co UpdateCourseDescription: command.NewUpdateCourseDescriptionHandler(courseadapter, log), }, Queries: app.Queries{ - GetCourse: query.NewGetCourseHandler(courseadapter, log), - ListCourses: query.NewListCourseHandler(courseadapter, mapper, log), + ListCourses: query.NewListCourseHandler(courseadapter, mapper, log), + ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log), + ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log), + GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log), }, }