merge pull request #5 from aabril/feat/add_endpoints
Define endpoints (implement server.ts)
This commit is contained in:
32
README.md
32
README.md
@ -1,19 +1,38 @@
|
|||||||
# dailytrends
|
# 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
|
## Changelog
|
||||||
|
|
||||||
|
|
||||||
- Inicializamos proyecto:
|
- Inicializamos proyecto:
|
||||||
- Usando npm init, con la node@latest (v24.4.1)
|
- Usando npm init, con la node@latest (v24.4.1)
|
||||||
|
|
||||||
- First part: [#1 PR : feat/project_structure ](https://github.com/aabril/dailytrends/pull/1)
|
- First part: [#1 PR : feat/project_structure ](https://github.com/aabril/dailytrends/pull/1)
|
||||||
- Añadimos dependencias que voy a usar
|
- Crea un proyecto TypeScript con una arquitectura de ficheros que consideres apropiada.
|
||||||
- Creo una estructura de directorio inicial
|
- Añadimos dependencias que voy a usar
|
||||||
- Añado un primer test (database.test.ts) para jest
|
- 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)
|
- 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ñadimos `moongose` a las dependencias
|
||||||
- Añado un docker-compose con mongo local (luego lo ampliaré para esta propia app)
|
- 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
|
- 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 tests para FeedService & Feed.model
|
||||||
- añadir funcionamiento de feed en las diferentes capas
|
- 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
|
## Feed layer abstractions
|
||||||
|
|
||||||
From higher to lower:
|
From higher to lower:
|
||||||
|
@ -3,12 +3,14 @@ import type { Config } from 'jest';
|
|||||||
const config: Config = {
|
const config: Config = {
|
||||||
preset: 'ts-jest',
|
preset: 'ts-jest',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||||
extensionsToTreatAsEsm: ['.ts'],
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.ts$': ['ts-jest', { useESM: true }],
|
'^.+\.ts$': ['ts-jest', { useESM: true }],
|
||||||
},
|
},
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
'^(\.{1,2}/.*)\.js$': '$1',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
23
package-lock.json
generated
23
package-lock.json
generated
@ -9,6 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.17.1",
|
||||||
|
"hono": "^4.8.9",
|
||||||
"mongoose": "^8.16.5",
|
"mongoose": "^8.16.5",
|
||||||
"pino": "^9.7.0"
|
"pino": "^9.7.0"
|
||||||
},
|
},
|
||||||
@ -1254,6 +1256,18 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@ -4153,6 +4167,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
@ -16,8 +16,8 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/server.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"lint": "eslint src/**/*.ts",
|
"lint": "eslint src/**/*.ts",
|
||||||
@ -36,6 +36,8 @@
|
|||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.17.1",
|
||||||
|
"hono": "^4.8.9",
|
||||||
"mongoose": "^8.16.5",
|
"mongoose": "^8.16.5",
|
||||||
"pino": "^9.7.0"
|
"pino": "^9.7.0"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
export interface IConfig {
|
export interface IConfig {
|
||||||
|
port: number;
|
||||||
mongodbUri: string;
|
mongodbUri: string;
|
||||||
nodeEnv: string;
|
nodeEnv: string;
|
||||||
}
|
}
|
||||||
@ -8,11 +7,13 @@ export interface IConfig {
|
|||||||
class Config implements IConfig {
|
class Config implements IConfig {
|
||||||
private static instance: Config;
|
private static instance: Config;
|
||||||
|
|
||||||
|
public readonly port: number;
|
||||||
public readonly mongodbUri: string;
|
public readonly mongodbUri: string;
|
||||||
public readonly nodeEnv: string;
|
public readonly nodeEnv: string;
|
||||||
|
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
|
this.port = parseInt(process.env.PORT || '4000', 10);
|
||||||
this.mongodbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/dailytrends';
|
this.mongodbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/dailytrends';
|
||||||
this.nodeEnv = process.env.NODE_ENV || 'development';
|
this.nodeEnv = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
|
@ -1,6 +1,112 @@
|
|||||||
// Aquí exportamos una classe que gestiona las peticiones y comunica con los servicios/repositorioes
|
import { Context } from 'hono';
|
||||||
// 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 { IFeedService } from '../services/FeedService.js';
|
||||||
|
import { NewsSource } from '../types/Feed.js';
|
||||||
|
|
||||||
export class FeedController {
|
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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
@ -1,5 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
const helloFn = (name) => {
|
|
||||||
return `Hola ${name}`;
|
|
||||||
};
|
|
||||||
console.log(helloFn("Mundo"));
|
|
@ -1,7 +0,0 @@
|
|||||||
const helloFn = (name: string): string => {
|
|
||||||
return `Hola ${name}`
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(helloFn("Mundo"));
|
|
||||||
|
|
||||||
|
|
23
src/routes/feedRoutes.ts
Normal file
23
src/routes/feedRoutes.ts
Normal file
@ -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 };
|
55
src/server.ts
Normal file
55
src/server.ts
Normal file
@ -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;
|
@ -46,6 +46,17 @@ export const Logger = {
|
|||||||
alreadyConnected: () => logger.info('🔄 Database is already connected'),
|
alreadyConnected: () => logger.info('🔄 Database is already connected'),
|
||||||
notConnected: () => logger.warn('⚠️ Database is not 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;
|
export default Logger;
|
@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "es2020",
|
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"esModuleInterop": true,
|
|
||||||
"strict": true,
|
|
||||||
"outDir": "dist",
|
"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"]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user