Skip to content

Commit

Permalink
Merge pull request #78 from line/dev
Browse files Browse the repository at this point in the history
beta release: 3.2346.16-beta
  • Loading branch information
h4l-yup authored Nov 15, 2023
2 parents 50c76a0 + bec5411 commit 8b8fed0
Show file tree
Hide file tree
Showing 43 changed files with 1,280 additions and 401 deletions.
15 changes: 12 additions & 3 deletions apps/api/src/domains/project/api-key/api-key.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,21 @@ describe('ApiKeyController', () => {
apiKeyController = module.get(ApiKeyController);
});

describe('create', () => {
it('', async () => {
describe('create ', () => {
it('creating succeeds without an api key', async () => {
jest.spyOn(MockApiKeyService, 'create');
const projectId = faker.number.int();

await apiKeyController.create(projectId, {});

expect(MockApiKeyService.create).toBeCalledTimes(1);
});
it('creating succeeds with an api key', async () => {
jest.spyOn(MockApiKeyService, 'create');
const projectId = faker.number.int();
const value = faker.string.alphanumeric(20);

await apiKeyController.create(projectId);
await apiKeyController.create(projectId, { value });

expect(MockApiKeyService.create).toBeCalledTimes(1);
});
Expand Down
12 changes: 10 additions & 2 deletions apps/api/src/domains/project/api-key/api-key.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* under the License.
*/
import {
Body,
Controller,
Delete,
Get,
Expand All @@ -26,6 +27,8 @@ import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { PermissionEnum } from '../role/permission.enum';
import { RequirePermission } from '../role/require-permission.decorator';
import { ApiKeyService } from './api-key.service';
import { CreateApiKeyDto } from './dtos';
import { CreateApiKeyRequestDto } from './dtos/requests';
import {
CreateApiKeyResponseDto,
FindApiKeysResponseDto,
Expand All @@ -39,9 +42,14 @@ export class ApiKeyController {
@RequirePermission(PermissionEnum.project_apikey_create)
@ApiCreatedResponse({ type: CreateApiKeyResponseDto })
@Post()
async create(@Param('projectId', ParseIntPipe) projectId: number) {
async create(
@Param('projectId', ParseIntPipe) projectId: number,
@Body() body: CreateApiKeyRequestDto,
) {
return CreateApiKeyResponseDto.transform(
await this.apiKeyService.create(projectId),
await this.apiKeyService.create(
CreateApiKeyDto.from({ ...body, projectId }),
),
);
}

Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/domains/project/api-key/api-key.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

import { ProjectModule } from '../project/project.module';
import { ProjectEntity } from '../project/project.entity';
import { ApiKeyController } from './api-key.controller';
import { ApiKeyEntity } from './api-key.entity';
import { ApiKeyService } from './api-key.service';

@Module({
imports: [TypeOrmModule.forFeature([ApiKeyEntity]), ProjectModule],
imports: [TypeOrmModule.forFeature([ApiKeyEntity, ProjectEntity])],
providers: [ApiKeyService],
controllers: [ApiKeyController],
exports: [ApiKeyService],
Expand Down
127 changes: 125 additions & 2 deletions apps/api/src/domains/project/api-key/api-key.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
import { randomBytes } from 'crypto';
import { faker } from '@faker-js/faker';
import { BadRequestException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
Expand All @@ -25,6 +26,7 @@ import { ProjectNotFoundException } from '../project/exceptions';
import { ProjectEntity } from '../project/project.entity';
import { ApiKeyEntity } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
import type { CreateApiKeyDto } from './dtos';

describe('ApiKeyService', () => {
let apiKeyService: ApiKeyService;
Expand Down Expand Up @@ -52,7 +54,24 @@ describe('ApiKeyService', () => {
value: randomBytes(10).toString('hex').toUpperCase(),
} as ApiKeyEntity);

const apiKey = await apiKeyService.create(projectId);
const apiKey = await apiKeyService.create({ projectId });

expect(projectRepo.findOneBy).toBeCalledTimes(1);
expect(projectRepo.findOneBy).toBeCalledWith({ id: projectId });
expect(apiKeyRepo.save).toBeCalledTimes(1);
expect(apiKey.value).toHaveLength(20);
});
it('creating an api key succeeds with a valid project id and a key', async () => {
const projectId = faker.number.int();
const value = faker.string.alphanumeric(20);
jest
.spyOn(projectRepo, 'findOneBy')
.mockResolvedValueOnce({ id: projectId } as ProjectEntity);
jest.spyOn(apiKeyRepo, 'save').mockResolvedValueOnce({
value: randomBytes(10).toString('hex').toUpperCase(),
} as ApiKeyEntity);

const apiKey = await apiKeyService.create({ projectId, value });

expect(projectRepo.findOneBy).toBeCalledTimes(1);
expect(projectRepo.findOneBy).toBeCalledWith({ id: projectId });
Expand All @@ -68,7 +87,111 @@ describe('ApiKeyService', () => {
value: randomBytes(10).toString('hex').toUpperCase(),
} as ApiKeyEntity);

await expect(apiKeyService.create(invalidProjectId)).rejects.toThrow(
await expect(
apiKeyService.create({ projectId: invalidProjectId }),
).rejects.toThrow(ProjectNotFoundException);

expect(projectRepo.findOneBy).toBeCalledTimes(1);
expect(projectRepo.findOneBy).toBeCalledWith({ id: invalidProjectId });
expect(apiKeyRepo.save).toBeCalledTimes(0);
});
it('creating an api key fails with an invalid api key', async () => {
const projectId = faker.number.int();
const value = faker.string.alphanumeric(
faker.number.int({ min: 1, max: 19 }),
);

await expect(apiKeyService.create({ projectId, value })).rejects.toThrow(
new BadRequestException('Invalid Api Key value'),
);

expect(apiKeyRepo.save).toBeCalledTimes(0);
});
it('creating an api key fails with an existent api key', async () => {
const projectId = faker.number.int();
const value = faker.string.alphanumeric(20);
jest
.spyOn(projectRepo, 'findOneBy')
.mockResolvedValueOnce({ id: projectId } as ProjectEntity);
jest
.spyOn(apiKeyRepo, 'findOneBy')
.mockResolvedValue({ value } as ApiKeyEntity);

await expect(apiKeyService.create({ projectId, value })).rejects.toThrow(
new BadRequestException('Api Key already exists'),
);

expect(apiKeyRepo.save).toBeCalledTimes(0);
});
});

describe('createMany', () => {
const projectId = faker.number.int();
const apiKeyCount = faker.number.int({ min: 2, max: 10 });
const apiKeys = Array.from({ length: apiKeyCount }).map(() => ({
projectId,
})) as CreateApiKeyDto[];
let dtos: CreateApiKeyDto[];
beforeEach(() => {
dtos = apiKeys;
});

it('creating api keys succeeds with a valid project id', async () => {
jest
.spyOn(projectRepo, 'findOneBy')
.mockResolvedValue({ id: projectId } as ProjectEntity);
jest.spyOn(apiKeyRepo, 'save').mockResolvedValueOnce(
dtos.map(({ projectId }) =>
ApiKeyEntity.from({
projectId,
value: faker.string.alphanumeric(20),
}),
) as any,
);

const apiKeys = await apiKeyService.createMany(dtos);

expect(projectRepo.findOneBy).toBeCalledTimes(apiKeyCount);
expect(projectRepo.findOneBy).toBeCalledWith({ id: projectId });
expect(apiKeyRepo.save).toBeCalledTimes(1);
expect(apiKeys).toHaveLength(apiKeyCount);
for (const apiKey of apiKeys) {
expect(apiKey.value).toHaveLength(20);
}
});
it('creating api keys succeeds with a valid project id and keys', async () => {
dtos.forEach((apiKey) => {
apiKey.value = faker.string.alphanumeric(20);
});
jest
.spyOn(projectRepo, 'findOneBy')
.mockResolvedValue({ id: projectId } as ProjectEntity);
jest
.spyOn(apiKeyRepo, 'save')
.mockResolvedValueOnce(
dtos.map(({ projectId, value }) =>
ApiKeyEntity.from({ projectId, value }),
) as any,
);

const apiKeys = await apiKeyService.createMany(dtos);

expect(projectRepo.findOneBy).toBeCalledTimes(apiKeyCount);
expect(projectRepo.findOneBy).toBeCalledWith({ id: projectId });
expect(apiKeyRepo.save).toBeCalledTimes(1);
expect(apiKeys).toHaveLength(apiKeyCount);
for (const apiKey of apiKeys) {
expect(apiKey.value).toHaveLength(20);
}
});
it('creating api keys fails with an invalid project id', async () => {
const invalidProjectId = faker.number.int();
dtos[0].projectId = invalidProjectId;
jest
.spyOn(projectRepo, 'findOneBy')
.mockResolvedValue(null as ProjectEntity);

await expect(apiKeyService.createMany(dtos)).rejects.toThrow(
ProjectNotFoundException,
);

Expand Down
47 changes: 39 additions & 8 deletions apps/api/src/domains/project/api-key/api-key.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,62 @@
* under the License.
*/
import { randomBytes } from 'crypto';
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import dayjs from 'dayjs';
import { Repository } from 'typeorm';
import { Transactional } from 'typeorm-transactional';

import { ProjectService } from '../project/project.service';
import { ProjectNotFoundException } from '../project/exceptions';
import { ProjectEntity } from '../project/project.entity';
import { ApiKeyEntity } from './api-key.entity';
import { CreateApiKeyDto } from './dtos';

@Injectable()
export class ApiKeyService {
constructor(
@InjectRepository(ApiKeyEntity)
private readonly repository: Repository<ApiKeyEntity>,
private readonly projectService: ProjectService,
@InjectRepository(ProjectEntity)
private readonly projectRepo: Repository<ProjectEntity>,
) {}

private async validateBeforeCreation(dto: CreateApiKeyDto) {
if (!dto.value) {
dto.value = randomBytes(10).toString('hex').toUpperCase();
}
const { projectId, value } = dto;
if (value.length !== 20)
throw new BadRequestException('Invalid Api Key value');

const project = await this.projectRepo.findOneBy({ id: projectId });
if (!project) throw new ProjectNotFoundException();

const apiKey = await this.repository.findOneBy({ value });
if (apiKey) throw new BadRequestException('Api Key already exists');
}

@Transactional()
async create(dto: CreateApiKeyDto) {
await this.validateBeforeCreation(dto);
const { projectId, value } = dto;

const newApiKey = ApiKeyEntity.from({ projectId, value });

return await this.repository.save(newApiKey);
}

@Transactional()
async create(projectId: number) {
await this.projectService.findById({ projectId });
async createMany(dtos: CreateApiKeyDto[]) {
for (const dto of dtos) {
await this.validateBeforeCreation(dto);
}

const value = randomBytes(10).toString('hex').toUpperCase();
const apiKey = ApiKeyEntity.from({ projectId, value });
const apiKeys = dtos.map(({ projectId, value }) =>
ApiKeyEntity.from({ projectId, value }),
);

return await this.repository.save(apiKey);
return await this.repository.save(apiKeys);
}

async findAllByProjectId(projectId: number) {
Expand Down
39 changes: 39 additions & 0 deletions apps/api/src/domains/project/api-key/dtos/create-api-key.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Copyright 2023 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { Expose, plainToInstance } from 'class-transformer';

import { ApiKeyEntity } from '../api-key.entity';

export class CreateApiKeyDto {
@Expose()
projectId: number;

@Expose()
value?: string;

public static from(params: any): CreateApiKeyDto {
return plainToInstance(CreateApiKeyDto, params, {
excludeExtraneousValues: true,
});
}

static toApiKeyEntity(params: CreateApiKeyDto) {
return ApiKeyEntity.from({
projectId: params.projectId,
value: params.value,
});
}
}
1 change: 1 addition & 0 deletions apps/api/src/domains/project/api-key/dtos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
* License for the specific language governing permissions and limitations
* under the License.
*/
export { CreateApiKeyDto } from './create-api-key.dto';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Copyright 2023 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, Length } from 'class-validator';

export class CreateApiKeyRequestDto {
@ApiProperty()
@IsString()
@IsOptional()
@Length(20)
value?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
* License for the specific language governing permissions and limitations
* under the License.
*/
export { CreateApiKeyRequestDto } from './create-api-key-request.dto';
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ import { IsObject } from 'class-validator';
export class CreateIssueTrackerRequestDto {
@ApiProperty()
@IsObject()
data: object;
data: Record<string, any>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ import { IssueTrackerService } from './issue-tracker.service';
imports: [TypeOrmModule.forFeature([IssueTrackerEntity])],
providers: [IssueTrackerService],
controllers: [IssueTrackerController],
exports: [IssueTrackerService],
})
export class IssueTrackerModule {}
Loading

0 comments on commit 8b8fed0

Please sign in to comment.