diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index f01e5ef8ab..8c4f889d74 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -2,6 +2,11 @@ name: Pull Request on: pull_request: workflow_dispatch: + inputs: + debug_cypress: + description: "Enable Cypress debug mode (videos, extra logs)" + type: boolean + default: false permissions: contents: read id-token: write # Required for upload.yaml AWS OIDC authentication @@ -120,6 +125,8 @@ jobs: !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') uses: ./.github/workflows/e2e.yaml + with: + debug_cypress: ${{ inputs.debug_cypress || false }} secrets: CONNECT_LICENSE: ${{ secrets.CONNECT_LICENSE }} CONNECT_LICENSE_FILE: ${{ secrets.CONNECT_LICENSE_FILE }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c41729b9f8..94e6546ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Connect Cloud users who have permissions to publish to multiple accounts are able to create credentials again. (#3446) +- Fixed configuration schema to use `auth_type` (snake_case) for integration requests, matching the format the extension actually produces. Added strict property validation to integration request items. (#3651) ## [1.34.0] diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index 990930bafe..08606c81b0 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.44", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "axios": "^1.13.6", "debounce": "^3.0.0", @@ -18,6 +20,7 @@ "filenamify": "^7.0.1", "get-port": "^7.1.0", "retry": "^0.13.1", + "smol-toml": "^1.6.0", "tar-stream": "^3.1.8", "vscode-uri": "^3.0.8" }, @@ -1765,22 +1768,38 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2870,6 +2889,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2906,6 +2942,13 @@ "node": ">=10.13.0" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -3223,7 +3266,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -3246,6 +3288,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3986,10 +4044,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5050,6 +5107,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -5240,6 +5306,18 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 7194fb0b3e..cda3645baf 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -680,6 +680,8 @@ }, "dependencies": { "@vscode/codicons": "^0.0.44", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "axios": "^1.13.6", "debounce": "^3.0.0", @@ -688,6 +690,7 @@ "filenamify": "^7.0.1", "get-port": "^7.1.0", "retry": "^0.13.1", + "smol-toml": "^1.6.0", "tar-stream": "^3.1.8", "vscode-uri": "^3.0.8" } diff --git a/extensions/vscode/src/api/resources/Configurations.ts b/extensions/vscode/src/api/resources/Configurations.ts index 891ad14b09..d926877e4d 100644 --- a/extensions/vscode/src/api/resources/Configurations.ts +++ b/extensions/vscode/src/api/resources/Configurations.ts @@ -2,12 +2,7 @@ import { AxiosInstance } from "axios"; -import { - Configuration, - ConfigurationDetails, - ConfigurationError, - ConfigurationInspectionResult, -} from "../types/configurations"; +import { ConfigurationInspectionResult } from "../types/configurations"; import { PythonExecutable, RExecutable } from "../../types/shared"; export class Configurations { @@ -17,52 +12,6 @@ export class Configurations { this.client = client; } - // Returns: - // 200 - success - // 404 - not found - // 500 - internal server error - get(configName: string, dir: string) { - const encodedName = encodeURIComponent(configName); - return this.client.get( - `/configurations/${encodedName}`, - { - params: { dir }, - }, - ); - } - - // Returns: - // 200 - success - // 500 - internal server error - getAll(dir: string, params?: { entrypoint?: string; recursive?: boolean }) { - return this.client.get>( - "/configurations", - { - params: { - dir, - ...params, - }, - }, - ); - } - - // Returns: - // 200 - success - // 400 - bad request - // 500 - internal server error - createOrUpdate(configName: string, cfg: ConfigurationDetails, dir: string) { - const encodedName = encodeURIComponent(configName); - return this.client.put( - `configurations/${encodedName}`, - cfg, - { - params: { - dir, - }, - }, - ); - } - // Inspect the project, returning all possible (detected) configurations // Returns: // 200 - success diff --git a/extensions/vscode/src/api/types/configurations.ts b/extensions/vscode/src/api/types/configurations.ts index d381b9f6b6..cc6465ee08 100644 --- a/extensions/vscode/src/api/types/configurations.ts +++ b/extensions/vscode/src/api/types/configurations.ts @@ -9,7 +9,6 @@ import { ProductType } from "./contentRecords"; export type ConfigurationLocation = { configurationName: string; configurationPath: string; - configurationRelPath: string; projectDir: string; }; @@ -114,26 +113,27 @@ export const contentTypeStrings = { export type ConfigurationDetails = { $schema: SchemaURL; + comments?: string[]; alternatives?: ConfigurationDetails[]; productType: ProductType; type: ContentType; entrypoint?: string; + entrypointObjectRef?: string; source?: string; title?: string; description?: string; - thumbnail?: string; - tags?: string[]; + hasParameters?: boolean; python?: PythonConfig; r?: RConfig; quarto?: QuartoConfig; + jupyter?: JupyterConfig; environment?: EnvironmentConfig; validate: boolean; files?: string[]; secrets?: string[]; integrationRequests?: IntegrationRequest[]; - schedules?: ScheduleConfig[]; - access?: AccessConfig; connect?: ConnectConfig; + connectCloud?: ConnectCloudConfig; }; export type IntegrationRequest = { @@ -151,12 +151,15 @@ export type PythonConfig = { version: string; packageFile: string; packageManager: string; + requiresPython?: string; }; export type RConfig = { version: string; packageFile: string; packageManager: string; + requiresR?: string; + packagesFromLibrary?: boolean; }; export type QuartoConfig = { @@ -166,35 +169,19 @@ export type QuartoConfig = { export type EnvironmentConfig = Record; -export type ScheduleConfig = { - start: string; - recurrence: string; +export type JupyterConfig = { + hideAllInput?: boolean; + hideTaggedInput?: boolean; }; -export enum AccessType { - ANONYMOUS = "all", - LOGGED_IN = "logged-in", - ACL = "acl", -} - -export type AccessConfig = { - type: AccessType; - users?: User[]; - groups?: Group[]; -}; - -export type User = { - id?: string; - guid?: string; - name?: string; - permissions: string; +export type ConnectCloudConfig = { + vanityName?: string; + accessControl?: ConnectCloudAccessControl; }; -export type Group = { - id?: string; - guid?: string; - name?: string; - permissions: string; +export type ConnectCloudAccessControl = { + publicAccess?: boolean; + organizationAccess?: string; }; export function UpdateAllConfigsWithDefaults( diff --git a/extensions/vscode/src/api/types/connect.ts b/extensions/vscode/src/api/types/connect.ts index 701c504297..c49f685f27 100644 --- a/extensions/vscode/src/api/types/connect.ts +++ b/extensions/vscode/src/api/types/connect.ts @@ -18,7 +18,7 @@ export type ConnectRuntime = { idleTimeout?: number; maxProcesses?: number; minProcesses?: number; - maxConnections?: number; + maxConnsPerProcess?: number; loadFactor?: number; }; @@ -30,7 +30,9 @@ export type ConnectKubernetes = { amdGpuLimit?: number; nvidiaGpuLimit?: number; serviceAccountName?: string; - imageName?: string; + defaultImageName?: string; + defaultREnvironmentManagement?: boolean; + defaultPyEnvironmentManagement?: boolean; }; // See types in internal/clients/connect/client.go diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index 7d88e80177..3ca1136879 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -41,6 +41,8 @@ import { import { getSummaryStringFromError } from "src/utils/errors"; import { isAxiosErrorWithJson } from "src/utils/errorTypes"; import { newConfigFileNameFromTitle, newDeploymentName } from "src/utils/names"; +import { loadAllConfigurations, writeConfigToFile } from "src/toml"; +import * as workspaces from "src/workspaces"; import { DeploymentObjects } from "src/types/shared"; import { showProgress } from "src/utils/progress"; import { @@ -842,11 +844,15 @@ export async function newDeployment( newDeploymentData.title; try { - const existingNames = ( - await api.configurations.getAll( - newDeploymentData.entrypoint.inspectionResult.projectDir, - ) - ).data.map((config) => config.configurationName); + const root = workspaces.path(); + if (!root) { + return getDeploymentObjects(); + } + const relProjectDir = + newDeploymentData.entrypoint.inspectionResult.projectDir; + + const allConfigs = await loadAllConfigurations(relProjectDir, root); + const existingNames = allConfigs.map((config) => config.configurationName); configName = newConfigFileNameFromTitle( newDeploymentData.title, @@ -856,19 +862,18 @@ export async function newDeployment( newDeploymentData.entrypoint.inspectionResult.configuration.productType = getProductType(newOrSelectedCredential.serverType); - configCreateResponse = ( - await api.configurations.createOrUpdate( - configName, - newDeploymentData.entrypoint.inspectionResult.configuration, - newDeploymentData.entrypoint.inspectionResult.projectDir, - ) - ).data; + configCreateResponse = await writeConfigToFile( + configName, + relProjectDir, + root, + newDeploymentData.entrypoint.inspectionResult.configuration, + ); const fileUri = Uri.file(configCreateResponse.configurationPath); newConfig = configCreateResponse; await commands.executeCommand("vscode.open", fileUri); } catch (error: unknown) { const summary = getSummaryStringFromError( - "newDeployment, configurations.createOrUpdate", + "newDeployment, writeConfigToFile", error, ); window.showErrorMessage(`Failed to create config file. ${summary}`); diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index 821b37f8c8..31821ff7f1 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -40,9 +40,12 @@ import { filterInspectionResultsToType, filterConfigurationsToValidAndType, } from "src/utils/filters"; +import { getProductType } from "src/utils/multiStepHelpers"; import { showProgress } from "src/utils/progress"; import { isRelativePathRoot } from "src/utils/files"; import { newConfigFileNameFromTitle } from "src/utils/names"; +import { loadAllConfigurations, writeConfigToFile } from "src/toml"; +import * as workspaces from "src/workspaces"; export async function selectNewOrExistingConfig( activeDeployment: ContentRecord | PreContentRecord, @@ -102,14 +105,13 @@ export async function selectNewOrExistingConfig( const getConfigurations = async () => { try { - // get all configurations - const response = await api.configurations.getAll( + const root = workspaces.path(); + if (!root) { + return; + } + const rawConfigs = await loadAllConfigurations( activeDeployment.projectDir, - ); - const rawConfigs = response.data; - // remove the errors - configurations = configurations.filter( - (cfg): cfg is Configuration => !isConfigurationError(cfg), + root, ); // Filter down configs to same content type as active deployment, // but also allowing configs if active Deployment is a preDeployment @@ -165,7 +167,7 @@ export async function selectNewOrExistingConfig( ); } catch (error: unknown) { const summary = getSummaryStringFromError( - "selectNewOrExistingConfig, configurations.getAll", + "selectNewOrExistingConfig, loadAllConfigurations", error, ); window.showInformationMessage( @@ -443,27 +445,38 @@ export async function selectNewOrExistingConfig( return; } - const existingNames = ( - await api.configurations.getAll(selectedInspectionResult.projectDir) - ).data.map((config) => config.configurationName); + const root = workspaces.path(); + if (!root) { + return; + } + const allConfigs = await loadAllConfigurations( + selectedInspectionResult.projectDir, + root, + ); + const existingNames = allConfigs.map( + (config) => config.configurationName, + ); const configName = newConfigFileNameFromTitle( state.data.title, existingNames, ); selectedInspectionResult.configuration.title = state.data.title; - const createResponse = await api.configurations.createOrUpdate( + selectedInspectionResult.configuration.productType = getProductType( + activeDeployment.serverType, + ); + const newConfig = await writeConfigToFile( configName, - selectedInspectionResult.configuration, selectedInspectionResult.projectDir, + root, + selectedInspectionResult.configuration, ); - const fileUri = Uri.file(createResponse.data.configurationPath); - const newConfig = createResponse.data; + const fileUri = Uri.file(newConfig.configurationPath); await commands.executeCommand("vscode.open", fileUri); return newConfig; } catch (error: unknown) { const summary = getSummaryStringFromError( - "selectNewOrExistingConfig, configurations.createOrUpdate", + "selectNewOrExistingConfig, writeConfigToFile", error, ); window.showErrorMessage(`Failed to create config file. ${summary}`); diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index c5de95e464..41b9cee27e 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -14,6 +14,7 @@ import { mkExtensionContextStateMock } from "src/test/unit-test-utils/vscode-moc import { LocalState } from "./constants"; import { PublisherState } from "./state"; import { AllContentRecordTypes, PreContentRecord } from "src/api"; +import { ConfigurationLoadError } from "src/toml"; class mockApiClient { readonly contentRecords = { @@ -21,11 +22,6 @@ class mockApiClient { getAll: vi.fn(), }; - readonly configurations = { - get: vi.fn(), - getAll: vi.fn(), - }; - readonly credentials = { list: vi.fn(), reset: vi.fn(), @@ -80,6 +76,22 @@ vi.mock("src/credentialSecretStorage", () => ({ syncAllCredentials: (...args: unknown[]) => mockSyncAllCredentials(...args), })); +const mockLoadConfiguration = vi.fn(); +const mockLoadAllConfigurationsRecursive = vi.fn(); + +vi.mock("src/toml", async (importOriginal) => { + return { + ...(await importOriginal()), + loadConfiguration: (...args: unknown[]) => mockLoadConfiguration(...args), + loadAllConfigurationsRecursive: (...args: unknown[]) => + mockLoadAllConfigurationsRecursive(...args), + }; +}); + +vi.mock("src/workspaces", () => ({ + path: () => "/workspace", +})); + vi.mock("vscode", () => { // mock Disposable const disposableMock = vi.fn(); @@ -297,7 +309,7 @@ describe("PublisherState", () => { let currentConfig = await publisherState.getSelectedConfiguration(); expect(mockWorkspace.get).toHaveBeenCalled(); expect(currentConfig).toEqual(undefined); - expect(mockClient.configurations.get).not.toHaveBeenCalled(); + expect(mockLoadConfiguration).not.toHaveBeenCalled(); // setup existing content record in cache const contentRecord = preContentRecordFactory.build({ @@ -305,25 +317,19 @@ describe("PublisherState", () => { }); publisherState.contentRecords.push(contentRecord); - // setup fake config API response, + // setup fake config from toml loader, // config name and project dir must be the same between content record and config const config = configurationFactory.build({ configurationName: contentRecord.configurationName, projectDir: contentRecord.projectDir, }); - mockClient.configurations.get.mockResolvedValue({ - data: config, - }); + mockLoadConfiguration.mockResolvedValue(config); // selection has something now await publisherState.updateSelection(contentRecordState); currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); + expect(mockLoadConfiguration).toHaveBeenCalledTimes(1); expect(currentConfig).toEqual(config); expect(publisherState.configurations).toEqual([config]); @@ -331,11 +337,11 @@ describe("PublisherState", () => { currentConfig = await publisherState.getSelectedConfiguration(); // Only the previous call is registered - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); + expect(mockLoadConfiguration).toHaveBeenCalledTimes(1); expect(currentConfig).toEqual(config); expect(publisherState.configurations).toEqual([config]); - // setup a second content record in cache and it's respective config API response + // setup a second content record in cache and its respective config const secondContentRecordState: DeploymentSelectorState = selectionStateFactory.build(); const secondContentRecord = preContentRecordFactory.build({ @@ -347,9 +353,7 @@ describe("PublisherState", () => { configurationName: secondContentRecord.configurationName, projectDir: secondContentRecord.projectDir, }); - mockClient.configurations.get.mockResolvedValue({ - data: secondConfig, - }); + mockLoadConfiguration.mockResolvedValue(secondConfig); // selection has something different this time await publisherState.updateSelection(secondContentRecordState); @@ -357,16 +361,15 @@ describe("PublisherState", () => { // third time will get a new configuration currentConfig = await publisherState.getSelectedConfiguration(); - // Two API calls were triggered, each for every different - expect(mockClient.configurations.get).toHaveBeenCalledTimes(2); + // Two calls were triggered, each for every different + expect(mockLoadConfiguration).toHaveBeenCalledTimes(2); expect(currentConfig).toEqual(secondConfig); expect(publisherState.configurations).toEqual([config, secondConfig]); }); - describe("error responses from API", () => { + describe("error responses", () => { let publisherState: PublisherState; let contentRecordState: DeploymentSelectorState; - let contentRecord: PreContentRecord; beforeEach(() => { contentRecordState = selectionStateFactory.build(); @@ -375,64 +378,59 @@ describe("PublisherState", () => { publisherState = new PublisherState(mockContext); // setup existing content record in cache - contentRecord = preContentRecordFactory.build({ + const contentRecord = preContentRecordFactory.build({ deploymentPath: contentRecordState.deploymentPath, }); publisherState.contentRecords.push(contentRecord); - // set an initial state so it tries to pull from API + // set an initial state so it tries to load config return publisherState.updateSelection(contentRecordState); }); - test("404", async () => { - // setup fake 404 error from api client - const axiosErr = new AxiosError(); - axiosErr.response = { - data: "", - status: 404, - statusText: "404", - headers: {}, - config: { headers: new AxiosHeaders() }, - }; - mockClient.configurations.get.mockRejectedValue(axiosErr); + test("ENOENT (missing file) is silently ignored", async () => { + const enoentErr = Object.assign( + new Error("ENOENT: no such file or directory"), + { code: "ENOENT" }, + ); + mockLoadConfiguration.mockRejectedValue(enoentErr); const currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); - // 404 errors are just ignored expect(currentConfig).toEqual(undefined); expect(publisherState.configurations).toEqual([]); expect(window.showInformationMessage).not.toHaveBeenCalled(); }); - test("Other than 404", async () => { - // NOT 404 errors are shown - const axiosErr = new AxiosError(); - axiosErr.response = { - data: "custom test error", - status: 401, - statusText: "401", - headers: {}, - config: { headers: new AxiosHeaders() }, - }; - mockClient.configurations.get.mockRejectedValue(axiosErr); + test("ConfigurationLoadError (invalid file) is silently ignored", async () => { + const loadErr = new ConfigurationLoadError({ + error: { + code: "invalidTOML", + msg: "bad toml", + operation: "test", + data: {}, + }, + configurationName: "test", + configurationPath: "/test", + projectDir: "/test", + }); + mockLoadConfiguration.mockRejectedValue(loadErr); + + const currentConfig = await publisherState.getSelectedConfiguration(); + + expect(currentConfig).toEqual(undefined); + expect(publisherState.configurations).toEqual([]); + expect(window.showInformationMessage).not.toHaveBeenCalled(); + }); + + test("Other errors are shown", async () => { + mockLoadConfiguration.mockRejectedValue(new Error("unexpected error")); const currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); - // This error is propagated up now expect(currentConfig).toEqual(undefined); expect(publisherState.configurations).toEqual([]); expect(window.showInformationMessage).toHaveBeenCalledWith( - "Unable to retrieve deployment configuration: custom test error", + "Unable to retrieve deployment configuration: unexpected error", ); }); }); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index fc9ffe3ca0..2e95ea9aae 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -36,6 +36,12 @@ import { isErrCannotBackupCredentialsFile, errCannotBackupCredentialsFileMessage, } from "src/utils/errorTypes"; +import { + loadConfiguration, + loadAllConfigurationsRecursive, + ConfigurationLoadError, +} from "src/toml"; +import * as workspaces from "src/workspaces"; import { DeploymentSelector, SelectionState } from "src/types/shared"; import { LocalState, Views } from "./constants"; import { getPythonInterpreterPath, getRInterpreterPath } from "./utils/vscode"; @@ -233,37 +239,50 @@ export class PublisherState implements Disposable { } // if not found, then retrieve it and add it to our cache. try { - const api = await useApi(); - const python = await getPythonInterpreterPath(); - const r = await getRInterpreterPath(); - - const response = await api.configurations.get( + const root = workspaces.path(); + if (!root) { + return undefined; + } + const cfg = await loadConfiguration( contentRecord.configurationName, contentRecord.projectDir, + root, ); + + const api = await useApi(); + const python = await getPythonInterpreterPath(); + const r = await getRInterpreterPath(); const defaults = await api.interpreters.get( contentRecord.projectDir, r, python, ); - const cfg = UpdateConfigWithDefaults(response.data, defaults.data); + const updated = UpdateConfigWithDefaults(cfg, defaults.data); // its not foolproof, but it may help - if (!this.findConfig(cfg.configurationName, cfg.projectDir)) { - this.configurations.push(cfg); + if (!this.findConfig(updated.configurationName, updated.projectDir)) { + this.configurations.push(updated); } - return cfg; + return updated; } catch (error: unknown) { - const code = getStatusFromError(error); - if (code !== 404) { - // 400 is expected when doesn't exist on disk - const summary = getSummaryStringFromError( - "getSelectedConfiguration, contentRecords.get", - error, - ); - window.showInformationMessage( - `Unable to retrieve deployment configuration: ${summary}`, - ); + // ENOENT is expected when file doesn't exist on disk + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + return undefined; + } + // ConfigurationLoadError means the file exists but is invalid + if (error instanceof ConfigurationLoadError) { + return undefined; } + const summary = getSummaryStringFromError( + "getSelectedConfiguration", + error, + ); + window.showInformationMessage( + `Unable to retrieve deployment configuration: ${summary}`, + ); return undefined; } } @@ -308,16 +327,19 @@ export class PublisherState implements Disposable { "Refreshing Configurations", Views.HomeView, async () => { + const root = workspaces.path(); + if (!root) { + return; + } + const api = await useApi(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); - const response = await api.configurations.getAll(".", { - recursive: true, - }); + const configs = await loadAllConfigurationsRecursive(root); const defaults = await api.interpreters.get(".", r, python); this.configurations = UpdateAllConfigsWithDefaults( - response.data, + configs, defaults.data, ); }, diff --git a/extensions/vscode/src/test/unit-test-utils/factories.ts b/extensions/vscode/src/test/unit-test-utils/factories.ts index b1f2656a82..b8d20d36a7 100644 --- a/extensions/vscode/src/test/unit-test-utils/factories.ts +++ b/extensions/vscode/src/test/unit-test-utils/factories.ts @@ -35,7 +35,6 @@ export const configurationFactory = Factory.define( projectDir: `report-GUD${sequence}`, configurationName: `configuration-GUD${sequence}`, configurationPath: `report/path/configuration-${sequence}`, - configurationRelPath: `report/path/configuration-${sequence}`, }), ); @@ -107,7 +106,6 @@ export const contentRecordFactory = Factory.define( projectDir: "", }, configurationPath: `report/path/configuration-${sequence}`, - configurationRelPath: `report/path/configuration-${sequence}`, connectCloud: { accountName: "" }, }), ); diff --git a/extensions/vscode/src/toml/compliance.test.ts b/extensions/vscode/src/toml/compliance.test.ts new file mode 100644 index 0000000000..d1fbd3c2aa --- /dev/null +++ b/extensions/vscode/src/toml/compliance.test.ts @@ -0,0 +1,221 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, it } from "vitest"; +import { forceProductTypeCompliance } from "./compliance"; +import { ConfigurationDetails, ContentType } from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; + +function makeConfig( + overrides: Partial = {}, +): ConfigurationDetails { + return { + $schema: + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + type: ContentType.PYTHON_DASH, + productType: ProductType.CONNECT, + validate: true, + ...overrides, + }; +} + +describe("forceProductTypeCompliance", () => { + describe("Connect Cloud", () => { + it("clears disallowed Python fields", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3.11"); + expect(config.python?.packageFile).toBe(""); + expect(config.python?.packageManager).toBe(""); + expect(config.python?.requiresPython).toBeUndefined(); + }); + + it("clears disallowed R fields", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + type: ContentType.R_SHINY, + r: { + version: "4.3.1", + packageFile: "renv.lock", + packageManager: "renv", + requiresR: ">=4.3", + packagesFromLibrary: false, + }, + }); + + forceProductTypeCompliance(config); + + expect(config.r?.version).toBe("4.3.1"); + expect(config.r?.packageFile).toBe(""); + expect(config.r?.packageManager).toBe(""); + expect(config.r?.requiresR).toBeUndefined(); + expect(config.r?.packagesFromLibrary).toBeUndefined(); + }); + + it("truncates Python version to X.Y format", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3.11.7", + packageFile: "", + packageManager: "", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3.11"); + }); + + it("leaves Python version alone if already X.Y", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3.11", + packageFile: "", + packageManager: "", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3.11"); + }); + + it("leaves single-segment Python version alone", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3", + packageFile: "", + packageManager: "", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3"); + }); + + it("clears quarto, jupyter, and hasParameters", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + type: ContentType.QUARTO_STATIC, + quarto: { version: "1.4" }, + jupyter: { hideAllInput: true }, + hasParameters: true, + }); + + forceProductTypeCompliance(config); + + expect(config.quarto).toBeUndefined(); + expect(config.jupyter).toBeUndefined(); + expect(config.hasParameters).toBeUndefined(); + }); + + it("handles missing optional sections gracefully", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + }); + + // Should not throw + forceProductTypeCompliance(config); + }); + }); + + describe("Connect", () => { + it("copies entrypointObjectRef to entrypoint", () => { + const config = makeConfig({ + productType: ProductType.CONNECT, + entrypoint: "app.py", + entrypointObjectRef: "shiny.express.app:app_2e_py", + }); + + forceProductTypeCompliance(config); + + expect(config.entrypoint).toBe("shiny.express.app:app_2e_py"); + expect(config.entrypointObjectRef).toBeUndefined(); + }); + + it("preserves entrypoint when entrypointObjectRef is not set", () => { + const config = makeConfig({ + productType: ProductType.CONNECT, + entrypoint: "app.py", + }); + + forceProductTypeCompliance(config); + + expect(config.entrypoint).toBe("app.py"); + expect(config.entrypointObjectRef).toBeUndefined(); + }); + + it("does not strip Python fields", () => { + const config = makeConfig({ + productType: ProductType.CONNECT, + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3.11.3"); + expect(config.python?.packageFile).toBe("requirements.txt"); + expect(config.python?.packageManager).toBe("pip"); + expect(config.python?.requiresPython).toBe(">=3.11"); + }); + + it("preserves quarto, jupyter, and hasParameters", () => { + const config = makeConfig({ + productType: ProductType.CONNECT, + quarto: { version: "1.4" }, + jupyter: { hideAllInput: true }, + hasParameters: true, + }); + + forceProductTypeCompliance(config); + + expect(config.quarto).toEqual({ version: "1.4" }); + expect(config.jupyter).toEqual({ hideAllInput: true }); + expect(config.hasParameters).toBe(true); + }); + }); + + describe("always", () => { + it("clears alternatives", () => { + const config = makeConfig({ + alternatives: [makeConfig({ type: ContentType.HTML })], + }); + + forceProductTypeCompliance(config); + + expect(config.alternatives).toBeUndefined(); + }); + + it("clears entrypointObjectRef for Connect Cloud", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + entrypoint: "app.py", + entrypointObjectRef: "shiny.express.app:app_2e_py", + }); + + forceProductTypeCompliance(config); + + // Connect Cloud doesn't copy entrypointObjectRef to entrypoint + expect(config.entrypoint).toBe("app.py"); + expect(config.entrypointObjectRef).toBeUndefined(); + }); + }); +}); diff --git a/extensions/vscode/src/toml/compliance.ts b/extensions/vscode/src/toml/compliance.ts new file mode 100644 index 0000000000..0c5995f9c0 --- /dev/null +++ b/extensions/vscode/src/toml/compliance.ts @@ -0,0 +1,47 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { ConfigurationDetails } from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; + +/** + * Modify a config in place to ensure it complies with the JSON schema + * for the target product type. + * + * Ports Go's Config.ForceProductTypeCompliance() from internal/config/types.go. + */ +export function forceProductTypeCompliance(config: ConfigurationDetails): void { + if (config.productType === ProductType.CONNECT) { + // Object-reference-style entrypoint is only allowed by Connect + if (config.entrypointObjectRef) { + config.entrypoint = config.entrypointObjectRef; + } + } else if (config.productType === ProductType.CONNECT_CLOUD) { + // These fields are disallowed by the Connect Cloud schema + if (config.python) { + config.python.packageManager = ""; + config.python.packageFile = ""; + config.python.requiresPython = undefined; + + if (config.python.version) { + // Connect Cloud requires Python version in "X.Y" format + const parts = config.python.version.split("."); + if (parts.length >= 2) { + config.python.version = `${parts[0]}.${parts[1]}`; + } + } + } + if (config.r) { + config.r.packageManager = ""; + config.r.packageFile = ""; + config.r.requiresR = undefined; + config.r.packagesFromLibrary = undefined; + } + config.quarto = undefined; + config.jupyter = undefined; + config.hasParameters = undefined; + } + + // Clear non-TOML fields so they don't interfere with schema validation + config.entrypointObjectRef = undefined; + config.alternatives = undefined; +} diff --git a/extensions/vscode/src/toml/convertKeys.test.ts b/extensions/vscode/src/toml/convertKeys.test.ts new file mode 100644 index 0000000000..a2be1f5f19 --- /dev/null +++ b/extensions/vscode/src/toml/convertKeys.test.ts @@ -0,0 +1,287 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, it } from "vitest"; +import { convertKeysToCamelCase, convertKeysToSnakeCase } from "./convertKeys"; + +describe("convertKeysToCamelCase", () => { + it("converts basic snake_case keys", () => { + const result = convertKeysToCamelCase({ + package_file: "requirements.txt", + package_manager: "pip", + }); + expect(result).toEqual({ + packageFile: "requirements.txt", + packageManager: "pip", + }); + }); + + it("handles nested objects", () => { + const result = convertKeysToCamelCase({ + connect: { + runtime: { + max_conns_per_process: 50, + load_factor: 0.5, + }, + }, + }); + expect(result).toEqual({ + connect: { + runtime: { + maxConnsPerProcess: 50, + loadFactor: 0.5, + }, + }, + }); + }); + + it("handles arrays of objects", () => { + const result = convertKeysToCamelCase({ + integration_requests: [{ auth_type: "oauth", guid: "abc-123" }], + }); + expect(result).toEqual({ + integrationRequests: [{ authType: "oauth", guid: "abc-123" }], + }); + }); + + it("preserves environment keys (user-defined)", () => { + const result = convertKeysToCamelCase({ + environment: { + MY_API_KEY: "secret", + DATABASE_URL: "postgres://...", + }, + }); + expect(result).toEqual({ + environment: { + MY_API_KEY: "secret", + DATABASE_URL: "postgres://...", + }, + }); + }); + + it("preserves config keys inside integration_requests", () => { + const result = convertKeysToCamelCase({ + integration_requests: [ + { + guid: "abc", + config: { + some_custom_key: "value", + ANOTHER_KEY: "val2", + }, + }, + ], + }); + expect(result).toEqual({ + integrationRequests: [ + { + guid: "abc", + config: { + some_custom_key: "value", + ANOTHER_KEY: "val2", + }, + }, + ], + }); + }); + + it("passes through already-camelCase keys unchanged", () => { + const result = convertKeysToCamelCase({ + authType: "oauth", + loadFactor: 0.5, + defaultImageName: "posit/connect", + }); + expect(result).toEqual({ + authType: "oauth", + loadFactor: 0.5, + defaultImageName: "posit/connect", + }); + }); + + it("handles null and primitive values", () => { + expect(convertKeysToCamelCase(null)).toBeNull(); + expect(convertKeysToCamelCase(42)).toBe(42); + expect(convertKeysToCamelCase("hello")).toBe("hello"); + expect(convertKeysToCamelCase(true)).toBe(true); + }); + + it("handles arrays of primitives", () => { + const result = convertKeysToCamelCase({ + files: ["app.py", "model.csv"], + }); + expect(result).toEqual({ + files: ["app.py", "model.csv"], + }); + }); + + it("converts connect_cloud nested keys", () => { + const result = convertKeysToCamelCase({ + connect_cloud: { + vanity_name: "my-app", + access_control: { + public_access: true, + organization_access: "viewer", + }, + }, + }); + expect(result).toEqual({ + connectCloud: { + vanityName: "my-app", + accessControl: { + publicAccess: true, + organizationAccess: "viewer", + }, + }, + }); + }); +}); + +describe("convertKeysToSnakeCase", () => { + it("converts basic camelCase keys", () => { + const result = convertKeysToSnakeCase({ + packageFile: "requirements.txt", + packageManager: "pip", + }); + expect(result).toEqual({ + package_file: "requirements.txt", + package_manager: "pip", + }); + }); + + it("handles nested objects", () => { + const result = convertKeysToSnakeCase({ + connect: { + runtime: { + maxConnsPerProcess: 50, + loadFactor: 0.5, + }, + }, + }); + expect(result).toEqual({ + connect: { + runtime: { + max_conns_per_process: 50, + load_factor: 0.5, + }, + }, + }); + }); + + it("handles arrays of objects", () => { + const result = convertKeysToSnakeCase({ + integrationRequests: [{ authType: "oauth", guid: "abc-123" }], + }); + expect(result).toEqual({ + integration_requests: [{ auth_type: "oauth", guid: "abc-123" }], + }); + }); + + it("preserves environment keys (user-defined)", () => { + const result = convertKeysToSnakeCase({ + environment: { + MY_API_KEY: "secret", + DATABASE_URL: "postgres://...", + }, + }); + expect(result).toEqual({ + environment: { + MY_API_KEY: "secret", + DATABASE_URL: "postgres://...", + }, + }); + }); + + it("preserves config keys inside integrationRequests", () => { + const result = convertKeysToSnakeCase({ + integrationRequests: [ + { + guid: "abc", + config: { + someCustomKey: "value", + ANOTHER_KEY: "val2", + }, + }, + ], + }); + expect(result).toEqual({ + integration_requests: [ + { + guid: "abc", + config: { + someCustomKey: "value", + ANOTHER_KEY: "val2", + }, + }, + ], + }); + }); + + it("passes through already-snake_case keys unchanged", () => { + const result = convertKeysToSnakeCase({ + auth_type: "oauth", + load_factor: 0.5, + default_image_name: "posit/connect", + }); + expect(result).toEqual({ + auth_type: "oauth", + load_factor: 0.5, + default_image_name: "posit/connect", + }); + }); + + it("handles null and primitive values", () => { + expect(convertKeysToSnakeCase(null)).toBeNull(); + expect(convertKeysToSnakeCase(42)).toBe(42); + expect(convertKeysToSnakeCase("hello")).toBe("hello"); + expect(convertKeysToSnakeCase(true)).toBe(true); + }); + + it("handles arrays of primitives", () => { + const result = convertKeysToSnakeCase({ + files: ["app.py", "model.csv"], + }); + expect(result).toEqual({ + files: ["app.py", "model.csv"], + }); + }); + + it("converts connectCloud nested keys", () => { + const result = convertKeysToSnakeCase({ + connectCloud: { + vanityName: "my-app", + accessControl: { + publicAccess: true, + organizationAccess: "viewer", + }, + }, + }); + expect(result).toEqual({ + connect_cloud: { + vanity_name: "my-app", + access_control: { + public_access: true, + organization_access: "viewer", + }, + }, + }); + }); + + it("roundtrips with convertKeysToCamelCase", () => { + const original = { + package_file: "requirements.txt", + has_parameters: false, + connect: { + runtime: { + max_conns_per_process: 50, + }, + kubernetes: { + default_image_name: "posit/connect", + }, + }, + environment: { + MY_KEY: "val", + }, + }; + const camel = convertKeysToCamelCase(original); + const snake = convertKeysToSnakeCase(camel); + expect(snake).toEqual(original); + }); +}); diff --git a/extensions/vscode/src/toml/convertKeys.ts b/extensions/vscode/src/toml/convertKeys.ts new file mode 100644 index 0000000000..523dc0e586 --- /dev/null +++ b/extensions/vscode/src/toml/convertKeys.ts @@ -0,0 +1,93 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** + * Convert a snake_case string to camelCase. + * Already-camelCase strings pass through unchanged. + */ +function snakeToCamel(key: string): string { + return key.replace(/_([a-z0-9])/g, (_, char: string) => char.toUpperCase()); +} + +/** + * Convert a camelCase string to snake_case. + * Already-snake_case strings pass through unchanged. + */ +function camelToSnake(key: string): string { + return key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`); +} + +// Keys at these paths contain user-defined names that must not be converted. +const PRESERVE_KEYS_PATHS = new Set(["environment"]); + +// Both camelCase and snake_case forms are checked because the parentKey +// has already been transformed when recursing into camelCase, but not yet +// when recursing into snake_case (and vice-versa). Checking both is harmless. +const INTEGRATION_REQUESTS_PARENTS = new Set([ + "integrationRequests", + "integration_requests", +]); + +/** + * Shared recursive key-conversion engine. + * + * @param transform - converts a single key (e.g. snakeToCamel or camelToSnake) + */ +function convertKeys( + obj: unknown, + transform: (key: string) => string, + parentKey?: string, +): unknown { + if (Array.isArray(obj)) { + return obj.map((item) => convertKeys(item, transform, parentKey)); + } + + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const newKey = transform(key); + + if (PRESERVE_KEYS_PATHS.has(key) || PRESERVE_KEYS_PATHS.has(newKey)) { + result[newKey] = value; + } else if ( + key === "config" && + parentKey !== undefined && + INTEGRATION_REQUESTS_PARENTS.has(parentKey) + ) { + result[newKey] = value; + } else { + result[newKey] = convertKeys(value, transform, newKey); + } + } + return result; + } + + return obj; +} + +/** + * Recursively convert all object keys from snake_case to camelCase. + * + * Exceptions: + * - Keys inside `environment` maps (user-defined env var names) + * - Keys inside `config` maps on integration request objects + */ +export function convertKeysToCamelCase( + obj: unknown, + parentKey?: string, +): unknown { + return convertKeys(obj, snakeToCamel, parentKey); +} + +/** + * Recursively convert all object keys from camelCase to snake_case. + * + * Exceptions: + * - Keys inside `environment` maps (user-defined env var names) + * - Keys inside `config` maps on integration request objects + */ +export function convertKeysToSnakeCase( + obj: unknown, + parentKey?: string, +): unknown { + return convertKeys(obj, camelToSnake, parentKey); +} diff --git a/extensions/vscode/src/toml/discovery.test.ts b/extensions/vscode/src/toml/discovery.test.ts new file mode 100644 index 0000000000..e8eb3205f8 --- /dev/null +++ b/extensions/vscode/src/toml/discovery.test.ts @@ -0,0 +1,247 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + getConfigDir, + getConfigPath, + listConfigFiles, + loadConfiguration, + loadAllConfigurations, + loadAllConfigurationsRecursive, +} from "./discovery"; +import { isConfigurationError } from "../api/types/configurations"; +import { ConfigurationLoadError } from "./errors"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-discovery-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function makePublishDir(projectDir: string): string { + const publishDir = path.join(projectDir, ".posit", "publish"); + fs.mkdirSync(publishDir, { recursive: true }); + return publishDir; +} + +function writeConfig(projectDir: string, name: string, content: string): void { + const publishDir = makePublishDir(projectDir); + fs.writeFileSync(path.join(publishDir, `${name}.toml`), content, "utf-8"); +} + +const validConfig = ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`; + +const invalidConfig = "this is not valid toml [[["; + +describe("getConfigDir", () => { + it("returns the .posit/publish path", () => { + expect(getConfigDir("/home/user/project")).toBe( + path.join("/home/user/project", ".posit", "publish"), + ); + }); +}); + +describe("getConfigPath", () => { + it("returns the full path to a named config", () => { + expect(getConfigPath("/home/user/project", "myapp")).toBe( + path.join("/home/user/project", ".posit", "publish", "myapp.toml"), + ); + }); +}); + +describe("listConfigFiles", () => { + it("returns empty array when directory does not exist", async () => { + const files = await listConfigFiles(tmpDir); + expect(files).toEqual([]); + }); + + it("returns sorted list of .toml files", async () => { + writeConfig(tmpDir, "beta", validConfig); + writeConfig(tmpDir, "alpha", validConfig); + + const files = await listConfigFiles(tmpDir); + expect(files).toEqual([ + path.join(tmpDir, ".posit", "publish", "alpha.toml"), + path.join(tmpDir, ".posit", "publish", "beta.toml"), + ]); + }); + + it("ignores non-toml files", async () => { + const publishDir = makePublishDir(tmpDir); + fs.writeFileSync(path.join(publishDir, "readme.md"), "# hi", "utf-8"); + writeConfig(tmpDir, "myapp", validConfig); + + const files = await listConfigFiles(tmpDir); + expect(files).toHaveLength(1); + expect(files[0]).toContain("myapp.toml"); + }); +}); + +describe("loadConfiguration", () => { + it("loads a valid config by name", async () => { + writeConfig(tmpDir, "myapp", validConfig); + + const cfg = await loadConfiguration("myapp", ".", tmpDir); + expect(cfg.configurationName).toBe("myapp"); + expect(cfg.configuration.type).toBe("html"); + }); + + it("stores relative projectDir", async () => { + writeConfig(tmpDir, "myapp", validConfig); + + const cfg = await loadConfiguration("myapp", ".", tmpDir); + expect(cfg.projectDir).toBe("."); + }); + + it("stores relative projectDir for subdirectory", async () => { + const subDir = path.join(tmpDir, "sub"); + fs.mkdirSync(subDir); + writeConfig(subDir, "myapp", validConfig); + + const cfg = await loadConfiguration("myapp", "sub", tmpDir); + expect(cfg.projectDir).toBe("sub"); + }); + + it("throws ConfigurationLoadError for invalid config", async () => { + writeConfig(tmpDir, "bad", invalidConfig); + + try { + await loadConfiguration("bad", ".", tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + } + }); + + it("throws ENOENT for missing config", async () => { + await expect(loadConfiguration("nope", ".", tmpDir)).rejects.toThrow( + /ENOENT/, + ); + }); +}); + +describe("loadAllConfigurations", () => { + it("returns empty array when no configs exist", async () => { + const results = await loadAllConfigurations(".", tmpDir); + expect(results).toEqual([]); + }); + + it("loads all valid configs", async () => { + writeConfig(tmpDir, "alpha", validConfig); + writeConfig(tmpDir, "beta", validConfig); + + const results = await loadAllConfigurations(".", tmpDir); + expect(results).toHaveLength(2); + expect(results.every((r) => !isConfigurationError(r))).toBe(true); + }); + + it("stores relative projectDir on loaded configs", async () => { + writeConfig(tmpDir, "alpha", validConfig); + + const results = await loadAllConfigurations(".", tmpDir); + expect(results).toHaveLength(1); + expect(!isConfigurationError(results[0]!) && results[0]!.projectDir).toBe( + ".", + ); + }); + + it("collects errors for invalid configs alongside valid ones", async () => { + writeConfig(tmpDir, "good", validConfig); + writeConfig(tmpDir, "bad", invalidConfig); + + const results = await loadAllConfigurations(".", tmpDir); + expect(results).toHaveLength(2); + + const valid = results.filter((r) => !isConfigurationError(r)); + const errors = results.filter((r) => isConfigurationError(r)); + expect(valid).toHaveLength(1); + expect(errors).toHaveLength(1); + }); +}); + +describe("loadAllConfigurationsRecursive", () => { + it("finds configs in the root project", async () => { + writeConfig(tmpDir, "root-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(1); + expect( + !isConfigurationError(results[0]!) && results[0]!.configurationName, + ).toBe("root-app"); + }); + + it("finds configs in subdirectories", async () => { + const subDir = path.join(tmpDir, "subproject"); + fs.mkdirSync(subDir); + writeConfig(subDir, "sub-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(1); + }); + + it("stores relative projectDir for root configs as '.'", async () => { + writeConfig(tmpDir, "root-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(1); + expect(!isConfigurationError(results[0]!) && results[0]!.projectDir).toBe( + ".", + ); + }); + + it("stores relative projectDir for subdirectory configs", async () => { + const subDir = path.join(tmpDir, "subproject"); + fs.mkdirSync(subDir); + writeConfig(subDir, "sub-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(1); + expect(!isConfigurationError(results[0]!) && results[0]!.projectDir).toBe( + "subproject", + ); + }); + + it("finds configs at multiple levels", async () => { + writeConfig(tmpDir, "root-app", validConfig); + const subDir = path.join(tmpDir, "sub"); + fs.mkdirSync(subDir); + writeConfig(subDir, "sub-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(2); + }); + + it("skips dot-directories", async () => { + const dotDir = path.join(tmpDir, ".hidden"); + fs.mkdirSync(dotDir); + writeConfig(dotDir, "hidden-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(0); + }); + + it("skips node_modules", async () => { + const nmDir = path.join(tmpDir, "node_modules"); + fs.mkdirSync(nmDir); + writeConfig(nmDir, "nm-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(0); + }); + + it("returns empty array for empty directory", async () => { + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toEqual([]); + }); +}); diff --git a/extensions/vscode/src/toml/discovery.ts b/extensions/vscode/src/toml/discovery.ts new file mode 100644 index 0000000000..820e6f39d5 --- /dev/null +++ b/extensions/vscode/src/toml/discovery.ts @@ -0,0 +1,182 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs/promises"; +import * as path from "path"; + +import { Configuration, ConfigurationError } from "../api/types/configurations"; +import { loadConfigFromFile } from "./loader"; +import { ConfigurationLoadError } from "./errors"; + +/** Standard path: /.posit/publish */ +export function getConfigDir(projectDir: string): string { + return path.join(projectDir, ".posit", "publish"); +} + +/** Full path to a named config file: /.posit/publish/.toml */ +export function getConfigPath(projectDir: string, configName: string): string { + return path.join(getConfigDir(projectDir), `${configName}.toml`); +} + +/** + * List TOML config file paths in a project's .posit/publish/ directory. + * Returns an empty array if the directory doesn't exist. + */ +export async function listConfigFiles(projectDir: string): Promise { + const configDir = getConfigDir(projectDir); + let entries: string[]; + try { + entries = await fs.readdir(configDir); + } catch { + return []; + } + return entries + .filter((f) => f.endsWith(".toml")) + .sort() + .map((f) => path.join(configDir, f)); +} + +/** + * Load a single configuration by name from a project directory. + * + * @param configName - Name of the configuration (without .toml extension) + * @param projectDir - Relative project directory (e.g., "." or "subdir") + * @param rootDir - Absolute workspace root directory + * + * Throws ConfigurationLoadError for invalid configs, or raw errors for I/O failures. + */ +export function loadConfiguration( + configName: string, + projectDir: string, + rootDir: string, +): Promise { + const absDir = path.resolve(rootDir, projectDir); + const configPath = getConfigPath(absDir, configName); + return loadConfigFromFile(configPath, relativeProjectDir(absDir, rootDir)); +} + +/** + * Load all configurations from a project's .posit/publish/ directory. + * Returns a mix of Configuration (valid) and ConfigurationError (invalid) objects. + * I/O errors other than invalid configs are propagated. + * + * @param projectDir - Relative project directory (e.g., "." or "subdir") + * @param rootDir - Absolute workspace root directory + */ +export async function loadAllConfigurations( + projectDir: string, + rootDir: string, +): Promise<(Configuration | ConfigurationError)[]> { + const absDir = path.resolve(rootDir, projectDir); + const relDir = relativeProjectDir(absDir, rootDir); + const configPaths = await listConfigFiles(absDir); + return loadConfigsFromPaths(configPaths, relDir); +} + +/** + * Walk a directory tree and load all configurations from every .posit/publish/ + * directory found. Returns a flat array of Configuration and ConfigurationError objects. + * + * @param rootDir - Absolute workspace root directory. All Configuration.projectDir + * values will be relative to this root. + * + * Skips: + * - Dot-directories (except .posit itself) + * - node_modules, __pycache__, renv, packrat + * - The walk does not descend into .posit directories (configs are loaded, not walked further) + */ +export async function loadAllConfigurationsRecursive( + rootDir: string, +): Promise<(Configuration | ConfigurationError)[]> { + return await walkForConfigs(rootDir, rootDir); +} + +// --- Private helpers --- + +/** + * Compute a relative projectDir from an absolute path, using "." for the root. + * Matches Go's convention where projectDir is relative to the workspace root. + */ +function relativeProjectDir(absDir: string, rootDir: string): string { + const rel = path.relative(rootDir, absDir); + return rel === "" ? "." : rel; +} + +// Load configs from a list of file paths, returning both valid configs and +// per-file errors. Invalid TOML or schema violations produce a ConfigurationError +// instead of throwing, so that one broken config file doesn't prevent the rest +// from loading. Only unexpected errors (e.g. permission denied) propagate. +async function loadConfigsFromPaths( + configPaths: string[], + relDir: string, +): Promise<(Configuration | ConfigurationError)[]> { + const settled = await Promise.allSettled( + configPaths.map((p) => loadConfigFromFile(p, relDir)), + ); + const results: (Configuration | ConfigurationError)[] = []; + for (const result of settled) { + if (result.status === "fulfilled") { + results.push(result.value); + } else if (result.reason instanceof ConfigurationLoadError) { + results.push(result.reason.configurationError); + } else { + throw result.reason; + } + } + return results; +} + +async function walkForConfigs( + dir: string, + rootDir: string, + depth: number = 20, +): Promise<(Configuration | ConfigurationError)[]> { + if (depth <= 0) return []; + + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + + const results: (Configuration | ConfigurationError)[] = []; + + // Check if this directory has a .posit directory (avoids a separate fs.stat call) + const hasPositDir = entries.some( + (e) => e.isDirectory() && e.name === ".posit", + ); + + if (hasPositDir) { + const relDir = relativeProjectDir(dir, rootDir); + const configPaths = await listConfigFiles(dir); + const configs = await loadConfigsFromPaths(configPaths, relDir); + results.push(...configs); + } + + // Recurse into subdirectories + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + + // Skip dot-directories (except we already handled .posit above) + if (name.startsWith(".")) continue; + // Skip directories that are large, never contain configs, and slow down the walk + if ( + name === "node_modules" || + name === "__pycache__" || + name === "renv" || + name === "packrat" + ) { + continue; + } + + const childResults = await walkForConfigs( + path.join(dir, name), + rootDir, + depth - 1, + ); + results.push(...childResults); + } + + return results; +} diff --git a/extensions/vscode/src/toml/errors.test.ts b/extensions/vscode/src/toml/errors.test.ts new file mode 100644 index 0000000000..90f5f00156 --- /dev/null +++ b/extensions/vscode/src/toml/errors.test.ts @@ -0,0 +1,206 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { ErrorObject } from "ajv/dist/2020"; +import { describe, expect, it } from "vitest"; +import { + createInvalidTOMLError, + createSchemaValidationError, + createConfigurationError, + formatValidationErrors, +} from "./errors"; + +describe("error factories", () => { + it("creates an invalidTOML error", () => { + const err = createInvalidTOMLError( + "/path/to/file.toml", + "unexpected '='", + 5, + 10, + ); + expect(err.code).toBe("invalidTOML"); + expect(err.msg).toContain("/path/to/file.toml"); + expect(err.msg).toContain("unexpected '='"); + expect(err.operation).toBe("config.loadFromFile"); + }); + + it("creates a schema validation error", () => { + const err = createSchemaValidationError( + "/path/to/file.toml", + "missing required field", + ); + expect(err.code).toBe("tomlValidationError"); + expect(err.msg).toContain("missing required field"); + expect(err.operation).toBe("config.loadFromFile"); + }); + + it("creates a ConfigurationError with location", () => { + const agentErr = createInvalidTOMLError("/p/file.toml", "bad", 1, 1); + const location = { + configurationName: "file", + configurationPath: "/p/file.toml", + projectDir: "/p", + }; + const cfgErr = createConfigurationError(agentErr, location); + expect(cfgErr.error).toBe(agentErr); + expect(cfgErr.configurationName).toBe("file"); + expect(cfgErr.configurationPath).toBe("/p/file.toml"); + expect(cfgErr.projectDir).toBe("/p"); + }); +}); + +// Helper to create minimal ErrorObject stubs for testing +function makeError( + overrides: Partial & Pick, +): ErrorObject { + return { + instancePath: "", + schemaPath: "", + params: {}, + ...overrides, + } as ErrorObject; +} + +describe("formatValidationErrors", () => { + it("formats unevaluatedProperties as 'key: not allowed.'", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "", + params: { unevaluatedProperty: "garbage" }, + }), + ]); + expect(result).toBe("garbage: not allowed."); + }); + + it("formats nested unevaluatedProperties", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "/python", + params: { unevaluatedProperty: "foo" }, + }), + ]); + expect(result).toBe("python.foo: not allowed."); + }); + + it("formats required errors as 'key: missing property.'", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "required", + instancePath: "/python", + params: { missingProperty: "version" }, + }), + ]); + expect(result).toBe("python.version: missing property."); + }); + + it("skips 'if' keyword errors", () => { + const result = formatValidationErrors([ + makeError({ keyword: "if", instancePath: "" }), + ]); + expect(result).toBe(""); + }); + + it("formats generic errors with message", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "enum", + instancePath: "/type", + message: "must be equal to one of the allowed values", + }), + ]); + expect(result).toBe("type: must be equal to one of the allowed values."); + }); + + it("joins multiple errors with '; '", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "additionalProperties", + instancePath: "", + params: { additionalProperty: "a" }, + }), + makeError({ + keyword: "additionalProperties", + instancePath: "", + params: { additionalProperty: "b" }, + }), + ]); + expect(result).toBe("a: not allowed.; b: not allowed."); + }); + + it("keeps unevaluatedProperties alongside sibling errors at the same path", () => { + // Go's key for unevaluatedProperties includes the property name + // (e.g., "python.garbage"), so a sibling error at "python.version" + // does NOT cause filtering — only a deeper error at + // "python.garbage.something" would. + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "/python", + params: { unevaluatedProperty: "garbage" }, + }), + makeError({ + keyword: "required", + instancePath: "/python", + params: { missingProperty: "version" }, + }), + ]); + expect(result).toBe( + "python.garbage: not allowed.; python.version: missing property.", + ); + }); + + it("filters unevaluatedProperties when a deeper error exists for the same key", () => { + // If there's an error at "python.garbage.x", the unevaluatedProperties + // error for "python.garbage" is redundant. + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "/python", + params: { unevaluatedProperty: "garbage" }, + }), + makeError({ + keyword: "required", + instancePath: "/python/garbage", + params: { missingProperty: "x" }, + }), + ]); + expect(result).toBe("python.garbage.x: missing property."); + }); + + it("keeps unevaluatedProperties when no other error shares the path", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "/python", + params: { unevaluatedProperty: "garbage" }, + }), + makeError({ + keyword: "required", + instancePath: "/r", + params: { missingProperty: "version" }, + }), + ]); + expect(result).toBe( + "python.garbage: not allowed.; r.version: missing property.", + ); + }); + + it("does not filter additionalProperties even when a deeper error exists", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "additionalProperties", + instancePath: "/python", + params: { additionalProperty: "garbage" }, + }), + makeError({ + keyword: "required", + instancePath: "/python/garbage", + params: { missingProperty: "x" }, + }), + ]); + expect(result).toBe( + "python.garbage: not allowed.; python.garbage.x: missing property.", + ); + }); +}); diff --git a/extensions/vscode/src/toml/errors.ts b/extensions/vscode/src/toml/errors.ts new file mode 100644 index 0000000000..6186ef10ec --- /dev/null +++ b/extensions/vscode/src/toml/errors.ts @@ -0,0 +1,126 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { ErrorObject } from "ajv/dist/2020"; + +import { AgentError } from "../api/types/error"; +import { + ConfigurationError, + ConfigurationLocation, +} from "../api/types/configurations"; + +/** + * Error thrown by the loader when a config file has invalid TOML or + * fails schema/business validation. Carries the structured + * ConfigurationError so discovery functions can collect it. + */ +export class ConfigurationLoadError extends Error { + constructor(public readonly configurationError: ConfigurationError) { + super(configurationError.error.msg); + this.name = "ConfigurationLoadError"; + } +} + +export function createInvalidTOMLError( + file: string, + problem: string, + line: number, + column: number, +): AgentError { + return { + code: "invalidTOML", + msg: `Invalid TOML in ${file}: ${problem}`, + operation: "config.loadFromFile", + data: { file, problem, line, column }, + }; +} + +export function createSchemaValidationError( + file: string, + message: string, +): AgentError { + return { + code: "tomlValidationError", + msg: message, + operation: "config.loadFromFile", + data: { file, message }, + }; +} + +export function createConfigurationError( + error: AgentError, + location: ConfigurationLocation, +): ConfigurationError { + return { + error, + ...location, + }; +} + +/** + * Format ajv validation errors to match Go's schema validation output. + * Go format: "key: problem" (e.g., "invalidParam: not allowed.") + * For nested paths: "python.garbage: not allowed." + * + * Filters redundant unevaluatedProperties errors when a more specific + * error exists at the same or deeper path (matching Go's behavior). + */ +export function formatValidationErrors(errors: ErrorObject[]): string { + // First pass: convert each error to { fullKey, message, isUnevaluated } + const entries: { + fullKey: string; + message: string; + isUnevaluated: boolean; + }[] = []; + + for (const e of errors) { + // Convert JSON pointer instancePath (e.g., "/python") to dotted key + const pathKey = e.instancePath.replace(/^\//, "").replace(/\//g, "."); + + if ( + e.keyword === "unevaluatedProperties" || + e.keyword === "additionalProperties" + ) { + const prop = + e.params.unevaluatedProperty ?? e.params.additionalProperty ?? ""; + const fullKey = pathKey ? `${pathKey}.${prop}` : prop; + entries.push({ + fullKey, + message: `${fullKey}: not allowed.`, + isUnevaluated: e.keyword === "unevaluatedProperties", + }); + } else if (e.keyword === "required") { + const prop = e.params.missingProperty ?? ""; + const fullKey = pathKey ? `${pathKey}.${prop}` : prop; + entries.push({ + fullKey, + message: `${fullKey}: missing property.`, + isUnevaluated: false, + }); + } else if (e.keyword === "if") { + // "if/then" errors are structural noise from conditional schemas — skip + continue; + } else { + const prefix = pathKey ? `${pathKey}: ` : ""; + entries.push({ + fullKey: pathKey, + message: `${prefix}${e.message ?? "validation error"}.`, + isUnevaluated: false, + }); + } + } + + // Second pass: filter redundant unevaluatedProperties errors. + // Go filters these when any other error's key starts with the same key. + // In Go, the key for unevaluatedProperties includes the property name + // (e.g., "python.garbage"), so only a deeper error at "python.garbage.x" + // would trigger filtering. We use fullKey to match that behavior. + const filtered = entries.filter((entry, i) => { + if (!entry.isUnevaluated) return true; + const prefix = entry.fullKey; + return !entries.some( + (other, j) => j !== i && other.fullKey.startsWith(prefix), + ); + }); + + return filtered.map((e) => e.message).join("; "); +} diff --git a/extensions/vscode/src/toml/index.ts b/extensions/vscode/src/toml/index.ts new file mode 100644 index 0000000000..bb0134e766 --- /dev/null +++ b/extensions/vscode/src/toml/index.ts @@ -0,0 +1,9 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +export { writeConfigToFile } from "./writer"; +export { ConfigurationLoadError } from "./errors"; +export { + loadConfiguration, + loadAllConfigurations, + loadAllConfigurationsRecursive, +} from "./discovery"; diff --git a/extensions/vscode/src/toml/loader.test.ts b/extensions/vscode/src/toml/loader.test.ts new file mode 100644 index 0000000000..1431cc232c --- /dev/null +++ b/extensions/vscode/src/toml/loader.test.ts @@ -0,0 +1,468 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadConfigFromFile } from "./loader"; +import { + isConfigurationError, + UpdateConfigWithDefaults, +} from "../api/types/configurations"; +import { ConfigurationLoadError } from "./errors"; +import { interpreterDefaultsFactory } from "../test/unit-test-utils/factories"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-loader-test-")); + fs.mkdirSync(path.join(tmpDir, ".posit", "publish"), { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeConfig(name: string, content: string): string { + const configPath = path.join(tmpDir, ".posit", "publish", `${name}.toml`); + fs.writeFileSync(configPath, content, "utf-8"); + return configPath; +} + +describe("loadConfigFromFile", () => { + it("loads a valid config with all Connect sections", async () => { + const configPath = writeConfig( + "myapp", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +title = "My App" +validate = true +has_parameters = false +files = ["app.py", "requirements.txt"] +secrets = ["API_KEY"] + +[python] +version = "3.11.3" +package_file = "requirements.txt" +package_manager = "pip" +requires_python = ">=3.11" + +[r] +version = "4.3.1" +package_file = "renv.lock" +package_manager = "renv" +requires_r = ">=4.3" +packages_from_library = false + +[jupyter] +hide_all_input = true +hide_tagged_input = false + +[environment] +MY_API_KEY = "not-a-secret" +DATABASE_URL = "postgres://localhost/db" + +[connect.runtime] +connection_timeout = 5 +max_conns_per_process = 50 +load_factor = 0.5 + +[connect.kubernetes] +default_image_name = "posit/connect-runtime-python3.11-r4.3" +cpu_limit = 2.0 +memory_limit = 100000000 +default_r_environment_management = true +default_py_environment_management = false +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + + expect(cfg.configuration.$schema).toBe( + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + ); + expect(cfg.configuration.type).toBe("python-dash"); + expect(cfg.configuration.entrypoint).toBe("app.py"); + expect(cfg.configuration.title).toBe("My App"); + expect(cfg.configuration.hasParameters).toBe(false); + expect(cfg.configuration.validate).toBe(true); + expect(cfg.configuration.secrets).toEqual(["API_KEY"]); + + // Python + expect(cfg.configuration.python?.version).toBe("3.11.3"); + expect(cfg.configuration.python?.packageFile).toBe("requirements.txt"); + expect(cfg.configuration.python?.packageManager).toBe("pip"); + expect(cfg.configuration.python?.requiresPython).toBe(">=3.11"); + + // R + expect(cfg.configuration.r?.version).toBe("4.3.1"); + expect(cfg.configuration.r?.packageFile).toBe("renv.lock"); + expect(cfg.configuration.r?.requiresR).toBe(">=4.3"); + expect(cfg.configuration.r?.packagesFromLibrary).toBe(false); + + // Jupyter + expect(cfg.configuration.jupyter?.hideAllInput).toBe(true); + expect(cfg.configuration.jupyter?.hideTaggedInput).toBe(false); + + // Environment keys preserved as-is + expect(cfg.configuration.environment).toEqual({ + MY_API_KEY: "not-a-secret", + DATABASE_URL: "postgres://localhost/db", + }); + + // Connect runtime + expect(cfg.configuration.connect?.runtime?.connectionTimeout).toBe(5); + expect(cfg.configuration.connect?.runtime?.maxConnsPerProcess).toBe(50); + expect(cfg.configuration.connect?.runtime?.loadFactor).toBe(0.5); + + // Connect kubernetes + expect(cfg.configuration.connect?.kubernetes?.defaultImageName).toBe( + "posit/connect-runtime-python3.11-r4.3", + ); + expect(cfg.configuration.connect?.kubernetes?.cpuLimit).toBe(2.0); + expect( + cfg.configuration.connect?.kubernetes?.defaultREnvironmentManagement, + ).toBe(true); + expect( + cfg.configuration.connect?.kubernetes?.defaultPyEnvironmentManagement, + ).toBe(false); + }); + + it("loads the example config.toml", async () => { + const examplePath = path.resolve(__dirname, "schemas/example-config.toml"); + const projectDir = path.dirname(examplePath); + + const cfg = await loadConfigFromFile(examplePath, projectDir); + + expect(cfg.configuration.type).toBe("quarto-static"); + expect(cfg.configuration.entrypoint).toBe("report.qmd"); + expect(cfg.configuration.productType).toBe("connect"); + expect( + cfg.configuration.connect?.kubernetes?.defaultPyEnvironmentManagement, + ).toBe(true); + expect(cfg.configuration.connect?.access?.runAs).toBe("rstudio-connect"); + }); + + it("loads a valid config with integration_requests", async () => { + const configPath = writeConfig( + "with-integrations", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" + +[python] +version = "3.11" + +[[integration_requests]] +guid = "12345678-1234-1234-1234-1234567890ab" +name = "My Integration" +description = "A test integration" +auth_type = "oauth" +type = "databricks" + +[integration_requests.config] +custom_key = "custom_value" +ANOTHER_KEY = "another_value" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + + expect(cfg.configuration.integrationRequests).toHaveLength(1); + const ir = cfg.configuration.integrationRequests![0]!; + expect(ir.guid).toBe("12345678-1234-1234-1234-1234567890ab"); + expect(ir.name).toBe("My Integration"); + expect(ir.description).toBe("A test integration"); + expect(ir.authType).toBe("oauth"); + expect(ir.type).toBe("databricks"); + expect(ir.config).toEqual({ + custom_key: "custom_value", + ANOTHER_KEY: "another_value", + }); + }); + + it("loads a valid Connect Cloud config", async () => { + const configPath = writeConfig( + "cloud", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +product_type = "connect_cloud" + +[python] +version = "3.11" + +[connect_cloud] +vanity_name = "my-app" + +[connect_cloud.access_control] +public_access = true +organization_access = "viewer" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + + expect(cfg.configuration.productType).toBe("connect_cloud"); + expect(cfg.configuration.connectCloud?.vanityName).toBe("my-app"); + expect(cfg.configuration.connectCloud?.accessControl?.publicAccess).toBe( + true, + ); + expect( + cfg.configuration.connectCloud?.accessControl?.organizationAccess, + ).toBe("viewer"); + }); + + it("throws ConfigurationLoadError for invalid TOML syntax", async () => { + const configPath = writeConfig("bad-toml", "this is not valid toml [[["); + + try { + await loadConfigFromFile(configPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.error.code).toBe("invalidTOML"); + expect(error.configurationError.configurationName).toBe("bad-toml"); + } + } + }); + + it("throws ConfigurationLoadError for schema validation failure", async () => { + const configPath = writeConfig( + "invalid-schema", + 'title = "Missing required fields"', + ); + + try { + await loadConfigFromFile(configPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.error.code).toBe("tomlValidationError"); + } + } + }); + + it("throws ENOENT for missing file", async () => { + const missingPath = path.join(tmpDir, ".posit", "publish", "nope.toml"); + await expect(loadConfigFromFile(missingPath, tmpDir)).rejects.toThrow( + /ENOENT/, + ); + }); + + it("ENOENT is not a ConfigurationLoadError", async () => { + const missingPath = path.join(tmpDir, ".posit", "publish", "nope.toml"); + try { + await loadConfigFromFile(missingPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).not.toBeInstanceOf(ConfigurationLoadError); + } + }); + + it("sets correct location metadata", async () => { + const configPath = writeConfig( + "location-test", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configurationName).toBe("location-test"); + expect(cfg.configurationPath).toBe(configPath); + expect(cfg.projectDir).toBe(tmpDir); + }); + + it("reads leading comments from the file", async () => { + const configPath = writeConfig( + "with-comments", + `# This is a comment +# Another comment line +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configuration.comments).toEqual([ + " This is a comment", + " Another comment line", + ]); + }); + + it("returns empty comments array when no leading comments", async () => { + const configPath = writeConfig( + "no-comments", + `"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configuration.comments).toEqual([]); + }); + + it("only reads comments from the top of the file", async () => { + const configPath = writeConfig( + "mid-comments", + `# Leading comment +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +# This is a mid-file comment (should not be captured) +type = "html" +entrypoint = "index.html" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configuration.comments).toEqual([" Leading comment"]); + }); + + it("defaults productType to 'connect' when not specified", async () => { + const configPath = writeConfig( + "default-product", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configuration.productType).toBe("connect"); + }); + + it("applies defaults for validate and files", async () => { + const configPath = writeConfig( + "defaults-test", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configuration.validate).toBe(true); + expect(cfg.configuration.files).toEqual([]); + }); + + it("throws for Connect Cloud config with unsupported content type", async () => { + const configPath = writeConfig( + "cloud-flask", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-flask" +entrypoint = "app.py" +product_type = "connect_cloud" + +[python] +version = "3.11" +`, + ); + + try { + await loadConfigFromFile(configPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.error.code).toBe("tomlValidationError"); + expect(error.message).toContain("python-flask"); + expect(error.message).toContain("not supported by Connect Cloud"); + } + } + }); + + it("accepts Connect Cloud config with supported content type", async () => { + const configPath = writeConfig( + "cloud-dash", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +product_type = "connect_cloud" + +[python] +version = "3.11" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configuration.type).toBe("python-dash"); + expect(cfg.configuration.productType).toBe("connect_cloud"); + }); + + it("does not override explicit validate=false", async () => { + const configPath = writeConfig( + "validate-false", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +validate = false +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configuration.validate).toBe(false); + }); +}); + +describe("downstream compatibility", () => { + it("loader output works with UpdateConfigWithDefaults", async () => { + const configPath = writeConfig( + "defaults-compat", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" + +[python] +version = "3.11" +`, + ); + + const cfg = await loadConfigFromFile(configPath, tmpDir); + + const defaults = interpreterDefaultsFactory.build(); + const updated = UpdateConfigWithDefaults(cfg, defaults); + + expect(isConfigurationError(updated)).toBe(false); + if (!isConfigurationError(updated)) { + expect(updated.configuration.python?.version).toBe("3.11"); + expect(updated.configuration.python?.packageFile).toBe( + defaults.python.packageFile, + ); + expect(updated.configuration.python?.packageManager).toBe( + defaults.python.packageManager, + ); + } + }); + + it("ConfigurationLoadError carries location metadata", async () => { + const configPath = writeConfig("meta-test", "not valid toml [[["); + + try { + await loadConfigFromFile(configPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.configurationName).toBe("meta-test"); + expect(error.configurationError.configurationPath).toBe(configPath); + expect(error.configurationError.projectDir).toBe(tmpDir); + } + } + }); +}); diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts new file mode 100644 index 0000000000..60f334df05 --- /dev/null +++ b/extensions/vscode/src/toml/loader.ts @@ -0,0 +1,139 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs/promises"; +import * as path from "path"; +import { parse as parseTOML, TomlError } from "smol-toml"; +import { + Configuration, + ConfigurationDetails, + ConfigurationLocation, + ContentType, +} from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; +import { AgentError } from "../api/types/error"; +import { convertKeysToCamelCase } from "./convertKeys"; +import { + createInvalidTOMLError, + createSchemaValidationError, + createConfigurationError, + ConfigurationLoadError, + formatValidationErrors, +} from "./errors"; +import { validate } from "./validate"; + +/** + * Load a TOML configuration file, validate it against the JSON schema, + * and return a Configuration. + * + * Throws ConfigurationLoadError for invalid TOML or schema/business validation failures. + * Throws raw errors for I/O failures (ENOENT etc.). + */ +export async function loadConfigFromFile( + configPath: string, + projectDir: string, +): Promise { + const configName = path.basename(configPath, ".toml"); + + const location: ConfigurationLocation = { + configurationName: configName, + configurationPath: configPath, + projectDir, + }; + + const loadError = (error: AgentError) => + new ConfigurationLoadError(createConfigurationError(error, location)); + + // Read file — let ENOENT propagate + const content = await fs.readFile(configPath, "utf-8"); + + // Parse TOML + let parsed; + try { + parsed = parseTOML(content); + } catch (err: unknown) { + if (err instanceof TomlError) { + const line = err.line ?? 0; + const column = err.column ?? 0; + throw loadError( + createInvalidTOMLError(configPath, err.message, line, column), + ); + } + throw loadError(createInvalidTOMLError(configPath, String(err), 0, 0)); + } + + // Validate against JSON schema (schema uses snake_case keys, which is what TOML produces) + const valid = validate(parsed); + if (!valid) { + const messages = formatValidationErrors(validate.errors ?? []); + throw loadError(createSchemaValidationError(configPath, messages)); + } + + // Extract leading comments from the raw file content (matches Go's readLeadingComments). + // TOML strips comments during parsing, so we read them from the raw text. + const comments = readLeadingComments(content); + + // Convert keys to camelCase and apply defaults to match Go's New() + PopulateDefaults(). + // The assertion is justified: the JSON schema validation above confirmed the object + // structure, and convertKeysToCamelCase only renames keys without changing the shape. + const converted = convertKeysToCamelCase(parsed) as ConfigurationDetails; + converted.comments = comments; + + if (converted.productType === undefined) { + converted.productType = ProductType.CONNECT; + } + if (converted.validate === undefined) { + converted.validate = true; + } + if (converted.files === undefined) { + converted.files = []; + } + // Business validation beyond schema: reject Connect Cloud configs with + // unsupported content types. Matches Go's validate() in config.go. + // This might make more sense as a deployment-time concern later, but for + // now we match Go's FromFile behavior which rejects at load time. + if (converted.productType === ProductType.CONNECT_CLOUD) { + if (!connectCloudSupportedTypes.has(converted.type)) { + throw loadError( + createSchemaValidationError( + configPath, + `content type '${converted.type}' is not supported by Connect Cloud`, + ), + ); + } + } + + return { + configuration: converted, + ...location, + }; +} + +// Extract leading comment lines from raw file content. +// Matches Go's readLeadingComments: collects consecutive lines starting +// with '#' from the top of the file, stripping the '#' prefix. +function readLeadingComments(content: string): string[] { + const comments: string[] = []; + for (const line of content.split("\n")) { + if (!line.startsWith("#")) { + break; + } + comments.push(line.slice(1)); + } + return comments; +} + +// Content types that have a mapping in Connect Cloud. +// Keep in sync with Go's CloudContentTypeFromPublisherType in internal/clients/types/types.go +const connectCloudSupportedTypes = new Set([ + ContentType.JUPYTER_NOTEBOOK, + ContentType.PYTHON_BOKEH, + ContentType.PYTHON_DASH, + ContentType.PYTHON_SHINY, + ContentType.R_SHINY, + ContentType.PYTHON_STREAMLIT, + ContentType.QUARTO, + ContentType.QUARTO_SHINY, + ContentType.QUARTO_STATIC, + ContentType.RMD, + ContentType.HTML, +]); diff --git a/extensions/vscode/src/toml/schema.test.ts b/extensions/vscode/src/toml/schema.test.ts new file mode 100644 index 0000000000..d2f4e55639 --- /dev/null +++ b/extensions/vscode/src/toml/schema.test.ts @@ -0,0 +1,253 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, it } from "vitest"; +import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; +import schema from "./schemas/posit-publishing-schema-v3.json"; + +const ajv = new Ajv2020({ strict: false, allErrors: true }); +addFormats(ajv); +const validate = ajv.compile(schema); + +// Helper: create a minimal valid config for a given product_type +function baseConfig(productType: string): Record { + return { + $schema: + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + product_type: productType, + type: "html", + entrypoint: "index.html", + }; +} + +// Helper: set a nested property on an object, creating intermediate objects as needed +function setNested( + obj: Record, + path: string[], + key: string, + value: unknown, +): void { + let target = obj; + for (const segment of path) { + if (target[segment] === undefined) { + target[segment] = {}; + } + target = target[segment] as Record; + } + target[key] = value; +} + +function expectValid(data: Record): void { + const valid = validate(data); + expect( + valid, + `Expected valid but got errors: ${JSON.stringify(validate.errors)}`, + ).toBe(true); +} + +function expectInvalid(data: Record): void { + const valid = validate(data); + expect(valid, "Expected validation to fail").toBe(false); +} + +describe("schema validates valid configs", () => { + it("connect config without product_type", () => { + const data = baseConfig("connect"); + delete data.product_type; + expectValid(data); + }); + + it("connect config with product_type", () => { + expectValid(baseConfig("connect")); + }); + + it("connect cloud config with product_type", () => { + const data = baseConfig("connect_cloud"); + data.python = { version: "3.11" }; + expectValid(data); + }); + + it("connect config with integration_requests", () => { + const data = baseConfig("connect"); + data.integration_requests = [ + { guid: "12345678-1234-1234-1234-1234567890ab" }, + ]; + expectValid(data); + }); +}); + +describe("schema rejects unknown property at root", () => { + it("rejects unknown root property", () => { + const data = baseConfig("connect"); + data.garbage = "value"; + expectInvalid(data); + }); +}); + +describe("schema rejects invalid integration_requests", () => { + it("rejects integration_requests as string instead of array", () => { + const data = baseConfig("connect"); + data.integration_requests = "string-instead-of-array"; + expectInvalid(data); + }); + + it("rejects integration_requests with string instead of object", () => { + const data = baseConfig("connect"); + data.integration_requests = ["string-instead-of-object"]; + expectInvalid(data); + }); +}); + +describe("schema rejects disallowed properties for Connect", () => { + it("rejects python.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["python"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects r.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["r"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects jupyter.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["jupyter"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects quarto.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["quarto"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["connect"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect.runtime.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["connect", "runtime"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect.kubernetes.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["connect", "kubernetes"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect.access.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["connect", "access"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect_cloud section with connect product_type", () => { + const data = baseConfig("connect"); + data.connect_cloud = {}; + expectInvalid(data); + }); +}); + +describe("schema rejects disallowed properties for Connect Cloud", () => { + it("rejects python.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["python"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects python.requires_python", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["python"], "requires_python", ">=3.8"); + expectInvalid(data); + }); + + it("rejects python.package_file", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["python"], "package_file", "requirements.txt"); + expectInvalid(data); + }); + + it("rejects python.package_manager", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["python"], "package_manager", "pip"); + expectInvalid(data); + }); + + it("rejects r.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["r"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects r.requires_r", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["r"], "requires_r", ">=4.2"); + expectInvalid(data); + }); + + it("rejects r.package_file", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["r"], "package_file", "renv.lock"); + expectInvalid(data); + }); + + it("rejects r.package_manager", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["r"], "package_manager", "renv"); + expectInvalid(data); + }); + + it("rejects connect_cloud.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["connect_cloud"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect_cloud.python.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["connect_cloud", "python"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect_cloud.r.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["connect_cloud", "r"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect_cloud.access_control.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["connect_cloud", "access_control"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects jupyter section", () => { + const data = baseConfig("connect_cloud"); + data.jupyter = {}; + expectInvalid(data); + }); + + it("rejects quarto section", () => { + const data = baseConfig("connect_cloud"); + data.quarto = {}; + expectInvalid(data); + }); + + it("rejects has_parameters", () => { + const data = baseConfig("connect_cloud"); + data.has_parameters = true; + expectInvalid(data); + }); + + it("rejects connect.kubernetes", () => { + const data = baseConfig("connect_cloud"); + data.connect = { kubernetes: {} }; + expectInvalid(data); + }); +}); diff --git a/extensions/vscode/src/toml/schemas/example-config.toml b/extensions/vscode/src/toml/schemas/example-config.toml new file mode 100644 index 0000000000..9bf9a0d682 --- /dev/null +++ b/extensions/vscode/src/toml/schemas/example-config.toml @@ -0,0 +1,58 @@ +# Example schema file +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "quarto-static" +entrypoint = "report.qmd" +title = "Regional Quarterly Sales Report" +description = "This is the quarterly sales report, broken down by region." +validate = true + +files = [ + "*.py", + "*.qmd", + "requirements.txt", +] +secrets = ["API_KEY", "DATABASE_PASSWORD"] + +[python] +version = "3.11.3" +package_file = "requirements.txt" +package_manager = "pip" +requires_python = "<3.12" + +[r] +version = "4.3.1" +package_file = "renv.lock" +package_manager = "renv" +requires_r = ">=4.3.0" + +[quarto] +version = "1.4" + +[environment] +API_URL = "https://example.com/api" + +[connect.access] +run_as = "rstudio-connect" +run_as_current_user = false + +[connect.runtime] +connection_timeout = 5 +read_timeout = 30 +init_timeout = 60 +idle_timeout = 120 +max_processes = 5 +min_processes = 1 +max_conns_per_process = 50 +load_factor = 0.5 + +[connect.kubernetes] +amd_gpu_limit = 0 +cpu_limit = 1.5 +cpu_request = 0.5 +default_image_name = "posit/connect-runtime-python3.11-r4.3" +memory_limit = 100000000 +memory_request = 20000000 +nvidia_gpu_limit = 0 +service_account_name = "posit-connect-content" +default_r_environment_management = true +default_py_environment_management = true diff --git a/extensions/vscode/src/toml/schemas/posit-publishing-schema-v3.json b/extensions/vscode/src/toml/schemas/posit-publishing-schema-v3.json new file mode 100644 index 0000000000..3111954fa4 --- /dev/null +++ b/extensions/vscode/src/toml/schemas/posit-publishing-schema-v3.json @@ -0,0 +1,599 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "object", + "description": "Posit Publishing Configuration", + "$defs": { + "config_connect": { + "description": "Extensions to the base schema for Posit Connect deployments.", + "properties": { + "has_parameters": { + "type": "boolean", + "description": "True if this is a report that accepts parameters.", + "default": false + }, + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {}, + "package_file": { + "type": "string", + "description": "File containing package dependencies. The file must exist and be listed under 'files'. The default is 'requirements.txt'.", + "default": "requirements.txt", + "examples": ["requirements.txt"] + }, + "package_manager": { + "type": "string", + "default": "pip", + "description": "Package manager that will install the dependencies. If package-manager is none, dependencies are assumed to be pre-installed on the server. The default is 'pip'.", + "examples": ["pip", "conda", "pipenv", "poetry", "none"] + }, + "requires_python": { + "type": "string", + "default": "", + "description": "Python interpreter version, in PEP 440 format, required to run the content. If not specified it will be detected from the one in use.", + "examples": [">3.8", "<3.9", "==3.5"] + } + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {}, + "package_file": { + "type": "string", + "default": "renv.lock", + "description": "File containing package dependencies. The file must exist and be listed under 'files'.", + "examples": ["renv.lock"] + }, + "package_manager": { + "type": "string", + "default": "renv", + "description": "Package manager that will install the dependencies. If package-manager is none, dependencies will be assumed to be pre-installed on the server.", + "examples": ["renv", "none"] + }, + "requires_r": { + "type": "string", + "default": "", + "description": "R interpreter version, required to run the content. If not specified it will be detected from the one in use.", + "examples": [">3.8", "<4.3", ">=3.5.0"] + }, + "packages_from_library": { + "type": "boolean", + "default": false, + "description": "When true, generate manifest packages from the local renv library (legacy behavior). When false (default), read packages from renv.lock." + } + } + }, + "jupyter": { + "type": "object", + "additionalProperties": false, + "description": "Additional rendering options for Jupyter Notebooks.", + "properties": { + "hide_all_input": { + "type": "boolean", + "description": "Hide all input cells when rendering output." + }, + "hide_tagged_input": { + "type": "boolean", + "description": "Hide input code cells with the 'hide_input' tag when rendering output." + } + } + }, + "quarto": { + "type": "object", + "additionalProperties": false, + "description": "Quarto version required to run the content.", + "required": ["version"], + "properties": { + "version": { + "type": "string", + "description": "Quarto version. The server must have a similar Quarto version in order to run the content.", + "examples": ["1.4"] + }, + "engines": { + "type": "array", + "description": "List of Quarto engines required for this content.", + "items": { + "type": "string", + "enum": ["knitr", "jupyter", "markdown"], + "examples": ["knitr", "jupyter", "markdown"] + } + } + } + }, + "connect": { + "type": "object", + "additionalProperties": false, + "description": "Setting specific to Posit Connect deployments.", + "properties": { + "runtime": {}, + "kubernetes": { + "type": "object", + "additionalProperties": false, + "description": "Settings used with Posit Connect's off-host execution feature, where content is run in Kubernetes.", + "properties": { + "amd_gpu_limit": { + "type": "integer", + "minimum": 0, + "description": "The number of AMD GPUs that will be allocated by Kubernetes to run this content.", + "examples": [0] + }, + "cpu_limit": { + "type": "number", + "minimum": 0, + "description": "The maximum amount of compute power this content will be allowed to consume when executing or rendering, expressed in CPU Units, where 1.0 unit is equivalent to 1 physical or virtual core. Fractional values are allowed. If the process tries to use more CPU than allowed, it will be throttled.", + "examples": [1] + }, + "cpu_request": { + "type": "number", + "minimum": 0, + "description": "The minimum amount of compute power this content needs when executing virtual core. Fractional values are allowed.", + "examples": [0.5] + }, + "default_image_name": { + "type": "string", + "pattern": "^[^\t\n\b\f\r ]*$", + "description": "Name of the target container image.", + "examples": ["posit/connect-runtime-python3.11-r4.3"] + }, + "memory_limit": { + "type": "integer", + "minimum": 0, + "description": "The maximum amount of RAM this content will be allowed to consume when executing or rendering, expressed in bytes. If the process tries to use more memory than allowed, it will be terminated", + "examples": ["100000000"] + }, + "memory_request": { + "type": "integer", + "minimum": 0, + "description": "The minimum amount of RAM this content needs when executing or rendering, expressed in bytes.", + "examples": ["20000000"] + }, + "nvidia_gpu_limit": { + "type": "integer", + "minimum": 0, + "description": "The number of NVIDIA GPUs that will be allocated by Kubernetes to run this content.", + "examples": [0] + }, + "service_account_name": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "description": "The name of the Kubernetes service account that is used to run this content. It must adhere to Kubernetes service account naming rules. You must be an administrator to set this value.", + "examples": ["posit-connect-content"] + }, + "default_r_environment_management": { + "type": "boolean", + "description": "Enables or disables R environment management. When false, Posit Connect will not install R packages and instead assume that all required packages are present in the container image.", + "examples": [true] + }, + "default_py_environment_management": { + "type": "boolean", + "description": "Enables or disables Python environment management. When false, Posit Connect will not install Python packages and instead assume that all required packages are present in the container image.", + "examples": [true] + } + } + }, + "access": { + "type": "object", + "additionalProperties": false, + "properties": { + "run_as": { + "type": "string", + "description": "The system username under which the content should be run. Must be an existing user in the allowed group. You must be an administrator to set this value.", + "examples": ["rstudio-connect"] + }, + "run_as_current_user": { + "type": "boolean", + "default": false, + "description": "For application content types, run a separate process under the user account of each visiting user under that user's server account. Requires PAM authentication on the Posit Connect server. You must be an administrator to set this value." + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": ["quarto-shiny", "quarto", "quarto-static"] + } + }, + "required": ["type"] + }, + "then": { + "required": ["quarto"] + } + } + ] + }, + "config_connect_cloud": { + "description": "Extensions to the base schema for Posit Connect Cloud deployments.", + "properties": { + "product_type": { + "type": "string", + "description": "Product type indicating this is a Posit Connect Cloud configuration.", + "enum": ["connect_cloud"] + }, + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {} + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {} + } + }, + "connect_cloud": { + "type": "object", + "additionalProperties": false, + "description": "Setting specific to Posit Connect Cloud deployments.", + "properties": { + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {} + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {} + } + }, + "vanity_name": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])$", + "description": "The custom component of the vanity URL. If your account name is 'my-account' and this value is 'vanity', the vanity URL will be https://my-account-vanity.share.connect.posit.cloud.", + "examples": ["sales-report"] + }, + "access_control": { + "type": "object", + "additionalProperties": false, + "properties": { + "public_access": { + "type": "boolean", + "description": "Whether or not the content is publicly accessible." + }, + "organization_access": { + "type": "string", + "enum": ["disabled", "viewer", "editor"], + "default": "disabled", + "description": "The default level of access for account members within an organizational account." + } + } + } + } + }, + "connect": { + "additionalProperties": false, + "properties": { + "runtime": {} + } + } + } + } + }, + "unevaluatedProperties": false, + "properties": { + "product_type": { + "type": "string", + "description": "The server type that this content will be deployed to.", + "enum": ["connect", "snowflake", "connect_cloud"], + "default": "connect" + }, + "comments": { + "type": "array", + "items": { + "type": ["string"] + }, + "description": "Comments are allowed at the top of the configuration file, with a leading '#' character." + }, + "$schema": { + "type": "string", + "format": "url", + "description": "URL of the json-schema definition for this file. Must be 'https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json'.", + "examples": [ + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" + ] + }, + "type": { + "type": "string", + "description": "Indicates the type of content being deployed. Valid values are: html, jupyter-notebook, jupyter-voila, python-bokeh, python-dash, python-fastapi, python-flask, python-gradio, python-panel, python-shiny, python-streamlit, quarto-shiny, quarto-static, r-plumber, r-shiny, rmd-shiny, rmd", + "enum": [ + "html", + "jupyter-notebook", + "jupyter-voila", + "python-bokeh", + "python-dash", + "python-fastapi", + "python-flask", + "python-gradio", + "python-panel", + "python-shiny", + "python-streamlit", + "quarto-shiny", + "quarto", + "quarto-static", + "r-plumber", + "r-shiny", + "rmd-shiny", + "rmd", + "unknown" + ], + "examples": ["quarto-static"] + }, + "entrypoint": { + "type": "string", + "description": "Name of the primary file containing the content. For Python flask, dash, fastapi, and python-shiny projects, this specifies the object within the file in module:object format. See the documentation at https://docs.posit.co/connect/user/publishing-cli-apps/#publishing-rsconnect-python-entrypoint.", + "examples": ["app.py", "report.qmd"] + }, + "source": { + "type": "string", + "description": "Name of the primary file that acts as the source for content that can be rendered on demand. Only useful when deploying static content which has been rendered from Quarto, Rmarkdown or Jupyter Notebooks", + "examples": ["report.qmd", "_quarto.yml"] + }, + "title": { + "type": "string", + "pattern": "^[^\t\n\f\r]{3,1000}$|", + "description": "Title for this content. If specified, it must be a single line containing between 3 and 1000 characters.", + "examples": ["Quarterly Sales Report"] + }, + "description": { + "type": "string", + "pattern": "^[^\t\f\r]*$", + "description": "Description for this content. It may span multiple lines and be up to 4000 characters.", + "examples": ["This is the quarterly sales report, broken down by region."] + }, + "validate": { + "type": "boolean", + "description": "Access the content after deploying, to validate that it is live. Defaults to true.", + "default": true + }, + "files": { + "type": "array", + "items": { + "type": ["string"] + }, + "description": "Project-relative paths of the files to be included in the deployment. Wildcards are accepted, using .gitignore syntax.", + "examples": ["app.py", "model/*.csv", "!model/excludeme.csv"] + }, + "python": { + "$comment": "Note: this object is extended by the other config definitions. Extensions should use additionalProperties: false to prevent unexpected properties.", + "type": "object", + "description": "Python language and dependencies.", + "properties": { + "version": { + "type": "string", + "description": "Python version. The server must have a matching Python major/minor version in order to run the content.", + "examples": ["3.11.3", "3.11"] + } + } + }, + "r": { + "$comment": "Note: this object is extended by the other config definitions. Extensions should use additionalProperties: false to prevent unexpected properties.", + "type": "object", + "description": "R language and dependencies.", + "properties": { + "version": { + "type": "string", + "description": "R version. The server will use the nearest R version to run the content.", + "examples": ["4.3.1"] + } + } + }, + "environment": { + "type": "object", + "additionalProperties": { + "type": ["string"] + }, + "description": "Environment variable/value map. All values must be strings. Secrets such as API keys or tokens should not be stored here.", + "examples": [ + { + "API_URL": "https://example.com/api" + } + ] + }, + "secrets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of secrets required by the application. Injected as environment variables.", + "examples": ["API_KEY", "DATABASE_PASSWORD"] + }, + "integration_requests": { + "type": "array", + "description": "Integration requests associated with this configuration", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "guid": { + "type": "string", + "description": "Unique identifier for the integration request" + }, + "name": { + "type": "string", + "description": "Name of the integration request" + }, + "description": { + "type": "string", + "description": "Description of the integration request" + }, + "auth_type": { + "type": "string", + "description": "Authentication type for the integration request" + }, + "type": { + "type": "string", + "description": "Integration type" + }, + "config": { + "type": "object", + "additionalProperties": true, + "description": "Configuration for the integration request" + } + } + } + }, + "connect": { + "type": "object", + "description": "Settings for Posit Connect applications.", + "properties": { + "runtime": { + "type": "object", + "additionalProperties": false, + "description": "Runtime settings for application content types.", + "properties": { + "connection_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "Maximum number of seconds allowed without data sent or received across a client connection. A value of `0` means connections will never time-out (not recommended).", + "examples": [5] + }, + "read_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "Maximum number of seconds allowed without data received from a client connection. A value of `0` means a lack of client (browser) interaction never causes the connection to close.", + "examples": [30] + }, + "init_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "The maximum number of seconds allowed for an interactive application to start. Posit Connect must be able to connect to a newly launched application before this threshold has elapsed.", + "examples": [60] + }, + "idle_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "The maximum number of seconds a worker process for an interactive application to remain alive after it goes idle (no active connections).", + "examples": [120] + }, + "max_processes": { + "type": "integer", + "minimum": 1, + "description": "Specifies the total number of concurrent processes allowed for a single interactive application.", + "examples": [5] + }, + "min_processes": { + "type": "integer", + "minimum": 0, + "description": "Specifies the minimum number of concurrent processes allowed for a single interactive application.", + "examples": [1] + }, + "max_conns_per_process": { + "type": "integer", + "minimum": 1, + "description": "Specifies the maximum number of client connections allowed to an individual process. Incoming connections which will exceed this limit are routed to a new process or rejected.", + "examples": [50] + }, + "load_factor": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Controls how aggressively new processes are spawned. The valid range is between 0.0 and 1.0.", + "examples": [0.5] + } + } + } + } + } + }, + "required": ["$schema", "type", "entrypoint"], + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "jupyter-notebook", + "jupyter-voila", + "python-bokeh", + "python-dash", + "python-fastapi", + "python-flask", + "python-gradio", + "python-panel", + "python-shiny", + "python-streamlit" + ] + } + }, + "required": ["type"] + }, + "then": { + "required": ["python"] + } + }, + { + "if": { + "properties": { + "type": { + "enum": ["r-plumber", "r-shiny", "rmd-shiny", "rmd"] + } + }, + "required": ["type"] + }, + "then": { + "required": ["r"] + } + }, + { + "if": { + "anyOf": [ + { + "$comment": "Default to connect if product_type is not specified.", + "properties": { + "product_type": false + } + }, + { + "required": ["product_type"], + "properties": { + "product_type": { + "const": "connect" + } + } + } + ] + }, + "then": { + "$ref": "#/$defs/config_connect" + } + }, + { + "if": { + "required": ["product_type"], + "properties": { + "product_type": { + "const": "connect_cloud" + } + } + }, + "then": { + "$ref": "#/$defs/config_connect_cloud" + } + } + ] +} diff --git a/extensions/vscode/src/toml/validate.ts b/extensions/vscode/src/toml/validate.ts new file mode 100644 index 0000000000..ae48474b8b --- /dev/null +++ b/extensions/vscode/src/toml/validate.ts @@ -0,0 +1,10 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; + +import schema from "./schemas/posit-publishing-schema-v3.json"; + +const ajv = new Ajv2020({ strict: false, allErrors: true }); +addFormats(ajv); +export const validate = ajv.compile(schema); diff --git a/extensions/vscode/src/toml/writer.test.ts b/extensions/vscode/src/toml/writer.test.ts new file mode 100644 index 0000000000..17a40ad8c4 --- /dev/null +++ b/extensions/vscode/src/toml/writer.test.ts @@ -0,0 +1,271 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { writeConfigToFile } from "./writer"; +import { ConfigurationDetails, ContentType } from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; +import { ConfigurationLoadError } from "./errors"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-writer-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function configPath(name: string): string { + return path.join(tmpDir, ".posit", "publish", `${name}.toml`); +} + +function makeConfig( + overrides: Partial = {}, +): ConfigurationDetails { + return { + $schema: + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + type: ContentType.PYTHON_DASH, + entrypoint: "app.py", + productType: ProductType.CONNECT, + validate: true, + files: ["app.py", "requirements.txt"], + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + }, + ...overrides, + }; +} + +describe("writeConfigToFile", () => { + it("writes a valid config and returns Configuration", async () => { + const cfg = await writeConfigToFile("myapp", ".", tmpDir, makeConfig()); + + expect(cfg.configurationName).toBe("myapp"); + expect(cfg.configurationPath).toBe(configPath("myapp")); + expect(cfg.projectDir).toBe("."); + expect(cfg.configuration.type).toBe(ContentType.PYTHON_DASH); + + // Verify the file was created + const content = fs.readFileSync(configPath("myapp"), "utf-8"); + expect(content).toContain('type = "python-dash"'); + expect(content).toContain('entrypoint = "app.py"'); + }); + + it("creates directory if it does not exist", async () => { + await writeConfigToFile("newdir-test", ".", tmpDir, makeConfig()); + + expect(fs.existsSync(configPath("newdir-test"))).toBe(true); + }); + + it("writes TOML with snake_case keys", async () => { + await writeConfigToFile( + "snake", + ".", + tmpDir, + makeConfig({ + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + }), + ); + + const content = fs.readFileSync(configPath("snake"), "utf-8"); + expect(content).toContain("package_file"); + expect(content).toContain("package_manager"); + expect(content).toContain("requires_python"); + // Should not contain camelCase + expect(content).not.toContain("packageFile"); + expect(content).not.toContain("packageManager"); + expect(content).not.toContain("requiresPython"); + }); + + it("writes leading comments", async () => { + const cfg = makeConfig({ + comments: [" This is a comment", " Another line"], + }); + + await writeConfigToFile("comments", ".", tmpDir, cfg); + + const content = fs.readFileSync(configPath("comments"), "utf-8"); + expect(content.startsWith("# This is a comment\n# Another line\n")).toBe( + true, + ); + }); + + it("does not mutate the input config", async () => { + const original = makeConfig({ + comments: [" comment"], + alternatives: [makeConfig()], + }); + const originalType = original.type; + const originalComments = [...original.comments!]; + + await writeConfigToFile("no-mutate", ".", tmpDir, original); + + expect(original.type).toBe(originalType); + expect(original.comments).toEqual(originalComments); + expect(original.alternatives).toHaveLength(1); + }); + + it("strips empty strings from optional fields", async () => { + await writeConfigToFile( + "strip-empty", + ".", + tmpDir, + makeConfig({ + title: "", + description: "", + }), + ); + + const content = fs.readFileSync(configPath("strip-empty"), "utf-8"); + expect(content).not.toContain("title"); + expect(content).not.toContain("description"); + }); + + it("preserves empty section objects required by schema", async () => { + await writeConfigToFile( + "empty-r", + ".", + tmpDir, + makeConfig({ + type: ContentType.R_SHINY, + entrypoint: "app.R", + r: { + version: "", + packageFile: "", + packageManager: "", + }, + }), + ); + + const content = fs.readFileSync(configPath("empty-r"), "utf-8"); + // The [r] section must be present even with all fields stripped, + // because the schema conditionally requires it for R content types + expect(content).toContain("[r]"); + }); + + it("applies Connect Cloud compliance", async () => { + await writeConfigToFile( + "cloud", + ".", + tmpDir, + makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + }), + ); + + const content = fs.readFileSync(configPath("cloud"), "utf-8"); + // Version truncated to X.Y + expect(content).toContain('version = "3.11"'); + // Disallowed fields stripped + expect(content).not.toContain("package_file"); + expect(content).not.toContain("package_manager"); + expect(content).not.toContain("requires_python"); + }); + + it("handles type unknown by substituting for validation", async () => { + const cfg = await writeConfigToFile( + "unknown-type", + ".", + tmpDir, + makeConfig({ type: ContentType.UNKNOWN }), + ); + + // The returned config preserves the original type + expect(cfg.configuration.type).toBe(ContentType.UNKNOWN); + + // The file contains "unknown" + const content = fs.readFileSync(configPath("unknown-type"), "utf-8"); + expect(content).toContain('type = "unknown"'); + }); + + it("preserves environment keys as-is", async () => { + await writeConfigToFile( + "env", + ".", + tmpDir, + makeConfig({ + environment: { + MY_API_KEY: "value", + DATABASE_URL: "postgres://localhost/db", + }, + }), + ); + + const content = fs.readFileSync(configPath("env"), "utf-8"); + expect(content).toContain("MY_API_KEY"); + expect(content).toContain("DATABASE_URL"); + }); + + it("throws ConfigurationLoadError for invalid config", async () => { + // Deliberately incomplete config to test schema validation — + // assertion needed because we're intentionally violating the type contract + const badConfig = { + $schema: + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + productType: ProductType.CONNECT, + validate: true, + } as ConfigurationDetails; + + try { + await writeConfigToFile("invalid", ".", tmpDir, badConfig); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.error.code).toBe("tomlValidationError"); + } + } + }); + + it("file ends with newline", async () => { + await writeConfigToFile("newline", ".", tmpDir, makeConfig()); + + const content = fs.readFileSync(configPath("newline"), "utf-8"); + expect(content.endsWith("\n")).toBe(true); + }); + + it("strips alternatives from written file", async () => { + await writeConfigToFile( + "no-alternatives", + ".", + tmpDir, + makeConfig({ + alternatives: [makeConfig({ type: ContentType.HTML })], + }), + ); + + const content = fs.readFileSync(configPath("no-alternatives"), "utf-8"); + expect(content).not.toContain("alternatives"); + }); + + it("strips comments field from TOML body", async () => { + await writeConfigToFile( + "no-comments-field", + ".", + tmpDir, + makeConfig({ comments: [] }), + ); + + const content = fs.readFileSync(configPath("no-comments-field"), "utf-8"); + // "comments" should not appear as a TOML key + expect(content).not.toMatch(/^comments\s*=/m); + }); +}); diff --git a/extensions/vscode/src/toml/writer.ts b/extensions/vscode/src/toml/writer.ts new file mode 100644 index 0000000000..96845ac113 --- /dev/null +++ b/extensions/vscode/src/toml/writer.ts @@ -0,0 +1,158 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs/promises"; +import * as path from "path"; +import { stringify as stringifyTOML } from "smol-toml"; +import { + Configuration, + ConfigurationDetails, + ConfigurationLocation, + ContentType, +} from "../api/types/configurations"; +import { AgentError } from "../api/types/error"; +import { forceProductTypeCompliance } from "./compliance"; +import { convertKeysToSnakeCase } from "./convertKeys"; +import { getConfigPath } from "./discovery"; +import { + createSchemaValidationError, + createConfigurationError, + ConfigurationLoadError, + formatValidationErrors, +} from "./errors"; +import { validate } from "./validate"; + +/** + * Write a configuration to a TOML file. + * + * 1. Clone config (don't mutate input) + * 2. Apply product-type compliance transformations + * 3. Convert keys to snake_case + * 4. Strip empty/undefined values to match Go's omitempty behavior + * 5. Validate against JSON schema + * 6. Write comment lines + TOML content + * 7. Return the written Configuration with location metadata + * + * @param configName - Name of the configuration (without .toml extension) + * @param projectDir - Relative project directory (e.g., "." or "subdir") + * @param rootDir - Absolute workspace root directory + * @param config - The configuration details to write + * + * Throws ConfigurationLoadError for validation failures. + */ +export async function writeConfigToFile( + configName: string, + projectDir: string, + rootDir: string, + config: ConfigurationDetails, +): Promise { + const absDir = path.resolve(rootDir, projectDir); + const configPath = getConfigPath(absDir, configName); + + const location: ConfigurationLocation = { + configurationName: configName, + configurationPath: configPath, + projectDir, + }; + + const loadError = (error: AgentError) => + new ConfigurationLoadError(createConfigurationError(error, location)); + + // Clone so we don't mutate the caller's object + const cfg = structuredClone(config); + + // Extract comments before compliance (compliance doesn't touch them) + const comments = cfg.comments ?? []; + + // Apply product-type compliance transformations + forceProductTypeCompliance(cfg); + + // Remove non-TOML fields + delete cfg.comments; + delete cfg.alternatives; + delete cfg.entrypointObjectRef; + + // Convert to snake_case for TOML + const snakeResult = convertKeysToSnakeCase(cfg); + if (!isRecord(snakeResult)) { + throw new Error("unexpected: snake_case conversion did not return object"); + } + const snakeObj = snakeResult; + + // Strip empty values to match Go's omitempty behavior. + // Go's TOML encoder with omitempty skips empty strings, nil pointers, + // and empty slices for fields tagged with omitempty. + stripEmpty(snakeObj); + + // Handle type: "unknown" — the schema doesn't allow it, but we permit + // creating configs with unknown type. Substitute "html" for validation, + // then restore. + const originalType = snakeObj["type"]; + if (originalType === "unknown") { + snakeObj["type"] = ContentType.HTML; + } + + // Validate against JSON schema + const valid = validate(snakeObj); + if (!valid) { + const messages = formatValidationErrors(validate.errors ?? []); + throw loadError(createSchemaValidationError(configPath, messages)); + } + + // Restore original type after validation + if (originalType === "unknown") { + snakeObj["type"] = originalType; + } + + // Build file content: comment lines + TOML + let content = ""; + for (const comment of comments) { + content += `#${comment}\n`; + } + content += stringifyTOML(snakeObj); + // Ensure file ends with newline + if (!content.endsWith("\n")) { + content += "\n"; + } + + // Create directory if needed and write file + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, content, "utf-8"); + + return { + configuration: { + ...cfg, + comments, + // Restore the original type in case we substituted for validation + type: config.type, + }, + ...location, + }; +} + +/** + * Recursively strip empty leaf values from an object to match Go's omitempty + * TOML encoding behavior. Removes keys whose values are: + * - undefined or null + * - empty strings ("") + * + * Does NOT remove empty objects — Go's TOML encoder writes section headers + * (e.g., `[r]`) even when all fields are omitted via omitempty, and the + * JSON schema conditionally requires these sections to exist. + * + * Mutates the object in place. + */ +function stripEmpty(obj: Record): void { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined || value === null) { + delete obj[key]; + } else if (typeof value === "string" && value === "") { + delete obj[key]; + } else if (isRecord(value)) { + stripEmpty(value); + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index 42c97ae8b4..06e385912f 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -43,6 +43,8 @@ import { IntegrationRequest, Integration, } from "src/api"; +import { loadAllConfigurations } from "src/toml"; +import * as workspaces from "src/workspaces"; import { EventStream } from "src/events"; import { getPythonInterpreterPath, getRInterpreterPath } from "../utils/vscode"; import { getSummaryStringFromError } from "src/utils/errors"; @@ -2075,24 +2077,25 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const configMap = new Map(); const getConfigurations = async () => { try { - const response = await api.configurations.getAll(entrypointDir, { - entrypoint: entrypointFile, - recursive: false, - }); - const rawConfigs = response.data; - rawConfigs.forEach((cfg) => { - if (!isConfigurationError(cfg)) { + const root = workspaces.path(); + if (!root) { + return; + } + const allConfigs = await loadAllConfigurations(entrypointDir, root); + allConfigs.forEach((cfg) => { + if ( + !isConfigurationError(cfg) && + cfg.configuration.entrypoint === entrypointFile + ) { configMap.set(cfg.configurationName, cfg); } }); } catch (error: unknown) { const summary = getSummaryStringFromError( - "handleFileInitiatedDeploymentSelection, configurations.getAll", + "handleFileInitiatedDeploymentSelection, loadAllConfigurations", error, ); - window.showErrorMessage( - `Unable to continue with API Error: ${summary}`, - ); + window.showErrorMessage(`Unable to load configurations: ${summary}`); throw error; } }; diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json index 38784b5778..73150f46a0 100644 --- a/extensions/vscode/tsconfig.json +++ b/extensions/vscode/tsconfig.json @@ -8,6 +8,7 @@ "target": "ES2023", "outDir": "out", "lib": ["ES2023", "DOM"], + "resolveJsonModule": true, "sourceMap": true, "rootDir": "src", /* Linting */ diff --git a/internal/clients/connect/content.go b/internal/clients/connect/content.go index bf63a26a29..3789d2dd41 100644 --- a/internal/clients/connect/content.go +++ b/internal/clients/connect/content.go @@ -64,10 +64,6 @@ func ConnectContentFromConfig(cfg *config.Config) *ConnectContent { Description: cfg.Description, } if cfg.Connect != nil { - if cfg.Connect.AccessControl != nil { - // access types map directly to Connect - c.AccessType = string(cfg.Connect.AccessControl.Type) - } if cfg.Connect.Runtime != nil { c.ConnectionTimeout = copy(cfg.Connect.Runtime.ConnectionTimeout) c.ReadTimeout = copy(cfg.Connect.Runtime.ReadTimeout) diff --git a/internal/config/types.go b/internal/config/types.go index 707558d3bd..87a3672ace 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -27,15 +27,12 @@ type Config struct { Files []string `toml:"files,multiline" json:"files"` Title string `toml:"title,omitempty" json:"title,omitempty"` Description string `toml:"description,multiline,omitempty" json:"description,omitempty"` - ThumbnailFile string `toml:"thumbnail,omitempty" json:"thumbnail,omitempty"` - Tags []string `toml:"tags,omitempty" json:"tags,omitempty"` Python *Python `toml:"python,omitempty" json:"python,omitempty"` R *R `toml:"r,omitempty" json:"r,omitempty"` Jupyter *Jupyter `toml:"jupyter,omitempty" json:"jupyter,omitempty"` Quarto *Quarto `toml:"quarto,omitempty" json:"quarto,omitempty"` Environment Environment `toml:"environment,omitempty" json:"environment,omitempty"` Secrets []string `toml:"secrets,omitempty" json:"secrets,omitempty"` - Schedules []Schedule `toml:"schedules,omitempty" json:"schedules,omitempty"` Connect *Connect `toml:"connect,omitempty" json:"connect,omitempty"` ConnectCloud *ConnectCloud `toml:"connect_cloud,omitempty" json:"connectCloud,omitempty"` IntegrationRequests []IntegrationRequest `toml:"integration_requests,omitempty,inline,multiline" json:"integration_requests,omitempty"` @@ -214,44 +211,10 @@ type Quarto struct { Engines []string `toml:"engines,omitempty" json:"engines,omitempty"` } -type Schedule struct { - Start string `toml:"start,omitempty" json:"start,omitempty"` - Recurrence string `toml:"recurrence,omitempty" json:"recurrence,omitempty"` -} - -type AccessType string - -const ( - AccessTypeAnonymous AccessType = "all" - AccessTypeLoggedIn AccessType = "logged-in" - AccessTypeACL AccessType = "acl" -) - -type ConnectAccessControl struct { - Type AccessType `toml:"type" json:"type,omitempty"` - Users []User `toml:"users,omitempty" json:"users,omitempty"` - Groups []Group `toml:"groups,omitempty" json:"groups,omitempty"` -} - -type User struct { - Id string `toml:"id,omitempty" json:"id,omitempty"` - GUID string `toml:"guid,omitempty" json:"guid,omitempty"` - Name string `toml:"name,omitempty" json:"name,omitempty"` - Permissions string `toml:"permissions" json:"permissions"` -} - -type Group struct { - Id string `toml:"id,omitempty" json:"id,omitempty"` - GUID string `toml:"guid,omitempty" json:"guid,omitempty"` - Name string `toml:"name,omitempty" json:"name,omitempty"` - Permissions string `toml:"permissions" json:"permissions"` -} - type Connect struct { - Access *ConnectAccess `toml:"access,omitempty" json:"access,omitempty"` - AccessControl *ConnectAccessControl `toml:"access_control,omitempty" json:"accessControl,omitempty"` - Runtime *ConnectRuntime `toml:"runtime,omitempty" json:"runtime,omitempty"` - Kubernetes *ConnectKubernetes `toml:"kubernetes,omitempty" json:"kubernetes,omitempty"` + Access *ConnectAccess `toml:"access,omitempty" json:"access,omitempty"` + Runtime *ConnectRuntime `toml:"runtime,omitempty" json:"runtime,omitempty"` + Kubernetes *ConnectKubernetes `toml:"kubernetes,omitempty" json:"kubernetes,omitempty"` } type ConnectAccess struct { diff --git a/internal/schema/schemas/draft/posit-publishing-schema-v3.json b/internal/schema/schemas/draft/posit-publishing-schema-v3.json index 29041d46b4..f6f0a27563 100644 --- a/internal/schema/schemas/draft/posit-publishing-schema-v3.json +++ b/internal/schema/schemas/draft/posit-publishing-schema-v3.json @@ -535,6 +535,7 @@ "description": "Integration requests associated with this configuration", "items": { "type": "object", + "additionalProperties": false, "properties": { "guid": { "type": "string", @@ -548,7 +549,7 @@ "type": "string", "description": "Description of the integration request" }, - "authType": { + "auth_type": { "type": "string", "description": "Authentication type for the integration request" }, diff --git a/internal/schema/schemas/posit-publishing-schema-v3.json b/internal/schema/schemas/posit-publishing-schema-v3.json index ce7457aa85..3111954fa4 100644 --- a/internal/schema/schemas/posit-publishing-schema-v3.json +++ b/internal/schema/schemas/posit-publishing-schema-v3.json @@ -423,6 +423,7 @@ "description": "Integration requests associated with this configuration", "items": { "type": "object", + "additionalProperties": false, "properties": { "guid": { "type": "string", @@ -436,7 +437,7 @@ "type": "string", "description": "Description of the integration request" }, - "authType": { + "auth_type": { "type": "string", "description": "Authentication type for the integration request" }, diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index 207f84fcd7..99c0d22099 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -96,18 +96,6 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log r.Handle(ToPath("connect", "open-content"), PostOpenConnectContentHandlerFunc(lister, log, emitter)). Methods(http.MethodPost) - // GET /api/configurations - r.Handle(ToPath("configurations"), GetConfigurationsHandlerFunc(base, log)). - Methods(http.MethodGet) - - // GET /api/configurations/$NAME - r.Handle(ToPath("configurations", "{name}"), GetConfigurationHandlerFunc(base, log)). - Methods(http.MethodGet) - - // PUT /api/configurations/$NAME - r.Handle(ToPath("configurations", "{name}"), PutConfigurationHandlerFunc(base, log)). - Methods(http.MethodPut) - // GET /api/configurations/$NAME/files r.Handle(ToPath("configurations", "{name}", "files"), GetConfigFilesHandlerFunc(base, filesService, log)). Methods(http.MethodGet) diff --git a/internal/services/api/get_configuration.go b/internal/services/api/get_configuration.go deleted file mode 100644 index 5cf2f2f54b..0000000000 --- a/internal/services/api/get_configuration.go +++ /dev/null @@ -1,64 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "errors" - "io/fs" - "net/http" - - "github.com/gorilla/mux" - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/types" - "github.com/posit-dev/publisher/internal/util" -) - -func GetConfigurationHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - name := mux.Vars(req)["name"] - projectDir, relProjectDir, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - path := config.GetConfigPath(projectDir, name) - relPath, err := path.Rel(projectDir) - if err != nil { - InternalError(w, req, log, err) - return - } - cfg, err := config.FromFile(path) - if err != nil && errors.Is(err, fs.ErrNotExist) { - http.NotFound(w, req) - return - } - w.Header().Set("content-type", "application/json") - if err != nil { - response := &configDTO{ - configLocation: configLocation{ - Name: name, - Path: path.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: nil, - Error: types.AsAgentError(err), - } - json.NewEncoder(w).Encode(response) - } else { - response := &configDTO{ - configLocation: configLocation{ - Name: name, - Path: path.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: cfg, - Error: nil, - } - json.NewEncoder(w).Encode(response) - } - } -} diff --git a/internal/services/api/get_configuration_test.go b/internal/services/api/get_configuration_test.go deleted file mode 100644 index 651b824e43..0000000000 --- a/internal/services/api/get_configuration_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "path/filepath" - "testing" - - "github.com/gorilla/mux" - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/contenttypes" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" -) - -type GetConfigurationuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestGetConfigurationuite(t *testing.T) { - suite.Run(t, new(GetConfigurationuite)) -} - -func (s *GetConfigurationuite) SetupSuite() { - s.log = logging.New() -} - -func (s *GetConfigurationuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *GetConfigurationuite) makeConfiguration(name string) *config.Config { - path := config.GetConfigPath(s.cwd, name) - cfg := config.New() - cfg.ProductType = config.ProductTypeConnect - cfg.Type = contenttypes.ContentTypePythonDash - cfg.Entrypoint = "app.py" - cfg.Python = &config.Python{ - Version: "3.4.5", - PackageManager: "pip", - } - err := cfg.WriteFile(path) - s.NoError(err) - r, err := config.FromFile(path) - s.NoError(err) - return r -} - -func (s *GetConfigurationuite) TestGetConfiguration() { - cfg := s.makeConfiguration("myConfig") - - h := GetConfigurationHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - relPath := filepath.Join(".posit", "publish", "myConfig.toml") - s.Equal(s.cwd.Join(relPath).String(), res.Path) - s.Equal(relPath, res.RelPath) - - s.Equal("myConfig", res.Name) - s.Equal(".", res.ProjectDir) - s.Nil(res.Error) - s.Equal(cfg, res.Configuration) -} - -func (s *GetConfigurationuite) TestGetConfigurationError() { - path2 := config.GetConfigPath(s.cwd, "myConfig") - err := path2.WriteFile([]byte(`foo = 1`), 0666) - s.NoError(err) - - h := GetConfigurationHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - var nilConfiguration *config.Config - relPath := filepath.Join(".posit", "publish", "myConfig.toml") - s.Equal(s.cwd.Join(relPath).String(), res.Path) - s.Equal(relPath, res.RelPath) - - s.Equal("myConfig", res.Name) - s.Equal(".", res.ProjectDir) - s.NotNil(res.Error) - s.Equal(nilConfiguration, res.Configuration) -} - -func (s *GetConfigurationuite) TestGetConfigurationNotFound() { - h := GetConfigurationHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) - - h(rec, req) - - s.Equal(http.StatusNotFound, rec.Result().StatusCode) -} - -func (s *GetConfigurationuite) TestGetConfigurationFromSubdir() { - cfg := s.makeConfiguration("myConfig") - - // Getting configurations from a subdirectory two levels down - base := s.cwd.Dir().Dir() - relProjectDir, err := s.cwd.Rel(base) - s.NoError(err) - - h := GetConfigurationHandlerFunc(base, s.log) - - dirParam := url.QueryEscape(relProjectDir.String()) - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig?dir="+dirParam, nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{ - "name": "myConfig", - }) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - relPath := filepath.Join(".posit", "publish", "myConfig.toml") - s.Equal(s.cwd.Join(relPath).String(), res.Path) - s.Equal(relPath, res.RelPath) - - s.Equal("myConfig", res.Name) - s.Equal(relProjectDir.String(), res.ProjectDir) - s.Nil(res.Error) - s.Equal(cfg, res.Configuration) -} - -func (s *GetConfigurationuite) TestGetConfigurationBadDir() { - // It's a Bad Request to try to get a config from a directory outside the project - _ = s.makeConfiguration("myConfig") - - h := GetConfigurationHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig?dir=../middleware", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"id": "myConfig"}) - h(rec, req) - - s.Equal(http.StatusBadRequest, rec.Result().StatusCode) -} diff --git a/internal/services/api/get_configurations.go b/internal/services/api/get_configurations.go index 0379f5a881..333ebb9b26 100644 --- a/internal/services/api/get_configurations.go +++ b/internal/services/api/get_configurations.go @@ -3,19 +3,8 @@ package api // Copyright (C) 2023 by Posit Software, PBC. import ( - "encoding/json" - "errors" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/posit-dev/publisher/internal/bundles/matcher" "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/types" - "github.com/posit-dev/publisher/internal/util" ) type configLocation struct { @@ -30,112 +19,3 @@ type configDTO struct { Configuration *config.Config `json:"configuration,omitempty"` Error *types.AgentError `json:"error,omitempty"` } - -func readConfigFiles(projectDir util.AbsolutePath, relProjectDir util.RelativePath, entrypoint string) ([]configDTO, error) { - paths, err := config.ListConfigFiles(projectDir) - if err != nil { - return nil, err - } - response := make([]configDTO, 0, len(paths)) - for _, path := range paths { - name := strings.TrimSuffix(path.Base(), ".toml") - relPath, err := path.Rel(projectDir) - if err != nil { - return nil, err - } - - cfg, err := config.FromFile(path) - - if entrypoint != "" { - // Filter out non-matching entrypoints - if cfg == nil || cfg.Entrypoint != entrypoint { - continue - } - } - if err != nil { - response = append(response, configDTO{ - configLocation: configLocation{ - Name: name, - Path: path.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: nil, - Error: types.AsAgentError(err), - }) - } else { - response = append(response, configDTO{ - configLocation: configLocation{ - Name: name, - Path: path.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: cfg, - Error: nil, - }) - } - } - return response, nil -} - -func GetConfigurationsHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - projectDir, relProjectDir, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - entrypoint := req.URL.Query().Get("entrypoint") - - response := make([]configDTO, 0) - if req.URL.Query().Get("recursive") == "true" { - // Recursively search for .posit directories - walker, err := matcher.NewMatchingWalker([]string{"*"}, projectDir, log) - if err != nil { - InternalError(w, req, log, err) - return - } - err = walker.Walk(projectDir, func(path util.AbsolutePath, info fs.FileInfo, err error) error { - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } else { - return err - } - } - if !info.IsDir() { - return nil - } - if path.Base() == ".posit" { - // Parent is a potential project directory - parent := path.Dir() - relParent, err := parent.Rel(base) - if err != nil { - return err - } - files, err := readConfigFiles(parent, relParent, entrypoint) - if err != nil { - return err - } - response = append(response, files...) - // no need to recurse into .posit directories - return filepath.SkipDir - } - return nil - }) - if err != nil { - InternalError(w, req, log, err) - return - } - } else { - response, err = readConfigFiles(projectDir, relProjectDir, entrypoint) - } - if err != nil { - InternalError(w, req, log, err) - return - } - w.Header().Set("content-type", "application/json") - json.NewEncoder(w).Encode(response) - } -} diff --git a/internal/services/api/get_configurations_test.go b/internal/services/api/get_configurations_test.go deleted file mode 100644 index eecb10c787..0000000000 --- a/internal/services/api/get_configurations_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "path/filepath" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/contenttypes" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" -) - -type GetConfigurationsSuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestGetConfigurationsSuite(t *testing.T) { - suite.Run(t, new(GetConfigurationsSuite)) -} - -func (s *GetConfigurationsSuite) SetupSuite() { - s.log = logging.New() -} - -func (s *GetConfigurationsSuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *GetConfigurationsSuite) makeConfiguration(name string) *config.Config { - path := config.GetConfigPath(s.cwd, name) - cfg := config.New() - cfg.ProductType = config.ProductTypeConnect - cfg.Type = contenttypes.ContentTypePythonDash - cfg.Entrypoint = "app.py" - cfg.Python = &config.Python{ - Version: "3.4.5", - PackageManager: "pip", - } - err := cfg.WriteFile(path) - s.NoError(err) - r, err := config.FromFile(path) - s.NoError(err) - return r -} - -func (s *GetConfigurationsSuite) TestGetConfigurations() { - cfg := s.makeConfiguration("default") - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations", nil) - s.NoError(err) - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 1) - - relPath := filepath.Join(".posit", "publish", "default.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - - s.Equal("default", res[0].Name) - s.Equal(".", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg, res[0].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsError() { - cfg := s.makeConfiguration("default") - - path2 := config.GetConfigPath(s.cwd, "other") - err := path2.WriteFile([]byte(`foo = 1`), 0666) - s.NoError(err) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations", nil) - s.NoError(err) - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 2) - - relPath := filepath.Join(".posit", "publish", "default.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - - s.Equal("default", res[0].Name) - s.Equal(".", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg, res[0].Configuration) - - var nilConfiguration *config.Config - relPath = filepath.Join(".posit", "publish", "other.toml") - s.Equal(s.cwd.Join(relPath).String(), res[1].Path) - s.Equal(relPath, res[1].RelPath) - - s.Equal("other", res[1].Name) - s.Equal(".", res[1].ProjectDir) - s.NotNil(res[1].Error) - s.Equal(nilConfiguration, res[1].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsFromSubdir() { - cfg := s.makeConfiguration("default") - - // Getting configurations from a subdirectory two levels down - base := s.cwd.Dir().Dir() - relProjectDir, err := s.cwd.Rel(base) - s.NoError(err) - h := GetConfigurationsHandlerFunc(base, s.log) - - dirParam := url.QueryEscape(relProjectDir.String()) - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?dir="+dirParam, nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 1) - - relPath := filepath.Join(".posit", "publish", "default.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - - s.Equal("default", res[0].Name) - s.Equal(relProjectDir.String(), res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg, res[0].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsByEntrypoint() { - matchingConfig := s.makeConfiguration("matching") - path := config.GetConfigPath(s.cwd, "nonmatching") - nonMatchingConfig := config.New() - nonMatchingConfig.Type = contenttypes.ContentTypeHTML - nonMatchingConfig.Entrypoint = "index.html" - err := nonMatchingConfig.WriteFile(path) - s.NoError(err) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?entrypoint=app.py", nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 1) - - relPath := filepath.Join(".posit", "publish", "matching.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - - s.Equal("matching", res[0].Name) - s.Equal(".", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(matchingConfig, res[0].Configuration) -} - -func (s *GetConfigurationsSuite) makeSubdirConfiguration(name string, subdir string) *config.Config { - subdirPath := s.cwd.Join(subdir) - err := subdirPath.MkdirAll(0777) - s.NoError(err) - - path := config.GetConfigPath(subdirPath, name) - cfg := config.New() - cfg.ProductType = config.ProductTypeConnect - cfg.Type = contenttypes.ContentTypePythonDash - - // make entrypoints unique by subdirectory for filtering - cfg.Entrypoint = subdir + ".py" - cfg.Python = &config.Python{ - Version: "3.4.5", - PackageManager: "pip", - } - err = cfg.WriteFile(path) - s.NoError(err) - r, err := config.FromFile(path) - s.NoError(err) - return r -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsRecursive() { - cfg0 := s.makeSubdirConfiguration("config0", ".") - cfg1 := s.makeSubdirConfiguration("config1", "subdir") - cfg2 := s.makeSubdirConfiguration("config2", "subdir") - subsubdir := filepath.Join("theAlphabeticvallyLastSubdir", "nested") - cfg3 := s.makeSubdirConfiguration("config3", subsubdir) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?recursive=true", nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 4) - - relPath := filepath.Join(".posit", "publish", "config0.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - s.Equal("config0", res[0].Name) - s.Equal(".", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg0, res[0].Configuration) - - relPath = filepath.Join(".posit", "publish", "config1.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[1].Path) - s.Equal(relPath, res[1].RelPath) - s.Equal("config1", res[1].Name) - s.Equal("subdir", res[1].ProjectDir) - s.Nil(res[1].Error) - s.Equal(cfg1, res[1].Configuration) - - relPath = filepath.Join(".posit", "publish", "config2.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[2].Path) - s.Equal(relPath, res[2].RelPath) - s.Equal("config2", res[2].Name) - s.Equal("subdir", res[2].ProjectDir) - s.Nil(res[2].Error) - s.Equal(cfg2, res[2].Configuration) - - relPath = filepath.Join(".posit", "publish", "config3.toml") - s.Equal(s.cwd.Join(subsubdir, relPath).String(), res[3].Path) - s.Equal(relPath, res[3].RelPath) - s.Equal("config3", res[3].Name) - s.Equal(subsubdir, res[3].ProjectDir) - s.Nil(res[3].Error) - s.Equal(cfg3, res[3].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsRecursiveWithEntrypoint() { - _ = s.makeSubdirConfiguration("config0", ".") - cfg1 := s.makeSubdirConfiguration("config1", "subdir") - cfg2 := s.makeSubdirConfiguration("config2", "subdir") - subsubdir := filepath.Join("theAlphabeticvallyLastSubdir", "nested") - _ = s.makeSubdirConfiguration("config3", subsubdir) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?recursive=true&entrypoint=subdir.py", nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 2) - - relPath := filepath.Join(".posit", "publish", "config1.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - s.Equal("config1", res[0].Name) - s.Equal("subdir", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg1, res[0].Configuration) - - relPath = filepath.Join(".posit", "publish", "config2.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[1].Path) - s.Equal(relPath, res[1].RelPath) - s.Equal("config2", res[1].Name) - s.Equal("subdir", res[1].ProjectDir) - s.Nil(res[1].Error) - s.Equal(cfg2, res[1].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsRecursiveWithSubdir() { - _ = s.makeSubdirConfiguration("config0", ".") - cfg1 := s.makeSubdirConfiguration("config1", "subdir") - cfg2 := s.makeSubdirConfiguration("config2", "subdir") - subsubdir := filepath.Join("theAlphabeticvallyLastSubdir", "nested") - _ = s.makeSubdirConfiguration("config3", subsubdir) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?recursive=true&dir=subdir", nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 2) - - relPath := filepath.Join(".posit", "publish", "config1.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - s.Equal("config1", res[0].Name) - s.Equal("subdir", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg1, res[0].Configuration) - - relPath = filepath.Join(".posit", "publish", "config2.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[1].Path) - s.Equal(relPath, res[1].RelPath) - s.Equal("config2", res[1].Name) - s.Equal("subdir", res[1].ProjectDir) - s.Nil(res[1].Error) - s.Equal(cfg2, res[1].Configuration) -} diff --git a/internal/services/api/put_configuration.go b/internal/services/api/put_configuration.go deleted file mode 100644 index 745775429c..0000000000 --- a/internal/services/api/put_configuration.go +++ /dev/null @@ -1,146 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "strings" - "unicode" - - "github.com/gorilla/mux" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/contenttypes" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/schema" - "github.com/posit-dev/publisher/internal/util" -) - -func camelToSnake(s string) string { - var out strings.Builder - for _, c := range s { - if unicode.ToLower(c) == c { - out.WriteRune(c) - } else { - out.WriteRune('_') - out.WriteRune(unicode.ToLower(c)) - } - } - return out.String() -} - -func camelToSnakeMap(m map[string]any) { - for k, v := range m { - vMap, ok := v.(map[string]any) - if ok { - camelToSnakeMap(vMap) - } - newKey := camelToSnake(k) - if newKey != k { - delete(m, k) - m[newKey] = v - } - } -} - -func PutConfigurationHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - name := mux.Vars(req)["name"] - projectDir, relProjectDir, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - err = util.ValidateFilename(name) - if err != nil { - BadRequest(w, req, log, err) - return - } - body, err := io.ReadAll(req.Body) - if err != nil { - InternalError(w, req, log, err) - return - } - - // First, load the body into a Config struct to make any necessary adjustments. - dec := json.NewDecoder(bytes.NewReader(body)) - dec.DisallowUnknownFields() - // use constructor to get default values - cfg := config.New() - err = dec.Decode(&cfg) - if err != nil { - BadRequest(w, req, log, err) - return - } - - // Apply any necessary transformations to the config to ensure schema compliance - cfg.ForceProductTypeCompliance() - - // Then marshal it back to JSON so we can load it into a map... - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - err = enc.Encode(cfg) - if err != nil { - InternalError(w, req, log, err) - return - } - - // Now load it into a map so we can validate it. - rawDecoder := json.NewDecoder(bytes.NewReader(buf.Bytes())) - var rawConfig map[string]any - err = rawDecoder.Decode(&rawConfig) - if err != nil { - BadRequest(w, req, log, err) - return - } - - // Translate keys from camelCase to kebab-case - camelToSnakeMap(rawConfig) - - t, ok := rawConfig["type"] - if ok && t == string(contenttypes.ContentTypeUnknown) { - // We permit configurations with `unknown` type to be created, - // even though they don't pass validation. Pass a known - // type to the validator. - rawConfig["type"] = string(contenttypes.ContentTypeHTML) - } - validator, err := schema.NewValidator[config.Config](schema.ConfigSchemaURL) - if err != nil { - InternalError(w, req, log, err) - return - } - err = validator.ValidateContent(rawConfig) - if err != nil { - BadRequest(w, req, log, err) - return - } - - configPath := config.GetConfigPath(projectDir, name) - - err = cfg.WriteFile(configPath) - if err != nil { - InternalError(w, req, log, err) - return - } - relPath, err := configPath.Rel(base) - if err != nil { - InternalError(w, req, log, err) - return - } - response := configDTO{ - configLocation: configLocation{ - Name: name, - Path: configPath.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: cfg, - } - w.Header().Set("content-type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - } -} diff --git a/internal/services/api/put_configuration_test.go b/internal/services/api/put_configuration_test.go deleted file mode 100644 index fef7efd741..0000000000 --- a/internal/services/api/put_configuration_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/gorilla/mux" - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/contenttypes" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" -) - -type PutConfigurationSuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestPutConfigurationSuite(t *testing.T) { - suite.Run(t, new(PutConfigurationSuite)) -} - -func (s *PutConfigurationSuite) SetupSuite() { - s.log = logging.New() -} - -func (s *PutConfigurationSuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *PutConfigurationSuite) TestPutConfiguration() { - log := logging.New() - - configName := "myConfig" - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/configurations/"+configName, nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": configName}) - - req.Body = io.NopCloser(strings.NewReader(`{ - "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", - "productType": "connect", - "comments": [ - " This is a configuration file", - " Use it to configure your project" - ], - "type": "python-shiny", - "entrypoint": "app.py", - "python": { - "version": "3.4.5", - "packageManager": "pip" - }, - "connect": { - "kubernetes": { - "defaultREnvironmentManagement": true - } - } - }`)) - - handler := PutConfigurationHandlerFunc(s.cwd, log) - handler(rec, req) - s.Equal(http.StatusOK, rec.Result().StatusCode) - - // Since the configuration was valid, it should have been written. - configPath := config.GetConfigPath(s.cwd, configName) - exists, err := configPath.Exists() - s.NoError(err) - s.True(exists) - - var responseBody configDTO - err = json.NewDecoder(rec.Result().Body).Decode(&responseBody) - s.NoError(err) - s.Nil(responseBody.Error) - s.Equal(".", responseBody.ProjectDir) - s.NotNil(responseBody.Configuration) - s.Equal(contenttypes.ContentTypePythonShiny, responseBody.Configuration.Type) - expected := true - s.Equal(&expected, responseBody.Configuration.Connect.Kubernetes.DefaultREnvironmentManagement) - s.Len(responseBody.Configuration.Comments, 2) -} - -func (s *PutConfigurationSuite) TestPutConfigurationBadConfig() { - log := logging.New() - - configName := "myConfig" - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/configurations/"+configName, nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": configName}) - - req.Body = io.NopCloser(strings.NewReader(`{"type": "this-is-not-valid"}`)) - - handler := PutConfigurationHandlerFunc(s.cwd, log) - handler(rec, req) - s.Equal(http.StatusBadRequest, rec.Result().StatusCode) - - // Since the configuration was invalid, it should not have been written. - configPath := config.GetConfigPath(s.cwd, configName) - exists, err := configPath.Exists() - s.NoError(err) - s.False(exists) -} - -func (s *PutConfigurationSuite) TestPutConfigurationBadName() { - log := logging.New() - - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "a/b"}) - - req.Body = io.NopCloser(strings.NewReader(`{}`)) - - handler := PutConfigurationHandlerFunc(s.cwd, log) - handler(rec, req) - s.Equal(http.StatusBadRequest, rec.Result().StatusCode) -} - -func (s *PutConfigurationSuite) TestPutConfigurationBadJSON() { - log := logging.New() - - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) - - req.Body = io.NopCloser(strings.NewReader(`{what}`)) - - handler := PutConfigurationHandlerFunc(s.cwd, log) - handler(rec, req) - s.Equal(http.StatusBadRequest, rec.Result().StatusCode) -} - -func (s *PutConfigurationSuite) TestPutConfigurationSubdir() { - log := logging.New() - - // Putting configuration to a subdirectory two levels down - base := s.cwd.Dir().Dir() - relProjectDir, err := s.cwd.Rel(base) - s.NoError(err) - - configName := "myConfig" - dirParam := url.QueryEscape(relProjectDir.String()) - apiUrl := fmt.Sprintf("/api/configurations/%s?dir=%s", configName, dirParam) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", apiUrl, nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{ - "name": configName, - }) - - req.Body = io.NopCloser(strings.NewReader(`{ - "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", - "productType": "connect", - "comments": [ - " This is a configuration file", - " Use it to configure your project" - ], - "type": "python-shiny", - "entrypoint": "app.py", - "python": { - "version": "3.4.5", - "packageManager": "pip" - }, - "connect": { - "kubernetes": { - "defaultREnvironmentManagement": true - } - } - }`)) - - handler := PutConfigurationHandlerFunc(base, log) - handler(rec, req) - s.Equal(http.StatusOK, rec.Result().StatusCode) - - // Since the configuration was valid, it should have been written. - configPath := config.GetConfigPath(s.cwd, configName) - exists, err := configPath.Exists() - s.NoError(err) - s.True(exists) - - var responseBody configDTO - err = json.NewDecoder(rec.Result().Body).Decode(&responseBody) - s.NoError(err) - s.Nil(responseBody.Error) - s.Equal(relProjectDir.String(), responseBody.ProjectDir) - s.NotNil(responseBody.Configuration) - s.Equal(contenttypes.ContentTypePythonShiny, responseBody.Configuration.Type) - expected := true - s.Equal(&expected, responseBody.Configuration.Connect.Kubernetes.DefaultREnvironmentManagement) - s.Len(responseBody.Configuration.Comments, 2) -}