Skip to content
Merged
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
5 changes: 3 additions & 2 deletions src/app/api/swagger/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createSwaggerSpec } from 'next-swagger-doc';
import { NextResponse } from 'next/server';

export function GET(): Response {
export function GET(): NextResponse {
const spec: object = createSwaggerSpec({
apiFolder: 'src/app/api',
schemaFolders: ['src/modules/shared/dto'],
Expand All @@ -13,5 +14,5 @@ export function GET(): Response {
},
});

return Response.json(spec);
return NextResponse.json(spec);
}
45 changes: 45 additions & 0 deletions src/app/api/vaults/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'reflect-metadata';
import type { NextRequest, NextResponse } from 'next/server';
import type { IdParam } from '@shared/dto/params/id.param';
import { container } from 'tsyringe';
import { handleApiRequest } from '@api/utils/handle-api-request';
import { StatusCodes } from 'http-status-codes';
import { DeleteVaultUseCase } from '@api/usecases/vaults/delete-vault.usecase';

/**
* @swagger
* /api/vaults/{id}:
* delete:
* tags:
* - Vaults
* description: Delete a vault by ID
* parameters:
* - in: path
* name: id
* required: true
* description: ID of vault to delete
* schema:
* type: string
* responses:
* 204:
* description: The vault has been successfully deleted
* 404:
* description: Vault not found
* 500:
* description: Internal Server Error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/HttpResponseDto'
*/
export async function DELETE(
request: NextRequest,
params: IdParam
): Promise<NextResponse> {
const deleteVaultUseCase: DeleteVaultUseCase =
container.resolve(DeleteVaultUseCase);
return await handleApiRequest(
() => deleteVaultUseCase.handle(params),
StatusCodes.NO_CONTENT
);
}
17 changes: 9 additions & 8 deletions src/app/api/vaults/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import 'reflect-metadata';
import type { NextRequest } from 'next/server';
import type { NextRequest, NextResponse } from 'next/server';
import { container } from 'tsyringe';
import { handleApiRequest } from '@api/utils/handle-api-request';
import type { CreateVaultParamsDto } from '@shared/dto/params/create-vault.params.dto';
import type { VaultModelDto } from '@shared/dto/models/vault.model.dto';
import type { CreateVaultResponseDto } from '@shared/dto/responses/create-vault.response.dto';
import { CreateVaultUseCase } from '@api/usecases/vaults/create-vault.usecase';
import type { GetMyVaultsResponseDto } from '@shared/dto/responses/get-my-vaults.response.dto';
import { GetMyVaultsUseCase } from '@api/usecases/vaults/get-my-vaults.usecase';
import type { CreateVaultRequestDto } from '@shared/dto/requests/create-vault.request.dto';
import { StatusCodes } from 'http-status-codes';

/**
* @swagger
Expand All @@ -30,7 +31,7 @@ import { GetMyVaultsUseCase } from '@api/usecases/vaults/get-my-vaults.usecase';
* schema:
* $ref: '#/components/schemas/HttpResponseDto'
*/
export async function GET(): Promise<Response> {
export async function GET(): Promise<NextResponse> {
const getMyVaultsUseCase: GetMyVaultsUseCase =
container.resolve(GetMyVaultsUseCase);
return await handleApiRequest(async () => {
Expand All @@ -52,9 +53,9 @@ export async function GET(): Promise<Response> {
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateVaultParamsDto'
* $ref: '#/components/schemas/CreateVaultRequestDto'
* responses:
* 200:
* 201:
* description: Returns the vault created
* content:
* application/json:
Expand All @@ -67,13 +68,13 @@ export async function GET(): Promise<Response> {
* schema:
* $ref: '#/components/schemas/HttpResponseDto'
*/
export async function POST(request: NextRequest): Promise<Response> {
const params: CreateVaultParamsDto = await request.json();
export async function POST(request: NextRequest): Promise<NextResponse> {
const params: CreateVaultRequestDto = await request.json();
const createVaultUseCase: CreateVaultUseCase =
container.resolve(CreateVaultUseCase);
return await handleApiRequest(async () => {
const vaultCreated: VaultModelDto = await createVaultUseCase.handle(params);
const response: CreateVaultResponseDto = { vaultCreated };
return response;
});
}, StatusCodes.CREATED);
}
8 changes: 8 additions & 0 deletions src/modules/api/errors/no-vault-found.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { HttpError } from '@api/errors/abstract/http-error';
import { StatusCodes } from 'http-status-codes';

export class NoVaultFoundError extends HttpError {
public constructor() {
super('Vault not found', StatusCodes.NOT_FOUND);
}
}
14 changes: 12 additions & 2 deletions src/modules/api/repositories/vaults.repository.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { injectable } from 'tsyringe';
import { Vault } from '@prisma/generated';
import prisma from '@lib/prisma';
import type { CreateVaultParamsDto } from '@shared/dto/params/create-vault.params.dto';
import { CreateVaultRequestDto } from '@shared/dto/requests/create-vault.request.dto';
import { NoVaultFoundError } from '@api/errors/no-vault-found.error';

@injectable()
export class VaultsRepository {
public async findAll(): Promise<Vault[]> {
return await prisma.vault.findMany();
}

public async create(params: CreateVaultParamsDto): Promise<Vault> {
public async create(params: CreateVaultRequestDto): Promise<Vault> {
return await prisma.vault.create({ data: params });
}

public async delete(id: string): Promise<void> {
try {
await prisma.vault.delete({ where: { id: id } });
} catch (error: unknown) {
console.error(error);
throw new NoVaultFoundError();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export interface IUseCaseWithInput<Input, Output> {
handle(params: Input): Promise<Output>;
handle(input: Input): Promise<Output>;
}
8 changes: 4 additions & 4 deletions src/modules/api/usecases/vaults/create-vault.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { inject, injectable } from 'tsyringe';
import type { VaultModelDto } from '@shared/dto/models/vault.model.dto';
import { IUseCaseWithInput } from '@api/usecases/abstract/usecase.with-input.interface';
import type { CreateVaultParamsDto } from '@shared/dto/params/create-vault.params.dto';
import { Vault } from '@prisma/generated';
import { VaultAdapter } from '@api/adapters/vault.adapter';
import { VaultsRepository } from '@api/repositories/vaults.repository';
import { CreateVaultRequestDto } from '@shared/dto/requests/create-vault.request.dto';

@injectable()
export class CreateVaultUseCase
implements IUseCaseWithInput<CreateVaultParamsDto, VaultModelDto>
implements IUseCaseWithInput<CreateVaultRequestDto, VaultModelDto>
{
public constructor(
@inject(VaultsRepository)
Expand All @@ -17,8 +17,8 @@ export class CreateVaultUseCase
private readonly _vaultAdapter: VaultAdapter
) {}

public async handle(params: CreateVaultParamsDto): Promise<VaultModelDto> {
const vaultCreated: Vault = await this._vaultsRepository.create(params);
public async handle(input: CreateVaultRequestDto): Promise<VaultModelDto> {
const vaultCreated: Vault = await this._vaultsRepository.create(input);
return this._vaultAdapter.getDtoFromModel(vaultCreated);
}
}
17 changes: 17 additions & 0 deletions src/modules/api/usecases/vaults/delete-vault.usecase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { inject, injectable } from 'tsyringe';
import { IUseCaseWithInput } from '@api/usecases/abstract/usecase.with-input.interface';
import { IdParam } from '@shared/dto/params/id.param';
import { VaultsRepository } from '@api/repositories/vaults.repository';

@injectable()
export class DeleteVaultUseCase implements IUseCaseWithInput<IdParam, void> {
public constructor(
@inject(VaultsRepository)
private readonly _vaultsRepository: VaultsRepository
) {}

public async handle(input: IdParam): Promise<void> {
const vaultId: string = (await input.params)?.id;
await this._vaultsRepository.delete(vaultId);
}
}
22 changes: 17 additions & 5 deletions src/modules/api/utils/handle-api-request.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { HttpError } from '@api/errors/abstract/http-error';
import { StatusCodes } from 'http-status-codes';
import { NextResponse } from 'next/server';

export async function handleApiRequest<T>(
callback: () => Promise<T>
): Promise<Response> {
callback: () => Promise<T>,
successStatusCode?: StatusCodes
): Promise<NextResponse> {
try {
const data: Awaited<T> = await callback();
return Response.json(data, { status: StatusCodes.OK });

if (successStatusCode === StatusCodes.NO_CONTENT) {
return new NextResponse(null, { status: StatusCodes.NO_CONTENT });
}

return NextResponse.json(data, {
status: successStatusCode || StatusCodes.OK,
});
} catch (error) {
if (error instanceof HttpError) {
return Response.json({ error: error.message }, { status: error.status });
return NextResponse.json(
{ error: error.message },
{ status: error.status }
);
}
console.error(error);
return Response.json(
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: StatusCodes.INTERNAL_SERVER_ERROR }
);
Expand Down
3 changes: 3 additions & 0 deletions src/modules/shared/dto/params/abstract/next-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type NextParams<T> = {
params: Promise<T>;
};
5 changes: 5 additions & 0 deletions src/modules/shared/dto/params/id.param.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { NextParams } from '@shared/dto/params/abstract/next-params';

export type IdParam = NextParams<{
id: string;
}>;
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @swagger
* components:
* schemas:
* CreateVaultParamsDto:
* CreateVaultRequestDto:
* type: object
* required:
* - label
Expand All @@ -15,7 +15,7 @@
* type: string
* description: Password, token, or other sensitive string to store
*/
export type CreateVaultParamsDto = {
export type CreateVaultRequestDto = {
label: string;
secret: string;
};
53 changes: 29 additions & 24 deletions tests/units/modules/api/utils/handle-api-request.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,43 @@
jest.mock('next/server', (): unknown => {
return {
NextResponse: {
json: jest.fn(
<T>(
body: T,
init: { status: number }
): { body: T; status: number } => ({
body,
status: init.status,
})
),
},
};
});

import { handleApiRequest } from '@api/utils/handle-api-request';
import { HttpError } from '@api/errors/abstract/http-error';
import { StatusCodes } from 'http-status-codes';
import { handleApiRequest } from '@api/utils/handle-api-request';
import { NextResponse } from 'next/server';

interface IJsonResponse<T> {
body: T;
status: number;
}

type ResponseJson = <T>(body: T, init: { status: number }) => IJsonResponse<T>;

declare global {
var Response: {
json: ResponseJson;
};
}
type NextResponseJson = <T>(
body: T,
init: { status: number }
) => IJsonResponse<T>;

describe('handleApiRequest', () => {
let jsonMock: jest.MockedFunction<ResponseJson>;
let jsonMock: jest.MockedFunction<NextResponseJson>;

beforeEach((): void => {
jsonMock = jest.fn(
<T>(body: T, init: { status: number }): IJsonResponse<T> => ({
body,
status: init.status,
})
);
globalThis.Response = { json: jsonMock };
});

afterEach((): void => {
jest.clearAllMocks();
jsonMock = NextResponse.json as jest.MockedFunction<NextResponseJson>;
jsonMock.mockClear();
});

it('should return 200 and data when callback resolves', async (): Promise<void> => {
it('returns 200 and data when callback resolves', async (): Promise<void> => {
const data: { foo: string } = { foo: 'bar' };
const callback: () => Promise<{ foo: string }> = jest.fn(
(): Promise<{ foo: string }> => Promise.resolve(data)
Expand All @@ -46,7 +51,7 @@ describe('handleApiRequest', () => {
expect(callback).toHaveBeenCalled();
});

it('should return error message and status when HttpError is thrown', async (): Promise<void> => {
it('returns error message and status when HttpError is thrown', async (): Promise<void> => {
const error: HttpError = new HttpError('Not Found', StatusCodes.NOT_FOUND);
const callback: () => Promise<unknown> = jest.fn(
(): Promise<unknown> => Promise.reject(error)
Expand All @@ -66,15 +71,15 @@ describe('handleApiRequest', () => {
expect(callback).toHaveBeenCalled();
});

it('should log error and return 500 when non-HttpError is thrown', async (): Promise<void> => {
it('logs error and returns 500 when non-HttpError is thrown', async (): Promise<void> => {
const error: Error = new Error('Unexpected');
const callback: () => Promise<unknown> = jest.fn(
(): Promise<unknown> => Promise.reject(error)
);
const consoleErrorSpy: jest.SpyInstance<void, [unknown]> = jest
.spyOn(console, 'error')
.mockImplementation((): void => {
/* ignore error */
return;
});

const response: IJsonResponse<{ error: string }> =
Expand Down