Skip to content
Open
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
68 changes: 49 additions & 19 deletions packages/forestadmin-client/src/build-application-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,50 @@ import IpWhiteListService from './ip-whitelist';
import McpServerConfigFromApiService from './mcp-server-config';
import ModelCustomizationFromApiService from './model-customizations/model-customization-from-api';
import ActionPermissionService from './permissions/action-permission';
import ForestHttpApi from './permissions/forest-http-api';
import PermissionService from './permissions/permission-with-cache';
import RenderingPermissionService from './permissions/rendering-permission';
import UserPermissionService from './permissions/user-permission';
import SchemaService from './schema';
import ContextVariablesInstantiator from './utils/context-variables-instantiator';
import defaultLogger from './utils/default-logger';

/**
* Merges a partial server interface with the default ForestHttpApi implementation.
* This allows consumers to override only the methods they need while falling back
* to the default HTTP implementation for the rest.
*/
function withDefaultImplementation(
customInterface: Partial<ForestAdminServerInterface>,
): ForestAdminServerInterface {
const defaultImplementation = new ForestHttpApi();

return new Proxy(customInterface, {
get(target, prop: string | symbol) {
// Handle Symbol properties (e.g., Symbol.toStringTag, Symbol.iterator)
if (typeof prop === 'symbol') {
return Reflect.get(target, prop);
}

const customMethod = target[prop as keyof ForestAdminServerInterface];

// Use custom implementation if provided
if (customMethod !== undefined) {
return typeof customMethod === 'function' ? customMethod.bind(target) : customMethod;
}

// Fallback to default implementation
const defaultMethod = defaultImplementation[prop as keyof ForestAdminServerInterface];

return typeof defaultMethod === 'function'
? defaultMethod.bind(defaultImplementation)
: defaultMethod;
},
}) as ForestAdminServerInterface;
}

export default function buildApplicationServices(
forestAdminServerInterface: ForestAdminServerInterface,
forestAdminServerInterface: Partial<ForestAdminServerInterface>,
options: ForestAdminClientOptions,
): {
optionsWithDefaults: ForestAdminClientOptionsWithDefaults;
Expand All @@ -50,21 +85,19 @@ export default function buildApplicationServices(
...options,
};

const usersPermission = new UserPermissionService(
optionsWithDefaults,
forestAdminServerInterface,
);
// Merge custom interface with default implementation (ForestHttpApi)
// This allows partial implementations to fallback to default HTTP calls
const serverInterface = withDefaultImplementation(forestAdminServerInterface);

const usersPermission = new UserPermissionService(optionsWithDefaults, serverInterface);

const renderingPermission = new RenderingPermissionService(
optionsWithDefaults,
usersPermission,
forestAdminServerInterface,
serverInterface,
);

const actionPermission = new ActionPermissionService(
optionsWithDefaults,
forestAdminServerInterface,
);
const actionPermission = new ActionPermissionService(optionsWithDefaults, serverInterface);

const contextVariables = new ContextVariablesInstantiator(renderingPermission);

Expand All @@ -86,17 +119,14 @@ export default function buildApplicationServices(
eventsSubscription,
eventsHandler,
chartHandler: new ChartHandler(contextVariables),
ipWhitelist: new IpWhiteListService(forestAdminServerInterface, optionsWithDefaults),
schema: new SchemaService(forestAdminServerInterface, optionsWithDefaults),
activityLogs: new ActivityLogsService(forestAdminServerInterface, optionsWithDefaults),
auth: forestAdminServerInterface.makeAuthService(optionsWithDefaults),
ipWhitelist: new IpWhiteListService(serverInterface, optionsWithDefaults),
schema: new SchemaService(serverInterface, optionsWithDefaults),
activityLogs: new ActivityLogsService(serverInterface, optionsWithDefaults),
auth: serverInterface.makeAuthService(optionsWithDefaults),
modelCustomizationService: new ModelCustomizationFromApiService(
forestAdminServerInterface,
optionsWithDefaults,
),
mcpServerConfigService: new McpServerConfigFromApiService(
forestAdminServerInterface,
serverInterface,
optionsWithDefaults,
),
mcpServerConfigService: new McpServerConfigFromApiService(serverInterface, optionsWithDefaults),
};
}
152 changes: 152 additions & 0 deletions packages/forestadmin-client/test/build-application-services.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { ForestAdminServerInterface } from '../src/types';

import * as factories from './__factories__';
import buildApplicationServices from '../src/build-application-services';
import ForestHttpApi from '../src/permissions/forest-http-api';

jest.mock('../src/permissions/forest-http-api');

describe('buildApplicationServices', () => {
let mockForestHttpApi: jest.Mocked<ForestAdminServerInterface>;

beforeEach(() => {
jest.clearAllMocks();

mockForestHttpApi = {
getRenderingPermissions: jest.fn(),
getEnvironmentPermissions: jest.fn(),
getUsers: jest.fn(),
getModelCustomizations: jest.fn(),
getMcpServerConfigs: jest.fn(),
makeAuthService: jest.fn().mockReturnValue({
init: jest.fn(),
getUserInfo: jest.fn(),
generateAuthorizationUrl: jest.fn(),
generateTokens: jest.fn(),
}),
getSchema: jest.fn(),
postSchema: jest.fn(),
checkSchemaHash: jest.fn(),
getIpWhitelistRules: jest.fn(),
createActivityLog: jest.fn(),
updateActivityLogStatus: jest.fn(),
};

(ForestHttpApi as jest.Mock).mockImplementation(() => mockForestHttpApi);
});

describe('withDefaultImplementation (fallback mechanism)', () => {
it('should use custom implementation when method is provided', async () => {
const customGetUsers = jest
.fn()
.mockResolvedValue([{ id: 1, name: 'Custom User', permissionLevel: 'admin' }]);
const partialInterface: Partial<ForestAdminServerInterface> = {
getUsers: customGetUsers,
};

const options = factories.forestAdminClientOptions.build();
const { renderingPermission } = buildApplicationServices(partialInterface, options);

// Trigger getUsers through the Proxy by calling a service that uses it
await renderingPermission.getUser(1);

// The custom method should have been called through the Proxy, not the default
expect(customGetUsers).toHaveBeenCalled();
expect(mockForestHttpApi.getUsers).not.toHaveBeenCalled();
});

it('should fallback to ForestHttpApi when method is not provided', () => {
const partialInterface: Partial<ForestAdminServerInterface> = {
// Only provide getUsers, not makeAuthService
getUsers: jest.fn(),
};

const options = factories.forestAdminClientOptions.build();
buildApplicationServices(partialInterface, options);

// makeAuthService should have been called from ForestHttpApi (the fallback)
expect(mockForestHttpApi.makeAuthService).toHaveBeenCalledWith(
expect.objectContaining({
envSecret: options.envSecret,
}),
);
});

it('should work with empty partial interface (all methods fallback)', () => {
const partialInterface: Partial<ForestAdminServerInterface> = {};

const options = factories.forestAdminClientOptions.build();
const result = buildApplicationServices(partialInterface, options);

// All services should be created successfully using ForestHttpApi fallbacks
expect(result.auth).toBeDefined();
expect(result.schema).toBeDefined();
expect(result.activityLogs).toBeDefined();
expect(mockForestHttpApi.makeAuthService).toHaveBeenCalled();
});

it('should allow partial override of methods', async () => {
const customCheckSchemaHash = jest.fn().mockResolvedValue({ sendSchema: false });
const partialInterface: Partial<ForestAdminServerInterface> = {
checkSchemaHash: customCheckSchemaHash,
// postSchema not provided - should fallback
};

const options = factories.forestAdminClientOptions.build();
const { schema } = buildApplicationServices(partialInterface, options);

// Mock the postSchema to not actually call the server
mockForestHttpApi.postSchema.mockResolvedValue(undefined);

await schema.postSchema({
collections: [],
meta: {
liana: 'test',
liana_version: '1.0.0',
liana_features: null,
stack: { engine: 'nodejs', engine_version: '16.0.0' },
},
});

// Custom checkSchemaHash should be used
expect(customCheckSchemaHash).toHaveBeenCalled();
// postSchema should fallback to ForestHttpApi (but not called since sendSchema: false)
expect(mockForestHttpApi.postSchema).not.toHaveBeenCalled();
});

it('should correctly bind this context for custom methods', async () => {
const customState = { called: false };
const customGetUsers = jest
.fn()
.mockImplementation(function setCalledFlag(this: typeof customState) {
this.called = true;

return Promise.resolve([{ id: 1, name: 'User', permissionLevel: 'admin' }]);
})
.bind(customState);

const customInterface: Partial<ForestAdminServerInterface> = {
getUsers: customGetUsers,
};

const options = factories.forestAdminClientOptions.build();
const { renderingPermission } = buildApplicationServices(customInterface, options);

// Trigger getUsers through the Proxy by calling a service that uses it
await renderingPermission.getUser(1);

expect(customState.called).toBe(true);
});

it('should correctly bind this context for fallback methods', () => {
const partialInterface: Partial<ForestAdminServerInterface> = {};

const options = factories.forestAdminClientOptions.build();
buildApplicationServices(partialInterface, options);

// makeAuthService is called during buildApplicationServices
// It should work correctly with proper this binding
expect(mockForestHttpApi.makeAuthService).toHaveBeenCalled();
});
});
});
Loading