953f016549
Add cross-cloud compute-provider migration to the CLI, closing the gap
where the canvas can migrate a workspace's compute box across clouds
(AWS <-> Hetzner <-> GCP) but the CLI could not (molecule-mcp-server#64).
- 'molecule workspace migrate-provider <id> --to <p> [--from <p>] --confirm'
POSTs to the CP-admin POST /api/v1/admin/workspaces/:id/migrate-provider
endpoint via cpAdminClient() (CP-admin bearer + MOLECULE_CP_URL — the
tenant Org API Key has no standing on the control plane). Validates the
provider enum + from!=to client-side, requires --from-instance-id for
non-AWS sources, and refuses without --confirm (a real migration mutates
two clouds — never auto-confirmed).
- 'molecule workspace migration-status <id>' GETs the same path and prints
the {migration:{state,from_provider,to_provider,detail,...}, terminal}
record.
New client methods MigrateProvider / GetMigrationStatus + MigrateProviderRequest
mirror the existing CP-admin org methods. Tests cover the URL/method/body/auth
(client httptest capture) and the provider validation helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
518 lines
20 KiB
Go
518 lines
20 KiB
Go
// Package client — management surface (orgs, secrets, tokens, templates,
|
|
// bundles, budget, billing-mode, events, approvals).
|
|
//
|
|
// Request/response shapes are aligned to the live workspace-server handlers
|
|
// (tenant) and controlplane handlers (CP). Auth tiers per
|
|
// PLATFORM-MANAGEMENT-API.md §1: tenant calls use the Org API Key as a bearer
|
|
// (+ X-Molecule-Org-Id); CP org calls use a DISTINCT CP-admin bearer
|
|
// (MOLECULE_CP_ADMIN_TOKEN) — the tenant Org API Key is never sent to the CP.
|
|
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Control-plane: orgs (ADMIN bearer surface — /api/v1/admin/orgs)
|
|
//
|
|
// The customer-facing /api/v1/orgs* routes are gated by a WorkOS browser
|
|
// session (controlplane auth.RequireSession), which a bearer-token CLI cannot
|
|
// satisfy — every call 401s in production, and the tenant Org API Key has no
|
|
// standing on the CP at all. So org-lifecycle verbs go to the CP ADMIN routes
|
|
// (controlplane router AdminGate), authenticated with a DISTINCT CP-admin
|
|
// bearer (MOLECULE_CP_ADMIN_TOKEN). The org key is never sent to the CP.
|
|
//
|
|
// Note: only create + list exist on the admin surface. There is NO admin
|
|
// GET /orgs/:slug and NO admin /orgs/:slug/export in controlplane — those are
|
|
// session-only — so the CLI fails those verbs fast rather than 401'ing.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Org mirrors controlplane handlers.adminOrgSummary, the per-row shape of
|
|
// GET /api/v1/admin/orgs. Intentionally narrower than the session-facing
|
|
// customer org view (no credits/overage — those aren't on the admin summary).
|
|
type Org struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Plan string `json:"plan"`
|
|
Status string `json:"status,omitempty"`
|
|
StripeCustomerID string `json:"stripe_customer_id,omitempty"`
|
|
MemberCount int `json:"member_count,omitempty"`
|
|
InstanceStatus string `json:"instance_status,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
// adminListOrgsResponse is the envelope returned by GET /api/v1/admin/orgs:
|
|
// {"limit":N,"offset":N,"orgs":[...]}.
|
|
type adminListOrgsResponse struct {
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
Orgs []Org `json:"orgs"`
|
|
}
|
|
|
|
// ListOrgs returns all orgs from the CP admin surface
|
|
// (GET /api/v1/admin/orgs). Requires a CP-admin bearer.
|
|
func (p *Platform) ListOrgs() ([]Org, error) {
|
|
var out adminListOrgsResponse
|
|
if err := p.getInto("/api/v1/admin/orgs", &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Orgs, nil
|
|
}
|
|
|
|
// CreateOrgRequest is the body for POST /api/v1/admin/orgs. owner_user_id is
|
|
// REQUIRED by the admin route (controlplane adminCreateOrgRequest): the
|
|
// server-to-server path has no implicit WorkOS session to hang the owner
|
|
// membership off of, so the caller passes it explicitly.
|
|
type CreateOrgRequest struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
OwnerUserID string `json:"owner_user_id"`
|
|
}
|
|
|
|
// CreateOrg creates an org on the CP admin surface (POST /api/v1/admin/orgs).
|
|
// Requires a CP-admin bearer. Returns the full created org.
|
|
func (p *Platform) CreateOrg(req CreateOrgRequest) (*Org, error) {
|
|
var out Org
|
|
if err := p.postInto("/api/v1/admin/orgs", req, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Control-plane: cross-cloud compute-provider migration
|
|
// (ADMIN bearer surface — /api/v1/admin/workspaces/:id/migrate-provider)
|
|
//
|
|
// Moves a workspace's compute box across clouds (AWS ↔ Hetzner ↔ GCP). The
|
|
// migration is DATA-SAFE + ASYNC (~15-20 min): the CP snapshots the source's
|
|
// /workspace to R2, provisions the target (which restores on boot), verifies
|
|
// it's healthy, then retires the source (verify-before-destroy + rollback live
|
|
// in the CP). Like the other org-lifecycle verbs this is a CP-admin route, so
|
|
// it requires a CP-admin bearer (MOLECULE_CP_ADMIN_TOKEN) — the tenant Org API
|
|
// Key has no standing on the control plane.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// MigrateProviderRequest is the body for
|
|
// POST /api/v1/admin/workspaces/:id/migrate-provider.
|
|
//
|
|
// confirm MUST be true — a real migration mutates two clouds; the CP 400s
|
|
// without it. from_instance_id is required by the CP for non-AWS (Hetzner/GCP)
|
|
// sources (they have no workspace→instance resolver) and optional for AWS
|
|
// (the CP resolves the real instance from EC2 tags). org_id/runtime are hints
|
|
// the CP fills from tenant_resources for non-AWS sources when omitted.
|
|
type MigrateProviderRequest struct {
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Confirm bool `json:"confirm"`
|
|
FromInstanceID string `json:"from_instance_id,omitempty"`
|
|
OrgID string `json:"org_id,omitempty"`
|
|
Runtime string `json:"runtime,omitempty"`
|
|
}
|
|
|
|
// MigrateProvider starts a cross-cloud provider migration for a workspace
|
|
// (POST /api/v1/admin/workspaces/:id/migrate-provider). Requires a CP-admin
|
|
// bearer. Returns the raw 202 {status:"migration_started", …} body. The
|
|
// migration runs asynchronously — poll GetMigrationStatus for progress.
|
|
func (p *Platform) MigrateProvider(id string, req MigrateProviderRequest) (json.RawMessage, error) {
|
|
return p.postRaw("/api/v1/admin/workspaces/"+url.PathEscape(id)+"/migrate-provider", req)
|
|
}
|
|
|
|
// GetMigrationStatus reads the latest cross-cloud provider-migration record for
|
|
// a workspace (GET /api/v1/admin/workspaces/:id/migrate-provider). Requires a
|
|
// CP-admin bearer. Returns the raw {migration:{state, from_provider,
|
|
// to_provider, detail, …}, terminal} body. The CP 404s when the workspace has
|
|
// never been migrated.
|
|
func (p *Platform) GetMigrationStatus(id string) (json.RawMessage, error) {
|
|
return p.getRaw("/api/v1/admin/workspaces/" + url.PathEscape(id) + "/migrate-provider")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tenant: org-from-template (POST /org/import) + allowlist + org tokens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ImportOrgRequest is the body for POST /org/import.
|
|
type ImportOrgRequest struct {
|
|
Dir string `json:"dir,omitempty"` // org template directory name
|
|
Mode string `json:"mode,omitempty"` // "merge" (default) | "reconcile"
|
|
}
|
|
|
|
// CreateOrgFromTemplate provisions workspaces from an org template directory
|
|
// (POST /org/import). Returns the raw per-workspace result list.
|
|
func (p *Platform) CreateOrgFromTemplate(req ImportOrgRequest) (json.RawMessage, error) {
|
|
return p.postRaw("/org/import", req)
|
|
}
|
|
|
|
// GetAllowlist returns the org plugin allowlist (GET /orgs/:id/plugins/allowlist).
|
|
func (p *Platform) GetAllowlist(orgID string) (json.RawMessage, error) {
|
|
return p.getRaw("/orgs/" + url.PathEscape(orgID) + "/plugins/allowlist")
|
|
}
|
|
|
|
// OrgToken is a row from GET /org/tokens.
|
|
type OrgToken struct {
|
|
ID string `json:"id"`
|
|
Prefix string `json:"prefix"`
|
|
Name string `json:"name,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
LastUsed string `json:"last_used_at,omitempty"`
|
|
}
|
|
|
|
// ListOrgTokens lists the org's Org API Keys (GET /org/tokens).
|
|
func (p *Platform) ListOrgTokens() ([]OrgToken, error) {
|
|
var out []OrgToken
|
|
if err := p.getInto("/org/tokens", &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CreateOrgTokenResponse is the POST /org/tokens response. The plaintext
|
|
// token is shown exactly once.
|
|
type CreateOrgTokenResponse struct {
|
|
ID string `json:"id"`
|
|
Prefix string `json:"prefix"`
|
|
Name string `json:"name,omitempty"`
|
|
Token string `json:"auth_token"`
|
|
Warning string `json:"warning,omitempty"`
|
|
}
|
|
|
|
// CreateOrgToken mints a new Org API Key (POST /org/tokens).
|
|
func (p *Platform) CreateOrgToken(name string) (*CreateOrgTokenResponse, error) {
|
|
var out CreateOrgTokenResponse
|
|
if err := p.postInto("/org/tokens", map[string]string{"name": name}, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// RevokeOrgToken revokes an Org API Key by id (DELETE /org/tokens/:id).
|
|
func (p *Platform) RevokeOrgToken(id string) error {
|
|
_, err := p.delete("/org/tokens/" + url.PathEscape(id))
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tenant: workspace lifecycle (pause/resume) + budget + billing-mode + token
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// PauseWorkspace pauses a workspace (POST /workspaces/:id/pause).
|
|
func (p *Platform) PauseWorkspace(id string) error {
|
|
_, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/pause")
|
|
return err
|
|
}
|
|
|
|
// ResumeWorkspace resumes a workspace (POST /workspaces/:id/resume).
|
|
func (p *Platform) ResumeWorkspace(id string) error {
|
|
_, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/resume")
|
|
return err
|
|
}
|
|
|
|
// SetRuntime updates a workspace's runtime (PATCH /workspaces/:id {runtime}).
|
|
// The response is the handler's raw JSON (e.g. {"status":"updated","needs_restart":true}).
|
|
func (p *Platform) SetRuntime(id, runtime string) (json.RawMessage, error) {
|
|
body := map[string]string{"runtime": runtime}
|
|
return p.patchRaw("/workspaces/"+url.PathEscape(id), body)
|
|
}
|
|
|
|
// SetModel updates a workspace's model override (PUT /workspaces/:id/model).
|
|
// The workspace-server validates the (runtime, model) pair and auto-restarts.
|
|
func (p *Platform) SetModel(id, model string) (json.RawMessage, error) {
|
|
body := map[string]string{"model": model}
|
|
return p.putRaw("/workspaces/"+url.PathEscape(id)+"/model", body)
|
|
}
|
|
|
|
// OfferedModel is one selectable (runtime, model) entry from the registry.
|
|
type OfferedModel struct {
|
|
Model string `json:"model"`
|
|
Provider string `json:"provider"`
|
|
PlatformBilled bool `json:"platform_billed"`
|
|
AuthEnv []string `json:"auth_env,omitempty"`
|
|
}
|
|
|
|
// OfferedModelsResponse is the envelope returned by GET /admin/llm/offered-models.
|
|
type OfferedModelsResponse struct {
|
|
Runtime string `json:"runtime"`
|
|
Models []OfferedModel `json:"models"`
|
|
}
|
|
|
|
// ErrRuntimeNotInRegistry is returned by ListOfferedModels when the server
|
|
// reports that the runtime is unknown to the provider registry. Callers that
|
|
// enforce model/runtime compatibility should treat this as a federation case
|
|
// and fail-open: the registry cannot validate runtimes it does not know.
|
|
var ErrRuntimeNotInRegistry = errors.New("runtime not in provider registry")
|
|
|
|
// ListOfferedModels returns the registry's native model menu for a runtime
|
|
// (GET /admin/llm/offered-models?runtime=...).
|
|
//
|
|
// - If the server returns 200 OK, the menu is returned.
|
|
// - If the server returns 404 because the runtime is unknown to the registry,
|
|
// it returns ErrRuntimeNotInRegistry so callers can fail-open for federated
|
|
// / third-party runtimes.
|
|
// - Any other HTTP error or network failure is returned as a normal error and
|
|
// MUST be treated as fail-closed by callers.
|
|
func (p *Platform) ListOfferedModels(runtime string) (*OfferedModelsResponse, error) {
|
|
u, err := url.Parse(p.BaseURL + "/admin/llm/offered-models")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse offered-models URL: %w", err)
|
|
}
|
|
q := u.Query()
|
|
q.Set("runtime", runtime)
|
|
u.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequest("GET", u.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new GET request: %w", err)
|
|
}
|
|
p.setAuth(req)
|
|
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GET %s: %w", u.String(), err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, ErrRuntimeNotInRegistry
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("GET %s: HTTP %d — %s", u.String(), resp.StatusCode, string(body))
|
|
}
|
|
|
|
var out OfferedModelsResponse
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return nil, fmt.Errorf("decode GET %s: %w", u.String(), err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// GetBudget returns a workspace's budget (GET /workspaces/:id/budget).
|
|
func (p *Platform) GetBudget(id string) (json.RawMessage, error) {
|
|
return p.getRaw("/workspaces/" + url.PathEscape(id) + "/budget")
|
|
}
|
|
|
|
// SetBudget sets per-period budget limits in USD cents
|
|
// (PATCH /workspaces/:id/budget, body {"budget_limits": {...}}). A nil value
|
|
// for a period clears that period's limit.
|
|
func (p *Platform) SetBudget(id string, limits map[string]*int64) (json.RawMessage, error) {
|
|
body := map[string]interface{}{"budget_limits": limits}
|
|
return p.patchRaw("/workspaces/"+url.PathEscape(id)+"/budget", body)
|
|
}
|
|
|
|
// SetBillingMode sets a workspace's LLM billing-mode override
|
|
// (PUT /admin/workspaces/:id/llm-billing-mode, body {"mode": ...}).
|
|
// Pass mode="" to clear the override (sends null).
|
|
func (p *Platform) SetBillingMode(id, mode string) (json.RawMessage, error) {
|
|
var modeVal interface{}
|
|
if mode == "" {
|
|
modeVal = nil
|
|
} else {
|
|
modeVal = mode
|
|
}
|
|
body := map[string]interface{}{"mode": modeVal}
|
|
return p.putRaw("/admin/workspaces/"+url.PathEscape(id)+"/llm-billing-mode", body)
|
|
}
|
|
|
|
// MintWorkspaceTokenResponse is the POST /workspaces/:id/tokens response.
|
|
type MintWorkspaceTokenResponse struct {
|
|
Token string `json:"auth_token"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// MintWorkspaceToken mints a new per-workspace auth token
|
|
// (POST /workspaces/:id/tokens). Plaintext is returned exactly once.
|
|
func (p *Platform) MintWorkspaceToken(id string) (*MintWorkspaceTokenResponse, error) {
|
|
var out MintWorkspaceTokenResponse
|
|
// Send an empty JSON object ({}) rather than a nil body: postInto would
|
|
// marshal nil to the literal `null`, which a handler that decodes into a
|
|
// struct/map can reject. {} matches sibling tooling and is always safe.
|
|
if err := p.postInto("/workspaces/"+url.PathEscape(id)+"/tokens", struct{}{}, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tenant: secrets (workspace + org)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Secret is a key entry from a secrets list. Values are not returned by the
|
|
// list endpoints (only keys + metadata).
|
|
type Secret struct {
|
|
Key string `json:"key"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
// ListWorkspaceSecrets lists a workspace's secret keys
|
|
// (GET /workspaces/:id/secrets).
|
|
func (p *Platform) ListWorkspaceSecrets(id string) (json.RawMessage, error) {
|
|
return p.getRaw("/workspaces/" + url.PathEscape(id) + "/secrets")
|
|
}
|
|
|
|
// SetWorkspaceSecret upserts a workspace secret (POST /workspaces/:id/secrets,
|
|
// body {"key","value"}). The tenant auto-restarts the workspace.
|
|
func (p *Platform) SetWorkspaceSecret(id, key, value string) error {
|
|
body := map[string]string{"key": key, "value": value}
|
|
return p.postInto("/workspaces/"+url.PathEscape(id)+"/secrets", body, nil)
|
|
}
|
|
|
|
// DeleteWorkspaceSecret deletes a workspace secret by key
|
|
// (DELETE /workspaces/:id/secrets/:key).
|
|
func (p *Platform) DeleteWorkspaceSecret(id, key string) error {
|
|
_, err := p.delete("/workspaces/" + url.PathEscape(id) + "/secrets/" + url.PathEscape(key))
|
|
return err
|
|
}
|
|
|
|
// ListOrgSecrets lists org-wide secret keys (GET /settings/secrets).
|
|
func (p *Platform) ListOrgSecrets() (json.RawMessage, error) {
|
|
return p.getRaw("/settings/secrets")
|
|
}
|
|
|
|
// SetOrgSecret upserts an org-wide secret (POST /settings/secrets).
|
|
func (p *Platform) SetOrgSecret(key, value string) error {
|
|
body := map[string]string{"key": key, "value": value}
|
|
return p.postInto("/settings/secrets", body, nil)
|
|
}
|
|
|
|
// DeleteOrgSecret deletes an org-wide secret by key
|
|
// (DELETE /settings/secrets/:key).
|
|
func (p *Platform) DeleteOrgSecret(key string) error {
|
|
_, err := p.delete("/settings/secrets/" + url.PathEscape(key))
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tenant: templates + bundles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListTemplates lists workspace templates (GET /templates).
|
|
func (p *Platform) ListTemplates() (json.RawMessage, error) {
|
|
return p.getRaw("/templates")
|
|
}
|
|
|
|
// ImportTemplate imports a template (POST /templates/import,
|
|
// body {"name","files"}).
|
|
func (p *Platform) ImportTemplate(name string, files map[string]string) (json.RawMessage, error) {
|
|
body := map[string]interface{}{"name": name, "files": files}
|
|
return p.postRaw("/templates/import", body)
|
|
}
|
|
|
|
// RefreshTemplates refreshes the template cache (POST /admin/templates/refresh).
|
|
func (p *Platform) RefreshTemplates() (json.RawMessage, error) {
|
|
return p.postRaw("/admin/templates/refresh", nil)
|
|
}
|
|
|
|
// ExportBundle exports a workspace bundle (GET /bundles/export/:id).
|
|
func (p *Platform) ExportBundle(id string) (json.RawMessage, error) {
|
|
return p.getRaw("/bundles/export/" + url.PathEscape(id))
|
|
}
|
|
|
|
// ImportBundle imports a bundle (POST /bundles/import). bundle is the raw
|
|
// bundle JSON as exported by ExportBundle.
|
|
func (p *Platform) ImportBundle(bundle json.RawMessage) (json.RawMessage, error) {
|
|
return p.postRaw("/bundles/import", bundle)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tenant: events + approvals
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListEvents lists recent structure_events (GET /events, AdminAuth).
|
|
func (p *Platform) ListEvents() (json.RawMessage, error) {
|
|
return p.getRaw("/events")
|
|
}
|
|
|
|
// ListPendingApprovals lists pending approvals (GET /approvals/pending).
|
|
func (p *Platform) ListPendingApprovals() (json.RawMessage, error) {
|
|
return p.getRaw("/approvals/pending")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Raw helpers — return the response body verbatim for endpoints whose shape
|
|
// is loose / pass-through (events, secrets lists, budget, exports, …).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (p *Platform) getRaw(path string) (json.RawMessage, error) {
|
|
req, err := http.NewRequest("GET", p.BaseURL+path, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new GET request: %w", err)
|
|
}
|
|
p.setAuth(req)
|
|
return p.doRaw(req, "GET", path)
|
|
}
|
|
|
|
func (p *Platform) postRaw(path string, body interface{}) (json.RawMessage, error) {
|
|
var encoded []byte
|
|
if body != nil {
|
|
// Allow callers to pass a pre-encoded json.RawMessage straight through.
|
|
if rm, ok := body.(json.RawMessage); ok {
|
|
encoded = rm
|
|
} else {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal POST body: %w", err)
|
|
}
|
|
encoded = b
|
|
}
|
|
}
|
|
var req *http.Request
|
|
var err error
|
|
if encoded != nil {
|
|
req, err = http.NewRequest("POST", p.BaseURL+path, bytes.NewReader(encoded))
|
|
} else {
|
|
req, err = http.NewRequest("POST", p.BaseURL+path, nil)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new POST request: %w", err)
|
|
}
|
|
if encoded != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
p.setAuth(req)
|
|
return p.doRaw(req, "POST", path)
|
|
}
|
|
|
|
func (p *Platform) putRaw(path string, body interface{}) (json.RawMessage, error) {
|
|
return p.bodyRaw("PUT", path, body)
|
|
}
|
|
|
|
func (p *Platform) patchRaw(path string, body interface{}) (json.RawMessage, error) {
|
|
return p.bodyRaw("PATCH", path, body)
|
|
}
|
|
|
|
func (p *Platform) bodyRaw(method, path string, body interface{}) (json.RawMessage, error) {
|
|
encoded, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal %s body: %w", method, err)
|
|
}
|
|
req, err := http.NewRequest(method, p.BaseURL+path, bytes.NewReader(encoded))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new %s request: %w", method, err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
p.setAuth(req)
|
|
return p.doRaw(req, method, path)
|
|
}
|
|
|
|
func (p *Platform) doRaw(req *http.Request, method, path string) (json.RawMessage, error) {
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s %s: %w", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("%s %s: HTTP %d — %s", method, path, resp.StatusCode, string(body))
|
|
}
|
|
return json.RawMessage(body), nil
|
|
}
|