From 0ab0f480ba7a187a52e2d52910c0dfb707350183 Mon Sep 17 00:00:00 2001 From: natansil Date: Thu, 1 Jan 2026 12:32:20 +0200 Subject: [PATCH 1/4] start the custom integrations implementation --- src/index.ts | 6 + src/modules/custom-integrations.ts | 87 ++++++++ src/modules/custom-integrations.types.ts | 134 ++++++++++++ src/modules/integrations.ts | 11 +- src/modules/integrations.types.ts | 22 ++ tests/e2e/custom-integrations.test.js | 119 +++++++++++ tests/unit/custom-integrations.test.ts | 258 +++++++++++++++++++++++ 7 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 src/modules/custom-integrations.ts create mode 100644 src/modules/custom-integrations.types.ts create mode 100644 tests/e2e/custom-integrations.test.js create mode 100644 tests/unit/custom-integrations.test.ts diff --git a/src/index.ts b/src/index.ts index dbaa1ce..0ae6849 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,12 @@ export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; export type { ConnectorsModule } from "./modules/connectors.types.js"; +export type { + CustomIntegrationsModule, + CustomIntegrationCallParams, + CustomIntegrationCallResponse, +} from "./modules/custom-integrations.types.js"; + // Auth utils types export type { GetAccessTokenOptions, diff --git a/src/modules/custom-integrations.ts b/src/modules/custom-integrations.ts new file mode 100644 index 0000000..07a9a20 --- /dev/null +++ b/src/modules/custom-integrations.ts @@ -0,0 +1,87 @@ +import { AxiosInstance } from "axios"; +import { + CustomIntegrationsModule, + CustomIntegrationCallParams, + CustomIntegrationCallResponse, +} from "./custom-integrations.types.js"; + +/** + * Normalizes parameters to snake_case for the API. + * + * Supports both camelCase (pathParams) and snake_case (path_params) input, + * always outputting snake_case for the backend. + */ +function normalizeParams(params?: CustomIntegrationCallParams): Record { + if (!params) { + return {}; + } + + const normalized: Record = {}; + + // Handle payload + if (params.payload !== undefined) { + normalized.payload = params.payload; + } + + // Handle path_params (support both camelCase and snake_case) + const pathParams = params.pathParams ?? params.path_params; + if (pathParams !== undefined) { + normalized.path_params = pathParams; + } + + // Handle query_params (support both camelCase and snake_case) + const queryParams = params.queryParams ?? params.query_params; + if (queryParams !== undefined) { + normalized.query_params = queryParams; + } + + // Handle headers + if (params.headers !== undefined) { + normalized.headers = params.headers; + } + + return normalized; +} + +/** + * Creates the custom integrations module for the Base44 SDK. + * + * @param axios - Axios instance for making HTTP requests + * @param appId - Application ID + * @returns Custom integrations module with `call()` method + * @internal + */ +export function createCustomIntegrationsModule( + axios: AxiosInstance, + appId: string +): CustomIntegrationsModule { + return { + async call( + slug: string, + operationId: string, + params?: CustomIntegrationCallParams + ): Promise { + // Validate required parameters + if (!slug) { + throw new Error("Integration slug is required"); + } + if (!operationId) { + throw new Error("Operation ID is required"); + } + + // Normalize parameters to snake_case + const normalizedParams = normalizeParams(params); + + // Make the API call + const response = await axios.post( + `/apps/${appId}/integrations/custom/${slug}/${operationId}`, + normalizedParams + ); + + // Return the response data + // Note: axios interceptor already extracts data from response + return response as unknown as CustomIntegrationCallResponse; + }, + }; +} + diff --git a/src/modules/custom-integrations.types.ts b/src/modules/custom-integrations.types.ts new file mode 100644 index 0000000..c6fa6fb --- /dev/null +++ b/src/modules/custom-integrations.types.ts @@ -0,0 +1,134 @@ +/** + * Parameters for calling a custom integration endpoint. + * + * Supports both camelCase and snake_case parameter names for developer convenience. + * The SDK will normalize to snake_case before sending to the API. + */ +export interface CustomIntegrationCallParams { + /** + * Request body payload to send to the external API. + */ + payload?: Record; + + /** + * Path parameters to substitute in the URL (e.g., `{ owner: "user", repo: "repo" }`). + * Can use either `pathParams` (camelCase) or `path_params` (snake_case). + */ + pathParams?: Record; + + /** + * Path parameters to substitute in the URL (snake_case variant). + * @see {@link pathParams} + */ + path_params?: Record; + + /** + * Query string parameters to append to the URL. + * Can use either `queryParams` (camelCase) or `query_params` (snake_case). + */ + queryParams?: Record; + + /** + * Query string parameters (snake_case variant). + * @see {@link queryParams} + */ + query_params?: Record; + + /** + * Additional headers to send with this specific request. + * These are merged with the integration's configured headers. + */ + headers?: Record; +} + +/** + * Response from a custom integration call. + */ +export interface CustomIntegrationCallResponse { + /** + * Whether the external API returned a 2xx status code. + */ + success: boolean; + + /** + * The HTTP status code returned by the external API. + */ + status_code: number; + + /** + * The response data from the external API. + * Can be any JSON-serializable value depending on the external API's response. + */ + data: any; +} + +/** + * Module for calling custom workspace-level API integrations. + * + * Custom integrations allow workspace administrators to connect any external API + * by importing an OpenAPI specification. Apps in the workspace can then call + * these integrations using this module. + * + * Unlike the built-in integrations (like `Core`), custom integrations: + * - Are defined per-workspace by importing OpenAPI specs + * - Use a slug-based identifier instead of package names + * - Proxy requests through Base44's backend (credentials never exposed to frontend) + * + * @example + * ```typescript + * // Call a custom GitHub integration + * const response = await base44.integrations.custom.call( + * "github", // integration slug (defined by workspace admin) + * "listIssues", // operation ID from the OpenAPI spec + * { + * pathParams: { owner: "myorg", repo: "myrepo" }, + * queryParams: { state: "open", per_page: 100 } + * } + * ); + * + * if (response.success) { + * console.log("Issues:", response.data); + * } else { + * console.error("API returned error:", response.status_code); + * } + * ``` + * + * @example + * ```typescript + * // Call with request body payload + * const response = await base44.integrations.custom.call( + * "github", + * "createIssue", + * { + * pathParams: { owner: "myorg", repo: "myrepo" }, + * payload: { + * title: "Bug report", + * body: "Something is broken", + * labels: ["bug"] + * } + * } + * ); + * ``` + */ +export interface CustomIntegrationsModule { + /** + * Call a custom integration endpoint. + * + * @param slug - The integration's unique identifier (slug), as defined by the workspace admin. + * @param operationId - The operation ID from the OpenAPI spec (e.g., "listIssues", "getUser"). + * @param params - Optional parameters including payload, path params, query params, and headers. + * @returns Promise resolving to the integration call response. + * + * @throws {Error} If slug is not provided. + * @throws {Error} If operationId is not provided. + * @throws {Base44Error} If the integration or operation is not found (404). + * @throws {Base44Error} If the external API call fails (502). + * @throws {Base44Error} If the request times out (504). + */ + call( + slug: string, + operationId: string, + params?: CustomIntegrationCallParams + ): Promise; +} + diff --git a/src/modules/integrations.ts b/src/modules/integrations.ts index 9dda317..cc72661 100644 --- a/src/modules/integrations.ts +++ b/src/modules/integrations.ts @@ -1,5 +1,6 @@ import { AxiosInstance } from "axios"; -import { IntegrationsModule } from "./integrations.types"; +import { IntegrationsModule } from "./integrations.types.js"; +import { createCustomIntegrationsModule } from "./custom-integrations.js"; /** * Creates the integrations module for the Base44 SDK. @@ -13,6 +14,9 @@ export function createIntegrationsModule( axios: AxiosInstance, appId: string ): IntegrationsModule { + // Create the custom integrations module once + const customModule = createCustomIntegrationsModule(axios, appId); + return new Proxy( {}, { @@ -26,6 +30,11 @@ export function createIntegrationsModule( return undefined; } + // Handle 'custom' specially - return the custom integrations module + if (packageName === "custom") { + return customModule; + } + // Create a proxy for integration endpoints return new Proxy( {}, diff --git a/src/modules/integrations.types.ts b/src/modules/integrations.types.ts index 189a650..e2b36a2 100644 --- a/src/modules/integrations.types.ts +++ b/src/modules/integrations.types.ts @@ -1,3 +1,5 @@ +import { CustomIntegrationsModule } from "./custom-integrations.types.js"; + /** * Function signature for calling an integration endpoint. * @@ -371,6 +373,26 @@ export type IntegrationsModule = { * Core package containing built-in Base44 integration functions. */ Core: CoreIntegrations; + + /** + * Custom integrations module for calling workspace-level API integrations. + * + * Allows calling external APIs that workspace admins have configured + * by importing OpenAPI specifications. + * + * @example + * ```typescript + * const response = await base44.integrations.custom.call( + * "github", // integration slug + * "listIssues", // operation ID + * { + * pathParams: { owner: "myorg", repo: "myrepo" }, + * queryParams: { state: "open" } + * } + * ); + * ``` + */ + custom: CustomIntegrationsModule; } & { /** * Access to additional integration packages. diff --git a/tests/e2e/custom-integrations.test.js b/tests/e2e/custom-integrations.test.js new file mode 100644 index 0000000..c527a40 --- /dev/null +++ b/tests/e2e/custom-integrations.test.js @@ -0,0 +1,119 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import { createClient, Base44Error } from '../../src/index.ts'; +import { getTestConfig } from '../utils/test-config.js'; + +// Get test configuration +const config = getTestConfig(); + +describe('Custom Integrations operations (E2E)', () => { + let base44; + + beforeAll(() => { + // Initialize the SDK client + base44 = createClient({ + serverUrl: config.serverUrl, + appId: config.appId, + }); + + // Set the authentication token + if (config.token) { + base44.setToken(config.token); + } + }); + + test('should handle non-existent custom integration gracefully', async () => { + try { + await base44.integrations.custom.call( + 'nonexistent-integration-slug', + 'nonexistent-operation', + {} + ); + // If we get here, the test should fail + fail('Expected an error but none was thrown'); + } catch (error) { + // Expect a Base44Error with 404 status + expect(error).toBeInstanceOf(Base44Error); + expect(error.status).toBe(404); + } + }); + + test('should handle non-existent operation in existing integration gracefully', async () => { + // This test requires a real custom integration to be set up + // Skip if TEST_CUSTOM_INTEGRATION_SLUG is not set + if (!process.env.TEST_CUSTOM_INTEGRATION_SLUG) { + console.log('Skipping: TEST_CUSTOM_INTEGRATION_SLUG not set'); + return; + } + + try { + await base44.integrations.custom.call( + process.env.TEST_CUSTOM_INTEGRATION_SLUG, + 'nonexistent-operation-id', + {} + ); + fail('Expected an error but none was thrown'); + } catch (error) { + expect(error).toBeInstanceOf(Base44Error); + expect(error.status).toBe(404); + } + }); + + test('should call a real custom integration successfully', async () => { + // This test requires a real custom integration to be set up + // Skip if required env vars are not set + if ( + !process.env.TEST_CUSTOM_INTEGRATION_SLUG || + !process.env.TEST_CUSTOM_INTEGRATION_OPERATION + ) { + console.log( + 'Skipping: TEST_CUSTOM_INTEGRATION_SLUG or TEST_CUSTOM_INTEGRATION_OPERATION not set' + ); + return; + } + + try { + const result = await base44.integrations.custom.call( + process.env.TEST_CUSTOM_INTEGRATION_SLUG, + process.env.TEST_CUSTOM_INTEGRATION_OPERATION, + { + // Parse params from env if provided + ...(process.env.TEST_CUSTOM_INTEGRATION_PARAMS + ? JSON.parse(process.env.TEST_CUSTOM_INTEGRATION_PARAMS) + : {}), + } + ); + + // Verify we got a response with expected structure + expect(result).toBeTruthy(); + expect(typeof result.success).toBe('boolean'); + expect(typeof result.status_code).toBe('number'); + expect(result).toHaveProperty('data'); + } catch (error) { + if (error instanceof Base44Error) { + console.error(`API Error: ${error.status} - ${error.message}`); + } + throw error; + } + }); + + test('custom.call should validate required parameters', async () => { + // Test that calling without slug throws an error + try { + // @ts-expect-error Testing invalid input + await base44.integrations.custom.call(); + fail('Expected an error but none was thrown'); + } catch (error) { + expect(error.message).toContain('slug'); + } + + // Test that calling without operationId throws an error + try { + // @ts-expect-error Testing invalid input + await base44.integrations.custom.call('some-slug'); + fail('Expected an error but none was thrown'); + } catch (error) { + expect(error.message).toContain('Operation'); + } + }); +}); + diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts new file mode 100644 index 0000000..14a37bd --- /dev/null +++ b/tests/unit/custom-integrations.test.ts @@ -0,0 +1,258 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; +import { createClient } from '../../src/index.ts'; + +describe('Custom Integrations Module', () => { + let base44: ReturnType; + let scope: nock.Scope; + const appId = 'test-app-id'; + const serverUrl = 'https://base44.app'; + + beforeEach(() => { + // Create a new client for each test + base44 = createClient({ + serverUrl, + appId, + }); + + // Create a nock scope for mocking API calls + scope = nock(serverUrl); + }); + + afterEach(() => { + // Clean up any pending mocks + nock.cleanAll(); + }); + + test('custom.call() should send POST request to correct endpoint', async () => { + const slug = 'github'; + const operationId = 'listIssues'; + const params = { + payload: { title: 'Test Issue' }, + path_params: { owner: 'testuser', repo: 'testrepo' }, + query_params: { state: 'open' }, + }; + + const mockResponse = { + success: true, + status_code: 200, + data: { issues: [{ id: 1, title: 'Test Issue' }] }, + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, params) + .reply(200, mockResponse); + + // Call the API + const result = await base44.integrations.custom.call(slug, operationId, params); + + // Verify the response + expect(result.success).toBe(true); + expect(result.status_code).toBe(200); + expect(result.data.issues).toHaveLength(1); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('custom.call() should support camelCase parameter names', async () => { + const slug = 'github'; + const operationId = 'getRepo'; + const params = { + pathParams: { owner: 'testuser', repo: 'testrepo' }, + queryParams: { include: 'stats' }, + }; + + // Expected body should have snake_case keys + const expectedBody = { + path_params: { owner: 'testuser', repo: 'testrepo' }, + query_params: { include: 'stats' }, + }; + + const mockResponse = { + success: true, + status_code: 200, + data: { name: 'testrepo', stars: 100 }, + }; + + // Mock the API response - expecting snake_case in the body + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, expectedBody) + .reply(200, mockResponse); + + // Call with camelCase params + const result = await base44.integrations.custom.call(slug, operationId, params); + + // Verify the response + expect(result.success).toBe(true); + expect(result.data.name).toBe('testrepo'); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('custom.call() should work with empty params', async () => { + const slug = 'github'; + const operationId = 'getAuthenticatedUser'; + + const mockResponse = { + success: true, + status_code: 200, + data: { login: 'testuser', id: 123 }, + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) + .reply(200, mockResponse); + + // Call without params + const result = await base44.integrations.custom.call(slug, operationId); + + // Verify the response + expect(result.success).toBe(true); + expect(result.data.login).toBe('testuser'); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('custom.call() should handle 404 error for non-existent integration', async () => { + const slug = 'nonexistent'; + const operationId = 'someEndpoint'; + + // Mock a 404 error response + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) + .reply(404, { + detail: `Custom integration '${slug}' not found in workspace`, + }); + + // Call the API and expect an error + await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + status: 404, + name: 'Base44Error', + }); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('custom.call() should handle 404 error for non-existent operation', async () => { + const slug = 'github'; + const operationId = 'nonExistentOperation'; + + // Mock a 404 error response + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) + .reply(404, { + detail: `Operation '${operationId}' not found in integration '${slug}'`, + }); + + // Call the API and expect an error + await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + status: 404, + name: 'Base44Error', + }); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('custom.call() should handle 502 error from external API', async () => { + const slug = 'github'; + const operationId = 'listIssues'; + + // Mock a 502 error response (external API failure) + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, {}) + .reply(502, { + detail: 'Failed to connect to external API: Connection refused', + }); + + // Call the API and expect an error + await expect(base44.integrations.custom.call(slug, operationId)).rejects.toMatchObject({ + status: 502, + name: 'Base44Error', + }); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('custom.call() should throw error when slug is missing', async () => { + // @ts-expect-error Testing invalid input + await expect(base44.integrations.custom.call()).rejects.toThrow( + 'Integration slug is required' + ); + }); + + test('custom.call() should throw error when operationId is missing', async () => { + // @ts-expect-error Testing invalid input + await expect(base44.integrations.custom.call('github')).rejects.toThrow( + 'Operation ID is required' + ); + }); + + test('custom.call() should include custom headers in request', async () => { + const slug = 'myapi'; + const operationId = 'getData'; + const params = { + headers: { 'X-Custom-Header': 'custom-value' }, + }; + + const mockResponse = { + success: true, + status_code: 200, + data: { result: 'ok' }, + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, params) + .reply(200, mockResponse); + + // Call the API + const result = await base44.integrations.custom.call(slug, operationId, params); + + // Verify the response + expect(result.success).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('custom property should not interfere with other integration packages', async () => { + // Test that Core still works + const coreParams = { + to: 'test@example.com', + subject: 'Test', + body: 'Test body', + }; + + scope + .post(`/api/apps/${appId}/integration-endpoints/Core/SendEmail`, coreParams) + .reply(200, { success: true }); + + const coreResult = await base44.integrations.Core.SendEmail(coreParams); + expect(coreResult.success).toBe(true); + + // Test that custom packages still work + const customPackageParams = { param: 'value' }; + + scope + .post( + `/api/apps/${appId}/integration-endpoints/installable/SomePackage/integration-endpoints/SomeEndpoint`, + customPackageParams + ) + .reply(200, { success: true }); + + const packageResult = await base44.integrations.SomePackage.SomeEndpoint(customPackageParams); + expect(packageResult.success).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); +}); + From a28df622f13efbeb33d0485f056ba61d7865337d Mon Sep 17 00:00:00 2001 From: natansil Date: Sun, 4 Jan 2026 11:06:14 +0200 Subject: [PATCH 2/4] remove camelCase param normalization from custom integrations, use snake_case only --- src/modules/custom-integrations.ts | 44 +----------------------- src/modules/custom-integrations.types.ts | 24 ++----------- tests/unit/custom-integrations.test.ts | 37 -------------------- 3 files changed, 4 insertions(+), 101 deletions(-) diff --git a/src/modules/custom-integrations.ts b/src/modules/custom-integrations.ts index 07a9a20..2bf0f17 100644 --- a/src/modules/custom-integrations.ts +++ b/src/modules/custom-integrations.ts @@ -5,44 +5,6 @@ import { CustomIntegrationCallResponse, } from "./custom-integrations.types.js"; -/** - * Normalizes parameters to snake_case for the API. - * - * Supports both camelCase (pathParams) and snake_case (path_params) input, - * always outputting snake_case for the backend. - */ -function normalizeParams(params?: CustomIntegrationCallParams): Record { - if (!params) { - return {}; - } - - const normalized: Record = {}; - - // Handle payload - if (params.payload !== undefined) { - normalized.payload = params.payload; - } - - // Handle path_params (support both camelCase and snake_case) - const pathParams = params.pathParams ?? params.path_params; - if (pathParams !== undefined) { - normalized.path_params = pathParams; - } - - // Handle query_params (support both camelCase and snake_case) - const queryParams = params.queryParams ?? params.query_params; - if (queryParams !== undefined) { - normalized.query_params = queryParams; - } - - // Handle headers - if (params.headers !== undefined) { - normalized.headers = params.headers; - } - - return normalized; -} - /** * Creates the custom integrations module for the Base44 SDK. * @@ -69,13 +31,10 @@ export function createCustomIntegrationsModule( throw new Error("Operation ID is required"); } - // Normalize parameters to snake_case - const normalizedParams = normalizeParams(params); - // Make the API call const response = await axios.post( `/apps/${appId}/integrations/custom/${slug}/${operationId}`, - normalizedParams + params ?? {} ); // Return the response data @@ -84,4 +43,3 @@ export function createCustomIntegrationsModule( }, }; } - diff --git a/src/modules/custom-integrations.types.ts b/src/modules/custom-integrations.types.ts index c6fa6fb..8421e7e 100644 --- a/src/modules/custom-integrations.types.ts +++ b/src/modules/custom-integrations.types.ts @@ -1,8 +1,5 @@ /** * Parameters for calling a custom integration endpoint. - * - * Supports both camelCase and snake_case parameter names for developer convenience. - * The SDK will normalize to snake_case before sending to the API. */ export interface CustomIntegrationCallParams { /** @@ -12,25 +9,11 @@ export interface CustomIntegrationCallParams { /** * Path parameters to substitute in the URL (e.g., `{ owner: "user", repo: "repo" }`). - * Can use either `pathParams` (camelCase) or `path_params` (snake_case). - */ - pathParams?: Record; - - /** - * Path parameters to substitute in the URL (snake_case variant). - * @see {@link pathParams} */ path_params?: Record; /** * Query string parameters to append to the URL. - * Can use either `queryParams` (camelCase) or `query_params` (snake_case). - */ - queryParams?: Record; - - /** - * Query string parameters (snake_case variant). - * @see {@link queryParams} */ query_params?: Record; @@ -81,8 +64,8 @@ export interface CustomIntegrationCallResponse { * "github", // integration slug (defined by workspace admin) * "listIssues", // operation ID from the OpenAPI spec * { - * pathParams: { owner: "myorg", repo: "myrepo" }, - * queryParams: { state: "open", per_page: 100 } + * path_params: { owner: "myorg", repo: "myrepo" }, + * query_params: { state: "open", per_page: 100 } * } * ); * @@ -100,7 +83,7 @@ export interface CustomIntegrationCallResponse { * "github", * "createIssue", * { - * pathParams: { owner: "myorg", repo: "myrepo" }, + * path_params: { owner: "myorg", repo: "myrepo" }, * payload: { * title: "Bug report", * body: "Something is broken", @@ -131,4 +114,3 @@ export interface CustomIntegrationsModule { params?: CustomIntegrationCallParams ): Promise; } - diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts index 14a37bd..6fed708 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -56,42 +56,6 @@ describe('Custom Integrations Module', () => { expect(scope.isDone()).toBe(true); }); - test('custom.call() should support camelCase parameter names', async () => { - const slug = 'github'; - const operationId = 'getRepo'; - const params = { - pathParams: { owner: 'testuser', repo: 'testrepo' }, - queryParams: { include: 'stats' }, - }; - - // Expected body should have snake_case keys - const expectedBody = { - path_params: { owner: 'testuser', repo: 'testrepo' }, - query_params: { include: 'stats' }, - }; - - const mockResponse = { - success: true, - status_code: 200, - data: { name: 'testrepo', stars: 100 }, - }; - - // Mock the API response - expecting snake_case in the body - scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, expectedBody) - .reply(200, mockResponse); - - // Call with camelCase params - const result = await base44.integrations.custom.call(slug, operationId, params); - - // Verify the response - expect(result.success).toBe(true); - expect(result.data.name).toBe('testrepo'); - - // Verify all mocks were called - expect(scope.isDone()).toBe(true); - }); - test('custom.call() should work with empty params', async () => { const slug = 'github'; const operationId = 'getAuthenticatedUser'; @@ -255,4 +219,3 @@ describe('Custom Integrations Module', () => { expect(scope.isDone()).toBe(true); }); }); - From 7b5104c3dfe28e4d32a6d92dae3d278395f67a76 Mon Sep 17 00:00:00 2001 From: natansil Date: Sun, 4 Jan 2026 11:16:01 +0200 Subject: [PATCH 3/4] claude review changes --- src/modules/custom-integrations.ts | 14 ++-- tests/unit/custom-integrations.test.ts | 100 ++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/modules/custom-integrations.ts b/src/modules/custom-integrations.ts index 2bf0f17..33144b3 100644 --- a/src/modules/custom-integrations.ts +++ b/src/modules/custom-integrations.ts @@ -24,21 +24,21 @@ export function createCustomIntegrationsModule( params?: CustomIntegrationCallParams ): Promise { // Validate required parameters - if (!slug) { - throw new Error("Integration slug is required"); + if (!slug?.trim()) { + throw new Error("Integration slug is required and cannot be empty"); } - if (!operationId) { - throw new Error("Operation ID is required"); + if (!operationId?.trim()) { + throw new Error("Operation ID is required and cannot be empty"); } // Make the API call - const response = await axios.post( + // Note: axios interceptor extracts response.data, so we get the payload directly + const response = await axios.post( `/apps/${appId}/integrations/custom/${slug}/${operationId}`, params ?? {} ); - // Return the response data - // Note: axios interceptor already extracts data from response + // The axios interceptor extracts response.data, so we get the payload directly return response as unknown as CustomIntegrationCallResponse; }, }; diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts index 6fed708..da0262e 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -148,17 +148,79 @@ describe('Custom Integrations Module', () => { test('custom.call() should throw error when slug is missing', async () => { // @ts-expect-error Testing invalid input await expect(base44.integrations.custom.call()).rejects.toThrow( - 'Integration slug is required' + 'Integration slug is required and cannot be empty' ); }); test('custom.call() should throw error when operationId is missing', async () => { // @ts-expect-error Testing invalid input await expect(base44.integrations.custom.call('github')).rejects.toThrow( - 'Operation ID is required' + 'Operation ID is required and cannot be empty' ); }); + test('custom.call() should throw error when slug is empty string', async () => { + await expect(base44.integrations.custom.call('', 'listIssues')).rejects.toThrow( + 'Integration slug is required and cannot be empty' + ); + }); + + test('custom.call() should throw error when slug is whitespace only', async () => { + await expect(base44.integrations.custom.call(' ', 'listIssues')).rejects.toThrow( + 'Integration slug is required and cannot be empty' + ); + }); + + test('custom.call() should throw error when operationId is empty string', async () => { + await expect(base44.integrations.custom.call('github', '')).rejects.toThrow( + 'Operation ID is required and cannot be empty' + ); + }); + + test('custom.call() should throw error when operationId is whitespace only', async () => { + await expect(base44.integrations.custom.call('github', ' \t\n ')).rejects.toThrow( + 'Operation ID is required and cannot be empty' + ); + }); + + test('custom.call() should handle large payloads', async () => { + const slug = 'myapi'; + const operationId = 'bulkCreate'; + + // Create a large payload with many items + const largeArray = Array.from({ length: 1000 }, (_, i) => ({ + id: i, + name: `Item ${i}`, + description: 'A'.repeat(100), + metadata: { key: `value_${i}` }, + })); + + const params = { + payload: { items: largeArray }, + }; + + const mockResponse = { + success: true, + status_code: 200, + data: { created: 1000 }, + }; + + // Mock the API response + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, params) + .reply(200, mockResponse); + + // Call the API with large payload + const result = await base44.integrations.custom.call(slug, operationId, params); + + // Verify the response + expect(result.success).toBe(true); + expect(result.data.created).toBe(1000); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + test('custom.call() should include custom headers in request', async () => { const slug = 'myapi'; const operationId = 'getData'; @@ -187,6 +249,40 @@ describe('Custom Integrations Module', () => { expect(scope.isDone()).toBe(true); }); + test('custom.call() should pass through multiple headers', async () => { + const slug = 'myapi'; + const operationId = 'secureEndpoint'; + const params = { + headers: { + 'X-API-Key': 'secret-key-123', + 'X-Request-ID': 'req-456', + 'Accept-Language': 'en-US', + 'X-Custom-Auth': 'Bearer token123', + }, + }; + + const mockResponse = { + success: true, + status_code: 200, + data: { authenticated: true }, + }; + + // Mock the API response - verify all headers are passed in the body + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, params) + .reply(200, mockResponse); + + // Call the API + const result = await base44.integrations.custom.call(slug, operationId, params); + + // Verify the response + expect(result.success).toBe(true); + expect(result.data.authenticated).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + test('custom property should not interfere with other integration packages', async () => { // Test that Core still works const coreParams = { From 5abbf773f06daa7db17f37da4dba2bda31c40878 Mon Sep 17 00:00:00 2001 From: natansil Date: Sun, 4 Jan 2026 12:40:40 +0200 Subject: [PATCH 4/4] SDK - camel api, internally switch to snake for python --- src/modules/custom-integrations.ts | 11 +++- src/modules/custom-integrations.types.ts | 12 ++--- tests/unit/custom-integrations.test.ts | 69 ++++++++++++++++++------ 3 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/modules/custom-integrations.ts b/src/modules/custom-integrations.ts index 33144b3..ca88d52 100644 --- a/src/modules/custom-integrations.ts +++ b/src/modules/custom-integrations.ts @@ -31,11 +31,18 @@ export function createCustomIntegrationsModule( throw new Error("Operation ID is required and cannot be empty"); } + // Convert camelCase to snake_case for Python backend + const { pathParams, queryParams, ...rest } = params ?? {}; + const body = { + ...rest, + ...(pathParams && { path_params: pathParams }), + ...(queryParams && { query_params: queryParams }), + }; + // Make the API call - // Note: axios interceptor extracts response.data, so we get the payload directly const response = await axios.post( `/apps/${appId}/integrations/custom/${slug}/${operationId}`, - params ?? {} + body ); // The axios interceptor extracts response.data, so we get the payload directly diff --git a/src/modules/custom-integrations.types.ts b/src/modules/custom-integrations.types.ts index 8421e7e..ecaf024 100644 --- a/src/modules/custom-integrations.types.ts +++ b/src/modules/custom-integrations.types.ts @@ -10,12 +10,12 @@ export interface CustomIntegrationCallParams { /** * Path parameters to substitute in the URL (e.g., `{ owner: "user", repo: "repo" }`). */ - path_params?: Record; + pathParams?: Record; /** * Query string parameters to append to the URL. */ - query_params?: Record; + queryParams?: Record; /** * Additional headers to send with this specific request. @@ -64,8 +64,8 @@ export interface CustomIntegrationCallResponse { * "github", // integration slug (defined by workspace admin) * "listIssues", // operation ID from the OpenAPI spec * { - * path_params: { owner: "myorg", repo: "myrepo" }, - * query_params: { state: "open", per_page: 100 } + * pathParams: { owner: "myorg", repo: "myrepo" }, + * queryParams: { state: "open", per_page: 100 } * } * ); * @@ -83,7 +83,7 @@ export interface CustomIntegrationCallResponse { * "github", * "createIssue", * { - * path_params: { owner: "myorg", repo: "myrepo" }, + * pathParams: { owner: "myorg", repo: "myrepo" }, * payload: { * title: "Bug report", * body: "Something is broken", @@ -99,7 +99,7 @@ export interface CustomIntegrationsModule { * * @param slug - The integration's unique identifier (slug), as defined by the workspace admin. * @param operationId - The operation ID from the OpenAPI spec (e.g., "listIssues", "getUser"). - * @param params - Optional parameters including payload, path params, query params, and headers. + * @param params - Optional parameters including payload, pathParams, queryParams, and headers. * @returns Promise resolving to the integration call response. * * @throws {Error} If slug is not provided. diff --git a/tests/unit/custom-integrations.test.ts b/tests/unit/custom-integrations.test.ts index da0262e..8ceef88 100644 --- a/tests/unit/custom-integrations.test.ts +++ b/tests/unit/custom-integrations.test.ts @@ -24,10 +24,19 @@ describe('Custom Integrations Module', () => { nock.cleanAll(); }); - test('custom.call() should send POST request to correct endpoint', async () => { + test('custom.call() should convert camelCase params to snake_case for backend', async () => { const slug = 'github'; const operationId = 'listIssues'; - const params = { + + // SDK call uses camelCase (JS convention) + const sdkParams = { + payload: { title: 'Test Issue' }, + pathParams: { owner: 'testuser', repo: 'testrepo' }, + queryParams: { state: 'open' }, + }; + + // Backend expects snake_case (Python convention) + const expectedBody = { payload: { title: 'Test Issue' }, path_params: { owner: 'testuser', repo: 'testrepo' }, query_params: { state: 'open' }, @@ -39,13 +48,13 @@ describe('Custom Integrations Module', () => { data: { issues: [{ id: 1, title: 'Test Issue' }] }, }; - // Mock the API response + // Mock expects snake_case body scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, params) + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, expectedBody) .reply(200, mockResponse); - // Call the API - const result = await base44.integrations.custom.call(slug, operationId, params); + // SDK call uses camelCase + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); // Verify the response expect(result.success).toBe(true); @@ -195,7 +204,7 @@ describe('Custom Integrations Module', () => { metadata: { key: `value_${i}` }, })); - const params = { + const sdkParams = { payload: { items: largeArray }, }; @@ -207,11 +216,11 @@ describe('Custom Integrations Module', () => { // Mock the API response scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, params) + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) .reply(200, mockResponse); // Call the API with large payload - const result = await base44.integrations.custom.call(slug, operationId, params); + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); // Verify the response expect(result.success).toBe(true); @@ -224,7 +233,7 @@ describe('Custom Integrations Module', () => { test('custom.call() should include custom headers in request', async () => { const slug = 'myapi'; const operationId = 'getData'; - const params = { + const sdkParams = { headers: { 'X-Custom-Header': 'custom-value' }, }; @@ -236,11 +245,11 @@ describe('Custom Integrations Module', () => { // Mock the API response scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, params) + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) .reply(200, mockResponse); // Call the API - const result = await base44.integrations.custom.call(slug, operationId, params); + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); // Verify the response expect(result.success).toBe(true); @@ -252,7 +261,7 @@ describe('Custom Integrations Module', () => { test('custom.call() should pass through multiple headers', async () => { const slug = 'myapi'; const operationId = 'secureEndpoint'; - const params = { + const sdkParams = { headers: { 'X-API-Key': 'secret-key-123', 'X-Request-ID': 'req-456', @@ -269,11 +278,11 @@ describe('Custom Integrations Module', () => { // Mock the API response - verify all headers are passed in the body scope - .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, params) + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, sdkParams) .reply(200, mockResponse); // Call the API - const result = await base44.integrations.custom.call(slug, operationId, params); + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); // Verify the response expect(result.success).toBe(true); @@ -283,6 +292,36 @@ describe('Custom Integrations Module', () => { expect(scope.isDone()).toBe(true); }); + test('custom.call() should only include defined params in body', async () => { + const slug = 'github'; + const operationId = 'getUser'; + + // SDK call with only pathParams + const sdkParams = { + pathParams: { username: 'octocat' }, + }; + + // Expected body should only have path_params, not empty payload/query_params/headers + const expectedBody = { + path_params: { username: 'octocat' }, + }; + + const mockResponse = { + success: true, + status_code: 200, + data: { login: 'octocat' }, + }; + + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, expectedBody) + .reply(200, mockResponse); + + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + + expect(result.success).toBe(true); + expect(scope.isDone()).toBe(true); + }); + test('custom property should not interfere with other integration packages', async () => { // Test that Core still works const coreParams = {