Files
probo/pkg/cli/api/client.go
Sacha Al Himdani 7be92defcc Add missing resources to CLI, MCP, and n8n surfaces
Audit all three API surfaces against the console GraphQL schema and add
missing resources: asset, audit, datum, dpia, evidence upload, measure,
obligation, processing activity, rights request, snapshot, task, tia,
trust center (with references/files), and vendor management CLI
commands; MCP tools for deletes, rights requests, trust center, vendor
contacts/services, and compliance external URLs; n8n nodes for
obligation, finding, task, evidence, processing activity, dpia, tia,
rights request, snapshot, audit log, access review, organization
context, trust center, and additional control/measure/vendor operations.

Include MCP e2e test infrastructure (testutil MCP client with API key
auth and JSON-RPC session management) and tests covering all new MCP
tools.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
2026-04-22 19:02:22 +02:00

375 lines
9.7 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
"go.probo.inc/probo/pkg/version"
)
type (
Client struct {
host string
token string
endpoint string
httpClient *http.Client
refresher *TokenRefresher
}
// TokenRefresher holds the information needed to automatically refresh
// an expired access token using the OAuth2 refresh_token grant.
TokenRefresher struct {
RefreshToken string
TokenEndpoint string
ClientID string
// OnRefresh is called after a successful token refresh with the new
// access token and refresh token so the caller can persist them.
OnRefresh func(accessToken, refreshToken string) error
}
Option func(*Client)
graphQLRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
graphQLResponse struct {
Data json.RawMessage `json:"data"`
Errors []graphQLError `json:"errors"`
}
graphQLError struct {
Message string `json:"message"`
}
tokenRefreshResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
}
)
func WithTokenRefresher(r *TokenRefresher) Option {
return func(c *Client) { c.refresher = r }
}
func NewClient(host string, token string, endpoint string, timeout time.Duration, opts ...Option) *Client {
c := &Client{
host: host,
token: token,
endpoint: endpoint,
httpClient: &http.Client{Timeout: timeout},
}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Client) Do(
query string,
variables map[string]any,
) (json.RawMessage, error) {
raw, err := c.DoRaw(query, variables)
if err != nil {
return nil, err
}
var resp graphQLResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("cannot parse GraphQL response: %w", err)
}
if len(resp.Errors) > 0 {
var msg strings.Builder
msg.WriteString(resp.Errors[0].Message)
for _, e := range resp.Errors[1:] {
msg.WriteString("; " + e.Message)
}
return nil, fmt.Errorf("GraphQL error: %s", msg.String())
}
return resp.Data, nil
}
func (c *Client) DoRaw(
query string,
variables map[string]any,
) ([]byte, error) {
respBody, statusCode, err := c.doRequest(query, variables)
if err != nil {
return nil, err
}
if statusCode == http.StatusUnauthorized && c.refresher != nil {
if refreshErr := c.tryRefreshToken(); refreshErr == nil {
respBody, statusCode, err = c.doRequest(query, variables)
if err != nil {
return nil, err
}
}
}
if statusCode != http.StatusOK {
switch statusCode {
case http.StatusUnauthorized:
return nil, fmt.Errorf("authentication failed (HTTP 401): token may be invalid or expired, try 'prb auth login'")
case http.StatusForbidden:
return nil, fmt.Errorf("access denied (HTTP 403): you do not have permission to perform this action")
default:
return nil, fmt.Errorf(
"HTTP %d: %s",
statusCode,
string(respBody),
)
}
}
return respBody, nil
}
func (c *Client) doRequest(
query string,
variables map[string]any,
) ([]byte, int, error) {
reqBody := graphQLRequest{
Query: query,
Variables: variables,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, 0, fmt.Errorf("cannot marshal GraphQL request: %w", err)
}
host := c.host
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "https://" + host
}
reqURL := host + c.endpoint
req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(body))
if err != nil {
return nil, 0, fmt.Errorf("cannot create HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("User-Agent", version.UserAgent("prb"))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("cannot send HTTP request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, fmt.Errorf("cannot read HTTP response: %w", err)
}
return respBody, resp.StatusCode, nil
}
// DoUpload sends a GraphQL multipart file upload request following the
// graphql-multipart-request-spec. It maps the given file to the variable
// at the provided path (e.g. "variables.input.file").
func (c *Client) DoUpload(
query string,
variables map[string]any,
varPath string,
filename string,
file io.Reader,
) (json.RawMessage, error) {
raw, err := c.doUploadRequest(query, variables, varPath, filename, file)
if err != nil {
return nil, err
}
var resp graphQLResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("cannot parse GraphQL response: %w", err)
}
if len(resp.Errors) > 0 {
var msg strings.Builder
msg.WriteString(resp.Errors[0].Message)
for _, e := range resp.Errors[1:] {
msg.WriteString("; " + e.Message)
}
return nil, fmt.Errorf("GraphQL error: %s", msg.String())
}
return resp.Data, nil
}
func (c *Client) doUploadRequest(
query string,
variables map[string]any,
varPath string,
filename string,
file io.Reader,
) ([]byte, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Part 1: operations
operationsJSON, err := json.Marshal(graphQLRequest{
Query: query,
Variables: variables,
})
if err != nil {
return nil, fmt.Errorf("cannot marshal operations: %w", err)
}
if err := writer.WriteField("operations", string(operationsJSON)); err != nil {
return nil, fmt.Errorf("cannot write operations field: %w", err)
}
// Part 2: map
mapJSON, err := json.Marshal(map[string][]string{
"0": {varPath},
})
if err != nil {
return nil, fmt.Errorf("cannot marshal map: %w", err)
}
if err := writer.WriteField("map", string(mapJSON)); err != nil {
return nil, fmt.Errorf("cannot write map field: %w", err)
}
// Part 3: file
part, err := writer.CreateFormFile("0", filename)
if err != nil {
return nil, fmt.Errorf("cannot create form file: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return nil, fmt.Errorf("cannot write file content: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("cannot close multipart writer: %w", err)
}
host := c.host
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "https://" + host
}
reqURL := host + c.endpoint
req, err := http.NewRequest(http.MethodPost, reqURL, &buf)
if err != nil {
return nil, fmt.Errorf("cannot create HTTP request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("User-Agent", version.UserAgent("prb"))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot send HTTP request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cannot read HTTP response: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized && c.refresher != nil {
if refreshErr := c.tryRefreshToken(); refreshErr == nil {
// Retry — but we can't re-read the file, so return the original error.
return nil, fmt.Errorf("authentication failed (HTTP 401): token was refreshed, please retry the command")
}
}
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusUnauthorized:
return nil, fmt.Errorf("authentication failed (HTTP 401): token may be invalid or expired, try 'prb auth login'")
case http.StatusForbidden:
return nil, fmt.Errorf("access denied (HTTP 403): you do not have permission to perform this action")
default:
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
}
return respBody, nil
}
func (c *Client) tryRefreshToken() error {
r := c.refresher
values := url.Values{
"grant_type": {"refresh_token"},
"client_id": {r.ClientID},
"refresh_token": {r.RefreshToken},
}
req, err := http.NewRequest(
http.MethodPost,
r.TokenEndpoint,
strings.NewReader(values.Encode()),
)
if err != nil {
return fmt.Errorf("cannot create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", version.UserAgent("prb"))
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cannot send refresh request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("cannot read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("refresh token request failed (HTTP %d)", resp.StatusCode)
}
var token tokenRefreshResponse
if err := json.Unmarshal(body, &token); err != nil {
return fmt.Errorf("cannot decode refresh response: %w", err)
}
c.token = token.AccessToken
r.RefreshToken = token.RefreshToken
if r.OnRefresh != nil {
return r.OnRefresh(token.AccessToken, token.RefreshToken)
}
return nil
}