1
0
Fork 0

feat: add basic app

This commit is contained in:
Vojtěch Mareš 2024-10-10 21:29:08 +02:00
parent d4c1af4831
commit c94098afef
Signed by: vojtech.mares
GPG key ID: C6827B976F17240D
13 changed files with 1850 additions and 0 deletions

View file

@ -0,0 +1,13 @@
package currency
type Currency string
const (
USD Currency = "USD"
EUR Currency = "EUR"
CZK Currency = "CZK"
)
var (
SupportedCurrencies = []Currency{USD, EUR, CZK}
)

71
internal/faker/faker.go Normal file
View file

@ -0,0 +1,71 @@
package faker
import (
"context"
"github.com/shopspring/decimal"
"gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/currency"
"gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/training"
)
type Faker struct {
trainingRepository training.Repository
}
func NewFaker(trainingRepository training.Repository) *Faker {
return &Faker{
trainingRepository: trainingRepository,
}
}
func (f *Faker) GenerateFakeData() error {
var t *training.Training
oneDayPricing := []training.Price{
{
Currency: currency.CZK,
Amount: decimal.NewFromInt(5900),
Type: training.OpenTrainingPriceType,
},
{
Currency: currency.CZK,
Amount: decimal.NewFromInt(24000),
Type: training.CorporateTrainingPriceType,
},
}
twoDayPricing := []training.Price{
{
Currency: currency.CZK,
Amount: decimal.NewFromInt(9900),
Type: training.OpenTrainingPriceType,
},
{
Currency: currency.CZK,
Amount: decimal.NewFromInt(44000),
Type: training.CorporateTrainingPriceType,
},
}
t = training.NewTraining("Kubernetes v1", 1, nil)
err := f.trainingRepository.Create(context.Background(), t)
if err != nil {
return err
}
t = training.NewTraining("Kubernetes", 2, twoDayPricing)
err = f.trainingRepository.Create(context.Background(), t)
if err != nil {
return err
}
t = training.NewTraining("Terraform", 1, oneDayPricing)
err = f.trainingRepository.Create(context.Background(), t)
if err != nil {
return err
}
return nil
}

1009
internal/server/api.gen.go Normal file

File diff suppressed because it is too large Load diff

114
internal/server/server.go Normal file
View file

@ -0,0 +1,114 @@
package server
import (
"context"
"errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
oapimiddleware "github.com/oapi-codegen/nethttp-middleware"
"gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/training"
"log"
"net/http"
"time"
)
type Server struct {
logger *log.Logger
hostname string
trainingRepository training.Repository
router *chi.Mux
srv *http.Server
tls *TLS
}
type TLS struct {
CertFile string
KeyFile string
}
func NewServer(hostname string, logger *log.Logger, trainingRepository training.Repository) *Server {
return &Server{
logger: logger,
hostname: hostname,
trainingRepository: trainingRepository,
router: chi.NewRouter(),
tls: nil,
}
}
func NewServerWithTLS(hostname string, tls *TLS, logger *log.Logger, trainingRepository training.Repository) *Server {
s := NewServer(hostname, logger, trainingRepository)
s.tls = tls
return s
}
func (s *Server) Run(ctx context.Context) error {
swagger, err := GetSwagger()
if err != nil {
return err
}
// Clear out the servers array in the swagger spec, that skips validating
// that server names match. We don't know how this thing will be run.
swagger.Servers = nil
s.router.Use(middleware.Logger)
// middleware.Recoverer recovers from panics, logs the panic (and a stack trace),
s.router.Use(middleware.Recoverer)
// we trust headers, since we are running on Kubernetes with Ingress Controller (Ingress-NGINX)
// and behind an L4 load balancer (on Hetzner Cloud)
s.router.Use(middleware.RealIP)
// middleware.RequestID generates a request ID and adds it to request context
// if the request has an `X-Request-ID` header, it will use that as the request ID
s.router.Use(middleware.RequestID)
s.router.Use(middleware.Timeout(10 * time.Second))
s.router.Use(oapimiddleware.OapiRequestValidator(swagger))
// create handler
h := NewStrictHandler(s, nil)
// register endpoints
HandlerFromMux(h, s.router)
s.srv = &http.Server{
Addr: ":8080", // TODO: make port configurable
Handler: s.router,
}
go func() {
var err error
if s.tls != nil {
s.logger.Printf("Starting HTTPS server on: %s\n", s.srv.Addr)
err = s.srv.ListenAndServeTLS(s.tls.CertFile, s.tls.KeyFile)
} else {
s.logger.Printf("Starting HTTP server on: %s\n", s.srv.Addr)
err = s.srv.ListenAndServe()
}
// suppress the error if the server was closed gracefully
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Printf("error: %v\n", err)
}
}()
// wait for the context to be done
// context done means the server is shutting down
<-ctx.Done()
// TODO: make graceful shutdown period configurable
timeout := 10 * time.Second
timeoutCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
s.logger.Printf("Shutting down server in %s seconds\n", timeout.String())
err = s.srv.Shutdown(timeoutCtx)
// suppress the error if the server was closed gracefully
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
s.logger.Println("Server shutdown successfully")
return nil
}

167
internal/server/training.go Normal file
View file

@ -0,0 +1,167 @@
package server
import (
"context"
"github.com/shopspring/decimal"
"gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/training"
)
func (s *Server) CreateTraining(ctx context.Context, req CreateTrainingRequestObject) (CreateTrainingResponseObject, error) {
pricing := make([]training.Price, len(req.Body.Training.Pricing))
for i, p := range req.Body.Training.Pricing {
amount, err := decimal.NewFromString(p.Amount)
if err != nil {
return CreateTraining400ApplicationProblemPlusJSONResponse{
InvalidInputErrorApplicationProblemPlusJSONResponse{
Title: "Invalid amount",
Detail: err.Error(),
Instance: s.hostname,
},
}, nil
}
pricing[i] = training.Price{
Currency: p.Currency,
Amount: amount,
Type: training.PriceType(p.Type),
}
}
newTr := training.NewTraining(req.Body.Training.Name, req.Body.Training.Days, pricing)
err := s.trainingRepository.Create(ctx, newTr)
if err != nil {
// returning response and nil as error
// since we suppress the error in code here and return an error response instead
return CreateTraining500ApplicationProblemPlusJSONResponse{
InternalErrorApplicationProblemPlusJSONResponse{
Title: "Internal Server Error",
Detail: err.Error(),
Instance: s.hostname,
},
}, nil
}
tr := trainingToAPITraining(newTr)
return CreateTraining201JSONResponse{
CreateTrainingResponseJSONResponse(tr),
}, nil
}
func (s *Server) GetTrainingByID(ctx context.Context, req GetTrainingByIDRequestObject) (GetTrainingByIDResponseObject, error) {
tr, err := s.trainingRepository.FindByID(ctx, req.TrainingID)
if err != nil {
return GetTrainingByID404ApplicationProblemPlusJSONResponse{
// returning response and nil as error
// since we suppress the error in code here and return an error response instead
NotFoundErrorApplicationProblemPlusJSONResponse{
Title: "Not Found Training by ID",
Detail: err.Error(),
Instance: s.hostname,
},
}, nil
}
trAPI := trainingToAPITraining(tr)
return GetTrainingByID200JSONResponse{
GetTrainingResponseJSONResponse(trAPI),
}, nil
}
func (s *Server) ListTrainings(ctx context.Context, _ ListTrainingsRequestObject) (ListTrainingsResponseObject, error) {
trs, err := s.trainingRepository.FindAll(ctx)
if err != nil {
return ListTrainings500ApplicationProblemPlusJSONResponse{
// returning response and nil as error
// since we suppress the error in code here and return an error response instead
InternalErrorApplicationProblemPlusJSONResponse{
Title: "Internal Server Error",
Detail: err.Error(),
Instance: s.hostname,
},
}, nil
}
var trAPIs []Training
for _, tr := range trs {
trAPIs = append(trAPIs, trainingToAPITraining(&tr))
}
return ListTrainings200JSONResponse{
ListTrainingsResponseJSONResponse{
Trainings: &trAPIs,
},
}, nil
}
func (s *Server) PublishTraining(ctx context.Context, req PublishTrainingRequestObject) (PublishTrainingResponseObject, error) {
err := s.trainingRepository.Publish(ctx, req.TrainingID)
if err != nil {
return PublishTraining500ApplicationProblemPlusJSONResponse{
// returning response and nil as error
// since we suppress the error in code here and return an error response instead
InternalErrorApplicationProblemPlusJSONResponse{
Title: "Internal Server Error",
Detail: err.Error(),
Instance: s.hostname,
},
}, nil
}
return PublishTraining204Response{}, nil
}
func (s *Server) UnpublishTraining(ctx context.Context, req UnpublishTrainingRequestObject) (UnpublishTrainingResponseObject, error) {
err := s.trainingRepository.Unpublish(ctx, req.TrainingID)
if err != nil {
return UnpublishTraining500ApplicationProblemPlusJSONResponse{
// returning response and nil as error
// since we suppress the error in code here and return an error response instead
InternalErrorApplicationProblemPlusJSONResponse{
Title: "Internal Server Error",
Detail: err.Error(),
Instance: s.hostname,
},
}, nil
}
return UnpublishTraining204Response{}, nil
}
func (s *Server) RetireTraining(ctx context.Context, req RetireTrainingRequestObject) (RetireTrainingResponseObject, error) {
err := s.trainingRepository.Retire(ctx, req.TrainingID)
if err != nil {
return RetireTraining500ApplicationProblemPlusJSONResponse{
// returning response and nil as error
// since we suppress the error in code here and return an error response instead
InternalErrorApplicationProblemPlusJSONResponse{
Title: "Internal Server Error",
Detail: err.Error(),
Instance: s.hostname,
},
}, nil
}
return RetireTraining204Response{}, nil
}
func trainingToAPITraining(t *training.Training) Training {
tPricing := t.Pricing()
pricing := make([]TrainingPrice, len(tPricing))
for i, p := range tPricing {
pricing[i] = TrainingPrice{
Currency: p.Currency,
Amount: p.Amount.String(),
Type: TrainingPriceType(p.Type),
}
}
return Training{
Id: t.ID(),
Name: t.Name(),
Days: t.Days(),
Published: t.Published(),
Retired: t.Retired(),
Pricing: pricing,
}
}

View file

@ -0,0 +1,7 @@
package training
type ErrNotFound struct{}
func (ErrNotFound) Error() string {
return "not found"
}

View file

@ -0,0 +1,105 @@
package training
import (
"context"
"sync"
)
type InMemoryRepository struct {
m sync.Mutex
nextID ID
trainings map[ID]*Training
}
type InMemoryPricingRepository struct{}
func NewInMemoryRepository() *InMemoryRepository {
return &InMemoryRepository{
m: sync.Mutex{},
nextID: 1,
trainings: make(map[ID]*Training),
}
}
func (r *InMemoryRepository) FindAll(_ context.Context) ([]Training, error) {
r.m.Lock()
defer r.m.Unlock()
var trainings []Training
for _, t := range r.trainings {
trainings = append(trainings, *t)
}
return trainings, nil
}
func (r *InMemoryRepository) FindByID(_ context.Context, id ID) (*Training, error) {
r.m.Lock()
defer r.m.Unlock()
t, ok := r.trainings[id]
if !ok {
return nil, &ErrNotFound{}
}
return t, nil
}
func (r *InMemoryRepository) Create(_ context.Context, training *Training) error {
r.m.Lock()
defer r.m.Unlock()
training.id = r.nextID
r.nextID++
r.trainings[training.id] = training
return nil
}
func (r *InMemoryRepository) Update(_ context.Context, training *Training) error {
r.m.Lock()
defer r.m.Unlock()
if _, ok := r.trainings[training.id]; !ok {
return &ErrNotFound{}
}
r.trainings[training.id] = training
return nil
}
func (r *InMemoryRepository) Publish(_ context.Context, id ID) error {
r.m.Lock()
defer r.m.Unlock()
t, ok := r.trainings[id]
if !ok {
return &ErrNotFound{}
}
t.published = true
return nil
}
func (r *InMemoryRepository) Unpublish(_ context.Context, id ID) error {
r.m.Lock()
defer r.m.Unlock()
t, ok := r.trainings[id]
if !ok {
return &ErrNotFound{}
}
t.published = false
return nil
}
func (r *InMemoryRepository) Retire(_ context.Context, id ID) error {
r.m.Lock()
defer r.m.Unlock()
t, ok := r.trainings[id]
if !ok {
return &ErrNotFound{}
}
t.retired = true
return nil
}

View file

@ -0,0 +1,76 @@
package training
import (
"github.com/shopspring/decimal"
"gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/currency"
)
type ID = int
type Training struct {
id ID // unique and auto-incrementing
published bool
retired bool
days int8
// In the future, this should be localized
name string
pricing []Price `db:"-"`
}
type Price struct {
Amount decimal.Decimal
Currency currency.Currency
Type PriceType
}
type PriceType string
const (
OpenTrainingPriceType PriceType = "OPEN"
CorporateTrainingPriceType PriceType = "CORPORATE"
)
func NewTraining(name string, days int8, pricing []Price) *Training {
t := &Training{
// metadata
published: false,
retired: false,
// data
name: name,
days: days,
pricing: make([]Price, 0),
}
if pricing != nil {
t.pricing = pricing
}
return t
}
func (t *Training) ID() ID {
return t.id
}
func (t *Training) Days() int8 {
return t.days
}
func (t *Training) Published() bool {
return t.published
}
func (t *Training) Retired() bool {
return t.retired
}
func (t *Training) Name() string {
return t.name
}
func (t *Training) Pricing() []Price {
return t.pricing
}

View file

@ -0,0 +1,129 @@
package training
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"log"
)
type PostgresRepository struct {
pg *pgxpool.Pool
logger *log.Logger
}
func NewPostgresRepository(logger *log.Logger, pg *pgxpool.Pool) *PostgresRepository {
return &PostgresRepository{
pg: pg,
logger: logger,
}
}
func (r *PostgresRepository) Create(ctx context.Context, training *Training) error {
tx, txErr := r.pg.Begin(ctx)
if txErr != nil {
return txErr
}
queryErr := tx.QueryRow(ctx, `
INSERT INTO training.trainings (name, days, published)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, training.name, training.days, training.published).Scan(&training.id)
if queryErr != nil {
return queryErr
}
// TODO: insert pricing using the transaction (tx)
return nil
}
func (r *PostgresRepository) Update(ctx context.Context, training *Training) error {
_, err := r.pg.Exec(ctx, `
UPDATE training.trainings
SET title = $2
WHERE id = $1
`, training.ID, training.name)
if err != nil {
r.logger.Printf("error: %v\n", err)
return err
}
return nil
}
func (r *PostgresRepository) FindByID(ctx context.Context, id ID) (*Training, error) {
var t Training
err := r.pg.QueryRow(ctx, `
SELECT id, title, published, retired
FROM training.trainings
WHERE id = $1
`, id).Scan(&t.id, &t.name, &t.published, &t.retired)
if err != nil {
r.logger.Printf("error: %v\n", err)
return nil, err
}
return &t, nil
}
func (r *PostgresRepository) FindAll(ctx context.Context) ([]Training, error) {
rows, err := r.pg.Query(ctx, `
SELECT id, title, published, retired
FROM training.trainings
`)
if err != nil {
r.logger.Printf("error: %v\n", err)
return nil, err
}
defer rows.Close()
var trainings []Training
for rows.Next() {
var t Training
err := rows.Scan(&t.id, &t.name, &t.published, &t.retired)
if err != nil {
r.logger.Printf("error: %v\n", err)
return nil, err
}
trainings = append(trainings, t)
}
return trainings, nil
}
func (r *PostgresRepository) Publish(ctx context.Context, id ID) error {
_, err := r.pg.Exec(ctx, `
UPDATE training.trainings
SET published = true
WHERE id = $1
`, id)
if err != nil {
r.logger.Printf("error: %v\n", err)
return err
}
return nil
}
func (r *PostgresRepository) Unpublish(ctx context.Context, id ID) error {
_, err := r.pg.Exec(ctx, `
UPDATE training.trainings
SET published = false
WHERE id = $1
`, id)
if err != nil {
r.logger.Printf("error: %v\n", err)
return err
}
return nil
}
func (r *PostgresRepository) Retire(ctx context.Context, id ID) error {
_, err := r.pg.Exec(ctx, `
UPDATE training.trainings
SET retired = true
WHERE id = $1
`, id)
if err != nil {
r.logger.Printf("error: %v\n", err)
return err
}
return nil
}

View file

@ -0,0 +1,21 @@
package training
import (
"context"
)
type Repository interface {
FindAll(ctx context.Context) ([]Training, error)
FindByID(ctx context.Context, id ID) (*Training, error)
Create(ctx context.Context, training *Training) error
Update(ctx context.Context, training *Training) error
Publish(ctx context.Context, id ID) error
Unpublish(ctx context.Context, id ID) error
Retire(ctx context.Context, id ID) error
}
type PricingRepository interface {
UpdateForCurrency(ctx context.Context, trainingID ID, currency string, price float64) error
AddCurrency(ctx context.Context, trainingID ID, currency string, price float64) error
RemoveCurrency(ctx context.Context, trainingID ID, currency string) error
}