make application with base logic

This commit is contained in:
Gitea
2023-11-26 15:39:34 +03:00
parent 0553ea71c3
commit 606b94e35b
32 changed files with 1070 additions and 27 deletions

View File

@ -1 +0,0 @@
package courses

View File

@ -9,8 +9,8 @@ import (
"strconv"
"strings"
"git.loyso.art/frx/kurious/internal/domain"
"git.loyso.art/frx/kurious/pkg/utilities/slices"
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/pkg/slices"
"github.com/go-resty/resty/v2"
"golang.org/x/net/html"
@ -21,8 +21,10 @@ const (
baseURL = "https://www.sravni.ru/kursy"
)
//go:generate mockery --name Client
type Client interface {
GetMainPageState() *PageState
GetMainPageState() (*PageState, error)
ListEducationalProducts(
ctx context.Context,
params ListEducationProductsParams,
@ -66,8 +68,8 @@ type client struct {
validCourseThematics querySet
}
func (c *client) GetMainPageState() *PageState {
return c.cachedMainPageInfo.Clone()
func (c *client) GetMainPageState() (*PageState, error) {
return c.cachedMainPageInfo.Clone(), nil
}
type ListEducationProductsParams struct {
@ -119,10 +121,10 @@ func (c *client) ListEducationalProducts(
}
if !c.validLearningTypes.hasValue(params.LearningType) {
return result, domain.NewValidationError("learning_type", "bad value")
return result, errors.NewValidationError("learning_type", "bad value")
}
if !c.validCourseThematics.hasValue(params.CoursesThematics) {
return result, domain.NewValidationError("courses_thematics", "bad value")
return result, errors.NewValidationError("courses_thematics", "bad value")
}
reqParams := ListEducationProductsRequest{
@ -161,7 +163,7 @@ func (c *client) ListEducationalProducts(
}
if resp.IsError() {
return result, fmt.Errorf("bad status code %d: %w", resp.StatusCode(), domain.ErrUnexpectedStatus)
return result, fmt.Errorf("bad status code %d: %w", resp.StatusCode(), errors.ErrUnexpectedStatus)
}
return result, nil
@ -202,7 +204,7 @@ func (c *client) getMainPageState(ctx context.Context) (*PageState, error) {
if resp.IsError() {
c.log.ErrorContext(ctx, "unable to proceed request", slog.String("body", string(resp.Body())))
return nil, fmt.Errorf("got %d, but expected success: %w", resp.StatusCode(), domain.ErrUnexpectedStatus)
return nil, fmt.Errorf("got %d, but expected success: %w", resp.StatusCode(), errors.ErrUnexpectedStatus)
}
traceInfo := resp.Request.TraceInfo()

View File

@ -3,11 +3,11 @@ package sravni
import (
"time"
"git.loyso.art/frx/kurious/internal/domain"
"git.loyso.art/frx/kurious/internal/common/errors"
)
const (
ErrClientNotInited domain.SimpleError = "client was not inited"
ErrClientNotInited errors.SimpleError = "client was not inited"
)
type PageStateRuntimeConfig struct {

View File

@ -0,0 +1,17 @@
package sravni
import (
"context"
"git.loyso.art/frx/kurious/internal/common/errors"
)
type NoopClient struct{}
func (NoopClient) GetMainPageState() (*PageState, error) {
return nil, errors.ErrNotImplemented
}
func (NoopClient) ListEducationalProducts(context.Context, ListEducationProductsParams) (ListEducationProductsResponse, error) {
return ListEducationProductsResponse{}, errors.ErrNotImplemented
}

View File

@ -0,0 +1,55 @@
package config
import (
"log/slog"
"os"
)
type LogFormat uint8
const (
LogFormatText LogFormat = iota
LogFormatJSON
)
type LogLevel uint8
const (
LogLevelDebug LogLevel = iota
LogLevelInfo
LogLevelWarn
LogLevelError
)
type LogConfig struct {
Level LogLevel
Format LogFormat
}
func NewSLogger(config LogConfig) *slog.Logger {
var level slog.Level
switch config.Level {
case LogLevelDebug:
level = slog.LevelDebug
case LogLevelInfo:
level = slog.LevelInfo
case LogLevelWarn:
level = slog.LevelWarn
case LogLevelError:
level = slog.LevelError
}
opts := &slog.HandlerOptions{
Level: level,
}
var h slog.Handler
switch config.Format {
case LogFormatJSON:
h = slog.NewJSONHandler(os.Stdout, opts)
case LogFormatText:
h = slog.NewTextHandler(os.Stdout, opts)
}
return slog.New(h)
}

View File

@ -0,0 +1,17 @@
package decorator
import (
"context"
"log/slog"
)
type CommandHandler[T any] interface {
Handle(ctx context.Context, params T) error
}
func ApplyCommandDecorators[T any](base CommandHandler[T], log *slog.Logger) CommandHandler[T] {
return commandLoggingDecorator[T]{
base: base,
log: log,
}
}

View File

@ -0,0 +1,64 @@
package decorator
import (
"context"
"fmt"
"log/slog"
"time"
"git.loyso.art/frx/kurious/internal/common/xcontext"
)
type commandLoggingDecorator[T any] struct {
base CommandHandler[T]
log *slog.Logger
}
func (c commandLoggingDecorator[T]) Handle(ctx context.Context, cmd T) (err error) {
handlerName := getTypeName[T]()
ctx = xcontext.WithLogFields(ctx, slog.String("handler", handlerName))
xcontext.LogDebug(ctx, c.log, "executing command")
start := time.Now()
defer func() {
elapsed := slog.Duration("elapsed", time.Since(start))
if err == nil {
xcontext.LogInfo(ctx, c.log, "command executed successfuly", elapsed)
} else {
xcontext.LogError(ctx, c.log, "command execution failed", elapsed, slog.Any("err", err))
}
}()
return c.base.Handle(ctx, cmd)
}
type queryLoggingDecorator[Q, U any] struct {
base QueryHandler[Q, U]
log *slog.Logger
}
func (q queryLoggingDecorator[Q, U]) Handle(ctx context.Context, query Q) (entity U, err error) {
handlerName := getTypeName[Q]()
ctx = xcontext.WithLogFields(ctx, slog.String("handler", handlerName))
xcontext.LogDebug(ctx, q.log, "executing command")
start := time.Now()
defer func() {
elapsed := slog.Duration("elapsed", time.Since(start))
if err == nil {
xcontext.LogInfo(ctx, q.log, "command executed successfuly", elapsed)
} else {
xcontext.LogError(ctx, q.log, "command execution failed", elapsed, slog.Any("err", err))
}
}()
return q.base.Handle(ctx, query)
}
func getTypeName[T any]() string {
var t T
out := fmt.Sprintf("%T", t)
return out
}

View File

@ -0,0 +1,17 @@
package decorator
import (
"context"
"log/slog"
)
type QueryHandler[Q, U any] interface {
Handle(ctx context.Context, query Q) (entity U, err error)
}
func AddQueryDecorators[Q, U any](base QueryHandler[Q, U], log *slog.Logger) QueryHandler[Q, U] {
return queryLoggingDecorator[Q, U]{
base: base,
log: log,
}
}

View File

@ -1,4 +1,4 @@
package domain
package errors
import (
"fmt"

View File

@ -0,0 +1,42 @@
package nullable
type Value[T any] struct {
value T
valid bool
}
func NewValue[T any](value T) Value[T] {
return Value[T]{
value: value,
valid: true,
}
}
func NewValuePtr[T any](value *T) Value[T] {
if value == nil {
return Value[T]{}
}
return NewValue(*value)
}
func (n Value[T]) Value() T {
return n.value
}
func (n Value[T]) Valid() bool {
return n.valid
}
func (n Value[T]) ValutPtr() *T {
if n.valid {
return &n.value
}
return nil
}
func (n *Value[T]) Set(value T) {
n.valid = true
n.value = value
}

View File

@ -0,0 +1,40 @@
package xcontext
import (
"context"
"log/slog"
)
type ctxLogKey struct{}
type ctxLogAttrStore struct {
attrs []slog.Attr
}
func WithLogFields(ctx context.Context, fields ...slog.Attr) context.Context {
store, _ := ctx.Value(ctxLogKey{}).(ctxLogAttrStore)
store.attrs = append(store.attrs, fields...)
return context.WithValue(ctx, ctxLogKey{}, store)
}
func LogDebug(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelDebug, msg, append(attrs, getLogFields(ctx)...)...)
}
func LogInfo(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelInfo, msg, append(attrs, getLogFields(ctx)...)...)
}
func LogWarn(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelWarn, msg, append(attrs, getLogFields(ctx)...)...)
}
func LogError(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelError, msg, append(attrs, getLogFields(ctx)...)...)
}
func getLogFields(ctx context.Context) []slog.Attr {
store, _ := ctx.Value(ctxLogKey{}).(ctxLogAttrStore)
return store.attrs
}

View File

@ -1,8 +0,0 @@
// Package adapters aggregates all external services and it's implementations.
package adapters
type Services struct{}
func NewServices() Services {
return Services{}
}

View File

@ -0,0 +1,32 @@
package adapters
import (
"context"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
func NewYDBCourseRepository() (*ydbCourseRepository, error) {
return &ydbCourseRepository{}, nil
}
type ydbCourseRepository struct{}
func (ydbCourseRepository) List(ctx context.Context, params domain.ListCoursesParams) ([]domain.Course, error) {
return nil, nil
}
func (ydbCourseRepository) Get(ctx context.Context, id string) (domain.Course, error) {
return domain.Course{}, nil
}
func (ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (domain.Course, error) {
return domain.Course{}, nil
}
func (ydbCourseRepository) Create(context.Context, domain.CreateCourseParams) (domain.Course, error) {
return domain.Course{}, nil
}
func (ydbCourseRepository) Delete(ctx context.Context, id string) error {
return nil
}
func (ydbCourseRepository) Close() error {
return nil
}

View File

@ -0,0 +1,21 @@
package app
import (
"git.loyso.art/frx/kurious/internal/kurious/app/command"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
)
type Commands struct {
InsertCourse command.CreateCourseHandler
DeleteCourse command.DeleteCourseHandler
}
type Queries struct {
GetCourse query.GetCourseHandler
ListCourses query.ListCourseHandler
}
type Application struct {
Commands Commands
Queries Queries
}

View File

@ -0,0 +1,54 @@
package command
import (
"context"
"fmt"
"log/slog"
"time"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type CreateCourse struct {
ID string
Name string
Description string
ExternalID nullable.Value[string]
SourceType domain.SourceType
SourceName nullable.Value[string]
OrganizationID string
OriginLink string
ImageLink string
FullPrice float64
Discount float64
Duration time.Duration
StartsAt time.Time
}
type CreateCourseHandler decorator.CommandHandler[CreateCourse]
type createCourseHandler struct {
repo domain.CourseRepository
}
func NewCreateCourseHandler(
repo domain.CourseRepository,
log *slog.Logger,
) CreateCourseHandler {
h := createCourseHandler{
repo: repo,
}
return decorator.ApplyCommandDecorators(h, log)
}
func (h createCourseHandler) Handle(ctx context.Context, cmd CreateCourse) error {
_, err := h.repo.Create(ctx, domain.CreateCourseParams(cmd))
if err != nil {
return fmt.Errorf("creating course: %w", err)
}
return nil
}

View File

@ -0,0 +1,40 @@
package command
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type DeleteCourse struct {
ID string
}
type DeleteCourseHandler decorator.CommandHandler[DeleteCourse]
type deleteCourseHandler struct {
repo domain.CourseRepository
}
func NewDeleteCourseHandler(
repo domain.CourseRepository,
log *slog.Logger,
) DeleteCourseHandler {
h := deleteCourseHandler{
repo: repo,
}
return decorator.ApplyCommandDecorators(h, log)
}
func (h deleteCourseHandler) Handle(ctx context.Context, cmd DeleteCourse) error {
err := h.repo.Delete(ctx, cmd.ID)
if err != nil {
return fmt.Errorf("deleting: %w", err)
}
return nil
}

View File

@ -0,0 +1,39 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type GetCourse struct {
ID string
}
type GetCourseHandler decorator.QueryHandler[GetCourse, domain.Course]
type getCourseHandler struct {
repo domain.CourseRepository
}
func NewGetCourseHandler(
repo domain.CourseRepository,
log *slog.Logger,
) GetCourseHandler {
h := getCourseHandler{
repo: repo,
}
return decorator.AddQueryDecorators(h, log)
}
func (h getCourseHandler) Handle(ctx context.Context, query GetCourse) (domain.Course, error) {
course, err := h.repo.Get(ctx, query.ID)
if err != nil {
return domain.Course{}, fmt.Errorf("getting course: %w", err)
}
return course, nil
}

View File

@ -0,0 +1,45 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListCourse struct {
CategoryID string
OrganizationID string
Keyword string
}
type ListCourseHandler decorator.QueryHandler[ListCourse, []domain.Course]
type listCourseHandler struct {
repo domain.CourseRepository
}
func NewListCourseHandler(
repo domain.CourseRepository,
log *slog.Logger,
) ListCourseHandler {
h := listCourseHandler{
repo: repo,
}
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{
CategoryID: query.CategoryID,
OrganizationID: query.OrganizationID,
Keyword: query.Keyword,
})
if err != nil {
return nil, fmt.Errorf("listing courses: %w", err)
}
return courses, nil
}

View File

@ -0,0 +1,42 @@
package domain
import (
"time"
"git.loyso.art/frx/kurious/internal/common/nullable"
)
// Course is a main entity of this project.
type Course struct {
// ID is our unique identifier
ID string
// ExternalID if exists
ExternalID nullable.Value[string]
SourceType SourceType
SourceName nullable.Value[string]
// OrganizationID that provides course.
OrganizationID string
// Link to the course
OriginLink string
ImageLink string
Name string
// Description of the course. Might be html encoded value.
// Maybe it's worth to add flag about it.
Description string
// FullPrice is a course full price without discount.
FullPrice float64
// Discount for the course.
Discount float64
Keywords []string
// Duration for the course. It will be splitted in values like:
// full month / full day / full hour.
Duration time.Duration
// StartsAt points to time when the course will start.
StartsAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt nullable.Value[time.Time]
}

View File

@ -0,0 +1,19 @@
package domain
// SourceType defines the method this course was added.
type SourceType uint8
const (
// SourceTypeUnset should be treated as invalid value.
SourceTypeUnset SourceType = iota
// SourceTypeManual defines this course was not parsed and
// has been added manually.
SourceTypeManual
// SourceTypeParsed defines this course was parsed
SourceTypeParsed
)
type Category struct {
ID string
Name string
}

View File

@ -0,0 +1,22 @@
package domain
import (
"time"
"git.loyso.art/frx/kurious/internal/common/nullable"
)
// Organization is an entity that can have coursed.
type Organization struct {
ID string
ExternalID nullable.Value[string]
Alias string
Name string
Site string
LogoLink string
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt nullable.Value[time.Time]
}

View File

@ -0,0 +1,65 @@
package domain
import (
"context"
"time"
"git.loyso.art/frx/kurious/internal/common/nullable"
)
type ListCoursesParams struct {
OrganizationID string
CategoryID string
Keyword string
}
type CreateCourseParams struct {
ID string
Name string
Description string
ExternalID nullable.Value[string]
SourceType SourceType
SourceName nullable.Value[string]
OrganizationID string
OriginLink string
ImageLink string
FullPrice float64
Discount float64
Duration time.Duration
StartsAt time.Time
}
//go:generate mockery --name CourseRepository
type CourseRepository interface {
// List courses by specifid parameters.
List(ctx context.Context, params ListCoursesParams) ([]Course, error)
// Get course by id.
// Should return ErrNotFound in case course not found.
Get(ctx context.Context, id string) (Course, error)
// GetByExternalID finds course by external id.
// Should return ErrNotFound in case course not found.
GetByExternalID(ctx context.Context, id string) (Course, error)
// Create course, but might fail in case of
// unique constraint violation.
Create(context.Context, CreateCourseParams) (Course, error)
// Delete course by id.
Delete(ctx context.Context, id string) error
}
type CreateOrganizationParams struct {
ID string
ExternalID nullable.Value[string]
Alias string
Name string
Site string
LogoLink string
}
//go:generate mockery --name OrganizationRepository
type OrganizationRepository interface {
Get(ctx context.Context) (Organization, error)
Create(context.Context, CreateOrganizationParams) (Organization, error)
Delete(ctx context.Context, id string) error
}

View File

@ -0,0 +1,59 @@
package service
import (
"context"
"fmt"
"io"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/kurious/adapters"
"git.loyso.art/frx/kurious/internal/kurious/app"
"git.loyso.art/frx/kurious/internal/kurious/app/command"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
)
type ApplicationConfig struct {
LogConfig config.LogConfig
}
type Application struct {
app.Application
log *slog.Logger
closers []io.Closer
}
func NewApplication(ctx context.Context, cfg ApplicationConfig) (Application, error) {
log := config.NewSLogger(cfg.LogConfig)
courseadapter, err := adapters.NewYDBCourseRepository()
if err != nil {
return Application{}, fmt.Errorf("making ydb course repository: %w", err)
}
application := app.Application{
Commands: app.Commands{
InsertCourse: command.NewCreateCourseHandler(courseadapter, log),
DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log),
},
Queries: app.Queries{
GetCourse: query.NewGetCourseHandler(courseadapter, log),
ListCourses: query.NewListCourseHandler(courseadapter, log),
},
}
out := Application{Application: application}
out.closers = append(out.closers, courseadapter)
out.log = log
return out, nil
}
func (app Application) Close() {
for _, closer := range app.closers {
err := closer.Close()
if err != nil {
app.log.Error("unable to close closer", slog.Any("err", err))
}
}
}