From 6481f14f729e318ddcc7d985bb9c10b4bfde866c Mon Sep 17 00:00:00 2001 From: fayzan Date: Mon, 17 Aug 2026 17:22:23 +0100 Subject: [PATCH 1/3] Add manage api-keys to CLI --- cmd/api_keys.go | 510 +++++++++++++++++++++++++++++++ cmd/output.go | 102 +++++++ cmd/requesty.go | 51 +++- go.mod | 3 + go.sum | 10 + internal/client/api_keys.go | 192 ++++++++++++ internal/client/api_keys_test.go | 256 ++++++++++++++++ internal/client/client.go | 82 +++++ 8 files changed, 1201 insertions(+), 5 deletions(-) create mode 100644 cmd/api_keys.go create mode 100644 cmd/output.go create mode 100644 internal/client/api_keys.go create mode 100644 internal/client/api_keys_test.go diff --git a/cmd/api_keys.go b/cmd/api_keys.go new file mode 100644 index 0000000..855cbab --- /dev/null +++ b/cmd/api_keys.go @@ -0,0 +1,510 @@ +package cmd + +import ( + "bufio" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/requestyai/cli/internal/client" + "github.com/shopspring/decimal" + "github.com/spf13/cobra" +) + +const ( + jsonFlag = "json" +) + +const ( + apiKeyNameFlag = "name" + apiKeyMonthlyLimitFlag = "monthly-limit" + apiKeyManagePermissionFlag = "manage-permission" + apiKeyCompletionsPermissionFlag = "completions-permission" + apiKeyYesFlag = "yes" +) + +// neverExpires is the expiry to pass for a key that should keep working. +const neverExpires = "never" + +func newAPIKeysCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "api-keys", + Aliases: []string{"api-key", "keys"}, + Short: "Manage the API keys in your organization", + Long: "Manage the API keys in your organization.\n\n" + + "The show command also takes " + client.SelfAPIKeyID + + " as the id, meaning the key this CLI is configured with.", + } + + cmd.PersistentFlags().Bool(jsonFlag, false, "print JSON instead of a table") + cmd.AddCommand( + newAPIKeysListCommand(env), + newAPIKeysShowCommand(env), + newAPIKeysCreateCommand(env), + newAPIKeysSetCommand(env), + newAPIKeysClearCommand(env), + newAPIKeysDeleteCommand(env), + ) + + return cmd +} + +func newAPIKeysListCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the API keys in your organization", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + keys, err := env.client.APIKeys(cmd.Context()) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, keys) + } + if len(keys) == 0 { + _, err := fmt.Fprintln(out, "No API keys yet.") + return err + } + + rows := make([][]string, 0, len(keys)) + for _, key := range keys { + rows = append(rows, []string{ + key.ID, + key.Name, + formatMoney(key.MonthlySpend), + formatLimit(key.MonthlyLimit), + formatLabels(key.Labels), + }) + } + + return writeTable(out, []string{"ID", "NAME", "SPEND", "LIMIT", "LABELS"}, rows) + }, + } +} + +func newAPIKeysShowCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show one API key", + Long: "Show one API key.\n\n" + + "Pass " + client.SelfAPIKeyID + " to read the key this CLI is configured with, which\n" + + "works even without manage permission.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + key, err := env.client.APIKey(cmd.Context(), id) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, key) + } + + return writeFields(out, [][2]string{ + {"ID", key.ID}, + {"Name", key.Name}, + {"Spend this month", formatMoney(key.MonthlySpend)}, + {"Monthly limit", formatLimit(key.MonthlyLimit)}, + {"Permissions", formatPermissions(key.Permissions)}, + {"Logging", strconv.FormatBool(key.Logging)}, + {"Group", formatGroup(key.Group)}, + }) + }, + } +} + +func newAPIKeysCreateCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create an API key", + Long: "Create an API key.\n\n" + + "The key itself is returned once and cannot be read again afterwards.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags := cmd.Flags() + + name, err := flags.GetString(apiKeyNameFlag) + if err != nil { + return err + } + + input := client.CreateAPIKeyInput{Name: name} + + if flags.Changed(apiKeyMonthlyLimitFlag) { + raw, err := flags.GetString(apiKeyMonthlyLimitFlag) + if err != nil { + return err + } + limit, err := parseMoney("--"+apiKeyMonthlyLimitFlag, raw) + if err != nil { + return err + } + input.MonthlyLimit = &limit + } + + manage, err := flags.GetString(apiKeyManagePermissionFlag) + if err != nil { + return err + } + completions, err := flags.GetString(apiKeyCompletionsPermissionFlag) + if err != nil { + return err + } + permissions, err := parsePermissions(manage, completions) + if err != nil { + return err + } + input.Permissions = permissions + + created, err := env.client.CreateAPIKey(cmd.Context(), input) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, created) + } + + if err := writeFields(out, [][2]string{ + {"ID", created.ID}, + {"Key", created.Secret}, + }); err != nil { + return err + } + + _, err = fmt.Fprintln(out, "\nStore this key now. It is never shown again.") + return err + }, + } + + flags := cmd.Flags() + flags.String(apiKeyNameFlag, "", "name for the new key") + flags.String(apiKeyMonthlyLimitFlag, "", "monthly spending cap in dollars, for example 100 (default: the organization setting)") + flags.String(apiKeyManagePermissionFlag, "", "access to the management API: none, read or write") + flags.String(apiKeyCompletionsPermissionFlag, "", "access to completions: none, read or write") + if err := cmd.MarkFlagRequired(apiKeyNameFlag); err != nil { + panic(err) + } + + return cmd +} + +func newAPIKeysSetCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "set", + Short: "Set a property on an API key", + } + cmd.AddCommand( + newAPIKeysSetLimitCommand(env), + newAPIKeysSetLabelsCommand(env), + newAPIKeysSetExpiryCommand(env), + ) + + return cmd +} + +func newAPIKeysSetLimitCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "limit ", + Short: "Set the monthly spending cap of an API key", + Long: "Set the monthly spending cap of an API key.\n\n" + + "The amount is in dollars, for example 100 or 49.99. Pass 0 to remove the cap.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + limit, err := parseMoney("amount", args[1]) + if err != nil { + return err + } + + if err := env.client.UpdateAPIKeyLimit(cmd.Context(), id, limit); err != nil { + return err + } + + return reportUpdate(cmd, id, "monthly_limit", + fmt.Sprintf("Monthly limit for %s is now %s.", id, formatLimit(limit))) + }, + } +} + +func newAPIKeysSetLabelsCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "labels ...", + Short: "Set the labels on an API key", + Long: "Set the labels on an API key.\n\n" + + "Labels are replaced wholesale: pass every label you want to keep.\n" + + "Use clear labels to remove them all.", + // A missing pair would otherwise wipe every label, so say what to run + // instead rather than reporting an argument count. + Args: func(_ *cobra.Command, args []string) error { + switch len(args) { + case 0: + return errors.New("missing api key id") + case 1: + return errors.New("no labels given: pass them as key=value, or run clear labels to remove them all") + default: + return nil + } + }, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + labels, err := parseLabels(args[1:]) + if err != nil { + return err + } + + if err := env.client.UpdateAPIKeyLabels(cmd.Context(), id, labels); err != nil { + return err + } + + return reportUpdate(cmd, id, "labels", + fmt.Sprintf("Labels on %s are now %s.", id, formatLabels(labels))) + }, + } +} + +func newAPIKeysClearCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "clear", + Short: "Clear a property on an API key", + } + cmd.AddCommand(newAPIKeysClearLabelsCommand(env)) + + return cmd +} + +func newAPIKeysClearLabelsCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "labels ", + Short: "Remove every label from an API key", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + if err := env.client.UpdateAPIKeyLabels(cmd.Context(), id, nil); err != nil { + return err + } + + return reportUpdate(cmd, id, "labels", fmt.Sprintf("Removed every label from %s.", id)) + }, + } +} + +func newAPIKeysSetExpiryCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "expiry ", + Short: "Set when an API key stops working", + Long: "Set when an API key stops working.\n\n" + + "The time is RFC3339, for example 2026-12-31T23:59:59Z. Pass " + neverExpires + " to make\n" + + "the key non-expiring. A key that has already expired cannot be revived.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + var expiresAt *time.Time + if !strings.EqualFold(strings.TrimSpace(args[1]), neverExpires) { + parsed, err := parseTime(args[1]) + if err != nil { + return err + } + expiresAt = &parsed + } + + if err := env.client.UpdateAPIKeyExpiry(cmd.Context(), id, expiresAt); err != nil { + return err + } + + message := fmt.Sprintf("%s no longer expires.", id) + if expiresAt != nil { + message = fmt.Sprintf("%s expires at %s.", id, expiresAt.Format(time.RFC3339)) + } + + return reportUpdate(cmd, id, "expiry", message) + }, + } +} + +func newAPIKeysDeleteCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an API key", + Long: "Delete an API key.\n\n" + + "Deletion is permanent, and every request made with the key fails from then on.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + skipPrompt, err := cmd.Flags().GetBool(apiKeyYesFlag) + if err != nil { + return err + } + + if !skipPrompt { + confirmed, err := confirm(cmd, fmt.Sprintf("Delete API key %s? This cannot be undone [y/N]: ", id)) + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "Left it alone.") + return err + } + } + + if err := env.client.DeleteAPIKey(cmd.Context(), id); err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, map[string]any{"id": id, "deleted": true}) + } + + _, err = fmt.Fprintf(out, "Deleted API key %s.\n", id) + return err + }, + } + + cmd.Flags().BoolP(apiKeyYesFlag, "y", false, "delete without asking first") + + return cmd +} + +// reportUpdate acknowledges a change the API accepted but does not echo back. +func reportUpdate(cmd *cobra.Command, id, field, message string) error { + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, map[string]string{"id": id, "updated": field}) + } + + _, err := fmt.Fprintln(out, message) + + return err +} + +// confirm asks the question and treats anything but yes as no. +func confirm(cmd *cobra.Command, question string) (bool, error) { + if _, err := fmt.Fprint(cmd.OutOrStdout(), question); err != nil { + return false, err + } + + answer, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, fmt.Errorf("failed to read confirmation: %w", err) + } + + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} + +// parseID keeps a blank identifier from being sent as a request for the whole +// collection. +func parseID(value string) (string, error) { + id := strings.TrimSpace(value) + if id == "" { + return "", errors.New("missing api key id") + } + + return id, nil +} + +// parsePermissions builds the permission block, which the API only takes with +// both halves set, so asking for one means saying what the other is too. +func parsePermissions(manage, completions string) (*client.APIKeyPermissions, error) { + if manage == "" && completions == "" { + return nil, nil + } + if manage == "" || completions == "" { + return nil, fmt.Errorf("set both --%s and --%s, or neither", apiKeyManagePermissionFlag, apiKeyCompletionsPermissionFlag) + } + + parsedManage, err := parsePermission(apiKeyManagePermissionFlag, manage) + if err != nil { + return nil, err + } + parsedCompletions, err := parsePermission(apiKeyCompletionsPermissionFlag, completions) + if err != nil { + return nil, err + } + + return &client.APIKeyPermissions{Manage: parsedManage, Completions: parsedCompletions}, nil +} + +func parsePermission(flag, value string) (client.APIKeyPermission, error) { + switch permission := client.APIKeyPermission(value); permission { + case client.APIKeyPermissionNone, client.APIKeyPermissionRead, client.APIKeyPermissionWrite: + return permission, nil + default: + return "", fmt.Errorf("invalid --%s %q: want none, read or write", flag, value) + } +} + +func parseLabels(pairs []string) (map[string]string, error) { + labels := make(map[string]string, len(pairs)) + for _, pair := range pairs { + key, value, found := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("invalid label %q: want key=value", pair) + } + labels[key] = value + } + + return labels, nil +} + +func parseTime(value string) (time.Time, error) { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("invalid expiry %q: want %s or an RFC3339 time such as 2026-12-31T23:59:59Z", + value, neverExpires) + } + + return parsed, nil +} + +// parseMoney reads an amount, named for whichever flag or argument it came from. +func parseMoney(name, value string) (decimal.Decimal, error) { + amount, err := decimal.NewFromString(strings.TrimPrefix(strings.TrimSpace(value), "$")) + if err != nil { + return decimal.Decimal{}, fmt.Errorf("invalid %s %q: want an amount such as 100 or 49.99", name, value) + } + if amount.IsNegative() { + return decimal.Decimal{}, fmt.Errorf("invalid %s %q: want zero or more", name, value) + } + + return amount, nil +} diff --git a/cmd/output.go b/cmd/output.go new file mode 100644 index 0000000..4b30657 --- /dev/null +++ b/cmd/output.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "text/tabwriter" + + "github.com/requestyai/cli/internal/client" + "github.com/shopspring/decimal" + "github.com/spf13/cobra" +) + +// jsonOutput reports whether the caller asked for machine-readable output. The +// flag is declared once on the parent command and inherited from there. +func jsonOutput(cmd *cobra.Command) bool { + value, err := cmd.Flags().GetBool(jsonFlag) + return err == nil && value +} + +// printJSON writes v as indented JSON, which is the contract scripts rely on. +func printJSON(w io.Writer, v any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + + return encoder.Encode(v) +} + +// writeTable prints rows under headers, each column as wide as its widest cell. +func writeTable(w io.Writer, headers []string, rows [][]string) error { + writer := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + if _, err := fmt.Fprintln(writer, strings.Join(headers, "\t")); err != nil { + return err + } + + for _, row := range rows { + if _, err := fmt.Fprintln(writer, strings.Join(row, "\t")); err != nil { + return err + } + } + + return writer.Flush() +} + +// writeFields prints label and value pairs, one per line, for a single record. +func writeFields(w io.Writer, fields [][2]string) error { + writer := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + for _, field := range fields { + if _, err := fmt.Fprintf(writer, "%s\t%s\n", field[0], field[1]); err != nil { + return err + } + } + + return writer.Flush() +} + +// formatMoney renders an amount in dollars and cents. +func formatMoney(amount decimal.Decimal) string { + return "$" + amount.StringFixed(2) +} + +// formatLimit renders a spending cap, where zero means there is none. +func formatLimit(limit decimal.Decimal) string { + if limit.IsZero() { + return "unlimited" + } + + return formatMoney(limit) +} + +// formatLabels renders labels as sorted key=value pairs. +func formatLabels(labels map[string]string) string { + if len(labels) == 0 { + return "-" + } + + pairs := make([]string, 0, len(labels)) + for key, value := range labels { + pairs = append(pairs, key+"="+value) + } + sort.Strings(pairs) + + return strings.Join(pairs, " ") +} + +// formatPermissions renders what a key is allowed to do. +func formatPermissions(permissions client.APIKeyPermissions) string { + return fmt.Sprintf("manage=%s completions=%s", permissions.Manage, permissions.Completions) +} + +// formatGroup renders the group a key belongs to, if it belongs to one. +func formatGroup(group *client.APIKeyGroup) string { + if group == nil || group.ID == "" { + return "-" + } + + return group.ID +} diff --git a/cmd/requesty.go b/cmd/requesty.go index 65bd515..3411758 100644 --- a/cmd/requesty.go +++ b/cmd/requesty.go @@ -4,20 +4,61 @@ import ( "fmt" tea "charm.land/bubbletea/v2" + "github.com/requestyai/cli/internal/client" "github.com/requestyai/cli/internal/config" "github.com/requestyai/cli/internal/tui" + "github.com/spf13/cobra" ) -// Run starts the Requesty terminal UI. +// Run executes the requesty command line. func Run() error { + env, err := newEnvironment() + if err != nil { + return fmt.Errorf("failed to initialize environment: %w", err) + } + + return newRootCommand(env).Execute() +} + +// environment is everything the commands need from the outside world, kept +// behind function fields so tests can stand in for the gateway and the UI. +type environment struct { + config config.Config + client *client.Client +} + +func newEnvironment() (environment, error) { cfg, err := config.Load() if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return environment{}, fmt.Errorf("failed to load config: %w", err) } - if _, err := tea.NewProgram(tui.NewRoot(cfg)).Run(); err != nil { - return fmt.Errorf("failed to run program: %w", err) + return environment{ + config: cfg, + client: client.New(cfg), + }, nil +} + +func newRootCommand(env environment) *cobra.Command { + root := &cobra.Command{ + Use: "requesty", + Short: "Point your AI coding harnesses at Requesty", + Long: "Requesty routes every AI coding harness on your machine through one gateway.\n\n" + + "Run with no arguments for the terminal app that configures harnesses and shows\n" + + "what you are spending. The subcommands manage your organization instead.", + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, _ []string) error { + if _, err := tea.NewProgram(tui.NewRoot(env.config)).Run(); err != nil { + return fmt.Errorf("failed to run program: %w", err) + } + + return nil + }, } - return nil + root.AddCommand(newAPIKeysCommand(env)) + + return root } diff --git a/go.mod b/go.mod index 74c50e8..b1fa598 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/x/ansi v0.11.7 github.com/pelletier/go-toml/v2 v2.4.3 github.com/shopspring/decimal v1.4.0 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -24,12 +25,14 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lrstanley/bubblezone/v2 v2.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect diff --git a/go.sum b/go.sum index 2a7e202..1d3494f 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,11 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA= github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= @@ -44,12 +47,19 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= diff --git a/internal/client/api_keys.go b/internal/client/api_keys.go new file mode 100644 index 0000000..246e48f --- /dev/null +++ b/internal/client/api_keys.go @@ -0,0 +1,192 @@ +package client + +import ( + "context" + "net/http" + "time" + + "github.com/shopspring/decimal" +) + +// SelfAPIKeyID stands in for the key the request is made with, so a key with +// no manage permission can still read its own record. +const SelfAPIKeyID = "self" + +// APIKeyPermission is the access a key has to one part of the API. +type APIKeyPermission string + +const ( + APIKeyPermissionNone APIKeyPermission = "none" + APIKeyPermissionRead APIKeyPermission = "read" + APIKeyPermissionWrite APIKeyPermission = "write" +) + +// APIKeyPermissions is what a key is allowed to do. +type APIKeyPermissions struct { + Manage APIKeyPermission `json:"manage"` + Completions APIKeyPermission `json:"completions"` +} + +// APIKeyUser identifies the person a key belongs to. +type APIKeyUser struct { + ID string `json:"id"` + Email string `json:"email"` +} + +// APIKeyGroup identifies the group a key belongs to. +type APIKeyGroup struct { + ID string `json:"id"` +} + +// APIKey is a key as it appears in the organization listing. A MonthlyLimit of +// zero means the key is not capped. +type APIKey struct { + ID string `json:"id"` + Name string `json:"name"` + MonthlyLimit decimal.Decimal `json:"monthly_limit"` + MonthlySpend decimal.Decimal `json:"monthly_spend"` + Permissions APIKeyPermissions `json:"permissions"` + Labels map[string]string `json:"labels,omitempty"` + CreatedBy *APIKeyUser `json:"created_by,omitempty"` + Group *APIKeyGroup `json:"group,omitempty"` +} + +// APIKeyDetails is a single key. The listing does not report logging, so this +// is a wider record than APIKey rather than the same one. +type APIKeyDetails struct { + ID string `json:"id"` + Name string `json:"name"` + Logging bool `json:"logging"` + MonthlyLimit decimal.Decimal `json:"monthly_limit"` + MonthlySpend decimal.Decimal `json:"monthly_spend"` + Permissions APIKeyPermissions `json:"permissions"` + Group *APIKeyGroup `json:"group,omitempty"` +} + +// CreateAPIKeyInput describes a key to create. A nil MonthlyLimit or +// Permissions leaves the organization default in place. +type CreateAPIKeyInput struct { + Name string + MonthlyLimit *decimal.Decimal + Permissions *APIKeyPermissions +} + +// CreatedAPIKey carries the secret the API returns once and never again. +type CreatedAPIKey struct { + ID string `json:"api_key_id"` + Secret string `json:"api_key"` +} + +// APIKeys lists the organization's keys. +func (c *Client) APIKeys(ctx context.Context) ([]APIKey, error) { + endpoint, err := c.manageURL("apikey") + if err != nil { + return nil, err + } + + var response struct { + Keys []APIKey `json:"keys"` + } + if err := c.do(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + + return response.Keys, nil +} + +// APIKey returns one key. Pass SelfAPIKeyID for the calling key. +func (c *Client) APIKey(ctx context.Context, id string) (APIKeyDetails, error) { + endpoint, err := c.manageURL("apikey", id) + if err != nil { + return APIKeyDetails{}, err + } + + var details APIKeyDetails + if err := c.do(ctx, http.MethodGet, endpoint, nil, &details); err != nil { + return APIKeyDetails{}, err + } + + return details, nil +} + +// CreateAPIKey issues a new key for the organization. +func (c *Client) CreateAPIKey(ctx context.Context, input CreateAPIKeyInput) (CreatedAPIKey, error) { + endpoint, err := c.manageURL("apikey") + if err != nil { + return CreatedAPIKey{}, err + } + + body := struct { + Name string `json:"name"` + MonthlyLimit *decimal.Decimal `json:"monthly_limit,omitempty"` + Permissions *APIKeyPermissions `json:"permissions,omitempty"` + }{ + Name: input.Name, + MonthlyLimit: input.MonthlyLimit, + Permissions: input.Permissions, + } + + var created CreatedAPIKey + if err := c.do(ctx, http.MethodPost, endpoint, body, &created); err != nil { + return CreatedAPIKey{}, err + } + + return created, nil +} + +// UpdateAPIKeyLimit sets the monthly spending cap. Zero removes the cap. +func (c *Client) UpdateAPIKeyLimit(ctx context.Context, id string, monthlyLimit decimal.Decimal) error { + endpoint, err := c.manageURL("apikey", id, "limit") + if err != nil { + return err + } + + body := struct { + MonthlyLimit decimal.Decimal `json:"monthly_limit"` + }{MonthlyLimit: monthlyLimit} + + return c.do(ctx, http.MethodPost, endpoint, body, nil) +} + +// UpdateAPIKeyLabels replaces every label on the key. An empty map clears them. +func (c *Client) UpdateAPIKeyLabels(ctx context.Context, id string, labels map[string]string) error { + endpoint, err := c.manageURL("apikey", id, "label") + if err != nil { + return err + } + + if labels == nil { + labels = map[string]string{} + } + + body := struct { + Labels map[string]string `json:"labels"` + }{Labels: labels} + + return c.do(ctx, http.MethodPost, endpoint, body, nil) +} + +// UpdateAPIKeyExpiry sets when the key stops working. A nil expiresAt makes the +// key non-expiring. A key that has already expired cannot be revived. +func (c *Client) UpdateAPIKeyExpiry(ctx context.Context, id string, expiresAt *time.Time) error { + endpoint, err := c.manageURL("apikey", id, "expiry") + if err != nil { + return err + } + + body := struct { + ExpiresAt *time.Time `json:"expires_at"` + }{ExpiresAt: expiresAt} + + return c.do(ctx, http.MethodPost, endpoint, body, nil) +} + +// DeleteAPIKey removes a key for good. Requests using it fail immediately. +func (c *Client) DeleteAPIKey(ctx context.Context, id string) error { + endpoint, err := c.manageURL("apikey", id) + if err != nil { + return err + } + + return c.do(ctx, http.MethodDelete, endpoint, nil, nil) +} diff --git a/internal/client/api_keys_test.go b/internal/client/api_keys_test.go new file mode 100644 index 0000000..148342c --- /dev/null +++ b/internal/client/api_keys_test.go @@ -0,0 +1,256 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/requestyai/cli/internal/config" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recorder captures what the client sent, so a test can assert on the request +// and choose the reply in one place. +type recorder struct { + method string + path string + auth string + query map[string][]string + body string +} + +func newTestClient(t *testing.T, status int, reply string) (*Client, *recorder) { + t.Helper() + + seen := &recorder{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + seen.method = r.Method + seen.path = r.URL.EscapedPath() + seen.auth = r.Header.Get("Authorization") + seen.query = r.URL.Query() + seen.body = string(body) + + w.WriteHeader(status) + if reply != "" { + _, err := io.WriteString(w, reply) + require.NoError(t, err) + } + })) + t.Cleanup(server.Close) + + return New(config.Config{APIBaseURL: server.URL, APIKey: "test-key"}), seen +} + +func TestClientAPIKeys(t *testing.T) { + reply := `{"keys":[{"id":"key-1","name":"production","monthly_limit":"500","monthly_spend":"12.5",` + + `"permissions":{"manage":"read","completions":"write"},"labels":{"env":"prod"},` + + `"created_by":{"id":"user-1","email":"you@example.com"},"group":{"id":"group-1"}}]}` + client, seen := newTestClient(t, http.StatusOK, reply) + + keys, err := client.APIKeys(context.Background()) + + require.NoError(t, err) + assert.Equal(t, http.MethodGet, seen.method) + assert.Equal(t, "/v1/manage/apikey", seen.path) + assert.Equal(t, "Bearer test-key", seen.auth) + assert.Equal(t, []APIKey{{ + ID: "key-1", + Name: "production", + MonthlyLimit: decimal.RequireFromString("500"), + MonthlySpend: decimal.RequireFromString("12.5"), + Permissions: APIKeyPermissions{Manage: APIKeyPermissionRead, Completions: APIKeyPermissionWrite}, + Labels: map[string]string{"env": "prod"}, + CreatedBy: &APIKeyUser{ID: "user-1", Email: "you@example.com"}, + Group: &APIKeyGroup{ID: "group-1"}, + }}, keys) +} + +func TestClientAPIKey(t *testing.T) { + reply := `{"id":"key-1","name":"production","logging":true,"monthly_limit":"0","monthly_spend":"3",` + + `"permissions":{"manage":"none","completions":"write"},"group":{"id":"group-1"}}` + client, seen := newTestClient(t, http.StatusOK, reply) + + key, err := client.APIKey(context.Background(), SelfAPIKeyID) + + require.NoError(t, err) + assert.Equal(t, http.MethodGet, seen.method) + assert.Equal(t, "/v1/manage/apikey/self", seen.path) + assert.Equal(t, APIKeyDetails{ + ID: "key-1", + Name: "production", + Logging: true, + MonthlyLimit: decimal.RequireFromString("0"), + MonthlySpend: decimal.RequireFromString("3"), + Permissions: APIKeyPermissions{Manage: APIKeyPermissionNone, Completions: APIKeyPermissionWrite}, + Group: &APIKeyGroup{ID: "group-1"}, + }, key) +} + +func TestClientAPIKeyEscapesID(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"id":"key-1"}`) + + _, err := client.APIKey(context.Background(), "../org") + + require.NoError(t, err) + assert.Equal(t, "/v1/manage/apikey/..%2Forg", seen.path) +} + +func TestClientCreateAPIKey(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"api_key_id":"key-1","api_key":"rqsty-secret"}`) + + limit := decimal.RequireFromString("100") + created, err := client.CreateAPIKey(context.Background(), CreateAPIKeyInput{ + Name: "production", + MonthlyLimit: &limit, + Permissions: &APIKeyPermissions{Manage: APIKeyPermissionRead, Completions: APIKeyPermissionWrite}, + }) + + require.NoError(t, err) + assert.Equal(t, http.MethodPost, seen.method) + assert.Equal(t, "/v1/manage/apikey", seen.path) + assert.JSONEq(t, `{"name":"production","monthly_limit":"100","permissions":{"manage":"read","completions":"write"}}`, seen.body) + assert.Equal(t, CreatedAPIKey{ID: "key-1", Secret: "rqsty-secret"}, created) +} + +func TestClientCreateAPIKeyOmitsUnsetFields(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"api_key_id":"key-1","api_key":"rqsty-secret"}`) + + _, err := client.CreateAPIKey(context.Background(), CreateAPIKeyInput{Name: "production"}) + + require.NoError(t, err) + assert.JSONEq(t, `{"name":"production"}`, seen.body) +} + +func TestClientUpdateAPIKeyLimit(t *testing.T) { + client, seen := newTestClient(t, http.StatusNoContent, "") + + err := client.UpdateAPIKeyLimit(context.Background(), "key-1", decimal.RequireFromString("49.99")) + + require.NoError(t, err) + assert.Equal(t, http.MethodPost, seen.method) + assert.Equal(t, "/v1/manage/apikey/key-1/limit", seen.path) + assert.JSONEq(t, `{"monthly_limit":"49.99"}`, seen.body) +} + +func TestClientUpdateAPIKeyLabels(t *testing.T) { + tests := []struct { + name string + labels map[string]string + wantBody string + }{ + {name: "set", labels: map[string]string{"env": "prod"}, wantBody: `{"labels":{"env":"prod"}}`}, + {name: "clear with empty map", labels: map[string]string{}, wantBody: `{"labels":{}}`}, + {name: "clear with nil map", labels: nil, wantBody: `{"labels":{}}`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, seen := newTestClient(t, http.StatusNoContent, "") + + err := client.UpdateAPIKeyLabels(context.Background(), "key-1", tc.labels) + + require.NoError(t, err) + assert.Equal(t, http.MethodPost, seen.method) + assert.Equal(t, "/v1/manage/apikey/key-1/label", seen.path) + assert.JSONEq(t, tc.wantBody, seen.body) + }) + } +} + +func TestClientUpdateAPIKeyExpiry(t *testing.T) { + expiresAt := time.Date(2026, time.December, 31, 23, 59, 59, 0, time.UTC) + + tests := []struct { + name string + expiresAt *time.Time + wantBody string + }{ + {name: "set", expiresAt: &expiresAt, wantBody: `{"expires_at":"2026-12-31T23:59:59Z"}`}, + {name: "clear", expiresAt: nil, wantBody: `{"expires_at":null}`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client, seen := newTestClient(t, http.StatusNoContent, "") + + err := client.UpdateAPIKeyExpiry(context.Background(), "key-1", tc.expiresAt) + + require.NoError(t, err) + assert.Equal(t, http.MethodPost, seen.method) + assert.Equal(t, "/v1/manage/apikey/key-1/expiry", seen.path) + assert.JSONEq(t, tc.wantBody, seen.body) + }) + } +} + +func TestClientDeleteAPIKey(t *testing.T) { + client, seen := newTestClient(t, http.StatusNoContent, "") + + err := client.DeleteAPIKey(context.Background(), "key-1") + + require.NoError(t, err) + assert.Equal(t, http.MethodDelete, seen.method) + assert.Equal(t, "/v1/manage/apikey/key-1", seen.path) + assert.Empty(t, seen.body) +} + +func TestClientAPIKeyReportsAPIError(t *testing.T) { + client, _ := newTestClient(t, http.StatusForbidden, `{"error":{"origin":"router","message":"manage read permission required"}}`) + + _, err := client.APIKey(context.Background(), "key-1") + + require.Error(t, err) + assert.Contains(t, err.Error(), "manage read permission required") + assert.Contains(t, err.Error(), "403") +} + +func TestClientAPIKeyFallsBackToStatus(t *testing.T) { + client, _ := newTestClient(t, http.StatusBadGateway, "upstream is down") + + _, err := client.APIKey(context.Background(), "key-1") + + require.Error(t, err) + assert.Contains(t, err.Error(), "502") +} + +func TestClientAPIKeysUnauthenticated(t *testing.T) { + var auth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + _, err := fmt.Fprint(w, `{"keys":[]}`) + require.NoError(t, err) + })) + defer server.Close() + + client := New(config.Config{APIBaseURL: server.URL}) + keys, err := client.APIKeys(context.Background()) + + require.NoError(t, err) + assert.Empty(t, keys) + assert.Empty(t, auth) +} + +func TestClientCreateAPIKeySendsJSONContentType(t *testing.T) { + var contentType string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentType = r.Header.Get("Content-Type") + require.NoError(t, json.NewEncoder(w).Encode(CreatedAPIKey{ID: "key-1", Secret: "rqsty-secret"})) + })) + defer server.Close() + + client := New(config.Config{APIBaseURL: server.URL, APIKey: "test-key"}) + _, err := client.CreateAPIKey(context.Background(), CreateAPIKeyInput{Name: "production"}) + + require.NoError(t, err) + assert.Equal(t, "application/json", contentType) +} diff --git a/internal/client/client.go b/internal/client/client.go index f0b9eb0..6bb8edb 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -1,7 +1,13 @@ package client import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" "net/http" + "net/url" "strings" "time" @@ -37,3 +43,79 @@ func (c *Client) authorize(req *http.Request) { req.Header.Set("Authorization", "Bearer "+c.config.APIKey) } } + +// manageURL builds a management API address. Each element is escaped, so an +// identifier that came from a user cannot reach into another path. +func (c *Client) manageURL(elements ...string) (string, error) { + apiBaseURL, err := c.apiBaseURL() + if err != nil { + return "", fmt.Errorf("failed to get api base url: %w", err) + } + + escaped := make([]string, 0, len(elements)) + for _, element := range elements { + escaped = append(escaped, url.PathEscape(element)) + } + + endpoint := fmt.Sprintf("%s/v1/manage/%s", apiBaseURL, strings.Join(escaped, "/")) + return endpoint, nil +} + +// do sends an authenticated request, encoding body as JSON when it is not nil +// and decoding the reply into out when out is not nil. +func (c *Client) do(ctx context.Context, method string, endpoint string, body, out any) error { + var payload io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to encode request: %w", err) + } + payload = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, payload) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + c.authorize(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return statusError(resp) + } + + if out == nil || resp.StatusCode == http.StatusNoContent { + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + return nil +} + +// statusError describes a rejected request, preferring the explanation the API +// sent over the bare status code. +func statusError(resp *http.Response) error { + var envelope struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err == nil && envelope.Error.Message != "" { + return fmt.Errorf("%s (status %d)", envelope.Error.Message, resp.StatusCode) + } + + return fmt.Errorf("status code not ok: %d", resp.StatusCode) +} From 0b8d44cd5b6e628edd7df27ab103f08263c31317 Mon Sep 17 00:00:00 2001 From: fayzan Date: Sun, 23 Aug 2026 12:46:35 +0100 Subject: [PATCH 2/3] Rename client to apiv2Client --- cmd/api_keys.go | 16 ++++++++-------- cmd/requesty.go | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 855cbab..9ae5042 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -58,7 +58,7 @@ func newAPIKeysListCommand(env environment) *cobra.Command { Short: "List the API keys in your organization", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - keys, err := env.client.APIKeys(cmd.Context()) + keys, err := env.apiv2Client.APIKeys(cmd.Context()) if err != nil { return err } @@ -102,7 +102,7 @@ func newAPIKeysShowCommand(env environment) *cobra.Command { return err } - key, err := env.client.APIKey(cmd.Context(), id) + key, err := env.apiv2Client.APIKey(cmd.Context(), id) if err != nil { return err } @@ -168,7 +168,7 @@ func newAPIKeysCreateCommand(env environment) *cobra.Command { } input.Permissions = permissions - created, err := env.client.CreateAPIKey(cmd.Context(), input) + created, err := env.apiv2Client.CreateAPIKey(cmd.Context(), input) if err != nil { return err } @@ -234,7 +234,7 @@ func newAPIKeysSetLimitCommand(env environment) *cobra.Command { return err } - if err := env.client.UpdateAPIKeyLimit(cmd.Context(), id, limit); err != nil { + if err := env.apiv2Client.UpdateAPIKeyLimit(cmd.Context(), id, limit); err != nil { return err } @@ -274,7 +274,7 @@ func newAPIKeysSetLabelsCommand(env environment) *cobra.Command { return err } - if err := env.client.UpdateAPIKeyLabels(cmd.Context(), id, labels); err != nil { + if err := env.apiv2Client.UpdateAPIKeyLabels(cmd.Context(), id, labels); err != nil { return err } @@ -305,7 +305,7 @@ func newAPIKeysClearLabelsCommand(env environment) *cobra.Command { return err } - if err := env.client.UpdateAPIKeyLabels(cmd.Context(), id, nil); err != nil { + if err := env.apiv2Client.UpdateAPIKeyLabels(cmd.Context(), id, nil); err != nil { return err } @@ -337,7 +337,7 @@ func newAPIKeysSetExpiryCommand(env environment) *cobra.Command { expiresAt = &parsed } - if err := env.client.UpdateAPIKeyExpiry(cmd.Context(), id, expiresAt); err != nil { + if err := env.apiv2Client.UpdateAPIKeyExpiry(cmd.Context(), id, expiresAt); err != nil { return err } @@ -380,7 +380,7 @@ func newAPIKeysDeleteCommand(env environment) *cobra.Command { } } - if err := env.client.DeleteAPIKey(cmd.Context(), id); err != nil { + if err := env.apiv2Client.DeleteAPIKey(cmd.Context(), id); err != nil { return err } diff --git a/cmd/requesty.go b/cmd/requesty.go index 3411758..9da434b 100644 --- a/cmd/requesty.go +++ b/cmd/requesty.go @@ -23,8 +23,8 @@ func Run() error { // environment is everything the commands need from the outside world, kept // behind function fields so tests can stand in for the gateway and the UI. type environment struct { - config config.Config - client *client.Client + config config.Config + apiv2Client *client.Client } func newEnvironment() (environment, error) { @@ -34,8 +34,8 @@ func newEnvironment() (environment, error) { } return environment{ - config: cfg, - client: client.New(cfg), + config: cfg, + apiv2Client: client.New(cfg), }, nil } From 29777456dadf608353ba725330f0db57480d89dc Mon Sep 17 00:00:00 2001 From: fayzan Date: Sun, 23 Aug 2026 13:04:10 +0100 Subject: [PATCH 3/3] Move parse functions to parse.go --- cmd/api_keys.go | 139 +++++----------------------------- internal/util/confirm.go | 29 +++++++ internal/util/confirm_test.go | 30 ++++++++ internal/util/parse.go | 103 +++++++++++++++++++++++++ internal/util/parse_test.go | 75 ++++++++++++++++++ 5 files changed, 257 insertions(+), 119 deletions(-) create mode 100644 internal/util/confirm.go create mode 100644 internal/util/confirm_test.go create mode 100644 internal/util/parse.go create mode 100644 internal/util/parse_test.go diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 9ae5042..cc3004e 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -1,16 +1,14 @@ package cmd import ( - "bufio" "errors" "fmt" - "io" "strconv" "strings" "time" "github.com/requestyai/cli/internal/client" - "github.com/shopspring/decimal" + "github.com/requestyai/cli/internal/util" "github.com/spf13/cobra" ) @@ -26,9 +24,6 @@ const ( apiKeyYesFlag = "yes" ) -// neverExpires is the expiry to pass for a key that should keep working. -const neverExpires = "never" - func newAPIKeysCommand(env environment) *cobra.Command { cmd := &cobra.Command{ Use: "api-keys", @@ -97,7 +92,7 @@ func newAPIKeysShowCommand(env environment) *cobra.Command { "works even without manage permission.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + id, err := util.ParseID(args[0]) if err != nil { return err } @@ -147,7 +142,7 @@ func newAPIKeysCreateCommand(env environment) *cobra.Command { if err != nil { return err } - limit, err := parseMoney("--"+apiKeyMonthlyLimitFlag, raw) + limit, err := util.ParseMoney("--"+apiKeyMonthlyLimitFlag, raw) if err != nil { return err } @@ -162,7 +157,7 @@ func newAPIKeysCreateCommand(env environment) *cobra.Command { if err != nil { return err } - permissions, err := parsePermissions(manage, completions) + permissions, err := util.ParsePermissions(manage, completions) if err != nil { return err } @@ -224,12 +219,12 @@ func newAPIKeysSetLimitCommand(env environment) *cobra.Command { "The amount is in dollars, for example 100 or 49.99. Pass 0 to remove the cap.", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + id, err := util.ParseID(args[0]) if err != nil { return err } - limit, err := parseMoney("amount", args[1]) + limit, err := util.ParseMoney("amount", args[1]) if err != nil { return err } @@ -264,12 +259,12 @@ func newAPIKeysSetLabelsCommand(env environment) *cobra.Command { } }, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + id, err := util.ParseID(args[0]) if err != nil { return err } - labels, err := parseLabels(args[1:]) + labels, err := util.ParseLabels(args[1:]) if err != nil { return err } @@ -300,7 +295,7 @@ func newAPIKeysClearLabelsCommand(env environment) *cobra.Command { Short: "Remove every label from an API key", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + id, err := util.ParseID(args[0]) if err != nil { return err } @@ -316,21 +311,21 @@ func newAPIKeysClearLabelsCommand(env environment) *cobra.Command { func newAPIKeysSetExpiryCommand(env environment) *cobra.Command { return &cobra.Command{ - Use: "expiry ", + Use: "expiry ", Short: "Set when an API key stops working", Long: "Set when an API key stops working.\n\n" + - "The time is RFC3339, for example 2026-12-31T23:59:59Z. Pass " + neverExpires + " to make\n" + + "The time is RFC3339, for example 2026-12-31T23:59:59Z. Pass " + util.NeverExpires + " to make\n" + "the key non-expiring. A key that has already expired cannot be revived.", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + id, err := util.ParseID(args[0]) if err != nil { return err } var expiresAt *time.Time - if !strings.EqualFold(strings.TrimSpace(args[1]), neverExpires) { - parsed, err := parseTime(args[1]) + if !strings.EqualFold(strings.TrimSpace(args[1]), util.NeverExpires) { + parsed, err := util.ParseTime(args[1]) if err != nil { return err } @@ -359,7 +354,7 @@ func newAPIKeysDeleteCommand(env environment) *cobra.Command { "Deletion is permanent, and every request made with the key fails from then on.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + id, err := util.ParseID(args[0]) if err != nil { return err } @@ -370,7 +365,11 @@ func newAPIKeysDeleteCommand(env environment) *cobra.Command { } if !skipPrompt { - confirmed, err := confirm(cmd, fmt.Sprintf("Delete API key %s? This cannot be undone [y/N]: ", id)) + confirmed, err := util.Confirm( + cmd.InOrStdin(), + cmd.OutOrStdout(), + fmt.Sprintf("Delete API key %s? This cannot be undone [y/N]: ", id), + ) if err != nil { return err } @@ -410,101 +409,3 @@ func reportUpdate(cmd *cobra.Command, id, field, message string) error { return err } - -// confirm asks the question and treats anything but yes as no. -func confirm(cmd *cobra.Command, question string) (bool, error) { - if _, err := fmt.Fprint(cmd.OutOrStdout(), question); err != nil { - return false, err - } - - answer, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return false, fmt.Errorf("failed to read confirmation: %w", err) - } - - switch strings.ToLower(strings.TrimSpace(answer)) { - case "y", "yes": - return true, nil - default: - return false, nil - } -} - -// parseID keeps a blank identifier from being sent as a request for the whole -// collection. -func parseID(value string) (string, error) { - id := strings.TrimSpace(value) - if id == "" { - return "", errors.New("missing api key id") - } - - return id, nil -} - -// parsePermissions builds the permission block, which the API only takes with -// both halves set, so asking for one means saying what the other is too. -func parsePermissions(manage, completions string) (*client.APIKeyPermissions, error) { - if manage == "" && completions == "" { - return nil, nil - } - if manage == "" || completions == "" { - return nil, fmt.Errorf("set both --%s and --%s, or neither", apiKeyManagePermissionFlag, apiKeyCompletionsPermissionFlag) - } - - parsedManage, err := parsePermission(apiKeyManagePermissionFlag, manage) - if err != nil { - return nil, err - } - parsedCompletions, err := parsePermission(apiKeyCompletionsPermissionFlag, completions) - if err != nil { - return nil, err - } - - return &client.APIKeyPermissions{Manage: parsedManage, Completions: parsedCompletions}, nil -} - -func parsePermission(flag, value string) (client.APIKeyPermission, error) { - switch permission := client.APIKeyPermission(value); permission { - case client.APIKeyPermissionNone, client.APIKeyPermissionRead, client.APIKeyPermissionWrite: - return permission, nil - default: - return "", fmt.Errorf("invalid --%s %q: want none, read or write", flag, value) - } -} - -func parseLabels(pairs []string) (map[string]string, error) { - labels := make(map[string]string, len(pairs)) - for _, pair := range pairs { - key, value, found := strings.Cut(pair, "=") - key = strings.TrimSpace(key) - if !found || key == "" { - return nil, fmt.Errorf("invalid label %q: want key=value", pair) - } - labels[key] = value - } - - return labels, nil -} - -func parseTime(value string) (time.Time, error) { - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - return time.Time{}, fmt.Errorf("invalid expiry %q: want %s or an RFC3339 time such as 2026-12-31T23:59:59Z", - value, neverExpires) - } - - return parsed, nil -} - -// parseMoney reads an amount, named for whichever flag or argument it came from. -func parseMoney(name, value string) (decimal.Decimal, error) { - amount, err := decimal.NewFromString(strings.TrimPrefix(strings.TrimSpace(value), "$")) - if err != nil { - return decimal.Decimal{}, fmt.Errorf("invalid %s %q: want an amount such as 100 or 49.99", name, value) - } - if amount.IsNegative() { - return decimal.Decimal{}, fmt.Errorf("invalid %s %q: want zero or more", name, value) - } - - return amount, nil -} diff --git a/internal/util/confirm.go b/internal/util/confirm.go new file mode 100644 index 0000000..a3604f9 --- /dev/null +++ b/internal/util/confirm.go @@ -0,0 +1,29 @@ +package util + +import ( + "bufio" + "errors" + "fmt" + "io" + "strings" +) + +// Confirm writes a question and reads one line of input. It returns true only +// for "y" or "yes", ignoring case and surrounding whitespace. +func Confirm(in io.Reader, out io.Writer, question string) (bool, error) { + if _, err := fmt.Fprint(out, question); err != nil { + return false, err + } + + answer, err := bufio.NewReader(in).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, fmt.Errorf("failed to read confirmation: %w", err) + } + + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} diff --git a/internal/util/confirm_test.go b/internal/util/confirm_test.go new file mode 100644 index 0000000..c02a386 --- /dev/null +++ b/internal/util/confirm_test.go @@ -0,0 +1,30 @@ +package util + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfirm(t *testing.T) { + for _, answer := range []string{"y\n", "yes\n", " YES "} { + var out bytes.Buffer + confirmed, err := Confirm(strings.NewReader(answer), &out, "Continue? ") + require.NoError(t, err) + assert.True(t, confirmed) + assert.Equal(t, "Continue? ", out.String()) + } +} + +func TestConfirmDefaultsToNo(t *testing.T) { + for _, answer := range []string{"\n", "no\n", ""} { + var out bytes.Buffer + confirmed, err := Confirm(strings.NewReader(answer), &out, "Continue? ") + require.NoError(t, err) + assert.False(t, confirmed) + assert.Equal(t, "Continue? ", out.String()) + } +} diff --git a/internal/util/parse.go b/internal/util/parse.go new file mode 100644 index 0000000..659504f --- /dev/null +++ b/internal/util/parse.go @@ -0,0 +1,103 @@ +package util + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/requestyai/cli/internal/client" + "github.com/shopspring/decimal" +) + +const ( + managePermissionFlag = "manage-permission" + completionsPermissionFlag = "completions-permission" + + // NeverExpires is the expiry argument for a key that should keep working. + NeverExpires = "never" +) + +// ParseID trims an API key ID and rejects an empty value. +func ParseID(value string) (string, error) { + id := strings.TrimSpace(value) + if id == "" { + return "", errors.New("missing api key id") + } + + return id, nil +} + +// ParsePermissions validates and combines the management and completions +// permissions. Both values must be provided together, or both left empty. +func ParsePermissions(manage, completions string) (*client.APIKeyPermissions, error) { + if manage == "" && completions == "" { + return nil, nil + } + if manage == "" || completions == "" { + return nil, fmt.Errorf("set both --%s and --%s, or neither", managePermissionFlag, completionsPermissionFlag) + } + + parsedManage, err := parsePermission(managePermissionFlag, manage) + if err != nil { + return nil, err + } + parsedCompletions, err := parsePermission(completionsPermissionFlag, completions) + if err != nil { + return nil, err + } + + return &client.APIKeyPermissions{Manage: parsedManage, Completions: parsedCompletions}, nil +} + +// parsePermission validates one API key permission and names its source flag +// in any returned error. +func parsePermission(flag, value string) (client.APIKeyPermission, error) { + switch permission := client.APIKeyPermission(value); permission { + case client.APIKeyPermissionNone, client.APIKeyPermissionRead, client.APIKeyPermissionWrite: + return permission, nil + default: + return "", fmt.Errorf("invalid --%s %q: want none, read or write", flag, value) + } +} + +// ParseLabels converts key=value arguments into labels. Keys are trimmed, while +// values are preserved as entered. +func ParseLabels(pairs []string) (map[string]string, error) { + labels := make(map[string]string, len(pairs)) + for _, pair := range pairs { + key, value, found := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("invalid label %q: want key=value", pair) + } + labels[key] = value + } + + return labels, nil +} + +// ParseTime parses an API key expiry in RFC3339 format. +func ParseTime(value string) (time.Time, error) { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("invalid expiry %q: want %s or an RFC3339 time such as 2026-12-31T23:59:59Z", + value, NeverExpires) + } + + return parsed, nil +} + +// ParseMoney parses a non-negative decimal amount. It accepts surrounding +// whitespace and an optional dollar sign; name identifies the value in errors. +func ParseMoney(name, value string) (decimal.Decimal, error) { + amount, err := decimal.NewFromString(strings.TrimPrefix(strings.TrimSpace(value), "$")) + if err != nil { + return decimal.Decimal{}, fmt.Errorf("invalid %s %q: want an amount such as 100 or 49.99", name, value) + } + if amount.IsNegative() { + return decimal.Decimal{}, fmt.Errorf("invalid %s %q: want zero or more", name, value) + } + + return amount, nil +} diff --git a/internal/util/parse_test.go b/internal/util/parse_test.go new file mode 100644 index 0000000..58fe78f --- /dev/null +++ b/internal/util/parse_test.go @@ -0,0 +1,75 @@ +package util + +import ( + "testing" + "time" + + "github.com/requestyai/cli/internal/client" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseID(t *testing.T) { + id, err := ParseID(" key-123 ") + require.NoError(t, err) + assert.Equal(t, "key-123", id) + + _, err = ParseID(" \t ") + assert.EqualError(t, err, "missing api key id") +} + +func TestParsePermissions(t *testing.T) { + permissions, err := ParsePermissions("", "") + require.NoError(t, err) + assert.Nil(t, permissions) + + permissions, err = ParsePermissions("read", "write") + require.NoError(t, err) + assert.Equal(t, &client.APIKeyPermissions{ + Manage: client.APIKeyPermissionRead, + Completions: client.APIKeyPermissionWrite, + }, permissions) + + _, err = ParsePermissions("read", "") + assert.EqualError(t, err, "set both --manage-permission and --completions-permission, or neither") + + _, err = ParsePermissions("invalid", "read") + assert.EqualError(t, err, `invalid --manage-permission "invalid": want none, read or write`) +} + +func TestParseLabels(t *testing.T) { + labels, err := ParseLabels([]string{" env =production", "endpoint=https://example.com?a=b"}) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "env": "production", + "endpoint": "https://example.com?a=b", + }, labels) + + _, err = ParseLabels([]string{"missing-value-separator"}) + assert.EqualError(t, err, `invalid label "missing-value-separator": want key=value`) +} + +func TestParseTime(t *testing.T) { + parsed, err := ParseTime("2026-12-31T23:59:59Z") + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC), parsed) + + _, err = ParseTime("tomorrow") + assert.EqualError(t, err, + `invalid expiry "tomorrow": want never or an RFC3339 time such as 2026-12-31T23:59:59Z`) +} + +func TestParseMoney(t *testing.T) { + for _, value := range []string{"49.99", " $49.99 "} { + amount, err := ParseMoney("amount", value) + require.NoError(t, err) + assert.True(t, decimal.RequireFromString("49.99").Equal(amount)) + } + + _, err := ParseMoney("amount", "free") + assert.EqualError(t, err, `invalid amount "free": want an amount such as 100 or 49.99`) + + _, err = ParseMoney("amount", "-1") + assert.EqualError(t, err, `invalid amount "-1": want zero or more`) +}