// Package hub is the catalog's client for T5DE's hub (server to server, docs/products.md "For catalogs"). package hub import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "regexp" "strings" "time" "git.t5de.dev/t5de/t5de-catalog/internal/store" ) // Client calls the hub as one catalog. type Client struct { url string auth string client *http.Client } // New returns a client for the hub at hubURL (its API), as catalog id with its key. client may be nil. func New(hubURL, id, key string, client *http.Client) *Client { if client == nil { client = &http.Client{Timeout: 60 * time.Second} } return &Client{url: strings.TrimRight(hubURL, "/"), auth: "Catalog " + id + ":" + key, client: client} } // NewRevision is what the catalog registers. type NewRevision struct { UploadToken string `json:"uploadToken"` Files []store.File `json:"files"` Rating string `json:"rating"` Parent int64 `json:"parent"` Name string `json:"name"` Notes string `json:"notes"` } // Registered is the hub's answer. type Registered struct { Product string `json:"product"` Revision int `json:"revision"` } // Error is the hub refusing a request: its status and, when it sent them, its error code and message. type Error struct { Status int Code string Message string } func (e *Error) Error() string { if e.Code != "" { return fmt.Sprintf("hub: %d %s: %s", e.Status, e.Code, e.Message) } return fmt.Sprintf("hub: %d", e.Status) } var productID = regexp.MustCompile(`^[a-z2-7]{10}$`) // ValidProduct reports whether s is a product ID (10 of a-z2-7). func ValidProduct(s string) bool { return productID.MatchString(s) } // RegisterRevision posts a revision to the hub (POST /api/v1/catalog/revisions). A refusal is an *Error; other // errors mean the hub couldn't be reached or answered nonsense. func (c *Client) RegisterRevision(ctx context.Context, r NewRevision) (*Registered, error) { if r.Files == nil { r.Files = []store.File{} } body, err := json.Marshal(r) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/api/v1/catalog/revisions", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", c.auth) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { e := &Error{Status: resp.StatusCode} var msg struct { Error string `json:"error"` Message string `json:"message"` } if json.Unmarshal(data, &msg) == nil { e.Code, e.Message = msg.Error, msg.Message } return nil, e } var reg Registered if err := json.Unmarshal(data, ®); err != nil { return nil, fmt.Errorf("hub: bad answer: %v", err) } if !ValidProduct(reg.Product) || reg.Revision < 1 { return nil, fmt.Errorf("hub: bad answer: product %q, revision %d", reg.Product, reg.Revision) } return ®, nil }