implement webserver

This commit is contained in:
Aleksandr Trushkin
2023-12-17 21:21:13 +03:00
parent f60ebcfb36
commit 1d4e8e10fb
12 changed files with 499 additions and 179 deletions

View File

@ -24,6 +24,29 @@ import (
yc "github.com/ydb-platform/ydb-go-yc"
)
var coursesFields = []string{
"id",
"external_id",
"source_type",
"source_name",
"course_thematic",
"learning_type",
"organization_id",
"origin_link",
"image_link",
"name",
"description",
"full_price",
"discount",
"duration",
"starts_at",
"created_at",
"updated_at",
"deleted_at",
}
var coursesFieldsStr = strings.Join(coursesFields, ",")
const (
defaultShutdownTimeout = time.Second * 10
)
@ -87,59 +110,31 @@ func (r *ydbCourseRepository) List(
) (result domain.ListCoursesResult, err error) {
const limit = 1000
const queryName = "list"
// const query = `
// DECLARE $limit AS Int32;
// DECLARE $id AS Text;
// SELECT
// id,
// external_id,
// source_type,
// source_name,
// course_thematic,
// learning_type,
// organization_id,
// origin_link,
// image_link,
// name,
// description,
// full_price,
// discount,
// duration,
// starts_at,
// created_at,
// updated_at,
// deleted_at
// FROM
// courses
// WHERE
// id > $id
// ORDER BY id
// LIMIT $limit;`
//
const fields = `id, external_id, source_type, source_name, course_thematic, learning_type, organization_id, origin_link, image_link, name, description, full_price, discount, duration, starts_at, created_at, updated_at, deleted_at`
if params.Limit == 0 {
params.Limit = limit
}
qtParams := queryTemplateParams{
Fields: fields,
Fields: coursesFieldsStr,
Table: "courses",
Suffix: "ORDER BY id\nLIMIT $limit",
Declares: []queryTemplateDeclaration{{
Name: "limit",
Type: "Int32",
}, {
Name: "id",
Type: "Text",
}},
Declares: []queryTemplateDeclaration{
{
Name: "limit",
Type: "Int32",
},
{
Name: "id",
Type: "Text",
},
},
Conditions: []string{
"id > $id",
},
}
options := make([]table.ParameterOption, 0, 4)
appendParams := func(name string, value string) {
opts := make([]table.ParameterOption, 0, 4)
appendTextParam := func(name string, value string) {
if value == "" {
return
}
@ -151,18 +146,17 @@ func (r *ydbCourseRepository) List(
}
qtParams.Declares = append(qtParams.Declares, d)
qtParams.Conditions = append(qtParams.Conditions, d.Name+"="+d.Arg())
options = append(options, table.ValueParam(d.Arg(), ydbvalue))
opts = append(opts, table.ValueParam(d.Arg(), ydbvalue))
}
appendParams("course_thematic", params.CourseThematic)
appendParams("learning_type", params.LearningType)
appendTextParam("course_thematic", params.CourseThematic)
appendTextParam("learning_type", params.LearningType)
var sb strings.Builder
err = template.Must(template.New("").Parse(queryTemplateSelect)).Execute(&sb, qtParams)
query, err := qtParams.render()
if err != nil {
return result, fmt.Errorf("executing template: %w", err)
return result, fmt.Errorf("rendering: %w", err)
}
query := sb.String()
xcontext.LogInfo(ctx, r.log, "planning to run query", slog.String("query", query), slog.Any("opts", opts))
courses := make([]domain.Course, 0, 1_000)
readTx := table.TxControl(
@ -171,9 +165,12 @@ func (r *ydbCourseRepository) List(
),
table.CommitTx(),
)
xcontext.LogInfo(ctx, r.log, "executing do")
err = r.db.Table().Do(
ctx,
func(ctx context.Context, s table.Session) error {
xcontext.LogInfo(ctx, r.log, "inside do")
start := time.Now()
defer func() {
since := time.Since(start)
@ -185,47 +182,51 @@ func (r *ydbCourseRepository) List(
)
}()
var lastKnownID string
for {
queryParams := table.NewQueryParameters(
table.ValueParam("$limit", types.Int32Value(limit)),
table.ValueParam("$id", types.TextValue(lastKnownID)),
)
_, res, err := s.Execute(
ctx, readTx, query, queryParams,
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing: %w", err)
}
queryParams := table.NewQueryParameters(opts...)
if !res.NextResultSet(ctx) || !res.HasNextRow() {
break
}
xcontext.LogDebug(ctx, r.log, "executing")
for res.NextRow() {
var cdb courseDB
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
courses = append(courses, mapCourseDB(cdb))
}
if err = res.Err(); err != nil {
return err
}
lastKnownID = courses[len(courses)-1].ID
_, res, err := s.Execute(
ctx, readTx, query, queryParams,
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing: %w", err)
}
xcontext.LogDebug(ctx, r.log, "checking")
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
xcontext.LogDebug(ctx, r.log, "scanning")
for res.NextRow() {
var cdb courseDB
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
courses = append(courses, mapCourseDB(cdb))
}
if err = res.Err(); err != nil {
return err
}
result.NextPageToken = courses[len(courses)-1].ID
xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(courses)))
return nil
},
table.WithIdempotent())
if err != nil {
return nil, err
return domain.ListCoursesResult{}, err
}
return courses, err
result.Courses = courses
return result, err
}
func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain.Course, err error) {
@ -579,9 +580,18 @@ type queryTemplateParams struct {
Suffix string
}
const queryTemplateSelect = `
{{ range .Declares }}DECLARE ${{.Name}} AS {{.Type}}\n{{end}}
func (p queryTemplateParams) render() (string, error) {
var sb strings.Builder
sb.Grow(len(queryTemplateSelect) * 3)
err := querySelect.Execute(&sb, p)
return sb.String(), err
}
const queryTemplateSelect = `{{ range .Declares }}DECLARE ${{.Name}} AS {{.Type}};{{end}}
SELECT {{.Fields}}
FROM {{.Table}}
WHERE {{ range .Conditions }}{{.}}\n{{end}}
WHERE {{ range .Conditions }}{{.}}{{end}}
{{.Suffix}}`
var querySelect = template.Must(template.New("").Parse(queryTemplateSelect))

View File

@ -16,11 +16,11 @@ type ListCourse struct {
OrganizationID string
Keyword string
Limit int
Offset int
Limit int
NextPageToken string
}
type ListCourseHandler decorator.QueryHandler[ListCourse, []domain.Course]
type ListCourseHandler decorator.QueryHandler[ListCourse, domain.ListCoursesResult]
type listCourseHandler struct {
repo domain.CourseRepository
@ -36,17 +36,35 @@ func NewListCourseHandler(
return decorator.AddQueryDecorators(h, log)
}
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) ([]domain.Course, error) {
courses, err := h.repo.List(ctx, domain.ListCoursesParams{
CourseThematic: query.CourseThematic,
LearningType: query.LearningType,
OrganizationID: query.OrganizationID,
Limit: query.Limit,
Offset: query.Offset,
})
if err != nil {
return nil, fmt.Errorf("listing courses: %w", err)
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out domain.ListCoursesResult, err error) {
out.NextPageToken = query.NextPageToken
drainFull := query.Limit == 0
if !drainFull {
out.Courses = make([]domain.Course, 0, query.Limit)
}
for {
print("listing")
result, err := h.repo.List(ctx, domain.ListCoursesParams{
CourseThematic: query.CourseThematic,
LearningType: query.LearningType,
OrganizationID: query.OrganizationID,
Limit: query.Limit,
NextPageToken: out.NextPageToken,
})
if err != nil {
return out, fmt.Errorf("listing courses: %w", err)
}
out.Courses = append(out.Courses, result.Courses...)
out.NextPageToken = result.NextPageToken
if drainFull && len(result.Courses) > 0 && result.NextPageToken != "" {
continue
}
break
}
return courses, nil
return out, nil
}

View File

@ -14,7 +14,6 @@ type ListCoursesParams struct {
NextPageToken string
Limit int
Offset int
}
type CreateCourseParams struct {

View File

@ -240,11 +240,13 @@ func (h *syncSravniHandler) fillCaches(ctx context.Context) error {
return nil
}
courses, err := h.svc.Queries.ListCourses.Handle(ctx, query.ListCourse{})
result, err := h.svc.Queries.ListCourses.Handle(ctx, query.ListCourse{})
if err != nil {
return fmt.Errorf("listing courses: %w", err)
}
courses := result.Courses
h.knownExternalIDs = make(map[string]struct{}, len(courses))
xslice.ForEach(courses, func(c domain.Course) {

View File

@ -1,49 +1,30 @@
package http
import (
"html/template"
"log/slog"
"net/http"
"strconv"
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xslice"
"git.loyso.art/frx/kurious/internal/kurious/app"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/internal/kurious/service"
)
type courseServer struct {
app app.Application
app service.Application
log *slog.Logger
}
type pagination struct {
page int
perPage int
}
func (p pagination) LimitOffset() (limit, offset int) {
if p.page < 0 {
p.page = 0
}
if p.perPage <= 0 {
p.perPage = defaultPerPage
}
return p.perPage, p.page * p.perPage
nextPageToken string
perPage int
}
func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
query := r.URL.Query()
if query.Has("page") {
out.page, err = strconv.Atoi(query.Get("page"))
if err != nil {
return out, errors.NewValidationError("page", "bad page value")
}
}
out.nextPageToken = query.Get("next")
if query.Has("per_page") {
out.perPage, err = strconv.Atoi(query.Get("per_page"))
@ -77,42 +58,84 @@ type listCoursesParams struct {
learningType string
}
type templateCourse domain.Course
type baseInfo struct {
ID string
Name string
Description string
}
func mapDomainCourseToTemplate(in ...domain.Course) []templateCourse {
return xslice.Map(in, func(v domain.Course) templateCourse {
return templateCourse(v)
type categoryInfo struct {
baseInfo
Subcategories []subcategoryInfo
}
type subcategoryInfo struct {
baseInfo
Courses []domain.Course
}
type listCoursesTemplateParams struct {
Categories []categoryInfo
}
func mapDomainCourseToTemplate(in ...domain.Course) listCoursesTemplateParams {
coursesBySubcategory := make(map[string][]domain.Course, len(in))
subcategoriesByCategories := make(map[string]map[string]struct{}, len(in))
xslice.ForEach(in, func(c domain.Course) {
coursesBySubcategory[c.Thematic] = append(coursesBySubcategory[c.Thematic], c)
if _, ok := subcategoriesByCategories[c.LearningType]; !ok {
subcategoriesByCategories[c.LearningType] = map[string]struct{}{}
}
subcategoriesByCategories[c.LearningType][c.Thematic] = struct{}{}
})
var out listCoursesTemplateParams
for category, subcategoryMap := range subcategoriesByCategories {
outCategory := categoryInfo{}
outCategory.ID = category
outCategory.Name = category
outCategory.Description = ""
for subcategory := range subcategoryMap {
outSubCategory := subcategoryInfo{
Courses: coursesBySubcategory[subcategory],
}
outSubCategory.ID = subcategory
outSubCategory.Name = subcategory
outSubCategory.Description = ""
outCategory.Subcategories = append(outCategory.Subcategories, outSubCategory)
}
}
return out
}
func (c courseServer) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
params, err := parseListCoursesParams(r)
if err != nil {
handleError(ctx, err, w, c.log, "unable to parse list courses params")
if handleError(ctx, err, w, c.log, "unable to parse list courses params") {
return
}
limit, offset := params.LimitOffset()
courses, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
result, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
CourseThematic: params.courseThematic,
LearningType: params.learningType,
Limit: limit,
Offset: offset,
Limit: params.perPage,
NextPageToken: params.nextPageToken,
})
if err != nil {
handleError(ctx, err, w, c.log, "unable to list courses")
}
templateCourses := mapDomainCourseToTemplate(courses...)
err = template.Must(template.ParseFiles("templates/list.tmpl")).Execute(w, templateCourses)
if err != nil {
handleError(ctx, err, w, c.log, "unable to execute template")
if handleError(ctx, err, w, c.log, "unable to list courses") {
return
}
w.WriteHeader(http.StatusOK)
courses := result.Courses
templateCourses := mapDomainCourseToTemplate(courses...)
err = listTemplateParsed.ExecuteTemplate(w, "courses", templateCourses)
if handleError(ctx, err, w, c.log, "unable to execute template") {
return
}
}

View File

@ -0,0 +1,99 @@
package http
import "html/template"
var listTemplateParsed = template.Must(
template.New("courses").
Parse(listTemplate),
)
const listTemplate = `{{define "courses"}}
<!DOCTYPE html>
<html>
<head>
<title>Courses</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
header {
background-color: #333;
color: white;
padding: 10px;
}
header h1 {
margin: 0;
}
nav ul {
list-style-type: none;
margin: 0;
padding: 0;
}
nav li {
display: inline;
margin-right: 10px;
}
nav a {
color: white;
text-decoration: none;
}
h1, h2, h3 {
margin-top: 0;
}
p {
margin-bottom: 10px;
}
.course-plate {
background-color: #f2f2f2;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
text-align: center;
}
.course-plate a {
color: #333;
text-decoration: none;
}
.course-plate a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<header>
<h1>Courses</h1>
<nav>
<ul>
<li><a href="/">Main page</a></li>
<li><a href="/about">About us</a></li>
<li><a href="/help">Help</a></li>
</ul>
</nav>
</header>
<h1>Courses</h1>
{{range $category := .Categories}}
<h2>{{$category.Name}}</h2>
<p>{{$category.Description}}</p>
{{range $subcategory := .Subcategories}}
<h2>{{$subcategory.Name}}</h2>
<p>{{$subcategory.Description}}</p>
{{range $course := $.Courses}}
<div class="course-plate">
<h3><a href="/courses/{{$course.ID}}">{{$course.Name}}</a></h3>
<p>{{$course.Description}}</p>
<p>Full price: {{$course.FullPrice}}</p>
<p>Discount: {{$course.Discount}}</p>
<p>Thematic: {{$course.Thematic}}</p>
<p>Learning type: {{$course.LearningType}}</p>
<p>Duration: {{$course.Duration}}</p>
<p>Starts at: {{$course.StartsAt}}</p>
</div>
{{end}}
{{end}}
{{end}}
</body>
</html>
{{end}}`

View File

@ -8,30 +8,28 @@ import (
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/app"
)
const (
defaultPerPage = 50
"git.loyso.art/frx/kurious/internal/kurious/service"
)
type Server struct {
app app.Application
app service.Application
log *slog.Logger
}
func NewServer(app app.Application) Server {
return Server{}
}
func (s Server) Courses() courseServer {
return courseServer{
app: s.app,
func NewServer(app service.Application, log *slog.Logger) Server {
return Server{
app: app,
log: log,
}
}
func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slog.Logger, msg string) {
func (s Server) Courses() courseServer {
return courseServer(s)
}
func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slog.Logger, msg string) bool {
if err == nil {
return
return false
}
var errorString string
@ -52,4 +50,6 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo
xcontext.LogWithWarnError(ctx, log, err, msg, slog.Int("status_code", code), slog.String("response", errorString))
http.Error(w, errorString, code)
return true
}