Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions src/modules/custom-integrations.ts
Original file line number Diff line number Diff line change
@@ -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<CustomIntegrationCallResponse> {
// 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;
},
};
}
116 changes: 116 additions & 0 deletions src/modules/custom-integrations.types.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;

/**
* Path parameters to substitute in the URL (e.g., `{ owner: "user", repo: "repo" }`).
*/
pathParams?: Record<string, string>;

/**
* Query string parameters to append to the URL.
*/
queryParams?: Record<string, any>;

/**
* Additional headers to send with this specific request.
* These are merged with the integration's configured headers.
*/
headers?: Record<string, string>;
}

/**
* 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<CustomIntegrationCallResponse>;
}
11 changes: 10 additions & 1 deletion src/modules/integrations.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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(
{},
{
Expand All @@ -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(
{},
Expand Down
22 changes: 22 additions & 0 deletions src/modules/integrations.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CustomIntegrationsModule } from "./custom-integrations.types.js";

/**
* Function signature for calling an integration endpoint.
*
Expand Down Expand Up @@ -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.
Expand Down
119 changes: 119 additions & 0 deletions tests/e2e/custom-integrations.test.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
});

Loading