package gitea 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("gitea: unreachable") ErrMalformedResponse = errors.New("gitea: malformed response") ErrAuth = errors.New("gitea: auth failed") ErrNotFound = errors.New("gitea: not found") ErrValidation = errors.New("gitea: validation failed") ErrServer = errors.New("gitea: server error") ) // Config configures a Client. type Config struct { BaseURL string // e.g. https://git.knownelement.com (no trailing slash needed) Token string // Gitea API token; header-only, never logged Timeout time.Duration } // Client is a Gitea API v1 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 against /api/v1. out may be nil for // empty bodies. 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 + "/api/v1" + 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("Authorization", "token "+c.cfg.Token) // token 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, 8<<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 // token (see the fakegitea 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) } }