From 17eb01dacd45b99c49287640d5d18ea4a742a4f2 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 15 Jan 2026 13:19:32 +0100 Subject: [PATCH 1/2] feat(forestadmin-client): add fallback to ForestHttpApi for partial server interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a partial ForestAdminServerInterface is provided to buildApplicationServices, methods that are not implemented will now fallback to the default ForestHttpApi implementation. This allows consumers to override only the methods they need. This fixes the issue where partial implementations (like ForestAdminServerSwitcher in cloud-lambda-layers) would fail when calling methods they didn't implement. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/build-application-services.ts | 63 +++++--- .../test/build-application-services.test.ts | 149 ++++++++++++++++++ 2 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 packages/forestadmin-client/test/build-application-services.test.ts diff --git a/packages/forestadmin-client/src/build-application-services.ts b/packages/forestadmin-client/src/build-application-services.ts index 78158f8668..4a1f1fca98 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,37 @@ 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: keyof ForestAdminServerInterface) { + const customMethod = target[prop]; + + // Use custom implementation if provided + if (customMethod !== undefined) { + return typeof customMethod === 'function' ? customMethod.bind(target) : customMethod; + } + + // Fallback to default implementation + const defaultMethod = defaultImplementation[prop]; + + return typeof defaultMethod === 'function' + ? defaultMethod.bind(defaultImplementation) + : defaultMethod; + }, + }) as ForestAdminServerInterface; +} + export default function buildApplicationServices( - forestAdminServerInterface: ForestAdminServerInterface, + forestAdminServerInterface: Partial, options: ForestAdminClientOptions, ): { optionsWithDefaults: ForestAdminClientOptionsWithDefaults; @@ -50,21 +80,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 +114,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..02d5c630ed --- /dev/null +++ b/packages/forestadmin-client/test/build-application-services.test.ts @@ -0,0 +1,149 @@ +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' }]); + const partialInterface: Partial = { + getUsers: customGetUsers, + }; + + const options = factories.forestAdminClientOptions.build(); + buildApplicationServices(partialInterface, options); + + // The custom method should be called, not the default + await customGetUsers(); + 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([]); + }) + .bind(customState); + + const customInterface: Partial = { + getUsers: customGetUsers, + }; + + const options = factories.forestAdminClientOptions.build(); + buildApplicationServices(customInterface, options); + + if (customInterface.getUsers) { + await customInterface.getUsers(options); + } + + 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(); + }); + }); +}); From 7065284bc018782540d96ee31150d4e85c6947e9 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 15 Jan 2026 13:48:44 +0100 Subject: [PATCH 2/2] fix(forestadmin-client): improve proxy tests and Symbol handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix tests to verify Proxy behavior through actual services instead of calling methods directly - Add proper Symbol property handling in Proxy's get trap 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/build-application-services.ts | 11 +++++++--- .../test/build-application-services.test.ts | 21 +++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/forestadmin-client/src/build-application-services.ts b/packages/forestadmin-client/src/build-application-services.ts index 4a1f1fca98..ece7ae317e 100644 --- a/packages/forestadmin-client/src/build-application-services.ts +++ b/packages/forestadmin-client/src/build-application-services.ts @@ -35,8 +35,13 @@ function withDefaultImplementation( const defaultImplementation = new ForestHttpApi(); return new Proxy(customInterface, { - get(target, prop: keyof ForestAdminServerInterface) { - const customMethod = target[prop]; + 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) { @@ -44,7 +49,7 @@ function withDefaultImplementation( } // Fallback to default implementation - const defaultMethod = defaultImplementation[prop]; + const defaultMethod = defaultImplementation[prop as keyof ForestAdminServerInterface]; return typeof defaultMethod === 'function' ? defaultMethod.bind(defaultImplementation) diff --git a/packages/forestadmin-client/test/build-application-services.test.ts b/packages/forestadmin-client/test/build-application-services.test.ts index 02d5c630ed..0fe246c445 100644 --- a/packages/forestadmin-client/test/build-application-services.test.ts +++ b/packages/forestadmin-client/test/build-application-services.test.ts @@ -37,16 +37,20 @@ describe('buildApplicationServices', () => { describe('withDefaultImplementation (fallback mechanism)', () => { it('should use custom implementation when method is provided', async () => { - const customGetUsers = jest.fn().mockResolvedValue([{ id: 1, name: 'Custom User' }]); + const customGetUsers = jest + .fn() + .mockResolvedValue([{ id: 1, name: 'Custom User', permissionLevel: 'admin' }]); const partialInterface: Partial = { getUsers: customGetUsers, }; const options = factories.forestAdminClientOptions.build(); - buildApplicationServices(partialInterface, options); + 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 be called, not the default - await customGetUsers(); + // The custom method should have been called through the Proxy, not the default expect(customGetUsers).toHaveBeenCalled(); expect(mockForestHttpApi.getUsers).not.toHaveBeenCalled(); }); @@ -117,7 +121,7 @@ describe('buildApplicationServices', () => { .mockImplementation(function setCalledFlag(this: typeof customState) { this.called = true; - return Promise.resolve([]); + return Promise.resolve([{ id: 1, name: 'User', permissionLevel: 'admin' }]); }) .bind(customState); @@ -126,11 +130,10 @@ describe('buildApplicationServices', () => { }; const options = factories.forestAdminClientOptions.build(); - buildApplicationServices(customInterface, options); + const { renderingPermission } = buildApplicationServices(customInterface, options); - if (customInterface.getUsers) { - await customInterface.getUsers(options); - } + // Trigger getUsers through the Proxy by calling a service that uses it + await renderingPermission.getUser(1); expect(customState.called).toBe(true); });