// 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 }