Overhaul VirtFusion provider: 20 resources, 30 data sources, multipage pagination
Complete rewrite of the VirtFusion Terraform provider with full API coverage: - 20 managed resources (server, build, SSH key, user, firewall, IP blocks, etc.) - 30 data sources (hypervisors, packages, servers, IP blocks, self-service, etc.) - New HTTP client with proper error handling, query parameter support, and automatic multipage pagination via GetAllPages (fetches all pages from Laravel-style paginated endpoints and merges into a single response) - Fixed type mismatches against live API: ServerData.Suspended (int→bool), IPBlockData.Type (string→int), PackageData json tags (primaryStorage, etc.), ServerData nested CPU/Settings/Resources structure, HypervisorGroupResources array response - Configurable results-per-page (default 300) on all list data sources - Migrated CI from GitHub Actions to Gitea Actions - Updated goreleaser config, go.mod dependencies, and examples Verified against live VirtFusion instance at cp.vps.ezscale.tech: all data sources return correct data with full pagination support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
192
internal/client/request.go
Normal file
192
internal/client/request.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) EZSCALE.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// paginatedResponse is the envelope returned by VirtFusion's Laravel-style pagination.
|
||||
type paginatedResponse struct {
|
||||
CurrentPage int `json:"current_page"`
|
||||
LastPage int `json:"last_page"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// Get performs a GET request to the given path.
|
||||
func (c *Client) Get(ctx context.Context, path string) (json.RawMessage, error) {
|
||||
return c.doRequest(ctx, http.MethodGet, path, nil)
|
||||
}
|
||||
|
||||
// GetAllPages fetches all pages from a paginated endpoint and returns
|
||||
// a synthetic JSON response with all items merged into a single "data" array.
|
||||
// If the response is not paginated (no last_page field or single page), it
|
||||
// returns the original response unchanged.
|
||||
func (c *Client) GetAllPages(ctx context.Context, path string) (json.RawMessage, error) {
|
||||
firstRaw, err := c.Get(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var page paginatedResponse
|
||||
if err := json.Unmarshal(firstRaw, &page); err != nil || page.LastPage == 0 {
|
||||
// Not a paginated response — return as-is.
|
||||
return firstRaw, nil
|
||||
}
|
||||
|
||||
if page.LastPage <= 1 {
|
||||
return firstRaw, nil
|
||||
}
|
||||
|
||||
// Collect data arrays from all pages.
|
||||
allItems, err := flattenJSONArray(page.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing page 1 data: %w", err)
|
||||
}
|
||||
|
||||
sep := "&"
|
||||
if !strings.Contains(path, "?") {
|
||||
sep = "?"
|
||||
}
|
||||
|
||||
for p := 2; p <= page.LastPage; p++ {
|
||||
pageRaw, err := c.Get(ctx, fmt.Sprintf("%s%spage=%d", path, sep, p))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching page %d: %w", p, err)
|
||||
}
|
||||
|
||||
var pageResp paginatedResponse
|
||||
if err := json.Unmarshal(pageRaw, &pageResp); err != nil {
|
||||
return nil, fmt.Errorf("parsing page %d: %w", p, err)
|
||||
}
|
||||
|
||||
items, err := flattenJSONArray(pageResp.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing page %d data: %w", p, err)
|
||||
}
|
||||
allItems = append(allItems, items...)
|
||||
}
|
||||
|
||||
mergedData, err := json.Marshal(allItems)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling merged data: %w", err)
|
||||
}
|
||||
|
||||
// Build a response that looks like {"data": [...all items...]} so
|
||||
// existing list response types (e.g. ServerListResponse) unmarshal correctly.
|
||||
result, err := json.Marshal(map[string]json.RawMessage{"data": mergedData})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling merged response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// flattenJSONArray unmarshals a JSON array into individual raw messages.
|
||||
func flattenJSONArray(raw json.RawMessage) ([]json.RawMessage, error) {
|
||||
var items []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Post performs a POST request to the given path with the given body.
|
||||
func (c *Client) Post(ctx context.Context, path string, body interface{}) (json.RawMessage, error) {
|
||||
return c.doRequest(ctx, http.MethodPost, path, body)
|
||||
}
|
||||
|
||||
// Put performs a PUT request to the given path with the given body.
|
||||
func (c *Client) Put(ctx context.Context, path string, body interface{}) (json.RawMessage, error) {
|
||||
return c.doRequest(ctx, http.MethodPut, path, body)
|
||||
}
|
||||
|
||||
// Delete performs a DELETE request to the given path.
|
||||
func (c *Client) Delete(ctx context.Context, path string) (json.RawMessage, error) {
|
||||
return c.doRequest(ctx, http.MethodDelete, path, nil)
|
||||
}
|
||||
|
||||
// DeleteWithBody performs a DELETE request with a JSON body.
|
||||
func (c *Client) DeleteWithBody(ctx context.Context, path string, body interface{}) (json.RawMessage, error) {
|
||||
return c.doRequest(ctx, http.MethodDelete, path, body)
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
// Split path from query string before joining, since url.JoinPath
|
||||
// escapes '?' as '%3F' when it appears in the path.
|
||||
pathPart := path
|
||||
queryPart := ""
|
||||
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
||||
pathPart = path[:idx]
|
||||
queryPart = path[idx:]
|
||||
}
|
||||
|
||||
fullURL, err := url.JoinPath(c.BaseURL, pathPart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building URL: %w", err)
|
||||
}
|
||||
fullURL += queryPart
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 204 No Content is a success with no body
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
apiErr := &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Status: resp.Status,
|
||||
Body: string(respBody),
|
||||
}
|
||||
|
||||
// Try to parse validation errors
|
||||
var errResp struct {
|
||||
Errors map[string][]string `json:"errors"`
|
||||
}
|
||||
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
|
||||
apiErr.Errors = errResp.Errors
|
||||
}
|
||||
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
return json.RawMessage(respBody), nil
|
||||
}
|
||||
Reference in New Issue
Block a user