Fix 380 lint violations across all Go source files including wsl_v5, nlreturn, noinlineerr, errcheck, funlen, funcorder, tagliatelle, perfsprint, modernize, revive, gosec, ireturn, mnd, forcetypeassert, cyclop, and others. Key changes: - Split large handler/command functions into smaller methods - Extract scan helpers for database queries - Reorder exported/unexported methods per funcorder - Add sentinel errors in models package - Use camelCase JSON tags per tagliatelle defaults - Add package comments - Fix .gitignore to not exclude cmd/chat-cli directory
293 lines
5.5 KiB
Go
293 lines
5.5 KiB
Go
// Package api provides a client for the chat server HTTP API.
|
|
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
httpTimeout = 30 * time.Second
|
|
pollExtraDelay = 5
|
|
httpErrThreshold = 400
|
|
)
|
|
|
|
// ErrHTTP is returned for non-2xx responses.
|
|
var ErrHTTP = errors.New("http error")
|
|
|
|
// ErrUnexpectedFormat is returned when the response format is
|
|
// not recognised.
|
|
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: httpTimeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
pollTimeout := time.Duration(
|
|
timeout+pollExtraDelay,
|
|
) * time.Second
|
|
|
|
client := &http.Client{Timeout: pollTimeout}
|
|
|
|
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.NewRequest( //nolint:noctx // CLI tool
|
|
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 from user config
|
|
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 >= httpErrThreshold {
|
|
return nil, fmt.Errorf(
|
|
"%w: %d: %s",
|
|
ErrHTTP, resp.StatusCode, string(data),
|
|
)
|
|
}
|
|
|
|
return decodeMessages(data)
|
|
}
|
|
|
|
func decodeMessages(data []byte) ([]Message, error) {
|
|
var msgs []Message
|
|
|
|
err := json.Unmarshal(data, &msgs)
|
|
if err == nil {
|
|
return msgs, nil
|
|
}
|
|
|
|
var wrapped MessagesResponse
|
|
|
|
err2 := json.Unmarshal(data, &wrapped)
|
|
if err2 != nil {
|
|
return nil, fmt.Errorf(
|
|
"decode messages: %w (raw: %s)",
|
|
err, string(data),
|
|
)
|
|
}
|
|
|
|
return wrapped.Messages, 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) {
|
|
path := "/api/v1/channels/" +
|
|
url.PathEscape(channel) + "/members"
|
|
|
|
data, err := c.do("GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var members []string
|
|
|
|
err = json.Unmarshal(data, &members)
|
|
if err != nil {
|
|
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.NewRequest( //nolint:noctx // CLI tool
|
|
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)
|
|
}
|
|
|
|
resp, err := c.HTTPClient.Do(req) //nolint:gosec // URL from user config
|
|
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 >= httpErrThreshold {
|
|
return data, fmt.Errorf(
|
|
"%w: %d: %s",
|
|
ErrHTTP, resp.StatusCode, string(data),
|
|
)
|
|
}
|
|
|
|
return data, nil
|
|
}
|