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)