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",
"@moncreneau/api": "^2.1.0",
"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
Ce service enveloppe le SDK Node.js officiel (@moncreneau/api) plutôt que d'appeler l'API en HTTP brut :
const Moncreneau = require('@moncreneau/api').default;
const pThrottle = require('p-throttle');
const config = require('../config/config');
const logger = require('../utils/logger');
class MonCreneauService {
constructor() {
this.client = new Moncreneau(config.moncreneau.apiKey, {
baseUrl: config.moncreneau.apiUrl,
timeout: 30000,
maxRetries: 3,
});
// Rate limiting côté client, en complément du maxRetries du SDK
// (80 req/min, marge de sécurité sous la limite par défaut de 1000/h)
const throttle = pThrottle({ limit: 80, interval: 60000, strict: true });
this.createAppointment = throttle(this._createAppointment.bind(this));
}
async _createAppointment(appointmentData) {
try {
logger.info('Creating appointment', { departmentId: appointmentData.departmentId });
const appointment = await this.client.appointments.create(appointmentData);
logger.info('Appointment created', {
appointmentId: appointment.id,
status: appointment.status,
});
return appointment;
} catch (error) {
logger.error('Failed to create appointment', { code: error.code, error: error.message });
throw error;
}
}
async getAppointment(appointmentId) {
try {
return await this.client.appointments.retrieve(appointmentId);
} catch (error) {
logger.error(`Failed to get appointment ${appointmentId}`, { code: error.code, error: error.message });
throw error;
}
}
async cancelAppointment(appointmentId) {
try {
logger.info(`Cancelling appointment ${appointmentId}`);
await this.client.appointments.cancel(appointmentId);
logger.info(`Appointment ${appointmentId} cancelled`);
} catch (error) {
logger.error(`Failed to cancel appointment ${appointmentId}`, { code: error.code, error: error.message });
throw error;
}
}
}
module.exports = new MonCreneauService();
Erreurs
Le SDK lève une `MoncreneauError` unique pour toute erreur API — inspectez `error.code` (`INSUFFICIENT_CREDITS`, `VALIDATION_ERROR`, `RATE_LIMIT_EXCEEDED`...) plutôt que de parser un code HTTP à la main. Voir Gestion des erreurs.
src/services/webhookService.js
const Moncreneau = require('@moncreneau/api').default;
const config = require('../config/config');
const logger = require('../utils/logger');
class WebhookService {
verifySignature(payload, signature) {
if (!signature) {
logger.warn('Missing signature header');
return false;
}
// Méthode statique du SDK — mêmes garanties (comparaison temps constant)
// qu'une implémentation maison, sans dupliquer la logique HMAC.
return Moncreneau.verifyWebhookSignature(
payload.toString('utf8'),
signature,
config.moncreneau.webhookSecret
);
}
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.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 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.code === 'INSUFFICIENT_CREDITS') {
return res.status(402).json({
error: 'Insufficient credits',
message: 'Please purchase more credits to continue',
});
}
if (err.code === 'VALIDATION_ERROR') {
return res.status(400).json({
error: 'Validation error',
message: err.message,
details: err.details,
});
}
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