diff --git a/packages/forestadmin-client/src/build-application-services.ts b/packages/forestadmin-client/src/build-application-services.ts index 78158f8668..ece7ae317e 100644 --- a/packages/forestadmin-client/src/build-application-services.ts +++ b/packages/forestadmin-client/src/build-application-services.ts @@ -16,6 +16,7 @@ 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'; @@ -23,8 +24,42 @@ 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 { + 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, options: ForestAdminClientOptions, ): { optionsWithDefaults: ForestAdminClientOptionsWithDefaults; @@ -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); @@ -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), }; } diff --git a/packages/forestadmin-client/test/build-application-services.test.ts b/packages/forestadmin-client/test/build-application-services.test.ts new file mode 100644 index 0000000000..0fe246c445 --- /dev/null +++ b/packages/forestadmin-client/test/build-application-services.test.ts @@ -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; + + 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 = { + 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 = { + // 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 = {}; + + 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 = { + 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 = { + 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 = {}; + + 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(); + }); + }); +});