diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index e39b29f08..e7858782f 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -4,6 +4,8 @@ import type { AgentOptions, AgentOptionsWithDefaults, AiConfiguration, + CustomRouterCallback, + CustomRouterOptions, HttpCallback, } from './types'; import type { @@ -17,8 +19,21 @@ import type { import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; -import { isModelSupportingTools } from '@forestadmin/ai-proxy'; +import { + AIBadRequestError, + AIError, + AINotFoundError, + Router as AiProxyRouter, + extractMcpOauthTokensFromHeaders, + injectOauthTokens, + isModelSupportingTools, +} from '@forestadmin/ai-proxy'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; +import { + BadRequestError, + NotFoundError, + UnprocessableError, +} from '@forestadmin/datasource-toolkit'; import bodyParser from '@koa/bodyparser'; import cors from '@koa/cors'; import Router from '@koa/router'; @@ -27,6 +42,7 @@ import stringify from 'json-stringify-pretty-compact'; import FrameworkMounter from './framework-mounter'; import makeRoutes from './routes'; +import CustomRoute from './routes/custom/custom-route'; import makeServices from './services'; import CustomizationService from './services/model-customizations/customization'; import SchemaGenerator from './utils/forest-schema/generator'; @@ -50,6 +66,12 @@ export default class Agent extends FrameworkMounter protected schemaGenerator: SchemaGenerator; protected aiConfigurations: AiConfiguration[] = []; + /** Custom router callbacks registered via addRouter() */ + private customRouterCallbacks: Array<{ + callback: CustomRouterCallback; + options?: CustomRouterOptions; + }> = []; + /** Whether MCP server should be mounted */ private mcpEnabled = false; @@ -259,13 +281,97 @@ export default class Agent extends FrameworkMounter 'Make sure to test Forest Admin AI features thoroughly to ensure compatibility.', ); + // Store for schema metadata this.aiConfigurations.push(configuration); + // Register AI proxy route via addRouter + this.addRouter((router, context) => { + const aiProxyRouter = new AiProxyRouter({ + aiConfigurations: [configuration], + logger: context.logger, + }); + + router.post('/_internal/ai-proxy/:route', async ctx => { + try { + const tokensByMcpServerName = extractMcpOauthTokensFromHeaders(ctx.request.headers); + const mcpConfigs = + await context.options.forestAdminClient.mcpServerConfigService.getConfiguration(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctx.response.body = await aiProxyRouter.route({ + route: ctx.params.route, + body: ctx.request.body, + query: ctx.query, + mcpConfigs: injectOauthTokens({ mcpConfigs, tokensByMcpServerName }), + } as any); + ctx.response.status = 200; + } catch (error) { + if (error instanceof AIError) { + context.logger('Error', `AI proxy error: ${(error as Error).message}`, error); + + if (error instanceof AIBadRequestError) throw new BadRequestError(error.message); + if (error instanceof AINotFoundError) throw new NotFoundError(error.message); + throw new UnprocessableError(error.message); + } + + throw error; + } + }); + }); + + return this; + } + + /** + * Add custom HTTP routes to the agent. + * + * Routes are authenticated by default (require Forest Admin authentication). + * Use `{ authenticated: false }` to create public routes. + * + * @param callback - Function that receives a Koa router and context to define routes + * @param options - Configuration options for the routes + * @param options.authenticated - Whether routes require authentication (default: true) + * @param options.prefix - URL prefix for all routes in this router (default: '') + * @returns The agent instance for chaining + * + * @example + * // Simple authenticated route + * agent.addRouter((router, context) => { + * router.get('/stats', async (ctx) => { + * const users = await context.dataSource + * .getCollection('users') + * .list(caller, new PaginatedFilter({}), new Projection('id')); + * ctx.body = { count: users.length }; + * }); + * }); + * + * @example + * // Public route (no authentication required) + * agent.addRouter( + * (router) => { + * router.get('/health', (ctx) => { + * ctx.body = { status: 'ok' }; + * }); + * }, + * { authenticated: false } + * ); + */ + addRouter(callback: CustomRouterCallback, options?: CustomRouterOptions): this { + this.customRouterCallbacks.push({ callback, options }); + return this; } protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) { - return makeRoutes(dataSource, this.options, services, this.aiConfigurations); + const routes = makeRoutes(dataSource, this.options, services); + + // Add custom routes (includes AI proxy route if addAi was called) + for (const { callback, options } of this.customRouterCallbacks) { + routes.push(new CustomRoute(services, this.options, dataSource, callback, options)); + } + + // Re-sort routes to ensure custom routes are in the correct order + return routes.sort((a, b) => a.type - b.type); } /** diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 49ade7e5d..2a4dfc767 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -8,7 +8,12 @@ export function createAgent(options: AgentOptions): } export { Agent }; -export { AgentOptions } from './types'; +export { + AgentOptions, + CustomRouterCallback, + CustomRouterContext, + CustomRouterOptions, +} from './types'; export * from '@forestadmin/datasource-customizer'; // export is necessary for the agent-generator package diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts deleted file mode 100644 index c36b308c8..000000000 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { ForestAdminHttpDriverServices } from '../../services'; -import type { AgentOptionsWithDefaults, AiConfiguration } from '../../types'; -import type KoaRouter from '@koa/router'; -import type { Context } from 'koa'; - -import { - AIBadRequestError, - AIError, - AINotConfiguredError, - AINotFoundError, - Router as AiProxyRouter, - extractMcpOauthTokensFromHeaders, - injectOauthTokens, -} from '@forestadmin/ai-proxy'; -import { - BadRequestError, - NotFoundError, - UnprocessableError, -} from '@forestadmin/datasource-toolkit'; - -import { HttpCode, RouteType } from '../../types'; -import BaseRoute from '../base-route'; - -export default class AiProxyRoute extends BaseRoute { - readonly type = RouteType.PrivateRoute; - private readonly aiProxyRouter: AiProxyRouter; - - constructor( - services: ForestAdminHttpDriverServices, - options: AgentOptionsWithDefaults, - aiConfigurations: AiConfiguration[], - ) { - super(services, options); - this.aiProxyRouter = new AiProxyRouter({ - aiConfigurations, - logger: this.options.logger, - }); - } - - setupRoutes(router: KoaRouter): void { - router.post('/_internal/ai-proxy/:route', this.handleAiProxy.bind(this)); - } - - private async handleAiProxy(context: Context): Promise { - try { - const tokensByMcpServerName = extractMcpOauthTokensFromHeaders(context.request.headers); - - const mcpConfigs = - await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); - - context.response.body = await this.aiProxyRouter.route({ - route: context.params.route, - body: context.request.body, - query: context.query, - mcpConfigs: injectOauthTokens({ mcpConfigs, tokensByMcpServerName }), - }); - context.response.status = HttpCode.Ok; - } catch (error) { - if (error instanceof AIError) { - this.options.logger('Error', `AI proxy error: ${error.message}`, error); - - if (error instanceof AINotConfiguredError) { - throw new UnprocessableError('AI is not configured. Please call addAi() on your agent.'); - } - - if (error instanceof AIBadRequestError) throw new BadRequestError(error.message); - if (error instanceof AINotFoundError) throw new NotFoundError(error.message); - throw new UnprocessableError(error.message); - } - - throw error; - } - } -} diff --git a/packages/agent/src/routes/custom/custom-route.ts b/packages/agent/src/routes/custom/custom-route.ts new file mode 100644 index 000000000..4b6cbc277 --- /dev/null +++ b/packages/agent/src/routes/custom/custom-route.ts @@ -0,0 +1,50 @@ +import type { ForestAdminHttpDriverServices } from '../../services'; +import type { + AgentOptionsWithDefaults, + CustomRouterCallback, + CustomRouterOptions, +} from '../../types'; +import type { DataSource } from '@forestadmin/datasource-toolkit'; +import type Router from '@koa/router'; + +import KoaRouter from '@koa/router'; + +import { RouteType } from '../../types'; +import BaseRoute from '../base-route'; +import CustomRouterContextImpl from './custom-router-context'; + +export default class CustomRoute extends BaseRoute { + private readonly dataSource: DataSource; + private readonly callback: CustomRouterCallback; + private readonly customOptions: Required; + + get type(): RouteType { + return this.customOptions.authenticated ? RouteType.CustomRoute : RouteType.PublicCustomRoute; + } + + constructor( + services: ForestAdminHttpDriverServices, + options: AgentOptionsWithDefaults, + dataSource: DataSource, + callback: CustomRouterCallback, + customOptions?: CustomRouterOptions, + ) { + super(services, options); + this.dataSource = dataSource; + this.callback = callback; + this.customOptions = { + authenticated: customOptions?.authenticated ?? true, + prefix: customOptions?.prefix ?? '', + }; + } + + setupRoutes(router: Router): void { + const context = new CustomRouterContextImpl(this.dataSource, this.services, this.options); + const customRouter = new KoaRouter({ prefix: this.customOptions.prefix || undefined }); + + this.callback(customRouter, context); + + router.use(customRouter.routes()); + router.use(customRouter.allowedMethods()); + } +} diff --git a/packages/agent/src/routes/custom/custom-router-context.ts b/packages/agent/src/routes/custom/custom-router-context.ts new file mode 100644 index 000000000..7ebf3dbee --- /dev/null +++ b/packages/agent/src/routes/custom/custom-router-context.ts @@ -0,0 +1,23 @@ +import type { ForestAdminHttpDriverServices } from '../../services'; +import type { AgentOptionsWithDefaults, CustomRouterContext } from '../../types'; +import type { DataSource, Logger } from '@forestadmin/datasource-toolkit'; + +export default class CustomRouterContextImpl implements CustomRouterContext { + readonly dataSource: DataSource; + readonly services: ForestAdminHttpDriverServices; + readonly options: AgentOptionsWithDefaults; + + constructor( + dataSource: DataSource, + services: ForestAdminHttpDriverServices, + options: AgentOptionsWithDefaults, + ) { + this.dataSource = dataSource; + this.services = services; + this.options = options; + } + + get logger(): Logger { + return this.options.logger; + } +} diff --git a/packages/agent/src/routes/index.ts b/packages/agent/src/routes/index.ts index cf9d4ab6d..4b336cdb5 100644 --- a/packages/agent/src/routes/index.ts +++ b/packages/agent/src/routes/index.ts @@ -1,5 +1,5 @@ import type { ForestAdminHttpDriverServices as Services } from '../services'; -import type { AiConfiguration, AgentOptionsWithDefaults as Options } from '../types'; +import type { AgentOptionsWithDefaults as Options } from '../types'; import type BaseRoute from './base-route'; import type { DataSource } from '@forestadmin/datasource-toolkit'; @@ -14,7 +14,6 @@ import Get from './access/get'; import List from './access/list'; import ListRelated from './access/list-related'; import NativeQueryDatasource from './access/native-query-datasource'; -import AiProxyRoute from './ai/ai-proxy'; import Capabilities from './capabilities'; import ActionRoute from './modification/action/action'; import AssociateRelated from './modification/associate-related'; @@ -165,21 +164,10 @@ function getActionRoutes( return routes; } -function getAiRoutes( - options: Options, - services: Services, - aiConfigurations: AiConfiguration[], -): BaseRoute[] { - if (aiConfigurations.length === 0) return []; - - return [new AiProxyRoute(services, options, aiConfigurations)]; -} - export default function makeRoutes( dataSource: DataSource, options: Options, services: Services, - aiConfigurations: AiConfiguration[] = [], ): BaseRoute[] { const routes = [ ...getRootRoutes(options, services), @@ -189,7 +177,6 @@ export default function makeRoutes( ...getApiChartRoutes(dataSource, options, services), ...getRelatedRoutes(dataSource, options, services), ...getActionRoutes(dataSource, options, services), - ...getAiRoutes(options, services, aiConfigurations), ]; // Ensure routes and middlewares are loaded in the right order. diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index ec3c823b2..910fc6fa8 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,6 +1,8 @@ +import type { ForestAdminHttpDriverServices } from './services'; import type { AiConfiguration, AiProvider } from '@forestadmin/ai-proxy'; -import type { CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit'; +import type { CompositeId, DataSource, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit'; import type { ForestAdminClient } from '@forestadmin/forestadmin-client'; +import type Router from '@koa/router'; import type { IncomingMessage, ServerResponse } from 'http'; export type { AiConfiguration, AiProvider }; @@ -68,11 +70,43 @@ export enum RouteType { LoggerHandler = 0, ErrorHandler = 1, PublicRoute = 2, + PublicCustomRoute = 2.5, Authentication = 3, PrivateRoute = 4, + CustomRoute = 5, } export type SelectionIds = { areExcluded: boolean; ids: CompositeId[]; }; + +/** + * Context provided to custom router callbacks. + * Gives access to the dataSource, services, options, and logger. + */ +export interface CustomRouterContext { + readonly dataSource: DataSource; + readonly services: ForestAdminHttpDriverServices; + readonly options: AgentOptionsWithDefaults; + readonly logger: Logger; +} + +/** + * Callback function for custom routes. + * Receives a Koa router and context to define custom HTTP endpoints. + */ +export type CustomRouterCallback = ( + router: Router, + context: CustomRouterContext, +) => void | Promise; + +/** + * Options for custom router configuration. + */ +export interface CustomRouterOptions { + /** Whether routes require authentication. Default: true */ + authenticated?: boolean; + /** URL prefix for all routes in this router. Default: '' */ + prefix?: string; +} diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index dadc9d566..723584cc7 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -489,4 +489,50 @@ describe('Agent', () => { ); }); }); + + describe('addRouter', () => { + const options = factories.forestAdminHttpDriverOptions.build({ + isProduction: false, + forestAdminClient: factories.forestAdminClient.build({ postSchema: mockPostSchema }), + }); + + test('should return agent instance for chaining', () => { + const agent = new Agent(options); + const result = agent.addRouter(() => {}); + + expect(result).toBe(agent); + }); + + test('should allow multiple addRouter calls', () => { + const agent = new Agent(options); + + agent.addRouter(() => {}); + agent.addRouter(() => {}); + agent.addRouter(() => {}); + + // No error should be thrown + expect(agent).toBeTruthy(); + }); + + test('should include custom routes when getRoutes is called', async () => { + const callback = jest.fn(); + const agent = new Agent(options); + + agent.addRouter(callback); + await agent.start(); + + // The custom route should have been set up (callback called) + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('should include custom routes with options', async () => { + const callback = jest.fn(); + const agent = new Agent(options); + + agent.addRouter(callback, { authenticated: false, prefix: '/api' }); + await agent.start(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts deleted file mode 100644 index fd4eb8379..000000000 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { - AIBadRequestError, - AIError, - AINotConfiguredError, - AINotFoundError, - AIToolNotFoundError, - AIUnprocessableError, -} from '@forestadmin/ai-proxy'; -import { - BadRequestError, - NotFoundError, - UnprocessableError, -} from '@forestadmin/datasource-toolkit'; -import { createMockContext } from '@shopify/jest-koa-mocks'; - -import AiProxyRoute from '../../../src/routes/ai/ai-proxy'; -import { HttpCode, RouteType } from '../../../src/types'; -import * as factories from '../../__factories__'; - -const mockRoute = jest.fn(); - -jest.mock('@forestadmin/ai-proxy', () => { - const actual = jest.requireActual('@forestadmin/ai-proxy'); - - return { - ...actual, - Router: jest.fn().mockImplementation(() => ({ - route: mockRoute, - })), - }; -}); - -describe('AiProxyRoute', () => { - const options = factories.forestAdminHttpDriverOptions.build(); - const services = factories.forestAdminHttpDriverServices.build(); - const router = factories.router.mockAllMethods().build(); - const aiConfigurations = [ - { - name: 'gpt4', - provider: 'openai' as const, - apiKey: 'test-key', - model: 'gpt-4o', - }, - ]; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('constructor', () => { - test('should have RouteType.PrivateRoute', () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - - expect(route.type).toBe(RouteType.PrivateRoute); - }); - }); - - describe('setupRoutes', () => { - test('should register POST route at /_internal/ai-proxy/:route', () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - route.setupRoutes(router); - - expect(router.post).toHaveBeenCalledWith('/_internal/ai-proxy/:route', expect.any(Function)); - }); - }); - - describe('handleAiProxy', () => { - test('should return 200 with response body on successful request', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - const expectedResponse = { result: 'success' }; - mockRoute.mockResolvedValueOnce(expectedResponse); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: { messages: [] }, - }); - - await (route as any).handleAiProxy(context); - - expect(context.response.status).toBe(HttpCode.Ok); - expect(context.response.body).toEqual(expectedResponse); - }); - - test('should pass route, body, query, mcpConfigs and tokensByMcpServerName to router', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockResolvedValueOnce({}); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - }, - requestBody: { messages: [{ role: 'user', content: 'Hello' }] }, - }); - // Set query directly on context as createMockContext doesn't handle it properly - context.query = { 'ai-name': 'gpt4' }; - - await (route as any).handleAiProxy(context); - - expect(mockRoute).toHaveBeenCalledWith({ - route: 'ai-query', - body: { messages: [{ role: 'user', content: 'Hello' }] }, - query: { 'ai-name': 'gpt4' }, - mcpConfigs: undefined, // mcpServerConfigService.getConfiguration returns undefined in test - }); - }); - - test('should inject oauth tokens into mcpConfigs when header is provided', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockResolvedValueOnce({}); - - const mcpConfigs = { - configs: { - server1: { type: 'http' as const, url: 'https://server1.com' }, - server2: { type: 'http' as const, url: 'https://server2.com' }, - }, - }; - jest - .spyOn(options.forestAdminClient.mcpServerConfigService, 'getConfiguration') - .mockResolvedValueOnce(mcpConfigs); - - const tokens = { server1: 'Bearer token1', server2: 'Bearer token2' }; - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - }, - requestBody: { messages: [] }, - headers: { 'x-mcp-oauth-tokens': JSON.stringify(tokens) }, - }); - context.query = {}; - - await (route as any).handleAiProxy(context); - - expect(mockRoute).toHaveBeenCalledWith( - expect.objectContaining({ - mcpConfigs: { - configs: { - server1: { - type: 'http', - url: 'https://server1.com', - headers: { Authorization: 'Bearer token1' }, - }, - server2: { - type: 'http', - url: 'https://server2.com', - headers: { Authorization: 'Bearer token2' }, - }, - }, - }, - }), - ); - }); - - test('should throw BadRequestError when x-mcp-oauth-tokens header contains invalid JSON', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - }, - requestBody: { messages: [] }, - headers: { 'x-mcp-oauth-tokens': '{ invalid json }' }, - }); - context.query = {}; - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(BadRequestError); - await expect((route as any).handleAiProxy(context)).rejects.toThrow( - 'Invalid JSON in x-mcp-oauth-tokens header', - ); - }); - - describe('error handling', () => { - test('should convert AINotConfiguredError to UnprocessableError with agent-specific message', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AINotConfiguredError()); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toMatchObject({ - name: 'UnprocessableError', - message: 'AI is not configured. Please call addAi() on your agent.', - }); - }); - - test('should convert AIToolNotFoundError to NotFoundError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AIToolNotFoundError('tool-name')); - - const context = createMockContext({ - customProperties: { - params: { route: 'invoke-remote-tool' }, - query: { 'tool-name': 'unknown-tool' }, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(NotFoundError); - }); - - test('should convert AINotFoundError to NotFoundError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AINotFoundError('Resource not found')); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(NotFoundError); - }); - - test('should convert AIBadRequestError to BadRequestError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AIBadRequestError('Invalid input')); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(BadRequestError); - }); - - test('should convert AIUnprocessableError to UnprocessableError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AIUnprocessableError('Invalid input')); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError); - }); - - test('should convert generic AIError to UnprocessableError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AIError('Generic AI error')); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError); - }); - - test('should re-throw unknown errors unchanged', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - const unknownError = new Error('Unknown error'); - mockRoute.mockRejectedValueOnce(unknownError); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - }, - requestBody: {}, - }); - context.query = {}; - - const promise = (route as any).handleAiProxy(context); - - await expect(promise).rejects.toBe(unknownError); - expect(unknownError).not.toBeInstanceOf(UnprocessableError); - }); - }); - }); -}); diff --git a/packages/agent/test/routes/custom/custom-route.test.ts b/packages/agent/test/routes/custom/custom-route.test.ts new file mode 100644 index 000000000..85eeba560 --- /dev/null +++ b/packages/agent/test/routes/custom/custom-route.test.ts @@ -0,0 +1,185 @@ +import type { + CustomRouterCallback, + CustomRouterContext, + CustomRouterOptions, +} from '../../../src/types'; + +import Router from '@koa/router'; + +import CustomRoute from '../../../src/routes/custom/custom-route'; +import { RouteType } from '../../../src/types'; +import * as factories from '../../__factories__'; + +describe('CustomRoute', () => { + const services = factories.forestAdminHttpDriverServices.build(); + const options = factories.forestAdminHttpDriverOptions.build(); + const dataSource = factories.dataSource.build(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('type', () => { + test('should return RouteType.CustomRoute when authenticated is true (default)', () => { + const callback: CustomRouterCallback = jest.fn(); + const customRoute = new CustomRoute(services, options, dataSource, callback); + + expect(customRoute.type).toBe(RouteType.CustomRoute); + }); + + test('should return RouteType.CustomRoute when authenticated is explicitly true', () => { + const callback: CustomRouterCallback = jest.fn(); + const customRoute = new CustomRoute(services, options, dataSource, callback, { + authenticated: true, + }); + + expect(customRoute.type).toBe(RouteType.CustomRoute); + }); + + test('should return RouteType.PublicCustomRoute when authenticated is false', () => { + const callback: CustomRouterCallback = jest.fn(); + const customRoute = new CustomRoute(services, options, dataSource, callback, { + authenticated: false, + }); + + expect(customRoute.type).toBe(RouteType.PublicCustomRoute); + }); + }); + + describe('setupRoutes', () => { + test('should call the callback with a router and context', () => { + const callback = jest.fn(); + const customRoute = new CustomRoute(services, options, dataSource, callback); + const mainRouter = new Router(); + jest.spyOn(mainRouter, 'use'); + + customRoute.setupRoutes(mainRouter); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(expect.any(Router), expect.any(Object)); + }); + + test('should provide context with dataSource', () => { + let receivedContext: CustomRouterContext | undefined; + + const callback: CustomRouterCallback = (_, context) => { + receivedContext = context; + }; + + const customRoute = new CustomRoute(services, options, dataSource, callback); + const mainRouter = new Router(); + + customRoute.setupRoutes(mainRouter); + + expect(receivedContext?.dataSource).toBe(dataSource); + }); + + test('should provide context with services', () => { + let receivedContext: CustomRouterContext | undefined; + + const callback: CustomRouterCallback = (_, context) => { + receivedContext = context; + }; + + const customRoute = new CustomRoute(services, options, dataSource, callback); + const mainRouter = new Router(); + + customRoute.setupRoutes(mainRouter); + + expect(receivedContext?.services).toBe(services); + }); + + test('should provide context with options', () => { + let receivedContext: CustomRouterContext | undefined; + + const callback: CustomRouterCallback = (_, context) => { + receivedContext = context; + }; + + const customRoute = new CustomRoute(services, options, dataSource, callback); + const mainRouter = new Router(); + + customRoute.setupRoutes(mainRouter); + + expect(receivedContext?.options).toBe(options); + }); + + test('should provide context with logger from options', () => { + let receivedContext: CustomRouterContext | undefined; + + const callback: CustomRouterCallback = (_, context) => { + receivedContext = context; + }; + + const customRoute = new CustomRoute(services, options, dataSource, callback); + const mainRouter = new Router(); + + customRoute.setupRoutes(mainRouter); + + expect(receivedContext?.logger).toBe(options.logger); + }); + + test('should mount the custom router on the main router', () => { + const callback: CustomRouterCallback = router => { + router.get('/test', ctx => { + ctx.body = 'ok'; + }); + }; + + const customRoute = new CustomRoute(services, options, dataSource, callback); + const mainRouter = new Router(); + jest.spyOn(mainRouter, 'use'); + + customRoute.setupRoutes(mainRouter); + + expect(mainRouter.use).toHaveBeenCalled(); + }); + + test('should apply prefix to the custom router when specified', () => { + let receivedRouter: Router | undefined; + + const callback: CustomRouterCallback = router => { + receivedRouter = router; + }; + + const customOptions: CustomRouterOptions = { prefix: '/custom' }; + const customRoute = new CustomRoute(services, options, dataSource, callback, customOptions); + const mainRouter = new Router(); + + customRoute.setupRoutes(mainRouter); + + expect(receivedRouter?.opts?.prefix).toBe('/custom'); + }); + + test('should not apply prefix when not specified', () => { + let receivedRouter: Router | undefined; + + const callback: CustomRouterCallback = router => { + receivedRouter = router; + }; + + const customRoute = new CustomRoute(services, options, dataSource, callback); + const mainRouter = new Router(); + + customRoute.setupRoutes(mainRouter); + + expect(receivedRouter?.opts?.prefix).toBeUndefined(); + }); + + test('should not apply prefix when empty string', () => { + let receivedRouter: Router | undefined; + + const callback: CustomRouterCallback = router => { + receivedRouter = router; + }; + + const customOptions: CustomRouterOptions = { prefix: '' }; + const customRoute = new CustomRoute(services, options, dataSource, callback, customOptions); + const mainRouter = new Router(); + + customRoute.setupRoutes(mainRouter); + + expect(receivedRouter?.opts?.prefix).toBeUndefined(); + }); + }); +}); diff --git a/packages/agent/test/routes/index.test.ts b/packages/agent/test/routes/index.test.ts index d2dc94aac..b52174213 100644 --- a/packages/agent/test/routes/index.test.ts +++ b/packages/agent/test/routes/index.test.ts @@ -17,7 +17,6 @@ import Get from '../../src/routes/access/get'; import List from '../../src/routes/access/list'; import ListRelated from '../../src/routes/access/list-related'; import DataSourceNativeQueryRoute from '../../src/routes/access/native-query-datasource'; -import AiProxyRoute from '../../src/routes/ai/ai-proxy'; import Capabilities from '../../src/routes/capabilities'; import AssociateRelated from '../../src/routes/modification/associate-related'; import Create from '../../src/routes/modification/create'; @@ -299,80 +298,5 @@ describe('Route index', () => { expect(lqRoute).toBeTruthy(); }); }); - - describe('with AI configurations', () => { - test('should not include AI routes when aiConfigurations is empty', () => { - const dataSource = factories.dataSource.buildWithCollections([ - factories.collection.build({ name: 'books' }), - ]); - - const routes = makeRoutes( - dataSource, - factories.forestAdminHttpDriverOptions.build(), - factories.forestAdminHttpDriverServices.build(), - [], - ); - - const aiRoute = routes.find(route => route instanceof AiProxyRoute); - expect(aiRoute).toBeUndefined(); - }); - - test('should include AiProxyRoute when AI configurations are provided', () => { - const dataSource = factories.dataSource.buildWithCollections([ - factories.collection.build({ name: 'books' }), - ]); - - const aiConfigurations = [ - { - name: 'gpt4', - provider: 'openai' as const, - apiKey: 'test-key', - model: 'gpt-4o', - }, - ]; - - const routes = makeRoutes( - dataSource, - factories.forestAdminHttpDriverOptions.build(), - factories.forestAdminHttpDriverServices.build(), - aiConfigurations, - ); - - const aiRoute = routes.find(route => route instanceof AiProxyRoute); - expect(aiRoute).toBeTruthy(); - expect(aiRoute).toBeInstanceOf(AiProxyRoute); - }); - - test('should include only one AiProxyRoute even with multiple AI configurations', () => { - const dataSource = factories.dataSource.buildWithCollections([ - factories.collection.build({ name: 'books' }), - ]); - - const aiConfigurations = [ - { - name: 'gpt4', - provider: 'openai' as const, - apiKey: 'test-key', - model: 'gpt-4o', - }, - { - name: 'gpt3', - provider: 'openai' as const, - apiKey: 'test-key-2', - model: 'gpt-3.5-turbo', - }, - ]; - - const routes = makeRoutes( - dataSource, - factories.forestAdminHttpDriverOptions.build(), - factories.forestAdminHttpDriverServices.build(), - aiConfigurations, - ); - - const aiRoutes = routes.filter(route => route instanceof AiProxyRoute); - expect(aiRoutes).toHaveLength(1); - }); - }); }); });