diff --git a/README.md b/README.md index aacaa1e..a32f52b 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,38 @@ # dailytrends +## Tareas a realizar +- [*] Crea un proyecto TypeScript con una arquitectura de ficheros que consideres apropiada. +- [*] | Crea un modelo Feed y define sus atributos. + | El origen de datos tiene que ser MongoDB, por lo que puedes usar algún ODM. + +- [*] | Define los diferentes endpoints para gestionar los servicios CRUD del modelo Feed. + | Intenta desacoplar las capas del API lo máximo posible. + +- [ ] | Crea un “servicio de lectura de feeds” que extraiga por web scraping (no lectura de fuentes RSS) + | en cada uno de los periódicos sus noticias de portada y que las guarde como Feeds. + | Esta es la parte donde más conceptos de orientación a objetos puedes usar y la más “compleja”, ponle especial atención. + +> Otros detalles +- Representa en un dibujo la arquitectura y las capas de la aplicación. +- Usa todas las buenas prácticas que conozcas. +- Demuestra conocimientos en programación orientada a objetos: + - abstracción, encapsulamiento, herencia y polimorfismo. + - Haz los tests que consideres necesarios. ## Changelog - - Inicializamos proyecto: - Usando npm init, con la node@latest (v24.4.1) - First part: [#1 PR : feat/project_structure ](https://github.com/aabril/dailytrends/pull/1) - - Añadimos dependencias que voy a usar - - Creo una estructura de directorio inicial - - Añado un primer test (database.test.ts) para jest + - Crea un proyecto TypeScript con una arquitectura de ficheros que consideres apropiada. + - Añadimos dependencias que voy a usar + - Creo una estructura de directorio inicial + - Añado un primer test (database.test.ts) para jest - Second part: [#2 PR : feat/database_and_feed_model ](https://github.com/aabril/dailytrends/pull/2) && [#4 PR: feat/database_and_feed_model 2nd part](https://github.com/aabril/dailytrends/pull/4) + - Crea un modelo Feed y define sus atributos. El origen de datos tiene que ser MongoDB, por lo que puedes usar algún ODM. - Añadimos `moongose` a las dependencias - Añado un docker-compose con mongo local (luego lo ampliaré para esta propia app) - Modificar el docker para tenerlo multistage y reducir el tamaño de las imagenes de contenedores @@ -24,6 +43,11 @@ - añadir tests para FeedService & Feed.model - añadir funcionamiento de feed en las diferentes capas +- Third part: [#5 PR : feat/add_endpoints ](https://github.com/aabril/dailytrends/pull/5) + - Define los diferentes endpoints para gestionar los servicios CRUD del modelo Feed. Intenta desacoplar las capas del API lo máximo posible. + - reemplazar index por server.ts + - implement a basic server.ts in server.ts + ## Feed layer abstractions From higher to lower: diff --git a/jest.config.ts b/jest.config.ts index 72ec980..2648d1c 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,12 +3,14 @@ import type { Config } from 'jest'; const config: Config = { preset: 'ts-jest', testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], extensionsToTreatAsEsm: ['.ts'], transform: { - '^.+\\.ts$': ['ts-jest', { useESM: true }], + '^.+\.ts$': ['ts-jest', { useESM: true }], }, moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', + '^(\.{1,2}/.*)\.js$': '$1', }, }; diff --git a/package-lock.json b/package-lock.json index abc36e6..482aa2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.1", "license": "AGPL-3.0-or-later", "dependencies": { + "@hono/node-server": "^1.17.1", + "hono": "^4.8.9", "mongoose": "^8.16.5", "pino": "^9.7.0" }, @@ -1254,6 +1256,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.17.1.tgz", + "integrity": "sha512-SY79W/C+2b1MyAzmIcV32Q47vO1b5XwLRwj8S9N6Jr5n1QCkIfAIH6umOSgqWZ4/v67hg6qq8Ha5vZonVidGsg==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4153,6 +4167,15 @@ "dev": true, "license": "MIT" }, + "node_modules/hono": { + "version": "4.8.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.8.9.tgz", + "integrity": "sha512-ERIxkXMRhUxGV7nS/Af52+j2KL60B1eg+k6cPtgzrGughS+espS9KQ7QO0SMnevtmRlBfAcN0mf1jKtO6j/doA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/package.json b/package.json index d115a73..0d03db3 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "main": "index.js", "scripts": { "build": "tsc", - "start": "node dist/index.js", - "dev": "tsx watch src/index.ts", + "start": "node dist/server.js", + "dev": "tsx watch src/server.ts", "test": "jest", "test:watch": "jest --watch", "lint": "eslint src/**/*.ts", @@ -36,6 +36,8 @@ "typescript": "^5.8.3" }, "dependencies": { + "@hono/node-server": "^1.17.1", + "hono": "^4.8.9", "mongoose": "^8.16.5", "pino": "^9.7.0" } diff --git a/src/config/config.ts b/src/config/config.ts index 0e5069c..dec4b91 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,6 +1,5 @@ - - export interface IConfig { + port: number; mongodbUri: string; nodeEnv: string; } @@ -8,11 +7,13 @@ export interface IConfig { class Config implements IConfig { private static instance: Config; + public readonly port: number; public readonly mongodbUri: string; public readonly nodeEnv: string; private constructor() { + this.port = parseInt(process.env.PORT || '4000', 10); this.mongodbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/dailytrends'; this.nodeEnv = process.env.NODE_ENV || 'development'; diff --git a/src/controllers/FeedController.ts b/src/controllers/FeedController.ts index 0fe775e..983a0e3 100644 --- a/src/controllers/FeedController.ts +++ b/src/controllers/FeedController.ts @@ -1,6 +1,112 @@ -// Aquí exportamos una classe que gestiona las peticiones y comunica con los servicios/repositorioes -// thinking out loud: creo que voy a usar hono.dev, usaba express hace tiempo, y veo que es una especie de express moderno compatible con express +import { Context } from 'hono'; +import { IFeedService } from '../services/FeedService.js'; +import { NewsSource } from '../types/Feed.js'; export class FeedController { + private feedService: IFeedService; + + constructor(feedService: IFeedService) { + this.feedService = feedService; + } + + public getAllFeeds = async (c: Context) => { + const query = c.req.query(); + + const feedQuery = { + source: query.source as NewsSource, + category: query.category, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + limit: query.limit ? parseInt(query.limit) : 20, + offset: query.offset ? parseInt(query.offset) : 0 + }; + + const result = await this.feedService.getAllFeeds(feedQuery); + + return c.json({ + success: true, + data: result.data, + pagination: result.pagination + }); + }; + + public getTodaysNews = async (c: Context) => { + const feeds = await this.feedService.getTodaysFrontPageNews(); + + return c.json({ + success: true, + data: feeds, + count: feeds.length + }); + }; + + public getFeedById = async (c: Context) => { + const id = c.req.param('id'); + const feed = await this.feedService.getFeedById(id); + + return c.json({ + success: true, + data: feed + }); + }; + + public createFeed = async (c: Context) => { + const body = await c.req.json(); + + const feedData = { + ...body, + source: NewsSource.MANUAL, + publishedAt: body.publishedAt ? new Date(body.publishedAt) : new Date() + }; + + const feed = await this.feedService.createFeed(feedData); + + return c.json({ + success: true, + data: feed, + message: 'Feed creado exitosamente' + }, 201); + }; + + public updateFeed = async (c: Context) => { + const id = c.req.param('id'); + const body = await c.req.json(); + + const updateData = { + ...body, + publishedAt: body.publishedAt ? new Date(body.publishedAt) : undefined + }; + + const feed = await this.feedService.updateFeed(id, updateData); + + return c.json({ + success: true, + data: feed, + message: 'Feed actualizado exitosamente' + }); + }; + + public deleteFeed = async (c: Context) => { + const id = c.req.param('id'); + const deleted = await this.feedService.deleteFeed(id); + + return c.json({ + success: true, + message: 'Feed eliminado exitosamente' + }); + }; + + public getFeedsBySource = async (c: Context) => { + const source = c.req.param('source') as NewsSource; + const limit = parseInt(c.req.query('limit') || '10'); + + const feeds = await this.feedService.getFeedsBySource(source, limit); + + return c.json({ + success: true, + data: feeds, + count: feeds.length + }); + }; } \ No newline at end of file diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 42854fe..0000000 --- a/src/index.js +++ /dev/null @@ -1,5 +0,0 @@ -"use strict"; -const helloFn = (name) => { - return `Hola ${name}`; -}; -console.log(helloFn("Mundo")); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index bfe3d99..0000000 --- a/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -const helloFn = (name: string): string => { - return `Hola ${name}` -}; - -console.log(helloFn("Mundo")); - - diff --git a/src/routes/feedRoutes.ts b/src/routes/feedRoutes.ts new file mode 100644 index 0000000..9bb4a67 --- /dev/null +++ b/src/routes/feedRoutes.ts @@ -0,0 +1,23 @@ +import { Hono } from 'hono'; +import { FeedController } from '../controllers/FeedController'; +import { FeedService } from '../services/FeedService'; +import FeedRepository from '../repositories/FeedRepository'; + +// Dependency injection setup +const feedRepository = new FeedRepository(); +const feedService = new FeedService(feedRepository); +const feedController = new FeedController(feedService); + +// Create Hono router +const feedRoutes = new Hono(); + +// Feed CRUD routes +feedRoutes.get('/', feedController.getAllFeeds); +feedRoutes.get('/today', feedController.getTodaysNews); +feedRoutes.get('/source/:source', feedController.getFeedsBySource); +feedRoutes.get('/:id', feedController.getFeedById); +feedRoutes.post('/', feedController.createFeed); +feedRoutes.put('/:id', feedController.updateFeed); +feedRoutes.delete('/:id', feedController.deleteFeed); + +export { feedRoutes }; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..7f830ef --- /dev/null +++ b/src/server.ts @@ -0,0 +1,55 @@ +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { prettyJSON } from 'hono/pretty-json'; +import { feedRoutes } from './routes/feedRoutes'; +import { DatabaseConnection } from './config/database'; +import { config } from './config/config'; +import { Logger } from './utils/logger'; + +const app = new Hono(); + +// Middleware +app.use('*', cors({ origin: ['http://localhost:3000', 'http://localhost:5173'] })); +app.use('*', logger(), prettyJSON()); + +app.get('/health', (c) => c.json({ status: 'OK', timestamp: new Date().toISOString() })); + +app.route('/api/v1/feeds', feedRoutes); +app.notFound((c) => c.json({ error: 'Not found' }, 404)); +app.onError((err, c) => { + Logger.error('Error', { error: err }); + return c.json({ error: err.message }, 500); +}); + +async function initializeApp() { + try { + await DatabaseConnection.getInstance().connect(); + Logger.database.connected(); + + serve({ fetch: app.fetch, port: config.port }); + Logger.server.running(config.port); + } catch (error) { + Logger.error('Failed to start server', { error }); + process.exit(1); + } +} + +const shutdown = async () => { + try { + await DatabaseConnection.getInstance().disconnect(); + Logger.database.disconnected(); + process.exit(0); + } catch (error) { + Logger.error('Error during shutdown', { error }); + process.exit(1); + } +}; + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +initializeApp().catch(error => Logger.error('Failed to initialize app', { error })); + +export default app; \ No newline at end of file diff --git a/src/utils/logger.ts b/src/utils/logger.ts index ad98dd7..17c3809 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -46,6 +46,17 @@ export const Logger = { alreadyConnected: () => logger.info('🔄 Database is already connected'), notConnected: () => logger.warn('⚠️ Database is not connected') }, + + server: { + starting: (port: number, env: string) => { + logger.info(undefined, `🚀 Starting DailyTrends API server on port ${port}`); + logger.info(undefined, `📱 Environment: ${env}`); + logger.info(undefined, `🔗 Health check: http://localhost:${port}/health`); + logger.info(undefined, `📰 API Base URL: http://localhost:${port}/api/v1`); + }, + running: (port: number) => logger.info(undefined, `✅ Server is running on http://localhost:${port}`), + shutdown: (signal: string) => logger.info(undefined, `\n🛑 Received ${signal}, shutting down gracefully...`) + }, }; export default Logger; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 687d98b..e92fb93 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,17 @@ { "compilerOptions": { + "target": "ES2020", "module": "ESNext", - "target": "es2020", "moduleResolution": "Node", - "esModuleInterop": true, - "strict": true, "outDir": "dist", - "rootDir": "src" + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "strict": true }, - "include": ["src"] + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] }