diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index 842f36e434..6a158b2d1b 100644 --- a/packages/agent-bff/src/action/action-routes-middleware.ts +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -4,6 +4,7 @@ import type { AgentActionClient, AgentActionClientOptions, } from './agent-action-client'; +import type { AgentTransport } from '../agent/agent-transport'; import type { Logger } from '../ports/logger-port'; import type ReadModelStore from '../read-model/read-model-store'; import type { Context, Middleware } from 'koa'; @@ -76,8 +77,7 @@ function describePayloadShape(raw: unknown): string { export interface ActionRoutesMiddlewareOptions { store: ReadModelStore; - agentUrl: string; - timeoutMs?: number; + transport: AgentTransport; logger: Logger; createClient?: (options: AgentActionClientOptions) => AgentActionClient; } @@ -159,8 +159,7 @@ async function handleExecute({ export default function createActionRoutesMiddleware({ store, - agentUrl, - timeoutMs, + transport, logger, createClient = defaultCreateAgentActionClient, }: ActionRoutesMiddlewareOptions): Middleware { @@ -193,10 +192,9 @@ export default function createActionRoutesMiddleware({ const values = parseValues(body.values); const client = createClient({ - agentUrl, + transport, token, actionEndpoints: readModel.getActionEndpoints(), - timeoutMs, }); const action = await callAgent( diff --git a/packages/agent-bff/src/action/agent-action-client.ts b/packages/agent-bff/src/action/agent-action-client.ts index af2be2c3df..b3a9664cb6 100644 --- a/packages/agent-bff/src/action/agent-action-client.ts +++ b/packages/agent-bff/src/action/agent-action-client.ts @@ -1,10 +1,9 @@ +import type { AgentTransport } from '../agent/agent-transport'; import type { ActionEndpointsByCollection } from '@forestadmin/agent-client'; import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client'; import { createRemoteAgentClient } from '@forestadmin/agent-client'; -import createAgentHttpRequester from '../agent/create-agent-http-requester'; - export interface ActionFormField { getName(): string; /** A list type is the array the agent sent, `['String']`, not `'StringList'`. */ @@ -41,10 +40,9 @@ export interface AgentActionClient { } export interface AgentActionClientOptions { - agentUrl: string; + transport: AgentTransport; token: string; actionEndpoints: ActionEndpointsByCollection; - timeoutMs?: number; } // The raw layout must be read AFTER tryToSetFields: a change hook rebuilds fields+layout in place. @@ -64,16 +62,15 @@ export function extractRawLayout(action: ActionForm): ForestServerActionFormLayo * than reimplementing it. The endpoint map from the read-model is the action allow-list. */ export default function createAgentActionClient({ - agentUrl, + transport, token, actionEndpoints, - timeoutMs, }: AgentActionClientOptions): AgentActionClient { const client = createRemoteAgentClient({ - url: agentUrl, + url: transport.url, token, actionEndpoints, - httpRequester: createAgentHttpRequester(token, agentUrl, timeoutMs), + httpRequester: transport.createRequester(token), }); return { diff --git a/packages/agent-bff/src/agent/agent-transport.ts b/packages/agent-bff/src/agent/agent-transport.ts new file mode 100644 index 0000000000..3ffc399d7a --- /dev/null +++ b/packages/agent-bff/src/agent/agent-transport.ts @@ -0,0 +1,28 @@ +import type { HttpRequester } from '@forestadmin/agent-client'; + +import createAgentHttpRequester from './create-agent-http-requester'; + +/** + * How the BFF reaches the agent. Everything that talks to the agent takes one of these instead of a + * URL, so the same routes serve a remote agent over HTTP and an agent embedded in the same process. + */ +export interface AgentTransport { + /** + * Base url `createRemoteAgentClient` still requires. Over HTTP it is the agent; in-process it is a + * sentinel that never reaches the network, since the requester answers before any socket is opened. + */ + url: string; + createRequester(token: string): HttpRequester; +} + +export interface HttpTransportOptions { + agentUrl: string; + timeoutMs?: number; +} + +export function createHttpTransport({ agentUrl, timeoutMs }: HttpTransportOptions): AgentTransport { + return { + url: agentUrl, + createRequester: token => createAgentHttpRequester(token, agentUrl, timeoutMs), + }; +} diff --git a/packages/agent-bff/src/agent/in-process-transport.ts b/packages/agent-bff/src/agent/in-process-transport.ts new file mode 100644 index 0000000000..a75bff2cb8 --- /dev/null +++ b/packages/agent-bff/src/agent/in-process-transport.ts @@ -0,0 +1,147 @@ +import type { AgentTransport } from './agent-transport'; + +import { HttpRequester } from '@forestadmin/agent-client'; + +import { streamingUnsupported } from '../http/bff-local-errors'; + +/** A sentinel that never reaches the network: `query` answers before any socket is opened. */ +const IN_PROCESS_URL = 'http://in-process.agent'; + +export interface AgentDispatchRequest { + method: 'get' | 'post' | 'put' | 'delete'; + path: string; + headers: Record; + query?: Record; + payload?: Record; + timeoutMs?: number; +} + +export interface AgentDispatchResponse { + status: number; + body: unknown; + text?: string; +} + +/** + * Dispatches a request into an agent living in the same process, without a socket. Declared + * structurally so neither package has to depend on the other for six primitive fields. + */ +export interface AgentDispatcher { + /** + * Bounding the call is the implementor's duty: there is no socket to abort, so nothing here can + * cut a hung agent handler loose. When `timeoutMs` is absent the deployment configured none — + * apply the 10s ceiling `HttpRequester.query` always has rather than waiting forever. A dispatch + * that fails, times out included, must reject. + */ + request(request: AgentDispatchRequest): Promise; +} + +/** + * Reaches the agent through the dispatcher instead of the network, reusing `HttpRequester`'s parse + * helpers so results and error shape stay identical to the HTTP path. + */ +class InProcessRequester extends HttpRequester { + constructor( + private readonly bearerToken: string, + private readonly dispatcher: AgentDispatcher, + private readonly defaultTimeoutMs?: number, + ) { + super(bearerToken, { url: IN_PROCESS_URL }); + } + + // No socket to stream from, and nothing in the BFF streams today. A typed 501 rather than a raw + // Error, so the day a route does reach here the client is told what is missing instead of being + // sent looking for a network the request never crossed. + override async stream(): Promise { + throw streamingUnsupported('Streaming is not supported over the in-process transport'); + } + + override async query({ + method, + path, + body, + query, + maxTimeAllowed, + contentType, + skipDeserialization, + }: { + method: 'get' | 'post' | 'put' | 'delete'; + path: string; + body?: Record; + query?: Record; + maxTimeAllowed?: number; + contentType?: 'application/json' | 'text/csv'; + skipDeserialization?: boolean; + }): Promise { + const target = InProcessRequester.toDispatchTarget(path); + + const { + status, + body: responseBody, + text, + } = await this.dispatch({ + method, + path: target.path, + headers: { + Authorization: `Bearer ${this.bearerToken}`, + 'Content-Type': contentType ?? 'application/json', + Accept: contentType ?? 'application/json', + }, + query: { timezone: 'Europe/Paris', ...target.query, ...query }, + payload: body, + timeoutMs: maxTimeAllowed ?? this.defaultTimeoutMs, + }); + + if (status >= 400) throw this.buildError(status, responseBody, text); + + return this.deserialize(responseBody, text, skipDeserialization); + } + + /** + * A rejection here is the agent throwing or the dispatch itself failing — never a network hop, + * since there is none. Rethrown with the shape an agent 5xx has, so `mapAgentError` logs the real + * cause and answers `agent_unavailable` instead of sending whoever debugs it to look for a socket. + */ + private async dispatch(request: AgentDispatchRequest): Promise { + try { + return await this.dispatcher.request(request); + } catch (error) { + const detail = error instanceof Error ? error.stack ?? error.message : String(error); + + throw this.buildError(500, { errors: [{ detail }] }); + } + } + + /** + * Callers hand over raw segments — a record id, a collection name — because `buildUrl` escapes the + * whole path on the HTTP side, so the escaping has to happen here too. Escaping alone is not + * enough: `escapeUrlSlug` prefixes `+?*` with a backslash, which the WHATWG parser `buildUrl` + * feeds reads as a path separator, and that parse also resolves `..` and splits a trailing query + * off. Running it here is what keeps a given id addressing the same record over both transports — + * both remain wrong for those three characters, which is PRD-1124's to fix on both at once. + */ + private static toDispatchTarget(path: string): { + path: string; + query: Record; + } { + const normalized = path.startsWith('/') ? path : `/${path}`; + const url = new URL(`${IN_PROCESS_URL}${HttpRequester.escapeUrlSlug(normalized)}`); + + return { path: url.pathname, query: Object.fromEntries(url.searchParams) }; + } +} + +export interface InProcessTransportOptions { + dispatcher: AgentDispatcher; + timeoutMs?: number; +} + +export default function createInProcessTransport({ + dispatcher, + timeoutMs, +}: InProcessTransportOptions): AgentTransport { + return { + url: IN_PROCESS_URL, + createRequester: token => new InProcessRequester(token, dispatcher, timeoutMs), + }; +} diff --git a/packages/agent-bff/src/build-bff.ts b/packages/agent-bff/src/build-bff.ts index cd2274c26f..5181a39826 100644 --- a/packages/agent-bff/src/build-bff.ts +++ b/packages/agent-bff/src/build-bff.ts @@ -14,6 +14,7 @@ import Koa from 'koa'; import createActionRoutesMiddleware from './action/action-routes-middleware'; import createConsoleLogger from './adapters/console-logger'; import createAgentStubMiddleware from './agent/agent-stub'; +import { createHttpTransport } from './agent/agent-transport'; import AiProxyClient from './ai/ai-proxy-client'; import createAiRoutesMiddleware, { AI_QUERY_ROUTE } from './ai/ai-routes-middleware'; import createApiKeyAuthenticator from './api-key/api-key-authenticator'; @@ -260,8 +261,10 @@ function toUnfoldSource( return { store: bundle.store, - agentUrl: config.agentUrl, - timeoutMs: config.agentTimeoutMs, + transport: createHttpTransport({ + agentUrl: config.agentUrl, + timeoutMs: config.agentTimeoutMs, + }), logger, }; } @@ -312,10 +315,12 @@ function buildAgentRouteMiddlewares( return [permissionsMiddleware, createAgentStubMiddleware()]; } + const transport = createHttpTransport({ agentUrl, timeoutMs }); + return [ permissionsMiddleware, - createDataRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), - createActionRoutesMiddleware({ store, agentUrl, timeoutMs, logger }), + createDataRoutesMiddleware({ store, transport, logger }), + createActionRoutesMiddleware({ store, transport, logger }), ]; } diff --git a/packages/agent-bff/src/data/agent-data-client.ts b/packages/agent-bff/src/data/agent-data-client.ts index 233dc215f1..2626a06369 100644 --- a/packages/agent-bff/src/data/agent-data-client.ts +++ b/packages/agent-bff/src/data/agent-data-client.ts @@ -1,9 +1,8 @@ -import createAgentHttpRequester from '../agent/create-agent-http-requester'; +import type { AgentTransport } from '../agent/agent-transport'; export interface AgentDataClientOptions { - agentUrl: string; + transport: AgentTransport; token: string; - timeoutMs?: number; } export interface AgentDataClient { @@ -29,11 +28,10 @@ export interface AgentDataClient { * endpoint's raw payload, which `collection.count()` coerces through `Number()` and loses. */ export default function createAgentDataClient({ - agentUrl, + transport, token, - timeoutMs, }: AgentDataClientOptions): AgentDataClient { - const requester = createAgentHttpRequester(token, agentUrl, timeoutMs); + const requester = transport.createRequester(token); // Segments are passed raw: HttpRequester.buildUrl already runs the whole path through // escapeUrlSlug/encodeURI, so pre-encoding here would double-encode (`|` -> `%257C`). diff --git a/packages/agent-bff/src/data/data-routes-middleware.ts b/packages/agent-bff/src/data/data-routes-middleware.ts index 1c9e633af6..e2235f81ab 100644 --- a/packages/agent-bff/src/data/data-routes-middleware.ts +++ b/packages/agent-bff/src/data/data-routes-middleware.ts @@ -5,6 +5,7 @@ import type { RelationCountRequestBody, RelationListRequestBody, } from './agent-query'; +import type { AgentTransport } from '../agent/agent-transport'; import type { Logger } from '../ports/logger-port'; import type { CapabilitiesResult } from '../read-model/capabilities-cache'; import type ReadModel from '../read-model/read-model'; @@ -43,8 +44,7 @@ const RELATION_ROUTE = /^\/agent\/v1\/([^/]+)\/relations\/([^/]+)\/(list|count)$ export interface DataRoutesMiddlewareOptions { store: ReadModelStore; - agentUrl: string; - timeoutMs?: number; + transport: AgentTransport; logger: Logger; createClient?: (options: AgentDataClientOptions) => AgentDataClient; } @@ -53,8 +53,7 @@ interface RequestHandlerDeps { collection: string; client: AgentDataClient; store: ReadModelStore; - agentUrl: string; - timeoutMs?: number; + transport: AgentTransport; token: string; timezone: string; logger: Logger; @@ -86,11 +85,7 @@ function resolveCapabilities( () => deps.store.getCapabilities( collection, - createAgentCapabilitiesFetcher({ - agentUrl: deps.agentUrl, - token: deps.token, - timeoutMs: deps.timeoutMs, - }), + createAgentCapabilitiesFetcher({ transport: deps.transport, token: deps.token }), ), deps.logger, ); @@ -306,8 +301,7 @@ async function handleRelation( export default function createDataRoutesMiddleware({ store, - agentUrl, - timeoutMs, + transport, logger, createClient = defaultCreateAgentDataClient, }: DataRoutesMiddlewareOptions): Middleware { @@ -336,10 +330,9 @@ export default function createDataRoutesMiddleware({ const deps: RequestHandlerDeps = { collection, - client: createClient({ agentUrl, token, timeoutMs }), + client: createClient({ transport, token }), store, - agentUrl, - timeoutMs, + transport, token, timezone: ctx.state.timezone as string, logger, diff --git a/packages/agent-bff/src/http/bff-local-errors.ts b/packages/agent-bff/src/http/bff-local-errors.ts index 5dafd88646..2626499842 100644 --- a/packages/agent-bff/src/http/bff-local-errors.ts +++ b/packages/agent-bff/src/http/bff-local-errors.ts @@ -40,6 +40,12 @@ export function unsupportedActionResult(message = 'Unsupported action result'): return new BffHttpError(501, 'unsupported_action_result', message); } +export function streamingUnsupported( + message = 'Streaming is not supported over this transport', +): BffHttpError { + return new BffHttpError(501, 'streaming_unsupported', message); +} + export function actionError(message = 'The action failed', details?: unknown): BffHttpError { return new BffHttpError(400, 'action_error', message, { details }); } diff --git a/packages/agent-bff/src/openapi/unfolded-document.ts b/packages/agent-bff/src/openapi/unfolded-document.ts index 6f4daa4be9..bb99153fa7 100644 --- a/packages/agent-bff/src/openapi/unfolded-document.ts +++ b/packages/agent-bff/src/openapi/unfolded-document.ts @@ -1,4 +1,5 @@ import type { Unfolding } from './unfolding'; +import type { AgentTransport } from '../agent/agent-transport'; import type { Logger } from '../ports/logger-port'; import type ReadModel from '../read-model/read-model'; import type ReadModelStore from '../read-model/read-model-store'; @@ -11,8 +12,7 @@ import createAgentCapabilitiesFetcher from '../read-model/agent-capabilities-fet /** Everything needed to unfold. Absent when the deployment cannot reach its schema or its agent. */ export interface UnfoldSource { store: ReadModelStore; - agentUrl: string; - timeoutMs?: number; + transport: AgentTransport; logger: Logger; } @@ -36,11 +36,7 @@ export default async function buildUnfoldedDocument( const unfolding = await collectUnfolding({ readModel, store: source.store, - capabilitiesFetcher: createAgentCapabilitiesFetcher({ - agentUrl: source.agentUrl, - token, - timeoutMs: source.timeoutMs, - }), + capabilitiesFetcher: createAgentCapabilitiesFetcher({ transport: source.transport, token }), logger: source.logger, }); diff --git a/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts b/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts index eaefdb7a62..0db628c279 100644 --- a/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts +++ b/packages/agent-bff/src/read-model/agent-capabilities-fetcher.ts @@ -1,14 +1,12 @@ import type { CapabilitiesFetcher } from './capabilities-cache'; +import type { AgentTransport } from '../agent/agent-transport'; import { createRemoteAgentClient } from '@forestadmin/agent-client'; -import createAgentHttpRequester from '../agent/create-agent-http-requester'; - export interface AgentCapabilitiesFetcherOptions { - agentUrl: string; + transport: AgentTransport; /** A borrowed request token, or a factory when the caller can mint one per fetch. */ token: string | (() => string); - timeoutMs?: number; } /** @@ -20,15 +18,14 @@ export interface AgentCapabilitiesFetcherOptions { * expired token. A request can only borrow its caller's, so it passes the string. */ export default function createAgentCapabilitiesFetcher({ - agentUrl, + transport, token, - timeoutMs, }: AgentCapabilitiesFetcherOptions): CapabilitiesFetcher { const clientFor = (bearer: string) => createRemoteAgentClient({ - url: agentUrl, + url: transport.url, token: bearer, - httpRequester: createAgentHttpRequester(bearer, agentUrl, timeoutMs), + httpRequester: transport.createRequester(bearer), }); if (typeof token === 'string') { diff --git a/packages/agent-bff/test/action/action-routes-middleware.test.ts b/packages/agent-bff/test/action/action-routes-middleware.test.ts index 35a053f681..20dd851669 100644 --- a/packages/agent-bff/test/action/action-routes-middleware.test.ts +++ b/packages/agent-bff/test/action/action-routes-middleware.test.ts @@ -13,11 +13,14 @@ import Koa from 'koa'; import request from 'supertest'; import createActionRoutesMiddleware from '../../src/action/action-routes-middleware'; +import { createHttpTransport } from '../../src/agent/agent-transport'; import createErrorMiddleware from '../../src/http/error-middleware'; import SchemaUnavailableError from '../../src/read-model/errors'; import ReadModel from '../../src/read-model/read-model'; import { action, collection, column } from '../read-model/fixtures'; +const TRANSPORT = createHttpTransport({ agentUrl: 'https://agent.example.com' }); + const TIMEZONE = 'Europe/Paris'; const noopLogger: Logger = () => {}; @@ -113,7 +116,7 @@ function buildApp( app.use( createActionRoutesMiddleware({ store, - agentUrl: 'https://agent.example.com', + transport: TRANSPORT, logger, createClient: () => client, }), @@ -139,7 +142,7 @@ function buildAppWithTerminal(client: AgentActionClient) { app.use( createActionRoutesMiddleware({ store: storeOf(readModel), - agentUrl: 'https://agent.example.com', + transport: TRANSPORT, logger: noopLogger, createClient: () => client, }), @@ -152,7 +155,7 @@ function buildAppWithTerminal(client: AgentActionClient) { } describe('action routes middleware', () => { - it('forwards the configured agent timeout to the action client', async () => { + it('hands the configured transport to the action client', async () => { const createClient = jest.fn( () => clientOf(makeAction({ fields: [], layout: [], skipped: [] })) as AgentActionClient, ); @@ -168,8 +171,7 @@ describe('action routes middleware', () => { app.use( createActionRoutesMiddleware({ store: storeOf(readModel), - agentUrl: 'https://agent.example.com', - timeoutMs: 2500, + transport: TRANSPORT, logger: noopLogger, createClient, }), @@ -179,10 +181,10 @@ describe('action routes middleware', () => { .post('/agent/v1/users/actions/approve/form') .send({ recordIds: ['42'], values: {} }); - expect(createClient).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 2500 })); + expect(createClient).toHaveBeenCalledWith(expect.objectContaining({ transport: TRANSPORT })); }); - it('leaves the action client timeout undefined when none is configured', async () => { + it('rebuilds the client on every call, with that call own agent token', async () => { const createClient = jest.fn( () => clientOf(makeAction({ fields: [], layout: [], skipped: [] })) as AgentActionClient, ); @@ -192,13 +194,13 @@ describe('action routes middleware', () => { app.use(bodyParser()); app.use(async (ctx, next) => { ctx.state.timezone = TIMEZONE; - ctx.state.agentToken = 'agent-jwt'; + ctx.state.agentToken = ctx.get('x-agent-token'); await next(); }); app.use( createActionRoutesMiddleware({ store: storeOf(readModel), - agentUrl: 'https://agent.example.com', + transport: TRANSPORT, logger: noopLogger, createClient, }), @@ -206,9 +208,22 @@ describe('action routes middleware', () => { await request(app.callback()) .post('/agent/v1/users/actions/approve/form') + .set('x-agent-token', 'jwt-1') + .send({ recordIds: ['42'], values: {} }); + await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .set('x-agent-token', 'jwt-2') .send({ recordIds: ['42'], values: {} }); - expect(createClient).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: undefined })); + expect(createClient).toHaveBeenCalledTimes(2); + expect(createClient).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ token: 'jwt-1', transport: TRANSPORT }), + ); + expect(createClient).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ token: 'jwt-2', transport: TRANSPORT }), + ); }); it('returns the full form shape with fields, canExecute, requiredFields, skippedFields and layout', async () => { diff --git a/packages/agent-bff/test/action/agent-action-client.test.ts b/packages/agent-bff/test/action/agent-action-client.test.ts index d8e69f2b95..80ca34e087 100644 --- a/packages/agent-bff/test/action/agent-action-client.test.ts +++ b/packages/agent-bff/test/action/agent-action-client.test.ts @@ -1,6 +1,11 @@ import { HttpRequester, createRemoteAgentClient } from '@forestadmin/agent-client'; import createAgentActionClient from '../../src/action/agent-action-client'; +import { createHttpTransport } from '../../src/agent/agent-transport'; + +function transportTo(agentUrl: string, timeoutMs?: number) { + return createHttpTransport({ agentUrl, timeoutMs }); +} jest.mock('@forestadmin/agent-client'); @@ -26,7 +31,7 @@ describe('createAgentActionClient', () => { const actionEndpoints = { users: { approve: {} } } as never; const client = createAgentActionClient({ - agentUrl: 'https://agent.example.com', + transport: transportTo('https://agent.example.com'), token: 'jwt-token', actionEndpoints, }); @@ -58,10 +63,9 @@ describe('createAgentActionClient', () => { }); createAgentActionClient({ - agentUrl: 'https://agent', + transport: transportTo('https://agent', 2500), token: 'tok', actionEndpoints: {} as never, - timeoutMs: 2500, }); const httpRequester = createRemoteAgentClientMock.mock.calls[0][0] diff --git a/packages/agent-bff/test/agent/in-process-transport.test.ts b/packages/agent-bff/test/agent/in-process-transport.test.ts new file mode 100644 index 0000000000..d3045c652d --- /dev/null +++ b/packages/agent-bff/test/agent/in-process-transport.test.ts @@ -0,0 +1,176 @@ +import type { AgentDispatcher } from '../../src/agent/in-process-transport'; + +import { AgentHttpError, HttpRequester } from '@forestadmin/agent-client'; +import nock from 'nock'; + +import createInProcessTransport from '../../src/agent/in-process-transport'; + +const AGENT_URL = 'http://in-process.agent'; + +function dispatcherReturning(response: { + status: number; + body: unknown; + text?: string; +}): AgentDispatcher & { request: jest.Mock } { + return { request: jest.fn().mockResolvedValue(response) }; +} + +/** What superagent actually puts on the wire for `path`, so parity is measured, not asserted. */ +async function httpTarget(path: string): Promise<{ path: string; query: URLSearchParams }> { + let sent = ''; + nock(AGENT_URL) + .get(/.*/) + .reply(function reply() { + sent = this.req.path; + + return [200, {}]; + }); + + await new HttpRequester('tok', { url: AGENT_URL }).query({ method: 'get', path }); + + const [pathname, search] = sent.split('?'); + + return { path: pathname, query: new URLSearchParams(search) }; +} + +function entriesOf(query: URLSearchParams): [string, string][] { + return [...query.entries()].sort(); +} + +describe('createInProcessTransport', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('should dispatch the request with the bearer token and the caller query', async () => { + const dispatcher = dispatcherReturning({ status: 200, body: { data: [] } }); + const requester = createInProcessTransport({ dispatcher }).createRequester('tok'); + + await requester.query({ method: 'post', path: '/forest/users', body: { a: 1 } }); + + expect(dispatcher.request).toHaveBeenCalledWith({ + method: 'post', + path: '/forest/users', + headers: { + Authorization: 'Bearer tok', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + query: { timezone: 'Europe/Paris' }, + payload: { a: 1 }, + timeoutMs: undefined, + }); + }); + + describe('when a path segment carries a character the HTTP path rewrites', () => { + it.each([ + ['a space', '/forest/orders/A B'], + ['a plus, which escapeUrlSlug turns into a path separator', '/forest/orders/A+c'], + ['a star', '/forest/orders/a*b'], + ['a question mark, which splits a query off', '/forest/orders/q?x'], + ['a traversal the URL parser resolves', '/forest/users/../admin'], + ])('should reach the same agent path as the HTTP transport for %s', async (_label, path) => { + const dispatcher = dispatcherReturning({ status: 200, body: {} }); + const requester = createInProcessTransport({ dispatcher }).createRequester('tok'); + const overHttp = await httpTarget(path); + + await requester.query({ method: 'get', path }); + + const dispatched = dispatcher.request.mock.calls[0][0]; + expect(dispatched.path).toBe(overHttp.path); + expect(entriesOf(new URLSearchParams(dispatched.query))).toEqual(entriesOf(overHttp.query)); + }); + }); + + it('should prefix a relative path with a slash, as buildUrl does', async () => { + const dispatcher = dispatcherReturning({ status: 200, body: {} }); + const requester = createInProcessTransport({ dispatcher }).createRequester('tok'); + + await requester.query({ method: 'get', path: 'forest/users' }); + + expect(dispatcher.request).toHaveBeenCalledWith( + expect.objectContaining({ path: '/forest/users' }), + ); + }); + + describe('when the caller sets no timeout', () => { + it('should fall back to the configured one', async () => { + const dispatcher = dispatcherReturning({ status: 200, body: {} }); + const requester = createInProcessTransport({ dispatcher, timeoutMs: 2500 }).createRequester( + 'tok', + ); + + await requester.query({ method: 'get', path: '/forest/users' }); + + expect(dispatcher.request).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 2500 })); + }); + + it('should let an explicit maxTimeAllowed win', async () => { + const dispatcher = dispatcherReturning({ status: 200, body: {} }); + const requester = createInProcessTransport({ dispatcher, timeoutMs: 2500 }).createRequester( + 'tok', + ); + + await requester.query({ method: 'get', path: '/forest/users', maxTimeAllowed: 100 }); + + expect(dispatcher.request).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 100 })); + }); + }); + + describe('when the agent answers with an error status', () => { + it('should throw the same AgentHttpError shape as the HTTP path', async () => { + const body = { errors: [{ name: 'ForbiddenError', detail: 'Forbidden' }] }; + const text = JSON.stringify(body); + const dispatcher = dispatcherReturning({ status: 403, body, text }); + const requester = createInProcessTransport({ dispatcher }).createRequester('tok'); + nock(AGENT_URL).get(/.*/).reply(403, body); + + const overHttp = (await new HttpRequester('tok', { url: AGENT_URL }) + .query({ method: 'get', path: '/forest/users' }) + .catch(error => error)) as AgentHttpError; + const overDispatch = await requester + .query({ method: 'get', path: '/forest/users' }) + .catch(error => error); + + expect(overDispatch).toBeInstanceOf(AgentHttpError); + expect(overDispatch).toMatchObject({ + name: 'AgentHttpError', + message: overHttp.message, + status: 403, + body, + responseText: text, + }); + }); + }); + + describe('when the dispatch itself fails', () => { + it('should carry the cause under a 500, not a transport failure', async () => { + const dispatcher = { request: jest.fn().mockRejectedValue(new Error('agent hook exploded')) }; + const requester = createInProcessTransport({ dispatcher }).createRequester('tok'); + + const error = (await requester + .query({ method: 'get', path: '/forest/users' }) + .catch(caught => caught)) as AgentHttpError; + + expect(error).toBeInstanceOf(AgentHttpError); + expect(error.status).toBe(500); + expect((error.body as { errors: { detail: string }[] }).errors[0].detail).toContain( + 'agent hook exploded', + ); + }); + }); + + it('should refuse to stream with a 501, since there is no socket to stream from', async () => { + const dispatcher = dispatcherReturning({ status: 200, body: {} }); + const requester = createInProcessTransport({ dispatcher }).createRequester('tok'); + + const stream = requester.stream as () => Promise; + + await expect(stream()).rejects.toMatchObject({ + name: 'BffHttpError', + status: 501, + type: 'streaming_unsupported', + message: 'Streaming is not supported over the in-process transport', + }); + }); +}); diff --git a/packages/agent-bff/test/build-bff.test.ts b/packages/agent-bff/test/build-bff.test.ts index 8fe0a75fe0..22281d947d 100644 --- a/packages/agent-bff/test/build-bff.test.ts +++ b/packages/agent-bff/test/build-bff.test.ts @@ -2,11 +2,18 @@ import type { Logger } from '../src/ports/logger-port'; import request from 'supertest'; +import { createHttpTransport } from '../src/agent/agent-transport'; import buildBff from '../src/build-bff'; import { parseConfig } from '../src/config/env-config'; import version from '../src/version'; import { restoreFetchAfterEach, stubEnvironmentIdFetch } from './helpers/fetch-stub'; +jest.mock('../src/agent/agent-transport', () => { + const actual = jest.requireActual('../src/agent/agent-transport'); + + return { ...actual, createHttpTransport: jest.fn(actual.createHttpTransport) }; +}); + const VALID_ENV = { FOREST_AUTH_SECRET: 'auth-secret', FOREST_ENV_SECRET: 'env-secret', @@ -28,9 +35,23 @@ describe('buildBff', () => { restoreFetchAfterEach(); beforeEach(() => { + jest.mocked(createHttpTransport).mockClear(); stubEnvironmentIdFetch(); }); + it('should cap every transport it builds with the configured BFF_AGENT_TIMEOUT_MS', async () => { + await buildBff({ + config: parseConfig({ ...VALID_ENV, BFF_AGENT_TIMEOUT_MS: '2500' }), + logger: noopLogger, + }); + + const built = jest.mocked(createHttpTransport).mock.calls.map(([options]) => options); + const capped = { agentUrl: VALID_ENV.AGENT_URL, timeoutMs: 2500 }; + + expect(built).not.toHaveLength(0); + expect(built).toEqual(built.map(() => capped)); + }); + describe('when every required key is present', () => { it('should answer /health with ok and the version', async () => { const callback = await buildCallback(VALID_ENV); diff --git a/packages/agent-bff/test/data/agent-data-client.test.ts b/packages/agent-bff/test/data/agent-data-client.test.ts index 8210a4a6e8..88287c6cc1 100644 --- a/packages/agent-bff/test/data/agent-data-client.test.ts +++ b/packages/agent-bff/test/data/agent-data-client.test.ts @@ -1,7 +1,12 @@ import { HttpRequester } from '@forestadmin/agent-client'; +import { createHttpTransport } from '../../src/agent/agent-transport'; import createAgentDataClient from '../../src/data/agent-data-client'; +function transportTo(agentUrl: string, timeoutMs?: number) { + return createHttpTransport({ agentUrl, timeoutMs }); +} + jest.mock('@forestadmin/agent-client'); const mockedHttpRequester = jest.mocked(HttpRequester); @@ -18,14 +23,14 @@ describe('createAgentDataClient', () => { }); it('should build the requester with the agent url and token', () => { - createAgentDataClient({ agentUrl: 'https://agent.example.com', token: 'tok' }); + createAgentDataClient({ transport: transportTo('https://agent.example.com'), token: 'tok' }); expect(HttpRequester).toHaveBeenCalledWith('tok', { url: 'https://agent.example.com' }); }); it('should query the list endpoint (deserialized) for the given collection', async () => { query.mockResolvedValue([{ id: '1' }]); - const client = createAgentDataClient({ agentUrl: 'https://agent', token: 'tok' }); + const client = createAgentDataClient({ transport: transportTo('https://agent'), token: 'tok' }); const result = await client.list('users', { timezone: 'Europe/Paris', filters: '{}' }); @@ -39,9 +44,8 @@ describe('createAgentDataClient', () => { it('should apply the configured timeout as maxTimeAllowed on every query', async () => { const client = createAgentDataClient({ - agentUrl: 'https://agent', + transport: transportTo('https://agent', 2500), token: 'tok', - timeoutMs: 2500, }); await client.list('users', { timezone: 'Europe/Paris' }); @@ -75,7 +79,7 @@ describe('createAgentDataClient', () => { it('should query the count endpoint with skipDeserialization to read the raw payload', async () => { query.mockResolvedValue({ meta: { count: 'deactivated' } }); - const client = createAgentDataClient({ agentUrl: 'https://agent', token: 'tok' }); + const client = createAgentDataClient({ transport: transportTo('https://agent'), token: 'tok' }); const result = await client.countRaw('users', { timezone: 'Europe/Paris' }); @@ -89,7 +93,7 @@ describe('createAgentDataClient', () => { }); it('should request the relation list path without pre-encoding the segments', async () => { - const client = createAgentDataClient({ agentUrl: 'https://agent', token: 'tok' }); + const client = createAgentDataClient({ transport: transportTo('https://agent'), token: 'tok' }); await client.listRelation('users', 'tenant-1|42', 'posts', { timezone: 'Europe/Paris' }); @@ -101,7 +105,7 @@ describe('createAgentDataClient', () => { }); it('should request the relation count path with skipDeserialization and no pre-encoding', async () => { - const client = createAgentDataClient({ agentUrl: 'https://agent', token: 'tok' }); + const client = createAgentDataClient({ transport: transportTo('https://agent'), token: 'tok' }); await client.countRelationRaw('users', 'tenant-1|42', 'posts', { timezone: 'Europe/Paris' }); diff --git a/packages/agent-bff/test/data/data-routes-middleware.test.ts b/packages/agent-bff/test/data/data-routes-middleware.test.ts index f52e5bb466..299e9b6e5d 100644 --- a/packages/agent-bff/test/data/data-routes-middleware.test.ts +++ b/packages/agent-bff/test/data/data-routes-middleware.test.ts @@ -1,4 +1,4 @@ -import type { AgentDataClient } from '../../src/data/agent-data-client'; +import type { AgentDataClient, AgentDataClientOptions } from '../../src/data/agent-data-client'; import type { Logger } from '../../src/ports/logger-port'; import type { CapabilitiesResult } from '../../src/read-model/capabilities-cache'; import type ReadModelStore from '../../src/read-model/read-model-store'; @@ -8,12 +8,15 @@ import { bodyParser } from '@koa/bodyparser'; import Koa from 'koa'; import request from 'supertest'; +import { createHttpTransport } from '../../src/agent/agent-transport'; import createDataRoutesMiddleware from '../../src/data/data-routes-middleware'; import createErrorMiddleware from '../../src/http/error-middleware'; import SchemaUnavailableError from '../../src/read-model/errors'; import ReadModel from '../../src/read-model/read-model'; import { collection, column, polymorphic, relation } from '../read-model/fixtures'; +const TRANSPORT = createHttpTransport({ agentUrl: 'https://agent.example.com' }); + const TIMEZONE = 'Europe/Paris'; const noopLogger: Logger = () => {}; @@ -51,8 +54,6 @@ function storeOf( } as unknown as ReadModelStore; } -const AGENT_URL = 'https://agent.example.com'; - function buildApp( store: ReadModelStore, client: Partial, @@ -62,7 +63,7 @@ function buildApp( logger = noopLogger, }: { agentToken?: string | null; - createClient?: (options: { agentUrl: string; token: string }) => AgentDataClient; + createClient?: (options: AgentDataClientOptions) => AgentDataClient; logger?: Logger; } = {}, ) { @@ -78,7 +79,7 @@ function buildApp( app.use( createDataRoutesMiddleware({ store, - agentUrl: AGENT_URL, + transport: TRANSPORT, logger, createClient, }), @@ -133,20 +134,16 @@ describe('data routes middleware', () => { expect(list).not.toHaveBeenCalled(); }); - it('should forward the agent url and resolved token to the data client', async () => { + it('should forward the transport and resolved token to the data client', async () => { const createClient = jest.fn(() => ({ list: async () => [] } as unknown as AgentDataClient)); const app = buildApp(storeOf(usersReadModel), {}, { agentToken: 'jwt-123', createClient }); await request(app.callback()).post('/agent/v1/users/list').send({}); - expect(createClient).toHaveBeenCalledWith({ - agentUrl: AGENT_URL, - token: 'jwt-123', - timeoutMs: undefined, - }); + expect(createClient).toHaveBeenCalledWith({ transport: TRANSPORT, token: 'jwt-123' }); }); - it('should forward the configured agent timeout to the data client', async () => { + it('should build one client per request, each bound to that request token', async () => { const createClient = jest.fn(() => ({ list: async () => [] } as unknown as AgentDataClient)); const app = new Koa(); app.silent = true; @@ -154,22 +151,30 @@ describe('data routes middleware', () => { app.use(bodyParser()); app.use(async (ctx, next) => { ctx.state.timezone = TIMEZONE; - ctx.state.agentToken = 'agent-jwt'; + ctx.state.agentToken = ctx.get('x-agent-token'); await next(); }); app.use( createDataRoutesMiddleware({ store: storeOf(usersReadModel), - agentUrl: AGENT_URL, - timeoutMs: 2500, + transport: TRANSPORT, logger: noopLogger, createClient, }), ); - await request(app.callback()).post('/agent/v1/users/list').send({}); + await request(app.callback()) + .post('/agent/v1/users/list') + .set('x-agent-token', 'jwt-1') + .send({}); + await request(app.callback()) + .post('/agent/v1/users/list') + .set('x-agent-token', 'jwt-2') + .send({}); - expect(createClient).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 2500 })); + expect(createClient).toHaveBeenCalledTimes(2); + expect(createClient).toHaveBeenNthCalledWith(1, { transport: TRANSPORT, token: 'jwt-1' }); + expect(createClient).toHaveBeenNthCalledWith(2, { transport: TRANSPORT, token: 'jwt-2' }); }); }); diff --git a/packages/agent-bff/test/data/search-agent.integration.test.ts b/packages/agent-bff/test/data/search-agent.integration.test.ts index d54caf5fe1..a70563a9d0 100644 --- a/packages/agent-bff/test/data/search-agent.integration.test.ts +++ b/packages/agent-bff/test/data/search-agent.integration.test.ts @@ -14,6 +14,7 @@ import path from 'path'; import request from 'supertest'; import SearchDataSource from './fixtures/search-datasource'; +import { createHttpTransport } from '../../src/agent/agent-transport'; import createDataRoutesMiddleware from '../../src/data/data-routes-middleware'; import createErrorMiddleware from '../../src/http/error-middleware'; import CapabilitiesCache from '../../src/read-model/capabilities-cache'; @@ -70,6 +71,7 @@ function schemaFetcherFromFile(schemaPath: string): SchemaFetcher { * production fetches it from the Forest server, which plays no part in search. */ function buildApp(agentUrl: string, schemaPath: string): Koa { + const transport = createHttpTransport({ agentUrl }); const token = agentToken(); const schemaCache = new SchemaCache({ fetcher: schemaFetcherFromFile(schemaPath), @@ -86,7 +88,7 @@ function buildApp(agentUrl: string, schemaPath: string): Koa { ctx.state.agentToken = token; await next(); }); - app.use(createDataRoutesMiddleware({ store, agentUrl, logger: noopLogger })); + app.use(createDataRoutesMiddleware({ store, transport, logger: noopLogger })); return app; } diff --git a/packages/agent-bff/test/http/bff-local-errors.test.ts b/packages/agent-bff/test/http/bff-local-errors.test.ts index 45a549c596..39b7dfefb0 100644 --- a/packages/agent-bff/test/http/bff-local-errors.test.ts +++ b/packages/agent-bff/test/http/bff-local-errors.test.ts @@ -6,6 +6,7 @@ import { openapiDisabled, relationNotAllowed, schemaUnavailable, + streamingUnsupported, unknownAction, unknownCollection, unknownRelation, @@ -25,6 +26,7 @@ describe('bff local errors', () => { [schemaUnavailable, 'schema_unavailable', 503], [unsupportedActionResult, 'unsupported_action_result', 501], [openapiDisabled, 'openapi_disabled', 404], + [streamingUnsupported, 'streaming_unsupported', 501], ])('%p builds a %s error with status %d', (factory, type, status) => { expect(factory()).toMatchObject({ type, status }); }); diff --git a/packages/agent-bff/test/openapi/openapi-generated-client.test.ts b/packages/agent-bff/test/openapi/openapi-generated-client.test.ts index 012d2e0242..9b539e403f 100644 --- a/packages/agent-bff/test/openapi/openapi-generated-client.test.ts +++ b/packages/agent-bff/test/openapi/openapi-generated-client.test.ts @@ -11,6 +11,7 @@ import Koa from 'koa'; import path from 'path'; import createActionRoutesMiddleware from '../../src/action/action-routes-middleware'; +import { createHttpTransport } from '../../src/agent/agent-transport'; import { BFF_KEY_HEADER } from '../../src/api-key/api-key-middleware'; import dispatchCli from '../../src/cli-dispatch'; import createDataRoutesMiddleware from '../../src/data/data-routes-middleware'; @@ -178,7 +179,7 @@ function buildApp(): Koa { app.use( createDataRoutesMiddleware({ store, - agentUrl: ENV.AGENT_URL, + transport: createHttpTransport({ agentUrl: ENV.AGENT_URL }), logger: noopLogger, createClient: () => dataClient, }), @@ -186,7 +187,7 @@ function buildApp(): Koa { app.use( createActionRoutesMiddleware({ store, - agentUrl: ENV.AGENT_URL, + transport: createHttpTransport({ agentUrl: ENV.AGENT_URL }), logger: noopLogger, createClient: () => actionClient, }), diff --git a/packages/agent-bff/test/openapi/openapi-routes.test.ts b/packages/agent-bff/test/openapi/openapi-routes.test.ts index d6f0d329a8..65fc52ee4c 100644 --- a/packages/agent-bff/test/openapi/openapi-routes.test.ts +++ b/packages/agent-bff/test/openapi/openapi-routes.test.ts @@ -4,6 +4,7 @@ import type { Middleware } from 'koa'; import request from 'supertest'; +import { createHttpTransport } from '../../src/agent/agent-transport'; import runCli from '../../src/cli-core'; import { issueBffAccessToken } from '../../src/oauth/bff-token'; import createOpenApiRoutes, { OPENAPI_PATH } from '../../src/openapi/openapi-routes'; @@ -400,7 +401,11 @@ describe('GET /agent/openapi.json', () => { version: '1.2.3', enabled: true, hasAiQueryRoute: false, - source: { store, agentUrl: 'https://agent.example.com', logger: noopLogger }, + source: { + store, + transport: createHttpTransport({ agentUrl: 'https://agent.example.com' }), + logger: noopLogger, + }, }); } diff --git a/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts b/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts index 30b94c3395..e7102847aa 100644 --- a/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts +++ b/packages/agent-bff/test/read-model/agent-capabilities-fetcher.test.ts @@ -1,7 +1,12 @@ import { HttpRequester, createRemoteAgentClient } from '@forestadmin/agent-client'; +import { createHttpTransport } from '../../src/agent/agent-transport'; import createAgentCapabilitiesFetcher from '../../src/read-model/agent-capabilities-fetcher'; +function transportTo(agentUrl: string, timeoutMs?: number) { + return createHttpTransport({ agentUrl, timeoutMs }); +} + jest.mock('@forestadmin/agent-client'); const createRemoteAgentClientMock = createRemoteAgentClient as jest.Mock; @@ -23,7 +28,10 @@ describe('createAgentCapabilitiesFetcher', () => { const collection = jest.fn().mockReturnValue({ capabilities }); createRemoteAgentClientMock.mockReturnValue({ collection }); - const fetcher = createAgentCapabilitiesFetcher({ agentUrl: 'https://agent', token: 'tok' }); + const fetcher = createAgentCapabilitiesFetcher({ + transport: transportTo('https://agent'), + token: 'tok', + }); const result = await fetcher('users'); expect(HttpRequester).toHaveBeenCalledWith('tok', { url: 'https://agent' }); @@ -43,7 +51,10 @@ describe('createAgentCapabilitiesFetcher', () => { .mockReturnValue({ capabilities: jest.fn().mockResolvedValue({ fields: [] }) }); createRemoteAgentClientMock.mockReturnValue({ collection }); - const fetcher = createAgentCapabilitiesFetcher({ agentUrl: 'https://agent', token: 'tok' }); + const fetcher = createAgentCapabilitiesFetcher({ + transport: transportTo('https://agent'), + token: 'tok', + }); await fetcher('users'); await fetcher('orders'); @@ -58,7 +69,7 @@ describe('createAgentCapabilitiesFetcher', () => { let minted = 0; const fetcher = createAgentCapabilitiesFetcher({ - agentUrl: 'https://agent', + transport: transportTo('https://agent'), token: () => { minted += 1; @@ -83,9 +94,8 @@ describe('createAgentCapabilitiesFetcher', () => { }); await createAgentCapabilitiesFetcher({ - agentUrl: 'https://agent', + transport: transportTo('https://agent', 2500), token: 'tok', - timeoutMs: 2500, })('users'); const httpRequester = createRemoteAgentClientMock.mock.calls[0][0]