diff --git a/.eslintrc b/.eslintrc index 4ff5c3e0..c937e8c3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,19 @@ { "extends": [ "codex/ts" - ] + ], + "rules": { + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": "property", + "format": ["camelCase", "PascalCase"], + "filter": { + // Allow "2xx" as a property name, used in the API response schema + "regex": "^(2xx)$", + "match": false + } + } + ] + } } diff --git a/src/presentation/http/decorators/notFound.ts b/src/presentation/http/decorators/notFound.ts new file mode 100644 index 00000000..0d9c78bd --- /dev/null +++ b/src/presentation/http/decorators/notFound.ts @@ -0,0 +1,23 @@ +import { StatusCodes } from 'http-status-codes'; +import type { FastifyReply } from 'fastify'; + +/** + * Custom method for sending 404 error + * + * @example + * + * if (note === null) { + * return fastify.notFound(reply, 'Note not found'); + * } + * + * @param reply - fastify reply instance + * @param message - custom message + */ +export default async function notFound(reply: FastifyReply, message = 'Not found'): Promise { + await reply + .code(StatusCodes.NOT_FOUND) + .type('application/json') + .send({ + message, + }); +} diff --git a/src/presentation/http/fastify.d.ts b/src/presentation/http/fastify.d.ts new file mode 100644 index 00000000..530ee6d7 --- /dev/null +++ b/src/presentation/http/fastify.d.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars */ +import type * as fastify from 'fastify'; +import type * as http from 'http'; + +declare module 'fastify' { + export interface FastifyInstance< + HttpServer = http.Server, + HttpRequest = http.IncomingMessage, + HttpResponse = http.ServerResponse + > { + /** + * Custom method for sending 404 error + * + * @example + * + * if (note === null) { + * return fastify.notFound(reply, 'Note not found'); + * } + * + * @param reply - Fastify reply object + * @param message - Optional message to send. If not specified, default message will be sent + */ + notFound: (reply: fastify.FastifyReply, message?: string) => Promise; + } +} diff --git a/src/presentation/http/http-server.ts b/src/presentation/http/http-server.ts index fa443dce..f9bce3c7 100644 --- a/src/presentation/http/http-server.ts +++ b/src/presentation/http/http-server.ts @@ -15,6 +15,8 @@ import cookie from '@fastify/cookie'; import UserRouter from '@presentation/http/router/user.js'; import AIRouter from './router/ai.js'; import EditorToolsRouter from './router/editorTools.js'; +import NotFoundDecorator from './decorators/notFound.js'; +import { addSchema } from './schema/index.js'; const appServerLogger = getLogger('appServer'); @@ -141,6 +143,16 @@ export default class HttpServer implements API { origin: this.config.allowedOrigins, }); + /** + * Add Fastify schema for validation and serialization + */ + addSchema(this.server); + + /** + * Custom method for sending 404 error + */ + this.server.decorate('notFound', NotFoundDecorator); + await this.server.listen({ host: this.config.host, port: this.config.port, diff --git a/src/presentation/http/middlewares/authRequired.ts b/src/presentation/http/middlewares/authRequired.ts index 35c79f9f..e6636c59 100644 --- a/src/presentation/http/middlewares/authRequired.ts +++ b/src/presentation/http/middlewares/authRequired.ts @@ -1,7 +1,6 @@ import type { preHandlerHookHandler } from 'fastify'; import type AuthService from '@domain/service/auth.js'; import { StatusCodes } from 'http-status-codes'; -import type { ErrorResponse } from '@presentation/http/types/HttpResponse.js'; import notEmpty from '@infrastructure/utils/notEmpty.js'; /** @@ -25,12 +24,11 @@ export default (authService: AuthService): preHandlerHookHandler => { * If authorization header is not present, return unauthorized response */ if (!notEmpty(authorizationHeader)) { - const response: ErrorResponse = { - status: StatusCodes.UNAUTHORIZED, - message: 'Missing authorization header', - }; - - return reply.send(response); + return reply + .code(StatusCodes.UNAUTHORIZED) + .send({ + message: 'Missing authorization header', + }); } /** @@ -43,12 +41,11 @@ export default (authService: AuthService): preHandlerHookHandler => { done(); } catch (error) { - const response: ErrorResponse = { - status: StatusCodes.UNAUTHORIZED, - message: 'Invalid access token', - }; - - return reply.send(response); + return reply + .code(StatusCodes.UNAUTHORIZED) + .send({ + message: 'Invalid access token', + }); } }; }; diff --git a/src/presentation/http/router/auth.ts b/src/presentation/http/router/auth.ts index 25181036..8f806c75 100644 --- a/src/presentation/http/router/auth.ts +++ b/src/presentation/http/router/auth.ts @@ -1,6 +1,6 @@ import type { FastifyPluginCallback } from 'fastify'; import type AuthService from '@domain/service/auth.js'; -import type { ErrorResponse, SuccessResponse } from '@presentation/http/types/HttpResponse.js'; +import type { ErrorResponse } from '@presentation/http/types/HttpResponse.js'; import { StatusCodes } from 'http-status-codes'; import type AuthSession from '@domain/entities/authSession.js'; @@ -38,6 +38,7 @@ const AuthRouter: FastifyPluginCallback = (fastify, opts, don */ fastify.post<{ Body: AuthOptions; + Reply: AuthSession | ErrorResponse; }>('/', async (request, reply) => { const { token } = request.body; @@ -47,12 +48,11 @@ const AuthRouter: FastifyPluginCallback = (fastify, opts, don * Check if session is valid */ if (!userSession) { - const response: ErrorResponse = { - status: StatusCodes.UNAUTHORIZED, - message: 'Session is not valid', - }; - - return reply.send(response); + return reply + .code(StatusCodes.UNAUTHORIZED) + .send({ + message: 'Session is not valid', + }); } const accessToken = opts.authService.signAccessToken({ id: userSession.userId }); @@ -60,14 +60,10 @@ const AuthRouter: FastifyPluginCallback = (fastify, opts, don await opts.authService.removeSessionByRefreshToken(token); const refreshToken = await opts.authService.signRefreshToken(userSession.userId); - const response: SuccessResponse = { - data: { - accessToken, - refreshToken, - }, - }; - - return reply.send(response); + return reply.send({ + accessToken, + refreshToken, + }); }); /** @@ -75,14 +71,13 @@ const AuthRouter: FastifyPluginCallback = (fastify, opts, don */ fastify.delete<{ Body: AuthOptions; + Reply: { ok: boolean } }>('/', async (request, reply) => { await opts.authService.removeSessionByRefreshToken(request.body.token); - const response: SuccessResponse = { - data: 'OK', - }; - - await reply.status(StatusCodes.OK).send(response); + return reply.status(StatusCodes.OK).send({ + ok: true, + }); }); done(); }; diff --git a/src/presentation/http/router/note.ts b/src/presentation/http/router/note.ts index 5c177581..6f078228 100644 --- a/src/presentation/http/router/note.ts +++ b/src/presentation/http/router/note.ts @@ -1,7 +1,7 @@ import type { FastifyPluginCallback } from 'fastify'; import type NoteService from '@domain/service/note.js'; import { StatusCodes } from 'http-status-codes'; -import type { ErrorResponse, SuccessResponse } from '@presentation/http/types/HttpResponse.js'; +import type { ErrorResponse } from '@presentation/http/types/HttpResponse.js'; import type { Note, NotePublicId } from '@domain/entities/note.js'; import type NotesSettings from '@domain/entities/notesSettings.js'; import type { Middlewares } from '@presentation/http/middlewares/index.js'; @@ -65,7 +65,10 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don /** * Get note by id */ - fastify.get<{ Params: GetNoteByIdOptions }>('/:id', { preHandler: opts.middlewares.withUser }, async (request, reply) => { + fastify.get<{ + Params: GetNoteByIdOptions, + Reply: Note | ErrorResponse + }>('/:id', { preHandler: opts.middlewares.withUser }, async (request, reply) => { const params = request.params; /** * TODO: Validate request params @@ -77,38 +80,24 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don /** * Check if note does not exist */ - if (!note) { - const response: ErrorResponse = { - status: StatusCodes.NOT_FOUND, - message: 'Note not found', - }; - - return reply.send(response); + if (note === null) { + return fastify.notFound(reply, 'Note not found'); } const noteSettings = await noteService.getNoteSettingsByNoteId(note.id); if (noteSettings?.enabled === true) { - /** - * Create success response - */ - const response: SuccessResponse = { - data: note, - }; - - return reply.send(response); + return reply.send(note); } /** * TODO: add check for collaborators by request context from auth middleware */ - - const response: ErrorResponse = { - status: StatusCodes.UNAUTHORIZED, - message: 'Permission denied', - }; - - await reply.send(response); + return reply + .code(StatusCodes.UNAUTHORIZED) + .send({ + message: 'Permission denied', + }); }); /** @@ -116,7 +105,10 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don * * @todo move to the NoteSettings Router */ - fastify.get<{ Params: GetNoteByIdOptions }>('/:id/settings', async (request, reply) => { + fastify.get<{ + Params: GetNoteByIdOptions, + Reply: NotesSettings + }>('/:id/settings', async (request, reply) => { const params = request.params; /** * TODO: Validate request params @@ -129,22 +121,10 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don * Check if note does not exist */ if (!notEmpty(noteSettings)) { - const response: ErrorResponse = { - status: StatusCodes.NOT_FOUND, - message: 'Note not found', - }; - - return reply.send(response); + return fastify.notFound(reply, 'Note settings not found'); } - /** - * Create success response - */ - const response: SuccessResponse = { - data: noteSettings, - }; - - return reply.send(response); + return reply.send(noteSettings); }); /** @@ -152,7 +132,8 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don */ fastify.patch<{ Body: Partial, - Params: GetNoteByIdOptions + Params: GetNoteByIdOptions, + Reply: NotesSettings, }>('/:id/settings', { preHandler: [opts.middlewares.authRequired, opts.middlewares.withUser] }, async (request, reply) => { const noteId = request.params.id; @@ -162,26 +143,21 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don const updatedNoteSettings = await noteService.patchNoteSettings(request.body, noteId); - if (!updatedNoteSettings) { - const response: ErrorResponse = { - status: StatusCodes.NOT_FOUND, - message: 'Note setting not found', - }; - - return reply.send(response); + if (updatedNoteSettings === null) { + return fastify.notFound(reply, 'Note settings not found'); } - const response: SuccessResponse = { - data: updatedNoteSettings, - }; - - return reply.send(response); + return reply.send(updatedNoteSettings); }); /** - * Add a new note + * Adds a new note. + * Responses with note public id. */ - fastify.post<{ Body: AddNoteOptions }>('/', { + fastify.post<{ + Body: AddNoteOptions, + Reply: { id: NotePublicId } + }>('/', { preHandler: [ opts.middlewares.authRequired, opts.middlewares.withUser, @@ -212,7 +188,10 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don /** * Get note by custom hostname */ - fastify.get<{ Params: ResolveHostnameOptions }>('/resolve-hostname/:hostname', async (request, reply) => { + fastify.get<{ + Params: ResolveHostnameOptions, + Reply: Note + }>('/resolve-hostname/:hostname', async (request, reply) => { const params = request.params; const note = await noteService.getNoteByHostname(params.hostname); @@ -220,23 +199,11 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don /** * Check if note does not exist */ - if (!note) { - const response: ErrorResponse = { - status: StatusCodes.NOT_FOUND, - message: 'Note not found', - }; - - return reply.send(response); + if (note === null) { + return fastify.notFound(reply, 'Note not found'); } - /** - * Create success response - */ - const response: SuccessResponse = { - data: note, - }; - - return reply.send(response); + return reply.send(note); }); done(); diff --git a/src/presentation/http/router/oauth.ts b/src/presentation/http/router/oauth.ts index 8fea2f5a..6f16d7f1 100644 --- a/src/presentation/http/router/oauth.ts +++ b/src/presentation/http/router/oauth.ts @@ -2,8 +2,6 @@ import type { FastifyPluginCallback } from 'fastify'; import type UserService from '@domain/service/user.js'; import { Provider } from '@domain/service/user.js'; import type AuthService from '@domain/service/auth.js'; -import type { ErrorResponse } from '@presentation/http/types/HttpResponse.js'; -import { StatusCodes } from 'http-status-codes'; /** * Interface for the oauth router. @@ -48,12 +46,7 @@ const OauthRouter: FastifyPluginCallback = (fastify, opts, d * Check if user exists */ if (!user) { - const response: ErrorResponse = { - status: StatusCodes.NOT_FOUND, - message: 'User not found', - }; - - return reply.send(response); + return fastify.notFound(reply, 'User not found'); } /** diff --git a/src/presentation/http/router/user.ts b/src/presentation/http/router/user.ts index 7444c187..eb09176b 100644 --- a/src/presentation/http/router/user.ts +++ b/src/presentation/http/router/user.ts @@ -1,6 +1,7 @@ import type { FastifyPluginCallback } from 'fastify'; import type { Middlewares } from '@presentation/http/middlewares/index.js'; import type UserService from '@domain/service/user.js'; +import type User from '@domain/entities/user.js'; /** * Interface for the user router @@ -33,14 +34,30 @@ const UserRouter: FastifyPluginCallback = (fastify, opts, don /** * Get user by session */ - fastify.get('/myself', { preHandler: [opts.middlewares.authRequired, opts.middlewares.withUser] }, async (request, reply) => { + fastify.get<{ + Reply: Pick, + }>('/myself', { + preHandler: [ + opts.middlewares.authRequired, + opts.middlewares.withUser, + ], + schema: { + response: { + '2xx': { + $ref: 'User', + }, + }, + }, + }, async (request, reply) => { const userId = request.ctx.auth.id; const user = await userService.getUserById(userId); - return reply.send({ - data: user, - }); + if (user === null) { + return fastify.notFound(reply, 'User not found'); + } + + return reply.send(user); }); /** diff --git a/src/presentation/http/schema/User.ts b/src/presentation/http/schema/User.ts new file mode 100644 index 00000000..9061d2f3 --- /dev/null +++ b/src/presentation/http/schema/User.ts @@ -0,0 +1,13 @@ +/** + * User entity used for validation and serialization + */ +export const UserSchema = { + $id: 'User', + type: 'object', + properties: { + id: { type: 'string' }, + email: { type: 'string' }, + name: { type: 'string' }, + photo: { type: 'string' }, + }, +}; diff --git a/src/presentation/http/schema/index.ts b/src/presentation/http/schema/index.ts new file mode 100644 index 00000000..9d40146a --- /dev/null +++ b/src/presentation/http/schema/index.ts @@ -0,0 +1,13 @@ +import type { FastifyInstance } from 'fastify'; +import type * as http from 'http'; +import type { pino } from 'pino'; +import { UserSchema } from './User.js'; + +/** + * Adds schemas to fastify instance + * + * @param fastify - server instance + */ +export function addSchema(fastify: FastifyInstance): void { + fastify.addSchema(UserSchema); +} diff --git a/src/presentation/http/types/HttpResponse.ts b/src/presentation/http/types/HttpResponse.ts index 96c25207..dd1e09bf 100644 --- a/src/presentation/http/types/HttpResponse.ts +++ b/src/presentation/http/types/HttpResponse.ts @@ -1,27 +1,16 @@ -interface SuccessResponse { - /** - * Response data - */ - data: Payload; -} - /** * Error response */ -interface ErrorResponse { +export interface ErrorResponse { /** - * Status code + * Message identifier used for translation on client side + * + * NOT HTTP STATUS CODE — it will be send in `status` field */ - status: number; + code?: number; /** - * Error message code + * Message text for better DX. Should not be showed to users. */ - message: string; + message?: string; } - -export type { - SuccessResponse, - ErrorResponse -}; -