diff --git a/.agents/skills/sync-cli-with-openapi/SKILL.md b/.agents/skills/sync-cli-with-openapi/SKILL.md new file mode 100644 index 0000000000..1f50c425c9 --- /dev/null +++ b/.agents/skills/sync-cli-with-openapi/SKILL.md @@ -0,0 +1,154 @@ +--- +name: sync-cli-with-openapi +description: Synchronize the `ap` CLI (cli/) with a REST API OpenAPI spec after the spec changes. Use when someone edits platform-api/src/resources/openapi.yaml (consumed by the `ap ai-ws` family via its LLM/MCP endpoints), portals/developer-portal/docs/devportal-openapi-spec-v1.yaml (the `ap devportal` family), or gateway/gateway-controller/api/management-openapi.yaml (the `ap gateway` family) and the matching CLI commands need updating — a changed request/response/parameter on an endpoint an existing command calls, OR a newly added endpoint that needs a brand-new command. Trigger on "update the CLI for this spec change", "the spec changed, sync the CLI commands", "add a CLI command for this new endpoint", or when naming this skill directly. Keeps commands, path constants/helpers, command registration, docs, and tests in lockstep with the spec. +allowed-tools: Bash, Read, Edit, Write, Grep, Glob +--- + +# Sync the `ap` CLI with an OpenAPI spec + +Keep the Go CLI under `cli/` in sync with a REST API contract after its OpenAPI spec changes. This covers two jobs: + +1. **Update existing commands** when an endpoint they already call changes shape (new/renamed parameter, changed request/response body, new required field, removed operation). +2. **Scaffold new commands** for endpoints newly added to a spec that have no CLI coverage yet. + +The linking pin is always the **HTTP path + method**: a spec operation maps to exactly one CLI call site. This skill works by discovering that call site (or its absence) and reconciling it — it does **not** assume a fixed spec→command table, because that drifts. + +## Invoking this skill + +There are two ways to drive it. Both run the same workflow — they differ only in *scope*. + +### Mode A — sync everything that changed in a spec (diff-driven) + +Use when a spec file changed (e.g. a `git pull` or merge brought in new/edited endpoints) and you want the CLI reconciled to match. You do **not** list the endpoints — the skill reads the diff and classifies every changed operation itself (Step 1). + +Trigger prompts: +- *"The devportal spec changed in the last pull — sync the CLI commands."* +- *"/sync-cli-with-openapi platform-api/src/resources/openapi.yaml"* + +**After a pull/merge the change is already committed**, so a plain `git diff` shows nothing — the skill must diff against the pre-change state (Step 1 lists the exact commands). If the user knows the base, they can say so: *"…diff it against `HEAD@{1}`"* or *"…against `main`"*. + +### Mode B — convert a named set of endpoints into commands (scope-driven) + +Use when you want CLI coverage for *specific* endpoints — regardless of what the diff says. You name the operations; the skill jumps straight to scaffolding them (Step 2 → Step 3, ADDED path) and ignores the rest. + +Trigger prompts: +- *"Add `ap` commands for `POST /devportal/v1/webhook-subscribers` and `GET /devportal/v1/webhook-subscribers/{subscriberId}`."* +- *"From platform-api/openapi.yaml, only wire the `llm-provider-templates` endpoints (list, get, copy) into the `ai-ws` CLI."* + +The skill still reads each named operation's real schema from the spec so flags/payloads/validation are correct — it just skips the diff-classification breadth. + +**In both modes**, if the user hasn't named the spec, infer it from the paths/family they mention and confirm. If a named endpoint has no consumer *and* no clear family (e.g. a non-LLM platform-api path), report it and ask before inventing a new command family. + +## Spec → CLI-family map + +Three specs feed the CLI. Each is consumed by a distinct command family with its own client and conventions: + +| Spec file | CLI family | Root dir | Client package | +|---|---|---|---| +| `gateway/gateway-controller/api/management-openapi.yaml` | `ap gateway ...` | `cli/src/cmd/gateway/` | `internal/gateway` | +| `portals/developer-portal/docs/devportal-openapi-spec-v1.yaml` | `ap devportal ...` | `cli/src/cmd/devportal/` | `internal/devportal` | +| `platform-api/src/resources/openapi.yaml` | `ap ai-ws ...` (partial — see note) | `cli/src/cmd/aiws/` | `internal/aiworkspace` | + +**Note on `platform-api/src/resources/openapi.yaml`:** the `ap ai-ws` (AI workspace) family consumes this spec, but **only its LLM/MCP subset** — `/llm-providers`, `/llm-proxies`, `/mcp-proxies` (and the `llm-provider-templates` endpoints). The CLI reaches them under the `/api-proxy/api/v0.9/` prefix via path constants in `cli/src/utils/constants.go` (e.g. `AIWorkspaceLLMProvidersPath = "/api-proxy/api/v0.9/llm-providers"`), so the spec path is *unversioned* (`/llm-providers`) but the CLI call is *versioned* — match on the resource segment, not the whole path. The spec's many other resources (`/organizations`, `/projects`, `/rest-apis`, `/gateways`, `/applications`, `/subscription-plans`, `/subscriptions`, …) have **no `ap` consumer today** — the `ap gateway` commands look similar but actually track the separate *gateway management* spec. So for a platform-api change: if it touches an LLM/MCP endpoint, sync the `ai-ws` family; otherwise discovery (Step 2) will find no consumer — say so and ask the user whether they want a new command family, rather than inventing edits. + +Full anatomy of a command, the exact helpers per family, and copy-ready templates live in **`references/cli-command-anatomy.md`**. Read that file before writing or editing any command — do not work from memory. + +## Workflow + +### Step 1 — Identify what changed in the spec + +**Mode B (named endpoints):** skip the diff — take the operations the user listed as the ADDED set and go to Step 2. Still open the spec file to read each operation's schema. + +**Mode A (diff-driven):** get the diff of the spec. Pick the command by where the change lives: + +```bash +git diff -- # uncommitted local edits (you're mid-editing the spec) +git diff HEAD@{1}..HEAD -- # change arrived via a pull (compare to pre-pull HEAD) +git diff main...HEAD -- # everything new on this branch vs main (PR review) +git diff .. -- +``` + +If none show a diff, the spec is unchanged relative to that base — confirm the base with the user (a pull needs `HEAD@{1}` or the merge commit, not a plain `git diff`). + +If the user hasn't said which spec, infer from the path they name and confirm it's one of the three above. From the diff, build a list of **changed operations**, each classified as: + +- **ADDED** — a new `path` or a new method under an existing path → candidate for a *new command*. +- **MODIFIED** — an existing operation whose parameters, `requestBody`, responses, required fields, or `operationId`/scopes changed → *update the existing command*. +- **REMOVED** — an operation deleted → *remove or deprecate the command*. + +For each operation record: HTTP method, full path (e.g. `POST /devportal/v1/subscriptions/{subId}/change-plan`), the changed parameters/fields, and the OAuth2 scope if the spec declares one. + +### Step 2 — Locate the CLI consumer (or confirm there is none) + +For each operation, find the call site. The path segment is the search key. Match against how each family stores paths: + +- **Gateway family** — paths are constants in `cli/src/utils/constants.go` (e.g. `GatewaySubscriptionPlansPath = "/subscription-plans"`, `%s` for IDs). Grep for the literal path, then for the constant name: + ```bash + grep -rn "subscription-plans" cli/src/utils/constants.go + grep -rn "GatewaySubscriptionPlansPath" cli/src/cmd/gateway/ + ``` +- **DevPortal family** — org-scoped paths are built with `internaldevportal.OrgScopedPath(orgID, "")`, which produces `/o/{orgId}/devportal/{APIVersion}/` (`APIVersion` = `v1`, in `internal/devportal/helpers.go`). Only org-management endpoints use the raw `/organizations...` path. Grep for the resource segment: + ```bash + grep -rn "OrgScopedPath\|\"/organizations" cli/src/cmd/devportal/ + ``` +- **AI-workspace family** — paths are constants in `cli/src/utils/constants.go` (`AIWorkspaceLLMProvidersPath` etc., under `/api-proxy/api/v0.9/`), wrapped by helpers in `cli/src/internal/aiworkspace/helpers.go` (`ProviderPath(orgID)`, `ProviderResourcePath(orgID,id)`, `ProviderListPath(orgID,q)`, and the `Proxy*`/`MCPProxy*` equivalents). Org/project scoping is a **query parameter** (`?organizationId=`/`?projectId=`), not a path segment. Match on the resource segment, since the spec path is unversioned: + ```bash + grep -rn "llm-providers\|llm-proxies\|mcp-proxies" cli/src/utils/constants.go + grep -rn "AIWorkspaceLLMProvidersPath\|ProviderPath\|ProviderResourcePath" cli/src/cmd/aiws/ cli/src/internal/aiworkspace/ + ``` + +Outcomes: +- **Found** → MODIFIED/REMOVED work targets this file. Note its dir (the cobra subcommand group). +- **Not found** for an ADDED op → this is new-command work. Identify the right subcommand group (existing dir like `cli/src/cmd/gateway/subscription/`, or a new one). +- **Not found** for a MODIFIED op → the endpoint has no CLI coverage; treat as ADDED or report it, don't silently skip. + +### Step 3 — Reconcile each operation + +Open `references/cli-command-anatomy.md` and follow the template for the operation's family. Then, per classification: + +**MODIFIED — update an existing command** +- New/renamed **parameter** → add/rename the cobra flag (use or add a `Flag*` constant in `cli/src/utils/flags.go`; wire with `utils.AddStringFlag`/`AddBoolFlag`), update the path/query building, and adjust validation (`MarkFlagRequired`, mutual-exclusion checks). +- Changed **request body** → update the struct/`map` marshalled into the payload and any `--file` CR parsing (`gateway.ParseResourceCR`). +- New **required** field → add validation that returns a clear `fmt.Errorf` before the request; add a flag or CR field for it. +- Changed **response** → update any local response struct and the print path (`PrintJSONResponse` needs no change for pass-through; typed decoders do). +- Changed **path/method** → update the path constant/helper and the client verb (`Get`/`PostJSON`/`Put`/`Delete`/`PostYAML`). + +**ADDED — scaffold a new command** +1. Pick the subcommand group dir. Reuse an existing one if the resource already has a group; otherwise create a new dir + `root.go` (a `Cmd` cobra group) and register it in the family root (`cli/src/cmd//root.go`). +2. Add the path constant (gateway) or use `OrgScopedPath` (devportal). +3. Create `.go` from the matching template in the reference: consts `CmdLiteral`/`CmdExample`, flag vars, the `cobra.Command`, `init()` wiring flags, and `runCommand` doing validate → client → request → status-check → print. +4. Register the new command in the group's `root.go` via `AddCommand`. +5. Map the operation's OAuth2 scope/auth to the family's auth model (gateway selection flags vs devportal resolve+`--insecure`) — the reference explains both. + +**REMOVED — retire a command** +- Confirm with the user first (removing a command is a breaking CLI change). If confirmed, delete the `.go`, drop its `AddCommand` line, remove now-unused path constants, and delete its tests. + +### Step 4 — Keep the surrounding artifacts in sync + +A command change is not done until these match: + +- **Flag constants** — new flags need a `Flag*` entry in `cli/src/utils/flags.go` (don't hardcode flag strings). +- **Path constants** — new/changed gateway endpoints need entries in `cli/src/utils/constants.go`. +- **Registration** — every new command/group is wired via `AddCommand` up to the family root. +- **Docs** (manually maintained, not generated) — update the relevant `docs/cli//README.md` and, if a top-level command/flag changed, `docs/cli/reference.md`. Match the existing entry format (CLI Command + example blocks). +- **Tests** — mirror the neighboring `*_test.go` / `commands_test.go` in the same dir. Add cases for new flags/validation; update expectations for changed payloads. + +### Step 5 — Build, test, and report + +From `cli/`: + +```bash +make build # builds (runs tests first) +make test # tests only +# or, faster while iterating, from cli/src: go build ./... && go test ./... +``` + +Fix compile/test failures. Then report: which operations were ADDED/MODIFIED/REMOVED, the files touched (commands, constants, flags, registration, docs, tests), the build/test result, and anything that needs manual follow-up (e.g. a REMOVED op awaiting confirmation, or a platform-api change with no CLI consumer). + +## Guardrails + +- **Never invent an endpoint.** Every path/verb/parameter a command uses must exist in the spec diff or the current spec. If the spec is ambiguous, read the operation's schema in the spec file rather than guessing. +- **Match the family's conventions exactly** — the gateway, devportal, and ai-ws families differ in client construction, path handling (constants vs `OrgScopedPath` vs query-param scoping), verb vocabulary (`create` vs `push`), auth flags, and error wrapping. Copy the closest existing sibling command; don't blend patterns across families. +- **Treat CLI removals/renames as breaking.** Confirm before removing or renaming a command, flag, or its short form. +- **Keep the license header** — every Go file starts with the Apache-2.0 WSO2 header block (copy it from any sibling file). +- Prefer editing a sibling-derived copy over authoring from scratch; the templates in the reference are a fallback, the real source of truth is the existing commands in the same group. diff --git a/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md b/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md new file mode 100644 index 0000000000..8967584d0d --- /dev/null +++ b/.agents/skills/sync-cli-with-openapi/references/cli-command-anatomy.md @@ -0,0 +1,351 @@ +# CLI command anatomy & templates + +Reference for the `sync-cli-with-openapi` skill. All paths are relative to the repo root; CLI Go source lives under `cli/src/` (module `github.com/wso2/api-platform/cli`, so imports are e.g. `.../cli/internal/gateway`, `.../cli/utils`). + +The two command families differ enough that you must not blend them. Pick the family from the spec (see the table in SKILL.md), then copy the closest existing sibling command in the same subcommand group. These templates are the fallback when no close sibling exists. + +--- + +## 1. Directory & registration layout (both families) + +``` +cli/src/cmd// # gateway | devportal | platform | aiws | project + root.go # Cmd cobra group; init() AddCommand(...) each child + .go # a leaf command directly under the family + / # a subcommand GROUP for a resource + root.go # Cmd group; init() AddCommand(create/list/get/update/delete...) + create.go list.go get.go update.go delete.go + commands_test.go # table tests for the group +``` + +Registration chain, bottom-up — a new command is invisible until every link exists: + +1. leaf `.go` defines `var Cmd = &cobra.Command{...}`. +2. group `root.go` `init()` calls `Cmd.AddCommand(Cmd)`. +3. family `root.go` `init()` calls `Cmd.AddCommand(.Cmd)`. +4. `Cmd` is registered on the CLI root in `cli/src/cmd/root.go`. + +A new resource group also needs a `root.go` (`Cmd`), added at link 3. + +### Shared conventions + +- **License header**: every `.go` file opens with the Apache-2.0 WSO2 header block (copyright 2026, WSO2 LLC.). Copy it verbatim from any sibling file. +- **Command consts**: each leaf declares `const CmdLiteral = ""` and `CmdExample = ` + "`" + `# ...\nap ...` + "`" + `. +- **Flag name constants**: never hardcode a flag string. Use a `Flag*` const from `cli/src/utils/flags.go`; add one there if missing. Register flags with `utils.AddStringFlag(cmd, utils.FlagX, &var, "", "")` / `utils.AddBoolFlag(...)`. Enforce required flags with `cmd.MarkFlagRequired(utils.FlagX)`. +- **Run wrapper**: `Run:` calls a `runCommand(...)` that returns `error`; on error print `fmt.Fprintf(os.Stderr, "Error: %v\n", err)` then `os.Exit(1)`. +- **Validate before calling**: check required/mutually-exclusive flags and return a clear `fmt.Errorf` first. + +--- + +## 2. Gateway family (`internal/gateway`) + +Consumes `gateway/gateway-controller/api/management-openapi.yaml`. Targets the selected/active gateway. + +**Key helpers** +- `gateway.AddSelectionFlags(cmd)` — adds the gateway-selection flags. Call in every `init()`. +- `gateway.NewClientFromCommand(cmd)` — builds the client for the selected/active gateway. +- Client verbs: `client.Get(path)`, `client.Post(path, body)`, `client.Put(path, body)`, `client.Delete(path)`, plus `PostYAML`/`PutYAML` for raw YAML bodies. +- `gateway.ParseResourceCR(filePath, expectedKind)` — parses a `--file` custom-resource (YAML/JSON) into `{apiVersion, kind, metadata, spec}`; use `cr.Spec` as the payload. +- `gateway.PrintJSONResponse(resp)` — pretty-prints the response body (pass-through; no typed struct needed). +- **Success check**: `resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices` → error. +- **Paths**: constants in `cli/src/utils/constants.go`, named `GatewayPath` (collection) and `GatewayByIDPath` (has one `%s` per path variable). Add a constant for a new endpoint; build with `fmt.Sprintf(utils.Gateway...ByIDPath, id)`. + +### Template — gateway create-from-CR (`POST` collection) + +```go +// + +package + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +const kind = "" // CR kind from the spec + +const ( + CreateCmdLiteral = "create" + CreateCmdExample = `# Create a from a CR file +ap gateway create --file .yaml` +) + +var createFilePath string + +var createCmd = &cobra.Command{ + Use: CreateCmdLiteral, + Short: "Create a on the gateway", + Long: "Creates a new from a custom resource file (YAML or JSON).", + Example: CreateCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runCreateCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(createCmd) + utils.AddStringFlag(createCmd, utils.FlagFile, &createFilePath, "", "Path to the CR file (YAML or JSON)") + createCmd.MarkFlagRequired(utils.FlagFile) +} + +func runCreateCommand(cmd *cobra.Command) error { + if strings.TrimSpace(createFilePath) == "" { + return fmt.Errorf("--%s is required", utils.FlagFile) + } + cr, err := gateway.ParseResourceCR(createFilePath, kind) + if err != nil { + return err + } + // Validate spec-required fields here, e.g.: + // if v, ok := cr.Spec[""].(string); !ok || strings.TrimSpace(v) == "" { + // return fmt.Errorf("invalid %s: spec. is required", kind) + // } + data, err := json.Marshal(cr.Spec) + if err != nil { + return fmt.Errorf("failed to build payload: %w", err) + } + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + resp, err := client.Post(utils.GatewaysPath, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create : %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("failed to create : received status code %d", resp.StatusCode) + } + fmt.Printf(" %q created successfully.\n", cr.Metadata.Name) + return gateway.PrintJSONResponse(resp) +} +``` + +For **get/list/delete**, copy `cli/src/cmd/gateway/restapi/get.go`, `list.go`, `delete.go` — they show ID-vs-name-lookup, `--format json|yaml` output, and `client.Get`/`client.Delete` against a `...ByIDPath`. + +--- + +## 3. DevPortal family (`internal/devportal`) + +Consumes `portals/developer-portal/docs/devportal-openapi-spec-v1.yaml`. Targets a resolved devportal within a platform. + +**Key helpers** +- `config.LoadConfig()` → `internaldevportal.ResolveDevPortal(cfg, name, platform)` → `(*config.DevPortal, resolvedPlatform, error)`. +- `internaldevportal.NewClientWithOptions(devPortal, insecure)` — client honoring `--insecure`. +- `internaldevportal.OrgScopedPath(orgID, "")` → `/o/{orgId}/devportal/v1/`. The `` is appended as-is, so escape interpolated segments (`"apis/"+url.PathEscape(apiID)`) and you may append a query string. Org-management endpoints use the raw `/organizations...` path instead. +- Client verbs: `client.Get(path)`, `client.PostJSON(path, []byte)`, `client.PutJSON(path, []byte)`, `client.Delete(path)`, `client.PostMultipartFile(path, field, filePath)`, `client.PutMultipartFile(...)`. +- Errors: transport errors → `internaldevportal.WrapRequestError("", err, insecure)`; non-2xx → `utils.FormatHTTPError("", resp, "DevPortal")`. **Check the exact status the spec documents** (`http.StatusOK` for GET, `http.StatusCreated` for a `201` POST, etc.). +- Output: `internaldevportal.PrintJSONResponse(resp)`. +- Standard flags: `--org` (`FlagOrgID`, usually required), `--display-name` (`FlagName`), `--platform` (`FlagPlatform`), `--insecure` (`FlagInsecure`). Add resource flags as needed. + +### Template — devportal create (`POST`, JSON body from flags) + +```go +// + +package + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + internaldevportal "github.com/wso2/api-platform/cli/internal/devportal" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + CreateCmdLiteral = "create" + CreateCmdExample = `# Create a +ap devportal create --org org_1 -- ` +) + +var ( + createOrgID string + create string + createName string + createPlatform string + createInsecure bool +) + +var createCmd = &cobra.Command{ + Use: CreateCmdLiteral, + Short: "Create a DevPortal ", + Long: "Creates a in the selected DevPortal.", + Example: CreateCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runCreateCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(createCmd, utils.FlagOrgID, &createOrgID, "", "Organization ID") + utils.AddStringFlag(createCmd, utils.Flag, &create, "", "") + utils.AddStringFlag(createCmd, utils.FlagName, &createName, "", "DevPortal display name") + utils.AddStringFlag(createCmd, utils.FlagPlatform, &createPlatform, "", "Platform name") + utils.AddBoolFlag(createCmd, utils.FlagInsecure, &createInsecure, false, "Skip TLS certificate verification") + _ = createCmd.MarkFlagRequired(utils.FlagOrgID) +} + +func runCreateCommand() error { + orgID := strings.TrimSpace(createOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + payload, err := json.Marshal(map[string]string{"": strings.TrimSpace(create)}) + if err != nil { + return fmt.Errorf("failed to build payload: %w", err) + } + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + devPortal, resolvedPlatform, err := internaldevportal.ResolveDevPortal(cfg, createName, createPlatform) + if err != nil { + return err + } + client := internaldevportal.NewClientWithOptions(devPortal, createInsecure) + resp, err := client.PostJSON(internaldevportal.OrgScopedPath(orgID, ""), payload) + if err != nil { + return internaldevportal.WrapRequestError("create ", err, createInsecure) + } + if resp.StatusCode != http.StatusCreated { + return utils.FormatHTTPError("create ", resp, "DevPortal") + } + fmt.Printf(" created using devportal %s (platform: %s)\n", devPortal.Name, resolvedPlatform) + return internaldevportal.PrintJSONResponse(resp) +} +``` + +For **list/get/delete/update**, copy `cli/src/cmd/devportal/subplan/list.go`, `subplan/get.go`, and `subscription/create.go` — they show `OrgScopedPath`, the resolve flow, and status handling. For `--file` JSON bodies use `internaldevportal.ReadJSONFile(path)`. For custom action sub-paths (e.g. `.../change-plan`, `.../regenerate-token`) build `OrgScopedPath(orgID, "subscriptions/"+url.PathEscape(subID)+"/change-plan")`. + +--- + +## 3b. AI-workspace family (`internal/aiworkspace`) + +Consumes the **LLM/MCP subset** of `platform-api/src/resources/openapi.yaml` — `/llm-providers`, `/llm-proxies`, `/mcp-proxies` (and `llm-provider-templates`) — reached under the `/api-proxy/api/v0.9/` prefix. Command literal is `ap ai-workspace`. Existing groups: `llmprovider` (`llm-provider`), `llmproxy` (`llm-proxy`), `mcpproxy` (`mcp-proxy`). Its verb vocabulary differs from gateway/devportal: **`push`** (create-or-update from a `--file` JSON artifact), **`edit`**, `get`, `list`, `delete`. + +**Key helpers** +- `config.LoadConfig()` → `aiworkspace.ResolveAIWorkspace(cfg, name, platform)` → `(*config.AIWorkspace, resolvedPlatform, error)`. +- `aiworkspace.NewClientWithOptions(aiWorkspace, insecure)`. +- Client verbs: `client.Get(path)`, `client.PostJSON(path, []byte)`, `client.PutJSON(path, []byte)`, `client.Delete(path)`. +- **Paths**: constants `AIWorkspacePath` in `cli/src/utils/constants.go` (already carry the `/api-proxy/api/v0.9/` prefix), wrapped by helpers in `cli/src/internal/aiworkspace/helpers.go`: + - collection with org scope: `ProviderPath(orgID)` → `.../llm-providers?organizationId=`. + - single resource: `ProviderResourcePath(orgID, id)` / `ProviderByIDPath(id)`. + - list with pagination: `ProviderListPath(orgID, aiworkspace.ListQuery{Limit, Offset})`; proxies/mcp use `projectId` scope (`ProxyListPath(projectID, q)`). + - Add a matching helper (and constant) for a new resource; **org/project scope is a query parameter (`?organizationId=`/`?projectId=`), never a path segment** — this is the main difference from the devportal family. +- Input: `aiworkspace.ReadJSONFile(filePath)` for `--file` JSON artifacts. +- Auth is handled by the client from workspace config or env vars (`WSO2AP_AIWORKSPACE_USERNAME`/`_PASSWORD`/`_TOKEN`/`_API_KEY`, header `x-wso2-api-key`). +- Standard flags: `--org` (`FlagOrgID`), `--file` (`FlagFile`), `--display-name` (`FlagName`), `--platform` (`FlagPlatform`), `--insecure` (`FlagInsecure`). + +### Template — ai-workspace push (create-or-update from `--file`) + +Copy the closest sibling — `cli/src/cmd/aiws/llmprovider/push.go` (create/update), `get.go`, `list.go`, `delete.go`, `edit.go`. The shape: + +```go +func runPushCommand() error { + orgID := strings.TrimSpace(pushOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + payload, err := aiworkspace.ReadJSONFile(pushFilePath) + if err != nil { + return err + } + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, pushName, pushPlatform) + if err != nil { + return err + } + client := aiworkspace.NewClientWithOptions(aiWorkspace, pushInsecure) + resp, err := client.PostJSON(aiworkspace.ProviderPath(orgID), payload) // PutJSON(ProviderResourcePath(orgID,id)) for update + if err != nil { + return err + } + // status check + print, matching the sibling command + _ = resp + _ = resolvedPlatform + return nil +} +``` + +Register new commands/groups the same way: group `root.go` `AddCommand`, then `cli/src/cmd/aiws/root.go`. + +## 4. Resource-group `root.go` template + +For a brand-new resource group: + +```go +// + +package + +import "github.com/spf13/cobra" + +const ( + CmdLiteral = "" + CmdExample = `# List all s +ap list` +) + +// Cmd is the command group. +var Cmd = &cobra.Command{ + Use: CmdLiteral, + Short: "Manage s", + Long: "This command allows you to manage s.", + Example: CmdExample, + Run: func(cmd *cobra.Command, args []string) { cmd.Help() }, +} + +func init() { + Cmd.AddCommand(createCmd) + Cmd.AddCommand(listCmd) + Cmd.AddCommand(getCmd) + Cmd.AddCommand(updateCmd) + Cmd.AddCommand(deleteCmd) +} +``` + +Then in `cli/src/cmd//root.go`: import the package and add `Cmd.AddCommand(.Cmd)` inside `init()`. + +--- + +## 5. Tests, docs, build + +- **Tests** — copy the group's `commands_test.go` (e.g. `cli/src/cmd/devportal/subplan/commands_test.go`, `gateway/subscriptionplan` tests). They validate flag wiring, required-flag errors, and payload building. Add a case per new flag/validation branch; update payload expectations on MODIFIED ops. +- **Docs** (hand-maintained) — `docs/cli/gateway/README.md`, `docs/cli/devportal/README.md`, and `docs/cli/reference.md`. Follow the existing "### N. " + `#### CLI Command` + shell block format. Update the short-flag table in `reference.md` only if a short flag changed. +- **Build/verify** — from `cli/`: `make build` (tests then builds) or `make test`. While iterating, from `cli/src/`: `go build ./...` and `go test ./...`. + +## 6. Mapping cheatsheet + +| Spec element | CLI counterpart | +|---|---| +| `path` + method | one client call (`Get`/`PostJSON`/`Post`/`Put`/`Delete`) in one `runCommand` | +| path variable `{id}` | `%s` in a gateway path constant, or `url.PathEscape` inside `OrgScopedPath` | +| query/path parameter | a cobra flag via `utils.Flag*` + `utils.AddStringFlag` | +| `requestBody` schema | the `map`/struct marshalled to JSON, or the CR `spec` (`ParseResourceCR`) | +| required field | a `MarkFlagRequired` / explicit `fmt.Errorf` validation | +| response schema | pass-through `PrintJSONResponse`, or a typed decode struct | +| `201`/`200`/`204` | the exact `resp.StatusCode` compared in the success check | +| OAuth2 scope / security | gateway selection flags, or devportal resolve + `--insecure` (auth handled by the client) | +| new tag / resource | a new subcommand group dir + `root.go`, registered in the family root | diff --git a/.gitignore b/.gitignore index 923b24f0ac..9410b7f69a 100644 --- a/.gitignore +++ b/.gitignore @@ -164,5 +164,7 @@ dev-policies/ gateway/target/ +portals/ai-workspace/docker-compose.yaml + # Devportal portals/developer-portal/target/ diff --git a/cli/src/cmd/aiworkspace/add.go b/cli/src/cmd/aiworkspace/add.go new file mode 100644 index 0000000000..d2626c2df9 --- /dev/null +++ b/cli/src/cmd/aiworkspace/add.go @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + AddCmdLiteral = "add" + AddCmdExample = `# Add a new AI workspace fully interactively +ap ai-workspace add + +# Add an AI workspace with basic auth +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth basic + +# Add an AI workspace with OAuth auth +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth + +# Add an AI workspace with API key auth +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key + +# Add an AI workspace without interactive prompts using flags +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth basic --no-interactive --username admin --password admin +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth --no-interactive --token your_token_here +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key --no-interactive --api-key your_api_key_here` +) + +var ( + addName string + addPlatform string + addServer string + addAuth string + addUsername string + addPassword string + addToken string + addAPIKey string + addNoInteractive bool +) + +var addCmd = &cobra.Command{ + Use: AddCmdLiteral, + Short: "Add a new AI workspace", + Long: "Add a new AI workspace configuration to the ap config file.", + Example: AddCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runAddCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(addCmd, utils.FlagName, &addName, "", "Display name of the AI workspace") + utils.AddStringFlag(addCmd, utils.FlagPlatform, &addPlatform, "", "Platform name for the AI workspace") + utils.AddStringFlag(addCmd, utils.FlagServer, &addServer, "", "Server URL of the AI workspace") + utils.AddStringFlag(addCmd, utils.FlagAuth, &addAuth, "", "Authentication type for the AI workspace. Supported values: basic, oauth, api-key") + utils.AddStringFlag(addCmd, utils.FlagUsername, &addUsername, "", "Username for AI workspace basic auth (not recommended, use interactive mode)") + utils.AddStringFlag(addCmd, utils.FlagPassword, &addPassword, "", "Password for AI workspace basic auth (not recommended, use interactive mode)") + utils.AddStringFlag(addCmd, utils.FlagToken, &addToken, "", "Token for AI workspace OAuth auth (not recommended, use interactive mode)") + utils.AddStringFlag(addCmd, utils.FlagAPIKey, &addAPIKey, "", "API key for AI workspace API key auth (not recommended, use interactive mode)") + utils.AddBoolFlag(addCmd, utils.FlagNoInteractive, &addNoInteractive, false, "Skip interactive prompts") +} + +func runAddCommand() error { + var err error + + if !addNoInteractive { + if strings.TrimSpace(addName) == "" { + addName, err = utils.PromptInput("Enter AI workspace display name: ") + if err != nil { + return fmt.Errorf("failed to read display name: %w", err) + } + } + if strings.TrimSpace(addPlatform) == "" { + addPlatform, err = utils.PromptInput(fmt.Sprintf("Enter platform (default: %s): ", config.DefaultPlatform)) + if err != nil { + return fmt.Errorf("failed to read platform: %w", err) + } + } + if strings.TrimSpace(addServer) == "" { + addServer, err = utils.PromptInput("Enter AI workspace server URL: ") + if err != nil { + return fmt.Errorf("failed to read server URL: %w", err) + } + } + if strings.TrimSpace(addAuth) == "" { + addAuth, err = utils.PromptInput("Enter auth type (basic, oauth, api-key): ") + if err != nil { + return fmt.Errorf("failed to read auth type: %w", err) + } + } + } + + addName = strings.TrimSpace(addName) + addPlatform = strings.TrimSpace(addPlatform) + addServer = strings.TrimSpace(addServer) + addAuth = strings.ToLower(strings.TrimSpace(addAuth)) + + if addName == "" { + return fmt.Errorf("missing required flag --%s (or provide it in interactive mode)", utils.FlagName) + } + if addServer == "" { + return fmt.Errorf("missing required flag --%s (or provide it in interactive mode)", utils.FlagServer) + } + if addAuth == "" { + return fmt.Errorf("missing required flag --%s (or provide it in interactive mode)", utils.FlagAuth) + } + if addAuth != utils.AuthTypeBasic && addAuth != utils.AuthTypeOAuth && addAuth != utils.AuthTypeAPIKey { + return fmt.Errorf("invalid auth type '%s'. AI workspace supports only: %s, %s, %s", addAuth, utils.AuthTypeBasic, utils.AuthTypeOAuth, utils.AuthTypeAPIKey) + } + + switch addAuth { + case utils.AuthTypeBasic: + if addToken != "" || addAPIKey != "" { + return fmt.Errorf("--token and --api-key cannot be used with auth type '%s'. Use --username and --password instead", addAuth) + } + case utils.AuthTypeOAuth: + if addUsername != "" || addPassword != "" || addAPIKey != "" { + return fmt.Errorf("--username, --password, and --api-key cannot be used with auth type '%s'. Use --token instead", addAuth) + } + case utils.AuthTypeAPIKey: + if addUsername != "" || addPassword != "" || addToken != "" { + return fmt.Errorf("--username, --password, and --token cannot be used with auth type '%s'. Use --api-key instead", addAuth) + } + } + + if addUsername != "" || addPassword != "" || addToken != "" || addAPIKey != "" { + fmt.Fprintln(os.Stderr, "Warning: Passing credentials via command-line flags is not recommended for security reasons.") + fmt.Fprintln(os.Stderr, "Consider using interactive mode or environment variables instead.") + fmt.Fprintln(os.Stderr, "") + } + + username := addUsername + password := addPassword + token := addToken + apiKey := addAPIKey + + if !addNoInteractive && username == "" && password == "" && token == "" && apiKey == "" { + username, password, token, apiKey, err = utils.PromptAIWorkspaceCredentials(addAuth) + if err != nil { + return fmt.Errorf("failed to read credentials: %w", err) + } + } + + if addAuth == utils.AuthTypeBasic { + if (username != "" && password == "") || (username == "" && password != "") { + return fmt.Errorf("for basic auth, both username and password must be provided, or leave both empty to use environment variables (%s and %s)", + utils.EnvAIWorkspaceUsername, utils.EnvAIWorkspacePassword) + } + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + resolvedPlatform := cfg.ResolvePlatform(addPlatform) + + aiWorkspace := config.AIWorkspace{ + Name: addName, + URL: addServer, + Auth: config.AuthConfig{ + Type: addAuth, + Username: username, + Password: password, + Token: token, + APIKey: apiKey, + }, + } + + if username != "" || password != "" || token != "" || apiKey != "" { + fmt.Fprintln(os.Stderr, "Note: Credentials will be stored in plaintext in the configuration file (mode 0600).") + fmt.Fprintln(os.Stderr, "To avoid storing secrets on disk, omit credentials and use environment variables instead.") + fmt.Fprintln(os.Stderr, "") + } + + if err := cfg.AddAIWorkspaceToPlatform(resolvedPlatform, aiWorkspace); err != nil { + return fmt.Errorf("failed to add AI workspace: %w", err) + } + + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + configPath, err := config.GetConfigPath() + if err != nil { + utils.LogWarning("could not determine config path", err) + configPath = "(unknown location)" + } + + fmt.Printf("AI workspace %s added (platform: %s, server: %s, auth: %s)\n", addName, resolvedPlatform, addServer, addAuth) + fmt.Printf("Configuration saved to: %s\n", configPath) + + if username != "" || password != "" || token != "" || apiKey != "" { + switch addAuth { + case utils.AuthTypeBasic: + fmt.Printf("Note: Credentials were stored in the configuration; exporting %s and %s will override them at runtime.\n", utils.EnvAIWorkspaceUsername, utils.EnvAIWorkspacePassword) + case utils.AuthTypeOAuth: + fmt.Printf("Note: Credentials were stored in the configuration; exporting %s will override them at runtime.\n", utils.EnvAIWorkspaceToken) + case utils.AuthTypeAPIKey: + fmt.Printf("Note: Credentials were stored in the configuration; exporting %s will override them at runtime.\n", utils.EnvAIWorkspaceAPIKey) + } + } else { + fmt.Println() + lines := []string{ + "No credentials stored for this AI workspace.", + "Set the following environment variable(s) before making AI workspace API calls:", + } + switch addAuth { + case utils.AuthTypeBasic: + lines = append(lines, " "+utils.EnvAIWorkspaceUsername, " "+utils.EnvAIWorkspacePassword) + case utils.AuthTypeOAuth: + lines = append(lines, " "+utils.EnvAIWorkspaceToken) + case utils.AuthTypeAPIKey: + lines = append(lines, " "+utils.EnvAIWorkspaceAPIKey) + } + utils.PrintBoxedMessage(lines) + } + + return nil +} diff --git a/cli/src/cmd/aiworkspace/apply.go b/cli/src/cmd/aiworkspace/apply.go new file mode 100644 index 0000000000..fe8770dc9b --- /dev/null +++ b/cli/src/cmd/aiworkspace/apply.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ApplyCmdLiteral = "apply" + ApplyCmdExample = `# Generate and apply the AI workspace artifact from the current project +ap ai-workspace apply + +# Apply a proxy or MCP artifact (--project-id is required for those kinds) +ap ai-workspace apply --project-id + +# Resolve ENV_CLI_* placeholders from a specific env file (defaults to .env in the project root) +ap ai-workspace apply --env-file ./secrets.env + +# Apply from a specific project directory using a specific AI workspace +ap ai-workspace apply -f /path/to/project --project-id --display-name my-workspace --platform eu` +) + +var ( + applyProjectDir string + applyProjectID string + applyEnvFile string + applyName string + applyPlatform string + applyInsecure bool + applyOutput string +) + +var applyCmd = &cobra.Command{ + Use: ApplyCmdLiteral, + Short: "Generate and apply an AI workspace artifact", + Long: "Generate the creation payload from the project's metadata.yaml, runtime.yaml and definition.yaml, " + + "then create the artifact on the WSO2 API Platform AI workspace. The target endpoint is selected by the " + + "artifact kind declared in the project (LlmProvider, LlmProxy, Mcp). For the LlmProxy and Mcp kinds " + + "--project-id is required.", + Example: ApplyCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runApplyCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(applyCmd, utils.FlagFile, &applyProjectDir, "", "Path to the project directory (defaults to current directory)") + utils.AddStringFlag(applyCmd, utils.FlagProjectID, &applyProjectID, "", "Project ID (required for LlmProxy and Mcp kinds)") + utils.AddStringFlag(applyCmd, utils.FlagEnvFile, &applyEnvFile, "", "Path to an env file resolving ENV_CLI_* placeholders (defaults to .env in the project root)") + utils.AddStringFlag(applyCmd, utils.FlagName, &applyName, "", "AI workspace display name") + utils.AddStringFlag(applyCmd, utils.FlagPlatform, &applyPlatform, "", "Platform name") + utils.AddStringFlag(applyCmd, utils.FlagOutput, &applyOutput, "", "Output format: \"json\" prints the full server response (default: summary)") + applyCmd.Flags().BoolVar(&applyInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runApplyCommand() error { + projectRoot, wsConfig, err := resolveProjectAIWorkspace(applyProjectDir) + if err != nil { + return err + } + + artifact, err := loadAIWorkspaceArtifact(projectRoot, wsConfig) + if err != nil { + return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) + } + + body, projectID, err := marshalAIWorkspacePayload(artifact, applyProjectID) + if err != nil { + return err + } + + // Resolve ENV_CLI_* placeholders carried from metadata.yaml/runtime.yaml + // into the generated payload before it is sent. + body, err = resolveEnvPlaceholders(body, projectRoot, applyEnvFile) + if err != nil { + return err + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, applyName, applyPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, applyInsecure) + resp, err := client.PostJSON(aiWorkspaceCreatePath(artifact.BaseKind), body) + if err != nil { + return aiworkspace.WrapRequestError(fmt.Sprintf("apply %s", artifact.BaseKind), err, applyInsecure) + } + + return aiworkspace.PrintApplyResult(resp, applyOutput, artifact.BaseKind, "applied", artifact.ResourceName, projectID) +} diff --git a/cli/src/cmd/aiworkspace/build.go b/cli/src/cmd/aiworkspace/build.go new file mode 100644 index 0000000000..96ec86f099 --- /dev/null +++ b/cli/src/cmd/aiworkspace/build.go @@ -0,0 +1,1410 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/project" + "github.com/wso2/api-platform/cli/utils" + "gopkg.in/yaml.v3" +) + +const ( + BuildCmdLiteral = "build" + BuildCmdExample = `# Validate the AI workspace artifact in the current directory +ap ai-workspace build + +# Validate from a specific project directory +ap ai-workspace build -f /path/to/project` +) + +var buildProjectDir string + +var buildCmd = &cobra.Command{ + Use: BuildCmdLiteral, + Short: "Validate the project's AI workspace artifact", + Long: "Validate the AI workspace artifact for the project located in the specified directory " + + "(or the current directory if not specified). The command reads the ai-workspace " + + "configuration in .api-platform/config.yaml and checks that its metadata.yaml, runtime.yaml " + + "and definition.yaml are present, that the metadata and runtime kinds align, and that the " + + "resource name matches. It does not generate or send any artifact — use `ap ai-workspace apply` " + + "to generate the creation payload and create the artifact.", + Example: BuildCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runBuildCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(buildCmd, utils.FlagFile, &buildProjectDir, "", "Path to the project directory (defaults to current directory)") +} + +func runBuildCommand() error { + projectRoot, wsConfig, err := resolveProjectAIWorkspace(buildProjectDir) + if err != nil { + return err + } + + artifact, err := loadAIWorkspaceArtifact(projectRoot, wsConfig) + if err != nil { + return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) + } + + fmt.Printf("AI workspace artifact %q (kind: %s) built successfully\n", artifact.ResourceName, artifact.BaseKind) + return nil +} + +func normalizeAIWorkspaceProjectConfig(config *project.AIWorkspaceConfig) { + if strings.TrimSpace(config.Name) == "" { + config.Name = "default" + } + if strings.TrimSpace(config.PortalRoot) == "" { + config.PortalRoot = "." + } + if strings.TrimSpace(config.FilePaths.Metadata) == "" { + config.FilePaths.Metadata = project.DefaultAIWorkspaceMetadata + } + if strings.TrimSpace(config.FilePaths.Runtime) == "" { + config.FilePaths.Runtime = project.DefaultAIWorkspaceRuntime + } + if strings.TrimSpace(config.FilePaths.Definition) == "" { + config.FilePaths.Definition = project.DefaultAIWorkspaceDefinition + } +} + +// aiWorkspaceArtifact holds the validated inputs for the project's ai-workspace +// artifact, loaded from metadata.yaml, runtime.yaml and definition.yaml. It is +// produced by loadAIWorkspaceArtifact (which validates but does not generate a +// payload) and consumed by buildPayload (which generates the creation payload). +// The split lets `build` validate only, while `apply`/`edit` validate then +// generate and send. +type aiWorkspaceArtifact struct { + ConfigName string + BaseKind string // LlmProvider / LlmProxy / Mcp ("Metadata" suffix stripped) + ResourceName string // metadata.name; becomes the payload id + metadata aiWorkspaceMetadata + runtime aiWorkspaceRuntime + openapi string // definition.yaml content (folded into provider/proxy payloads) + mcpDef mcpDefinition // parsed definition, populated for the Mcp kind +} + +// resolveProjectAIWorkspace resolves the project root from projectDir, loads the +// project config, ensures a single ai-workspace configuration exists (creating a +// default and persisting it when absent), normalizes it, and returns the project +// root and the ai-workspace config entry. Shared by build, apply and edit. +func resolveProjectAIWorkspace(projectDir string) (string, *project.AIWorkspaceConfig, error) { + if strings.TrimSpace(projectDir) == "" { + projectDir = "." + } + projectRoot, err := filepath.Abs(projectDir) + if err != nil { + return "", nil, fmt.Errorf("failed to resolve project directory: %w", err) + } + + projectConfigDir := filepath.Join(projectRoot, ".api-platform") + if _, err := os.Stat(projectConfigDir); os.IsNotExist(err) { + return "", nil, fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return "", nil, fmt.Errorf("failed to inspect project directory: %w", err) + } + + projectConfigPath := filepath.Join(projectConfigDir, "config.yaml") + if _, err := os.Stat(projectConfigPath); os.IsNotExist(err) { + return "", nil, fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return "", nil, fmt.Errorf("failed to inspect project config: %w", err) + } + + projectConfig, err := project.Load(projectConfigPath) + if err != nil { + return "", nil, err + } + + // A project has at most one ai-workspace config. Create a default one if it + // is absent and persist it so the project records the configuration used. + if projectConfig.AIWorkspace == nil { + projectConfig.AIWorkspace = &project.AIWorkspaceConfig{ + Name: "default", + PortalRoot: ".", + } + if err := project.Save(projectConfigPath, projectConfig); err != nil { + return "", nil, err + } + } + + normalizeAIWorkspaceProjectConfig(projectConfig.AIWorkspace) + return projectRoot, projectConfig.AIWorkspace, nil +} + +// loadAIWorkspaceArtifact reads and validates the ai-workspace artifact for the +// given config: it checks that metadata.yaml, runtime.yaml and definition.yaml +// are present and within the project, that the metadata/runtime kinds align +// (after stripping the metadata "Metadata" suffix), that metadata.name is set +// and matches between the two files, and (for the Mcp kind) that the definition +// parses. It does NOT generate the creation payload — call buildPayload for that. +func loadAIWorkspaceArtifact(projectRoot string, config *project.AIWorkspaceConfig) (*aiWorkspaceArtifact, error) { + baseDir := resolveProjectPath(projectRoot, config.PortalRoot) + if err := ensureWithinProjectRoot(projectRoot, baseDir, config.Name, "portalRoot"); err != nil { + return nil, err + } + if err := ensurePathExists(baseDir, true, config.Name, "portalRoot"); err != nil { + return nil, err + } + + metadataPath := resolveProjectPath(baseDir, config.FilePaths.Metadata) + runtimePath := resolveProjectPath(baseDir, config.FilePaths.Runtime) + + // metadata.yaml and runtime.yaml are the required inputs for the payload. + for _, required := range []struct { + label string + path string + }{ + {label: "metadata", path: metadataPath}, + {label: "runtime", path: runtimePath}, + } { + if err := ensureWithinProjectRoot(projectRoot, required.path, config.Name, required.label); err != nil { + return nil, err + } + if err := ensurePathExists(required.path, false, config.Name, required.label); err != nil { + return nil, err + } + } + + var metadata aiWorkspaceMetadata + if err := readYAMLFile(metadataPath, &metadata); err != nil { + return nil, fmt.Errorf("ai-workspace config %q: failed to read metadata: %w", config.Name, err) + } + var runtime aiWorkspaceRuntime + if err := readYAMLFile(runtimePath, &runtime); err != nil { + return nil, fmt.Errorf("ai-workspace config %q: failed to read runtime: %w", config.Name, err) + } + + // The kind declared in metadata.yaml carries a "Metadata" suffix + // (e.g. LlmProxyMetadata) while runtime.yaml uses the base kind (LlmProxy). + // Normalize both to the base kind before comparing and dispatching. + rawMetadataKind := strings.TrimSpace(metadata.Kind) + rawRuntimeKind := strings.TrimSpace(runtime.Kind) + metadataKind := strings.TrimSuffix(rawMetadataKind, metadataKindSuffix) + runtimeKind := strings.TrimSuffix(rawRuntimeKind, metadataKindSuffix) + if metadataKind != runtimeKind { + return nil, fmt.Errorf("ai-workspace config %q: kind mismatch: metadata.yaml has kind %q but runtime.yaml has kind %q", config.Name, rawMetadataKind, rawRuntimeKind) + } + + resourceName := strings.TrimSpace(metadata.Metadata.Name) + if resourceName == "" { + return nil, fmt.Errorf("ai-workspace config %q is invalid: metadata.metadata.name is required", config.Name) + } + if runtimeName := strings.TrimSpace(runtime.Metadata.Name); runtimeName != resourceName { + return nil, fmt.Errorf("ai-workspace config %q: name mismatch: metadata.yaml has metadata.name %q but runtime.yaml has metadata.name %q", config.Name, resourceName, runtimeName) + } + + artifact := &aiWorkspaceArtifact{ + ConfigName: config.Name, + BaseKind: metadataKind, + ResourceName: resourceName, + metadata: metadata, + runtime: runtime, + } + + // The OpenAPI spec (definition.yaml) is required for every kind. + spec, err := loadAIWorkspaceSpec(projectRoot, baseDir, config, true) + if err != nil { + return nil, err + } + artifact.openapi = spec + + switch metadataKind { + case kindLLMProxy, kindLLMProvider: + // Folded into the payload verbatim; no further parsing at build/validate. + case kindMCP: + // An MCP proxy's capabilities live in the definition, so it must parse. + var definition mcpDefinition + if err := yaml.Unmarshal([]byte(spec), &definition); err != nil { + return nil, fmt.Errorf("ai-workspace config %q: failed to parse definition: %w", config.Name, err) + } + artifact.mcpDef = definition + default: + return nil, fmt.Errorf("ai-workspace config %q: unsupported kind %q (supported: %s, %s, %s)", config.Name, metadataKind, kindLLMProxy, kindLLMProvider, kindMCP) + } + + return artifact, nil +} + +// buildPayload generates the creation payload for the validated artifact. The +// payload shape is driven by the artifact kind. Returns nil for an unsupported +// kind (loadAIWorkspaceArtifact already rejects those, so callers can treat nil +// as a programming error). +func (a *aiWorkspaceArtifact) buildPayload() interface{} { + switch a.BaseKind { + case kindLLMProxy: + return buildLLMProxyPayload(a.ResourceName, a.metadata, a.runtime, a.openapi) + case kindLLMProvider: + return buildLLMProviderPayload(a.ResourceName, a.metadata, a.runtime, a.openapi) + case kindMCP: + return buildMCPProxyPayload(a.ResourceName, a.metadata, a.runtime, a.mcpDef) + default: + return nil + } +} + +// marshalAIWorkspacePayload generates the creation payload from the validated +// artifact and returns it as JSON. LlmProxy and Mcp artifacts are project-scoped +// and require a projectId (from --project-id) injected into the body; providers +// are not project-scoped. Returns the JSON body and the projectId used (empty +// for providers). +func marshalAIWorkspacePayload(artifact *aiWorkspaceArtifact, projectIDFlag string) ([]byte, string, error) { + payload := artifact.buildPayload() + if payload == nil { + return nil, "", fmt.Errorf("unsupported kind %q", artifact.BaseKind) + } + + raw, err := json.Marshal(payload) + if err != nil { + return nil, "", fmt.Errorf("failed to encode payload: %w", err) + } + + // Providers are not project-scoped; send the payload as generated. + if artifact.BaseKind == kindLLMProvider { + return raw, "", nil + } + + // Proxies and MCP proxies require a projectId. Inject it without dropping any + // generated fields. + projectID := strings.TrimSpace(projectIDFlag) + if projectID == "" { + return nil, "", fmt.Errorf("project ID is required for %s artifacts (use --project-id)", artifact.BaseKind) + } + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + return nil, "", fmt.Errorf("failed to encode payload: %w", err) + } + m["projectId"] = projectID + body, err := json.Marshal(m) + if err != nil { + return nil, "", fmt.Errorf("failed to encode payload: %w", err) + } + return body, projectID, nil +} + +// cliEnvPlaceholderPattern matches ENV_CLI_* placeholders carried from +// metadata.yaml/runtime.yaml into the generated payload. Both `${ENV_CLI_X}` +// and `$ENV_CLI_X` forms are supported, as well as a bare `ENV_CLI_X` token. +// The bare form requires a leading word boundary so a placeholder embedded in a +// larger identifier (e.g. MY_ENV_CLI_X) is not partially substituted. +var cliEnvPlaceholderPattern = regexp.MustCompile(`\$\{ENV_CLI_[A-Za-z0-9_]+\}|\$ENV_CLI_[A-Za-z0-9_]+|\bENV_CLI_[A-Za-z0-9_]+`) + +// cliEnvPlaceholderName strips the `${...}`/`$` wrapping from a matched +// placeholder, returning the bare variable name (ENV_CLI_X). +func cliEnvPlaceholderName(placeholder string) string { + name := strings.TrimPrefix(placeholder, "${") + name = strings.TrimSuffix(name, "}") + return strings.TrimPrefix(name, "$") +} + +// resolveEnvPlaceholders substitutes ENV_CLI_* placeholders in the generated +// payload before it is sent to the AI workspace. Values are looked up in an +// env file first — the file given via --env-file when provided (it must +// exist), otherwise the project root's .env when present — falling back to the +// process environment for names the file does not define. When the payload has +// no placeholders it is returned unchanged. Any placeholder that cannot be +// resolved fails the command with the full list of missing variables. +func resolveEnvPlaceholders(body []byte, projectRoot, envFileFlag string) ([]byte, error) { + content := string(body) + matches := cliEnvPlaceholderPattern.FindAllString(content, -1) + if len(matches) == 0 { + return body, nil + } + + fileValues, err := loadEnvValues(projectRoot, envFileFlag) + if err != nil { + return nil, err + } + + missing := map[string]bool{} + resolved := cliEnvPlaceholderPattern.ReplaceAllStringFunc(content, func(placeholder string) string { + name := cliEnvPlaceholderName(placeholder) + value, ok := fileValues[name] + if !ok { + value, ok = os.LookupEnv(name) + } + if !ok { + missing[name] = true + return placeholder + } + // The replacement happens inside JSON string values, so the value must be + // JSON-escaped (sans the surrounding quotes json.Marshal adds). + escaped, _ := json.Marshal(value) + return string(escaped[1 : len(escaped)-1]) + }) + + if len(missing) > 0 { + names := make([]string, 0, len(missing)) + for name := range missing { + names = append(names, name) + } + sort.Strings(names) + return nil, fmt.Errorf("unresolved environment variable(s) in the generated artifact: %s (define them in an env file passed via --%s, a .env file in the project root, or the environment)", + strings.Join(names, ", "), utils.FlagEnvFile) + } + + return []byte(resolved), nil +} + +// loadEnvValues returns the KEY=VALUE pairs from the env file selected for +// placeholder resolution: the --env-file path when given (missing file is an +// error), else the project root's .env when it exists, else an empty map (the +// process environment is consulted by the caller as the fallback). +func loadEnvValues(projectRoot, envFileFlag string) (map[string]string, error) { + if path := strings.TrimSpace(envFileFlag); path != "" { + values, err := parseEnvFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read env file %q: %w", path, err) + } + return values, nil + } + + defaultPath := filepath.Join(projectRoot, ".env") + if _, err := os.Stat(defaultPath); err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, fmt.Errorf("failed to inspect .env file: %w", err) + } + values, err := parseEnvFile(defaultPath) + if err != nil { + return nil, fmt.Errorf("failed to read .env file %q: %w", defaultPath, err) + } + return values, nil +} + +// parseEnvFile reads a dotenv-style file: one KEY=VALUE per line, blank lines +// and #-comments ignored, an optional `export ` prefix allowed, and single or +// double quotes around the value stripped. +func parseEnvFile(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + values := map[string]string{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) || + (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { + value = value[1 : len(value)-1] + } + } + values[key] = value + } + return values, nil +} + +// aiWorkspaceCreatePath returns the collection endpoint to POST to for the kind. +func aiWorkspaceCreatePath(baseKind string) string { + switch baseKind { + case kindLLMProvider: + return aiworkspace.ProviderPath() + case kindLLMProxy: + return aiworkspace.ProxyPath() + case kindMCP: + return aiworkspace.MCPProxyPath() + default: + return "" + } +} + +// aiWorkspaceUpdatePath returns the by-id endpoint to PUT to for the kind. +func aiWorkspaceUpdatePath(baseKind, id string) string { + switch baseKind { + case kindLLMProvider: + return aiworkspace.ProviderByIDPath(id) + case kindLLMProxy: + return aiworkspace.ProxyByIDPath(id) + case kindMCP: + return aiworkspace.MCPProxyByIDPath(id) + default: + return "" + } +} + +// Supported artifact kinds. These match the `kind` declared in metadata.yaml +// and runtime.yaml. +const ( + kindLLMProxy = "LlmProxy" + kindLLMProvider = "LlmProvider" + kindMCP = "Mcp" + // metadataKindSuffix is appended to the metadata.yaml kind for ai-workspace + // artifacts (e.g. LlmProxyMetadata) to distinguish it from the runtime kind + // (LlmProxy). It is stripped before comparing/dispatching on the kind. + metadataKindSuffix = "Metadata" +) + +// loadAIWorkspaceSpec reads the configured definition.yaml relative to baseDir +// and returns its content. When required is true a missing definition is an +// error; otherwise a missing definition yields an empty spec. +func loadAIWorkspaceSpec(projectRoot, baseDir string, config *project.AIWorkspaceConfig, required bool) (string, error) { + definitionPath := resolveProjectPath(baseDir, config.FilePaths.Definition) + if err := ensureWithinProjectRoot(projectRoot, definitionPath, config.Name, "definition"); err != nil { + return "", err + } + + info, err := os.Stat(definitionPath) + if err != nil { + if os.IsNotExist(err) { + if required { + return "", fmt.Errorf("ai-workspace config %q is invalid: definition path does not exist: %s", config.Name, definitionPath) + } + return "", nil + } + return "", fmt.Errorf("ai-workspace config %q: failed to inspect definition: %w", config.Name, err) + } + if info.IsDir() { + return "", fmt.Errorf("ai-workspace config %q is invalid: definition must be a file: %s", config.Name, definitionPath) + } + + data, err := os.ReadFile(definitionPath) + if err != nil { + return "", fmt.Errorf("ai-workspace config %q: failed to read definition: %w", config.Name, err) + } + return string(data), nil +} + +// templateModelIDs maps an LLM provider template to the model IDs it exposes. +// When a provider's template matches a key here, the build emits a single +// modelProviders entry (keyed by the template) carrying these models. +var templateModelIDs = map[string][]string{ + "meta": { + "us.meta.llama3-3-70b-instruct-v1:0", + "us.meta.llama4-maverick-17b-instruct-v1:0", + }, + "openai": {"gpt-4o-mini", "gpt-4.1-mini", "o4-mini"}, + "anthropic": {"claude-3.5-sonnet", "claude-3-opus"}, + "google-vertex": {"gemini-1.5-pro", "gemini-1.5-flash"}, + "aws-bedrock": {"amazon.titan-text-premier", "anthropic.claude-v2"}, + "mistralai": { + "mistral-large-latest", + "mistral-small-latest", + "open-mixtral-8x22b", + }, +} + +// modelProvidersForTemplate returns the modelProviders block for a provider +// template. It returns a single model provider (keyed by the template name) +// carrying the template's models, or nil for an unknown template so the payload +// omits the field. +func modelProvidersForTemplate(template string) []llmModelProvider { + template = strings.TrimSpace(template) + modelIDs, ok := templateModelIDs[template] + if !ok || len(modelIDs) == 0 { + return nil + } + + provider := llmModelProvider{ID: template, DisplayName: template} + for _, modelID := range modelIDs { + provider.Models = append(provider.Models, llmModel{ID: modelID, DisplayName: modelID}) + } + return []llmModelProvider{provider} +} + +// buildLLMProviderPayload assembles the createLLMProvider request body from the +// project's metadata.yaml (name/version) and runtime.yaml (context, template, +// upstream, accessControl, policies). The api-key-auth policy is mapped to the +// security block, and modelProviders is derived from the template (see +// modelProvidersForTemplate). +func buildLLMProviderPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProviderPayload { + template := strings.TrimSpace(runtime.Spec.Template) + payload := llmProviderPayload{ + ID: name, + DisplayName: strings.TrimSpace(metadata.Spec.DisplayName), + Version: strings.TrimSpace(metadata.Spec.Version), + Context: strings.TrimSpace(runtime.Spec.Context), + Template: template, + OpenAPI: openapi, + ModelProviders: modelProvidersForTemplate(template), + AssociatedGateways: normalizeAssociatedGateways(metadata.Spec.AssociatedGateways), + } + + if up := runtime.Spec.Upstream; up != nil { + target := llmUpstreamTarget{URL: strings.TrimSpace(up.URL)} + if up.Auth != nil { + target.Auth = &llmUpstreamAuth{Type: up.Auth.Type, Header: up.Auth.Header, Value: up.Auth.Value} + } + payload.Upstream = &llmUpstream{Main: target} + } + + if ac := runtime.Spec.AccessControl; ac != nil { + mapped := &llmAccessControl{Mode: ac.Mode} + for _, exception := range ac.Exceptions { + mapped.Exceptions = append(mapped.Exceptions, routeException{Methods: exception.Methods, Path: exception.Path}) + } + payload.AccessControl = mapped + } + + payload.RateLimiting = buildRateLimitingFromPolicies(runtime.Spec.Policies) + payload.Security = buildSecurityFromPolicies(runtime.Spec.Policies) + + // Any policy that is not mapped to security (api-key-auth) or rateLimiting + // (*-ratelimit) passes through into the policies array unchanged. + for _, policy := range runtime.Spec.Policies { + if policy.Name == "api-key-auth" || strings.HasSuffix(policy.Name, "-ratelimit") { + continue + } + payload.Policies = append(payload.Policies, mapPolicy(policy)) + } + + return payload +} + +// mapPolicy converts a runtime.yaml policy into the payload policy shape. +func mapPolicy(policy runtimeProviderPolicy) llmPolicy { + mapped := llmPolicy{ + Name: policy.Name, + Version: policy.Version, + Paths: make([]llmPolicyPath, 0, len(policy.Paths)), + } + for _, path := range policy.Paths { + mapped.Paths = append(mapped.Paths, llmPolicyPath{ + Path: path.Path, + Methods: path.Methods, + Params: path.Params, + }) + } + return mapped +} + +// buildMCPProxyPayload assembles the MCP proxy creation payload from the +// project's metadata.yaml (name/version), runtime.yaml (context, mcpSpecVersion, +// upstream, policies) and definition.yaml (capabilities). projectId is left out +// here and injected at publish time. +func buildMCPProxyPayload(name string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, definition mcpDefinition) mcpProxyPayload { + payload := mcpProxyPayload{ + ID: name, + DisplayName: strings.TrimSpace(metadata.Spec.DisplayName), + Version: strings.TrimSpace(metadata.Spec.Version), + Context: strings.TrimSpace(runtime.Spec.Context), + Description: "", + MCPSpecVersion: strings.TrimSpace(runtime.Spec.SpecVersion), + Capabilities: &mcpCapabilities{ + Prompts: definition.Prompts, + Resources: mcpResources(definition.Resources), + Tools: definition.Tools, + }, + AssociatedGateways: normalizeAssociatedGateways(metadata.Spec.AssociatedGateways), + } + + if up := runtime.Spec.Upstream; up != nil { + target := llmUpstreamTarget{URL: strings.TrimSpace(up.URL)} + if up.Auth != nil { + target.Auth = &llmUpstreamAuth{Type: up.Auth.Type, Header: up.Auth.Header, Value: up.Auth.Value} + } + payload.Upstream = &llmUpstream{Main: target} + } + + for _, policy := range runtime.Spec.Policies { + payload.Policies = append(payload.Policies, mcpPolicy{ + Name: policy.Name, + Version: policy.Version, + Params: policy.Params, + }) + } + + return payload +} + +// mcpResources strips each definition resource down to the fields the +// capabilities block carries (uri, name, mimeType), dropping inline text/blob +// content. +func mcpResources(resources []map[string]interface{}) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(resources)) + for _, resource := range resources { + trimmed := map[string]interface{}{} + for _, key := range []string{"uri", "name", "mimeType"} { + if value, ok := resource[key]; ok { + trimmed[key] = value + } + } + out = append(out, trimmed) + } + return out +} + +// normalizeAssociatedGateways trims the associatedGateways read from +// metadata.yaml: gateway ids are trimmed and entries without an id are +// dropped. It returns nil when nothing remains so the payload omits the field +// (matching the optional schema in openapi.yaml). +func normalizeAssociatedGateways(gateways []associatedGateway) []associatedGateway { + out := make([]associatedGateway, 0, len(gateways)) + for _, gateway := range gateways { + id := strings.TrimSpace(gateway.ID) + if id == "" { + continue + } + out = append(out, associatedGateway{ID: id, Configurations: gateway.Configurations}) + } + if len(out) == 0 { + return nil + } + return out +} + +// buildRateLimitingFromPolicies maps the *-ratelimit policies in runtime.yaml +// into the provider's rateLimiting block. The policy name selects the dimension +// (advanced-* -> request, token-based-* -> token, llm-cost-based-* -> cost) and +// the scope is consumer-level when the policy is flagged consumerBased (or, for +// advanced quotas, when the quota name carries a "consumer" prefix); otherwise +// it is provider-level. +// +// Each limit is applied globally when its path is "/*" and resource-wise (keyed +// by the path) otherwise. A scope that has any resource-wise limit is emitted as +// resourceWise, with any "/*" limits folded into its default. +func buildRateLimitingFromPolicies(policies []runtimeProviderPolicy) *llmRateLimiting { + provider := &scopeAccumulator{} + consumer := &scopeAccumulator{} + + for _, policy := range policies { + if !strings.HasSuffix(policy.Name, "-ratelimit") { + continue + } + for _, path := range policy.Paths { + params := path.Params + if params == nil { + continue + } + consumerBased := asBool(params["consumerBased"]) + + switch { + case strings.HasPrefix(policy.Name, "advanced"): + dimension, quotaConsumer := advancedRequestDimension(params) + if dimension == nil { + continue + } + scope := provider + if consumerBased || quotaConsumer { + scope = consumer + } + scope.configFor(path.Path).Request = dimension + case strings.Contains(policy.Name, "token"): + dimension := tokenDimension(params) + if dimension == nil { + continue + } + scope := provider + if consumerBased { + scope = consumer + } + scope.configFor(path.Path).Token = dimension + case strings.Contains(policy.Name, "cost"): + dimension := costDimension(params) + if dimension == nil { + continue + } + scope := provider + if consumerBased { + scope = consumer + } + scope.configFor(path.Path).Cost = dimension + } + } + } + + providerScope := provider.build() + consumerScope := consumer.build() + if providerScope == nil && consumerScope == nil { + return nil + } + return &llmRateLimiting{ProviderLevel: providerScope, ConsumerLevel: consumerScope} +} + +// scopeAccumulator collects rate-limit dimensions for one scope (provider or +// consumer), separating global ("/*") limits from per-path (resource-wise) ones. +type scopeAccumulator struct { + global *rateLimitConfig + resources map[string]*rateLimitConfig + order []string // preserves resource insertion order +} + +// configFor returns the limit config a dimension on path should be written to, +// creating it on first use. +func (a *scopeAccumulator) configFor(path string) *rateLimitConfig { + if path == "" || path == "/*" { + if a.global == nil { + a.global = &rateLimitConfig{} + } + return a.global + } + if a.resources == nil { + a.resources = map[string]*rateLimitConfig{} + } + if config, ok := a.resources[path]; ok { + return config + } + config := &rateLimitConfig{} + a.resources[path] = config + a.order = append(a.order, path) + return config +} + +// build renders the accumulator into a scope: global when only "/*" limits were +// seen, resourceWise (with "/*" limits as the default) when any path-specific +// limit was seen, or nil when empty. +func (a *scopeAccumulator) build() *rateLimitScope { + if len(a.order) == 0 { + if a.global == nil { + return nil + } + return &rateLimitScope{Global: a.global} + } + + defaultConfig := a.global + if defaultConfig == nil { + defaultConfig = &rateLimitConfig{} + } + resourceWise := &resourceWiseConfig{Default: defaultConfig} + for _, path := range a.order { + resourceWise.Resources = append(resourceWise.Resources, resourceLimit{Resource: path, Limit: a.resources[path]}) + } + return &rateLimitScope{ResourceWise: resourceWise} +} + +// advancedRequestDimension reads the first quota's first limit into a request +// dimension and reports whether the quota name marks it consumer-scoped. +func advancedRequestDimension(params map[string]interface{}) (*rateLimitDimension, bool) { + quota := firstMap(params["quotas"]) + if quota == nil { + return nil, false + } + isConsumer := strings.HasPrefix(asString(quota["name"]), "consumer") + limit := firstMap(quota["limits"]) + if limit == nil { + return nil, isConsumer + } + count, _ := asInt(limit["limit"]) + return &rateLimitDimension{ + Enabled: true, + Count: count, + Reset: parseResetWindow(asString(limit["duration"])), + }, isConsumer +} + +func tokenDimension(params map[string]interface{}) *rateLimitDimension { + limit := firstMap(params["totalTokenLimits"]) + if limit == nil { + return nil + } + count, _ := asInt(limit["count"]) + return &rateLimitDimension{ + Enabled: true, + Count: count, + Reset: parseResetWindow(asString(limit["duration"])), + } +} + +func costDimension(params map[string]interface{}) *rateLimitCostDimension { + limit := firstMap(params["budgetLimits"]) + if limit == nil { + return nil + } + amount, _ := asFloat(limit["amount"]) + return &rateLimitCostDimension{ + Enabled: true, + Amount: amount, + Reset: parseResetWindow(asString(limit["duration"])), + } +} + +// parseResetWindow turns a duration like "1h" or "3h" into a {duration, unit} +// reset window. Unknown unit suffixes are passed through unchanged. +func parseResetWindow(value string) *rateLimitReset { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + i := 0 + for i < len(value) && value[i] >= '0' && value[i] <= '9' { + i++ + } + if i == 0 { + return nil + } + duration, err := strconv.Atoi(value[:i]) + if err != nil { + return nil + } + unit := strings.ToLower(strings.TrimSpace(value[i:])) + switch unit { + case "m", "min", "minute", "minutes": + unit = "minute" + case "h", "hr", "hour", "hours": + unit = "hour" + case "d", "day", "days": + unit = "day" + case "w", "week", "weeks": + unit = "week" + case "mo", "month", "months": + unit = "month" + } + return &rateLimitReset{Duration: duration, Unit: unit} +} + +// --- free-form params accessors (runtime policy params are open JSON) --- + +func firstMap(value interface{}) map[string]interface{} { + slice, ok := value.([]interface{}) + if !ok || len(slice) == 0 { + return nil + } + m, _ := slice[0].(map[string]interface{}) + return m +} + +func asString(value interface{}) string { + s, _ := value.(string) + return s +} + +func asBool(value interface{}) bool { + b, _ := value.(bool) + return b +} + +func asInt(value interface{}) (int, bool) { + switch n := value.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + } + return 0, false +} + +func asFloat(value interface{}) (float64, bool) { + switch n := value.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + } + return 0, false +} + +// buildSecurityFromPolicies maps the api-key-auth policy (if present) to the +// provider's security block. +func buildSecurityFromPolicies(policies []runtimeProviderPolicy) *securityConfig { + for _, policy := range policies { + if policy.Name != "api-key-auth" { + continue + } + apiKey := &apiKeySecurity{Enabled: true} + for _, path := range policy.Paths { + if v, ok := path.Params["key"].(string); ok && apiKey.Key == "" { + apiKey.Key = v + } + if v, ok := path.Params["in"].(string); ok && apiKey.In == "" { + apiKey.In = v + } + } + return &securityConfig{Enabled: true, APIKey: apiKey} + } + return nil +} + +// defaultProxyDescription is used when runtime.yaml carries no spec.description. +const defaultProxyDescription = "No description provided for this proxy." + +// buildLLMProxyPayload assembles the createLLMProxy request body from the +// project's metadata.yaml (name/version/displayName) and runtime.yaml (context, +// provider, description, policies). Policies come from runtime.yaml's split +// globalPolicies / operationPolicies sections: the api-key-auth global policy is +// mapped to the security block, every other global policy passes through into +// globalPolicies, and operationPolicies pass through with their per-path params. +// Each policy's params are policy-specific (no common schema) and are copied +// verbatim. projectId is intentionally omitted for the caller to inject at +// publish time. +func buildLLMProxyPayload(proxyName string, metadata aiWorkspaceMetadata, runtime aiWorkspaceRuntime, openapi string) llmProxyPayload { + description := strings.TrimSpace(runtime.Spec.Description) + if description == "" { + description = defaultProxyDescription + } + + payload := llmProxyPayload{ + ID: proxyName, + DisplayName: strings.TrimSpace(metadata.Spec.DisplayName), + Version: strings.TrimSpace(metadata.Spec.Version), + Context: strings.TrimSpace(runtime.Spec.Context), + Description: description, + OpenAPI: openapi, + ReadOnly: false, + Provider: llmProxyProvider{ID: strings.TrimSpace(runtime.Spec.Provider.ID)}, + AssociatedGateways: normalizeAssociatedGateways(metadata.Spec.AssociatedGateways), + } + + // The proxy references its provider by id; the provider owns the credential + // value, so only the auth type/header are carried here (never the secret). + if auth := runtime.Spec.Provider.Auth; auth != nil { + payload.Provider.Auth = &llmUpstreamAuth{ + Type: auth.Type, + Header: auth.Header, + } + } + + // api-key-auth is expressed as the security block; all other global policies + // pass through with their policy-specific params. + payload.Security = buildSecurityFromGlobalPolicies(runtime.Spec.GlobalPolicies) + for _, policy := range runtime.Spec.GlobalPolicies { + if policy.Name == "api-key-auth" { + continue + } + payload.GlobalPolicies = append(payload.GlobalPolicies, llmGlobalPolicy{ + Name: policy.Name, + Version: policy.Version, + Params: policy.Params, + }) + } + + for _, policy := range runtime.Spec.OperationPolicies { + payload.OperationPolicies = append(payload.OperationPolicies, mapPolicy(policy)) + } + + return payload +} + +// buildSecurityFromGlobalPolicies maps the api-key-auth global policy (if +// present) to the proxy's security block. Its params sit at the policy level +// (in, key), unlike the provider's paths-based api-key-auth policy. +func buildSecurityFromGlobalPolicies(policies []runtimeProviderPolicy) *securityConfig { + for _, policy := range policies { + if policy.Name != "api-key-auth" { + continue + } + apiKey := &apiKeySecurity{Enabled: true} + if v, ok := policy.Params["key"].(string); ok { + apiKey.Key = v + } + if v, ok := policy.Params["in"].(string); ok { + apiKey.In = v + } + return &securityConfig{Enabled: true, APIKey: apiKey} + } + return nil +} + +func readYAMLFile(path string, out interface{}) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := yaml.Unmarshal(data, out); err != nil { + return fmt.Errorf("failed to parse %s: %w", filepath.Base(path), err) + } + return nil +} + +// --- metadata.yaml / runtime.yaml input shapes (only the fields used here) --- + +type aiWorkspaceMetadata struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + // AssociatedGateways lives under spec in metadata.yaml; the build extracts + // it from there and folds it into the generated payload. + AssociatedGateways []associatedGateway `yaml:"associatedGateways"` + } `yaml:"spec"` +} + +// associatedGateway mirrors the AssociatedGateway schema (openapi.yaml): the +// gateway id plus a free-form per-gateway configuration override. The same +// shape is used to parse metadata.yaml and to emit the build payload. +type associatedGateway struct { + ID string `json:"id" yaml:"id"` + Configurations map[string]interface{} `json:"configurations,omitempty" yaml:"configurations"` +} + +type aiWorkspaceRuntime struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Context string `yaml:"context"` + Description string `yaml:"description"` + Template string `yaml:"template"` + SpecVersion string `yaml:"specVersion"` + Provider runtimeProvider `yaml:"provider"` + Upstream *runtimeUpstream `yaml:"upstream"` + AccessControl *runtimeAccessControl `yaml:"accessControl"` + // Policies is the legacy flat list still used by the LLM provider and MCP + // proxy builders. LLM proxies use the split globalPolicies / + // operationPolicies below. + Policies []runtimeProviderPolicy `yaml:"policies"` + GlobalPolicies []runtimeProviderPolicy `yaml:"globalPolicies"` + OperationPolicies []runtimeProviderPolicy `yaml:"operationPolicies"` + } `yaml:"spec"` +} + +type runtimeProvider struct { + ID string `yaml:"id"` + Auth *runtimeProviderAuth `yaml:"auth"` +} + +type runtimeProviderAuth struct { + Type string `yaml:"type"` + Header string `yaml:"header"` + Value string `yaml:"value"` +} + +type runtimeUpstream struct { + URL string `yaml:"url"` + Auth *runtimeProviderAuth `yaml:"auth"` +} + +type runtimeAccessControl struct { + Mode string `yaml:"mode"` + Exceptions []runtimeRouteException `yaml:"exceptions"` +} + +type runtimeRouteException struct { + Methods []string `yaml:"methods"` + Path string `yaml:"path"` +} + +type runtimeProviderPolicy struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Paths []runtimePolicyPath `yaml:"paths"` + // Params holds policy-level params used by MCP policies (LLM proxy/provider + // policies carry their params under paths[].params instead). + Params map[string]interface{} `yaml:"params"` +} + +type runtimePolicyPath struct { + Path string `yaml:"path"` + Methods []string `yaml:"methods"` + Params map[string]interface{} `yaml:"params"` +} + +// --- createLLMProxy request body (subset; see openapi.yaml LLMProxy schema) --- + +type llmProxyPayload struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Description string `json:"description"` + Provider llmProxyProvider `json:"provider"` + OpenAPI string `json:"openapi"` + ReadOnly bool `json:"readOnly"` + Security *securityConfig `json:"security,omitempty"` + GlobalPolicies []llmGlobalPolicy `json:"globalPolicies,omitempty"` + OperationPolicies []llmPolicy `json:"operationPolicies,omitempty"` + AssociatedGateways []associatedGateway `json:"associatedGateways,omitempty"` +} + +// llmGlobalPolicy is an api-level policy applied across all operations. Unlike +// operation policies it has no paths; its params are policy-specific and passed +// through verbatim. +type llmGlobalPolicy struct { + Name string `json:"name"` + Version string `json:"version"` + Params map[string]interface{} `json:"params,omitempty"` +} + +type llmProxyProvider struct { + ID string `json:"id"` + Auth *llmUpstreamAuth `json:"auth,omitempty"` +} + +type llmUpstreamAuth struct { + Type string `json:"type,omitempty"` + Header string `json:"header,omitempty"` + Value string `json:"value,omitempty"` +} + +type llmPolicy struct { + Name string `json:"name"` + Version string `json:"version"` + Paths []llmPolicyPath `json:"paths"` +} + +type llmPolicyPath struct { + Path string `json:"path"` + Methods []string `json:"methods"` + Params map[string]interface{} `json:"params"` +} + +// --- MCP definition input (definition.yaml) and MCP proxy request body --- + +// mcpDefinition is the parsed definition.yaml for an MCP proxy. Prompts and +// tools pass through verbatim; resources are trimmed before being emitted. +type mcpDefinition struct { + Prompts []map[string]interface{} `yaml:"prompts"` + Resources []map[string]interface{} `yaml:"resources"` + Tools []map[string]interface{} `yaml:"tools"` +} + +type mcpProxyPayload struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Description string `json:"description"` + MCPSpecVersion string `json:"mcpSpecVersion,omitempty"` + Upstream *llmUpstream `json:"upstream,omitempty"` + Capabilities *mcpCapabilities `json:"capabilities,omitempty"` + Policies []mcpPolicy `json:"policies,omitempty"` + AssociatedGateways []associatedGateway `json:"associatedGateways,omitempty"` +} + +type mcpCapabilities struct { + Prompts []map[string]interface{} `json:"prompts"` + Resources []map[string]interface{} `json:"resources"` + Tools []map[string]interface{} `json:"tools"` +} + +type mcpPolicy struct { + Name string `json:"name"` + Version string `json:"version"` + Params map[string]interface{} `json:"params"` +} + +// --- createLLMProvider request body (subset; see openapi.yaml LLMProvider schema) --- + +type llmProviderPayload struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Context string `json:"context,omitempty"` + Template string `json:"template"` + ModelProviders []llmModelProvider `json:"modelProviders,omitempty"` + Upstream *llmUpstream `json:"upstream,omitempty"` + AccessControl *llmAccessControl `json:"accessControl,omitempty"` + OpenAPI string `json:"openapi"` + RateLimiting *llmRateLimiting `json:"rateLimiting,omitempty"` + Security *securityConfig `json:"security,omitempty"` + Policies []llmPolicy `json:"policies,omitempty"` + AssociatedGateways []associatedGateway `json:"associatedGateways,omitempty"` +} + +// llmModelProvider / llmModel mirror the LLMModelProvider / LLMModel schemas +// (openapi.yaml). The build derives them from the provider template. +type llmModelProvider struct { + ID string `json:"id,omitempty"` + DisplayName string `json:"displayName"` + Models []llmModel `json:"models,omitempty"` +} + +type llmModel struct { + ID string `json:"id,omitempty"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` +} + +type llmRateLimiting struct { + ProviderLevel *rateLimitScope `json:"providerLevel,omitempty"` + ConsumerLevel *rateLimitScope `json:"consumerLevel,omitempty"` +} + +type rateLimitScope struct { + Global *rateLimitConfig `json:"global,omitempty"` + ResourceWise *resourceWiseConfig `json:"resourceWise,omitempty"` +} + +type resourceWiseConfig struct { + Default *rateLimitConfig `json:"default,omitempty"` + Resources []resourceLimit `json:"resources"` +} + +type resourceLimit struct { + Resource string `json:"resource"` + Limit *rateLimitConfig `json:"limit,omitempty"` +} + +type rateLimitConfig struct { + Request *rateLimitDimension `json:"request,omitempty"` + Token *rateLimitDimension `json:"token,omitempty"` + Cost *rateLimitCostDimension `json:"cost,omitempty"` +} + +type rateLimitDimension struct { + Enabled bool `json:"enabled"` + Count int `json:"count"` + Reset *rateLimitReset `json:"reset,omitempty"` +} + +type rateLimitCostDimension struct { + Enabled bool `json:"enabled"` + Amount float64 `json:"amount"` + Reset *rateLimitReset `json:"reset,omitempty"` +} + +type rateLimitReset struct { + Duration int `json:"duration"` + Unit string `json:"unit"` +} + +type llmUpstream struct { + Main llmUpstreamTarget `json:"main"` +} + +type llmUpstreamTarget struct { + URL string `json:"url,omitempty"` + Auth *llmUpstreamAuth `json:"auth,omitempty"` +} + +type llmAccessControl struct { + Mode string `json:"mode"` + Exceptions []routeException `json:"exceptions,omitempty"` +} + +type routeException struct { + Methods []string `json:"methods"` + Path string `json:"path"` +} + +type securityConfig struct { + Enabled bool `json:"enabled"` + APIKey *apiKeySecurity `json:"apiKey,omitempty"` +} + +type apiKeySecurity struct { + Enabled bool `json:"enabled"` + Key string `json:"key,omitempty"` + In string `json:"in,omitempty"` +} + +// --- path helpers --- + +func resolveProjectPath(root, pathValue string) string { + trimmed := strings.TrimSpace(pathValue) + if trimmed == "" { + return root + } + + trimmed = strings.TrimPrefix(trimmed, "./") + return filepath.Join(root, filepath.Clean(trimmed)) +} + +// ensureWithinProjectRoot rejects resolved paths that escape the project root +// (e.g. via ".." segments or symlinks in a config value), keeping build inputs +// bounded to the project directory. +func ensureWithinProjectRoot(projectRoot, path, configName, fieldName string) error { + canonicalRoot, err := canonicalizePath(projectRoot) + if err != nil { + return fmt.Errorf("failed to resolve project root for ai-workspace config %q: %w", configName, err) + } + canonicalTarget, err := canonicalizePath(path) + if err != nil { + return fmt.Errorf("failed to resolve %s for ai-workspace config %q: %w", fieldName, configName, err) + } + + rel, err := filepath.Rel(canonicalRoot, canonicalTarget) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("ai-workspace config %q is invalid: %s path resolves outside the project root: %s", configName, fieldName, path) + } + + return nil +} + +// canonicalizePath returns an absolute, symlink-resolved form of path so that +// containment checks are reliable across differing path forms. When the path +// does not yet exist, it resolves symlinks on the nearest existing ancestor and +// re-appends the remaining segments rather than failing. +func canonicalizePath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + + remainder := "" + current := abs + for { + resolved, err := filepath.EvalSymlinks(current) + if err == nil { + if remainder == "" { + return resolved, nil + } + return filepath.Join(resolved, remainder), nil + } + if !os.IsNotExist(err) { + return "", err + } + + parent := filepath.Dir(current) + if parent == current { + return abs, nil + } + remainder = filepath.Join(filepath.Base(current), remainder) + current = parent + } +} + +func ensurePathExists(path string, wantDir bool, configName, fieldName string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("ai-workspace config %q is invalid: %s path does not exist: %s", configName, fieldName, path) + } + return fmt.Errorf("failed to inspect %s for ai-workspace config %q: %w", fieldName, configName, err) + } + + if wantDir && !info.IsDir() { + return fmt.Errorf("ai-workspace config %q is invalid: %s must be a directory: %s", configName, fieldName, path) + } + if !wantDir && info.IsDir() { + return fmt.Errorf("ai-workspace config %q is invalid: %s must be a file: %s", configName, fieldName, path) + } + + return nil +} diff --git a/cli/src/cmd/aiworkspace/build_test.go b/cli/src/cmd/aiworkspace/build_test.go new file mode 100644 index 0000000000..b37f78aeaf --- /dev/null +++ b/cli/src/cmd/aiworkspace/build_test.go @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func newProxyRuntime() aiWorkspaceRuntime { + var rt aiWorkspaceRuntime + rt.Spec.Context = "/default/claude-proxy2" + rt.Spec.Provider = runtimeProvider{ + ID: "wso2-claude-provider", + Auth: &runtimeProviderAuth{Type: "api-key", Header: "X-API-Key", Value: "{{ secret \"abc\" }}"}, + } + rt.Spec.GlobalPolicies = []runtimeProviderPolicy{ + {Name: "api-key-auth", Version: "", Params: map[string]interface{}{"in": "header", "key": "X-API-Key"}}, + {Name: "aws-bedrock-guardrail", Version: "v1", Params: map[string]interface{}{ + "request": map[string]interface{}{"enabled": true, "jsonPath": "$.messages[-1].content"}, + }}, + } + rt.Spec.OperationPolicies = []runtimeProviderPolicy{ + {Name: "basic-auth", Version: "v1", Paths: []runtimePolicyPath{ + {Path: "/v1/messages", Methods: []string{"POST"}, Params: map[string]interface{}{"username": "admin", "password": "admin"}}, + }}, + } + return rt +} + +func newProxyMetadata() aiWorkspaceMetadata { + var md aiWorkspaceMetadata + md.Metadata.Name = "claude-proxy2" + md.Spec.DisplayName = "claude proxy2" + md.Spec.Version = "v1.0" + return md +} + +func TestBuildLLMProxyPayload_MapsGlobalAndOperationPolicies(t *testing.T) { + payload := buildLLMProxyPayload("claude-proxy2", newProxyMetadata(), newProxyRuntime(), "") + + if payload.ID != "claude-proxy2" || payload.DisplayName != "claude proxy2" || payload.Version != "v1.0" { + t.Fatalf("unexpected identity fields: %+v", payload) + } + if payload.Description != defaultProxyDescription { + t.Fatalf("expected default description, got %q", payload.Description) + } + + // api-key-auth is lifted into security, not left in globalPolicies. + if payload.Security == nil || payload.Security.APIKey == nil { + t.Fatalf("expected security block from api-key-auth, got %+v", payload.Security) + } + if !payload.Security.Enabled || payload.Security.APIKey.In != "header" || payload.Security.APIKey.Key != "X-API-Key" { + t.Fatalf("unexpected security block: %+v", payload.Security.APIKey) + } + + // Remaining global policies pass through with their free-form params intact. + if len(payload.GlobalPolicies) != 1 || payload.GlobalPolicies[0].Name != "aws-bedrock-guardrail" { + t.Fatalf("expected 1 global policy (aws-bedrock-guardrail), got %+v", payload.GlobalPolicies) + } + req, ok := payload.GlobalPolicies[0].Params["request"].(map[string]interface{}) + if !ok || req["jsonPath"] != "$.messages[-1].content" { + t.Fatalf("global policy params not preserved verbatim: %+v", payload.GlobalPolicies[0].Params) + } + + // Operation policies keep their per-path free-form params. + if len(payload.OperationPolicies) != 1 || payload.OperationPolicies[0].Name != "basic-auth" { + t.Fatalf("expected 1 operation policy (basic-auth), got %+v", payload.OperationPolicies) + } + op := payload.OperationPolicies[0] + if len(op.Paths) != 1 || op.Paths[0].Path != "/v1/messages" || op.Paths[0].Params["username"] != "admin" { + t.Fatalf("operation policy path/params not preserved: %+v", op.Paths) + } + + // The provider auth carries type/header but never the secret value. + if payload.Provider.Auth == nil || payload.Provider.Auth.Value != "" { + t.Fatalf("expected provider auth without secret value, got %+v", payload.Provider.Auth) + } +} + +func TestBuildLLMProxyPayload_UsesRuntimeDescriptionWhenSet(t *testing.T) { + rt := newProxyRuntime() + rt.Spec.Description = "custom proxy description" + payload := buildLLMProxyPayload("claude-proxy2", newProxyMetadata(), rt, "") + if payload.Description != "custom proxy description" { + t.Fatalf("expected runtime description, got %q", payload.Description) + } +} + +func TestBuildLLMProxyPayload_OmitsPoliciesWhenNone(t *testing.T) { + var md aiWorkspaceMetadata + md.Spec.DisplayName = "p" + md.Spec.Version = "v1.0" + payload := buildLLMProxyPayload("p", md, aiWorkspaceRuntime{}, "") + if payload.Security != nil { + t.Fatalf("expected no security block, got %+v", payload.Security) + } + if payload.GlobalPolicies != nil || payload.OperationPolicies != nil { + t.Fatalf("expected no policies, got global=%+v operation=%+v", payload.GlobalPolicies, payload.OperationPolicies) + } +} + +func TestModelProvidersForTemplate_KnownTemplate(t *testing.T) { + got := modelProvidersForTemplate("openai") + if len(got) != 1 { + t.Fatalf("expected 1 model provider, got %d", len(got)) + } + provider := got[0] + if provider.ID != "openai" || provider.DisplayName != "openai" { + t.Fatalf("expected provider id/displayName %q, got id=%q displayName=%q", "openai", provider.ID, provider.DisplayName) + } + + wantModels := []string{"gpt-4o-mini", "gpt-4.1-mini", "o4-mini"} + if len(provider.Models) != len(wantModels) { + t.Fatalf("expected %d models, got %d", len(wantModels), len(provider.Models)) + } + for i, want := range wantModels { + if provider.Models[i].ID != want || provider.Models[i].DisplayName != want { + t.Fatalf("model[%d]: expected id/displayName %q, got id=%q displayName=%q", i, want, provider.Models[i].ID, provider.Models[i].DisplayName) + } + } +} + +func TestModelProvidersForTemplate_TrimsAndAllTemplates(t *testing.T) { + // Every documented template maps to a non-empty model provider, and the + // template is trimmed before lookup. + for _, template := range []string{"meta", "openai", "anthropic", "google-vertex", "aws-bedrock", "mistralai"} { + if got := modelProvidersForTemplate(" " + template + " "); len(got) != 1 || len(got[0].Models) == 0 { + t.Fatalf("template %q: expected one provider with models, got %#v", template, got) + } + } +} + +func TestModelProvidersForTemplate_UnknownTemplateOmitted(t *testing.T) { + if got := modelProvidersForTemplate("custom-template"); got != nil { + t.Fatalf("expected nil for unknown template, got %#v", got) + } + if got := modelProvidersForTemplate(""); got != nil { + t.Fatalf("expected nil for empty template, got %#v", got) + } +} + +func TestBuildLLMProviderPayload_IncludesModelProviders(t *testing.T) { + var metadata aiWorkspaceMetadata + metadata.Metadata.Name = "wso2-claude-provider" + metadata.Spec.Version = "v1.0" + + var runtime aiWorkspaceRuntime + runtime.Spec.Template = "anthropic" + + payload := buildLLMProviderPayload("wso2-claude-provider", metadata, runtime, "") + if len(payload.ModelProviders) != 1 { + t.Fatalf("expected modelProviders populated for template %q, got %#v", runtime.Spec.Template, payload.ModelProviders) + } + if payload.ModelProviders[0].ID != "anthropic" { + t.Fatalf("expected model provider id %q, got %q", "anthropic", payload.ModelProviders[0].ID) + } +} + +func TestBuildLLMProviderPayload_OmitsModelProvidersForUnknownTemplate(t *testing.T) { + var metadata aiWorkspaceMetadata + metadata.Spec.Version = "v1.0" + + var runtime aiWorkspaceRuntime + runtime.Spec.Template = "my-custom-template" + + payload := buildLLMProviderPayload("p", metadata, runtime, "") + if payload.ModelProviders != nil { + t.Fatalf("expected no modelProviders for unknown template, got %#v", payload.ModelProviders) + } +} + +func writeTestEnvFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write env file: %v", err) + } + return path +} + +func TestResolveEnvPlaceholders_AllFormsFromProjectDotEnv(t *testing.T) { + root := t.TempDir() + writeTestEnvFile(t, root, ".env", "ENV_CLI_A=alpha\nENV_CLI_B=beta\nENV_CLI_C=gamma\n") + + body := []byte(`{"a":"${ENV_CLI_A}","b":"$ENV_CLI_B","c":"ENV_CLI_C"}`) + got, err := resolveEnvPlaceholders(body, root, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"a":"alpha","b":"beta","c":"gamma"}` + if string(got) != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestResolveEnvPlaceholders_ExplicitFileWinsProcessEnvFillsGaps(t *testing.T) { + root := t.TempDir() + // A .env in the project root must be ignored when --env-file is given. + writeTestEnvFile(t, root, ".env", "ENV_CLI_KEY=from-dotenv\n") + explicit := writeTestEnvFile(t, root, "custom.env", "ENV_CLI_KEY=from-file\n") + + t.Setenv("ENV_CLI_KEY", "from-process") + t.Setenv("ENV_CLI_ONLY_PROCESS", "process-value") + + body := []byte(`{"k":"${ENV_CLI_KEY}","p":"${ENV_CLI_ONLY_PROCESS}"}`) + got, err := resolveEnvPlaceholders(body, root, explicit) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"k":"from-file","p":"process-value"}` + if string(got) != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestResolveEnvPlaceholders_MissingVariablesError(t *testing.T) { + root := t.TempDir() + body := []byte(`{"a":"${ENV_CLI_MISSING_ONE}","b":"$ENV_CLI_MISSING_TWO"}`) + _, err := resolveEnvPlaceholders(body, root, "") + if err == nil { + t.Fatal("expected an error for unresolved placeholders") + } + for _, name := range []string{"ENV_CLI_MISSING_ONE", "ENV_CLI_MISSING_TWO"} { + if !strings.Contains(err.Error(), name) { + t.Fatalf("error should name %s: %v", name, err) + } + } +} + +func TestResolveEnvPlaceholders_NoPlaceholdersUnchangedAndNoEnvNeeded(t *testing.T) { + root := t.TempDir() + body := []byte(`{"a":"plain"}`) + got, err := resolveEnvPlaceholders(body, root, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != string(body) { + t.Fatalf("body changed: %s", got) + } +} + +func TestResolveEnvPlaceholders_JSONEscapesValues(t *testing.T) { + root := t.TempDir() + writeTestEnvFile(t, root, ".env", `ENV_CLI_SECRET=va"l\ue`+"\n") + + body := []byte(`{"s":"${ENV_CLI_SECRET}"}`) + got, err := resolveEnvPlaceholders(body, root, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var decoded map[string]string + if err := json.Unmarshal(got, &decoded); err != nil { + t.Fatalf("resolved payload is not valid JSON: %v (%s)", err, got) + } + if decoded["s"] != `va"l\ue` { + t.Fatalf("got %q, want %q", decoded["s"], `va"l\ue`) + } +} + +func TestResolveEnvPlaceholders_ExplicitEnvFileMissingIsError(t *testing.T) { + root := t.TempDir() + body := []byte(`{"a":"${ENV_CLI_A}"}`) + if _, err := resolveEnvPlaceholders(body, root, filepath.Join(root, "nope.env")); err == nil { + t.Fatal("expected an error for a missing --env-file") + } +} + +func TestParseEnvFile_CommentsExportAndQuotes(t *testing.T) { + root := t.TempDir() + path := writeTestEnvFile(t, root, "vals.env", strings.Join([]string{ + "# comment", + "", + "export ENV_CLI_EXPORTED=one", + `ENV_CLI_DOUBLE="two words"`, + "ENV_CLI_SINGLE='three'", + "ENV_CLI_EQ=a=b", + "not-a-pair", + }, "\n")) + + values, err := parseEnvFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := map[string]string{ + "ENV_CLI_EXPORTED": "one", + "ENV_CLI_DOUBLE": "two words", + "ENV_CLI_SINGLE": "three", + "ENV_CLI_EQ": "a=b", + } + for k, v := range want { + if values[k] != v { + t.Fatalf("%s: got %q, want %q", k, values[k], v) + } + } + if len(values) != len(want) { + t.Fatalf("unexpected extra entries: %#v", values) + } +} + +func TestResolveEnvPlaceholders_BareFormRequiresWordBoundary(t *testing.T) { + root := t.TempDir() + writeTestEnvFile(t, root, ".env", "ENV_CLI_FOO=resolved\n") + + // A bare placeholder at a boundary resolves; the same token embedded in a + // larger identifier (MY_ENV_CLI_FOO) must be left untouched. + body := []byte(`{"a":"ENV_CLI_FOO","b":"MY_ENV_CLI_FOO"}`) + got, err := resolveEnvPlaceholders(body, root, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"a":"resolved","b":"MY_ENV_CLI_FOO"}` + if string(got) != want { + t.Fatalf("got %s, want %s", got, want) + } +} diff --git a/cli/src/cmd/aiworkspace/current.go b/cli/src/cmd/aiworkspace/current.go new file mode 100644 index 0000000000..ce178ae26a --- /dev/null +++ b/cli/src/cmd/aiworkspace/current.go @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + CurrentCmdLiteral = "current" + CurrentCmdExample = `# Show the current active AI workspace +ap ai-workspace current` +) + +var currentCmd = &cobra.Command{ + Use: CurrentCmdLiteral, + Short: "Show the current active AI workspace", + Long: "Display the current active AI workspace configuration.", + Example: CurrentCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runCurrentCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var currentPlatform string + +func init() { + utils.AddStringFlag(currentCmd, utils.FlagPlatform, ¤tPlatform, "", "Platform name") +} + +func runCurrentCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + resolvedPlatform := cfg.ResolvePlatform(currentPlatform) + aiWorkspace, err := cfg.GetActiveAIWorkspaceFromPlatform(resolvedPlatform) + if err != nil { + return err + } + + fmt.Printf("Current ai-workspace: %s - %s (platform: %s, auth: %s)\n", aiWorkspace.Name, aiWorkspace.URL, resolvedPlatform, aiWorkspace.Auth.Type) + + return nil +} diff --git a/cli/src/cmd/aiworkspace/edit.go b/cli/src/cmd/aiworkspace/edit.go new file mode 100644 index 0000000000..5cb7cc0996 --- /dev/null +++ b/cli/src/cmd/aiworkspace/edit.go @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + EditCmdLiteral = "edit" + EditCmdExample = `# Generate and update the AI workspace artifact from the current project +ap ai-workspace edit + +# Update a proxy or MCP artifact (--project-id is required for those kinds) +ap ai-workspace edit --project-id + +# Update from a specific project directory using a specific AI workspace +ap ai-workspace edit -f /path/to/project --project-id --display-name my-workspace --platform eu` +) + +var ( + editProjectDir string + editProjectID string + editEnvFile string + editName string + editPlatform string + editInsecure bool + editOutput string +) + +var editCmd = &cobra.Command{ + Use: EditCmdLiteral, + Short: "Generate and update an existing AI workspace artifact", + Long: "Generate the payload from the project's metadata.yaml, runtime.yaml and definition.yaml, then " + + "update the existing artifact on the WSO2 API Platform AI workspace with a PUT request. The target " + + "endpoint is selected by the artifact kind declared in the project (LlmProvider, LlmProxy, Mcp) and the " + + "artifact is identified by metadata.name. For the LlmProxy and Mcp kinds --project-id is required.", + Example: EditCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runEditCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(editCmd, utils.FlagFile, &editProjectDir, "", "Path to the project directory (defaults to current directory)") + utils.AddStringFlag(editCmd, utils.FlagProjectID, &editProjectID, "", "Project ID (required for LlmProxy and Mcp kinds)") + utils.AddStringFlag(editCmd, utils.FlagEnvFile, &editEnvFile, "", "Path to an env file resolving ENV_CLI_* placeholders (defaults to .env in the project root)") + utils.AddStringFlag(editCmd, utils.FlagName, &editName, "", "AI workspace display name") + utils.AddStringFlag(editCmd, utils.FlagPlatform, &editPlatform, "", "Platform name") + utils.AddStringFlag(editCmd, utils.FlagOutput, &editOutput, "", "Output format: \"json\" prints the full server response (default: summary)") + editCmd.Flags().BoolVar(&editInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runEditCommand() error { + projectRoot, wsConfig, err := resolveProjectAIWorkspace(editProjectDir) + if err != nil { + return err + } + + artifact, err := loadAIWorkspaceArtifact(projectRoot, wsConfig) + if err != nil { + return fmt.Errorf("AI workspace validation failed for %q: %w", wsConfig.Name, err) + } + + body, projectID, err := marshalAIWorkspacePayload(artifact, editProjectID) + if err != nil { + return err + } + + // Resolve ENV_CLI_* placeholders carried from metadata.yaml/runtime.yaml + // into the generated payload before it is sent. + body, err = resolveEnvPlaceholders(body, projectRoot, editEnvFile) + if err != nil { + return err + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, editName, editPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, editInsecure) + resp, err := client.PutJSON(aiWorkspaceUpdatePath(artifact.BaseKind, artifact.ResourceName), body) + if err != nil { + return aiworkspace.WrapRequestError(fmt.Sprintf("update %s", artifact.BaseKind), err, editInsecure) + } + + return aiworkspace.PrintApplyResult(resp, editOutput, artifact.BaseKind, "updated", artifact.ResourceName, projectID) +} diff --git a/cli/src/cmd/aiworkspace/list.go b/cli/src/cmd/aiworkspace/list.go new file mode 100644 index 0000000000..2b5883719c --- /dev/null +++ b/cli/src/cmd/aiworkspace/list.go @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all AI workspaces +ap ai-workspace list` +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all AI workspaces", + Long: "List all AI workspace configurations from the ap config file.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +var listPlatform string + +func init() { + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") +} + +func runListCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + resolvedPlatform := cfg.ResolvePlatform(listPlatform) + platform := cfg.Platforms[resolvedPlatform] + if platform == nil || len(platform.AIWorkspaces) == 0 { + fmt.Printf("No ai-workspace configured for platform %s\n", resolvedPlatform) + return nil + } + + headers := []string{"PLATFORM", "NAME", "URL", "AUTH", "CURRENT"} + rows := make([][]string, 0, len(platform.AIWorkspaces)) + for name, ws := range platform.AIWorkspaces { + current := "" + if name == platform.ActiveAIWorkspace { + current = "*" + } + rows = append(rows, []string{resolvedPlatform, name, ws.URL, ws.Auth.Type, current}) + } + utils.PrintTable(headers, rows) + + return nil +} diff --git a/cli/src/cmd/aiworkspace/llmprovider/delete.go b/cli/src/cmd/aiworkspace/llmprovider/delete.go new file mode 100644 index 0000000000..4131f4e05a --- /dev/null +++ b/cli/src/cmd/aiworkspace/llmprovider/delete.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + DeleteCmdLiteral = "delete" + DeleteCmdExample = `# Delete an LLM provider by ID using the active AI workspace +ap ai-workspace llm-provider delete --id wso2-claude + +# Delete using a specific AI workspace +ap ai-workspace llm-provider delete --id wso2-claude --display-name my-workspace --platform eu` +) + +var ( + deleteID string + deleteName string + deletePlatform string + deleteInsecure bool +) + +var deleteCmd = &cobra.Command{ + Use: DeleteCmdLiteral, + Short: "Delete an LLM provider from the AI workspace", + Long: "Delete an LLM provider from the WSO2 API Platform AI workspace by its identifier.", + Example: DeleteCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runDeleteCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(deleteCmd, utils.FlagID, &deleteID, "", "LLM provider ID (required)") + utils.AddStringFlag(deleteCmd, utils.FlagName, &deleteName, "", "AI workspace display name") + utils.AddStringFlag(deleteCmd, utils.FlagPlatform, &deletePlatform, "", "Platform name") + deleteCmd.Flags().BoolVar(&deleteInsecure, "insecure", false, "Skip TLS certificate verification") + _ = deleteCmd.MarkFlagRequired(utils.FlagID) +} + +func runDeleteCommand() error { + id := strings.TrimSpace(deleteID) + if id == "" { + return fmt.Errorf("LLM provider ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, deleteName, deletePlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, deleteInsecure) + resp, err := client.Delete(aiworkspace.ProviderByIDPath(id)) + if err != nil { + return aiworkspace.WrapRequestError("delete llm provider", err, deleteInsecure) + } + resp.Body.Close() + + fmt.Printf("LLM provider %q deleted from ai-workspace %s (platform: %s)\n", id, aiWorkspace.Name, resolvedPlatform) + return nil +} diff --git a/cli/src/cmd/aiworkspace/llmprovider/get.go b/cli/src/cmd/aiworkspace/llmprovider/get.go new file mode 100644 index 0000000000..bd26e0bf85 --- /dev/null +++ b/cli/src/cmd/aiworkspace/llmprovider/get.go @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + GetCmdLiteral = "get" + GetCmdExample = `# List all LLM providers +ap ai-workspace llm-provider get + +# Get a single LLM provider by ID +ap ai-workspace llm-provider get --id wso2-claude + +# List using a specific AI workspace with pagination +ap ai-workspace llm-provider get --limit 50 --offset 0 --display-name my-workspace --platform eu` +) + +var ( + getID string + getLimit string + getOffset string + getName string + getPlatform string + getInsecure bool +) + +var getCmd = &cobra.Command{ + Use: GetCmdLiteral, + Short: "Get one or all LLM providers from the AI workspace", + Long: "Retrieve LLM providers from the WSO2 API Platform AI workspace. With --id a single provider is fetched; without it all providers in the organization are listed.", + Example: GetCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGetCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(getCmd, utils.FlagID, &getID, "", "LLM provider ID (omit to list all)") + utils.AddStringFlag(getCmd, utils.FlagLimit, &getLimit, "", "Maximum number of providers to return when listing") + utils.AddStringFlag(getCmd, utils.FlagOffset, &getOffset, "", "Number of providers to skip when listing") + utils.AddStringFlag(getCmd, utils.FlagName, &getName, "", "AI workspace display name") + utils.AddStringFlag(getCmd, utils.FlagPlatform, &getPlatform, "", "Platform name") + getCmd.Flags().BoolVar(&getInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runGetCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, getName, getPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, getInsecure) + + var path, action string + if id := strings.TrimSpace(getID); id != "" { + path = aiworkspace.ProviderByIDPath(id) + action = "get llm provider" + } else { + path = aiworkspace.ProviderListPath(aiworkspace.ListQuery{Limit: getLimit, Offset: getOffset}) + action = "list llm providers" + } + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError(action, err, getInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiworkspace/llmprovider/list.go b/cli/src/cmd/aiworkspace/llmprovider/list.go new file mode 100644 index 0000000000..325e992a73 --- /dev/null +++ b/cli/src/cmd/aiworkspace/llmprovider/list.go @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all LLM providers +ap ai-workspace llm-provider list + +# List with pagination +ap ai-workspace llm-provider list --limit 50 --offset 0 + +# List using a specific AI workspace +ap ai-workspace llm-provider list --display-name my-workspace --platform eu` +) + +var ( + listLimit string + listOffset string + listName string + listPlatform string + listInsecure bool +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all LLM providers in the AI workspace", + Long: "Retrieves all LLM providers for a given organization from the WSO2 API Platform AI workspace.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(listCmd, utils.FlagLimit, &listLimit, "", "Maximum number of providers to return") + utils.AddStringFlag(listCmd, utils.FlagOffset, &listOffset, "", "Number of providers to skip") + utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "AI workspace display name") + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runListCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, listName, listPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, listInsecure) + path := aiworkspace.ProviderListPath(aiworkspace.ListQuery{Limit: listLimit, Offset: listOffset}) + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError("list llm providers", err, listInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiworkspace/llmprovider/root.go b/cli/src/cmd/aiworkspace/llmprovider/root.go new file mode 100644 index 0000000000..c5d58f25e7 --- /dev/null +++ b/cli/src/cmd/aiworkspace/llmprovider/root.go @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmprovider + +import ( + "github.com/spf13/cobra" +) + +const ( + LLMProviderCmdLiteral = "llm-provider" + LLMProviderCmdExample = `# List LLM providers on the AI workspace +ap ai-workspace llm-provider list + +# Create or update a provider from a project with: +# ap ai-workspace apply / ap ai-workspace edit` +) + +// LLMProviderCmd is the parent command for LLM provider operations. +var LLMProviderCmd = &cobra.Command{ + Use: LLMProviderCmdLiteral, + Short: "Manage LLM providers on the AI workspace", + Long: "This command allows you to manage LLM providers on the WSO2 API Platform AI workspace.", + Example: LLMProviderCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + LLMProviderCmd.AddCommand(listCmd) + LLMProviderCmd.AddCommand(getCmd) + LLMProviderCmd.AddCommand(deleteCmd) +} diff --git a/cli/src/cmd/aiworkspace/llmproxy/delete.go b/cli/src/cmd/aiworkspace/llmproxy/delete.go new file mode 100644 index 0000000000..98ac5bc44a --- /dev/null +++ b/cli/src/cmd/aiworkspace/llmproxy/delete.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + DeleteCmdLiteral = "delete" + DeleteCmdExample = `# Delete an LLM proxy by ID using the active AI workspace +ap ai-workspace app-llm-proxy delete --id wso2-openai-proxy + +# Delete using a specific AI workspace +ap ai-workspace app-llm-proxy delete --id wso2-openai-proxy --display-name my-workspace --platform eu` +) + +var ( + deleteID string + deleteName string + deletePlatform string + deleteInsecure bool +) + +var deleteCmd = &cobra.Command{ + Use: DeleteCmdLiteral, + Short: "Delete an LLM proxy from the AI workspace", + Long: "Delete an LLM proxy from the WSO2 API Platform AI workspace by its identifier.", + Example: DeleteCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runDeleteCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(deleteCmd, utils.FlagID, &deleteID, "", "LLM proxy ID (required)") + utils.AddStringFlag(deleteCmd, utils.FlagName, &deleteName, "", "AI workspace display name") + utils.AddStringFlag(deleteCmd, utils.FlagPlatform, &deletePlatform, "", "Platform name") + deleteCmd.Flags().BoolVar(&deleteInsecure, "insecure", false, "Skip TLS certificate verification") + _ = deleteCmd.MarkFlagRequired(utils.FlagID) +} + +func runDeleteCommand() error { + id := strings.TrimSpace(deleteID) + if id == "" { + return fmt.Errorf("LLM proxy ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, deleteName, deletePlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, deleteInsecure) + resp, err := client.Delete(aiworkspace.ProxyByIDPath(id)) + if err != nil { + return aiworkspace.WrapRequestError("delete llm proxy", err, deleteInsecure) + } + resp.Body.Close() + + fmt.Printf("LLM proxy %q deleted from ai-workspace %s (platform: %s)\n", id, aiWorkspace.Name, resolvedPlatform) + return nil +} diff --git a/cli/src/cmd/aiworkspace/llmproxy/get.go b/cli/src/cmd/aiworkspace/llmproxy/get.go new file mode 100644 index 0000000000..795a72fe2b --- /dev/null +++ b/cli/src/cmd/aiworkspace/llmproxy/get.go @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + GetCmdLiteral = "get" + GetCmdExample = `# List all LLM proxies in a project +ap ai-workspace app-llm-proxy get --project-id + +# Get a single LLM proxy by ID +ap ai-workspace app-llm-proxy get --id wso2-openai-proxy + +# List using a specific AI workspace with pagination +ap ai-workspace app-llm-proxy get --project-id --limit 50 --offset 0 --display-name my-workspace --platform eu` +) + +var ( + getID string + getProjectID string + getLimit string + getOffset string + getName string + getPlatform string + getInsecure bool +) + +var getCmd = &cobra.Command{ + Use: GetCmdLiteral, + Short: "Get one or all LLM proxies from the AI workspace", + Long: "Retrieve LLM proxies from the WSO2 API Platform AI workspace. With --id a single proxy is fetched by its identifier; without it all proxies in the project are listed (--project-id is required for listing).", + Example: GetCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGetCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(getCmd, utils.FlagID, &getID, "", "LLM proxy ID (omit to list all)") + utils.AddStringFlag(getCmd, utils.FlagProjectID, &getProjectID, "", "Project ID (required when listing)") + utils.AddStringFlag(getCmd, utils.FlagLimit, &getLimit, "", "Maximum number of proxies to return when listing") + utils.AddStringFlag(getCmd, utils.FlagOffset, &getOffset, "", "Number of proxies to skip when listing") + utils.AddStringFlag(getCmd, utils.FlagName, &getName, "", "AI workspace display name") + utils.AddStringFlag(getCmd, utils.FlagPlatform, &getPlatform, "", "Platform name") + getCmd.Flags().BoolVar(&getInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runGetCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, getName, getPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, getInsecure) + + var path, action string + if id := strings.TrimSpace(getID); id != "" { + // Fetching a single proxy takes only the id path parameter. + path = aiworkspace.ProxyByIDPath(id) + action = "get llm proxy" + } else { + projectID := strings.TrimSpace(getProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required when listing proxies (use --id to get a single proxy)") + } + path = aiworkspace.ProxyListPath(projectID, aiworkspace.ListQuery{Limit: getLimit, Offset: getOffset}) + action = "list llm proxies" + } + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError(action, err, getInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiworkspace/llmproxy/list.go b/cli/src/cmd/aiworkspace/llmproxy/list.go new file mode 100644 index 0000000000..88d6c2346a --- /dev/null +++ b/cli/src/cmd/aiworkspace/llmproxy/list.go @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all LLM proxies in a project +ap ai-workspace app-llm-proxy list --project-id + +# List with pagination +ap ai-workspace app-llm-proxy list --project-id --limit 50 --offset 0 + +# List using a specific AI workspace +ap ai-workspace app-llm-proxy list --project-id --display-name my-workspace --platform eu` +) + +var ( + listProjectID string + listLimit string + listOffset string + listName string + listPlatform string + listInsecure bool +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all LLM proxies in a project", + Long: "Retrieves all LLM proxies for a given project from the WSO2 API Platform AI workspace.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(listCmd, utils.FlagProjectID, &listProjectID, "", "Project ID (required)") + utils.AddStringFlag(listCmd, utils.FlagLimit, &listLimit, "", "Maximum number of proxies to return") + utils.AddStringFlag(listCmd, utils.FlagOffset, &listOffset, "", "Number of proxies to skip") + utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "AI workspace display name") + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runListCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + projectID := strings.TrimSpace(listProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required (use --project-id)") + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, listName, listPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, listInsecure) + path := aiworkspace.ProxyListPath(projectID, aiworkspace.ListQuery{Limit: listLimit, Offset: listOffset}) + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError("list llm proxies", err, listInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiworkspace/llmproxy/root.go b/cli/src/cmd/aiworkspace/llmproxy/root.go new file mode 100644 index 0000000000..90293199d7 --- /dev/null +++ b/cli/src/cmd/aiworkspace/llmproxy/root.go @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package llmproxy + +import ( + "github.com/spf13/cobra" +) + +const ( + LLMProxyCmdLiteral = "app-llm-proxy" + LLMProxyCmdExample = `# List LLM proxies in a project on the AI workspace +ap ai-workspace app-llm-proxy list --project-id + +# Create or update a proxy from a project with: +# ap ai-workspace apply --project-id +# ap ai-workspace edit --project-id ` +) + +// LLMProxyCmd is the parent command for LLM proxy operations. +var LLMProxyCmd = &cobra.Command{ + Use: LLMProxyCmdLiteral, + Short: "Manage LLM proxies on the AI workspace", + Long: "This command allows you to manage LLM proxies on the WSO2 API Platform AI workspace.", + Example: LLMProxyCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + LLMProxyCmd.AddCommand(listCmd) + LLMProxyCmd.AddCommand(getCmd) + LLMProxyCmd.AddCommand(deleteCmd) +} diff --git a/cli/src/cmd/aiworkspace/mcpproxy/delete.go b/cli/src/cmd/aiworkspace/mcpproxy/delete.go new file mode 100644 index 0000000000..596c10b8bd --- /dev/null +++ b/cli/src/cmd/aiworkspace/mcpproxy/delete.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + DeleteCmdLiteral = "delete" + DeleteCmdExample = `# Delete an MCP proxy by ID using the active AI workspace +ap ai-workspace mcp-proxy delete --id bijira-mcp-everything + +# Delete using a specific AI workspace +ap ai-workspace mcp-proxy delete --id bijira-mcp-everything --display-name my-workspace --platform eu` +) + +var ( + deleteID string + deleteName string + deletePlatform string + deleteInsecure bool +) + +var deleteCmd = &cobra.Command{ + Use: DeleteCmdLiteral, + Short: "Delete an MCP proxy from the AI workspace", + Long: "Delete an MCP proxy from the WSO2 API Platform AI workspace by its identifier.", + Example: DeleteCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runDeleteCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(deleteCmd, utils.FlagID, &deleteID, "", "MCP proxy ID (required)") + utils.AddStringFlag(deleteCmd, utils.FlagName, &deleteName, "", "AI workspace display name") + utils.AddStringFlag(deleteCmd, utils.FlagPlatform, &deletePlatform, "", "Platform name") + deleteCmd.Flags().BoolVar(&deleteInsecure, "insecure", false, "Skip TLS certificate verification") + _ = deleteCmd.MarkFlagRequired(utils.FlagID) +} + +func runDeleteCommand() error { + id := strings.TrimSpace(deleteID) + if id == "" { + return fmt.Errorf("MCP proxy ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, resolvedPlatform, err := aiworkspace.ResolveAIWorkspace(cfg, deleteName, deletePlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, deleteInsecure) + resp, err := client.Delete(aiworkspace.MCPProxyByIDPath(id)) + if err != nil { + return aiworkspace.WrapRequestError("delete mcp proxy", err, deleteInsecure) + } + resp.Body.Close() + + fmt.Printf("MCP proxy %q deleted from ai-workspace %s (platform: %s)\n", id, aiWorkspace.Name, resolvedPlatform) + return nil +} diff --git a/cli/src/cmd/aiworkspace/mcpproxy/get.go b/cli/src/cmd/aiworkspace/mcpproxy/get.go new file mode 100644 index 0000000000..c454c18475 --- /dev/null +++ b/cli/src/cmd/aiworkspace/mcpproxy/get.go @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + GetCmdLiteral = "get" + GetCmdExample = `# List all MCP proxies in a project +ap ai-workspace mcp-proxy get --project-id + +# Get a single MCP proxy by ID +ap ai-workspace mcp-proxy get --id bijira-mcp-everything + +# List using a specific AI workspace with pagination +ap ai-workspace mcp-proxy get --project-id --limit 50 --offset 0 --display-name my-workspace --platform eu` +) + +var ( + getID string + getProjectID string + getLimit string + getOffset string + getName string + getPlatform string + getInsecure bool +) + +var getCmd = &cobra.Command{ + Use: GetCmdLiteral, + Short: "Get one or all MCP proxies from the AI workspace", + Long: "Retrieve MCP proxies from the WSO2 API Platform AI workspace. With --id a single proxy is fetched by its identifier; without it all proxies in the project are listed (--project-id is required for listing).", + Example: GetCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGetCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(getCmd, utils.FlagID, &getID, "", "MCP proxy ID (omit to list all)") + utils.AddStringFlag(getCmd, utils.FlagProjectID, &getProjectID, "", "Project ID (required when listing)") + utils.AddStringFlag(getCmd, utils.FlagLimit, &getLimit, "", "Maximum number of proxies to return when listing") + utils.AddStringFlag(getCmd, utils.FlagOffset, &getOffset, "", "Number of proxies to skip when listing") + utils.AddStringFlag(getCmd, utils.FlagName, &getName, "", "AI workspace display name") + utils.AddStringFlag(getCmd, utils.FlagPlatform, &getPlatform, "", "Platform name") + getCmd.Flags().BoolVar(&getInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runGetCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, getName, getPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, getInsecure) + + var path, action string + if id := strings.TrimSpace(getID); id != "" { + // Fetching a single proxy takes only the id path parameter. + path = aiworkspace.MCPProxyByIDPath(id) + action = "get mcp proxy" + } else { + projectID := strings.TrimSpace(getProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required when listing proxies (use --id to get a single proxy)") + } + path = aiworkspace.MCPProxyListPath(projectID, aiworkspace.ListQuery{Limit: getLimit, Offset: getOffset}) + action = "list mcp proxies" + } + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError(action, err, getInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiworkspace/mcpproxy/list.go b/cli/src/cmd/aiworkspace/mcpproxy/list.go new file mode 100644 index 0000000000..9479d9e78c --- /dev/null +++ b/cli/src/cmd/aiworkspace/mcpproxy/list.go @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/aiworkspace" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all MCP proxies in a project +ap ai-workspace mcp-proxy list --project-id + +# List with pagination +ap ai-workspace mcp-proxy list --project-id --limit 50 --offset 0 + +# List using a specific AI workspace +ap ai-workspace mcp-proxy list --project-id --display-name my-workspace --platform eu` +) + +var ( + listProjectID string + listLimit string + listOffset string + listName string + listPlatform string + listInsecure bool +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all MCP proxies in a project", + Long: "Retrieves all MCP proxies for a given project from the WSO2 API Platform AI workspace.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(listCmd, utils.FlagProjectID, &listProjectID, "", "Project ID (required)") + utils.AddStringFlag(listCmd, utils.FlagLimit, &listLimit, "", "Maximum number of proxies to return") + utils.AddStringFlag(listCmd, utils.FlagOffset, &listOffset, "", "Number of proxies to skip") + utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "AI workspace display name") + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") +} + +func runListCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + projectID := strings.TrimSpace(listProjectID) + if projectID == "" { + return fmt.Errorf("project ID is required (use --project-id)") + } + + aiWorkspace, _, err := aiworkspace.ResolveAIWorkspace(cfg, listName, listPlatform) + if err != nil { + return err + } + + client := aiworkspace.NewClientWithOptions(aiWorkspace, listInsecure) + path := aiworkspace.MCPProxyListPath(projectID, aiworkspace.ListQuery{Limit: listLimit, Offset: listOffset}) + + resp, err := client.Get(path) + if err != nil { + return aiworkspace.WrapRequestError("list mcp proxies", err, listInsecure) + } + + return aiworkspace.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/aiworkspace/mcpproxy/root.go b/cli/src/cmd/aiworkspace/mcpproxy/root.go new file mode 100644 index 0000000000..a06cac9d3a --- /dev/null +++ b/cli/src/cmd/aiworkspace/mcpproxy/root.go @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcpproxy + +import ( + "github.com/spf13/cobra" +) + +const ( + MCPProxyCmdLiteral = "mcp-proxy" + MCPProxyCmdExample = `# List MCP proxies in a project on the AI workspace +ap ai-workspace mcp-proxy list --project-id + +# Create or update an MCP proxy from a project with: +# ap ai-workspace apply --project-id +# ap ai-workspace edit --project-id ` +) + +// MCPProxyCmd is the parent command for MCP proxy operations. +var MCPProxyCmd = &cobra.Command{ + Use: MCPProxyCmdLiteral, + Short: "Manage MCP proxies on the AI workspace", + Long: "This command allows you to manage MCP proxies on the WSO2 API Platform AI workspace.", + Example: MCPProxyCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + MCPProxyCmd.AddCommand(listCmd) + MCPProxyCmd.AddCommand(getCmd) + MCPProxyCmd.AddCommand(deleteCmd) +} diff --git a/cli/src/cmd/aiworkspace/remove.go b/cli/src/cmd/aiworkspace/remove.go new file mode 100644 index 0000000000..75299f143a --- /dev/null +++ b/cli/src/cmd/aiworkspace/remove.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + RemoveCmdLiteral = "remove" + RemoveCmdExample = `# Remove an AI workspace +ap ai-workspace remove --display-name my-workspace` +) + +var ( + removeName string + removePlatform string +) + +var removeCmd = &cobra.Command{ + Use: RemoveCmdLiteral, + Short: "Remove an AI workspace", + Long: "Remove an AI workspace configuration from the ap config file.", + Example: RemoveCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runRemoveCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(removeCmd, utils.FlagName, &removeName, "", "Name of the AI workspace to remove (required)") + utils.AddStringFlag(removeCmd, utils.FlagPlatform, &removePlatform, "", "Platform name") + removeCmd.MarkFlagRequired(utils.FlagName) +} + +func runRemoveCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + resolvedPlatform := cfg.ResolvePlatform(removePlatform) + if err := cfg.RemoveAIWorkspaceFromPlatform(resolvedPlatform, removeName); err != nil { + return err + } + + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("AI workspace removed successfully.") + + return nil +} diff --git a/cli/src/cmd/aiworkspace/root.go b/cli/src/cmd/aiworkspace/root.go new file mode 100644 index 0000000000..900c706a5c --- /dev/null +++ b/cli/src/cmd/aiworkspace/root.go @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/cmd/aiworkspace/llmprovider" + "github.com/wso2/api-platform/cli/cmd/aiworkspace/llmproxy" + "github.com/wso2/api-platform/cli/cmd/aiworkspace/mcpproxy" +) + +const ( + AiWSCmdLiteral = "ai-workspace" + AiWSCmdExample = `# Add a new AI-Workspace +ap ai-workspace add --display-name my-portal --platform eu --server https://ai-workspace.example.com --auth api-key` +) + +var AiWSCmd = &cobra.Command{ + Use: AiWSCmdLiteral, + Short: "Execute AI-Workspace operations", + Long: "This command allows you to execute various operations related to AI-Workspaces.", + Example: AiWSCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + AiWSCmd.AddCommand(addCmd) + AiWSCmd.AddCommand(listCmd) + AiWSCmd.AddCommand(removeCmd) + AiWSCmd.AddCommand(useCmd) + AiWSCmd.AddCommand(currentCmd) + AiWSCmd.AddCommand(buildCmd) + AiWSCmd.AddCommand(applyCmd) + AiWSCmd.AddCommand(editCmd) + AiWSCmd.AddCommand(llmprovider.LLMProviderCmd) + AiWSCmd.AddCommand(llmproxy.LLMProxyCmd) + AiWSCmd.AddCommand(mcpproxy.MCPProxyCmd) +} diff --git a/cli/src/cmd/aiworkspace/use.go b/cli/src/cmd/aiworkspace/use.go new file mode 100644 index 0000000000..8f566979ae --- /dev/null +++ b/cli/src/cmd/aiworkspace/use.go @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiws + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + UseCmdLiteral = "use" + UseCmdExample = `# Set my-workspace as the active AI workspace +ap ai-workspace use --display-name my-workspace` +) + +var ( + useName string + usePlatform string +) + +var useCmd = &cobra.Command{ + Use: UseCmdLiteral, + Short: "Set the active AI workspace", + Long: "Set the active AI workspace that will be used by default for operations.", + Example: UseCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runUseCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(useCmd, utils.FlagName, &useName, "", "Name of the AI workspace to use (required)") + utils.AddStringFlag(useCmd, utils.FlagPlatform, &usePlatform, "", "Platform name") + useCmd.MarkFlagRequired(utils.FlagName) +} + +func runUseCommand() error { + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + resolvedPlatform := cfg.ResolvePlatform(usePlatform) + aiWorkspace, err := cfg.GetAIWorkspaceFromPlatform(resolvedPlatform, useName) + if err != nil { + return err + } + + if err := cfg.SetActiveAIWorkspaceForPlatform(resolvedPlatform, useName); err != nil { + return err + } + + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("AI workspace set to %s (platform: %s, auth: %s).\n", useName, resolvedPlatform, aiWorkspace.Auth.Type) + + hasEnvCreds := false + hasConfigCreds := false + message := "" + + switch aiWorkspace.Auth.Type { + case utils.AuthTypeBasic: + hasEnvCreds = os.Getenv(utils.EnvAIWorkspaceUsername) != "" && os.Getenv(utils.EnvAIWorkspacePassword) != "" + hasConfigCreds = aiWorkspace.Auth.Username != "" && aiWorkspace.Auth.Password != "" + message = fmt.Sprintf("\nBasic authentication requires the following environment variables:\n %s\n %s\n", utils.EnvAIWorkspaceUsername, utils.EnvAIWorkspacePassword) + case utils.AuthTypeOAuth: + hasEnvCreds = os.Getenv(utils.EnvAIWorkspaceToken) != "" + hasConfigCreds = aiWorkspace.Auth.Token != "" + message = fmt.Sprintf("\nOAuth authentication requires the following environment variable:\n %s\n", utils.EnvAIWorkspaceToken) + case utils.AuthTypeAPIKey: + hasEnvCreds = os.Getenv(utils.EnvAIWorkspaceAPIKey) != "" + hasConfigCreds = aiWorkspace.Auth.APIKey != "" + message = fmt.Sprintf("\nAPI key authentication requires the following environment variable:\n %s\n", utils.EnvAIWorkspaceAPIKey) + } + + if !hasEnvCreds && !hasConfigCreds { + fmt.Print(message) + } else if hasConfigCreds && !hasEnvCreds { + fmt.Println("Using credentials from configuration.") + } else if hasEnvCreds { + fmt.Println("Using credentials from environment variables.") + } + + return nil +} diff --git a/cli/src/cmd/apiproject/init.go b/cli/src/cmd/apiproject/init.go deleted file mode 100644 index d713219578..0000000000 --- a/cli/src/cmd/apiproject/init.go +++ /dev/null @@ -1,314 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package apiproject - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/spf13/cobra" - "github.com/wso2/api-platform/cli/utils" -) - -const ( - InitCmdLiteral = "init" - InitCmdExample = `# Initialize a new API project -ap apiproject init --display-name foo-api --type rest --version 1.0 --context /foo - -# Add a API project fully interactively cobra -ap apiproject init` -) - -var displayName string -var apiType string -var apiVersion string -var apiContext string -var addNoInteractive bool - -var initCmd = &cobra.Command{ - Use: InitCmdLiteral, - Short: "Initialize a new API project", - Long: "Initialize a new API project with the specified parameters.", - Example: InitCmdExample, - Run: func(cmd *cobra.Command, args []string) { - if err := runInitCommand(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func init() { - utils.AddStringFlag(initCmd, utils.FlagName, &displayName, "", "Display name of the API") - utils.AddStringFlag(initCmd, utils.FlagType, &apiType, "", "Type of the API") - utils.AddStringFlag(initCmd, utils.FlagVersion, &apiVersion, "", "Version of the API") - utils.AddStringFlag(initCmd, utils.FlagContext, &apiContext, "", "Context of the API") - utils.AddBoolFlag(initCmd, utils.FlagNoInteractive, &addNoInteractive, false, "Skip interactive prompts") -} - -func runInitCommand() error { - var err error - if !addNoInteractive { - if strings.TrimSpace(displayName) == "" { - displayName, err = utils.PromptInput("Enter API Project name: ") - if err != nil { - return fmt.Errorf("Failed to read display name: %w", err) - } - } - if strings.TrimSpace(apiType) == "" { - apiType, err = utils.PromptInput("Enter API type (e.g., rest): ") - if err != nil { - return fmt.Errorf("Failed to read API type: %w", err) - } - } - if strings.TrimSpace(apiVersion) == "" { - apiVersion, err = utils.PromptInput("Enter API version: ") - if err != nil { - return fmt.Errorf("Failed to read API version: %w", err) - } - } - if strings.TrimSpace(apiContext) == "" { - apiContext, err = utils.PromptInput("Enter API context (e.g., /foo): ") - if err != nil { - return fmt.Errorf("Failed to read API context: %w", err) - } - } - } - - displayName = strings.TrimSpace(displayName) - apiType = strings.ToLower(strings.TrimSpace(apiType)) - apiVersion = strings.TrimSpace(apiVersion) - apiContext = strings.TrimSpace(apiContext) - - if displayName == "" { - return fmt.Errorf("display name is required") - } - if apiType == "" { - return fmt.Errorf("API type is required") - } - if apiVersion == "" { - return fmt.Errorf("API version is required") - } - if apiContext == "" { - return fmt.Errorf("API context is required") - } - - if apiType != utils.APITypeREST { - return fmt.Errorf("unsupported API type: %s", apiType) - } - - if err := buildDirectoryStructure(displayName, apiType, apiVersion, apiContext); err != nil { - return err - } - - fmt.Printf("API project created at .%c%s\n", os.PathSeparator, displayName) - return nil -} - -func buildDirectoryStructure(name, apiType, version, context string) error { - if apiType != utils.APITypeREST { - return fmt.Errorf("API project scaffolding currently supports only %s APIs", utils.APITypeREST) - } - - projectDirName, err := validateProjectDirectoryName(name) - if err != nil { - return err - } - - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to determine current working directory: %w", err) - } - - projectRoot := filepath.Join(cwd, projectDirName) - if _, err := os.Stat(projectRoot); err == nil { - return fmt.Errorf("project directory already exists: %s", projectRoot) - } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to inspect project directory: %w", err) - } - - directories := []string{ - filepath.Join(projectRoot, ".api-platform"), - filepath.Join(projectRoot, "docs"), - filepath.Join(projectRoot, "tests"), - } - for _, dir := range directories { - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - } - - resourceName := buildResourceName(name, version) - files := map[string]string{ - filepath.Join(projectRoot, ".api-platform", "config.yaml"): buildConfigYAML(), - filepath.Join(projectRoot, "api.yaml"): buildAPIYAML(resourceName), - filepath.Join(projectRoot, "gateway.yaml"): buildGatewayYAML(resourceName, name, version, context), - filepath.Join(projectRoot, "definition.yaml"): buildDefinitionYAML(name, version, context), - } - for path, content := range files { - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", path, err) - } - } - - return nil -} - -func validateProjectDirectoryName(name string) (string, error) { - name = strings.TrimSpace(name) - if name == "" { - return "", fmt.Errorf("display name is required") - } - - if name == "." || name == ".." { - return "", fmt.Errorf("display name cannot be %q", name) - } - - if strings.ContainsRune(name, os.PathSeparator) { - return "", fmt.Errorf("display name cannot contain path separators") - } - - if os.PathSeparator != '/' && strings.ContainsRune(name, '/') { - return "", fmt.Errorf("display name cannot contain path separators") - } - - return name, nil -} - -func buildResourceName(name, version string) string { - normalized := strings.ToLower(strings.TrimSpace(name)) - normalized = strings.ReplaceAll(normalized, "_", "-") - normalized = strings.ReplaceAll(normalized, " ", "-") - - invalidChars := regexp.MustCompile(`[^a-z0-9.-]+`) - repeatedHyphens := regexp.MustCompile(`-+`) - - normalized = invalidChars.ReplaceAllString(normalized, "-") - normalized = repeatedHyphens.ReplaceAllString(normalized, "-") - normalized = strings.Trim(normalized, "-.") - - if normalized == "" { - normalized = "api" - } - - return fmt.Sprintf("%s-%s", normalized, version) -} - -func buildConfigYAML() string { - return `version: 1.0.0 - -# Default file paths (can be customized) -filePaths: - deploymentArtifact: ./gateway.yaml - apiMetadata: ./api.yaml - apiDefinition: ./definition.yaml - docs: ./docs - tests: ./tests - -# Governance rulesets for design-time validation -governanceRulesets: [] - -# Auto-sync configuration for vscode plugin -autoSync: - gatewayArtifactFromDefinition: true # Auto-generate gateway.yaml when definition.yaml changes -` -} - -func buildAPIYAML(resourceName string) string { - return fmt.Sprintf(`apiVersion: management.api-platform.wso2.com/v1 -kind: Api -metadata: - name: %q -spec: - description: "" - gatewayType: wso2/api-platform - status: PUBLISHED - referenceID: "" - tags: [] - labels: [] - businessInformation: - businessOwner: "" - businessOwnerEmail: "" - technicalOwner: "" - technicalOwnerEmail: "" - endpoints: - sandboxUrl: "" - productionUrl: "" -`, resourceName) -} - -func buildGatewayYAML(resourceName, displayName, version, context string) string { - return fmt.Sprintf(`apiVersion: gateway.api-platform.wso2.com/v1 -kind: RestApi -metadata: - name: %q -spec: - displayName: %q - version: %q - context: %q - upstream: - main: - url: "http://sample-backend.org:9080" # Change this to your backend URL - operations: - - path: /* - method: GET - - path: /* - method: POST - - path: /* - method: PUT - - path: /* - method: DELETE - - path: /* - method: OPTIONS -`, resourceName, displayName, version, context) -} - -func buildDefinitionYAML(displayName, version, context string) string { - return fmt.Sprintf(`openapi: 3.0.3 -info: - title: %q - version: %q -servers: - - url: %q -paths: - "/*": - get: - responses: - "200": - description: OK - post: - responses: - "200": - description: OK - put: - responses: - "200": - description: OK - delete: - responses: - "200": - description: OK - options: - responses: - "200": - description: OK -`, displayName, version, context) -} diff --git a/cli/src/cmd/apiproject/init_test.go b/cli/src/cmd/apiproject/init_test.go deleted file mode 100644 index 8991b0baf1..0000000000 --- a/cli/src/cmd/apiproject/init_test.go +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package apiproject - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/wso2/api-platform/cli/utils" -) - -func TestBuildDirectoryStructureCreatesExpectedFiles(t *testing.T) { - tempDir := t.TempDir() - - originalWD, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get working directory: %v", err) - } - t.Cleanup(func() { - if chdirErr := os.Chdir(originalWD); chdirErr != nil { - t.Fatalf("failed to restore working directory: %v", chdirErr) - } - }) - - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change working directory: %v", err) - } - - if err := buildDirectoryStructure("FooAPI", utils.APITypeREST, "1.0.0", "/petstore"); err != nil { - t.Fatalf("buildDirectoryStructure returned an error: %v", err) - } - - projectRoot := filepath.Join(tempDir, "FooAPI") - expectedPaths := []string{ - filepath.Join(projectRoot, ".api-platform", "config.yaml"), - filepath.Join(projectRoot, "api.yaml"), - filepath.Join(projectRoot, "gateway.yaml"), - filepath.Join(projectRoot, "definition.yaml"), - filepath.Join(projectRoot, "docs"), - filepath.Join(projectRoot, "tests"), - } - - for _, expectedPath := range expectedPaths { - if _, err := os.Stat(expectedPath); err != nil { - t.Fatalf("expected path to exist: %s (%v)", expectedPath, err) - } - } - - gatewayYAML, err := os.ReadFile(filepath.Join(projectRoot, "gateway.yaml")) - if err != nil { - t.Fatalf("failed to read gateway.yaml: %v", err) - } - if !strings.Contains(string(gatewayYAML), `displayName: "FooAPI"`) { - t.Fatalf("gateway.yaml does not contain the expected display name") - } - if !strings.Contains(string(gatewayYAML), `context: "/petstore"`) { - t.Fatalf("gateway.yaml does not contain the expected context") - } - - definitionYAML, err := os.ReadFile(filepath.Join(projectRoot, "definition.yaml")) - if err != nil { - t.Fatalf("failed to read definition.yaml: %v", err) - } - if !strings.Contains(string(definitionYAML), `"/*":`) { - t.Fatalf("definition.yaml does not contain the wildcard path") - } - if !strings.Contains(string(definitionYAML), "options:") { - t.Fatalf("definition.yaml does not contain the OPTIONS operation") - } -} diff --git a/cli/src/cmd/devportal/apikey/commands_test.go b/cli/src/cmd/devportal/apikey/commands_test.go index b08eb546df..e8d7238dbe 100644 --- a/cli/src/cmd/devportal/apikey/commands_test.go +++ b/cli/src/cmd/devportal/apikey/commands_test.go @@ -36,7 +36,7 @@ func TestRunGenerateCommand_SendsPayload(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/api-keys/generate" { + if req.URL.Path != "/o/org-1/devportal/v1/api-keys/generate" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -75,7 +75,7 @@ func TestRunGetCommand_ListsAPIKeys(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/api-keys" { + if req.URL.Path != "/o/org-1/devportal/v1/api-keys" { t.Fatalf("unexpected request path %s", req.URL.Path) } if got := req.URL.Query().Get("apiId"); got != "api-1" { @@ -105,7 +105,7 @@ func TestRunRegenerateCommand_PostsToRegenerate(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/api-keys/key-1/regenerate" { + if req.URL.Path != "/o/org-1/devportal/v1/api-keys/key-1/regenerate" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -132,7 +132,7 @@ func TestRunRevokeCommand_PostsToRevoke(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/api-keys/key-1/revoke" { + if req.URL.Path != "/o/org-1/devportal/v1/api-keys/key-1/revoke" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.WriteHeader(http.StatusNoContent) diff --git a/cli/src/cmd/devportal/apikey/generate.go b/cli/src/cmd/devportal/apikey/generate.go index a0ff46f48e..dfe3c4faef 100644 --- a/cli/src/cmd/devportal/apikey/generate.go +++ b/cli/src/cmd/devportal/apikey/generate.go @@ -22,7 +22,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "os" "regexp" "strings" @@ -70,8 +69,8 @@ type apiKeyRequest struct { } var generateCmd = &cobra.Command{ - Use: GenerateCmdLiteral, - Short: "Generate a DevPortal API key", + Use: GenerateCmdLiteral, + Short: "Generate a DevPortal API key", Long: "Generates an API key for an API in the selected DevPortal. The plaintext secret is " + "returned once in the response and never persisted. Run without the required flags to be " + "prompted interactively.", @@ -123,7 +122,7 @@ func runGenerateCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, generateInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/api-keys/generate", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "api-keys/generate") resp, err := client.PostJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("generate API key", err, generateInsecure) diff --git a/cli/src/cmd/devportal/apikey/get.go b/cli/src/cmd/devportal/apikey/get.go index 166996e539..b59ab9de67 100644 --- a/cli/src/cmd/devportal/apikey/get.go +++ b/cli/src/cmd/devportal/apikey/get.go @@ -93,7 +93,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/api-keys?apiId=%s", url.PathEscape(orgID), url.QueryEscape(apiID)) + path := internaldevportal.OrgScopedPath(orgID, "api-keys?apiId="+url.QueryEscape(apiID)) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("get API keys", err, getInsecure) diff --git a/cli/src/cmd/devportal/apikey/regenerate.go b/cli/src/cmd/devportal/apikey/regenerate.go index 7bba2b096d..f52c0441f4 100644 --- a/cli/src/cmd/devportal/apikey/regenerate.go +++ b/cli/src/cmd/devportal/apikey/regenerate.go @@ -95,7 +95,7 @@ func runRegenerateCommand() error { client := internaldevportal.NewClientWithOptions(devPortal, regenerateInsecure) baseURL := strings.TrimSuffix(devPortal.URL, "/") - path := fmt.Sprintf("/devportal/organizations/%s/api-keys/%s/regenerate", url.PathEscape(orgID), url.PathEscape(apiKeyID)) + path := internaldevportal.OrgScopedPath(orgID, "api-keys/"+url.PathEscape(apiKeyID)+"/regenerate") req, err := http.NewRequest(http.MethodPost, baseURL+path, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) diff --git a/cli/src/cmd/devportal/apikey/revoke.go b/cli/src/cmd/devportal/apikey/revoke.go index dee00159ca..ae6a22a6d9 100644 --- a/cli/src/cmd/devportal/apikey/revoke.go +++ b/cli/src/cmd/devportal/apikey/revoke.go @@ -95,7 +95,7 @@ func runRevokeCommand() error { client := internaldevportal.NewClientWithOptions(devPortal, revokeInsecure) baseURL := strings.TrimSuffix(devPortal.URL, "/") - path := fmt.Sprintf("/devportal/organizations/%s/api-keys/%s/revoke", url.PathEscape(orgID), url.PathEscape(apiKeyID)) + path := internaldevportal.OrgScopedPath(orgID, "api-keys/"+url.PathEscape(apiKeyID)+"/revoke") req, err := http.NewRequest(http.MethodPost, baseURL+path, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) diff --git a/cli/src/cmd/devportal/application/commands_test.go b/cli/src/cmd/devportal/application/commands_test.go index 9217ca0727..3e428757cb 100644 --- a/cli/src/cmd/devportal/application/commands_test.go +++ b/cli/src/cmd/devportal/application/commands_test.go @@ -37,7 +37,7 @@ func TestRunCreateCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/applications" { + if req.URL.Path != "/o/org-1/devportal/v1/applications" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -109,7 +109,7 @@ func TestRunUpdateCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPut { t.Fatalf("expected PUT request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/applications/app-1" { + if req.URL.Path != "/o/org-1/devportal/v1/applications/app-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -145,13 +145,13 @@ func TestRunGetCommand_ListAllAndSingle(t *testing.T) { server := testutil.NewDevPortalServer(t, func(w http.ResponseWriter, req *http.Request) { switch req.URL.Path { - case "/devportal/organizations/org-1/applications": + case "/o/org-1/devportal/v1/applications": if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"applicationId":"app-1"}]`)) - case "/devportal/organizations/org-1/applications/app-1": + case "/o/org-1/devportal/v1/applications/app-1": if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } @@ -197,7 +197,7 @@ func TestRunDeleteCommand_SendsDelete(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/applications/app-1" { + if req.URL.Path != "/o/org-1/devportal/v1/applications/app-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") diff --git a/cli/src/cmd/devportal/application/create.go b/cli/src/cmd/devportal/application/create.go index c8620ac09e..f8fdd4127c 100644 --- a/cli/src/cmd/devportal/application/create.go +++ b/cli/src/cmd/devportal/application/create.go @@ -22,7 +22,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "os" "strings" @@ -110,7 +109,7 @@ func runCreateCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, createInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/applications", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "applications") resp, err := client.PostJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("create application", err, createInsecure) diff --git a/cli/src/cmd/devportal/application/delete.go b/cli/src/cmd/devportal/application/delete.go index 15b75f90ce..cdc196bedb 100644 --- a/cli/src/cmd/devportal/application/delete.go +++ b/cli/src/cmd/devportal/application/delete.go @@ -93,7 +93,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/applications/%s", url.PathEscape(orgID), url.PathEscape(appID)) + path := internaldevportal.OrgScopedPath(orgID, "applications/"+url.PathEscape(appID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete application", err, deleteInsecure) diff --git a/cli/src/cmd/devportal/application/get.go b/cli/src/cmd/devportal/application/get.go index 82f84ed7f7..dcbdc349b9 100644 --- a/cli/src/cmd/devportal/application/get.go +++ b/cli/src/cmd/devportal/application/get.go @@ -90,7 +90,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/applications", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "applications") appID := strings.TrimSpace(getAppID) if appID != "" { path = fmt.Sprintf("%s/%s", path, url.PathEscape(appID)) diff --git a/cli/src/cmd/devportal/application/update.go b/cli/src/cmd/devportal/application/update.go index 139164c2e8..6a48be94b1 100644 --- a/cli/src/cmd/devportal/application/update.go +++ b/cli/src/cmd/devportal/application/update.go @@ -110,7 +110,7 @@ func runUpdateCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, updateInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/applications/%s", url.PathEscape(orgID), url.PathEscape(appID)) + path := internaldevportal.OrgScopedPath(orgID, "applications/"+url.PathEscape(appID)) resp, err := client.PutJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("update application", err, updateInsecure) diff --git a/cli/src/cmd/devportal/build.go b/cli/src/cmd/devportal/build.go index db28b6be79..fbd660cf43 100644 --- a/cli/src/cmd/devportal/build.go +++ b/cli/src/cmd/devportal/build.go @@ -18,6 +18,7 @@ package devportal import ( + "bytes" "fmt" "io" "os" @@ -25,27 +26,44 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/project" "github.com/wso2/api-platform/cli/utils" "gopkg.in/yaml.v3" ) +// Standard names every devportal artifact takes inside the archive, regardless +// of the source paths configured in .api-platform/config.yaml. Standardizing +// here gives the published zip a predictable layout for the devportal to +// unpack, while letting authors keep arbitrary on-disk filenames. +const ( + archiveMetadataFileName = "devportal.yaml" + archiveDefinitionFileName = "definition.yaml" + archiveDocsDirName = "docs" + archiveContentDirName = "content" +) + const ( BuildCmdLiteral = "build" - BuildCmdExample = `# Build the api project for devportal -ap devportal build #Build the project in current directory + BuildCmdExample = `# Build the project in the current directory for devportal +ap devportal build -# Build the project in a specified directory -ap devportal build -f /path/to/project` +# Build a project in a specified directory +ap devportal build -f /path/to/project + +# Build and stamp a reference ID into each devportal manifest (declarative mode) +ap devportal build -f /path/to/project --reference-id 1ba42a09-45c0-40f8-a1bf-e4aa7cde1575 --gateway-type wso2/api-platform` ) var ( - buildProjectDir string + buildProjectDir string + buildReferenceID string + buildGatewayType string ) var buildCmd = &cobra.Command{ Use: BuildCmdLiteral, - Short: "Build the API project for devportal", - Long: "Build the API project located in the specified directory (or current directory if not specified) for deployment to the devportal.", + Short: "Build the project for devportal", + Long: "Build the project located in the specified directory (or current directory if not specified) for deployment to the devportal.", Example: BuildCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runBuildCommand(); err != nil { @@ -56,7 +74,9 @@ var buildCmd = &cobra.Command{ } func init() { - utils.AddStringFlag(buildCmd, utils.FlagFile, &buildProjectDir, "", "Path to the API project directory (defaults to current directory)") + utils.AddStringFlag(buildCmd, utils.FlagFile, &buildProjectDir, "", "Path to the project directory (defaults to current directory)") + utils.AddStringFlag(buildCmd, utils.FlagReferenceID, &buildReferenceID, "", "Reference ID to set under spec.referenceID in every devportal manifest") + utils.AddStringFlag(buildCmd, utils.FlagGatewayType, &buildGatewayType, "", "Gateway type to set under spec.gatewayType in every devportal manifest") } func runBuildCommand() error { @@ -71,36 +91,43 @@ func runBuildCommand() error { projectConfigDir := filepath.Join(projectRoot, ".api-platform") if _, err := os.Stat(projectConfigDir); os.IsNotExist(err) { - return fmt.Errorf("unable to find api project directory, please execute this command inside api project") + return fmt.Errorf("unable to find project directory, please execute this command inside a project") } else if err != nil { - return fmt.Errorf("failed to inspect api project directory: %w", err) + return fmt.Errorf("failed to inspect project directory: %w", err) } projectConfigPath := filepath.Join(projectConfigDir, "config.yaml") if _, err := os.Stat(projectConfigPath); os.IsNotExist(err) { - return fmt.Errorf("unable to find api project directory, please execute this command inside api project") + return fmt.Errorf("unable to find project directory, please execute this command inside a project") } else if err != nil { return fmt.Errorf("failed to inspect project config: %w", err) } - projectConfig, err := loadProjectConfig(projectConfigPath) + projectConfig, err := project.Load(projectConfigPath) if err != nil { return err } - isDevportalConfigExists := projectConfig.isDevportalConfigExists() - // create default devportal config if not exists and add to project config - if !isDevportalConfigExists { - portalConfig, err := projectConfig.createDefaultDevPortalConfig(projectRoot) + // Build only zips devportal folders; it never generates their contents + // (that is `ap devportal gen`'s job, which also registers the config). As a + // fallback, if the project config carries no devportal configs but the + // default ./devportal folder exists, register it here and persist it so the + // build can proceed. + if len(projectConfig.DevPortals) == 0 { + portalConfig, err := registerDefaultDevPortalConfig(projectRoot) if err != nil { return err } projectConfig.DevPortals = append(projectConfig.DevPortals, *portalConfig) - if err := saveProjectConfig(projectConfigPath, projectConfig); err != nil { + if err := project.Save(projectConfigPath, projectConfig); err != nil { return err } } + for i := range projectConfig.DevPortals { + normalizeDevPortalProjectConfig(&projectConfig.DevPortals[i]) + } + buildDir := filepath.Join(projectRoot, "build") if err := os.RemoveAll(buildDir); err != nil { return fmt.Errorf("failed to clean build directory: %w", err) @@ -109,7 +136,7 @@ func runBuildCommand() error { return fmt.Errorf("failed to recreate build directory: %w", err) } - zipPaths, err := buildDevPortalArchives(projectRoot, buildDir, projectConfig.DevPortals) + zipPaths, failures, err := buildDevPortalArchives(projectRoot, buildDir, projectConfig.DevPortals) if err != nil { return err } @@ -117,354 +144,210 @@ func runBuildCommand() error { for _, zipPath := range zipPaths { fmt.Printf("DevPortal build created at %s\n", zipPath) } - return nil -} - -type apiProjectConfig struct { - Version string `yaml:"version,omitempty"` - FilePaths apiProjectFilePaths `yaml:"filePaths,omitempty"` - GovernanceRulesets []string `yaml:"governanceRulesets,omitempty"` - AutoSync map[string]interface{} `yaml:"autoSync,omitempty"` - DevPortals []devPortalProjectConfig `yaml:"devportals,omitempty"` -} - -type apiProjectFilePaths struct { - DeploymentArtifact string `yaml:"deploymentArtifact,omitempty"` - APIMetadata string `yaml:"apiMetadata,omitempty"` - APIDefinition string `yaml:"apiDefinition,omitempty"` - Docs string `yaml:"docs,omitempty"` - Tests string `yaml:"tests,omitempty"` -} - -type devPortalProjectConfig struct { - Name string `yaml:"name,omitempty"` - PortalRoot string `yaml:"portalRoot,omitempty"` - FilePaths devPortalProjectPaths `yaml:"filePaths,omitempty"` -} -type devPortalProjectPaths struct { - APIMetadata string `yaml:"apiMetadata,omitempty"` - APIDefinition string `yaml:"apiDefinition,omitempty"` - Docs string `yaml:"docs,omitempty"` - Content string `yaml:"content,omitempty"` -} - -type projectAPIResource struct { - Metadata struct { - Name string `yaml:"name"` - } `yaml:"metadata"` - Spec struct { - Description string `yaml:"description"` - ReferenceID string `yaml:"referenceID"` - GatewayType string `yaml:"gatewayType"` - Status string `yaml:"status"` - Tags []string `yaml:"tags"` - Labels []string `yaml:"labels"` - BusinessInformation devPortalBusinessInformation `yaml:"businessInformation"` - Endpoints devPortalEndpoints `yaml:"endpoints"` - } `yaml:"spec"` -} - -type gatewayResource struct { - Spec struct { - DisplayName string `yaml:"displayName"` - Version string `yaml:"version"` - SubscriptionPlans []string `yaml:"subscriptionPlans"` - } `yaml:"spec"` -} - -type devPortalManifest struct { - APIVersion string `yaml:"apiVersion"` - Kind string `yaml:"kind"` - Metadata devPortalManifestMeta `yaml:"metadata"` - Spec devPortalManifestSpec `yaml:"spec"` -} + if len(failures) > 0 { + messages := make([]string, 0, len(failures)) + for _, failure := range failures { + fmt.Fprintf(os.Stderr, "DevPortal build failed for %q: %v\n", failure.name, failure.err) + messages = append(messages, failure.err.Error()) + } + return fmt.Errorf("failed to build %d of %d devportal configuration(s): %s", + len(failures), len(projectConfig.DevPortals), strings.Join(messages, "; ")) + } -type devPortalManifestMeta struct { - Name string `yaml:"name"` + return nil } -type devPortalManifestSpec struct { - DisplayName string `yaml:"displayName"` - Version string `yaml:"version"` - Description string `yaml:"description"` - Provider string `yaml:"provider"` - GatewayType string `yaml:"gatewayType"` - Status string `yaml:"status"` - ReferenceID string `yaml:"referenceID"` - Tags []string `yaml:"tags"` - Labels []string `yaml:"labels"` - SubscriptionPolicies []string `yaml:"subscriptionPolicies"` - Visibility string `yaml:"visibility"` - VisibleGroups []string `yaml:"visibleGroups"` - BusinessInformation devPortalBusinessInformation `yaml:"businessInformation"` - Endpoints devPortalEndpoints `yaml:"endpoints"` +// failedPortal records a devportal config that could not be built so the others +// can still be archived and the failures reported together. +type failedPortal struct { + name string + err error } -type devPortalBusinessInformation struct { - BusinessOwner string `yaml:"businessOwner"` - BusinessOwnerEmail string `yaml:"businessOwnerEmail"` - TechnicalOwner string `yaml:"technicalOwner"` - TechnicalOwnerEmail string `yaml:"technicalOwnerEmail"` +// defaultDevPortalConfig returns the portal config describing a project's +// default ./devportal folder. Both `gen` (when generating the folder) and +// `build` (as a fallback when no config is registered) use it so the layout +// stays in one place. +func defaultDevPortalConfig() project.PortalConfig { + return project.PortalConfig{ + Name: "default", + PortalRoot: "./devportal", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./definition.yaml", + Docs: "./docs", + Content: "./content", + }, + } } -type devPortalEndpoints struct { - SandboxURL string `yaml:"sandboxUrl"` - ProductionURL string `yaml:"productionUrl"` -} +// registerDefaultDevPortalConfig is build's fallback path: when the project +// config carries no devportal configs but the default ./devportal folder +// exists on disk, it returns the config describing that folder so build can +// archive it. The folder's contents are produced by `ap devportal gen`; build +// never generates them. It errors if the folder is missing. +func registerDefaultDevPortalConfig(projectRoot string) (*project.PortalConfig, error) { + portalConfig := defaultDevPortalConfig() -func loadProjectConfig(configPath string) (*apiProjectConfig, error) { - data, err := os.ReadFile(configPath) + portalRoot := resolveProjectPath(projectRoot, portalConfig.PortalRoot) + info, err := os.Stat(portalRoot) if err != nil { - return nil, fmt.Errorf("failed to read project config: %w", err) + if os.IsNotExist(err) { + return nil, fmt.Errorf("no devportal artifact found at %s; run 'ap devportal gen' to generate one", portalRoot) + } + return nil, fmt.Errorf("failed to inspect devportal directory: %w", err) } - - var config apiProjectConfig - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse project config: %w", err) + if !info.IsDir() { + return nil, fmt.Errorf("expected devportal artifact directory but found a file at %s", portalRoot) } - normalizeAPIProjectConfig(&config) - return &config, nil + return &portalConfig, nil } -func saveProjectConfig(configPath string, config *apiProjectConfig) error { - data, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal project config: %w", err) - } - - if err := os.WriteFile(configPath, data, 0644); err != nil { - return fmt.Errorf("failed to save project config: %w", err) +func resolveProjectPath(projectRoot, pathValue string) string { + trimmed := strings.TrimSpace(pathValue) + if trimmed == "" { + return projectRoot } - return nil -} - -func (c *apiProjectConfig) isDevportalConfigExists() bool { - if len(c.DevPortals) == 0 { - return false - } - return true + trimmed = strings.TrimPrefix(trimmed, "./") + return filepath.Join(projectRoot, filepath.Clean(trimmed)) } -func (c *apiProjectConfig) createDefaultDevPortalConfig(projectRoot string) (*devPortalProjectConfig, error) { - portalConfig := &devPortalProjectConfig{ - Name: "default", - PortalRoot: "./devportal", - FilePaths: devPortalProjectPaths{ - APIMetadata: "./devportal.yaml", - APIDefinition: "./definition.yaml", - Docs: "./docs", - Content: "./content", - }, - } - - portalRoot := resolveProjectPath(projectRoot, portalConfig.PortalRoot) - if err := os.MkdirAll(portalRoot, 0755); err != nil { - return nil, fmt.Errorf("failed to create devportal directory: %w", err) +func normalizeDevPortalProjectConfig(config *project.PortalConfig) { + if strings.TrimSpace(config.Name) == "" { + config.Name = "default" } - - definitionSource := resolveProjectPath(projectRoot, c.FilePaths.APIDefinition) - if err := ensureWithinProjectRoot(projectRoot, definitionSource, portalConfig.Name, "apiDefinition"); err != nil { - return nil, err + if strings.TrimSpace(config.PortalRoot) == "" { + config.PortalRoot = "./devportal" } - definitionTarget := filepath.Join(portalRoot, "definition.yaml") - if err := copyFile(definitionSource, definitionTarget); err != nil { - return nil, err + if strings.TrimSpace(config.FilePaths.MetadataFile) == "" { + config.FilePaths.MetadataFile = "./devportal.yaml" } - - docsSource := resolveProjectPath(projectRoot, c.FilePaths.Docs) - if err := ensureWithinProjectRoot(projectRoot, docsSource, portalConfig.Name, "docs"); err != nil { - return nil, err + if strings.TrimSpace(config.FilePaths.Definition) == "" { + config.FilePaths.Definition = "./definition.yaml" } - docsTarget := filepath.Join(portalRoot, "docs") - if err := copyDirectory(docsSource, docsTarget); err != nil { - return nil, err + if strings.TrimSpace(config.FilePaths.Docs) == "" { + config.FilePaths.Docs = "./docs" } - - contentDir := filepath.Join(portalRoot, "content") - if err := os.MkdirAll(contentDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create devportal content directory: %w", err) + if strings.TrimSpace(config.FilePaths.Content) == "" { + config.FilePaths.Content = "./content" } +} - manifest, err := buildDefaultDevPortalManifest(projectRoot, c) - if err != nil { - return nil, err +func buildDevPortalArchives(projectRoot, buildDir string, portalConfigs []project.PortalConfig) ([]string, []failedPortal, error) { + if err := ensureUniqueDevPortalZipNames(portalConfigs); err != nil { + return nil, nil, err } - manifestData, err := yaml.Marshal(manifest) - if err != nil { - return nil, fmt.Errorf("failed to marshal devportal manifest: %w", err) - } + zipPaths := make([]string, 0, len(portalConfigs)) + failures := make([]failedPortal, 0) - manifestPath := filepath.Join(portalRoot, "devportal.yaml") - if err := os.WriteFile(manifestPath, manifestData, 0644); err != nil { - return nil, fmt.Errorf("failed to write devportal manifest: %w", err) + for i := range portalConfigs { + zipPath, err := buildSingleDevPortalArchive(projectRoot, buildDir, &portalConfigs[i]) + if err != nil { + failures = append(failures, failedPortal{name: portalConfigs[i].Name, err: err}) + continue + } + zipPaths = append(zipPaths, zipPath) } - return portalConfig, nil + return zipPaths, failures, nil } -func buildDefaultDevPortalManifest(projectRoot string, projectConfig *apiProjectConfig) (*devPortalManifest, error) { - apiMetadataPath := resolveProjectPath(projectRoot, projectConfig.FilePaths.APIMetadata) - if err := ensureWithinProjectRoot(projectRoot, apiMetadataPath, "default", "apiMetadata"); err != nil { - return nil, err - } - gatewayPath := resolveProjectPath(projectRoot, projectConfig.FilePaths.DeploymentArtifact) - if err := ensureWithinProjectRoot(projectRoot, gatewayPath, "default", "deploymentArtifact"); err != nil { - return nil, err +// buildSingleDevPortalArchive validates one devportal config, stamps any +// --reference-id / --gateway-type overrides into its manifest, and zips it. +// Returning an error here drops only this config; the caller keeps building the +// rest. +func buildSingleDevPortalArchive(projectRoot, buildDir string, portalConfig *project.PortalConfig) (string, error) { + if err := validateDevPortalConfig(projectRoot, portalConfig); err != nil { + return "", err } - apiMetadataData, err := os.ReadFile(apiMetadataPath) + stagingDir, err := createDevPortalArchiveStagingDir(projectRoot, buildDir, portalConfig) if err != nil { - return nil, fmt.Errorf("failed to read api metadata: %w", err) - } - - var apiMetadata projectAPIResource - if err := yaml.Unmarshal(apiMetadataData, &apiMetadata); err != nil { - return nil, fmt.Errorf("failed to parse api metadata: %w", err) + return "", err } - gatewayData, err := os.ReadFile(gatewayPath) - if err != nil { - return nil, fmt.Errorf("failed to read gateway artifact: %w", err) + // Stamp any --reference-id / --gateway-type overrides into the *staged* + // manifest, never the project's source manifest, so build flags do not leak + // back into the workspace. + stagedManifest := filepath.Join(stagingDir, archiveMetadataFileName) + if err := applyManifestOverrides(stagedManifest, portalConfig.Name); err != nil { + _ = os.RemoveAll(stagingDir) + return "", err } - var gateway gatewayResource - if err := yaml.Unmarshal(gatewayData, &gateway); err != nil { - return nil, fmt.Errorf("failed to parse gateway artifact: %w", err) + zipPath := filepath.Join(buildDir, buildDevPortalZipFileName(portalConfig.Name)) + if err := utils.ZipDirectory(stagingDir, zipPath); err != nil { + _ = os.RemoveAll(stagingDir) + return "", fmt.Errorf("failed to build devportal archive for %s: %w", portalConfig.Name, err) } - - tags := apiMetadata.Spec.Tags - if len(tags) == 0 { - tags = []string{"default"} + if err := os.RemoveAll(stagingDir); err != nil { + return "", fmt.Errorf("failed to clean staging directory for devportal config %q: %w", portalConfig.Name, err) } - labels := apiMetadata.Spec.Labels - if len(labels) == 0 { - labels = []string{"default"} - } + return zipPath, nil +} - gatewayType := apiMetadata.Spec.GatewayType - if gatewayType == "" { - gatewayType = "wso2/api-platform" +// applyManifestOverrides stamps the build-time --reference-id / --gateway-type +// flags into the staged devportal manifest at manifestPath, under +// spec.referenceID / spec.gatewayType. It operates on the archive's staged copy, +// never the project's source manifest, so the flags do not leak back into the +// workspace. The manifest carries no reference ID by default; supplying one at +// build time lets the same artifact be published to different devportals (each +// wired to a different gateway). Existing manifest fields and ordering are +// preserved. +func applyManifestOverrides(manifestPath, portalName string) error { + referenceID := strings.TrimSpace(buildReferenceID) + gatewayType := strings.TrimSpace(buildGatewayType) + if referenceID == "" && gatewayType == "" { + return nil } - status := strings.TrimSpace(apiMetadata.Spec.Status) - if status == "" { - status = "PUBLISHED" + data, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("failed to read devportal manifest for config %q: %w", portalName, err) } - return &devPortalManifest{ - APIVersion: "devportal.api-platform.wso2.com/v1", - Kind: "RestApi", - Metadata: devPortalManifestMeta{ - Name: strings.TrimSpace(apiMetadata.Metadata.Name), - }, - Spec: devPortalManifestSpec{ - DisplayName: strings.TrimSpace(gateway.Spec.DisplayName), - Version: strings.TrimSpace(gateway.Spec.Version), - Description: strings.TrimSpace(apiMetadata.Spec.Description), - Provider: "WSO2", - GatewayType: gatewayType, - Status: status, - ReferenceID: strings.TrimSpace(apiMetadata.Spec.ReferenceID), - Tags: tags, - Labels: labels, - SubscriptionPolicies: gateway.Spec.SubscriptionPlans, - Visibility: "PUBLIC", - VisibleGroups: []string{}, - BusinessInformation: apiMetadata.Spec.BusinessInformation, - Endpoints: apiMetadata.Spec.Endpoints, - }, - }, nil -} - -func resolveProjectPath(projectRoot, pathValue string) string { - trimmed := strings.TrimSpace(pathValue) - if trimmed == "" { - return projectRoot + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("failed to parse devportal manifest for config %q: %w", portalName, err) } - trimmed = strings.TrimPrefix(trimmed, "./") - return filepath.Join(projectRoot, filepath.Clean(trimmed)) -} - -func normalizeAPIProjectConfig(config *apiProjectConfig) { - if strings.TrimSpace(config.FilePaths.DeploymentArtifact) == "" { - config.FilePaths.DeploymentArtifact = "./gateway.yaml" - } - if strings.TrimSpace(config.FilePaths.APIMetadata) == "" { - config.FilePaths.APIMetadata = "./api.yaml" - } - if strings.TrimSpace(config.FilePaths.APIDefinition) == "" { - config.FilePaths.APIDefinition = "./definition.yaml" + root := &doc + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + root = root.Content[0] } - if strings.TrimSpace(config.FilePaths.Docs) == "" { - config.FilePaths.Docs = "./docs" - } - if strings.TrimSpace(config.FilePaths.Tests) == "" { - config.FilePaths.Tests = "./tests" + if root.Kind != yaml.MappingNode { + return fmt.Errorf("devportal manifest for config %q is not a mapping", portalName) } -} -func normalizeDevPortalProjectConfig(config *devPortalProjectConfig) { - if strings.TrimSpace(config.Name) == "" { - config.Name = "default" + spec := mappingValueNode(root, "spec") + if spec == nil { + spec = &yaml.Node{Kind: yaml.MappingNode} + setMappingChild(root, "spec", spec) } - if strings.TrimSpace(config.PortalRoot) == "" { - config.PortalRoot = "./devportal" - } - if strings.TrimSpace(config.FilePaths.APIMetadata) == "" { - config.FilePaths.APIMetadata = "./devportal.yaml" + if referenceID != "" { + setMappingScalar(spec, "referenceID", referenceID) } - if strings.TrimSpace(config.FilePaths.APIDefinition) == "" { - config.FilePaths.APIDefinition = "./definition.yaml" + if gatewayType != "" { + setMappingScalar(spec, "gatewayType", gatewayType) } - if strings.TrimSpace(config.FilePaths.Docs) == "" { - config.FilePaths.Docs = "./docs" - } - if strings.TrimSpace(config.FilePaths.Content) == "" { - config.FilePaths.Content = "./content" - } -} -func buildDevPortalArchives(projectRoot, buildDir string, portalConfigs []devPortalProjectConfig) ([]string, error) { - if err := ensureUniqueDevPortalZipNames(portalConfigs); err != nil { - return nil, err + out, err := marshalNode(&doc) + if err != nil { + return fmt.Errorf("failed to marshal devportal manifest for config %q: %w", portalName, err) } - - zipPaths := make([]string, 0, len(portalConfigs)) - - for i := range portalConfigs { - normalizeDevPortalProjectConfig(&portalConfigs[i]) - - if err := validateDevPortalConfig(projectRoot, &portalConfigs[i]); err != nil { - return nil, err - } - - stagingDir, err := createDevPortalArchiveStagingDir(projectRoot, buildDir, &portalConfigs[i]) - if err != nil { - return nil, err - } - - zipPath := filepath.Join(buildDir, buildDevPortalZipFileName(portalConfigs[i].Name)) - if err := utils.ZipDirectory(stagingDir, zipPath); err != nil { - _ = os.RemoveAll(stagingDir) - return nil, fmt.Errorf("failed to build devportal archive for %s: %w", portalConfigs[i].Name, err) - } - if err := os.RemoveAll(stagingDir); err != nil { - return nil, fmt.Errorf("failed to clean staging directory for devportal config %q: %w", portalConfigs[i].Name, err) - } - - zipPaths = append(zipPaths, zipPath) + if err := os.WriteFile(manifestPath, out, 0644); err != nil { + return fmt.Errorf("failed to write devportal manifest for config %q: %w", portalName, err) } - return zipPaths, nil + return nil } -func validateDevPortalConfig(projectRoot string, portalConfig *devPortalProjectConfig) error { +func validateDevPortalConfig(projectRoot string, portalConfig *project.PortalConfig) error { portalRoot := resolveProjectPath(projectRoot, portalConfig.PortalRoot) if err := ensureWithinProjectRoot(projectRoot, portalRoot, portalConfig.Name, "portalRoot"); err != nil { return err @@ -478,8 +361,8 @@ func validateDevPortalConfig(projectRoot string, portalConfig *devPortalProjectC path string isDir bool }{ - {label: "apiMetadata", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.APIMetadata), isDir: false}, - {label: "apiDefinition", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.APIDefinition), isDir: false}, + {label: "metadataFile", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.MetadataFile), isDir: false}, + {label: "definition", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Definition), isDir: false}, {label: "docs", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Docs), isDir: true}, {label: "content", path: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Content), isDir: true}, } @@ -572,7 +455,7 @@ func ensurePathExists(path string, wantDir bool, portalName, fieldName string) e return nil } -func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig *devPortalProjectConfig) (string, error) { +func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig *project.PortalConfig) (string, error) { stagingRoot, err := os.MkdirTemp(buildDir, "devportal-build-*") if err != nil { return "", fmt.Errorf("failed to create staging directory for devportal config %q: %w", portalConfig.Name, err) @@ -590,23 +473,23 @@ func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig isDir bool }{ { - source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.APIMetadata), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.APIMetadata)), + source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.MetadataFile), + target: filepath.Join(stagingRoot, archiveMetadataFileName), isDir: false, }, { - source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.APIDefinition), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.APIDefinition)), + source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Definition), + target: filepath.Join(stagingRoot, archiveDefinitionFileName), isDir: false, }, { source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Docs), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.Docs)), + target: filepath.Join(stagingRoot, archiveDocsDirName), isDir: true, }, { source: resolvePortalConfigPath(projectRoot, portalConfig, portalConfig.FilePaths.Content), - target: filepath.Join(stagingRoot, archiveRelativePath(portalConfig.FilePaths.Content)), + target: filepath.Join(stagingRoot, archiveContentDirName), isDir: true, }, } @@ -628,7 +511,7 @@ func createDevPortalArchiveStagingDir(projectRoot, buildDir string, portalConfig return stagingRoot, nil } -func resolvePortalConfigPath(projectRoot string, portalConfig *devPortalProjectConfig, pathValue string) string { +func resolvePortalConfigPath(projectRoot string, portalConfig *project.PortalConfig, pathValue string) string { portalRoot := resolveProjectPath(projectRoot, portalConfig.PortalRoot) trimmed := strings.TrimSpace(pathValue) if trimmed == "" { @@ -638,31 +521,10 @@ func resolvePortalConfigPath(projectRoot string, portalConfig *devPortalProjectC return filepath.Clean(filepath.Join(portalRoot, trimmed)) } -func archiveRelativePath(pathValue string) string { - cleanPath := filepath.Clean(strings.TrimSpace(pathValue)) - cleanPath = strings.TrimPrefix(cleanPath, "."+string(os.PathSeparator)) - cleanPath = strings.TrimPrefix(cleanPath, "."+"/") - - for strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator)) || cleanPath == ".." { - cleanPath = strings.TrimPrefix(cleanPath, ".."+string(os.PathSeparator)) - if cleanPath == ".." { - cleanPath = "" - } - } - - cleanPath = strings.TrimPrefix(cleanPath, string(os.PathSeparator)) - cleanPath = strings.TrimPrefix(cleanPath, "/") - if cleanPath == "" || cleanPath == "." { - return "content" - } - - return cleanPath -} - // ensureUniqueDevPortalZipNames fails fast when two devportal configs sanitize // to the same archive filename, which would otherwise silently overwrite an // earlier artifact during the build loop. -func ensureUniqueDevPortalZipNames(portalConfigs []devPortalProjectConfig) error { +func ensureUniqueDevPortalZipNames(portalConfigs []project.PortalConfig) error { seen := make(map[string]string, len(portalConfigs)) for i := range portalConfigs { displayName := strings.TrimSpace(portalConfigs[i].Name) @@ -750,3 +612,63 @@ func copyDirectory(sourceDir, targetDir string) error { return copyFile(path, targetPath) }) } + +// marshalManifest renders a generated manifest with the 2-space indentation +// used across the project's YAML artifacts. +func marshalManifest(v interface{}) ([]byte, error) { + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(v); err != nil { + return nil, err + } + if err := encoder.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// marshalNode renders an already-parsed YAML node, preserving its structure and +// comments while applying the project's 2-space indentation. +func marshalNode(node *yaml.Node) ([]byte, error) { + return marshalManifest(node) +} + +// mappingValueNode returns the value node for key in a mapping node, or nil. +func mappingValueNode(mapping *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +// setMappingChild sets (or replaces) the value node for key in a mapping node. +func setMappingChild(mapping *yaml.Node, key string, value *yaml.Node) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content[i+1] = value + return + } + } + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + value) +} + +// setMappingScalar sets a string-valued key in a mapping node, updating it in +// place if present so surrounding fields and ordering are preserved. +func setMappingScalar(mapping *yaml.Node, key, value string) { + if mapping.Kind != yaml.MappingNode { + mapping.Kind = yaml.MappingNode + } + if existing := mappingValueNode(mapping, key); existing != nil { + existing.Kind = yaml.ScalarNode + existing.Tag = "!!str" + existing.Value = value + existing.Style = 0 + return + } + setMappingChild(mapping, key, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}) +} diff --git a/cli/src/cmd/devportal/build_test.go b/cli/src/cmd/devportal/build_test.go index 589de2eb16..525adf7474 100644 --- a/cli/src/cmd/devportal/build_test.go +++ b/cli/src/cmd/devportal/build_test.go @@ -18,14 +18,18 @@ package devportal import ( + "archive/zip" + "io" "os" "path/filepath" "strings" "testing" + + "github.com/wso2/api-platform/cli/internal/project" ) func TestEnsureUniqueDevPortalZipNames_DetectsCollision(t *testing.T) { - configs := []devPortalProjectConfig{ + configs := []project.PortalConfig{ {Name: "My Portal"}, {Name: "my_portal"}, } @@ -40,7 +44,7 @@ func TestEnsureUniqueDevPortalZipNames_DetectsCollision(t *testing.T) { } func TestEnsureUniqueDevPortalZipNames_AllowsDistinctNames(t *testing.T) { - configs := []devPortalProjectConfig{ + configs := []project.PortalConfig{ {Name: ""}, {Name: "eu"}, {Name: "us"}, @@ -68,9 +72,9 @@ func TestEnsureWithinProjectRoot_AllowsContainedPaths(t *testing.T) { projectRoot := t.TempDir() cases := []string{ - projectRoot, // the root itself - filepath.Join(projectRoot, "devportal"), // nested dir - filepath.Join(projectRoot, "devportal", "api.yaml"), // nested file + projectRoot, // the root itself + filepath.Join(projectRoot, "devportal"), // nested dir + filepath.Join(projectRoot, "devportal", "metadata.yaml"), // nested file } for _, path := range cases { if err := ensureWithinProjectRoot(projectRoot, path, "default", "portalRoot"); err != nil { @@ -90,7 +94,7 @@ func TestEnsureWithinProjectRoot_RejectsSymlinkEscape(t *testing.T) { t.Skipf("symlinks unsupported on this platform: %v", err) } - err := ensureWithinProjectRoot(projectRoot, filepath.Join(link, "secret.yaml"), "default", "apiMetadata") + err := ensureWithinProjectRoot(projectRoot, filepath.Join(link, "secret.yaml"), "default", "metadataFile") if err == nil || !strings.Contains(err.Error(), "resolves outside the project root") { t.Fatalf("expected symlink escape to be rejected, got %v", err) } @@ -98,7 +102,7 @@ func TestEnsureWithinProjectRoot_RejectsSymlinkEscape(t *testing.T) { func TestValidateDevPortalConfig_RejectsEscapingPortalRoot(t *testing.T) { projectRoot := t.TempDir() - portalConfig := &devPortalProjectConfig{Name: "default", PortalRoot: "../../etc"} + portalConfig := &project.PortalConfig{Name: "default", PortalRoot: "../../etc"} err := validateDevPortalConfig(projectRoot, portalConfig) if err == nil { @@ -109,100 +113,157 @@ func TestValidateDevPortalConfig_RejectsEscapingPortalRoot(t *testing.T) { } } -func TestBuildDefaultDevPortalManifest_RejectsEscapingSourcePath(t *testing.T) { +func TestRegisterDefaultDevPortalConfig_RequiresExistingFolder(t *testing.T) { projectRoot := t.TempDir() - projectConfig := &apiProjectConfig{ - FilePaths: apiProjectFilePaths{ - APIMetadata: "../../../etc/passwd", - DeploymentArtifact: "./gateway.yaml", - }, - } - _, err := buildDefaultDevPortalManifest(projectRoot, projectConfig) - if err == nil || !strings.Contains(err.Error(), "resolves outside the project root") { - t.Fatalf("expected containment error, got %v", err) + _, err := registerDefaultDevPortalConfig(projectRoot) + if err == nil || !strings.Contains(err.Error(), "no devportal artifact found") { + t.Fatalf("expected missing-folder error, got %v", err) } } -func TestCreateDefaultDevPortalConfig_RejectsEscapingSourcePath(t *testing.T) { +func TestRegisterDefaultDevPortalConfig_ReturnsConfigForExistingFolder(t *testing.T) { projectRoot := t.TempDir() - projectConfig := &apiProjectConfig{ - FilePaths: apiProjectFilePaths{ - APIDefinition: "../../outside/definition.yaml", - Docs: "./docs", - }, + if err := os.MkdirAll(filepath.Join(projectRoot, "devportal"), 0755); err != nil { + t.Fatalf("failed to create devportal dir: %v", err) } - _, err := projectConfig.createDefaultDevPortalConfig(projectRoot) - if err == nil || !strings.Contains(err.Error(), "resolves outside the project root") { - t.Fatalf("expected containment error, got %v", err) + portalConfig, err := registerDefaultDevPortalConfig(projectRoot) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if portalConfig.Name != "default" || portalConfig.PortalRoot != "./devportal" { + t.Fatalf("unexpected portal config: %+v", portalConfig) } } -func TestRunBuildCommand_RequiresAPIProjectDirectory(t *testing.T) { +func TestRunBuildCommand_RequiresProjectDirectory(t *testing.T) { workDir := t.TempDir() buildProjectDir = workDir err := runBuildCommand() - if err == nil || err.Error() != "unable to find api project directory, please execute this command inside api project" { - t.Fatalf("expected api project directory error, got %v", err) + if err == nil || err.Error() != "unable to find project directory, please execute this command inside a project" { + t.Fatalf("expected project directory error, got %v", err) } } -func TestRunBuildCommand_CreatesDefaultDevPortalArtifacts(t *testing.T) { - projectRoot := createAPIProjectFixture(t) +func TestRunBuildCommand_RequiresDevPortalFolderWhenNoConfig(t *testing.T) { + projectRoot := createProjectFixture(t) + buildProjectDir = projectRoot + + err := runBuildCommand() + if err == nil || !strings.Contains(err.Error(), "no devportal artifact found") { + t.Fatalf("expected missing-folder error, got %v", err) + } +} + +func TestRunBuildCommand_RegistersExistingFolderAndArchives(t *testing.T) { + projectRoot := createProjectFixture(t) + createDevPortalFolderFixture(t, projectRoot, "devportal") buildProjectDir = projectRoot if err := runBuildCommand(); err != nil { t.Fatalf("unexpected error: %v", err) } - for _, path := range []string{ - filepath.Join(projectRoot, "devportal"), - filepath.Join(projectRoot, "devportal", "devportal.yaml"), - filepath.Join(projectRoot, "devportal", "definition.yaml"), - filepath.Join(projectRoot, "devportal", "docs"), - filepath.Join(projectRoot, "devportal", "content"), - filepath.Join(projectRoot, "build", "devportal.zip"), - } { - if _, err := os.Stat(path); err != nil { - t.Fatalf("expected path to exist: %s (%v)", path, err) - } + if _, err := os.Stat(filepath.Join(projectRoot, "build", "devportal.zip")); err != nil { + t.Fatalf("expected devportal.zip to be created: %v", err) } - projectConfig, err := loadProjectConfig(filepath.Join(projectRoot, ".api-platform", "config.yaml")) + projectConfig, err := project.Load(filepath.Join(projectRoot, ".api-platform", "config.yaml")) if err != nil { t.Fatalf("failed to load updated project config: %v", err) } if len(projectConfig.DevPortals) != 1 || projectConfig.DevPortals[0].Name != "default" { - t.Fatalf("expected default devportal config to be added, got %+v", projectConfig.DevPortals) + t.Fatalf("expected default devportal config to be registered, got %+v", projectConfig.DevPortals) + } +} + +func TestRunBuildCommand_StampsReferenceIDAndGatewayTypeIntoArchiveOnly(t *testing.T) { + projectRoot := createProjectFixture(t) + createDevPortalFolderFixture(t, projectRoot, "devportal") + buildProjectDir = projectRoot + + buildReferenceID = "1ba42a09-45c0-40f8-a1bf-e4aa7cde1575" + buildGatewayType = "wso2/api-platform" + t.Cleanup(func() { + buildReferenceID = "" + buildGatewayType = "" + }) + + if err := runBuildCommand(); err != nil { + t.Fatalf("unexpected error: %v", err) } - manifestData, err := os.ReadFile(filepath.Join(projectRoot, "devportal", "devportal.yaml")) + // The project's source manifest must be left untouched — the build flags + // must not leak back into the workspace. + sourceData, readErr := os.ReadFile(filepath.Join(projectRoot, "devportal", "devportal.yaml")) + if readErr != nil { + t.Fatalf("failed to read source manifest: %v", readErr) + } + for _, unwanted := range []string{"referenceID", "gatewayType"} { + if strings.Contains(string(sourceData), unwanted) { + t.Fatalf("source manifest should not be mutated by build, but contains %q: %s", unwanted, sourceData) + } + } + + // The stamped values must be present in the archived (staged) manifest. + archived := readFileFromZip(t, filepath.Join(projectRoot, "build", "devportal.zip"), archiveMetadataFileName) + for _, want := range []string{ + "referenceID: 1ba42a09-45c0-40f8-a1bf-e4aa7cde1575", + "gatewayType: wso2/api-platform", + } { + if !strings.Contains(archived, want) { + t.Fatalf("expected archived manifest to contain %q, got %q", want, archived) + } + } +} + +// readFileFromZip returns the contents of the named entry inside the zip at +// zipPath, failing the test if the archive or entry cannot be read. +func readFileFromZip(t *testing.T, zipPath, entryName string) string { + t.Helper() + + reader, err := zip.OpenReader(zipPath) if err != nil { - t.Fatalf("failed to read manifest: %v", err) + t.Fatalf("failed to open archive %s: %v", zipPath, err) } - if !strings.Contains(string(manifestData), "displayName: Petstore API") { - t.Fatalf("expected generated manifest to contain displayName, got %q", string(manifestData)) + defer reader.Close() + + for _, file := range reader.File { + if file.Name != entryName { + continue + } + rc, err := file.Open() + if err != nil { + t.Fatalf("failed to open %s in archive: %v", entryName, err) + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("failed to read %s from archive: %v", entryName, err) + } + return string(data) } + + t.Fatalf("entry %q not found in archive %s", entryName, zipPath) + return "" } func TestRunBuildCommand_BuildsMultipleConfiguredDevPortalsAndCleansBuildDir(t *testing.T) { - projectRoot := createAPIProjectFixture(t) + projectRoot := createProjectFixture(t) defaultPortalRoot := filepath.Join(projectRoot, "devportal") petstorePortalRoot := filepath.Join(projectRoot, "petstore") - if err := os.MkdirAll(filepath.Join(defaultPortalRoot, "docs"), 0755); err != nil { - t.Fatalf("failed to create docs: %v", err) - } - if err := os.MkdirAll(filepath.Join(defaultPortalRoot, "content"), 0755); err != nil { - t.Fatalf("failed to create content: %v", err) - } - if err := os.MkdirAll(filepath.Join(petstorePortalRoot, "docs"), 0755); err != nil { - t.Fatalf("failed to create docs: %v", err) - } - if err := os.MkdirAll(filepath.Join(petstorePortalRoot, "content"), 0755); err != nil { - t.Fatalf("failed to create content: %v", err) + for _, dir := range []string{ + filepath.Join(defaultPortalRoot, "docs"), + filepath.Join(defaultPortalRoot, "content"), + filepath.Join(petstorePortalRoot, "docs"), + filepath.Join(petstorePortalRoot, "content"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create dir %s: %v", dir, err) + } } for _, path := range []string{ @@ -214,34 +275,28 @@ func TestRunBuildCommand_BuildsMultipleConfiguredDevPortalsAndCleansBuildDir(t * } } - if err := saveProjectConfig(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &apiProjectConfig{ - Version: "1.0.0", - FilePaths: apiProjectFilePaths{ - DeploymentArtifact: "./gateway.yaml", - APIMetadata: "./api.yaml", - APIDefinition: "./definition.yaml", - Docs: "./docs", - Tests: "./tests", - }, - DevPortals: []devPortalProjectConfig{ + if err := project.Save(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &project.Config{ + Version: "1.0.0", + FilePaths: project.DefaultFilePaths(), + DevPortals: []project.PortalConfig{ { Name: "default", PortalRoot: "./devportal", - FilePaths: devPortalProjectPaths{ - APIMetadata: "./devportal.yaml", - APIDefinition: "./../definition.yaml", - Docs: "./docs", - Content: "./content", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", }, }, { Name: "petstore", PortalRoot: "./petstore", - FilePaths: devPortalProjectPaths{ - APIMetadata: "./devportal.yaml", - APIDefinition: "./../definition.yaml", - Docs: "./docs", - Content: "./content", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", }, }, }, @@ -276,8 +331,75 @@ func TestRunBuildCommand_BuildsMultipleConfiguredDevPortalsAndCleansBuildDir(t * } } +func TestRunBuildCommand_PartialSuccessReportsFailureAndBuildsRest(t *testing.T) { + projectRoot := createProjectFixture(t) + + // "default" is fully set up; "broken" is missing its required content dir. + defaultPortalRoot := filepath.Join(projectRoot, "devportal") + brokenPortalRoot := filepath.Join(projectRoot, "broken") + for _, dir := range []string{ + filepath.Join(defaultPortalRoot, "docs"), + filepath.Join(defaultPortalRoot, "content"), + filepath.Join(brokenPortalRoot, "docs"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create dir %s: %v", dir, err) + } + } + for _, path := range []string{ + filepath.Join(defaultPortalRoot, "devportal.yaml"), + filepath.Join(brokenPortalRoot, "devportal.yaml"), + } { + if err := os.WriteFile(path, []byte("apiVersion: devportal.api-platform.wso2.com/v1\nkind: RestApi\n"), 0644); err != nil { + t.Fatalf("failed to write portal manifest: %v", err) + } + } + + if err := project.Save(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &project.Config{ + FilePaths: project.DefaultFilePaths(), + DevPortals: []project.PortalConfig{ + { + Name: "default", + PortalRoot: "./devportal", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", + }, + }, + { + Name: "broken", + PortalRoot: "./broken", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", + }, + }, + }, + }); err != nil { + t.Fatalf("failed to save project config: %v", err) + } + + buildProjectDir = projectRoot + err := runBuildCommand() + if err == nil || !strings.Contains(err.Error(), `devportal config "broken" is invalid: content path does not exist`) { + t.Fatalf("expected failure for the broken config, got %v", err) + } + + // The healthy config must still have produced its archive. + if _, statErr := os.Stat(filepath.Join(projectRoot, "build", "devportal.zip")); statErr != nil { + t.Fatalf("expected the healthy config to still build its archive: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(projectRoot, "build", "devportal_broken.zip")); !os.IsNotExist(statErr) { + t.Fatalf("did not expect an archive for the broken config, got err=%v", statErr) + } +} + func TestRunBuildCommand_ValidatesRequiredContentPath(t *testing.T) { - projectRoot := createAPIProjectFixture(t) + projectRoot := createProjectFixture(t) portalRoot := filepath.Join(projectRoot, "devportal") if err := os.MkdirAll(filepath.Join(portalRoot, "docs"), 0755); err != nil { @@ -287,23 +409,17 @@ func TestRunBuildCommand_ValidatesRequiredContentPath(t *testing.T) { t.Fatalf("failed to write manifest: %v", err) } - if err := saveProjectConfig(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &apiProjectConfig{ - FilePaths: apiProjectFilePaths{ - DeploymentArtifact: "./gateway.yaml", - APIMetadata: "./api.yaml", - APIDefinition: "./definition.yaml", - Docs: "./docs", - Tests: "./tests", - }, - DevPortals: []devPortalProjectConfig{ + if err := project.Save(filepath.Join(projectRoot, ".api-platform", "config.yaml"), &project.Config{ + FilePaths: project.DefaultFilePaths(), + DevPortals: []project.PortalConfig{ { Name: "default", PortalRoot: "./devportal", - FilePaths: devPortalProjectPaths{ - APIMetadata: "./devportal.yaml", - APIDefinition: "./../definition.yaml", - Docs: "./docs", - Content: "./content", + FilePaths: project.PortalFilePaths{ + MetadataFile: "./devportal.yaml", + Definition: "./../definition.yaml", + Docs: "./docs", + Content: "./content", }, }, }, @@ -318,7 +434,7 @@ func TestRunBuildCommand_ValidatesRequiredContentPath(t *testing.T) { } } -func createAPIProjectFixture(t *testing.T) string { +func createProjectFixture(t *testing.T) string { t.Helper() projectRoot := t.TempDir() @@ -333,9 +449,9 @@ func createAPIProjectFixture(t *testing.T) string { } files := map[string]string{ - filepath.Join(projectRoot, ".api-platform", "config.yaml"): "version: 1.0.0\nfilePaths:\n deploymentArtifact: ./gateway.yaml\n apiMetadata: ./api.yaml\n apiDefinition: ./definition.yaml\n docs: ./docs\n tests: ./tests\n", - filepath.Join(projectRoot, "api.yaml"): "apiVersion: management.api-platform.wso2.com/v1\nkind: Api\nmetadata:\n name: foo-1.0.0\n", - filepath.Join(projectRoot, "gateway.yaml"): "apiVersion: gateway.api-platform.wso2.com/v1\nkind: RestApi\nspec:\n displayName: Petstore API\n version: 1.0.0\n subscriptionPlans:\n - gold\n - silver\n", + filepath.Join(projectRoot, ".api-platform", "config.yaml"): "version: 1.0.0\nfilePaths:\n deploymentArtifact: ./runtime.yaml\n metadataFile: ./metadata.yaml\n definition: ./definition.yaml\n docs: ./docs\n tests: ./tests\n", + filepath.Join(projectRoot, "metadata.yaml"): "apiVersion: management.api-platform.wso2.com/v1\nkind: RestApi\nmetadata:\n name: foo-1.0.0\nspec:\n referenceID: \"\"\n", + filepath.Join(projectRoot, "runtime.yaml"): "apiVersion: gateway.api-platform.wso2.com/v1\nkind: RestApi\nspec:\n displayName: Petstore API\n version: 1.0.0\n subscriptionPlans:\n - gold\n - silver\n", filepath.Join(projectRoot, "definition.yaml"): "openapi: 3.0.0\ninfo:\n title: Petstore API\n", filepath.Join(projectRoot, "docs", "README.md"): "# Docs\n", } @@ -348,3 +464,30 @@ func createAPIProjectFixture(t *testing.T) string { return projectRoot } + +// createDevPortalFolderFixture creates a complete devportal artifact folder +// (as `ap devportal gen` would) under projectRoot/portalDir so build has +// something to archive. +func createDevPortalFolderFixture(t *testing.T, projectRoot, portalDir string) { + t.Helper() + + portalRoot := filepath.Join(projectRoot, portalDir) + for _, dir := range []string{ + filepath.Join(portalRoot, "docs"), + filepath.Join(portalRoot, "content"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create devportal fixture dir %s: %v", dir, err) + } + } + + files := map[string]string{ + filepath.Join(portalRoot, "devportal.yaml"): "apiVersion: devportal.api-platform.wso2.com/v1\nkind: RestApi\nmetadata:\n name: foo-1.0.0\nspec:\n type: REST\n displayName: Petstore API\n", + filepath.Join(portalRoot, "definition.yaml"): "openapi: 3.0.0\ninfo:\n title: Petstore API\n", + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write devportal fixture file %s: %v", path, err) + } + } +} diff --git a/cli/src/cmd/devportal/commands_test.go b/cli/src/cmd/devportal/commands_test.go index 56e2c6f83e..29c5ca1c14 100644 --- a/cli/src/cmd/devportal/commands_test.go +++ b/cli/src/cmd/devportal/commands_test.go @@ -428,4 +428,3 @@ func writeDevPortalConfig(t *testing.T, cfg *config.Config) { t.Helper() testutil.WriteCLIConfig(t, cfg) } - diff --git a/cli/src/cmd/devportal/gen.go b/cli/src/cmd/devportal/gen.go new file mode 100644 index 0000000000..c7a2bc4dfb --- /dev/null +++ b/cli/src/cmd/devportal/gen.go @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package devportal + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/project" + "github.com/wso2/api-platform/cli/utils" + "gopkg.in/yaml.v3" +) + +const ( + GenCmdLiteral = "gen" + GenCmdExample = `# Generate the default devportal artifact in the current project +ap devportal gen + +# Generate the default devportal artifact for a project in a specified directory +ap devportal gen -f /path/to/project` +) + +var genProjectDir string + +var genCmd = &cobra.Command{ + Use: GenCmdLiteral, + Short: "Generate the default devportal artifact for the project", + Long: "Generate a default devportal artifact (devportal directory with devportal.yaml, definition.yaml, docs and content) inside the project located in the specified directory (or current directory if not specified).", + Example: GenCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGenCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(genCmd, utils.FlagFile, &genProjectDir, "", "Path to the project directory (defaults to current directory)") +} + +// genMetadataResource is the subset of the project metadata.yaml the generated +// devportal manifest is derived from. +type genMetadataResource struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + } `yaml:"spec"` +} + +func runGenCommand() error { + if genProjectDir == "" { + genProjectDir = "." + } + + projectRoot, err := filepath.Abs(genProjectDir) + if err != nil { + return fmt.Errorf("failed to resolve project directory: %w", err) + } + + projectConfigPath, err := resolveProjectConfigPath(projectRoot) + if err != nil { + return err + } + + projectConfig, err := project.Load(projectConfigPath) + if err != nil { + return err + } + + // The default devportal artifact lives in a fixed "devportal" directory at + // the project root. Refuse to overwrite an existing one so a hand-edited + // artifact is never clobbered by a regeneration. + portalRoot := filepath.Join(projectRoot, "devportal") + if _, err := os.Stat(portalRoot); err == nil { + return fmt.Errorf("devportal directory already exists: %s", portalRoot) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to inspect devportal directory: %w", err) + } + + manifest, err := buildGeneratedDevPortalManifest(projectRoot, projectConfig) + if err != nil { + return err + } + + // Verify all inputs before creating anything on disk, so a missing + // definition never leaves an empty devportal directory behind. definition.yaml + // is copied from the project's home definition so the devportal artifact + // carries the same API definition. + definitionSource := resolveProjectPath(projectRoot, projectConfig.FilePaths.Definition) + if _, err := os.Stat(definitionSource); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("unable to find project definition file at %s", definitionSource) + } + return fmt.Errorf("failed to inspect project definition file: %w", err) + } + + if err := os.MkdirAll(portalRoot, 0755); err != nil { + return fmt.Errorf("failed to create devportal directory: %w", err) + } + // Any failure after the directory is created must not leave a partial + // artifact behind; remove it on error until the artifact is fully written. + cleanupOnError := true + defer func() { + if cleanupOnError { + _ = os.RemoveAll(portalRoot) + } + }() + + if err := copyFile(definitionSource, filepath.Join(portalRoot, "definition.yaml")); err != nil { + return err + } + + for _, dir := range []string{"docs", "content"} { + if err := os.MkdirAll(filepath.Join(portalRoot, dir), 0755); err != nil { + return fmt.Errorf("failed to create devportal %s directory: %w", dir, err) + } + } + + if err := os.WriteFile(filepath.Join(portalRoot, "devportal.yaml"), []byte(manifest), 0644); err != nil { + return fmt.Errorf("failed to write devportal manifest: %w", err) + } + + // The artifact is now complete on disk; a later config-registration failure + // should not delete the user's generated files. + cleanupOnError = false + + // Register the generated artifact in the project config so `ap devportal + // build` archives it from the config instead of having to re-detect the + // folder. Skip if a config entry already points at this folder. + portalConfig := defaultDevPortalConfig() + if !hasDevPortalConfig(projectConfig, portalConfig.PortalRoot) { + projectConfig.DevPortals = append(projectConfig.DevPortals, portalConfig) + if err := project.Save(projectConfigPath, projectConfig); err != nil { + return err + } + } + + fmt.Printf("DevPortal artifact generated at %s\n", portalRoot) + return nil +} + +// hasDevPortalConfig reports whether config already has a devportal entry whose +// portalRoot resolves to the same relative folder as portalRoot. +func hasDevPortalConfig(config *project.Config, portalRoot string) bool { + target := normalizePortalRoot(portalRoot) + for i := range config.DevPortals { + if normalizePortalRoot(config.DevPortals[i].PortalRoot) == target { + return true + } + } + return false +} + +func normalizePortalRoot(portalRoot string) string { + trimmed := strings.TrimSpace(portalRoot) + trimmed = strings.TrimPrefix(trimmed, "./") + return filepath.Clean(trimmed) +} + +// resolveProjectConfigPath validates that projectRoot is an api-platform +// project (it has a .api-platform/config.yaml) and returns the config path. +func resolveProjectConfigPath(projectRoot string) (string, error) { + projectConfigDir := filepath.Join(projectRoot, ".api-platform") + if _, err := os.Stat(projectConfigDir); os.IsNotExist(err) { + return "", fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return "", fmt.Errorf("failed to inspect project directory: %w", err) + } + + projectConfigPath := filepath.Join(projectConfigDir, "config.yaml") + if _, err := os.Stat(projectConfigPath); os.IsNotExist(err) { + return "", fmt.Errorf("unable to find project directory, please execute this command inside a project") + } else if err != nil { + return "", fmt.Errorf("failed to inspect project config: %w", err) + } + + return projectConfigPath, nil +} + +// buildGeneratedDevPortalManifest renders the default devportal.yaml, sourcing +// metadata.name, spec.displayName and spec.version from the project metadata.yaml. +func buildGeneratedDevPortalManifest(projectRoot string, projectConfig *project.Config) (string, error) { + metadataPath := resolveProjectPath(projectRoot, projectConfig.FilePaths.MetadataFile) + metadataData, err := os.ReadFile(metadataPath) + if err != nil { + return "", fmt.Errorf("failed to read project metadata: %w", err) + } + + var metadata genMetadataResource + if err := yaml.Unmarshal(metadataData, &metadata); err != nil { + return "", fmt.Errorf("failed to parse project metadata: %w", err) + } + + kind := strings.TrimSpace(metadata.Kind) + if kind == "" { + kind = "RestApi" + } + + return renderGeneratedDevPortalManifest( + kind, + strings.TrimSpace(metadata.Metadata.Name), + strings.TrimSpace(metadata.Spec.DisplayName), + strings.TrimSpace(metadata.Spec.Version), + ), nil +} + +// generatedDevPortalTemplate is the default devportal artifact. The dynamic +// values (kind, metadata.name, spec.displayName, spec.version) come from the +// project metadata.yaml; the remaining fields are sample defaults the user is +// expected to edit. +const generatedDevPortalTemplate = `apiVersion: devportal.api-platform.wso2.com/v1 +kind: %s + +metadata: + name: %s + +spec: + type: REST + displayName: %s + version: %s + description: + provider: WSO2 + gatewayType: wso2/api-platform + referenceID: + + tags: [] + + labels: + - default + + subscriptionPolicies: [] + + visibility: PUBLIC + visibleGroups: [] + + businessInformation: + businessOwner: Platform Owner + businessOwnerEmail: support@example.com + technicalOwner: API Team + technicalOwnerEmail: architecture@example.com + + endpoints: + sandboxUrl: http://localhost:8080/booking + productionUrl: http://localhost:8080/booking +` + +func renderGeneratedDevPortalManifest(kind, name, displayName, version string) string { + return fmt.Sprintf( + generatedDevPortalTemplate, + yamlScalar(kind), + yamlScalar(name), + yamlScalar(displayName), + yamlScalar(version), + ) +} + +// yamlScalar encodes v as a single-line YAML scalar, quoting or escaping it as +// needed so values containing special characters (":", "#", leading indicators +// like "*"/"&", newlines, etc.) cannot break the generated manifest. The result +// is safe to interpolate inline after a "key: " in the template. Marshalling a +// string never fails and never produces a block scalar, so it always stays on a +// single line; only the trailing newline needs trimming. +func yamlScalar(v string) string { + encoded, err := yaml.Marshal(v) + if err != nil { + return v + } + return strings.TrimRight(string(encoded), "\n") +} diff --git a/cli/src/cmd/devportal/gen_test.go b/cli/src/cmd/devportal/gen_test.go new file mode 100644 index 0000000000..69a0e4804c --- /dev/null +++ b/cli/src/cmd/devportal/gen_test.go @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package devportal + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestRunGenCommand_RequiresProjectDirectory(t *testing.T) { + genProjectDir = t.TempDir() + + err := runGenCommand() + if err == nil || err.Error() != "unable to find project directory, please execute this command inside a project" { + t.Fatalf("expected project directory error, got %v", err) + } +} + +func TestRunGenCommand_GeneratesDefaultArtifact(t *testing.T) { + projectRoot := createProjectFixture(t) + // The shared fixture's metadata.yaml omits displayName/version, which gen + // sources from metadata.yaml, so provide them here. + metadata := "apiVersion: management.api-platform.wso2.com/v1\nkind: RestApi\nmetadata:\n name: booking-api-v1.0\nspec:\n displayName: Booking API\n version: v1.0\n" + if err := os.WriteFile(filepath.Join(projectRoot, "metadata.yaml"), []byte(metadata), 0644); err != nil { + t.Fatalf("failed to write metadata fixture: %v", err) + } + genProjectDir = projectRoot + + if err := runGenCommand(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, path := range []string{ + filepath.Join(projectRoot, "devportal"), + filepath.Join(projectRoot, "devportal", "devportal.yaml"), + filepath.Join(projectRoot, "devportal", "definition.yaml"), + filepath.Join(projectRoot, "devportal", "docs"), + filepath.Join(projectRoot, "devportal", "content"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected path to exist: %s (%v)", path, err) + } + } + + manifestData, err := os.ReadFile(filepath.Join(projectRoot, "devportal", "devportal.yaml")) + if err != nil { + t.Fatalf("failed to read manifest: %v", err) + } + manifest := string(manifestData) + for _, want := range []string{ + "name: booking-api-v1.0", + "type: REST", + "displayName: Booking API", + "version: v1.0", + "provider: WSO2", + "gatewayType: wso2/api-platform", + "visibility: PUBLIC", + "- default", + } { + if !strings.Contains(manifest, want) { + t.Fatalf("expected manifest to contain %q, got:\n%s", want, manifest) + } + } + + // definition.yaml must be copied from the project home definition. + definitionData, err := os.ReadFile(filepath.Join(projectRoot, "devportal", "definition.yaml")) + if err != nil { + t.Fatalf("failed to read copied definition: %v", err) + } + if !strings.Contains(string(definitionData), "title: Petstore API") { + t.Fatalf("expected copied definition to match project home definition, got %q", string(definitionData)) + } +} + +func TestRunGenCommand_StopsWhenDevPortalExists(t *testing.T) { + projectRoot := createProjectFixture(t) + if err := os.MkdirAll(filepath.Join(projectRoot, "devportal"), 0755); err != nil { + t.Fatalf("failed to pre-create devportal dir: %v", err) + } + genProjectDir = projectRoot + + err := runGenCommand() + if err == nil || !strings.Contains(err.Error(), "devportal directory already exists") { + t.Fatalf("expected already-exists error, got %v", err) + } +} + +func TestRenderGeneratedDevPortalManifest_EscapesSpecialCharacters(t *testing.T) { + // Values with YAML-significant characters must not break the generated + // manifest: it must stay valid YAML and round-trip to the same values. + kind := "RestApi" + name := "foo: bar #1" + displayName := "*My \"API\": v2" + version := "1.0" + + manifest := renderGeneratedDevPortalManifest(kind, name, displayName, version) + + var parsed struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + } `yaml:"spec"` + } + if err := yaml.Unmarshal([]byte(manifest), &parsed); err != nil { + t.Fatalf("generated manifest is not valid YAML: %v\n%s", err, manifest) + } + + if parsed.Kind != kind { + t.Errorf("kind: got %q, want %q", parsed.Kind, kind) + } + if parsed.Metadata.Name != name { + t.Errorf("metadata.name: got %q, want %q", parsed.Metadata.Name, name) + } + if parsed.Spec.DisplayName != displayName { + t.Errorf("spec.displayName: got %q, want %q", parsed.Spec.DisplayName, displayName) + } + if parsed.Spec.Version != version { + t.Errorf("spec.version: got %q, want %q", parsed.Spec.Version, version) + } +} + +func TestRunGenCommand_LeavesNoPartialDirWhenDefinitionMissing(t *testing.T) { + projectRoot := createProjectFixture(t) + // Remove the project's home definition so generation fails after inputs are + // verified but before (previously) the directory would have been created. + if err := os.Remove(filepath.Join(projectRoot, "definition.yaml")); err != nil { + t.Fatalf("failed to remove project definition: %v", err) + } + genProjectDir = projectRoot + + err := runGenCommand() + if err == nil || !strings.Contains(err.Error(), "unable to find project definition file") { + t.Fatalf("expected missing-definition error, got %v", err) + } + + // The failed run must not leave a devportal directory behind (which would + // otherwise trip the already-exists guard on the next run). + if _, statErr := os.Stat(filepath.Join(projectRoot, "devportal")); !os.IsNotExist(statErr) { + t.Fatalf("expected no devportal directory after a failed gen, got err=%v", statErr) + } +} diff --git a/cli/src/cmd/devportal/org/add.go b/cli/src/cmd/devportal/org/add.go index 1159b91015..66fc3af2c5 100644 --- a/cli/src/cmd/devportal/org/add.go +++ b/cli/src/cmd/devportal/org/add.go @@ -84,7 +84,7 @@ func runAddCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, addInsecure) - resp, err := client.PostMultipartFile("/devportal/organizations", "organization", organizationPath) + resp, err := client.PostMultipartFile("/organizations", "organization", organizationPath) if err != nil { return internaldevportal.WrapRequestError("create organization", err, addInsecure) } diff --git a/cli/src/cmd/devportal/org/commands_test.go b/cli/src/cmd/devportal/org/commands_test.go index ac7dc49a52..5c6cafcb9f 100644 --- a/cli/src/cmd/devportal/org/commands_test.go +++ b/cli/src/cmd/devportal/org/commands_test.go @@ -37,7 +37,7 @@ func TestRunListCommand_PrintsOrganizationTable(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations" { + if req.URL.Path != "/organizations" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -70,7 +70,7 @@ func TestRunGetCommand_PrintsJSON(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1" { + if req.URL.Path != "/organizations/org-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -101,7 +101,7 @@ func TestRunAddCommand_SendsOrganizationYAMLMultipart(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations" { + if req.URL.Path != "/organizations" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartOrganization(t, req, "org.yaml", "apiVersion: devportal.api-platform.wso2.com/v1\nkind: Organization\n") @@ -170,7 +170,7 @@ func TestRunEditCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPut { t.Fatalf("expected PUT request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1" { + if req.URL.Path != "/organizations/org-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -228,7 +228,7 @@ func TestRunDeleteCommand_SendsDelete(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1" { + if req.URL.Path != "/organizations/org-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") diff --git a/cli/src/cmd/devportal/org/delete.go b/cli/src/cmd/devportal/org/delete.go index a1bfb13040..d25044fca1 100644 --- a/cli/src/cmd/devportal/org/delete.go +++ b/cli/src/cmd/devportal/org/delete.go @@ -84,7 +84,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s", url.PathEscape(orgID)) + path := fmt.Sprintf("/organizations/%s", url.PathEscape(orgID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete organization", err, deleteInsecure) @@ -93,4 +93,3 @@ func runDeleteCommand() error { fmt.Printf("Organization deleted from devportal %s (platform: %s)\n", devPortal.Name, resolvedPlatform) return internaldevportal.PrintJSONResponse(resp) } - diff --git a/cli/src/cmd/devportal/org/edit.go b/cli/src/cmd/devportal/org/edit.go index 3356a775c6..9ee390adb6 100644 --- a/cli/src/cmd/devportal/org/edit.go +++ b/cli/src/cmd/devportal/org/edit.go @@ -92,7 +92,7 @@ func runEditCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, editInsecure) - path := fmt.Sprintf("/devportal/organizations/%s", url.PathEscape(orgID)) + path := fmt.Sprintf("/organizations/%s", url.PathEscape(orgID)) resp, err := client.PutJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("update organization", err, editInsecure) diff --git a/cli/src/cmd/devportal/org/get.go b/cli/src/cmd/devportal/org/get.go index 143f020d52..996a841948 100644 --- a/cli/src/cmd/devportal/org/get.go +++ b/cli/src/cmd/devportal/org/get.go @@ -84,7 +84,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s", url.PathEscape(orgID)) + path := fmt.Sprintf("/organizations/%s", url.PathEscape(orgID)) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("get organization", err, getInsecure) diff --git a/cli/src/cmd/devportal/org/list.go b/cli/src/cmd/devportal/org/list.go index 7a21712a7d..db1c965a06 100644 --- a/cli/src/cmd/devportal/org/list.go +++ b/cli/src/cmd/devportal/org/list.go @@ -84,7 +84,7 @@ func runListCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, listInsecure) - resp, err := client.Get("/devportal/organizations") + resp, err := client.Get("/organizations") if err != nil { return internaldevportal.WrapRequestError("list organizations", err, listInsecure) } diff --git a/cli/src/cmd/devportal/restapi/commands_test.go b/cli/src/cmd/devportal/restapi/commands_test.go index 1f061facfe..4398fa3d45 100644 --- a/cli/src/cmd/devportal/restapi/commands_test.go +++ b/cli/src/cmd/devportal/restapi/commands_test.go @@ -38,7 +38,7 @@ func TestRunListCommand_PrintsTable(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis" { + if req.URL.Path != "/o/org-1/devportal/v1/apis" { t.Fatalf("unexpected request path %s", req.URL.Path) } if req.URL.RawQuery != "tags=default" { @@ -138,7 +138,7 @@ func TestRunGetCommand_PrintsJSON(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis/api-1" { + if req.URL.Path != "/o/org-1/devportal/v1/apis/api-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -174,7 +174,7 @@ func TestRunPublishCommand_DefaultArtifactAndMultipartUpload(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis" { + if req.URL.Path != "/o/org-1/devportal/v1/apis" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartArtifact(t, req, "devportal.zip") @@ -227,7 +227,7 @@ func TestRunEditCommand_UploadsArtifact(t *testing.T) { if req.Method != http.MethodPut { t.Fatalf("expected PUT request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis/api-1" { + if req.URL.Path != "/o/org-1/devportal/v1/apis/api-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartArtifact(t, req, "artifact.zip") @@ -254,7 +254,7 @@ func TestRunDeleteCommand_SendsDelete(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/apis/api-1" { + if req.URL.Path != "/o/org-1/devportal/v1/apis/api-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -338,4 +338,3 @@ func TestRestAPIImports(t *testing.T) { _ = multipart.ErrMessageTooLarge _ = os.ErrNotExist } - diff --git a/cli/src/cmd/devportal/restapi/delete.go b/cli/src/cmd/devportal/restapi/delete.go index f1dadd65c9..c8dfe01a10 100644 --- a/cli/src/cmd/devportal/restapi/delete.go +++ b/cli/src/cmd/devportal/restapi/delete.go @@ -92,7 +92,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis/%s", url.PathEscape(orgID), url.PathEscape(apiID)) + path := internaldevportal.OrgScopedPath(orgID, "apis/"+url.PathEscape(apiID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete api artifact", err, deleteInsecure) diff --git a/cli/src/cmd/devportal/restapi/edit.go b/cli/src/cmd/devportal/restapi/edit.go index 11e89d576f..e1017fb0b8 100644 --- a/cli/src/cmd/devportal/restapi/edit.go +++ b/cli/src/cmd/devportal/restapi/edit.go @@ -102,7 +102,7 @@ func runEditCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, editInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis/%s", url.PathEscape(orgID), url.PathEscape(apiID)) + path := internaldevportal.OrgScopedPath(orgID, "apis/"+url.PathEscape(apiID)) resp, err := client.PutMultipartFile(path, "artifact", artifactPath) if err != nil { return internaldevportal.WrapRequestError("edit api artifact", err, editInsecure) diff --git a/cli/src/cmd/devportal/restapi/get.go b/cli/src/cmd/devportal/restapi/get.go index c18f51bec5..f5b98c731e 100644 --- a/cli/src/cmd/devportal/restapi/get.go +++ b/cli/src/cmd/devportal/restapi/get.go @@ -92,7 +92,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis/%s", url.PathEscape(orgID), url.PathEscape(apiID)) + path := internaldevportal.OrgScopedPath(orgID, "apis/"+url.PathEscape(apiID)) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("get api artifact", err, getInsecure) diff --git a/cli/src/cmd/devportal/restapi/list.go b/cli/src/cmd/devportal/restapi/list.go index cf15afdb51..8e9ec83789 100644 --- a/cli/src/cmd/devportal/restapi/list.go +++ b/cli/src/cmd/devportal/restapi/list.go @@ -39,16 +39,22 @@ const ( ap devportal rest-api list --org org_1 # List all APIs using a specific devportal -ap devportal rest-api list --org org_1 --display-name my-portal --platform eu` +ap devportal rest-api list --org org_1 --display-name my-portal --platform eu + +# List APIs from a specific view (defaults to "default") +ap devportal rest-api list --org org_1 --view internal` ) var ( listOrgID string listName string listPlatform string + listView string listInsecure bool ) +const defaultView = "default" + type apiListRow struct { APIID string `json:"apiID"` APIHandle string `json:"apiHandle"` @@ -75,6 +81,7 @@ func init() { utils.AddStringFlag(listCmd, utils.FlagOrgID, &listOrgID, "", "Organization ID") utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "DevPortal display name") utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + utils.AddStringFlag(listCmd, utils.FlagView, &listView, defaultView, "View to list APIs from") listCmd.Flags().BoolVar(&listInsecure, "insecure", false, "Skip TLS certificate verification") _ = listCmd.MarkFlagRequired(utils.FlagOrgID) } @@ -95,8 +102,14 @@ func runListCommand() error { return err } + view := strings.TrimSpace(listView) + if view == "" { + view = defaultView + } + client := internaldevportal.NewClientWithOptions(devPortal, listInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis?tags=default", url.PathEscape(orgID)) + resource := "apis?view=" + url.QueryEscape(view) + path := internaldevportal.OrgScopedPath(orgID, resource) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("list api artifacts", err, listInsecure) diff --git a/cli/src/cmd/devportal/restapi/publish.go b/cli/src/cmd/devportal/restapi/publish.go index ad66746f52..82d4a4bb26 100644 --- a/cli/src/cmd/devportal/restapi/publish.go +++ b/cli/src/cmd/devportal/restapi/publish.go @@ -20,7 +20,6 @@ package restapi import ( "fmt" - "net/url" "os" "strings" @@ -96,7 +95,7 @@ func runPublishCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, publishInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/apis", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "apis") resp, err := client.PostMultipartFile(path, "artifact", artifactPath) if err != nil { return internaldevportal.WrapRequestError("publish api artifact", err, publishInsecure) diff --git a/cli/src/cmd/devportal/root.go b/cli/src/cmd/devportal/root.go index 1e5e020d00..cfaa1c7d70 100644 --- a/cli/src/cmd/devportal/root.go +++ b/cli/src/cmd/devportal/root.go @@ -53,6 +53,7 @@ func init() { DevPortalCmd.AddCommand(currentCmd) DevPortalCmd.AddCommand(healthCmd) DevPortalCmd.AddCommand(buildCmd) + DevPortalCmd.AddCommand(genCmd) DevPortalCmd.AddCommand(devportalorg.OrgCmd) DevPortalCmd.AddCommand(devportalapikey.APIKeyCmd) DevPortalCmd.AddCommand(devportalapplication.ApplicationCmd) diff --git a/cli/src/cmd/devportal/subplan/commands_test.go b/cli/src/cmd/devportal/subplan/commands_test.go index 52d92149fc..3ec35ac923 100644 --- a/cli/src/cmd/devportal/subplan/commands_test.go +++ b/cli/src/cmd/devportal/subplan/commands_test.go @@ -70,7 +70,7 @@ func TestRunPublishCommand_UploadsSinglePlanMultipart(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscription-policies" { + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartPlan(t, req, "plan.yaml", singlePlanYAML) @@ -100,7 +100,7 @@ func TestRunPublishCommand_UploadsPlanListMultipart(t *testing.T) { testutil.WithTempHome(t) server := testutil.NewDevPortalServer(t, func(w http.ResponseWriter, req *http.Request) { - if req.URL.Path != "/devportal/organizations/org-1/subscription-policies" { + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies" { t.Fatalf("unexpected request path %s", req.URL.Path) } assertMultipartPlan(t, req, "plans.yaml", planListYAML) @@ -177,7 +177,7 @@ func TestRunGetCommand_GetsPlanByPolicyID(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscription-policies/plan-1" { + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies/plan-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -208,6 +208,42 @@ func TestRunGetCommand_MissingPolicyID(t *testing.T) { } } +func TestRunListCommand_ListsPlans(t *testing.T) { + testutil.WithTempHome(t) + + server := testutil.NewDevPortalServer(t, func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + t.Fatalf("expected GET request, got %s", req.Method) + } + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies" { + t.Fatalf("unexpected request path %s", req.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"policyId":"plan-1","policyName":"Gold"}]`)) + }) + + writeSubPlanConfig(t, server.URL) + + listOrgID = "org-1" + listName = "" + listPlatform = "" + listInsecure = false + + if err := runListCommand(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunListCommand_MissingOrgID(t *testing.T) { + testutil.WithTempHome(t) + + listOrgID = "" + + if err := runListCommand(); err == nil || !strings.Contains(err.Error(), "organization ID is required") { + t.Fatalf("expected organization ID validation error, got %v", err) + } +} + func TestRunDeleteCommand_DeletesPlanByPolicyID(t *testing.T) { testutil.WithTempHome(t) @@ -215,7 +251,7 @@ func TestRunDeleteCommand_DeletesPlanByPolicyID(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscription-policies/plan-1" { + if req.URL.Path != "/o/org-1/devportal/v1/subscription-policies/plan-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.WriteHeader(http.StatusNoContent) diff --git a/cli/src/cmd/devportal/subplan/delete.go b/cli/src/cmd/devportal/subplan/delete.go index d72810fcb6..841145c03e 100644 --- a/cli/src/cmd/devportal/subplan/delete.go +++ b/cli/src/cmd/devportal/subplan/delete.go @@ -93,7 +93,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscription-policies/%s", url.PathEscape(orgID), url.PathEscape(policyID)) + path := internaldevportal.OrgScopedPath(orgID, "subscription-policies/"+url.PathEscape(policyID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete subscription plan", err, deleteInsecure) diff --git a/cli/src/cmd/devportal/subplan/get.go b/cli/src/cmd/devportal/subplan/get.go index f76b4efa0d..bf46ae2b20 100644 --- a/cli/src/cmd/devportal/subplan/get.go +++ b/cli/src/cmd/devportal/subplan/get.go @@ -93,7 +93,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscription-policies/%s", url.PathEscape(orgID), url.PathEscape(policyID)) + path := internaldevportal.OrgScopedPath(orgID, "subscription-policies/"+url.PathEscape(policyID)) resp, err := client.Get(path) if err != nil { return internaldevportal.WrapRequestError("get subscription plan", err, getInsecure) diff --git a/cli/src/cmd/devportal/subplan/list.go b/cli/src/cmd/devportal/subplan/list.go new file mode 100644 index 0000000000..b50333c0c4 --- /dev/null +++ b/cli/src/cmd/devportal/subplan/list.go @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package subplan + +import ( + "fmt" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + internaldevportal "github.com/wso2/api-platform/cli/internal/devportal" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all subscription plans in an organization using the active devportal +ap devportal sub-plan list --org org_1 + +# List all subscription plans using a specific devportal +ap devportal sub-plan list --org org_1 --display-name my-portal --platform eu` +) + +var ( + listOrgID string + listName string + listPlatform string + listInsecure bool +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all DevPortal subscription plans", + Long: "Retrieves all subscription plans for a given DevPortal organization.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(listCmd, utils.FlagOrgID, &listOrgID, "", "Organization ID") + utils.AddStringFlag(listCmd, utils.FlagName, &listName, "", "DevPortal display name") + utils.AddStringFlag(listCmd, utils.FlagPlatform, &listPlatform, "", "Platform name") + utils.AddBoolFlag(listCmd, utils.FlagInsecure, &listInsecure, false, "Skip TLS certificate verification") + _ = listCmd.MarkFlagRequired(utils.FlagOrgID) +} + +func runListCommand() error { + orgID := strings.TrimSpace(listOrgID) + if orgID == "" { + return fmt.Errorf("organization ID is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + devPortal, resolvedPlatform, err := internaldevportal.ResolveDevPortal(cfg, listName, listPlatform) + if err != nil { + return err + } + + client := internaldevportal.NewClientWithOptions(devPortal, listInsecure) + path := internaldevportal.OrgScopedPath(orgID, "subscription-policies") + resp, err := client.Get(path) + if err != nil { + return internaldevportal.WrapRequestError("list subscription plans", err, listInsecure) + } + if resp.StatusCode != http.StatusOK { + return utils.FormatHTTPError("list subscription plans", resp, "DevPortal") + } + + fmt.Printf("Subscription plans from devportal %s (platform: %s)\n", devPortal.Name, resolvedPlatform) + return internaldevportal.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/devportal/subplan/publish.go b/cli/src/cmd/devportal/subplan/publish.go index 4a7393b004..1194b0629f 100644 --- a/cli/src/cmd/devportal/subplan/publish.go +++ b/cli/src/cmd/devportal/subplan/publish.go @@ -20,7 +20,6 @@ package subplan import ( "fmt" - "net/url" "os" "strings" @@ -138,7 +137,7 @@ func runPublishCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, publishInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscription-policies", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "subscription-policies") resp, err := client.PostMultipartFile(path, multipartFieldName, filePath) if err != nil { return internaldevportal.WrapRequestError("publish subscription plan", err, publishInsecure) diff --git a/cli/src/cmd/devportal/subplan/root.go b/cli/src/cmd/devportal/subplan/root.go index 729de03687..f24deb9018 100644 --- a/cli/src/cmd/devportal/subplan/root.go +++ b/cli/src/cmd/devportal/subplan/root.go @@ -44,6 +44,7 @@ var SubPlanCmd = &cobra.Command{ func init() { SubPlanCmd.AddCommand(publishCmd) + SubPlanCmd.AddCommand(listCmd) SubPlanCmd.AddCommand(getCmd) SubPlanCmd.AddCommand(deleteCmd) } diff --git a/cli/src/cmd/devportal/subscription/commands_test.go b/cli/src/cmd/devportal/subscription/commands_test.go index 11148e62d4..4d691537a0 100644 --- a/cli/src/cmd/devportal/subscription/commands_test.go +++ b/cli/src/cmd/devportal/subscription/commands_test.go @@ -36,7 +36,7 @@ func TestRunCreateCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("expected POST request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscriptions" { + if req.URL.Path != "/o/org-1/devportal/v1/subscriptions" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -74,7 +74,7 @@ func TestRunEditCommand_SendsJSONPayload(t *testing.T) { if req.Method != http.MethodPut { t.Fatalf("expected PUT request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscriptions/sub-1" { + if req.URL.Path != "/o/org-1/devportal/v1/subscriptions/sub-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } body, err := io.ReadAll(req.Body) @@ -108,13 +108,13 @@ func TestRunGetCommand_ListAllAndSingle(t *testing.T) { server := testutil.NewDevPortalServer(t, func(w http.ResponseWriter, req *http.Request) { switch req.URL.Path { - case "/devportal/organizations/org-1/subscriptions": + case "/o/org-1/devportal/v1/subscriptions": if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[{"subscriptionId":"sub-1"}]`)) - case "/devportal/organizations/org-1/subscriptions/sub-1": + case "/o/org-1/devportal/v1/subscriptions/sub-1": if req.Method != http.MethodGet { t.Fatalf("expected GET request, got %s", req.Method) } @@ -160,7 +160,7 @@ func TestRunDeleteCommand_SendsDelete(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("expected DELETE request, got %s", req.Method) } - if req.URL.Path != "/devportal/organizations/org-1/subscriptions/sub-1" { + if req.URL.Path != "/o/org-1/devportal/v1/subscriptions/sub-1" { t.Fatalf("unexpected request path %s", req.URL.Path) } w.Header().Set("Content-Type", "application/json") diff --git a/cli/src/cmd/devportal/subscription/create.go b/cli/src/cmd/devportal/subscription/create.go index 3e0ac06dcb..cadf544891 100644 --- a/cli/src/cmd/devportal/subscription/create.go +++ b/cli/src/cmd/devportal/subscription/create.go @@ -22,7 +22,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "os" "strings" @@ -96,7 +95,7 @@ func runCreateCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, createInsecure) - resp, err := client.PostJSON(fmt.Sprintf("/devportal/organizations/%s/subscriptions", url.PathEscape(orgID)), payload) + resp, err := client.PostJSON(internaldevportal.OrgScopedPath(orgID, "subscriptions"), payload) if err != nil { return internaldevportal.WrapRequestError("create platform subscription", err, createInsecure) } diff --git a/cli/src/cmd/devportal/subscription/delete.go b/cli/src/cmd/devportal/subscription/delete.go index 35fc450854..f77e846a13 100644 --- a/cli/src/cmd/devportal/subscription/delete.go +++ b/cli/src/cmd/devportal/subscription/delete.go @@ -93,7 +93,7 @@ func runDeleteCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, deleteInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscriptions/%s", url.PathEscape(orgID), url.PathEscape(subscriptionID)) + path := internaldevportal.OrgScopedPath(orgID, "subscriptions/"+url.PathEscape(subscriptionID)) resp, err := client.Delete(path) if err != nil { return internaldevportal.WrapRequestError("delete platform subscription", err, deleteInsecure) @@ -104,4 +104,3 @@ func runDeleteCommand() error { fmt.Printf("Platform subscription deleted from devportal %s (platform: %s)\n", devPortal.Name, resolvedPlatform) return internaldevportal.PrintJSONResponse(resp) } - diff --git a/cli/src/cmd/devportal/subscription/edit.go b/cli/src/cmd/devportal/subscription/edit.go index 8fdd412319..58ca831624 100644 --- a/cli/src/cmd/devportal/subscription/edit.go +++ b/cli/src/cmd/devportal/subscription/edit.go @@ -97,7 +97,7 @@ func runEditCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, editInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscriptions/%s", url.PathEscape(editOrgID), url.PathEscape(subscriptionID)) + path := internaldevportal.OrgScopedPath(editOrgID, "subscriptions/"+url.PathEscape(subscriptionID)) resp, err := client.PutJSON(path, payload) if err != nil { return internaldevportal.WrapRequestError("update platform subscription", err, editInsecure) diff --git a/cli/src/cmd/devportal/subscription/get.go b/cli/src/cmd/devportal/subscription/get.go index c3923d99e3..924d5ab507 100644 --- a/cli/src/cmd/devportal/subscription/get.go +++ b/cli/src/cmd/devportal/subscription/get.go @@ -87,7 +87,7 @@ func runGetCommand() error { } client := internaldevportal.NewClientWithOptions(devPortal, getInsecure) - path := fmt.Sprintf("/devportal/organizations/%s/subscriptions", url.PathEscape(orgID)) + path := internaldevportal.OrgScopedPath(orgID, "subscriptions") if subscriptionID := strings.TrimSpace(getSubscription); subscriptionID != "" { path = fmt.Sprintf("%s/%s", path, url.PathEscape(subscriptionID)) } @@ -110,4 +110,3 @@ func runGetCommand() error { } return internaldevportal.PrintJSONResponse(resp) } - diff --git a/cli/src/cmd/gateway/apply.go b/cli/src/cmd/gateway/apply.go index 49041949fe..94d248172a 100644 --- a/cli/src/cmd/gateway/apply.go +++ b/cli/src/cmd/gateway/apply.go @@ -49,7 +49,7 @@ var ( var applyCmd = &cobra.Command{ Use: ApplyCmdLiteral, Short: "Apply a resource to the gateway", - Long: "Create or update a gateway resource (API, MCP proxy, etc.) from a YAML or JSON file.", + Long: "Create or update a gateway resource (RestApi, Mcp, LlmProvider, LlmProxy) from a YAML or JSON file.", Example: ApplyCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runApplyCommand(cmd); err != nil { diff --git a/cli/src/cmd/platform/remove.go b/cli/src/cmd/platform/remove.go new file mode 100644 index 0000000000..e79e5ea95b --- /dev/null +++ b/cli/src/cmd/platform/remove.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package platform + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + RemoveCmdLiteral = "remove" + RemoveCmdExample = `# Remove a platform +ap platform remove --display-name dev` +) + +var removeName string + +var removeCmd = &cobra.Command{ + Use: RemoveCmdLiteral, + Short: "Remove a platform", + Long: "Remove a platform and all of its connections (gateways, devportals, AI workspaces) from the CLI configuration.", + Example: RemoveCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runRemoveCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(removeCmd, utils.FlagName, &removeName, "", "Name of the platform to remove (required)") + _ = removeCmd.MarkFlagRequired(utils.FlagName) +} + +func runRemoveCommand() error { + name := strings.TrimSpace(removeName) + if name == "" { + return fmt.Errorf("platform name is required") + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if err := cfg.RemovePlatform(name); err != nil { + return err + } + + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("Platform %s removed.\n", name) + return nil +} diff --git a/cli/src/cmd/platform/root.go b/cli/src/cmd/platform/root.go index 21f1129ccc..0be35be40f 100644 --- a/cli/src/cmd/platform/root.go +++ b/cli/src/cmd/platform/root.go @@ -32,7 +32,10 @@ ap platform use --display-name dev ap platform current # List all platforms -ap platform list` +ap platform list + +# Remove a platform +ap platform remove --display-name dev` ) var PlatformCmd = &cobra.Command{ @@ -50,4 +53,5 @@ func init() { PlatformCmd.AddCommand(useCmd) PlatformCmd.AddCommand(currentCmd) PlatformCmd.AddCommand(listCmd) + PlatformCmd.AddCommand(removeCmd) } diff --git a/cli/src/cmd/project/init.go b/cli/src/cmd/project/init.go new file mode 100644 index 0000000000..496fa0bda8 --- /dev/null +++ b/cli/src/cmd/project/init.go @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package project + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/project" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + InitCmdLiteral = "init" + InitCmdExample = `# Initialize a new project +ap project init --display-name foo-api --type rest + +# Initialize a new project interactively +ap project init` +) + +var displayName string +var projectType string +var addNoInteractive bool + +var initCmd = &cobra.Command{ + Use: InitCmdLiteral, + Short: "Initialize a new project", + Long: "Initialize a new project with the specified parameters.", + Example: InitCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runInitCommand(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + utils.AddStringFlag(initCmd, utils.FlagName, &displayName, "", "Display name of the artifact") + utils.AddStringFlag(initCmd, utils.FlagType, &projectType, "", "Type of the artifact") + utils.AddBoolFlag(initCmd, utils.FlagNoInteractive, &addNoInteractive, false, "Skip interactive prompts") +} + +func runInitCommand() error { + var err error + if !addNoInteractive { + if strings.TrimSpace(displayName) == "" { + displayName, err = utils.PromptInput("Enter project name: ") + if err != nil { + return fmt.Errorf("Failed to read display name: %w", err) + } + } + if strings.TrimSpace(projectType) == "" { + projectType, err = utils.PromptSelect("Select artifact type:", project.SupportedArtifactTypes()) + if err != nil { + return fmt.Errorf("Failed to read artifact type: %w", err) + } + } + } + + displayName = strings.TrimSpace(displayName) + + if displayName == "" { + return fmt.Errorf("display name is required") + } + if projectType == "" { + return fmt.Errorf("artifact type is required") + } + + if !project.IsValidArtifactType(projectType) { + return fmt.Errorf("unsupported artifact type: %s (supported types: %s)", projectType, strings.Join(project.SupportedArtifactTypes(), ", ")) + } + + if err := buildDirectoryStructure(displayName, projectType); err != nil { + return err + } + + fmt.Printf("Project created at .%c%s\n", os.PathSeparator, displayName) + return nil +} + +func buildDirectoryStructure(name, artifactType string) error { + if !project.IsValidArtifactType(artifactType) { + return fmt.Errorf("unsupported artifact type: %s (supported types: %s)", artifactType, strings.Join(project.SupportedArtifactTypes(), ", ")) + } + + projectDirName, err := validateProjectDirectoryName(name) + if err != nil { + return err + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to determine current working directory: %w", err) + } + + projectRoot := filepath.Join(cwd, projectDirName) + if _, err := os.Stat(projectRoot); err == nil { + return fmt.Errorf("project directory already exists: %s", projectRoot) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to inspect project directory: %w", err) + } + + directories := []string{ + filepath.Join(projectRoot, ".api-platform"), + filepath.Join(projectRoot, "docs"), + filepath.Join(projectRoot, "tests"), + } + for _, dir := range directories { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + + resourceName := buildResourceName(name) + + configYAML, err := project.BuildConfigYAML() + if err != nil { + return err + } + metadataYAML, err := project.BuildMetadataYAML(artifactType, resourceName, name) + if err != nil { + return err + } + runtimeYAML, err := project.BuildRuntimeYAML(artifactType, resourceName, name) + if err != nil { + return err + } + + files := map[string]string{ + filepath.Join(projectRoot, ".api-platform", "config.yaml"): configYAML, + filepath.Join(projectRoot, "metadata.yaml"): metadataYAML, + filepath.Join(projectRoot, "runtime.yaml"): runtimeYAML, + filepath.Join(projectRoot, "definition.yaml"): project.BuildDefinitionYAML(name), + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + } + + return nil +} + +func validateProjectDirectoryName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("display name is required") + } + + if name == "." || name == ".." { + return "", fmt.Errorf("display name cannot be %q", name) + } + + if strings.ContainsRune(name, os.PathSeparator) { + return "", fmt.Errorf("display name cannot contain path separators") + } + + if os.PathSeparator != '/' && strings.ContainsRune(name, '/') { + return "", fmt.Errorf("display name cannot contain path separators") + } + + return name, nil +} + +func buildResourceName(name string) string { + normalized := strings.ToLower(strings.TrimSpace(name)) + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.ReplaceAll(normalized, " ", "-") + + invalidChars := regexp.MustCompile(`[^a-z0-9.-]+`) + repeatedHyphens := regexp.MustCompile(`-+`) + + normalized = invalidChars.ReplaceAllString(normalized, "-") + normalized = repeatedHyphens.ReplaceAllString(normalized, "-") + normalized = strings.Trim(normalized, "-.") + + if normalized == "" { + normalized = "api" + } + + return normalized +} diff --git a/cli/src/cmd/project/init_test.go b/cli/src/cmd/project/init_test.go new file mode 100644 index 0000000000..163ef065af --- /dev/null +++ b/cli/src/cmd/project/init_test.go @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package project + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wso2/api-platform/cli/utils" +) + +func chdirTemp(t *testing.T) string { + t.Helper() + tempDir := t.TempDir() + + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + t.Cleanup(func() { + if chdirErr := os.Chdir(originalWD); chdirErr != nil { + t.Fatalf("failed to restore working directory: %v", chdirErr) + } + }) + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to change working directory: %v", err) + } + return tempDir +} + +func TestBuildDirectoryStructureCreatesExpectedFiles(t *testing.T) { + tempDir := chdirTemp(t) + + if err := buildDirectoryStructure("FooAPI", utils.TypeREST); err != nil { + t.Fatalf("buildDirectoryStructure returned an error: %v", err) + } + + projectRoot := filepath.Join(tempDir, "FooAPI") + expectedPaths := []string{ + filepath.Join(projectRoot, ".api-platform", "config.yaml"), + filepath.Join(projectRoot, "metadata.yaml"), + filepath.Join(projectRoot, "runtime.yaml"), + filepath.Join(projectRoot, "definition.yaml"), + filepath.Join(projectRoot, "docs"), + filepath.Join(projectRoot, "tests"), + } + + for _, expectedPath := range expectedPaths { + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("expected path to exist: %s (%v)", expectedPath, err) + } + } + + runtimeYAML := readFile(t, filepath.Join(projectRoot, "runtime.yaml")) + if !strings.Contains(runtimeYAML, "kind: RestApi") { + t.Fatalf("runtime.yaml does not contain the expected kind: %q", runtimeYAML) + } + if !strings.Contains(runtimeYAML, "displayName: FooAPI") { + t.Fatalf("runtime.yaml does not contain the expected display name: %q", runtimeYAML) + } + if !strings.Contains(runtimeYAML, "method: OPTIONS") { + t.Fatalf("runtime.yaml does not contain the OPTIONS operation: %q", runtimeYAML) + } + + metadataYAML := readFile(t, filepath.Join(projectRoot, "metadata.yaml")) + if !strings.Contains(metadataYAML, "apiVersion: management.api-platform.wso2.com/v1") { + t.Fatalf("metadata.yaml does not carry the management apiVersion: %q", metadataYAML) + } + if !strings.Contains(metadataYAML, "businessInformation:") { + t.Fatalf("management metadata.yaml should contain businessInformation: %q", metadataYAML) + } + + configYAML := readFile(t, filepath.Join(projectRoot, ".api-platform", "config.yaml")) + for _, want := range []string{"deploymentArtifact: ./runtime.yaml", "metadataFile: ./metadata.yaml", "definition: ./definition.yaml"} { + if !strings.Contains(configYAML, want) { + t.Fatalf("config.yaml missing %q: %s", want, configYAML) + } + } + + definitionYAML := readFile(t, filepath.Join(projectRoot, "definition.yaml")) + if !strings.Contains(definitionYAML, `"/*":`) { + t.Fatalf("definition.yaml does not contain the wildcard path") + } + if !strings.Contains(definitionYAML, "options:") { + t.Fatalf("definition.yaml does not contain the OPTIONS operation") + } +} + +func TestBuildDirectoryStructureAIWorkspaceMetadata(t *testing.T) { + tempDir := chdirTemp(t) + + if err := buildDirectoryStructure("OpenAI Dev LLM", utils.TypeLLMProxy); err != nil { + t.Fatalf("buildDirectoryStructure returned an error: %v", err) + } + + projectRoot := filepath.Join(tempDir, "OpenAI Dev LLM") + metadataYAML := readFile(t, filepath.Join(projectRoot, "metadata.yaml")) + + for _, want := range []string{ + "apiVersion: ai-workspace.api-platform.wso2.com/v1alpha", + "kind: LlmProxyMetadata", + "name: openai-dev-llm", + "displayName: OpenAI Dev LLM", + } { + if !strings.Contains(metadataYAML, want) { + t.Fatalf("ai-workspace metadata.yaml missing %q: %s", want, metadataYAML) + } + } + + // The slim ai-workspace metadata must not carry the management-only blocks. + if strings.Contains(metadataYAML, "businessInformation:") || strings.Contains(metadataYAML, "endpoints:") { + t.Fatalf("ai-workspace metadata.yaml should not contain management-only fields: %s", metadataYAML) + } + + runtimeYAML := readFile(t, filepath.Join(projectRoot, "runtime.yaml")) + if !strings.Contains(runtimeYAML, "kind: LlmProxy") { + t.Fatalf("runtime.yaml should carry the artifact kind: %s", runtimeYAML) + } +} + +func TestBuildDirectoryStructureRejectsUnsupportedType(t *testing.T) { + chdirTemp(t) + + err := buildDirectoryStructure("FooAPI", "graphql") + if err == nil || !strings.Contains(err.Error(), "unsupported artifact type") { + t.Fatalf("expected unsupported artifact type error, got %v", err) + } + if !strings.Contains(err.Error(), "supported types:") { + t.Fatalf("expected error to list supported types, got %v", err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + return string(data) +} diff --git a/cli/src/cmd/apiproject/root.go b/cli/src/cmd/project/root.go similarity index 64% rename from cli/src/cmd/apiproject/root.go rename to cli/src/cmd/project/root.go index fbaf768e29..c9e1264f0c 100644 --- a/cli/src/cmd/apiproject/root.go +++ b/cli/src/cmd/project/root.go @@ -15,26 +15,26 @@ * specific language governing permissions and limitations * under the License. */ -package apiproject +package project import "github.com/spf13/cobra" const ( - ApiProjectCmdLiteral = "apiproject" - ApiProjectCmdExample = `# Add a new API project -ap apiproject init --display-name foo-api --type rest --version 1.0 --context /foo` + ProjectCmdLiteral = "project" + ProjectCmdExample = `# Initialize a new project +ap project init --display-name foo-api --type rest` ) -var ApiProjectCmd = &cobra.Command{ - Use: ApiProjectCmdLiteral, - Short: "Execute API project operations", - Long: "This command allows you to manage API projects.", - Example: ApiProjectCmdExample, +var ProjectCmd = &cobra.Command{ + Use: ProjectCmdLiteral, + Short: "Execute project operations", + Long: "This command allows you to manage projects (REST/SOAP/GraphQL APIs, LLM proxies/providers, MCP proxies).", + Example: ProjectCmdExample, Run: func(cmd *cobra.Command, args []string) { cmd.Help() }, } func init() { - ApiProjectCmd.AddCommand(initCmd) + ProjectCmd.AddCommand(initCmd) } \ No newline at end of file diff --git a/cli/src/cmd/root.go b/cli/src/cmd/root.go index 68fa1ed94a..ec3350a454 100644 --- a/cli/src/cmd/root.go +++ b/cli/src/cmd/root.go @@ -23,10 +23,11 @@ import ( "os" "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/cmd/aiworkspace" "github.com/wso2/api-platform/cli/cmd/devportal" "github.com/wso2/api-platform/cli/cmd/gateway" "github.com/wso2/api-platform/cli/cmd/platform" - "github.com/wso2/api-platform/cli/cmd/apiproject" + "github.com/wso2/api-platform/cli/cmd/project" "github.com/wso2/api-platform/cli/utils" ) @@ -67,10 +68,11 @@ var versionCmd = &cobra.Command{ } func init() { + rootCmd.AddCommand(aiws.AiWSCmd) rootCmd.AddCommand(devportal.DevPortalCmd) rootCmd.AddCommand(gateway.GatewayCmd) rootCmd.AddCommand(platform.PlatformCmd) - rootCmd.AddCommand(apiproject.ApiProjectCmd) + rootCmd.AddCommand(project.ProjectCmd) rootCmd.AddCommand(versionCmd) } diff --git a/cli/src/internal/aiworkspace/client.go b/cli/src/internal/aiworkspace/client.go new file mode 100644 index 0000000000..e52ba87e77 --- /dev/null +++ b/cli/src/internal/aiworkspace/client.go @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package aiworkspace holds the HTTP client used by the `ap ai-workspace` commands to +// talk to an AI Workspace server (LLM proxies / providers). +package aiworkspace + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +type Client struct { + aiWorkspace *config.AIWorkspace + httpClient *http.Client +} + +type credCtxKey struct{} + +func NewClient(aiWorkspace *config.AIWorkspace) *Client { + return NewClientWithOptions(aiWorkspace, false) +} + +func NewClientWithOptions(aiWorkspace *config.AIWorkspace, insecure bool) *Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: insecure, + }, + } + + return &Client{ + aiWorkspace: aiWorkspace, + httpClient: &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + }, + } +} + +// PutJSON sends a JSON body with the PUT method. +func (c *Client) PutJSON(path string, body []byte) (*http.Response, error) { + return c.sendJSON(http.MethodPut, path, body) +} + +// PostJSON sends a JSON body with the POST method. +func (c *Client) PostJSON(path string, body []byte) (*http.Response, error) { + return c.sendJSON(http.MethodPost, path, body) +} + +// Get sends a GET request and returns the response when it is a 2xx. +func (c *Client) Get(path string) (*http.Response, error) { + return c.sendNoBody(http.MethodGet, path) +} + +// Delete sends a DELETE request and returns the response when it is a 2xx. +func (c *Client) Delete(path string) (*http.Response, error) { + return c.sendNoBody(http.MethodDelete, path) +} + +func (c *Client) sendNoBody(method, path string) (*http.Response, error) { + baseURL := strings.TrimSuffix(c.aiWorkspace.URL, "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + req, err := http.NewRequest(method, baseURL+path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + if baseURL != "" { + req.Header.Set("Origin", baseURL) + } + + resp, err := c.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + return nil, c.formatHTTPError(fmt.Sprintf("%s %s", method, path), resp) +} + +func (c *Client) sendJSON(method, path string, body []byte) (*http.Response, error) { + baseURL := strings.TrimSuffix(c.aiWorkspace.URL, "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if baseURL != "" { + req.Header.Set("Origin", baseURL) + } + + resp, err := c.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + return nil, c.formatHTTPError(fmt.Sprintf("%s %s", method, path), resp) +} + +// Do attaches credentials based on the configured auth type (with environment +// variable overrides) and sends the request. +func (c *Client) Do(req *http.Request) (*http.Response, error) { + authType := c.aiWorkspace.Auth.Type + var credSource utils.CredentialSource + + switch authType { + case utils.AuthTypeBasic: + envUsername := os.Getenv(utils.EnvAIWorkspaceUsername) + envPassword := os.Getenv(utils.EnvAIWorkspacePassword) + if envUsername != "" && envPassword != "" { + req.SetBasicAuth(envUsername, envPassword) + credSource = utils.CredSourceEnv + } else { + if c.aiWorkspace.Auth.Username == "" || c.aiWorkspace.Auth.Password == "" { + return nil, c.missingCredsError(authType, utils.EnvAIWorkspaceUsername+" and "+utils.EnvAIWorkspacePassword) + } + req.SetBasicAuth(c.aiWorkspace.Auth.Username, c.aiWorkspace.Auth.Password) + credSource = utils.CredSourceConfig + } + + case utils.AuthTypeOAuth: + envToken := os.Getenv(utils.EnvAIWorkspaceToken) + if envToken != "" { + req.Header.Set("Authorization", "Bearer "+envToken) + credSource = utils.CredSourceEnv + } else { + if c.aiWorkspace.Auth.Token == "" { + return nil, c.missingCredsError(authType, utils.EnvAIWorkspaceToken) + } + req.Header.Set("Authorization", "Bearer "+c.aiWorkspace.Auth.Token) + credSource = utils.CredSourceConfig + } + + case utils.AuthTypeAPIKey: + envAPIKey := os.Getenv(utils.EnvAIWorkspaceAPIKey) + if envAPIKey != "" { + req.Header.Set(utils.AIWorkspaceAPIHeader, envAPIKey) + credSource = utils.CredSourceEnv + } else { + if c.aiWorkspace.Auth.APIKey == "" { + return nil, c.missingCredsError(authType, utils.EnvAIWorkspaceAPIKey) + } + req.Header.Set(utils.AIWorkspaceAPIHeader, c.aiWorkspace.Auth.APIKey) + credSource = utils.CredSourceConfig + } + + default: + return nil, fmt.Errorf("unsupported auth type '%s' for ai-workspace '%s'", authType, c.aiWorkspace.Name) + } + + req = req.WithContext(context.WithValue(req.Context(), credCtxKey{}, credSource)) + return c.httpClient.Do(req) +} + +func (c *Client) missingCredsError(authType, envVars string) error { + return fmt.Errorf("authentication credentials not found for ai-workspace '%s' (auth type: %s).\nPlease either:\n - Re-add ai-workspace: ap ai-workspace add --display-name %s --server --auth %s\n - Or export: %s", + c.aiWorkspace.Name, authType, c.aiWorkspace.Name, authType, envVars) +} + +func (c *Client) formatHTTPError(operation string, resp *http.Response) error { + var credSource utils.CredentialSource + if resp != nil && resp.Request != nil { + if v := resp.Request.Context().Value(credCtxKey{}); v != nil { + if cs, ok := v.(utils.CredentialSource); ok { + credSource = cs + } + } + } + return utils.FormatHTTPErrorWithCredSource(operation, resp, "AI Workspace", c.aiWorkspace.Auth.Type, credSource, c.aiWorkspace.Name) +} diff --git a/cli/src/internal/aiworkspace/helpers.go b/cli/src/internal/aiworkspace/helpers.go new file mode 100644 index 0000000000..3e49dbed02 --- /dev/null +++ b/cli/src/internal/aiworkspace/helpers.go @@ -0,0 +1,315 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiworkspace + +import ( + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/utils" +) + +// ResolveAIWorkspace resolves the AI Workspace to use from either explicit +// flags or the active AI Workspace in the resolved platform. +func ResolveAIWorkspace(cfg *config.Config, selectedName, selectedPlatform string) (*config.AIWorkspace, string, error) { + selectedName = strings.TrimSpace(selectedName) + selectedPlatform = strings.TrimSpace(selectedPlatform) + + if selectedName != "" { + resolvedPlatform := config.DefaultPlatform + if selectedPlatform != "" { + resolvedPlatform = cfg.ResolvePlatform(selectedPlatform) + } + aiWorkspace, err := cfg.GetAIWorkspaceFromPlatform(resolvedPlatform, selectedName) + if err != nil { + return nil, "", err + } + return aiWorkspace, resolvedPlatform, nil + } + + resolvedPlatform := cfg.ResolvePlatform(selectedPlatform) + aiWorkspace, err := cfg.GetActiveAIWorkspaceFromPlatform(resolvedPlatform) + if err != nil { + return nil, "", err + } + return aiWorkspace, resolvedPlatform, nil +} + +// ProviderPath returns the llm-providers collection path used to create a +// provider. The organization is derived from the auth token, so no +// organizationId query parameter is added. +func ProviderPath() string { + return utils.AIWorkspaceLLMProvidersPath +} + +// ProxyPath returns the llm-proxies collection path used to create a proxy. The +// organization is derived from the auth token, so no organizationId query +// parameter is added. +func ProxyPath() string { + return utils.AIWorkspaceLLMProxiesPath +} + +// MCPProxyPath returns the mcp-proxies collection path used to create an MCP +// proxy. The organization is derived from the auth token, so no organizationId +// query parameter is added. +func MCPProxyPath() string { + return utils.AIWorkspaceMCPProxiesPath +} + +// ProviderByIDPath builds the llm-providers/{id} path with only the id path +// parameter (no organizationId/projectId query). Used for delete. +func ProviderByIDPath(id string) string { + return utils.AIWorkspaceLLMProvidersPath + "/" + url.PathEscape(id) +} + +// ProxyByIDPath builds the llm-proxies/{id} path. Fetching a single proxy takes +// only the id path parameter (no organizationId/projectId query). +func ProxyByIDPath(id string) string { + return utils.AIWorkspaceLLMProxiesPath + "/" + url.PathEscape(id) +} + +// MCPProxyByIDPath builds the mcp-proxies/{id} path. Fetching a single proxy +// takes only the id path parameter (no organizationId/projectId query). +func MCPProxyByIDPath(id string) string { + return utils.AIWorkspaceMCPProxiesPath + "/" + url.PathEscape(id) +} + +func withProject(path, projectID string) string { + return fmt.Sprintf("%s?projectId=%s", path, url.QueryEscape(projectID)) +} + +// ListQuery holds optional pagination parameters for list requests. +type ListQuery struct { + Limit string + Offset string +} + +func withProjectListParams(basePath, projectID string, q ListQuery) string { + return appendPagination(withProject(basePath, projectID), q) +} + +func appendPagination(path string, q ListQuery) string { + // Use "?" for the first query parameter when the path has none yet + // (provider list), otherwise "&" (proxy/mcp list already carry ?projectId=). + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + addParam := func(key, value string) { + if v := strings.TrimSpace(value); v != "" { + path += sep + key + "=" + url.QueryEscape(v) + sep = "&" + } + } + addParam("limit", q.Limit) + addParam("offset", q.Offset) + return path +} + +// ProviderListPath builds the llm-providers list path with optional pagination. +// The organization is derived from the auth token, so no organizationId query +// parameter is added. +func ProviderListPath(q ListQuery) string { + return appendPagination(utils.AIWorkspaceLLMProvidersPath, q) +} + +// ProxyListPath builds the llm-proxies list path with the projectId query +// parameter and optional pagination. +func ProxyListPath(projectID string, q ListQuery) string { + return withProjectListParams(utils.AIWorkspaceLLMProxiesPath, projectID, q) +} + +// MCPProxyListPath builds the mcp-proxies list path with the projectId query +// parameter and optional pagination. +func MCPProxyListPath(projectID string, q ListQuery) string { + return withProjectListParams(utils.AIWorkspaceMCPProxiesPath, projectID, q) +} + +// ReadJSONFile reads and validates a JSON payload file from disk. +func ReadJSONFile(filePath string) ([]byte, error) { + resolvedPath, err := filepath.Abs(strings.TrimSpace(filePath)) + if err != nil { + return nil, fmt.Errorf("failed to resolve file path: %w", err) + } + + info, err := os.Stat(resolvedPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %s", resolvedPath) + } + return nil, fmt.Errorf("failed to inspect file: %w", err) + } + if info.IsDir() { + return nil, fmt.Errorf("file path must point to a JSON file, got directory: %s", resolvedPath) + } + + content, err := os.ReadFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + if !json.Valid(content) { + return nil, fmt.Errorf("file is not valid JSON: %s", resolvedPath) + } + return content, nil +} + +// WrapRequestError formats request failures and adds a hint about --insecure +// when the error is caused by certificate verification. +func WrapRequestError(action string, err error, insecure bool) error { + if shouldSuggestInsecure(err) && !insecure { + return fmt.Errorf("failed to %s: %w\nhint: retry with --insecure if you are using a self-signed or local development certificate", action, err) + } + return fmt.Errorf("failed to %s: %w", action, err) +} + +func shouldSuggestInsecure(err error) bool { + var unknownAuthorityErr x509.UnknownAuthorityError + var hostnameError x509.HostnameError + var certificateInvalidError x509.CertificateInvalidError + const certificateInvalidErrorString = "tls: failed to verify certificate: x509" + + return errors.As(err, &unknownAuthorityErr) || + errors.As(err, &hostnameError) || + errors.As(err, &certificateInvalidError) || + strings.Contains(err.Error(), certificateInvalidErrorString) +} + +// OutputJSON reports whether the requested output format is the full JSON body. +func OutputJSON(format string) bool { + return strings.EqualFold(strings.TrimSpace(format), "json") +} + +// PrintApplyResult prints the result of a create/edit (push/edit) operation in +// the same structured, key-value form as `ap gateway apply`, e.g.: +// +// Status: success +// Message: LlmProvider applied successfully +// ID: wso2-claude-provider +// Organization: 019f2324-... +// Project: 019f2324-... +// Created At: 2026-07-03T06:31:22Z +// Updated At: 2026-07-03T06:31:22Z +// State: deployed +// +// kind is the artifact kind ("LlmProvider", "LlmProxy", "Mcp"); action is the +// past-tense verb ("applied" for create, "updated" for edit). Organization / +// Project / Created At / Updated At / State are printed only when known. +// Organization comes from the response (the CLI derives it from the auth token, +// so it only shows when the server echoes organizationId); Project comes from +// the response or, failing that, the locally supplied fallbackProject +// (--project-id). When the output format is "json" the full response body is +// pretty-printed instead, so the command stays scriptable (e.g. `... -o json | jq`). +// +// fallbackID / fallbackProject are the values known locally (from the pushed +// payload / --project-id); they are used when the server response omits them. +func PrintApplyResult(resp *http.Response, outputFormat, kind, action, fallbackID, fallbackProject string) error { + if OutputJSON(outputFormat) { + return PrintJSONResponse(resp) + } + + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var m map[string]interface{} + _ = json.Unmarshal(body, &m) + + id := stringField(m, "id") + if id == "" { + id = strings.TrimSpace(fallbackID) + } + project := stringField(m, "projectId") + if project == "" { + project = strings.TrimSpace(fallbackProject) + } + + fmt.Println("Status: success") + fmt.Printf("Message: %s %s successfully\n", kind, action) + if id != "" { + fmt.Printf("ID: %s\n", id) + } + if v := stringField(m, "organizationId"); v != "" { + fmt.Printf("Organization: %s\n", v) + } + if project != "" { + fmt.Printf("Project: %s\n", project) + } + if v := stringField(m, "createdAt"); v != "" { + fmt.Printf("Created At: %s\n", v) + } + if v := stringField(m, "updatedAt"); v != "" { + fmt.Printf("Updated At: %s\n", v) + } + // The LLM provider/proxy and MCP proxy responses carry the deployment state + // in the top-level "status" field (pending/deployed/failed). + if v := stringField(m, "status"); v != "" { + fmt.Printf("State: %s\n", v) + } + return nil +} + +// stringField returns the trimmed string value of m[key], or "" when absent or +// not a string. +func stringField(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return strings.TrimSpace(v) +} + +// PrintJSONResponse prints an HTTP response as pretty JSON when possible and +// falls back to the raw trimmed body otherwise. +func PrintJSONResponse(resp *http.Response) error { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return nil + } + + var jsonBody interface{} + if err := json.Unmarshal(body, &jsonBody); err == nil { + if pretty, marshalErr := json.MarshalIndent(jsonBody, "", " "); marshalErr == nil { + fmt.Println(string(pretty)) + return nil + } + } + + fmt.Println(trimmed) + return nil +} + +func ResourceID(displayName, fallback string) string { + id := strings.Join(strings.Fields(strings.ToLower(displayName)), "-") + if id == "" { + return fallback + } + return id +} diff --git a/cli/src/internal/aiworkspace/paths_test.go b/cli/src/internal/aiworkspace/paths_test.go new file mode 100644 index 0000000000..3e87dbd7cc --- /dev/null +++ b/cli/src/internal/aiworkspace/paths_test.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiworkspace + +import ( + "testing" + + "github.com/wso2/api-platform/cli/utils" +) + +func TestProviderListPath_NoOrgWithPagination(t *testing.T) { + base := utils.AIWorkspaceLLMProvidersPath + + if got := ProviderListPath(ListQuery{}); got != base { + t.Fatalf("no pagination: got %q, want %q", got, base) + } + if got := ProviderListPath(ListQuery{Limit: "50"}); got != base+"?limit=50" { + t.Fatalf("limit only: got %q", got) + } + if got := ProviderListPath(ListQuery{Limit: "50", Offset: "10"}); got != base+"?limit=50&offset=10" { + t.Fatalf("limit+offset: got %q", got) + } + if got := ProviderListPath(ListQuery{Offset: "10"}); got != base+"?offset=10" { + t.Fatalf("offset only: got %q", got) + } +} + +func TestProviderByIDPath_NoQuery(t *testing.T) { + want := utils.AIWorkspaceLLMProvidersPath + "/wso2-claude" + if got := ProviderByIDPath("wso2-claude"); got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestProxyListPath_KeepsProjectIdThenAmpersand(t *testing.T) { + // Project-scoped list still uses ?projectId=... and & for pagination. + got := ProxyListPath("proj-1", ListQuery{Limit: "5"}) + want := utils.AIWorkspaceLLMProxiesPath + "?projectId=proj-1&limit=5" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/cli/src/internal/aiworkspace/print_test.go b/cli/src/internal/aiworkspace/print_test.go new file mode 100644 index 0000000000..9460f9c8f4 --- /dev/null +++ b/cli/src/internal/aiworkspace/print_test.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aiworkspace + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/wso2/api-platform/cli/test/testutil" +) + +func responseWith(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{}, + } +} + +func TestPrintApplyResult_StructuredOutput(t *testing.T) { + body := `{"id":"claude-proxy2","organizationId":"org-1","projectId":"proj-1","createdAt":"2026-07-03T06:31:22Z","updatedAt":"2026-07-03T06:31:22Z","status":"deployed"}` + resp := responseWith(body) + + out := testutil.CaptureStdout(t, func() { + if err := PrintApplyResult(resp, "", "LlmProxy", "applied", "fallback-id", "fallback-proj"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + for _, want := range []string{ + "Status: success", + "Message: LlmProxy applied successfully", + "ID: claude-proxy2", + "Organization: org-1", + "Project: proj-1", + "Created At: 2026-07-03T06:31:22Z", + "Updated At: 2026-07-03T06:31:22Z", + "State: deployed", + } { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q\n---\n%s", want, out) + } + } +} + +func TestPrintApplyResult_FallbackProjectAndNoOrgWhenAbsent(t *testing.T) { + // Response omits organizationId and projectId: Project falls back to the + // locally supplied --project-id, Organization is dropped entirely. + resp := responseWith(`{"id":"p"}`) + + out := testutil.CaptureStdout(t, func() { + if err := PrintApplyResult(resp, "", "LlmProxy", "updated", "fallback-id", "local-proj"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "Project: local-proj") { + t.Fatalf("expected fallback project, got:\n%s", out) + } + if strings.Contains(out, "Organization:") { + t.Fatalf("expected no Organization line when absent, got:\n%s", out) + } +} + +func TestPrintApplyResult_UsesFallbackIDAndOmitsMissingFields(t *testing.T) { + // Response without id/timestamps/status and no project fallback: id falls + // back, optional lines dropped. + resp := responseWith(`{}`) + + out := testutil.CaptureStdout(t, func() { + if err := PrintApplyResult(resp, "", "LlmProvider", "updated", "fallback-id", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "Message: LlmProvider updated successfully") { + t.Fatalf("expected updated message, got:\n%s", out) + } + if !strings.Contains(out, "ID: fallback-id") { + t.Fatalf("expected fallback id, got:\n%s", out) + } + if strings.Contains(out, "Created At:") || strings.Contains(out, "State:") || + strings.Contains(out, "Organization:") || strings.Contains(out, "Project:") { + t.Fatalf("expected no optional lines, got:\n%s", out) + } +} + +func TestPrintApplyResult_JSONOutputPassthrough(t *testing.T) { + resp := responseWith(`{"id":"x","status":"deployed"}`) + + out := testutil.CaptureStdout(t, func() { + if err := PrintApplyResult(resp, "json", "Mcp", "applied", "x", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + // json output mode prints the raw response, not the structured summary. + if strings.Contains(out, "Status: success") { + t.Fatalf("expected raw json output, got structured summary:\n%s", out) + } + if !strings.Contains(out, "\"status\": \"deployed\"") { + t.Fatalf("expected pretty-printed json, got:\n%s", out) + } +} diff --git a/cli/src/internal/config/config.go b/cli/src/internal/config/config.go index b0b51509bf..6d10eedc3d 100644 --- a/cli/src/internal/config/config.go +++ b/cli/src/internal/config/config.go @@ -41,10 +41,10 @@ type AuthConfig struct { // Gateway represents a gateway configuration. type Gateway struct { - Name string `yaml:"-"` - Server string `yaml:"server"` - Auth AuthConfig `yaml:"auth,omitempty"` - AdminServer string `yaml:"adminServer,omitempty"` + Name string `yaml:"-"` + Server string `yaml:"server"` + Auth AuthConfig `yaml:"auth,omitempty"` + AdminServer string `yaml:"adminServer,omitempty"` } // DevPortal represents a developer portal configuration. @@ -54,12 +54,21 @@ type DevPortal struct { Auth AuthConfig `yaml:"auth,omitempty"` } +// AIWorkspace represents an AI workspace configuration. +type AIWorkspace struct { + Name string `yaml:"-"` + URL string `yaml:"url"` + Auth AuthConfig `yaml:"auth,omitempty"` +} + // Platform groups the CLI resources that belong to a single platform. type Platform struct { - Gateways map[string]*Gateway `yaml:"gateways,omitempty"` - ActiveGateway string `yaml:"activeGateway,omitempty"` - DevPortals map[string]*DevPortal `yaml:"devportals,omitempty"` - ActiveDevPortal string `yaml:"activeDevPortal,omitempty"` + Gateways map[string]*Gateway `yaml:"gateways,omitempty"` + ActiveGateway string `yaml:"activeGateway,omitempty"` + DevPortals map[string]*DevPortal `yaml:"devportals,omitempty"` + ActiveDevPortal string `yaml:"activeDevPortal,omitempty"` + AIWorkspaces map[string]*AIWorkspace `yaml:"aiWorkspaces,omitempty"` + ActiveAIWorkspace string `yaml:"activeAIWorkspace,omitempty"` } // Config represents the ap configuration file. @@ -94,6 +103,15 @@ func normalizeDevPortalAuth(devPortal *DevPortal) { } } +func normalizeAIWorkspaceAuth(aiWorkspace *AIWorkspace) { + if aiWorkspace == nil { + return + } + if aiWorkspace.Auth.Type == "" { + aiWorkspace.Auth.Type = utils.AuthTypeAPIKey + } +} + func normalizePlatform(platform *Platform) { if platform == nil { return @@ -120,6 +138,17 @@ func normalizePlatform(platform *Platform) { devPortal.Name = name normalizeDevPortalAuth(devPortal) } + if platform.AIWorkspaces == nil { + platform.AIWorkspaces = map[string]*AIWorkspace{} + } + for name, aiWorkspace := range platform.AIWorkspaces { + if aiWorkspace == nil { + aiWorkspace = &AIWorkspace{} + platform.AIWorkspaces[name] = aiWorkspace + } + aiWorkspace.Name = name + normalizeAIWorkspaceAuth(aiWorkspace) + } } func (c *Config) ensurePlatform(platform string) *Platform { @@ -275,14 +304,37 @@ func SaveConfig(config *Config) error { return nil } -func (c *Config) AddGatewayToPlatform(platformName string, gateway Gateway) error { +// resourceSection describes one named-resource map on a Platform plus its +// companion "active" pointer. A single value of this type captures everything +// that varies between gateways, devportals and ai-workspaces (which map to +// read, which active field to track, the error label, and how to read/write a +// resource's name and per-kind defaults), so the generic CRUD helpers below are +// written once and reused across all three resource kinds. +// +// The accessors exist because Go generics abstract over behaviour, not struct +// fields: a type parameter T cannot reach `.Name`, `p.DevPortals`, etc. +// directly, so the section supplies small closures that do. +type resourceSection[T any] struct { + label string // resource name used in error messages + items func(*Platform) map[string]*T // the per-kind map on a Platform + active func(*Platform) string // reads the "active" pointer + setActive func(*Platform, string) // writes the "active" pointer + getName func(*T) string // reads a resource's name + setName func(*T, string) // writes a resource's name + normalize func(*T) // applies per-kind defaults (e.g. auth type) +} + +// add stores resource under its trimmed name, making it active if no active +// resource is set yet. +func (s resourceSection[T]) add(c *Config, platformName string, resource *T) error { platformName = c.ResolvePlatform(platformName) platform := c.ensurePlatform(platformName) - gateway.Name = strings.TrimSpace(gateway.Name) - normalizeGatewayAuth(&gateway) - platform.Gateways[gateway.Name] = &gateway - if platform.ActiveGateway == "" { - platform.ActiveGateway = gateway.Name + name := strings.TrimSpace(s.getName(resource)) + s.setName(resource, name) + s.normalize(resource) + s.items(platform)[name] = resource + if s.active(platform) == "" { + s.setActive(platform, name) } if c.CurrentPlatform == "" { c.CurrentPlatform = platformName @@ -290,38 +342,104 @@ func (c *Config) AddGatewayToPlatform(platformName string, gateway Gateway) erro return nil } -func (c *Config) AddGateway(gateway Gateway) error { - return c.AddGatewayToPlatform("", gateway) +// get looks up a resource by name, stamping the name back onto the returned +// value so callers always see it populated. +func (s resourceSection[T]) get(c *Config, platformName, name string) (*T, error) { + platformName = c.ResolvePlatform(platformName) + platform := c.ensurePlatform(platformName) + resource, ok := s.items(platform)[name] + if !ok { + return nil, fmt.Errorf("%s '%s' not found in platform '%s'", s.label, name, platformName) + } + s.setName(resource, name) + return resource, nil } -func (c *Config) AddDevPortalToPlatform(platformName string, devPortal DevPortal) error { +// getActive returns the platform's currently active resource of this kind. +func (s resourceSection[T]) getActive(c *Config, platformName string) (*T, error) { platformName = c.ResolvePlatform(platformName) platform := c.ensurePlatform(platformName) - devPortal.Name = strings.TrimSpace(devPortal.Name) - normalizeDevPortalAuth(&devPortal) - platform.DevPortals[devPortal.Name] = &devPortal - if platform.ActiveDevPortal == "" { - platform.ActiveDevPortal = devPortal.Name + activeName := s.active(platform) + if activeName == "" { + return nil, fmt.Errorf("no active %s set for platform '%s'", s.label, platformName) } - if c.CurrentPlatform == "" { - c.CurrentPlatform = platformName - } - return nil + return s.get(c, platformName, activeName) } -func (c *Config) AddDevPortal(devPortal DevPortal) error { - return c.AddDevPortalToPlatform("", devPortal) +// setActiveByName marks an existing resource as active (failing if it does not +// exist) and switches the current platform to its owner. +func (s resourceSection[T]) setActiveByName(c *Config, platformName, name string) error { + platformName = c.ResolvePlatform(platformName) + if _, err := s.get(c, platformName, name); err != nil { + return err + } + platform := c.ensurePlatform(platformName) + s.setActive(platform, name) + c.CurrentPlatform = platformName + return nil } -func (c *Config) GetGatewayFromPlatform(platformName, name string) (*Gateway, error) { +// remove deletes a resource, clearing the active pointer when it referenced the +// removed resource. +func (s resourceSection[T]) remove(c *Config, platformName, name string) error { platformName = c.ResolvePlatform(platformName) platform := c.ensurePlatform(platformName) - gateway, ok := platform.Gateways[name] - if !ok { - return nil, fmt.Errorf("gateway '%s' not found in platform '%s'", name, platformName) + if _, ok := s.items(platform)[name]; !ok { + return fmt.Errorf("%s '%s' not found in platform '%s'", s.label, name, platformName) + } + delete(s.items(platform), name) + if s.active(platform) == name { + s.setActive(platform, "") } - gateway.Name = name - return gateway, nil + return nil +} + +// Per-kind section descriptors. These are the only place the concrete fields of +// each resource are wired into the generic helpers. +var ( + gatewaySection = resourceSection[Gateway]{ + label: "gateway", + items: func(p *Platform) map[string]*Gateway { return p.Gateways }, + active: func(p *Platform) string { return p.ActiveGateway }, + setActive: func(p *Platform, n string) { p.ActiveGateway = n }, + getName: func(g *Gateway) string { return g.Name }, + setName: func(g *Gateway, n string) { g.Name = n }, + normalize: normalizeGatewayAuth, + } + + devPortalSection = resourceSection[DevPortal]{ + label: "devportal", + items: func(p *Platform) map[string]*DevPortal { return p.DevPortals }, + active: func(p *Platform) string { return p.ActiveDevPortal }, + setActive: func(p *Platform, n string) { p.ActiveDevPortal = n }, + getName: func(d *DevPortal) string { return d.Name }, + setName: func(d *DevPortal, n string) { d.Name = n }, + normalize: normalizeDevPortalAuth, + } + + aiWorkspaceSection = resourceSection[AIWorkspace]{ + label: "ai-workspace", + items: func(p *Platform) map[string]*AIWorkspace { return p.AIWorkspaces }, + active: func(p *Platform) string { return p.ActiveAIWorkspace }, + setActive: func(p *Platform, n string) { p.ActiveAIWorkspace = n }, + getName: func(w *AIWorkspace) string { return w.Name }, + setName: func(w *AIWorkspace, n string) { w.Name = n }, + normalize: normalizeAIWorkspaceAuth, + } +) + +// --- Gateway public API (thin, type-safe wrappers over gatewaySection) --- + +func (c *Config) AddGatewayToPlatform(platformName string, gateway Gateway) error { + return gatewaySection.add(c, platformName, &gateway) +} + +func (c *Config) AddGateway(gateway Gateway) error { + return c.AddGatewayToPlatform("", gateway) +} + +func (c *Config) GetGatewayFromPlatform(platformName, name string) (*Gateway, error) { + return gatewaySection.get(c, platformName, name) } func (c *Config) GetGateway(name string) (*Gateway, error) { @@ -329,12 +447,7 @@ func (c *Config) GetGateway(name string) (*Gateway, error) { } func (c *Config) GetActiveGatewayFromPlatform(platformName string) (*Gateway, error) { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - if platform.ActiveGateway == "" { - return nil, fmt.Errorf("no active gateway set for platform '%s'", platformName) - } - return c.GetGatewayFromPlatform(platformName, platform.ActiveGateway) + return gatewaySection.getActive(c, platformName) } func (c *Config) GetActiveGateway() (*Gateway, error) { @@ -342,14 +455,7 @@ func (c *Config) GetActiveGateway() (*Gateway, error) { } func (c *Config) SetActiveGatewayForPlatform(platformName, name string) error { - platformName = c.ResolvePlatform(platformName) - if _, err := c.GetGatewayFromPlatform(platformName, name); err != nil { - return err - } - platform := c.ensurePlatform(platformName) - platform.ActiveGateway = name - c.CurrentPlatform = platformName - return nil + return gatewaySection.setActiveByName(c, platformName, name) } func (c *Config) SetActiveGateway(name string) error { @@ -357,31 +463,25 @@ func (c *Config) SetActiveGateway(name string) error { } func (c *Config) RemoveGatewayFromPlatform(platformName, name string) error { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - if _, ok := platform.Gateways[name]; !ok { - return fmt.Errorf("gateway '%s' not found in platform '%s'", name, platformName) - } - delete(platform.Gateways, name) - if platform.ActiveGateway == name { - platform.ActiveGateway = "" - } - return nil + return gatewaySection.remove(c, platformName, name) } func (c *Config) RemoveGateway(name string) error { return c.RemoveGatewayFromPlatform("", name) } +// --- DevPortal public API (thin, type-safe wrappers over devPortalSection) --- + +func (c *Config) AddDevPortalToPlatform(platformName string, devPortal DevPortal) error { + return devPortalSection.add(c, platformName, &devPortal) +} + +func (c *Config) AddDevPortal(devPortal DevPortal) error { + return c.AddDevPortalToPlatform("", devPortal) +} + func (c *Config) GetDevPortalFromPlatform(platformName, name string) (*DevPortal, error) { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - devPortal, ok := platform.DevPortals[name] - if !ok { - return nil, fmt.Errorf("devportal '%s' not found in platform '%s'", name, platformName) - } - devPortal.Name = name - return devPortal, nil + return devPortalSection.get(c, platformName, name) } func (c *Config) GetDevPortal(name string) (*DevPortal, error) { @@ -389,12 +489,7 @@ func (c *Config) GetDevPortal(name string) (*DevPortal, error) { } func (c *Config) GetActiveDevPortalFromPlatform(platformName string) (*DevPortal, error) { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - if platform.ActiveDevPortal == "" { - return nil, fmt.Errorf("no active devportal set for platform '%s'", platformName) - } - return c.GetDevPortalFromPlatform(platformName, platform.ActiveDevPortal) + return devPortalSection.getActive(c, platformName) } func (c *Config) GetActiveDevPortal() (*DevPortal, error) { @@ -402,14 +497,7 @@ func (c *Config) GetActiveDevPortal() (*DevPortal, error) { } func (c *Config) SetActiveDevPortalForPlatform(platformName, name string) error { - platformName = c.ResolvePlatform(platformName) - if _, err := c.GetDevPortalFromPlatform(platformName, name); err != nil { - return err - } - platform := c.ensurePlatform(platformName) - platform.ActiveDevPortal = name - c.CurrentPlatform = platformName - return nil + return devPortalSection.setActiveByName(c, platformName, name) } func (c *Config) SetActiveDevPortal(name string) error { @@ -417,18 +505,66 @@ func (c *Config) SetActiveDevPortal(name string) error { } func (c *Config) RemoveDevPortalFromPlatform(platformName, name string) error { - platformName = c.ResolvePlatform(platformName) - platform := c.ensurePlatform(platformName) - if _, ok := platform.DevPortals[name]; !ok { - return fmt.Errorf("devportal '%s' not found in platform '%s'", name, platformName) - } - delete(platform.DevPortals, name) - if platform.ActiveDevPortal == name { - platform.ActiveDevPortal = "" - } - return nil + return devPortalSection.remove(c, platformName, name) } func (c *Config) RemoveDevPortal(name string) error { return c.RemoveDevPortalFromPlatform("", name) } + +// --- AIWorkspace public API (thin, type-safe wrappers over aiWorkspaceSection) --- + +func (c *Config) AddAIWorkspaceToPlatform(platformName string, aiWorkspace AIWorkspace) error { + return aiWorkspaceSection.add(c, platformName, &aiWorkspace) +} + +func (c *Config) AddAIWorkspace(aiWorkspace AIWorkspace) error { + return c.AddAIWorkspaceToPlatform("", aiWorkspace) +} + +func (c *Config) GetAIWorkspaceFromPlatform(platformName, name string) (*AIWorkspace, error) { + return aiWorkspaceSection.get(c, platformName, name) +} + +func (c *Config) GetAIWorkspace(name string) (*AIWorkspace, error) { + return c.GetAIWorkspaceFromPlatform("", name) +} + +func (c *Config) GetActiveAIWorkspaceFromPlatform(platformName string) (*AIWorkspace, error) { + return aiWorkspaceSection.getActive(c, platformName) +} + +func (c *Config) GetActiveAIWorkspace() (*AIWorkspace, error) { + return c.GetActiveAIWorkspaceFromPlatform("") +} + +func (c *Config) SetActiveAIWorkspaceForPlatform(platformName, name string) error { + return aiWorkspaceSection.setActiveByName(c, platformName, name) +} + +func (c *Config) SetActiveAIWorkspace(name string) error { + return c.SetActiveAIWorkspaceForPlatform("", name) +} + +func (c *Config) RemoveAIWorkspaceFromPlatform(platformName, name string) error { + return aiWorkspaceSection.remove(c, platformName, name) +} + +func (c *Config) RemoveAIWorkspace(name string) error { + return c.RemoveAIWorkspaceFromPlatform("", name) +} + +// RemovePlatform deletes a platform and all of its connections from the config. +// If the removed platform was the current one, the current selection is reset so +// it falls back to the default platform. +func (c *Config) RemovePlatform(platformName string) error { + name := normalizePlatformName(platformName) + if c.Platforms == nil || c.Platforms[name] == nil { + return fmt.Errorf("platform '%s' not found", name) + } + delete(c.Platforms, name) + if normalizePlatformName(c.CurrentPlatform) == name { + c.CurrentPlatform = "" + } + return nil +} diff --git a/cli/src/internal/config/remove_platform_test.go b/cli/src/internal/config/remove_platform_test.go new file mode 100644 index 0000000000..9e7b48ac68 --- /dev/null +++ b/cli/src/internal/config/remove_platform_test.go @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "strings" + "testing" +) + +func TestRemovePlatform_DeletesPlatform(t *testing.T) { + cfg := &Config{ + CurrentPlatform: "eu", + Platforms: map[string]*Platform{ + "eu": {}, + "default": {}, + }, + } + + if err := cfg.RemovePlatform("eu"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := cfg.Platforms["eu"]; ok { + t.Fatalf("expected platform 'eu' to be removed") + } + // Removing the current platform resets the selection to the default. + if got := cfg.GetCurrentPlatform(); got != DefaultPlatform { + t.Fatalf("expected current platform to reset to %q, got %q", DefaultPlatform, got) + } +} + +func TestRemovePlatform_KeepsCurrentWhenOtherRemoved(t *testing.T) { + cfg := &Config{ + CurrentPlatform: "eu", + Platforms: map[string]*Platform{ + "eu": {}, + "us": {}, + }, + } + + if err := cfg.RemovePlatform("us"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.GetCurrentPlatform() != "eu" { + t.Fatalf("expected current platform to stay 'eu', got %q", cfg.GetCurrentPlatform()) + } +} + +func TestRemovePlatform_NotFound(t *testing.T) { + cfg := &Config{Platforms: map[string]*Platform{"default": {}}} + + err := cfg.RemovePlatform("missing") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not-found error, got %v", err) + } +} diff --git a/cli/src/internal/devportal/helpers.go b/cli/src/internal/devportal/helpers.go index 23fda11c2a..b920fcb8b3 100644 --- a/cli/src/internal/devportal/helpers.go +++ b/cli/src/internal/devportal/helpers.go @@ -25,6 +25,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -32,6 +33,25 @@ import ( "github.com/wso2/api-platform/cli/internal/config" ) +// APIVersion is the Developer Portal REST API version segment used in all +// organization-scoped resource paths. Bump this in one place when the API +// version changes (for example "v1" -> "v2"). +const APIVersion = "v1" + +// OrgScopedPath builds an organization-scoped Developer Portal resource path of +// the form /o/{orgId}/devportal/{version}/{resource}. The orgID is path-escaped +// here, so callers pass it raw. The resource is appended as-is, so callers +// escape any path segments they interpolate and may include a trailing query +// string (for example "apis/"+url.PathEscape(apiID) or "api-keys?apiId=x"). +// +// Only organization-management endpoints (under /organizations) live outside +// this prefix; every other devportal endpoint should be built through here so +// the /o/{orgId}/devportal/{version} prefix is defined in a single place. +func OrgScopedPath(orgID, resource string) string { + return fmt.Sprintf("/o/%s/devportal/%s/%s", + url.PathEscape(orgID), APIVersion, strings.TrimPrefix(resource, "/")) +} + // ResolveDevPortal resolves the DevPortal to use from either explicit flags // or the active DevPortal in the resolved platform. func ResolveDevPortal(cfg *config.Config, selectedName, selectedPlatform string) (*config.DevPortal, string, error) { diff --git a/cli/src/internal/gateway/resources.go b/cli/src/internal/gateway/resources.go index d161a2aa49..95f94ca4ac 100644 --- a/cli/src/internal/gateway/resources.go +++ b/cli/src/internal/gateway/resources.go @@ -26,8 +26,10 @@ import ( // ResourceKind represents the type of gateway resource const ( - ResourceKindRestAPI = "RestApi" - ResourceKindMCP = "Mcp" + ResourceKindRestAPI = "RestApi" + ResourceKindMCP = "Mcp" + ResourceKindLLMProvider = "LlmProvider" + ResourceKindLLMProxy = "LlmProxy" ) // Resource represents a parsed gateway resource @@ -79,6 +81,36 @@ func (h *MCPHandler) UpdateEndpoint(handle string) string { return fmt.Sprintf(utils.GatewayMCPProxyByIDPath, handle) } +// LLMProviderHandler handles LlmProvider kind resources +type LLMProviderHandler struct{} + +func (h *LLMProviderHandler) GetEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayLLMProviderByIDPath, handle) +} + +func (h *LLMProviderHandler) CreateEndpoint() string { + return utils.GatewayLLMProvidersPath +} + +func (h *LLMProviderHandler) UpdateEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayLLMProviderByIDPath, handle) +} + +// LLMProxyHandler handles LlmProxy kind resources +type LLMProxyHandler struct{} + +func (h *LLMProxyHandler) GetEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayLLMProxyByIDPath, handle) +} + +func (h *LLMProxyHandler) CreateEndpoint() string { + return utils.GatewayLLMProxiesPath +} + +func (h *LLMProxyHandler) UpdateEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayLLMProxyByIDPath, handle) +} + // GetResourceHandler returns the appropriate handler for a resource kind func GetResourceHandler(kind string) ResourceHandler { switch kind { @@ -86,6 +118,10 @@ func GetResourceHandler(kind string) ResourceHandler { return &RestAPIHandler{} case ResourceKindMCP: return &MCPHandler{} + case ResourceKindLLMProvider: + return &LLMProviderHandler{} + case ResourceKindLLMProxy: + return &LLMProxyHandler{} default: return nil } diff --git a/cli/src/internal/project/artifact.go b/cli/src/internal/project/artifact.go new file mode 100644 index 0000000000..59313155b5 --- /dev/null +++ b/cli/src/internal/project/artifact.go @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package project + +import ( + "github.com/wso2/api-platform/cli/utils" +) + +// apiVersions stamped into generated manifests, keyed by the platform plane the +// artifact belongs to. +const ( + ManagementAPIVersion = "management.api-platform.wso2.com/v1" + AIWorkspaceAPIVersion = "ai-workspace.api-platform.wso2.com/v1alpha" + GatewayAPIVersion = "gateway.api-platform.wso2.com/v1" +) + +// DefaultGatewayType is used when no gateway type is supplied for an artifact. +const DefaultGatewayType = "wso2/api-platform" + +// SupportedArtifactTypes returns the artifact types the CLI can scaffold and +// build, in display order. +func SupportedArtifactTypes() []string { + return []string{ + utils.TypeREST, + utils.TypeLLMProxy, + utils.TypeLLMProvider, + utils.TypeMCPProxy, + } +} + +// IsValidArtifactType reports whether artifactType is one the CLI supports. +func IsValidArtifactType(artifactType string) bool { + for _, t := range SupportedArtifactTypes() { + if artifactType == t { + return true + } + } + return false +} + +// IsAIWorkspaceType reports whether the artifact type belongs to the +// ai-workspace plane (LLM proxies/providers, MCP proxies) rather than the +// management plane (REST APIs). +func IsAIWorkspaceType(artifactType string) bool { + switch artifactType { + case utils.TypeLLMProxy, utils.TypeLLMProvider, utils.TypeMCPProxy: + return true + default: + return false + } +} + +// ArtifactKind maps an artifact type to its manifest kind. +func ArtifactKind(artifactType string) string { + switch artifactType { + case utils.TypeREST: + return "RestApi" + case utils.TypeLLMProxy: + return "LlmProxy" + case utils.TypeLLMProvider: + return "LlmProvider" + case utils.TypeMCPProxy: + return "Mcp" + default: + return "RestApi" + } +} + +// MetadataKind returns the kind stamped into an artifact's metadata.yaml. +// AI-workspace metadata carries a "Metadata" suffix (e.g. LlmProxyMetadata) to +// distinguish it from the runtime/gateway kind (which keeps the bare kind); +// management artifacts use the bare kind unchanged. +func MetadataKind(artifactType string) string { + kind := ArtifactKind(artifactType) + if IsAIWorkspaceType(artifactType) { + return kind + MetadataKindSuffix + } + return kind +} + +// MetadataKindSuffix is appended to the metadata.yaml kind for ai-workspace +// artifacts to distinguish it from the runtime kind (e.g. LlmProxyMetadata vs +// LlmProxy). +const MetadataKindSuffix = "Metadata" + +// MetadataAPIVersion returns the apiVersion for an artifact's metadata.yaml, +// which differs between the management and ai-workspace planes. +func MetadataAPIVersion(artifactType string) string { + if IsAIWorkspaceType(artifactType) { + return AIWorkspaceAPIVersion + } + return ManagementAPIVersion +} diff --git a/cli/src/internal/project/config.go b/cli/src/internal/project/config.go new file mode 100644 index 0000000000..d57ffb58dd --- /dev/null +++ b/cli/src/internal/project/config.go @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package project holds the shared model for an api-platform project on disk: +// the .api-platform/config.yaml schema, the per-portal config layout, and the +// helpers used to scaffold and load them. Keeping these types here lets the +// project, devportal and (future) ai-workspace commands share a single +// source of truth instead of redeclaring the structs in each command package. +package project + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Default version stamped into a freshly scaffolded project config. +const DefaultConfigVersion = "1.0.0" + +// Default project-level file paths (relative to the project root). The project +// is used for both APIs and non-API artifacts (MCP proxies, LLM providers), +// so the keys deliberately avoid the "api" prefix. +const ( + DefaultDeploymentArtifact = "./runtime.yaml" + DefaultMetadataFile = "./metadata.yaml" + DefaultDefinition = "./definition.yaml" + DefaultDocs = "./docs" + DefaultTests = "./tests" +) + +// FilePaths describes where the core project artifacts live, relative to the +// project root. +type FilePaths struct { + DeploymentArtifact string `yaml:"deploymentArtifact,omitempty"` + MetadataFile string `yaml:"metadataFile,omitempty"` + Definition string `yaml:"definition,omitempty"` + Docs string `yaml:"docs,omitempty"` + Tests string `yaml:"tests,omitempty"` +} + +// PortalFilePaths describes a devportal's artifact layout, relative to its +// portalRoot. +type PortalFilePaths struct { + MetadataFile string `yaml:"metadataFile,omitempty"` + Definition string `yaml:"definition,omitempty"` + Docs string `yaml:"docs,omitempty"` + Content string `yaml:"content,omitempty"` +} + +// PortalConfig is the layout for a devportal section in the project config. +type PortalConfig struct { + Name string `yaml:"name,omitempty"` + PortalRoot string `yaml:"portalRoot,omitempty"` + FilePaths PortalFilePaths `yaml:"filePaths,omitempty"` +} + +// Default file paths for an ai-workspace portal config, relative to its +// portalRoot. +const ( + DefaultAIWorkspaceMetadata = "./metadata.yaml" + DefaultAIWorkspaceRuntime = "./runtime.yaml" + DefaultAIWorkspaceDefinition = "./definition.yaml" + DefaultAIWorkspaceDocs = "./docs" +) + +// AIWorkspaceFilePaths describes an ai-workspace portal's artifact layout, +// relative to its portalRoot. Unlike a devportal it carries a runtime artifact. +// Definition is the optional OpenAPI spec folded into the generated llm-proxy +// payload. +type AIWorkspaceFilePaths struct { + Metadata string `yaml:"metadata,omitempty"` + Runtime string `yaml:"runtime,omitempty"` + Definition string `yaml:"definition,omitempty"` +} + +// AIWorkspaceConfig is the ai-workspace portal configuration in the project +// config. A project can have at most one ai-workspace configuration (unlike +// devportals, which are a list). +type AIWorkspaceConfig struct { + Name string `yaml:"name,omitempty"` + PortalRoot string `yaml:"portalRoot,omitempty"` + FilePaths AIWorkspaceFilePaths `yaml:"filePaths,omitempty"` +} + +// Config is the on-disk .api-platform/config.yaml for an api-platform project. +type Config struct { + Version string `yaml:"version,omitempty"` + FilePaths FilePaths `yaml:"filePaths,omitempty"` + GovernanceRulesets []string `yaml:"governanceRulesets"` + AutoSync map[string]interface{} `yaml:"autoSync,omitempty"` + DevPortals []PortalConfig `yaml:"devportals,omitempty"` + // AIWorkspace is a single configuration (an object, not a list): a project + // can have at most one ai-workspace configuration. + AIWorkspace *AIWorkspaceConfig `yaml:"ai-workspace,omitempty"` +} + +// DefaultFilePaths returns the project-level file paths used when scaffolding a +// new project. +func DefaultFilePaths() FilePaths { + return FilePaths{ + DeploymentArtifact: DefaultDeploymentArtifact, + MetadataFile: DefaultMetadataFile, + Definition: DefaultDefinition, + Docs: DefaultDocs, + Tests: DefaultTests, + } +} + +// Normalize fills any empty project-level file path with its default so callers +// can rely on every path being populated after a load. +func (c *Config) Normalize() { + defaults := DefaultFilePaths() + if strings.TrimSpace(c.FilePaths.DeploymentArtifact) == "" { + c.FilePaths.DeploymentArtifact = defaults.DeploymentArtifact + } + if strings.TrimSpace(c.FilePaths.MetadataFile) == "" { + c.FilePaths.MetadataFile = defaults.MetadataFile + } + if strings.TrimSpace(c.FilePaths.Definition) == "" { + c.FilePaths.Definition = defaults.Definition + } + if strings.TrimSpace(c.FilePaths.Docs) == "" { + c.FilePaths.Docs = defaults.Docs + } + if strings.TrimSpace(c.FilePaths.Tests) == "" { + c.FilePaths.Tests = defaults.Tests + } +} + +// Load reads and parses a project config from configPath, normalizing the file +// paths before returning. +func Load(configPath string) (*Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read project config: %w", err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse project config: %w", err) + } + + config.Normalize() + return &config, nil +} + +// Save marshals config back to configPath. +func Save(configPath string, config *Config) error { + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal project config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to save project config: %w", err) + } + + return nil +} diff --git a/cli/src/internal/project/scaffold.go b/cli/src/internal/project/scaffold.go new file mode 100644 index 0000000000..5d01222919 --- /dev/null +++ b/cli/src/internal/project/scaffold.go @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package project + +import ( + "bytes" + "fmt" + + "gopkg.in/yaml.v3" +) + +// manifestMeta is the metadata block shared by every generated manifest. +type manifestMeta struct { + Name string `yaml:"name"` +} + +// BusinessInformation captures the ownership metadata carried by management +// (REST) artifacts. +type BusinessInformation struct { + BusinessOwner string `yaml:"businessOwner"` + BusinessOwnerEmail string `yaml:"businessOwnerEmail"` + TechnicalOwner string `yaml:"technicalOwner"` + TechnicalOwnerEmail string `yaml:"technicalOwnerEmail"` +} + +// Endpoints captures the backend endpoints carried by management artifacts. +type Endpoints struct { + SandboxURL string `yaml:"sandboxUrl"` + ProductionURL string `yaml:"productionUrl"` +} + +type manifest struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata manifestMeta `yaml:"metadata"` + Spec interface{} `yaml:"spec"` +} + +// managementMetadataSpec is the metadata.yaml spec for management-plane (REST) +// artifacts. +type managementMetadataSpec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Description string `yaml:"description"` + GatewayType string `yaml:"gatewayType"` + Status string `yaml:"status"` + ReferenceID string `yaml:"referenceID"` + Tags []string `yaml:"tags"` + Labels []string `yaml:"labels"` + BusinessInformation BusinessInformation `yaml:"businessInformation"` + Endpoints Endpoints `yaml:"endpoints"` +} + +// aiWorkspaceMetadataSpec is the slimmer metadata.yaml spec for ai-workspace +// artifacts (LLM proxies/providers, MCP proxies). +type aiWorkspaceMetadataSpec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` +} + +type runtimeSpec struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Context string `yaml:"context"` + Upstream runtimeUpstream `yaml:"upstream"` + Operations []runtimeOperation `yaml:"operations"` +} + +type runtimeUpstream struct { + Main runtimeUpstreamTarget `yaml:"main"` +} + +type runtimeUpstreamTarget struct { + URL string `yaml:"url"` +} + +type runtimeOperation struct { + Path string `yaml:"path"` + Method string `yaml:"method"` +} + +// portalConfigTemplate is appended (commented out) to a freshly scaffolded +// config.yaml so users have a ready-to-edit reference for wiring up +// ai-workspace and devportal publishing targets. Uncomment and adjust to add a +// portal; the keys match the structs the build commands parse. +const portalConfigTemplate = ` +# AI-Workspace portal configuration (a single object; a project has at most one) +# ai-workspace: +# name: dev +# portalRoot: ./ai-workspace +# filePaths: # paths relative to portal root +# metadata: ./artifact.yaml +# runtime: ./runtime.yaml +# definition: ./definition.yaml # OpenAPI spec folded into the payload + +# Dev portal configurations +# devportals: +# - name: default +# portalRoot: ./devportal +# filePaths: # paths relative to portal root +# metadata: ./devportal.yaml +# definition: ./definition.yaml +# docs: ./docs +# content: ./content +` + +// BuildConfigYAML renders the default .api-platform/config.yaml for a new +// project, sourcing the file-path values from the shared FilePaths struct so +// the scaffold and the loader can never drift apart. A commented-out portal +// configuration template is appended for the user to edit. +func BuildConfigYAML() (string, error) { + config := Config{ + Version: DefaultConfigVersion, + FilePaths: DefaultFilePaths(), + GovernanceRulesets: []string{}, + AutoSync: map[string]interface{}{ + "gatewayArtifactFromDefinition": true, + }, + } + + rendered, err := renderYAML(config, map[string]string{ + "filePaths": "Default file paths (can be customized)", + "governanceRulesets": "Governance rulesets for design-time validation", + "autoSync": "Auto-sync configuration for vscode plugin", + }, map[string]string{ + "autoSync.gatewayArtifactFromDefinition": "Auto-generate runtime.yaml when definition.yaml changes", + }) + if err != nil { + return "", err + } + + return rendered + portalConfigTemplate, nil +} + +// BuildMetadataYAML renders the default metadata.yaml for the given artifact +// type. Management (REST) artifacts get the full business/ownership metadata; +// ai-workspace artifacts get the slim displayName/version form. +func BuildMetadataYAML(artifactType, resourceName, displayName string) (string, error) { + m := manifest{ + APIVersion: MetadataAPIVersion(artifactType), + Kind: MetadataKind(artifactType), + Metadata: manifestMeta{Name: resourceName}, + } + + if IsAIWorkspaceType(artifactType) { + m.Spec = aiWorkspaceMetadataSpec{ + DisplayName: displayName, + Version: "v1.0", + } + } else { + m.Spec = managementMetadataSpec{ + DisplayName: displayName, + Version: "v1.0", + GatewayType: DefaultGatewayType, + Status: "PUBLISHED", + Tags: []string{}, + Labels: []string{}, + } + } + + return renderYAML(m, nil, nil) +} + +// BuildRuntimeYAML renders the default runtime.yaml (the gateway deployment +// artifact) for the given artifact type. +func BuildRuntimeYAML(artifactType, resourceName, displayName string) (string, error) { + m := manifest{ + APIVersion: GatewayAPIVersion, + Kind: ArtifactKind(artifactType), + Metadata: manifestMeta{Name: resourceName}, + Spec: runtimeSpec{ + DisplayName: displayName, + Upstream: runtimeUpstream{ + Main: runtimeUpstreamTarget{URL: "http://sample-backend.org:9080"}, + }, + Operations: []runtimeOperation{ + {Path: "/*", Method: "GET"}, + {Path: "/*", Method: "POST"}, + {Path: "/*", Method: "PUT"}, + {Path: "/*", Method: "DELETE"}, + {Path: "/*", Method: "OPTIONS"}, + }, + }, + } + + return renderYAML(m, nil, map[string]string{ + "spec.upstream.main.url": "Change this to your backend URL", + }) +} + +// BuildDefinitionYAML renders the default OpenAPI definition.yaml. +func BuildDefinitionYAML(displayName string) string { + return fmt.Sprintf(`openapi: 3.0.3 +info: + title: %q + version: v1.0 +servers: + - url: https://example.com +paths: + "/*": + get: + responses: + "200": + description: OK + post: + responses: + "200": + description: OK + put: + responses: + "200": + description: OK + delete: + responses: + "200": + description: OK + options: + responses: + "200": + description: OK +`, displayName) +} + +// renderYAML marshals v to YAML and attaches the supplied head/line comments to +// the addressed keys. Comment keys are dotted paths into the document +// (e.g. "spec.upstream.main.url"). +func renderYAML(v interface{}, headComments, lineComments map[string]string) (string, error) { + var doc yaml.Node + if err := doc.Encode(v); err != nil { + return "", fmt.Errorf("failed to encode manifest: %w", err) + } + + for path, comment := range headComments { + applyComment(&doc, splitPath(path), comment, true) + } + for path, comment := range lineComments { + applyComment(&doc, splitPath(path), comment, false) + } + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(&doc); err != nil { + return "", fmt.Errorf("failed to marshal manifest: %w", err) + } + if err := encoder.Close(); err != nil { + return "", fmt.Errorf("failed to flush manifest: %w", err) + } + return buf.String(), nil +} + +func splitPath(path string) []string { + segments := make([]string, 0) + for _, segment := range splitOnDot(path) { + if segment != "" { + segments = append(segments, segment) + } + } + return segments +} + +func splitOnDot(path string) []string { + var segments []string + current := "" + for _, r := range path { + if r == '.' { + segments = append(segments, current) + current = "" + continue + } + current += string(r) + } + return append(segments, current) +} + +// applyComment walks the mapping nodes of doc following path and sets a head or +// line comment on the addressed key. Missing keys are ignored so callers can +// describe optional fields without guarding each one. +func applyComment(doc *yaml.Node, path []string, comment string, head bool) { + if len(path) == 0 { + return + } + + node := doc + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + + for depth, key := range path { + if node.Kind != yaml.MappingNode { + return + } + matched := false + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + if keyNode.Value != key { + continue + } + if depth == len(path)-1 { + if head { + keyNode.HeadComment = comment + } else { + keyNode.LineComment = comment + } + return + } + node = node.Content[i+1] + matched = true + break + } + if !matched { + return + } + } +} diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index 7e475ae7f5..0db1ae0be3 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -37,12 +37,16 @@ const ( DefaultGatewayRuntime = "ghcr.io/wso2/api-platform/gateway-runtime:%s" // %s = version // REST API Endpoints - GatewayHealthPath = "/health" - GatewayAPIsPath = "/rest-apis" - GatewayAPIByIDPath = "/rest-apis/%s" - GatewayMCPProxiesPath = "/mcp-proxies" - GatewayMCPProxyByIDPath = "/mcp-proxies/%s" - DevPortalHealthPath = "/health" + GatewayHealthPath = "/health" + GatewayAPIsPath = "/rest-apis" + GatewayAPIByIDPath = "/rest-apis/%s" + GatewayMCPProxiesPath = "/mcp-proxies" + GatewayMCPProxyByIDPath = "/mcp-proxies/%s" + GatewayLLMProvidersPath = "/llm-providers" + GatewayLLMProviderByIDPath = "/llm-providers/%s" + GatewayLLMProxiesPath = "/llm-proxies" + GatewayLLMProxyByIDPath = "/llm-proxies/%s" + DevPortalHealthPath = "/health" // API Key Endpoints (scoped to a REST API) GatewayAPIKeysPath = "/rest-apis/%s/api-keys" // %s = REST API id @@ -76,6 +80,18 @@ const ( EnvDevPortalAPIKey = "WSO2AP_DEVPORTAL_API_KEY" // For DevPortal API key auth DevPortalAPIHeader = "x-wso2-api-key" + // AI Workspace Authentication Environment Variables + EnvAIWorkspaceUsername = "WSO2AP_AIWORKSPACE_USERNAME" // For AI workspace basic auth + EnvAIWorkspacePassword = "WSO2AP_AIWORKSPACE_PASSWORD" // For AI workspace basic auth + EnvAIWorkspaceToken = "WSO2AP_AIWORKSPACE_TOKEN" // For AI workspace OAuth auth + EnvAIWorkspaceAPIKey = "WSO2AP_AIWORKSPACE_API_KEY" // For AI workspace API key auth + AIWorkspaceAPIHeader = "x-wso2-api-key" + + // AI Workspace REST API Endpoints + AIWorkspaceLLMProvidersPath = "/api/v0.9/llm-providers" + AIWorkspaceLLMProxiesPath = "/api/v0.9/llm-proxies" + AIWorkspaceMCPProxiesPath = "/api/v0.9/mcp-proxies" + // Image Build Configuration GatewayVerifyChecksumOnBuild = true @@ -84,9 +100,12 @@ const ( MaxUncompressedPerFile = 20 * 1024 * 1024 // Maximum uncompressed size allowed per file (20 MB). MaxTotalUncompressed = 100 * 1024 * 1024 // Maximum total uncompressed size allowed for the archive (100 MB). - // API Types - APITypeREST = "rest" - APITypeSOAP = "soap" + // Artifact Types + TypeREST = "REST" + TypeSOAP = "SOAP" + TypeLLMProxy = "LLM-Proxy" + TypeLLMProvider = "App-LLM-Provider" + TypeMCPProxy = "MCP-Proxy" ) // PolicyHub REST API defaults and paths diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index c7ce8e348a..6a675e1998 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -39,6 +39,8 @@ const ( FlagFormat = "format" FlagVersion = "version" FlagID = "id" + FlagLimit = "limit" + FlagOffset = "offset" FlagAPIID = "api-id" FlagSubID = "sub-id" FlagConfirm = "confirm" @@ -50,6 +52,7 @@ const ( FlagHeader = "header" FlagPolicyId = "policy-id" FlagOrgID = "org" + FlagView = "view" FlagRequestCount = "request-count" FlagEventCount = "event-count" FlagPricingModel = "pricing-model" @@ -78,6 +81,10 @@ const ( FlagSubscriptionToken = "subscription-token" FlagBillingCustomerID = "billing-customer-id" FlagBillingSubscriptionID = "billing-subscription-id" + FlagReferenceID = "reference-id" + FlagGatewayType = "gateway-type" + FlagProjectID = "project-id" + FlagEnvFile = "env-file" ) var shortFlags = map[string]string{ diff --git a/cli/src/utils/input.go b/cli/src/utils/input.go index dfc6d696e5..1a5f518814 100644 --- a/cli/src/utils/input.go +++ b/cli/src/utils/input.go @@ -22,23 +22,65 @@ import ( "bufio" "fmt" "os" + "strconv" "strings" "syscall" "golang.org/x/term" ) +// stdinReader is a shared buffered reader over os.Stdin. Prompts must share one +// reader: a bufio.Reader reads ahead, so a fresh reader per prompt can discard +// input buffered by a previous prompt (breaking successive prompts on piped +// input). +var stdinReader = bufio.NewReader(os.Stdin) + // PromptInput prompts the user for input and returns the trimmed response func PromptInput(prompt string) (string, error) { fmt.Print(prompt) - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') + input, err := stdinReader.ReadString('\n') if err != nil { return "", fmt.Errorf("failed to read input: %w", err) } return strings.TrimSpace(input), nil } +// PromptSelect presents a numbered list of options and returns the option the +// user selects. The user may enter either the number (1-based) or the option +// value itself (case-insensitive). It re-prompts on invalid input and returns +// an error only when input cannot be read (e.g. EOF). +func PromptSelect(prompt string, options []string) (string, error) { + fmt.Println(prompt) + for i, option := range options { + fmt.Printf(" %d) %s\n", i+1, option) + } + + for { + fmt.Printf("Enter number [1-%d]: ", len(options)) + input, err := stdinReader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + input = strings.TrimSpace(input) + + if n, convErr := strconv.Atoi(input); convErr == nil { + if n >= 1 && n <= len(options) { + return options[n-1], nil + } + fmt.Printf("Invalid selection %q; enter a number between 1 and %d.\n", input, len(options)) + continue + } + + // Fall back to matching the option value itself. + for _, option := range options { + if strings.EqualFold(input, option) { + return option, nil + } + } + fmt.Printf("Invalid selection %q; enter a number between 1 and %d.\n", input, len(options)) + } +} + // PromptPassword prompts the user for a password with masked input func PromptPassword(prompt string) (string, error) { fmt.Print(prompt) @@ -142,3 +184,41 @@ func PromptDevPortalCredentials(authType string) (username, password, token, api return "", "", "", "", fmt.Errorf("unsupported devportal auth type '%s'", authType) } } + +// PromptAIWorkspaceCredentials prompts for AI workspace credentials based on auth type. +// Empty values indicate user chose to use environment variables. +func PromptAIWorkspaceCredentials(authType string) (username, password, token, apiKey string, err error) { + switch authType { + case AuthTypeBasic: + username, err = PromptInput(fmt.Sprintf("Enter AI workspace username (leave empty to use %s env var): ", EnvAIWorkspaceUsername)) + if err != nil { + return "", "", "", "", err + } + + password, err = PromptPassword(fmt.Sprintf("Enter AI workspace password (leave empty to use %s env var): ", EnvAIWorkspacePassword)) + if err != nil { + return "", "", "", "", err + } + + return username, password, "", "", nil + + case AuthTypeOAuth: + token, err = PromptPassword(fmt.Sprintf("Enter AI workspace OAuth token (leave empty to use %s env var): ", EnvAIWorkspaceToken)) + if err != nil { + return "", "", "", "", err + } + + return "", "", token, "", nil + + case AuthTypeAPIKey: + apiKey, err = PromptPassword(fmt.Sprintf("Enter AI workspace API key (leave empty to use %s env var): ", EnvAIWorkspaceAPIKey)) + if err != nil { + return "", "", "", "", err + } + + return "", "", "", apiKey, nil + + default: + return "", "", "", "", fmt.Errorf("unsupported ai-workspace auth type '%s'", authType) + } +} diff --git a/docs/cli/README.md b/docs/cli/README.md index 304d20908f..06f6505f7a 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -7,5 +7,7 @@ | Document | Description | |----------|-------------| | [Quick Start Guide](quick-start-guide.md) | Install the `ap` binary and configure environment variables to get up and running | +| [End-to-End Workflow](end-to-end-workflow.md) | The full flow with a diagram: create a project → deploy to the gateway → build and publish to the Developer Portal or an AI Workspace | | [CLI Reference](reference.md) | Full reference for all `ap gateway` sub-commands (add, list, apply, build, MCP, and more) | | [Customizing Gateway Policies](customizing-gateway-policies.md) | Build custom gateway images with local or PolicyHub policies using `ap gateway image build` | +| [AI-Workspace CLI Reference](ai-workspace/README.md) | Manage AI-Workspace connections (`add`, `list`, `use`, `current`, `remove`), validate a project artifact with `ap ai-workspace build`, and create/update artifacts with `ap ai-workspace apply`/`edit` | diff --git a/docs/cli/ai-workspace/README.md b/docs/cli/ai-workspace/README.md new file mode 100644 index 0000000000..d3f84f92e2 --- /dev/null +++ b/docs/cli/ai-workspace/README.md @@ -0,0 +1,459 @@ +# AI-Workspace CLI Reference + +This guide covers the AI-Workspace commands currently implemented under `cli/src/cmd/aiws`. + +Available command group: + +- `ap ai-workspace` + +The `add`, `list`, `remove`, `use`, and `current` commands manage AI-Workspace **server connections** stored in the CLI config file (the same per-platform config used by `ap gateway` and `ap devportal`). The `build`, `apply`, and `edit` commands are different: they work inside an **API project**. `build` **validates** the project's artifact; `apply`/`edit` **generate** the creation payload from the project's artifacts and create/update the artifact on the server (the endpoint is chosen by the artifact kind). + +## Prerequisites + +- Add at least one AI-Workspace configuration before using commands that target a specific workspace connection. +- AI-Workspace connections are stored in the CLI config file managed by `ap ai-workspace add`. +- Commands that use an active AI-Workspace resolve the platform first, then the active AI-Workspace under that platform. + +## Authentication + +Supported AI-Workspace auth types: + +- `basic` +- `oauth` +- `api-key` (default) + +Environment variables override credentials stored in the CLI config. + +| Auth type | Environment variables | +| --- | --- | +| `basic` | `WSO2AP_AIWORKSPACE_USERNAME`, `WSO2AP_AIWORKSPACE_PASSWORD` | +| `oauth` | `WSO2AP_AIWORKSPACE_TOKEN` | +| `api-key` | `WSO2AP_AIWORKSPACE_API_KEY` | + +## Connection Notes + +- When a command accepts `--display-name` and `--platform`, it resolves the AI-Workspace explicitly. +- When `--display-name` is provided without `--platform`, the command looks in the `default` platform. +- When `--display-name` is not provided, the command uses the active AI-Workspace in the resolved platform. + +## Connection Commands + +### `ap ai-workspace add` + +Adds an AI-Workspace configuration to the CLI config file. + +```shell +ap ai-workspace add --display-name --server --auth [--platform ] [--no-interactive] +``` + +Examples: + +```shell +ap ai-workspace add +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth basic +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth oauth +ap ai-workspace add --display-name my-workspace --server https://ai-workspace.example.com --auth api-key +ap ai-workspace add --display-name my-workspace --platform eu --server https://ai-workspace.example.com --auth api-key --no-interactive +``` + +Notes: + +- Interactive mode prompts for missing values. +- Supplying credentials as flags is supported, but interactive mode or environment variables are preferred. +- If credentials are omitted, runtime commands expect the corresponding environment variables. + +### `ap ai-workspace list` + +Lists AI-Workspace configurations for a platform. + +```shell +ap ai-workspace list [--platform ] +``` + +Examples: + +```shell +ap ai-workspace list +ap ai-workspace list --platform eu +``` + +Notes: + +- If `--platform` is omitted, the current platform is used. +- The active AI-Workspace is marked in the output table. + +### `ap ai-workspace remove` + +Removes an AI-Workspace configuration from a platform. + +```shell +ap ai-workspace remove --display-name [--platform ] +``` + +Example: + +```shell +ap ai-workspace remove --display-name my-workspace +``` + +### `ap ai-workspace use` + +Sets the active AI-Workspace for a platform. + +```shell +ap ai-workspace use --display-name [--platform ] +``` + +Examples: + +```shell +ap ai-workspace use --display-name my-workspace +ap ai-workspace use --display-name my-workspace --platform eu +``` + +Notes: + +- If `--platform` is omitted, the current platform is used. +- The command reports whether credentials will come from environment variables or the stored config. + +### `ap ai-workspace current` + +Shows the active AI-Workspace for a platform. + +```shell +ap ai-workspace current [--platform ] +``` + +Example: + +```shell +ap ai-workspace current +``` + +## `ap ai-workspace build` + +**Validates** the project's AI workspace artifact. Validation confirms that the configured files are present, that the metadata and runtime **kinds align**, and that the resource **name matches** between them. + +The kind is declared in `metadata.yaml`/`runtime.yaml`. In `metadata.yaml` the kind carries a `Metadata` suffix (the ai-workspace metadata kind); `runtime.yaml` uses the bare kind: + +- `kind: LlmProxyMetadata` (metadata.yaml) / `LlmProxy` (runtime.yaml) +- `kind: LlmProviderMetadata` (metadata.yaml) / `LlmProvider` (runtime.yaml) +- `kind: McpMetadata` (metadata.yaml) / `Mcp` (runtime.yaml) + +Any other kind is rejected. The `Metadata` suffix is stripped before the metadata and runtime kinds are matched, so the two files still refer to the same artifact (e.g. `LlmProxyMetadata` in metadata.yaml matches `LlmProxy` in runtime.yaml). + +```shell +ap ai-workspace build [-f ] +``` + +Examples: + +```shell +# Validate using the current directory as the project root +ap ai-workspace build + +# Validate a specific project directory +ap ai-workspace build -f /path/to/project +``` + +### What it reads + +The command expects an API project containing `.api-platform/config.yaml`, and reads the `ai-workspace` section of that file. Unlike `devportals` (a list), a project has **at most one** `ai-workspace` configuration, as one project can have only one AI-Workspace: + +```yaml +ai-workspace: + name: dev + portalRoot: . + filePaths: # paths relative to portalRoot + metadata: ./metadata.yaml + runtime: ./runtime.yaml + definition: ./definition.yaml # OpenAPI spec, required for all kinds +``` + +Validation (the same checks `apply` and `edit` run before sending): + +- Resolves `metadata`, `runtime`, and `definition` relative to the entry's `portalRoot` (defaults: `./metadata.yaml`, `./runtime.yaml`, `./definition.yaml`; `portalRoot` defaults to `.`, the project root). +- Requires `metadata.yaml` and `runtime.yaml` to exist. +- Requires the `kind` declared in `metadata.yaml` and `runtime.yaml` to match once the metadata's `Metadata` suffix is stripped (e.g. `LlmProxyMetadata` vs `LlmProxy`); otherwise validation fails with a kind-mismatch error. +- Requires `metadata.name` to match between `metadata.yaml` and `runtime.yaml`; otherwise validation fails with a name-mismatch error. +- Requires `definition.yaml` (the OpenAPI spec) for every kind — see [The OpenAPI spec](#the-openapi-spec) below. +- If no `ai-workspace` section exists, a single `default` entry (`portalRoot: .`) is created in the project config and used. + +All resolved paths are constrained to the project directory; a path that escapes the project root fails validation. + +#### Associating gateways (`metadata.yaml`) + +Optionally list the gateways the artifact can be deployed to, with per-gateway configuration overrides, in an `associatedGateways` section **under `spec`** in `metadata.yaml`. This applies to all artifact kinds (`LlmProxyMetadata`, `LlmProviderMetadata`, `McpMetadata`). Each entry is keyed by the gateway `id`. `apply`/`edit` extract this list from `spec.associatedGateways` and copy it into the generated payload verbatim (entries without an `id` are dropped; the field is omitted entirely when absent): + +```yaml +# metadata.yaml +kind: LlmProviderMetadata +metadata: + name: wso2-claude-provider +spec: + displayName: wso2 claude provider + version: v1.0 + associatedGateways: + - id: default + configurations: + host: prod-gw.example.com +``` + +`configurations` is a free-form object — the supported keys depend on the artifact type. + +## `ap ai-workspace apply` / `ap ai-workspace edit` + +These commands run the same validation as `build`, then **generate** the creation payload from the project artifacts and send it to the AI workspace. `apply` **creates** the artifact (`POST`); `edit` **updates** an existing one (`PUT /{resource}/{id}`, where `id` is `metadata.name`). Both live at the root of `ap ai-workspace` (not under a per-kind group) and select the endpoint from the artifact **kind**: + +| Kind | `apply` endpoint | `edit` endpoint | +| --- | --- | --- | +| `LlmProvider` | `POST /llm-providers` | `PUT /llm-providers/{id}` | +| `LlmProxy` | `POST /llm-proxies` | `PUT /llm-proxies/{id}` | +| `Mcp` | `POST /mcp-proxies` | `PUT /mcp-proxies/{id}` | + +```shell +ap ai-workspace apply [-f ] [--project-id ] [--env-file ] [--display-name ] [--platform ] [--insecure] [-o json] +ap ai-workspace edit [-f ] [--project-id ] [--env-file ] [--display-name ] [--platform ] [--insecure] [-o json] +``` + +Examples: + +```shell +# Create/update a provider (no project scoping) +ap ai-workspace apply +ap ai-workspace edit + +# Create/update a proxy or MCP proxy (project-scoped) +ap ai-workspace apply --project-id +ap ai-workspace edit --project-id + +# Resolve ENV_CLI_* placeholders from a specific env file +ap ai-workspace apply --env-file ./secrets.env +``` + +Notes: + +- `-f` is the **project directory** (defaults to the current directory), not a payload file — the payload is generated in-memory and never written to disk. +- `--project-id` is **required** for the `LlmProxy` and `Mcp` kinds (they are project-scoped) and is injected into the payload; providers are not project-scoped and ignore it. +- The organization is derived from the auth token, so there is **no `--org` flag**. The AI workspace connection and credentials resolve like the other commands (`--display-name`/`--platform` or the active workspace; see [Authentication](#authentication)). +- By default a structured result is printed (like `ap gateway apply`): `Status`, `Message`, `ID`, and — when known — `Organization`, `Project`, `Created At`, `Updated At`, and `State`. `Project` shows the `--project-id` you supplied (proxies/MCP); `Organization` is derived from the auth token so it only appears when the server echoes `organizationId`. Pass `--output json` (or `-o json`) to print the full server response instead (useful for piping to `jq`). +- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. + +### Environment variable placeholders (`ENV_CLI_*`) + +`metadata.yaml` and `runtime.yaml` may reference **environment variables** for specific field values using the `ENV_CLI_` prefix. This lets you keep values that change between environments — upstream URLs, hosts, project IDs, model names, etc. — out of the project files and supply them at apply time. Supported forms: `${ENV_CLI_NAME}`, `$ENV_CLI_NAME`, or a bare `ENV_CLI_NAME` token. + +> **This is for environment-specific configuration values, not secrets.** The resolved value is substituted into the artifact and sent to the server (it travels in the request body and is stored in the created artifact). Do **not** use it for API keys, tokens, or other secrets. Secrets should be managed server-side and referenced with the platform's `{{ secret "name" }}` placeholders (resolved by the server from its encrypted secret store), which the CLI leaves untouched. + +```yaml +# runtime.yaml — a per-environment upstream URL supplied at apply time +spec: + upstream: + url: ${ENV_CLI_UPSTREAM_URL} +``` + +`apply`/`edit` resolve the placeholders in the **generated payload** just before it is sent, looking up each variable in this order: + +1. **`--env-file `** — when given, values come from that env file (a missing file is an error). +2. **`.env` in the project root** — used by default when `--env-file` is not given. +3. **Process environment** — fills any names the selected file does not define (and is the sole source when neither file exists). + +The env file format is one `KEY=VALUE` per line; blank lines and `#` comments are ignored, an `export ` prefix is allowed, and single/double quotes around the value are stripped. + +**Apply/edit fail if a referenced variable has no value** at apply time — the command errors and names every unresolved variable, and nothing is sent to the server. Define the missing variables in an env file (`--env-file` or the project's `.env`) or in the environment and retry. `build` does not resolve placeholders — it only validates the project files. + +### Generated payload + +The payload shape is selected by kind. + +#### `LlmProxy` + +| Payload field | Source | +| --- | --- | +| `id` | `metadata.yaml` → `metadata.name` | +| `displayName` | `metadata.yaml` → `spec.displayName` | +| `version` | `metadata.yaml` → `spec.version` | +| `context` | `runtime.yaml` → `spec.context` | +| `description` | `runtime.yaml` → `spec.description` (defaults to `"No description provided for this proxy."` when absent) | +| `provider` (`id`, `auth.{type,header}`) | `runtime.yaml` → `spec.provider` (the auth secret `value` is **not** copied — the provider owns it) | +| `security` (`enabled`, `apiKey.{enabled,in,key}`) | the `api-key-auth` policy in `runtime.yaml` → `spec.globalPolicies` | +| `globalPolicies[]` (`name`, `version`, `params`) | every other `runtime.yaml` → `spec.globalPolicies` entry; `params` is copied verbatim (policy-specific, no fixed schema) | +| `operationPolicies[]` (`name`, `version`, `paths[].{path,methods,params}`) | `runtime.yaml` → `spec.operationPolicies`; each path's `params` is copied verbatim | +| `readOnly` | always `false` | +| `openapi` | content of `definition.yaml` (**required**) | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `spec.associatedGateways` (omitted when absent) | +| `projectId` | intentionally omitted (injected by `apply`/`edit` via `--project-id`) | + +#### `LlmProvider` + +| Payload field | Source | +| --- | --- | +| `id` | `metadata.yaml` → `metadata.name` | +| `name` | `metadata.yaml` → `metadata.name` | +| `version` | `metadata.yaml` → `spec.version` | +| `context` | `runtime.yaml` → `spec.context` | +| `template` | `runtime.yaml` → `spec.template` | +| `modelProviders[]` (`id`, `displayName`, `models[].{id,displayName}`) | derived from `spec.template` (see below); omitted for an unknown template | +| `upstream` (`main.{url,auth}`) | `runtime.yaml` → `spec.upstream` | +| `accessControl` (`mode`, `exceptions[]`) | `runtime.yaml` → `spec.accessControl` | +| `security` (`apiKey.{key,in}`) | the `api-key-auth` policy in `runtime.yaml` → `spec.policies` | +| `rateLimiting` | the `*-ratelimit` policies (see below) | +| `policies[]` (`name`, `version`, `paths[].{path,methods,params}`) | every other `runtime.yaml` → `spec.policies` entry (i.e. not `api-key-auth` or `*-ratelimit`) | +| `openapi` | content of `definition.yaml` (**required** for providers) | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `spec.associatedGateways` (omitted when absent) | + +**rateLimiting mapping.** Each policy whose name ends with `-ratelimit` becomes a rate-limiting dimension, selected by name: + +| Policy name | Dimension | Value source (`params`) | +| --- | --- | --- | +| `advanced-ratelimit` | `request` | `quotas[].limits[].{limit,duration}` | +| `token-based-ratelimit` | `token` | `totalTokenLimits[].{count,duration}` | +| `llm-cost-based-ratelimit` | `cost` | `budgetLimits[].{amount,duration}` | + +Each dimension is placed under `consumerLevel` when the policy params carry `consumerBased: true` (or, for `advanced-ratelimit`, when the quota `name` starts with `consumer`); otherwise under `providerLevel`. Durations like `1h`/`3h` are parsed into `{duration, unit}` reset windows. + +A limit whose path is `/*` is applied as a `global` limit for its scope; a limit on a specific path is applied `resourceWise`, keyed by that path (with any `/*` limits in the same scope folded into the resourceWise `default`). + +**modelProviders mapping.** When `spec.template` matches a known template, the build emits a single `modelProviders` entry keyed by the template name (`id` = `displayName` = the template), carrying that template's models (each model's `id` and `displayName` are the model identifier). Unknown templates emit no `modelProviders`. Supported templates and their models: + +| Template | Models | +| --- | --- | +| `meta` | `us.meta.llama3-3-70b-instruct-v1:0`, `us.meta.llama4-maverick-17b-instruct-v1:0` | +| `openai` | `gpt-4o-mini`, `gpt-4.1-mini`, `o4-mini` | +| `anthropic` | `claude-3.5-sonnet`, `claude-3-opus` | +| `google-vertex` | `gemini-1.5-pro`, `gemini-1.5-flash` | +| `aws-bedrock` | `amazon.titan-text-premier`, `anthropic.claude-v2` | +| `mistralai` | `mistral-large-latest`, `mistral-small-latest`, `open-mixtral-8x22b` | + +#### `Mcp` + +| Payload field | Source | +| --- | --- | +| `id` | `metadata.yaml` → `metadata.name` | +| `name` | `metadata.yaml` → `metadata.name` | +| `version` | `metadata.yaml` → `spec.version` | +| `context` | `runtime.yaml` → `spec.context` | +| `mcpSpecVersion` | `runtime.yaml` → `spec.specVersion` | +| `upstream` (`main.{url,auth}`) | `runtime.yaml` → `spec.upstream` | +| `policies[]` (`name`, `version`, `params`) | `runtime.yaml` → `spec.policies` (auth/authz/etc.) | +| `capabilities` (`prompts`, `resources`, `tools`) | `definition.yaml` (**required** for MCP) | +| `associatedGateways[]` (`id`, `configurations`) | `metadata.yaml` → `spec.associatedGateways` (omitted when absent) | +| `description` | empty | + +`definition.yaml` for an MCP proxy holds `prompts`, `resources`, and `tools`. `prompts` and `tools` are passed through unchanged; `resources` are trimmed to `uri`, `name`, and `mimeType` (any inline `text`/`blob` content is dropped). `projectId` is omitted and injected at publish time. + +### The OpenAPI spec + +`definition.yaml` is **required for every kind** — validation errors if it is missing: + +- **`LlmProvider`** — folded into `openapi`. +- **`LlmProxy`** — folded into `openapi`. +- **`Mcp`** — its `prompts`/`resources`/`tools` populate `capabilities`. + +## Get Commands + +These commands retrieve artifacts from the AI workspace resolved from the CLI config (`--display-name`/`--platform`, or the active AI workspace). With `--id` a single artifact is fetched; without it all artifacts are listed, with optional `--limit`/`--offset` pagination. The full JSON response is printed. + +The scoping query parameter differs by resource: + +- **LLM providers** need no scoping parameter — the organization is derived from the auth token (`GET /llm-providers`, `GET /llm-providers/{id}`). +- **LLM/MCP proxies** are scoped by `projectId` (`--project-id`) when listing; fetching a single proxy by `--id` takes only the id path parameter (no org/project query). + +### `ap ai-workspace llm-provider list` + +Lists all LLM providers (`GET /llm-providers`, operationId `listLLMProviders`). The organization comes from the auth token, so no `--org` is needed. + +```shell +ap ai-workspace llm-provider list [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-workspace llm-provider get` + +The organization comes from the auth token, so no `--org` is needed. + +```shell +# List all LLM providers (GET /llm-providers) +ap ai-workspace llm-provider get [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] + +# Get a single LLM provider (GET /llm-providers/{id}) +ap ai-workspace llm-provider get --id +``` + +### `ap ai-workspace app-llm-proxy list` + +Lists all LLM proxies in a project (`GET /llm-proxies?projectId={project}`, operationId `listLLMProxies`). `--project-id` is required. + +```shell +ap ai-workspace app-llm-proxy list --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-workspace app-llm-proxy get` + +```shell +# List all LLM proxies in a project (GET /llm-proxies?projectId={project}) +ap ai-workspace app-llm-proxy get --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] + +# Get a single LLM proxy (GET /llm-proxies/{id}) +ap ai-workspace app-llm-proxy get --id +``` + +### `ap ai-workspace mcp-proxy list` + +Lists all MCP proxies in a project (`GET /mcp-proxies?projectId={project}`, operationId `listMCPProxies`). `--project-id` is required. + +```shell +ap ai-workspace mcp-proxy list --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-workspace mcp-proxy get` + +```shell +# List all MCP proxies in a project (GET /mcp-proxies?projectId={project}) +ap ai-workspace mcp-proxy get --project-id [--limit ] [--offset ] [--display-name ] [--platform ] [--insecure] + +# Get a single MCP proxy (GET /mcp-proxies/{id}) +ap ai-workspace mcp-proxy get --id +``` + +Notes: + +- `llm-provider list` and `llm-provider get` both list providers when no `--id` is given; `list` is the dedicated list-all command, while `get` additionally fetches a single provider with `--id`. Neither needs `--org` (the organization is derived from the auth token). +- `llm-proxy`/`mcp-proxy` each have a dedicated `list` command (project-scoped, `--project-id` required) alongside `get`, which lists when no `--id` is given and fetches a single proxy with `--id`. +- For `llm-proxy`/`mcp-proxy get`, `--project-id` is required only when listing; fetching a single proxy needs just `--id`. +- `--limit` and `--offset` apply only when listing. +- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. + +## Delete Commands + +These commands delete an artifact by its identifier (`DELETE /{resource}/{id}`). The artifact is identified solely by `--id` — no organization or project scoping is required — and a successful delete (`204 No Content`) prints a confirmation line. + +### `ap ai-workspace llm-provider delete` + +```shell +ap ai-workspace llm-provider delete --id [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-workspace app-llm-proxy delete` + +```shell +ap ai-workspace app-llm-proxy delete --id [--display-name ] [--platform ] [--insecure] +``` + +### `ap ai-workspace mcp-proxy delete` + +```shell +ap ai-workspace mcp-proxy delete --id [--display-name ] [--platform ] [--insecure] +``` + +Notes: + +- `--id` is required for all delete commands. +- `--insecure` skips TLS verification for local or self-signed HTTPS endpoints. + +## Related Commands + +- `ap platform add` +- `ap platform use` +- `ap ai-workspace use` +- `ap ai-workspace build` +- `ap project init` diff --git a/docs/cli/devportal/README.md b/docs/cli/devportal/README.md index 9c5c81de53..36bbda4d53 100644 --- a/docs/cli/devportal/README.md +++ b/docs/cli/devportal/README.md @@ -727,6 +727,25 @@ Notes: - `type` accepts `requestcount` or `eventcount`. Use `-1` for an unlimited request/event count. - Equivalent request shape: `curl -X POST /devportal/organizations//subscription-policies -F "subscriptionPolicy=@sub_plan_gold.yaml"`. +### `ap devportal sub-plan list` + +Lists all subscription plans in an organization. + +```shell +ap devportal sub-plan list --org [--display-name ] [--platform ] [--insecure] +``` + +Examples: + +```shell +ap devportal sub-plan list --org org_1 +ap devportal sub-plan list --org org_1 --display-name my-portal --platform eu +``` + +Notes: + +- Calls `GET /o//devportal/v1/subscription-policies` (operationId `listSubscriptionPolicies`). + ### `ap devportal sub-plan get` Gets a single subscription plan by its policy ID. diff --git a/docs/cli/end-to-end-workflow.md b/docs/cli/end-to-end-workflow.md new file mode 100644 index 0000000000..5a94f44d27 --- /dev/null +++ b/docs/cli/end-to-end-workflow.md @@ -0,0 +1,107 @@ +# End-to-End Workflow + +This guide shows the full lifecycle with the `ap` CLI: create an API **project**, **deploy** it to a gateway, then **build** a portal artifact and **publish** it — either to the **Developer Portal** or to an **AI Workspace**. + +A single API project is the source of truth for all destinations: + +- `runtime.yaml` → deployed to the **gateway** (where the API is served). +- `metadata.yaml` + `definition.yaml` → used to generate the default Developer Portal artifact. +- `metadata.yaml` + `runtime.yaml` + `definition.yaml` → bundled into the **AI Workspace** artifact. + +## Flow + +```mermaid +flowchart LR + A["0 · Set up once
connect + select
gateway · devportal · ai-workspace"] --> B["1 · Create
ap project init"] + B --> C["Author
metadata.yaml · runtime.yaml · definition.yaml"] + C --> D["2 · Deploy
ap gateway apply -f runtime.yaml"] + D --> E{"Publish to?"} + + subgraph DP["Developer Portal"] + direction LR + F["3a · Generate
ap devportal gen"] --> G["4a · Build
ap devportal build"] --> H["5a · Publish
ap devportal rest-api publish"] + end + + subgraph AW["AI Workspace"] + direction LR + I["3b · Validate
ap ai-workspace build"] --> J["4b · Apply
ap ai-workspace apply"] + end + + E -->|REST API| F + E -->|LLM proxy/ provider / MCP proxy| I + + classDef dp fill:#e8f0fe,stroke:#4285f4,color:#1a3d7c; + classDef aiws fill:#e6f4ea,stroke:#34a853,color:#1e4620; + class F,G,H dp; + class I,J aiws; +``` + +## Steps + +### 0. Configure connections (one-time) + +Register and select the servers the CLI talks to. Each connection lives under the active platform. + +```shell +ap platform add --display-name --control-plane # optional; if you use platforms +ap gateway add --display-name --server && ap gateway use --display-name +ap devportal add --display-name --server --auth api-key && ap devportal use --display-name +ap ai-workspace add --display-name --server --auth api-key && ap ai-workspace use --display-name +``` + +Commands resolve the **active** gateway / devportal / ai-workspace of the active platform unless you pass `--display-name` (and `--platform`). See [Gateway](gateway/README.md), [DevPortal](devportal/README.md), and [AI-Workspace](ai-workspace/README.md) references. + +### 1. Create the project + +```shell +ap project init --display-name echo-api --type rest --version v2.0 --context /ping +cd echo-api +``` + +Scaffolds `metadata.yaml`, `runtime.yaml`, `definition.yaml`, `docs/`, `tests/`, and `.api-platform/config.yaml`. See the [API Project reference](apiproject/README.md). + +### 2. Deploy to the gateway + +Edit `runtime.yaml` (real upstream, policies, operations), then deploy: + +```shell +ap gateway apply -f runtime.yaml +``` + +The response includes the gateway-assigned **API ID**. Re-read it any time with +`ap gateway rest-api get --display-name "" --version `. + +### 3+. Build the portal artifact and publish + +Pick the destination for the deployed API. + +#### Developer Portal + +```shell +# Set spec.referenceID in metadata.yaml to the gateway API ID from step 2, then: +ap devportal gen # generate ./devportal (devportal.yaml, definition, docs, content) +ap devportal build # package ./devportal → build/devportal.zip +ap devportal rest-api publish -f build/devportal.zip --org +``` + +`gen` generates the devportal artifact source and registers it in the project config; edit `./devportal/devportal.yaml` to customize before `build`. `build` only packages the generated folder (run `gen` first). Follow-ups once published: `ap devportal sub-plan publish`, `ap devportal api-key generate`, `ap devportal subscription create`. + +#### AI Workspace + +```shell +ap ai-workspace build # validate the project's artifact +ap ai-workspace apply --project-id # generate the payload and create the artifact +# (--project-id is required for LlmProxy/Mcp kinds, not for LlmProvider) +# to update an existing artifact instead of creating: ap ai-workspace edit --project-id +# the endpoint is chosen by the artifact kind; the organization comes from the auth token — no --org flag +``` + +`ap ai-workspace build` reads the ai-workspace entry in `.api-platform/config.yaml` and **validates** the artifact (files present, metadata/runtime kinds align, name matches). `ap ai-workspace apply`/`edit` run the same validation, then generate the creation payload (folding the OpenAPI spec from `definition.yaml` into it) and create/update the artifact on the server. + +## Notes + +- `ap devportal gen`, `ap devportal build`, and `ap ai-workspace build`/`apply`/`edit` all operate on an API project (they require `.api-platform/config.yaml`). +- Developer Portal is two stages: `gen` **generates** the editable artifact source under `./devportal`, then `build` **packages** it into `build/devportal.zip`. +- AI Workspace's `build` only **validates** — it writes nothing. The creation payload is generated in-memory by `apply`/`edit` at publish time (no build artifact is written to `build/`). +- `--org` on the publish/apply commands is the target organization in the Developer Portal / AI Workspace. +- Add `--insecure` to any portal/gateway command when talking to a local or self-signed HTTPS endpoint.