From 49e05cac107eafc1ae8fabafde07349bb50c2c14 Mon Sep 17 00:00:00 2001 From: Vojtech Mares Date: Sat, 4 May 2024 18:21:37 +0200 Subject: [PATCH] WIP: EOL --- .redocly.lint-ignore.yaml | 34 ++++ Makefile | 18 ++ api/v1/openapi.yaml | 206 +++-------------------- cmd/migrate.go | 20 +++ cmd/migrate_up.go | 26 +++ cmd/server.go | 20 +++ cmd/version.go | 21 +++ db/migrations/20240329172935_init.up.sql | 50 +++--- go.mod | 2 + internal/api/api.gen.go | 83 +++++---- internal/api/server.go | 126 ++++++++++++++ internal/currency/currency.go | 13 ++ internal/postgres/attendee_repository.go | 1 + internal/postgres/date_repository.go | 1 + internal/postgres/training_repository.go | 1 + internal/server/config.go | 7 + internal/server/server.go | 145 ++++++++++++++++ internal/version/version.go | 6 + pkg/training/attendee.go | 5 + pkg/training/date.go | 28 +-- pkg/training/price.go | 21 ++- pkg/training/training.go | 7 +- redocly.yaml | 25 +++ 23 files changed, 613 insertions(+), 253 deletions(-) create mode 100644 .redocly.lint-ignore.yaml create mode 100644 cmd/migrate.go create mode 100644 cmd/migrate_up.go create mode 100644 cmd/server.go create mode 100644 cmd/version.go create mode 100644 internal/api/server.go create mode 100644 internal/currency/currency.go create mode 100644 internal/server/config.go create mode 100644 internal/server/server.go create mode 100644 internal/version/version.go create mode 100644 redocly.yaml diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml new file mode 100644 index 0000000..d7d8b44 --- /dev/null +++ b/.redocly.lint-ignore.yaml @@ -0,0 +1,34 @@ +# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. +# See https://redoc.ly/docs/cli/ for more information. +api/v1/openapi.yaml: + info-license-url: + - '#/info/license/url' + no-server-example.com: + - '#/servers/0/url' + operation-4xx-response: + - '#/paths/~1trainings/get/responses' + - '#/paths/~1trainings~1{trainingID}~1dates/get/responses' + - >- + #/paths/~1trainings~1{trainingID}~1dates~1{dateID}~1attendees/get/responses + - '#/paths/~1trainings~1{trainingID}~1dates~1{dateID}~1feedback/get/responses' + - '#/paths/~1trainings~1{trainingID}~1feedback/get/responses' + - '#/paths/~1trainings~1upcoming-dates/get/responses' + security-defined: + - '#/paths/~1trainings/get' + - '#/paths/~1trainings/post' + - '#/paths/~1trainings~1{trainingID}/get' + - '#/paths/~1trainings~1{trainingID}/put' + - '#/paths/~1trainings~1{trainingID}/delete' + - '#/paths/~1trainings~1{trainingID}~1dates/get' + - '#/paths/~1trainings~1{trainingID}~1dates/post' + - '#/paths/~1trainings~1{trainingID}~1dates~1{dateID}/put' + - '#/paths/~1trainings~1{trainingID}~1dates~1{dateID}/delete' + - '#/paths/~1trainings~1{trainingID}~1dates~1{dateID}~1attendees/get' + - '#/paths/~1trainings~1{trainingID}~1dates~1{dateID}~1attendees/post' + - >- + #/paths/~1trainings~1{trainingID}~1dates~1{dateID}~1attendees~1{attendeeID}/delete + - >- + #/paths/~1trainings~1{trainingID}~1dates~1{dateID}~1attendees~1{attendeeID}~1feedback/post + - '#/paths/~1trainings~1{trainingID}~1dates~1{dateID}~1feedback/get' + - '#/paths/~1trainings~1{trainingID}~1feedback/get' + - '#/paths/~1trainings~1upcoming-dates/get' diff --git a/Makefile b/Makefile index 5758993..3e3ba98 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,21 @@ .PHONY: generate generate: go generate . + +POSTGRES_URL="postgres://official:backoffice@localhost:5432/backoffice_dev?sslmode=disable" + +.PHONY: local-migrate-up +local-migrate-up: + migrate -database ${POSTGRES_URL} -path ./db/migrations up + +.PHONY: local-migrate-force +local-migrate-force: + migrate -database ${POSTGRES_URL} -path ./db/migrations force + +.PHONY: local-migrate-down +local-migrate-down: + migrate -database ${POSTGRES_URL} -path ./db/migrations down + +.PHONY: local-migrate-drop +local-migrate-drop: + migrate -database ${POSTGRES_URL} -path ./db/migrations drop -f diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index 902c529..cf30217 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -493,139 +493,6 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" - /trainings/{trainingID}/dates/{dateID}/attendees/{attendeeID}/feedback: - post: - summary: Submit feedback for an attendee of a date of a training - operationId: createTrainingDateAttendeeFeedback - tags: - - training attendee feedback - parameters: - - name: trainingID - in: path - required: true - schema: - type: string - format: uuid - x-go-type: training.ID - x-go-type-import: - path: gitlab.mareshq.com/hq/backoffice/backoffice-api/pkg/training - - name: dateID - in: path - required: true - schema: - type: string - format: uuid - x-go-type: training.DateID - x-go-type-import: - path: gitlab.mareshq.com/hq/backoffice/backoffice-api/pkg/training - - name: attendeeID - in: path - required: true - schema: - type: string - format: uuid - x-go-type: training.AttendeeID - x-go-type-import: - path: gitlab.mareshq.com/hq/backoffice/backoffice-api/pkg/training - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/NewTrainingFeedback" - responses: - "201": - description: Feedback created - content: - application/json: - schema: - $ref: "#/components/schemas/TrainingFeedback" - "409": - description: Feedback already submitted - content: - application/problem+json: - schema: - $ref: "#/components/schemas/ProblemDetails" - "500": - description: Internal error - content: - application/problem+json: - schema: - $ref: "#/components/schemas/ProblemDetails" - - /trainings/{trainingID}/dates/{dateID}/feedback: - get: - summary: List all feedback of a date of a training - operationId: listTrainingDateFeedback - tags: - - training attendee feedback - parameters: - - name: trainingID - in: path - required: true - schema: - type: string - format: uuid - x-go-type: training.ID - x-go-type-import: - path: gitlab.mareshq.com/hq/backoffice/backoffice-api/pkg/training - - name: dateID - in: path - required: true - schema: - type: string - format: uuid - x-go-type: training.DateID - x-go-type-import: - path: gitlab.mareshq.com/hq/backoffice/backoffice-api/pkg/training - responses: - "200": - description: A list of feedback - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/TrainingFeedback" - "500": - description: Internal error - content: - application/problem+json: - schema: - $ref: "#/components/schemas/ProblemDetails" - - /trainings/{trainingID}/feedback: - get: - summary: List all feedback of a training - operationId: listTrainingFeedback - tags: - - training attendee feedback - parameters: - - name: trainingID - in: path - required: true - schema: - type: string - format: uuid - x-go-type: training.ID - x-go-type-import: - path: gitlab.mareshq.com/hq/backoffice/backoffice-api/pkg/training - responses: - "200": - description: A list of feedback - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/TrainingFeedback" - "500": - description: Internal error - content: - application/problem+json: - schema: - $ref: "#/components/schemas/ProblemDetails" - /trainings/upcoming-dates: get: summary: List all upcoming dates of all trainings @@ -679,22 +546,14 @@ components: properties: name: type: string - duration: + days: type: integer format: int32 price: - type: object - properties: - open: - $ref: "#/components/schemas/Price" - corporate: - $ref: "#/components/schemas/Price" - length: - type: integer - format: int32 + $ref: "#/components/schemas/TrainingPrice" required: - name - - duration + - days - price Training: allOf: @@ -710,6 +569,31 @@ components: required: - id + TrainingPrice: + types: array + items: + type: object + required: + - type + - price + properties: + type: + type: string + enum: [open, corporate] + price: + type: object + required: + - currency + - amount + properties: + currency: + type: string + enum: [CZK, EUR, USD] + amount: + type: number + format: float64 + minimum: 0 + NewTrainingDate: type: object properties: @@ -776,40 +660,6 @@ components: required: - id - NewTrainingFeedback: - type: object - properties: - rating: - type: integer - format: int32 - minimum: 0 - maximum: 10 - comment: - type: string - anonymous: - type: boolean - default: false - isSharingAllowed: - type: boolean - default: false - required: - - rating - - comment - - TrainingFeedback: - allOf: - - $ref: "#/components/schemas/NewTrainingFeedback" - - type: object - properties: - id: - type: string - format: uuid - x-go-type: training.FeedbackID - x-go-type-import: - path: gitlab.mareshq.com/hq/backoffice/backoffice-api/pkg/training - required: - - id - Price: type: object properties: diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000..ffd2a73 --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(versionCmd) +} + +var migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Migrate database schema up/down", + Long: `All software has versions. This is backoffice backend's`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Migrate database schema up/down") + }, +} diff --git a/cmd/migrate_up.go b/cmd/migrate_up.go new file mode 100644 index 0000000..c042707 --- /dev/null +++ b/cmd/migrate_up.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func init() { + migrateCmd.AddCommand(migrateUpCmd) +} + +var migrateUpCmd = &cobra.Command{ + Use: "up", + Short: "Migrate database schema up", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + // TODO: Implement this + + // TODO: get database URL + + // TODO: run migrations + + // TODO: handle errors + + // TODO: gracefully shutdown + }, +} diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..58610ba --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/server" +) + +func init() { + rootCmd.AddCommand(serverCmd) +} + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Starts backoffice backend API server", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + srv := server.NewServer() + srv.Run() + }, +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..ac6a402 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/version" +) + +func init() { + rootCmd.AddCommand(versionCmd) +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of backoffice backend", + Long: `All software has versions. This is backoffice backend's`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Version: %s (commit: %s)\n", version.Version, version.Commit) + }, +} diff --git a/db/migrations/20240329172935_init.up.sql b/db/migrations/20240329172935_init.up.sql index 8337a00..9ad7be3 100644 --- a/db/migrations/20240329172935_init.up.sql +++ b/db/migrations/20240329172935_init.up.sql @@ -10,40 +10,52 @@ CREATE TABLE IF NOT EXISTS training.trainings ( price decimal NOT NULL ); +CREATE TABLE IF NOT EXISTS training.prices ( + training_id UUID REFERENCES training.trainings(id), + amount NUMERIC(6, 4) NOT NULL, + currency VARCHAR(3) NOT NULL, + CHECK (currency IN ('USD', 'EUR', 'CZK')) + type VARCHAR(10) NOT NULL, + CHECK (type IN ('OPEN', 'CORPORATE', 'STUDENT', 'GOVERNMENT')) + PRIMARY KEY (training_id, currency, type) -- composite primary key +) + CREATE TABLE IF NOT EXISTS training.dates ( id UUID PRIMARY KEY, training_id UUID NOT NULL, - date date NOT NULL, - start_time time NOT NULL, - days smallint NOT NULL, - price decimal NOT NULL, - is_online boolean NOT NULL, - location varchar(255) NOT NULL, - address varchar(255) NOT NULL, - capacity smallint NOT NULL, + date DATE NOT NULL, + start_time TIME NOT NULL, + days SMALLINT NOT NULL, + is_online BOOLEAN NOT NULL, + location VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + capacity SMALLINT NOT NULL, + amount NUMERIC(6, 4) NOT NULL, + currency VARCHAR(3) NOT NULL, + CHECK (currency IN ('USD', 'EUR', 'CZK')) FOREIGN KEY (training_id) REFERENCES training.trainings(id) ); CREATE TABLE IF NOT EXISTS training.attendees ( id UUID PRIMARY KEY, date_id UUID NOT NULL, - name varchar(255) NOT NULL, - email varchar(255) NOT NULL, - company varchar(255) NOT NULL, - role varchar(255) NOT NULL, - is_student boolean NOT NULL, - has_attended boolean NOT NULL, - has_paid boolean NOT NULL, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + company VARCHAR(255) NOT NULL, + role VARCHAR(255) NOT NULL, + is_student BOOLEAN NOT NULL, + has_attended BOOLEAN NOT NULL, + has_paid BOOLEAN NOT NULL, FOREIGN KEY (date_id) REFERENCES training.dates(id) ); CREATE TABLE IF NOT EXISTS training.feedback ( id UUID PRIMARY KEY, attendee_id UUID NOT NULL, - rating smallint NOT NULL, - comment text NOT NULL, - is_anonymous boolean NOT NULL, - is_sharing_allowed boolean NOT NULL, + rating SMALLINT NOT NULL, + comment TEXT NOT NULL, + is_anonymous BOOLEAN NOT NULL, + is_sharing_allowed BOOLEAN NOT NULL, FOREIGN KEY (attendee_id) REFERENCES training.attendees(id) ); diff --git a/go.mod b/go.mod index 2b4a498..d31a9cc 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.22.0 require ( github.com/deepmap/oapi-codegen/v2 v2.1.0 github.com/getkin/kin-openapi v0.122.0 + github.com/gofiber/contrib/fiberzap/v2 v2.1.3 github.com/gofiber/contrib/swagger v1.1.2 github.com/gofiber/fiber/v2 v2.52.4 github.com/google/uuid v1.5.0 github.com/oapi-codegen/fiber-middleware v1.0.1 github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/cobra v1.8.0 + go.uber.org/zap v1.27.0 ) require ( diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 00745c7..f4caade 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -29,10 +29,9 @@ const ( // NewTraining defines model for NewTraining. type NewTraining struct { - Duration int32 `json:"duration"` - Length *int32 `json:"length,omitempty"` - Name string `json:"name"` - Price struct { + Days int32 `json:"days"` + Name string `json:"name"` + Price struct { Corporate *Price `json:"corporate,omitempty"` Open *Price `json:"open,omitempty"` } `json:"price"` @@ -90,11 +89,10 @@ type ProblemDetails struct { // Training defines model for Training. type Training struct { - Duration int32 `json:"duration"` - Id training.ID `json:"id"` - Length *int32 `json:"length,omitempty"` - Name string `json:"name"` - Price struct { + Days int32 `json:"days"` + Id training.ID `json:"id"` + Name string `json:"name"` + Price struct { Corporate *Price `json:"corporate,omitempty"` Open *Price `json:"open,omitempty"` } `json:"price"` @@ -1675,40 +1673,39 @@ func (sh *strictHandler) ListTrainingFeedback(ctx *fiber.Ctx, trainingID trainin // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "RgAA", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..c61817b --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,126 @@ +package api + +import ( + "database/sql" + "fmt" + + "github.com/gofiber/fiber/v2" + "gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/postgres" + "gitlab.mareshq.com/hq/backoffice/backoffice-api/pkg/training" + "go.uber.org/zap" +) + +type APIServer struct { + logger *zap.SugaredLogger + + trainingRepo *postgres.TrainingRepository + dateRepo *postgres.DateRepository + attendeeRepo *postgres.AttendeeRepository +} + +func NewAPIServer(db *sql.DB, logger *zap.SugaredLogger) *APIServer { + return &APIServer{ + logger: logger, + + trainingRepo: postgres.NewTrainingRepository(db), + dateRepo: postgres.NewDateRepository(db), + attendeeRepo: postgres.NewAttendeeRepository(db), + } +} + +func (s *APIServer) ListTrainings(c *fiber.Ctx) error { + all, err := s.trainingRepo.FindAll() + if err != nil { + s.logger.Error("ListTrainings", zap.Error(err)) + c.JSON(ListTrainings500ApplicationProblemPlusJSONResponse{ + Status: fiber.StatusInternalServerError, + Title: fmt.Sprintf("Internal server error: %s", "failed to list trainings"), + }) + } + + c.JSON(ListTrainings200JSONResponse{}) +} + +func (s *APIServer) CreateTraining(c *fiber.Ctx) error { + tr := &training.Training{} + if err := c.BodyParser(tr); err != nil { + s.logger.Error("CreateTraining", zap.Error(err)) + c.JSON(CreateTraining400ApplicationProblemPlusJSONResponse{ + Status: fiber.StatusBadRequest, + Title: fmt.Sprintf("Internal server error: %s", "failed to parse request body"), + }) + } + + if err := s.trainingRepo.Create(tr); err != nil { + s.logger.Error("CreateTraining", zap.Error(err)) + c.JSON(CreateTraining500ApplicationProblemPlusJSONResponse{ + Status: fiber.StatusInternalServerError, + Title: fmt.Sprintf("Internal server error: %s", "failed to save training"), + }) + } + + c.JSON(CreateTraining201JSONResponse{ + Id: tr.ID, + Name: tr.Name, + Days: int32(tr.Days), + Price: tr.Price, + }) + +} + +func (s *APIServer) UpdateTraining(c *fiber.Ctx) error { + s.logger.Info("UpdateTraining") +} + +func (s *APIServer) DeleteTraining(c *fiber.Ctx, trainingID training.ID) error { + s.logger.Info("DeleteTraining") +} + +func (s *APIServer) GetTraining(c *fiber.Ctx, trainingID training.ID) error { + s.logger.Info("GetTraining") +} + +func (s *APIServer) ListTrainingDates(c *fiber.Ctx, trainingID training.ID) error { + s.logger.Info("ListTrainingDates") +} + +func (s *APIServer) CreateTrainingDate(c *fiber.Ctx, trainingID training.ID) error { + s.logger.Info("CreateTrainingDate") +} + +func (s *APIServer) UpdateTrainingDate(c *fiber.Ctx, trainingID training.ID, dateID training.DateID) error { + s.logger.Info("UpdateTrainingDate") +} + +func (s *APIServer) DeleteTrainingDate(c *fiber.Ctx, trainingID training.ID, dateID training.DateID) error { + s.logger.Info("DeleteTrainingDate") +} + +func (s *APIServer) ListUpcomingTrainingDates(c *fiber.Ctx, params ListUpcomingTrainingDatesParams) error { + s.logger.Info("ListUpcomingTrainingDates") + return nil +} + +func (s *APIServer) ListTrainingDateAttendees(c *fiber.Ctx, trainingID training.ID, dateID training.DateID) error { + s.logger.Info("ListTrainingDateAttendees") +} + +func (s *APIServer) CreateTrainingDateAttendee(c *fiber.Ctx, trainingID training.ID, dateID training.DateID) error { + s.logger.Info("CreateTrainingDateAttendee") +} + +func (s *APIServer) DeleteTrainingDateAttendee(c *fiber.Ctx, trainingID training.ID, dateID training.DateID, attendeeID training.AttendeeID) error { + s.logger.Info("DeleteTrainingDateAttendee") +} + +func (s *APIServer) CreateTrainingDateAttendeeFeedback(c *fiber.Ctx, trainingID training.ID, dateID training.DateID, attendeeID training.AttendeeID) error { + s.logger.Info("CreateTrainingDateAttendeeFeedback") +} + +func (s *APIServer) ListTrainingDateFeedback(c *fiber.Ctx, trainingID training.ID, dateID training.DateID) error { + s.logger.Info("ListTrainingDateFeedback") +} + +func (s *APIServer) ListTrainingFeedback(c *fiber.Ctx, trainingID training.ID) error { + s.logger.Info("ListTrainingFeedback") +} diff --git a/internal/currency/currency.go b/internal/currency/currency.go new file mode 100644 index 0000000..67af763 --- /dev/null +++ b/internal/currency/currency.go @@ -0,0 +1,13 @@ +package currency + +type Currency string + +var ( + Currencies = []Currency{CZK, EUR, USD} +) + +const ( + CZK Currency = "CZK" + EUR Currency = "EUR" + USD Currency = "USD" +) diff --git a/internal/postgres/attendee_repository.go b/internal/postgres/attendee_repository.go index 3f6c6ea..b0f40c8 100644 --- a/internal/postgres/attendee_repository.go +++ b/internal/postgres/attendee_repository.go @@ -77,6 +77,7 @@ func (r *AttendeeRepository) CountForDate(dateID training.DateID) (int, error) { } func (r *AttendeeRepository) Create(a *training.Attendee) error { + a.ID = training.NewAttendeeID() _, err := r.db.Exec("INSERT INTO attendee (id, date_id, name, email, company, role, is_student, has_attended, has_paid) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", a.ID, a.DateID, a.Name, a.Email, a.Company, a.Role, a.IsStudent, a.HasAttended, a.HasPaid) return err } diff --git a/internal/postgres/date_repository.go b/internal/postgres/date_repository.go index 50bab6a..36d43d6 100644 --- a/internal/postgres/date_repository.go +++ b/internal/postgres/date_repository.go @@ -69,6 +69,7 @@ func (r *DateRepository) FindAllForTraining(id training.ID) ([]training.Date, er } func (r *DateRepository) Create(d *training.Date) error { + d.ID = training.NewDateID() _, err := r.db.Exec("INSERT INTO date (id, date, training_id, start_time, days, price, is_online, location, address, capacity) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", d.ID, d.Date, d.TrainingID, d.StartTime, d.Days, d.Price, d.IsOnline, d.Location, d.Address, d.Capacity) return err } diff --git a/internal/postgres/training_repository.go b/internal/postgres/training_repository.go index cb95166..276217a 100644 --- a/internal/postgres/training_repository.go +++ b/internal/postgres/training_repository.go @@ -15,6 +15,7 @@ func NewTrainingRepository(db *sql.DB) *TrainingRepository { } func (r *TrainingRepository) Create(t *training.Training) error { + t.ID = training.NewID() _, err := r.db.Exec("INSERT INTO training (id, name, days, description, price) VALUES ($1, $2, $3)", t.ID, t.Name, t.Days, t.Description, t.Price) return err } diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..9970144 --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,7 @@ +package server + +type Config struct { + PostgresURL string + Port int + ShutdownGraceSeconds int +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..6be185b --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,145 @@ +package server + +import ( + "context" + "os" + "os/signal" + "sync" + "syscall" + "time" + + fiberzap "github.com/gofiber/contrib/fiberzap/v2" + "github.com/gofiber/contrib/swagger" + "github.com/gofiber/fiber/v2" + middleware "github.com/oapi-codegen/fiber-middleware" + "gitlab.mareshq.com/hq/backoffice/backoffice-api/internal/api" + "go.uber.org/zap" +) + +type Server struct { + ready ready +} + +func NewServer() *Server { + return &Server{} +} + +func (s *Server) Run() { + shutdownCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + logger, err := zap.NewProduction() + if err != nil { + logger.Fatal("failed to initialize logger", zap.Error(err)) + } + defer logger.Sync() + sugaredLogger := logger.Sugar() + + fiberConfig := fiber.Config{ + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 5 * time.Second, + + DisableStartupMessage: true, + } + + app := fiber.New(fiberConfig) + + app.Get("/", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusNotFound) + }) + + app.Get("/livez", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + app.Get("/readyz", func(c *fiber.Ctx) error { + if s.ready.isReady() { + return c.SendStatus(fiber.StatusOK) + } + + return c.SendStatus(fiber.StatusServiceUnavailable) + }) + + // TODO: add /metrics endpoint + + app.Use(fiberzap.New(fiberzap.Config{ + Logger: logger, + })) + + swaggerConfig := swagger.Config{ + BasePath: "/", + FilePath: "./api/v1/openapi.yaml", + Path: "/swagger", + Title: "Swagger API Docs", + } + + app.Use(swagger.New(swaggerConfig)) + + app = registerAPIHandlers(app, sugaredLogger) + + <-shutdownCtx.Done() + stop() + s.ready.notReady() + + // wait till Pod is signaling not ready (no new requests) + time.Sleep(5 * time.Second) + + shutdownBegan := time.Now() + + sugaredLogger.Info("Shutdown signal recieved. Shutting down gracefully...") + + // * Gracefully shut down fiber server + // * If does not shutdown within `gracefulShutdownPeriod`, force shutdown + // ! Does not close keep-alive connections + // ! fiber.Config{ReadTimeout} must be set and non-zero and greater than the `gracefulShutdownPeriod` + // TODO: make shutdown timeout configurable + err = app.ShutdownWithTimeout(time.Second * 10) + if err != nil { + sugaredLogger.Errorw("Error during shutdown", "error", err) + } + + sugaredLogger.Infof("Shutdown completed in %v", time.Since(shutdownBegan)) +} + +func registerAPIHandlers(fiberApp *fiber.App, logger *zap.SugaredLogger) *fiber.App { + swagger, err := api.GetSwagger() + if err != nil { + logger.Fatalf("Error getting swagger: %v", err) + } + + // TODO: validate this. Copied from example + // See: https://github.com/deepmap/oapi-codegen/blob/master/examples/petstore-expanded/fiber/petstore.go#L41 + // 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 + + fiberApp.Use(middleware.OapiRequestValidator(swagger)) + + api.RegisterHandlers(fiberApp) + + return fiberApp +} + +type ready struct { + ready bool + lock sync.Mutex +} + +func (r *ready) setReady() { + r.lock.Lock() + defer r.lock.Unlock() + r.ready = true +} + +func (r *ready) isReady() bool { + r.lock.Lock() + defer r.lock.Unlock() + return r.ready +} + +func (r *ready) notReady() { + r.lock.Lock() + defer r.lock.Unlock() + r.ready = false +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..d555de3 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,6 @@ +package version + +var ( + Version = "dev" + Commit = "N/A" +) diff --git a/pkg/training/attendee.go b/pkg/training/attendee.go index 9e7c923..5334923 100644 --- a/pkg/training/attendee.go +++ b/pkg/training/attendee.go @@ -4,6 +4,11 @@ import "github.com/google/uuid" type AttendeeID uuid.UUID +func NewAttendeeID() AttendeeID { + id := uuid.Must(uuid.NewV7()) + return AttendeeID(id) +} + type Attendee struct { ID AttendeeID DateID DateID diff --git a/pkg/training/date.go b/pkg/training/date.go index dd283b0..365b205 100644 --- a/pkg/training/date.go +++ b/pkg/training/date.go @@ -8,15 +8,21 @@ import ( type DateID uuid.UUID -type Date struct { - ID DateID - TrainingID ID - Date time.Time - StartTime time.Time - Days int8 - Price Price - IsOnline bool - Location string // could be empty (null) for example: Prague, Brno, London, ... - Address string // could be empty (null) - Capacity int8 +func NewDateID() DateID { + id := uuid.Must(uuid.NewV7()) + return DateID(id) +} + +type Date struct { + ID DateID + TrainingID ID + Date time.Time + StartTime time.Time + Days int8 + IsOnline bool + Location string // could be empty (null) for example: Prague, Brno, London, ... + Address string // could be empty (null) + Capacity int8 + PriceAmount float64 + PriceCurrency string } diff --git a/pkg/training/price.go b/pkg/training/price.go index 7c19588..47cb16e 100644 --- a/pkg/training/price.go +++ b/pkg/training/price.go @@ -1,3 +1,22 @@ package training -type Price float32 +// type Price float32 + +type TrainingPrice struct { + Currency string `json:"currency"` + Amount float64 `json:"amount"` + Type PriceType `json:"type"` // open | corporate +} + +type PriceType string + +var ( + PriceTypes = []PriceType{OpenPrice, CorporatePrice, StudentPrice, GovernmentPrice} +) + +const ( + OpenPrice PriceType = "OPEN" + CorporatePrice PriceType = "CORPORATE" + StudentPrice PriceType = "STUDENT" + GovernmentPrice PriceType = "GOVERNMENT" +) diff --git a/pkg/training/training.go b/pkg/training/training.go index 8e89aa1..366b2d9 100644 --- a/pkg/training/training.go +++ b/pkg/training/training.go @@ -6,10 +6,15 @@ import ( type ID uuid.UUID +func NewID() ID { + id := uuid.Must(uuid.NewV7()) + return ID(id) +} + type Training struct { ID ID Days int8 Name string Description string - Price Price + Price []TrainingPrice } diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000..f220d31 --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,25 @@ +# See https://redocly.com/docs/cli/configuration/ + +extends: + - recommended + +apis: + backoffice@v1: + root: ./api/v1/openapi.yaml + rules: + no-ambiguous-paths: error + +rules: + no-unused-components: error + operation-singular-tag: warn + boolean-parameter-prefixes: + severity: error + prefixes: ["can", "is", "has"] + +theme: + openapi: + generateCodeSamples: + languages: + - lang: curl + - lang: JavaScript + - lang: Go