account/list marks the contract's primary account "main" on the live API, not "mymain" as the published docs example shows, so matching the documented spelling reported every account as a non-admin, including the contract owner. Roles cannot stand in: a main account carries the same technical/billing values a sub-account can hold. Classify off "sub" instead, the one value the docs and the API agree on. The fixture copied the docs example, so the test passed on the same wrong assumption. Its bodies now mirror a live capture, anonymized: the main account carries no main_account or allow_gui, sub-accounts add them plus the access lists, and the primary account's type is "main". A table test pins both spellings. A review keys accounts on email plus external ID. Email came only from account/details, and any failure blanked it while still emitting the record, so a transient 5xx moved an account to a different key and surfaced it as one account removed and another added. Only the stable answers now degrade: UpCloud returns 403 ACCOUNT_FORBIDDEN, not 404, for an account outside the token's reach, and both keep the list-only fields. Anything else aborts the run. A blank username no longer discards every account already collected, matching the sibling drivers. Resolve the source name from GET /1.3/account so sources read "UpCloud <username>" rather than staying generic, and link the connector to its documentation page. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
1855 lines
54 KiB
Go
1855 lines
54 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package drivers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"go.probo.inc/probo/pkg/connector"
|
|
admin "google.golang.org/api/admin/directory/v1"
|
|
"google.golang.org/api/googleapi"
|
|
"google.golang.org/api/option"
|
|
)
|
|
|
|
// NameResolver fetches the human-readable instance name from a provider
|
|
// (e.g. Slack workspace name, Google Workspace domain).
|
|
type NameResolver interface {
|
|
ResolveInstanceName(ctx context.Context) (string, error)
|
|
}
|
|
|
|
// ErrTerminalNameResolution marks a permanent name-resolution failure
|
|
// (auth/bad-request) that retrying cannot fix: the source-name worker keeps
|
|
// the generic name and marks the source synced instead of re-claiming it.
|
|
// Transient failures (5xx, network) stay plain errors so they keep retrying.
|
|
var ErrTerminalNameResolution = errors.New("terminal name resolution failure")
|
|
|
|
// nameStatusError classifies a non-2xx response from a name-resolution
|
|
// request. Permanent client errors (400, 401, 403, 404) wrap
|
|
// ErrTerminalNameResolution; everything else (notably 5xx) stays retryable.
|
|
func nameStatusError(what string, statusCode int) error {
|
|
switch statusCode {
|
|
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound:
|
|
return fmt.Errorf("cannot fetch %s: unexpected status %d: %w", what, statusCode, ErrTerminalNameResolution)
|
|
default:
|
|
return fmt.Errorf("cannot fetch %s: unexpected status %d", what, statusCode)
|
|
}
|
|
}
|
|
|
|
// slackNameResolver resolves the Slack workspace name via auth.test.
|
|
type slackNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewSlackNameResolver(httpClient *http.Client) NameResolver {
|
|
return &slackNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *slackNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/auth.test", nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create slack auth.test request: %w", err)
|
|
}
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute slack auth.test request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
var resp struct {
|
|
OK bool `json:"ok"`
|
|
Team string `json:"team"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode slack auth.test response: %w", err)
|
|
}
|
|
|
|
if !resp.OK {
|
|
return "", fmt.Errorf("slack auth.test returned ok=false")
|
|
}
|
|
|
|
return resp.Team, nil
|
|
}
|
|
|
|
// googleWorkspaceNameResolver resolves the Google Workspace primary domain.
|
|
type googleWorkspaceNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewGoogleWorkspaceNameResolver(httpClient *http.Client) NameResolver {
|
|
return &googleWorkspaceNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *googleWorkspaceNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
adminService, err := admin.NewService(ctx, option.WithHTTPClient(r.httpClient))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create google admin service: %w", err)
|
|
}
|
|
|
|
customer, err := adminService.Customers.Get("my_customer").Context(ctx).Do()
|
|
if err != nil {
|
|
var gerr *googleapi.Error
|
|
if errors.As(err, &gerr) && gerr.Code == http.StatusForbidden {
|
|
return "", nil
|
|
}
|
|
|
|
return "", fmt.Errorf("cannot fetch google workspace customer: %w", err)
|
|
}
|
|
|
|
return customer.CustomerDomain, nil
|
|
}
|
|
|
|
// linearNameResolver resolves the Linear organization name via GraphQL.
|
|
type linearNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewLinearNameResolver(httpClient *http.Client) NameResolver {
|
|
return &linearNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *linearNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
body := struct {
|
|
Query string `json:"query"`
|
|
}{
|
|
Query: `{ organization { name } }`,
|
|
}
|
|
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot marshal linear organization query: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, linearGraphQLEndpoint, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create linear organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute linear organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("linear organization", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Data struct {
|
|
Organization struct {
|
|
Name string `json:"name"`
|
|
} `json:"organization"`
|
|
} `json:"data"`
|
|
Errors []struct {
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode linear organization response: %w", err)
|
|
}
|
|
|
|
if len(resp.Errors) > 0 {
|
|
return "", fmt.Errorf("linear graphql error: %s", resp.Errors[0].Message)
|
|
}
|
|
|
|
return resp.Data.Organization.Name, nil
|
|
}
|
|
|
|
// cloudflareNameResolver resolves the Cloudflare account name.
|
|
type cloudflareNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewCloudflareNameResolver(httpClient *http.Client) NameResolver {
|
|
return &cloudflareNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *cloudflareNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
cfURL, err := url.Parse("https://api.cloudflare.com/client/v4/accounts")
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot parse cloudflare accounts URL: %w", err)
|
|
}
|
|
|
|
q := cfURL.Query()
|
|
q.Set("page", "1")
|
|
// Cloudflare requires per_page in the range 5..50; per_page=1 is rejected
|
|
// with a 400 (which, before terminal classification, caused a 400 storm).
|
|
// Do not "optimize" this back down to 1.
|
|
q.Set("per_page", "50")
|
|
cfURL.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfURL.String(), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create cloudflare accounts request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute cloudflare accounts request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("cloudflare accounts", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Result []struct {
|
|
Name string `json:"name"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode cloudflare accounts response: %w", err)
|
|
}
|
|
|
|
if len(resp.Result) == 0 {
|
|
return "", fmt.Errorf("no cloudflare accounts found")
|
|
}
|
|
|
|
return resp.Result[0].Name, nil
|
|
}
|
|
|
|
// brexNameResolver resolves the Brex company name.
|
|
type brexNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewBrexNameResolver(httpClient *http.Client) NameResolver {
|
|
return &brexNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *brexNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://platform.brexapis.com/v2/company",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create brex company request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute brex company request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("brex company", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
LegalName string `json:"legal_name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode brex company response: %w", err)
|
|
}
|
|
|
|
return resp.LegalName, nil
|
|
}
|
|
|
|
// tallyNameResolver resolves the Tally organization name.
|
|
type tallyNameResolver struct {
|
|
httpClient *http.Client
|
|
organizationID string
|
|
}
|
|
|
|
func NewTallyNameResolver(httpClient *http.Client, organizationID string) NameResolver {
|
|
return &tallyNameResolver{
|
|
httpClient: httpClient,
|
|
organizationID: organizationID,
|
|
}
|
|
}
|
|
|
|
func (r *tallyNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
endpoint, err := url.JoinPath("https://api.tally.so", "organizations", url.PathEscape(r.organizationID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build tally organization URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create tally organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute tally organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("tally organization", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode tally organization response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// qoveryNameResolver resolves the Qovery organization name.
|
|
type qoveryNameResolver struct {
|
|
httpClient *http.Client
|
|
organizationID string
|
|
}
|
|
|
|
func NewQoveryNameResolver(httpClient *http.Client, organizationID string) NameResolver {
|
|
return &qoveryNameResolver{
|
|
httpClient: httpClient,
|
|
organizationID: organizationID,
|
|
}
|
|
}
|
|
|
|
func (r *qoveryNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.organizationID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath(qoveryAPIBaseURL, "organization", url.PathEscape(r.organizationID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build qovery organization URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create qovery organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute qovery organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// Best-effort: a non-2xx (revoked token, deleted org, stale ID) must not
|
|
// make the source-name worker retry forever. Give up gracefully and keep
|
|
// the generic source name; a dead token surfaces on the next ListAccounts.
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode qovery organization response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// renderNameResolver resolves the Render workspace (owner) name from
|
|
// GET /v1/owners/{ownerId}, used to title the AccessReviewSource "Render <name>".
|
|
type renderNameResolver struct {
|
|
httpClient *http.Client
|
|
ownerID string
|
|
}
|
|
|
|
func NewRenderNameResolver(httpClient *http.Client, ownerID string) NameResolver {
|
|
return &renderNameResolver{
|
|
httpClient: httpClient,
|
|
ownerID: ownerID,
|
|
}
|
|
}
|
|
|
|
func (r *renderNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.ownerID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath(renderAPIBaseURL, "owners", url.PathEscape(r.ownerID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build render owner URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create render owner request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute render owner request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// Best-effort: a non-2xx (revoked token, deleted workspace, stale ID) must
|
|
// not make the source-name worker retry forever. Give up gracefully and
|
|
// keep the generic source name; a dead token surfaces on the next
|
|
// ListAccounts.
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode render owner response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// neonNameResolver resolves the Neon organization name.
|
|
type neonNameResolver struct {
|
|
httpClient *http.Client
|
|
organizationID string
|
|
}
|
|
|
|
func NewNeonNameResolver(httpClient *http.Client, organizationID string) NameResolver {
|
|
return &neonNameResolver{
|
|
httpClient: httpClient,
|
|
organizationID: organizationID,
|
|
}
|
|
}
|
|
|
|
func (r *neonNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.organizationID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath(neonAPIBaseURL, "organizations", url.PathEscape(r.organizationID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build neon organization URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create neon organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute neon organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// Best-effort: a non-2xx (revoked key, deleted org, stale ID) must not
|
|
// make the source-name worker retry forever. Give up gracefully and keep
|
|
// the generic source name; a dead key surfaces on the next ListAccounts.
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode neon organization response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// hubspotNameResolver resolves the HubSpot account name.
|
|
type hubspotNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewHubSpotNameResolver(httpClient *http.Client) NameResolver {
|
|
return &hubspotNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *hubspotNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://api.hubapi.com/account-info/v3/details",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create hubspot account-info request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute hubspot account-info request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("hubspot account info", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
PortalID int `json:"portalId"`
|
|
AccountName string `json:"accountName"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode hubspot account-info response: %w", err)
|
|
}
|
|
|
|
return resp.AccountName, nil
|
|
}
|
|
|
|
// docusignNameResolver resolves the configured DocuSign account's name from
|
|
// the OAuth2 userinfo endpoint, for the AccessReviewSource title.
|
|
type docusignNameResolver struct {
|
|
httpClient *http.Client
|
|
accountID string
|
|
}
|
|
|
|
func NewDocuSignNameResolver(httpClient *http.Client, accountID string) NameResolver {
|
|
return &docusignNameResolver{httpClient: httpClient, accountID: accountID}
|
|
}
|
|
|
|
func (r *docusignNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
accounts, err := fetchDocuSignAccounts(ctx, r.httpClient)
|
|
if err != nil {
|
|
// A dead token (non-2xx) is terminal: the source-name worker marks
|
|
// the source synced on ("", nil), so it stops retrying. Transient and
|
|
// decode failures stay errors so the worker retries.
|
|
if errors.Is(err, errDocuSignUserInfoStatus) {
|
|
return "", nil
|
|
}
|
|
|
|
return "", err
|
|
}
|
|
|
|
for _, account := range accounts {
|
|
if account.AccountID == r.accountID {
|
|
return account.AccountName, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
// openaiNameResolver resolves the OpenAI organization name.
|
|
type openaiNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewOpenAINameResolver(httpClient *http.Client) NameResolver {
|
|
return &openaiNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *openaiNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://api.openai.com/v1/organization",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create openai organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute openai organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
// OpenAI may not support this endpoint for all token types.
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode openai organization response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// anthropicNameResolver resolves the Anthropic organization name via the
|
|
// Admin API /v1/organizations/me endpoint, which returns the org an
|
|
// admin key belongs to.
|
|
type anthropicNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewAnthropicNameResolver(httpClient *http.Client) NameResolver {
|
|
return &anthropicNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *anthropicNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://api.anthropic.com/v1/organizations/me",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create anthropic organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("anthropic-version", anthropicAPIVersion)
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute anthropic organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// Best-effort: a non-2xx (e.g. a revoked admin key) must not make the
|
|
// source-name worker retry forever. Give up gracefully and keep the
|
|
// generic source name; a dead key surfaces on the next ListAccounts.
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode anthropic organization response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// sendGridNameResolver resolves the SendGrid account's company name from
|
|
// the user profile endpoint, used as the AccessReviewSource instance label.
|
|
type sendGridNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewSendGridNameResolver(httpClient *http.Client) NameResolver {
|
|
return &sendGridNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *sendGridNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
"https://api.sendgrid.com/v3/user/profile",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create sendgrid profile request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute sendgrid profile request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// Best-effort: a non-2xx (revoked key, or a key without the
|
|
// user.profile.read scope) must not make the source-name worker retry
|
|
// forever. Give up gracefully and keep the generic source name; a dead
|
|
// key surfaces on the next ListAccounts.
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Company string `json:"company"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode sendgrid profile response: %w", err)
|
|
}
|
|
|
|
return resp.Company, nil
|
|
}
|
|
|
|
// sentryNameResolver resolves the Sentry organization name.
|
|
type sentryNameResolver struct {
|
|
httpClient *http.Client
|
|
orgSlug string
|
|
}
|
|
|
|
func NewSentryNameResolver(httpClient *http.Client, orgSlug string) NameResolver {
|
|
return &sentryNameResolver{httpClient: httpClient, orgSlug: orgSlug}
|
|
}
|
|
|
|
func (r *sentryNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.orgSlug == "" {
|
|
return "", nil
|
|
}
|
|
|
|
// Trailing slash required; see SentryDriver.ListAccounts.
|
|
endpoint, err := url.JoinPath("https://sentry.io", "api", "0", "organizations", url.PathEscape(r.orgSlug)+"/")
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build sentry organization URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create sentry organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute sentry organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// 404 means the stored slug is no longer visible to this token.
|
|
// Treat as terminal so the worker stops looping; other non-2xx
|
|
// stay retryable for token refresh / transient outages.
|
|
if httpResp.StatusCode == http.StatusNotFound {
|
|
return "", nil
|
|
}
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("sentry organization", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode sentry organization response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// githubNameResolver resolves the GitHub organization name.
|
|
type githubNameResolver struct {
|
|
httpClient *http.Client
|
|
org string
|
|
}
|
|
|
|
func NewGitHubNameResolver(httpClient *http.Client, org string) NameResolver {
|
|
return &githubNameResolver{httpClient: httpClient, org: org}
|
|
}
|
|
|
|
func (r *githubNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.org == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath("https://api.github.com", "orgs", url.PathEscape(r.org))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build github organization URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create github organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute github organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("github organization", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode github organization response: %w", err)
|
|
}
|
|
|
|
if resp.Name == "" {
|
|
return r.org, nil
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// supabaseNameResolver returns the Supabase organization slug as the name.
|
|
type supabaseNameResolver struct {
|
|
orgSlug string
|
|
}
|
|
|
|
func NewSupabaseNameResolver(orgSlug string) NameResolver {
|
|
return &supabaseNameResolver{orgSlug: orgSlug}
|
|
}
|
|
|
|
func (r *supabaseNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
|
return r.orgSlug, nil
|
|
}
|
|
|
|
// intercomNameResolver resolves the Intercom app name.
|
|
type intercomNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewIntercomNameResolver(httpClient *http.Client) NameResolver {
|
|
return &intercomNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *intercomNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.intercom.io/me", nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create intercom me request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Intercom-Version", "2.11")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute intercom me request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
App struct {
|
|
Name string `json:"name"`
|
|
} `json:"app"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode intercom me response: %w", err)
|
|
}
|
|
|
|
return resp.App.Name, nil
|
|
}
|
|
|
|
// resendNameResolver returns a static name for Resend.
|
|
type resendNameResolver struct{}
|
|
|
|
func NewResendNameResolver() NameResolver {
|
|
return &resendNameResolver{}
|
|
}
|
|
|
|
func (r *resendNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
|
return "Resend", nil
|
|
}
|
|
|
|
// betterStackNameResolver returns the Better Stack team name captured when
|
|
// the API-key connector was created. The team name is the human-readable
|
|
// instance identifier, so no HTTP call is required.
|
|
type betterStackNameResolver struct {
|
|
teamName string
|
|
}
|
|
|
|
func NewBetterStackNameResolver(teamName string) NameResolver {
|
|
return &betterStackNameResolver{teamName: teamName}
|
|
}
|
|
|
|
func (r *betterStackNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
|
return r.teamName, nil
|
|
}
|
|
|
|
// gitlabNameResolver resolves the GitLab group name.
|
|
type gitlabNameResolver struct {
|
|
httpClient *http.Client
|
|
groupID string
|
|
}
|
|
|
|
func NewGitLabNameResolver(httpClient *http.Client, groupID string) NameResolver {
|
|
return &gitlabNameResolver{httpClient: httpClient, groupID: groupID}
|
|
}
|
|
|
|
func (r *gitlabNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.groupID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath("https://gitlab.com", "api", "v4", "groups", url.PathEscape(r.groupID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build gitlab group URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create gitlab group request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute gitlab group request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("gitlab group", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
FullPath string `json:"full_path"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode gitlab group response: %w", err)
|
|
}
|
|
|
|
if resp.Name != "" {
|
|
return resp.Name, nil
|
|
}
|
|
|
|
return resp.FullPath, nil
|
|
}
|
|
|
|
// bitbucketNameResolver resolves the Bitbucket workspace name.
|
|
type bitbucketNameResolver struct {
|
|
httpClient *http.Client
|
|
workspace string
|
|
}
|
|
|
|
func NewBitbucketNameResolver(httpClient *http.Client, workspace string) NameResolver {
|
|
return &bitbucketNameResolver{httpClient: httpClient, workspace: workspace}
|
|
}
|
|
|
|
func (r *bitbucketNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.workspace == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath("https://api.bitbucket.org", "2.0", "workspaces", url.PathEscape(r.workspace))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build bitbucket workspace URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create bitbucket workspace request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute bitbucket workspace request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("bitbucket workspace", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode bitbucket workspace response: %w", err)
|
|
}
|
|
|
|
if resp.Name != "" {
|
|
return resp.Name, nil
|
|
}
|
|
|
|
return resp.Slug, nil
|
|
}
|
|
|
|
// herokuNameResolver resolves the Heroku team name.
|
|
type herokuNameResolver struct {
|
|
httpClient *http.Client
|
|
teamID string
|
|
}
|
|
|
|
func NewHerokuNameResolver(httpClient *http.Client, teamID string) NameResolver {
|
|
return &herokuNameResolver{httpClient: httpClient, teamID: teamID}
|
|
}
|
|
|
|
func (r *herokuNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.teamID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
// A personal account has no Team to name; short-circuit before hitting
|
|
// GET /teams/@personal, which 404s and would loop the source-name worker.
|
|
if r.teamID == herokuPersonalAccountSlug {
|
|
return herokuPersonalAccountDisplayName, nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath("https://api.heroku.com", "teams", url.PathEscape(r.teamID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build heroku team URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create heroku team request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/vnd.heroku+json; version=3")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute heroku team request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("heroku team", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode heroku team response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// pagerdutyNameResolver returns the PagerDuty subdomain stored in connector
|
|
// settings. The subdomain is captured during the OAuth callback (see
|
|
// handleConnectorComplete) so no HTTP call is required.
|
|
type pagerdutyNameResolver struct {
|
|
subdomain string
|
|
}
|
|
|
|
func NewPagerDutyNameResolver(subdomain string) NameResolver {
|
|
return &pagerdutyNameResolver{subdomain: subdomain}
|
|
}
|
|
|
|
func (r *pagerdutyNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
|
return r.subdomain, nil
|
|
}
|
|
|
|
// datadogNameResolver returns the Datadog site/region label stored in
|
|
// connector settings (e.g. "US3"), captured during the OAuth callback. No
|
|
// HTTP call is required; the AccessReviewSource title becomes "Datadog <region>".
|
|
// Org-name resolution is intentionally omitted to keep scopes to
|
|
// user_access_read (the org name endpoint needs org_management).
|
|
type datadogNameResolver struct {
|
|
region string
|
|
}
|
|
|
|
func NewDatadogNameResolver(region string) NameResolver {
|
|
return &datadogNameResolver{region: region}
|
|
}
|
|
|
|
func (r *datadogNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
|
return r.region, nil
|
|
}
|
|
|
|
// oktaNameResolver resolves the Okta org name via GET /api/v1/org on the
|
|
// configured org host. A non-2xx is terminal — a read-only API token may
|
|
// lack org-settings read, so it returns ("", nil) to keep the generic
|
|
// source name rather than make the source-name worker retry forever.
|
|
type oktaNameResolver struct {
|
|
httpClient *http.Client
|
|
domain string
|
|
}
|
|
|
|
func NewOktaNameResolver(httpClient *http.Client, domain string) NameResolver {
|
|
return &oktaNameResolver{httpClient: httpClient, domain: domain}
|
|
}
|
|
|
|
func (r *oktaNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.domain == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint := url.URL{Scheme: "https", Host: r.domain, Path: "/api/v1/org"}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create okta org request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute okta org request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
CompanyName string `json:"companyName"`
|
|
Subdomain string `json:"subdomain"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode okta org response: %w", err)
|
|
}
|
|
|
|
if resp.CompanyName != "" {
|
|
return resp.CompanyName, nil
|
|
}
|
|
|
|
return resp.Subdomain, nil
|
|
}
|
|
|
|
// zendeskNameResolver returns the Zendesk subdomain stored in connector
|
|
// settings (e.g. "acme" for acme.zendesk.com), captured at connect time. No
|
|
// HTTP call is required; the AccessReviewSource title becomes "Zendesk <subdomain>".
|
|
// Account-name resolution is intentionally omitted to keep the scope to
|
|
// users:read (Zendesk exposes no human account name on that scope).
|
|
type zendeskNameResolver struct {
|
|
subdomain string
|
|
}
|
|
|
|
func NewZendeskNameResolver(subdomain string) NameResolver {
|
|
return &zendeskNameResolver{subdomain: subdomain}
|
|
}
|
|
|
|
func (r *zendeskNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
|
return r.subdomain, nil
|
|
}
|
|
|
|
// asanaNameResolver resolves the Asana workspace name.
|
|
type asanaNameResolver struct {
|
|
httpClient *http.Client
|
|
workspaceGID string
|
|
}
|
|
|
|
func NewAsanaNameResolver(httpClient *http.Client, workspaceGID string) NameResolver {
|
|
return &asanaNameResolver{httpClient: httpClient, workspaceGID: workspaceGID}
|
|
}
|
|
|
|
func (r *asanaNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.workspaceGID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath("https://app.asana.com", "api", "1.0", "workspaces", url.PathEscape(r.workspaceGID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build asana workspace URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create asana workspace request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute asana workspace request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("asana workspace", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Data struct {
|
|
Name string `json:"name"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode asana workspace response: %w", err)
|
|
}
|
|
|
|
return resp.Data.Name, nil
|
|
}
|
|
|
|
// netlifyNameResolver resolves the Netlify account name.
|
|
type netlifyNameResolver struct {
|
|
httpClient *http.Client
|
|
accountSlug string
|
|
}
|
|
|
|
func NewNetlifyNameResolver(httpClient *http.Client, accountSlug string) NameResolver {
|
|
return &netlifyNameResolver{httpClient: httpClient, accountSlug: accountSlug}
|
|
}
|
|
|
|
func (r *netlifyNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.accountSlug == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath("https://api.netlify.com", "api", "v1", "accounts", url.PathEscape(r.accountSlug))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build netlify account URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create netlify account request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute netlify account request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("netlify account", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode netlify account response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
// clickupNameResolver resolves the ClickUp team name.
|
|
type clickupNameResolver struct {
|
|
httpClient *http.Client
|
|
teamID string
|
|
}
|
|
|
|
func NewClickUpNameResolver(httpClient *http.Client, teamID string) NameResolver {
|
|
return &clickupNameResolver{httpClient: httpClient, teamID: teamID}
|
|
}
|
|
|
|
func (r *clickupNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.teamID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath("https://api.clickup.com", "api", "v2", "team", url.PathEscape(r.teamID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build clickup team URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create clickup team request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute clickup team request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("clickup team", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Team struct {
|
|
Name string `json:"name"`
|
|
} `json:"team"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode clickup team response: %w", err)
|
|
}
|
|
|
|
return resp.Team.Name, nil
|
|
}
|
|
|
|
// vercelNameResolver resolves the Vercel team name. When the captured
|
|
// TeamID is a personal-account UID, the v2 teams endpoint returns 404;
|
|
// the resolver falls back to /v2/user and uses `username` (or `name`)
|
|
// as the display name.
|
|
type vercelNameResolver struct {
|
|
httpClient *http.Client
|
|
teamID string
|
|
}
|
|
|
|
func NewVercelNameResolver(httpClient *http.Client, teamID string) NameResolver {
|
|
return &vercelNameResolver{httpClient: httpClient, teamID: teamID}
|
|
}
|
|
|
|
func (r *vercelNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.teamID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
teamURL, err := url.JoinPath("https://api.vercel.com", "v2", "teams", url.PathEscape(r.teamID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build vercel team URL: %w", err)
|
|
}
|
|
|
|
teamReq, err := http.NewRequestWithContext(ctx, http.MethodGet, teamURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create vercel team request: %w", err)
|
|
}
|
|
|
|
teamReq.Header.Set("Accept", "application/json")
|
|
|
|
teamResp, err := r.httpClient.Do(teamReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute vercel team request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = teamResp.Body.Close() }()
|
|
|
|
if teamResp.StatusCode == http.StatusOK {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
if err := json.NewDecoder(teamResp.Body).Decode(&body); err != nil {
|
|
return "", fmt.Errorf("cannot decode vercel team response: %w", err)
|
|
}
|
|
|
|
if body.Name != "" {
|
|
return body.Name, nil
|
|
}
|
|
|
|
return body.Slug, nil
|
|
}
|
|
|
|
if teamResp.StatusCode != http.StatusNotFound {
|
|
return "", nameStatusError("vercel team", teamResp.StatusCode)
|
|
}
|
|
|
|
// Personal-account fallback: /v2/teams/<uid> returns 404, but
|
|
// /v2/user works with the same Bearer token.
|
|
user, err := connector.FetchVercelUser(ctx, r.httpClient)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if user.Username != "" {
|
|
return user.Username, nil
|
|
}
|
|
|
|
return user.Name, nil
|
|
}
|
|
|
|
// mondayNameResolver resolves the Monday.com account name via GraphQL.
|
|
type mondayNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewMondayNameResolver(httpClient *http.Client) NameResolver {
|
|
return &mondayNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *mondayNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
body := struct {
|
|
Query string `json:"query"`
|
|
}{
|
|
Query: `query { account { id name slug tier } }`,
|
|
}
|
|
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot marshal monday account query: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, mondayGraphQLEndpoint, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create monday account request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute monday account request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("monday account", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Data struct {
|
|
Account struct {
|
|
Name string `json:"name"`
|
|
} `json:"account"`
|
|
} `json:"data"`
|
|
Errors []struct {
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode monday account response: %w", err)
|
|
}
|
|
|
|
if len(resp.Errors) > 0 {
|
|
// Provider-supplied messages may carry tenant identifiers or
|
|
// query fragments — never embed them. Driver scrubs the same
|
|
// field; keep both call sites aligned.
|
|
return "", fmt.Errorf("cannot fetch monday account: graphql error")
|
|
}
|
|
|
|
return resp.Data.Account.Name, nil
|
|
}
|
|
|
|
// notionNameResolver resolves the Notion workspace name via /v1/users/me.
|
|
type notionNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewNotionNameResolver(httpClient *http.Client) NameResolver {
|
|
return ¬ionNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *notionNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.notion.com/v1/users/me", nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create notion users/me request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Notion-Version", notionAPIVersion)
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute notion users/me request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("notion users/me", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Bot struct {
|
|
WorkspaceName string `json:"workspace_name"`
|
|
} `json:"bot"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode notion users/me response: %w", err)
|
|
}
|
|
|
|
return resp.Bot.WorkspaceName, nil
|
|
}
|
|
|
|
// microsoft365NameResolver resolves the Microsoft 365 tenant display name
|
|
// via the Microsoft Graph organization endpoint.
|
|
type microsoft365NameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewMicrosoft365NameResolver(httpClient *http.Client) NameResolver {
|
|
return µsoft365NameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *microsoft365NameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
msURL, err := url.Parse("https://graph.microsoft.com/v1.0/organization")
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot parse microsoft 365 organization URL: %w", err)
|
|
}
|
|
|
|
q := msURL.Query()
|
|
q.Set("$select", "displayName,verifiedDomains")
|
|
msURL.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, msURL.String(), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create microsoft 365 organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute microsoft 365 organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("microsoft 365 organization", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Value []struct {
|
|
DisplayName string `json:"displayName"`
|
|
VerifiedDomains []struct {
|
|
Name string `json:"name"`
|
|
IsDefault bool `json:"isDefault"`
|
|
} `json:"verifiedDomains"`
|
|
} `json:"value"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode microsoft 365 organization response: %w", err)
|
|
}
|
|
|
|
if len(resp.Value) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
org := resp.Value[0]
|
|
if org.DisplayName != "" {
|
|
return org.DisplayName, nil
|
|
}
|
|
|
|
for _, d := range org.VerifiedDomains {
|
|
if d.IsDefault {
|
|
return d.Name, nil
|
|
}
|
|
}
|
|
|
|
if len(org.VerifiedDomains) > 0 {
|
|
return org.VerifiedDomains[0].Name, nil
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
// railwayNameResolver resolves the Railway workspace name via GraphQL, for the
|
|
// AccessReviewSource title. With a single workspace it uses that workspace's
|
|
// name; with several it falls back to the account holder's name, since the
|
|
// source spans all of the account's workspaces.
|
|
type railwayNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewRailwayNameResolver(httpClient *http.Client) NameResolver {
|
|
return &railwayNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *railwayNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
httpResp, err := railwayPost(ctx, r.httpClient, "account", `query { me { name workspaces { id name } } }`)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// Best-effort: a non-2xx must not make the source-name worker retry forever
|
|
// — keep the generic name. (Railway also signals auth failure with a 200 +
|
|
// errors body, handled below.)
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Data struct {
|
|
Me *struct {
|
|
Name string `json:"name"`
|
|
Workspaces []struct {
|
|
Name string `json:"name"`
|
|
} `json:"workspaces"`
|
|
} `json:"me"`
|
|
} `json:"data"`
|
|
Errors []struct {
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode railway account response: %w", err)
|
|
}
|
|
|
|
if len(resp.Errors) > 0 || resp.Data.Me == nil {
|
|
return "", nil
|
|
}
|
|
|
|
// A single workspace names the source directly; with several (or none) fall
|
|
// back to the account holder's display name. Never the email — a terminal
|
|
// empty result keeps the generic source name, which the worker tolerates.
|
|
me := resp.Data.Me
|
|
if len(me.Workspaces) == 1 {
|
|
return me.Workspaces[0].Name, nil
|
|
}
|
|
|
|
return me.Name, nil
|
|
}
|
|
|
|
// crispNameResolver resolves the Crisp website name via GET /v1/website/{id},
|
|
// for the AccessReviewSource title. Like the driver it sends the X-Crisp-Tier
|
|
// header; the Basic credential is supplied by the connection transport.
|
|
type crispNameResolver struct {
|
|
httpClient *http.Client
|
|
websiteID string
|
|
}
|
|
|
|
func NewCrispNameResolver(httpClient *http.Client, websiteID string) NameResolver {
|
|
return &crispNameResolver{httpClient: httpClient, websiteID: websiteID}
|
|
}
|
|
|
|
func (r *crispNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.websiteID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
httpResp, err := crispGet(ctx, r.httpClient, "website", "website", url.PathEscape(r.websiteID))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// Best-effort: a non-2xx (revoked token, stale website id) must not make the
|
|
// source-name worker retry forever — keep the generic name.
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Data struct {
|
|
Name string `json:"name"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode crisp website response: %w", err)
|
|
}
|
|
|
|
return resp.Data.Name, nil
|
|
}
|
|
|
|
// squareNameResolver resolves the Square merchant's business name via
|
|
// GET /v2/merchants/me. A Square token — OAuth or PAT — is scoped to a single
|
|
// merchant, so "me" resolves it for both connection kinds.
|
|
type squareNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewSquareNameResolver(httpClient *http.Client) NameResolver {
|
|
return &squareNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *squareNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://connect.squareup.com/v2/merchants/me", nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create square merchant request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Square-Version", squareAPIVersion)
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute square merchant request: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = httpResp.Body.Close()
|
|
}()
|
|
|
|
// A non-2xx (revoked token, missing scope) is terminal: keep the generic
|
|
// source name rather than make the source-name worker retry forever.
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Merchant struct {
|
|
BusinessName string `json:"business_name"`
|
|
} `json:"merchant"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode square merchant response: %w", err)
|
|
}
|
|
|
|
return resp.Merchant.BusinessName, nil
|
|
}
|
|
|
|
// googleAnalyticsNameResolver resolves a GA4 account's display name.
|
|
type googleAnalyticsNameResolver struct {
|
|
httpClient *http.Client
|
|
accountID string
|
|
}
|
|
|
|
func NewGoogleAnalyticsNameResolver(httpClient *http.Client, accountID string) NameResolver {
|
|
return &googleAnalyticsNameResolver{httpClient: httpClient, accountID: accountID}
|
|
}
|
|
|
|
func (r *googleAnalyticsNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.accountID == "" {
|
|
return "", nil
|
|
}
|
|
|
|
endpoint, err := url.JoinPath("https://"+googleAnalyticsAPIHost, "v1alpha", "accounts", url.PathEscape(r.accountID))
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot build google analytics account URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create google analytics account request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute google analytics account request: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = httpResp.Body.Close()
|
|
}()
|
|
|
|
// A non-2xx (revoked token, renamed/deleted account) is terminal: keep the
|
|
// generic source name rather than retry forever.
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode google analytics account response: %w", err)
|
|
}
|
|
|
|
return resp.DisplayName, nil
|
|
}
|
|
|
|
// segmentNameResolver resolves the Segment workspace name. The Public API
|
|
// binds a token to exactly one workspace and exposes it at the API root, so
|
|
// the base URL (which already encodes the US/EU region) is the whole request.
|
|
type segmentNameResolver struct {
|
|
httpClient *http.Client
|
|
baseURL string
|
|
}
|
|
|
|
func NewSegmentNameResolver(httpClient *http.Client, baseURL string) NameResolver {
|
|
return &segmentNameResolver{httpClient: httpClient, baseURL: baseURL}
|
|
}
|
|
|
|
func (r *segmentNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
if r.baseURL == "" {
|
|
return "", nil
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.baseURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create segment workspace request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute segment workspace request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("segment workspace", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Data struct {
|
|
Workspace struct {
|
|
Name string `json:"name"`
|
|
} `json:"workspace"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode segment workspace response: %w", err)
|
|
}
|
|
|
|
return resp.Data.Workspace.Name, nil
|
|
}
|
|
|
|
// upcloudNameResolver names the source after the username the API token
|
|
// belongs to, via GET /1.3/account. UpCloud exposes no organisation or
|
|
// workspace name, and account/list carries no marker for which of its rows
|
|
// the token authenticated as.
|
|
type upcloudNameResolver struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewUpCloudNameResolver(httpClient *http.Client) NameResolver {
|
|
return &upcloudNameResolver{httpClient: httpClient}
|
|
}
|
|
|
|
func (r *upcloudNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upcloudAPIBaseURL+"/account", nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create upcloud account request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute upcloud account request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
return "", nameStatusError("upcloud account", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp struct {
|
|
Account struct {
|
|
Username string `json:"username"`
|
|
} `json:"account"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode upcloud account response: %w", err)
|
|
}
|
|
|
|
return resp.Account.Username, nil
|
|
}
|