feat: initial gateway implementation
This commit is contained in:
399
pkg/client/client.go
Normal file
399
pkg/client/client.go
Normal file
@@ -0,0 +1,399 @@
|
||||
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 {
|
||||
// Check if we have JSON parameters
|
||||
hasJSONParams := false
|
||||
jsonParams := make(map[string]interface{})
|
||||
form := make(map[string][]string)
|
||||
query := make(map[string][]string)
|
||||
|
||||
for name, value := range params {
|
||||
location := "body"
|
||||
paramType := "string"
|
||||
|
||||
// Check if value is a parameter object with location and type info
|
||||
if paramObj, ok := value.(map[string]interface{}); ok {
|
||||
if loc, exists := paramObj["location"].(string); exists {
|
||||
location = loc
|
||||
}
|
||||
if ptype, exists := paramObj["type"].(string); exists {
|
||||
paramType = ptype
|
||||
}
|
||||
// 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:
|
||||
// Special handling for certain parameters that expect form-encoded data
|
||||
// even when marked as "json" type
|
||||
if paramType == "json" && name == "labels" {
|
||||
// The labels parameter expects form-encoded data, not JSON
|
||||
form[name] = append(form[name], fmt.Sprintf("%v", value))
|
||||
} else if paramType == "json" {
|
||||
hasJSONParams = true
|
||||
jsonParams[name] = value
|
||||
} else {
|
||||
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
|
||||
if hasJSONParams {
|
||||
// Send JSON body
|
||||
jsonData, err := json.Marshal(jsonParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal JSON params: %w", err)
|
||||
}
|
||||
body = strings.NewReader(string(jsonData))
|
||||
contentType = "application/json"
|
||||
} else if len(form) > 0 {
|
||||
// Send form-encoded body
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user