Files
mrcharles 20dbcf604d Add the Redmine REST client with a fake-server test suite
Issues (create/update/partial PUT, notes-as-journals), versions,
categories, relations, and name-to-id resolution over trackers/
statuses/priorities. Sentinel errors carry http codes one-line; response
bodies are never surfaced so the API key cannot leak through error
strings (the fake deliberately echoes the presented key to prove it).
2026-08-29 06:35:20 -05:00

131 lines
4.1 KiB
Go

// Package redmine is a stdlib-only client for the Redmine REST API
// (JSON). It exists so the PMO, the harness, and every vertical talk to
// Redmine through one Go library instead of python glue: issue
// create/update/notes, versions, categories, relations, and name-to-id
// resolution over the enumeration endpoints.
//
// Security discipline: the API key travels ONLY in the X-Redmine-API-Key
// header. Response bodies are never surfaced in error strings (a server
// echo is assumed to be able to carry the key), so errors are one-line,
// parseable "sentinel: http NNN" shapes.
package redmine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Sentinel errors. Wrap-check with errors.Is; every API failure maps to
// exactly one of these plus an "http NNN" code in the message.
var (
ErrUnreachable = errors.New("redmine: unreachable")
ErrMalformedResponse = errors.New("redmine: malformed response")
ErrAuth = errors.New("redmine: auth failed")
ErrNotFound = errors.New("redmine: not found")
ErrValidation = errors.New("redmine: validation failed")
ErrServer = errors.New("redmine: server error")
)
// Config configures a Client.
type Config struct {
BaseURL string // e.g. https://projects.knownelement.com (no trailing slash needed)
APIKey string // Redmine API key; header-only, never logged
Timeout time.Duration
}
// Client is a Redmine REST client. Safe for concurrent use.
type Client struct {
cfg Config
http *http.Client
}
// New builds a client. A zero Timeout means 30s.
func New(cfg Config) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
}
// do performs one JSON exchange. out may be nil for empty bodies (204).
func (c *Client) do(ctx context.Context, method, path string, query url.Values, in any, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("%w: cannot encode request", ErrMalformedResponse)
}
body = strings.NewReader(string(b))
}
u := c.cfg.BaseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, body)
if err != nil {
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
}
req.Header.Set("X-Redmine-API-Key", c.cfg.APIKey) // key lives here and only here
req.Header.Set("Accept", "application/json")
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
// Transport errors embed URLs and peer text; drop them all.
return ErrUnreachable
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return ErrUnreachable
}
return handleResponse(resp.StatusCode, raw, out)
}
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
// are never surfaced: a server echo is assumed to be able to contain the
// API key (see the fakeredmine package, which deliberately echoes it).
func handleResponse(status int, body []byte, out any) error {
switch {
case status >= 200 && status < 300:
if out == nil {
return nil
}
if len(body) == 0 {
return fmt.Errorf("%w: empty body", ErrMalformedResponse)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
}
return nil
default:
return statusError(status)
}
}
// statusError maps a non-2xx status to a sentinel + parseable one-liner.
func statusError(status int) error {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("%w: http %d", ErrAuth, status)
case status == http.StatusNotFound:
return fmt.Errorf("%w: http %d", ErrNotFound, status)
case status == http.StatusUnprocessableEntity || status == http.StatusBadRequest || status == http.StatusConflict:
return fmt.Errorf("%w: http %d", ErrValidation, status)
case status >= 500:
return fmt.Errorf("%w: http %d", ErrServer, status)
default:
return fmt.Errorf("%w: http %d", ErrServer, status)
}
}