Files
spore-gateway/pkg/client/client.go

377 lines
11 KiB
Go

package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
// SporeClient represents a client for communicating with SPORE nodes
type SporeClient struct {
BaseURL string
HTTPClient *http.Client
}
// NewSporeClient creates a new SPORE API client
func NewSporeClient(baseURL string) *SporeClient {
return &SporeClient{
BaseURL: baseURL,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// ClusterStatusResponse represents the response from /api/cluster/members
type ClusterStatusResponse struct {
Members []ClusterMember `json:"members"`
}
// ClusterMember represents a member in the cluster
type ClusterMember struct {
IP string `json:"ip"`
Hostname string `json:"hostname"`
Status string `json:"status"`
Latency int64 `json:"latency"`
LastSeen int64 `json:"lastSeen"`
Labels map[string]string `json:"labels"`
Resources map[string]interface{} `json:"resources"`
}
// TaskStatusResponse represents the response from /api/tasks/status
type TaskStatusResponse struct {
Summary TaskSummary `json:"summary"`
Tasks []TaskInfo `json:"tasks"`
System SystemInfo `json:"system"`
}
// TaskSummary represents task summary information
type TaskSummary struct {
TotalTasks int `json:"totalTasks"`
ActiveTasks int `json:"activeTasks"`
}
// TaskInfo represents information about a task
type TaskInfo struct {
Name string `json:"name"`
Interval int `json:"interval"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
AutoStart bool `json:"autoStart"`
}
// SystemInfo represents system information
type SystemInfo struct {
FreeHeap int64 `json:"freeHeap"`
Uptime int64 `json:"uptime"`
}
// SystemStatusResponse represents the response from /api/node/status
type SystemStatusResponse struct {
FreeHeap int64 `json:"freeHeap"`
ChipID int64 `json:"chipId"`
SDKVersion string `json:"sdkVersion"`
CPUFreqMHz int `json:"cpuFreqMHz"`
FlashChipSize int64 `json:"flashChipSize"`
Labels map[string]string `json:"labels"`
}
// CapabilitiesResponse represents the response from /api/node/endpoints
type CapabilitiesResponse struct {
Endpoints []EndpointInfo `json:"endpoints"`
}
// EndpointInfo represents information about an API endpoint
type EndpointInfo struct {
URI string `json:"uri"`
Method string `json:"method"`
Parameters []ParameterInfo `json:"params"`
}
// ParameterInfo represents information about a parameter
type ParameterInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Description string `json:"description"`
Location string `json:"location"` // query, path, body
Default string `json:"default,omitempty"`
Values []string `json:"values,omitempty"`
}
// FirmwareUpdateResponse represents the response from firmware update
type FirmwareUpdateResponse struct {
Status string `json:"status"`
Message string `json:"message"`
}
// GetClusterStatus retrieves cluster member information
func (c *SporeClient) GetClusterStatus() (*ClusterStatusResponse, error) {
url := fmt.Sprintf("%s/api/cluster/members", c.BaseURL)
resp, err := c.HTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to get cluster status: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cluster status request failed with status %d", resp.StatusCode)
}
var clusterStatus ClusterStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&clusterStatus); err != nil {
return nil, fmt.Errorf("failed to decode cluster status response: %w", err)
}
return &clusterStatus, nil
}
// GetTaskStatus retrieves task status information
func (c *SporeClient) GetTaskStatus() (*TaskStatusResponse, error) {
url := fmt.Sprintf("%s/api/tasks/status", c.BaseURL)
resp, err := c.HTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to get task status: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("task status request failed with status %d", resp.StatusCode)
}
var taskStatus TaskStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&taskStatus); err != nil {
return nil, fmt.Errorf("failed to decode task status response: %w", err)
}
return &taskStatus, nil
}
// GetSystemStatus retrieves system status information
func (c *SporeClient) GetSystemStatus() (*SystemStatusResponse, error) {
url := fmt.Sprintf("%s/api/node/status", c.BaseURL)
resp, err := c.HTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to get system status: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("system status request failed with status %d", resp.StatusCode)
}
var systemStatus SystemStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&systemStatus); err != nil {
return nil, fmt.Errorf("failed to decode system status response: %w", err)
}
return &systemStatus, nil
}
// GetCapabilities retrieves available API endpoints
func (c *SporeClient) GetCapabilities() (*CapabilitiesResponse, error) {
url := fmt.Sprintf("%s/api/node/endpoints", c.BaseURL)
resp, err := c.HTTPClient.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to get capabilities: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("capabilities request failed with status %d", resp.StatusCode)
}
var capabilities CapabilitiesResponse
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
return nil, fmt.Errorf("failed to decode capabilities response: %w", err)
}
return &capabilities, nil
}
// UpdateFirmware uploads firmware to a SPORE node
func (c *SporeClient) UpdateFirmware(firmwareData []byte, filename string) (*FirmwareUpdateResponse, error) {
url := fmt.Sprintf("%s/api/node/update", c.BaseURL)
// Create multipart form
var requestBody bytes.Buffer
contentType := createMultipartForm(&requestBody, firmwareData, filename)
if contentType == "" {
return nil, fmt.Errorf("failed to create multipart form")
}
req, err := http.NewRequest("POST", url, &requestBody)
if err != nil {
return nil, fmt.Errorf("failed to create firmware update request: %w", err)
}
req.Header.Set("Content-Type", contentType)
// Create a client with extended timeout for firmware uploads
firmwareClient := &http.Client{
Timeout: 5 * time.Minute, // 5 minutes for firmware uploads
}
resp, err := firmwareClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to upload firmware: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("firmware update failed with status %d: %s", resp.StatusCode, string(body))
}
var updateResponse FirmwareUpdateResponse
if err := json.NewDecoder(resp.Body).Decode(&updateResponse); err != nil {
return nil, fmt.Errorf("failed to decode firmware update response: %w", err)
}
return &updateResponse, nil
}
// ProxyCall makes a generic HTTP request to a SPORE node endpoint
func (c *SporeClient) ProxyCall(method, uri string, params map[string]interface{}) (*http.Response, error) {
// Build target URL
targetURL := fmt.Sprintf("%s%s", c.BaseURL, uri)
// Parse parameters and build request
req, err := c.buildProxyRequest(method, targetURL, params)
if err != nil {
return nil, fmt.Errorf("failed to build proxy request: %w", err)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("proxy call failed: %w", err)
}
return resp, nil
}
// buildProxyRequest builds an HTTP request for proxy calls
func (c *SporeClient) buildProxyRequest(method, targetURL string, params map[string]interface{}) (*http.Request, error) {
var body io.Reader
var contentType string
if method != "GET" && params != nil {
// Use form-encoded data for all body parameters (matching index-standalone.js behavior)
form := make(map[string][]string)
query := make(map[string][]string)
for name, value := range params {
location := "body"
// Check if value is a parameter object with location info
if paramObj, ok := value.(map[string]interface{}); ok {
if loc, exists := paramObj["location"].(string); exists {
location = loc
}
// Extract the actual value
if val, exists := paramObj["value"]; exists {
value = val
}
}
switch location {
case "query":
query[name] = append(query[name], fmt.Sprintf("%v", value))
case "path":
// Replace {name} or :name in path
placeholder := fmt.Sprintf("{%s}", name)
if strings.Contains(targetURL, placeholder) {
targetURL = strings.ReplaceAll(targetURL, placeholder, fmt.Sprintf("%v", value))
}
placeholder = fmt.Sprintf(":%s", name)
if strings.Contains(targetURL, placeholder) {
targetURL = strings.ReplaceAll(targetURL, placeholder, fmt.Sprintf("%v", value))
}
default:
// Use form-encoded data for all body parameters (matching index-standalone.js behavior)
// This is simpler and more reliable than trying to detect JSON automatically
form[name] = append(form[name], fmt.Sprintf("%v", value))
}
}
// Add query parameters to URL
if len(query) > 0 {
urlObj, err := url.Parse(targetURL)
if err != nil {
return nil, fmt.Errorf("invalid target URL: %w", err)
}
q := urlObj.Query()
for key, values := range query {
for _, value := range values {
q.Add(key, value)
}
}
urlObj.RawQuery = q.Encode()
targetURL = urlObj.String()
}
// Create request body - always use form-encoded data for consistency
if len(form) > 0 {
data := url.Values{}
for key, values := range form {
for _, value := range values {
data.Add(key, value)
}
}
body = strings.NewReader(data.Encode())
contentType = "application/x-www-form-urlencoded"
}
}
req, err := http.NewRequest(method, targetURL, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if method != "GET" && contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return req, nil
}
// Helper function to create multipart form for file uploads
func createMultipartForm(requestBody *bytes.Buffer, firmwareData []byte, filename string) string {
writer := multipart.NewWriter(requestBody)
// Add file field
fileWriter, err := writer.CreateFormFile("firmware", filename)
if err != nil {
log.WithError(err).Error("Failed to create form file")
return ""
}
_, err = fileWriter.Write(firmwareData)
if err != nil {
log.WithError(err).Error("Failed to write file data")
return ""
}
err = writer.Close()
if err != nil {
log.WithError(err).Error("Failed to close multipart writer")
return ""
}
return writer.FormDataContentType()
}