You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

162 lines
4.4 KiB
Go

package questionbank
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
app "knowfoolery/backend/services/game-session-service/internal/application/session"
sharederrors "knowfoolery/backend/shared/domain/errors"
)
// Client implements Question Bank HTTP integration.
type Client struct {
baseURL string
httpClient *http.Client
}
// NewClient creates a question-bank client.
func NewClient(baseURL string, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 3 * time.Second
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: timeout,
},
}
}
type responseEnvelope[T any] struct {
Success bool `json:"success"`
Data T `json:"data"`
}
type errorEnvelope struct {
Error bool `json:"error"`
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details"`
}
type randomQuestionRequest struct {
ExcludeQuestionIDs []string `json:"exclude_question_ids"`
Theme string `json:"theme,omitempty"`
Difficulty string `json:"difficulty,omitempty"`
}
type validateAnswerRequest struct {
Answer string `json:"answer"`
}
type validateAnswerResponse struct {
Matched bool `json:"matched"`
Score float64 `json:"score"`
}
// GetRandomQuestion fetches one random question with exclusions.
func (c *Client) GetRandomQuestion(
ctx context.Context,
exclusions []string,
theme, difficulty string,
) (*app.SessionQuestion, error) {
payload := randomQuestionRequest{
ExcludeQuestionIDs: exclusions,
Theme: strings.TrimSpace(theme),
Difficulty: strings.TrimSpace(difficulty),
}
var out responseEnvelope[app.SessionQuestion]
if err := c.doJSON(ctx, http.MethodPost, "/questions/random", payload, &out); err != nil {
return nil, err
}
return &out.Data, nil
}
// GetQuestionByID fetches a question by id.
func (c *Client) GetQuestionByID(ctx context.Context, id string) (*app.SessionQuestion, error) {
path := fmt.Sprintf("/questions/%s", strings.TrimSpace(id))
var out responseEnvelope[app.SessionQuestion]
if err := c.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil {
return nil, err
}
return &out.Data, nil
}
// ValidateAnswer checks whether a proposed answer matches.
func (c *Client) ValidateAnswer(
ctx context.Context,
questionID, answer string,
) (*app.AnswerValidationResult, error) {
path := fmt.Sprintf("/questions/%s/validate-answer", strings.TrimSpace(questionID))
var out responseEnvelope[validateAnswerResponse]
if err := c.doJSON(ctx, http.MethodPost, path, validateAnswerRequest{Answer: answer}, &out); err != nil {
return nil, err
}
return &app.AnswerValidationResult{
Matched: out.Data.Matched,
Score: out.Data.Score,
}, nil
}
func (c *Client) doJSON(
ctx context.Context,
method, path string,
body any,
target any,
) error {
var payload io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return sharederrors.Wrap(sharederrors.CodeInternal, "marshal upstream request", err)
}
payload = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, payload)
if err != nil {
return sharederrors.Wrap(sharederrors.CodeInternal, "build upstream request", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return sharederrors.Wrap(sharederrors.CodeInternal, "question-bank request failed", err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return sharederrors.Wrap(sharederrors.CodeInternal, "read question-bank response", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e errorEnvelope
if jsonErr := json.Unmarshal(raw, &e); jsonErr == nil && strings.TrimSpace(e.Code) != "" {
return sharederrors.New(sharederrors.ErrorCode(e.Code), e.Message)
}
if resp.StatusCode == http.StatusNotFound {
return sharederrors.New(sharederrors.CodeQuestionNotFound, "question not found")
}
if resp.StatusCode == http.StatusUnprocessableEntity {
return sharederrors.New(sharederrors.CodeNoQuestionsAvailable, "no questions available")
}
return sharederrors.New(sharederrors.CodeInternal, "question-bank upstream error")
}
if err := json.Unmarshal(raw, target); err != nil {
return sharederrors.Wrap(sharederrors.CodeInternal, "decode question-bank response", err)
}
return nil
}
var _ app.QuestionBankClient = (*Client)(nil)