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
27 changes: 27 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ func TestFunctionMethodsUseGeneratedRoutes(t *testing.T) {
}
var batchFilenames []string
var updateBody map[string]bool
var invokeBody map[string]any
var schedulerCreateBody map[string]any
var schedulerUpdateBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -338,6 +339,9 @@ func TestFunctionMethodsUseGeneratedRoutes(t *testing.T) {
case r.Method == http.MethodPatch && r.URL.Path == "/projects/"+projectIDText+"/functions/"+functionIDText:
require.NoError(t, json.NewDecoder(r.Body).Decode(&updateBody))
writeAPIJSON(t, w, http.StatusOK, functionResponse(functionIDText, projectIDText, "hello"))
case r.Method == http.MethodPost && r.URL.Path == "/functions/"+functionIDText+"/invoke":
require.NoError(t, json.NewDecoder(r.Body).Decode(&invokeBody))
writeAPIJSON(t, w, http.StatusOK, map[string]any{"ok": true})
case r.Method == http.MethodGet && r.URL.Path == "/functions/runtimes":
writeAPIJSON(t, w, http.StatusOK, map[string]any{
"runtimes": []any{
Expand Down Expand Up @@ -439,6 +443,14 @@ func TestFunctionMethodsUseGeneratedRoutes(t *testing.T) {
assert.Equal(t, "hello", updated.Name)
assert.Equal(t, map[string]bool{"is_public": true}, updateBody)

invoked, err := client.InvokeFunction(context.Background(), functionID, FunctionInvokeInput{
Payload: map[string]any{"k": "v"},
})
require.NoError(t, err)
require.NotNil(t, invoked)
assert.Equal(t, true, (*invoked)["ok"])
assert.Equal(t, map[string]any{"payload": map[string]any{"k": "v"}}, invokeBody)

runtimes, err := client.ListFunctionRuntimes(context.Background())
require.NoError(t, err)
assert.Equal(t, "nodejs24.x", runtimes[0].Name)
Expand Down Expand Up @@ -498,6 +510,7 @@ func TestFunctionMethodsUseGeneratedRoutes(t *testing.T) {
"GET /projects/" + projectIDText + "/functions/" + functionIDText,
"DELETE /projects/" + projectIDText + "/functions/" + functionIDText,
"PATCH /projects/" + projectIDText + "/functions/" + functionIDText,
"POST /functions/" + functionIDText + "/invoke",
"GET /functions/runtimes",
"GET /projects/" + projectIDText + "/functions/" + functionIDText + "/deployments?page=3&limit=10",
"GET /projects/" + projectIDText + "/functions/" + functionIDText + "/logs?limit=50&next_token=fn-next",
Expand Down Expand Up @@ -527,6 +540,20 @@ func TestDeleteFunctionAcceptsAsyncAndNoContent(t *testing.T) {
}
}

func TestInvokeFunctionErrorsNormalize(t *testing.T) {
functionID := mustProjectID(t, "22222222-2222-4222-8222-222222222222")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeAPIJSON(t, w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"})
}))
defer server.Close()

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

_, err = client.InvokeFunction(context.Background(), functionID, FunctionInvokeInput{})
require.ErrorContains(t, err, "HTTP 429: rate limited")
}

func mustProjectID(t *testing.T, value string) uuid.UUID {
t.Helper()
id, err := uuid.Parse(value)
Expand Down
23 changes: 23 additions & 0 deletions internal/api/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type FunctionSchedulerInput struct {
Enabled *bool
}

// FunctionInvokeInput contains one function invocation request.
type FunctionInvokeInput struct {
Payload map[string]any
}

// ListFunctions lists one function page for a project.
func (c *Client) ListFunctions(ctx context.Context, projectID uuid.UUID, page, limit int) (*apiclient.PaginatedFunctions, error) {
resp, err := c.client.ListFunctionsWithResponse(ctx, projectID, &apiclient.ListFunctionsParams{
Expand Down Expand Up @@ -107,6 +112,24 @@ func (c *Client) UpdateFunctionVisibility(ctx context.Context, projectID, functi
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON404)
}

// InvokeFunction invokes one function by ID.
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)
if err != nil {
return nil, err
}
if resp.JSONDefault != nil && resp.StatusCode() >= 200 && resp.StatusCode() < 300 {
return resp.JSONDefault, nil
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON429, resp.JSON503)
}

// ListFunctionRuntimes returns the function runtime catalog.
func (c *Client) ListFunctionRuntimes(ctx context.Context) ([]apiclient.FunctionRuntimeOption, error) {
resp, err := c.client.ListFunctionRuntimesWithResponse(ctx)
Expand Down
121 changes: 121 additions & 0 deletions internal/cmd/functions/alias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package functions

import (
"context"
"fmt"
"io"
"strings"

"github.com/spf13/cobra"

clifunction "github.com/Kong/volcano-cli/internal/function"
"github.com/Kong/volcano-cli/internal/output"
cliruntime "github.com/Kong/volcano-cli/internal/runtime"
)

type aliasOptions struct {
deps cliruntime.Deps
alias string
functionID string
out io.Writer
}

func newAlias(deps cliruntime.Deps) *cobra.Command {
cmd := &cobra.Command{
Use: "alias",
Short: "Manage function invoke aliases",
Long: "Manage per-user aliases used by functions invoke.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newAliasSet(deps))
cmd.AddCommand(newAliasList(deps))
cmd.AddCommand(newAliasDelete(deps))
return cmd
}

func newAliasSet(deps cliruntime.Deps) *cobra.Command {
return &cobra.Command{
Use: "set <alias> <function-id>",
Short: "Set a function invoke alias",
Example: fmt.Sprintf(` %s`,

Check failure on line 43 in internal/cmd/functions/alias.go

View workflow job for this annotation

GitHub Actions / check / check

string-format: fmt.Sprintf can be replaced with string concatenation (perfsprint)
cliruntime.CommandPath(deps, "functions alias set hello 33333333-3333-4333-8333-333333333333")),
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return runAliasSet(cmd.Context(), aliasOptions{
deps: deps,
alias: strings.TrimSpace(args[0]),
functionID: strings.TrimSpace(args[1]),
out: cmd.OutOrStdout(),
})
},
}
}

func newAliasList(deps cliruntime.Deps) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List function invoke aliases",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runAliasList(cmd.Context(), aliasOptions{
deps: deps,
out: cmd.OutOrStdout(),
})
},
}
}

func newAliasDelete(deps cliruntime.Deps) *cobra.Command {
return &cobra.Command{
Use: "delete <alias>",
Short: "Delete a function invoke alias",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAliasDelete(cmd.Context(), aliasOptions{
deps: deps,
alias: strings.TrimSpace(args[0]),
out: cmd.OutOrStdout(),
})
},
}
}

func runAliasSet(ctx context.Context, opts aliasOptions) error {
alias, err := clifunction.NewService(opts.deps).SetAlias(ctx, opts.alias, opts.functionID)
if err != nil {
return err
}
fmt.Fprintf(opts.out, "Alias: %s\n", alias.Name)
fmt.Fprintf(opts.out, "Function ID: %s\n", alias.FunctionID)
output.Success(opts.out, "Set function alias %q", alias.Name)
return nil
}

func runAliasList(ctx context.Context, opts aliasOptions) error {
aliases, err := clifunction.NewService(opts.deps).ListAliases(ctx)
if err != nil {
return err
}
if len(aliases) == 0 {
fmt.Fprintln(opts.out, "No function aliases configured")
return nil
}

fmt.Fprintf(opts.out, "%-24s %-36s\n", "Alias", "Function ID")
fmt.Fprintln(opts.out, strings.Repeat("-", 62))
for _, alias := range aliases {
fmt.Fprintf(opts.out, "%-24s %-36s\n", alias.Name, alias.FunctionID)
}
return nil
}

func runAliasDelete(ctx context.Context, opts aliasOptions) error {
if err := clifunction.NewService(opts.deps).DeleteAlias(ctx, opts.alias); err != nil {
return err
}
output.Success(opts.out, "Deleted function alias %q", opts.alias)
return nil
}
Comment on lines +115 to +121
2 changes: 2 additions & 0 deletions internal/cmd/functions/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ func newWithOptions(deps cliruntime.Deps, opts commandOptions) *cobra.Command {
cmd.AddCommand(newDeploy(deps, opts.batchDeployAll))
cmd.AddCommand(newList(deps))
cmd.AddCommand(newGet(deps))
cmd.AddCommand(newInvoke(deps))
cmd.AddCommand(newAlias(deps))
cmd.AddCommand(newDelete(deps))
cmd.AddCommand(newUpdate(deps))
cmd.AddCommand(newLogs(deps))
Expand Down
112 changes: 112 additions & 0 deletions internal/cmd/functions/invoke.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package functions

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"

"github.com/google/uuid"
"github.com/spf13/cobra"

clifunction "github.com/Kong/volcano-cli/internal/function"
cliruntime "github.com/Kong/volcano-cli/internal/runtime"
)

type invokeOptions struct {
deps cliruntime.Deps
identifier string
functionID string
payload string
jsonOutput bool
out io.Writer
}

func newInvoke(deps cliruntime.Deps) *cobra.Command {
opts := invokeOptions{}
cmd := &cobra.Command{
Use: "invoke [name]",
Short: "Invoke a function",
Long: "Invoke a deployed function by alias, name, path, or ID.",
Example: fmt.Sprintf(` %s
%s
%s`,
cliruntime.CommandPath(deps, `functions invoke hello --payload '{"name":"Ada"}'`),
cliruntime.CommandPath(deps, `functions invoke hello --json`),
cliruntime.CommandPath(deps, `functions invoke --id 33333333-3333-4333-8333-333333333333`)),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.deps = deps
if len(args) == 1 {
opts.identifier = strings.TrimSpace(args[0])
}
opts.out = cmd.OutOrStdout()
return runInvoke(cmd.Context(), opts)
},
}
cmd.Flags().StringVar(&opts.functionID, "id", "", "Function ID to invoke directly")
cmd.Flags().StringVar(&opts.payload, "payload", "", "Inline JSON object passed as the invocation payload")
cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "Print compact JSON output")
return cmd
}

func runInvoke(ctx context.Context, opts invokeOptions) error {
hasName := strings.TrimSpace(opts.identifier) != ""
hasID := strings.TrimSpace(opts.functionID) != ""
switch {
case hasName && hasID:
return errors.New("specify either a function name or --id, not both")
case !hasName && !hasID:
return errors.New("specify a function name or --id")
}

payload, err := loadInvokePayload(opts.payload)
if err != nil {
return err
}

service := clifunction.NewService(opts.deps)
var resp any
if hasID {
functionID, err := uuid.Parse(strings.TrimSpace(opts.functionID))
if err != nil {
return fmt.Errorf("invalid function ID %q: %w", opts.functionID, err)
}
resp, err = service.InvokeByID(ctx, functionID, payload)
if err != nil {
return err
}
} else {
resp, err = service.Invoke(ctx, opts.identifier, payload)
if err != nil {
return err
}
}

return writeInvocationResponse(opts.out, resp, opts.jsonOutput)
}

func loadInvokePayload(value string) (map[string]any, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil

Check failure on line 94 in internal/cmd/functions/invoke.go

View workflow job for this annotation

GitHub Actions / check / check

return both a `nil` error and an invalid value: use a sentinel error instead (nilnil)
}
var payload map[string]any
if err := json.Unmarshal([]byte(value), &payload); err != nil {
return nil, fmt.Errorf("payload must be a JSON object: %w", err)
}
if payload == nil {
return nil, errors.New("payload must be a JSON object")
}
return payload, nil
}

func writeInvocationResponse(w io.Writer, resp any, compact bool) error {
encoder := json.NewEncoder(w)
if !compact {
encoder.SetIndent("", " ")
}
return encoder.Encode(resp)
}
Loading
Loading