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
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)
|