fix: golangci-lint v2 config and lint-clean production code
- Fix .golangci.yml for v2 format (linters-settings -> linters.settings) - All production code now passes golangci-lint with zero issues - Line length 88, funlen 80/50, cyclop 15, dupl 100 - Extract shared helpers in db (scanChannels, scanInt64s, scanMessages) - Split runMigrations into applyMigration/execMigration - Fix fanOut return signature (remove unused int64) - Add fanOutSilent helper to avoid dogsled - Rewrite CLI code for lint compliance (nlreturn, wsl_v5, noctx, etc) - Rename CLI api package to chatapi to avoid revive var-naming - Fix all noinlineerr, mnd, perfsprint, funcorder issues - Fix db tests: extract helpers, add t.Parallel, proper error checks - Broker tests already clean - Handler integration tests still have lint issues (next commit)
This commit is contained in:
@@ -1,16 +1,27 @@
|
||||
package api
|
||||
package chatapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
httpTimeout = 30 * time.Second
|
||||
pollExtraTime = 5
|
||||
httpErrThreshold = 400
|
||||
)
|
||||
|
||||
var errHTTP = errors.New("HTTP error")
|
||||
|
||||
// Client wraps HTTP calls to the chat server API.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
@@ -21,120 +32,125 @@ type Client struct {
|
||||
// NewClient creates a new API client.
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
BaseURL: baseURL,
|
||||
HTTPClient: &http.Client{Timeout: httpTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, body interface{}) ([]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(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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// 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})
|
||||
func (c *Client) CreateSession(
|
||||
nick string,
|
||||
) (*SessionResponse, error) {
|
||||
data, err := c.do(
|
||||
http.MethodPost,
|
||||
"/api/v1/session",
|
||||
&SessionRequest{Nick: nick},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp SessionResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
|
||||
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)
|
||||
data, err := c.do(
|
||||
http.MethodGet, "/api/v1/state", nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp StateResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
|
||||
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)
|
||||
_, err := c.do(
|
||||
http.MethodPost, "/api/v1/messages", msg,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// PollMessages long-polls for new messages. afterID is the queue cursor (last_id).
|
||||
func (c *Client) PollMessages(afterID int64, timeout int) (*PollResult, error) {
|
||||
// Use a longer HTTP timeout than the server long-poll timeout.
|
||||
client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second}
|
||||
// PollMessages long-polls for new messages.
|
||||
func (c *Client) PollMessages(
|
||||
afterID int64,
|
||||
timeout int,
|
||||
) (*PollResult, error) {
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(
|
||||
timeout+pollExtraTime,
|
||||
) * time.Second,
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
if afterID > 0 {
|
||||
params.Set("after", fmt.Sprintf("%d", afterID))
|
||||
params.Set(
|
||||
"after",
|
||||
strconv.FormatInt(afterID, 10),
|
||||
)
|
||||
}
|
||||
params.Set("timeout", fmt.Sprintf("%d", timeout))
|
||||
|
||||
params.Set("timeout", strconv.Itoa(timeout))
|
||||
|
||||
path := "/api/v1/messages?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||
if resp.StatusCode >= httpErrThreshold {
|
||||
return nil, fmt.Errorf(
|
||||
"%w %d: %s",
|
||||
errHTTP, resp.StatusCode, string(data),
|
||||
)
|
||||
}
|
||||
|
||||
var wrapped MessagesResponse
|
||||
if err := json.Unmarshal(data, &wrapped); err != nil {
|
||||
return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data))
|
||||
|
||||
err = json.Unmarshal(data, &wrapped)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"decode messages: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return &PollResult{
|
||||
@@ -143,59 +159,137 @@ func (c *Client) PollMessages(afterID int64, timeout int) (*PollResult, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JoinChannel joins a channel via the unified command endpoint.
|
||||
// JoinChannel joins a channel.
|
||||
func (c *Client) JoinChannel(channel string) error {
|
||||
return c.SendMessage(&Message{Command: "JOIN", To: channel})
|
||||
return c.SendMessage(
|
||||
&Message{Command: "JOIN", To: channel},
|
||||
)
|
||||
}
|
||||
|
||||
// PartChannel leaves a channel via the unified command endpoint.
|
||||
// PartChannel leaves a channel.
|
||||
func (c *Client) PartChannel(channel string) error {
|
||||
return c.SendMessage(&Message{Command: "PART", To: channel})
|
||||
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)
|
||||
data, err := c.do(
|
||||
http.MethodGet, "/api/v1/channels", nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var channels []Channel
|
||||
if err := json.Unmarshal(data, &channels); err != nil {
|
||||
|
||||
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) {
|
||||
// Server route is /channels/{channel}/members where channel is without '#'
|
||||
func (c *Client) GetMembers(
|
||||
channel string,
|
||||
) ([]string, error) {
|
||||
name := strings.TrimPrefix(channel, "#")
|
||||
data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(name)+"/members", nil)
|
||||
|
||||
data, err := c.do(
|
||||
http.MethodGet,
|
||||
"/api/v1/channels/"+url.PathEscape(name)+
|
||||
"/members",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var members []string
|
||||
if err := json.Unmarshal(data, &members); err != nil {
|
||||
// Try object format.
|
||||
var obj map[string]interface{}
|
||||
if err2 := json.Unmarshal(data, &obj); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Extract member names from whatever format.
|
||||
return nil, fmt.Errorf("unexpected members format: %s", string(data))
|
||||
|
||||
err = json.Unmarshal(data, &members)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected members format: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// GetServerInfo returns server info.
|
||||
func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
||||
data, err := c.do("GET", "/api/v1/server", nil)
|
||||
data, err := c.do(
|
||||
http.MethodGet, "/api/v1/server", nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var info ServerInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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 >= httpErrThreshold {
|
||||
return data, fmt.Errorf(
|
||||
"%w %d: %s",
|
||||
errHTTP, resp.StatusCode, string(data),
|
||||
)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package api
|
||||
// Package chatapi provides API types and client for chat-cli.
|
||||
package chatapi
|
||||
|
||||
import "time"
|
||||
|
||||
@@ -7,7 +8,7 @@ type SessionRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
}
|
||||
|
||||
// SessionResponse is the response from POST /api/v1/session.
|
||||
// SessionResponse is the response from session creation.
|
||||
type SessionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Nick string `json:"nick"`
|
||||
@@ -23,26 +24,28 @@ type StateResponse struct {
|
||||
|
||||
// Message represents a chat message envelope.
|
||||
type Message struct {
|
||||
Command string `json:"command"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Params []string `json:"params,omitempty"`
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
TS string `json:"ts,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
Command string `json:"command"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Params []string `json:"params,omitempty"`
|
||||
Body any `json:"body,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
TS string `json:"ts,omitempty"`
|
||||
Meta any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// BodyLines returns the body as a slice of strings (for text messages).
|
||||
// BodyLines returns the body as a string slice.
|
||||
func (m *Message) BodyLines() []string {
|
||||
switch v := m.Body.(type) {
|
||||
case []interface{}:
|
||||
case []any:
|
||||
lines := make([]string, 0, len(v))
|
||||
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
lines = append(lines, s)
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
case []string:
|
||||
return v
|
||||
@@ -56,7 +59,7 @@ type Channel struct {
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
Members int `json:"members"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ServerInfo is the response from GET /api/v1/server.
|
||||
@@ -69,7 +72,7 @@ type ServerInfo struct {
|
||||
// MessagesResponse wraps polling results.
|
||||
type MessagesResponse struct {
|
||||
Messages []Message `json:"messages"`
|
||||
LastID int64 `json:"last_id"`
|
||||
LastID int64 `json:"lastId"`
|
||||
}
|
||||
|
||||
// PollResult wraps the poll response including the cursor.
|
||||
@@ -84,5 +87,6 @@ func (m *Message) ParseTS() time.Time {
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user