diff --git a/src/app/api/swagger/route.ts b/src/app/api/swagger/route.ts index d3f8412a..878ce4d3 100644 --- a/src/app/api/swagger/route.ts +++ b/src/app/api/swagger/route.ts @@ -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'], @@ -13,5 +14,5 @@ export function GET(): Response { }, }); - return Response.json(spec); + return NextResponse.json(spec); } diff --git a/src/app/api/vaults/[id]/route.ts b/src/app/api/vaults/[id]/route.ts new file mode 100644 index 00000000..9a87071a --- /dev/null +++ b/src/app/api/vaults/[id]/route.ts @@ -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 { + const deleteVaultUseCase: DeleteVaultUseCase = + container.resolve(DeleteVaultUseCase); + return await handleApiRequest( + () => deleteVaultUseCase.handle(params), + StatusCodes.NO_CONTENT + ); +} diff --git a/src/app/api/vaults/route.ts b/src/app/api/vaults/route.ts index 814b5b5d..19ac3a88 100644 --- a/src/app/api/vaults/route.ts +++ b/src/app/api/vaults/route.ts @@ -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 @@ -30,7 +31,7 @@ import { GetMyVaultsUseCase } from '@api/usecases/vaults/get-my-vaults.usecase'; * schema: * $ref: '#/components/schemas/HttpResponseDto' */ -export async function GET(): Promise { +export async function GET(): Promise { const getMyVaultsUseCase: GetMyVaultsUseCase = container.resolve(GetMyVaultsUseCase); return await handleApiRequest(async () => { @@ -52,9 +53,9 @@ export async function GET(): Promise { * content: * application/json: * schema: - * $ref: '#/components/schemas/CreateVaultParamsDto' + * $ref: '#/components/schemas/CreateVaultRequestDto' * responses: - * 200: + * 201: * description: Returns the vault created * content: * application/json: @@ -67,13 +68,13 @@ export async function GET(): Promise { * schema: * $ref: '#/components/schemas/HttpResponseDto' */ -export async function POST(request: NextRequest): Promise { - const params: CreateVaultParamsDto = await request.json(); +export async function POST(request: NextRequest): Promise { + 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); } diff --git a/src/modules/api/errors/no-vault-found.error.ts b/src/modules/api/errors/no-vault-found.error.ts new file mode 100644 index 00000000..5a805ef3 --- /dev/null +++ b/src/modules/api/errors/no-vault-found.error.ts @@ -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); + } +} diff --git a/src/modules/api/repositories/vaults.repository.ts b/src/modules/api/repositories/vaults.repository.ts index b7833fe3..eac29b89 100644 --- a/src/modules/api/repositories/vaults.repository.ts +++ b/src/modules/api/repositories/vaults.repository.ts @@ -1,7 +1,8 @@ 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 { @@ -9,7 +10,16 @@ export class VaultsRepository { return await prisma.vault.findMany(); } - public async create(params: CreateVaultParamsDto): Promise { + public async create(params: CreateVaultRequestDto): Promise { return await prisma.vault.create({ data: params }); } + + public async delete(id: string): Promise { + try { + await prisma.vault.delete({ where: { id: id } }); + } catch (error: unknown) { + console.error(error); + throw new NoVaultFoundError(); + } + } } diff --git a/src/modules/api/usecases/abstract/usecase.with-input.interface.ts b/src/modules/api/usecases/abstract/usecase.with-input.interface.ts index 5894ab0d..ea4a4775 100644 --- a/src/modules/api/usecases/abstract/usecase.with-input.interface.ts +++ b/src/modules/api/usecases/abstract/usecase.with-input.interface.ts @@ -1,3 +1,3 @@ export interface IUseCaseWithInput { - handle(params: Input): Promise; + handle(input: Input): Promise; } diff --git a/src/modules/api/usecases/vaults/create-vault.usecase.ts b/src/modules/api/usecases/vaults/create-vault.usecase.ts index f12f0336..523d1eb3 100644 --- a/src/modules/api/usecases/vaults/create-vault.usecase.ts +++ b/src/modules/api/usecases/vaults/create-vault.usecase.ts @@ -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 + implements IUseCaseWithInput { public constructor( @inject(VaultsRepository) @@ -17,8 +17,8 @@ export class CreateVaultUseCase private readonly _vaultAdapter: VaultAdapter ) {} - public async handle(params: CreateVaultParamsDto): Promise { - const vaultCreated: Vault = await this._vaultsRepository.create(params); + public async handle(input: CreateVaultRequestDto): Promise { + const vaultCreated: Vault = await this._vaultsRepository.create(input); return this._vaultAdapter.getDtoFromModel(vaultCreated); } } diff --git a/src/modules/api/usecases/vaults/delete-vault.usecase.ts b/src/modules/api/usecases/vaults/delete-vault.usecase.ts new file mode 100644 index 00000000..e51adec6 --- /dev/null +++ b/src/modules/api/usecases/vaults/delete-vault.usecase.ts @@ -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 { + public constructor( + @inject(VaultsRepository) + private readonly _vaultsRepository: VaultsRepository + ) {} + + public async handle(input: IdParam): Promise { + const vaultId: string = (await input.params)?.id; + await this._vaultsRepository.delete(vaultId); + } +} diff --git a/src/modules/api/utils/handle-api-request.ts b/src/modules/api/utils/handle-api-request.ts index 9c2f9d0a..ac3b8b2e 100644 --- a/src/modules/api/utils/handle-api-request.ts +++ b/src/modules/api/utils/handle-api-request.ts @@ -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( - callback: () => Promise -): Promise { + callback: () => Promise, + successStatusCode?: StatusCodes +): Promise { try { const data: Awaited = 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 } ); diff --git a/src/modules/shared/dto/params/abstract/next-params.ts b/src/modules/shared/dto/params/abstract/next-params.ts new file mode 100644 index 00000000..7b4fc0a5 --- /dev/null +++ b/src/modules/shared/dto/params/abstract/next-params.ts @@ -0,0 +1,3 @@ +export type NextParams = { + params: Promise; +}; diff --git a/src/modules/shared/dto/params/id.param.ts b/src/modules/shared/dto/params/id.param.ts new file mode 100644 index 00000000..6cdfcf70 --- /dev/null +++ b/src/modules/shared/dto/params/id.param.ts @@ -0,0 +1,5 @@ +import type { NextParams } from '@shared/dto/params/abstract/next-params'; + +export type IdParam = NextParams<{ + id: string; +}>; diff --git a/src/modules/shared/dto/params/create-vault.params.dto.ts b/src/modules/shared/dto/requests/create-vault.request.dto.ts similarity index 85% rename from src/modules/shared/dto/params/create-vault.params.dto.ts rename to src/modules/shared/dto/requests/create-vault.request.dto.ts index edec22fc..abd950e0 100644 --- a/src/modules/shared/dto/params/create-vault.params.dto.ts +++ b/src/modules/shared/dto/requests/create-vault.request.dto.ts @@ -2,7 +2,7 @@ * @swagger * components: * schemas: - * CreateVaultParamsDto: + * CreateVaultRequestDto: * type: object * required: * - label @@ -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; }; diff --git a/tests/units/modules/api/utils/handle-api-request.test.ts b/tests/units/modules/api/utils/handle-api-request.test.ts index 6d0311bb..ea4a2384 100644 --- a/tests/units/modules/api/utils/handle-api-request.test.ts +++ b/tests/units/modules/api/utils/handle-api-request.test.ts @@ -1,38 +1,43 @@ +jest.mock('next/server', (): unknown => { + return { + NextResponse: { + json: jest.fn( + ( + 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 { body: T; status: number; } -type ResponseJson = (body: T, init: { status: number }) => IJsonResponse; - -declare global { - var Response: { - json: ResponseJson; - }; -} +type NextResponseJson = ( + body: T, + init: { status: number } +) => IJsonResponse; describe('handleApiRequest', () => { - let jsonMock: jest.MockedFunction; + let jsonMock: jest.MockedFunction; beforeEach((): void => { - jsonMock = jest.fn( - (body: T, init: { status: number }): IJsonResponse => ({ - body, - status: init.status, - }) - ); - globalThis.Response = { json: jsonMock }; - }); - - afterEach((): void => { - jest.clearAllMocks(); + jsonMock = NextResponse.json as jest.MockedFunction; + jsonMock.mockClear(); }); - it('should return 200 and data when callback resolves', async (): Promise => { + it('returns 200 and data when callback resolves', async (): Promise => { const data: { foo: string } = { foo: 'bar' }; const callback: () => Promise<{ foo: string }> = jest.fn( (): Promise<{ foo: string }> => Promise.resolve(data) @@ -46,7 +51,7 @@ describe('handleApiRequest', () => { expect(callback).toHaveBeenCalled(); }); - it('should return error message and status when HttpError is thrown', async (): Promise => { + it('returns error message and status when HttpError is thrown', async (): Promise => { const error: HttpError = new HttpError('Not Found', StatusCodes.NOT_FOUND); const callback: () => Promise = jest.fn( (): Promise => Promise.reject(error) @@ -66,7 +71,7 @@ describe('handleApiRequest', () => { expect(callback).toHaveBeenCalled(); }); - it('should log error and return 500 when non-HttpError is thrown', async (): Promise => { + it('logs error and returns 500 when non-HttpError is thrown', async (): Promise => { const error: Error = new Error('Unexpected'); const callback: () => Promise = jest.fn( (): Promise => Promise.reject(error) @@ -74,7 +79,7 @@ describe('handleApiRequest', () => { const consoleErrorSpy: jest.SpyInstance = jest .spyOn(console, 'error') .mockImplementation((): void => { - /* ignore error */ + return; }); const response: IJsonResponse<{ error: string }> =