// Bridge — thin HTTP client for the HarborForge backend's Calendar API. // All operations carry the API key as Authorization: Bearer; absent // key means missing-auth errors from the backend (caller should // handle them as transient and log). package calendar import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" ) // Bridge is the typed wrapper around an HTTP client + backend URL. type Bridge struct { BackendURL string APIKey string HTTP *http.Client } // New constructs a bridge with a sensible default timeout. func New(backendURL, apiKey string) *Bridge { return &Bridge{ BackendURL: strings.TrimRight(backendURL, "/"), APIKey: apiKey, HTTP: &http.Client{Timeout: 20 * time.Second}, } } // Heartbeat POSTs /calendar/agent/heartbeat. Returns the backend's // reply or an error. func (b *Bridge) Heartbeat(ctx context.Context, payload HeartbeatPayload) (HeartbeatResponse, error) { raw, err := b.post(ctx, "/calendar/agent/heartbeat", payload) if err != nil { return HeartbeatResponse{}, err } var out HeartbeatResponse if len(raw) > 0 { if err := json.Unmarshal(raw, &out); err != nil { return HeartbeatResponse{}, fmt.Errorf("decode heartbeat: %w (body=%q)", err, truncate(raw, 200)) } } return out, nil } // UpdateSlotStatus POSTs /calendar/slot//status to mark a slot // completed / aborted / paused / resumed. func (b *Bridge) UpdateSlotStatus(ctx context.Context, slotID string, update SlotUpdate) error { if slotID == "" { return errors.New("calendar: slot id required") } _, err := b.post(ctx, "/calendar/slot/"+slotID+"/status", update) return err } // RestartPending GETs /restart/status — returns the backend's // current restart-requested flag. func (b *Bridge) RestartPending(ctx context.Context) (bool, error) { raw, err := b.get(ctx, "/restart/status") if err != nil { return false, err } var out struct { Pending bool `json:"pending"` } if len(raw) > 0 { if err := json.Unmarshal(raw, &out); err != nil { return false, fmt.Errorf("decode restart-status: %w", err) } } return out.Pending, nil } // post serialises body as JSON, attaches Authorization, returns // response body bytes. Non-2xx becomes an error with the body // included for diagnostics. func (b *Bridge) post(ctx context.Context, path string, body any) ([]byte, error) { raw, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal %s: %w", path, err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.BackendURL+path, bytes.NewReader(raw)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") if b.APIKey != "" { req.Header.Set("Authorization", "Bearer "+b.APIKey) } return b.do(req) } func (b *Bridge) get(ctx context.Context, path string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.BackendURL+path, nil) if err != nil { return nil, err } if b.APIKey != "" { req.Header.Set("Authorization", "Bearer "+b.APIKey) } return b.do(req) } func (b *Bridge) do(req *http.Request) ([]byte, error) { res, err := b.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL.Path, err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("%s %s → %d: %s", req.Method, req.URL.Path, res.StatusCode, truncate(body, 300)) } return body, nil } func truncate(b []byte, n int) string { if len(b) <= n { return string(b) } return string(b[:n]) + "…" }