Files
terraform-provider-virtfusion/internal/client/request.go
Andrew 39c8cf0b58
Some checks failed
CI / build (push) Failing after 34s
Fix golangci-lint: formatting, nilerr false positive, deprecated linter names
- gofmt: fix struct field alignment in types.go, resource_server.go,
  data_source_ssh_keys_by_user.go
- nilerr: refactor GetAllPages pagination detection to avoid returning
  nil error when json.Unmarshal fails (intentional passthrough for
  non-paginated responses)
- .golangci.yml: replace deprecated linter names (vet -> govet,
  tenv -> usetesting)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 02:17:13 -04:00

196 lines
5.6 KiB
Go

// 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
// Attempt to detect pagination metadata. If the response doesn't look
// like a paginated envelope (unmarshal fails or last_page is absent/zero),
// return the raw response as-is — this is not an error.
unmarshallable := json.Unmarshal(firstRaw, &page) == nil
if !unmarshallable || page.LastPage <= 1 {
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
}