package http import ( "errors" "strconv" "github.com/gofiber/fiber/v3" appq "knowfoolery/backend/services/question-bank-service/internal/application/question" domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" sharederrors "knowfoolery/backend/shared/domain/errors" "knowfoolery/backend/shared/infra/observability/logging" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/utils/httputil" "knowfoolery/backend/shared/infra/utils/validation" ) // Handler implements HTTP endpoint handlers. type Handler struct { service *appq.Service validator *validation.Validator logger *logging.Logger metrics *sharedmetrics.Metrics bulkMaxItems int } // NewHandler creates a new HTTP handler set. func NewHandler(service *appq.Service, validator *validation.Validator, logger *logging.Logger, metrics *sharedmetrics.Metrics, bulkMaxItems int) *Handler { return &Handler{service: service, validator: validator, logger: logger, metrics: metrics, bulkMaxItems: bulkMaxItems} } // PostRandomQuestion handles POST /questions/random. func (h *Handler) PostRandomQuestion(c fiber.Ctx) error { var req RandomQuestionRequest if err := c.Bind().Body(&req); err != nil { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) } if req.Difficulty != "" && !domain.IsValidDifficulty(req.Difficulty) { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeValidationFailed, "invalid difficulty", nil)) } result, err := h.service.GetRandomQuestion(c.Context(), appq.RandomQuestionRequest{ ExcludeQuestionIDs: req.ExcludeQuestionIDs, Theme: req.Theme, Difficulty: req.Difficulty, }) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/questions/random", fiber.StatusOK) if h.logger != nil { h.logger.Info("question selected") } return httputil.OK(c, toQuestionResponse(result, false)) } // GetQuestionByID handles GET /questions/:id. func (h *Handler) GetQuestionByID(c fiber.Ctx) error { id := c.Params("id") result, err := h.service.GetQuestionByID(c.Context(), id) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("GET", "/questions/{id}", fiber.StatusOK) return httputil.OK(c, toQuestionResponse(result, false)) } // PostValidateAnswer handles POST /questions/:id/validate-answer. func (h *Handler) PostValidateAnswer(c fiber.Ctx) error { var req ValidateAnswerRequest if err := c.Bind().Body(&req); err != nil { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) } if err := h.validator.Validate(req); err != nil { return httputil.SendError(c, err) } result, err := h.service.ValidateAnswerByQuestionID(c.Context(), c.Params("id"), req.Answer) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/questions/{id}/validate-answer", fiber.StatusOK) return httputil.OK(c, fiber.Map{ "matched": result.Matched, "score": result.Score, "threshold": result.Threshold, }) } // AdminCreateQuestion handles POST /admin/questions. func (h *Handler) AdminCreateQuestion(c fiber.Ctx) error { var req CreateQuestionRequest if err := c.Bind().Body(&req); err != nil { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) } if err := h.validator.Validate(req); err != nil { return httputil.SendError(c, err) } result, err := h.service.CreateQuestion(c.Context(), appq.CreateQuestionInput(req)) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/admin/questions", fiber.StatusCreated) return c.Status(fiber.StatusCreated).JSON(httputil.NewResponse(toQuestionResponse(result, true))) } // AdminUpdateQuestion handles PUT /admin/questions/:id. func (h *Handler) AdminUpdateQuestion(c fiber.Ctx) error { var req UpdateQuestionRequest if err := c.Bind().Body(&req); err != nil { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) } if err := h.validator.Validate(req); err != nil { return httputil.SendError(c, err) } result, err := h.service.UpdateQuestion(c.Context(), c.Params("id"), appq.UpdateQuestionInput(req)) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("PUT", "/admin/questions/{id}", fiber.StatusOK) return httputil.OK(c, toQuestionResponse(result, true)) } // AdminDeleteQuestion handles DELETE /admin/questions/:id. func (h *Handler) AdminDeleteQuestion(c fiber.Ctx) error { if err := h.service.DeleteQuestion(c.Context(), c.Params("id")); err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("DELETE", "/admin/questions/{id}", fiber.StatusNoContent) return httputil.NoContent(c) } // AdminListThemes handles GET /admin/themes. func (h *Handler) AdminListThemes(c fiber.Ctx) error { themes, err := h.service.ListThemes(c.Context()) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("GET", "/admin/themes", fiber.StatusOK) return httputil.OK(c, fiber.Map{"themes": themes}) } // AdminBulkImport handles POST /admin/questions/bulk. func (h *Handler) AdminBulkImport(c fiber.Ctx) error { var req BulkImportRequest if err := c.Bind().Body(&req); err != nil { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err)) } if len(req.Questions) == 0 { return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeValidationFailed, "questions is required", nil)) } items := make([]appq.BulkImportItem, 0, len(req.Questions)) for _, q := range req.Questions { items = append(items, appq.BulkImportItem{ Theme: q.Theme, Text: q.Text, Answer: q.Answer, Hint: q.Hint, Difficulty: q.Difficulty, }) } result, err := h.service.BulkImport(c.Context(), items, h.bulkMaxItems) if err != nil { return h.sendMappedError(c, err) } h.recordRequestMetric("POST", "/admin/questions/bulk", fiber.StatusOK) return httputil.OK(c, result) } func (h *Handler) sendMappedError(c fiber.Ctx, err error) error { var domainErr *sharederrors.DomainError if errors.As(err, &domainErr) { return httputil.SendError(c, domainErr) } return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInternal, "internal error", err)) } func (h *Handler) recordRequestMetric(method, endpoint string, status int) { if h.metrics == nil { return } h.metrics.HTTPRequestsTotal.WithLabelValues(method, endpoint, strconv.Itoa(status), h.metricsConfigService()).Inc() } func (h *Handler) metricsConfigService() string { return "question-bank-service" }