diff --git a/packages/agent-bff/src/cors/cors-middleware.ts b/packages/agent-bff/src/cors/cors-middleware.ts index 0ca76a7dd4..b737af31d5 100644 --- a/packages/agent-bff/src/cors/cors-middleware.ts +++ b/packages/agent-bff/src/cors/cors-middleware.ts @@ -4,7 +4,7 @@ import { originAllowed } from './origin'; export const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; export const ALLOWED_HEADERS = - 'Authorization, Content-Type, X-Forest-Timezone, X-Forest-Bff-Key, X-Request-Id'; + 'Authorization, Content-Type, X-Forest-Timezone, X-Forest-Bff-Key, X-Request-Id, Forest-Projection'; export const PREFLIGHT_MAX_AGE_SECONDS = 600; export interface CorsMiddlewareOptions { diff --git a/packages/agent-bff/test/cors/cors-middleware.test.ts b/packages/agent-bff/test/cors/cors-middleware.test.ts index f90630e5a1..52a6194e63 100644 --- a/packages/agent-bff/test/cors/cors-middleware.test.ts +++ b/packages/agent-bff/test/cors/cors-middleware.test.ts @@ -74,13 +74,14 @@ describe('cors middleware (layer 1)', () => { expect(terminal).not.toHaveBeenCalled(); }); - it('lists all five allowed request headers', () => { + it('lists all six allowed request headers', () => { expect(ALLOWED_HEADERS.split(', ')).toEqual([ 'Authorization', 'Content-Type', 'X-Forest-Timezone', 'X-Forest-Bff-Key', 'X-Request-Id', + 'Forest-Projection', ]); }); diff --git a/packages/agent/src/routes/access/get.ts b/packages/agent/src/routes/access/get.ts index 4e7af200bf..718a50348c 100644 --- a/packages/agent/src/routes/access/get.ts +++ b/packages/agent/src/routes/access/get.ts @@ -24,10 +24,14 @@ export default class GetRoute extends CollectionRoute { ), }); + const projection = + QueryStringParser.parseProjectionFromHeader(this.collection, context) ?? + QueryStringParser.parseProjection(this.collection, context); + const records = await this.collection.list( QueryStringParser.parseCaller(context), filter, - QueryStringParser.parseProjectionWithPks(this.collection, context), + projection.withPks(this.collection), ); if (!records.length) { diff --git a/packages/agent/src/routes/capabilities.ts b/packages/agent/src/routes/capabilities.ts index 02fe46ab8b..744bccc702 100644 --- a/packages/agent/src/routes/capabilities.ts +++ b/packages/agent/src/routes/capabilities.ts @@ -39,6 +39,7 @@ export default class Capabilities extends BaseRoute { ), agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: diff --git a/packages/agent/src/utils/query-string.ts b/packages/agent/src/utils/query-string.ts index 8b726af409..fcd942a93d 100644 --- a/packages/agent/src/utils/query-string.ts +++ b/packages/agent/src/utils/query-string.ts @@ -71,6 +71,21 @@ export default class QueryStringParser { } } + static parseProjectionFromHeader(collection: Collection, context: Context): Projection | null { + const header = context.request.headers['forest-projection']?.toString().trim(); + if (!header) return null; + + try { + const fields = header.split(',').map(field => field.trim()); + + ProjectionValidator.validate(collection, fields); + + return new Projection(...fields); + } catch (e) { + throw new ValidationError(`Invalid Forest-Projection header: ${e.message}`); + } + } + static parseProjectionWithPks(collection: Collection, context: Context): Projection { const projection = QueryStringParser.parseProjection(collection, context); diff --git a/packages/agent/test/agent-integration.test.ts b/packages/agent/test/agent-integration.test.ts index 1fec67e3fa..3e09425b00 100644 --- a/packages/agent/test/agent-integration.test.ts +++ b/packages/agent/test/agent-integration.test.ts @@ -302,6 +302,43 @@ describe('Agent Integration Tests', () => { await expect(superagent.get(`${testContext.baseUrl}/forest/users`)).rejects.toThrow(); }); + it('should allow the Forest-Projection header on CORS preflight requests', async () => { + const response = await superagent + .options(`${testContext.baseUrl}/forest/users/1`) + .set('Origin', 'https://app.forestadmin.com') + .set('Access-Control-Request-Method', 'GET') + .set('Access-Control-Request-Headers', 'authorization,forest-projection'); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-headers']).toContain('forest-projection'); + }); + + it('should honor the Forest-Projection header on get-one requests', async () => { + const token = createTestToken(); + + const response = await superagent + .get(`${testContext.baseUrl}/forest/users/1`) + .query({ timezone: 'Europe/Paris' }) + .set('Authorization', `Bearer ${token}`) + .set('Forest-Projection', 'firstName'); + + expect(response.status).toBe(200); + expect(response.body.data.attributes).toEqual({ id: 1, firstName: 'John' }); + }); + + it('should return 400 when the Forest-Projection header is invalid', async () => { + const token = createTestToken(); + + const error: { status?: number } = await superagent + .get(`${testContext.baseUrl}/forest/users/1`) + .query({ timezone: 'Europe/Paris' }) + .set('Authorization', `Bearer ${token}`) + .set('Forest-Projection', 'field-that-do-not-exist') + .catch(err => err); + + expect(error.status).toBe(400); + }); + it('should accept authenticated requests with valid JWT', async () => { const token = createTestToken(); diff --git a/packages/agent/test/routes/access/get.test.ts b/packages/agent/test/routes/access/get.test.ts index 7fe5d8e03d..ee2ecccfab 100644 --- a/packages/agent/test/routes/access/get.test.ts +++ b/packages/agent/test/routes/access/get.test.ts @@ -207,6 +207,97 @@ describe('GetRoute', () => { ); }); + test('it should read the projection from the Forest-Projection header', async () => { + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ title: 'test ' }]); + services.serializer.serialize = jest.fn().mockReturnValue('test'); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': 'name,author:id' }, + customProperties: { + query: { timezone: 'Europe/Paris' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await get.handleGet(context); + + expect(dataSource.getCollection('books').list).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + ['name', 'author:id', 'id'], + ); + }); + + test('it should give precedence to the Forest-Projection header over query params', async () => { + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ title: 'test ' }]); + services.serializer.serialize = jest.fn().mockReturnValue('test'); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': 'author:id' }, + customProperties: { + query: { timezone: 'Europe/Paris', 'fields[books]': 'name' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await get.handleGet(context); + + expect(dataSource.getCollection('books').list).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + ['author:id', 'id'], + ); + }); + + test('it should not fall back to query params when the header is invalid', async () => { + const listSpy = jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ title: 'test ' }]); + listSpy.mockClear(); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': 'field-that-do-not-exist' }, + customProperties: { + query: { timezone: 'Europe/Paris', 'fields[books]': 'name' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await expect(get.handleGet(context)).rejects.toThrow('Invalid Forest-Projection header'); + expect(listSpy).not.toHaveBeenCalled(); + }); + + test('it should fall back to query params when the header is empty', async () => { + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ title: 'test ' }]); + services.serializer.serialize = jest.fn().mockReturnValue('test'); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': '' }, + customProperties: { + query: { timezone: 'Europe/Paris', 'fields[books]': 'name' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await get.handleGet(context); + + expect(dataSource.getCollection('books').list).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + ['name', 'id'], + ); + }); + test('it should handle multiple projected fields on a belongsTo relation', async () => { jest .spyOn(dataSource.getCollection('books'), 'list') @@ -257,6 +348,76 @@ describe('GetRoute', () => { }); }); + describe('with a nested projection in the Forest-Projection header', () => { + test('it should request the nested projection with the intermediate pks', async () => { + const services = factories.forestAdminHttpDriverServices.build(); + const options = factories.forestAdminHttpDriverOptions.build(); + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build({ columnType: 'String' }), + author: factories.oneToOneSchema.build({ + foreignCollection: 'persons', + originKey: 'bookId', + originKeyTarget: 'id', + }), + }, + }), + }), + factories.collection.build({ + name: 'persons', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + bookId: factories.columnSchema.build({ columnType: 'Uuid' }), + addressId: factories.columnSchema.build({ columnType: 'Uuid' }), + address: factories.manyToOneSchema.build({ + foreignCollection: 'addresses', + foreignKey: 'addressId', + }), + }, + }), + }), + factories.collection.build({ + name: 'addresses', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + street: factories.columnSchema.build({ columnType: 'String' }), + }, + }), + }), + ]); + const listSpy = jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: '2d162303-78bf-599e-b197-93590ac3d315' }]); + services.serializer.serialize = jest.fn().mockReturnValue('serialized'); + const get = new Get(services, options, dataSource, 'books'); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + headers: { 'forest-projection': 'name,author:address:street' }, + customProperties: { + query: { timezone: 'Europe/Paris' }, + params: { id: '2d162303-78bf-599e-b197-93590ac3d315' }, + }, + }); + + await get.handleGet(context); + + const projection = listSpy.mock.calls[0][2]; + expect([...projection].sort()).toEqual([ + 'author:address:id', + 'author:address:street', + 'author:id', + 'id', + 'name', + ]); + }); + }); + describe('with special characters in names', () => { it('should register routes with escaped characters', () => { const options = factories.forestAdminHttpDriverOptions.build(); diff --git a/packages/agent/test/routes/capabilities.test.ts b/packages/agent/test/routes/capabilities.test.ts index 1e9068ff6a..49d3b7b912 100644 --- a/packages/agent/test/routes/capabilities.test.ts +++ b/packages/agent/test/routes/capabilities.test.ts @@ -77,6 +77,7 @@ describe('Capabilities', () => { nativeQueryConnections: [{ name: 'main' }, { name: 'replica' }], agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: [], @@ -99,6 +100,7 @@ describe('Capabilities', () => { nativeQueryConnections: [], agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: [], @@ -119,6 +121,7 @@ describe('Capabilities', () => { nativeQueryConnections: [], agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: [], @@ -141,6 +144,7 @@ describe('Capabilities', () => { nativeQueryConnections: [], agentCapabilities: { canUseProjectionOnGetOne: true, + canUseProjectionViaHeader: true, canUseMultipleFieldsProjectionOnRelation: true, }, collections: [ diff --git a/packages/agent/test/services/serializer.test.ts b/packages/agent/test/services/serializer.test.ts index 421502670a..e936f07c68 100644 --- a/packages/agent/test/services/serializer.test.ts +++ b/packages/agent/test/services/serializer.test.ts @@ -388,6 +388,75 @@ describe('Serializer', () => { }); }); + test('serialize should serialize nested relations of relations', () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'book', + schema: factories.collectionSchema.build({ + fields: { + isbn: factories.columnSchema.uuidPrimaryKey().build(), + authorId: factories.columnSchema.build(), + author: factories.manyToOneSchema.build({ + foreignCollection: 'person', + foreignKey: 'authorId', + }), + }, + }), + }), + factories.collection.build({ + name: 'person', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build(), + addressId: factories.columnSchema.build(), + address: factories.manyToOneSchema.build({ + foreignCollection: 'address', + foreignKey: 'addressId', + }), + }, + }), + }), + factories.collection.build({ + name: 'address', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + street: factories.columnSchema.build(), + }, + }), + }), + ]); + + const result = setupSerializer().serialize(dataSource.getCollection('book'), { + isbn: '9780345317988', + author: { id: 'asim00', name: 'Asimov', address: { id: 'addr01', street: 'Main street' } }, + }); + + expect(result).toStrictEqual({ + data: { + type: 'book', + id: '9780345317988', + attributes: { isbn: '9780345317988' }, + relationships: { author: { data: { type: 'person', id: 'asim00' } } }, + }, + included: [ + { + type: 'address', + id: 'addr01', + attributes: { id: 'addr01', street: 'Main street' }, + }, + { + type: 'person', + id: 'asim00', + attributes: { id: 'asim00', name: 'Asimov' }, + relationships: { address: { data: { type: 'address', id: 'addr01' } } }, + }, + ], + jsonapi: { version: '1.0' }, + }); + }); + test('serialize should encode the primary key', () => { const result = setupSerializer().serialize(setupWithRelation().collections[0], { isbn: '9780345317988', diff --git a/packages/agent/test/utils/query-string.test.ts b/packages/agent/test/utils/query-string.test.ts index 3c7fad23c1..dd54ebdf9d 100644 --- a/packages/agent/test/utils/query-string.test.ts +++ b/packages/agent/test/utils/query-string.test.ts @@ -220,6 +220,204 @@ describe('QueryStringParser', () => { }); }); + describe('parseProjectionFromHeader', () => { + test('should return null when the header is missing', () => { + const context = createMockContext({ + customProperties: { query: { 'fields[books]': 'id' } }, + }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toBeNull(); + }); + + test('should return null when the header is empty', () => { + const context = createMockContext({ headers: { 'forest-projection': '' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toBeNull(); + }); + + test('should return null when the header only contains whitespace', () => { + const context = createMockContext({ headers: { 'forest-projection': ' ' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toBeNull(); + }); + + test('should handle a repeated header sent as an array of values', () => { + const context = createMockContext({ + headers: { 'forest-projection': ['id', 'name'] as unknown as string }, + }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toEqual(new Projection('id', 'name')); + }); + + test('should keep duplicated fields as-is, like the query string parsing', () => { + const context = createMockContext({ headers: { 'forest-projection': 'id,id' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toEqual(new Projection('id', 'id')); + }); + + test('should throw a ValidationError on a trailing comma', () => { + const context = createMockContext({ headers: { 'forest-projection': 'id,' } }); + + const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(fn).toThrow(ValidationError); + }); + + test('should throw a ValidationError when a subfield targets a column', () => { + const context = createMockContext({ headers: { 'forest-projection': 'name:foo' } }); + + const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(fn).toThrow(ValidationError); + }); + + test('should convert the header to a valid projection', () => { + const context = createMockContext({ headers: { 'forest-projection': 'id,name' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toEqual(new Projection('id', 'name')); + }); + + test('should trim spaces around field names', () => { + const context = createMockContext({ headers: { 'forest-projection': 'id, name' } }); + + const projection = QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(projection).toEqual(new Projection('id', 'name')); + }); + + test('should support relation fields with the colon notation', () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cars', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + owner: factories.oneToOneSchema.build({ + foreignCollection: 'owner', + originKey: 'id', + originKeyTarget: 'id', + }), + }, + }), + }), + factories.collection.build({ + name: 'owner', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build(), + }, + }), + }), + ]); + + const context = createMockContext({ + headers: { 'forest-projection': 'id,owner:id,owner:name' }, + }); + + const projection = QueryStringParser.parseProjectionFromHeader( + dataSource.getCollection('cars'), + context, + ); + + expect(projection).toEqual(new Projection('id', 'owner:id', 'owner:name')); + }); + + test('should support nested projections through to-one relation chains', () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cars', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + owner: factories.oneToOneSchema.build({ + foreignCollection: 'owner', + originKey: 'id', + originKeyTarget: 'id', + }), + }, + }), + }), + factories.collection.build({ + name: 'owner', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + addressId: factories.columnSchema.build({ columnType: 'Uuid' }), + address: factories.manyToOneSchema.build({ + foreignCollection: 'address', + foreignKey: 'addressId', + }), + }, + }), + }), + factories.collection.build({ + name: 'address', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + street: factories.columnSchema.build(), + countryId: factories.columnSchema.build({ columnType: 'Uuid' }), + country: factories.manyToOneSchema.build({ + foreignCollection: 'country', + foreignKey: 'countryId', + }), + }, + }), + }), + factories.collection.build({ + name: 'country', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build(), + }, + }), + }), + ]); + + const context = createMockContext({ + headers: { + 'forest-projection': 'id,owner:address:street,owner:address:country:name', + }, + }); + + const projection = QueryStringParser.parseProjectionFromHeader( + dataSource.getCollection('cars'), + context, + ); + + expect(projection).toEqual( + new Projection('id', 'owner:address:street', 'owner:address:country:name'), + ); + }); + + test('should throw a ValidationError when the header contains an unknown field', () => { + const context = createMockContext({ + headers: { 'forest-projection': 'field-that-do-not-exist' }, + }); + + const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context); + + expect(fn).toThrow(ValidationError); + expect(fn).toThrow( + "Invalid Forest-Projection header: The 'books.field-that-do-not-exist' field was not found. Available fields are: [id,name]. Please check if the field name is correct.", + ); + }); + }); + describe('parseProjectionWithPks', () => { describe('when the request does not contain the primary keys', () => { test('should return the requested project with the primary keys', () => {