From 0097057ed11e8cd36e424edb3ff37110bc1faf2b Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Tue, 8 Dec 2020 16:37:24 +0100 Subject: [PATCH] Add support for the group profile element to Microsoft Graph processor --- .changeset/witty-scissors-divide.md | 7 ++ .../processors/microsoftGraph/client.test.ts | 44 ++++++- .../processors/microsoftGraph/client.ts | 112 +++++++++++------- .../processors/microsoftGraph/read.test.ts | 25 +++- .../processors/microsoftGraph/read.ts | 39 ++++-- 5 files changed, 172 insertions(+), 55 deletions(-) create mode 100644 .changeset/witty-scissors-divide.md diff --git a/.changeset/witty-scissors-divide.md b/.changeset/witty-scissors-divide.md new file mode 100644 index 0000000000..f288088ef2 --- /dev/null +++ b/.changeset/witty-scissors-divide.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Support `profile` of groups including `displayName` and `email` in +`MicrosoftGraphOrgReaderProcessor`. Importing `picture` doesn't work yet, as +the Microsoft Graph API does not expose them correctly. diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts index e51a7753af..5ef82432bb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.test.ts @@ -210,7 +210,7 @@ describe('MicrosoftGraphClient', () => { expect(photo).toBeFalsy(); }); - it('should load profile photo', async () => { + it('should load user profile photo', async () => { worker.use( rest.get('https://example.com/users/user-id/photo/*', (_, res, ctx) => res(ctx.status(200), ctx.text('911')), @@ -222,7 +222,7 @@ describe('MicrosoftGraphClient', () => { expect(photo).toEqual('data:image/jpeg;base64,OTEx'); }); - it('should load profile photo for size 120', async () => { + it('should load user profile photo for size 120', async () => { worker.use( rest.get( 'https://example.com/users/user-id/photos/120/*', @@ -252,6 +252,46 @@ describe('MicrosoftGraphClient', () => { expect(values).toEqual([{ surname: 'Example' }]); }); + it('should load group profile photo with max size of 120', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/photos', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + value: [ + { + height: 120, + id: 120, + }, + ], + }), + ), + ), + ); + worker.use( + rest.get( + 'https://example.com/groups/group-id/photos/120/*', + (_, res, ctx) => res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getGroupPhotoWithSizeLimit('group-id', 120); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + + it('should load group profile photo', async () => { + worker.use( + rest.get('https://example.com/groups/group-id/photo/*', (_, res, ctx) => + res(ctx.status(200), ctx.text('911')), + ), + ); + + const photo = await client.getGroupPhoto('group-id'); + + expect(photo).toEqual('data:image/jpeg;base64,OTEx'); + }); + it('should load groups', async () => { worker.use( rest.get('https://example.com/groups', (_, res, ctx) => diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts index 9db76542f4..bdb5918b2f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts @@ -117,59 +117,34 @@ export class MicrosoftGraphClient { userId: string, maxSize: number, ): Promise { - const response = await this.requestApi(`users/${userId}/photos`); - - if (response.status === 404) { - return undefined; - } else if (response.status !== 200) { - await this.handleError('user photos', response); - } - - const result = await response.json(); - const photos = result.value as MicrosoftGraph.ProfilePhoto[]; - let selectedPhoto: MicrosoftGraph.ProfilePhoto | undefined = undefined; - - // Find the biggest picture that is smaller than the max size - for (const p of photos) { - if ( - !selectedPhoto || - (p.height! >= selectedPhoto.height! && p.height! <= maxSize) - ) { - selectedPhoto = p; - } - } - - if (!selectedPhoto) { - return undefined; - } - - return await this.getUserPhoto(userId, selectedPhoto.id!); + return await this.getPhotoWithSizeLimit('users', userId, maxSize); } async getUserPhoto( userId: string, sizeId?: string, ): Promise { - const path = sizeId - ? `users/${userId}/photos/${sizeId}/$value` - : `users/${userId}/photo/$value`; - const response = await this.requestApi(path); - - if (response.status === 404) { - return undefined; - } else if (response.status !== 200) { - await this.handleError('photo', response); - } - - return `data:image/jpeg;base64,${Buffer.from( - await response.arrayBuffer(), - ).toString('base64')}`; + return await this.getPhoto('users', userId, sizeId); } async *getUsers(query?: ODataQuery): AsyncIterable { yield* this.requestCollection(`users`, query); } + async getGroupPhotoWithSizeLimit( + groupId: string, + maxSize: number, + ): Promise { + return await this.getPhotoWithSizeLimit('groups', groupId, maxSize); + } + + async getGroupPhoto( + groupId: string, + sizeId?: string, + ): Promise { + return await this.getPhoto('groups', groupId, sizeId); + } + async *getGroups(query?: ODataQuery): AsyncIterable { yield* this.requestCollection(`groups`, query); } @@ -190,6 +165,61 @@ export class MicrosoftGraphClient { return await response.json(); } + private async getPhotoWithSizeLimit( + entityName: string, + id: string, + maxSize: number, + ): Promise { + const response = await this.requestApi(`${entityName}/${id}/photos`); + + if (response.status === 404) { + return undefined; + } else if (response.status !== 200) { + await this.handleError(`${entityName} photos`, response); + } + + const result = await response.json(); + const photos = result.value as MicrosoftGraph.ProfilePhoto[]; + let selectedPhoto: MicrosoftGraph.ProfilePhoto | undefined = undefined; + + // Find the biggest picture that is smaller than the max size + for (const p of photos) { + if ( + !selectedPhoto || + (p.height! >= selectedPhoto.height! && p.height! <= maxSize) + ) { + selectedPhoto = p; + } + } + + if (!selectedPhoto) { + return undefined; + } + + return await this.getPhoto(entityName, id, selectedPhoto.id!); + } + + private async getPhoto( + entityName: string, + id: string, + sizeId?: string, + ): Promise { + const path = sizeId + ? `${entityName}/${id}/photos/${sizeId}/$value` + : `${entityName}/${id}/photo/$value`; + const response = await this.requestApi(path); + + if (response.status === 404) { + return undefined; + } else if (response.status !== 200) { + await this.handleError('photo', response); + } + + return `data:image/jpeg;base64,${Buffer.from( + await response.arrayBuffer(), + ).toString('base64')}`; + } + private async handleError(path: string, response: Response): Promise { const result = await response.json(); const error = result.error as MicrosoftGraph.PublicError; diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts index b0d446c417..d8598da1a9 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.test.ts @@ -63,6 +63,7 @@ describe('read microsoft graph', () => { getGroups: jest.fn(), getGroupMembers: jest.fn(), getUserPhotoWithSizeLimit: jest.fn(), + getGroupPhotoWithSizeLimit: jest.fn(), getOrganization: jest.fn(), } as any; @@ -150,6 +151,9 @@ describe('read microsoft graph', () => { }, spec: { type: 'root', + profile: { + displayName: 'Organization Name', + }, }, }), ); @@ -165,6 +169,8 @@ describe('read microsoft graph', () => { yield { id: 'groupid', displayName: 'Group Name', + description: 'Group Description', + mail: 'group@example.com', }; } @@ -185,6 +191,9 @@ describe('read microsoft graph', () => { id: 'tenantid', displayName: 'Organization Name', }); + client.getGroupPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); const { groups, @@ -205,6 +214,9 @@ describe('read microsoft graph', () => { }, spec: { type: 'root', + profile: { + displayName: 'Organization Name', + }, }, }); expect(groups).toEqual([ @@ -215,10 +227,17 @@ describe('read microsoft graph', () => { 'graph.microsoft.com/group-id': 'groupid', }, name: 'group_name', - description: 'Group Name', + description: 'Group Description', }, spec: { type: 'team', + profile: { + displayName: 'Group Name', + email: 'group@example.com', + // TODO: Loading groups doesn't work right now as Microsoft Graph + // doesn't allows this yet + /* picture: 'data:image/jpeg;base64,...',*/ + }, }, }), ]); @@ -230,10 +249,12 @@ describe('read microsoft graph', () => { expect(client.getGroups).toBeCalledTimes(1); expect(client.getGroups).toBeCalledWith({ filter: 'securityEnabled eq false', - select: ['id', 'displayName', 'mailNickname'], + select: ['id', 'displayName', 'description', 'mail', 'mailNickname'], }); expect(client.getGroupMembers).toBeCalledTimes(1); expect(client.getGroupMembers).toBeCalledWith('groupid'); + expect(client.getGroupPhotoWithSizeLimit).toBeCalledTimes(1); + expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts index 532e1c3777..448ff82b9f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/read.ts @@ -37,7 +37,7 @@ export async function readMicrosoftGraphUsers( users: UserEntity[]; // With all relations empty }> { const entities: UserEntity[] = []; - const picturePromises: Promise[] = []; + const promises: Promise[] = []; const limiter = limiterFactory(10); for await (const user of client.getUsers({ @@ -82,12 +82,12 @@ export async function readMicrosoftGraphUsers( ); }); - picturePromises.push(loadPhoto); + promises.push(loadPhoto); entities.push(entity); } // Wait for all photos to be downloaded - await Promise.all(picturePromises); + await Promise.all(promises); return { users: entities }; } @@ -113,6 +113,9 @@ export async function readMicrosoftGraphOrganization( }, spec: { type: 'root', + profile: { + displayName: organization.displayName!, + }, children: [], }, }; @@ -139,11 +142,11 @@ export async function readMicrosoftGraphGroups( groupMember.set(rootGroup.metadata.name, new Set()); groups.push(rootGroup); - const groupMemberPromises: Promise[] = []; + const promises: Promise[] = []; for await (const group of client.getGroups({ filter: options?.groupFilter, - select: ['id', 'displayName', 'mailNickname'], + select: ['id', 'displayName', 'description', 'mail', 'mailNickname'], })) { if (!group.id || !group.displayName) { continue; @@ -155,14 +158,17 @@ export async function readMicrosoftGraphGroups( kind: 'Group', metadata: { name: name, - description: group.displayName, + description: group.description ?? undefined, annotations: { [MICROSOFT_GRAPH_GROUP_ID_ANNOTATION]: group.id, }, }, spec: { type: 'team', - // TODO: We could include a group email and picture + profile: { + displayName: group.displayName, + email: group.mail ?? undefined, + }, children: [], }, }; @@ -184,12 +190,25 @@ export async function readMicrosoftGraphGroups( } }); - groupMemberPromises.push(loadGroupMembers); + // TODO: Loading groups doesn't work right now as Microsoft Graph doesn't + // allows this yet: https://microsoftgraph.uservoice.com/forums/920506-microsoft-graph-feature-requests/suggestions/37884922-allow-application-to-set-or-update-a-group-s-photo + /*/ / Download the photos in parallel, otherwise it can take quite some time + const loadPhoto = limiter(async () => { + entity.spec.profile!.picture = await client.getGroupPhotoWithSizeLimit( + group.id!, + // We are limiting the photo size, as groups with full resolution photos + // can make the Backstage API slow + 120, + ); + }); + + promises.push(loadPhoto);*/ + promises.push(loadGroupMembers); groups.push(entity); } - // Wait for all group members to be loaded - await Promise.all(groupMemberPromises); + // Wait for all group members and photos to be loaded + await Promise.all(promises); return { groups,