Intégration Express
Exemple complet d'intégration de l'API MONCRENEAU avec Express.js et Node.js.
Structure du Projet
moncreneau-express/
├── package.json
├── .env.example
├── .gitignore
├── src/
│ ├── index.js
│ ├── config/
│ │ └── config.js
│ ├── routes/
│ │ ├── appointments.js
│ │ └── webhooks.js
│ ├── services/
│ │ ├── moncreneauService.js
│ │ └── webhookService.js
│ ├── middleware/
│ │ ├── errorHandler.js
│ │ └── rateLimiter.js
│ └── utils/
│ └── logger.js
├── tests/
│ ├── appointments.test.js
│ └── webhooks.test.js
└── README.md
Configuration
package.json
{
"name": "moncreneau-express",
"version": "1.0.0",
"description": "MONCRENEAU API integration with Express.js",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest --coverage",
"test:watch": "jest --watch"
},
"dependencies": {
"express": "^4.18.2",
"axios": "^1.6.2",
"dotenv": "^16.3.1",
"express-rate-limit": "^7.1.5",
"winston": "^3.11.0",
"p-throttle": "^6.1.0",
"bull": "^4.12.0"
},
"devDependencies": {
"jest": "^29.7.0",
"supertest": "^6.3.3",
"nodemon": "^3.0.2"
}
}
.env.example
# MONCRENEAU API
MONCRENEAU_API_URL=https://mc-prd.duckdns.org/api/v1
MONCRENEAU_API_KEY=YOUR_API_KEY
MONCRENEAU_WEBHOOK_SECRET=your_webhook_secret_here
# Server
PORT=3000
NODE_ENV=development
# Redis (for Bull queue)
REDIS_HOST=localhost
REDIS_PORT=6379
Configuration
src/config/config.js
require('dotenv').config();
module.exports = {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
moncreneau: {
apiUrl: process.env.MONCRENEAU_API_URL,
apiKey: process.env.MONCRENEAU_API_KEY,
webhookSecret: process.env.MONCRENEAU_WEBHOOK_SECRET,
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
},
};
Services
src/services/moncreneauService.js
const axios = require('axios');
const pThrottle = require('p-throttle');
const config = require('../config/config');
const logger = require('../utils/logger');
class MonCreneauService {
constructor() {
// Rate limiting: 80 req/min (safety margin)
const throttle = pThrottle({
limit: 80,
interval: 60000,
strict: true,
});
this.api = axios.create({
baseURL: config.moncreneau.apiUrl,
headers: {
'Authorization': `Bearer ${config.moncreneau.apiKey}`,
'Content-Type': 'application/json',
},
timeout: 30000,
});
// Throttle POST requests
this.throttledPost = throttle(this.api.post.bind(this.api));
// Error interceptor
this.api.interceptors.response.use(
response => response,
error => this.handleApiError(error)
);
}
async createAppointment(appointmentData) {
try {
logger.info('Creating appointment', {
organizationId: appointmentData.organizationId,
departmentId: appointmentData.departmentId,
});
const response = await this.throttledPost('/appointments', appointmentData);
logger.info('Appointment created', {
appointmentId: response.data.id,
status: response.data.status,
});
return response.data;
} catch (error) {
logger.error('Failed to create appointment', { error: error.message });
throw error;
}
}
async getAppointment(appointmentId) {
try {
const response = await this.api.get(`/appointments/${appointmentId}`);
return response.data;
} catch (error) {
logger.error(`Failed to get appointment ${appointmentId}`, { error: error.message });
throw error;
}
}
async cancelAppointment(appointmentId) {
try {
logger.info(`Cancelling appointment ${appointmentId}`);
await this.api.delete(`/appointments/${appointmentId}`);
logger.info(`Appointment ${appointmentId} cancelled`);
} catch (error) {
logger.error(`Failed to cancel appointment ${appointmentId}`, { error: error.message });
throw error;
}
}
handleApiError(error) {
if (error.response) {
const { status, data } = error.response;
if (status === 402) {
throw new Error('INSUFFICIENT_CREDITS: ' + data.error.message);
} else if (status === 400) {
throw new Error('VALIDATION_ERROR: ' + JSON.stringify(data.errors));
} else if (status === 429) {
throw new Error('RATE_LIMIT_EXCEEDED');
}
}
throw error;
}
}
module.exports = new MonCreneauService();
src/services/webhookService.js
const crypto = require('crypto');
const config = require('../config/config');
const logger = require('../utils/logger');
class WebhookService {
verifySignature(payload, signature) {
if (!signature || !signature.startsWith('sha256=')) {
logger.warn('Invalid signature format');
return false;
}
const receivedHash = signature.substring(7);
const expectedHash = crypto
.createHmac('sha256', config.moncreneau.webhookSecret)
.update(payload)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(receivedHash, 'hex'),
Buffer.from(expectedHash, 'hex')
);
} catch (error) {
logger.error('Signature verification error', { error: error.message });
return false;
}
}
async processEvent(event) {
logger.info('Processing webhook event', { type: event.type, id: event.id });
switch (event.type) {
case 'appointment.created':
await this.handleAppointmentCreated(event.data);
break;
case 'appointment.updated':
await this.handleAppointmentUpdated(event.data);
break;
case 'appointment.cancelled':
await this.handleAppointmentCancelled(event.data);
break;
case 'appointment.validated':
await this.handleAppointmentValidated(event.data);
break;
case 'appointment.completed':
await this.handleAppointmentCompleted(event.data);
break;
default:
logger.warn('Unknown event type', { type: event.type });
}
}
async handleAppointmentCreated(data) {
logger.info('Handling appointment.created', { appointmentId: data.object.id });
// Send confirmation email
// Send SMS
// Update local database
}
async handleAppointmentUpdated(data) {
logger.info('Handling appointment.updated', { appointmentId: data.object.id });
// Notify user of changes
}
async handleAppointmentCancelled(data) {
logger.info('Handling appointment.cancelled', { appointmentId: data.object.id });
// Send cancellation email
// Free resources
}
async handleAppointmentValidated(data) {
logger.info('Handling appointment.validated', { appointmentId: data.object.id });
// Confirm to staff
}
async handleAppointmentCompleted(data) {
logger.info('Handling appointment.completed', { appointmentId: data.object.id });
// Send satisfaction survey
}
}
module.exports = new WebhookService();
Routes
src/routes/appointments.js
const express = require('express');
const moncreneauService = require('../services/moncreneauService');
const router = express.Router();
router.post('/', async (req, res, next) => {
try {
const appointment = await moncreneauService.createAppointment(req.body);
res.status(201).json(appointment);
} catch (error) {
next(error);
}
});
router.get('/:id', async (req, res, next) => {
try {
const appointment = await moncreneauService.getAppointment(req.params.id);
res.json(appointment);
} catch (error) {
next(error);
}
});
router.delete('/:id', async (req, res, next) => {
try {
await moncreneauService.cancelAppointment(req.params.id);
res.status(200).json({ message: 'Appointment cancelled' });
} catch (error) {
next(error);
}
});
module.exports = router;
src/routes/webhooks.js
const express = require('express');
const webhookService = require('../services/webhookService');
const logger = require('../utils/logger');
const router = express.Router();
// IMPORTANT: Parse body as raw Buffer for signature verification
router.use(express.raw({ type: 'application/json' }));
router.post('/moncreneau', async (req, res) => {
const signature = req.headers['x-moncreneau-signature'];
const payload = req.body; // Buffer
// Verify signature
if (!webhookService.verifySignature(payload, signature)) {
logger.warn('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// Respond immediately
res.status(200).send('OK');
// Process asynchronously
try {
const event = JSON.parse(payload.toString());
// In production, use a queue (Bull, BullMQ, etc.)
setImmediate(() => webhookService.processEvent(event));
} catch (error) {
logger.error('Error processing webhook', { error: error.message });
}
});
module.exports = router;
Middleware
src/middleware/errorHandler.js
const logger = require('../utils/logger');
module.exports = (err, req, res, next) => {
logger.error('Error', {
error: err.message,
stack: err.stack,
path: req.path,
});
if (err.message.includes('INSUFFICIENT_CREDITS')) {
return res.status(402).json({
error: 'Insufficient credits',
message: 'Please purchase more credits to continue',
});
}
if (err.message.includes('VALIDATION_ERROR')) {
return res.status(400).json({
error: 'Validation error',
message: err.message,
});
}
res.status(500).json({
error: 'Internal server error',
message: 'An unexpected error occurred',
});
};
src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP',
});
module.exports = limiter;
Utils
src/utils/logger.js
const winston = require('winston');
const config = require('../config/config');
const logger = winston.createLogger({
level: config.nodeEnv === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
module.exports = logger;
Application
src/index.js
const express = require('express');
const config = require('./config/config');
const logger = require('./utils/logger');
const errorHandler = require('./middleware/errorHandler');
const rateLimiter = require('./middleware/rateLimiter');
const appointmentsRouter = require('./routes/appointments');
const webhooksRouter = require('./routes/webhooks');
const app = express();
// Middleware
app.use(rateLimiter);
// Routes - webhooks first (uses raw body)
app.use('/webhooks', webhooksRouter);
// JSON parser for other routes
app.use(express.json());
// API routes
app.use('/api/appointments', appointmentsRouter);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handler (must be last)
app.use(errorHandler);
// Start server
app.listen(config.port, () => {
logger.info(`Server running on port ${config.port}`);
logger.info(`Environment: ${config.nodeEnv}`);
});
module.exports = app;
Tests
tests/webhooks.test.js
const request = require('supertest');
const crypto = require('crypto');
const app = require('../src/index');
const WEBHOOK_SECRET = 'test_secret';
function signPayload(payload) {
const signature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
return `sha256=${signature}`;
}
describe('Webhook Endpoint', () => {
beforeAll(() => {
process.env.MONCRENEAU_WEBHOOK_SECRET = WEBHOOK_SECRET;
});
it('should accept valid webhook', async () => {
const payload = JSON.stringify({
id: 'evt_test_123',
type: 'appointment.created',
data: { object: { id: 123 } },
});
const signature = signPayload(payload);
const response = await request(app)
.post('/webhooks/moncreneau')
.set('Content-Type', 'application/json')
.set('X-Moncreneau-Signature', signature)
.send(payload);
expect(response.status).toBe(200);
});
it('should reject invalid signature', async () => {
const response = await request(app)
.post('/webhooks/moncreneau')
.set('X-Moncreneau-Signature', 'sha256=invalid')
.send(JSON.stringify({ id: 'evt_test' }));
expect(response.status).toBe(401);
});
});
Démarrage
# 1. Installation
npm install
# 2. Configuration
cp .env.example .env
# Éditer .env avec vos clés API
# 3. Développement
npm run dev
# 4. Production
npm start
# Avec Docker
docker build -t moncreneau-express .
docker run -p 3000:3000 --env-file .env moncreneau-express
Tests
# Lancer les tests
npm test
# Avec coverage
npm test -- --coverage
# Mode watch
npm run test:watch