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

Feat: domain error handler #159

Merged
merged 9 commits into from
Jan 19, 2024
11 changes: 11 additions & 0 deletions src/domain/entities/DomainError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* DomainError entity — manually thrown exception provided by business logic
*/
export class DomainError extends Error {
/**
* @param message - description of why the error was thrown
*/
constructor(message: string) {
super(message);
}
}
7 changes: 4 additions & 3 deletions src/domain/service/note.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Note, NoteInternalId, NotePublicId } from '@domain/entities/note.js';
import type NoteRepository from '@repository/note.repository.js';
import { createPublicId } from '@infrastructure/utils/id.js';
import { DomainError } from '@domain/entities/DomainError';

/**
* Note service
Expand Down Expand Up @@ -54,7 +55,7 @@ export default class NoteService {
const updatedNote = await this.repository.updateNoteContentById(id, content);

if (updatedNote === null) {
throw new Error(`Note with id ${id} was not updated`);
throw new DomainError(`Note with id ${id} was not updated`);
}

return updatedNote;
Expand All @@ -69,7 +70,7 @@ export default class NoteService {
const note = await this.repository.getNoteById(id);

if (note === null) {
throw new Error(`Note with id ${id} was not found`);
throw new DomainError(`Note with id ${id} was not found`);
}

return note;
Expand All @@ -84,7 +85,7 @@ export default class NoteService {
const note = await this.repository.getNoteByPublicId(publicId);

if (note === null) {
throw new Error(`Note with public id ${publicId} was not found`);
throw new DomainError(`Note with public id ${publicId} was not found`);
}

return note;
Expand Down
17 changes: 14 additions & 3 deletions src/domain/service/noteSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Team, TeamMember, TeamMemberCreationAttributes } from '@domain/ent
import { MemberRole } from '@domain/entities/team.js';
import type User from '@domain/entities/user.js';
import { createInvitationHash } from '@infrastructure/utils/invitationHash.js';
import { DomainError } from '@domain/entities/DomainError';

/**
* Service responsible for Note Settings
Expand Down Expand Up @@ -44,7 +45,7 @@ export default class NoteSettingsService {
* Check if invitation hash is valid
*/
if (noteSettings === null) {
throw new Error(`Wrong invitation`);
throw new DomainError(`Wrong invitation`);
}

/**
Expand All @@ -53,7 +54,7 @@ export default class NoteSettingsService {
const isUserTeamMember = await this.teamRepository.isUserInTeam(userId, noteSettings.noteId);

if (isUserTeamMember) {
throw new Error(`User already in team`);
throw new DomainError(`User already in team`);
}

return await this.teamRepository.createTeamMembership({
Expand All @@ -69,7 +70,13 @@ export default class NoteSettingsService {
* @param id - note internal id
*/
public async getNoteSettingsByNoteId(id: NoteInternalId): Promise<NoteSettings> {
return await this.noteSettingsRepository.getNoteSettingsByNoteId(id);
const settings = await this.noteSettingsRepository.getNoteSettingsByNoteId(id);

if (settings === null) {
throw new DomainError(`Note settings not found`);
}

return settings;
}

/**
Expand Down Expand Up @@ -97,6 +104,10 @@ export default class NoteSettingsService {
public async patchNoteSettingsByNoteId(noteId: NoteInternalId, data: Partial<NoteSettings>): Promise<NoteSettings | null> {
const noteSettings = await this.noteSettingsRepository.getNoteSettingsByNoteId(noteId);

if (noteSettings === null) {
throw new DomainError(`Note settings not found`);
}

return await this.noteSettingsRepository.patchNoteSettingsById(noteSettings.id, data);
}

Expand Down
3 changes: 2 additions & 1 deletion src/domain/service/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Provider } from '@repository/user.repository.js';
import type User from '@domain/entities/user.js';
import type EditorTool from '@domain/entities/editorTools';
import type { SharedDomainMethods } from './shared/index.js';
import { DomainError } from '@domain/entities/DomainError.js';

export {
Provider
Expand Down Expand Up @@ -57,7 +58,7 @@ export default class UserService {
const user = await this.getUserById(userId);

if (user === null) {
throw new Error('User not found');
throw new DomainError('User not found');
}

const userToolsIds = user.editorTools ?? [];
Expand Down
30 changes: 30 additions & 0 deletions src/presentation/http/decorators/domainError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { StatusCodes } from 'http-status-codes';
import type { FastifyReply } from 'fastify';

/**
* Custom method for replying with information that business logic dismissed the request for some reason
*
* Send this error when a domain-level error is thrown
*
* @example
*
* try {
* service.someAction()
* } catch (error: Error) {
* if (error instanceof DomainError) {
* reply.domainError(error.message);
* return;
* }
* throw error;
* }
*
* @param message - Optional message to send. If not specified, default message will be sent
*/
export default async function domainError(this: FastifyReply, message = 'Domain level error'): Promise<void> {
await this
.code(StatusCodes.BAD_REQUEST)
.type('application/json')
.send({
message,
});
}
4 changes: 3 additions & 1 deletion src/presentation/http/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import notFound from './notFound.js';
import forbidden from './forbidden.js';
import unauthorized from './unauthorized.js';
import notAcceptable from './notAcceptable.js';
import domainError from './domainError.js';

export {
notFound,
forbidden,
unauthorized,
notAcceptable
notAcceptable,
domainError
};
21 changes: 21 additions & 0 deletions src/presentation/http/fastify.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,26 @@ declare module 'fastify' {
* @param message - Optional message to send. If not specified, default message will be sent
*/
notAcceptable: (message?: string) => Promise<void>;

/**
* Custom method for replying with information that business logic dismissed the request for some reason
*
* Send this error when a domain-level error is thrown
*
* @example
*
* try {
* service.someAction()
* } catch (error: Error) {
* if (error instanceof DomainError) {
* reply.domainError(error.message);
* return;
* }
* throw error;
* }
*
* @param message - Optional message to send. If not specified, default message will be sent
*/
domainError: (message?: string) => Promise<void>;
}
}
21 changes: 20 additions & 1 deletion src/presentation/http/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUI from '@fastify/swagger-ui';
import addUserIdResolver from '@presentation/http/middlewares/common/userIdResolver.js';
import cookie from '@fastify/cookie';
import { notFound, forbidden, unauthorized, notAcceptable } from './decorators/index.js';
import { notFound, forbidden, unauthorized, notAcceptable, domainError } from './decorators/index.js';
import NoteRouter from '@presentation/http/router/note.js';
import OauthRouter from '@presentation/http/router/oauth.js';
import AuthRouter from '@presentation/http/router/auth.js';
Expand All @@ -26,6 +26,7 @@ import NoteListRouter from '@presentation/http/router/noteList.js';
import { EditorToolSchema } from './schema/EditorTool.js';
import JoinRouter from '@presentation/http/router/join.js';
import { JoinSchemaParams, JoinSchemaResponse } from './schema/Join.js';
import { DomainError } from '@domain/entities/DomainError.js';


const appServerLogger = getLogger('appServer');
Expand Down Expand Up @@ -81,6 +82,8 @@ export default class HttpApi implements Api {
this.addPoliciesCheckHook();

await this.addApiRoutes(domainServices);

this.domainErrorHandler();
}


Expand Down Expand Up @@ -287,6 +290,7 @@ export default class HttpApi implements Api {
this.server?.decorateReply('forbidden', forbidden);
this.server?.decorateReply('unauthorized', unauthorized);
this.server?.decorateReply('notAcceptable', notAcceptable);
this.server?.decorateReply('domainError', domainError);
}

/**
Expand Down Expand Up @@ -329,5 +333,20 @@ export default class HttpApi implements Api {
});
});
}

/**
* Domain error handler
*/
private domainErrorHandler(): void {
this.server?.setErrorHandler(function (error, request, reply) {
/**
* If we have an error that occurs in the domain-level we reply it with special format
*/
if (error instanceof DomainError) {
this.log.error(error);
void reply.domainError(error.message);
}
});
}
}

2 changes: 1 addition & 1 deletion src/repository/noteSettings.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default class NoteSettingsRepository {
* @param id - note id
* @returns found note settings
*/
public async getNoteSettingsByNoteId(id: NoteInternalId): Promise<NoteSettings> {
public async getNoteSettingsByNoteId(id: NoteInternalId): Promise<NoteSettings | null> {
return await this.storage.getNoteSettingsByNoteId(id);
}

Expand Down
7 changes: 2 additions & 5 deletions src/repository/storage/postgres/orm/sequelize/noteSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,15 @@ export default class NoteSettingsSequelizeStorage {
* @param noteId - note id
* @returns { Promise<NoteSettings | null> } - note settings
*/
public async getNoteSettingsByNoteId(noteId: NoteSettings['noteId']): Promise<NoteSettings> {
public async getNoteSettingsByNoteId(noteId: NoteSettings['noteId']): Promise<NoteSettings | null> {
const settings = await this.model.findOne({
where: {
noteId: noteId,
},
});

if (!settings) {
/**
* TODO: improve exceptions
*/
throw new Error('Note settings not found');
return null;
}

return settings;
Expand Down
Loading