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..ca88d52 --- /dev/null +++ b/src/modules/custom-integrations.ts @@ -0,0 +1,52 @@ +import { AxiosInstance } from "axios"; +import { + CustomIntegrationsModule, + CustomIntegrationCallParams, + CustomIntegrationCallResponse, +} from "./custom-integrations.types.js"; + +/** + * 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?.trim()) { + throw new Error("Integration slug is required and cannot be empty"); + } + if (!operationId?.trim()) { + 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 + const response = await axios.post( + `/apps/${appId}/integrations/custom/${slug}/${operationId}`, + body + ); + + // The axios interceptor extracts response.data, so we get the payload directly + 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..ecaf024 --- /dev/null +++ b/src/modules/custom-integrations.types.ts @@ -0,0 +1,116 @@ +/** + * Parameters for calling a custom integration endpoint. + */ +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" }`). + */ + pathParams?: Record; + + /** + * Query string parameters to append to the URL. + */ + queryParams?: 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, pathParams, queryParams, 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..8ceef88 --- /dev/null +++ b/tests/unit/custom-integrations.test.ts @@ -0,0 +1,356 @@ +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 convert camelCase params to snake_case for backend', async () => { + const slug = 'github'; + const operationId = 'listIssues'; + + // 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' }, + }; + + const mockResponse = { + success: true, + status_code: 200, + data: { issues: [{ id: 1, title: 'Test Issue' }] }, + }; + + // Mock expects snake_case body + scope + .post(`/api/apps/${appId}/integrations/custom/${slug}/${operationId}`, expectedBody) + .reply(200, mockResponse); + + // SDK call uses camelCase + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + + // 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 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 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 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 sdkParams = { + 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}`, sdkParams) + .reply(200, mockResponse); + + // Call the API with large payload + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + + // 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'; + const sdkParams = { + 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}`, sdkParams) + .reply(200, mockResponse); + + // Call the API + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + + // Verify the response + expect(result.success).toBe(true); + + // Verify all mocks were called + expect(scope.isDone()).toBe(true); + }); + + test('custom.call() should pass through multiple headers', async () => { + const slug = 'myapi'; + const operationId = 'secureEndpoint'; + const sdkParams = { + 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}`, sdkParams) + .reply(200, mockResponse); + + // Call the API + const result = await base44.integrations.custom.call(slug, operationId, sdkParams); + + // 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.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 = { + 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); + }); +});