Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/agent-client/src/domains/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,16 @@ export default class Collection extends CollectionChart {
});
}

async getOne<Data = unknown>(id: RecordId, options?: SelectOptions): Promise<Data> {
async getOne<Data = unknown>(
id: RecordId,
options?: SelectOptions,
{ skipDeserialization = false }: { skipDeserialization?: boolean } = {},
): Promise<Data> {
return this.httpRequester.query<Data>({
method: 'get',
path: `/forest/${this.name}/${serializeRecordId(id)}`,
query: QuerySerializer.serialize(options, this.name),
skipDeserialization,
});
}

Expand Down
6 changes: 6 additions & 0 deletions packages/agent-client/src/http-requester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ export default class HttpRequester {
query,
maxTimeAllowed,
contentType,
skipDeserialization,
}: {
method: 'get' | 'post' | 'put' | 'delete';
path: string;
body?: Record<string, unknown>;
query?: Record<string, unknown>;
maxTimeAllowed?: number;
contentType?: 'application/json' | 'text/csv';
skipDeserialization?: boolean;
}): Promise<Data> {
try {
const url = this.buildUrl(path);
Expand All @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions packages/agent-client/test/domains/collection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down
31 changes: 10 additions & 21 deletions packages/workflow-executor/src/adapters/agent-client-agent-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<relation>.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 `<relation>@@@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<string, { data?: { type?: string; id?: string } | null }>;
};
}>(id, { fields: [`${relation}@@@id`] }, { skipDeserialization: true });

const body = (await response.json()) as {
data?: { relationships?: Record<string, { data?: { type?: string; id?: string } | null }> };
};
const linkage = body?.data?.relationships?.[relation]?.data;

return linkage?.type ? { type: String(linkage.type), id: String(linkage.id) } : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

const mocks = createMockClient();
({ mockCollection, mockRelation, mockAction } = mocks);
mockedCreateRemoteAgentClient.mockReturnValue(mocks.client as any);

Check warning on line 52 in packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Unexpected any. Specify a different type

const schemaCache = new SchemaCache();
schemaCache.set('users', {
Expand Down Expand Up @@ -824,52 +824,44 @@
});

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' },
user,
);

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' },
Expand All @@ -879,8 +871,8 @@
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(
Expand Down
Loading