Skip to content
Open
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
62 changes: 62 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,68 @@ func TestInvokeFunctionErrorsNormalize(t *testing.T) {
require.ErrorContains(t, err, "HTTP 429: rate limited")
}

func TestInvokeFunctionSuccessWithNonObjectBody(t *testing.T) {
functionID := mustProjectID(t, "22222222-2222-4222-8222-222222222222")

testCases := []struct {
name string
contentType string
status int
body string
want map[string]any
}{
{
name: "plain text body",
contentType: "text/plain; charset=utf-8",
status: http.StatusOK,
body: "plain text ok",
want: map[string]any{"body": "plain text ok"},
},
{
name: "no content type",
contentType: "",
status: http.StatusOK,
body: `{"ok":true}`,
want: map[string]any{"ok": true},
},
{
name: "json array body",
contentType: "application/json",
status: http.StatusOK,
body: `["a","b"]`,
want: map[string]any{"body": []any{"a", "b"}},
},
{
name: "empty body",
contentType: "",
status: http.StatusNoContent,
body: "",
want: map[string]any{},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if tc.contentType != "" {
w.Header().Set("Content-Type", tc.contentType)
}
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer server.Close()

client, err := NewClient(server.URL, "", WithHTTPClient(server.Client()))
require.NoError(t, err)

resp, err := client.InvokeFunction(context.Background(), functionID, FunctionInvokeInput{})
require.NoError(t, err, "a genuine 2xx response must not be reported as an error")
require.NotNil(t, resp)
assert.Equal(t, tc.want, map[string]any(*resp))
})
}
}

func mustProjectID(t *testing.T, value string) uuid.UUID {
t.Helper()
id, err := uuid.Parse(value)
Expand Down
42 changes: 38 additions & 4 deletions internal/api/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"

"github.com/google/uuid"
Expand Down Expand Up @@ -112,21 +113,54 @@ func (c *Client) UpdateFunctionVisibility(ctx context.Context, projectID, functi
}

// InvokeFunction invokes one function by ID.
//
// The invoke endpoint passes through whatever the target function handler
// returns, so unlike other endpoints its response body isn't guaranteed to
// be a JSON object matching the generated schema (it may be plain text,
// HTML, empty, or non-object JSON). The generated WithResponse client only
// populates JSON200/JSONDefault when the body is a JSON object, so this
// calls the raw client method and classifies success/failure on the HTTP
// status code directly, decoding the body leniently rather than erroring
// out on a genuine 2xx.
func (c *Client) InvokeFunction(ctx context.Context, functionID uuid.UUID, input FunctionInvokeInput) (*apiclient.FunctionInvocationResponse, error) {
body := apiclient.InvokeFunctionJSONRequestBody{}
if input.Payload != nil {
payload := input.Payload
body.Payload = &payload
}

resp, err := c.client.InvokeFunctionWithResponse(ctx, functionID, body)
httpResp, err := c.client.InvokeFunction(ctx, functionID, body)
if err != nil {
return nil, err
}
if resp.JSONDefault != nil && resp.StatusCode() >= 200 && resp.StatusCode() < 300 {
return resp.JSONDefault, nil
defer func() { _ = httpResp.Body.Close() }()
respBody, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}

if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 {
return decodeInvocationResponseBody(respBody), nil
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON429, resp.JSON503)
return nil, apiError(httpResp.StatusCode, respBody)
}

// decodeInvocationResponseBody interprets a successful invocation's raw body
// leniently: a JSON object is used as-is, any other JSON value or plain-text
// body is wrapped under a "body" key, and an empty body yields an empty map.
func decodeInvocationResponseBody(body []byte) *apiclient.FunctionInvocationResponse {
if len(body) == 0 {
return &apiclient.FunctionInvocationResponse{}
}
var asMap apiclient.FunctionInvocationResponse
if json.Unmarshal(body, &asMap) == nil {
return &asMap
}
var asValue any
if json.Unmarshal(body, &asValue) == nil {
return &apiclient.FunctionInvocationResponse{"body": asValue}
}
return &apiclient.FunctionInvocationResponse{"body": string(body)}
}

// ListFunctionRuntimes returns the function runtime catalog.
Expand Down