Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/agent-bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"zod": "4.3.6"
},
"devDependencies": {
"@forestadmin/agent": "1.96.0",
"@forestadmin/agent-testing": "1.1.79",
"@redocly/cli": "2.35.1",
"@types/jsonwebtoken": "^9.0.1",
"@types/koa": "^2.13.5",
Expand Down
44 changes: 44 additions & 0 deletions packages/agent-bff/src/data/agent-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ export interface ListRequestBody {
projection?: string[];
sort?: BffSortClause[];
page?: BffPage;
search?: string;
searchExtended?: boolean;
}

export interface CountRequestBody {
filter?: unknown;
search?: string;
searchExtended?: boolean;
}

export type RelationListRequestBody = ListRequestBody & { parentId: string };
Expand Down Expand Up @@ -55,11 +59,29 @@ function assertNoNodeReadableAsBothLeafAndBranch(node: unknown, depth = 0): void
// Validate the untyped request body before it reaches the query builders, so malformed shapes
// (e.g. `projection` or `sort` as a string) surface as 400 invalid_request rather than a 500 from
// an array method blowing up downstream.
/**
* The agent reads `search`/`searchExtended` from query params, so it coerces both from strings.
* The BFF is a JSON contract: a real boolean is required here, like `page.limit` requires a real
* integer. Only the type is checked — whether a blank search is worth sending is the builder's
* call, so a cleared search box parses the same way as an absent one.
*/
function assertValidSearch(search: unknown, searchExtended: unknown): void {
if (search !== undefined && typeof search !== 'string') {
throw invalidRequest('search must be a string');
}

if (searchExtended !== undefined && typeof searchExtended !== 'boolean') {
throw invalidRequest('searchExtended must be a boolean');
}
}

export function parseListRequest(body: unknown): ListRequestBody {
if (!isPlainObject(body)) throw invalidRequest('Request body must be an object');

const { filter, projection, sort, page } = body;

assertValidSearch(body.search, body.searchExtended);

if (projection !== undefined) {
if (!Array.isArray(projection) || projection.some(field => typeof field !== 'string')) {
throw invalidRequest('projection must be an array of field names');
Expand Down Expand Up @@ -105,6 +127,8 @@ export function parseListRequest(body: unknown): ListRequestBody {
export function parseCountRequest(body: unknown): CountRequestBody {
if (!isPlainObject(body)) throw invalidRequest('Request body must be an object');

assertValidSearch(body.search, body.searchExtended);

if (body.filter !== undefined) {
if (!isPlainObject(body.filter)) throw invalidRequest('filter must be an object');
assertNoNodeReadableAsBothLeafAndBranch(body.filter);
Expand Down Expand Up @@ -138,6 +162,24 @@ function serializePage(page: BffPage): Record<string, number> {
return { 'page[size]': limit, 'page[number]': offset / limit + 1 };
}

/**
* `search` and `searchExtended` are the wire names the agent reads; no other spelling is parsed.
*
* A blank search is dropped rather than forwarded. The agent's search decorator already treats it
* as absent, but `parseSearch` guards on a truthy value, so a whitespace-only search would raise
* "Collection is not searchable" on a non-searchable collection while an empty one would not — a
* cleared search box must not depend on how many spaces it holds.
*
* `searchExtended` only ships alongside a real search: on its own it changes nothing agent-side,
* and emitting it would alter the outgoing query of every search-less request.
*/
function applySearch(query: AgentQuery, body: CountRequestBody): void {
if (!body.search?.trim()) return;

query.search = body.search;
if (body.searchExtended !== undefined) query.searchExtended = body.searchExtended;
}

export function buildListAgentQuery(
collection: string,
timezone: string,
Expand All @@ -149,6 +191,7 @@ export function buildListAgentQuery(
if (body.projection?.length) query[`fields[${collection}]`] = body.projection.join(',');
if (body.sort?.length) query.sort = serializeSort(body.sort);
if (body.page) Object.assign(query, serializePage(body.page));
applySearch(query, body);

return query;
}
Expand All @@ -157,6 +200,7 @@ export function buildCountAgentQuery(timezone: string, body: CountRequestBody):
const query: AgentQuery = { timezone };

if (body.filter !== undefined) query.filters = JSON.stringify(body.filter);
applySearch(query, body);

return query;
}
Expand Down
30 changes: 28 additions & 2 deletions packages/agent-bff/src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,22 +61,48 @@ export const TimezoneSchema = z.string().openapi('Timezone', {
'missing_timezone.',
});

export const SearchSchema = z.string().openapi('Search', {
description:
"The agent's native full-text search, applied on top of `filter` rather than instead of it. " +
'An empty or whitespace-only value is treated as absent, so clearing a search box is not an ' +
'error. Searching a collection whose search is disabled is not rejected here: the agent ' +
'answers 400 validation_error with "Collection is not searchable". The response does not say ' +
'which field matched.',
});

export const SearchExtendedSchema = z.boolean().openapi('SearchExtended', {
description:
'Widens `search` to the related collections reachable from this one. Meaningless on its own: ' +
'sent without `search` it is ignored and changes nothing. Note it reads relation fields even ' +
'though naming a relation field path in `filter`, `sort` or `projection` is rejected with 422 ' +
'relation_field_not_supported — records can therefore match on a field the response cannot ' +
'show.',
});

export const ListRequestSchema = z
.object({
filter: ConditionTreeSchema.optional(),
projection: z.array(z.string()).optional(),
sort: z.array(SortClauseSchema).optional(),
page: PageSchema.optional(),
search: SearchSchema.optional(),
searchExtended: SearchExtendedSchema.optional(),
timezone: TimezoneSchema.optional(),
})
.openapi('ListRequest');

export const CountRequestSchema = z
.object({
filter: ConditionTreeSchema.optional(),
search: SearchSchema.optional(),
searchExtended: SearchExtendedSchema.optional(),
timezone: TimezoneSchema.optional(),
})
.openapi('CountRequest');
.openapi('CountRequest', {
description:
'Accepts the same search inputs as list, so a client can count exactly the rows its search ' +
'returns.',
});

const ParentIdSchema = z.union([z.string().regex(/\S/), z.number()]).openapi('ParentId', {
description:
Expand All @@ -88,7 +114,7 @@ export const RelationListRequestSchema = ListRequestSchema.extend({
parentId: ParentIdSchema,
}).openapi('RelationListRequest', {
description:
'Filter, sort and projection apply to the FOREIGN collection; the parent only resolves ' +
'Filter, sort, projection and search apply to the FOREIGN collection; the parent only resolves ' +
'which records are related.',
});

Expand Down
130 changes: 130 additions & 0 deletions packages/agent-bff/test/data/agent-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,84 @@ describe('buildCountAgentQuery', () => {
});
});

describe('search in the outgoing agent query', () => {
it('should send the search term under the wire name the agent reads', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { search: 'ada' })).toEqual({
timezone: 'Europe/Paris',
search: 'ada',
});
});

it('should send searchExtended under the wire name the agent reads', () => {
expect(
buildListAgentQuery('users', 'Europe/Paris', { search: 'ada', searchExtended: true }),
).toEqual({ timezone: 'Europe/Paris', search: 'ada', searchExtended: true });
});

it('should send searchExtended false when explicitly disabled alongside a search', () => {
expect(
buildListAgentQuery('users', 'Europe/Paris', { search: 'ada', searchExtended: false }),
).toEqual({ timezone: 'Europe/Paris', search: 'ada', searchExtended: false });
});

it('should send both the filter and the search so the agent intersects them', () => {
expect(
buildListAgentQuery('users', 'Europe/Paris', {
filter: { field: 'active', operator: 'equal', value: true },
search: 'ada',
}),
).toEqual({
timezone: 'Europe/Paris',
filters: JSON.stringify({ field: 'active', operator: 'equal', value: true }),
search: 'ada',
});
});

it('should treat an empty search as absent', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { search: '' })).toEqual({
timezone: 'Europe/Paris',
});
});

it('should treat a whitespace-only search as absent', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { search: ' ' })).toEqual({
timezone: 'Europe/Paris',
});
});

it('should not send searchExtended when it arrives without a search', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { searchExtended: true })).toEqual({
timezone: 'Europe/Paris',
});
});

it('should not send searchExtended when the search it accompanies is blank', () => {
expect(
buildListAgentQuery('users', 'Europe/Paris', { search: ' ', searchExtended: true }),
).toEqual({ timezone: 'Europe/Paris' });
});

it('should send the search term unchanged, including its inner spacing', () => {
expect(buildListAgentQuery('users', 'Europe/Paris', { search: 'ada lovelace' }).search).toBe(
'ada lovelace',
);
});

it('should accept the same search inputs on count as on list', () => {
expect(buildCountAgentQuery('Europe/Paris', { search: 'ada', searchExtended: true })).toEqual({
timezone: 'Europe/Paris',
search: 'ada',
searchExtended: true,
});
});

it('should leave the count query untouched when the search is blank', () => {
expect(buildCountAgentQuery('UTC', { search: ' ', searchExtended: true })).toEqual({
timezone: 'UTC',
});
});
});

describe('collectListFieldPaths', () => {
it('should collect field paths from projection, filter and sort', () => {
const paths = collectListFieldPaths({
Expand Down Expand Up @@ -113,6 +191,43 @@ describe('parseListRequest', () => {
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
});

it('should accept a body carrying search and searchExtended', () => {
const body = { search: 'ada', searchExtended: true };

expect(parseListRequest(body)).toBe(body);
});

it('should accept a blank search rather than rejecting a cleared search box', () => {
const body = { search: ' ' };

expect(parseListRequest(body)).toBe(body);
});

it.each([
['a non-string search', { search: 42 }],
['a null search', { search: null }],
['an array search', { search: ['ada'] }],
])('should reject %s with 400 invalid_request', (_label, body) => {
expect(() => parseListRequest(body)).toThrow(
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
});

it.each([
['the string "true"', { search: 'ada', searchExtended: 'true' }],
['the string "false"', { search: 'ada', searchExtended: 'false' }],
['the number 1', { search: 'ada', searchExtended: 1 }],
['the string "0"', { search: 'ada', searchExtended: '0' }],
['a null value', { search: 'ada', searchExtended: null }],
])(
'should reject searchExtended sent as %s rather than coercing it like the agent does',
(_label, body) => {
expect(() => parseListRequest(body)).toThrow(
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
},
);
});

describe('parseCountRequest', () => {
Expand All @@ -131,6 +246,21 @@ describe('parseCountRequest', () => {
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
});

it('should accept a body carrying search and searchExtended', () => {
const body = { search: 'ada', searchExtended: false };

expect(parseCountRequest(body)).toBe(body);
});

it.each([
['a non-string search', { search: 42 }],
['a non-boolean searchExtended', { search: 'ada', searchExtended: 'true' }],
])('should reject %s with 400 invalid_request', (_label, body) => {
expect(() => parseCountRequest(body)).toThrow(
expect.objectContaining({ type: 'invalid_request', status: 400 }),
);
});
});

describe('a filter node readable as both a leaf and a branch', () => {
Expand Down
Loading
Loading