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).
131 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|