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
6 changes: 5 additions & 1 deletion packages/agent/src/routes/access/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/routes/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default class Capabilities extends BaseRoute {
),
agentCapabilities: {
canUseProjectionOnGetOne: true,
canUseProjectionViaHeader: true,
canUseMultipleFieldsProjectionOnRelation: true,
},
collections:
Expand Down
23 changes: 23 additions & 0 deletions packages/agent/src/utils/query-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,29 @@ 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());

// Keep parity with the `fields[...]` query params, which cannot express
// projections deeper than one relation level.
const nestedField = fields.find(field => field.split(':').length > 2);

if (nestedField) {
throw new ValidationError(`nested projections are not supported ('${nestedField}')`);
}

ProjectionValidator.validate(collection, fields);

return new Projection(...fields);
} catch (e) {
throw new ValidationError(`Invalid projection: ${e.message}`);
}
}

static parseProjectionWithPks(collection: Collection, context: Context): Projection {
const projection = QueryStringParser.parseProjection(collection, context);

Expand Down
37 changes: 37 additions & 0 deletions packages/agent/test/agent-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
91 changes: 91 additions & 0 deletions packages/agent/test/routes/access/get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 projection');
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')
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/test/routes/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ describe('Capabilities', () => {
nativeQueryConnections: [{ name: 'main' }, { name: 'replica' }],
agentCapabilities: {
canUseProjectionOnGetOne: true,
canUseProjectionViaHeader: true,
canUseMultipleFieldsProjectionOnRelation: true,
},
collections: [],
Expand All @@ -99,6 +100,7 @@ describe('Capabilities', () => {
nativeQueryConnections: [],
agentCapabilities: {
canUseProjectionOnGetOne: true,
canUseProjectionViaHeader: true,
canUseMultipleFieldsProjectionOnRelation: true,
},
collections: [],
Expand All @@ -119,6 +121,7 @@ describe('Capabilities', () => {
nativeQueryConnections: [],
agentCapabilities: {
canUseProjectionOnGetOne: true,
canUseProjectionViaHeader: true,
canUseMultipleFieldsProjectionOnRelation: true,
},
collections: [],
Expand All @@ -141,6 +144,7 @@ describe('Capabilities', () => {
nativeQueryConnections: [],
agentCapabilities: {
canUseProjectionOnGetOne: true,
canUseProjectionViaHeader: true,
canUseMultipleFieldsProjectionOnRelation: true,
},
collections: [
Expand Down
142 changes: 142 additions & 0 deletions packages/agent/test/utils/query-string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,148 @@ 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 throw a ValidationError on nested projections', () => {
const context = createMockContext({
headers: { 'forest-projection': 'id,owner:address:street' },
});

const fn = () => QueryStringParser.parseProjectionFromHeader(collectionSimple, context);

expect(fn).toThrow(ValidationError);
expect(fn).toThrow(
"Invalid projection: nested projections are not supported ('owner:address:street')",
);
});

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 projection: 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', () => {
Expand Down
Loading