feat(gitea): client core, fake server and repo endpoints
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// User is the account projection repos and PRs carry.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
}
|
||||
|
||||
// Repo is the repository projection (read shape).
|
||||
type Repo struct {
|
||||
ID int64 `json:"id"`
|
||||
Owner User `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
Empty bool `json:"empty"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
}
|
||||
|
||||
// RepoParams is the repo-create write shape. Owner routes the create:
|
||||
// empty creates under the authenticated account (POST /user/repos), any
|
||||
// other value creates under that organization (POST /orgs/{owner}/repos).
|
||||
type RepoParams struct {
|
||||
Owner string
|
||||
Name string
|
||||
Description string
|
||||
Private bool
|
||||
AutoInit bool
|
||||
DefaultBranch string
|
||||
}
|
||||
|
||||
type repoWrite struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Private bool `json:"private"`
|
||||
AutoInit bool `json:"auto_init"`
|
||||
DefaultBranch string `json:"default_branch,omitempty"`
|
||||
}
|
||||
|
||||
// CreateRepo creates a repository and returns it.
|
||||
func (c *Client) CreateRepo(ctx context.Context, p RepoParams) (*Repo, error) {
|
||||
path := "/user/repos"
|
||||
if p.Owner != "" {
|
||||
path = "/orgs/" + url.PathEscape(p.Owner) + "/repos"
|
||||
}
|
||||
var out Repo
|
||||
if err := c.do(ctx, "POST", path, nil, repoWrite{
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Private: p.Private,
|
||||
AutoInit: p.AutoInit,
|
||||
DefaultBranch: p.DefaultBranch,
|
||||
}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ListRepos lists repositories. An empty owner lists the authenticated
|
||||
// account's own repos (GET /user/repos); otherwise the named account's
|
||||
// or organization's (GET /users/{owner}/repos).
|
||||
func (c *Client) ListRepos(ctx context.Context, owner string) ([]Repo, error) {
|
||||
path := "/user/repos"
|
||||
if owner != "" {
|
||||
path = "/users/" + url.PathEscape(owner) + "/repos"
|
||||
}
|
||||
var out []Repo
|
||||
if err := c.do(ctx, "GET", path, nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRepo fetches one repository.
|
||||
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
|
||||
var out Repo
|
||||
if err := c.do(ctx, "GET", "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(name), nil, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user