package qcclient import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "time" ) type Client interface { Health(ctx context.Context) error ListAITemplates(ctx context.Context) ([]Template, error) GetTemplate(ctx context.Context, templateID int64) (*Template, error) GetTemplateSchema(ctx context.Context, templateID int64) (json.RawMessage, error) GenerateContent(ctx context.Context, req GenerateContentRequest) (GenerateContentData, json.RawMessage, error) CreateSite(ctx context.Context, req CreateSiteRequest) (*CreateSiteResponseData, json.RawMessage, error) GetJob(ctx context.Context, jobID int64) (*JobStatusData, json.RawMessage, error) GetEditorURL(ctx context.Context, siteID int64) (*SiteEditorLoginData, json.RawMessage, error) } type HTTPClient struct { baseURL string token string client *http.Client logger *slog.Logger } func New(baseURL, token string, timeout time.Duration, logger *slog.Logger) *HTTPClient { if timeout <= 0 { timeout = 10 * time.Second } return &HTTPClient{ baseURL: strings.TrimRight(baseURL, "/"), token: token, client: &http.Client{Timeout: timeout}, logger: logger, } } func (c *HTTPClient) Health(ctx context.Context) error { _, _, err := c.doJSON(ctx, http.MethodGet, "/health", nil) return err } func (c *HTTPClient) ListAITemplates(ctx context.Context) ([]Template, error) { body, _, err := c.doJSON(ctx, http.MethodGet, "/templates?type=ai", nil) if err != nil { return nil, err } var rsp APIResponse[[]Template] if err := json.Unmarshal(body, &rsp); err != nil { return nil, fmt.Errorf("decode templates response: %w", err) } return rsp.Data, nil } func (c *HTTPClient) GetTemplate(ctx context.Context, templateID int64) (*Template, error) { body, _, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/templates/%d", templateID), nil) if err != nil { return nil, err } var rsp APIResponse[Template] if err := json.Unmarshal(body, &rsp); err != nil { return nil, fmt.Errorf("decode template response: %w", err) } return &rsp.Data, nil } func (c *HTTPClient) GetTemplateSchema(ctx context.Context, templateID int64) (json.RawMessage, error) { body, _, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/templates/%d/schema", templateID), nil) if err != nil { return nil, err } return json.RawMessage(body), nil } func (c *HTTPClient) GenerateContent(ctx context.Context, req GenerateContentRequest) (GenerateContentData, json.RawMessage, error) { body, raw, err := c.doJSON(ctx, http.MethodPost, "/generate-content", req) if err != nil { return nil, nil, err } var rsp APIResponse[GenerateContentData] if err := json.Unmarshal(body, &rsp); err != nil { return nil, raw, fmt.Errorf("decode generate-content response: %w", err) } return rsp.Data, raw, nil } func (c *HTTPClient) CreateSite(ctx context.Context, req CreateSiteRequest) (*CreateSiteResponseData, json.RawMessage, error) { body, raw, err := c.doJSON(ctx, http.MethodPost, "/sites", req) if err != nil { return nil, nil, err } var rsp APIResponse[CreateSiteResponseData] if err := json.Unmarshal(body, &rsp); err != nil { return nil, raw, fmt.Errorf("decode create site response: %w", err) } return &rsp.Data, raw, nil } func (c *HTTPClient) GetJob(ctx context.Context, jobID int64) (*JobStatusData, json.RawMessage, error) { body, raw, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/jobs/%d", jobID), nil) if err != nil { return nil, nil, err } var rsp APIResponse[JobStatusData] if err := json.Unmarshal(body, &rsp); err != nil { return nil, raw, fmt.Errorf("decode job response: %w", err) } return &rsp.Data, raw, nil } func (c *HTTPClient) GetEditorURL(ctx context.Context, siteID int64) (*SiteEditorLoginData, json.RawMessage, error) { body, raw, err := c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/sites/%d/editor-url", siteID), nil) if err != nil { return nil, nil, err } var rsp APIResponse[SiteEditorLoginData] if err := json.Unmarshal(body, &rsp); err != nil { return nil, raw, fmt.Errorf("decode editor-url response: %w", err) } return &rsp.Data, raw, nil } func (c *HTTPClient) doJSON(ctx context.Context, method, path string, payload any) ([]byte, json.RawMessage, error) { var body io.Reader if payload != nil { b, err := json.Marshal(payload) if err != nil { return nil, nil, fmt.Errorf("marshal request: %w", err) } body = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) if err != nil { return nil, nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } start := time.Now() res, err := c.client.Do(req) if err != nil { return nil, nil, fmt.Errorf("do request: %w", err) } defer res.Body.Close() respBody, err := io.ReadAll(res.Body) if err != nil { return nil, nil, fmt.Errorf("read response: %w", err) } c.logger.Info("qc request", "method", method, "path", path, "status", res.StatusCode, "duration_ms", time.Since(start).Milliseconds(), "auth", "Bearer [REDACTED]", ) if res.StatusCode >= 400 { return nil, json.RawMessage(respBody), c.mapHTTPError(res.StatusCode, respBody) } return respBody, json.RawMessage(respBody), nil } func (c *HTTPClient) mapHTTPError(statusCode int, raw []byte) error { var apiErr APIError _ = json.Unmarshal(raw, &apiErr) err := &HTTPError{ StatusCode: statusCode, APIError: &apiErr, RawBody: raw, } switch statusCode { case http.StatusBadRequest: return fmt.Errorf("%w: %w", ErrBadRequest, err) case http.StatusUnauthorized: return fmt.Errorf("%w: %w", ErrUnauthorized, err) case http.StatusNotFound: return fmt.Errorf("%w: %w", ErrNotFound, err) default: return err } }