Repository navigation
feat: add client for the IaC API [ENG-9440] #265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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), | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,130 @@ | ||||||||||||||||||
| // 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 result into response. | ||||||||||||||||||
| func (c *client) Get(ctx context.Context, path string, query url.Values, response any) error { | ||||||||||||||||||
| return c.do(ctx, http.MethodGet, path, query, nil, response) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Post performs a POST request with payload as body, decoding the result into response. | ||||||||||||||||||
| func (c *client) Post(ctx context.Context, path string, payload any, response any) error { | ||||||||||||||||||
| return c.do(ctx, http.MethodPost, path, nil, payload, response) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Put performs a PUT request with payload as body, decoding the result into response. | ||||||||||||||||||
| func (c *client) Put(ctx context.Context, path string, payload any, response any) error { | ||||||||||||||||||
| return c.do(ctx, http.MethodPut, path, nil, payload, response) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Patch performs a PATCH request with payload as body, decoding the result into response. | ||||||||||||||||||
| func (c *client) Patch(ctx context.Context, path string, payload any, response any) error { | ||||||||||||||||||
| return c.do(ctx, http.MethodPatch, path, nil, payload, response) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Delete performs a DELETE request, decoding the result into response. | ||||||||||||||||||
| func (c *client) Delete(ctx context.Context, path string, response any) error { | ||||||||||||||||||
| return c.do(ctx, http.MethodDelete, path, nil, nil, response) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| func (c *client) do(ctx context.Context, method string, path string, query url.Values, payload any, response any) error { | ||||||||||||||||||
| reqURL, err := url.Parse(c.endpoint) | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Custom agent: Flag Security Vulnerabilities This accepts Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is not relevant at the client level, actual deployments always use https |
||||||||||||||||||
| if err != nil { | ||||||||||||||||||
| return api.NewAPIError(fmt.Errorf("invalid IaC API endpoint: %w", err)) | ||||||||||||||||||
| } | ||||||||||||||||||
| reqURL = reqURL.JoinPath(path) | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||||||||||||||||||
| reqURL.RawQuery = query.Encode() | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This assignment drops query parameters supplied as part of the configured IaC endpoint. Preserve the endpoint query and merge request parameters instead, otherwise valid endpoint configurations with tenant, routing, or gateway parameters send requests to the wrong API context. Prompt for AI agents
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The request URL is meant to be a plain URL, not containing parameters |
||||||||||||||||||
|
|
||||||||||||||||||
| var body io.Reader | ||||||||||||||||||
| if payload != nil { | ||||||||||||||||||
| content, err := json.Marshal(payload) | ||||||||||||||||||
| 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 payload != 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 response == nil || len(content) == 0 { | ||||||||||||||||||
| return nil | ||||||||||||||||||
| } | ||||||||||||||||||
| if err := json.Unmarshal(content, response); 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 { | ||||||||||||||||||
| switch part { | ||||||||||||||||||
| case ".", "..": | ||||||||||||||||||
| // dots aren't escaped by PathEscape, and dot segments would be | ||||||||||||||||||
| // removed when joining paths | ||||||||||||||||||
| escaped[i] = strings.ReplaceAll(part, ".", "%2E") | ||||||||||||||||||
| default: | ||||||||||||||||||
| escaped[i] = url.PathEscape(part) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| return strings.Join(escaped, "/") | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| // Copyright Stacklet, Inc. 2025, 2026 | ||
|
|
||
| package iac | ||
|
|
||
| import ( | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "net/url" | ||
| "strings" | ||
| "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"}`) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: These raw-string JSON fixtures escape quotes unnecessarily, so the tests send malformed JSON and fail instead of exercising the client. Remove the backslashes from all JSON raw strings, including the expected request body. Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is not true, the content is passed as-is |
||
|
|
||
| var response item | ||
| err := c.Get(t.Context(), resourcePath("projects", "a/b c"), url.Values{"page": {"2"}}, &response) | ||
|
|
||
| assert.NoError(t, err) | ||
| assert.Equal(t, item{Name: "foo"}, response) | ||
| 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 TestClientDotSegments(t *testing.T) { | ||
| for _, name := range []string{".", ".."} { | ||
| t.Run(name, func(t *testing.T) { | ||
| c, req := newTestServer(t, http.StatusNotFound, `{"detail": "Not found"}`) | ||
|
|
||
| err := c.Get(t.Context(), resourcePath("projects", name), nil, nil) | ||
|
|
||
| assert.Equal(t, api.NotFound{Message: "Not found"}, err) | ||
| assert.Equal(t, "/base/projects/"+strings.ReplaceAll(name, ".", "%2E"), req.URI) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestClientWithBody(t *testing.T) { | ||
| tests := []struct { | ||
| method string | ||
| call func(c *client, response *item) error | ||
| }{ | ||
| { | ||
| method: http.MethodPost, | ||
| call: func(c *client, response *item) error { | ||
| return c.Post(t.Context(), "projects", item{Name: "foo"}, response) | ||
| }, | ||
| }, | ||
| { | ||
| method: http.MethodPut, | ||
| call: func(c *client, response *item) error { | ||
| return c.Put(t.Context(), "projects", item{Name: "foo"}, response) | ||
| }, | ||
| }, | ||
| { | ||
| method: http.MethodPatch, | ||
| call: func(c *client, response *item) error { | ||
| return c.Patch(t.Context(), "projects", item{Name: "foo"}, response) | ||
| }, | ||
| }, | ||
| } | ||
| for _, tc := range tests { | ||
| t.Run(tc.method, func(t *testing.T) { | ||
| c, req := newTestServer(t, http.StatusCreated, `{"name": "foo"}`) | ||
|
|
||
| var response item | ||
| err := tc.call(c, &response) | ||
|
|
||
| assert.NoError(t, err) | ||
| assert.Equal(t, item{Name: "foo"}, response) | ||
| 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 response item | ||
| err := c.Get(t.Context(), "projects/foo", nil, &response) | ||
|
|
||
| assert.ErrorContains(t, err, "invalid API response") | ||
| } | ||
|
|
||
| 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) | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1:
iac.Newreturns an API wrapper with no exported operations, so resources and data sources receiving*iac.APIcannot make any IaC request. Expose the request methods onAPI(or embed an exported client) before wiring this client into provider data.Prompt for AI agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This branch just adds the barebone client, followup will start exposing resources through the API object (similar to the graphql one)