diff --git a/.claude/skills/gen-api-cli/SKILL.md b/.claude/skills/gen-api-cli/SKILL.md index c70a8fd1a5a..28fbfd6fc4b 100644 --- a/.claude/skills/gen-api-cli/SKILL.md +++ b/.claude/skills/gen-api-cli/SKILL.md @@ -25,7 +25,7 @@ When the argument is `check`: 3. Report: - **Missing commands**: endpoints in the spec that have no CLI command - **Stale commands**: CLI commands whose descriptions no longer match the OpenAPI spec (compare verbatim, ignoring markdown stripping) - - **Flag mismatches**: request body fields in the spec that are missing as CLI flags, or CLI flags that no longer exist in the spec + - **Flag mismatches**: request body fields in the spec that are missing as CLI flags, CLI flags that no longer exist in the spec, leaves without exactly one explicit `--body` flag, or request-building flags without `cli.MutuallyExclusive("body")`. Do not require mutual exclusion on operational flags. - **Type mismatches**: flag types that don't match the OpenAPI property types (e.g., spec says integer but flag is string) 4. Output a summary with file paths and specific lines that need attention 5. Do NOT modify any files in this mode @@ -51,9 +51,15 @@ Parse all paths from the OpenAPI spec. They look like `/v2/{group}.{action}`. Group them by resource: - `apis` — endpoints matching `apis.*` +- `apps` — endpoints matching `apps.*` +- `deployments` — endpoints matching `deployments.*` +- `environments` — endpoints matching `environments.*` +- `gateway` — endpoints matching `gateway.*` - `keys` — endpoints matching `keys.*` - `identities` — endpoints matching `identities.*` - `permissions` — endpoints matching `permissions.*` +- `portal` — bearer-authenticated endpoints matching `portal.*`; exclude browser-session, cookie-authenticated, and unauthenticated operations +- `projects` — endpoints matching `projects.*` - `ratelimit` — endpoints matching `ratelimit.*` - `analytics` — endpoints matching `analytics.*` @@ -70,7 +76,7 @@ cmd/api/ │ ├── client.go # CreateClient, APIAction │ ├── output.go # Output │ ├── errors.go # FormatError -│ └── flags.go # RootKeyFlag, APIURLFlag, ConfigFlag, OutputFlag +│ └── flags.go # Shared flags ├── apis/ │ ├── root.go # Cmd (group command), registers leaf commands │ ├── create_api.go @@ -111,6 +117,8 @@ The variable name is unexported: `createAPICmd`, `verifyKeyCmd`, `listOverridesC After creating a new group subpackage, import it in `cmd/api/root.go` and add `{group}.Cmd` to the `Commands` slice. Only add entries that don't already exist. +Each leaf command explicitly declares one fresh `--body` flag. Group commands and `cmd/api.Cmd()` do not add or decorate body flags. Every request-building flag on the leaf must include `cli.MutuallyExclusive("body")`. Operational flags for authentication, API URL, configuration, and output must not include that option. + ### Disclaimer Every group `root.go` must append `util.Disclaimer` to its Description: @@ -184,13 +192,29 @@ Copy the entire OpenAPI path `description` field verbatim, with these adjustment Use the `Examples` field ([]string) on the Command struct. Each entry is one example invocation. These are rendered in a separate EXAMPLES section at the bottom of --help output. Always use `--flag=value` syntax (not `--flag value`) in examples for clarity. +Every example must pass all required flags and satisfy `RequireOneOf` or any +action-level source/update requirement. Use realistic IDs and slugs, and use +syntactically valid JSON that satisfies the referenced schema. Run examples +through command parsing in tests when practical. ### Flag descriptions Use a short, one-sentence summary of the OpenAPI property `description`. Since every command links to full docs, flag descriptions should be concise — just enough to know what the flag does. Do NOT copy the full multi-sentence OpenAPI description. +For ID-or-slug selectors, say so explicitly. For pagination, document the +cursor's source and the authoritative default or bounds from the schema. For +closed enums, list every valid choice. + +### Closed enums + +Validate closed enum-backed request-building flags before making an API call. +Build allowed values from generated SDK constants rather than duplicating +string literals. Use `cli.Enum` for scalar values. For comma-separated enum +lists, use `cli.StringSlice` with `cli.Validate` to validate every parsed value, +and include all choices in help. This validation does not apply to `--body`, +which continues to be decoded by the SDK. ## Flag mapping -Every leaf command MUST include these four flags first: +Every leaf command includes these operational flags: ```go util.RootKeyFlag(), util.APIURLFlag(), @@ -198,6 +222,8 @@ util.ConfigFlag(), util.OutputFlag(), ``` +Add one explicit `cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive.")` to the leaf. Keep flag order consistent with existing commands. + Then map OpenAPI request body properties to CLI flags: | OpenAPI type | SDK Go type | CLI flag | Read value | @@ -215,16 +241,22 @@ if cmd.FlagIsSet("enabled") { } ``` | `array of strings` | `[]string` | `cli.StringSlice("name", "desc")` | `cmd.StringSlice("name")` | -| `object` / `map` / nested | complex | `cli.String("name-json", "JSON: ...")` | `json.Unmarshal` | +| `object` / `map` / nested | complex | `cli.String("domain-name", "Domain value as a JSON object or array.")` | `json.Unmarshal` | -**JSON flags**: For complex types exposed as `--*-json` flags, keep the flag description focused on what the field does. Show the JSON shape in the command's `Examples` field instead, with realistic values. Every command that has JSON flags MUST include at least one example showing their usage. +**JSON flags**: Name complex flags after their domain field, such as `--meta`, `--credits`, or `--ratelimits`. Do not add a `-json` suffix. The CLI help description and product docs MUST explicitly state that the value is encoded as JSON, using wording such as "as JSON," "JSON object," or "JSON array." Show the JSON shape in the command's `Examples` field with realistic values. Every command that has JSON flags MUST include at least one example showing its usage. + +Keep cohesive domain objects together instead of expanding their nested fields +into separate flags. In particular, `gateway.updatePolicy` uses one `--policy` +JSON object for all mutable policy fields, while `gateway.setPolicies` uses one +`--policies` JSON array. Project, app, environment, and policy ID remain separate +selector flags. **Flag naming**: Convert camelCase property names to kebab-case: `apiId` → `api-id`, `externalId` → `external-id`. ## Action pattern Every leaf command uses a plain `func(ctx, cmd) error` action. No wrappers — each command -explicitly creates the client, times the call, formats errors, and prints output: +explicitly creates the client, formats errors, and prints output: ```go Action: func(ctx context.Context, cmd *cli.Command) error { @@ -233,11 +265,17 @@ Action: func(ctx context.Context, cmd *cli.Command) error { return err } - // Build request from flags - req := components.V2ApisCreateAPIRequestBody{ - Name: cmd.String("name"), // required: assign directly + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apis.CreateAPI, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ApisCreateAPIResponseBody) } + req := components.V2ApisCreateAPIRequestBody{Name: cmd.String("name")} + // Optional string: if v := cmd.String("prefix"); v != "" { req.Prefix = &v @@ -250,8 +288,8 @@ Action: func(ctx context.Context, cmd *cli.Command) error { // Optional bool — look up the default in the OpenAPI spec (check `default:` on the property). // Use cli.Bool with cli.Default and ptr.P from pkg/ptr: - req.Enabled = ptr.P(cmd.Bool("enabled")) // default: true in spec - req.Decrypt = ptr.P(cmd.Bool("decrypt")) // default: false in spec + req.Enabled = ptr.P(cmd.Bool("enabled")) // default: true in spec + req.Decrypt = ptr.P(cmd.Bool("decrypt")) // default: false in spec // For partial-update endpoints where omitting a bool means "don't change": if cmd.FlagIsSet("enabled") { @@ -264,25 +302,30 @@ Action: func(ctx context.Context, cmd *cli.Command) error { } // JSON field (complex object): - if v := cmd.String("meta-json"); v != "" { + if v := cmd.String("meta"); v != "" { var meta map[string]any if err := json.Unmarshal([]byte(v), &meta); err != nil { - return fmt.Errorf("invalid JSON for --meta-json: %w", err) + return fmt.Errorf("invalid JSON for --meta: %w", err) } req.Meta = meta } - // Call SDK and handle errors - start := time.Now() res, err := client.Apis.CreateAPI(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - - return util.Output(cmd, res.V2ApisCreateAPIResponseBody, time.Since(start)) + return util.Output(cmd, res.V2ApisCreateAPIResponseBody) }, ``` +Every leaf command explicitly declares `cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive.")`. Add `cli.MutuallyExclusive("body")` to every endpoint request-building flag, including optional pagination, filter, and boolean flags. Do not add it to `util.RootKeyFlag()`, `util.APIURLFlag()`, `util.ConfigFlag()`, or `util.OutputFlag()`. + +Explicitly supplied `--body` JSON is decoded and sent by `util.SendBody` using the endpoint's exact generated SDK request type. Branch on `cmd.FlagIsSet("body")` immediately after client creation and before normal request construction, JSON parsing, and action-level validation. Pass `cmd.String("body")` and the generated SDK method directly, for example `client.Apis.CreateAPI`. Do not wrap the method in a closure. An explicitly empty body activates body mode and is rejected as invalid JSON. Mutual exclusion waives required and `RequireOneOf` checks for request-building flags. `SendBody` applies standard SDK error formatting; request schema and business validation belong to the API. + +Authentication and transport/output flags remain active. Root-key and config bearer authentication still apply, while `--api-url` and `--output` retain their normal behavior. Do not generate browser-session or cookie-authenticated commands; this CLI supports bearer-authenticated API operations only. + +Generated union and slice request types are inferred from the direct SDK method value and are supported when they implement normal JSON unmarshalling. + ## Reference implementation See `cmd/api/apis/create_api.go` for a complete working example. Follow this pattern exactly for all new commands. @@ -335,13 +378,13 @@ like 'payment-service-prod' or 'user-api-dev' to clearly identify purpose and en Whether the key is active for verification. - + Arbitrary JSON metadata returned during key verification. For JSON flags, use an Expandable to show the schema: - + Credit and refill configuration. @@ -371,6 +414,7 @@ Include this exact section on every command doc: | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format — use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -382,7 +426,7 @@ unkey api apis create-api --name=payment-service-prod unkey api apis create-api --name=user-api-dev --output=json ``` ```bash With JSON flags -unkey api keys create-key --api-id=api_123 --meta-json='{"plan":"pro"}' +unkey api keys create-key --api-id=api_123 --meta='{"plan":"pro"}' ``` ``` @@ -430,7 +474,9 @@ unkey api keys create-key --api-id=api_123 --meta-json='{"plan":"pro"}' When running in `check` mode, also report: - **Missing docs**: CLI commands with no corresponding `.mdx` file in `docs/product/cli/` - **Missing from nav**: Doc files that exist but aren't registered in `docs.json` -- **Missing tests**: CLI command files with no corresponding `_test.go` file +- **Missing tests**: CLI command files with no corresponding `_test.go` file. Do not require per-leaf body-mode tests unless the command has structurally unusual request construction. +- **Body flag ownership**: A leaf command that does not explicitly define exactly one `--body` flag. +- **Body-mode gating**: A leaf that does not declare its own `--body`, mark every request-building flag with `cli.MutuallyExclusive("body")`, or branch on `cmd.FlagIsSet("body")` before ordinary request-flag parsing and validation. ## Step 6: Write tests @@ -488,6 +534,8 @@ For each command, write test cases covering: - **Boolean omission on update endpoints**: verify omitting `--enabled` does NOT send an `enabled` field (for partial-update commands using `FlagIsSet`) - **JSON flags**: verify complex objects unmarshal correctly into the expected nested structs +The root integration suite in `cmd/api/root_test.go` owns shared `--body` behavior, including one explicit fresh flag per leaf, typed unmarshalling and SDK re-serialization, malformed JSON rejection (including an explicitly empty body), mutual exclusion with request-building flags, required and `RequireOneOf` waiver, and bearer authentication. Keep unusual generated unions and custom action-level validation covered by tests proving the body branch runs before ordinary request construction and validation. + ### Types - Import request types from `github.com/unkeyed/unkey/svc/api/openapi` (e.g., `openapi.V2KeysCreateKeyRequestBody`) @@ -509,7 +557,8 @@ See `cmd/api/keys/create_key_test.go` and `cmd/api/keys/update_key_test.go` for ### Audit mode test check When running in `check` mode, also report: -- **Missing tests**: CLI command files with no corresponding `_test.go` file +- **Missing tests**: CLI command files with no corresponding `_test.go` file for request-building behavior +- **Missing unusual body-mode tests**: Structurally unusual commands whose typed body branch, mutual exclusion, or required-check waiver is not covered ## Step 7: Verify diff --git a/cmd/api/analytics/get_verifications.go b/cmd/api/analytics/get_verifications.go index b91ca47211a..0c37f6a0115 100644 --- a/cmd/api/analytics/get_verifications.go +++ b/cmd/api/analytics/get_verifications.go @@ -3,7 +3,6 @@ package analytics import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -23,11 +22,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/analytic `unkey api analytics get-verifications --query="SELECT key_id, outcome, COUNT(*) as cnt FROM key_verifications_v1 GROUP BY key_id, outcome"`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("query", "SQL SELECT query to run against analytics data.", cli.Required()), + cli.String("query", "SQL SELECT query to run against analytics data.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -35,14 +35,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/analytic return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Analytics.GetVerifications, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2AnalyticsGetVerificationsResponseBody) + } + res, err := client.Analytics.GetVerifications(ctx, components.V2AnalyticsGetVerificationsRequestBody{ Query: cmd.String("query"), }) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2AnalyticsGetVerificationsResponseBody, time.Since(start)) + return util.Output(cmd, res.V2AnalyticsGetVerificationsResponseBody) }, } } diff --git a/cmd/api/analytics/get_verifications_test.go b/cmd/api/analytics/get_verifications_test.go index 1a0743f12af..68d46c0c5ea 100644 --- a/cmd/api/analytics/get_verifications_test.go +++ b/cmd/api/analytics/get_verifications_test.go @@ -43,6 +43,11 @@ func captureRequest[T any](t *testing.T, cmd *cli.Command, args string) T { t.Fatalf("failed to create pipe: %v", err) } os.Stdout = w + t.Cleanup(func() { + os.Stdout = origStdout + _ = w.Close() + _ = r.Close() + }) fullArgs := fmt.Sprintf("unkey %s --api-url=%s --root-key=test_key", args, srv.URL) root := &cli.Command{ @@ -72,9 +77,6 @@ func captureRequest[T any](t *testing.T, cmd *cli.Command, args string) T { } func TestGetVerifications(t *testing.T) { - // Note: the shared test harness splits args with strings.Fields, so query - // values must not contain spaces. This is fine because we are testing - // flag-to-request mapping, not SQL validity. tests := []struct { name string args string diff --git a/cmd/api/apis/create_api.go b/cmd/api/apis/create_api.go index be37cb1360f..416714be138 100644 --- a/cmd/api/apis/create_api.go +++ b/cmd/api/apis/create_api.go @@ -3,7 +3,6 @@ package apis import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -29,11 +28,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/cre "unkey api apis create-api --name=user-api-dev", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("name", "The name for the new API namespace.", cli.Required()), + cli.String("name", "The name for the new API namespace.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -41,7 +41,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/cre return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apis.CreateAPI, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ApisCreateAPIResponseBody) + } + res, err := client.Apis.CreateAPI(ctx, components.V2ApisCreateAPIRequestBody{ Name: cmd.String("name"), }) @@ -49,7 +57,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/cre return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2ApisCreateAPIResponseBody, time.Since(start)) + return util.Output(cmd, res.V2ApisCreateAPIResponseBody) }, } } diff --git a/cmd/api/apis/delete_api.go b/cmd/api/apis/delete_api.go index b0993b0a1fb..49c11e4c711 100644 --- a/cmd/api/apis/delete_api.go +++ b/cmd/api/apis/delete_api.go @@ -3,7 +3,6 @@ package apis import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -31,11 +30,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/del "unkey api apis delete-api --api-id=api_1234abcd", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("api-id", "The API ID to delete.", cli.Required()), + cli.String("api-id", "The API ID to delete.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -43,7 +43,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/del return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apis.DeleteAPI, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ApisDeleteAPIResponseBody) + } + res, err := client.Apis.DeleteAPI(ctx, components.V2ApisDeleteAPIRequestBody{ APIID: cmd.String("api-id"), }) @@ -51,7 +59,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/del return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2ApisDeleteAPIResponseBody, time.Since(start)) + return util.Output(cmd, res.V2ApisDeleteAPIResponseBody) }, } } diff --git a/cmd/api/apis/get_api.go b/cmd/api/apis/get_api.go index 32e7a1deb7d..c00761796ae 100644 --- a/cmd/api/apis/get_api.go +++ b/cmd/api/apis/get_api.go @@ -3,7 +3,6 @@ package apis import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -27,11 +26,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/get "unkey api apis get-api --api-id=api_1234abcd", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("api-id", "The API ID to retrieve.", cli.Required()), + cli.String("api-id", "The API ID to retrieve.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -39,7 +39,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/get return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apis.GetAPI, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ApisGetAPIResponseBody) + } + res, err := client.Apis.GetAPI(ctx, components.V2ApisGetAPIRequestBody{ APIID: cmd.String("api-id"), }) @@ -47,7 +55,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/get return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2ApisGetAPIResponseBody, time.Since(start)) + return util.Output(cmd, res.V2ApisGetAPIResponseBody) }, } } diff --git a/cmd/api/apis/list_keys.go b/cmd/api/apis/list_keys.go index 1e777599c6c..24c8bb78ac6 100644 --- a/cmd/api/apis/list_keys.go +++ b/cmd/api/apis/list_keys.go @@ -3,7 +3,6 @@ package apis import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -38,16 +37,17 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/lis "unkey api apis list-keys --api-id=api_1234abcd --external-id=user_1234abcd", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("api-id", "The API ID whose keys to list.", cli.Required()), - cli.Int64("limit", "Maximum number of keys to return per page."), - cli.String("cursor", "Pagination cursor from a previous response."), - cli.String("external-id", "Filter keys by external ID."), - cli.Bool("decrypt", "Include the plaintext key value in the response.", cli.Default(false)), - cli.Bool("revalidate-keys-cache", "Bypass the cache and read keys directly from the database.", cli.Default(false)), + cli.String("api-id", "The API ID whose keys to list.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("limit", "Maximum number of keys to return per page.", cli.MutuallyExclusive("body")), + cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body")), + cli.String("external-id", "Filter keys by external ID.", cli.MutuallyExclusive("body")), + cli.Bool("decrypt", "Include the plaintext key value in the response.", cli.Default(false), cli.MutuallyExclusive("body")), + cli.Bool("revalidate-keys-cache", "Bypass the cache and read keys directly from the database.", cli.Default(false), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -55,6 +55,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/lis return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apis.ListKeys, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ApisListKeysResponseBody) + } + req := components.V2ApisListKeysRequestBody{ APIID: cmd.String("api-id"), Limit: nil, @@ -76,13 +85,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/apis/lis req.ExternalID = &v } - start := time.Now() res, err := client.Apis.ListKeys(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2ApisListKeysResponseBody, time.Since(start)) + return util.Output(cmd, res.V2ApisListKeysResponseBody) }, } } diff --git a/cmd/api/apps/create_app.go b/cmd/api/apps/create_app.go new file mode 100644 index 00000000000..985bf2fec23 --- /dev/null +++ b/cmd/api/apps/create_app.go @@ -0,0 +1,48 @@ +package apps + +import ( + "context" + "fmt" + + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func createAppCmd() *cli.Command { + return &cli.Command{Name: "create-app", Usage: "Create an app within a project.", Description: `Create an app within a project. The app is created with default production and preview environments. + +The slug you provide is the stable, caller-defined handle used to reference this app. It must be unique within the project. + +Important: The slug cannot collide with an existing app in the same project. A duplicate slug returns a 409 conflict. + +Required Permissions +- project.*.create_app (to create apps in any project) +- project..create_app (to create apps in a specific project) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/apps/create-app` + util.Disclaimer, Examples: []string{"unkey api apps create-app --project=payments --name='Payments API' --slug=payments-api"}, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), + util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), + cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("name", "Human-readable name for this app.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("slug", "Stable app slug.", cli.Required(), cli.MutuallyExclusive("body")), + }, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apps.CreateApp, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2AppsCreateAppResponseBody) + } + req := components.V2AppsCreateAppRequestBody{Project: cmd.String("project"), Name: cmd.String("name"), Slug: cmd.String("slug")} + res, err := client.Apps.CreateApp(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2AppsCreateAppResponseBody) + }} +} diff --git a/cmd/api/apps/create_app_test.go b/cmd/api/apps/create_app_test.go new file mode 100644 index 00000000000..b288c22a6d1 --- /dev/null +++ b/cmd/api/apps/create_app_test.go @@ -0,0 +1,20 @@ +package apps + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestCreateApp(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2AppsCreateAppRequestBody + }{{"all fields", "apps create-app --project=payments --name=Payments --slug=payments-api", openapi.V2AppsCreateAppRequestBody{Project: "payments", Name: "Payments", Slug: "payments-api"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequest[openapi.V2AppsCreateAppRequestBody](t, Cmd(), tt.args)) + }) + } +} diff --git a/cmd/api/apps/delete_app.go b/cmd/api/apps/delete_app.go new file mode 100644 index 00000000000..f7d4b9f04d2 --- /dev/null +++ b/cmd/api/apps/delete_app.go @@ -0,0 +1,43 @@ +package apps + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func deleteAppCmd() *cli.Command { + return &cli.Command{Name: "delete-app", Usage: "Delete an existing app, identified by its id.", Description: `Delete an existing app, identified by its id. + +Deletion is asynchronous and eventually consistent. The app and all of its associated resources (environments, deployments, custom domains) are torn down by a background workflow. A successful response indicates the deletion was enqueued, not that every resource has already been removed. + +Apps with delete protection enabled cannot be deleted until protection is disabled. + +Required Permissions +- app.*.delete_app (to delete any app) +- app..delete_app (to delete a specific app) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/apps/delete-app` + util.Disclaimer, Examples: []string{"unkey api apps delete-app --project=payments --app=app_1234abcd"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apps.DeleteApp, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2AppsDeleteAppResponseBody) + } + req := components.V2AppsDeleteAppRequestBody{Project: cmd.String("project"), App: cmd.String("app")} + res, err := client.Apps.DeleteApp(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2AppsDeleteAppResponseBody) + }} +} diff --git a/cmd/api/apps/delete_app_test.go b/cmd/api/apps/delete_app_test.go new file mode 100644 index 00000000000..f0da2a56b26 --- /dev/null +++ b/cmd/api/apps/delete_app_test.go @@ -0,0 +1,19 @@ +package apps + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestDeleteApp(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2AppsDeleteAppRequestBody + }{{"by id", "apps delete-app --project=payments --app=app_123", openapi.V2AppsDeleteAppRequestBody{Project: "payments", App: "app_123"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, captureAcceptedRequest[openapi.V2AppsDeleteAppRequestBody](t, Cmd(), tt.args)) + }) + } +} diff --git a/cmd/api/apps/get_app.go b/cmd/api/apps/get_app.go new file mode 100644 index 00000000000..50b7467d8b5 --- /dev/null +++ b/cmd/api/apps/get_app.go @@ -0,0 +1,41 @@ +package apps + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func getAppCmd() *cli.Command { + return &cli.Command{Name: "get-app", Usage: "Retrieve a single app by its id or slug within a project.", Description: `Retrieve a single app by its id or slug within a project. + +Use this to fetch app details after creation or to verify an app exists before performing operations. + +Required Permissions +- app.*.read_app (to read any app) +- app..read_app (to read a specific app) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/apps/get-app` + util.Disclaimer, Examples: []string{"unkey api apps get-app --project=payments --app=app_1234abcd"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apps.GetApp, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2AppsGetAppResponseBody) + } + req := components.V2AppsGetAppRequestBody{Project: cmd.String("project"), App: cmd.String("app")} + res, err := client.Apps.GetApp(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2AppsGetAppResponseBody) + }} +} diff --git a/cmd/api/apps/get_app_test.go b/cmd/api/apps/get_app_test.go new file mode 100644 index 00000000000..451ee8fbadf --- /dev/null +++ b/cmd/api/apps/get_app_test.go @@ -0,0 +1,20 @@ +package apps + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestGetApp(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2AppsGetAppRequestBody + }{{"by slug", "apps get-app --project=payments --app=payments-api", openapi.V2AppsGetAppRequestBody{Project: "payments", App: "payments-api"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequest[openapi.V2AppsGetAppRequestBody](t, Cmd(), tt.args)) + }) + } +} diff --git a/cmd/api/apps/list_apps.go b/cmd/api/apps/list_apps.go new file mode 100644 index 00000000000..58e39a3a28f --- /dev/null +++ b/cmd/api/apps/list_apps.go @@ -0,0 +1,47 @@ +package apps + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" +) + +func listAppsCmd() *cli.Command { + return &cli.Command{Name: "list-apps", Usage: "Retrieve a paginated list of apps within a project.", Description: `Retrieve a paginated list of apps within a project. + +Use this to enumerate every app in a project. Results are ordered by app id and paginated; when hasMore is true, pass the returned cursor to fetch the next page. + +Required Permissions +- app.*.read_app (to read apps in any project) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/apps/list-apps` + util.Disclaimer, Examples: []string{"unkey api apps list-apps --project=payments", "unkey api apps list-apps --project=payments --limit=25 --search=checkout"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.Int64("limit", "Maximum number of apps to return per request.", cli.Default(int64(100)), cli.MutuallyExclusive("body")), cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body")), cli.String("search", "Free-form text to filter apps.", cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apps.ListApps, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2AppsListAppsResponseBody) + } + req := components.V2AppsListAppsRequestBody{Project: cmd.String("project"), Limit: ptr.P(cmd.Int64("limit")), Cursor: nil, Search: nil} + if v := cmd.String("cursor"); v != "" { + req.Cursor = &v + } + if v := cmd.String("search"); v != "" { + req.Search = &v + } + res, err := client.Apps.ListApps(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2AppsListAppsResponseBody) + }} +} diff --git a/cmd/api/apps/list_apps_test.go b/cmd/api/apps/list_apps_test.go new file mode 100644 index 00000000000..00e71dbf353 --- /dev/null +++ b/cmd/api/apps/list_apps_test.go @@ -0,0 +1,21 @@ +package apps + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/ptr" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestListApps(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2AppsListAppsRequestBody + }{{"defaults", "apps list-apps --project=payments", openapi.V2AppsListAppsRequestBody{Project: "payments", Limit: ptr.P(100)}}, {"all options", "apps list-apps --project=payments --limit=25 --cursor=app_1 --search=checkout", openapi.V2AppsListAppsRequestBody{Project: "payments", Limit: ptr.P(25), Cursor: ptr.P("app_1"), Search: ptr.P("checkout")}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequestWithResponse[openapi.V2AppsListAppsRequestBody](t, Cmd(), tt.args, `{"meta":{"requestId":"test"},"data":[],"pagination":{"hasMore":false}}`)) + }) + } +} diff --git a/cmd/api/apps/root.go b/cmd/api/apps/root.go new file mode 100644 index 00000000000..748bf156156 --- /dev/null +++ b/cmd/api/apps/root.go @@ -0,0 +1,13 @@ +package apps + +import ( + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +// Cmd groups all apps.* subcommands. +func Cmd() *cli.Command { + return &cli.Command{Name: "apps", Usage: "Manage apps", Description: "Create, read, update, and delete apps within projects." + util.Disclaimer, Commands: []*cli.Command{ + createAppCmd(), deleteAppCmd(), getAppCmd(), listAppsCmd(), updateAppCmd(), + }} +} diff --git a/cmd/api/apps/test_helpers_test.go b/cmd/api/apps/test_helpers_test.go new file mode 100644 index 00000000000..31fd92d0095 --- /dev/null +++ b/cmd/api/apps/test_helpers_test.go @@ -0,0 +1,59 @@ +package apps + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/unkeyed/unkey/pkg/cli" +) + +func captureAcceptedRequest[T any](t *testing.T, cmd *cli.Command, args string) T { + t.Helper() + var body []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + var err error + body, err = io.ReadAll(request.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"meta":{"requestId":"test"},"data":{}}`)) + })) + t.Cleanup(server.Close) + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + t.Cleanup(func() { + os.Stdout = original + _ = writer.Close() + _ = reader.Close() + }) + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{cmd}} + runErr := root.Run(context.Background(), strings.Fields(fmt.Sprintf("unkey %s --api-url=%s --root-key=test", args, server.URL))) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + os.Stdout = original + _, _ = io.Copy(&bytes.Buffer{}, reader) + if runErr != nil { + t.Fatalf("CLI command failed: %v", runErr) + } + var request T + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + return request +} diff --git a/cmd/api/apps/update_app.go b/cmd/api/apps/update_app.go new file mode 100644 index 00000000000..b1d23b17c39 --- /dev/null +++ b/cmd/api/apps/update_app.go @@ -0,0 +1,56 @@ +package apps + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" +) + +func updateAppCmd() *cli.Command { + return &cli.Command{Name: "update-app", Usage: "Update an existing app, identified by its id.", Description: `Update an existing app, identified by its id. + +The app name, slug, default branch, and delete protection setting can be changed. Omitted fields are left unchanged. Changing the slug affects the deployment domains generated for this app. + +Important: The slug cannot collide with an existing app in the same project. A duplicate slug returns a 409 conflict. + +Required Permissions +- app.*.update_app (to update any app) +- app..update_app (to update a specific app) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/apps/update-app` + util.Disclaimer, Examples: []string{"unkey api apps update-app --project=payments --app=app_1234abcd --name='Payments API'", "unkey api apps update-app --project=payments --app=payments-api --delete-protection=true"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("name", "New human-readable name for the app.", cli.MutuallyExclusive("body")), cli.String("slug", "New app slug.", cli.MutuallyExclusive("body")), cli.String("default-branch", "New default git branch deployments track.", cli.MutuallyExclusive("body")), cli.Bool("delete-protection", "Enable or disable delete protection for the app.", cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Apps.UpdateApp, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2AppsUpdateAppResponseBody) + } + req := components.V2AppsUpdateAppRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Name: nil, Slug: nil, DefaultBranch: nil, DeleteProtection: nil} + if v := cmd.String("name"); v != "" { + req.Name = &v + } + if v := cmd.String("slug"); v != "" { + req.Slug = &v + } + if v := cmd.String("default-branch"); v != "" { + req.DefaultBranch = &v + } + if cmd.FlagIsSet("delete-protection") { + req.DeleteProtection = ptr.P(cmd.Bool("delete-protection")) + } + res, err := client.Apps.UpdateApp(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2AppsUpdateAppResponseBody) + }} +} diff --git a/cmd/api/apps/update_app_test.go b/cmd/api/apps/update_app_test.go new file mode 100644 index 00000000000..75df021cf3e --- /dev/null +++ b/cmd/api/apps/update_app_test.go @@ -0,0 +1,21 @@ +package apps + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/ptr" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestUpdateApp(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2AppsUpdateAppRequestBody + }{{"omits optional fields", "apps update-app --project=payments --app=app_1", openapi.V2AppsUpdateAppRequestBody{Project: "payments", App: "app_1"}}, {"all options including false boolean", "apps update-app --project=payments --app=app_1 --name=Pay --slug=pay --default-branch=trunk --delete-protection=false", openapi.V2AppsUpdateAppRequestBody{Project: "payments", App: "app_1", Name: ptr.P("Pay"), Slug: ptr.P("pay"), DefaultBranch: ptr.P("trunk"), DeleteProtection: ptr.P(false)}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequest[openapi.V2AppsUpdateAppRequestBody](t, Cmd(), tt.args)) + }) + } +} diff --git a/cmd/api/deployments/create_deployment.go b/cmd/api/deployments/create_deployment.go new file mode 100644 index 00000000000..cf5d61bb4d4 --- /dev/null +++ b/cmd/api/deployments/create_deployment.go @@ -0,0 +1,82 @@ +package deployments + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func createDeploymentCmd() *cli.Command { + return &cli.Command{ + Name: "create-deployment", Usage: "Create a deployment from Git, an image, or an existing deployment", + Description: "Create a deployment asynchronously from exactly one source: a Git branch, a container image, or an existing deployment. The response includes a deployment ID that you can pass to get-deployment to monitor progress.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/deployments/create-deployment" + util.Disclaimer, + Examples: []string{"unkey api deployments create-deployment --project=payments --app=payments-api --environment=production --git='{" + `"branch":"main"` + "}'"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("git", "Git source as JSON.", cli.MutuallyExclusive("body")), cli.String("image", "Image source as JSON.", cli.MutuallyExclusive("body")), cli.String("deployment", "Existing deployment source as JSON.", cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Deployments.CreateDeployment, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2DeploymentsCreateDeploymentResponseBody) + } + send := func(req components.V2DeploymentsCreateDeploymentRequestBodyUnion) error { + res, err := client.Deployments.CreateDeployment(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2DeploymentsCreateDeploymentResponseBody) + } + project, app, environment := cmd.String("project"), cmd.String("app"), cmd.String("environment") + var req components.V2DeploymentsCreateDeploymentRequestBodyUnion + sources := 0 + if raw := cmd.String("git"); raw != "" { + var source *components.DeploymentSourceGit + if err := json.Unmarshal([]byte(raw), &source); err != nil { + return fmt.Errorf("invalid JSON for --git: %w", err) + } + if source == nil { + return fmt.Errorf("--git must be a JSON object, not null") + } + req = components.CreateV2DeploymentsCreateDeploymentRequestBodyUnionV2DeploymentsCreateDeploymentRequestBody2(components.V2DeploymentsCreateDeploymentRequestBody2{Project: project, App: app, Environment: environment, Git: *source, Image: nil, Deployment: nil}) + sources++ + } + if raw := cmd.String("image"); raw != "" { + var source *components.DeploymentSourceImage + if err := json.Unmarshal([]byte(raw), &source); err != nil { + return fmt.Errorf("invalid JSON for --image: %w", err) + } + if source == nil { + return fmt.Errorf("--image must be a JSON object, not null") + } + req = components.CreateV2DeploymentsCreateDeploymentRequestBodyUnionV2DeploymentsCreateDeploymentRequestBody1(components.V2DeploymentsCreateDeploymentRequestBody1{Project: project, App: app, Environment: environment, Git: nil, Image: *source, Deployment: nil}) + sources++ + } + if raw := cmd.String("deployment"); raw != "" { + var source *components.DeploymentSourceDeployment + if err := json.Unmarshal([]byte(raw), &source); err != nil { + return fmt.Errorf("invalid JSON for --deployment: %w", err) + } + if source == nil { + return fmt.Errorf("--deployment must be a JSON object, not null") + } + req = components.CreateV2DeploymentsCreateDeploymentRequestBodyUnionV2DeploymentsCreateDeploymentRequestBody3(components.V2DeploymentsCreateDeploymentRequestBody3{Project: project, App: app, Environment: environment, Git: nil, Image: nil, Deployment: *source}) + sources++ + } + if sources != 1 { + return fmt.Errorf("exactly one of --git, --image, or --deployment is required") + } + return send(req) + }, + } +} diff --git a/cmd/api/deployments/create_deployment_test.go b/cmd/api/deployments/create_deployment_test.go new file mode 100644 index 00000000000..29424c30e5c --- /dev/null +++ b/cmd/api/deployments/create_deployment_test.go @@ -0,0 +1,51 @@ +package deployments + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/svc/api/openapi" +) + +func TestCreateDeployment(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2DeploymentsCreateDeploymentRequestBody + }{{"git", `deployments create-deployment --project=p --app=a --environment=e --git={"branch":"main"}`, openapi.V2DeploymentsCreateDeploymentRequestBody{Project: "p", App: "a", Environment: "e", Git: &openapi.DeploymentSourceGit{Branch: func() *string { v := "main"; return &v }()}}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureStatus[openapi.V2DeploymentsCreateDeploymentRequestBody](t, Cmd(), tt.args, http.StatusCreated) + require.Equal(t, tt.want.Project, got.Project) + require.Equal(t, tt.want.App, got.App) + require.Equal(t, tt.want.Environment, got.Environment) + require.Equal(t, tt.want.Git, got.Git) + require.Nil(t, got.Image) + require.Nil(t, got.Deployment) + }) + } +} + +func TestCreateDeploymentRejectsInvalidSources(t *testing.T) { + tests := []struct { + name string + args string + want string + }{ + {name: "missing source", args: "", want: "exactly one of"}, + {name: "null source", args: " --git=null", want: "must be a JSON object"}, + {name: "multiple sources", args: ` --git={} --image={"dockerImage":"ghcr.io/unkeyed/app:latest"}`, want: "exactly one of"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := "unkey deployments create-deployment --project=p --app=a --environment=e --root-key=test" + tt.args + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), strings.Fields(args)) + require.ErrorContains(t, err, tt.want) + }) + } +} diff --git a/cmd/api/deployments/get_deployment.go b/cmd/api/deployments/get_deployment.go new file mode 100644 index 00000000000..203bd9aa501 --- /dev/null +++ b/cmd/api/deployments/get_deployment.go @@ -0,0 +1,38 @@ +package deployments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func getDeploymentCmd() *cli.Command { + return &cli.Command{ + Name: "get-deployment", Usage: "Retrieve a deployment by ID", Description: "Retrieve deployment details, lifecycle status, build steps, and runtime information by deployment ID.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/deployments/get-deployment" + util.Disclaimer, + Examples: []string{"unkey api deployments get-deployment --deployment-id=dep_1234abcd"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("deployment-id", "Unique deployment ID.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Deployments.GetDeployment, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2DeploymentsGetDeploymentResponseBody) + } + req := components.V2DeploymentsGetDeploymentRequestBody{DeploymentID: cmd.String("deployment-id")} + res, err := client.Deployments.GetDeployment(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2DeploymentsGetDeploymentResponseBody) + }, + } +} diff --git a/cmd/api/deployments/get_deployment_test.go b/cmd/api/deployments/get_deployment_test.go new file mode 100644 index 00000000000..c7ba32b7914 --- /dev/null +++ b/cmd/api/deployments/get_deployment_test.go @@ -0,0 +1,21 @@ +package deployments + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestGetDeployment(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2DeploymentsGetDeploymentRequestBody + }{{"request", "deployments get-deployment --deployment-id=x", openapi.V2DeploymentsGetDeploymentRequestBody{DeploymentId: "x"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequest[openapi.V2DeploymentsGetDeploymentRequestBody](t, Cmd(), tt.args) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/deployments/list_deployments.go b/cmd/api/deployments/list_deployments.go new file mode 100644 index 00000000000..5d7a13208c3 --- /dev/null +++ b/cmd/api/deployments/list_deployments.go @@ -0,0 +1,81 @@ +package deployments + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +var deploymentStatuses = []string{ + string(components.DeploymentStatusPending), string(components.DeploymentStatusStarting), + string(components.DeploymentStatusBuilding), string(components.DeploymentStatusDeploying), + string(components.DeploymentStatusNetwork), string(components.DeploymentStatusFinalizing), + string(components.DeploymentStatusReady), string(components.DeploymentStatusFailed), + string(components.DeploymentStatusSkipped), string(components.DeploymentStatusAwaitingApproval), + string(components.DeploymentStatusStopped), string(components.DeploymentStatusSuperseded), + string(components.DeploymentStatusCancelled), +} + +func validateDeploymentStatuses(value string) error { + for _, status := range strings.Split(value, ",") { + status = strings.TrimSpace(status) + if status != "" && !slices.Contains(deploymentStatuses, status) { + return fmt.Errorf("invalid status %q; valid choices: %s", status, strings.Join(deploymentStatuses, ", ")) + } + } + return nil +} + +func listDeploymentsCmd() *cli.Command { + return &cli.Command{ + Name: "list-deployments", Usage: "List deployments with optional resource and status filters", Description: "List deployments in reverse chronological order. Filter by project, app, environment, or one or more lifecycle statuses. Use the cursor returned by a response to fetch the next page.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/deployments/list-deployments" + util.Disclaimer, + Examples: []string{"unkey api deployments list-deployments --project=payments --app=payments-api --environment=production", "unkey api deployments list-deployments --status=ready,failed --limit=25"}, + Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug to filter by.", cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug to filter by.", cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug to filter by.", cli.MutuallyExclusive("body")), + cli.StringSlice("status", "Lifecycle statuses to include. Valid choices: "+strings.Join(deploymentStatuses, ", ")+".", cli.Validate(validateDeploymentStatuses), cli.MutuallyExclusive("body")), cli.Int64("limit", "Maximum deployments to return per page (default 100).", cli.MutuallyExclusive("body")), cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Deployments.ListDeployments, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2DeploymentsListDeploymentsResponseBody) + } + req := components.V2DeploymentsListDeploymentsRequestBody{Project: nil, App: nil, Environment: nil, Status: nil, Limit: nil, Cursor: nil} + if v := cmd.String("project"); v != "" { + req.Project = &v + } + if v := cmd.String("app"); v != "" { + req.App = &v + } + if v := cmd.String("environment"); v != "" { + req.Environment = &v + } + for _, v := range cmd.StringSlice("status") { + req.Status = append(req.Status, components.DeploymentStatus(v)) + } + if v := cmd.Int64("limit"); v != 0 { + req.Limit = &v + } + if v := cmd.String("cursor"); v != "" { + req.Cursor = &v + } + res, err := client.Deployments.ListDeployments(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2DeploymentsListDeploymentsResponseBody) + }, + } +} diff --git a/cmd/api/deployments/list_deployments_test.go b/cmd/api/deployments/list_deployments_test.go new file mode 100644 index 00000000000..2fa38233fcd --- /dev/null +++ b/cmd/api/deployments/list_deployments_test.go @@ -0,0 +1,35 @@ +package deployments + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" + "github.com/unkeyed/unkey/svc/api/openapi" +) + +func TestListDeployments(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2DeploymentsListDeploymentsRequestBody + }{ + {"request", "deployments list-deployments ", openapi.V2DeploymentsListDeploymentsRequestBody{Project: nil, App: nil, Environment: nil, Status: nil, Limit: ptr.P(100), Cursor: nil}}, + {"valid statuses", "deployments list-deployments --status=ready,failed", openapi.V2DeploymentsListDeploymentsRequestBody{Project: nil, App: nil, Environment: nil, Status: ptr.P([]openapi.DeploymentStatus{openapi.DeploymentStatusReady, openapi.DeploymentStatusFailed}), Limit: ptr.P(100), Cursor: nil}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequestWithResponse[openapi.V2DeploymentsListDeploymentsRequestBody](t, Cmd(), tt.args, `{"meta":{"requestId":"test"},"data":[],"pagination":{"hasMore":false}}`) + require.Equal(t, tt.want, got) + }) + } +} + +func TestListDeploymentsStatusValidation(t *testing.T) { + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), strings.Fields("unkey deployments list-deployments --status=ready,not-a-status --root-key=test")) + require.ErrorContains(t, err, `invalid status "not-a-status"`) +} diff --git a/cmd/api/deployments/promote_deployment.go b/cmd/api/deployments/promote_deployment.go new file mode 100644 index 00000000000..6516f2c72aa --- /dev/null +++ b/cmd/api/deployments/promote_deployment.go @@ -0,0 +1,38 @@ +package deployments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func promoteDeploymentCmd() *cli.Command { + return &cli.Command{ + Name: "promote-deployment", Usage: "Promote a deployment to production", Description: "Promote an existing deployment to its app's production environment. The promotion runs asynchronously.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/deployments/promote-deployment" + util.Disclaimer, + Examples: []string{"unkey api deployments promote-deployment --deployment-id=dep_1234abcd"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("deployment-id", "Unique deployment ID to promote.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Deployments.PromoteDeployment, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2DeploymentsPromoteDeploymentResponseBody) + } + req := components.V2DeploymentsPromoteDeploymentRequestBody{DeploymentID: cmd.String("deployment-id")} + res, err := client.Deployments.PromoteDeployment(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2DeploymentsPromoteDeploymentResponseBody) + }, + } +} diff --git a/cmd/api/deployments/promote_deployment_test.go b/cmd/api/deployments/promote_deployment_test.go new file mode 100644 index 00000000000..11e06c4723d --- /dev/null +++ b/cmd/api/deployments/promote_deployment_test.go @@ -0,0 +1,21 @@ +package deployments + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/svc/api/openapi" + "net/http" + "testing" +) + +func TestPromoteDeployment(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2DeploymentsPromoteDeploymentRequestBody + }{{"request", "deployments promote-deployment --deployment-id=x", openapi.V2DeploymentsPromoteDeploymentRequestBody{DeploymentId: "x"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureStatus[openapi.V2DeploymentsPromoteDeploymentRequestBody](t, Cmd(), tt.args, http.StatusAccepted) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/deployments/rollback_deployment.go b/cmd/api/deployments/rollback_deployment.go new file mode 100644 index 00000000000..71a3c827370 --- /dev/null +++ b/cmd/api/deployments/rollback_deployment.go @@ -0,0 +1,38 @@ +package deployments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func rollbackDeploymentCmd() *cli.Command { + return &cli.Command{ + Name: "rollback-deployment", Usage: "Roll back to an earlier deployment", Description: "Create an asynchronous rollback using the selected deployment as the source.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/deployments/rollback-deployment" + util.Disclaimer, + Examples: []string{"unkey api deployments rollback-deployment --deployment-id=dep_1234abcd"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("deployment-id", "Unique deployment ID to restore.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Deployments.RollbackDeployment, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2DeploymentsRollbackDeploymentResponseBody) + } + req := components.V2DeploymentsRollbackDeploymentRequestBody{DeploymentID: cmd.String("deployment-id")} + res, err := client.Deployments.RollbackDeployment(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2DeploymentsRollbackDeploymentResponseBody) + }, + } +} diff --git a/cmd/api/deployments/rollback_deployment_test.go b/cmd/api/deployments/rollback_deployment_test.go new file mode 100644 index 00000000000..e3cb0078ad9 --- /dev/null +++ b/cmd/api/deployments/rollback_deployment_test.go @@ -0,0 +1,21 @@ +package deployments + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/svc/api/openapi" + "net/http" + "testing" +) + +func TestRollbackDeployment(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2DeploymentsRollbackDeploymentRequestBody + }{{"request", "deployments rollback-deployment --deployment-id=x", openapi.V2DeploymentsRollbackDeploymentRequestBody{DeploymentId: "x"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureStatus[openapi.V2DeploymentsRollbackDeploymentRequestBody](t, Cmd(), tt.args, http.StatusAccepted) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/deployments/root.go b/cmd/api/deployments/root.go new file mode 100644 index 00000000000..4c1d288cc98 --- /dev/null +++ b/cmd/api/deployments/root.go @@ -0,0 +1,19 @@ +package deployments + +import ( + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +// Cmd returns the deployments group command. +func Cmd() *cli.Command { + return &cli.Command{Name: "deployments", Usage: "Manage deployments", Description: "Manage Unkey deployments." + util.Disclaimer, Commands: []*cli.Command{ + createDeploymentCmd(), + getDeploymentCmd(), + listDeploymentsCmd(), + promoteDeploymentCmd(), + rollbackDeploymentCmd(), + startDeploymentCmd(), + stopDeploymentCmd(), + }} +} diff --git a/cmd/api/deployments/start_deployment.go b/cmd/api/deployments/start_deployment.go new file mode 100644 index 00000000000..ddf4ba26ea8 --- /dev/null +++ b/cmd/api/deployments/start_deployment.go @@ -0,0 +1,38 @@ +package deployments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func startDeploymentCmd() *cli.Command { + return &cli.Command{ + Name: "start-deployment", Usage: "Start a stopped deployment", Description: "Request that a stopped deployment start serving again. The operation runs asynchronously.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/deployments/start-deployment" + util.Disclaimer, + Examples: []string{"unkey api deployments start-deployment --deployment-id=dep_1234abcd"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("deployment-id", "Unique deployment ID to start.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Deployments.StartDeployment, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2DeploymentsStartDeploymentResponseBody) + } + req := components.V2DeploymentsStartDeploymentRequestBody{DeploymentID: cmd.String("deployment-id")} + res, err := client.Deployments.StartDeployment(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2DeploymentsStartDeploymentResponseBody) + }, + } +} diff --git a/cmd/api/deployments/start_deployment_test.go b/cmd/api/deployments/start_deployment_test.go new file mode 100644 index 00000000000..a50f1e4969e --- /dev/null +++ b/cmd/api/deployments/start_deployment_test.go @@ -0,0 +1,21 @@ +package deployments + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/svc/api/openapi" + "net/http" + "testing" +) + +func TestStartDeployment(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2DeploymentsStartDeploymentRequestBody + }{{"request", "deployments start-deployment --deployment-id=x", openapi.V2DeploymentsStartDeploymentRequestBody{DeploymentId: "x"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureStatus[openapi.V2DeploymentsStartDeploymentRequestBody](t, Cmd(), tt.args, http.StatusAccepted) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/deployments/stop_deployment.go b/cmd/api/deployments/stop_deployment.go new file mode 100644 index 00000000000..ca31c9e1b3b --- /dev/null +++ b/cmd/api/deployments/stop_deployment.go @@ -0,0 +1,38 @@ +package deployments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func stopDeploymentCmd() *cli.Command { + return &cli.Command{ + Name: "stop-deployment", Usage: "Stop a running deployment", Description: "Request that a deployment stop serving and release its running compute resources. The operation runs asynchronously.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/deployments/stop-deployment" + util.Disclaimer, + Examples: []string{"unkey api deployments stop-deployment --deployment-id=dep_1234abcd"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("deployment-id", "Unique deployment ID to stop.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Deployments.StopDeployment, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2DeploymentsStopDeploymentResponseBody) + } + req := components.V2DeploymentsStopDeploymentRequestBody{DeploymentID: cmd.String("deployment-id")} + res, err := client.Deployments.StopDeployment(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2DeploymentsStopDeploymentResponseBody) + }, + } +} diff --git a/cmd/api/deployments/stop_deployment_test.go b/cmd/api/deployments/stop_deployment_test.go new file mode 100644 index 00000000000..aabf0fb3316 --- /dev/null +++ b/cmd/api/deployments/stop_deployment_test.go @@ -0,0 +1,59 @@ +package deployments + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/svc/api/openapi" +) + +func captureStatus[T any](t *testing.T, cmd *cli.Command, args string, status int) T { + t.Helper() + var body []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + body, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, err = w.Write([]byte(`{"meta":{"requestId":"test"},"data":{}}`)) + require.NoError(t, err) + })) + t.Cleanup(srv.Close) + stdout := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stdout = writer + t.Cleanup(func() { os.Stdout = stdout; _ = reader.Close() }) + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{cmd}} + err = root.Run(context.Background(), strings.Fields(fmt.Sprintf("unkey %s --api-url=%s --root-key=test", args, srv.URL))) + require.NoError(t, err) + require.NoError(t, writer.Close()) + _, _ = io.Copy(io.Discard, bytes.NewBuffer(nil)) + var got T + require.NoError(t, json.Unmarshal(body, &got)) + return got +} + +func TestStopDeployment(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2DeploymentsStopDeploymentRequestBody + }{{"request", "deployments stop-deployment --deployment-id=x", openapi.V2DeploymentsStopDeploymentRequestBody{DeploymentId: "x"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureStatus[openapi.V2DeploymentsStopDeploymentRequestBody](t, Cmd(), tt.args, http.StatusAccepted) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/environments/get_environment.go b/cmd/api/environments/get_environment.go new file mode 100644 index 00000000000..c3b918cadc7 --- /dev/null +++ b/cmd/api/environments/get_environment.go @@ -0,0 +1,38 @@ +package environments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func getEnvironmentCmd() *cli.Command { + return &cli.Command{ + Name: "get-environment", Usage: "Retrieve an environment by ID or slug", Description: "Retrieve an environment and its build and runtime settings within an app. Project, app, and environment selectors accept IDs or slugs.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/environments/get-environment" + util.Disclaimer, + Examples: []string{"unkey api environments get-environment --project=payments --app=payments-api --environment=production"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Environments.GetEnvironment, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2EnvironmentsGetEnvironmentResponseBody) + } + req := components.V2EnvironmentsGetEnvironmentRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Environment: cmd.String("environment")} + res, err := client.Environments.GetEnvironment(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2EnvironmentsGetEnvironmentResponseBody) + }, + } +} diff --git a/cmd/api/environments/get_environment_test.go b/cmd/api/environments/get_environment_test.go new file mode 100644 index 00000000000..37a906ef9ca --- /dev/null +++ b/cmd/api/environments/get_environment_test.go @@ -0,0 +1,21 @@ +package environments + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestGetEnvironment(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2EnvironmentsGetEnvironmentRequestBody + }{{"request", "environments get-environment --project=x --app=x --environment=x", openapi.V2EnvironmentsGetEnvironmentRequestBody{Project: "x", App: "x", Environment: "x"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequest[openapi.V2EnvironmentsGetEnvironmentRequestBody](t, Cmd(), tt.args) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/environments/list_environment_variables.go b/cmd/api/environments/list_environment_variables.go new file mode 100644 index 00000000000..33e4a48ed16 --- /dev/null +++ b/cmd/api/environments/list_environment_variables.go @@ -0,0 +1,44 @@ +package environments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func listEnvironmentVariablesCmd() *cli.Command { + return &cli.Command{ + Name: "list-environment-variables", Usage: "List an environment's variables", Description: "List environment variables in pages of up to 100 entries by default. Use the cursor returned by a response to fetch the next page.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/environments/list-environment-variables" + util.Disclaimer, + Examples: []string{"unkey api environments list-environment-variables --project=payments --app=payments-api --environment=production", "unkey api environments list-environment-variables --project=payments --app=payments-api --environment=production --limit=25"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.Int64("limit", "Maximum variables to return per page (default 100).", cli.MutuallyExclusive("body")), cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Environments.ListEnvironmentVariables, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2EnvironmentsListEnvironmentVariablesResponseBody) + } + req := components.V2EnvironmentsListEnvironmentVariablesRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Environment: cmd.String("environment"), Limit: nil, Cursor: nil} + if v := cmd.Int64("limit"); v != 0 { + req.Limit = &v + } + if v := cmd.String("cursor"); v != "" { + req.Cursor = &v + } + res, err := client.Environments.ListEnvironmentVariables(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2EnvironmentsListEnvironmentVariablesResponseBody) + }, + } +} diff --git a/cmd/api/environments/list_environment_variables_test.go b/cmd/api/environments/list_environment_variables_test.go new file mode 100644 index 00000000000..3e58f2116c8 --- /dev/null +++ b/cmd/api/environments/list_environment_variables_test.go @@ -0,0 +1,22 @@ +package environments + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/ptr" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestListEnvironmentVariables(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2EnvironmentsListEnvironmentVariablesRequestBody + }{{"request", "environments list-environment-variables --project=x --app=x --environment=x", openapi.V2EnvironmentsListEnvironmentVariablesRequestBody{Project: "x", App: "x", Environment: "x", Limit: ptr.P(100), Cursor: nil}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequestWithResponse[openapi.V2EnvironmentsListEnvironmentVariablesRequestBody](t, Cmd(), tt.args, `{"meta":{"requestId":"test"},"data":[],"pagination":{"hasMore":false}}`) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/environments/list_environments.go b/cmd/api/environments/list_environments.go new file mode 100644 index 00000000000..161264bd699 --- /dev/null +++ b/cmd/api/environments/list_environments.go @@ -0,0 +1,38 @@ +package environments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func listEnvironmentsCmd() *cli.Command { + return &cli.Command{ + Name: "list-environments", Usage: "List environments for an app", Description: "List the production and preview environments configured for an app. Project and app selectors accept IDs or slugs.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/environments/list-environments" + util.Disclaimer, + Examples: []string{"unkey api environments list-environments --project=payments --app=payments-api"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Environments.ListEnvironments, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2EnvironmentsListEnvironmentsResponseBody) + } + req := components.V2EnvironmentsListEnvironmentsRequestBody{Project: cmd.String("project"), App: cmd.String("app")} + res, err := client.Environments.ListEnvironments(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2EnvironmentsListEnvironmentsResponseBody) + }, + } +} diff --git a/cmd/api/environments/list_environments_test.go b/cmd/api/environments/list_environments_test.go new file mode 100644 index 00000000000..24b2e7fd012 --- /dev/null +++ b/cmd/api/environments/list_environments_test.go @@ -0,0 +1,21 @@ +package environments + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestListEnvironments(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2EnvironmentsListEnvironmentsRequestBody + }{{"request", "environments list-environments --project=x --app=x", openapi.V2EnvironmentsListEnvironmentsRequestBody{Project: "x", App: "x"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequestWithResponse[openapi.V2EnvironmentsListEnvironmentsRequestBody](t, Cmd(), tt.args, `{"meta":{"requestId":"test"},"data":[]}`) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/environments/remove_environment_variables.go b/cmd/api/environments/remove_environment_variables.go new file mode 100644 index 00000000000..6b118ad8733 --- /dev/null +++ b/cmd/api/environments/remove_environment_variables.go @@ -0,0 +1,39 @@ +package environments + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func removeEnvironmentVariablesCmd() *cli.Command { + return &cli.Command{ + Name: "remove-environment-variables", Usage: "Remove variables from an environment", Description: "Remove the named environment variables from an environment in one request. Variables not named by the command remain unchanged.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/environments/remove-environment-variables" + util.Disclaimer, + Examples: []string{"unkey api environments remove-environment-variables --project=payments --app=payments-api --environment=production --variables=OLD_TOKEN,LEGACY_URL"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.StringSlice("variables", "Comma-separated variable names to remove.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Environments.RemoveEnvironmentVariables, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2EnvironmentsRemoveEnvironmentVariablesResponseBody) + } + req := components.V2EnvironmentsRemoveEnvironmentVariablesRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Environment: cmd.String("environment"), Variables: nil} + req.Variables = cmd.StringSlice("variables") + res, err := client.Environments.RemoveEnvironmentVariables(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2EnvironmentsRemoveEnvironmentVariablesResponseBody) + }, + } +} diff --git a/cmd/api/environments/remove_environment_variables_test.go b/cmd/api/environments/remove_environment_variables_test.go new file mode 100644 index 00000000000..87bcb645c5d --- /dev/null +++ b/cmd/api/environments/remove_environment_variables_test.go @@ -0,0 +1,31 @@ +package environments + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/svc/api/openapi" +) + +func TestRemoveEnvironmentVariables(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2EnvironmentsRemoveEnvironmentVariablesRequestBody + }{{"request", "environments remove-environment-variables --project=x --app=x --environment=x --variables=A,B", openapi.V2EnvironmentsRemoveEnvironmentVariablesRequestBody{Project: "x", App: "x", Environment: "x", Variables: []string{"A", "B"}}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequest[openapi.V2EnvironmentsRemoveEnvironmentVariablesRequestBody](t, Cmd(), tt.args) + require.Equal(t, tt.want, got) + }) + } +} + +func TestRemoveEnvironmentVariablesRequiresVariables(t *testing.T) { + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), strings.Fields("unkey environments remove-environment-variables --project=p --app=a --environment=e --root-key=test")) + require.ErrorContains(t, err, "variables") +} diff --git a/cmd/api/environments/root.go b/cmd/api/environments/root.go new file mode 100644 index 00000000000..0c8b9e7bbd0 --- /dev/null +++ b/cmd/api/environments/root.go @@ -0,0 +1,18 @@ +package environments + +import ( + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +// Cmd returns the environments group command. +func Cmd() *cli.Command { + return &cli.Command{Name: "environments", Usage: "Manage environments", Description: "Manage Unkey environments." + util.Disclaimer, Commands: []*cli.Command{ + setEnvironmentVariablesCmd(), + updateSettingsCmd(), + getEnvironmentCmd(), + listEnvironmentVariablesCmd(), + listEnvironmentsCmd(), + removeEnvironmentVariablesCmd(), + }} +} diff --git a/cmd/api/environments/set_environment_variables.go b/cmd/api/environments/set_environment_variables.go new file mode 100644 index 00000000000..fa2408ac7ca --- /dev/null +++ b/cmd/api/environments/set_environment_variables.go @@ -0,0 +1,42 @@ +package environments + +import ( + "context" + "encoding/json" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" +) + +func setEnvironmentVariablesCmd() *cli.Command { + return &cli.Command{Name: "set-environment-variables", Usage: "Set environment variables", Description: "Upsert environment variables atomically. With --prune=true, variables omitted from the supplied array are removed.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/environments/set-environment-variables" + util.Disclaimer, Examples: []string{`unkey api environments set-environment-variables --project=payments --app=payments-api --environment=production --variables='[{"key":"TOKEN","value":"secret"}]'`}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("variables", "Variables as a JSON array.", cli.Required(), cli.MutuallyExclusive("body")), cli.Bool("prune", "Remove variables not included.", cli.Default(false), cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Environments.SetEnvironmentVariables, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2EnvironmentsSetEnvironmentVariablesResponseBody) + } + send := func(req components.V2EnvironmentsSetEnvironmentVariablesRequestBody) error { + res, err := client.Environments.SetEnvironmentVariables(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2EnvironmentsSetEnvironmentVariablesResponseBody) + } + var variables []components.EnvironmentVariableInput + if err := json.Unmarshal([]byte(cmd.String("variables")), &variables); err != nil { + return fmt.Errorf("invalid JSON for --variables: %w", err) + } + req := components.V2EnvironmentsSetEnvironmentVariablesRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Environment: cmd.String("environment"), Variables: variables, Prune: ptr.P(cmd.Bool("prune"))} + return send(req) + }} +} diff --git a/cmd/api/environments/set_environment_variables_test.go b/cmd/api/environments/set_environment_variables_test.go new file mode 100644 index 00000000000..f0922156d9d --- /dev/null +++ b/cmd/api/environments/set_environment_variables_test.go @@ -0,0 +1,23 @@ +package environments + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/ptr" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestSetEnvironmentVariables(t *testing.T) { + kind := openapi.EnvironmentVariableKind("writeonly") + tests := []struct { + name, args string + want openapi.V2EnvironmentsSetEnvironmentVariablesRequestBody + }{{"variables", `environments set-environment-variables --project=p --app=a --environment=e --variables=[{"key":"TOKEN","value":"secret"}]`, openapi.V2EnvironmentsSetEnvironmentVariablesRequestBody{Project: "p", App: "a", Environment: "e", Variables: []openapi.EnvironmentVariableInput{{Key: "TOKEN", Value: "secret", Kind: &kind}}, Prune: ptr.P(false)}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequest[openapi.V2EnvironmentsSetEnvironmentVariablesRequestBody](t, Cmd(), tt.args) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/api/environments/update_settings.go b/cmd/api/environments/update_settings.go new file mode 100644 index 00000000000..c36a0bba6f6 --- /dev/null +++ b/cmd/api/environments/update_settings.go @@ -0,0 +1,104 @@ +package environments + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" +) + +func updateSettingsCmd() *cli.Command { + shutdownSignals := []string{string(components.EnvironmentShutdownSignalSigterm), string(components.EnvironmentShutdownSignalSigint), string(components.EnvironmentShutdownSignalSigquit), string(components.EnvironmentShutdownSignalSigkill)} + upstreamProtocols := []string{string(components.EnvironmentUpstreamProtocolHttp1), string(components.EnvironmentUpstreamProtocolH2c)} + return &cli.Command{Name: "update-settings", Usage: "Update environment build and runtime settings", Description: "Update selected build, runtime, health check, and region settings for an environment. Request-building flags that you omit leave their stored values unchanged.\n\nFor full documentation, see https://www.unkey.com/docs/api-reference/v2/environments/update-settings" + util.Disclaimer, Examples: []string{`unkey api environments update-settings --project=payments --app=payments-api --environment=production --healthcheck='{"method":"GET","path":"/health"}' --regions='[{"name":"us-east-1","replicas":{"min":1,"max":2}}]'`}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("dockerfile", "Dockerfile path.", cli.MutuallyExclusive("body")), cli.String("root-directory", "Build root directory.", cli.MutuallyExclusive("body")), cli.String("build-command", "Build command.", cli.MutuallyExclusive("body")), cli.StringSlice("watch-paths", "Paths that trigger deployment.", cli.MutuallyExclusive("body")), cli.Bool("auto-deploy", "Whether pushes auto-deploy.", cli.MutuallyExclusive("body")), cli.Int64("port", "Container port.", cli.MutuallyExclusive("body")), cli.Float("v-cpus", "CPU allocation in vCPUs.", cli.MutuallyExclusive("body")), cli.Int64("memory-mib", "Memory allocation in MiB.", cli.MutuallyExclusive("body")), cli.Int64("storage-mib", "Storage allocation in MiB.", cli.MutuallyExclusive("body")), cli.StringSlice("command", "Container command.", cli.MutuallyExclusive("body")), cli.String("healthcheck", "Healthcheck configuration as JSON.", cli.MutuallyExclusive("body")), cli.Enum("shutdown-signal", "Container shutdown signal.", shutdownSignals, cli.MutuallyExclusive("body")), cli.Enum("upstream-protocol", "Protocol used to reach the container.", upstreamProtocols, cli.MutuallyExclusive("body")), cli.String("openapi-spec-path", "OpenAPI specification path.", cli.MutuallyExclusive("body")), cli.String("regions", "Region configuration as JSON.", cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Environments.UpdateSettings, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2EnvironmentsUpdateSettingsResponseBody) + } + send := func(req components.V2EnvironmentsUpdateSettingsRequestBody) error { + res, err := client.Environments.UpdateSettings(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2EnvironmentsUpdateSettingsResponseBody) + } + req := components.V2EnvironmentsUpdateSettingsRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Environment: cmd.String("environment"), Dockerfile: nil, RootDirectory: nil, BuildCommand: nil, WatchPaths: nil, AutoDeploy: nil, Port: nil, VCpus: nil, MemoryMib: nil, StorageMib: nil, Command: nil, Healthcheck: nil, ShutdownSignal: nil, UpstreamProtocol: nil, OpenapiSpecPath: nil, Regions: nil} + if v := cmd.String("dockerfile"); v != "" { + req.Dockerfile = &v + } + if v := cmd.String("root-directory"); v != "" { + req.RootDirectory = &v + } + if v := cmd.String("build-command"); v != "" { + req.BuildCommand = &v + } + if cmd.FlagIsSet("watch-paths") { + req.WatchPaths = cmd.StringSlice("watch-paths") + } + if cmd.FlagIsSet("auto-deploy") { + req.AutoDeploy = ptr.P(cmd.Bool("auto-deploy")) + } + if cmd.FlagIsSet("port") { + req.Port = ptr.P(cmd.Int64("port")) + } + if cmd.FlagIsSet("v-cpus") { + req.VCpus = ptr.P(cmd.Float("v-cpus")) + } + if cmd.FlagIsSet("memory-mib") { + req.MemoryMib = ptr.P(cmd.Int64("memory-mib")) + } + if cmd.FlagIsSet("storage-mib") { + req.StorageMib = ptr.P(cmd.Int64("storage-mib")) + } + if cmd.FlagIsSet("command") { + req.Command = cmd.StringSlice("command") + } + if raw := cmd.String("healthcheck"); raw != "" { + if strings.TrimSpace(raw) == "null" { + return fmt.Errorf("--healthcheck cannot be null with the current SDK") + } + var v components.EnvironmentHealthcheck + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return fmt.Errorf("invalid JSON for --healthcheck: %w", err) + } + req.Healthcheck = &v + } + if v := cmd.Enum("shutdown-signal"); v != "" { + x := components.EnvironmentShutdownSignal(v) + req.ShutdownSignal = &x + } + if v := cmd.Enum("upstream-protocol"); v != "" { + x := components.EnvironmentUpstreamProtocol(v) + req.UpstreamProtocol = &x + } + if v := cmd.String("openapi-spec-path"); v != "" { + req.OpenapiSpecPath = &v + } + if raw := cmd.String("regions"); raw != "" { + if strings.TrimSpace(raw) == "null" { + return fmt.Errorf("--regions must be a non-empty JSON array") + } + if err := json.Unmarshal([]byte(raw), &req.Regions); err != nil { + return fmt.Errorf("invalid JSON for --regions: %w", err) + } + if len(req.Regions) == 0 { + return fmt.Errorf("--regions must contain at least one region") + } + } + return send(req) + }} +} diff --git a/cmd/api/environments/update_settings_test.go b/cmd/api/environments/update_settings_test.go new file mode 100644 index 00000000000..7404f550e47 --- /dev/null +++ b/cmd/api/environments/update_settings_test.go @@ -0,0 +1,58 @@ +package environments + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" + "github.com/unkeyed/unkey/svc/api/openapi" +) + +func TestUpdateSettings(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2EnvironmentsUpdateSettingsRequestBody + }{{"omits partial fields", `environments update-settings --project=p --app=a --environment=e`, openapi.V2EnvironmentsUpdateSettingsRequestBody{Project: "p", App: "a", Environment: "e"}}, {"scalar options", `environments update-settings --project=p --app=a --environment=e --auto-deploy=false --port=8080 --v-cpus=0.5 --memory-mib=512 --shutdown-signal=SIGTERM --upstream-protocol=h2c`, openapi.V2EnvironmentsUpdateSettingsRequestBody{Project: "p", App: "a", Environment: "e", AutoDeploy: ptr.P(false), Port: ptr.P(8080), VCpus: ptr.P(0.5), MemoryMib: ptr.P(512), ShutdownSignal: ptr.P(openapi.SIGTERM), UpstreamProtocol: ptr.P(openapi.H2c)}}, {"clear slices", `environments update-settings --project=p --app=a --environment=e --watch-paths= --command=`, openapi.V2EnvironmentsUpdateSettingsRequestBody{Project: "p", App: "a", Environment: "e", WatchPaths: ptr.P([]string{}), Command: ptr.P([]string{})}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequest[openapi.V2EnvironmentsUpdateSettingsRequestBody](t, Cmd(), tt.args) + require.Equal(t, tt.want, got) + }) + } +} + +func TestUpdateSettingsRejectsUnrepresentableJSON(t *testing.T) { + tests := []struct { + name string + flag string + want string + }{ + {name: "null healthcheck", flag: "--healthcheck=null", want: "cannot be null"}, + {name: "null regions", flag: "--regions=null", want: "non-empty JSON array"}, + {name: "empty regions", flag: "--regions=[]", want: "at least one region"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := "unkey environments update-settings --project=p --app=a --environment=e --root-key=test " + tt.flag + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), strings.Fields(args)) + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestUpdateSettingsRejectsInvalidEnums(t *testing.T) { + tests := []string{"--shutdown-signal=SIGHUP", "--upstream-protocol=http2"} + for _, flag := range tests { + t.Run(flag, func(t *testing.T) { + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), strings.Fields("unkey environments update-settings --project=p --app=a --environment=e --root-key=test "+flag)) + require.ErrorContains(t, err, "invalid enum value") + }) + } +} diff --git a/cmd/api/gateway/list_policies.go b/cmd/api/gateway/list_policies.go new file mode 100644 index 00000000000..a1bb7e95b4b --- /dev/null +++ b/cmd/api/gateway/list_policies.go @@ -0,0 +1,50 @@ +package gateway + +import ( + "context" + "fmt" + + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func listPoliciesCmd() *cli.Command { + return &cli.Command{ + Name: "list-policies", Usage: "Retrieve an environment's gateway policies in evaluation order", + Description: `Retrieve an environment's gateway policies in evaluation order: the gateway evaluates them top to bottom and the first rejection short-circuits the request. + +The full policy list is returned in a single response. + +Required Permissions + +Your root key must have one of the following permissions: +- environment.*.read_policies (for any environment) +- environment..read_policies (for a specific environment) + +For full documentation, see https://www.unkey.com/docs/api-reference/gateway/list-policies` + util.Disclaimer, + Examples: []string{"unkey api gateway list-policies --project=payments --app=payments-api --environment=production"}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Gateway.ListPolicies, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2GatewayListPoliciesResponseBody) + } + req := components.V2GatewayListPoliciesRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Environment: cmd.String("environment")} + res, err := client.Gateway.ListPolicies(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2GatewayListPoliciesResponseBody) + }, + } +} diff --git a/cmd/api/gateway/list_policies_test.go b/cmd/api/gateway/list_policies_test.go new file mode 100644 index 00000000000..8048a1d62ca --- /dev/null +++ b/cmd/api/gateway/list_policies_test.go @@ -0,0 +1,20 @@ +package gateway + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestListPolicies(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2GatewayListPoliciesRequestBody + }{{"minimal", "gateway list-policies --project=p --app=a --environment=e", openapi.V2GatewayListPoliciesRequestBody{Project: "p", App: "a", Environment: "e"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequestWithResponse[openapi.V2GatewayListPoliciesRequestBody](t, Cmd(), tt.args, `{"meta":{"requestId":"test"},"data":[]}`)) + }) + } +} diff --git a/cmd/api/gateway/root.go b/cmd/api/gateway/root.go new file mode 100644 index 00000000000..094fac07d22 --- /dev/null +++ b/cmd/api/gateway/root.go @@ -0,0 +1,10 @@ +package gateway + +import ( + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func Cmd() *cli.Command { + return &cli.Command{Name: "gateway", Usage: "Manage gateway policies", Description: "List, replace, and update gateway policies." + util.Disclaimer, Commands: []*cli.Command{listPoliciesCmd(), setPoliciesCmd(), updatePolicyCmd()}} +} diff --git a/cmd/api/gateway/set_policies.go b/cmd/api/gateway/set_policies.go new file mode 100644 index 00000000000..ccd996e7699 --- /dev/null +++ b/cmd/api/gateway/set_policies.go @@ -0,0 +1,61 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func setPoliciesCmd() *cli.Command { + return &cli.Command{ + Name: "set-policies", Usage: "Replace an environment's gateway policies in a single atomic request", + Description: `Replace an environment's gateway policies in a single atomic request. Policies run at the edge before requests reach your app: verify API keys, rate limit, block requests outright, or validate them against your OpenAPI spec. + +Policies are an ordered list: the gateway evaluates them top to bottom and the first rejection short-circuits the request. Every call is a full, atomic replace; an empty list removes all policies. + +Required Permissions +- environment.*.set_policies (for any environment) +- environment..set_policies (for a specific environment) + +For full documentation, see https://www.unkey.com/docs/api-reference/gateway/set-policies` + util.Disclaimer, + Examples: []string{`unkey api gateway set-policies --project=payments --app=payments-api --environment=production --policies='[{"name":"Require API key","enabled":true,"keyauth":{"keyspaces":["ks_1234abcd"]}}]'`, `unkey api gateway set-policies --project=payments --app=payments-api --environment=production --policies='[]'`}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("policies", "JSON array containing the complete ordered policy list.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Gateway.SetPolicies, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2GatewaySetPoliciesResponseBody) + } + send := func(req components.V2GatewaySetPoliciesRequestBody) error { + res, err := client.Gateway.SetPolicies(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2GatewaySetPoliciesResponseBody) + } + var policies []components.Policy + rawPolicies := cmd.String("policies") + if strings.TrimSpace(rawPolicies) == "null" { + return fmt.Errorf("--policies must be a JSON array") + } + if err := json.Unmarshal([]byte(rawPolicies), &policies); err != nil { + return fmt.Errorf("invalid JSON for --policies: %w", err) + } + req := components.V2GatewaySetPoliciesRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Environment: cmd.String("environment"), Policies: policies} + return send(req) + }, + } +} diff --git a/cmd/api/gateway/set_policies_test.go b/cmd/api/gateway/set_policies_test.go new file mode 100644 index 00000000000..f6bca2b8f39 --- /dev/null +++ b/cmd/api/gateway/set_policies_test.go @@ -0,0 +1,32 @@ +package gateway + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/svc/api/openapi" +) + +func TestSetPolicies(t *testing.T) { + tests := []struct { + name, args string + count int + }{{"minimal empty", "gateway set-policies --project=p --app=a --environment=e --policies=[]", 0}, {"all policy data", `gateway set-policies --project=p --app=a --environment=e --policies=[{"name":"deny","enabled":true,"firewall":{"action":"ACTION_DENY"}}]`, 1}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequest[openapi.V2GatewaySetPoliciesRequestBody](t, Cmd(), tt.args) + require.Equal(t, "p", got.Project) + require.Len(t, got.Policies, tt.count) + }) + } +} + +func TestSetPoliciesRejectsNull(t *testing.T) { + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), strings.Fields("unkey gateway set-policies --project=p --app=a --environment=e --policies=null --root-key=test")) + require.ErrorContains(t, err, "must be a JSON array") +} diff --git a/cmd/api/gateway/update_policy.go b/cmd/api/gateway/update_policy.go new file mode 100644 index 00000000000..66aa57b63a4 --- /dev/null +++ b/cmd/api/gateway/update_policy.go @@ -0,0 +1,166 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +type policyUpdate struct { + Name json.RawMessage `json:"name"` + Enabled json.RawMessage `json:"enabled"` + Match json.RawMessage `json:"match"` + Keyauth json.RawMessage `json:"keyauth"` + Ratelimit json.RawMessage `json:"ratelimit"` + Firewall json.RawMessage `json:"firewall"` + Openapi json.RawMessage `json:"openapi"` +} + +func updatePolicyCmd() *cli.Command { + return &cli.Command{ + Name: "update-policy", Usage: "Update a single policy in place without resending the environment's full policy list", + Description: `Update a single policy in place without resending the environment's full policy list. The policy keeps its id and position, and all other policies are untouched. + +Pass the policy fields to update as one JSON object. Omitted fields keep their stored values. Setting match to null or an empty array removes all match expressions. At least one update field is required, and at most one rule field may be set. + +Required Permissions +- environment.*.update_policy (for any environment) +- environment..update_policy (for a specific environment) + +For full documentation, see https://www.unkey.com/docs/api-reference/gateway/update-policy` + util.Disclaimer, + Examples: []string{`unkey api gateway update-policy --project=payments --app=payments-api --environment=production --policy-id=pol_123 --policy='{"enabled":false}'`, `unkey api gateway update-policy --project=payments --app=payments-api --environment=production --policy-id=pol_123 --policy='{"match":null}'`, `unkey api gateway update-policy --project=payments --app=payments-api --environment=production --policy-id=pol_123 --policy='{"firewall":{"action":"ACTION_DENY"}}'`}, + Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("app", "App ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("environment", "Environment ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("policy-id", "ID of the policy to update.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("policy", "Policy fields to update as a JSON object.", cli.Required(), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + body, err = normalizeUpdatePolicyBody(body) + if err != nil { + return err + } + res, err := util.SendBody(ctx, client.Gateway.UpdatePolicy, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2GatewayUpdatePolicyResponseBody) + } + send := func(req components.V2GatewayUpdatePolicyRequestBody) error { + res, err := client.Gateway.UpdatePolicy(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2GatewayUpdatePolicyResponseBody) + } + var update policyUpdate + decoder := json.NewDecoder(strings.NewReader(cmd.String("policy"))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&update); err != nil { + return fmt.Errorf("invalid JSON for --policy: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return fmt.Errorf("invalid JSON for --policy: multiple JSON values") + } + return fmt.Errorf("invalid JSON for --policy: %w", err) + } + + updates := 0 + var name *string + if update.Name != nil { + if strings.TrimSpace(string(update.Name)) == "null" { + return fmt.Errorf("name in --policy must be a string, not null") + } + if err := json.Unmarshal(update.Name, &name); err != nil { + return fmt.Errorf("invalid name in --policy: %w", err) + } + updates++ + } + var enabled *bool + if update.Enabled != nil { + if strings.TrimSpace(string(update.Enabled)) == "null" { + return fmt.Errorf("enabled in --policy must be a boolean, not null") + } + if err := json.Unmarshal(update.Enabled, &enabled); err != nil { + return fmt.Errorf("invalid enabled in --policy: %w", err) + } + updates++ + } + if update.Match != nil { + updates++ + } + + type ruleUpdate struct { + name string + value json.RawMessage + target any + } + req := components.V2GatewayUpdatePolicyRequestBody{Project: cmd.String("project"), App: cmd.String("app"), Environment: cmd.String("environment"), PolicyID: cmd.String("policy-id"), Name: name, Enabled: enabled, Match: nil, Keyauth: nil, Ratelimit: nil, Firewall: nil, Openapi: nil} + ruleUpdates := []ruleUpdate{ + {name: "keyauth", value: update.Keyauth, target: &req.Keyauth}, + {name: "ratelimit", value: update.Ratelimit, target: &req.Ratelimit}, + {name: "firewall", value: update.Firewall, target: &req.Firewall}, + {name: "openapi", value: update.Openapi, target: &req.Openapi}, + } + rules := 0 + for _, rule := range ruleUpdates { + if rule.value != nil { + updates++ + rules++ + } + } + if updates == 0 { + return fmt.Errorf("--policy must contain at least one update field") + } + if rules > 1 { + return fmt.Errorf("--policy may contain at most one of keyauth, ratelimit, firewall, or openapi") + } + if update.Match != nil { + if strings.TrimSpace(string(update.Match)) == "null" { + req.Match = make([]components.MatchExpr, 0) + } else if err := json.Unmarshal(update.Match, &req.Match); err != nil { + return fmt.Errorf("invalid match in --policy: %w", err) + } + } + for _, rule := range ruleUpdates { + if rule.value != nil { + if strings.TrimSpace(string(rule.value)) == "null" { + return fmt.Errorf("%s in --policy must be a JSON object, not null", rule.name) + } + if err := json.Unmarshal(rule.value, rule.target); err != nil { + return fmt.Errorf("invalid %s in --policy: %w", rule.name, err) + } + } + } + return send(req) + }, + } +} + +// normalizeUpdatePolicyBody preserves the API's explicit match:null clear +// operation across the generated SDK's decode and re-encode cycle. +func normalizeUpdatePolicyBody(body string) (string, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(body), &fields); err != nil { + return body, nil + } + match, ok := fields["match"] + if !ok || strings.TrimSpace(string(match)) != "null" { + return body, nil + } + fields["match"] = json.RawMessage("[]") + normalized, err := json.Marshal(fields) + if err != nil { + return "", fmt.Errorf("normalize match in --body: %w", err) + } + return string(normalized), nil +} diff --git a/cmd/api/gateway/update_policy_test.go b/cmd/api/gateway/update_policy_test.go new file mode 100644 index 00000000000..10af5f5cee4 --- /dev/null +++ b/cmd/api/gateway/update_policy_test.go @@ -0,0 +1,64 @@ +package gateway + +import ( + "context" + "strings" + "testing" + + "github.com/oapi-codegen/nullable" + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/svc/api/openapi" +) + +func TestUpdatePolicy(t *testing.T) { + base := "gateway update-policy --project=p --app=a --environment=e --policy-id=pol_1" + tests := []struct { + name, args string + check func(*testing.T, openapi.V2GatewayUpdatePolicyRequestBody) + }{ + {"optional", base + ` --policy={"enabled":false}`, func(t *testing.T, got openapi.V2GatewayUpdatePolicyRequestBody) { + require.NotNil(t, got.Enabled) + require.False(t, *got.Enabled) + }}, + {"name and keyauth", base + ` --policy={"name":"n","keyauth":{"keyspaces":["ks_1"]}}`, func(t *testing.T, got openapi.V2GatewayUpdatePolicyRequestBody) { + require.NotNil(t, got.Name) + require.Equal(t, "n", *got.Name) + require.NotNil(t, got.Keyauth) + }}, + {"clear match", base + ` --policy={"match":null}`, func(t *testing.T, got openapi.V2GatewayUpdatePolicyRequestBody) { + require.Equal(t, nullable.NewNullableWithValue([]openapi.MatchExpr{}), got.Match) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequest[openapi.V2GatewayUpdatePolicyRequestBody](t, Cmd(), tt.args) + require.Equal(t, "pol_1", got.PolicyId) + tt.check(t, got) + }) + } +} + +func TestUpdatePolicyRejectsInvalidUpdates(t *testing.T) { + base := "unkey gateway update-policy --project=p --app=a --environment=e --policy-id=pol_1 --root-key=test" + tests := []struct { + name string + args string + want string + }{ + {name: "no updates", args: base + " --policy={}", want: "at least one update field"}, + {name: "multiple rules", args: base + ` --policy={"keyauth":{"keyspaces":["ks_1"]},"firewall":{"action":"ACTION_DENY"}}`, want: "at most one of"}, + {name: "unknown field", args: base + ` --policy={"unknown":true}`, want: `unknown field "unknown"`}, + {name: "null name", args: base + ` --policy={"name":null,"enabled":true}`, want: "name in --policy must be a string, not null"}, + {name: "null enabled", args: base + ` --policy={"enabled":null,"name":"updated"}`, want: "enabled in --policy must be a boolean, not null"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), strings.Fields(tt.args)) + require.ErrorContains(t, err, tt.want) + }) + } +} diff --git a/cmd/api/identities/create_identity.go b/cmd/api/identities/create_identity.go index ab71e176a08..07e5b290c97 100644 --- a/cmd/api/identities/create_identity.go +++ b/cmd/api/identities/create_identity.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -25,17 +24,18 @@ Requires identity.*.create_identity permission For full documentation, see https://www.unkey.com/docs/api-reference/v2/identities/create-identity` + util.Disclaimer, Examples: []string{ "unkey api identities create-identity --external-id=user_123", - `unkey api identities create-identity --external-id=user_123 --meta-json='{"email":"alice@acme.com","name":"Alice Smith","plan":"premium"}'`, - `unkey api identities create-identity --external-id=user_123 --ratelimits-json='[{"name":"requests","limit":1000,"duration":60000,"autoApply":false}]'`, + `unkey api identities create-identity --external-id=user_123 --meta='{"email":"alice@acme.com","name":"Alice Smith","plan":"premium"}'`, + `unkey api identities create-identity --external-id=user_123 --ratelimits='[{"name":"requests","limit":1000,"duration":60000,"autoApply":false}]'`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("external-id", "Your system's unique identifier for the user, organization, or entity.", cli.Required()), - cli.String("meta-json", "JSON object of arbitrary metadata stored on the identity."), - cli.String("ratelimits-json", "JSON array of shared rate limit configurations for all keys under this identity."), + cli.String("external-id", "Your system's unique identifier for the user, organization, or entity.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("meta", "JSON object of arbitrary metadata stored on the identity.", cli.MutuallyExclusive("body")), + cli.String("ratelimits", "JSON array of shared rate limit configurations for all keys under this identity.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -43,34 +43,43 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Identities.CreateIdentity, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2IdentitiesCreateIdentityResponseBody) + } + send := func(req components.V2IdentitiesCreateIdentityRequestBody) error { + res, err := client.Identities.CreateIdentity(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2IdentitiesCreateIdentityResponseBody) + } req := components.V2IdentitiesCreateIdentityRequestBody{ ExternalID: cmd.String("external-id"), Meta: nil, Ratelimits: nil, } - if v := cmd.String("meta-json"); v != "" { + if v := cmd.String("meta"); v != "" { var meta map[string]any if err := json.Unmarshal([]byte(v), &meta); err != nil { - return fmt.Errorf("invalid JSON for --meta-json: %w", err) + return fmt.Errorf("invalid JSON for --meta: %w", err) } req.Meta = meta } - if v := cmd.String("ratelimits-json"); v != "" { + if v := cmd.String("ratelimits"); v != "" { var ratelimits []components.RatelimitRequest if err := json.Unmarshal([]byte(v), &ratelimits); err != nil { - return fmt.Errorf("invalid JSON for --ratelimits-json: %w", err) + return fmt.Errorf("invalid JSON for --ratelimits: %w", err) } req.Ratelimits = ratelimits } - - start := time.Now() - res, err := client.Identities.CreateIdentity(ctx, req) - if err != nil { - return fmt.Errorf("%s", util.FormatError(err)) - } - return util.Output(cmd, res.V2IdentitiesCreateIdentityResponseBody, time.Since(start)) + return send(req) }, } } diff --git a/cmd/api/identities/create_identity_test.go b/cmd/api/identities/create_identity_test.go index e63b0d529a8..763592309e1 100644 --- a/cmd/api/identities/create_identity_test.go +++ b/cmd/api/identities/create_identity_test.go @@ -24,7 +24,7 @@ func TestCreateIdentity(t *testing.T) { }, { name: "with meta json", - args: `identities create-identity --external-id=user_123 --meta-json={"email":"alice@acme.com","plan":"premium"}`, + args: `identities create-identity --external-id=user_123 --meta={"email":"alice@acme.com","plan":"premium"}`, want: openapi.V2IdentitiesCreateIdentityRequestBody{ ExternalId: "user_123", Meta: ptr.P(map[string]interface{}{"email": "alice@acme.com", "plan": "premium"}), @@ -32,7 +32,7 @@ func TestCreateIdentity(t *testing.T) { }, { name: "with ratelimits json", - args: `identities create-identity --external-id=user_123 --ratelimits-json=[{"name":"requests","limit":1000,"duration":60000,"autoApply":false}]`, + args: `identities create-identity --external-id=user_123 --ratelimits=[{"name":"requests","limit":1000,"duration":60000,"autoApply":false}]`, want: openapi.V2IdentitiesCreateIdentityRequestBody{ ExternalId: "user_123", Ratelimits: &[]openapi.RatelimitRequest{ @@ -42,7 +42,7 @@ func TestCreateIdentity(t *testing.T) { }, { name: "all flags", - args: `identities create-identity --external-id=user_123 --meta-json={"email":"alice@acme.com","plan":"premium"} --ratelimits-json=[{"name":"requests","limit":1000,"duration":60000,"autoApply":true}]`, + args: `identities create-identity --external-id=user_123 --meta={"email":"alice@acme.com","plan":"premium"} --ratelimits=[{"name":"requests","limit":1000,"duration":60000,"autoApply":true}]`, want: openapi.V2IdentitiesCreateIdentityRequestBody{ ExternalId: "user_123", Meta: ptr.P(map[string]interface{}{"email": "alice@acme.com", "plan": "premium"}), diff --git a/cmd/api/identities/delete_identity.go b/cmd/api/identities/delete_identity.go index aee2ea821c3..d3693173968 100644 --- a/cmd/api/identities/delete_identity.go +++ b/cmd/api/identities/delete_identity.go @@ -3,7 +3,6 @@ package identities import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -28,11 +27,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti "unkey api identities delete-identity --identity=user_123", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("identity", "The identity ID or external ID to delete.", cli.Required()), + cli.String("identity", "The identity ID or external ID to delete.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -40,14 +40,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Identities.DeleteIdentity, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2IdentitiesDeleteIdentityResponseBody) + } + res, err := client.Identities.DeleteIdentity(ctx, components.V2IdentitiesDeleteIdentityRequestBody{ Identity: cmd.String("identity"), }) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2IdentitiesDeleteIdentityResponseBody, time.Since(start)) + return util.Output(cmd, res.V2IdentitiesDeleteIdentityResponseBody) }, } } diff --git a/cmd/api/identities/get_identity.go b/cmd/api/identities/get_identity.go index 1c09dcf4b35..9fd8cbc8423 100644 --- a/cmd/api/identities/get_identity.go +++ b/cmd/api/identities/get_identity.go @@ -3,7 +3,6 @@ package identities import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -26,11 +25,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti "unkey api identities get-identity --identity=user_123", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("identity", "The ID of the identity to retrieve, either externalId or identityId.", cli.Required()), + cli.String("identity", "The ID of the identity to retrieve, either externalId or identityId.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -38,14 +38,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Identities.GetIdentity, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2IdentitiesGetIdentityResponseBody) + } + res, err := client.Identities.GetIdentity(ctx, components.V2IdentitiesGetIdentityRequestBody{ Identity: cmd.String("identity"), }) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2IdentitiesGetIdentityResponseBody, time.Since(start)) + return util.Output(cmd, res.V2IdentitiesGetIdentityResponseBody) }, } } diff --git a/cmd/api/identities/list_identities.go b/cmd/api/identities/list_identities.go index 28b87bde35d..0a2df9313bd 100644 --- a/cmd/api/identities/list_identities.go +++ b/cmd/api/identities/list_identities.go @@ -3,7 +3,6 @@ package identities import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -25,14 +24,17 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti "unkey api identities list-identities", "unkey api identities list-identities --limit=50", "unkey api identities list-identities --limit=50 --cursor=cursor_eyJrZXkiOiJrZXlfMTIzNCJ9", + "unkey api identities list-identities --search=user_123", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.Int64("limit", "Maximum number of identities to return per page."), - cli.String("cursor", "Pagination cursor from a previous response."), + cli.Int64("limit", "Maximum number of identities to return per page.", cli.MutuallyExclusive("body")), + cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body")), + cli.String("search", "Filter identities by ID or external ID.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -40,9 +42,19 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Identities.ListIdentities, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2IdentitiesListIdentitiesResponseBody) + } + req := components.V2IdentitiesListIdentitiesRequestBody{ Limit: nil, Cursor: nil, + Search: nil, } if v := cmd.Int64("limit"); v != 0 { @@ -53,12 +65,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti req.Cursor = &v } - start := time.Now() + if v := cmd.String("search"); v != "" { + req.Search = &v + } + res, err := client.Identities.ListIdentities(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2IdentitiesListIdentitiesResponseBody, time.Since(start)) + return util.Output(cmd, res.V2IdentitiesListIdentitiesResponseBody) }, } } diff --git a/cmd/api/identities/list_identities_test.go b/cmd/api/identities/list_identities_test.go index 13fb8d37383..1d8fa19f279 100644 --- a/cmd/api/identities/list_identities_test.go +++ b/cmd/api/identities/list_identities_test.go @@ -37,6 +37,23 @@ func TestListIdentities(t *testing.T) { Cursor: ptr.P("cursor_eyJrZXkiOiJrZXlfMTIzNCJ9"), }, }, + { + name: "with search", + args: "identities list-identities --search=user_123", + want: openapi.V2IdentitiesListIdentitiesRequestBody{ + Limit: ptr.P(100), + Search: ptr.P("user_123"), + }, + }, + { + name: "with all flags", + args: "identities list-identities --limit=25 --cursor=cursor_123 --search=user_123", + want: openapi.V2IdentitiesListIdentitiesRequestBody{ + Limit: ptr.P(25), + Cursor: ptr.P("cursor_123"), + Search: ptr.P("user_123"), + }, + }, } for _, tt := range tests { diff --git a/cmd/api/identities/update_identity.go b/cmd/api/identities/update_identity.go index 972643fe5dd..9110d1b0cd3 100644 --- a/cmd/api/identities/update_identity.go +++ b/cmd/api/identities/update_identity.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -26,17 +25,18 @@ Rate limit changes propagate within 30 seconds For full documentation, see https://www.unkey.com/docs/api-reference/v2/identities/update-identity` + util.Disclaimer, Examples: []string{ "unkey api identities update-identity --identity=user_123", - `unkey api identities update-identity --identity=user_123 --meta-json='{"plan":"premium","name":"Alice Smith"}'`, - `unkey api identities update-identity --identity=user_123 --ratelimits-json='[{"name":"requests","limit":1000,"duration":3600000,"autoApply":true}]'`, + `unkey api identities update-identity --identity=user_123 --meta='{"plan":"premium","name":"Alice Smith"}'`, + `unkey api identities update-identity --identity=user_123 --ratelimits='[{"name":"requests","limit":1000,"duration":3600000,"autoApply":true}]'`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("identity", "The identity ID or externalId to update.", cli.Required()), - cli.String("meta-json", "JSON object of metadata to replace existing metadata."), - cli.String("ratelimits-json", "JSON array of rate limit configurations."), + cli.String("identity", "The identity ID or externalId to update.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("meta", "JSON object of metadata to replace existing metadata.", cli.MutuallyExclusive("body")), + cli.String("ratelimits", "JSON array of rate limit configurations.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -44,34 +44,43 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/identiti return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Identities.UpdateIdentity, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2IdentitiesUpdateIdentityResponseBody) + } + send := func(req components.V2IdentitiesUpdateIdentityRequestBody) error { + res, err := client.Identities.UpdateIdentity(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2IdentitiesUpdateIdentityResponseBody) + } req := components.V2IdentitiesUpdateIdentityRequestBody{ Identity: cmd.String("identity"), Meta: nil, Ratelimits: nil, } - if v := cmd.String("meta-json"); v != "" { + if v := cmd.String("meta"); v != "" { var meta map[string]any if err := json.Unmarshal([]byte(v), &meta); err != nil { - return fmt.Errorf("invalid JSON for --meta-json: %w", err) + return fmt.Errorf("invalid JSON for --meta: %w", err) } req.Meta = meta } - if v := cmd.String("ratelimits-json"); v != "" { + if v := cmd.String("ratelimits"); v != "" { var ratelimits []components.RatelimitRequest if err := json.Unmarshal([]byte(v), &ratelimits); err != nil { - return fmt.Errorf("invalid JSON for --ratelimits-json: %w", err) + return fmt.Errorf("invalid JSON for --ratelimits: %w", err) } req.Ratelimits = ratelimits } - - start := time.Now() - res, err := client.Identities.UpdateIdentity(ctx, req) - if err != nil { - return fmt.Errorf("%s", util.FormatError(err)) - } - return util.Output(cmd, res.V2IdentitiesUpdateIdentityResponseBody, time.Since(start)) + return send(req) }, } } diff --git a/cmd/api/identities/update_identity_test.go b/cmd/api/identities/update_identity_test.go index 81cfe2ae45a..7aa1f0920c9 100644 --- a/cmd/api/identities/update_identity_test.go +++ b/cmd/api/identities/update_identity_test.go @@ -23,16 +23,16 @@ func TestUpdateIdentity(t *testing.T) { }, }, { - name: "with meta-json", - args: `identities update-identity --identity=user_123 --meta-json={"plan":"premium","name":"Alice"}`, + name: "with meta", + args: `identities update-identity --identity=user_123 --meta={"plan":"premium","name":"Alice"}`, want: openapi.V2IdentitiesUpdateIdentityRequestBody{ Identity: "user_123", Meta: ptr.P(map[string]any{"plan": "premium", "name": "Alice"}), }, }, { - name: "with ratelimits-json", - args: `identities update-identity --identity=user_123 --ratelimits-json=[{"name":"requests","limit":1000,"duration":3600000,"autoApply":true}]`, + name: "with ratelimits", + args: `identities update-identity --identity=user_123 --ratelimits=[{"name":"requests","limit":1000,"duration":3600000,"autoApply":true}]`, want: openapi.V2IdentitiesUpdateIdentityRequestBody{ Identity: "user_123", Ratelimits: ptr.P([]openapi.RatelimitRequest{ @@ -47,7 +47,7 @@ func TestUpdateIdentity(t *testing.T) { }, { name: "all flags", - args: `identities update-identity --identity=user_123 --meta-json={"tier":"enterprise"} --ratelimits-json=[{"name":"api","limit":500,"duration":60000,"autoApply":false}]`, + args: `identities update-identity --identity=user_123 --meta={"tier":"enterprise"} --ratelimits=[{"name":"api","limit":500,"duration":60000,"autoApply":false}]`, want: openapi.V2IdentitiesUpdateIdentityRequestBody{ Identity: "user_123", Meta: ptr.P(map[string]any{"tier": "enterprise"}), diff --git a/cmd/api/keys/add_permissions.go b/cmd/api/keys/add_permissions.go index 435f09c61fa..08b391e574d 100644 --- a/cmd/api/keys/add_permissions.go +++ b/cmd/api/keys/add_permissions.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -35,12 +34,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/add "unkey api keys add-permissions --key-id=key_1234abcd --permissions=documents.read,documents.write", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to add permissions to.", cli.Required()), - cli.StringSlice("permissions", "Comma-separated list of permission names to add.", cli.Required()), + cli.String("key-id", "The key ID to add permissions to.", cli.Required(), cli.MutuallyExclusive("body")), + cli.StringSlice("permissions", "Comma-separated list of permission names to add.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -48,7 +48,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/add return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.AddPermissions, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysAddPermissionsResponseBody) + } + req := components.V2KeysAddPermissionsRequestBody{ KeyID: cmd.String("key-id"), Permissions: cmd.StringSlice("permissions"), @@ -58,7 +66,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/add if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysAddPermissionsResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysAddPermissionsResponseBody) }, } } diff --git a/cmd/api/keys/add_roles.go b/cmd/api/keys/add_roles.go index 450e07d212b..504f74a07ee 100644 --- a/cmd/api/keys/add_roles.go +++ b/cmd/api/keys/add_roles.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -35,12 +34,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/add "unkey api keys add-roles --key-id=key_1234abcd --roles=api_admin,billing_reader", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to add roles to.", cli.Required()), - cli.StringSlice("roles", "Comma-separated list of role names to add.", cli.Required()), + cli.String("key-id", "The key ID to add roles to.", cli.Required(), cli.MutuallyExclusive("body")), + cli.StringSlice("roles", "Comma-separated list of role names to add.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -48,7 +48,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/add return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.AddRoles, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysAddRolesResponseBody) + } + req := components.V2KeysAddRolesRequestBody{ KeyID: cmd.String("key-id"), Roles: cmd.StringSlice("roles"), @@ -58,7 +66,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/add if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysAddRolesResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysAddRolesResponseBody) }, } } diff --git a/cmd/api/keys/create_key.go b/cmd/api/keys/create_key.go index e2576d1cd77..7f174c13b57 100644 --- a/cmd/api/keys/create_key.go +++ b/cmd/api/keys/create_key.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -38,28 +37,29 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/cre "unkey api keys create-key --api-id=api_1234abcd", "unkey api keys create-key --api-id=api_1234abcd --prefix=prod --name='Payment Service Key'", "unkey api keys create-key --api-id=api_1234abcd --external-id=user_1234abcd --roles=api_admin,billing_reader", - `unkey api keys create-key --api-id=api_1234abcd --meta-json='{"plan":"pro","team":"acme"}'`, - `unkey api keys create-key --api-id=api_1234abcd --credits-json='{"remaining":1000,"refill":{"interval":"monthly","amount":100}}'`, - `unkey api keys create-key --api-id=api_1234abcd --ratelimits-json='[{"name":"requests","limit":100,"duration":60000,"autoApply":true}]'`, + `unkey api keys create-key --api-id=api_1234abcd --meta='{"plan":"pro","team":"acme"}'`, + `unkey api keys create-key --api-id=api_1234abcd --credits='{"remaining":1000,"refill":{"interval":"monthly","amount":100}}'`, + `unkey api keys create-key --api-id=api_1234abcd --ratelimits='[{"name":"requests","limit":100,"duration":60000,"autoApply":true}]'`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("api-id", "The API namespace this key belongs to.", cli.Required()), - cli.String("prefix", "Prefix prepended to the generated key string."), - cli.String("name", "Human-readable name for the key."), - cli.Int64("byte-length", "Cryptographic key length in bytes."), - cli.String("external-id", "Your system's user or entity identifier to link to this key."), - cli.String("meta-json", "JSON object of arbitrary metadata returned during verification."), - cli.StringSlice("roles", "Comma-separated list of role names to assign."), - cli.StringSlice("permissions", "Comma-separated list of permission names to grant."), - cli.Int64("expires", "Unix timestamp in milliseconds when the key expires."), - cli.String("credits-json", "JSON object of credit and refill configuration."), - cli.String("ratelimits-json", "JSON array of rate limit configurations."), - cli.Bool("enabled", "Whether the key is active for verification.", cli.Default(true)), - cli.Bool("recoverable", "Whether the plaintext key is stored for later retrieval.", cli.Default(false)), + cli.String("api-id", "The API namespace this key belongs to.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("prefix", "Prefix prepended to the generated key string.", cli.MutuallyExclusive("body")), + cli.String("name", "Human-readable name for the key.", cli.MutuallyExclusive("body")), + cli.Int64("byte-length", "Cryptographic key length in bytes.", cli.MutuallyExclusive("body")), + cli.String("external-id", "Your system's user or entity identifier to link to this key.", cli.MutuallyExclusive("body")), + cli.String("meta", "JSON object of arbitrary metadata returned during verification.", cli.MutuallyExclusive("body")), + cli.StringSlice("roles", "Comma-separated list of role names to assign.", cli.MutuallyExclusive("body")), + cli.StringSlice("permissions", "Comma-separated list of permission names to grant.", cli.MutuallyExclusive("body")), + cli.Int64("expires", "Unix timestamp in milliseconds when the key expires.", cli.MutuallyExclusive("body")), + cli.String("credits", "JSON object of credit and refill configuration.", cli.MutuallyExclusive("body")), + cli.String("ratelimits", "JSON array of rate limit configurations.", cli.MutuallyExclusive("body")), + cli.Bool("enabled", "Whether the key is active for verification.", cli.Default(true), cli.MutuallyExclusive("body")), + cli.Bool("recoverable", "Whether the plaintext key is stored for later retrieval.", cli.Default(false), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -67,7 +67,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/cre return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.CreateKey, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysCreateKeyResponseBody) + } + + send := func(req components.V2KeysCreateKeyRequestBody) error { + res, err := client.Keys.CreateKey(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2KeysCreateKeyResponseBody) + } req := components.V2KeysCreateKeyRequestBody{ APIID: cmd.String("api-id"), Prefix: nil, @@ -100,10 +115,10 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/cre req.ExternalID = &v } - if v := cmd.String("meta-json"); v != "" { + if v := cmd.String("meta"); v != "" { var meta map[string]any if err := json.Unmarshal([]byte(v), &meta); err != nil { - return fmt.Errorf("invalid JSON for --meta-json: %w", err) + return fmt.Errorf("invalid JSON for --meta: %w", err) } req.Meta = meta } @@ -120,27 +135,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/cre req.Expires = &v } - if v := cmd.String("credits-json"); v != "" { + if v := cmd.String("credits"); v != "" { var credits components.KeyCreditsData if err := json.Unmarshal([]byte(v), &credits); err != nil { - return fmt.Errorf("invalid JSON for --credits-json: %w", err) + return fmt.Errorf("invalid JSON for --credits: %w", err) } req.Credits = &credits } - if v := cmd.String("ratelimits-json"); v != "" { + if v := cmd.String("ratelimits"); v != "" { var ratelimits []components.RatelimitRequest if err := json.Unmarshal([]byte(v), &ratelimits); err != nil { - return fmt.Errorf("invalid JSON for --ratelimits-json: %w", err) + return fmt.Errorf("invalid JSON for --ratelimits: %w", err) } req.Ratelimits = ratelimits } - - res, err := client.Keys.CreateKey(ctx, req) - if err != nil { - return fmt.Errorf("%s", util.FormatError(err)) - } - return util.Output(cmd, res.V2KeysCreateKeyResponseBody, time.Since(start)) + return send(req) }, } } diff --git a/cmd/api/keys/create_key_test.go b/cmd/api/keys/create_key_test.go index 215bcf8f48e..d7c803bcd4d 100644 --- a/cmd/api/keys/create_key_test.go +++ b/cmd/api/keys/create_key_test.go @@ -104,7 +104,7 @@ func TestCreateKey(t *testing.T) { }, { name: "with metadata json", - args: `keys create-key --api-id=api_123 --meta-json={"plan":"pro","org":"acme"}`, + args: `keys create-key --api-id=api_123 --meta={"plan":"pro","org":"acme"}`, want: openapi.V2KeysCreateKeyRequestBody{ ApiId: "api_123", Meta: ptr.P(map[string]any{"plan": "pro", "org": "acme"}), @@ -115,7 +115,7 @@ func TestCreateKey(t *testing.T) { }, { name: "with credits json", - args: `keys create-key --api-id=api_123 --credits-json={"remaining":1000}`, + args: `keys create-key --api-id=api_123 --credits={"remaining":1000}`, want: openapi.V2KeysCreateKeyRequestBody{ ApiId: "api_123", Credits: &openapi.KeyCreditsData{ @@ -128,7 +128,7 @@ func TestCreateKey(t *testing.T) { }, { name: "with ratelimits json", - args: `keys create-key --api-id=api_123 --ratelimits-json=[{"name":"req","limit":100,"duration":60000,"autoApply":true}]`, + args: `keys create-key --api-id=api_123 --ratelimits=[{"name":"req","limit":100,"duration":60000,"autoApply":true}]`, want: openapi.V2KeysCreateKeyRequestBody{ ApiId: "api_123", Ratelimits: ptr.P([]openapi.RatelimitRequest{ @@ -141,7 +141,7 @@ func TestCreateKey(t *testing.T) { }, { name: "all flags", - args: `keys create-key --api-id=api_123 --prefix=sk --name=test --byte-length=32 --external-id=user_456 --expires=1700000000000 --enabled=false --recoverable --roles=admin,reader --permissions=docs.read,docs.write --meta-json={"plan":"pro"} --credits-json={"remaining":1000} --ratelimits-json=[{"name":"req","limit":100,"duration":60000,"autoApply":false}]`, + args: `keys create-key --api-id=api_123 --prefix=sk --name=test --byte-length=32 --external-id=user_456 --expires=1700000000000 --enabled=false --recoverable --roles=admin,reader --permissions=docs.read,docs.write --meta={"plan":"pro"} --credits={"remaining":1000} --ratelimits=[{"name":"req","limit":100,"duration":60000,"autoApply":false}]`, want: openapi.V2KeysCreateKeyRequestBody{ ApiId: "api_123", Prefix: ptr.P("sk"), diff --git a/cmd/api/keys/delete_key.go b/cmd/api/keys/delete_key.go index c06d06eda3f..891feabea5a 100644 --- a/cmd/api/keys/delete_key.go +++ b/cmd/api/keys/delete_key.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -33,12 +32,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/del "unkey api keys delete-key --key-id=key_1234abcd --permanent", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to delete.", cli.Required()), - cli.Bool("permanent", "Whether to permanently erase the key instead of soft-deleting.", cli.Default(false)), + cli.String("key-id", "The key ID to delete.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Bool("permanent", "Whether to permanently erase the key instead of soft-deleting.", cli.Default(false), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -46,7 +46,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/del return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.DeleteKey, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysDeleteKeyResponseBody) + } + req := components.V2KeysDeleteKeyRequestBody{ KeyID: cmd.String("key-id"), Permanent: ptr.P(cmd.Bool("permanent")), @@ -56,7 +64,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/del if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysDeleteKeyResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysDeleteKeyResponseBody) }, } } diff --git a/cmd/api/keys/get_key.go b/cmd/api/keys/get_key.go index a1fe7370472..a2b11a0b129 100644 --- a/cmd/api/keys/get_key.go +++ b/cmd/api/keys/get_key.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -36,12 +35,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/get "unkey api keys get-key --key-id=key_1234abcd --decrypt", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to retrieve.", cli.Required()), - cli.Bool("decrypt", "Whether to include the plaintext key value in the response.", cli.Default(false)), + cli.String("key-id", "The key ID to retrieve.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Bool("decrypt", "Whether to include the plaintext key value in the response.", cli.Default(false), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -49,7 +49,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/get return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.GetKey, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysGetKeyResponseBody) + } + req := components.V2KeysGetKeyRequestBody{ KeyID: cmd.String("key-id"), Decrypt: ptr.P(cmd.Bool("decrypt")), @@ -59,7 +67,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/get if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysGetKeyResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysGetKeyResponseBody) }, } } diff --git a/cmd/api/keys/migrate_keys.go b/cmd/api/keys/migrate_keys.go index d9a49cfc6b9..b85781727cf 100644 --- a/cmd/api/keys/migrate_keys.go +++ b/cmd/api/keys/migrate_keys.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -25,16 +24,17 @@ Your root key must have one of the following permissions for basic key informati For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/migrate-api-keys` + util.Disclaimer, Examples: []string{ - `unkey api keys migrate-keys --migration-id=your_company --api-id=api_123456789 --keys-json='[{"hash":"abc123","enabled":true}]'`, + `unkey api keys migrate-keys --migration-id=your_company --api-id=api_123456789 --keys='[{"hash":"abc123","enabled":true}]'`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("migration-id", "Migration provider ID from Unkey support.", cli.Required()), - cli.String("api-id", "The API ID to migrate keys into.", cli.Required()), - cli.String("keys-json", "JSON array of key migration objects.", cli.Required()), + cli.String("migration-id", "Migration provider ID from Unkey support.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("api-id", "The API ID to migrate keys into.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("keys", "JSON array of key migration objects.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -42,11 +42,26 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/mig return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.MigrateKeys, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysMigrateKeysResponseBody) + } + + send := func(req components.V2KeysMigrateKeysRequestBody) error { + res, err := client.Keys.MigrateKeys(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2KeysMigrateKeysResponseBody) + } var keys []components.V2KeysMigrateKeyData - if v := cmd.String("keys-json"); v != "" { + if v := cmd.String("keys"); v != "" { if err := json.Unmarshal([]byte(v), &keys); err != nil { - return fmt.Errorf("invalid JSON for --keys-json: %w", err) + return fmt.Errorf("invalid JSON for --keys: %w", err) } } @@ -56,11 +71,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/mig Keys: keys, } - res, err := client.Keys.MigrateKeys(ctx, req) - if err != nil { - return fmt.Errorf("%s", util.FormatError(err)) - } - return util.Output(cmd, res.V2KeysMigrateKeysResponseBody, time.Since(start)) + return send(req) }, } } diff --git a/cmd/api/keys/migrate_keys_test.go b/cmd/api/keys/migrate_keys_test.go index fcfbf984e42..a6481bb72cf 100644 --- a/cmd/api/keys/migrate_keys_test.go +++ b/cmd/api/keys/migrate_keys_test.go @@ -16,8 +16,8 @@ func TestMigrateKeys(t *testing.T) { want openapi.V2KeysMigrateKeysRequestBody }{ { - name: "with migration-id, api-id, and keys-json", - args: `keys migrate-keys --migration-id=acme_migration --api-id=api_123456789 --keys-json=[{"hash":"abc123","enabled":true}]`, + name: "with migration-id, api-id, and keys", + args: `keys migrate-keys --migration-id=acme_migration --api-id=api_123456789 --keys=[{"hash":"abc123","enabled":true}]`, want: openapi.V2KeysMigrateKeysRequestBody{ MigrationId: "acme_migration", ApiId: "api_123456789", diff --git a/cmd/api/keys/remove_permissions.go b/cmd/api/keys/remove_permissions.go index 1dbd7438569..a6e1c0ec214 100644 --- a/cmd/api/keys/remove_permissions.go +++ b/cmd/api/keys/remove_permissions.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -35,12 +34,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rem "unkey api keys remove-permissions --key-id=key_1234abcd --permissions=documents.read,documents.write", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to remove permissions from.", cli.Required()), - cli.StringSlice("permissions", "Comma-separated list of permission names to remove.", cli.Required()), + cli.String("key-id", "The key ID to remove permissions from.", cli.Required(), cli.MutuallyExclusive("body")), + cli.StringSlice("permissions", "Comma-separated list of permission names to remove.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -48,7 +48,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rem return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.RemovePermissions, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysRemovePermissionsResponseBody) + } + req := components.V2KeysRemovePermissionsRequestBody{ KeyID: cmd.String("key-id"), Permissions: cmd.StringSlice("permissions"), @@ -58,7 +66,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rem if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysRemovePermissionsResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysRemovePermissionsResponseBody) }, } } diff --git a/cmd/api/keys/remove_roles.go b/cmd/api/keys/remove_roles.go index c101c4d5a94..f11d7bc94bb 100644 --- a/cmd/api/keys/remove_roles.go +++ b/cmd/api/keys/remove_roles.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -35,12 +34,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rem "unkey api keys remove-roles --key-id=key_1234abcd --roles=api_admin,billing_reader", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to remove roles from.", cli.Required()), - cli.StringSlice("roles", "Comma-separated list of role names to remove.", cli.Required()), + cli.String("key-id", "The key ID to remove roles from.", cli.Required(), cli.MutuallyExclusive("body")), + cli.StringSlice("roles", "Comma-separated list of role names to remove.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -48,7 +48,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rem return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.RemoveRoles, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysRemoveRolesResponseBody) + } + req := components.V2KeysRemoveRolesRequestBody{ KeyID: cmd.String("key-id"), Roles: cmd.StringSlice("roles"), @@ -58,7 +66,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rem if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysRemoveRolesResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysRemoveRolesResponseBody) }, } } diff --git a/cmd/api/keys/reroll_key.go b/cmd/api/keys/reroll_key.go index 55d250821ed..1877a82bf1c 100644 --- a/cmd/api/keys/reroll_key.go +++ b/cmd/api/keys/reroll_key.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -53,12 +52,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rer "unkey api keys reroll-key --key-id=key_1234abcd --expiration=86400000", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to reroll.", cli.Required()), - cli.Int64("expiration", "Milliseconds until the original key is revoked. 0 for immediate.", cli.Required()), + cli.String("key-id", "The key ID to reroll.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("expiration", "Milliseconds until the original key is revoked. 0 for immediate.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -66,7 +66,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rer return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.RerollKey, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysRerollKeyResponseBody) + } + req := components.V2KeysRerollKeyRequestBody{ KeyID: cmd.String("key-id"), Expiration: cmd.Int64("expiration"), @@ -76,7 +84,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/rer if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysRerollKeyResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysRerollKeyResponseBody) }, } } diff --git a/cmd/api/keys/set_permissions.go b/cmd/api/keys/set_permissions.go index e6d07ab06c3..2cd734f159c 100644 --- a/cmd/api/keys/set_permissions.go +++ b/cmd/api/keys/set_permissions.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -35,12 +34,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/set "unkey api keys set-permissions --key-id=key_1234abcd --permissions=documents.read,documents.write,settings.view", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to set permissions on.", cli.Required()), - cli.StringSlice("permissions", "Comma-separated list of permissions. Replaces all existing permissions.", cli.Required()), + cli.String("key-id", "The key ID to set permissions on.", cli.Required(), cli.MutuallyExclusive("body")), + cli.StringSlice("permissions", "Comma-separated list of permissions. Replaces all existing permissions.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -48,7 +48,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/set return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.SetPermissions, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysSetPermissionsResponseBody) + } + req := components.V2KeysSetPermissionsRequestBody{ KeyID: cmd.String("key-id"), Permissions: cmd.StringSlice("permissions"), @@ -58,7 +66,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/set if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysSetPermissionsResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysSetPermissionsResponseBody) }, } } diff --git a/cmd/api/keys/set_roles.go b/cmd/api/keys/set_roles.go index 1fb4f540089..85954231b9a 100644 --- a/cmd/api/keys/set_roles.go +++ b/cmd/api/keys/set_roles.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -35,12 +34,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/set "unkey api keys set-roles --key-id=key_1234abcd --roles=api_admin,billing_reader", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to set roles on.", cli.Required()), - cli.StringSlice("roles", "Comma-separated list of roles. Replaces all existing roles.", cli.Required()), + cli.String("key-id", "The key ID to set roles on.", cli.Required(), cli.MutuallyExclusive("body")), + cli.StringSlice("roles", "Comma-separated list of roles. Replaces all existing roles.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -48,7 +48,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/set return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.SetRoles, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysSetRolesResponseBody) + } + req := components.V2KeysSetRolesRequestBody{ KeyID: cmd.String("key-id"), Roles: cmd.StringSlice("roles"), @@ -58,7 +66,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/set if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysSetRolesResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysSetRolesResponseBody) }, } } diff --git a/cmd/api/keys/update_credits.go b/cmd/api/keys/update_credits.go index f19218c0e72..3106ae81d73 100644 --- a/cmd/api/keys/update_credits.go +++ b/cmd/api/keys/update_credits.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -37,13 +36,14 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/upd "unkey api keys update-credits --key-id=key_1234abcd --operation=decrement --value=100", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to update credits for.", cli.Required()), - cli.String("operation", "How to modify credits: set, increment, or decrement.", cli.Required()), - cli.Int64("value", "The credit amount for the operation."), + cli.String("key-id", "The key ID to update credits for.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("operation", "How to modify credits: set, increment, or decrement.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("value", "The credit amount for the operation.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -51,7 +51,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/upd return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.UpdateCredits, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysUpdateCreditsResponseBody) + } + req := components.V2KeysUpdateCreditsRequestBody{ KeyID: cmd.String("key-id"), Operation: components.Operation(cmd.String("operation")), @@ -66,7 +74,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/upd if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysUpdateCreditsResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysUpdateCreditsResponseBody) }, } } diff --git a/cmd/api/keys/update_key.go b/cmd/api/keys/update_key.go index b022df5be76..12765ad280a 100644 --- a/cmd/api/keys/update_key.go +++ b/cmd/api/keys/update_key.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -37,25 +36,26 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/upd "unkey api keys update-key --key-id=key_1234abcd --name='Updated Key Name'", "unkey api keys update-key --key-id=key_1234abcd --enabled=false", "unkey api keys update-key --key-id=key_1234abcd --external-id=user_5678 --roles=api_admin,billing_reader", - `unkey api keys update-key --key-id=key_1234abcd --meta-json='{"plan":"enterprise","team":"acme"}'`, - `unkey api keys update-key --key-id=key_1234abcd --credits-json='{"remaining":5000,"refill":{"interval":"monthly","amount":5000}}'`, - `unkey api keys update-key --key-id=key_1234abcd --ratelimits-json='[{"name":"requests","limit":500,"duration":60000,"autoApply":true}]'`, + `unkey api keys update-key --key-id=key_1234abcd --meta='{"plan":"enterprise","team":"acme"}'`, + `unkey api keys update-key --key-id=key_1234abcd --credits='{"remaining":5000,"refill":{"interval":"monthly","amount":5000}}'`, + `unkey api keys update-key --key-id=key_1234abcd --ratelimits='[{"name":"requests","limit":500,"duration":60000,"autoApply":true}]'`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key-id", "The key ID to update.", cli.Required()), - cli.String("name", "Human-readable name for the key."), - cli.String("external-id", "Your system's user or entity identifier."), - cli.String("meta-json", "JSON object of arbitrary metadata."), - cli.Int64("expires", "Unix timestamp in milliseconds when the key expires."), - cli.String("credits-json", "JSON object for credit and refill configuration."), - cli.String("ratelimits-json", "JSON array of rate limit configurations."), - cli.Bool("enabled", "Whether the key is active for verification."), - cli.StringSlice("roles", "Comma-separated list of role names to assign."), - cli.StringSlice("permissions", "Comma-separated list of permission names to grant."), + cli.String("key-id", "The key ID to update.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("name", "Human-readable name for the key.", cli.MutuallyExclusive("body")), + cli.String("external-id", "Your system's user or entity identifier.", cli.MutuallyExclusive("body")), + cli.String("meta", "JSON object of arbitrary metadata.", cli.MutuallyExclusive("body")), + cli.Int64("expires", "Unix timestamp in milliseconds when the key expires.", cli.MutuallyExclusive("body")), + cli.String("credits", "JSON object for credit and refill configuration.", cli.MutuallyExclusive("body")), + cli.String("ratelimits", "JSON array of rate limit configurations.", cli.MutuallyExclusive("body")), + cli.Bool("enabled", "Whether the key is active for verification.", cli.MutuallyExclusive("body")), + cli.StringSlice("roles", "Comma-separated list of role names to assign.", cli.MutuallyExclusive("body")), + cli.StringSlice("permissions", "Comma-separated list of permission names to grant.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -63,7 +63,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/upd return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.UpdateKey, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysUpdateKeyResponseBody) + } + + send := func(req components.V2KeysUpdateKeyRequestBody) error { + res, err := client.Keys.UpdateKey(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2KeysUpdateKeyResponseBody) + } req := components.V2KeysUpdateKeyRequestBody{ KeyID: cmd.String("key-id"), Name: nil, @@ -85,10 +100,10 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/upd req.ExternalID = &v } - if v := cmd.String("meta-json"); v != "" { + if v := cmd.String("meta"); v != "" { var meta map[string]any if err := json.Unmarshal([]byte(v), &meta); err != nil { - return fmt.Errorf("invalid JSON for --meta-json: %w", err) + return fmt.Errorf("invalid JSON for --meta: %w", err) } req.Meta = meta } @@ -97,18 +112,18 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/upd req.Expires = &v } - if v := cmd.String("credits-json"); v != "" { + if v := cmd.String("credits"); v != "" { var credits components.UpdateKeyCreditsData if err := json.Unmarshal([]byte(v), &credits); err != nil { - return fmt.Errorf("invalid JSON for --credits-json: %w", err) + return fmt.Errorf("invalid JSON for --credits: %w", err) } req.Credits = &credits } - if v := cmd.String("ratelimits-json"); v != "" { + if v := cmd.String("ratelimits"); v != "" { var ratelimits []components.RatelimitRequest if err := json.Unmarshal([]byte(v), &ratelimits); err != nil { - return fmt.Errorf("invalid JSON for --ratelimits-json: %w", err) + return fmt.Errorf("invalid JSON for --ratelimits: %w", err) } req.Ratelimits = ratelimits } @@ -124,12 +139,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/upd if v := cmd.StringSlice("permissions"); len(v) > 0 { req.Permissions = v } - - res, err := client.Keys.UpdateKey(ctx, req) - if err != nil { - return fmt.Errorf("%s", util.FormatError(err)) - } - return util.Output(cmd, res.V2KeysUpdateKeyResponseBody, time.Since(start)) + return send(req) }, } } diff --git a/cmd/api/keys/update_key_test.go b/cmd/api/keys/update_key_test.go index cf7ff9378a4..981cc70829b 100644 --- a/cmd/api/keys/update_key_test.go +++ b/cmd/api/keys/update_key_test.go @@ -66,7 +66,7 @@ func TestUpdateKey(t *testing.T) { }, { name: "with metadata json", - args: `keys update-key --key-id=key_123 --meta-json={"tier":"enterprise"}`, + args: `keys update-key --key-id=key_123 --meta={"tier":"enterprise"}`, want: openapi.V2KeysUpdateKeyRequestBody{ KeyId: "key_123", Meta: nullable.NewNullableWithValue(map[string]any{"tier": "enterprise"}), @@ -74,7 +74,7 @@ func TestUpdateKey(t *testing.T) { }, { name: "multiple fields at once", - args: `keys update-key --key-id=key_123 --name=updated --enabled=false --roles=admin --meta-json={"plan":"pro"}`, + args: `keys update-key --key-id=key_123 --name=updated --enabled=false --roles=admin --meta={"plan":"pro"}`, want: openapi.V2KeysUpdateKeyRequestBody{ KeyId: "key_123", Name: nullable.NewNullableWithValue("updated"), diff --git a/cmd/api/keys/verify_key.go b/cmd/api/keys/verify_key.go index 0878b0174aa..beeb5240857 100644 --- a/cmd/api/keys/verify_key.go +++ b/cmd/api/keys/verify_key.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -39,20 +38,21 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/ver "unkey api keys verify-key --key=sk_1234abcdef", "unkey api keys verify-key --key=sk_1234abcdef --permissions='documents.read AND users.view'", "unkey api keys verify-key --key=sk_1234abcdef --tags=endpoint=/users/profile,method=GET", - `unkey api keys verify-key --key=sk_1234abcdef --credits-json='{"cost":5}'`, - `unkey api keys verify-key --key=sk_1234abcdef --ratelimits-json='[{"name":"requests","limit":100,"duration":60000}]'`, + `unkey api keys verify-key --key=sk_1234abcdef --credits='{"cost":5}'`, + `unkey api keys verify-key --key=sk_1234abcdef --ratelimits='[{"name":"requests","limit":100,"duration":60000}]'`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key", "The API key to verify, including any prefix.", cli.Required()), - cli.StringSlice("tags", "Metadata tags for analytics in key=value format."), - cli.String("permissions", "Permission query to check, supports AND/OR operators."), - cli.String("credits-json", "JSON object for credit consumption configuration."), - cli.String("ratelimits-json", "JSON array of rate limit checks to enforce."), - cli.String("migration-id", "Migration provider ID for on-demand key migration."), + cli.String("key", "The API key to verify, including any prefix.", cli.Required(), cli.MutuallyExclusive("body")), + cli.StringSlice("tags", "Metadata tags for analytics in key=value format.", cli.MutuallyExclusive("body")), + cli.String("permissions", "Permission query to check, supports AND/OR operators.", cli.MutuallyExclusive("body")), + cli.String("credits", "JSON object for credit consumption configuration.", cli.MutuallyExclusive("body")), + cli.String("ratelimits", "JSON array of rate limit checks to enforce.", cli.MutuallyExclusive("body")), + cli.String("migration-id", "Migration provider ID for on-demand key migration.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -60,7 +60,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/ver return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.VerifyKey, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysVerifyKeyResponseBody) + } + + send := func(req components.V2KeysVerifyKeyRequestBody) error { + res, err := client.Keys.VerifyKey(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2KeysVerifyKeyResponseBody) + } req := components.V2KeysVerifyKeyRequestBody{ Key: cmd.String("key"), Tags: nil, @@ -78,18 +93,18 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/ver req.Permissions = &v } - if v := cmd.String("credits-json"); v != "" { + if v := cmd.String("credits"); v != "" { var credits components.KeysVerifyKeyCredits if err := json.Unmarshal([]byte(v), &credits); err != nil { - return fmt.Errorf("invalid JSON for --credits-json: %w", err) + return fmt.Errorf("invalid JSON for --credits: %w", err) } req.Credits = &credits } - if v := cmd.String("ratelimits-json"); v != "" { + if v := cmd.String("ratelimits"); v != "" { var ratelimits []components.KeysVerifyKeyRatelimit if err := json.Unmarshal([]byte(v), &ratelimits); err != nil { - return fmt.Errorf("invalid JSON for --ratelimits-json: %w", err) + return fmt.Errorf("invalid JSON for --ratelimits: %w", err) } req.Ratelimits = ratelimits } @@ -97,12 +112,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/ver if v := cmd.String("migration-id"); v != "" { req.MigrationID = &v } - - res, err := client.Keys.VerifyKey(ctx, req) - if err != nil { - return fmt.Errorf("%s", util.FormatError(err)) - } - return util.Output(cmd, res.V2KeysVerifyKeyResponseBody, time.Since(start)) + return send(req) }, } } diff --git a/cmd/api/keys/verify_key_test.go b/cmd/api/keys/verify_key_test.go index d3961b83c1a..827beb2631c 100644 --- a/cmd/api/keys/verify_key_test.go +++ b/cmd/api/keys/verify_key_test.go @@ -40,7 +40,7 @@ func TestVerifyKey(t *testing.T) { }, { name: "with credits json", - args: `keys verify-key --key=sk_1234abcdef --credits-json={"cost":5}`, + args: `keys verify-key --key=sk_1234abcdef --credits={"cost":5}`, want: openapi.V2KeysVerifyKeyRequestBody{ Key: "sk_1234abcdef", Credits: &openapi.KeysVerifyKeyCredits{ @@ -50,7 +50,7 @@ func TestVerifyKey(t *testing.T) { }, { name: "with ratelimits json", - args: `keys verify-key --key=sk_1234abcdef --ratelimits-json=[{"name":"requests","limit":100,"duration":60000}]`, + args: `keys verify-key --key=sk_1234abcdef --ratelimits=[{"name":"requests","limit":100,"duration":60000}]`, want: openapi.V2KeysVerifyKeyRequestBody{ Key: "sk_1234abcdef", Ratelimits: ptr.P([]openapi.KeysVerifyKeyRatelimit{ diff --git a/cmd/api/keys/whoami.go b/cmd/api/keys/whoami.go index e3e5d914bab..01468745726 100644 --- a/cmd/api/keys/whoami.go +++ b/cmd/api/keys/whoami.go @@ -3,7 +3,6 @@ package keys import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -29,11 +28,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/get "unkey api keys whoami --key=sk_1234abcdef5678", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("key", "The full API key string, including any prefix.", cli.Required()), + cli.String("key", "The full API key string, including any prefix.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -41,7 +41,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/get return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Keys.Whoami, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2KeysWhoamiResponseBody) + } + req := components.V2KeysWhoamiRequestBody{ Key: cmd.String("key"), } @@ -50,7 +58,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/keys/get if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2KeysWhoamiResponseBody, time.Since(start)) + return util.Output(cmd, res.V2KeysWhoamiResponseBody) }, } } diff --git a/cmd/api/permissions/create_permission.go b/cmd/api/permissions/create_permission.go index df21d22a629..0742da7b3c9 100644 --- a/cmd/api/permissions/create_permission.go +++ b/cmd/api/permissions/create_permission.go @@ -3,7 +3,6 @@ package permissions import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -29,13 +28,14 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi `unkey api permissions create-permission --name=billing.write --slug=billing-write --description="Grants write access to billing resources"`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("name", "Human-readable name describing the permission's purpose.", cli.Required()), - cli.String("slug", "URL-safe identifier for use in APIs and integrations.", cli.Required()), - cli.String("description", "Detailed documentation of what this permission grants access to."), + cli.String("name", "Human-readable name describing the permission's purpose.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("slug", "URL-safe identifier for use in APIs and integrations.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("description", "Detailed documentation of what this permission grants access to.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -43,6 +43,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Permissions.CreatePermission, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PermissionsCreatePermissionResponseBody) + } + req := components.V2PermissionsCreatePermissionRequestBody{ Name: cmd.String("name"), Slug: cmd.String("slug"), @@ -53,12 +62,11 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi req.Description = &v } - start := time.Now() res, err := client.Permissions.CreatePermission(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2PermissionsCreatePermissionResponseBody, time.Since(start)) + return util.Output(cmd, res.V2PermissionsCreatePermissionResponseBody) }, } } diff --git a/cmd/api/permissions/create_role.go b/cmd/api/permissions/create_role.go index d140cd4f295..dbad95abeca 100644 --- a/cmd/api/permissions/create_role.go +++ b/cmd/api/permissions/create_role.go @@ -3,7 +3,6 @@ package permissions import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -27,12 +26,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi "unkey api permissions create-role --name=api.reader", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("name", "Unique name for the role within your workspace.", cli.Required()), - cli.String("description", "Documentation of what this role encompasses and what access it grants."), + cli.String("name", "Unique name for the role within your workspace.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("description", "Documentation of what this role encompasses and what access it grants.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -40,6 +40,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Permissions.CreateRole, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PermissionsCreateRoleResponseBody) + } + req := components.V2PermissionsCreateRoleRequestBody{ Name: cmd.String("name"), Description: nil, @@ -49,12 +58,11 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi req.Description = &v } - start := time.Now() res, err := client.Permissions.CreateRole(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2PermissionsCreateRoleResponseBody, time.Since(start)) + return util.Output(cmd, res.V2PermissionsCreateRoleResponseBody) }, } } diff --git a/cmd/api/permissions/delete_permission.go b/cmd/api/permissions/delete_permission.go index 5f5e3cbb92f..3aada7cfceb 100644 --- a/cmd/api/permissions/delete_permission.go +++ b/cmd/api/permissions/delete_permission.go @@ -3,7 +3,6 @@ package permissions import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -27,11 +26,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi "unkey api permissions delete-permission --permission=documents.read", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("permission", "The permission ID or slug to permanently delete.", cli.Required()), + cli.String("permission", "The permission ID or slug to permanently delete.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -39,14 +39,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Permissions.DeletePermission, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PermissionsDeletePermissionResponseBody) + } + res, err := client.Permissions.DeletePermission(ctx, components.V2PermissionsDeletePermissionRequestBody{ Permission: cmd.String("permission"), }) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2PermissionsDeletePermissionResponseBody, time.Since(start)) + return util.Output(cmd, res.V2PermissionsDeletePermissionResponseBody) }, } } diff --git a/cmd/api/permissions/delete_role.go b/cmd/api/permissions/delete_role.go index e542d8274ff..14de47f62e3 100644 --- a/cmd/api/permissions/delete_role.go +++ b/cmd/api/permissions/delete_role.go @@ -3,7 +3,6 @@ package permissions import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -27,11 +26,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi "unkey api permissions delete-role --role=admin", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("role", "The role ID or name to permanently delete.", cli.Required()), + cli.String("role", "The role ID or name to permanently delete.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -39,14 +39,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Permissions.DeleteRole, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PermissionsDeleteRoleResponseBody) + } + res, err := client.Permissions.DeleteRole(ctx, components.V2PermissionsDeleteRoleRequestBody{ Role: cmd.String("role"), }) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2PermissionsDeleteRoleResponseBody, time.Since(start)) + return util.Output(cmd, res.V2PermissionsDeleteRoleResponseBody) }, } } diff --git a/cmd/api/permissions/get_permission.go b/cmd/api/permissions/get_permission.go index 5c9f06e18df..fc9edd08b25 100644 --- a/cmd/api/permissions/get_permission.go +++ b/cmd/api/permissions/get_permission.go @@ -3,7 +3,6 @@ package permissions import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -24,11 +23,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi "unkey api permissions get-permission --permission=perm_1234567890abcdef", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("permission", "The unique identifier of the permission to retrieve.", cli.Required()), + cli.String("permission", "The unique identifier of the permission to retrieve.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -36,14 +36,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Permissions.GetPermission, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PermissionsGetPermissionResponseBody) + } + res, err := client.Permissions.GetPermission(ctx, components.V2PermissionsGetPermissionRequestBody{ Permission: cmd.String("permission"), }) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2PermissionsGetPermissionResponseBody, time.Since(start)) + return util.Output(cmd, res.V2PermissionsGetPermissionResponseBody) }, } } diff --git a/cmd/api/permissions/get_role.go b/cmd/api/permissions/get_role.go index 8fc9ee90bc5..6700175d5fd 100644 --- a/cmd/api/permissions/get_role.go +++ b/cmd/api/permissions/get_role.go @@ -3,7 +3,6 @@ package permissions import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -25,11 +24,12 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi `unkey api permissions get-role --role=my-role-name`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("role", "Role ID (starting with role_) or role name to retrieve.", cli.Required()), + cli.String("role", "Role ID (starting with role_) or role name to retrieve.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -37,14 +37,22 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Permissions.GetRole, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PermissionsGetRoleResponseBody) + } + res, err := client.Permissions.GetRole(ctx, components.V2PermissionsGetRoleRequestBody{ Role: cmd.String("role"), }) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2PermissionsGetRoleResponseBody, time.Since(start)) + return util.Output(cmd, res.V2PermissionsGetRoleResponseBody) }, } } diff --git a/cmd/api/permissions/list_permissions.go b/cmd/api/permissions/list_permissions.go index aeb784f21fb..c80c80f8ae2 100644 --- a/cmd/api/permissions/list_permissions.go +++ b/cmd/api/permissions/list_permissions.go @@ -3,7 +3,6 @@ package permissions import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -25,14 +24,17 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi "unkey api permissions list-permissions", "unkey api permissions list-permissions --limit=50", "unkey api permissions list-permissions --limit=50 --cursor=eyJrZXkiOiJwZXJtXzEyMzQifQ==", + "unkey api permissions list-permissions --search=documents", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.Int64("limit", "Maximum number of permissions to return per page."), - cli.String("cursor", "Pagination cursor from a previous response."), + cli.Int64("limit", "Maximum number of permissions to return per page.", cli.MutuallyExclusive("body")), + cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body")), + cli.String("search", "Filter permissions by ID, name, slug, or description.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -40,9 +42,19 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Permissions.ListPermissions, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PermissionsListPermissionsResponseBody) + } + req := components.V2PermissionsListPermissionsRequestBody{ Limit: nil, Cursor: nil, + Search: nil, } if v := cmd.Int64("limit"); v != 0 { @@ -53,12 +65,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi req.Cursor = &v } - start := time.Now() + if v := cmd.String("search"); v != "" { + req.Search = &v + } + res, err := client.Permissions.ListPermissions(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2PermissionsListPermissionsResponseBody, time.Since(start)) + return util.Output(cmd, res.V2PermissionsListPermissionsResponseBody) }, } } diff --git a/cmd/api/permissions/list_permissions_test.go b/cmd/api/permissions/list_permissions_test.go index 8151a34d5d6..a96ad5b9f7c 100644 --- a/cmd/api/permissions/list_permissions_test.go +++ b/cmd/api/permissions/list_permissions_test.go @@ -47,6 +47,23 @@ func TestListPermissions(t *testing.T) { Cursor: ptr.P("eyJrZXkiOiJwZXJtXzU2NzgifQ=="), }, }, + { + name: "with search", + args: "permissions list-permissions --search=documents", + want: openapi.V2PermissionsListPermissionsRequestBody{ + Limit: ptr.P(100), + Search: ptr.P("documents"), + }, + }, + { + name: "with all flags", + args: "permissions list-permissions --limit=25 --cursor=cursor_123 --search=documents", + want: openapi.V2PermissionsListPermissionsRequestBody{ + Limit: ptr.P(25), + Cursor: ptr.P("cursor_123"), + Search: ptr.P("documents"), + }, + }, } for _, tt := range tests { diff --git a/cmd/api/permissions/list_roles.go b/cmd/api/permissions/list_roles.go index 02aaa2de15e..92df222ee32 100644 --- a/cmd/api/permissions/list_roles.go +++ b/cmd/api/permissions/list_roles.go @@ -3,7 +3,6 @@ package permissions import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -25,14 +24,17 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi "unkey api permissions list-roles", "unkey api permissions list-roles --limit=50", "unkey api permissions list-roles --limit=50 --cursor=eyJrZXkiOiJyb2xlXzEyMzQifQ==", + "unkey api permissions list-roles --search=admin", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.Int64("limit", "Maximum number of roles to return per page."), - cli.String("cursor", "Pagination cursor from a previous response."), + cli.Int64("limit", "Maximum number of roles to return per page.", cli.MutuallyExclusive("body")), + cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body")), + cli.String("search", "Filter roles by ID, name, or description.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -40,9 +42,19 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Permissions.ListRoles, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PermissionsListRolesResponseBody) + } + req := components.V2PermissionsListRolesRequestBody{ Limit: nil, Cursor: nil, + Search: nil, } if v := cmd.Int64("limit"); v != 0 { @@ -53,12 +65,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/permissi req.Cursor = &v } - start := time.Now() + if v := cmd.String("search"); v != "" { + req.Search = &v + } + res, err := client.Permissions.ListRoles(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2PermissionsListRolesResponseBody, time.Since(start)) + return util.Output(cmd, res.V2PermissionsListRolesResponseBody) }, } } diff --git a/cmd/api/permissions/list_roles_test.go b/cmd/api/permissions/list_roles_test.go index 0c8b7963c0a..6a6bff44c8e 100644 --- a/cmd/api/permissions/list_roles_test.go +++ b/cmd/api/permissions/list_roles_test.go @@ -47,6 +47,23 @@ func TestListRoles(t *testing.T) { Cursor: ptr.P("eyJrZXkiOiJyb2xlXzU2NzgifQ=="), }, }, + { + name: "with search", + args: "permissions list-roles --search=admin", + want: openapi.V2PermissionsListRolesRequestBody{ + Limit: ptr.P(100), + Search: ptr.P("admin"), + }, + }, + { + name: "with all flags", + args: "permissions list-roles --limit=25 --cursor=cursor_123 --search=admin", + want: openapi.V2PermissionsListRolesRequestBody{ + Limit: ptr.P(25), + Cursor: ptr.P("cursor_123"), + Search: ptr.P("admin"), + }, + }, } for _, tt := range tests { diff --git a/cmd/api/portal/create_session.go b/cmd/api/portal/create_session.go new file mode 100644 index 00000000000..875cfcdd28d --- /dev/null +++ b/cmd/api/portal/create_session.go @@ -0,0 +1,71 @@ +package portal + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" +) + +var portalPermissions = []string{ + string(components.PermissionEnumKeysRead), string(components.PermissionEnumKeysCreate), + string(components.PermissionEnumKeysReroll), string(components.PermissionEnumAnalyticsRead), +} + +func validatePortalPermissions(value string) error { + for _, permission := range strings.Split(value, ",") { + permission = strings.TrimSpace(permission) + if permission != "" && !slices.Contains(portalPermissions, permission) { + return fmt.Errorf("invalid permission %q; valid choices: %s", permission, strings.Join(portalPermissions, ", ")) + } + } + return nil +} + +func createSessionCmd() *cli.Command { + return &cli.Command{Name: "create-session", Usage: "Create a short-lived session token for an end user to access the Customer Portal", Description: `Create a short-lived session token for an end user to access the Customer Portal. + +The returned session ID is valid for 15 minutes and can be exchanged exactly once for a 24-hour browser session via portal.exchangeSession. Redirect the end user to the returned URL to start the portal experience. + +Required Permissions + +Your root key must be associated with a workspace that has an enabled portal configuration. + +` + util.Disclaimer, + Examples: []string{"unkey api portal create-session --slug=my-portal --external-id=user_123 --permissions=keys:read,keys:reroll", "unkey api portal create-session --slug=my-portal --external-id=user_123 --permissions=analytics:read --preview=true"}, + Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("slug", "Portal configuration slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("external-id", "End user's identifier in your system.", cli.Required(), cli.MutuallyExclusive("body")), + cli.StringSlice("permissions", "Portal capabilities. Valid choices: "+strings.Join(portalPermissions, ", ")+".", cli.Required(), cli.Validate(validatePortalPermissions), cli.MutuallyExclusive("body")), cli.Bool("preview", "Create a preview session.", cli.Default(false), cli.MutuallyExclusive("body"))}, + Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Portal.CreateSession, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2PortalCreateSessionResponseBody) + } + values := cmd.StringSlice("permissions") + permissions := make([]components.PermissionEnum, len(values)) + for i, value := range values { + permissions[i] = components.PermissionEnum(value) + } + req := components.V2PortalCreateSessionRequestBody{Slug: cmd.String("slug"), ExternalID: cmd.String("external-id"), Permissions: permissions, Preview: ptr.P(cmd.Bool("preview"))} + res, err := client.Portal.CreateSession(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2PortalCreateSessionResponseBody) + }, + } +} diff --git a/cmd/api/portal/create_session_test.go b/cmd/api/portal/create_session_test.go new file mode 100644 index 00000000000..a466b844437 --- /dev/null +++ b/cmd/api/portal/create_session_test.go @@ -0,0 +1,32 @@ +package portal + +import ( + "context" + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/svc/api/openapi" + "strings" + "testing" +) + +func TestCreateSession(t *testing.T) { + tests := []struct { + name, args string + preview bool + count int + }{{"minimal", "portal create-session --slug=my-portal --external-id=u --permissions=keys:read", false, 1}, {"all flags", "portal create-session --slug=my-portal --external-id=u --permissions=keys:read,analytics:read --preview=true", true, 2}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := util.CaptureRequest[openapi.V2PortalCreateSessionRequestBody](t, Cmd(), tt.args) + require.Equal(t, tt.preview, *got.Preview) + require.Len(t, got.Permissions, tt.count) + }) + } +} + +func TestCreateSessionPermissionValidation(t *testing.T) { + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), strings.Fields("unkey portal create-session --slug=my-portal --external-id=u --permissions=keys:read,keys:delete --root-key=test")) + require.ErrorContains(t, err, `invalid permission "keys:delete"`) +} diff --git a/cmd/api/portal/root.go b/cmd/api/portal/root.go new file mode 100644 index 00000000000..8368798b66e --- /dev/null +++ b/cmd/api/portal/root.go @@ -0,0 +1,10 @@ +package portal + +import ( + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func Cmd() *cli.Command { + return &cli.Command{Name: "portal", Usage: "Manage Customer Portal sessions", Description: "Create Customer Portal sessions using bearer authentication." + util.Disclaimer, Commands: []*cli.Command{createSessionCmd()}} +} diff --git a/cmd/api/projects/create_project.go b/cmd/api/projects/create_project.go new file mode 100644 index 00000000000..e6c2a2efe65 --- /dev/null +++ b/cmd/api/projects/create_project.go @@ -0,0 +1,42 @@ +package projects + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func createProjectCmd() *cli.Command { + return &cli.Command{Name: "create-project", Usage: "Create a project to group deployments and applications under a workspace-scoped slug.", Description: `Create a project to group deployments and applications under a workspace-scoped slug. + +The slug you provide is the stable, caller-defined handle used to reference this project in subsequent operations (get, update, delete). It must be unique within your workspace. + +Important: The slug cannot collide with an existing project in your workspace. A duplicate slug returns a 409 conflict. + +Required Permissions +- project.*.create_project (to create projects in your workspace) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/projects/create-project` + util.Disclaimer, Examples: []string{"unkey api projects create-project --name='Payments Service' --slug=payments-service"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("name", "Human-readable name for this project.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("slug", "Stable project slug.", cli.Required(), cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Projects.CreateProject, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ProjectsCreateProjectResponseBody) + } + req := components.V2ProjectsCreateProjectRequestBody{Name: cmd.String("name"), Slug: cmd.String("slug")} + res, err := client.Projects.CreateProject(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2ProjectsCreateProjectResponseBody) + }} +} diff --git a/cmd/api/projects/create_project_test.go b/cmd/api/projects/create_project_test.go new file mode 100644 index 00000000000..c8f23a63b9b --- /dev/null +++ b/cmd/api/projects/create_project_test.go @@ -0,0 +1,20 @@ +package projects + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestCreateProject(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2ProjectsCreateProjectRequestBody + }{{"all fields", "projects create-project --name=Payments --slug=payments", openapi.V2ProjectsCreateProjectRequestBody{Name: "Payments", Slug: "payments"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequest[openapi.V2ProjectsCreateProjectRequestBody](t, Cmd(), tt.args)) + }) + } +} diff --git a/cmd/api/projects/delete_project.go b/cmd/api/projects/delete_project.go new file mode 100644 index 00000000000..45a8eb88de4 --- /dev/null +++ b/cmd/api/projects/delete_project.go @@ -0,0 +1,43 @@ +package projects + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func deleteProjectCmd() *cli.Command { + return &cli.Command{Name: "delete-project", Usage: "Delete an existing project in your workspace, identified by its id.", Description: `Delete an existing project in your workspace, identified by its id. + +Deletion is asynchronous and eventually consistent. The project and all of its associated resources (apps, environments, deployments, custom domains) are torn down by a background workflow. A successful response indicates the deletion was enqueued, not that every resource has already been removed. + +Projects with delete protection enabled cannot be deleted until protection is disabled. + +Required Permissions +- project.*.delete_project (to delete any project) +- project..delete_project (to delete a specific project) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/projects/delete-project` + util.Disclaimer, Examples: []string{"unkey api projects delete-project --project=proj_1234abcd"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Projects.DeleteProject, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ProjectsDeleteProjectResponseBody) + } + req := components.V2ProjectsDeleteProjectRequestBody{Project: cmd.String("project")} + res, err := client.Projects.DeleteProject(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2ProjectsDeleteProjectResponseBody) + }} +} diff --git a/cmd/api/projects/delete_project_test.go b/cmd/api/projects/delete_project_test.go new file mode 100644 index 00000000000..da8fabed7fe --- /dev/null +++ b/cmd/api/projects/delete_project_test.go @@ -0,0 +1,19 @@ +package projects + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestDeleteProject(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2ProjectsDeleteProjectRequestBody + }{{"by slug", "projects delete-project --project=payments", openapi.V2ProjectsDeleteProjectRequestBody{Project: "payments"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, captureAcceptedRequest[openapi.V2ProjectsDeleteProjectRequestBody](t, Cmd(), tt.args)) + }) + } +} diff --git a/cmd/api/projects/get_project.go b/cmd/api/projects/get_project.go new file mode 100644 index 00000000000..cbac9ead456 --- /dev/null +++ b/cmd/api/projects/get_project.go @@ -0,0 +1,41 @@ +package projects + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +func getProjectCmd() *cli.Command { + return &cli.Command{Name: "get-project", Usage: "Retrieve a single project in your workspace by its id.", Description: `Retrieve a single project in your workspace by its id. + +Use this to fetch project details after creation, verify a project exists before performing operations, or resolve a project's metadata from its id. + +Required Permissions +- project.*.read_project (to read any project) +- project..read_project (to read a specific project) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/projects/get-project` + util.Disclaimer, Examples: []string{"unkey api projects get-project --project=proj_1234abcd"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Projects.GetProject, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ProjectsGetProjectResponseBody) + } + req := components.V2ProjectsGetProjectRequestBody{Project: cmd.String("project")} + res, err := client.Projects.GetProject(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2ProjectsGetProjectResponseBody) + }} +} diff --git a/cmd/api/projects/get_project_test.go b/cmd/api/projects/get_project_test.go new file mode 100644 index 00000000000..b36bf991629 --- /dev/null +++ b/cmd/api/projects/get_project_test.go @@ -0,0 +1,20 @@ +package projects + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestGetProject(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2ProjectsGetProjectRequestBody + }{{"by id", "projects get-project --project=proj_123", openapi.V2ProjectsGetProjectRequestBody{Project: "proj_123"}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequest[openapi.V2ProjectsGetProjectRequestBody](t, Cmd(), tt.args)) + }) + } +} diff --git a/cmd/api/projects/list_projects.go b/cmd/api/projects/list_projects.go new file mode 100644 index 00000000000..e5e79028107 --- /dev/null +++ b/cmd/api/projects/list_projects.go @@ -0,0 +1,47 @@ +package projects + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" +) + +func listProjectsCmd() *cli.Command { + return &cli.Command{Name: "list-projects", Usage: "Retrieve a paginated list of projects in your workspace.", Description: `Retrieve a paginated list of projects in your workspace. + +Use this to build project management dashboards or to enumerate projects for administrative purposes. Results are ordered by project id and returned in pages. When hasMore is true, pass the returned cursor to fetch the next page. + +Required Permissions +- project.*.read_project (to read projects in your workspace) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/projects/list-projects` + util.Disclaimer, Examples: []string{"unkey api projects list-projects", "unkey api projects list-projects --limit=25 --search=billing"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.Int64("limit", "Maximum number of projects to return per request.", cli.Default(int64(100)), cli.MutuallyExclusive("body")), cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body")), cli.String("search", "Free-form text to filter projects.", cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Projects.ListProjects, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ProjectsListProjectsResponseBody) + } + req := components.V2ProjectsListProjectsRequestBody{Limit: ptr.P(cmd.Int64("limit")), Cursor: nil, Search: nil} + if v := cmd.String("cursor"); v != "" { + req.Cursor = &v + } + if v := cmd.String("search"); v != "" { + req.Search = &v + } + res, err := client.Projects.ListProjects(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2ProjectsListProjectsResponseBody) + }} +} diff --git a/cmd/api/projects/list_projects_test.go b/cmd/api/projects/list_projects_test.go new file mode 100644 index 00000000000..b4560e2405c --- /dev/null +++ b/cmd/api/projects/list_projects_test.go @@ -0,0 +1,21 @@ +package projects + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/ptr" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestListProjects(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2ProjectsListProjectsRequestBody + }{{"defaults", "projects list-projects", openapi.V2ProjectsListProjectsRequestBody{Limit: ptr.P(100)}}, {"all options", "projects list-projects --limit=10 --cursor=proj_1 --search=billing", openapi.V2ProjectsListProjectsRequestBody{Limit: ptr.P(10), Cursor: ptr.P("proj_1"), Search: ptr.P("billing")}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequestWithResponse[openapi.V2ProjectsListProjectsRequestBody](t, Cmd(), tt.args, `{"meta":{"requestId":"test"},"data":[],"pagination":{"hasMore":false}}`)) + }) + } +} diff --git a/cmd/api/projects/root.go b/cmd/api/projects/root.go new file mode 100644 index 00000000000..c056dbb6aab --- /dev/null +++ b/cmd/api/projects/root.go @@ -0,0 +1,13 @@ +package projects + +import ( + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" +) + +// Cmd groups all projects.* subcommands. +func Cmd() *cli.Command { + return &cli.Command{Name: "projects", Usage: "Manage projects", Description: "Create, read, update, and delete workspace projects." + util.Disclaimer, Commands: []*cli.Command{ + createProjectCmd(), deleteProjectCmd(), getProjectCmd(), listProjectsCmd(), updateProjectCmd(), + }} +} diff --git a/cmd/api/projects/test_helpers_test.go b/cmd/api/projects/test_helpers_test.go new file mode 100644 index 00000000000..731d564cc8c --- /dev/null +++ b/cmd/api/projects/test_helpers_test.go @@ -0,0 +1,59 @@ +package projects + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/unkeyed/unkey/pkg/cli" +) + +func captureAcceptedRequest[T any](t *testing.T, cmd *cli.Command, args string) T { + t.Helper() + var body []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + var err error + body, err = io.ReadAll(request.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"meta":{"requestId":"test"},"data":{}}`)) + })) + t.Cleanup(server.Close) + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + t.Cleanup(func() { + os.Stdout = original + _ = writer.Close() + _ = reader.Close() + }) + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{cmd}} + runErr := root.Run(context.Background(), strings.Fields(fmt.Sprintf("unkey %s --api-url=%s --root-key=test", args, server.URL))) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + os.Stdout = original + _, _ = io.Copy(&bytes.Buffer{}, reader) + if runErr != nil { + t.Fatalf("CLI command failed: %v", runErr) + } + var request T + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + return request +} diff --git a/cmd/api/projects/update_project.go b/cmd/api/projects/update_project.go new file mode 100644 index 00000000000..493055df08b --- /dev/null +++ b/cmd/api/projects/update_project.go @@ -0,0 +1,51 @@ +package projects + +import ( + "context" + "fmt" + "github.com/unkeyed/sdks/api/go/v2/models/components" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/cli" + "github.com/unkeyed/unkey/pkg/ptr" +) + +func updateProjectCmd() *cli.Command { + return &cli.Command{Name: "update-project", Usage: "Update an existing project in your workspace, identified by its id.", Description: `Update an existing project in your workspace, identified by its id. + +The project name, slug, and delete protection setting can be changed. Omitted fields are left unchanged. Changing the slug affects the deployment domains generated for this project. + +Required Permissions +- project.*.update_project (to update any project) +- project..update_project (to update a specific project) + +For full documentation, see https://www.unkey.com/docs/api-reference/v2/projects/update-project` + util.Disclaimer, Examples: []string{"unkey api projects update-project --project=proj_1234abcd --name='Payments API'", "unkey api projects update-project --project=payments --delete-protection=true"}, Flags: []cli.Flag{cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), cli.String("project", "Project ID or slug.", cli.Required(), cli.MutuallyExclusive("body")), cli.String("slug", "New project slug.", cli.MutuallyExclusive("body")), cli.String("name", "New human-readable name for the project.", cli.MutuallyExclusive("body")), cli.Bool("delete-protection", "Enable or disable delete protection for the project.", cli.MutuallyExclusive("body"))}, Action: func(ctx context.Context, cmd *cli.Command) error { + client, err := util.CreateClient(cmd) + if err != nil { + return err + } + + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Projects.UpdateProject, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2ProjectsUpdateProjectResponseBody) + } + req := components.V2ProjectsUpdateProjectRequestBody{Project: cmd.String("project"), Slug: nil, Name: nil, DeleteProtection: nil} + if v := cmd.String("slug"); v != "" { + req.Slug = &v + } + if v := cmd.String("name"); v != "" { + req.Name = &v + } + if cmd.FlagIsSet("delete-protection") { + req.DeleteProtection = ptr.P(cmd.Bool("delete-protection")) + } + res, err := client.Projects.UpdateProject(ctx, req) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2ProjectsUpdateProjectResponseBody) + }} +} diff --git a/cmd/api/projects/update_project_test.go b/cmd/api/projects/update_project_test.go new file mode 100644 index 00000000000..9234ab94633 --- /dev/null +++ b/cmd/api/projects/update_project_test.go @@ -0,0 +1,21 @@ +package projects + +import ( + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/cmd/api/util" + "github.com/unkeyed/unkey/pkg/ptr" + "github.com/unkeyed/unkey/svc/api/openapi" + "testing" +) + +func TestUpdateProject(t *testing.T) { + tests := []struct { + name, args string + want openapi.V2ProjectsUpdateProjectRequestBody + }{{"omits optional fields", "projects update-project --project=proj_1", openapi.V2ProjectsUpdateProjectRequestBody{Project: "proj_1"}}, {"all options including false boolean", "projects update-project --project=proj_1 --slug=pay --name=Payments --delete-protection=false", openapi.V2ProjectsUpdateProjectRequestBody{Project: "proj_1", Slug: ptr.P("pay"), Name: ptr.P("Payments"), DeleteProtection: ptr.P(false)}}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, util.CaptureRequest[openapi.V2ProjectsUpdateProjectRequestBody](t, Cmd(), tt.args)) + }) + } +} diff --git a/cmd/api/ratelimit/delete_override.go b/cmd/api/ratelimit/delete_override.go index cba56c2bdc6..d44694068c0 100644 --- a/cmd/api/ratelimit/delete_override.go +++ b/cmd/api/ratelimit/delete_override.go @@ -3,7 +3,6 @@ package ratelimit import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -30,12 +29,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi `unkey api ratelimit delete-override --namespace=api.requests --identifier="premium_*"`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("namespace", "The id or name of the namespace containing the override.", cli.Required()), - cli.String("identifier", "The exact identifier pattern of the override to delete.", cli.Required()), + cli.String("namespace", "The id or name of the namespace containing the override.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("identifier", "The exact identifier pattern of the override to delete.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -43,7 +43,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Ratelimit.DeleteOverride, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2RatelimitDeleteOverrideResponseBody) + } + res, err := client.Ratelimit.DeleteOverride(ctx, components.V2RatelimitDeleteOverrideRequestBody{ Namespace: cmd.String("namespace"), Identifier: cmd.String("identifier"), @@ -51,7 +59,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2RatelimitDeleteOverrideResponseBody, time.Since(start)) + return util.Output(cmd, res.V2RatelimitDeleteOverrideResponseBody) }, } } diff --git a/cmd/api/ratelimit/get_override.go b/cmd/api/ratelimit/get_override.go index adcfc4999cc..337de117c2f 100644 --- a/cmd/api/ratelimit/get_override.go +++ b/cmd/api/ratelimit/get_override.go @@ -3,7 +3,6 @@ package ratelimit import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -30,12 +29,13 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi `unkey api ratelimit get-override --namespace=api.requests --identifier="premium_*"`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("namespace", "The id or name of the namespace containing the override.", cli.Required()), - cli.String("identifier", "The exact identifier pattern of the override to retrieve.", cli.Required()), + cli.String("namespace", "The id or name of the namespace containing the override.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("identifier", "The exact identifier pattern of the override to retrieve.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -43,7 +43,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Ratelimit.GetOverride, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2RatelimitGetOverrideResponseBody) + } + res, err := client.Ratelimit.GetOverride(ctx, components.V2RatelimitGetOverrideRequestBody{ Namespace: cmd.String("namespace"), Identifier: cmd.String("identifier"), @@ -51,7 +59,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2RatelimitGetOverrideResponseBody, time.Since(start)) + return util.Output(cmd, res.V2RatelimitGetOverrideResponseBody) }, } } diff --git a/cmd/api/ratelimit/limit.go b/cmd/api/ratelimit/limit.go index 21a30c9ca30..18917ee5e07 100644 --- a/cmd/api/ratelimit/limit.go +++ b/cmd/api/ratelimit/limit.go @@ -3,7 +3,6 @@ package ratelimit import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -33,15 +32,16 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi "unkey api ratelimit limit --namespace=api.heavy_operations --identifier=user_def456 --limit=50 --duration=3600000 --cost=5", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("namespace", "The id or name of the namespace.", cli.Required()), - cli.String("identifier", "The entity being rate limited (user ID, IP, etc.).", cli.Required()), - cli.Int64("limit", "Maximum operations allowed within the duration window.", cli.Required()), - cli.Int64("duration", "Rate limit window duration in milliseconds.", cli.Required()), - cli.Int64("cost", "How much quota this request consumes (default 1)."), + cli.String("namespace", "The id or name of the namespace.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("identifier", "The entity being rate limited (user ID, IP, etc.).", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("limit", "Maximum operations allowed within the duration window.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("duration", "Rate limit window duration in milliseconds.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("cost", "How much quota this request consumes (default 1).", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -49,6 +49,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Ratelimit.Limit, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2RatelimitLimitResponseBody) + } + req := components.V2RatelimitLimitRequestBody{ Namespace: cmd.String("namespace"), Identifier: cmd.String("identifier"), @@ -61,12 +70,11 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi req.Cost = &v } - start := time.Now() res, err := client.Ratelimit.Limit(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2RatelimitLimitResponseBody, time.Since(start)) + return util.Output(cmd, res.V2RatelimitLimitResponseBody) }, } } diff --git a/cmd/api/ratelimit/list_overrides.go b/cmd/api/ratelimit/list_overrides.go index 7b8e7146a06..1dd59573e49 100644 --- a/cmd/api/ratelimit/list_overrides.go +++ b/cmd/api/ratelimit/list_overrides.go @@ -3,7 +3,6 @@ package ratelimit import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -31,13 +30,14 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi "unkey api ratelimit list-overrides --namespace=api.requests --cursor=cursor_eyJsYXN0SWQiOiJvdnJfM2RITGNOeVN6SnppRHlwMkpla2E5ciJ9", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("namespace", "The ID or name of the rate limit namespace.", cli.Required()), - cli.Int64("limit", "Maximum number of overrides to return per page."), - cli.String("cursor", "Pagination cursor from a previous response."), + cli.String("namespace", "The ID or name of the rate limit namespace.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("limit", "Maximum number of overrides to return per page.", cli.MutuallyExclusive("body")), + cli.String("cursor", "Pagination cursor from a previous response.", cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -45,6 +45,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi return err } + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Ratelimit.ListOverrides, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2RatelimitListOverridesResponseBody) + } + req := components.V2RatelimitListOverridesRequestBody{ Namespace: cmd.String("namespace"), Cursor: nil, @@ -59,12 +68,11 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi req.Cursor = &v } - start := time.Now() res, err := client.Ratelimit.ListOverrides(ctx, req) if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2RatelimitListOverridesResponseBody, time.Since(start)) + return util.Output(cmd, res.V2RatelimitListOverridesResponseBody) }, } } diff --git a/cmd/api/ratelimit/multi_limit.go b/cmd/api/ratelimit/multi_limit.go index ae34be94034..34c50929d06 100644 --- a/cmd/api/ratelimit/multi_limit.go +++ b/cmd/api/ratelimit/multi_limit.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -29,15 +28,16 @@ Your root key must have one of the following permissions: For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimit/apply-multiple-rate-limit-checks` + util.Disclaimer, Examples: []string{ - `unkey api ratelimit multi-limit --limits-json='[{"namespace":"api.requests","identifier":"user_abc123","limit":100,"duration":60000},{"namespace":"auth.login","identifier":"user_abc123","limit":5,"duration":60000}]'`, - `unkey api ratelimit multi-limit --limits-json='[{"namespace":"api.light_operations","identifier":"user_xyz789","limit":100,"duration":60000,"cost":1},{"namespace":"api.heavy_operations","identifier":"user_xyz789","limit":50,"duration":3600000,"cost":5}]'`, + `unkey api ratelimit multi-limit --limits='[{"namespace":"api.requests","identifier":"user_abc123","limit":100,"duration":60000},{"namespace":"auth.login","identifier":"user_abc123","limit":5,"duration":60000}]'`, + `unkey api ratelimit multi-limit --limits='[{"namespace":"api.light_operations","identifier":"user_xyz789","limit":100,"duration":60000,"cost":1},{"namespace":"api.heavy_operations","identifier":"user_xyz789","limit":50,"duration":3600000,"cost":5}]'`, }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("limits-json", "JSON array of rate limit check objects.", cli.Required()), + cli.String("limits", "JSON array of rate limit check objects.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -45,17 +45,26 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi return err } - var limits []components.V2RatelimitLimitRequestBody - if err := json.Unmarshal([]byte(cmd.String("limits-json")), &limits); err != nil { - return fmt.Errorf("invalid JSON for --limits-json: %w", err) + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Ratelimit.MultiLimit, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2RatelimitMultiLimitResponseBody) } - - start := time.Now() - res, err := client.Ratelimit.MultiLimit(ctx, limits) - if err != nil { - return fmt.Errorf("%s", util.FormatError(err)) + send := func(limits []components.V2RatelimitLimitRequestBody) error { + res, err := client.Ratelimit.MultiLimit(ctx, limits) + if err != nil { + return fmt.Errorf("%s", util.FormatError(err)) + } + return util.Output(cmd, res.V2RatelimitMultiLimitResponseBody) + } + var limits []components.V2RatelimitLimitRequestBody + if err := json.Unmarshal([]byte(cmd.String("limits")), &limits); err != nil { + return fmt.Errorf("invalid JSON for --limits: %w", err) } - return util.Output(cmd, res.V2RatelimitMultiLimitResponseBody, time.Since(start)) + return send(limits) }, } } diff --git a/cmd/api/ratelimit/multi_limit_test.go b/cmd/api/ratelimit/multi_limit_test.go index da2c6370152..1312d640830 100644 --- a/cmd/api/ratelimit/multi_limit_test.go +++ b/cmd/api/ratelimit/multi_limit_test.go @@ -17,7 +17,7 @@ func TestMultiLimit(t *testing.T) { }{ { name: "single limit", - args: `ratelimit multi-limit --limits-json=[{"namespace":"api.requests","identifier":"user_abc123","limit":100,"duration":60000}]`, + args: `ratelimit multi-limit --limits=[{"namespace":"api.requests","identifier":"user_abc123","limit":100,"duration":60000}]`, want: []openapi.V2RatelimitLimitRequestBody{ { Namespace: "api.requests", @@ -30,7 +30,7 @@ func TestMultiLimit(t *testing.T) { }, { name: "two limits with cost", - args: `ratelimit multi-limit --limits-json=[{"namespace":"api.light_operations","identifier":"user_xyz789","limit":100,"duration":60000,"cost":1},{"namespace":"api.heavy_operations","identifier":"user_xyz789","limit":50,"duration":3600000,"cost":5}]`, + args: `ratelimit multi-limit --limits=[{"namespace":"api.light_operations","identifier":"user_xyz789","limit":100,"duration":60000,"cost":1},{"namespace":"api.heavy_operations","identifier":"user_xyz789","limit":50,"duration":3600000,"cost":5}]`, want: []openapi.V2RatelimitLimitRequestBody{ { Namespace: "api.light_operations", diff --git a/cmd/api/ratelimit/set_override.go b/cmd/api/ratelimit/set_override.go index 99e2208dcfe..ef8b918941e 100644 --- a/cmd/api/ratelimit/set_override.go +++ b/cmd/api/ratelimit/set_override.go @@ -3,7 +3,6 @@ package ratelimit import ( "context" "fmt" - "time" "github.com/unkeyed/sdks/api/go/v2/models/components" "github.com/unkeyed/unkey/cmd/api/util" @@ -30,14 +29,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi "unkey api ratelimit set-override --namespace=api.requests --identifier='premium_*' --limit=500 --duration=60000", }, Flags: []cli.Flag{ + cli.String("body", "Decode this JSON as the endpoint request body. Request-building flags are mutually exclusive."), util.RootKeyFlag(), util.APIURLFlag(), util.ConfigFlag(), util.OutputFlag(), - cli.String("namespace", "The ID or name of the rate limit namespace.", cli.Required()), - cli.String("identifier", "Identifier of the entity receiving this custom rate limit.", cli.Required()), - cli.Int64("limit", "Maximum number of requests allowed for this override.", cli.Required()), - cli.Int64("duration", "Duration in milliseconds for the rate limit window.", cli.Required()), + cli.String("namespace", "The ID or name of the rate limit namespace.", cli.Required(), cli.MutuallyExclusive("body")), + cli.String("identifier", "Identifier of the entity receiving this custom rate limit.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("limit", "Maximum number of requests allowed for this override.", cli.Required(), cli.MutuallyExclusive("body")), + cli.Int64("duration", "Duration in milliseconds for the rate limit window.", cli.Required(), cli.MutuallyExclusive("body")), }, Action: func(ctx context.Context, cmd *cli.Command) error { client, err := util.CreateClient(cmd) @@ -45,7 +45,15 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi return err } - start := time.Now() + if cmd.FlagIsSet("body") { + body := cmd.String("body") + res, err := util.SendBody(ctx, client.Ratelimit.SetOverride, body) + if err != nil { + return err + } + return util.Output(cmd, res.V2RatelimitSetOverrideResponseBody) + } + res, err := client.Ratelimit.SetOverride(ctx, components.V2RatelimitSetOverrideRequestBody{ Namespace: cmd.String("namespace"), Identifier: cmd.String("identifier"), @@ -55,7 +63,7 @@ For full documentation, see https://www.unkey.com/docs/api-reference/v2/ratelimi if err != nil { return fmt.Errorf("%s", util.FormatError(err)) } - return util.Output(cmd, res.V2RatelimitSetOverrideResponseBody, time.Since(start)) + return util.Output(cmd, res.V2RatelimitSetOverrideResponseBody) }, } } diff --git a/cmd/api/root.go b/cmd/api/root.go index de4e3f63558..8fec9ff4c56 100644 --- a/cmd/api/root.go +++ b/cmd/api/root.go @@ -3,9 +3,15 @@ package api import ( "github.com/unkeyed/unkey/cmd/api/analytics" "github.com/unkeyed/unkey/cmd/api/apis" + "github.com/unkeyed/unkey/cmd/api/apps" + "github.com/unkeyed/unkey/cmd/api/deployments" + "github.com/unkeyed/unkey/cmd/api/environments" + "github.com/unkeyed/unkey/cmd/api/gateway" "github.com/unkeyed/unkey/cmd/api/identities" "github.com/unkeyed/unkey/cmd/api/keys" "github.com/unkeyed/unkey/cmd/api/permissions" + "github.com/unkeyed/unkey/cmd/api/portal" + "github.com/unkeyed/unkey/cmd/api/projects" "github.com/unkeyed/unkey/cmd/api/ratelimit" "github.com/unkeyed/unkey/cmd/api/util" "github.com/unkeyed/unkey/pkg/cli" @@ -16,13 +22,19 @@ func Cmd() *cli.Command { return &cli.Command{ Name: "api", Usage: "Interact with the Unkey API", - Description: "Manage APIs, keys, identities, permissions, rate limits, and analytics." + util.Disclaimer, + Description: "Manage Unkey resources through the public API." + util.Disclaimer, Commands: []*cli.Command{ analytics.Cmd(), apis.Cmd(), + apps.Cmd(), + deployments.Cmd(), + environments.Cmd(), + gateway.Cmd(), identities.Cmd(), keys.Cmd(), permissions.Cmd(), + portal.Cmd(), + projects.Cmd(), ratelimit.Cmd(), }, } diff --git a/cmd/api/root_test.go b/cmd/api/root_test.go new file mode 100644 index 00000000000..9613b90ce5f --- /dev/null +++ b/cmd/api/root_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/pkg/cli" +) + +func TestEveryAPILeafHasOneBodyFlag(t *testing.T) { + var visit func(*cli.Command) + visit = func(command *cli.Command) { + if len(command.Commands) > 0 { + for _, child := range command.Commands { + visit(child) + } + return + } + count := 0 + for _, flag := range command.Flags { + if flag.Name() == "body" { + count++ + } + } + require.Equal(t, 1, count, command.Name) + } + visit(Cmd()) +} + +func TestTypedBodyRejectsRequestFlags(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + called = true + })) + t.Cleanup(server.Close) + + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), []string{"unkey", "api", "apis", "create-api", `--body={"name":"from-body"}`, "--name=ignored", "--api-url=" + server.URL, "--root-key=test", "--output=json"}) + require.ErrorContains(t, err, "flags --name and --body are mutually exclusive") + require.False(t, called) +} + +func TestBodyRejectsInvalidJSONLocally(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + called = true + })) + t.Cleanup(server.Close) + + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), []string{"unkey", "api", "apis", "create-api", "--body={", "--api-url=" + server.URL, "--root-key=test"}) + require.ErrorContains(t, err, "invalid JSON for --body") + require.False(t, called) +} + +func TestExplicitEmptyBodyReachesJSONDecoding(t *testing.T) { + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), []string{"unkey", "api", "apis", "create-api", "--body=", "--root-key=test"}) + require.ErrorContains(t, err, "invalid JSON for --body") +} + +func TestUpdatePolicyBodyPreservesMatchClear(t *testing.T) { + var got []byte + var readErr error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + got, readErr = io.ReadAll(request.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"meta":{"requestId":"test"},"data":{}}`)) + })) + t.Cleanup(server.Close) + + body := `{"project":"payments","app":"payments-api","environment":"production","policyId":"pol_123","match":null}` + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + err := root.Run(context.Background(), []string{"unkey", "api", "gateway", "update-policy", "--body=" + body, "--api-url=" + server.URL, "--root-key=test"}) + require.NoError(t, err) + require.NoError(t, readErr) + + var request map[string]json.RawMessage + require.NoError(t, json.Unmarshal(got, &request)) + require.JSONEq(t, `[]`, string(request["match"])) +} + +func TestBodyBypassesSpecialRequestConstruction(t *testing.T) { + tests := []struct { + name string + command []string + body string + statusCode int + }{ + { + name: "deployment union", + command: []string{"deployments", "create-deployment"}, + body: `{"project":"project","app":"app","environment":"production","git":{"branch":"main"}}`, + statusCode: http.StatusCreated, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + var err error + got, err = io.ReadAll(request.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + _, err = w.Write([]byte("{\"meta\":{\"requestId\":\"test\"},\"data\":{}}")) + require.NoError(t, err) + })) + t.Cleanup(server.Close) + + args := []string{"unkey", "api"} + args = append(args, tt.command...) + args = append(args, "--body="+tt.body, "--api-url="+server.URL, "--root-key=test") + root := &cli.Command{Name: "unkey", Commands: []*cli.Command{Cmd()}} + require.NoError(t, root.Run(context.Background(), args)) + require.NotEmpty(t, got) + }) + } +} diff --git a/cmd/api/util/body.go b/cmd/api/util/body.go new file mode 100644 index 00000000000..c80fc433cd0 --- /dev/null +++ b/cmd/api/util/body.go @@ -0,0 +1,42 @@ +package util + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/unkeyed/sdks/api/go/v2/models/operations" +) + +// SendBody decodes a JSON body and sends it using the generated SDK method. +func SendBody[Request, Response any]( + ctx context.Context, + send func(context.Context, Request, ...operations.Option) (*Response, error), + body string, +) (*Response, error) { + if strings.TrimSpace(body) == "null" { + return nil, fmt.Errorf("invalid JSON for --body: top-level null is not a request body") + } + + var request Request + decoder := json.NewDecoder(strings.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + return nil, fmt.Errorf("invalid JSON for --body: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("invalid JSON for --body: multiple JSON values") + } + return nil, fmt.Errorf("invalid JSON for --body: %w", err) + } + + response, err := send(ctx, request) + if err != nil { + return nil, fmt.Errorf("%s", FormatError(err)) + } + + return response, nil +} diff --git a/cmd/api/util/body_test.go b/cmd/api/util/body_test.go new file mode 100644 index 00000000000..54f54596c55 --- /dev/null +++ b/cmd/api/util/body_test.go @@ -0,0 +1,81 @@ +package util + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/sdks/api/go/v2/models/operations" +) + +func TestSendBody(t *testing.T) { + type request struct { + Name string `json:"name"` + Limit int64 `json:"limit"` + } + type response struct { + ID string + } + + t.Run("typed valid body", func(t *testing.T) { + got, err := SendBody(context.Background(), func(_ context.Context, req request, _ ...operations.Option) (*response, error) { + require.Equal(t, request{Name: "production", Limit: 42}, req) + return &response{ID: "res_123"}, nil + }, `{"name":"production","limit":42}`) + require.NoError(t, err) + require.Equal(t, &response{ID: "res_123"}, got) + }) + + t.Run("malformed body is not sent", func(t *testing.T) { + called := false + got, err := SendBody(context.Background(), func(_ context.Context, _ request, _ ...operations.Option) (*response, error) { + called = true + return &response{}, nil + }, `{"name":`) + require.ErrorContains(t, err, "invalid JSON for --body") + require.False(t, called) + require.Nil(t, got) + }) + + t.Run("unknown field is not sent", func(t *testing.T) { + called := false + got, err := SendBody(context.Background(), func(_ context.Context, _ request, _ ...operations.Option) (*response, error) { + called = true + return &response{}, nil + }, `{"name":"production","limti":42}`) + require.ErrorContains(t, err, `invalid JSON for --body: json: unknown field "limti"`) + require.False(t, called) + require.Nil(t, got) + }) + + t.Run("multiple values are not sent", func(t *testing.T) { + called := false + got, err := SendBody(context.Background(), func(_ context.Context, _ request, _ ...operations.Option) (*response, error) { + called = true + return &response{}, nil + }, `{} {}`) + require.EqualError(t, err, "invalid JSON for --body: multiple JSON values") + require.False(t, called) + require.Nil(t, got) + }) + + t.Run("top-level null is not sent", func(t *testing.T) { + called := false + got, err := SendBody(context.Background(), func(_ context.Context, _ request, _ ...operations.Option) (*response, error) { + called = true + return &response{}, nil + }, `null`) + require.EqualError(t, err, "invalid JSON for --body: top-level null is not a request body") + require.False(t, called) + require.Nil(t, got) + }) + + t.Run("SDK error formatting", func(t *testing.T) { + got, err := SendBody(context.Background(), func(_ context.Context, _ request, _ ...operations.Option) (*response, error) { + return nil, errors.New("request failed") + }, `{}`) + require.EqualError(t, err, "request failed") + require.Nil(t, got) + }) +} diff --git a/cmd/api/util/output.go b/cmd/api/util/output.go index 98a5685f250..2ecb8987315 100644 --- a/cmd/api/util/output.go +++ b/cmd/api/util/output.go @@ -4,15 +4,14 @@ import ( "encoding/json" "fmt" "os" - "time" "github.com/unkeyed/unkey/pkg/cli" ) // Output prints the API response to stdout. With -o json, it prints the full -// response envelope as-is for piping. Otherwise it prints the request ID with -// latency, followed by the data payload as indented JSON. -func Output(cmd *cli.Command, v any, latency time.Duration) error { +// response envelope as-is for piping. Otherwise it prints the request ID, +// followed by the data payload as indented JSON. +func Output(cmd *cli.Command, v any) error { if cmd.String("output") == "json" { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -37,7 +36,7 @@ func Output(cmd *cli.Command, v any, latency time.Duration) error { } if envelope.Meta.RequestID != "" { - fmt.Printf("%s (took %s)\n\n", envelope.Meta.RequestID, latency.Round(time.Millisecond)) + fmt.Printf("%s\n\n", envelope.Meta.RequestID) } indented, err := json.MarshalIndent(json.RawMessage(envelope.Data), "", " ") diff --git a/cmd/api/util/testharness.go b/cmd/api/util/testharness.go index 25278b23c2e..e8ce5368a48 100644 --- a/cmd/api/util/testharness.go +++ b/cmd/api/util/testharness.go @@ -56,6 +56,11 @@ func CaptureRequestWithResponse[T any](t *testing.T, cmd *cli.Command, args stri t.Fatalf("failed to create pipe: %v", err) } os.Stdout = w + t.Cleanup(func() { + os.Stdout = origStdout + _ = w.Close() + _ = r.Close() + }) fullArgs := fmt.Sprintf("unkey %s --api-url=%s --root-key=test_key", args, srv.URL) root := &cli.Command{ diff --git a/docs/product/cli/analytics/get-verifications.mdx b/docs/product/cli/analytics/get-verifications.mdx index 5a1c6a50780..f0914248b12 100644 --- a/docs/product/cli/analytics/get-verifications.mdx +++ b/docs/product/cli/analytics/get-verifications.mdx @@ -36,6 +36,7 @@ SQL `SELECT` query to execute against your analytics data. Only `SELECT` queries | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -53,10 +54,10 @@ unkey api analytics get-verifications --query="SELECT outcome, COUNT(*) as total ## Output -Default output shows the request ID with latency, followed by the query results: +Default output shows the request ID, followed by the query results: ```text -req_2c9a0jf23l4k567 (took 120ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/apis/create-api.mdx b/docs/product/cli/apis/create-api.mdx index 4681e7f1920..fb19c6fce4d 100644 --- a/docs/product/cli/apis/create-api.mdx +++ b/docs/product/cli/apis/create-api.mdx @@ -36,6 +36,7 @@ Unique identifier for this API namespace within your workspace. Use descriptive | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -54,10 +55,10 @@ unkey api keys create-key --api-id=$API_ID ## Output -Default output shows the request ID with latency, followed by the created API: +Default output shows the request ID, followed by the created API: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "id": "api_1234abcd", diff --git a/docs/product/cli/apis/delete-api.mdx b/docs/product/cli/apis/delete-api.mdx index e1424aa0c9e..a0488af1591 100644 --- a/docs/product/cli/apis/delete-api.mdx +++ b/docs/product/cli/apis/delete-api.mdx @@ -39,6 +39,7 @@ Before proceeding, ensure you have the correct API ID and understand that this a | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -57,10 +58,10 @@ unkey api apis delete-api --api-id=$API_ID ## Output -Default output shows the request ID with latency: +Default output shows the request ID: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 {} ``` diff --git a/docs/product/cli/apis/get-api.mdx b/docs/product/cli/apis/get-api.mdx index 4669d4033b0..6911072a877 100644 --- a/docs/product/cli/apis/get-api.mdx +++ b/docs/product/cli/apis/get-api.mdx @@ -35,6 +35,7 @@ Specifies which API to retrieve by its unique identifier. Must be a valid API ID | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -52,10 +53,10 @@ unkey api apis get-api --api-id=api_1234abcd && unkey api keys create-key --api- ## Output -Default output shows the request ID with latency, followed by the API information: +Default output shows the request ID, followed by the API information: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "id": "api_1234abcd", diff --git a/docs/product/cli/apis/list-keys.mdx b/docs/product/cli/apis/list-keys.mdx index f9df0fe1f54..6ac1cc2270b 100644 --- a/docs/product/cli/apis/list-keys.mdx +++ b/docs/product/cli/apis/list-keys.mdx @@ -58,6 +58,7 @@ When true, includes the plaintext key value in the response. Only works for keys | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -85,10 +86,10 @@ done ## Output -Default output shows the request ID with latency, followed by the list of keys: +Default output shows the request ID, followed by the list of keys: ```text -req_1234abcd (took 82ms) +req_1234abcd [ { diff --git a/docs/product/cli/apps/create-app.mdx b/docs/product/cli/apps/create-app.mdx new file mode 100644 index 00000000000..aa4d14c3577 --- /dev/null +++ b/docs/product/cli/apps/create-app.mdx @@ -0,0 +1,28 @@ +--- +title: "create-app" +description: "Create an app within a project" +--- + +Create an app within a project. The app is created with default `production` and `preview` environments. The caller-defined slug is stable and must be unique within the project. + +## Usage +```bash +unkey api apps create-app [flags] +``` +## Flags +Project ID or slug. +Human-readable name for this app. Use a descriptive name like 'Payments API' to identify its purpose. +App slug. Accepts a resource ID or slug format. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api apps create-app --project=payments --name="Payments API" --slug=payments-api +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/apps/create-app). diff --git a/docs/product/cli/apps/delete-app.mdx b/docs/product/cli/apps/delete-app.mdx new file mode 100644 index 00000000000..9e387b6ea5c --- /dev/null +++ b/docs/product/cli/apps/delete-app.mdx @@ -0,0 +1,27 @@ +--- +title: "delete-app" +description: "Delete an existing app" +--- + +Delete an existing app by ID or slug. Deletion is asynchronous and eventually consistent. Apps with delete protection enabled cannot be deleted until protection is disabled. + +## Usage +```bash +unkey api apps delete-app [flags] +``` +## Flags +Project ID or slug. +App ID or slug. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api apps delete-app --project=payments --app=app_1234abcd +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/apps/delete-app). diff --git a/docs/product/cli/apps/get-app.mdx b/docs/product/cli/apps/get-app.mdx new file mode 100644 index 00000000000..77f8a295e8d --- /dev/null +++ b/docs/product/cli/apps/get-app.mdx @@ -0,0 +1,27 @@ +--- +title: "get-app" +description: "Retrieve a single app" +--- + +Retrieve a single app by its ID or slug within a project. Use this to fetch app details after creation or verify that an app exists. + +## Usage +```bash +unkey api apps get-app [flags] +``` +## Flags +Project ID or slug. +App ID or slug. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api apps get-app --project=payments --app=payments-api +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/apps/get-app). diff --git a/docs/product/cli/apps/list-apps.mdx b/docs/product/cli/apps/list-apps.mdx new file mode 100644 index 00000000000..4c7a8bc4fb9 --- /dev/null +++ b/docs/product/cli/apps/list-apps.mdx @@ -0,0 +1,29 @@ +--- +title: "list-apps" +description: "Retrieve a paginated list of apps within a project" +--- + +Retrieve a paginated list of apps within a project. Results are ordered by app ID. When `hasMore` is true, pass the returned `cursor` to fetch the next page. + +## Usage +```bash +unkey api apps list-apps [flags] +``` +## Flags +Project ID or slug. +Maximum number of apps to return per request. Must be between 1 and 100. +Pagination cursor from a previous response to fetch the next page. +Free-form text matching app ID, name, or slug, case-insensitively. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api apps list-apps --project=payments --limit=25 --search=checkout +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/apps/list-apps). diff --git a/docs/product/cli/apps/update-app.mdx b/docs/product/cli/apps/update-app.mdx new file mode 100644 index 00000000000..e2e49fbe45c --- /dev/null +++ b/docs/product/cli/apps/update-app.mdx @@ -0,0 +1,31 @@ +--- +title: "update-app" +description: "Update an existing app" +--- + +Update an app by ID or slug. The name, slug, default branch, and delete protection can be changed. Omitted fields remain unchanged. A new slug must not collide with another app in the project. + +## Usage +```bash +unkey api apps update-app [flags] +``` +## Flags +Project ID or slug. +App ID or slug. +New human-readable name. Omit to leave unchanged. +New app slug. Omit to leave unchanged. +New default git branch deployments track. Omit to leave unchanged. +Enable or disable delete protection. Omit to leave unchanged. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api apps update-app --project=payments --app=app_1234abcd --delete-protection=true +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/apps/update-app). diff --git a/docs/product/cli/deployments/create-deployment.mdx b/docs/product/cli/deployments/create-deployment.mdx new file mode 100644 index 00000000000..2be1542f5e9 --- /dev/null +++ b/docs/product/cli/deployments/create-deployment.mdx @@ -0,0 +1,57 @@ +--- +title: "create-deployment" +description: "Create deployment with the Unkey API CLI" +--- + +Create a deployment from a Git branch, container image, or existing deployment. + +## Usage + +```bash +unkey api deployments create-deployment [flags] +``` + +## Flags + + +Project identifier or slug. + + +Application identifier or slug. + + +Environment identifier or slug. + + +Git source with branch, commitSha, and repository. +The JSON object uses the properties and constraints listed above. + + +Image source with dockerImage. +The JSON object uses the properties and constraints listed above. + + +Existing source with deploymentId. +The JSON object uses the properties and constraints listed above. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api deployments create-deployment --project=payments --app=payments-api \ + --environment=production --git='{"branch":"main"}' +``` + + +See the [Create deployment API reference](https://www.unkey.com/docs/api-reference/v2/deployments/create-deployment). diff --git a/docs/product/cli/deployments/get-deployment.mdx b/docs/product/cli/deployments/get-deployment.mdx new file mode 100644 index 00000000000..d8043e2cce3 --- /dev/null +++ b/docs/product/cli/deployments/get-deployment.mdx @@ -0,0 +1,38 @@ +--- +title: "get-deployment" +description: "Get deployment with the Unkey API CLI" +--- + +Retrieve a deployment and its current lifecycle status by ID. + +## Usage + +```bash +unkey api deployments get-deployment [flags] +``` + +## Flags + + +Unique deployment identifier. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api deployments get-deployment --deployment-id=dep_1234abcd +``` + + +See the [Get deployment API reference](https://www.unkey.com/docs/api-reference/v2/deployments/get-deployment). diff --git a/docs/product/cli/deployments/list-deployments.mdx b/docs/product/cli/deployments/list-deployments.mdx new file mode 100644 index 00000000000..5fd57714f88 --- /dev/null +++ b/docs/product/cli/deployments/list-deployments.mdx @@ -0,0 +1,53 @@ +--- +title: "list-deployments" +description: "List deployments with the Unkey API CLI" +--- + +List deployments with optional resource and lifecycle status filters. + +## Usage + +```bash +unkey api deployments list-deployments [flags] +``` + +## Flags + + +Project identifier or slug. + + +Application identifier or slug. + + +Environment identifier or slug. + + +Comma-separated lifecycle statuses. Valid choices are `pending`, `starting`, `building`, `deploying`, `network`, `finalizing`, `ready`, `failed`, `skipped`, `awaiting_approval`, `stopped`, `superseded`, and `cancelled`. + + +Maximum deployments to return per page. The default is 100. + + +Pagination cursor from a previous response. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api deployments list-deployments --project=payments --app=payments-api --environment=production --status=ready,failed +``` + + +See the [List deployments API reference](https://www.unkey.com/docs/api-reference/v2/deployments/list-deployments). diff --git a/docs/product/cli/deployments/promote-deployment.mdx b/docs/product/cli/deployments/promote-deployment.mdx new file mode 100644 index 00000000000..3529eac2250 --- /dev/null +++ b/docs/product/cli/deployments/promote-deployment.mdx @@ -0,0 +1,38 @@ +--- +title: "promote-deployment" +description: "Promote deployment with the Unkey API CLI" +--- + +Promote an existing deployment to production. + +## Usage + +```bash +unkey api deployments promote-deployment [flags] +``` + +## Flags + + +Unique deployment identifier. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api deployments promote-deployment --deployment-id=dep_1234abcd +``` + + +See the [Promote deployment API reference](https://www.unkey.com/docs/api-reference/v2/deployments/promote-deployment). diff --git a/docs/product/cli/deployments/rollback-deployment.mdx b/docs/product/cli/deployments/rollback-deployment.mdx new file mode 100644 index 00000000000..07dcf301bc8 --- /dev/null +++ b/docs/product/cli/deployments/rollback-deployment.mdx @@ -0,0 +1,38 @@ +--- +title: "rollback-deployment" +description: "Rollback deployment with the Unkey API CLI" +--- + +Create a rollback from an earlier deployment. + +## Usage + +```bash +unkey api deployments rollback-deployment [flags] +``` + +## Flags + + +Unique deployment identifier. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api deployments rollback-deployment --deployment-id=dep_1234abcd +``` + + +See the [Rollback deployment API reference](https://www.unkey.com/docs/api-reference/v2/deployments/rollback-deployment). diff --git a/docs/product/cli/deployments/start-deployment.mdx b/docs/product/cli/deployments/start-deployment.mdx new file mode 100644 index 00000000000..37f078bb4aa --- /dev/null +++ b/docs/product/cli/deployments/start-deployment.mdx @@ -0,0 +1,38 @@ +--- +title: "start-deployment" +description: "Start deployment with the Unkey API CLI" +--- + +Start a stopped deployment. + +## Usage + +```bash +unkey api deployments start-deployment [flags] +``` + +## Flags + + +Unique deployment identifier. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api deployments start-deployment --deployment-id=dep_1234abcd +``` + + +See the [Start deployment API reference](https://www.unkey.com/docs/api-reference/v2/deployments/start-deployment). diff --git a/docs/product/cli/deployments/stop-deployment.mdx b/docs/product/cli/deployments/stop-deployment.mdx new file mode 100644 index 00000000000..ee57588b78c --- /dev/null +++ b/docs/product/cli/deployments/stop-deployment.mdx @@ -0,0 +1,38 @@ +--- +title: "stop-deployment" +description: "Stop deployment with the Unkey API CLI" +--- + +Stop a running deployment and release its compute resources. + +## Usage + +```bash +unkey api deployments stop-deployment [flags] +``` + +## Flags + + +Unique deployment identifier. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api deployments stop-deployment --deployment-id=dep_1234abcd +``` + + +See the [Stop deployment API reference](https://www.unkey.com/docs/api-reference/v2/deployments/stop-deployment). diff --git a/docs/product/cli/environments/get-environment.mdx b/docs/product/cli/environments/get-environment.mdx new file mode 100644 index 00000000000..33168b51e7f --- /dev/null +++ b/docs/product/cli/environments/get-environment.mdx @@ -0,0 +1,44 @@ +--- +title: "get-environment" +description: "Get environment with the Unkey API CLI" +--- + +Retrieve an environment and its settings by project, app, and environment selector. + +## Usage + +```bash +unkey api environments get-environment [flags] +``` + +## Flags + + +Project identifier or slug. + + +Application identifier or slug. + + +Environment identifier or slug. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api environments get-environment --project=payments --app=payments-api --environment=production +``` + + +See the [Get environment API reference](https://www.unkey.com/docs/api-reference/v2/environments/get-environment). diff --git a/docs/product/cli/environments/list-environment-variables.mdx b/docs/product/cli/environments/list-environment-variables.mdx new file mode 100644 index 00000000000..142120160d0 --- /dev/null +++ b/docs/product/cli/environments/list-environment-variables.mdx @@ -0,0 +1,50 @@ +--- +title: "list-environment-variables" +description: "List environment variables with the Unkey API CLI" +--- + +List an environment's variables with cursor-based pagination. + +## Usage + +```bash +unkey api environments list-environment-variables [flags] +``` + +## Flags + + +Project identifier or slug. + + +Application identifier or slug. + + +Environment identifier or slug. + + +Maximum variables to return per page. The default is 100. + + +Pagination cursor from a previous response. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api environments list-environment-variables --project=payments --app=payments-api --environment=production +``` + + +See the [List environment variables API reference](https://www.unkey.com/docs/api-reference/v2/environments/list-environment-variables). diff --git a/docs/product/cli/environments/list-environments.mdx b/docs/product/cli/environments/list-environments.mdx new file mode 100644 index 00000000000..036bc9bfb36 --- /dev/null +++ b/docs/product/cli/environments/list-environments.mdx @@ -0,0 +1,41 @@ +--- +title: "list-environments" +description: "List environments with the Unkey API CLI" +--- + +List the environments configured for an app. + +## Usage + +```bash +unkey api environments list-environments [flags] +``` + +## Flags + + +Project identifier or slug. + + +Application identifier or slug. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api environments list-environments --project=payments --app=payments-api +``` + + +See the [List environments API reference](https://www.unkey.com/docs/api-reference/v2/environments/list-environments). diff --git a/docs/product/cli/environments/remove-environment-variables.mdx b/docs/product/cli/environments/remove-environment-variables.mdx new file mode 100644 index 00000000000..e2147112781 --- /dev/null +++ b/docs/product/cli/environments/remove-environment-variables.mdx @@ -0,0 +1,47 @@ +--- +title: "remove-environment-variables" +description: "Remove environment variables with the Unkey API CLI" +--- + +Remove named variables from an environment without changing other variables. + +## Usage + +```bash +unkey api environments remove-environment-variables [flags] +``` + +## Flags + + +Project identifier or slug. + + +Application identifier or slug. + + +Environment identifier or slug. + + +Comma-separated variable names to remove. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api environments remove-environment-variables --project=payments --app=payments-api --environment=production --variables=OLD_TOKEN,LEGACY_URL +``` + + +See the [Remove environment variables API reference](https://www.unkey.com/docs/api-reference/v2/environments/remove-environment-variables). diff --git a/docs/product/cli/environments/set-environment-variables.mdx b/docs/product/cli/environments/set-environment-variables.mdx new file mode 100644 index 00000000000..b9604789f1f --- /dev/null +++ b/docs/product/cli/environments/set-environment-variables.mdx @@ -0,0 +1,52 @@ +--- +title: "set-environment-variables" +description: "Set environment variables with the Unkey API CLI" +--- + +Upsert environment variables atomically, with optional pruning. + +## Usage + +```bash +unkey api environments set-environment-variables [flags] +``` + +## Flags + + +Project identifier or slug. + + +Application identifier or slug. + + +Environment identifier or slug. + + +Variables with key, value, kind, and description. +The JSON object uses the properties and constraints listed above. + + +Remove existing variables that aren't included in `--variables`. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api environments set-environment-variables --project=payments --app=payments-api \ + --environment=production --variables='[{"key":"TOKEN","value":"secret"}]' +``` + + +See the [Set environment variables API reference](https://www.unkey.com/docs/api-reference/v2/environments/set-environment-variables). diff --git a/docs/product/cli/environments/update-settings.mdx b/docs/product/cli/environments/update-settings.mdx new file mode 100644 index 00000000000..89ab53ac10b --- /dev/null +++ b/docs/product/cli/environments/update-settings.mdx @@ -0,0 +1,97 @@ +--- +title: "update-settings" +description: "Update settings with the Unkey API CLI" +--- + +Update selected build and runtime settings for an environment. + +## Usage + +```bash +unkey api environments update-settings [flags] +``` + +## Flags + + +Project identifier or slug. + + +Application identifier or slug. + + +Environment identifier or slug. + + +Dockerfile path. + + +Build root directory. + + +Command used to build the app. + + +Paths that trigger a deployment. Pass an empty value to clear the current paths. + + +Whether pushes deploy automatically. + + +Container port. + + +CPU allocation in vCPUs. + + +Memory allocation in MiB. + + +Storage allocation in MiB. + + +Container command. Pass an empty value to clear the current command. + + +Healthcheck with method, path, intervalSeconds, timeoutSeconds, failureThreshold, and initialDelaySeconds. +The JSON object uses the properties and constraints listed above. + + +Container shutdown signal. Valid choices are `SIGTERM`, `SIGINT`, `SIGQUIT`, and `SIGKILL`. + + +Protocol used to reach the container. Valid choices are `http1` and `h2c`. + + +OpenAPI specification path. + + +Regions with name and min/max replica bounds. +The JSON object uses the properties and constraints listed above. + + +## Global Flags + +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format - use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | + +## Examples + + +```bash Basic +unkey api environments update-settings --project=payments --app=payments-api \ + --environment=production --port=8080 --shutdown-signal=SIGTERM \ + --upstream-protocol=http1 +``` + + + + The current Go SDK can't represent explicit `null` values for `dockerfile`, `buildCommand`, `healthcheck`, or `openapiSpecPath`. Use the HTTP API directly to clear these settings. + + +See the [Update settings API reference](https://www.unkey.com/docs/api-reference/v2/environments/update-settings). diff --git a/docs/product/cli/gateway/list-policies.mdx b/docs/product/cli/gateway/list-policies.mdx new file mode 100644 index 00000000000..ad5c63f8819 --- /dev/null +++ b/docs/product/cli/gateway/list-policies.mdx @@ -0,0 +1,28 @@ +--- +title: "list-policies" +description: "Retrieve gateway policies in evaluation order" +--- + +Retrieve an environment's gateway policies in evaluation order. The gateway evaluates policies top to bottom and the first rejection short-circuits the request. The full list is returned in one response. + +## Usage +```bash +unkey api gateway list-policies [flags] +``` +## Flags +Project ID or slug. +Application ID or slug. +Environment ID or slug. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format, use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api gateway list-policies --project=payments --app=payments-api --environment=production +``` +See the [API reference](/api-reference/gateway/list-policies). diff --git a/docs/product/cli/gateway/set-policies.mdx b/docs/product/cli/gateway/set-policies.mdx new file mode 100644 index 00000000000..6da3a69ca5f --- /dev/null +++ b/docs/product/cli/gateway/set-policies.mdx @@ -0,0 +1,32 @@ +--- +title: "set-policies" +description: "Atomically replace an environment's gateway policies" +--- + +Replace an environment's complete ordered policy list. An empty list removes every policy. The operation is atomic and accepts at most 50 policies. + +## Usage +```bash +unkey api gateway set-policies [flags] +``` +## Flags +Project ID or slug. +Application ID or slug. +Environment ID or slug. + +The complete policy list in evaluation order. Each policy requires `name` and `enabled`, and exactly one of `keyauth`, `ratelimit`, `firewall`, or `openapi`. +Dashboard name.Whether evaluation is enabled.Optional path, method, header, or query parameter match expressions.Keyspaces, locations, permission query, and rate limits.Limit, windowMs, and identifier.Firewall action.OpenAPI validation configuration. + +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format, use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api gateway set-policies --project=payments --app=payments-api --environment=production --policies='[{"name":"Block internal","enabled":true,"firewall":{"action":"ACTION_DENY"}}]' +``` +See the [API reference](/api-reference/gateway/set-policies). diff --git a/docs/product/cli/gateway/update-policy.mdx b/docs/product/cli/gateway/update-policy.mdx new file mode 100644 index 00000000000..5245cf678f4 --- /dev/null +++ b/docs/product/cli/gateway/update-policy.mdx @@ -0,0 +1,34 @@ +--- +title: "update-policy" +description: "Update one gateway policy in place" +--- + +Update one policy while preserving its ID, evaluation position, and all omitted fields. Supply the fields to update in a single policy JSON object. At least one update field is required, and at most one rule field may be supplied. + +## Usage +```bash +unkey api gateway update-policy [flags] +``` +## Flags +Project ID or slug. +Application ID or slug. +Environment ID or slug. +Current policy ID from `gateway.listPolicies`. + +Policy fields to update. Supported fields are `name`, `enabled`, `match`, `keyauth`, `ratelimit`, `firewall`, and `openapi`. Omitted fields remain unchanged. Set `match` to `null` or an empty array to clear all match expressions. Include at most one of `keyauth`, `ratelimit`, `firewall`, or `openapi`. + +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format, use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api gateway update-policy --project=payments --app=payments-api \ + --environment=production --policy-id=pol_123 \ + --policy='{"enabled":false,"firewall":{"action":"ACTION_DENY"}}' +``` +See the [API reference](/api-reference/gateway/update-policy). diff --git a/docs/product/cli/identities/create-identity.mdx b/docs/product/cli/identities/create-identity.mdx index 6aab0202f32..80f8a812485 100644 --- a/docs/product/cli/identities/create-identity.mdx +++ b/docs/product/cli/identities/create-identity.mdx @@ -28,7 +28,7 @@ unkey api identities create-identity [flags] Your system's unique identifier for the user, organization, or entity. Must be unique across your workspace --- duplicate external IDs return a `409 Conflict` error. This identifier links Unkey identities to your authentication system, database records, or tenant structure. Accepts letters, numbers, underscores, dots, and hyphens (1--255 characters). - + JSON object of arbitrary metadata stored on the identity. This metadata is returned during key verification, eliminating additional database lookups for contextual information. Useful for subscription details, feature flags, user preferences, and organization information. Avoid storing sensitive data as it is returned in verification responses. @@ -38,7 +38,7 @@ Arbitrary key-value pairs. Values can be strings, numbers, booleans, arrays, or - + JSON array of shared rate limit configurations for all keys under this identity. Rate limit counters are shared across all keys belonging to this identity, preventing abuse by users with multiple keys. Each named limit can have different thresholds and windows. Maximum of 50 rate limit configurations. @@ -65,6 +65,7 @@ Whether this rate limit should be automatically applied when verifying a key. De | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -73,10 +74,10 @@ Whether this rate limit should be automatically applied when verifying a key. De unkey api identities create-identity --external-id=user_123 ``` ```bash With metadata -unkey api identities create-identity --external-id=user_123 --meta-json='{"email":"alice@acme.com","name":"Alice Smith","plan":"premium"}' +unkey api identities create-identity --external-id=user_123 --meta='{"email":"alice@acme.com","name":"Alice Smith","plan":"premium"}' ``` ```bash With rate limits -unkey api identities create-identity --external-id=user_123 --ratelimits-json='[{"name":"requests","limit":1000,"duration":60000,"autoApply":false}]' +unkey api identities create-identity --external-id=user_123 --ratelimits='[{"name":"requests","limit":1000,"duration":60000,"autoApply":false}]' ``` ```bash JSON output for scripting unkey api identities create-identity --external-id=user_123 --output=json @@ -85,10 +86,10 @@ unkey api identities create-identity --external-id=user_123 --output=json ## Output -Default output shows the request ID with latency, followed by the created identity: +Default output shows the request ID, followed by the created identity: ```text -req_01H9TQPP77V5E48E9SH0BG0ZQX (took 45ms) +req_01H9TQPP77V5E48E9SH0BG0ZQX { "identityId": "id_1234567890abcdef" diff --git a/docs/product/cli/identities/delete-identity.mdx b/docs/product/cli/identities/delete-identity.mdx index ce12925c99d..eff3e8277ec 100644 --- a/docs/product/cli/identities/delete-identity.mdx +++ b/docs/product/cli/identities/delete-identity.mdx @@ -38,6 +38,7 @@ The ID of the identity to delete. This can be either the external ID (from your | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -52,10 +53,10 @@ unkey api identities delete-identity --identity=user_123 --output=json ## Output -Default output shows the request ID with latency: +Default output shows the request ID: ```text -req_01H9TQPP77V5E48E9SH0BG0ZQX (took 38ms) +req_01H9TQPP77V5E48E9SH0BG0ZQX ``` With `--output=json`, the full response envelope is returned: diff --git a/docs/product/cli/identities/get-identity.mdx b/docs/product/cli/identities/get-identity.mdx index b3d873a8632..a5802aeefac 100644 --- a/docs/product/cli/identities/get-identity.mdx +++ b/docs/product/cli/identities/get-identity.mdx @@ -34,6 +34,7 @@ The ID of the identity to retrieve. This can be either the externalId (from your | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -51,10 +52,10 @@ unkey api identities get-identity --identity=id_1234567890abcdef ## Output -Default output shows the request ID with latency, followed by the identity: +Default output shows the request ID, followed by the identity: ```text -req_01H9TQPP77V5E48E9SH0BG0ZQX (took 32ms) +req_01H9TQPP77V5E48E9SH0BG0ZQX { "id": "id_1234567890abcdef", diff --git a/docs/product/cli/identities/list-identities.mdx b/docs/product/cli/identities/list-identities.mdx index 1c07965feb4..59e87c72e11 100644 --- a/docs/product/cli/identities/list-identities.mdx +++ b/docs/product/cli/identities/list-identities.mdx @@ -30,6 +30,10 @@ The maximum number of identities to return in a single request. Use this to cont Pagination cursor from a previous response. Use this to fetch subsequent pages of results when the response contains a cursor value. + +Free-form text to filter identities. Returns identities whose ID or external ID contains the search string. Matching is case-insensitive. + + ## Global Flags | Flag | Type | Description | @@ -38,6 +42,7 @@ Pagination cursor from a previous response. Use this to fetch subsequent pages o | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -51,6 +56,9 @@ unkey api identities list-identities --limit=50 ```bash Paginate with cursor unkey api identities list-identities --limit=50 --cursor=cursor_eyJrZXkiOiJrZXlfMTIzNCJ9 ``` +```bash Search identities +unkey api identities list-identities --search=user_123 +``` ```bash JSON output for scripting unkey api identities list-identities --limit=10 --output=json ``` @@ -58,10 +66,10 @@ unkey api identities list-identities --limit=10 --output=json ## Output -Default output shows the request ID with latency, followed by the list of identities: +Default output shows the request ID, followed by the list of identities: ```text -req_01H9TQPP77V5E48E9SH0BG0ZQX (took 120ms) +req_01H9TQPP77V5E48E9SH0BG0ZQX { "identities": [ diff --git a/docs/product/cli/identities/update-identity.mdx b/docs/product/cli/identities/update-identity.mdx index d2b327e7225..6d45b9b9ce8 100644 --- a/docs/product/cli/identities/update-identity.mdx +++ b/docs/product/cli/identities/update-identity.mdx @@ -9,8 +9,8 @@ Use this for subscription changes, plan upgrades, or updating user information. **Important:** - Rate limit changes propagate within 30 seconds across all regions -- Providing `--meta-json` replaces all existing metadata; omitting it preserves current metadata -- Providing `--ratelimits-json` replaces all existing rate limits; omitting it preserves current rate limits +- Providing `--meta` replaces all existing metadata; omitting it preserves current metadata +- Providing `--ratelimits` replaces all existing rate limits; omitting it preserves current rate limits **Required permissions:** - `identity.*.update_identity` (to update identities in any workspace) @@ -31,11 +31,11 @@ unkey api identities update-identity [flags] The ID of the identity to update. Accepts either the externalId (your system-generated identifier) or the identityId (internal identifier returned by the identity service). - + JSON object of metadata to replace existing metadata. Omitting this flag preserves existing metadata, while providing an empty object `'{}'` clears all metadata. Avoid storing sensitive data here as it is returned in verification responses. Large metadata objects increase verification latency and should stay under 10KB total size. - + JSON array of rate limit configurations. Replaces all existing identity rate limits with this complete list. Omitting this flag preserves existing rate limits, while providing an empty array `'[]'` removes all rate limits. These limits are shared across all keys belonging to this identity, preventing abuse through multiple keys. @@ -62,27 +62,28 @@ JSON array of rate limit configurations. Replaces all existing identity rate lim | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples ```bash Update metadata -unkey api identities update-identity --identity=user_123 --meta-json='{"plan":"premium","name":"Alice Smith"}' +unkey api identities update-identity --identity=user_123 --meta='{"plan":"premium","name":"Alice Smith"}' ``` ```bash Update rate limits -unkey api identities update-identity --identity=user_123 --ratelimits-json='[{"name":"requests","limit":1000,"duration":3600000,"autoApply":true}]' +unkey api identities update-identity --identity=user_123 --ratelimits='[{"name":"requests","limit":1000,"duration":3600000,"autoApply":true}]' ``` ```bash JSON output for scripting -unkey api identities update-identity --identity=user_123 --meta-json='{"plan":"enterprise"}' --output=json +unkey api identities update-identity --identity=user_123 --meta='{"plan":"enterprise"}' --output=json ``` ## Output -Default output shows the request ID with latency, followed by the updated identity: +Default output shows the request ID, followed by the updated identity: ```text -req_2c9a0jf23l4k567 (took 52ms) +req_2c9a0jf23l4k567 { "id": "id_1234abcd", diff --git a/docs/product/cli/keys/add-permissions.mdx b/docs/product/cli/keys/add-permissions.mdx index a2f1c78f298..ddd0eafb776 100644 --- a/docs/product/cli/keys/add-permissions.mdx +++ b/docs/product/cli/keys/add-permissions.mdx @@ -41,6 +41,7 @@ Comma-separated list of permission names to add. Adding permissions never remove | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -58,10 +59,10 @@ unkey api keys add-permissions --key-id=key_1234abcd --permissions=admin.read,ad ## Output -Default output shows the request ID with latency, followed by the permissions now assigned to the key: +Default output shows the request ID, followed by the permissions now assigned to the key: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/keys/add-roles.mdx b/docs/product/cli/keys/add-roles.mdx index 35cd9e423a4..eeabdb64d60 100644 --- a/docs/product/cli/keys/add-roles.mdx +++ b/docs/product/cli/keys/add-roles.mdx @@ -41,6 +41,7 @@ Comma-separated list of role names to add. Operations are idempotent, adding exi | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -59,10 +60,10 @@ unkey api keys add-roles --key-id=$KEY_ID --roles=api_admin,billing_reader ## Output -Default output shows the request ID with latency, followed by the updated list of roles assigned to the key: +Default output shows the request ID, followed by the updated list of roles assigned to the key: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/keys/create-key.mdx b/docs/product/cli/keys/create-key.mdx index 29312d3d70f..1bb68f3999c 100644 --- a/docs/product/cli/keys/create-key.mdx +++ b/docs/product/cli/keys/create-key.mdx @@ -47,7 +47,7 @@ Cryptographic key length in bytes. The default of 16 bytes provides 2^128 possib Your system's user or entity identifier to link to this key. Returned during verification to identify the key owner without additional database lookups. Accepts letters, numbers, underscores, dots, and hyphens. - + JSON object of arbitrary metadata returned during key verification. Eliminates additional database lookups during verification, improving performance for stateless services. Avoid storing sensitive data here as it is returned in verification responses. @@ -69,7 +69,7 @@ Comma-separated list of permission names to grant directly to this key. Wildcard Unix timestamp in milliseconds when the key expires. Verification fails with `code=EXPIRED` immediately after this time passes. Omit to create a permanent key that never expires. - + JSON object of credit and refill configuration. Controls usage-based limits through credit consumption with optional automatic refills. Unlike rate limits which control frequency, credits control total usage with global consistency. @@ -93,7 +93,7 @@ JSON object of credit and refill configuration. Controls usage-based limits thro - + JSON array of rate limit configurations. Defines time-based rate limits that protect against abuse by controlling request frequency. Unlike credits which track total usage, rate limits reset automatically after each window expires. @@ -128,6 +128,7 @@ Whether the plaintext key is stored in an encrypted vault for later retrieval. O | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -142,13 +143,13 @@ unkey api keys create-key --api-id=api_1234abcd --prefix=prod --name='Payment Se unkey api keys create-key --api-id=api_1234abcd --external-id=user_1234abcd --roles=api_admin,billing_reader ``` ```bash With metadata -unkey api keys create-key --api-id=api_1234abcd --meta-json='{"plan":"pro","team":"acme"}' +unkey api keys create-key --api-id=api_1234abcd --meta='{"plan":"pro","team":"acme"}' ``` ```bash With credits and refill -unkey api keys create-key --api-id=api_1234abcd --credits-json='{"remaining":1000,"refill":{"interval":"monthly","amount":100}}' +unkey api keys create-key --api-id=api_1234abcd --credits='{"remaining":1000,"refill":{"interval":"monthly","amount":100}}' ``` ```bash With rate limits -unkey api keys create-key --api-id=api_1234abcd --ratelimits-json='[{"name":"requests","limit":100,"duration":60000,"autoApply":true}]' +unkey api keys create-key --api-id=api_1234abcd --ratelimits='[{"name":"requests","limit":100,"duration":60000,"autoApply":true}]' ``` ```bash JSON output for scripting unkey api keys create-key --api-id=api_1234abcd --output=json @@ -161,10 +162,10 @@ echo "Created key: $KEY_ID" ## Output -Default output shows the request ID with latency, followed by the created key: +Default output shows the request ID, followed by the created key: ```text -req_2c9a0jf23l4k567 (took 120ms) +req_2c9a0jf23l4k567 { "keyId": "key_2cGKbMxRyIzhCxo1Idjz8q", diff --git a/docs/product/cli/keys/delete-key.mdx b/docs/product/cli/keys/delete-key.mdx index 880fa3f1ef5..15b747bcd47 100644 --- a/docs/product/cli/keys/delete-key.mdx +++ b/docs/product/cli/keys/delete-key.mdx @@ -45,6 +45,7 @@ Use permanent deletion only for regulatory compliance (GDPR), resolving hash col | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -62,10 +63,10 @@ unkey api keys delete-key --key-id=key_1234abcd --output=json ## Output -Default output shows the request ID with latency: +Default output shows the request ID: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 {} ``` diff --git a/docs/product/cli/keys/get-key.mdx b/docs/product/cli/keys/get-key.mdx index 9a1973e4ba8..968ba9aab11 100644 --- a/docs/product/cli/keys/get-key.mdx +++ b/docs/product/cli/keys/get-key.mdx @@ -41,6 +41,7 @@ Whether to include the plaintext key value in the response. Only works for keys | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -61,10 +62,10 @@ unkey api keys get-key --key-id=key_1234abcd --output=json | jq '.data.permissio ## Output -Default output shows the request ID with latency, followed by the key details: +Default output shows the request ID, followed by the key details: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "keyId": "key_1234abcd", diff --git a/docs/product/cli/keys/migrate-keys.mdx b/docs/product/cli/keys/migrate-keys.mdx index 7d8d74c0927..6829128e4cc 100644 --- a/docs/product/cli/keys/migrate-keys.mdx +++ b/docs/product/cli/keys/migrate-keys.mdx @@ -35,7 +35,7 @@ Identifier of the configured migration provider/strategy to use (e.g., "your_com The ID of the API that the keys should be inserted into. Must be 3-255 characters. - + JSON array of key migration objects. Each object describes a single key to migrate. @@ -131,6 +131,7 @@ Whether this rate limit should be automatically applied when verifying a key. | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -139,29 +140,29 @@ Whether this rate limit should be automatically applied when verifying a key. unkey api keys migrate-keys \ --migration-id=your_company \ --api-id=api_123456789 \ - --keys-json='[{"hash":"c4fbfe7c69a067cb0841dea343346a750a69908a08ea9656d2a8c19fb0823c64","enabled":true}]' + --keys='[{"hash":"c4fbfe7c69a067cb0841dea343346a750a69908a08ea9656d2a8c19fb0823c64","enabled":true}]' ``` ```bash With metadata and roles unkey api keys migrate-keys \ --migration-id=your_company \ --api-id=api_123456789 \ - --keys-json='[{"hash":"c4fbfe7c69a067cb0841dea343346a750a69908a08ea9656d2a8c19fb0823c64","name":"Production API Key","externalId":"user_abc123","enabled":true,"meta":{"plan":"enterprise"},"roles":["admin"],"permissions":["api.read","api.write"]}]' + --keys='[{"hash":"c4fbfe7c69a067cb0841dea343346a750a69908a08ea9656d2a8c19fb0823c64","name":"Production API Key","externalId":"user_abc123","enabled":true,"meta":{"plan":"enterprise"},"roles":["admin"],"permissions":["api.read","api.write"]}]' ``` ```bash JSON output for scripting unkey api keys migrate-keys \ --migration-id=your_company \ --api-id=api_123456789 \ - --keys-json='[{"hash":"abc123","enabled":true}]' \ + --keys='[{"hash":"abc123","enabled":true}]' \ --output=json ``` ## Output -Default output shows the request ID with latency, followed by the migration results: +Default output shows the request ID, followed by the migration results: ```text -req_2c9a0jf23l4k567 (took 120ms) +req_2c9a0jf23l4k567 { "migrated": [ diff --git a/docs/product/cli/keys/remove-permissions.mdx b/docs/product/cli/keys/remove-permissions.mdx index 095649c3647..cf5c7bf939b 100644 --- a/docs/product/cli/keys/remove-permissions.mdx +++ b/docs/product/cli/keys/remove-permissions.mdx @@ -43,6 +43,7 @@ Comma-separated list of permission names to remove. You can specify permissions | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -60,10 +61,10 @@ unkey api keys remove-permissions --key-id=key_1234abcd --permissions=billing.ma ## Output -Default output shows the request ID with latency, followed by the remaining direct permissions on the key: +Default output shows the request ID, followed by the remaining direct permissions on the key: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/keys/remove-roles.mdx b/docs/product/cli/keys/remove-roles.mdx index e43f5e82e6a..bc7caea29db 100644 --- a/docs/product/cli/keys/remove-roles.mdx +++ b/docs/product/cli/keys/remove-roles.mdx @@ -43,6 +43,7 @@ Comma-separated list of role names to remove. Operations are idempotent, removin | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -60,10 +61,10 @@ unkey api keys remove-roles --key-id=key_1234abcd --roles=temporary_access ## Output -Default output shows the request ID with latency, followed by the remaining roles assigned to the key: +Default output shows the request ID, followed by the remaining roles assigned to the key: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/keys/reroll-key.mdx b/docs/product/cli/keys/reroll-key.mdx index 6ada428299c..8e034152e42 100644 --- a/docs/product/cli/keys/reroll-key.mdx +++ b/docs/product/cli/keys/reroll-key.mdx @@ -45,6 +45,7 @@ Duration in milliseconds until the original key is revoked, starting from now. S | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -65,10 +66,10 @@ NEW_KEY=$(unkey api keys reroll-key --key-id=key_1234abcd --expiration=3600000 - ## Output -Default output shows the request ID with latency, followed by the new key details: +Default output shows the request ID, followed by the new key details: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "keyId": "key_5678efgh", diff --git a/docs/product/cli/keys/set-permissions.mdx b/docs/product/cli/keys/set-permissions.mdx index 7cd5c7693ac..49fae8ece9e 100644 --- a/docs/product/cli/keys/set-permissions.mdx +++ b/docs/product/cli/keys/set-permissions.mdx @@ -41,6 +41,7 @@ Comma-separated list of permissions. Replaces all existing direct permissions wi | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -58,10 +59,10 @@ unkey api keys set-permissions --key-id=key_1234abcd --permissions= ## Output -Default output shows the request ID with latency, followed by the updated list of direct permissions on the key: +Default output shows the request ID, followed by the updated list of direct permissions on the key: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/keys/set-roles.mdx b/docs/product/cli/keys/set-roles.mdx index 934048163fc..7f8038e9eef 100644 --- a/docs/product/cli/keys/set-roles.mdx +++ b/docs/product/cli/keys/set-roles.mdx @@ -41,6 +41,7 @@ Comma-separated list of roles. Replaces all existing roles on the key with this | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -58,10 +59,10 @@ unkey api keys set-roles --key-id=key_1234abcd --roles=api_admin,billing_reader ## Output -Default output shows the request ID with latency, followed by the roles now assigned to the key: +Default output shows the request ID, followed by the roles now assigned to the key: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/keys/update-credits.mdx b/docs/product/cli/keys/update-credits.mdx index fef5954c06a..bd08086d04e 100644 --- a/docs/product/cli/keys/update-credits.mdx +++ b/docs/product/cli/keys/update-credits.mdx @@ -55,6 +55,7 @@ Required when using `increment` or `decrement` operations. Optional for `set` op | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -75,10 +76,10 @@ unkey api keys update-credits --key-id=key_1234abcd --operation=set --value=1000 ## Output -Default output shows the request ID with latency, followed by the updated credit data: +Default output shows the request ID, followed by the updated credit data: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "remaining": 1000, diff --git a/docs/product/cli/keys/update-key.mdx b/docs/product/cli/keys/update-key.mdx index fe8c6aeacba..eb62f1ac563 100644 --- a/docs/product/cli/keys/update-key.mdx +++ b/docs/product/cli/keys/update-key.mdx @@ -43,7 +43,7 @@ Sets a human-readable name for internal organization and identification. Omittin Links this key to a user or entity in your system for ownership tracking during verification. Omitting this flag preserves the current association. Essential for user-specific analytics, billing, and key management across multiple users. - + JSON object of arbitrary metadata returned during key verification. Omitting this flag preserves existing metadata. Avoid storing sensitive data here as it's returned in verification responses. Large metadata objects increase verification latency and should stay under 10KB total size. @@ -57,7 +57,7 @@ JSON object of arbitrary metadata returned during key verification. Omitting thi Unix timestamp in milliseconds when the key automatically expires. Verification fails with `code=EXPIRED` immediately after this time passes. Avoid setting timestamps in the past as they immediately invalidate the key. Keys expire based on server time, not client time. - + JSON object for credit and refill configuration. Controls usage-based limits for this key through credit consumption. Setting null enables unlimited usage. Essential for implementing usage-based pricing and subscription quotas. @@ -81,7 +81,7 @@ JSON object for credit and refill configuration. Controls usage-based limits for - + JSON array of rate limit configurations. Defines time-based rate limits that protect against abuse by controlling request frequency. Unlike credits which track total usage, rate limits reset automatically after each window expires. Multiple rate limits can control different operation types with separate thresholds and windows. @@ -120,6 +120,7 @@ Comma-separated list of permission names to grant directly to this key. Wildcard | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format -- use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -134,13 +135,13 @@ unkey api keys update-key --key-id=key_1234abcd --enabled=false unkey api keys update-key --key-id=key_1234abcd --external-id=user_5678 --roles=api_admin,billing_reader ``` ```bash Update metadata -unkey api keys update-key --key-id=key_1234abcd --meta-json='{"plan":"enterprise","team":"acme"}' +unkey api keys update-key --key-id=key_1234abcd --meta='{"plan":"enterprise","team":"acme"}' ``` ```bash Configure credits with monthly refill -unkey api keys update-key --key-id=key_1234abcd --credits-json='{"remaining":5000,"refill":{"interval":"monthly","amount":5000}}' +unkey api keys update-key --key-id=key_1234abcd --credits='{"remaining":5000,"refill":{"interval":"monthly","amount":5000}}' ``` ```bash Set rate limits -unkey api keys update-key --key-id=key_1234abcd --ratelimits-json='[{"name":"requests","limit":500,"duration":60000,"autoApply":true}]' +unkey api keys update-key --key-id=key_1234abcd --ratelimits='[{"name":"requests","limit":500,"duration":60000,"autoApply":true}]' ``` ```bash JSON output for scripting unkey api keys update-key --key-id=key_1234abcd --name='Scripted Update' --output=json @@ -149,10 +150,10 @@ unkey api keys update-key --key-id=key_1234abcd --name='Scripted Update' --outpu ## Output -Default output shows the request ID with latency: +Default output shows the request ID: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 {} ``` diff --git a/docs/product/cli/keys/verify-key.mdx b/docs/product/cli/keys/verify-key.mdx index 22a1ad4d66f..9a95cd621f2 100644 --- a/docs/product/cli/keys/verify-key.mdx +++ b/docs/product/cli/keys/verify-key.mdx @@ -41,20 +41,20 @@ Metadata tags for analytics in `key=value` format. Attaches metadata for analyti Permission query to check, supports `AND`/`OR` operators and parentheses for grouping. Examples: `"documents.read"`, `"documents.read AND documents.write"`, `"(documents.read OR documents.write) AND users.view"`. Verification fails if the key lacks the required permissions through direct assignment or role inheritance. - + JSON object for credit consumption configuration. Controls credit deduction for usage-based billing and quota enforcement. Omitting this field uses the default cost of 1 credit per verification. - + Sets how many credits to deduct for this verification request. Use `0` for read-only operations or free tier access, higher values for premium features. Credits are deducted after all security checks pass. Min: `0`, max: `1000000000`. - + JSON array of rate limit checks to enforce during verification. Omitting this field skips rate limit checks entirely, relying only on configured key rate limits. Multiple rate limits can be checked simultaneously, each with different costs and temporary overrides. - + References an existing rate limit by its name. Key rate limits take precedence over identifier-based limits. @@ -82,6 +82,7 @@ Migration provider ID for on-demand key migration from your previous system. Rea | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -96,10 +97,10 @@ unkey api keys verify-key --key=sk_1234abcdef --permissions='documents.read AND unkey api keys verify-key --key=sk_1234abcdef --tags=endpoint=/users/profile,method=GET ``` ```bash With credit cost -unkey api keys verify-key --key=sk_1234abcdef --credits-json='{"cost":5}' +unkey api keys verify-key --key=sk_1234abcdef --credits='{"cost":5}' ``` ```bash With rate limits -unkey api keys verify-key --key=sk_1234abcdef --ratelimits-json='[{"name":"requests","limit":100,"duration":60000}]' +unkey api keys verify-key --key=sk_1234abcdef --ratelimits='[{"name":"requests","limit":100,"duration":60000}]' ``` ```bash JSON output for scripting unkey api keys verify-key --key=sk_1234abcdef --output=json @@ -112,10 +113,10 @@ if [ "$VALID" = "true" ]; then echo "Key is valid"; fi ## Output -Default output shows the request ID with latency, followed by the verification result: +Default output shows the request ID, followed by the verification result: ```text -req_2c9a0jf23l4k567 (took 32ms) +req_2c9a0jf23l4k567 { "valid": true, diff --git a/docs/product/cli/keys/whoami.mdx b/docs/product/cli/keys/whoami.mdx index 6fbd4fff790..a55ffad9287 100644 --- a/docs/product/cli/keys/whoami.mdx +++ b/docs/product/cli/keys/whoami.mdx @@ -39,6 +39,7 @@ The complete API key string, including any prefix. Must be provided exactly as i | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -57,10 +58,10 @@ echo $KEY_ID ## Output -Default output shows the request ID with latency, followed by the key details: +Default output shows the request ID, followed by the key details: ```text -req_1234abcd (took 52ms) +req_1234abcd { "keyId": "key_1234abcd", diff --git a/docs/product/cli/overview.mdx b/docs/product/cli/overview.mdx index ea6c094d299..f34deae6771 100644 --- a/docs/product/cli/overview.mdx +++ b/docs/product/cli/overview.mdx @@ -105,10 +105,10 @@ unkey api keys add-permissions --key-id=key_1234abcd --permissions=documents.rea ## Output -By default, the CLI prints the request ID with latency, followed by the response data: +By default, the CLI prints the request ID, followed by the response data: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "id": "api_1234abcd", @@ -122,6 +122,28 @@ For scripting, use `--output=json` to get the raw API response envelope (meta + unkey api apis create-api --name=my-api --output=json | jq '.data.id' ``` +## Send a typed JSON body + +Every `unkey api` action accepts `--body` for sending a JSON string directly to the API. This is useful for AI agents and scripts that already produce an API request body: + +```bash +REQUEST_BODY='{"apiId":"api_1234abcd","name":"Agent-created key"}' +unkey api keys create-key --body="$REQUEST_BODY" --output=json +``` + +Explicitly passing `--body` selects typed body mode. The CLI decodes the value into the endpoint's generated SDK request type, then the SDK serializes that typed request. Malformed JSON is rejected locally. An explicitly empty value, including `--body=''`, selects body mode and is rejected as malformed JSON. Request schema and business validation remain the API's responsibility. + +You cannot combine `--body` with request-building flags. Selecting body mode waives required request-building flag checks because the typed JSON supplies the request instead. + +Operational flags continue to work in body mode. Your configured root key or `--root-key` is still used, while `--api-url` and `--output` keep their normal behavior. + +Quote the JSON so your shell passes it as one argument. Single quotes are usually the clearest choice for inline JSON in Bash: + +```bash +unkey api apis create-api \ + --body='{"name":"payment-service-prod"}' +``` + ## Global Flags Every command accepts these flags: @@ -132,6 +154,7 @@ Every command accepts these flags: | `--api-url` | Override the API base URL (default: `https://api.unkey.com`) | | `--config` | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | Output format. Use `json` for raw JSON | +| `--body` | Send this JSON string as the typed request body. You cannot combine it with request-building flags. | ## Help diff --git a/docs/product/cli/permissions/create-permission.mdx b/docs/product/cli/permissions/create-permission.mdx index 966a75d43d9..e771a33f9e6 100644 --- a/docs/product/cli/permissions/create-permission.mdx +++ b/docs/product/cli/permissions/create-permission.mdx @@ -44,6 +44,7 @@ Detailed documentation of what this permission grants access to. Include informa | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -65,10 +66,10 @@ echo "Created permission: $PERM_ID" ## Output -Default output shows the request ID with latency, followed by the created permission: +Default output shows the request ID, followed by the created permission: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "permissionId": "perm_1234567890abcdef" diff --git a/docs/product/cli/permissions/create-role.mdx b/docs/product/cli/permissions/create-role.mdx index d3aab68118d..90af2b8ed33 100644 --- a/docs/product/cli/permissions/create-role.mdx +++ b/docs/product/cli/permissions/create-role.mdx @@ -38,6 +38,7 @@ Provides comprehensive documentation of what this role encompasses and what acce | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -58,10 +59,10 @@ ROLE_ID=$(unkey api permissions create-role --name=support.readonly --output=jso ## Output -Default output shows the request ID with latency, followed by the created role: +Default output shows the request ID, followed by the created role: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "roleId": "role_5678efgh9012wxyz" diff --git a/docs/product/cli/permissions/delete-permission.mdx b/docs/product/cli/permissions/delete-permission.mdx index 5fa6a329b97..e510db11966 100644 --- a/docs/product/cli/permissions/delete-permission.mdx +++ b/docs/product/cli/permissions/delete-permission.mdx @@ -42,6 +42,7 @@ Before deletion, ensure you have updated any keys or roles that depend on this p | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -59,10 +60,10 @@ unkey api permissions delete-permission --permission=perm_1234567890abcdef --out ## Output -Default output shows the request ID with latency: +Default output shows the request ID: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 {} ``` diff --git a/docs/product/cli/permissions/delete-role.mdx b/docs/product/cli/permissions/delete-role.mdx index ed6e011bc30..e142e6f5415 100644 --- a/docs/product/cli/permissions/delete-role.mdx +++ b/docs/product/cli/permissions/delete-role.mdx @@ -34,6 +34,7 @@ The role ID or name to permanently delete. Must either be a valid role ID that b | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -51,10 +52,10 @@ unkey api permissions delete-role --role=role_dns_manager --output=json ## Output -Default output shows the request ID with latency: +Default output shows the request ID: ```text -req_2c9a0jf23l4k567 (took 38ms) +req_2c9a0jf23l4k567 {} ``` diff --git a/docs/product/cli/permissions/get-permission.mdx b/docs/product/cli/permissions/get-permission.mdx index 99dfeb7cbb2..4c03a992813 100644 --- a/docs/product/cli/permissions/get-permission.mdx +++ b/docs/product/cli/permissions/get-permission.mdx @@ -34,6 +34,7 @@ The unique identifier of the permission to retrieve. Must be a valid permission | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -52,10 +53,10 @@ echo "Permission name: $PERM_NAME" ## Output -Default output shows the request ID with latency, followed by the permission details: +Default output shows the request ID, followed by the permission details: ```text -req_1234abcd (took 38ms) +req_1234abcd { "id": "perm_1234567890abcdef", diff --git a/docs/product/cli/permissions/get-role.mdx b/docs/product/cli/permissions/get-role.mdx index 752033cd8fc..45f5cb5217f 100644 --- a/docs/product/cli/permissions/get-role.mdx +++ b/docs/product/cli/permissions/get-role.mdx @@ -34,6 +34,7 @@ Role ID (starting with `role_`) or role name to retrieve. Must be 3-255 characte | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -54,10 +55,10 @@ unkey api permissions get-role --role=support.readonly --output=json | jq '.data ## Output -Default output shows the request ID with latency, followed by the role details: +Default output shows the request ID, followed by the role details: ```text -req_2c9a0jf23l4k567 (took 45ms) +req_2c9a0jf23l4k567 { "id": "role_1234567890abcdef", diff --git a/docs/product/cli/permissions/list-permissions.mdx b/docs/product/cli/permissions/list-permissions.mdx index a8f47af3dca..f7790c65a7d 100644 --- a/docs/product/cli/permissions/list-permissions.mdx +++ b/docs/product/cli/permissions/list-permissions.mdx @@ -30,6 +30,10 @@ Maximum number of permissions to return in a single response. Accepts a value be Pagination cursor from a previous response to fetch the next page of permissions. Include this value when you need to retrieve additional permissions beyond the initial response. Leave empty or omit this flag to start from the beginning of the permission list. Cursors are temporary and may expire -- always handle cases where a cursor becomes invalid. + +Free-form text to filter permissions. Returns permissions whose ID, name, slug, or description contains the search string. Matching is case-insensitive. + + ## Global Flags | Flag | Type | Description | @@ -38,6 +42,7 @@ Pagination cursor from a previous response to fetch the next page of permissions | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -51,6 +56,9 @@ unkey api permissions list-permissions --limit=50 ```bash Paginate with cursor unkey api permissions list-permissions --limit=50 --cursor=eyJrZXkiOiJwZXJtXzEyMzQifQ== ``` +```bash Search permissions +unkey api permissions list-permissions --search=documents +``` ```bash JSON output for scripting unkey api permissions list-permissions --output=json ``` @@ -68,10 +76,10 @@ done ## Output -Default output shows the request ID with latency, followed by the list of permissions: +Default output shows the request ID, followed by the list of permissions: ```text -req_2c9a0jf23l4k567 (took 32ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/permissions/list-roles.mdx b/docs/product/cli/permissions/list-roles.mdx index 54516d35f08..2e71c7a7fdf 100644 --- a/docs/product/cli/permissions/list-roles.mdx +++ b/docs/product/cli/permissions/list-roles.mdx @@ -30,6 +30,10 @@ Maximum number of roles to return in a single response (1-100, default 100). Use Pagination cursor from a previous response to fetch the next page of roles. Include this when you need to retrieve additional roles beyond the first page. Each response containing more results will include a cursor value that can be used here. Leave empty or omit this flag to start from the beginning of the role list. + +Free-form text to filter roles. Returns roles whose ID, name, or description contains the search string. Matching is case-insensitive. + + ## Global Flags | Flag | Type | Description | @@ -38,6 +42,7 @@ Pagination cursor from a previous response to fetch the next page of roles. Incl | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -51,6 +56,9 @@ unkey api permissions list-roles --limit=50 ```bash Paginate through results unkey api permissions list-roles --limit=50 --cursor=eyJrZXkiOiJyb2xlXzEyMzQifQ== ``` +```bash Search roles +unkey api permissions list-roles --search=admin +``` ```bash JSON output for scripting unkey api permissions list-roles --output=json ``` @@ -58,10 +66,10 @@ unkey api permissions list-roles --output=json ## Output -Default output shows the request ID with latency, followed by the list of roles and their permissions: +Default output shows the request ID, followed by the list of roles and their permissions: ```text -req_2c9a0jf23l4k567 (took 32ms) +req_2c9a0jf23l4k567 [ { diff --git a/docs/product/cli/portal/create-session.mdx b/docs/product/cli/portal/create-session.mdx new file mode 100644 index 00000000000..5b06fdb72f9 --- /dev/null +++ b/docs/product/cli/portal/create-session.mdx @@ -0,0 +1,28 @@ +--- +title: "create-session" +description: "Create a short-lived Customer Portal session" +--- + +Create a session ID valid for 15 minutes and exchangeable exactly once for a 24-hour browser session. Redirect the end user to the returned URL. + +## Usage +```bash +unkey api portal create-session [flags] +``` +## Flags +The 3-64 character lowercase portal configuration slug. +The end user's identifier in your system. +Capabilities: `keys:read`, `keys:create`, `keys:reroll`, and `analytics:read`. +Create a preview session for testing. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format, use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api portal create-session --slug=my-portal --external-id=user_123 --permissions=keys:read,keys:reroll +``` diff --git a/docs/product/cli/projects/create-project.mdx b/docs/product/cli/projects/create-project.mdx new file mode 100644 index 00000000000..3a6dbbc2871 --- /dev/null +++ b/docs/product/cli/projects/create-project.mdx @@ -0,0 +1,27 @@ +--- +title: "create-project" +description: "Create a workspace project" +--- + +Create a project to group deployments and applications under a workspace-scoped slug. The slug is a stable handle and must be unique within your workspace. + +## Usage +```bash +unkey api projects create-project [flags] +``` +## Flags +Human-readable name for this project. Use a descriptive name like 'Payments Service'. +Stable project slug. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api projects create-project --name="Payments Service" --slug=payments-service +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/projects/create-project). diff --git a/docs/product/cli/projects/delete-project.mdx b/docs/product/cli/projects/delete-project.mdx new file mode 100644 index 00000000000..480d0476d08 --- /dev/null +++ b/docs/product/cli/projects/delete-project.mdx @@ -0,0 +1,26 @@ +--- +title: "delete-project" +description: "Delete an existing project" +--- + +Delete a project by ID or slug. Deletion is asynchronous and eventually consistent. Projects with delete protection enabled cannot be deleted until protection is disabled. + +## Usage +```bash +unkey api projects delete-project [flags] +``` +## Flags +Project ID or slug. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api projects delete-project --project=proj_1234abcd +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/projects/delete-project). diff --git a/docs/product/cli/projects/get-project.mdx b/docs/product/cli/projects/get-project.mdx new file mode 100644 index 00000000000..e5c32ed5179 --- /dev/null +++ b/docs/product/cli/projects/get-project.mdx @@ -0,0 +1,26 @@ +--- +title: "get-project" +description: "Retrieve a single project" +--- + +Retrieve a project by ID or slug. Use this to fetch details after creation, verify a project exists, or resolve its metadata. + +## Usage +```bash +unkey api projects get-project [flags] +``` +## Flags +Project ID or slug. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api projects get-project --project=payments-service +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/projects/get-project). diff --git a/docs/product/cli/projects/list-projects.mdx b/docs/product/cli/projects/list-projects.mdx new file mode 100644 index 00000000000..01a72275b15 --- /dev/null +++ b/docs/product/cli/projects/list-projects.mdx @@ -0,0 +1,28 @@ +--- +title: "list-projects" +description: "Retrieve a paginated list of projects" +--- + +Retrieve a paginated list of projects in your workspace. Results are ordered by project ID. When `hasMore` is true, pass the returned `cursor` to fetch the next page. + +## Usage +```bash +unkey api projects list-projects [flags] +``` +## Flags +Maximum number of projects to return per request. Must be between 1 and 100. +Pagination cursor from a previous response to fetch the next page. +Free-form text matching project ID, name, or slug, case-insensitively. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api projects list-projects --limit=25 --search=billing +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/projects/list-projects). diff --git a/docs/product/cli/projects/update-project.mdx b/docs/product/cli/projects/update-project.mdx new file mode 100644 index 00000000000..29c8da288d1 --- /dev/null +++ b/docs/product/cli/projects/update-project.mdx @@ -0,0 +1,29 @@ +--- +title: "update-project" +description: "Update an existing project" +--- + +Update a project by ID or slug. The name, slug, and delete protection can be changed. Omitted fields remain unchanged. Changing the slug affects generated deployment domains. + +## Usage +```bash +unkey api projects update-project [flags] +``` +## Flags +Project ID or slug. +New project slug. Omit to leave unchanged. +New human-readable project name. Omit to leave unchanged. +Enable or disable delete protection. Omit to leave unchanged. +## Global Flags +| Flag | Type | Description | +|------|------|-------------| +| `--root-key` | string | Override root key (`$UNKEY_ROOT_KEY`) | +| `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | +| `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | +| `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | +## Examples +```bash +unkey api projects update-project --project=proj_1234abcd --delete-protection=true +``` +See the [API reference](https://www.unkey.com/docs/api-reference/v2/projects/update-project). diff --git a/docs/product/cli/ratelimit/delete-override.mdx b/docs/product/cli/ratelimit/delete-override.mdx index d770daf4ef6..7c89b91fb8b 100644 --- a/docs/product/cli/ratelimit/delete-override.mdx +++ b/docs/product/cli/ratelimit/delete-override.mdx @@ -43,6 +43,7 @@ After deletion, any identifiers previously affected by this override will immedi | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -60,10 +61,10 @@ unkey api ratelimit delete-override --namespace=api.requests --identifier=premiu ## Output -Default output shows the request ID with latency, followed by the result: +Default output shows the request ID, followed by the result: ```text -req_2cGKbMxRyIzhCxo1Idjz8q (took 45ms) +req_2cGKbMxRyIzhCxo1Idjz8q {} ``` diff --git a/docs/product/cli/ratelimit/get-override.mdx b/docs/product/cli/ratelimit/get-override.mdx index 63bb300636b..314f763f642 100644 --- a/docs/product/cli/ratelimit/get-override.mdx +++ b/docs/product/cli/ratelimit/get-override.mdx @@ -41,6 +41,7 @@ The exact identifier pattern of the override to retrieve. This must match exactl | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -62,10 +63,10 @@ echo "Current limit: $LIMIT" ## Output -Default output shows the request ID with latency, followed by the override configuration: +Default output shows the request ID, followed by the override configuration: ```text -req_2cGKbMxRyIzhCxo1Idjz8q (took 45ms) +req_2cGKbMxRyIzhCxo1Idjz8q { "overrideId": "ovr_1234567890abcdef", diff --git a/docs/product/cli/ratelimit/limit.mdx b/docs/product/cli/ratelimit/limit.mdx index c222a18b486..9894b1ccdfb 100644 --- a/docs/product/cli/ratelimit/limit.mdx +++ b/docs/product/cli/ratelimit/limit.mdx @@ -53,6 +53,7 @@ How much of the rate limit quota this request consumes, enabling weighted rate l | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -73,10 +74,10 @@ unkey api ratelimit limit --namespace=api.requests --identifier=user_abc123 --li ## Output -Default output shows the request ID with latency, followed by the rate limit result: +Default output shows the request ID, followed by the rate limit result: ```text -req_01H9TQPP77V5E48E9SH0BG0ZQX (took 32ms) +req_01H9TQPP77V5E48E9SH0BG0ZQX { "limit": 100, diff --git a/docs/product/cli/ratelimit/list-overrides.mdx b/docs/product/cli/ratelimit/list-overrides.mdx index aa0809d85a6..a9b942940a8 100644 --- a/docs/product/cli/ratelimit/list-overrides.mdx +++ b/docs/product/cli/ratelimit/list-overrides.mdx @@ -50,6 +50,7 @@ Pagination cursor from a previous response. Include this when fetching subsequen | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -70,10 +71,10 @@ unkey api ratelimit list-overrides --namespace=api.requests --output=json ## Output -Default output shows the request ID with latency, followed by the list of overrides: +Default output shows the request ID, followed by the list of overrides: ```text -req_2cGKbMxRyIzhCxo1Idjz8q (took 38ms) +req_2cGKbMxRyIzhCxo1Idjz8q { "overrides": [ diff --git a/docs/product/cli/ratelimit/multi-limit.mdx b/docs/product/cli/ratelimit/multi-limit.mdx index a79f48fd795..7ee29431e73 100644 --- a/docs/product/cli/ratelimit/multi-limit.mdx +++ b/docs/product/cli/ratelimit/multi-limit.mdx @@ -25,7 +25,7 @@ unkey api ratelimit multi-limit [flags] ## Flags - + JSON array of rate limit check objects. Each object defines an independent rate limit check to perform. @@ -59,31 +59,32 @@ JSON array of rate limit check objects. Each object defines an independent rate | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format -- use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples ```bash Multiple namespace checks unkey api ratelimit multi-limit \ - --limits-json='[{"namespace":"api.requests","identifier":"user_abc123","limit":100,"duration":60000},{"namespace":"auth.login","identifier":"user_abc123","limit":5,"duration":60000}]' + --limits='[{"namespace":"api.requests","identifier":"user_abc123","limit":100,"duration":60000},{"namespace":"auth.login","identifier":"user_abc123","limit":5,"duration":60000}]' ``` ```bash Weighted cost checks unkey api ratelimit multi-limit \ - --limits-json='[{"namespace":"api.light_operations","identifier":"user_xyz789","limit":100,"duration":60000,"cost":1},{"namespace":"api.heavy_operations","identifier":"user_xyz789","limit":50,"duration":3600000,"cost":5}]' + --limits='[{"namespace":"api.light_operations","identifier":"user_xyz789","limit":100,"duration":60000,"cost":1},{"namespace":"api.heavy_operations","identifier":"user_xyz789","limit":50,"duration":3600000,"cost":5}]' ``` ```bash JSON output for scripting unkey api ratelimit multi-limit \ - --limits-json='[{"namespace":"api.requests","identifier":"user_abc123","limit":100,"duration":60000}]' \ + --limits='[{"namespace":"api.requests","identifier":"user_abc123","limit":100,"duration":60000}]' \ --output=json ``` ## Output -Default output shows the request ID with latency, followed by the multi-limit result: +Default output shows the request ID, followed by the multi-limit result: ```text -req_01H9TQPP77V5E48E9SH0BG0ZQX (took 38ms) +req_01H9TQPP77V5E48E9SH0BG0ZQX { "passed": true, diff --git a/docs/product/cli/ratelimit/set-override.mdx b/docs/product/cli/ratelimit/set-override.mdx index b9bf76561ee..52ce454bfcf 100644 --- a/docs/product/cli/ratelimit/set-override.mdx +++ b/docs/product/cli/ratelimit/set-override.mdx @@ -61,6 +61,7 @@ Common values: `60000` (1 minute), `3600000` (1 hour), `86400000` (1 day). This | `--api-url` | string | Override API base URL (default: `https://api.unkey.com`) | | `--config` | string | Path to config file (default: `~/.unkey/config.toml`) | | `--output` | string | Output format. Use `json` for raw JSON | +| `--body` | string | Send this JSON string as the request body. You cannot combine it with request-building flags. | ## Examples @@ -81,10 +82,10 @@ unkey api ratelimit set-override --namespace=api.requests --identifier=partner_a ## Output -Default output shows the request ID with latency, followed by the created or updated override: +Default output shows the request ID, followed by the created or updated override: ```text -req_2cGKbMxRyIzhCxo1Idjz8q (took 45ms) +req_2cGKbMxRyIzhCxo1Idjz8q { "overrideId": "ovr_1234567890abcdef" diff --git a/docs/product/docs.json b/docs/product/docs.json index 8ad659065cb..b24fc9611a3 100644 --- a/docs/product/docs.json +++ b/docs/product/docs.json @@ -385,6 +385,63 @@ "cli/apis/list-keys" ] }, + { + "group": "projects", + "pages": [ + "cli/projects/create-project", + "cli/projects/delete-project", + "cli/projects/get-project", + "cli/projects/list-projects", + "cli/projects/update-project" + ] + }, + { + "group": "apps", + "pages": [ + "cli/apps/create-app", + "cli/apps/delete-app", + "cli/apps/get-app", + "cli/apps/list-apps", + "cli/apps/update-app" + ] + }, + { + "group": "environments", + "pages": [ + "cli/environments/get-environment", + "cli/environments/list-environment-variables", + "cli/environments/list-environments", + "cli/environments/remove-environment-variables", + "cli/environments/set-environment-variables", + "cli/environments/update-settings" + ] + }, + { + "group": "deployments", + "pages": [ + "cli/deployments/create-deployment", + "cli/deployments/get-deployment", + "cli/deployments/list-deployments", + "cli/deployments/promote-deployment", + "cli/deployments/rollback-deployment", + "cli/deployments/start-deployment", + "cli/deployments/stop-deployment" + ] + }, + { + "group": "gateway", + "pages": [ + "cli/gateway/list-policies", + "cli/gateway/set-policies", + "cli/gateway/update-policy" + ] + }, + { + "group": "portal", + "pages": [ + "cli/portal/create-session" + ] + }, { "group": "keys", "pages": [ diff --git a/go.mod b/go.mod index 99f644287c7..9906f117b6d 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/unkeyed/unkey -go 1.25.0 - -toolchain go1.25.1 +go 1.25.10 // Yaml parsing errors replace github.com/dprotaso/go-yit => github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 @@ -67,7 +65,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/stripe/stripe-go/v86 v86.0.0 github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f - github.com/unkeyed/sdks/api/go/v2 v2.6.1 + github.com/unkeyed/sdks/api/go/v2 v2.7.1 github.com/vishvananda/netlink v1.3.1 go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 diff --git a/go.sum b/go.sum index f41eedd72dd..69d9518c1f4 100644 --- a/go.sum +++ b/go.sum @@ -678,8 +678,8 @@ github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Q github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/unkeyed/sdks/api/go/v2 v2.6.1 h1:Sz4Rv4fhh7ByCTIcJ7orK+2ZOMyoXLsAV6t+q4B+Gck= -github.com/unkeyed/sdks/api/go/v2 v2.6.1/go.mod h1:1eT/d35dAxF/Ncbg9jrDSuw9CHo/qKzGPppo0gABOU4= +github.com/unkeyed/sdks/api/go/v2 v2.7.1 h1:qLsrVYZZC71VjSDOWkBIqQ6DPEasHu0L9gNemyc2TW0= +github.com/unkeyed/sdks/api/go/v2 v2.7.1/go.mod h1:gt+BiRjnnAR6Q/4r6xhxeZ9KBp6r6O2egC8njM86IOw= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= diff --git a/pkg/cli/docs.go b/pkg/cli/docs.go index 0a0d2fa35ee..c9696ef9cb6 100644 --- a/pkg/cli/docs.go +++ b/pkg/cli/docs.go @@ -312,14 +312,23 @@ func (c *Command) extractFlags() []MDXFlag { if flag == nil { continue } + exclusiveNames := c.mutuallyExclusiveNames(flag) + description := flag.Usage() + if len(exclusiveNames) > 0 { + exclusiveFlags := "--" + strings.Join(exclusiveNames, " or --") + if flag.Required() { + description += fmt.Sprintf(" Required unless %s is set.", exclusiveFlags) + } + description += fmt.Sprintf(" Mutually exclusive with %s.", exclusiveFlags) + } mdxFlag := MDXFlag{ Name: flag.Name(), - Description: flag.Usage(), + Description: description, Type: c.getTypeString(flag), Default: c.getDefaultValue(flag), EnvVar: c.getEnvVar(flag), - Required: flag.Required(), + Required: flag.Required() && len(exclusiveNames) == 0, } flags = append(flags, mdxFlag) } diff --git a/pkg/cli/flag.go b/pkg/cli/flag.go index 42687c8e642..194020e0a58 100644 --- a/pkg/cli/flag.go +++ b/pkg/cli/flag.go @@ -33,12 +33,13 @@ type ValidateFunc func(value string) error // baseFlag contains common fields and methods shared by all flag types type baseFlag struct { - name string // Flag name - usage string // Help description - envVar string // Environment variable to check for default - required bool // Whether flag is mandatory - set bool // Whether user explicitly provided this flag - validate ValidateFunc // Optional validation function + name string // Flag name + usage string // Help description + envVar string // Environment variable to check for default + required bool // Whether flag is mandatory + set bool // Whether user explicitly provided this flag + validate ValidateFunc // Optional validation function + mutuallyExclusive []string // Flag names that cannot be explicitly set with this flag } // Name returns the flag name @@ -359,6 +360,38 @@ func Required() FlagOption { } } +// MutuallyExclusive declares flags that cannot be explicitly supplied with this flag. +func MutuallyExclusive(otherNames ...string) FlagOption { + return func(f any) { + if flag, ok := baseFlagOf(f); ok { + flag.mutuallyExclusive = append(flag.mutuallyExclusive, otherNames...) + } + } +} + +func baseFlagOf(f any) (*baseFlag, bool) { + switch flag := f.(type) { + case *StringFlag: + return &flag.baseFlag, true + case *BoolFlag: + return &flag.baseFlag, true + case *IntFlag: + return &flag.baseFlag, true + case *Int64Flag: + return &flag.baseFlag, true + case *FloatFlag: + return &flag.baseFlag, true + case *StringSliceFlag: + return &flag.baseFlag, true + case *DurationFlag: + return &flag.baseFlag, true + case *EnumFlag: + return &flag.baseFlag, true + default: + return nil, false + } +} + // EnvVar sets an environment variable to check for default values func EnvVar(envVar string) FlagOption { return func(f any) { diff --git a/pkg/cli/help.go b/pkg/cli/help.go index 25da605f16c..5aa18f372e6 100644 --- a/pkg/cli/help.go +++ b/pkg/cli/help.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "os" + "slices" "strings" "golang.org/x/term" @@ -168,7 +169,12 @@ func (c *Command) showFlags() { c.showFlag(flag) } for _, group := range c.RequireOneOf { - fmt.Printf(" exactly one of --%s is required\n", strings.Join(group, " or --")) + waivers := c.commonMutuallyExclusiveNames(group) + if len(waivers) > 0 { + fmt.Printf(" exactly one of --%s is required unless --%s is set\n", strings.Join(group, " or --"), strings.Join(waivers, " or --")) + } else { + fmt.Printf(" exactly one of --%s is required\n", strings.Join(group, " or --")) + } } fmt.Printf("\n") } @@ -301,10 +307,16 @@ func flagTypeLabel(flag Flag) string { // buildFlagUsage constructs the complete usage string for a flag func (c *Command) buildFlagUsage(flag Flag) string { usage := flag.Usage() + exclusiveNames := c.mutuallyExclusiveNames(flag) + exclusiveFlags := "--" + strings.Join(exclusiveNames, " or --") // Add required indicator - if flag.Required() { + if flag.Required() && len(exclusiveNames) > 0 { + usage += fmt.Sprintf(" (required unless %s is set; mutually exclusive with %s)", exclusiveFlags, exclusiveFlags) + } else if flag.Required() { usage += " (required)" + } else if len(exclusiveNames) > 0 { + usage += fmt.Sprintf(" (mutually exclusive with %s)", exclusiveFlags) } // Add environment variable info if available @@ -322,6 +334,37 @@ func (c *Command) buildFlagUsage(flag Flag) string { return usage } +func (c *Command) flagByName(name string) Flag { + for _, flag := range c.Flags { + if flag.Name() == name { + return flag + } + } + return nil +} + +func (c *Command) commonMutuallyExclusiveNames(group []string) []string { + if len(group) == 0 { + return nil + } + first := c.flagByName(group[0]) + if first == nil { + return nil + } + common := c.mutuallyExclusiveNames(first) + for _, name := range group[1:] { + flag := c.flagByName(name) + if flag == nil { + return nil + } + exclusiveNames := c.mutuallyExclusiveNames(flag) + common = slices.DeleteFunc(common, func(candidate string) bool { + return !slices.Contains(exclusiveNames, candidate) + }) + } + return common +} + // getEnvVar extracts environment variable name from flag if it supports it func (c *Command) getEnvVar(flag Flag) string { switch f := flag.(type) { diff --git a/pkg/cli/help_test.go b/pkg/cli/help_test.go index ebffcb12a3d..df435c3317c 100644 --- a/pkg/cli/help_test.go +++ b/pkg/cli/help_test.go @@ -35,3 +35,21 @@ func TestWrapText_EmptyString(t *testing.T) { lines := wrapText("", 80) require.Equal(t, []string{""}, lines) } + +func TestBuildFlagUsage_MutuallyExclusive(t *testing.T) { + cmd := &Command{Flags: []Flag{ + String("request", "Request input.", Required(), MutuallyExclusive("body")), + String("body", "Body input."), + }} + + require.Equal( + t, + "Request input. (required unless --body is set; mutually exclusive with --body)", + cmd.buildFlagUsage(cmd.Flags[0]), + ) + require.Equal( + t, + "Body input. (mutually exclusive with --request)", + cmd.buildFlagUsage(cmd.Flags[1]), + ) +} diff --git a/pkg/cli/parser.go b/pkg/cli/parser.go index f7719dcf2b0..c0bd739860f 100644 --- a/pkg/cli/parser.go +++ b/pkg/cli/parser.go @@ -199,29 +199,86 @@ func (c *Command) initFlagMap() { // validateRequiredFlags checks that all required flags have been set and that // every RequireOneOf group has exactly one of its flags set. func (c *Command) validateRequiredFlags() error { + // Validate command definitions before invocation requirements. + for _, group := range c.RequireOneOf { + if len(group) == 0 { + return fmt.Errorf("RequireOneOf group cannot be empty") + } + for _, name := range group { + if _, ok := c.flagMap[name]; !ok { + return fmt.Errorf("RequireOneOf references unknown flag: %s", name) + } + } + } + for _, flag := range c.Flags { + base, ok := baseFlagOf(flag) + if !ok { + continue + } + for _, otherName := range base.mutuallyExclusive { + if otherName == flag.Name() { + return fmt.Errorf("flag --%s cannot be mutually exclusive with itself", flag.Name()) + } + other, exists := c.flagMap[otherName] + if !exists { + return fmt.Errorf("flag --%s references unknown mutually exclusive flag: %s", flag.Name(), otherName) + } + if flag.IsSet() && other.IsSet() { + return fmt.Errorf("flags --%s and --%s are mutually exclusive", flag.Name(), otherName) + } + } + } + for _, flag := range c.Flags { - if flag.Required() && !flag.HasValue() { + if flag.Required() && !flag.HasValue() && !c.requirementWaived(flag) { return fmt.Errorf("required flag missing: %s", flag.Name()) } } for _, group := range c.RequireOneOf { set := 0 + allWaived := true for _, name := range group { - flag, ok := c.flagMap[name] - if !ok { - // A group naming a flag the command does not define is a - // programming error in the command definition, not user input. - return fmt.Errorf("RequireOneOf references unknown flag: %s", name) - } + flag := c.flagMap[name] if flag.HasValue() { set++ } + if !c.requirementWaived(flag) { + allWaived = false + } } - if set != 1 { + if set != 1 && !(set == 0 && allWaived) { return fmt.Errorf("exactly one of --%s is required", strings.Join(group, " or --")) } } return nil } + +func (c *Command) requirementWaived(flag Flag) bool { + for _, otherName := range c.mutuallyExclusiveNames(flag) { + if other := c.flagMap[otherName]; other != nil && other.IsSet() { + return true + } + } + return false +} + +// mutuallyExclusiveNames returns both directly declared and reverse-declared +// relationships because mutual exclusion is symmetric. +func (c *Command) mutuallyExclusiveNames(flag Flag) []string { + var names []string + if base, ok := baseFlagOf(flag); ok { + names = append(names, base.mutuallyExclusive...) + } + for _, other := range c.Flags { + if other.Name() == flag.Name() || slices.Contains(names, other.Name()) { + continue + } + base, ok := baseFlagOf(other) + if ok && slices.Contains(base.mutuallyExclusive, flag.Name()) { + names = append(names, other.Name()) + } + } + return names +} diff --git a/pkg/cli/parser_test.go b/pkg/cli/parser_test.go index f3f94fe3e4c..975687099ae 100644 --- a/pkg/cli/parser_test.go +++ b/pkg/cli/parser_test.go @@ -7,6 +7,68 @@ import ( "github.com/stretchr/testify/require" ) +func TestParser_MutuallyExclusive(t *testing.T) { + t.Run("conflict", func(t *testing.T) { + cmd := &Command{Name: "test", Flags: []Flag{String("request", "", MutuallyExclusive("body")), String("body", "")}} + require.EqualError(t, cmd.parse(context.Background(), []string{"--request=value", "--body={}"}), "flags --request and --body are mutually exclusive") + }) + + t.Run("required and group requirements are waived", func(t *testing.T) { + called := false + cmd := &Command{Name: "test", Flags: []Flag{ + String("required", "", Required(), MutuallyExclusive("body")), + String("left", "", MutuallyExclusive("body")), + String("right", "", MutuallyExclusive("body")), + String("body", ""), + }, RequireOneOf: [][]string{{"left", "right"}}, Action: func(context.Context, *Command) error { called = true; return nil }} + require.NoError(t, cmd.parse(context.Background(), []string{"--body={}"})) + require.True(t, called) + }) + + t.Run("reverse declaration waives required and group requirements", func(t *testing.T) { + called := false + cmd := &Command{Name: "test", Flags: []Flag{ + String("required", "", Required()), + String("left", ""), + String("right", ""), + String("body", "", MutuallyExclusive("required", "left", "right")), + }, RequireOneOf: [][]string{{"left", "right"}}, Action: func(context.Context, *Command) error { called = true; return nil }} + require.NoError(t, cmd.parse(context.Background(), []string{"--body={}"})) + require.True(t, called) + }) + + t.Run("environment value does not activate", func(t *testing.T) { + t.Setenv("TEST_BODY", "{}") + cmd := &Command{Name: "test", Flags: []Flag{String("required", "", Required(), MutuallyExclusive("body")), String("body", "", EnvVar("TEST_BODY"))}} + require.ErrorContains(t, cmd.parse(context.Background(), nil), "required flag missing: required") + }) + + t.Run("default value does not activate", func(t *testing.T) { + cmd := &Command{Name: "test", Flags: []Flag{String("required", "", Required(), MutuallyExclusive("body")), String("body", "", Default("{}"))}} + require.ErrorContains(t, cmd.parse(context.Background(), nil), "required flag missing: required") + }) + + t.Run("explicit empty waives requirement", func(t *testing.T) { + cmd := &Command{Name: "test", Flags: []Flag{String("required", "", Required(), MutuallyExclusive("body")), String("body", "")}} + require.NoError(t, cmd.parse(context.Background(), []string{"--body="})) + }) + + t.Run("unknown reference", func(t *testing.T) { + cmd := &Command{Name: "test", Flags: []Flag{String("request", "", MutuallyExclusive("missing"))}} + require.ErrorContains(t, cmd.parse(context.Background(), nil), "references unknown mutually exclusive flag: missing") + }) + + t.Run("self reference", func(t *testing.T) { + cmd := &Command{Name: "test", Flags: []Flag{String("request", "", MutuallyExclusive("request"))}} + require.ErrorContains(t, cmd.parse(context.Background(), nil), "cannot be mutually exclusive with itself") + }) + + t.Run("empty RequireOneOf group", func(t *testing.T) { + cmd := &Command{Name: "test", RequireOneOf: [][]string{{}}} + require.ErrorContains(t, cmd.parse(context.Background(), nil), "RequireOneOf group cannot be empty") + }) +} + func TestParser_AcceptsArgs_FlagsAfterPositionalArg(t *testing.T) { // AcceptsArgs should allow flags after positional arguments // Example: deploy nginx:latest --project=local --env=preview