Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion dev-docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
├── internal/
│ ├── provider/ # Provider configuration and registration
│ ├── api/ # Shared HTTP transports and errors for API clients
│ │ ├── iac/ # IaC (Sinistral) REST API client
│ │ └── platform/ # Platform GraphQL API client and wrappers
│ ├── resources/ # Terraform resource implementations
│ ├── datasources/ # Terraform data source implementations
Expand Down Expand Up @@ -37,6 +38,10 @@
- **Filtering**: Filter API in `internal/api/platform/filter.go` for constructing GraphQL filter queries
- `filterElementInput` and `filterValueInput` types for building filter expressions
- `newExactMatchFilter()` helper for creating exact-match filters with "equals" operator
- **REST Client**: The IaC client (`internal/api/iac/`) talks to the IaC (Sinistral) REST API, using the same API key as the platform one
- `Get()`, `Post()`, `Put()`, `Patch()`, `Delete()` methods for JSON requests
- `resourcePath()` builds request paths, escaping each component
- Error responses are converted to `api.NotFound` (404) or `api.APIError`, reporting FastAPI `detail` messages (including validation errors)

## Resource Architecture

Expand Down Expand Up @@ -117,7 +122,8 @@ Models in `internal/models/` define Terraform schema structure with `tfsdk` stru
- New code should use `diag.Diagnostics` directly and the `typehelpers` package functions which all return diagnostics

**Provider Data** (`internal/providerdata/`):
- `providerData` struct holds API clients for resources/data sources, built by `New()`
- `providerData` struct holds API clients for resources/data sources, built by `New()`: `API` for the platform API and `IaCAPI` for the IaC API
- Exposed as `api` and `iacAPI` fields in the `apiResource` and `apiDataSource` base types
- `GetForResource()` - Retrieves provider data in resources
- `GetForDataSource()` - Retrieves provider data in data sources

Expand Down
4 changes: 3 additions & 1 deletion dev-docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ Provider supports multiple authentication methods (in priority order):
2. Environment variables (`STACKLET_ENDPOINT`, `STACKLET_API_KEY`)
3. stacklet-admin CLI (`~/.stacklet/config.json`, `~/.stacklet/credentials`)

Each credential is resolved independently using the first non-empty value found. Unit tests in `internal/provider/provider_test.go` verify precedence and mixed sources.
Each credential is resolved independently using the first non-empty value found.

The IaC API endpoint (`iac_endpoint`) is resolved from the provider block, then the `STACKLET_IAC_ENDPOINT` environment variable. If neither is set, it's derived from the API endpoint by replacing the `api.` hostname prefix with `api.sinistral.` (e.g. `https://api.example.stacklet.io/` becomes `https://api.sinistral.example.stacklet.io/`). If it can't be derived, IaC resources and data sources fail when used. The API key is shared by both APIs. Unit tests in `internal/provider/provider_test.go` verify precedence and mixed sources.

## Development Tools

Expand Down
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ May also be provided via STACKLET_API_KEY environment variable, or from the stac
- `endpoint` (String) The endpoint URL of the Stacklet GraphQL API.

May also be provided via STACKLET_ENDPOINT environment variable, or from the stacklet-admin CLI configuration.
- `iac_endpoint` (String) The endpoint URL of the Stacklet IaC API.

May also be provided via STACKLET_IAC_ENDPOINT environment variable. If not set, it's derived from the Stacklet API endpoint.
15 changes: 15 additions & 0 deletions internal/api/iac/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright Stacklet, Inc. 2025, 2026

package iac

// API provides access to the IaC REST API.
type API struct {
c *client
}

// New creates an API wrapper.
func New(config ClientConfig) *API {
return &API{
c: newClient(config),
}
}
129 changes: 129 additions & 0 deletions internal/api/iac/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright Stacklet, Inc. 2025, 2026

// package iac provides access to the IaC (Sinistral) REST API.
package iac

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/stacklet/terraform-provider-stacklet/internal/api"
)

// ClientConfig is the configuration for the API client.
type ClientConfig struct {
Endpoint string
APIKey string
Version string
}

// client is a JSON client for the REST API.
type client struct {
httpClient *http.Client
endpoint string
}

// newClient returns a configured REST client.
func newClient(config ClientConfig) *client {
return &client{
httpClient: &http.Client{
Transport: api.NewTransport(config.APIKey, config.Version),
},
endpoint: config.Endpoint,
}
}

// Get performs a GET request, decoding the response in out.
func (c *client) Get(ctx context.Context, path string, query url.Values, out any) error {
return c.do(ctx, http.MethodGet, path, query, nil, out)
}

// Post performs a POST request with in as body, decoding the response in out.
func (c *client) Post(ctx context.Context, path string, in any, out any) error {
return c.do(ctx, http.MethodPost, path, nil, in, out)
}

// Put performs a PUT request with in as body, decoding the response in out.
func (c *client) Put(ctx context.Context, path string, in any, out any) error {
return c.do(ctx, http.MethodPut, path, nil, in, out)
}

// Patch performs a PATCH request with in as body, decoding the response in out.
func (c *client) Patch(ctx context.Context, path string, in any, out any) error {
return c.do(ctx, http.MethodPatch, path, nil, in, out)
}

// Delete performs a DELETE request, decoding the response in out.
func (c *client) Delete(ctx context.Context, path string, out any) error {
return c.do(ctx, http.MethodDelete, path, nil, nil, out)
}

func (c *client) do(ctx context.Context, method string, path string, query url.Values, in any, out any) error {
if c.endpoint == "" {
return api.APIError{
Kind: "Missing IaC API Endpoint",
Detail: "The IaC API endpoint could not be determined. Set the iac_endpoint value in the provider configuration, or the STACKLET_IAC_ENDPOINT environment variable.",
}
}
reqURL, err := url.Parse(c.endpoint)
if err != nil {
return api.NewAPIError(fmt.Errorf("invalid IaC API endpoint: %w", err))
}
reqURL = reqURL.JoinPath(path)
reqURL.RawQuery = query.Encode()

var body io.Reader
if in != nil {
content, err := json.Marshal(in)
if err != nil {
return api.NewAPIError(err)
}
body = bytes.NewReader(content)
}

req, err := http.NewRequestWithContext(ctx, method, reqURL.String(), body)
if err != nil {
return api.NewAPIError(err)
}
req.Header.Set("Accept", "application/json")
if in != nil {
req.Header.Set("Content-Type", "application/json")
}

resp, err := c.httpClient.Do(req)
if err != nil {
return api.NewAPIError(err)
}
defer resp.Body.Close()

content, err := io.ReadAll(resp.Body)
if err != nil {
return api.NewAPIError(err)
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return newResponseError(resp.StatusCode, content)
}
if out == nil || len(content) == 0 {
return nil
}
if err := json.Unmarshal(content, out); err != nil {
return api.NewAPIError(fmt.Errorf("invalid API response: %w", err))
}
return nil
}

// resourcePath returns an API path from its parts, escaping each of them.
func resourcePath(parts ...string) string {
escaped := make([]string, len(parts))
for i, part := range parts {
escaped[i] = url.PathEscape(part)
}
return strings.Join(escaped, "/")
}
186 changes: 186 additions & 0 deletions internal/api/iac/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright Stacklet, Inc. 2025, 2026

package iac

import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/stretchr/testify/assert"

"github.com/stacklet/terraform-provider-stacklet/internal/api"
)

type item struct {
Name string `json:"name"`
}

type capturedRequest struct {
Method string
URI string
Body string
ContentType string
Auth string
}

func newTestServer(t *testing.T, status int, response string) (*client, *capturedRequest) {
captured := &capturedRequest{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
*captured = capturedRequest{
Method: r.Method,
URI: r.URL.RequestURI(),
Body: string(body),
ContentType: r.Header.Get("Content-Type"),
Auth: r.Header.Get("Authorization"),
}
w.WriteHeader(status)
_, _ = w.Write([]byte(response))
}))
t.Cleanup(server.Close)

c := newClient(ClientConfig{Endpoint: server.URL + "/base/", APIKey: "secret", Version: "1.0.0"})
return c, captured
}

func TestClientGet(t *testing.T) {
c, req := newTestServer(t, http.StatusOK, `{"name": "foo"}`)

var out item
err := c.Get(t.Context(), resourcePath("projects", "a/b c"), url.Values{"page": {"2"}}, &out)

assert.NoError(t, err)
assert.Equal(t, item{Name: "foo"}, out)
assert.Equal(t, http.MethodGet, req.Method)
assert.Equal(t, "/base/projects/a%2Fb%20c?page=2", req.URI)
assert.Equal(t, "Bearer secret", req.Auth)
assert.Equal(t, "", req.ContentType)
}

func TestClientWithBody(t *testing.T) {
tests := []struct {
method string
call func(c *client, out *item) error
}{
{
method: http.MethodPost,
call: func(c *client, out *item) error {
return c.Post(t.Context(), "projects", item{Name: "foo"}, out)
},
},
{
method: http.MethodPut,
call: func(c *client, out *item) error {
return c.Put(t.Context(), "projects", item{Name: "foo"}, out)
},
},
{
method: http.MethodPatch,
call: func(c *client, out *item) error {
return c.Patch(t.Context(), "projects", item{Name: "foo"}, out)
},
},
}
for _, tc := range tests {
t.Run(tc.method, func(t *testing.T) {
c, req := newTestServer(t, http.StatusCreated, `{"name": "foo"}`)

var out item
err := tc.call(c, &out)

assert.NoError(t, err)
assert.Equal(t, item{Name: "foo"}, out)
assert.Equal(t, tc.method, req.Method)
assert.Equal(t, "/base/projects", req.URI)
assert.Equal(t, `{"name":"foo"}`, req.Body)
assert.Equal(t, "application/json", req.ContentType)
})
}
}

func TestClientDeleteNoContent(t *testing.T) {
c, req := newTestServer(t, http.StatusNoContent, "")

err := c.Delete(t.Context(), "projects/foo", nil)

assert.NoError(t, err)
assert.Equal(t, http.MethodDelete, req.Method)
assert.Equal(t, "/base/projects/foo", req.URI)
}

func TestClientInvalidResponse(t *testing.T) {
c, _ := newTestServer(t, http.StatusOK, "not json")

var out item
err := c.Get(t.Context(), "projects/foo", nil, &out)

assert.ErrorContains(t, err, "invalid API response")
}

func TestClientNoEndpoint(t *testing.T) {
c := newClient(ClientConfig{APIKey: "secret"})

err := c.Get(t.Context(), "projects", nil, nil)

var apiErr api.APIError
assert.ErrorAs(t, err, &apiErr)
assert.Equal(t, "Missing IaC API Endpoint", apiErr.Summary())
}

func TestClientErrors(t *testing.T) {
tests := []struct {
name string
status int
response string
err error
}{
{
name: "not found",
status: http.StatusNotFound,
response: `{"detail": "Project not found"}`,
err: api.NotFound{Message: "Project not found"},
},
{
name: "unauthorized",
status: http.StatusUnauthorized,
response: `{"detail": "Invalid Authorization token"}`,
err: api.APIError{Kind: "Authorization Error", Detail: "Invalid Authorization token"},
},
{
name: "gateway message",
status: http.StatusForbidden,
response: `{"message": "Forbidden"}`,
err: api.APIError{Kind: "Authorization Error", Detail: "Forbidden"},
},
{
name: "validation",
status: http.StatusUnprocessableEntity,
response: `{"detail": [{"loc": ["body", "name"], "msg": "Field required"}, {"loc": ["body", "groups", 0], "msg": "Invalid"}]}`,
err: api.APIError{Kind: "API Error", Detail: "body.name: Field required\nbody.groups.0: Invalid"},
},
{
name: "not json",
status: http.StatusInternalServerError,
response: "Internal server error",
err: api.APIError{Kind: "API Error", Detail: "Internal server error"},
},
{
name: "empty",
status: http.StatusBadGateway,
response: "",
err: api.APIError{Kind: "API Error", Detail: "Bad Gateway"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c, _ := newTestServer(t, tc.status, tc.response)

err := c.Get(t.Context(), "projects/foo", nil, nil)

assert.Equal(t, tc.err, err)
})
}
}
Loading
Loading