// Package client provides an HTTP client for the MCQ REST API. package client import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "regexp" "strings" ) var ( ErrNotFound = errors.New("not found") ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden") ) // Document represents a document returned by the MCQ API. type Document struct { ID int64 `json:"id"` Slug string `json:"slug"` Title string `json:"title"` Body string `json:"body"` PushedBy string `json:"pushed_by"` PushedAt string `json:"pushed_at"` Read bool `json:"read"` } // Client talks to a remote MCQ server's REST API. type Client struct { baseURL string token string httpClient *http.Client } // Option configures a Client. type Option func(*Client) // WithHTTPClient sets a custom HTTP client (useful for testing). func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } } // New creates a Client. baseURL is the MCQ server URL (e.g. "https://mcq.example.com:8443"). // token is a Bearer token from MCIAS login. func New(baseURL, token string, opts ...Option) *Client { c := &Client{ baseURL: strings.TrimRight(baseURL, "/"), token: token, httpClient: http.DefaultClient, } for _, o := range opts { o(c) } return c } func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) { var bodyReader io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } bodyReader = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } switch resp.StatusCode { case http.StatusUnauthorized: _ = resp.Body.Close() return nil, ErrUnauthorized case http.StatusForbidden: _ = resp.Body.Close() return nil, ErrForbidden case http.StatusNotFound: _ = resp.Body.Close() return nil, ErrNotFound } return resp, nil } func decodeJSON[T any](resp *http.Response) (T, error) { defer func() { _ = resp.Body.Close() }() var v T if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { return v, fmt.Errorf("decode response: %w", err) } return v, nil } // Login authenticates with MCIAS and returns a bearer token. func (c *Client) Login(ctx context.Context, username, password, totpCode string) (string, error) { resp, err := c.do(ctx, http.MethodPost, "/v1/auth/login", map[string]string{ "username": username, "password": password, "totp_code": totpCode, }) if err != nil { return "", err } result, err := decodeJSON[struct { Token string `json:"token"` }](resp) if err != nil { return "", err } return result.Token, nil } // ListDocuments returns all documents in the queue. func (c *Client) ListDocuments(ctx context.Context) ([]Document, error) { resp, err := c.do(ctx, http.MethodGet, "/v1/documents", nil) if err != nil { return nil, err } result, err := decodeJSON[struct { Documents []Document `json:"documents"` }](resp) if err != nil { return nil, err } return result.Documents, nil } // GetDocument fetches a single document by slug. func (c *Client) GetDocument(ctx context.Context, slug string) (*Document, error) { resp, err := c.do(ctx, http.MethodGet, "/v1/documents/"+slug, nil) if err != nil { return nil, err } doc, err := decodeJSON[Document](resp) if err != nil { return nil, err } return &doc, nil } // PutDocument creates or replaces a document. func (c *Client) PutDocument(ctx context.Context, slug, title, body string) (*Document, error) { resp, err := c.do(ctx, http.MethodPut, "/v1/documents/"+slug, map[string]string{ "title": title, "body": body, }) if err != nil { return nil, err } doc, err := decodeJSON[Document](resp) if err != nil { return nil, err } return &doc, nil } // DeleteDocument removes a document by slug. func (c *Client) DeleteDocument(ctx context.Context, slug string) error { resp, err := c.do(ctx, http.MethodDelete, "/v1/documents/"+slug, nil) if err != nil { return err } _ = resp.Body.Close() return nil } // MarkRead marks a document as read. func (c *Client) MarkRead(ctx context.Context, slug string) (*Document, error) { resp, err := c.do(ctx, http.MethodPost, "/v1/documents/"+slug+"/read", nil) if err != nil { return nil, err } doc, err := decodeJSON[Document](resp) if err != nil { return nil, err } return &doc, nil } // MarkUnread marks a document as unread. func (c *Client) MarkUnread(ctx context.Context, slug string) (*Document, error) { resp, err := c.do(ctx, http.MethodPost, "/v1/documents/"+slug+"/unread", nil) if err != nil { return nil, err } doc, err := decodeJSON[Document](resp) if err != nil { return nil, err } return &doc, nil } var h1Re = regexp.MustCompile(`(?m)^#\s+(.+)$`) // ExtractTitle returns the first H1 heading from markdown source. // If no H1 is found, it returns the fallback string. func ExtractTitle(markdown, fallback string) string { m := h1Re.FindStringSubmatch(markdown) if m == nil { return fallback } return strings.TrimSpace(m[1]) }