package tenantengine import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" ) // HTTPClient calls tenant-engine's live-lookup endpoint // (GET /tenants/{id}/roles/live). type HTTPClient struct { BaseURL string Client *http.Client } // NewHTTPClient creates an HTTP-backed tenant-engine client. func NewHTTPClient(baseURL string) (*HTTPClient, error) { if baseURL == "" { return nil, fmt.Errorf("tenant-engine base URL is required") } return &HTTPClient{ BaseURL: strings.TrimRight(baseURL, "/"), Client: &http.Client{Timeout: 3 * time.Second}, }, nil } // LiveRoles calls GET /tenants/{tenantID}/roles/live. // // Fail-closed by construction: any transport error, non-200 response, or // malformed body returns LiveRolesResult{Available: false} alongside a // non-nil error. Nothing is inferred as "zero roles" from a failure -- // callers must check Available, not just the length of Roles. func (c *HTTPClient) LiveRoles(ctx context.Context, tenantID string) (LiveRolesResult, error) { url := fmt.Sprintf("%s/tenants/%s/roles/live", c.BaseURL, tenantID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err) } resp, err := c.Client.Do(req) if err != nil { return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return LiveRolesResult{Available: false}, NewBackendError( FailureUnavailable, "live_roles", fmt.Errorf("status %d", resp.StatusCode), ) } var body struct { TenantID string `json:"tenant_id"` Roles []string `json:"roles"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return LiveRolesResult{Available: false}, NewBackendError(FailureInvalidResponse, "live_roles", err) } return LiveRolesResult{Roles: body.Roles, Available: true}, nil }