279 lines
8.2 KiB
Go
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
|
|
}
|