hugin/cli/internal/api/api.go
2026-10-27 08:33:21 +01:00

279 lines
8.2 KiB
Go

// Package api spricht mit dem Hugin-Server unter /api/v1 (→ KONZEPT.md 14.6).
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type Client struct {
Base string // z. B. https://hugin.intern
Key string
HTTP *http.Client
}
// AllowHttp: Umgebungsvariable, die Keys auch unverschlüsselt an andere Rechner schickt (nur zum Testen).
const AllowHttp = "HUGIN_ALLOW_HTTP"
// New baut einen Client. Keys gehen nur über HTTPS oder an den eigenen Rechner;
// das gilt auch für Weiterleitungen.
func New(base, key string) (*Client, error) {
if err := safe(base); err != nil {
return nil, err
}
c := &Client{Base: strings.TrimSuffix(base, "/"), Key: key, HTTP: &http.Client{Timeout: 60 * time.Second}}
c.HTTP.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("zu viele Weiterleitungen")
}
return safe(req.URL.String())
}
return c, nil
}
func safe(address string) error {
u, err := url.Parse(address)
if err != nil || u.Host == "" {
return fmt.Errorf("Server %q: keine gültige Adresse (z. B. https://hugin.intern)", address)
}
switch {
case u.Scheme == "https":
return nil
case u.Scheme != "http":
return fmt.Errorf("Server %q: nur https:// (oder http:// zum eigenen Rechner)", address)
}
host := u.Hostname()
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return nil
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return nil
}
if os.Getenv(AllowHttp) == "1" {
fmt.Fprintf(os.Stderr, "hugin: Warnung: Key geht unverschlüsselt an %s (%s=1)\n", u.Host, AllowHttp)
return nil
}
return fmt.Errorf("Server %s: Keys gehen nicht unverschlüsselt über http:// an andere Rechner; https:// nutzen (zum Testen: %s=1)", u.Host, AllowHttp)
}
// BundleRequest entspricht contract/bundle-request.schema.json.
type BundleRequest struct {
Packages []string `json:"packages"`
Unsubscribed []string `json:"unsubscribed,omitempty"`
Tools map[string]string `json:"tools"`
Mandatory bool `json:"mandatory"`
Major bool `json:"major,omitempty"`
Options map[string]bool `json:"options,omitempty"`
Installed map[string]string `json:"installed,omitempty"`
}
// Bundle holt das ZIP für die Anfrage.
func (c *Client) Bundle(request BundleRequest) ([]byte, error) {
return c.post("/api/v1/bundle", request, "application/zip")
}
// Configuration entspricht GET /api/v1/config.
type Configuration struct {
Floors map[string]string `json:"floors"`
Mandatory []string `json:"mandatory"`
CLI struct {
MinVersion string `json:"min_version"`
Newest string `json:"newest"`
} `json:"cli"`
}
func (c *Client) Configuration() (*Configuration, error) {
var k Configuration
return &k, c.get("/api/v1/config", &k)
}
// Matches ist ein Eintrag im Katalog (GET /api/v1/packages).
type Matches struct {
ID string `json:"id"`
Kind string `json:"kind"`
Version string `json:"version"`
Description string `json:"description"`
Risk string `json:"risk"`
Compatibility string `json:"compatibility"`
}
func (c *Client) Search(text, kind string) ([]Matches, error) {
path := "/api/v1/packages?q=" + url.QueryEscape(text)
if kind != "" {
path += "&kind=" + url.QueryEscape(kind)
}
var t []Matches
return t, c.get(path, &t)
}
func (c *Client) get(path string, target any) error {
req, err := http.NewRequest(http.MethodGet, c.Base+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("Hugin nicht erreichbar: %w", err)
}
defer resp.Body.Close()
response, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return err
}
if resp.StatusCode >= 300 {
text, _ := fehlertext(response)
return &APIError{Status: resp.StatusCode, Text: "GET " + strings.SplitN(path, "?", 2)[0] + ": " + resp.Status + " " + text}
}
return json.Unmarshal(response, target)
}
// Finding ist eine Meldung des Linters (→ KONZEPT.md 8).
type Finding struct {
Id string `json:"id"`
Severity string `json:"severity"`
Text string `json:"text"`
File string `json:"file"`
}
// ValidationResult ist die Antwort von POST /api/v1/validate.
type ValidationResult struct {
Findings []Finding `json:"findings"`
Risk string `json:"risk"`
Compatibility *string `json:"compatibility"`
Preview map[string][]map[string]any `json:"preview"`
}
func (c *Client) Validate(packageZip []byte) (*ValidationResult, error) {
raw, err := c.send("/api/v1/validate", packageZip, "application/zip", "application/json")
if err != nil {
return nil, err
}
var p ValidationResult
return &p, json.Unmarshal(raw, &p)
}
// Trial liefert ein Bundle für einen ungespeicherten Paketordner.
func (c *Client) Trial(packageZip []byte, tools map[string]string) ([]byte, error) {
var parts []string
for name, version := range tools {
parts = append(parts, name+":"+version)
}
path := "/api/v1/trial?tools=" + url.QueryEscape(strings.Join(parts, ","))
return c.send(path, packageZip, "application/zip", "application/zip")
}
// Version ist der Stand einer Version nach Hochladen oder Einreichen.
type Version struct {
Package string `json:"package"`
Version string `json:"version"`
Status string `json:"status"`
Risk string `json:"risk"`
Linter []Finding `json:"linter"`
}
func (c *Client) Upload(packageZip []byte) (*Version, error) {
raw, err := c.send("/api/v1/drafts", packageZip, "application/zip", "application/json")
if err != nil {
return nil, err
}
var v Version
return &v, json.Unmarshal(raw, &v)
}
func (c *Client) Submit(pkg, version string) (*Version, error) {
raw, err := c.send("/api/v1/packages/"+url.PathEscape(pkg)+"/versions/"+url.PathEscape(version)+"/submit",
nil, "application/json", "application/json")
if err != nil {
return nil, err
}
var v Version
return &v, json.Unmarshal(raw, &v)
}
// APIError ist eine Fehlerantwort des Servers; bei 422 vom Linter mit Befunden.
type APIError struct {
Status int
Text string
Findings []Finding
}
func (f *APIError) Error() string { return f.Text }
// Usage entspricht der Meldung nach sync (→ KONZEPT.md 11.5, Schritt 7).
type Usage struct {
Repo string `json:"repo"`
Tools map[string]string `json:"tools"`
Installed map[string]string `json:"installed"`
}
func (c *Client) Usage(n Usage) error {
_, err := c.post("/api/v1/usage", n, "application/json")
return err
}
func (c *Client) post(path string, body any, accept string) ([]byte, error) {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
return c.send(path, raw, "application/json", accept)
}
func (c *Client) send(path string, body []byte, inhaltstyp, accept string) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, c.Base+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", inhaltstyp)
req.Header.Set("Accept", accept)
req.Header.Set("Authorization", "Bearer "+c.Key)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("Hugin nicht erreichbar: %w", err)
}
defer resp.Body.Close()
response, err := io.ReadAll(io.LimitReader(resp.Body, 256<<20))
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
f := &APIError{Status: resp.StatusCode}
f.Text, f.Findings = fehlertext(response)
f.Text = fmt.Sprintf("%s %s: %s %s", http.MethodPost, strings.SplitN(path, "?", 2)[0], resp.Status, f.Text)
return nil, f
}
return response, nil
}
func fehlertext(response []byte) (string, []Finding) {
var f struct {
Detail json.RawMessage `json:"detail"`
}
if json.Unmarshal(response, &f) == nil && len(f.Detail) > 0 {
var withFindings struct {
Findings []Finding `json:"findings"`
}
if json.Unmarshal(f.Detail, &withFindings) == nil && len(withFindings.Findings) > 0 {
return "vom Linter abgelehnt", withFindings.Findings
}
var text string
if json.Unmarshal(f.Detail, &text) == nil {
return text, nil
}
return string(f.Detail), nil
}
if len(response) > 200 {
response = response[:200]
}
return strings.TrimSpace(string(response)), nil
}