diff --git a/docs-mintlify/api-reference/api.yaml b/docs-mintlify/api-reference/api.yaml index 26d800ede091d..f7b62151099cc 100644 --- a/docs-mintlify/api-reference/api.yaml +++ b/docs-mintlify/api-reference/api.yaml @@ -44,6 +44,7 @@ tags: - name: Embed - name: Embed Tenants - name: Dashboard Embed Access + - name: OpenAPI Spec paths: /api/v1/app-config: get: @@ -184,11 +185,22 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Deployment' + $ref: '#/components/schemas/DeploymentSettings' description: '' - summary: Update deployment + summary: Update a deployment tags: - Deployments + x-mint: + content: >- + Update any subset of a deployment’s settings — everything the console’s Settings pages own + — and read the full settings back. Fields you omit are left alone, and `templateVariables` + is merged key-by-key rather than replaced, so setting one configuration flag never clears + the others. `releaseChannelVersion` must be one of the versions `GET + /:deploymentId/versions` lists for the target channel (in any of the forms it reports) — + anything else is a 400, and the container image is resolved from the version rather than + sent. Choosing a version that is not the channel’s latest also sets + `releaseChannelVersionHold` unless you send it explicitly, so auto-upgrade does not undo + the pin. /api/v1/deployments/{deploymentId}/build-status: get: operationId: buildStatus @@ -285,6 +297,77 @@ paths: summary: Start a dbt sync for a deployment tags: - dbt Sync + /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}: + delete: + operationId: cancelDbtSync + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + - in: path + name: syncJobId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DbtSyncCancelResponse' + description: '' + summary: Cancel a running dbt sync + tags: + - dbt Sync + get: + operationId: getDbtSyncStatus + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + - in: path + name: syncJobId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DbtSyncStatusResponse' + description: '' + summary: Get the status of a dbt sync + tags: + - dbt Sync + /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}/result: + get: + operationId: getDbtSyncResult + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + - in: path + name: syncJobId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DbtSyncResultResponse' + description: '' + summary: Get the result of a completed dbt sync + tags: + - dbt Sync /api/v1/deployments/{deploymentId}/env-vars: get: operationId: getEnvVariables @@ -337,7 +420,9 @@ paths: content: >- Upserts deployment environment variables by name; variables not included keep their existing values. Passing the `[ENCRYPTED]` placeholder (the masked read value) is rejected - — omit variables you do not intend to change. + — omit variables you do not intend to change. New variable names must be POSIX identifiers + (`^[A-Za-z_][A-Za-z0-9_]*$`); names already stored on the deployment may be resent + unchanged so a legacy name can still be updated or removed. /api/v1/deployments/{deploymentId}/environments: get: operationId: getDeploymentEnvironments @@ -1575,6 +1660,32 @@ paths: summary: Refresh report tags: - Reports + /api/v1/deployments/{deploymentId}/settings: + get: + operationId: getDeploymentSettings + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentSettings' + description: '' + summary: Get deployment settings + tags: + - Deployments + x-mint: + content: >- + Every setting of a deployment in one payload — the full contents of the console Settings + pages (general, configuration flags, deploy, launchpad, Cube Store private storage), plus + the read-only values displayed alongside them. Secrets are excluded; read environment + variables through `/env-vars` instead. Writes go to `PUT /:deploymentId`, which accepts + the same fields and returns this payload. /api/v1/deployments/{deploymentId}/shared-workspace: get: operationId: sharedWorkspaceObjects @@ -1673,6 +1784,32 @@ paths: summary: Deployment token tags: - Deployments + /api/v1/deployments/{deploymentId}/versions: + get: + operationId: listDeploymentVersions + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentVersionsResponse' + description: '' + summary: List available Cube versions + tags: + - Deployments + x-mint: + content: >- + Every Cube version this deployment can be switched to — the same set the console’s version + picker offers: the head of each release channel, plus the older versions the tenant has + run before (its release history). Pass a listed `releaseChannelVersion` to `PUT + /:deploymentId`; anything else is rejected. The container image is resolved server-side + and is not part of the write surface. /api/v1/deployments/{deploymentId}/workbooks: get: operationId: getWorkbooks @@ -1920,37 +2057,6 @@ paths: summary: Update workbook dashboard tags: - Workbooks - /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread: - post: - operationId: updatePublishedDashboardAiWidgetThread - parameters: - - in: path - name: deploymentId - required: true - schema: - type: number - - in: path - name: workbookId - required: true - schema: - type: number - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/UpdatePublishedAiWidgetThreadInput' - description: UpdatePublishedAiWidgetThreadInput - required: false - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Dashboard' - description: '' - summary: Update published dashboard AI widget thread - tags: - - Workbooks /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/duplicate: post: operationId: duplicateWorkbook @@ -3329,6 +3435,25 @@ paths: summary: List regions tags: - Regions + /api/v1/spec: + get: + operationId: getSpec + responses: + '200': + content: + application/json: + schema: + additionalProperties: true + type: object + description: The OpenAPI 3.1 document + summary: Get the OpenAPI specification + tags: + - OpenAPI Spec + x-mint: + content: >- + The full OpenAPI 3.1 document for this API, as served by the build handling the request — + every path, parameter, request body and schema. Intended for runtime discovery by clients + and agents; `cube spec` wraps it with filtering. /api/v1/tenant/settings: get: operationId: getTenantSettings @@ -3747,6 +3872,39 @@ paths: summary: Create a branch (optionally entering dev mode on it) tags: - Data Model + /build/api/v1/deployments/{deploymentId}/branches/staging-environment: + put: + operationId: setBranchStagingEnvironment + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SetBranchStagingEnvironmentRequest' + description: SetBranchStagingEnvironmentRequest + required: false + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SetBranchStagingEnvironmentResponse' + description: '' + summary: Enable or disable a branch's staging environment + tags: + - Data Model + x-mint: + content: >- + Enabling a branch keeps its staging environment always active and accessible at + `/dev-mode/{branchName}/cubejs-api/v1`; disabled (the default) it is only + active while the branch is viewed in Cube Cloud. Enabled branches are the ones listed by + `GET /api/v1/deployments/{deploymentId}/environments?type=staging`. Only shared branches + qualify — personal dev branches and the deploy branch are rejected. /build/api/v1/deployments/{deploymentId}/commit: post: operationId: commitChanges @@ -4243,6 +4401,10 @@ components: oneOf: - $ref: '#/components/schemas/SheetsUiSettings' - type: 'null' + timezoneSettings: + oneOf: + - $ref: '#/components/schemas/TimezoneSettings' + - type: 'null' required: - applyThemeGlobally type: object @@ -4308,6 +4470,10 @@ components: properties: id: type: integer + isStagingEnvironmentEnabled: + oneOf: + - type: boolean + - type: 'null' lastHash: oneOf: - type: string @@ -5015,6 +5181,60 @@ components: - type: string - type: 'null' type: object + CspsConfig: + properties: + awsRoleArn: + oneOf: + - type: string + - type: 'null' + azureClientId: + oneOf: + - type: string + - type: 'null' + azureContainer: + oneOf: + - type: string + - type: 'null' + azureStorageAccount: + oneOf: + - type: string + - type: 'null' + azureTenantId: + oneOf: + - type: string + - type: 'null' + enabled: + type: boolean + gcpServiceAccountEmail: + oneOf: + - type: string + - type: 'null' + gcpWorkloadIdentityProvider: + oneOf: + - type: string + - type: 'null' + gcsBucket: + oneOf: + - type: string + - type: 'null' + s3Bucket: + oneOf: + - type: string + - type: 'null' + s3Region: + oneOf: + - type: string + - type: 'null' + s3Sse: + oneOf: + - type: string + - type: 'null' + storageProvider: + type: string + required: + - enabled + - storageProvider + type: object CspsConfigInput: properties: awsRoleArn: @@ -5707,8 +5927,10 @@ components: - TEXT - FILTER - AI - - TABS_CONTAINER - TIME_GRAIN + - SPACER + - DIVIDER + - CONTAINER type: string DashboardWidgetInput: properties: @@ -5718,6 +5940,7 @@ components: additionalProperties: true - type: 'null' id: + maxLength: 128 type: string position: $ref: '#/components/schemas/DashboardWidgetPositionInput' @@ -5743,8 +5966,10 @@ components: - TEXT - FILTER - AI - - TABS_CONTAINER - TIME_GRAIN + - SPACER + - DIVIDER + - CONTAINER type: string DashboardWidgetPosition: properties: @@ -5812,6 +6037,53 @@ components: required: - success type: object + DbtSyncCancelResponse: + properties: + message: + type: string + required: + - message + type: object + DbtSyncGeneratedFile: + properties: + path: + description: Path of the generated file within the data model project. + type: string + required: + - path + type: object + DbtSyncManifestStats: + properties: + macros: + type: integer + models: + type: integer + sources: + type: integer + required: + - models + - sources + - macros + type: object + DbtSyncProgress: + properties: + message: + oneOf: + - type: string + description: Human-readable description of what the sync is doing right now. + - type: 'null' + percentComplete: + description: >- + Rough completion percentage derived from the stage. Progress reporting only — do not + gate on it; gate on the status reaching COMPLETED or FAILED. + type: integer + stage: + description: The stage the sync is currently in, from the same set of values as the sync status. + type: string + required: + - stage + - percentComplete + type: object DbtSyncResponse: properties: branchName: @@ -5825,6 +6097,61 @@ components: - workflowId - branchName type: object + DbtSyncResultResponse: + properties: + completedAt: + description: When the sync finished, as an ISO 8601 timestamp. + type: string + cubeCount: + description: How many cubes the sync generated from the dbt manifest. + type: integer + durationMs: + description: End-to-end sync duration in milliseconds. + type: integer + generatedFiles: + description: The cube files this sync generated and committed to the branch it created. + items: + $ref: '#/components/schemas/DbtSyncGeneratedFile' + type: array + manifestStats: + $ref: '#/components/schemas/DbtSyncManifestStats' + syncJobId: + type: string + required: + - syncJobId + - generatedFiles + - manifestStats + - cubeCount + - completedAt + - durationMs + type: object + DbtSyncStatusResponse: + properties: + error: + oneOf: + - type: string + description: Why the sync stopped. Present only when the status is FAILED. + - type: 'null' + progress: + $ref: '#/components/schemas/DbtSyncProgress' + status: + description: >- + One of INITIALIZING, CREATING_SANDBOX, UPLOADING_PROJECT, INSTALLING_DEPENDENCIES, + RUNNING_DBT_DEPS, COMPILING_DBT, PROCESSING_MANIFEST, WRITING_CUBE_FILES, FINALIZING, + COMPLETED, FAILED. COMPLETED and FAILED are the only terminal values: poll until one of + them, then read the result. Treat any unrecognized value as still running. + type: string + syncJobId: + type: string + updatedAt: + description: When this status was last updated, as an ISO 8601 timestamp. + type: string + required: + - syncJobId + - status + - progress + - updatedAt + type: object Deployment: properties: creationStep: @@ -6031,6 +6358,156 @@ components: required: - items type: object + DeploymentSettings: + properties: + autoDomain: + oneOf: + - type: string + - type: 'null' + cloudProvider: + $ref: '#/components/schemas/DeploymentSettingsCloudProvider' + creationMethod: + oneOf: + - $ref: '#/components/schemas/DeploymentSettingsCreationMethod' + - type: 'null' + creationStep: + oneOf: + - $ref: '#/components/schemas/CreationStep' + - type: 'null' + cspsConfig: + oneOf: + - $ref: '#/components/schemas/CspsConfig' + - type: 'null' + cubeImageCloud: + oneOf: + - type: string + - type: 'null' + cubeImageVersion: + oneOf: + - type: string + - type: 'null' + customDomain: + oneOf: + - type: string + - type: 'null' + defaultLaunchpadViewGroup: + oneOf: + - type: string + - type: 'null' + deployBranchMergeAllowed: + type: boolean + deployBranchName: + type: string + deployBranchReadOnly: + type: boolean + deployMode: + oneOf: + - $ref: '#/components/schemas/DeploymentSettingsDeployMode' + - type: 'null' + deployProjectRoot: + type: string + deploymentUrl: + oneOf: + - type: string + - type: 'null' + id: + type: integer + isCold: + oneOf: + - type: boolean + - type: 'null' + isCubeSqlEnabled: + type: boolean + isManaged: + oneOf: + - type: boolean + - type: 'null' + isSupportPreAggregations: + type: boolean + launchpadTabs: + oneOf: + - items: + type: string + type: array + - type: 'null' + name: + type: string + region: + oneOf: + - type: string + - type: 'null' + releaseChannel: + $ref: '#/components/schemas/DeploymentSettingsReleaseChannel' + releaseChannelVersion: + oneOf: + - type: string + - type: 'null' + releaseChannelVersionHold: + oneOf: + - type: boolean + - type: 'null' + repoType: + oneOf: + - $ref: '#/components/schemas/DeploymentSettingsRepoType' + - type: 'null' + targetPlatform: + oneOf: + - type: string + - type: 'null' + template: + $ref: '#/components/schemas/DeploymentSettingsTemplate' + templateVariables: + oneOf: + - type: object + additionalProperties: true + - type: 'null' + required: + - id + - name + - template + - cloudProvider + - releaseChannel + - deployBranchName + - deployBranchReadOnly + - deployBranchMergeAllowed + - deployProjectRoot + - isCubeSqlEnabled + - isSupportPreAggregations + type: object + DeploymentSettingsCloudProvider: + enum: + - cubecloud + - aws + - gcp + type: string + DeploymentSettingsCreationMethod: + enum: + - upload + - cubecloud + - github + - ssh + type: string + DeploymentSettingsDeployMode: + enum: + - git + - cli + type: string + DeploymentSettingsReleaseChannel: + enum: + - latest + - release + type: string + DeploymentSettingsRepoType: + enum: + - git + - deployRepo + type: string + DeploymentSettingsTemplate: + enum: + - single + - simpleCluster + - multiCluster + type: string DeploymentTokenResponse: properties: cubeApiToken: @@ -6038,6 +6515,39 @@ components: required: - cubeApiToken type: object + DeploymentVersion: + properties: + isCurrent: + type: boolean + isLatestInChannel: + type: boolean + releaseChannel: + $ref: '#/components/schemas/DeploymentVersionReleaseChannel' + releaseChannelVersion: + type: string + version: + type: string + required: + - releaseChannelVersion + - version + - releaseChannel + - isLatestInChannel + - isCurrent + type: object + DeploymentVersionReleaseChannel: + enum: + - latest + - release + type: string + DeploymentVersionsResponse: + properties: + data: + items: + $ref: '#/components/schemas/DeploymentVersion' + type: array + required: + - data + type: object DeploymentsListResponse: properties: count: @@ -6238,6 +6748,10 @@ components: oneOf: - type: boolean - type: 'null' + timezone: + oneOf: + - type: string + - type: 'null' type: object EmbedTenant: properties: @@ -7661,6 +8175,10 @@ components: - type: 'null' id: type: integer + isSourceReport: + oneOf: + - type: boolean + - type: 'null' kind: oneOf: - $ref: '#/components/schemas/ReportSnapshotDtoKind' @@ -7839,6 +8357,28 @@ components: - name - actions type: object + SetBranchStagingEnvironmentRequest: + properties: + branchId: + oneOf: + - type: integer + - type: 'null' + branchName: + oneOf: + - type: string + - type: 'null' + enabled: + type: boolean + required: + - enabled + type: object + SetBranchStagingEnvironmentResponse: + properties: + data: + $ref: '#/components/schemas/BranchResponse' + required: + - data + type: object SetEnvVariablesInput: properties: env_variables: @@ -7871,6 +8411,15 @@ components: oneOf: - type: string - type: 'null' + ref: + oneOf: + - type: string + description: >- + Git ref in the dbt repository to sync from — a branch or a tag, NOT a commit SHA. + Overrides the branch saved on the deployment’s dbt git integration for this sync + only, so CI can sync the ref under review (e.g. a pull request’s head branch) + instead of the tracked branch. The generated Cube branch is named after it. + - type: 'null' type: object StartDevModeRequest: properties: @@ -7972,6 +8521,21 @@ components: - url - format type: object + TimezoneSettings: + properties: + allowUserOverride: + oneOf: + - type: boolean + - type: 'null' + enabled: + oneOf: + - type: boolean + - type: 'null' + timezone: + oneOf: + - type: string + - type: 'null' + type: object UpdateDashboardEmbeddingInput: properties: allowEmbed: @@ -7999,6 +8563,10 @@ components: type: object UpdateDeploymentInput: properties: + cloudProvider: + oneOf: + - $ref: '#/components/schemas/UpdateDeploymentInputCloudProvider' + - type: 'null' creationMethod: oneOf: - $ref: '#/components/schemas/UpdateDeploymentInputCreationMethod' @@ -8011,6 +8579,10 @@ components: oneOf: - type: string - type: 'null' + defaultLaunchpadViewGroup: + oneOf: + - type: string + - type: 'null' deployBranchMergeAllowed: oneOf: - type: boolean @@ -8031,11 +8603,48 @@ components: oneOf: - type: string - type: 'null' + launchpadTabs: + oneOf: + - items: + type: string + type: array + - type: 'null' name: oneOf: - type: string - type: 'null' + region: + oneOf: + - type: string + - type: 'null' + releaseChannel: + oneOf: + - $ref: '#/components/schemas/UpdateDeploymentInputReleaseChannel' + - type: 'null' + releaseChannelVersion: + oneOf: + - type: string + - type: 'null' + releaseChannelVersionHold: + oneOf: + - type: boolean + - type: 'null' + template: + oneOf: + - $ref: '#/components/schemas/UpdateDeploymentInputTemplate' + - type: 'null' + templateVariables: + oneOf: + - type: object + additionalProperties: true + - type: 'null' type: object + UpdateDeploymentInputCloudProvider: + enum: + - cubecloud + - aws + - gcp + type: string UpdateDeploymentInputCreationMethod: enum: - upload @@ -8048,6 +8657,17 @@ components: - git - cli type: string + UpdateDeploymentInputReleaseChannel: + enum: + - latest + - release + type: string + UpdateDeploymentInputTemplate: + enum: + - single + - simpleCluster + - multiCluster + type: string UpdateEmbedAccessInput: properties: action: @@ -8236,20 +8856,6 @@ components: maxLength: 128 - type: 'null' type: object - UpdatePublishedAiWidgetThreadInput: - properties: - checksum: - oneOf: - - type: string - - type: 'null' - threadId: - type: string - widgetId: - type: string - required: - - widgetId - - threadId - type: object UpdateReportInput: properties: endResultCell: diff --git a/docs-mintlify/api-reference/introduction.mdx b/docs-mintlify/api-reference/introduction.mdx index f30073ea71ba9..3c416dff09162 100644 --- a/docs-mintlify/api-reference/introduction.mdx +++ b/docs-mintlify/api-reference/introduction.mdx @@ -95,6 +95,7 @@ Resources by entity: | [Embed](/api-reference/embed/get-an-embeddable-dashboard) | `/api/v1/embed` | v1 | | [Embed Tenants](/api-reference/embed-tenants/list-embed-tenants) | `/api/v1/embed-tenants` | v1 | | [Dashboard Embed Access](/api-reference/dashboard-embed-access/list-a-dashboards-embed-access) | `/api/v1/deployments/{deploymentId}/workbooks/{workbookId}/embed-access` | v1 | +| [OpenAPI Spec](/api-reference/openapi-spec/get-the-openapi-specification) | `/api/v1/spec` | v1 | {/* AUTOGEN:platform-endpoints END */} | [Users (SCIM)](/api-reference/scim-users/list-users) | `/scim/v2/Users` | SCIM 2.0 | | [Groups (SCIM)](/api-reference/scim-groups/list-groups) | `/scim/v2/Groups` | SCIM 2.0 | diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json index 0195b5120c9d9..a1598b5ece19a 100644 --- a/docs-mintlify/docs.json +++ b/docs-mintlify/docs.json @@ -762,7 +762,9 @@ "POST /api/v1/deployments/{deploymentId}/creation-step/reset", "GET /api/v1/deployments/{deploymentId}/logs", "GET /api/v1/deployments/{deploymentId}/pods", - "POST /api/v1/deployments/{deploymentId}/token" + "GET /api/v1/deployments/{deploymentId}/settings", + "POST /api/v1/deployments/{deploymentId}/token", + "GET /api/v1/deployments/{deploymentId}/versions" ] }, { @@ -803,6 +805,7 @@ "pages": [ "GET /build/api/v1/deployments/{deploymentId}/branches", "POST /build/api/v1/deployments/{deploymentId}/branches", + "PUT /build/api/v1/deployments/{deploymentId}/branches/staging-environment", "POST /build/api/v1/deployments/{deploymentId}/commit", "GET /build/api/v1/deployments/{deploymentId}/data-model/files", "PUT /build/api/v1/deployments/{deploymentId}/data-model/files", @@ -846,7 +849,10 @@ "group": "dbt Sync", "openapi": "/api-reference/api.yaml", "pages": [ - "POST /api/v1/deployments/{deploymentId}/dbt-sync" + "POST /api/v1/deployments/{deploymentId}/dbt-sync", + "GET /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}", + "DELETE /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}", + "GET /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}/result" ] }, { @@ -885,7 +891,6 @@ "PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}", "DELETE /api/v1/deployments/{deploymentId}/workbooks/{workbookId}", "PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard", - "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread", "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/duplicate", "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/publish" ] @@ -1025,6 +1030,13 @@ "PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/embed-access" ] }, + { + "group": "OpenAPI Spec", + "openapi": "/api-reference/api.yaml", + "pages": [ + "GET /api/v1/spec" + ] + }, { "group": "Users", "openapi": "/api-reference/scim.yaml", @@ -1238,6 +1250,10 @@ { "source": "/docs/integrations/power-bi/ntlm", "destination": "/reference/core-data-apis/dax-api/ntlm" + }, + { + "source": "/api-reference/workbooks/update-published-dashboard-ai-widget-thread", + "destination": "/api-reference/workbooks/update-workbook-dashboard" } ] } diff --git a/docs-mintlify/reference/cli.mdx b/docs-mintlify/reference/cli.mdx index 047ab4f288817..8f991fcae6872 100644 --- a/docs-mintlify/reference/cli.mdx +++ b/docs-mintlify/reference/cli.mdx @@ -146,7 +146,7 @@ Run `cube --help` for the full options of any command. | Command | Description | | --- | --- | | `login`, `logout`, `whoami`, `context` | Authentication and saved contexts | -| `deployments` | List, get, create, update, delete deployments; `token`, `build-status`, `advance-step`, `reset-step` | +| `deployments` | List, get, create, update, delete deployments; `settings`, `versions`, `token`, `build-status`, `advance-step`, `reset-step` | | `deploy` | Upload a local project directory and build it | | `logs` | Tail deployment pod logs (`--pod`, `-c/--container`, `--source production\|dev`) | | `regions` | List available deployment regions | @@ -159,6 +159,7 @@ Run `cube --help` for the full options of any command. | `tenant`, `notifications`, `integrations`, `oidc`, `api-keys` | Account administration | | `embed` | Embed sessions, tokens, embed tenants; `enable-dashboard`/`disable-dashboard` toggle signed embedding for a dashboard | | `agents`, `app`, `meta`, `scim` | Agents, app config, model metadata, SCIM v2 | +| `spec` | Show the API's OpenAPI specification — see [Discovering the API](#discovering-the-api) | | `api` | Raw authenticated API request (escape hatch): `cube api GET /api/v1/... -q key=value -d '{...}'` | | `update` | Update the CLI to the latest release | | `completion` | Generate shell completions | @@ -166,6 +167,81 @@ Run `cube --help` for the full options of any command. List commands print tables by default; pass `--json` anywhere for raw JSON output, suitable for piping to `jq`. +## Changing the Cube version + +`cube deployments versions` lists the Cube versions a deployment can switch to +— the head of each [update channel][ref-update-channels], plus the older +versions your account has run before: + +```bash +cube deployments versions DEPLOYMENT_ID +``` + +``` +VERSION CHANNEL LATEST CURRENT PASS AS +1.7.20 latest true true cubejs/cube:v1.7.20 +1.6.69 latest false false cubejs/cube:v1.6.69 +``` + +Apply one with `update`. Any of `1.7.20`, `v1.7.20` or `cubejs/cube:v1.7.20` is +accepted; a version that is not on the list is rejected. The container image is +resolved from the version, so there is nothing else to set: + +```bash +cube deployments update DEPLOYMENT_ID --release-channel-version 1.7.20 +cube deployments update DEPLOYMENT_ID --release-channel release # move to a channel's latest +``` + +`cube deployments settings DEPLOYMENT_ID` reads back every setting, including +the version and channel currently in effect. + +## Discovering the API + +`cube spec` prints the OpenAPI specification of the API you are logged into, so +neither you nor an AI agent has to guess an endpoint's parameters. It reads +`/api/v1/spec` from the deployment itself, which means the contract you get is +the one that build actually serves. + +With no arguments it lists every operation: + +```bash +cube spec +``` + +``` +METHOD PATH SUMMARY +GET /api/v1/deployments Get deployments +PUT /api/v1/deployments/{deploymentId} Update a deployment +... +``` + +Pass a pattern to narrow it down. The match is case-insensitive and covers the +method, path, summary, and operation id: + +```bash +cube spec settings +``` + +Add `--json` to get OpenAPI instead of a table. Unfiltered, that is the entire +document — pipe it into a code generator or a validator. Filtered, it is a +smaller but still valid document containing just the matching operations plus +every schema they reference, transitively: + +```bash +cube spec updateDeployment --json +``` + +That last form is the one to reach for when you want an endpoint's full +parameter list: the request body's schema is included rather than left as a +`$ref` pointing into a document you would then have to fetch in full. + + + +Point an agent at `cube spec --json` and it can construct a correct +request without any hardcoded knowledge of the API. + + + ## Data model Git workflow Edit the data model through branches without touching production: @@ -224,3 +300,4 @@ or explicitly with `CUBE_NO_TELEMETRY=1` (or the legacy `CUBEJS_TELEMETRY=false` [ref-api-keys]: /admin/account-billing/api-keys [ref-rest-api]: /reference/core-data-apis/rest-api/index [ref-staging-env]: /admin/deployment/environments#staging-environments +[ref-update-channels]: /admin/deployment#update-channels diff --git a/docs-mintlify/reference/control-plane-api.mdx b/docs-mintlify/reference/control-plane-api.mdx index 66b9683849104..0122386052f57 100644 --- a/docs-mintlify/reference/control-plane-api.mdx +++ b/docs-mintlify/reference/control-plane-api.mdx @@ -273,6 +273,59 @@ Example response: The same operation is available in the [CLI][ref-cli] as `cube data-model enable-branch` / `cube data-model disable-branch`. +### `/api/v1/deployments/{deployment_id}/versions` + +Send a `GET` request to list the Cube versions a deployment can be switched +to — the same set the Cube Cloud UI's version picker offers: the head of each +[update channel][ref-update-channels], plus the older versions your account has +run before. + +Example request: + +```bash +curl \ + -H "Authorization: Bearer YOUR_API_KEY" \ + "https://YOUR_CUBE_CLOUD_HOST/api/v1/deployments/123/versions" +``` + +Example response: + +```json +{ + "data": [ + { + "releaseChannelVersion": "cubejs/cube:v1.7.20", + "version": "1.7.20", + "releaseChannel": "latest", + "isLatestInChannel": true, + "isCurrent": true + }, + { + "releaseChannelVersion": "cubejs/cube:v1.6.69", + "version": "1.6.69", + "releaseChannel": "latest", + "isLatestInChannel": false, + "isCurrent": false + } + ] +} +``` + +To change the version, send a listed value as `releaseChannelVersion` to +`PUT /api/v1/deployments/{deployment_id}`. Any of `1.7.20`, `v1.7.20` or +`cubejs/cube:v1.7.20` is accepted; a version that is not on the list is +rejected with a `400`. The container image is resolved from the version +server-side and cannot be set directly. + + +Leaving `releaseChannelVersion` out and sending only `releaseChannel` moves the +deployment to that channel's latest version. + + +The same operation is available in the [CLI][ref-cli] as +`cube deployments versions`, with `cube deployments update ID +--release-channel-version 1.7.20` to apply one. + ### `/api/v1/audit-logs/export` Send a `GET` request to export [audit log][ref-audit-log] events as a CSV @@ -327,4 +380,5 @@ curl \ [ref-cli]: /reference/cli [ref-api-keys]: /admin/account-billing/api-keys [ref-security-context]: /docs/data-modeling/access-control/context -[ref-audit-log]: /admin/monitoring/audit-log \ No newline at end of file +[ref-audit-log]: /admin/monitoring/audit-log +[ref-update-channels]: /admin/deployment#update-channels \ No newline at end of file diff --git a/docs-mintlify/scripts/extract-api.mjs b/docs-mintlify/scripts/extract-api.mjs index 77926d22af232..a685e7f0c8706 100644 --- a/docs-mintlify/scripts/extract-api.mjs +++ b/docs-mintlify/scripts/extract-api.mjs @@ -175,6 +175,8 @@ const TAG_MAP = { 'Deployments Build Public': 'Deployment Creation', 'Uploads Public': 'Data Model Uploads', 'Git Hub Deployment Public': 'GitHub Connection', + // Auto-cleaning title-cases the controller name into "Open Api Spec". + 'Open Api Spec Public': 'OpenAPI Spec', }; // Preferred nav order. Tags not listed here are appended alphabetically, so the // docs stay complete even when the upstream spec adds new areas. @@ -186,6 +188,7 @@ const TAG_ORDER = [ 'User Attributes', 'User Attribute Values', 'Resource Policies', 'Tenant Settings', 'OAuth Integrations', 'User OAuth Tokens', 'OIDC Token Configs', 'App Theme', 'AI Engineer', 'Embed', 'Embed Tenants', 'Dashboard Embed Access', + 'OpenAPI Spec', ]; // Mintlify renders the OpenAPI operation `description` as a plain-text node — it diff --git a/packages/cubejs-api-gateway/src/gateway.ts b/packages/cubejs-api-gateway/src/gateway.ts index 8a4ef7874a034..e595cc3e61c1b 100644 --- a/packages/cubejs-api-gateway/src/gateway.ts +++ b/packages/cubejs-api-gateway/src/gateway.ts @@ -130,6 +130,18 @@ function systemAsyncHandler(handler: (req: Request & { context: ExtendedRequestC }; } +const DEV_TOKEN_SCOPE = 'dev-token'; + +function hasDevTokenScope(securityContext: unknown): boolean { + if (typeof securityContext !== 'object' || securityContext === null) { + return false; + } + + const { scope } = >securityContext; + + return Array.isArray(scope) && scope.includes(DEV_TOKEN_SCOPE); +} + // Prepared CheckAuthFn, default or from config: always async type PreparedCheckAuthFn = (ctx: any, authorization?: string) => Promise<{ securityContext: any; @@ -2615,7 +2627,8 @@ class ApiGateway { if (auth) { try { req.securityContext = await checkAuthFn(auth); - req.signedWithPlaygroundAuthSecret = Boolean(internalOptions?.isPlaygroundCheckAuth); + req.signedWithPlaygroundAuthSecret = + Boolean(internalOptions?.isPlaygroundCheckAuth) && hasDevTokenScope(req.securityContext); } catch (e: any) { if (this.enforceSecurityChecks) { throw new CubejsHandlerError(403, 'Forbidden', 'Invalid token', e); diff --git a/packages/cubejs-api-gateway/test/auth.test.ts b/packages/cubejs-api-gateway/test/auth.test.ts index 2758183f5d8dd..049137a5e5c2c 100644 --- a/packages/cubejs-api-gateway/test/auth.test.ts +++ b/packages/cubejs-api-gateway/test/auth.test.ts @@ -280,6 +280,69 @@ describe('test authorization', () => { expectSecurityContext(handlerMock.mock.calls[0][0].context.authInfo); }); + describe('signedWithPlaygroundAuthSecret requires the dev-token scope', () => { + const playgroundAuthSecret = 'playgroundSecret'; + const loggerMock = jest.fn(() => { + // + }); + + // The playground secret signs every token a Cube Cloud deployment mints, + // including ones handed to end users and external BI tools, so the + // signature alone must not unlock the developer affordances gated on this + // flag (hidden meta members, generated SQL, pre-aggregation debug info). + const flagFor = async (payload: Record) => { + const seen: boolean[] = []; + const handlerMock = jest.fn((req, res) => { + seen.push(req.context.signedWithPlaygroundAuthSecret); + res.status(200).end(); + }); + + const { app } = createApiGateway(handlerMock, loggerMock, { playgroundAuthSecret }); + + await request(app) + .get('/test-auth-fake') + .set('Authorization', `Authorization: ${generateAuthToken(payload, {}, playgroundAuthSecret)}`) + .expect(200); + + return seen[0]; + }; + + test('is false for a playground-signed token with no scope at all', async () => { + expect(await flagFor({ uid: 5 })).toBe(false); + }); + + test('is false for a playground-signed token scoped to something else', async () => { + expect(await flagFor({ uid: 5, scope: ['sql-runner', 'agents-config'] })).toBe(false); + }); + + test('is true for a playground-signed token carrying the dev-token scope', async () => { + expect(await flagFor({ uid: 5, scope: ['dev-token'] })).toBe(true); + // Alongside the service scopes it is minted with in practice. + expect(await flagFor({ uid: 5, scope: ['sql-runner', 'dev-token'] })).toBe(true); + }); + + test('is false when scope is not an array of scope names', async () => { + expect(await flagFor({ uid: 5, scope: 'dev-token' })).toBe(false); + expect(await flagFor({ uid: 5, scope: { 'dev-token': true } })).toBe(false); + }); + + test('is false for a token signed with the main api secret, scope or not', async () => { + const handlerMock = jest.fn((req, res) => { + expect(req.context.signedWithPlaygroundAuthSecret).toBe(false); + res.status(200).end(); + }); + + const { app } = createApiGateway(handlerMock, loggerMock, { playgroundAuthSecret }); + + await request(app) + .get('/test-auth-fake') + .set('Authorization', `Authorization: ${generateAuthToken({ uid: 5, scope: ['dev-token'] }, {})}`) + .expect(200); + + expect(handlerMock.mock.calls.length).toEqual(1); + }); + }); + test('default authorization with JWT token and securityContext in u', async () => { const loggerMock = jest.fn(() => { // diff --git a/rust/cube-cli/README.md b/rust/cube-cli/README.md index 1a010a36e3a6f..df7843f5577b8 100644 --- a/rust/cube-cli/README.md +++ b/rust/cube-cli/README.md @@ -128,7 +128,7 @@ Every endpoint of the Console Server public API is covered: | Group | Endpoints | |---|---| -| `deployments` | list, get, create (`--bootstrap` scaffolds + builds a serving deployment), update, delete, token, advance-step, reset-step | +| `deployments` | list, get, create (`--bootstrap` scaffolds + builds a serving deployment), update (`--release-channel`, `--release-channel-version`), settings, versions, delete, token, advance-step, reset-step | | `regions` | list available deployment regions | | `logs` | tail deployment pod logs (`--pod`, `-c/--container`; defaults to the Cube API container) | | `github` (`gh`) | status, installations, repos, branches, connect (import a repo into a deployment + first build) | @@ -152,6 +152,7 @@ Every endpoint of the Console Server public API is covered: | `app` | config, theme | | `meta` | POST /api/v1/meta/ | | `scim` | Users/Groups CRUD + patch, resource-types, schemas, service-provider-config | +| `spec` | the API's own OpenAPI document from `/api/v1/spec`: bare lists every operation, `` filters on method/path/summary/operationId, `--json` prints OpenAPI (filtered = matching operations + the transitive schema closure) | | `api` | raw escape hatch: `cube api GET /api/v1/... -q key=value -d '{...}'` | Conventions: diff --git a/rust/cube-cli/src/commands/deployments.rs b/rust/cube-cli/src/commands/deployments.rs index e7b8899740c23..5cc9b08feeef8 100644 --- a/rust/cube-cli/src/commands/deployments.rs +++ b/rust/cube-cli/src/commands/deployments.rs @@ -69,10 +69,26 @@ enum Cmd { /// Name #[arg(long)] name: Option, + /// Release channel: latest, release + #[arg(long)] + release_channel: Option, + /// Cube version to run — see `cube deployments versions ` + #[arg(long)] + release_channel_version: Option, /// Request body as JSON (inline, @file, or - for stdin) #[arg(long, short = 'd')] data: Option, }, + /// Show every setting of a deployment + Settings { + /// Deployment id + deployment: i64, + }, + /// List the Cube versions a deployment can switch to + Versions { + /// Deployment id + deployment: i64, + }, /// Delete a deployment #[command(alias = "rm")] Delete { @@ -181,10 +197,14 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { Cmd::Update { deployment, name, + release_channel, + release_channel_version, data, } => { let mut body = util::parse_data(data.as_deref())?; util::set(&mut body, "name", &name); + util::set(&mut body, "releaseChannel", &release_channel); + util::set(&mut body, "releaseChannelVersion", &release_channel_version); let res = api .put( &format!("/api/v1/deployments/{deployment}"), @@ -193,6 +213,34 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { .await?; output::print_json(&res); } + Cmd::Settings { deployment } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/settings"), + &Vec::new(), + ) + .await?; + output::print_json(&res); + } + Cmd::Versions { deployment } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/versions"), + &Vec::new(), + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("VERSION", "version"), + ("CHANNEL", "releaseChannel"), + ("LATEST", "isLatestInChannel"), + ("CURRENT", "isCurrent"), + ("PASS AS", "releaseChannelVersion"), + ], + ); + } Cmd::Delete { deployment } => { let res = api .delete(&format!("/api/v1/deployments/{deployment}"), None) diff --git a/rust/cube-cli/src/commands/mod.rs b/rust/cube-cli/src/commands/mod.rs index 96e17a55de8bb..001e7b4375a06 100644 --- a/rust/cube-cli/src/commands/mod.rs +++ b/rust/cube-cli/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod policies; pub mod regions; pub mod reports; pub mod scim; +pub mod spec; pub mod tenant; pub mod update; pub mod users; diff --git a/rust/cube-cli/src/commands/spec.rs b/rust/cube-cli/src/commands/spec.rs new file mode 100644 index 0000000000000..73d129c30b7bf --- /dev/null +++ b/rust/cube-cli/src/commands/spec.rs @@ -0,0 +1,440 @@ +use std::collections::BTreeSet; + +use anyhow::{bail, Result}; +use serde_json::{json, Map, Value}; + +use crate::{output, Ctx}; + +/// The HTTP verbs an OpenAPI path item can hold. Anything else under a path +/// (`parameters`, `summary`, `$ref`, extensions) is not an operation. +const METHODS: [&str; 8] = [ + "get", "put", "post", "patch", "delete", "options", "head", "trace", +]; + +const SCHEMA_REF_PREFIX: &str = "#/components/schemas/"; +const COMPONENT_REF_PREFIX: &str = "#/components/"; + +/// Fetch the API's own OpenAPI document, so every endpoint, parameter and +/// schema can be discovered at runtime rather than guessed. +/// +/// Without `--json` this prints an index of operations, which is what a human +/// scanning for an endpoint wants. With `--json` it prints OpenAPI: the whole +/// document when unfiltered, or — when a pattern is given — a valid but much +/// smaller document containing only the matching operations plus the transitive +/// closure of the schemas they reference. That closure is the point of the +/// filter: an agent asking "what does this endpoint take?" needs the request +/// body's schema resolved, not a dangling `$ref` that forces it to pull the +/// whole spec anyway. +#[derive(clap::Args)] +pub struct Args { + /// Show only operations whose method, path, summary or operationId contains + /// this text (case-insensitive) + pattern: Option, +} + +/// One operation, flattened out of the nested `paths` → method structure. +struct Operation<'a> { + path: &'a str, + method: &'a str, + op: &'a Value, +} + +impl Operation<'_> { + fn summary(&self) -> &str { + self.op + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + } + + fn operation_id(&self) -> &str { + self.op + .get("operationId") + .and_then(Value::as_str) + .unwrap_or_default() + } + + fn matches(&self, needle: &str) -> bool { + let haystack = format!( + "{} {} {} {}", + self.method, + self.path, + self.summary(), + self.operation_id() + ) + .to_lowercase(); + haystack.contains(needle) + } +} + +fn operations(spec: &Value) -> Vec> { + let Some(paths) = spec.get("paths").and_then(Value::as_object) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (path, item) in paths { + let Some(item) = item.as_object() else { + continue; + }; + // Iterate METHODS rather than the object's keys so operations always + // come out in verb order, not whatever order the server serialized. + for method in METHODS { + if let Some(op) = item.get(method) { + out.push(Operation { path, method, op }); + } + } + } + out +} + +/// Every `#/components/schemas/...` name referenced anywhere inside `value`. +fn collect_refs(value: &Value, out: &mut BTreeSet) { + match value { + Value::Object(map) => { + for (key, child) in map { + if key == "$ref" { + if let Some(name) = child + .as_str() + .and_then(|r| r.strip_prefix(SCHEMA_REF_PREFIX)) + { + out.insert(name.to_string()); + } + } + collect_refs(child, out); + } + } + Value::Array(items) => { + for item in items { + collect_refs(item, out); + } + } + _ => {} + } +} + +/// Expand a set of schema names to include everything they reference, however +/// deeply. Schemas are mutually recursive in places, so this runs to a fixpoint +/// over a visited set rather than recursing through the graph. +fn schema_closure(spec: &Value, seeds: BTreeSet) -> BTreeSet { + let all = spec + .get("components") + .and_then(|c| c.get("schemas")) + .and_then(Value::as_object); + let Some(all) = all else { + return BTreeSet::new(); + }; + + let mut resolved: BTreeSet = BTreeSet::new(); + let mut pending: Vec = seeds.into_iter().collect(); + while let Some(name) = pending.pop() { + if !resolved.insert(name.clone()) { + continue; + } + if let Some(schema) = all.get(&name) { + let mut refs = BTreeSet::new(); + collect_refs(schema, &mut refs); + pending.extend(refs.into_iter().filter(|r| !resolved.contains(r))); + } + } + resolved +} + +/// Build a valid OpenAPI document holding only `matched`, carrying over the +/// document-level fields a client needs to make sense of it (version, servers, +/// security) plus just the schemas those operations reach. +fn filtered_document(spec: &Value, matched: &[&Operation<'_>]) -> Value { + let mut paths = Map::new(); + let mut seeds = BTreeSet::new(); + for op in matched { + collect_refs(op.op, &mut seeds); + let item = paths + .entry(op.path.to_string()) + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("path item is an object"); + item.insert(op.method.to_string(), op.op.clone()); + + // A path item may declare `parameters` that apply to every operation + // under it. Dropping them would silently shrink the parameter list — + // the one thing this command exists to report — so they come along and + // their refs are seeded too. + if let Some(shared) = spec + .get("paths") + .and_then(|p| p.get(op.path)) + .and_then(|i| i.get("parameters")) + { + collect_refs(shared, &mut seeds); + item.insert("parameters".into(), shared.clone()); + } + } + + let names = schema_closure(spec, seeds); + let mut schemas = Map::new(); + if let Some(all) = spec + .get("components") + .and_then(|c| c.get("schemas")) + .and_then(Value::as_object) + { + for name in &names { + if let Some(schema) = all.get(name) { + schemas.insert(name.clone(), schema.clone()); + } + } + } + + let mut components = Map::new(); + components.insert("schemas".into(), Value::Object(schemas)); + // Security schemes are tiny and tell the reader how to authenticate the + // operations it just asked about, so they ride along. + if let Some(security_schemes) = spec + .get("components") + .and_then(|c| c.get("securitySchemes")) + { + components.insert("securitySchemes".into(), security_schemes.clone()); + } + + let mut doc = Map::new(); + for key in ["openapi", "info", "servers", "security"] { + if let Some(value) = spec.get(key) { + doc.insert(key.to_string(), value.clone()); + } + } + doc.insert("paths".into(), Value::Object(paths)); + doc.insert("components".into(), Value::Object(components)); + Value::Object(doc) +} + +/// Every `#/components//` ref in `value`, as (bucket, name). +fn collect_component_refs(value: &Value, out: &mut BTreeSet<(String, String)>) { + match value { + Value::Object(map) => { + for (key, child) in map { + if key == "$ref" { + if let Some(rest) = child + .as_str() + .and_then(|r| r.strip_prefix(COMPONENT_REF_PREFIX)) + { + if let Some((bucket, name)) = rest.split_once('/') { + out.insert((bucket.to_string(), name.to_string())); + } + } + } + collect_component_refs(child, out); + } + } + Value::Array(items) => { + for item in items { + collect_component_refs(item, out); + } + } + _ => {} + } +} + +/// Refuse to emit a document whose `$ref`s don't resolve inside it. +/// +/// The closure only follows `#/components/schemas/`, which is everything the +/// current spec uses. If an operation ever references another bucket +/// (`parameters`, `responses`, `requestBodies`, …), the filtered document would +/// still carry the `$ref` but not its target: a validator rejects it and an +/// agent resolves it to nothing. Since this output is meant to be consumed +/// unattended, fail loudly rather than hand back something quietly wrong. +fn check_no_dangling_refs(doc: &Value) -> Result<()> { + let mut refs = BTreeSet::new(); + collect_component_refs(doc, &mut refs); + + let dangling: Vec = refs + .into_iter() + .filter(|(bucket, name)| { + doc.get("components") + .and_then(|c| c.get(bucket)) + .and_then(|b| b.get(name)) + .is_none() + }) + .map(|(bucket, name)| format!("#/components/{bucket}/{name}")) + .collect(); + + if !dangling.is_empty() { + bail!( + "internal error building the filtered spec: unresolved {} — \ + re-run without a pattern to get the whole document", + dangling.join(", ") + ); + } + Ok(()) +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let spec = ctx.api()?.get("/api/v1/spec", &Vec::new()).await?; + + // Unfiltered JSON is the raw document — no reshaping, so it can be piped + // straight into a generator or a validator. + let Some(pattern) = args.pattern.as_deref() else { + if ctx.json { + output::print_json(&spec); + } else { + print_index(&operations(&spec).iter().collect::>()); + } + return Ok(()); + }; + + let needle = pattern.to_lowercase(); + let all = operations(&spec); + let matched: Vec<&Operation<'_>> = all.iter().filter(|op| op.matches(&needle)).collect(); + + if matched.is_empty() { + bail!("no operation matches `{pattern}` — run `cube spec` to list them all"); + } + + if ctx.json { + let doc = filtered_document(&spec, &matched); + check_no_dangling_refs(&doc)?; + output::print_json(&doc); + } else { + print_index(&matched); + } + Ok(()) +} + +fn print_index(operations: &[&Operation<'_>]) { + let rows = operations + .iter() + .map(|op| { + vec![ + op.method.to_uppercase(), + op.path.to_string(), + op.summary().to_string(), + ] + }) + .collect(); + output::table(&["METHOD", "PATH", "SUMMARY"], rows); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "t", "version": "1" }, + "paths": { + "/api/v1/deployments/{id}/settings": { + "get": { "operationId": "getSettings", "summary": "Get deployment settings" }, + "put": { + "operationId": "updateSettings", + "summary": "Update deployment settings", + "requestBody": { "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateDeploymentInput" } } } } + }, + "parameters": [{ "name": "id", "in": "path" }] + }, + "/api/v1/regions": { "get": { "operationId": "listRegions", "summary": "List regions" } } + }, + "components": { + "securitySchemes": { "bearerAuth": { "type": "http" } }, + "schemas": { + "UpdateDeploymentInput": { "properties": { + "cspsConfig": { "$ref": "#/components/schemas/CspsConfig" } } }, + "CspsConfig": { "properties": { + "self": { "$ref": "#/components/schemas/CspsConfig" } } }, + "Unrelated": { "type": "object" } + } + } + }) + } + + #[test] + fn lists_operations_in_verb_order_ignoring_non_operation_keys() { + let spec = spec(); + let ops = operations(&spec); + assert_eq!(ops.len(), 3); + // `parameters` on the path item is not an operation. + assert!(ops.iter().all(|o| METHODS.contains(&o.method))); + let settings: Vec<&str> = ops + .iter() + .filter(|o| o.path.ends_with("/settings")) + .map(|o| o.method) + .collect(); + assert_eq!(settings, vec!["get", "put"]); + } + + #[test] + fn matches_on_path_summary_and_operation_id() { + let spec = spec(); + let ops = operations(&spec); + assert_eq!(ops.iter().filter(|o| o.matches("settings")).count(), 2); + assert_eq!(ops.iter().filter(|o| o.matches("listregions")).count(), 1); + // Case-insensitive, and the method is part of the haystack. + assert_eq!(ops.iter().filter(|o| o.matches("put")).count(), 1); + assert_eq!(ops.iter().filter(|o| o.matches("nope")).count(), 0); + } + + #[test] + fn filtered_document_carries_the_reachable_schemas_only() { + let spec = spec(); + let ops = operations(&spec); + let matched: Vec<&Operation<'_>> = ops.iter().filter(|o| o.matches("settings")).collect(); + let doc = filtered_document(&spec, &matched); + + let paths = doc["paths"].as_object().unwrap(); + assert_eq!(paths.len(), 1); + let item = paths["/api/v1/deployments/{id}/settings"] + .as_object() + .unwrap(); + assert!(item.contains_key("get") && item.contains_key("put")); + + let schemas = doc["components"]["schemas"].as_object().unwrap(); + // Reached through the request body, and through CspsConfig's self-ref + // (which must terminate rather than spin). + assert!(schemas.contains_key("UpdateDeploymentInput")); + assert!(schemas.contains_key("CspsConfig")); + assert!(!schemas.contains_key("Unrelated")); + // Still a usable document: version, info and auth survive. + assert_eq!(doc["openapi"], "3.1.0"); + assert!(doc["info"].is_object()); + assert!(doc["components"]["securitySchemes"]["bearerAuth"].is_object()); + // Nothing points outside the document it just built. + check_no_dangling_refs(&doc).unwrap(); + } + + #[test] + fn filtered_document_keeps_path_level_parameters() { + let spec = spec(); + let ops = operations(&spec); + let matched: Vec<&Operation<'_>> = ops.iter().filter(|o| o.matches("settings")).collect(); + let doc = filtered_document(&spec, &matched); + + // `id` is declared once on the path item, not per operation. Losing it + // would understate the endpoint's parameters — the exact thing this + // command is supposed to report. + let params = doc["paths"]["/api/v1/deployments/{id}/settings"]["parameters"] + .as_array() + .expect("path-level parameters survive filtering"); + assert_eq!(params.len(), 1); + assert_eq!(params[0]["name"], "id"); + } + + #[test] + fn dangling_component_refs_are_rejected() { + // A ref into a bucket the closure doesn't follow: the document keeps the + // `$ref` but not its target, so it must be refused rather than emitted. + let doc = json!({ + "openapi": "3.1.0", + "paths": { "/x": { "get": { + "parameters": [{ "$ref": "#/components/parameters/Missing" }] } } }, + "components": { "schemas": {} } + }); + let err = check_no_dangling_refs(&doc).unwrap_err().to_string(); + assert!(err.contains("#/components/parameters/Missing"), "{err}"); + + // A resolvable ref in a non-schema bucket is fine. + let ok = json!({ + "openapi": "3.1.0", + "paths": { "/x": { "get": { + "parameters": [{ "$ref": "#/components/parameters/Present" }] } } }, + "components": { "parameters": { "Present": { "name": "p", "in": "query" } } } + }); + check_no_dangling_refs(&ok).unwrap(); + } +} diff --git a/rust/cube-cli/src/main.rs b/rust/cube-cli/src/main.rs index 7df4dd0b12b5d..a6bd3181cddc5 100644 --- a/rust/cube-cli/src/main.rs +++ b/rust/cube-cli/src/main.rs @@ -190,6 +190,8 @@ enum Command { /// SCIM v2 user and group provisioning Scim(commands::scim::Args), + /// Show this API's OpenAPI specification (endpoints, parameters, schemas) + Spec(commands::spec::Args), /// Make an authenticated raw API request (escape hatch) Api(commands::api::Args), /// Update the CLI to the latest release @@ -239,6 +241,7 @@ impl Command { App(_) => "app", Meta(_) => "meta", Scim(_) => "scim", + Spec(_) => "spec", Api(_) => "api", Update(_) => "update", Completion(_) => "completion", @@ -335,6 +338,7 @@ async fn run(global: GlobalArgs, command: Command) -> Result<()> { App(args) => commands::app::command(args, &ctx).await, Meta(args) => commands::meta::command(args, &ctx).await, Scim(args) => commands::scim::command(args, &ctx).await, + Spec(args) => commands::spec::command(args, &ctx).await, Api(args) => commands::api::command(args, &ctx).await, Update(args) => commands::update::command(args, &ctx).await, Completion(args) => commands::completion::command(args),