Adds push, list, get, delete, and login subcommands backed by an HTTP API client, plus an MCP server for tool-based access to the document queue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
224 lines
5.3 KiB
Go
224 lines
5.3 KiB
Go
// 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])
|
|
}
|