diff --git a/packages/agent-client/src/domains/collection.ts b/packages/agent-client/src/domains/collection.ts index 7438caafb0..7a2c520466 100644 --- a/packages/agent-client/src/domains/collection.ts +++ b/packages/agent-client/src/domains/collection.ts @@ -171,11 +171,16 @@ export default class Collection extends CollectionChart { }); } - async getOne(id: RecordId, options?: SelectOptions): Promise { + async getOne( + id: RecordId, + options?: SelectOptions, + { skipDeserialization = false }: { skipDeserialization?: boolean } = {}, + ): Promise { return this.httpRequester.query({ method: 'get', path: `/forest/${this.name}/${serializeRecordId(id)}`, query: QuerySerializer.serialize(options, this.name), + skipDeserialization, }); } diff --git a/packages/agent-client/src/http-requester.ts b/packages/agent-client/src/http-requester.ts index 9e70ad2287..1cc8c54476 100644 --- a/packages/agent-client/src/http-requester.ts +++ b/packages/agent-client/src/http-requester.ts @@ -26,6 +26,7 @@ export default class HttpRequester { query, maxTimeAllowed, contentType, + skipDeserialization, }: { method: 'get' | 'post' | 'put' | 'delete'; path: string; @@ -33,6 +34,7 @@ export default class HttpRequester { query?: Record; maxTimeAllowed?: number; contentType?: 'application/json' | 'text/csv'; + skipDeserialization?: boolean; }): Promise { try { const url = this.buildUrl(path); @@ -48,6 +50,10 @@ export default class HttpRequester { const response = await req; + if (skipDeserialization) { + return response.body as Data; + } + try { return (await this.deserializer.deserialize(response.body)) as Data; } catch { diff --git a/packages/agent-client/test/domains/collection.test.ts b/packages/agent-client/test/domains/collection.test.ts index bae15fe19e..8df256bae2 100644 --- a/packages/agent-client/test/domains/collection.test.ts +++ b/packages/agent-client/test/domains/collection.test.ts @@ -174,6 +174,41 @@ describe('Collection', () => { }); }); + describe('getOne', () => { + it('deserializes by default (skipDeserialization not set)', async () => { + httpRequester.query.mockResolvedValue({ id: 7, name: 'Alice' }); + + const result = await collection.getOne(7); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + path: '/forest/users/7', + skipDeserialization: false, + }), + ); + expect(result).toEqual({ id: 7, name: 'Alice' }); + }); + + it('returns the raw JSON:API body when skipDeserialization is set', async () => { + const body = { + data: { relationships: { commentable: { data: { type: 'orders', id: '99' } } } }, + }; + httpRequester.query.mockResolvedValue(body); + + const result = await collection.getOne( + 7, + { fields: ['commentable@@@id'] }, + { skipDeserialization: true }, + ); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ skipDeserialization: true }), + ); + expect(result).toEqual(body); + }); + }); + describe('delete', () => { it('should call httpRequester.query with DELETE method and correct body', async () => { httpRequester.query.mockResolvedValue({}); diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 8046319029..fc1cd1fbf7 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -247,33 +247,22 @@ export default class AgentClientAgentPort implements AgentPort { }); } - // The agent-client deserializer drops relationship `type`, so read the raw record-with-projection - // response: `data.relationships..data = { type, id }`. No UI-exposed discriminator needed. + // Resolves a polymorphic relation's target from the raw JSON:API linkage. The deserializer drops + // relationship `type`, so we read the raw body (getOne `raw`) via a `@@@id` projection + // and extract the linkage here — agent-client stays generic (URL/auth/serialization). async resolvePolymorphicType( { collection, id, relation }: ResolvePolymorphicTypeQuery, user: StepUser, ): Promise<{ type: string; id: string } | null> { return this.callAgent('resolvePolymorphicType', async () => { - const recordId = id.map(String).join('|'); - const params = new URLSearchParams({ - [`fields[${collection}]`]: relation, - [`fields[${relation}]`]: 'id', - timezone: 'Europe/Paris', // matches HttpRequester's default - }); - const base = this.agentUrl.replace(/\/+$/, ''); - const response = await fetch(`${base}/forest/${collection}/${recordId}?${params}`, { - headers: { Authorization: `Bearer ${this.mintToken(user)}`, Accept: 'application/json' }, - }); - - if (!response.ok) { - throw new Error( - `resolvePolymorphicType ${collection}/${recordId}: HTTP ${response.status}`, - ); - } + const body = await this.createClient(user) + .collection(collection) + .getOne<{ + data?: { + relationships?: Record; + }; + }>(id, { fields: [`${relation}@@@id`] }, { skipDeserialization: true }); - const body = (await response.json()) as { - data?: { relationships?: Record }; - }; const linkage = body?.data?.relationships?.[relation]?.data; return linkage?.type ? { type: String(linkage.type), id: String(linkage.id) } : null; diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts index 6f8892f1c6..4e5489e04c 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -824,24 +824,12 @@ describe('AgentClientAgentPort', () => { }); describe('resolvePolymorphicType', () => { - let fetchSpy: jest.SpyInstance; - - beforeEach(() => { - fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(jest.fn()); - }); - - afterEach(() => { - fetchSpy.mockRestore(); - }); - - function linkageResponse(data: unknown) { - return new Response(JSON.stringify({ data: { relationships: { commentable: { data } } } }), { - status: 200, - }); + function linkageBody(data: unknown) { + return { data: { relationships: { commentable: { data } } } }; } - it('reads the linkage type/id and projects the relation on the by-id route', async () => { - fetchSpy.mockResolvedValue(linkageResponse({ type: 'orders', id: '99' })); + it('reads the raw linkage via getOne and extracts { type, id }', async () => { + mockCollection.getOne.mockResolvedValue(linkageBody({ type: 'orders', id: '99' })); const result = await port.resolvePolymorphicType( { collection: 'comments', id: [7], relation: 'commentable' }, @@ -849,27 +837,31 @@ describe('AgentClientAgentPort', () => { ); expect(result).toEqual({ type: 'orders', id: '99' }); - - const [url, options] = fetchSpy.mock.calls[0]; - expect(url).toContain('http://localhost:3310/forest/comments/7?'); - expect(url).toContain(`${encodeURIComponent('fields[comments]')}=commentable`); - expect(url).toContain(`${encodeURIComponent('fields[commentable]')}=id`); - expect(options.headers.Authorization).toMatch(/^Bearer /); + // raw projection read on the source record, parsing done here in the adapter. + expect(mockCollection.getOne).toHaveBeenCalledWith( + [7], + { fields: ['commentable@@@id'] }, + { skipDeserialization: true }, + ); }); - it('joins composite ids with "|" in the by-id route', async () => { - fetchSpy.mockResolvedValue(linkageResponse({ type: 'orders', id: '1|2' })); + it('passes composite ids through to the raw read', async () => { + mockCollection.getOne.mockResolvedValue(linkageBody({ type: 'orders', id: '1|2' })); await port.resolvePolymorphicType( { collection: 'comments', id: ['tenant-1', 5], relation: 'commentable' }, user, ); - expect(fetchSpy.mock.calls[0][0]).toContain('/forest/comments/tenant-1|5?'); + expect(mockCollection.getOne).toHaveBeenCalledWith( + ['tenant-1', 5], + { fields: ['commentable@@@id'] }, + { skipDeserialization: true }, + ); }); it('returns null when the relation has no linkage', async () => { - fetchSpy.mockResolvedValue(linkageResponse(null)); + mockCollection.getOne.mockResolvedValue(linkageBody(null)); const result = await port.resolvePolymorphicType( { collection: 'comments', id: [7], relation: 'commentable' }, @@ -879,8 +871,8 @@ describe('AgentClientAgentPort', () => { expect(result).toBeNull(); }); - it('throws AgentPortError when the agent responds non-2xx', async () => { - fetchSpy.mockResolvedValue(new Response(null, { status: 500 })); + it('wraps agent errors in AgentPortError', async () => { + mockCollection.getOne.mockRejectedValue(new Error('boom')); await expect( port.resolvePolymorphicType(