aboutsummaryrefslogtreecommitdiffstats
path: root/api
diff options
context:
space:
mode:
Diffstat (limited to 'api')
-rw-r--r--api/dto/favourites.go34
-rw-r--r--api/dto/response.go21
-rw-r--r--api/dto/schedule.go10
-rw-r--r--api/dto/users.go20
-rw-r--r--api/handlers/favourites.go112
-rw-r--r--api/handlers/schedule.go25
-rw-r--r--api/handlers/users.go112
-rw-r--r--api/handlers/util.go28
-rw-r--r--api/middleware/auth.go46
-rw-r--r--api/router.go67
10 files changed, 475 insertions, 0 deletions
diff --git a/api/dto/favourites.go b/api/dto/favourites.go
new file mode 100644
index 0000000..0f8021f
--- /dev/null
+++ b/api/dto/favourites.go
@@ -0,0 +1,34 @@
+package dto
+
+import "github.com/LMBishop/confplanner/pkg/database/sqlc"
+
+type CreateFavouritesRequest struct {
+ GUID *string `json:"eventGuid"`
+ ID *int32 `json:"eventId"`
+}
+
+type CreateFavouritesResponse struct {
+ ID int32 `json:"id"`
+}
+
+type GetFavouritesResponse struct {
+ ID int32 `json:"id"`
+ GUID *string `json:"eventGuid,omitempty"`
+ EventID *int32 `json:"eventId,omitempty"`
+}
+
+func (dst *GetFavouritesResponse) Scan(src sqlc.Favourite) {
+ dst.ID = src.ID
+ if src.EventGuid.Valid {
+ strGuid := src.EventGuid.String()
+ dst.GUID = &strGuid
+ }
+ if src.EventID.Valid {
+ dst.EventID = &src.EventID.Int32
+ }
+}
+
+type DeleteFavouritesRequest struct {
+ GUID *string `json:"eventGuid"`
+ ID *int32 `json:"eventId"`
+}
diff --git a/api/dto/response.go b/api/dto/response.go
new file mode 100644
index 0000000..43f98bd
--- /dev/null
+++ b/api/dto/response.go
@@ -0,0 +1,21 @@
+package dto
+
+import "fmt"
+
+type OkResponse struct {
+ Code int `json:"code"`
+ Data interface{} `json:"data,omitempty"`
+}
+
+type ErrorResponse struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+func (r *OkResponse) Error() string {
+ return fmt.Sprintf("HTTP status %d", r.Code)
+}
+
+func (r *ErrorResponse) Error() string {
+ return fmt.Sprintf("HTTP status %d: %s", r.Code, r.Message)
+}
diff --git a/api/dto/schedule.go b/api/dto/schedule.go
new file mode 100644
index 0000000..0d652aa
--- /dev/null
+++ b/api/dto/schedule.go
@@ -0,0 +1,10 @@
+package dto
+
+import (
+ "time"
+)
+
+type GetScheduleResponse struct {
+ Schedule interface{} `json:"schedule"`
+ LastUpdated time.Time `json:"lastUpdated"`
+}
diff --git a/api/dto/users.go b/api/dto/users.go
new file mode 100644
index 0000000..685fa07
--- /dev/null
+++ b/api/dto/users.go
@@ -0,0 +1,20 @@
+package dto
+
+type RegisterRequest struct {
+ Username string `json:"username" validate:"required"`
+ Password string `json:"password" validate:"required"`
+}
+
+type RegisterResponse struct {
+ ID int32 `json:"id"`
+}
+
+type LoginRequest struct {
+ Username string `json:"username" validate:"required"`
+ Password string `json:"password" validate:"required"`
+}
+
+type LoginResponse struct {
+ ID int32 `json:"id"`
+ Username string `json:"username"`
+}
diff --git a/api/handlers/favourites.go b/api/handlers/favourites.go
new file mode 100644
index 0000000..862d366
--- /dev/null
+++ b/api/handlers/favourites.go
@@ -0,0 +1,112 @@
+package handlers
+
+import (
+ "github.com/LMBishop/confplanner/api/dto"
+ "github.com/LMBishop/confplanner/pkg/favourites"
+ "github.com/gofiber/fiber/v2"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func CreateFavourite(service favourites.Service) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var request dto.CreateFavouritesRequest
+ if err := readBody(c, &request); err != nil {
+ return err
+ }
+
+ if request.GUID == nil && request.ID == nil {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusBadRequest,
+ Message: "One of event GUID or event ID must be specified",
+ }
+ }
+
+ uid := c.Locals("uid").(int32)
+ var uuid pgtype.UUID
+ if request.GUID != nil {
+ if err := uuid.Scan(*request.GUID); err != nil {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusBadRequest,
+ Message: "Bad event GUID",
+ }
+ }
+ }
+
+ createdFavourite, err := service.CreateFavouriteForUser(uid, uuid, request.ID)
+ if err != nil {
+ return err
+ }
+
+ return &dto.OkResponse{
+ Code: fiber.StatusCreated,
+ Data: &dto.CreateFavouritesResponse{
+ ID: createdFavourite.ID,
+ },
+ }
+ }
+}
+
+func GetFavourites(service favourites.Service) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ uid := c.Locals("uid").(int32)
+
+ favourites, err := service.GetFavouritesForUser(uid)
+ if err != nil {
+ return err
+ }
+
+ favouritesResponse := make([]dto.GetFavouritesResponse, 0)
+ for _, favourite := range *favourites {
+ var favouriteResponse dto.GetFavouritesResponse
+ favouriteResponse.Scan(favourite)
+
+ favouritesResponse = append(favouritesResponse, favouriteResponse)
+ }
+
+ return &dto.OkResponse{
+ Code: fiber.StatusOK,
+ Data: favouritesResponse,
+ }
+ }
+}
+
+func DeleteFavourite(service favourites.Service) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var request dto.DeleteFavouritesRequest
+ if err := readBody(c, &request); err != nil {
+ return err
+ }
+
+ if request.GUID == nil && request.ID == nil {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusBadRequest,
+ Message: "One of event GUID or event ID must be specified",
+ }
+ }
+
+ uid := c.Locals("uid").(int32)
+ var err error
+ var uuid pgtype.UUID
+ if err := uuid.Scan(*request.GUID); err != nil {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusBadRequest,
+ Message: "Bad event GUID",
+ }
+ }
+
+ err = service.DeleteFavouriteForUserByEventDetails(uid, uuid, request.ID)
+ if err != nil {
+ if err == favourites.ErrNotFound {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusNotFound,
+ Message: "Favourite not found",
+ }
+ }
+ return err
+ }
+
+ return &dto.OkResponse{
+ Code: fiber.StatusOK,
+ }
+ }
+}
diff --git a/api/handlers/schedule.go b/api/handlers/schedule.go
new file mode 100644
index 0000000..fd3a183
--- /dev/null
+++ b/api/handlers/schedule.go
@@ -0,0 +1,25 @@
+package handlers
+
+import (
+ "github.com/LMBishop/confplanner/api/dto"
+ "github.com/LMBishop/confplanner/pkg/schedule"
+ "github.com/gofiber/fiber/v2"
+ "github.com/golang-cz/nilslice"
+)
+
+func GetSchedule(service schedule.Service) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ schedule, lastUpdated, err := service.GetSchedule()
+ if err != nil {
+ return err
+ }
+
+ return &dto.OkResponse{
+ Code: fiber.StatusOK,
+ Data: &dto.GetScheduleResponse{
+ Schedule: nilslice.Initialize(*schedule),
+ LastUpdated: *lastUpdated,
+ },
+ }
+ }
+}
diff --git a/api/handlers/users.go b/api/handlers/users.go
new file mode 100644
index 0000000..deda9ca
--- /dev/null
+++ b/api/handlers/users.go
@@ -0,0 +1,112 @@
+package handlers
+
+import (
+ "errors"
+ "time"
+
+ "github.com/LMBishop/confplanner/api/dto"
+ "github.com/LMBishop/confplanner/pkg/user"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/session"
+)
+
+func Register(service user.Service) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var request dto.RegisterRequest
+ if err := readBody(c, &request); err != nil {
+ return err
+ }
+
+ createdUser, err := service.CreateUser(request.Username, request.Password)
+ if err != nil {
+ if errors.Is(err, user.ErrUserExists) {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusConflict,
+ Message: "User with that username already exists",
+ }
+ } else if errors.Is(err, user.ErrNotAcceptingRegistrations) {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusForbidden,
+ Message: "This service is not currently accepting registrations",
+ }
+ }
+
+ return err
+ }
+
+ return &dto.OkResponse{
+ Code: fiber.StatusCreated,
+ Data: &dto.RegisterResponse{
+ ID: createdUser.ID,
+ },
+ }
+ }
+}
+
+func Login(service user.Service, store *session.Store) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ var request dto.LoginRequest
+ if err := readBody(c, &request); err != nil {
+ return err
+ }
+
+ user, err := service.Authenticate(request.Username, request.Password)
+ if err != nil {
+ return err
+ }
+
+ if user == nil {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusBadRequest,
+ Message: "Username and password combination not found",
+ }
+ }
+
+ s, err := store.Get(c)
+ if err != nil {
+ return err
+ }
+
+ if s.Fresh() {
+ uid := user.ID
+ sid := s.ID()
+
+ s.Set("uid", uid)
+ s.Set("sid", sid)
+ s.Set("ip", c.Context().RemoteIP().String())
+ s.Set("login", time.Unix(time.Now().Unix(), 0).UTC().String())
+ s.Set("ua", string(c.Request().Header.UserAgent()))
+
+ err = s.Save()
+ if err != nil {
+ return err
+ }
+ }
+
+ return &dto.OkResponse{
+ Code: fiber.StatusOK,
+ Data: &dto.LoginResponse{
+ ID: user.ID,
+ Username: user.Username,
+ },
+ }
+ }
+}
+
+func Logout(store *session.Store) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ s, err := store.Get(c)
+ if err != nil {
+ return err
+ }
+
+ err = s.Destroy()
+ if err != nil {
+ return err
+ }
+
+ return &dto.OkResponse{
+ Code: fiber.StatusNoContent,
+ }
+ }
+}
diff --git a/api/handlers/util.go b/api/handlers/util.go
new file mode 100644
index 0000000..b0cf344
--- /dev/null
+++ b/api/handlers/util.go
@@ -0,0 +1,28 @@
+package handlers
+
+import (
+ "fmt"
+
+ "github.com/go-playground/validator/v10"
+ "github.com/gofiber/fiber/v2"
+)
+
+var validate = validator.New(validator.WithRequiredStructEnabled())
+
+func readBody(c *fiber.Ctx, request interface{}) error {
+ if err := c.BodyParser(request); err != nil {
+ return &fiber.Error{
+ Code: fiber.StatusBadRequest,
+ Message: fmt.Errorf("Invalid request (%w)", err).Error(),
+ }
+ }
+
+ if err := validate.Struct(request); err != nil {
+ return &fiber.Error{
+ Code: fiber.StatusBadRequest,
+ Message: err.Error(),
+ }
+ }
+
+ return nil
+}
diff --git a/api/middleware/auth.go b/api/middleware/auth.go
new file mode 100644
index 0000000..611276a
--- /dev/null
+++ b/api/middleware/auth.go
@@ -0,0 +1,46 @@
+package middleware
+
+import (
+ "errors"
+
+ "github.com/LMBishop/confplanner/api/dto"
+ "github.com/LMBishop/confplanner/pkg/user"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/session"
+)
+
+func RequireAuthenticated(service user.Service, store *session.Store) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ s, err := store.Get(c)
+ if err != nil {
+ return err
+ }
+
+ if s.Fresh() || len(s.Keys()) == 0 {
+ return &dto.ErrorResponse{
+ Code: fiber.StatusUnauthorized,
+ Message: "Unauthorized",
+ }
+ }
+
+ uid := s.Get("uid").(int32)
+
+ fetchedUser, err := service.GetUserByID(uid)
+ if err != nil {
+ if errors.Is(err, user.ErrUserNotFound) {
+ s.Destroy()
+ return &dto.ErrorResponse{
+ Code: fiber.StatusUnauthorized,
+ Message: "Invalid session",
+ }
+ }
+
+ return err
+ }
+
+ c.Locals("uid", uid)
+ c.Locals("username", fetchedUser.Username)
+
+ return c.Next()
+ }
+}
diff --git a/api/router.go b/api/router.go
new file mode 100644
index 0000000..82a29b1
--- /dev/null
+++ b/api/router.go
@@ -0,0 +1,67 @@
+package api
+
+import (
+ "errors"
+ "log/slog"
+ "time"
+
+ "github.com/LMBishop/confplanner/api/dto"
+ "github.com/LMBishop/confplanner/api/handlers"
+ "github.com/LMBishop/confplanner/api/middleware"
+ "github.com/LMBishop/confplanner/pkg/favourites"
+ "github.com/LMBishop/confplanner/pkg/schedule"
+ "github.com/LMBishop/confplanner/pkg/user"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/session"
+)
+
+type ApiServices struct {
+ UserService user.Service
+ FavouritesService favourites.Service
+ ScheduleService schedule.Service
+}
+
+func NewServer(apiServices ApiServices) *fiber.App {
+ sessionStore := session.New(session.Config{
+ Expiration: 24 * time.Hour,
+ KeyLookup: "cookie:confplanner_session",
+ CookieSameSite: "None",
+ })
+
+ app := fiber.New(fiber.Config{
+ ErrorHandler: func(ctx *fiber.Ctx, err error) error {
+ var ok *dto.OkResponse
+ if errors.As(err, &ok) {
+ return ctx.Status(ok.Code).JSON(ok)
+ }
+
+ var e *dto.ErrorResponse
+ if errors.As(err, &e) {
+ return ctx.Status(e.Code).JSON(e)
+ }
+
+ slog.Error("fiber runtime error", "error", err, "URL", ctx.OriginalURL())
+ return ctx.Status(500).JSON(dto.ErrorResponse{
+ Code: 500,
+ Message: "Internal Server Error",
+ })
+ },
+ AppName: "confplanner",
+ })
+
+ // app.Use(cors.New())
+
+ requireAuthenticated := middleware.RequireAuthenticated(apiServices.UserService, sessionStore)
+
+ app.Post("/register", handlers.Register(apiServices.UserService))
+ app.Post("/login", handlers.Login(apiServices.UserService, sessionStore))
+ app.Post("/logout", requireAuthenticated, handlers.Logout(sessionStore))
+
+ app.Get("/favourites", requireAuthenticated, handlers.GetFavourites(apiServices.FavouritesService))
+ app.Post("/favourites", requireAuthenticated, handlers.CreateFavourite(apiServices.FavouritesService))
+ app.Delete("/favourites", requireAuthenticated, handlers.DeleteFavourite(apiServices.FavouritesService))
+
+ app.Get("/schedule", requireAuthenticated, handlers.GetSchedule(apiServices.ScheduleService))
+
+ return app
+}