- Resolve duplicate method declarations (CreateUser, GetUserByToken,
GetUserByNick) between db.go and queries.go by renaming queries.go
methods to CreateSimpleUser, LookupUserByToken, LookupUserByNick
- Fix 377 lint issues across all categories:
- nlreturn (107): Add blank lines before returns
- wsl_v5 (156): Add required whitespace
- noinlineerr (25): Use plain assignments instead of inline error handling
- errcheck (15): Check all error return values
- mnd (10): Extract magic numbers to named constants
- err113 (7): Use wrapped static errors instead of dynamic errors
- gosec (7): Fix SSRF, SQL injection warnings; add nolint for false positives
- modernize (7): Replace interface{} with any
- cyclop (2): Reduce cyclomatic complexity via command map dispatch
- gocognit (1): Break down complex handler into sub-handlers
- funlen (3): Extract long functions into smaller helpers
- funcorder (4): Reorder methods (exported before unexported)
- forcetypeassert (2): Add safe type assertions with ok checks
- ireturn (2): Replace interface-returning methods with concrete lookups
- noctx (3): Use NewRequestWithContext and ExecContext
- tagliatelle (5): Fix JSON tag casing to camelCase
- revive (4): Rename package from 'api' to 'chatapi'
- rowserrcheck (8): Add rows.Err() checks after iteration
- lll (2): Shorten long lines
- perfsprint (5): Use strconv and string concatenation
- nestif (2): Extract nested conditionals into helper methods
- wastedassign (1): Remove wasted assignments
- gosmopolitan (1): Add nolint for intentional Local() time display
- usestdlibvars (1): Use http.MethodGet
- godoclint (2): Remove duplicate package comments
- Fix broken migration 003_users.sql that conflicted with 002_schema.sql
(different column types causing test failures)
- All tests pass, make check reports 0 issues
260 lines
5.7 KiB
Go
260 lines
5.7 KiB
Go
// Package chatapi provides a client for the chat server's HTTP API.
|
|
package chatapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
httpClientTimeout = 30
|
|
httpErrorMinCode = 400
|
|
pollExtraTimeout = 5
|
|
)
|
|
|
|
// ErrHTTPError is returned when the server responds with an error status.
|
|
var ErrHTTPError = errors.New("HTTP error")
|
|
|
|
// ErrUnexpectedFormat is returned when a response has an unexpected structure.
|
|
var ErrUnexpectedFormat = errors.New("unexpected format")
|
|
|
|
// Client wraps HTTP calls to the chat server API.
|
|
type Client struct {
|
|
BaseURL string
|
|
Token string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a new API client.
|
|
func NewClient(baseURL string) *Client {
|
|
return &Client{
|
|
BaseURL: baseURL,
|
|
HTTPClient: &http.Client{
|
|
Timeout: httpClientTimeout * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// CreateSession creates a new session on the server.
|
|
func (c *Client) CreateSession(nick string) (*SessionResponse, error) {
|
|
data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp SessionResponse
|
|
|
|
err = json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode session: %w", err)
|
|
}
|
|
|
|
c.Token = resp.Token
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// GetState returns the current user state.
|
|
func (c *Client) GetState() (*StateResponse, error) {
|
|
data, err := c.do("GET", "/api/v1/state", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp StateResponse
|
|
|
|
err = json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode state: %w", err)
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
// SendMessage sends a message (any IRC command).
|
|
func (c *Client) SendMessage(msg *Message) error {
|
|
_, err := c.do("POST", "/api/v1/messages", msg)
|
|
|
|
return err
|
|
}
|
|
|
|
// PollMessages long-polls for new messages.
|
|
func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) {
|
|
// Use a longer HTTP timeout than the server long-poll timeout.
|
|
client := &http.Client{Timeout: time.Duration(timeout+pollExtraTimeout) * time.Second}
|
|
|
|
params := url.Values{}
|
|
if afterID != "" {
|
|
params.Set("after", afterID)
|
|
}
|
|
|
|
params.Set("timeout", strconv.Itoa(timeout))
|
|
|
|
path := "/api/v1/messages"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
|
|
resp, err := client.Do(req) //nolint:gosec // URL is constructed from trusted base URL + API path, not user-tainted
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode >= httpErrorMinCode {
|
|
return nil, fmt.Errorf("%w: %d: %s", ErrHTTPError, resp.StatusCode, string(data))
|
|
}
|
|
|
|
// The server may return an array directly or wrapped.
|
|
var msgs []Message
|
|
|
|
err = json.Unmarshal(data, &msgs)
|
|
if err != nil {
|
|
// Try wrapped format.
|
|
var wrapped MessagesResponse
|
|
|
|
err2 := json.Unmarshal(data, &wrapped)
|
|
if err2 != nil {
|
|
return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data))
|
|
}
|
|
|
|
msgs = wrapped.Messages
|
|
}
|
|
|
|
return msgs, nil
|
|
}
|
|
|
|
// JoinChannel joins a channel via the unified command endpoint.
|
|
func (c *Client) JoinChannel(channel string) error {
|
|
return c.SendMessage(&Message{Command: "JOIN", To: channel})
|
|
}
|
|
|
|
// PartChannel leaves a channel via the unified command endpoint.
|
|
func (c *Client) PartChannel(channel string) error {
|
|
return c.SendMessage(&Message{Command: "PART", To: channel})
|
|
}
|
|
|
|
// ListChannels returns all channels on the server.
|
|
func (c *Client) ListChannels() ([]Channel, error) {
|
|
data, err := c.do("GET", "/api/v1/channels", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var channels []Channel
|
|
|
|
err = json.Unmarshal(data, &channels)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return channels, nil
|
|
}
|
|
|
|
// GetMembers returns members of a channel.
|
|
func (c *Client) GetMembers(channel string) ([]string, error) {
|
|
data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var members []string
|
|
|
|
err = json.Unmarshal(data, &members)
|
|
if err != nil {
|
|
// Try object format.
|
|
var obj map[string]any
|
|
|
|
err2 := json.Unmarshal(data, &obj)
|
|
if err2 != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Extract member names from whatever format.
|
|
return nil, fmt.Errorf("%w: members: %s", ErrUnexpectedFormat, string(data))
|
|
}
|
|
|
|
return members, nil
|
|
}
|
|
|
|
// GetServerInfo returns server info.
|
|
func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
|
data, err := c.do("GET", "/api/v1/server", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var info ServerInfo
|
|
|
|
err = json.Unmarshal(data, &info)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &info, nil
|
|
}
|
|
|
|
func (c *Client) do(method, path string, body any) ([]byte, error) {
|
|
var bodyReader io.Reader
|
|
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal: %w", err)
|
|
}
|
|
|
|
bodyReader = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), method, c.BaseURL+path, bodyReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
if c.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
}
|
|
|
|
//nolint:gosec // URL built from trusted base + API path
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= httpErrorMinCode {
|
|
return data, fmt.Errorf("%w: %d: %s", ErrHTTPError, resp.StatusCode, string(data))
|
|
}
|
|
|
|
return data, nil
|
|
}
|