Skip to content
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

N21-1941 Bulk import for external tools #5023

Merged
merged 8 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import { SchoolExternalToolEntity } from '../../../school-external-tool/entity';
import { ExternalToolEntity } from '../../entity';
import { externalToolEntityFactory, externalToolFactory } from '../../testing';
import {
ExternalToolBulkCreateParams,
ExternalToolCreateParams,
ExternalToolImportResultListResponse,
ExternalToolImportResultResponse,
ExternalToolMetadataResponse,
ExternalToolResponse,
ExternalToolSearchListResponse,
Expand Down Expand Up @@ -221,6 +224,108 @@ describe('ToolController (API)', () => {
});
});

describe('[POST] tools/external-tools', () => {
const logoUrl = 'https://link.to-my-logo.com';
const postParams: ExternalToolCreateParams = {
name: 'Tool 1',
parameters: [
{
name: 'key',
description: 'This is a parameter.',
displayName: 'User Friendly Name',
defaultValue: 'abc',
isOptional: false,
isProtected: false,
type: CustomParameterTypeParams.STRING,
regex: 'abc',
regexComment: 'Regex accepts "abc" as value.',
location: CustomParameterLocationParams.PATH,
scope: CustomParameterScopeTypeParams.GLOBAL,
},
],
config: {
type: ToolConfigType.BASIC,
baseUrl: 'https://link.to-my-tool.com/:key',
},
isHidden: false,
isDeactivated: false,
logoUrl,
url: 'https://link.to-my-tool.com',
openNewTab: true,
medium: {
mediumId: 'medium:1',
mediaSourceId: 'source:1',
},
};

describe('when valid data is given', () => {
const setup = async () => {
const params: ExternalToolBulkCreateParams = { data: [{ ...postParams }] };

const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]);
await em.persistAndFlush([adminAccount, adminUser]);
em.clear();

const base64Logo: string = externalToolFactory.withBase64Logo().build().logo as string;
const logoBuffer: Buffer = Buffer.from(base64Logo, 'base64');
axiosMock.onGet(logoUrl).reply(HttpStatus.OK, logoBuffer);

const loggedInClient: TestApiClient = await testApiClient.login(adminAccount);

return {
loggedInClient,
params,
};
};

it('should create a tool', async () => {
const { loggedInClient, params } = await setup();

const response: Response = await loggedInClient.post('/import', params);
const body: ExternalToolImportResultListResponse = response.body as ExternalToolImportResultListResponse;

expect(body.results[0]?.toolId).toBeDefined();
const loaded: Loaded<ExternalToolEntity> = await em.findOneOrFail(ExternalToolEntity, {
id: body.results[0].toolId,
});
expect(loaded).toBeDefined();
});

it('should return the created tool', async () => {
const { loggedInClient, params } = await setup();

const response: Response = await loggedInClient.post('/import', params);
const body: ExternalToolImportResultListResponse = response.body as ExternalToolImportResultListResponse;

expect(response.statusCode).toEqual(HttpStatus.CREATED);
expect(body.results[0]).toBeDefined();
expect(body.results[0]).toEqual<ExternalToolImportResultResponse>({
toolId: expect.any(String),
mediumId: postParams.medium?.mediumId,
mediumSourceId: postParams.medium?.mediaSourceId,
toolName: postParams.name,
error: undefined,
});
});
});

describe('when user is not authenticated', () => {
const setup = () => {
const params: ExternalToolBulkCreateParams = { data: [{ ...postParams }] };

return { params };
};

it('should return unauthorized', async () => {
const { params } = setup();

const response: Response = await testApiClient.post('/import', params);

expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED);
});
});
});

describe('[GET] tools/external-tools', () => {
describe('when requesting tools', () => {
const setup = async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';
import { ExternalToolCreateParams } from './external-tool-create.params';

export class ExternalToolBulkCreateParams {
@ValidateNested()
@Type(() => ExternalToolCreateParams)
@ApiProperty({ type: [ExternalToolCreateParams], description: 'List of external tools' })
data!: ExternalToolCreateParams[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './context-ref.params';
export * from './school-external-tool-id.params';
export * from './context-external-tool-id.params';
export { ExternalToolMediumParams } from './external-tool-medium.params';
export { ExternalToolBulkCreateParams } from './external-tool-bulk-create.params';
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ApiProperty } from '@nestjs/swagger';

export class ExternalToolImportResultResponse {
@ApiProperty({ description: 'Name of the external tool' })
toolName: string;

@ApiProperty({ description: 'Medium id of the external tool' })
mediumId?: string;

@ApiProperty({ description: 'Medium source of the external tool' })
mediumSourceId?: string;

@ApiProperty({ description: 'ObjectId of the created external tool' })
toolId?: string;

@ApiProperty({ description: 'Status message of the error that occurred' })
error?: string;

constructor(props: ExternalToolImportResultResponse) {
this.toolName = props.toolName;
this.mediumId = props.mediumId;
this.mediumSourceId = props.mediumSourceId;
this.toolId = props.toolId;
this.error = props.error;
}
}

export class ExternalToolImportResultListResponse {
@ApiProperty({
type: [ExternalToolImportResultResponse],
description: 'List of operation results for the provided external tools',
})
results: ExternalToolImportResultResponse[];

constructor(props: ExternalToolImportResultListResponse) {
this.results = props.results;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ export * from './school-external-tool-configuration-template-list.response';
export * from './external-tool-metadata.response';
export * from './tool-context-types-list.response';
export { ExternalToolMediumResponse } from './external-tool-medium.response';
export {
ExternalToolImportResultListResponse,
ExternalToolImportResultResponse,
} from './external-tool-import-result-response';
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
StreamableFile,
} from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiFoundResponse,
Expand All @@ -36,10 +37,12 @@ import { ExternalToolLogo } from '../domain/external-tool-logo';

import { ExternalToolMetadataMapper, ExternalToolRequestMapper, ExternalToolResponseMapper } from '../mapper';
import { ExternalToolLogoService } from '../service';
import { ExternalToolCreate, ExternalToolUc, ExternalToolUpdate } from '../uc';
import { ExternalToolCreate, ExternalToolImportResult, ExternalToolUc, ExternalToolUpdate } from '../uc';
import {
ExternalToolBulkCreateParams,
ExternalToolCreateParams,
ExternalToolIdParams,
ExternalToolImportResultListResponse,
ExternalToolMetadataResponse,
ExternalToolResponse,
ExternalToolSearchListResponse,
Expand Down Expand Up @@ -81,6 +84,28 @@ export class ToolController {
return mapped;
}

@Post('/import')
@ApiCreatedResponse({ description: 'The Tool has been successfully created.', type: ExternalToolResponse })
@ApiForbiddenResponse({ description: 'User is not allowed to access this resource.' })
@ApiUnauthorizedResponse({ description: 'User is not logged in.' })
@ApiBadRequestResponse({ description: 'Request data has invalid format.' })
@ApiOperation({ summary: 'Creates multiple ExternalTools at the same time.' })
async importExternalTools(
@CurrentUser() currentUser: ICurrentUser,
@Body() externalToolBulkParams: ExternalToolBulkCreateParams
): Promise<ExternalToolImportResultListResponse> {
const externalTools: ExternalToolCreate[] = this.externalToolDOMapper.mapBulkCreateRequest(externalToolBulkParams);

const results: ExternalToolImportResult[] = await this.externalToolUc.importExternalTools(
currentUser.userId,
externalTools
);

const response: ExternalToolImportResultListResponse = ExternalToolResponseMapper.mapToImportResponse(results);

return response;
}

@Get()
@ApiFoundResponse({ description: 'Tools has been found.', type: ExternalToolSearchListResponse })
@ApiUnauthorizedResponse()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Oauth2ToolConfigUpdateParams,
SortExternalToolParams,
} from '../controller/dto';
import { ExternalToolBulkCreateParams } from '../controller/dto/request/external-tool-bulk-create.params';
import { ExternalTool } from '../domain';
import {
BasicToolConfigDto,
Expand Down Expand Up @@ -119,6 +120,14 @@ export class ExternalToolRequestMapper {
};
}

public mapBulkCreateRequest(externalToolCreateParams: ExternalToolBulkCreateParams): ExternalToolCreate[] {
MarvinOehlerkingCap marked this conversation as resolved.
Show resolved Hide resolved
const toolList: ExternalToolCreate[] = externalToolCreateParams.data.map(
(createParams: ExternalToolCreateParams): ExternalToolCreate => this.mapCreateRequest(createParams)
);

return toolList;
}

private mapRequestToExternalToolMedium(
externalToolMediumParams: ExternalToolMediumParams | undefined
): ExternalToolMediumDto | undefined {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import {
import {
BasicToolConfigResponse,
CustomParameterResponse,
ExternalToolImportResultListResponse,
ExternalToolImportResultResponse,
ExternalToolMediumResponse,
ExternalToolResponse,
Lti11ToolConfigResponse,
Oauth2ToolConfigResponse,
} from '../controller/dto';
import { BasicToolConfig, ExternalTool, ExternalToolMedium, Lti11ToolConfig, Oauth2ToolConfig } from '../domain';
import { ExternalToolImportResult } from '../uc';

const scopeMapping: Record<CustomParameterScope, CustomParameterScopeTypeParams> = {
[CustomParameterScope.GLOBAL]: CustomParameterScopeTypeParams.GLOBAL,
Expand Down Expand Up @@ -110,4 +113,15 @@ export class ExternalToolResponseMapper {
};
});
}

static mapToImportResponse(results: ExternalToolImportResult[]): ExternalToolImportResultListResponse {
MarvinOehlerkingCap marked this conversation as resolved.
Show resolved Hide resolved
const response: ExternalToolImportResultListResponse = new ExternalToolImportResultListResponse({
results: results.map(
(result: ExternalToolImportResult): ExternalToolImportResultResponse =>
new ExternalToolImportResultResponse(result)
),
});

return response;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export class ExternalToolImportResult {
toolName: string;

mediumId?: string;

mediumSourceId?: string;

toolId?: string;

error?: string;

constructor(props: ExternalToolImportResult) {
this.toolName = props.toolName;
this.mediumId = props.mediumId;
this.mediumSourceId = props.mediumSourceId;
this.toolId = props.toolId;
this.error = props.error;
}
}
1 change: 1 addition & 0 deletions apps/server/src/modules/tool/external-tool/uc/dto/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './external-tool.types';
export * from './external-tool-configuration.types';
export { ExternalToolImportResult } from './external-tool-import-result';
Loading
Loading