// 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 }