-
Notifications
You must be signed in to change notification settings - Fork 12
feat(agent-bff): expose full-text search on the list and count endpoints #1843
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -61,22 +61,52 @@ export const TimezoneSchema = z.string().openapi('Timezone', { | |||||||||||
| 'missing_timezone.', | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| export const SearchSchema = z.string().openapi('Search', { | ||||||||||||
| description: | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The relation-field warning is on the wrong schema: Mechanism: Worth deciding beyond the doc: the BFF exposure allow-list is separate from agent permissions (
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verified, and it is worse than the doc placement. I reproduced the mechanism you describe: Against a real agent with
Search is the only one of the four doors open. The hidden data is not returned (projection stays on Doc fixed as you asked, on Two integration tests pin the contrast so it cannot drift silently: the search crossing the relation, and the same path in a filter drawing 422. On "worth deciding beyond the doc" β agreed, and I did not want to decide it inside this PR. Blocking it needs the relation paths extracted from the query string, which means running the agent's own It also rests on a question I do not think this PR should answer alone: is the BFF exposure allow-list a security boundary, or a convenience that trims what a client sees? Filed as PRD-1037 with the evidence, both options (authorize the relation target against the read-model, vs reject any relation path) and that open question. If you read the allow-list as a real boundary, say so and I will treat PRD-1037 as a prerequisite to this merge rather than a follow-up. |
||||||||||||
| "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. The value is a query, not a plain term: `column:value` narrows the ' + | ||||||||||||
| 'search to one column, and `relation.column:value` narrows it to a column of a related ' + | ||||||||||||
| 'collection β so a search reaches relation fields on its own, with no `searchExtended`, and ' + | ||||||||||||
| 'escapes the 422 relation_field_not_supported that the same path draws in `filter`, `sort` or ' + | ||||||||||||
| '`projection`. The agent resolves a relation named in a query against its own schema, so a ' + | ||||||||||||
| 'query can filter on a column of a collection this BFF does not expose.', | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| export const SearchExtendedSchema = z.boolean().openapi('SearchExtended', { | ||||||||||||
| description: | ||||||||||||
| 'Widens `search` to every related collection reachable from this one, instead of only this ' + | ||||||||||||
| "collection's own columns. Meaningless on its own: sent without `search` it is ignored and " + | ||||||||||||
| 'changes nothing. It is not the only way a search reaches a relation β see `Search` for the ' + | ||||||||||||
| '`relation.column:value` syntax, which does so without this flag.', | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| 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: | ||||||||||||
|
|
@@ -88,7 +118,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.', | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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({ | ||
|
|
@@ -113,6 +191,42 @@ describe('parseListRequest', () => { | |
| expect.objectContaining({ type: 'invalid_request', status: 400 }), | ||
| ); | ||
| }); | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, Left the pre-existing |
||
| it('should pass search and searchExtended through rather than strip them', () => { | ||
| expect(parseListRequest({ search: 'ada', searchExtended: true })).toMatchObject({ | ||
| search: 'ada', | ||
| searchExtended: true, | ||
| }); | ||
| }); | ||
|
|
||
| it('should accept a blank search rather than rejecting a cleared search box', () => { | ||
| expect(parseListRequest({ search: ' ' })).toMatchObject({ search: ' ' }); | ||
| }); | ||
|
|
||
| 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', () => { | ||
|
|
@@ -131,6 +245,22 @@ describe('parseCountRequest', () => { | |
| expect.objectContaining({ type: 'invalid_request', status: 400 }), | ||
| ); | ||
| }); | ||
|
|
||
| it('should pass search and searchExtended through rather than strip them', () => { | ||
| expect(parseCountRequest({ search: 'ada', searchExtended: false })).toMatchObject({ | ||
| search: 'ada', | ||
| searchExtended: false, | ||
| }); | ||
| }); | ||
|
|
||
| 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', () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
assertValidSearchslots between theparseListRequestcomment and the function it documents, so that comment now reads as this helper's. Move the new function above the three// Validate the untyped request body...lines.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, and I had not noticed. The
// Validate the untyped request body...lines documentedparseListRequestand my helper landed between the two, so they now read as its docblock.assertValidSearchmoved above them.