Skip to content

Commit 17eb01d

Browse files
alban bertoliniclaude
andcommitted
feat(forestadmin-client): add fallback to ForestHttpApi for partial server interfaces
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 <noreply@anthropic.com>
1 parent 26c06b0 commit 17eb01d

2 files changed

Lines changed: 193 additions & 19 deletions

File tree

packages/forestadmin-client/src/build-application-services.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,45 @@ import IpWhiteListService from './ip-whitelist';
1616
import McpServerConfigFromApiService from './mcp-server-config';
1717
import ModelCustomizationFromApiService from './model-customizations/model-customization-from-api';
1818
import ActionPermissionService from './permissions/action-permission';
19+
import ForestHttpApi from './permissions/forest-http-api';
1920
import PermissionService from './permissions/permission-with-cache';
2021
import RenderingPermissionService from './permissions/rendering-permission';
2122
import UserPermissionService from './permissions/user-permission';
2223
import SchemaService from './schema';
2324
import ContextVariablesInstantiator from './utils/context-variables-instantiator';
2425
import defaultLogger from './utils/default-logger';
2526

27+
/**
28+
* Merges a partial server interface with the default ForestHttpApi implementation.
29+
* This allows consumers to override only the methods they need while falling back
30+
* to the default HTTP implementation for the rest.
31+
*/
32+
function withDefaultImplementation(
33+
customInterface: Partial<ForestAdminServerInterface>,
34+
): ForestAdminServerInterface {
35+
const defaultImplementation = new ForestHttpApi();
36+
37+
return new Proxy(customInterface, {
38+
get(target, prop: keyof ForestAdminServerInterface) {
39+
const customMethod = target[prop];
40+
41+
// Use custom implementation if provided
42+
if (customMethod !== undefined) {
43+
return typeof customMethod === 'function' ? customMethod.bind(target) : customMethod;
44+
}
45+
46+
// Fallback to default implementation
47+
const defaultMethod = defaultImplementation[prop];
48+
49+
return typeof defaultMethod === 'function'
50+
? defaultMethod.bind(defaultImplementation)
51+
: defaultMethod;
52+
},
53+
}) as ForestAdminServerInterface;
54+
}
55+
2656
export default function buildApplicationServices(
27-
forestAdminServerInterface: ForestAdminServerInterface,
57+
forestAdminServerInterface: Partial<ForestAdminServerInterface>,
2858
options: ForestAdminClientOptions,
2959
): {
3060
optionsWithDefaults: ForestAdminClientOptionsWithDefaults;
@@ -50,21 +80,19 @@ export default function buildApplicationServices(
5080
...options,
5181
};
5282

53-
const usersPermission = new UserPermissionService(
54-
optionsWithDefaults,
55-
forestAdminServerInterface,
56-
);
83+
// Merge custom interface with default implementation (ForestHttpApi)
84+
// This allows partial implementations to fallback to default HTTP calls
85+
const serverInterface = withDefaultImplementation(forestAdminServerInterface);
86+
87+
const usersPermission = new UserPermissionService(optionsWithDefaults, serverInterface);
5788

5889
const renderingPermission = new RenderingPermissionService(
5990
optionsWithDefaults,
6091
usersPermission,
61-
forestAdminServerInterface,
92+
serverInterface,
6293
);
6394

64-
const actionPermission = new ActionPermissionService(
65-
optionsWithDefaults,
66-
forestAdminServerInterface,
67-
);
95+
const actionPermission = new ActionPermissionService(optionsWithDefaults, serverInterface);
6896

6997
const contextVariables = new ContextVariablesInstantiator(renderingPermission);
7098

@@ -86,17 +114,14 @@ export default function buildApplicationServices(
86114
eventsSubscription,
87115
eventsHandler,
88116
chartHandler: new ChartHandler(contextVariables),
89-
ipWhitelist: new IpWhiteListService(forestAdminServerInterface, optionsWithDefaults),
90-
schema: new SchemaService(forestAdminServerInterface, optionsWithDefaults),
91-
activityLogs: new ActivityLogsService(forestAdminServerInterface, optionsWithDefaults),
92-
auth: forestAdminServerInterface.makeAuthService(optionsWithDefaults),
117+
ipWhitelist: new IpWhiteListService(serverInterface, optionsWithDefaults),
118+
schema: new SchemaService(serverInterface, optionsWithDefaults),
119+
activityLogs: new ActivityLogsService(serverInterface, optionsWithDefaults),
120+
auth: serverInterface.makeAuthService(optionsWithDefaults),
93121
modelCustomizationService: new ModelCustomizationFromApiService(
94-
forestAdminServerInterface,
95-
optionsWithDefaults,
96-
),
97-
mcpServerConfigService: new McpServerConfigFromApiService(
98-
forestAdminServerInterface,
122+
serverInterface,
99123
optionsWithDefaults,
100124
),
125+
mcpServerConfigService: new McpServerConfigFromApiService(serverInterface, optionsWithDefaults),
101126
};
102127
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { ForestAdminServerInterface } from '../src/types';
2+
3+
import * as factories from './__factories__';
4+
import buildApplicationServices from '../src/build-application-services';
5+
import ForestHttpApi from '../src/permissions/forest-http-api';
6+
7+
jest.mock('../src/permissions/forest-http-api');
8+
9+
describe('buildApplicationServices', () => {
10+
let mockForestHttpApi: jest.Mocked<ForestAdminServerInterface>;
11+
12+
beforeEach(() => {
13+
jest.clearAllMocks();
14+
15+
mockForestHttpApi = {
16+
getRenderingPermissions: jest.fn(),
17+
getEnvironmentPermissions: jest.fn(),
18+
getUsers: jest.fn(),
19+
getModelCustomizations: jest.fn(),
20+
getMcpServerConfigs: jest.fn(),
21+
makeAuthService: jest.fn().mockReturnValue({
22+
init: jest.fn(),
23+
getUserInfo: jest.fn(),
24+
generateAuthorizationUrl: jest.fn(),
25+
generateTokens: jest.fn(),
26+
}),
27+
getSchema: jest.fn(),
28+
postSchema: jest.fn(),
29+
checkSchemaHash: jest.fn(),
30+
getIpWhitelistRules: jest.fn(),
31+
createActivityLog: jest.fn(),
32+
updateActivityLogStatus: jest.fn(),
33+
};
34+
35+
(ForestHttpApi as jest.Mock).mockImplementation(() => mockForestHttpApi);
36+
});
37+
38+
describe('withDefaultImplementation (fallback mechanism)', () => {
39+
it('should use custom implementation when method is provided', async () => {
40+
const customGetUsers = jest.fn().mockResolvedValue([{ id: 1, name: 'Custom User' }]);
41+
const partialInterface: Partial<ForestAdminServerInterface> = {
42+
getUsers: customGetUsers,
43+
};
44+
45+
const options = factories.forestAdminClientOptions.build();
46+
buildApplicationServices(partialInterface, options);
47+
48+
// The custom method should be called, not the default
49+
await customGetUsers();
50+
expect(customGetUsers).toHaveBeenCalled();
51+
expect(mockForestHttpApi.getUsers).not.toHaveBeenCalled();
52+
});
53+
54+
it('should fallback to ForestHttpApi when method is not provided', () => {
55+
const partialInterface: Partial<ForestAdminServerInterface> = {
56+
// Only provide getUsers, not makeAuthService
57+
getUsers: jest.fn(),
58+
};
59+
60+
const options = factories.forestAdminClientOptions.build();
61+
buildApplicationServices(partialInterface, options);
62+
63+
// makeAuthService should have been called from ForestHttpApi (the fallback)
64+
expect(mockForestHttpApi.makeAuthService).toHaveBeenCalledWith(
65+
expect.objectContaining({
66+
envSecret: options.envSecret,
67+
}),
68+
);
69+
});
70+
71+
it('should work with empty partial interface (all methods fallback)', () => {
72+
const partialInterface: Partial<ForestAdminServerInterface> = {};
73+
74+
const options = factories.forestAdminClientOptions.build();
75+
const result = buildApplicationServices(partialInterface, options);
76+
77+
// All services should be created successfully using ForestHttpApi fallbacks
78+
expect(result.auth).toBeDefined();
79+
expect(result.schema).toBeDefined();
80+
expect(result.activityLogs).toBeDefined();
81+
expect(mockForestHttpApi.makeAuthService).toHaveBeenCalled();
82+
});
83+
84+
it('should allow partial override of methods', async () => {
85+
const customCheckSchemaHash = jest.fn().mockResolvedValue({ sendSchema: false });
86+
const partialInterface: Partial<ForestAdminServerInterface> = {
87+
checkSchemaHash: customCheckSchemaHash,
88+
// postSchema not provided - should fallback
89+
};
90+
91+
const options = factories.forestAdminClientOptions.build();
92+
const { schema } = buildApplicationServices(partialInterface, options);
93+
94+
// Mock the postSchema to not actually call the server
95+
mockForestHttpApi.postSchema.mockResolvedValue(undefined);
96+
97+
await schema.postSchema({
98+
collections: [],
99+
meta: {
100+
liana: 'test',
101+
liana_version: '1.0.0',
102+
liana_features: null,
103+
stack: { engine: 'nodejs', engine_version: '16.0.0' },
104+
},
105+
});
106+
107+
// Custom checkSchemaHash should be used
108+
expect(customCheckSchemaHash).toHaveBeenCalled();
109+
// postSchema should fallback to ForestHttpApi (but not called since sendSchema: false)
110+
expect(mockForestHttpApi.postSchema).not.toHaveBeenCalled();
111+
});
112+
113+
it('should correctly bind this context for custom methods', async () => {
114+
const customState = { called: false };
115+
const customGetUsers = jest
116+
.fn()
117+
.mockImplementation(function setCalledFlag(this: typeof customState) {
118+
this.called = true;
119+
120+
return Promise.resolve([]);
121+
})
122+
.bind(customState);
123+
124+
const customInterface: Partial<ForestAdminServerInterface> = {
125+
getUsers: customGetUsers,
126+
};
127+
128+
const options = factories.forestAdminClientOptions.build();
129+
buildApplicationServices(customInterface, options);
130+
131+
if (customInterface.getUsers) {
132+
await customInterface.getUsers(options);
133+
}
134+
135+
expect(customState.called).toBe(true);
136+
});
137+
138+
it('should correctly bind this context for fallback methods', () => {
139+
const partialInterface: Partial<ForestAdminServerInterface> = {};
140+
141+
const options = factories.forestAdminClientOptions.build();
142+
buildApplicationServices(partialInterface, options);
143+
144+
// makeAuthService is called during buildApplicationServices
145+
// It should work correctly with proper this binding
146+
expect(mockForestHttpApi.makeAuthService).toHaveBeenCalled();
147+
});
148+
});
149+
});

0 commit comments

Comments
 (0)