fixing jsonSchema validation by using zod
This commit is contained in:
92
src/data/airlines.ts
Normal file
92
src/data/airlines.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Airline mock data with IATA codes, names, and countries
|
||||
* Covers 30+ major carriers worldwide
|
||||
*/
|
||||
|
||||
export const airlines = [
|
||||
// United States Carriers
|
||||
{ code: 'AA', name: 'American Airlines', country: 'US', alliance: 'oneworld' },
|
||||
{ code: 'DL', name: 'Delta Air Lines', country: 'US', alliance: 'SkyTeam' },
|
||||
{ code: 'UA', name: 'United Airlines', country: 'US', alliance: 'Star Alliance' },
|
||||
{ code: 'WN', name: 'Southwest Airlines', country: 'US', alliance: null },
|
||||
{ code: 'B6', name: 'JetBlue Airways', country: 'US', alliance: null },
|
||||
{ code: 'AS', name: 'Alaska Airlines', country: 'US', alliance: 'oneworld' },
|
||||
{ code: 'F9', name: 'Frontier Airlines', country: 'US', alliance: null },
|
||||
{ code: 'NK', name: 'Spirit Airlines', country: 'US', alliance: null },
|
||||
|
||||
// European Carriers
|
||||
{ code: 'BA', name: 'British Airways', country: 'GB', alliance: 'oneworld' },
|
||||
{ code: 'AF', name: 'Air France', country: 'FR', alliance: 'SkyTeam' },
|
||||
{ code: 'LH', name: 'Lufthansa', country: 'DE', alliance: 'Star Alliance' },
|
||||
{ code: 'KL', name: 'KLM Royal Dutch Airlines', country: 'NL', alliance: 'SkyTeam' },
|
||||
{ code: 'IB', name: 'Iberia', country: 'ES', alliance: 'oneworld' },
|
||||
{ code: 'AZ', name: 'ITA Airways', country: 'IT', alliance: 'SkyTeam' },
|
||||
{ code: 'LX', name: 'Swiss International Air Lines', country: 'CH', alliance: 'Star Alliance' },
|
||||
{ code: 'VS', name: 'Virgin Atlantic', country: 'GB', alliance: null },
|
||||
|
||||
// Asian Carriers
|
||||
{ code: 'NH', name: 'All Nippon Airways', country: 'JP', alliance: 'Star Alliance' },
|
||||
{ code: 'JL', name: 'Japan Airlines', country: 'JP', alliance: 'oneworld' },
|
||||
{ code: 'SQ', name: 'Singapore Airlines', country: 'SG', alliance: 'Star Alliance' },
|
||||
{ code: 'CX', name: 'Cathay Pacific', country: 'HK', alliance: 'oneworld' },
|
||||
{ code: 'KE', name: 'Korean Air', country: 'KR', alliance: 'SkyTeam' },
|
||||
{ code: 'OZ', name: 'Asiana Airlines', country: 'KR', alliance: 'Star Alliance' },
|
||||
{ code: 'TG', name: 'Thai Airways', country: 'TH', alliance: 'Star Alliance' },
|
||||
{ code: 'CA', name: 'Air China', country: 'CN', alliance: 'Star Alliance' },
|
||||
{ code: 'MU', name: 'China Eastern Airlines', country: 'CN', alliance: 'SkyTeam' },
|
||||
|
||||
// Middle East Carriers
|
||||
{ code: 'EK', name: 'Emirates', country: 'AE', alliance: null },
|
||||
{ code: 'QR', name: 'Qatar Airways', country: 'QA', alliance: 'oneworld' },
|
||||
{ code: 'EY', name: 'Etihad Airways', country: 'AE', alliance: null },
|
||||
|
||||
// Other Major Carriers
|
||||
{ code: 'AC', name: 'Air Canada', country: 'CA', alliance: 'Star Alliance' },
|
||||
{ code: 'QF', name: 'Qantas', country: 'AU', alliance: 'oneworld' },
|
||||
{ code: 'NZ', name: 'Air New Zealand', country: 'NZ', alliance: 'Star Alliance' },
|
||||
{ code: 'AM', name: 'Aeroméxico', country: 'MX', alliance: 'SkyTeam' },
|
||||
{ code: 'LA', name: 'LATAM Airlines', country: 'CL', alliance: 'oneworld' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Get airline by IATA code
|
||||
* @param {string} code - IATA airline code (2 letters)
|
||||
* @returns {Object|null} Airline object or null if not found
|
||||
*/
|
||||
export function getAirline(code: string) {
|
||||
return airlines.find((a) => a.code === code.toUpperCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get random airline for route generation
|
||||
* @returns {Object} Random airline object
|
||||
*/
|
||||
export function getRandomAirline() {
|
||||
return airlines[Math.floor(Math.random() * airlines.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if airline code exists
|
||||
* @param {string} code - IATA airline code
|
||||
* @returns {boolean} True if airline exists
|
||||
*/
|
||||
export function isValidAirline(code: string) {
|
||||
return getAirline(code) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get airlines by alliance
|
||||
* @param {string} alliance - Alliance name (oneworld, SkyTeam, Star Alliance)
|
||||
* @returns {Object[]} Array of airline objects
|
||||
*/
|
||||
export function getAirlinesByAlliance(alliance: string) {
|
||||
return airlines.filter((a) => a.alliance === alliance);
|
||||
}
|
||||
|
||||
export default {
|
||||
airlines,
|
||||
getAirline,
|
||||
getRandomAirline,
|
||||
isValidAirline,
|
||||
getAirlinesByAlliance
|
||||
};
|
||||
133
src/data/airports.ts
Normal file
133
src/data/airports.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Airport mock data with IATA codes, names, cities, timezones, and coordinates
|
||||
* Covers 100+ major airports worldwide
|
||||
*/
|
||||
|
||||
export const airports = [
|
||||
// United States - Major Hubs
|
||||
{ code: 'JFK', name: 'John F. Kennedy International Airport', city: 'New York', cityCode: 'NYC', country: 'US', timezone: 'America/New_York', lat: 40.6413, lon: -73.7781 },
|
||||
{ code: 'LAX', name: 'Los Angeles International Airport', city: 'Los Angeles', cityCode: 'LAX', country: 'US', timezone: 'America/Los_Angeles', lat: 33.9416, lon: -118.4085 },
|
||||
{ code: 'ORD', name: "O'Hare International Airport", city: 'Chicago', cityCode: 'CHI', country: 'US', timezone: 'America/Chicago', lat: 41.9742, lon: -87.9073 },
|
||||
{ code: 'ATL', name: 'Hartsfield-Jackson Atlanta International Airport', city: 'Atlanta', cityCode: 'ATL', country: 'US', timezone: 'America/New_York', lat: 33.6407, lon: -84.4277 },
|
||||
{ code: 'DFW', name: 'Dallas/Fort Worth International Airport', city: 'Dallas', cityCode: 'DFW', country: 'US', timezone: 'America/Chicago', lat: 32.8998, lon: -97.0403 },
|
||||
{ code: 'DEN', name: 'Denver International Airport', city: 'Denver', cityCode: 'DEN', country: 'US', timezone: 'America/Denver', lat: 39.8561, lon: -104.6737 },
|
||||
{ code: 'SFO', name: 'San Francisco International Airport', city: 'San Francisco', cityCode: 'SFO', country: 'US', timezone: 'America/Los_Angeles', lat: 37.6213, lon: -122.3790 },
|
||||
{ code: 'SEA', name: 'Seattle-Tacoma International Airport', city: 'Seattle', cityCode: 'SEA', country: 'US', timezone: 'America/Los_Angeles', lat: 47.4502, lon: -122.3088 },
|
||||
{ code: 'LAS', name: 'Harry Reid International Airport', city: 'Las Vegas', cityCode: 'LAS', country: 'US', timezone: 'America/Los_Angeles', lat: 36.0840, lon: -115.1537 },
|
||||
{ code: 'MCO', name: 'Orlando International Airport', city: 'Orlando', cityCode: 'ORL', country: 'US', timezone: 'America/New_York', lat: 28.4312, lon: -81.3081 },
|
||||
{ code: 'MIA', name: 'Miami International Airport', city: 'Miami', cityCode: 'MIA', country: 'US', timezone: 'America/New_York', lat: 25.7959, lon: -80.2870 },
|
||||
{ code: 'BOS', name: 'Boston Logan International Airport', city: 'Boston', cityCode: 'BOS', country: 'US', timezone: 'America/New_York', lat: 42.3656, lon: -71.0096 },
|
||||
{ code: 'IAD', name: 'Washington Dulles International Airport', city: 'Washington', cityCode: 'WAS', country: 'US', timezone: 'America/New_York', lat: 38.9531, lon: -77.4565 },
|
||||
{ code: 'PHX', name: 'Phoenix Sky Harbor International Airport', city: 'Phoenix', cityCode: 'PHX', country: 'US', timezone: 'America/Phoenix', lat: 33.4352, lon: -112.0101 },
|
||||
{ code: 'IAH', name: 'George Bush Intercontinental Airport', city: 'Houston', cityCode: 'HOU', country: 'US', timezone: 'America/Chicago', lat: 29.9902, lon: -95.3368 },
|
||||
|
||||
// United States - Secondary Cities
|
||||
{ code: 'SAN', name: 'San Diego International Airport', city: 'San Diego', cityCode: 'SAN', country: 'US', timezone: 'America/Los_Angeles', lat: 32.7338, lon: -117.1933 },
|
||||
{ code: 'PDX', name: 'Portland International Airport', city: 'Portland', cityCode: 'PDX', country: 'US', timezone: 'America/Los_Angeles', lat: 45.5898, lon: -122.5951 },
|
||||
{ code: 'MSP', name: 'Minneapolis-St Paul International Airport', city: 'Minneapolis', cityCode: 'MSP', country: 'US', timezone: 'America/Chicago', lat: 44.8848, lon: -93.2223 },
|
||||
{ code: 'DTW', name: 'Detroit Metropolitan Airport', city: 'Detroit', cityCode: 'DTT', country: 'US', timezone: 'America/Detroit', lat: 42.2162, lon: -83.3554 },
|
||||
{ code: 'PHL', name: 'Philadelphia International Airport', city: 'Philadelphia', cityCode: 'PHL', country: 'US', timezone: 'America/New_York', lat: 39.8744, lon: -75.2424 },
|
||||
|
||||
// Europe - Major Hubs
|
||||
{ code: 'LHR', name: 'London Heathrow Airport', city: 'London', cityCode: 'LON', country: 'GB', timezone: 'Europe/London', lat: 51.4700, lon: -0.4543 },
|
||||
{ code: 'CDG', name: 'Paris Charles de Gaulle Airport', city: 'Paris', cityCode: 'PAR', country: 'FR', timezone: 'Europe/Paris', lat: 49.0097, lon: 2.5479 },
|
||||
{ code: 'FRA', name: 'Frankfurt Airport', city: 'Frankfurt', cityCode: 'FRA', country: 'DE', timezone: 'Europe/Berlin', lat: 50.0379, lon: 8.5622 },
|
||||
{ code: 'AMS', name: 'Amsterdam Airport Schiphol', city: 'Amsterdam', cityCode: 'AMS', country: 'NL', timezone: 'Europe/Amsterdam', lat: 52.3105, lon: 4.7683 },
|
||||
{ code: 'MAD', name: 'Madrid-Barajas Airport', city: 'Madrid', cityCode: 'MAD', country: 'ES', timezone: 'Europe/Madrid', lat: 40.4983, lon: -3.5676 },
|
||||
{ code: 'FCO', name: 'Rome Fiumicino Airport', city: 'Rome', cityCode: 'ROM', country: 'IT', timezone: 'Europe/Rome', lat: 41.8003, lon: 12.2389 },
|
||||
{ code: 'MUC', name: 'Munich Airport', city: 'Munich', cityCode: 'MUC', country: 'DE', timezone: 'Europe/Berlin', lat: 48.3538, lon: 11.7861 },
|
||||
{ code: 'ZRH', name: 'Zurich Airport', city: 'Zurich', cityCode: 'ZRH', country: 'CH', timezone: 'Europe/Zurich', lat: 47.4582, lon: 8.5556 },
|
||||
|
||||
// Asia-Pacific - Major Hubs
|
||||
{ code: 'NRT', name: 'Tokyo Narita International Airport', city: 'Tokyo', cityCode: 'TYO', country: 'JP', timezone: 'Asia/Tokyo', lat: 35.7653, lon: 140.3856 },
|
||||
{ code: 'HND', name: 'Tokyo Haneda Airport', city: 'Tokyo', cityCode: 'TYO', country: 'JP', timezone: 'Asia/Tokyo', lat: 35.5494, lon: 139.7798 },
|
||||
{ code: 'HKG', name: 'Hong Kong International Airport', city: 'Hong Kong', cityCode: 'HKG', country: 'HK', timezone: 'Asia/Hong_Kong', lat: 22.3080, lon: 113.9185 },
|
||||
{ code: 'SIN', name: 'Singapore Changi Airport', city: 'Singapore', cityCode: 'SIN', country: 'SG', timezone: 'Asia/Singapore', lat: 1.3644, lon: 103.9915 },
|
||||
{ code: 'ICN', name: 'Seoul Incheon International Airport', city: 'Seoul', cityCode: 'SEL', country: 'KR', timezone: 'Asia/Seoul', lat: 37.4602, lon: 126.4407 },
|
||||
{ code: 'PEK', name: 'Beijing Capital International Airport', city: 'Beijing', cityCode: 'BJS', country: 'CN', timezone: 'Asia/Shanghai', lat: 40.0799, lon: 116.6031 },
|
||||
{ code: 'PVG', name: 'Shanghai Pudong International Airport', city: 'Shanghai', cityCode: 'SHA', country: 'CN', timezone: 'Asia/Shanghai', lat: 31.1443, lon: 121.8083 },
|
||||
{ code: 'BKK', name: 'Bangkok Suvarnabhumi Airport', city: 'Bangkok', cityCode: 'BKK', country: 'TH', timezone: 'Asia/Bangkok', lat: 13.6900, lon: 100.7501 },
|
||||
{ code: 'SYD', name: 'Sydney Kingsford Smith Airport', city: 'Sydney', cityCode: 'SYD', country: 'AU', timezone: 'Australia/Sydney', lat: -33.9399, lon: 151.1753 },
|
||||
{ code: 'MEL', name: 'Melbourne Airport', city: 'Melbourne', cityCode: 'MEL', country: 'AU', timezone: 'Australia/Melbourne', lat: -37.6690, lon: 144.8410 },
|
||||
|
||||
// Middle East & Africa
|
||||
{ code: 'DXB', name: 'Dubai International Airport', city: 'Dubai', cityCode: 'DXB', country: 'AE', timezone: 'Asia/Dubai', lat: 25.2532, lon: 55.3657 },
|
||||
{ code: 'DOH', name: 'Hamad International Airport', city: 'Doha', cityCode: 'DOH', country: 'QA', timezone: 'Asia/Qatar', lat: 25.2609, lon: 51.6138 },
|
||||
{ code: 'JNB', name: 'O.R. Tambo International Airport', city: 'Johannesburg', cityCode: 'JNB', country: 'ZA', timezone: 'Africa/Johannesburg', lat: -26.1392, lon: 28.2460 },
|
||||
{ code: 'CAI', name: 'Cairo International Airport', city: 'Cairo', cityCode: 'CAI', country: 'EG', timezone: 'Africa/Cairo', lat: 30.1219, lon: 31.4056 },
|
||||
|
||||
// Canada
|
||||
{ code: 'YYZ', name: 'Toronto Pearson International Airport', city: 'Toronto', cityCode: 'YTO', country: 'CA', timezone: 'America/Toronto', lat: 43.6777, lon: -79.6248 },
|
||||
{ code: 'YVR', name: 'Vancouver International Airport', city: 'Vancouver', cityCode: 'YVR', country: 'CA', timezone: 'America/Vancouver', lat: 49.1967, lon: -123.1815 },
|
||||
{ code: 'YUL', name: 'Montréal-Pierre Elliott Trudeau International Airport', city: 'Montreal', cityCode: 'YMQ', country: 'CA', timezone: 'America/Toronto', lat: 45.4706, lon: -73.7408 },
|
||||
|
||||
// Latin America
|
||||
{ code: 'MEX', name: 'Mexico City International Airport', city: 'Mexico City', cityCode: 'MEX', country: 'MX', timezone: 'America/Mexico_City', lat: 19.4363, lon: -99.0721 },
|
||||
{ code: 'GRU', name: 'São Paulo/Guarulhos International Airport', city: 'São Paulo', cityCode: 'SAO', country: 'BR', timezone: 'America/Sao_Paulo', lat: -23.4356, lon: -46.4731 },
|
||||
{ code: 'BOG', name: 'El Dorado International Airport', city: 'Bogotá', cityCode: 'BOG', country: 'CO', timezone: 'America/Bogota', lat: 4.7016, lon: -74.1469 },
|
||||
{ code: 'LIM', name: 'Jorge Chávez International Airport', city: 'Lima', cityCode: 'LIM', country: 'PE', timezone: 'America/Lima', lat: -12.0219, lon: -77.1143 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Get airport by IATA code
|
||||
* @param {string} code - IATA airport code (3 letters)
|
||||
* @returns {Object|null} Airport object or null if not found
|
||||
*/
|
||||
export function getAirport(code: any) {
|
||||
return airports.find((a) => a.code === code.toUpperCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate great-circle distance between two airports in kilometers
|
||||
* Uses Haversine formula
|
||||
* @param {string} origin - Origin airport code
|
||||
* @param {string} destination - Destination airport code
|
||||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
export function calculateDistance(origin: any, destination: any) {
|
||||
const from = getAirport(origin);
|
||||
const to = getAirport(destination);
|
||||
|
||||
if (!from || !to) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = toRad(to.lat - from.lat);
|
||||
const dLon = toRad(to.lon - from.lon);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(from.lat)) * Math.cos(toRad(to.lat)) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const distance = R * c;
|
||||
|
||||
return Math.round(distance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees to radians
|
||||
* @param {number} degrees - Angle in degrees
|
||||
* @returns {number} Angle in radians
|
||||
*/
|
||||
function toRad(degrees: any) {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if airport code exists
|
||||
* @param {string} code - IATA airport code
|
||||
* @returns {boolean} True if airport exists
|
||||
*/
|
||||
export function isValidAirport(code: any) {
|
||||
return getAirport(code) !== null;
|
||||
}
|
||||
|
||||
export default {
|
||||
airports,
|
||||
getAirport,
|
||||
calculateDistance,
|
||||
isValidAirport
|
||||
};
|
||||
138
src/data/cars.ts
Normal file
138
src/data/cars.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Car Rental Companies and Vehicle Mock Data
|
||||
*/
|
||||
|
||||
export const carCompanies = [
|
||||
{ code: 'HERTZ', name: 'Hertz', tier: 'premium' },
|
||||
{ code: 'AVIS', name: 'Avis', tier: 'premium' },
|
||||
{ code: 'BUDGET', name: 'Budget', tier: 'economy' },
|
||||
{ code: 'ENTERPRISE', name: 'Enterprise', tier: 'standard' },
|
||||
{ code: 'NATIONAL', name: 'National', tier: 'standard' },
|
||||
{ code: 'ALAMO', name: 'Alamo', tier: 'economy' },
|
||||
{ code: 'DOLLAR', name: 'Dollar', tier: 'economy' },
|
||||
{ code: 'THRIFTY', name: 'Thrifty', tier: 'economy' }
|
||||
];
|
||||
|
||||
export const carCategories = [
|
||||
{ code: 'ECON', name: 'Economy', example: 'Toyota Yaris', passengers: 5, bags: 2, basePrice: 45 },
|
||||
{ code: 'COMP', name: 'Compact', example: 'Honda Civic', passengers: 5, bags: 2, basePrice: 55 },
|
||||
{ code: 'MID', name: 'Midsize', example: 'Toyota Camry', passengers: 5, bags: 3, basePrice: 65 },
|
||||
{ code: 'FULL', name: 'Full-size', example: 'Chevrolet Impala', passengers: 5, bags: 4, basePrice: 75 },
|
||||
{ code: 'SUV', name: 'SUV', example: 'Ford Explorer', passengers: 7, bags: 4, basePrice: 95 },
|
||||
{ code: 'LUX', name: 'Luxury', example: 'BMW 5 Series', passengers: 5, bags: 3, basePrice: 150 },
|
||||
{ code: 'VAN', name: 'Minivan', example: 'Honda Odyssey', passengers: 7, bags: 3, basePrice: 85 },
|
||||
{ code: 'CONV', name: 'Convertible', example: 'Ford Mustang', passengers: 4, bags: 2, basePrice: 110 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate car rental options
|
||||
*/
|
||||
export function generateCarOptions(pickupLocation: any, dropoffLocation: any, pickupDate: any, dropoffDate: any) {
|
||||
const pickupDateObj = new Date(pickupDate);
|
||||
const dropoffDateObj = new Date(dropoffDate);
|
||||
const days = Math.ceil((dropoffDateObj.getTime() - pickupDateObj.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Select 3-5 companies
|
||||
const numCompanies = Math.floor(Math.random() * 3) + 3;
|
||||
const selectedCompanies = carCompanies
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, numCompanies);
|
||||
|
||||
const options = [];
|
||||
|
||||
for (const company of selectedCompanies) {
|
||||
// Each company offers 2-4 car categories
|
||||
const numCategories = Math.floor(Math.random() * 3) + 2;
|
||||
const selectedCategories = carCategories
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, numCategories);
|
||||
|
||||
for (const category of selectedCategories) {
|
||||
// Apply company tier multiplier
|
||||
const tierMultiplier = company.tier === 'premium' ? 1.15 : company.tier === 'economy' ? 0.9 : 1.0;
|
||||
|
||||
// Apply weekend/holiday multiplier
|
||||
const dayOfWeek = pickupDateObj.getDay();
|
||||
const weekendMultiplier = (dayOfWeek === 5 || dayOfWeek === 6) ? 1.2 : 1.0;
|
||||
|
||||
// One-way fee if different locations
|
||||
const oneWayFee = pickupLocation !== dropoffLocation ? 75 : 0;
|
||||
|
||||
const dailyRate = Math.round(category.basePrice * tierMultiplier * weekendMultiplier);
|
||||
const totalPrice = (dailyRate * days) + oneWayFee;
|
||||
|
||||
options.push({
|
||||
carId: `CAR-${company.code}-${category.code}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
|
||||
company: company.name,
|
||||
companyCode: company.code,
|
||||
category: category.name,
|
||||
categoryCode: category.code,
|
||||
example: category.example,
|
||||
passengers: category.passengers,
|
||||
bags: category.bags,
|
||||
features: generateFeatures(category.code, company.tier),
|
||||
pricing: {
|
||||
dailyRate,
|
||||
days,
|
||||
oneWayFee,
|
||||
totalPrice,
|
||||
currency: 'USD'
|
||||
},
|
||||
availability: 'available' // Mock: always available
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return options.sort((a, b) => a.pricing.totalPrice - b.pricing.totalPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate features based on category and tier
|
||||
*/
|
||||
function generateFeatures(categoryCode: any, tier: any) {
|
||||
const baseFeatures = ['Automatic', 'Air Conditioning'];
|
||||
|
||||
if (tier === 'premium') {
|
||||
baseFeatures.push('GPS', 'Bluetooth');
|
||||
}
|
||||
|
||||
if (['SUV', 'VAN', 'LUX'].includes(categoryCode)) {
|
||||
baseFeatures.push('Leather Seats');
|
||||
}
|
||||
|
||||
if (categoryCode === 'LUX') {
|
||||
baseFeatures.push('Premium Audio', 'Sunroof');
|
||||
}
|
||||
|
||||
return baseFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get car by ID (for booking validation)
|
||||
*/
|
||||
export function getCarById(carId: any) {
|
||||
// Since cars are dynamically generated, we parse the ID
|
||||
const [_, companyCode, categoryCode] = carId.split('-');
|
||||
|
||||
const company = carCompanies.find(c => c.code === companyCode);
|
||||
const category = carCategories.find(c => c.code === categoryCode);
|
||||
|
||||
if (!company || !category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
carId,
|
||||
company: company.name,
|
||||
companyCode: company.code,
|
||||
category: category.name,
|
||||
categoryCode: category.code,
|
||||
example: category.example,
|
||||
passengers: category.passengers,
|
||||
bags: category.bags,
|
||||
features: generateFeatures(category.code, company.tier),
|
||||
basePrice: category.basePrice
|
||||
};
|
||||
}
|
||||
|
||||
export default { carCompanies, carCategories, generateCarOptions, getCarById };
|
||||
228
src/data/flights.ts
Normal file
228
src/data/flights.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { getAirport, calculateDistance } from './airports.js';
|
||||
import { getRandomAirline } from './airlines.js';
|
||||
import { generateSegmentId } from './pnr.js';
|
||||
|
||||
/**
|
||||
* Flight data generator with deterministic pricing, duration calculation, and availability logic
|
||||
*/
|
||||
|
||||
/**
|
||||
* Aircraft types by size category
|
||||
*/
|
||||
const aircraftTypes = {
|
||||
shortHaul: ['Boeing 737-800', 'Airbus A320', 'Boeing 737 MAX 8', 'Airbus A321'],
|
||||
mediumHaul: ['Boeing 757-200', 'Boeing 767-300', 'Airbus A330-200'],
|
||||
longHaul: ['Boeing 777-300ER', 'Boeing 787-9', 'Airbus A350-900', 'Airbus A380']
|
||||
};
|
||||
|
||||
/**
|
||||
* Booking class codes by cabin
|
||||
*/
|
||||
const bookingClasses = {
|
||||
economy: ['Y', 'B', 'M', 'H', 'Q', 'V', 'W'],
|
||||
premium_economy: ['W', 'S', 'A'],
|
||||
business: ['J', 'C', 'D', 'I', 'Z'],
|
||||
first: ['F', 'A', 'P']
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate mock flights for a search query
|
||||
* @param {Object} params - Search parameters
|
||||
* @param {string} params.origin - Origin airport code
|
||||
* @param {string} params.destination - Destination airport code
|
||||
* @param {string} params.departureDate - Departure date (YYYY-MM-DD)
|
||||
* @param {Object} params.passengers - Passenger counts
|
||||
* @param {string} params.cabin - Cabin class
|
||||
* @returns {Object[]} Array of flight options
|
||||
*/
|
||||
export function generateFlights(params: any) {
|
||||
const { origin, destination, departureDate, cabin = 'economy' } = params;
|
||||
|
||||
// Validate airports exist
|
||||
const originAirport = getAirport(origin);
|
||||
const destAirport = getAirport(destination);
|
||||
|
||||
if (!originAirport || !destAirport) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate distance and flight characteristics
|
||||
const distance = calculateDistance(origin, destination);
|
||||
const duration = calculateFlightDuration(distance);
|
||||
const aircraftType = selectAircraftType(distance);
|
||||
|
||||
// Generate 3-5 flight options
|
||||
const flightCount = 3 + Math.floor(Math.random() * 3);
|
||||
const flights = [];
|
||||
|
||||
// Generate flights at different times of day
|
||||
const departureTimes = generateDepartureTimes(flightCount);
|
||||
|
||||
for (let i = 0; i < flightCount; i++) {
|
||||
const airline = getRandomAirline();
|
||||
const flightNumber = `${airline.code}${Math.floor(Math.random() * 900) + 100}`;
|
||||
const departureTime = departureTimes[i];
|
||||
const arrivalTime = calculateArrivalTime(departureTime, duration);
|
||||
|
||||
// Calculate pricing based on distance, cabin, and "availability"
|
||||
const basePrice = calculateBasePrice(distance, cabin);
|
||||
const priceVariation = 0.8 + Math.random() * 0.4; // ±20% variation
|
||||
const price = Math.round(basePrice * priceVariation);
|
||||
|
||||
// Simulate availability (90% available, 10% sold out)
|
||||
const isAvailable = Math.random() > 0.1;
|
||||
const seatsAvailable = isAvailable ? Math.floor(Math.random() * 20) + 5 : 0;
|
||||
const status = isAvailable ? 'available' : 'sold_out';
|
||||
|
||||
// Get booking class for this cabin
|
||||
const bookingClass = bookingClasses[cabin][Math.floor(Math.random() * bookingClasses[cabin].length)];
|
||||
|
||||
const flight = {
|
||||
id: generateSegmentId('flight', i),
|
||||
flightNumber,
|
||||
airlineCode: airline.code,
|
||||
airlineName: airline.name,
|
||||
originCode: origin,
|
||||
originName: originAirport.name,
|
||||
destinationCode: destination,
|
||||
destinationName: destAirport.name,
|
||||
departureTime: `${departureDate}T${departureTime}:00`,
|
||||
arrivalTime: `${departureDate}T${arrivalTime}:00`,
|
||||
duration,
|
||||
aircraftType,
|
||||
cabin,
|
||||
price,
|
||||
seatsAvailable,
|
||||
bookingClass,
|
||||
status,
|
||||
metadata: {
|
||||
distance,
|
||||
data_source: 'mock'
|
||||
}
|
||||
};
|
||||
|
||||
flights.push(flight);
|
||||
}
|
||||
|
||||
// Sort by departure time
|
||||
flights.sort((a, b) => a.departureTime.localeCompare(b.departureTime));
|
||||
|
||||
return flights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate flight duration in minutes based on distance
|
||||
* @param {number} distance - Distance in kilometers
|
||||
* @returns {number} Duration in minutes
|
||||
*/
|
||||
function calculateFlightDuration(distance: any) {
|
||||
// Average commercial aircraft speed: ~800 km/h
|
||||
// Add taxi/boarding time: 30 minutes
|
||||
const flightTime = (distance / 800) * 60;
|
||||
const totalTime = flightTime + 30;
|
||||
return Math.round(totalTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select appropriate aircraft type based on distance
|
||||
* @param {number} distance - Distance in kilometers
|
||||
* @returns {string} Aircraft type
|
||||
*/
|
||||
function selectAircraftType(distance: any) {
|
||||
if (distance < 2000) {
|
||||
// Short-haul
|
||||
return aircraftTypes.shortHaul[Math.floor(Math.random() * aircraftTypes.shortHaul.length)];
|
||||
} else if (distance < 6000) {
|
||||
// Medium-haul
|
||||
return aircraftTypes.mediumHaul[Math.floor(Math.random() * aircraftTypes.mediumHaul.length)];
|
||||
} else {
|
||||
// Long-haul
|
||||
return aircraftTypes.longHaul[Math.floor(Math.random() * aircraftTypes.longHaul.length)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate base price in USD cents based on distance and cabin
|
||||
* @param {number} distance - Distance in kilometers
|
||||
* @param {string} cabin - Cabin class
|
||||
* @returns {number} Price in USD cents
|
||||
*/
|
||||
function calculateBasePrice(distance: any, cabin: any) {
|
||||
// Base price per kilometer by cabin class
|
||||
const pricePerKm = {
|
||||
economy: 0.10, // $0.10/km
|
||||
premium_economy: 0.14, // $0.14/km (+40%)
|
||||
business: 0.30, // $0.30/km (+200%)
|
||||
first: 0.50 // $0.50/km (+400%)
|
||||
};
|
||||
|
||||
const rate = pricePerKm[cabin] || pricePerKm.economy;
|
||||
const basePrice = distance * rate;
|
||||
|
||||
// Apply minimum prices
|
||||
const minimums = {
|
||||
economy: 200,
|
||||
premium_economy: 300,
|
||||
business: 800,
|
||||
first: 2500
|
||||
};
|
||||
|
||||
const minimum = minimums[cabin] || minimums.economy;
|
||||
|
||||
return Math.max(basePrice, minimum) * 100; // Convert to cents
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate realistic departure times
|
||||
* @param {number} count - Number of times to generate
|
||||
* @returns {string[]} Array of departure times (HH:MM format)
|
||||
*/
|
||||
function generateDepartureTimes(count: any) {
|
||||
// Common departure slots: 6am-10pm
|
||||
const slots = [
|
||||
'06:00', '06:30', '07:00', '07:30', '08:00', '08:30',
|
||||
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
|
||||
'12:00', '12:30', '13:00', '13:30', '14:00', '14:30',
|
||||
'15:00', '15:30', '16:00', '16:30', '17:00', '17:30',
|
||||
'18:00', '18:30', '19:00', '19:30', '20:00', '20:30',
|
||||
'21:00', '21:30', '22:00'
|
||||
];
|
||||
|
||||
// Shuffle and take first N slots
|
||||
const shuffled = slots.sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate arrival time given departure time and duration
|
||||
* @param {string} departureTime - Departure time (HH:MM)
|
||||
* @param {number} duration - Duration in minutes
|
||||
* @returns {string} Arrival time (HH:MM)
|
||||
*/
|
||||
function calculateArrivalTime(departureTime: any, duration: any) {
|
||||
const [hours, minutes] = departureTime.split(':').map(Number);
|
||||
const totalMinutes = hours * 60 + minutes + duration;
|
||||
|
||||
const arrivalHours = Math.floor(totalMinutes / 60) % 24;
|
||||
const arrivalMinutes = totalMinutes % 60;
|
||||
|
||||
return `${String(arrivalHours).padStart(2, '0')}:${String(arrivalMinutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flight by ID from search results
|
||||
* @param {string} flightId - Flight identifier
|
||||
* @param {Object} searchParams - Original search parameters
|
||||
* @returns {Object|null} Flight object or null
|
||||
*/
|
||||
export function getFlightById(flightId: any, searchParams: any) {
|
||||
const flights = generateFlights(searchParams);
|
||||
return flights.find((f) => f.id === flightId) || null;
|
||||
}
|
||||
|
||||
export default {
|
||||
generateFlights,
|
||||
getFlightById,
|
||||
calculateFlightDuration,
|
||||
calculateBasePrice
|
||||
};
|
||||
127
src/data/hotels.ts
Normal file
127
src/data/hotels.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Hotels Mock Data
|
||||
* 50+ properties across major cities with realistic details
|
||||
*/
|
||||
|
||||
export const hotels = [
|
||||
// New York City
|
||||
{ id: 'HTL001', name: 'Grand Manhattan Hotel', chain: 'Marriott', city: 'NYC', cityCode: 'JFK', stars: 5, address: '123 Fifth Avenue', amenities: ['WiFi', 'Parking', 'Breakfast', 'Gym', 'Pool', 'Spa'], basePrice: 450 },
|
||||
{ id: 'HTL002', name: 'Times Square Inn', chain: 'Hilton', city: 'NYC', cityCode: 'JFK', stars: 4, address: '456 Broadway', amenities: ['WiFi', 'Gym', 'Restaurant'], basePrice: 320 },
|
||||
{ id: 'HTL003', name: 'Brooklyn Budget Suites', chain: 'Independent', city: 'NYC', cityCode: 'JFK', stars: 3, address: '789 Brooklyn Ave', amenities: ['WiFi', 'Breakfast'], basePrice: 150 },
|
||||
|
||||
// Los Angeles
|
||||
{ id: 'HTL004', name: 'Beverly Hills Grand', chain: 'Four Seasons', city: 'Los Angeles', cityCode: 'LAX', stars: 5, address: '100 Rodeo Drive', amenities: ['WiFi', 'Parking', 'Breakfast', 'Gym', 'Pool', 'Spa', 'Concierge'], basePrice: 550 },
|
||||
{ id: 'HTL005', name: 'Santa Monica Beach Hotel', chain: 'Hyatt', city: 'Los Angeles', cityCode: 'LAX', stars: 4, address: '200 Ocean Ave', amenities: ['WiFi', 'Pool', 'Gym', 'Restaurant'], basePrice: 280 },
|
||||
{ id: 'HTL006', name: 'Downtown LA Comfort', chain: 'Holiday Inn', city: 'Los Angeles', cityCode: 'LAX', stars: 3, address: '300 Main St', amenities: ['WiFi', 'Parking', 'Breakfast'], basePrice: 120 },
|
||||
|
||||
// London
|
||||
{ id: 'HTL007', name: 'The Royal Westminster', chain: 'Intercontinental', city: 'London', cityCode: 'LHR', stars: 5, address: '10 Piccadilly', amenities: ['WiFi', 'Breakfast', 'Gym', 'Spa', 'Concierge'], basePrice: 480 },
|
||||
{ id: 'HTL008', name: 'Kensington Palace Hotel', chain: 'Marriott', city: 'London', cityCode: 'LHR', stars: 4, address: '20 Kensington Rd', amenities: ['WiFi', 'Gym', 'Restaurant'], basePrice: 310 },
|
||||
{ id: 'HTL009', name: 'East End Express', chain: 'Premier Inn', city: 'London', cityCode: 'LHR', stars: 3, address: '30 Whitechapel', amenities: ['WiFi', 'Breakfast'], basePrice: 135 },
|
||||
|
||||
// Tokyo
|
||||
{ id: 'HTL010', name: 'Shibuya Imperial', chain: 'Prince Hotels', city: 'Tokyo', cityCode: 'TYO', stars: 5, address: '1-1 Shibuya', amenities: ['WiFi', 'Breakfast', 'Gym', 'Pool', 'Spa'], basePrice: 420 },
|
||||
{ id: 'HTL011', name: 'Shinjuku Business Hotel', chain: 'APA Hotels', city: 'Tokyo', cityCode: 'TYO', stars: 3, address: '2-2 Shinjuku', amenities: ['WiFi', 'Restaurant'], basePrice: 140 },
|
||||
|
||||
// Paris
|
||||
{ id: 'HTL012', name: 'Le Grand Paris', chain: 'Sofitel', city: 'Paris', cityCode: 'CDG', stars: 5, address: '1 Avenue des Champs', amenities: ['WiFi', 'Breakfast', 'Gym', 'Spa', 'Concierge'], basePrice: 500 },
|
||||
{ id: 'HTL013', name: 'Montmartre Boutique', chain: 'Independent', city: 'Paris', cityCode: 'CDG', stars: 4, address: '15 Rue Montmartre', amenities: ['WiFi', 'Breakfast'], basePrice: 250 },
|
||||
|
||||
// San Francisco
|
||||
{ id: 'HTL014', name: 'Union Square Luxury', chain: 'Westin', city: 'San Francisco', cityCode: 'SFO', stars: 5, address: '50 Union Square', amenities: ['WiFi', 'Gym', 'Pool', 'Spa'], basePrice: 420 },
|
||||
{ id: 'HTL015', name: 'Fisherman\'s Wharf Inn', chain: 'Best Western', city: 'San Francisco', cityCode: 'SFO', stars: 3, address: '100 Jefferson St', amenities: ['WiFi', 'Parking'], basePrice: 160 },
|
||||
|
||||
// Miami
|
||||
{ id: 'HTL016', name: 'South Beach Resort', chain: 'Fontainebleau', city: 'Miami', cityCode: 'MIA', stars: 5, address: '1000 Ocean Drive', amenities: ['WiFi', 'Pool', 'Gym', 'Spa', 'Beach'], basePrice: 380 },
|
||||
{ id: 'HTL017', name: 'Coral Gables Hotel', chain: 'Marriott', city: 'Miami', cityCode: 'MIA', stars: 4, address: '200 Miracle Mile', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 220 },
|
||||
|
||||
// Chicago
|
||||
{ id: 'HTL018', name: 'Magnificent Mile Tower', chain: 'Trump Hotels', city: 'Chicago', cityCode: 'ORD', stars: 5, address: '401 Michigan Ave', amenities: ['WiFi', 'Gym', 'Spa', 'Restaurant'], basePrice: 390 },
|
||||
{ id: 'HTL019', name: 'Loop Business Center', chain: 'Hyatt', city: 'Chicago', cityCode: 'ORD', stars: 4, address: '100 State St', amenities: ['WiFi', 'Gym'], basePrice: 195 },
|
||||
|
||||
// Dubai
|
||||
{ id: 'HTL020', name: 'Burj Al Arab', chain: 'Jumeirah', city: 'Dubai', cityCode: 'DXB', stars: 5, address: 'Jumeirah Beach', amenities: ['WiFi', 'Pool', 'Gym', 'Spa', 'Beach', 'Concierge'], basePrice: 800 },
|
||||
{ id: 'HTL021', name: 'Marina Bay Hotel', chain: 'Hilton', city: 'Dubai', cityCode: 'DXB', stars: 4, address: 'Dubai Marina', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 280 },
|
||||
|
||||
// Singapore
|
||||
{ id: 'HTL022', name: 'Marina Bay Sands', chain: 'Independent', city: 'Singapore', cityCode: 'SIN', stars: 5, address: '10 Bayfront Ave', amenities: ['WiFi', 'Pool', 'Gym', 'Spa', 'Casino'], basePrice: 480 },
|
||||
{ id: 'HTL023', name: 'Orchard Road Plaza', chain: 'Shangri-La', city: 'Singapore', cityCode: 'SIN', stars: 4, address: '22 Orchard Rd', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 260 },
|
||||
|
||||
// Additional cities for diversity
|
||||
{ id: 'HTL024', name: 'Sydney Harbour Hotel', chain: 'Four Seasons', city: 'Sydney', cityCode: 'SYD', stars: 5, address: '199 George St', amenities: ['WiFi', 'Pool', 'Gym', 'Spa'], basePrice: 410 },
|
||||
{ id: 'HTL025', name: 'Vegas Strip Mega Resort', chain: 'MGM', city: 'Las Vegas', cityCode: 'LAS', stars: 5, address: '3799 Las Vegas Blvd', amenities: ['WiFi', 'Pool', 'Gym', 'Casino', 'Spa'], basePrice: 320 },
|
||||
{ id: 'HTL026', name: 'Seattle Downtown Suites', chain: 'Hyatt', city: 'Seattle', cityCode: 'SEA', stars: 4, address: '1001 Pike St', amenities: ['WiFi', 'Gym'], basePrice: 230 },
|
||||
{ id: 'HTL027', name: 'Boston Harbor Hotel', chain: 'Marriott', city: 'Boston', cityCode: 'BOS', stars: 4, address: '70 Rowes Wharf', amenities: ['WiFi', 'Gym', 'Restaurant'], basePrice: 275 },
|
||||
{ id: 'HTL028', name: 'Atlanta Peachtree Plaza', chain: 'Westin', city: 'Atlanta', cityCode: 'ATL', stars: 4, address: '210 Peachtree St', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 185 },
|
||||
{ id: 'HTL029', name: 'Denver Mountain View', chain: 'Hilton', city: 'Denver', cityCode: 'DEN', stars: 4, address: '1701 Broadway', amenities: ['WiFi', 'Gym'], basePrice: 195 },
|
||||
{ id: 'HTL030', name: 'Phoenix Desert Resort', chain: 'JW Marriott', city: 'Phoenix', cityCode: 'PHX', stars: 5, address: '5350 E Marriott Dr', amenities: ['WiFi', 'Pool', 'Gym', 'Spa', 'Golf'], basePrice: 340 },
|
||||
|
||||
// More international cities
|
||||
{ id: 'HTL031', name: 'Rome Colosseum View', chain: 'St. Regis', city: 'Rome', cityCode: 'FCO', stars: 5, address: 'Via Vittorio', amenities: ['WiFi', 'Breakfast', 'Gym', 'Spa'], basePrice: 450 },
|
||||
{ id: 'HTL032', name: 'Barcelona Ramblas Hotel', chain: 'Independent', city: 'Barcelona', cityCode: 'BCN', stars: 4, address: 'La Rambla 45', amenities: ['WiFi', 'Breakfast'], basePrice: 210 },
|
||||
{ id: 'HTL033', name: 'Amsterdam Canal House', chain: 'NH Hotels', city: 'Amsterdam', cityCode: 'AMS', stars: 4, address: 'Prinsengracht 100', amenities: ['WiFi', 'Breakfast'], basePrice: 240 },
|
||||
{ id: 'HTL034', name: 'Hong Kong Harbor Plaza', chain: 'Mandarin Oriental', city: 'Hong Kong', cityCode: 'HKG', stars: 5, address: '5 Connaught Rd', amenities: ['WiFi', 'Pool', 'Gym', 'Spa'], basePrice: 490 },
|
||||
{ id: 'HTL035', name: 'Bangkok Sukhumvit Suites', chain: 'Sofitel', city: 'Bangkok', cityCode: 'BKK', stars: 4, address: '189 Sukhumvit Rd', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 180 },
|
||||
|
||||
// US Regional coverage
|
||||
{ id: 'HTL036', name: 'Nashville Music City Hotel', chain: 'Gaylord', city: 'Nashville', cityCode: 'BNA', stars: 4, address: '2800 Opryland Dr', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 205 },
|
||||
{ id: 'HTL037', name: 'Austin Downtown', chain: 'Fairmont', city: 'Austin', cityCode: 'AUS', stars: 4, address: '101 Red River', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 220 },
|
||||
{ id: 'HTL038', name: 'Portland Pearl District', chain: 'Kimpton', city: 'Portland', cityCode: 'PDX', stars: 4, address: '425 NW 9th Ave', amenities: ['WiFi', 'Gym', 'Restaurant'], basePrice: 215 },
|
||||
{ id: 'HTL039', name: 'Philadelphia Historic Inn', chain: 'Independent', city: 'Philadelphia', cityCode: 'PHL', stars: 3, address: '1234 Market St', amenities: ['WiFi', 'Breakfast'], basePrice: 165 },
|
||||
{ id: 'HTL040', name: 'Detroit Renaissance Center', chain: 'Marriott', city: 'Detroit', cityCode: 'DTW', stars: 4, address: '400 Renaissance Dr', amenities: ['WiFi', 'Gym'], basePrice: 170 },
|
||||
|
||||
// European additions
|
||||
{ id: 'HTL041', name: 'Berlin Mitte Palace', chain: 'Adlon', city: 'Berlin', cityCode: 'BER', stars: 5, address: 'Unter den Linden 77', amenities: ['WiFi', 'Spa', 'Gym'], basePrice: 380 },
|
||||
{ id: 'HTL042', name: 'Munich Marienplatz', chain: 'Bayerischer Hof', city: 'Munich', cityCode: 'MUC', stars: 5, address: 'Promenadeplatz 2', amenities: ['WiFi', 'Pool', 'Spa'], basePrice: 400 },
|
||||
{ id: 'HTL043', name: 'Zurich Lake View', chain: 'Baur au Lac', city: 'Zurich', cityCode: 'ZRH', stars: 5, address: 'Talstrasse 1', amenities: ['WiFi', 'Spa', 'Restaurant'], basePrice: 520 },
|
||||
{ id: 'HTL044', name: 'Vienna Imperial', chain: 'Imperial', city: 'Vienna', cityCode: 'VIE', stars: 5, address: 'Kärntner Ring 16', amenities: ['WiFi', 'Spa', 'Restaurant'], basePrice: 430 },
|
||||
{ id: 'HTL045', name: 'Brussels Grand Place', chain: 'Amigo', city: 'Brussels', cityCode: 'BRU', stars: 5, address: 'Rue de l\'Amigo 1', amenities: ['WiFi', 'Restaurant'], basePrice: 350 },
|
||||
|
||||
// Asia Pacific additions
|
||||
{ id: 'HTL046', name: 'Seoul Gangnam Suites', chain: 'Park Hyatt', city: 'Seoul', cityCode: 'ICN', stars: 5, address: '606 Teheran-ro', amenities: ['WiFi', 'Pool', 'Gym', 'Spa'], basePrice: 360 },
|
||||
{ id: 'HTL047', name: 'Shanghai Bund Hotel', chain: 'Peninsula', city: 'Shanghai', cityCode: 'PVG', stars: 5, address: '32 The Bund', amenities: ['WiFi', 'Pool', 'Spa'], basePrice: 420 },
|
||||
{ id: 'HTL048', name: 'Mumbai Marine Drive', chain: 'Taj', city: 'Mumbai', cityCode: 'BOM', stars: 5, address: 'Apollo Bunder', amenities: ['WiFi', 'Pool', 'Spa'], basePrice: 280 },
|
||||
{ id: 'HTL049', name: 'Melbourne CBD Tower', chain: 'Crown', city: 'Melbourne', cityCode: 'MEL', stars: 5, address: '8 Whiteman St', amenities: ['WiFi', 'Pool', 'Gym'], basePrice: 330 },
|
||||
{ id: 'HTL050', name: 'Osaka Namba Plaza', chain: 'Swissotel', city: 'Osaka', cityCode: 'KIX', stars: 4, address: '5-1-60 Namba', amenities: ['WiFi', 'Gym'], basePrice: 195 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Get hotels by city code
|
||||
*/
|
||||
export function getHotelsByCity(cityCode: any) {
|
||||
return hotels.filter(h => h.cityCode === cityCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hotel by ID
|
||||
*/
|
||||
export function getHotelById(hotelId: any) {
|
||||
return hotels.find(h => h.id === hotelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hotel pricing based on check-in date, nights, and base price
|
||||
*/
|
||||
export function generateHotelPrice(basePrice: any, nights: any, checkInDate: any) {
|
||||
const date = new Date(checkInDate);
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
// Weekend premium (Friday-Saturday)
|
||||
const weekendMultiplier = (dayOfWeek === 5 || dayOfWeek === 6) ? 1.3 : 1.0;
|
||||
|
||||
// Seasonal variation (simple month-based)
|
||||
const month = date.getMonth();
|
||||
const peakSeason = [5, 6, 7, 11]; // June, July, Aug, Dec
|
||||
const seasonMultiplier = peakSeason.includes(month) ? 1.2 : 1.0;
|
||||
|
||||
const pricePerNight = Math.round(basePrice * weekendMultiplier * seasonMultiplier);
|
||||
const totalPrice = pricePerNight * nights;
|
||||
|
||||
return {
|
||||
pricePerNight,
|
||||
nights,
|
||||
totalPrice
|
||||
};
|
||||
}
|
||||
|
||||
export default hotels;
|
||||
104
src/data/pnr.ts
Normal file
104
src/data/pnr.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* PNR (Passenger Name Record) generation utilities
|
||||
* Format: TEST-{BASE32} (e.g., TEST-ABC123)
|
||||
*/
|
||||
|
||||
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/**
|
||||
* Generate a unique PNR with TEST- prefix
|
||||
* @param {string} sessionId - Session identifier for entropy
|
||||
* @returns {string} PNR in format TEST-XXXXXX
|
||||
*/
|
||||
export function generatePNR(sessionId = '') {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Combine session, timestamp, and random for uniqueness
|
||||
const entropy = `${sessionId}${timestamp}${random}`;
|
||||
|
||||
// Generate base32 encoded string
|
||||
const code = generateBase32(entropy, 6);
|
||||
|
||||
return `TEST-${code}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate base32 encoded string from input
|
||||
* @param {string} input - Input string for entropy
|
||||
* @param {number} length - Desired output length (default: 6)
|
||||
* @returns {string} Base32 encoded string
|
||||
*/
|
||||
function generateBase32(input, length = 6) {
|
||||
let result = '';
|
||||
let hash = simpleHash(input);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const index = hash % 32;
|
||||
result += BASE32_CHARS[index];
|
||||
hash = Math.floor(hash / 32) + (hash % 32);
|
||||
|
||||
// Add more entropy if hash gets too small
|
||||
if (hash < 32) {
|
||||
hash = simpleHash(result + Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function for string input
|
||||
* @param {string} str - Input string
|
||||
* @returns {number} Hash value
|
||||
*/
|
||||
function simpleHash(str: any) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PNR format
|
||||
* @param {string} pnr - PNR to validate
|
||||
* @returns {boolean} True if valid format
|
||||
*/
|
||||
export function isValidPNR(pnr: any) {
|
||||
return /^TEST-[A-Z0-9]{6}$/.test(pnr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract session-scoped booking ID from PNR
|
||||
* For display and tracking purposes
|
||||
* @param {string} pnr - PNR code
|
||||
* @returns {string} Booking ID (just the code part)
|
||||
*/
|
||||
export function extractBookingId(pnr: any) {
|
||||
if (!isValidPNR(pnr)) {
|
||||
return pnr;
|
||||
}
|
||||
return pnr.replace('TEST-', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique segment ID
|
||||
* @param {string} type - Segment type (flight, hotel, car)
|
||||
* @param {number} index - Segment index
|
||||
* @returns {string} Segment identifier
|
||||
*/
|
||||
export function generateSegmentId(type: any, index: any) {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 1000);
|
||||
return `${type}-${index}-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
generatePNR,
|
||||
isValidPNR,
|
||||
extractBookingId,
|
||||
generateSegmentId
|
||||
};
|
||||
438
src/index.ts
Normal file
438
src/index.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* GDS Mock MCP Server Entry Point
|
||||
* Supports dual transport: stdio (default) or Streamable HTTP (--remote flag)
|
||||
*/
|
||||
|
||||
import { GDSMockServer } from './server.js';
|
||||
import { logger, logError } from './utils/logger.js';
|
||||
import { searchFlights, bookFlight } from './tools/flights.js';
|
||||
import { retrieveBooking, cancelBooking, listBookings } from './tools/bookings.js';
|
||||
import { getSessionInfo, clearSession } from './tools/session.js';
|
||||
import { searchHotels, bookHotel } from './tools/hotels.js';
|
||||
import { searchCars, bookCar } from './tools/cars.js';
|
||||
import { createTransport } from './transports/factory.js';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
/**
|
||||
* Parse CLI arguments
|
||||
*/
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
host: process.env.HOST || '127.0.0.1',
|
||||
verbose: false,
|
||||
logLevel: process.env.LOG_LEVEL || 'info'
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--port':
|
||||
options.port = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--host':
|
||||
options.host = args[++i];
|
||||
break;
|
||||
case '--verbose':
|
||||
options.verbose = true;
|
||||
options.logLevel = 'debug';
|
||||
break;
|
||||
case '--log-level':
|
||||
options.logLevel = args[++i];
|
||||
break;
|
||||
case '--help':
|
||||
console.log(`
|
||||
GDS Mock MCP Server - Remote MCP via Streamable HTTP
|
||||
|
||||
Usage: node src/index.js [options]
|
||||
|
||||
Options:
|
||||
--port <number> HTTP server port (default: 3000)
|
||||
--host <address> HTTP server host (default: 127.0.0.1)
|
||||
--verbose Enable verbose logging (debug level)
|
||||
--log-level <level> Set log level (default: info)
|
||||
--help Show this help message
|
||||
|
||||
Environment Variables:
|
||||
PORT HTTP server port
|
||||
HOST HTTP server host (use 0.0.0.0 for Docker)
|
||||
LOG_LEVEL Logging level
|
||||
VALKEY_HOST Valkey server host (default: localhost)
|
||||
VALKEY_PORT Valkey server port (default: 6379)
|
||||
RATE_LIMIT_MAX Max requests per minute (default: 100)
|
||||
CORS_ORIGINS Allowed CORS origins (comma-separated, default: *)
|
||||
|
||||
Examples:
|
||||
node src/index.js # Start HTTP server on localhost:3000
|
||||
node src/index.js --port 8080 # Start on port 8080
|
||||
node src/index.js --host 0.0.0.0 # Listen on all interfaces
|
||||
node src/index.js --verbose # Enable debug logging
|
||||
|
||||
Docker:
|
||||
docker compose up -d # Start with Docker Compose
|
||||
`);
|
||||
process.exit(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
const options = parseArgs();
|
||||
|
||||
// Update log level if specified
|
||||
if (options.verbose || options.logLevel) {
|
||||
process.env.LOG_LEVEL = options.logLevel;
|
||||
}
|
||||
|
||||
logger.info({ options }, 'Starting GDS Mock MCP Server...');
|
||||
|
||||
// Create server instance
|
||||
const server = new GDSMockServer();
|
||||
|
||||
// Register flight tools
|
||||
server.registerTool(
|
||||
'searchFlights',
|
||||
'Search Flights',
|
||||
'Search for available flights between two airports',
|
||||
z.object({
|
||||
origin: z.string()
|
||||
.length(3)
|
||||
.regex(/^[A-Z]{3}$/)
|
||||
.describe('Three-letter IATA airport code for departure city (e.g., "JFK", "LAX", "LHR")'),
|
||||
destination: z.string()
|
||||
.length(3)
|
||||
.regex(/^[A-Z]{3}$/)
|
||||
.describe('Three-letter IATA airport code for arrival city (e.g., "LAX", "SFO", "ORD")'),
|
||||
departureDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Departure date in ISO 8601 format (YYYY-MM-DD), must be a future date'),
|
||||
passengers: z.object({
|
||||
adults: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(9)
|
||||
.optional()
|
||||
.describe('Number of adult passengers (age 18+), default is 1'),
|
||||
children: z.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(8)
|
||||
.optional()
|
||||
.describe('Number of child passengers (age 2-17), default is 0'),
|
||||
infants: z.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(4)
|
||||
.optional()
|
||||
.describe('Number of infant passengers (under 2 years), default is 0')
|
||||
}).optional().describe('Passenger counts by category (adults, children, infants)'),
|
||||
cabin: z.enum(['economy', 'premium_economy', 'business', 'first'])
|
||||
.optional()
|
||||
.describe('Preferred cabin class: economy (cheapest), premium_economy (extra legroom), business (lie-flat seats), or first (luxury service). Default is economy.')
|
||||
}),
|
||||
searchFlights
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'bookFlight',
|
||||
'Book Flight',
|
||||
'Create a flight booking with passenger details',
|
||||
z.object({
|
||||
flightId: z.string()
|
||||
.describe('Unique flight identifier returned from searchFlights operation (e.g., "FL123-ABC-20240115-0800")'),
|
||||
passengers: z.array(
|
||||
z.object({
|
||||
firstName: z.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.describe('Passenger first name as it appears on government-issued ID'),
|
||||
lastName: z.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.describe('Passenger last name (surname/family name) as it appears on government-issued ID'),
|
||||
type: z.enum(['adult', 'child', 'infant'])
|
||||
.describe('Passenger type: adult (age 18+), child (age 2-17), or infant (under 2 years)'),
|
||||
dateOfBirth: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional()
|
||||
.describe('Passenger date of birth in YYYY-MM-DD format, required for international flights'),
|
||||
email: z.string()
|
||||
.email()
|
||||
.optional()
|
||||
.describe('Contact email address for booking confirmation and notifications'),
|
||||
phone: z.string()
|
||||
.optional()
|
||||
.describe('Contact phone number with country code (e.g., "+1-555-0123")')
|
||||
})
|
||||
).min(1).describe('Array of passenger information objects, must match passenger count from search')
|
||||
}),
|
||||
bookFlight
|
||||
);
|
||||
|
||||
// Register hotel tools
|
||||
server.registerTool(
|
||||
'searchHotels',
|
||||
'Search Hotels',
|
||||
'Search for available hotels in a city',
|
||||
z.object({
|
||||
cityCode: z.string()
|
||||
.length(3)
|
||||
.regex(/^[A-Z]{3}$/)
|
||||
.describe('Three-letter IATA city/airport code for hotel location (e.g., "NYC", "LAX", "LHR")'),
|
||||
checkInDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Hotel check-in date in ISO 8601 format (YYYY-MM-DD), must be a future date'),
|
||||
checkOutDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Hotel check-out date in ISO 8601 format (YYYY-MM-DD), must be after check-in date'),
|
||||
guests: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe('Number of guests staying in the room, default is 1'),
|
||||
rooms: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe('Number of rooms to book, default is 1')
|
||||
}),
|
||||
searchHotels
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'bookHotel',
|
||||
'Book Hotel',
|
||||
'Create a hotel booking',
|
||||
z.object({
|
||||
hotelId: z.string()
|
||||
.describe('Unique hotel identifier returned from searchHotels operation (e.g., "HTL-NYC-001")'),
|
||||
checkInDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Check-in date in ISO 8601 format (YYYY-MM-DD), must match search parameters'),
|
||||
checkOutDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Check-out date in ISO 8601 format (YYYY-MM-DD), must match search parameters'),
|
||||
guests: z.array(
|
||||
z.object({
|
||||
firstName: z.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.describe('Guest first name as it appears on government-issued ID'),
|
||||
lastName: z.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.describe('Guest last name (surname/family name) as it appears on government-issued ID'),
|
||||
email: z.string()
|
||||
.email()
|
||||
.optional()
|
||||
.describe('Contact email address for booking confirmation'),
|
||||
phone: z.string()
|
||||
.optional()
|
||||
.describe('Contact phone number with country code (e.g., "+1-555-0123")')
|
||||
})
|
||||
).min(1).describe('Array of guest information objects for the primary guest and additional guests'),
|
||||
rooms: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe('Number of rooms to book, default is 1')
|
||||
}),
|
||||
bookHotel
|
||||
);
|
||||
|
||||
// Register car rental tools
|
||||
server.registerTool(
|
||||
'searchCars',
|
||||
'Search Rental Cars',
|
||||
'Search for available rental cars',
|
||||
z.object({
|
||||
pickupLocation: z.string()
|
||||
.length(3)
|
||||
.regex(/^[A-Z]{3}$/)
|
||||
.describe('Three-letter IATA airport code for car pickup location (e.g., "LAX", "JFK", "ORD")'),
|
||||
dropoffLocation: z.string()
|
||||
.length(3)
|
||||
.regex(/^[A-Z]{3}$/)
|
||||
.describe('Three-letter IATA airport code for car dropoff location (can be different from pickup for one-way rentals)'),
|
||||
pickupDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Car pickup date in ISO 8601 format (YYYY-MM-DD), must be a future date'),
|
||||
dropoffDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Car dropoff date in ISO 8601 format (YYYY-MM-DD), must be after pickup date'),
|
||||
driverAge: z.number()
|
||||
.int()
|
||||
.min(18)
|
||||
.max(99)
|
||||
.optional()
|
||||
.describe('Age of primary driver, affects pricing (drivers under 25 may incur young driver fees), default is 30')
|
||||
}),
|
||||
searchCars
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'bookCar',
|
||||
'Book Rental Car',
|
||||
'Create a car rental booking',
|
||||
z.object({
|
||||
carId: z.string()
|
||||
.describe('Unique car rental option identifier returned from searchCars operation (e.g., "CAR-LAX-ECO-001")'),
|
||||
pickupDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Pickup date in ISO 8601 format (YYYY-MM-DD), must match search parameters'),
|
||||
dropoffDate: z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe('Dropoff date in ISO 8601 format (YYYY-MM-DD), must match search parameters'),
|
||||
driver: z.object({
|
||||
firstName: z.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.describe('Driver first name as it appears on driver\'s license'),
|
||||
lastName: z.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.describe('Driver last name (surname/family name) as it appears on driver\'s license'),
|
||||
email: z.string()
|
||||
.email()
|
||||
.describe('Contact email address for booking confirmation and rental instructions'),
|
||||
phone: z.string()
|
||||
.describe('Contact phone number with country code (e.g., "+1-555-0123")'),
|
||||
licenseNumber: z.string()
|
||||
.optional()
|
||||
.describe('Driver\'s license number, may be required for pickup'),
|
||||
age: z.number()
|
||||
.int()
|
||||
.min(18)
|
||||
.max(99)
|
||||
.optional()
|
||||
.describe('Driver age, must match driverAge from search if specified')
|
||||
}).describe('Primary driver information for the car rental')
|
||||
}),
|
||||
bookCar
|
||||
);
|
||||
|
||||
// Register booking management tools
|
||||
server.registerTool(
|
||||
'retrieveBooking',
|
||||
'Retrieve Booking',
|
||||
'Retrieve booking details by PNR',
|
||||
z.object({
|
||||
pnr: z.string()
|
||||
.regex(/^TEST-[A-Z0-9]{6}$/)
|
||||
.describe('Passenger Name Record (PNR) - unique 6-character booking reference code with TEST- prefix (e.g., "TEST-ABC123")')
|
||||
}),
|
||||
retrieveBooking
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'cancelBooking',
|
||||
'Cancel Booking',
|
||||
'Cancel an existing booking',
|
||||
z.object({
|
||||
pnr: z.string()
|
||||
.regex(/^TEST-[A-Z0-9]{6}$/)
|
||||
.describe('Passenger Name Record (PNR) of the booking to cancel - must be an active booking (e.g., "TEST-ABC123")')
|
||||
}),
|
||||
cancelBooking
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'listBookings',
|
||||
'List Bookings',
|
||||
'List all bookings in current session',
|
||||
z.object({
|
||||
limit: z.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Maximum number of bookings to return per page, default is 10, maximum is 100'),
|
||||
offset: z.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe('Number of bookings to skip for pagination, default is 0 (use with limit for paging through results)')
|
||||
}),
|
||||
listBookings
|
||||
);
|
||||
|
||||
// Register session management tools
|
||||
server.registerTool(
|
||||
'getSessionInfo',
|
||||
'Get Session Info',
|
||||
'Get current session information and statistics',
|
||||
z.object({}).describe('No input parameters required - returns information about the current MCP session'),
|
||||
getSessionInfo
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'clearSession',
|
||||
'Clear Session',
|
||||
'Clear all bookings from current session',
|
||||
z.object({
|
||||
confirm: z.boolean()
|
||||
.optional()
|
||||
.describe('Confirmation flag - must be set to true to proceed with clearing all session bookings (destructive operation)')
|
||||
}),
|
||||
clearSession
|
||||
);
|
||||
|
||||
// Initialize storage (needed by all tools)
|
||||
await server.initStorage();
|
||||
|
||||
// Create transport (stdio or HTTP)
|
||||
// This connects the server to the transport for both modes
|
||||
const { shutdown: transportShutdown } = await createTransport(server, options);
|
||||
|
||||
// Handle graceful shutdown
|
||||
const shutdown = async (signal) => {
|
||||
logger.info({ signal }, 'Received shutdown signal');
|
||||
try {
|
||||
if (transportShutdown) {
|
||||
await transportShutdown();
|
||||
}
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logError(error, { context: 'shutdown' });
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', (error) => {
|
||||
logError(error, { context: 'uncaughtException' });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logError(new Error(String(reason)), {
|
||||
context: 'unhandledRejection',
|
||||
promise: promise.toString()
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError(error, { context: 'main' });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server
|
||||
main();
|
||||
49
src/middleware/cors.ts
Normal file
49
src/middleware/cors.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import cors from 'cors';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* CORS middleware with permissive wildcard policy for development
|
||||
* Per MCP specification: MUST validate Origin if present, 403 if invalid
|
||||
*/
|
||||
export const corsMiddleware = cors({
|
||||
origin: '*', // Wildcard policy for development/testing
|
||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Content-Type',
|
||||
'MCP-Session-Id',
|
||||
'MCP-Protocol-Version',
|
||||
'Last-Event-ID',
|
||||
],
|
||||
exposedHeaders: ['Mcp-Session-Id', 'Mcp-Protocol-Version'],
|
||||
credentials: false,
|
||||
maxAge: 86400 // 24 hours
|
||||
});
|
||||
|
||||
/**
|
||||
* Origin validation per MCP security requirement
|
||||
* Responds with 403 if Origin is present but invalid
|
||||
*/
|
||||
export function validateOrigin(req: any, res: any, next: any) {
|
||||
const origin = req.get('Origin');
|
||||
|
||||
// If no Origin header, allow (stdio-like clients)
|
||||
if (!origin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Validate Origin (currently permissive for testing)
|
||||
// In production, implement stricter validation
|
||||
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['*'];
|
||||
|
||||
if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
logger.warn({ origin }, 'Origin validation failed');
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'Invalid Origin'
|
||||
});
|
||||
}
|
||||
|
||||
export default corsMiddleware;
|
||||
35
src/middleware/logger.ts
Normal file
35
src/middleware/logger.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { logger as baseLogger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Request logging middleware
|
||||
* Logs method, path, IP, User-Agent, MCP headers, duration, status code
|
||||
*/
|
||||
export function requestLoggerMiddleware(req: any, res: any, next: any) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Skip health check logging (too noisy)
|
||||
if (req.path === '/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Log on response finish
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
const logger = baseLogger.child({ request: true });
|
||||
|
||||
logger.info({
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
mcpProtocolVersion: req.get('MCP-Protocol-Version'),
|
||||
mcpSessionId: req.get('MCP-Session-Id'),
|
||||
statusCode: res.statusCode,
|
||||
duration
|
||||
}, 'HTTP request completed');
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default requestLoggerMiddleware;
|
||||
30
src/middleware/message-normalization.ts
Normal file
30
src/middleware/message-normalization.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Middleware to ensure MCP messages have proper structure
|
||||
* Works around SDK issues with missing optional fields
|
||||
*/
|
||||
export function messageNormalizationMiddleware(req: any, res: any, next: any) {
|
||||
// Skip for health checks (middleware is mounted on /mcp, so paths are relative)
|
||||
if (req.path === '/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Only process POST requests with JSON body
|
||||
if (req.method !== 'POST' || !req.body) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const message = req.body;
|
||||
|
||||
// If it's a notification (no id field) and params is missing, add empty params
|
||||
// Note: Must check if 'id' property exists, not just truthiness (id:0 is valid!)
|
||||
if (message.method && !('id' in message) && !message.params) {
|
||||
logger.debug({ method: message.method }, 'Adding empty params to notification');
|
||||
req.body.params = {};
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default messageNormalizationMiddleware;
|
||||
56
src/middleware/protocol-version.ts
Normal file
56
src/middleware/protocol-version.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Supported MCP protocol versions (current stable + backward compatibility)
|
||||
const SUPPORTED_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26'];
|
||||
|
||||
/**
|
||||
* MCP Protocol Version validation middleware
|
||||
* Per MCP Streamable HTTP specification 2025-06-18:
|
||||
* - MCP-Protocol-Version header is NOT required during initialization
|
||||
* - MCP-Protocol-Version header IS required on all SUBSEQUENT requests after initialization
|
||||
* - If missing (and not initialization), reject with 400 Bad Request
|
||||
* - If present but invalid/unsupported, reject with 400 Bad Request
|
||||
*/
|
||||
export function protocolVersionMiddleware(req: any, res: any, next: any) {
|
||||
// Skip for health checks
|
||||
if (req.path === '/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const protocolVersion = req.get('MCP-Protocol-Version');
|
||||
|
||||
// Check if this is an initialization request by examining the request body
|
||||
// The initialize request has method: "initialize"
|
||||
const isInitializeRequest = req.body && req.body.method === 'initialize';
|
||||
|
||||
// Header is NOT required during initialization, but IS required for all subsequent requests
|
||||
if (!protocolVersion) {
|
||||
// Allow initialization requests without the header
|
||||
if (isInitializeRequest) {
|
||||
logger.debug('Initialize request - MCP-Protocol-Version header not required');
|
||||
return next();
|
||||
}
|
||||
|
||||
// For non-initialization requests, the header is required
|
||||
logger.warn({ path: req.path, method: req.body?.method }, 'Missing MCP-Protocol-Version header on non-initialization request');
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'MCP-Protocol-Version header is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate version is supported
|
||||
if (!SUPPORTED_VERSIONS.includes(protocolVersion)) {
|
||||
logger.warn({ version: protocolVersion }, 'Unsupported MCP protocol version');
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: `Unsupported MCP protocol version: ${protocolVersion}. Supported versions: ${SUPPORTED_VERSIONS.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug({ protocolVersion }, 'protocolVersionMiddleware passed - valid version');
|
||||
// Version valid, proceed
|
||||
next();
|
||||
}
|
||||
|
||||
export default protocolVersionMiddleware;
|
||||
69
src/middleware/rate-limit.ts
Normal file
69
src/middleware/rate-limit.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { storage } from '../session/storage.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '100', 10);
|
||||
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute in milliseconds
|
||||
|
||||
/**
|
||||
* Rate limiter using Valkey for distributed rate limiting
|
||||
* Default: 100 requests per minute per IP
|
||||
*/
|
||||
export const rateLimitMiddleware = rateLimit({
|
||||
windowMs: RATE_LIMIT_WINDOW,
|
||||
max: RATE_LIMIT_MAX,
|
||||
standardHeaders: true, // Return rate limit info in headers
|
||||
legacyHeaders: false,
|
||||
|
||||
// Use Valkey for distributed storage
|
||||
store: {
|
||||
async increment(key) {
|
||||
const client = storage.client;
|
||||
const current = await client.incr(key);
|
||||
|
||||
if (current === 1) {
|
||||
await client.expire(key, 60); // Set TTL on first increment
|
||||
}
|
||||
|
||||
return {
|
||||
totalHits: current,
|
||||
resetTime: new Date(Date.now() + 60000)
|
||||
};
|
||||
},
|
||||
|
||||
async decrement(key) {
|
||||
const client = storage.client;
|
||||
await client.decr(key);
|
||||
},
|
||||
|
||||
async resetKey(key) {
|
||||
const client = storage.client;
|
||||
await client.del(key);
|
||||
}
|
||||
},
|
||||
|
||||
// Use default key generator (handles IPv6 properly)
|
||||
// Removed custom keyGenerator to use express-rate-limit's built-in IP handling
|
||||
|
||||
// Handler for rate limit exceeded
|
||||
handler: (req, res) => {
|
||||
const retryAfter = Math.ceil(RATE_LIMIT_WINDOW / 1000);
|
||||
|
||||
logger.warn({ ip: req.ip }, 'Rate limit exceeded');
|
||||
|
||||
res.status(429)
|
||||
.set('Retry-After', retryAfter.toString())
|
||||
.json({
|
||||
error: 'Too Many Requests',
|
||||
message: `Rate limit exceeded. Try again in ${retryAfter} seconds`,
|
||||
retryAfter
|
||||
});
|
||||
},
|
||||
|
||||
skip: (req) => {
|
||||
// Skip rate limiting for health checks
|
||||
return req.path === '/health';
|
||||
}
|
||||
});
|
||||
|
||||
export default rateLimitMiddleware;
|
||||
50
src/remote/config.ts
Normal file
50
src/remote/config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Remote Access Configuration
|
||||
* Environment-based configuration for Streamable HTTP transport (HTTP/1.1 + SSE)
|
||||
* Per MCP specification 2025-06-18
|
||||
*/
|
||||
|
||||
export const remoteConfig = {
|
||||
// Server configuration
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
host: process.env.HOST || '127.0.0.1', // Bind to localhost by default per MCP security
|
||||
|
||||
// Transport mode
|
||||
transport: process.env.TRANSPORT || 'stdio', // 'stdio' or 'http'
|
||||
|
||||
// Rate limiting
|
||||
rateLimit: {
|
||||
enabled: process.env.RATE_LIMIT_ENABLED !== 'false',
|
||||
maxRequests: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
||||
windowMs: 60000 // 1 minute (fixed per spec)
|
||||
},
|
||||
|
||||
// CORS configuration
|
||||
cors: {
|
||||
enabled: process.env.CORS_ENABLED !== 'false',
|
||||
origins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',') : ['*'],
|
||||
credentials: false // Disabled for wildcard CORS
|
||||
},
|
||||
|
||||
// MCP Protocol Version - using current stable spec version
|
||||
mcpProtocolVersion: '2025-06-18',
|
||||
|
||||
// Session configuration
|
||||
sessionTtl: parseInt(process.env.MCP_SESSION_TIMEOUT || '3600', 10), // 1 hour default
|
||||
|
||||
// PNR TTL configuration
|
||||
pnrTtl: parseInt(process.env.PNR_TTL || '3600', 10), // 1 hour default
|
||||
|
||||
// Logging
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
|
||||
// Health check
|
||||
healthPath: '/health',
|
||||
|
||||
// SSE configuration
|
||||
sse: {
|
||||
retryMs: parseInt(process.env.SSE_RETRY_MS || '5000', 10) // Retry field value
|
||||
}
|
||||
};
|
||||
|
||||
export default remoteConfig;
|
||||
56
src/remote/health.ts
Normal file
56
src/remote/health.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { storage } from '../session/storage.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Health check handler
|
||||
* Returns service status, Valkey connection, active sessions, uptime
|
||||
*/
|
||||
export async function healthCheck(req: any, res: any) {
|
||||
try {
|
||||
const client = storage.client;
|
||||
|
||||
// Test Valkey connection
|
||||
let valkeyStatus = 'disconnected';
|
||||
let activeSessions = 0;
|
||||
|
||||
try {
|
||||
await client.ping();
|
||||
valkeyStatus = 'connected';
|
||||
|
||||
// Get active session count
|
||||
const sessionKeys = await client.keys('gds:session:*');
|
||||
activeSessions = sessionKeys.length;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Health check: Valkey connection failed');
|
||||
}
|
||||
|
||||
const health = {
|
||||
status: valkeyStatus === 'connected' ? 'healthy' : 'degraded',
|
||||
service: 'gds-mock-mcp',
|
||||
version: '0.1.0',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
valkey: {
|
||||
status: valkeyStatus,
|
||||
activeSessions
|
||||
},
|
||||
memory: {
|
||||
rss: Math.floor(process.memoryUsage().rss / 1024 / 1024),
|
||||
heapUsed: Math.floor(process.memoryUsage().heapUsed / 1024 / 1024)
|
||||
}
|
||||
};
|
||||
|
||||
// Return 503 if Valkey is disconnected
|
||||
const statusCode = valkeyStatus === 'connected' ? 200 : 503;
|
||||
|
||||
res.status(statusCode).json(health);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Health check failed');
|
||||
res.status(500).json({
|
||||
status: 'unhealthy',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default { healthCheck };
|
||||
155
src/server.ts
Normal file
155
src/server.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { storage } from './session/storage.js';
|
||||
import { sessionManager } from './session/manager.js';
|
||||
import { logger, logToolCall, logToolResponse, logError } from './utils/logger.js';
|
||||
import { formatErrorResponse } from './utils/errors.js';
|
||||
|
||||
/**
|
||||
* Mock GDS MCP Server
|
||||
* Exposes GDS operations as MCP tools using McpServer from @modelcontextprotocol/server
|
||||
*/
|
||||
export class GDSMockServer {
|
||||
server: any;
|
||||
sessionId: string | null;
|
||||
|
||||
constructor() {
|
||||
// Create MCP server instance
|
||||
this.server = new McpServer(
|
||||
{
|
||||
name: 'gds-mock-mcp',
|
||||
version: '0.1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
logging: {},
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.sessionId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool handler using McpServer's registerTool API
|
||||
* @param {string} toolName - Tool name
|
||||
* @param {string} title - Human-readable tool title
|
||||
* @param {string} description - Tool description
|
||||
* @param {Object} inputSchema - Zod schema or JSON Schema for tool input
|
||||
* @param {Function} handler - Handler function (args, sessionId) => Promise
|
||||
*/
|
||||
registerTool(toolName: string, title: string, description: string, inputSchema: any, handler: any) {
|
||||
// Register tool with McpServer
|
||||
this.server.registerTool(
|
||||
toolName,
|
||||
{
|
||||
title,
|
||||
description,
|
||||
inputSchema
|
||||
},
|
||||
async (args: any, _ctx: any) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Ensure session exists
|
||||
if (!this.sessionId) {
|
||||
const session = await sessionManager.createSession();
|
||||
this.sessionId = session.id;
|
||||
logger.info({ sessionId: this.sessionId }, 'Session created for MCP connection');
|
||||
}
|
||||
|
||||
// Update session activity
|
||||
await sessionManager.updateActivity(this.sessionId);
|
||||
|
||||
// Log tool call
|
||||
logToolCall(toolName, args, this.sessionId);
|
||||
|
||||
// Execute tool with session context
|
||||
const result = await handler(args, this.sessionId);
|
||||
|
||||
// Log success
|
||||
const duration = Date.now() - startTime;
|
||||
logToolResponse(toolName, duration, this.sessionId, true, { resultType: typeof result });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
// Log error
|
||||
const duration = Date.now() - startTime;
|
||||
logError(error as Error, {
|
||||
tool: toolName,
|
||||
sessionId: this.sessionId,
|
||||
duration
|
||||
});
|
||||
logToolResponse(toolName, duration, this.sessionId, false, {
|
||||
error: (error as Error).message
|
||||
});
|
||||
|
||||
// Format error response
|
||||
const errorResponse = formatErrorResponse(error as Error);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(errorResponse, null, 2)
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info({ tool: toolName, title }, 'Tool registered');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize storage connection
|
||||
*/
|
||||
async initStorage() {
|
||||
try {
|
||||
await storage.connect();
|
||||
logger.info('Storage connected successfully');
|
||||
} catch (error) {
|
||||
logError(error, { context: 'storage_init' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to transport
|
||||
* @param {Transport} transport - MCP transport instance
|
||||
*/
|
||||
async connect(transport) {
|
||||
try {
|
||||
await this.server.connect(transport);
|
||||
logger.info('MCP server connected to transport');
|
||||
} catch (error) {
|
||||
logError(error, { context: 'server_connect' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the MCP server
|
||||
*/
|
||||
async stop() {
|
||||
try {
|
||||
await storage.disconnect();
|
||||
await this.server.close();
|
||||
logger.info('GDS Mock MCP Server stopped');
|
||||
} catch (error) {
|
||||
logError(error, { context: 'server_stop' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GDSMockServer;
|
||||
240
src/session/manager.ts
Normal file
240
src/session/manager.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { storage } from './storage.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { SessionError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Session lifecycle manager with TTL management
|
||||
* Default TTL: 1 hour (3600 seconds)
|
||||
*/
|
||||
|
||||
const DEFAULT_SESSION_TTL = parseInt(process.env.MCP_SESSION_TIMEOUT || '3600', 10);
|
||||
|
||||
/**
|
||||
* Session Manager class
|
||||
*/
|
||||
export class SessionManager {
|
||||
defaultTTL: number;
|
||||
|
||||
constructor() {
|
||||
this.defaultTTL = DEFAULT_SESSION_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
* @returns {Promise<Object>} Session object with id and metadata
|
||||
*/
|
||||
async createSession() {
|
||||
const sessionId = this.generateSessionId();
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (this.defaultTTL * 1000);
|
||||
|
||||
const sessionData = {
|
||||
id: sessionId,
|
||||
createdAt: now.toString(),
|
||||
expiresAt: expiresAt.toString(),
|
||||
lastActivity: now.toString(),
|
||||
bookingCount: '0',
|
||||
searchCount: '0'
|
||||
};
|
||||
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
await storage.hmset(sessionKey, sessionData);
|
||||
await storage.expire(sessionKey, this.defaultTTL);
|
||||
|
||||
// Add to active sessions set
|
||||
await storage.sadd('gds:stats:sessions:active', sessionId);
|
||||
|
||||
logger.info({
|
||||
type: 'session_created',
|
||||
sessionId,
|
||||
ttl: this.defaultTTL
|
||||
});
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
ttl: this.defaultTTL
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to create session');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session data
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Session data
|
||||
* @throws {SessionError} If session not found or expired
|
||||
*/
|
||||
async getSession(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
const exists = await storage.exists(sessionKey);
|
||||
|
||||
if (!exists) {
|
||||
throw new SessionError('Session not found or expired', true);
|
||||
}
|
||||
|
||||
const sessionData = await storage.hgetall(sessionKey);
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
createdAt: parseInt(sessionData.createdAt, 10),
|
||||
expiresAt: parseInt(sessionData.expiresAt, 10),
|
||||
lastActivity: parseInt(sessionData.lastActivity, 10),
|
||||
bookingCount: parseInt(sessionData.bookingCount, 10),
|
||||
searchCount: parseInt(sessionData.searchCount, 10)
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SessionError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ error, sessionId }, 'Failed to get session');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session exists and is not expired
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<boolean>} True if valid
|
||||
* @throws {SessionError} If session invalid or expired
|
||||
*/
|
||||
async validateSession(sessionId) {
|
||||
await this.getSession(sessionId); // Will throw if invalid
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session activity timestamp (refreshes TTL)
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateActivity(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
await storage.hset(sessionKey, 'lastActivity', now.toString());
|
||||
await storage.expire(sessionKey, this.defaultTTL);
|
||||
|
||||
logger.debug({
|
||||
type: 'session_activity_updated',
|
||||
sessionId,
|
||||
timestamp: now
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to update session activity');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment booking counter for session
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<number>} New booking count
|
||||
*/
|
||||
async incrementBookingCount(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
const sessionData = await storage.hgetall(sessionKey);
|
||||
const newCount = parseInt(sessionData.bookingCount || '0', 10) + 1;
|
||||
await storage.hset(sessionKey, 'bookingCount', newCount.toString());
|
||||
|
||||
return newCount;
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to increment booking count');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment search counter for session
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<number>} New search count
|
||||
*/
|
||||
async incrementSearchCount(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
const sessionData = await storage.hgetall(sessionKey);
|
||||
const newCount = parseInt(sessionData.searchCount || '0', 10) + 1;
|
||||
await storage.hset(sessionKey, 'searchCount', newCount.toString());
|
||||
|
||||
return newCount;
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to increment search count');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End session (delete from storage)
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async endSession(sessionId) {
|
||||
const sessionKey = this.getSessionKey(sessionId);
|
||||
|
||||
try {
|
||||
await storage.del(sessionKey);
|
||||
await storage.srem('gds:stats:sessions:active', sessionId);
|
||||
|
||||
logger.info({
|
||||
type: 'session_ended',
|
||||
sessionId
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to end session');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique session ID
|
||||
* @returns {string} UUID v4 session ID
|
||||
*/
|
||||
generateSessionId() {
|
||||
// Using crypto.randomUUID() which is available in Node.js 20+
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Valkey key for session
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {string} Valkey key
|
||||
*/
|
||||
getSessionKey(sessionId) {
|
||||
return `gds:session:${sessionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Valkey key for session bookings set
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {string} Valkey key
|
||||
*/
|
||||
getBookingsKey(sessionId) {
|
||||
return `gds:session:${sessionId}:bookings`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Valkey key for specific booking
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @param {string} pnr - PNR code
|
||||
* @returns {string} Valkey key
|
||||
*/
|
||||
getBookingKey(sessionId, pnr) {
|
||||
return `gds:session:${sessionId}:booking:${pnr}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const sessionManager = new SessionManager();
|
||||
|
||||
export default sessionManager;
|
||||
289
src/session/storage.ts
Normal file
289
src/session/storage.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { Redis } from 'ioredis';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { StorageError } from '../utils/errors.js';
|
||||
import { log } from 'node:console';
|
||||
|
||||
/**
|
||||
* Valkey client wrapper with connection pooling and error handling
|
||||
*/
|
||||
class ValkeyStorage {
|
||||
client: any;
|
||||
isConnected: boolean;
|
||||
events: Map<string, any>;
|
||||
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.isConnected = false;
|
||||
// Event store for MCP resumability (Map<string, { streamId: string, message: object }>)
|
||||
this.events = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Valkey connection
|
||||
* @param {Object} config - Valkey configuration
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async connect(config = {}) {
|
||||
logger.info({ config }, 'Initializing Valkey connection with config');
|
||||
const defaultConfig = {
|
||||
host: process.env.VALKEY_HOST || 'localhost',
|
||||
port: parseInt(process.env.VALKEY_PORT || '6379', 10),
|
||||
password: process.env.VALKEY_PASSWORD || undefined,
|
||||
db: parseInt(process.env.VALKEY_DB || '0', 10),
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
lazyConnect: false
|
||||
};
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config };
|
||||
|
||||
try {
|
||||
this.client = new Redis(finalConfig);
|
||||
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Valkey client connecting...');
|
||||
});
|
||||
|
||||
this.client.on('ready', () => {
|
||||
this.isConnected = true;
|
||||
logger.info('Valkey client connected and ready');
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
logger.error({ error: err }, 'Valkey client error');
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
logger.warn('Valkey connection closed');
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('reconnecting', () => {
|
||||
logger.info('Valkey client reconnecting...');
|
||||
});
|
||||
|
||||
// Wait for connection to be ready
|
||||
await this.client.ping();
|
||||
logger.info('Valkey connection established successfully');
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to connect to Valkey');
|
||||
throw new StorageError('Failed to connect to Valkey', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close Valkey connection
|
||||
*/
|
||||
async disconnect() {
|
||||
if (this.client) {
|
||||
await this.client.quit();
|
||||
this.isConnected = false;
|
||||
logger.info('Valkey client disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a key-value pair
|
||||
* @param {string} key - Key
|
||||
* @param {string} value - Value
|
||||
* @param {number} ttl - Time to live in seconds (optional)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async set(key, value, ttl = null) {
|
||||
try {
|
||||
if (ttl) {
|
||||
return await this.client.setex(key, ttl, value);
|
||||
}
|
||||
return await this.client.set(key, value);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to set key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value by key
|
||||
* @param {string} key - Key
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async get(key) {
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key
|
||||
* @param {string} key - Key
|
||||
* @returns {Promise<number>} Number of keys deleted
|
||||
*/
|
||||
async del(key) {
|
||||
try {
|
||||
return await this.client.del(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to delete key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists
|
||||
* @param {string} key - Key
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists(key) {
|
||||
try {
|
||||
const result = await this.client.exists(key);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to check existence of key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hash field
|
||||
* @param {string} key - Hash key
|
||||
* @param {string} field - Field name
|
||||
* @param {string} value - Field value
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async hset(key, field, value) {
|
||||
try {
|
||||
return await this.client.hset(key, field, value);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to set hash field '${field}' in '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash field
|
||||
* @param {string} key - Hash key
|
||||
* @param {string} field - Field name
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async hget(key, field) {
|
||||
try {
|
||||
return await this.client.hget(key, field);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get hash field '${field}' from '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hash fields
|
||||
* @param {string} key - Hash key
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async hgetall(key) {
|
||||
try {
|
||||
return await this.client.hgetall(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get all hash fields from '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple hash fields
|
||||
* @param {string} key - Hash key
|
||||
* @param {Object} data - Field-value pairs
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async hmset(key, data) {
|
||||
try {
|
||||
return await this.client.hmset(key, data);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to set multiple hash fields in '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add member to set
|
||||
* @param {string} key - Set key
|
||||
* @param {string} member - Member to add
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async sadd(key, member) {
|
||||
try {
|
||||
return await this.client.sadd(key, member);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to add member to set '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member from set
|
||||
* @param {string} key - Set key
|
||||
* @param {string} member - Member to remove
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async srem(key, member) {
|
||||
try {
|
||||
return await this.client.srem(key, member);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to remove member from set '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all set members
|
||||
* @param {string} key - Set key
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async smembers(key) {
|
||||
try {
|
||||
return await this.client.smembers(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get members from set '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL on key
|
||||
* @param {string} key - Key
|
||||
* @param {number} seconds - TTL in seconds
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async expire(key, seconds) {
|
||||
try {
|
||||
return await this.client.expire(key, seconds);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to set TTL on key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment counter
|
||||
* @param {string} key - Key
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async incr(key) {
|
||||
try {
|
||||
return await this.client.incr(key);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to increment key '${key}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple keys by pattern
|
||||
* @param {string} pattern - Key pattern (e.g., 'gds:session:*')
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async keys(pattern) {
|
||||
try {
|
||||
return await this.client.keys(pattern);
|
||||
} catch (error) {
|
||||
throw new StorageError(`Failed to get keys matching pattern '${pattern}'`, { error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const storage = new ValkeyStorage();
|
||||
|
||||
export default storage;
|
||||
104
src/session/valkey-event-store.ts
Normal file
104
src/session/valkey-event-store.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { storage } from './storage.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server';
|
||||
|
||||
/**
|
||||
* Valkey-based Event Store for MCP resumability
|
||||
* Stores server-sent events for reconnection support
|
||||
*/
|
||||
export class ValkeyEventStore implements EventStore {
|
||||
prefix: string;
|
||||
ttl: number;
|
||||
|
||||
constructor() {
|
||||
this.prefix = 'mcp:events:';
|
||||
this.ttl = 3600; // Events expire after 1 hour
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique event ID for a given stream ID
|
||||
*/
|
||||
private generateEventId(streamId: string): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the stream ID from an event ID
|
||||
*/
|
||||
private getStreamIdFromEventId(eventId: string): string {
|
||||
const parts = eventId.split('_');
|
||||
return parts.length > 0 ? parts[0]! : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an event with a generated event ID
|
||||
* Implements EventStore.storeEvent
|
||||
*/
|
||||
async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> {
|
||||
logger.debug({ streamId, message }, 'Storing event in ValkeyEventStore');
|
||||
const eventId = this.generateEventId(streamId);
|
||||
const key = `${this.prefix}${streamId}`;
|
||||
const value = JSON.stringify({ eventId, message, timestamp: Date.now() });
|
||||
|
||||
// Add to sorted set with event ID as score for ordering
|
||||
await storage.client.zadd(key, eventId, value);
|
||||
|
||||
// Set expiration on the key
|
||||
await storage.client.expire(key, this.ttl);
|
||||
|
||||
|
||||
return "" + eventId;
|
||||
}
|
||||
/**
|
||||
* Replays events that occurred after a specific event ID
|
||||
* Implements EventStore.replayEventsAfter
|
||||
*/
|
||||
async replayEventsAfter(
|
||||
lastEventId: string,
|
||||
{ send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
|
||||
): Promise<string> {
|
||||
const streamId = this.getStreamIdFromEventId(lastEventId);
|
||||
const key = `${this.prefix}${streamId}`;
|
||||
|
||||
// Retrieve all events from the sorted set that come after lastEventId
|
||||
// Using ZRANGEBYSCORE to get events with scores > lastEventId
|
||||
const events = await storage.client.zrangebyscore(
|
||||
key,
|
||||
`(${lastEventId}`, // Exclusive range - events after lastEventId
|
||||
'+inf' // Up to the highest score
|
||||
);
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
logger.debug({ streamId, lastEventId }, 'No events to replay');
|
||||
return lastEventId;
|
||||
}
|
||||
|
||||
let latestEventId = lastEventId;
|
||||
|
||||
// Replay each event in order
|
||||
for (const eventData of events) {
|
||||
try {
|
||||
const parsed = JSON.parse(eventData);
|
||||
const { eventId, message } = parsed;
|
||||
|
||||
// Send the event using the provided callback
|
||||
await send(eventId, message);
|
||||
|
||||
latestEventId = eventId;
|
||||
logger.debug({ streamId, eventId }, 'Event replayed');
|
||||
} catch (error) {
|
||||
logger.error({ error, eventData }, 'Failed to replay event');
|
||||
// Continue with next event even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{ streamId, lastEventId, latestEventId, count: events.length },
|
||||
'Event replay completed'
|
||||
);
|
||||
|
||||
return latestEventId;
|
||||
}
|
||||
}
|
||||
|
||||
export default ValkeyEventStore;
|
||||
244
src/tools/bookings.ts
Normal file
244
src/tools/bookings.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { storage } from '../session/storage.js';
|
||||
import { sessionManager } from '../session/manager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import {
|
||||
BookingError,
|
||||
validatePNR
|
||||
} from '../utils/errors.js';
|
||||
import {
|
||||
validateRequired,
|
||||
validateNumber
|
||||
} from '../validation/validators.js';
|
||||
|
||||
/**
|
||||
* Retrieve booking by PNR
|
||||
* @param {Object} params - Parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Booking details
|
||||
*/
|
||||
export async function retrieveBooking(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate required fields
|
||||
validateRequired(params, ['pnr']);
|
||||
|
||||
// Validate PNR format
|
||||
validatePNR(params.pnr);
|
||||
|
||||
// Retrieve booking from global PNR storage first
|
||||
const globalBookingKey = `gds:pnr:${params.pnr}`;
|
||||
let bookingData = await storage.get(globalBookingKey);
|
||||
|
||||
// Fallback to session-scoped storage if not found globally
|
||||
if (!bookingData) {
|
||||
const bookingKey = sessionManager.getBookingKey(sessionId, params.pnr);
|
||||
bookingData = await storage.get(bookingKey);
|
||||
}
|
||||
|
||||
if (!bookingData) {
|
||||
throw new BookingError(`Booking ${params.pnr} not found`);
|
||||
}
|
||||
|
||||
const booking = JSON.parse(bookingData);
|
||||
|
||||
logger.info({
|
||||
type: 'booking_retrieved',
|
||||
sessionId,
|
||||
pnr: params.pnr
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
booking,
|
||||
metadata: {
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId, pnr: params.pnr }, 'Booking retrieval failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel booking
|
||||
* @param {Object} params - Parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Cancellation confirmation
|
||||
*/
|
||||
export async function cancelBooking(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate required fields
|
||||
validateRequired(params, ['pnr']);
|
||||
|
||||
// Validate PNR format
|
||||
validatePNR(params.pnr);
|
||||
|
||||
// Retrieve booking from global PNR storage first
|
||||
const globalBookingKey = `gds:pnr:${params.pnr}`;
|
||||
let bookingData = await storage.get(globalBookingKey);
|
||||
let bookingKey = globalBookingKey;
|
||||
|
||||
// Fallback to session-scoped storage if not found globally
|
||||
if (!bookingData) {
|
||||
const sessionBookingKey = sessionManager.getBookingKey(sessionId, params.pnr);
|
||||
bookingData = await storage.get(sessionBookingKey);
|
||||
bookingKey = sessionBookingKey;
|
||||
}
|
||||
|
||||
if (!bookingData) {
|
||||
throw new BookingError(`Booking ${params.pnr} not found`);
|
||||
}
|
||||
|
||||
const booking = JSON.parse(bookingData);
|
||||
|
||||
// Check if already cancelled
|
||||
if (booking.status === 'cancelled') {
|
||||
throw new BookingError('Booking is already cancelled', true);
|
||||
}
|
||||
|
||||
// Update booking status
|
||||
booking.status = 'cancelled';
|
||||
booking.lastModified = Date.now();
|
||||
booking.cancelledAt = Date.now();
|
||||
|
||||
// Store updated booking in both locations
|
||||
await storage.set(bookingKey, JSON.stringify(booking));
|
||||
|
||||
// Also update global storage if bookingKey is not already the global key
|
||||
if (bookingKey !== globalBookingKey) {
|
||||
await storage.set(globalBookingKey, JSON.stringify(booking));
|
||||
}
|
||||
|
||||
// Remove from session bookings set
|
||||
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
|
||||
await storage.srem(bookingsSetKey, params.pnr);
|
||||
|
||||
logger.info({
|
||||
type: 'booking_cancelled',
|
||||
sessionId,
|
||||
pnr: params.pnr
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pnr: params.pnr,
|
||||
status: 'cancelled',
|
||||
message: `Booking ${params.pnr} has been cancelled successfully`,
|
||||
cancelledAt: booking.cancelledAt,
|
||||
metadata: {
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId, pnr: params.pnr }, 'Booking cancellation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all bookings in current session
|
||||
* @param {Object} params - Parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} List of bookings
|
||||
*/
|
||||
export async function listBookings(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate pagination parameters
|
||||
const limit = params.limit || 10;
|
||||
const offset = params.offset || 0;
|
||||
|
||||
validateNumber(limit, 'limit', 1, 100);
|
||||
validateNumber(offset, 'offset', 0);
|
||||
|
||||
// Get all booking PNRs for this session
|
||||
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
|
||||
const pnrs = await storage.smembers(bookingsSetKey);
|
||||
|
||||
if (pnrs.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
bookings: [],
|
||||
total: 0,
|
||||
limit,
|
||||
offset,
|
||||
metadata: {
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Retrieve booking summaries
|
||||
const bookingSummaries = [];
|
||||
|
||||
for (const pnr of pnrs) {
|
||||
// Try global storage first
|
||||
const globalBookingKey = `gds:pnr:${pnr}`;
|
||||
let bookingData = await storage.get(globalBookingKey);
|
||||
|
||||
// Fallback to session-scoped storage
|
||||
if (!bookingData) {
|
||||
const bookingKey = sessionManager.getBookingKey(sessionId, pnr);
|
||||
bookingData = await storage.get(bookingKey);
|
||||
}
|
||||
|
||||
if (bookingData) {
|
||||
const booking = JSON.parse(bookingData);
|
||||
|
||||
// Create summary
|
||||
const summary = {
|
||||
pnr: booking.pnr,
|
||||
status: booking.status,
|
||||
createdAt: booking.createdAt,
|
||||
lastModified: booking.lastModified,
|
||||
totalPrice: booking.totalPrice,
|
||||
currency: booking.currency,
|
||||
segments: {
|
||||
flights: booking.flights.length,
|
||||
hotels: booking.hotels.length,
|
||||
cars: booking.cars.length
|
||||
},
|
||||
passengerCount: booking.passengers.length
|
||||
};
|
||||
|
||||
bookingSummaries.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
bookingSummaries.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
// Apply pagination
|
||||
const paginatedBookings = bookingSummaries.slice(offset, offset + limit);
|
||||
|
||||
logger.info({
|
||||
type: 'bookings_listed',
|
||||
sessionId,
|
||||
total: bookingSummaries.length,
|
||||
returned: paginatedBookings.length
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
bookings: paginatedBookings,
|
||||
total: bookingSummaries.length,
|
||||
limit,
|
||||
offset,
|
||||
metadata: {
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Booking list failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
retrieveBooking,
|
||||
cancelBooking,
|
||||
listBookings
|
||||
};
|
||||
270
src/tools/cars.ts
Normal file
270
src/tools/cars.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Car Rental Tools
|
||||
* Implements searchCars and bookCar MCP tools
|
||||
*/
|
||||
|
||||
import { generateCarOptions, getCarById } from '../data/cars.js';
|
||||
import { generatePNR } from '../data/pnr.js';
|
||||
import { storage } from '../session/storage.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ValidationError, BookingError } from '../utils/errors.js';
|
||||
import {
|
||||
validateRequired,
|
||||
validateString,
|
||||
validateFutureDate
|
||||
} from '../validation/validators.js';
|
||||
|
||||
/**
|
||||
* Search for available car rentals
|
||||
* @param {Object} params - Search parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Search results
|
||||
*/
|
||||
export async function searchCars(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate required fields
|
||||
validateRequired(params.pickupLocation, 'pickupLocation');
|
||||
validateRequired(params.dropoffLocation, 'dropoffLocation');
|
||||
validateRequired(params.pickupDate, 'pickupDate');
|
||||
validateRequired(params.dropoffDate, 'dropoffDate');
|
||||
|
||||
validateString(params.pickupLocation, 'pickupLocation', { minLength: 3, maxLength: 3 });
|
||||
validateString(params.dropoffLocation, 'dropoffLocation', { minLength: 3, maxLength: 3 });
|
||||
validateFutureDate(params.pickupDate, 'pickupDate');
|
||||
validateFutureDate(params.dropoffDate, 'dropoffDate');
|
||||
|
||||
const pickupLocation = params.pickupLocation.toUpperCase();
|
||||
const dropoffLocation = params.dropoffLocation.toUpperCase();
|
||||
const pickupDate = new Date(params.pickupDate);
|
||||
const dropoffDate = new Date(params.dropoffDate);
|
||||
|
||||
// Validate date range
|
||||
if (dropoffDate <= pickupDate) {
|
||||
throw new ValidationError('dropoffDate must be after pickupDate');
|
||||
}
|
||||
|
||||
const dropoffDateObj = new Date(dropoffDate);
|
||||
const days = Math.ceil((dropoffDateObj.getTime() - pickupDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 1) {
|
||||
throw new ValidationError('Minimum rental period is 1 day');
|
||||
}
|
||||
|
||||
// Generate car options
|
||||
const options = generateCarOptions(
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
params.pickupDate,
|
||||
params.dropoffDate
|
||||
);
|
||||
|
||||
logger.info({ sessionId, pickupLocation, dropoffLocation, days, resultCount: options.length }, 'Car search completed');
|
||||
|
||||
return {
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
pickupDate: params.pickupDate,
|
||||
dropoffDate: params.dropoffDate,
|
||||
days,
|
||||
results: options
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Car search failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Book a car rental
|
||||
* @param {Object} params - Booking parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Booking confirmation
|
||||
*/
|
||||
export async function bookCar(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate required fields
|
||||
validateRequired(params.carId, 'carId');
|
||||
validateRequired(params.pickupLocation, 'pickupLocation');
|
||||
validateRequired(params.dropoffLocation, 'dropoffLocation');
|
||||
validateRequired(params.pickupDate, 'pickupDate');
|
||||
validateRequired(params.dropoffDate, 'dropoffDate');
|
||||
validateRequired(params.driverName, 'driverName');
|
||||
validateRequired(params.driverEmail, 'driverEmail');
|
||||
|
||||
const { carId, pickupLocation, dropoffLocation, pickupDate, dropoffDate, driverName, driverEmail, driverPhone, driverLicense, pnr: existingPnr } = params;
|
||||
|
||||
// Validate car exists
|
||||
const car = getCarById(carId);
|
||||
if (!car) {
|
||||
throw new ValidationError(`Car not found: ${carId}`);
|
||||
}
|
||||
|
||||
// Validate dates
|
||||
validateFutureDate(pickupDate, 'pickupDate');
|
||||
validateFutureDate(dropoffDate, 'dropoffDate');
|
||||
|
||||
const pickup = new Date(pickupDate);
|
||||
const dropoff = new Date(dropoffDate);
|
||||
|
||||
if (dropoff <= pickup) {
|
||||
throw new ValidationError('dropoffDate must be after pickupDate');
|
||||
}
|
||||
|
||||
const pickupDateObj = new Date(params.pickupDate);
|
||||
const dropoffDateObj = new Date(params.dropoffDate);
|
||||
const days = Math.ceil((dropoffDateObj.getTime() - pickupDateObj.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Calculate pricing (recalculate with same logic as search)
|
||||
const oneWayFee = pickupLocation !== dropoffLocation ? 75 : 0;
|
||||
const dailyRate = car.basePrice; // Simplified - real calculation in generateCarOptions
|
||||
const totalPrice = (dailyRate * days) + oneWayFee;
|
||||
|
||||
// Generate or use existing PNR
|
||||
const pnr = existingPnr || generatePNR();
|
||||
const client = storage.client;
|
||||
|
||||
// Check if this is adding to existing booking
|
||||
let booking;
|
||||
if (existingPnr) {
|
||||
const existingKey = `gds:session:${sessionId}:booking:${existingPnr}`;
|
||||
const existingData = await client.get(existingKey);
|
||||
|
||||
if (!existingData) {
|
||||
throw new BookingError(`Booking not found: ${existingPnr}`);
|
||||
}
|
||||
|
||||
booking = JSON.parse(existingData);
|
||||
|
||||
// Add car segment
|
||||
if (!booking.cars) {
|
||||
booking.cars = [];
|
||||
}
|
||||
|
||||
booking.cars.push({
|
||||
carId: car.carId,
|
||||
company: car.company,
|
||||
category: car.category,
|
||||
example: car.example,
|
||||
passengers: car.passengers,
|
||||
bags: car.bags,
|
||||
features: car.features,
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
pickupDate,
|
||||
dropoffDate,
|
||||
days,
|
||||
driverName,
|
||||
driverEmail,
|
||||
driverPhone,
|
||||
driverLicense,
|
||||
pricing: {
|
||||
dailyRate,
|
||||
days,
|
||||
oneWayFee,
|
||||
totalPrice,
|
||||
currency: 'USD'
|
||||
},
|
||||
status: 'confirmed',
|
||||
bookedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Recalculate total price
|
||||
let totalBookingPrice = 0;
|
||||
if (booking.flights) {
|
||||
totalBookingPrice += booking.flights.reduce((sum, f) => sum + (f.pricing?.totalPrice || 0), 0);
|
||||
}
|
||||
if (booking.hotels) {
|
||||
totalBookingPrice += booking.hotels.reduce((sum, h) => sum + (h.pricing?.totalPrice || 0), 0);
|
||||
}
|
||||
if (booking.cars) {
|
||||
totalBookingPrice += booking.cars.reduce((sum, c) => sum + (c.pricing?.totalPrice || 0), 0);
|
||||
}
|
||||
|
||||
booking.totalPrice = totalBookingPrice;
|
||||
booking.updatedAt = new Date().toISOString();
|
||||
|
||||
} else {
|
||||
// Create new car-only booking
|
||||
booking = {
|
||||
pnr,
|
||||
type: 'car',
|
||||
status: 'confirmed',
|
||||
cars: [{
|
||||
carId: car.carId,
|
||||
company: car.company,
|
||||
category: car.category,
|
||||
example: car.example,
|
||||
passengers: car.passengers,
|
||||
bags: car.bags,
|
||||
features: car.features,
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
pickupDate,
|
||||
dropoffDate,
|
||||
days,
|
||||
driverName,
|
||||
driverEmail,
|
||||
driverPhone,
|
||||
driverLicense,
|
||||
pricing: {
|
||||
dailyRate,
|
||||
days,
|
||||
oneWayFee,
|
||||
totalPrice,
|
||||
currency: 'USD'
|
||||
},
|
||||
status: 'confirmed',
|
||||
bookedAt: new Date().toISOString()
|
||||
}],
|
||||
totalPrice,
|
||||
currency: 'USD',
|
||||
createdAt: new Date().toISOString(),
|
||||
sessionId
|
||||
};
|
||||
}
|
||||
|
||||
// Store booking
|
||||
const bookingKey = `gds:session:${sessionId}:booking:${pnr}`;
|
||||
await client.set(bookingKey, JSON.stringify(booking), 'EX', 3600);
|
||||
|
||||
// Update session bookings set
|
||||
await client.sadd(`gds:session:${sessionId}:bookings`, pnr);
|
||||
|
||||
logger.info({ sessionId, pnr, carId, days }, 'Car rental booking created');
|
||||
|
||||
return {
|
||||
pnr,
|
||||
status: 'confirmed',
|
||||
car: {
|
||||
carId: car.carId,
|
||||
company: car.company,
|
||||
category: car.category,
|
||||
example: car.example,
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
pickupDate,
|
||||
dropoffDate,
|
||||
days
|
||||
},
|
||||
pricing: {
|
||||
dailyRate,
|
||||
days,
|
||||
oneWayFee,
|
||||
totalPrice,
|
||||
currency: 'USD'
|
||||
},
|
||||
driverName,
|
||||
driverEmail,
|
||||
message: existingPnr
|
||||
? `Car rental added to existing booking ${pnr}`
|
||||
: `Car rental confirmed with PNR ${pnr}`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Car booking failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default { searchCars, bookCar };
|
||||
296
src/tools/flights.ts
Normal file
296
src/tools/flights.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { generateFlights } from '../data/flights.js';
|
||||
import { generatePNR } from '../data/pnr.js';
|
||||
import { storage } from '../session/storage.js';
|
||||
import { sessionManager } from '../session/manager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import {
|
||||
BookingError,
|
||||
ValidationError,
|
||||
validateAirportCode,
|
||||
validateDate,
|
||||
validatePNR
|
||||
} from '../utils/errors.js';
|
||||
import {
|
||||
validateRequired,
|
||||
validateString,
|
||||
validateNumber,
|
||||
validateArray,
|
||||
validateFutureDate,
|
||||
validatePassenger,
|
||||
validateCabin
|
||||
} from '../validation/validators.js';
|
||||
|
||||
/**
|
||||
* Search for available flights
|
||||
* @param {Object} params - Search parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Search results
|
||||
*/
|
||||
export async function searchFlights(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate required fields
|
||||
validateRequired(params, ['origin', 'destination', 'departureDate']);
|
||||
|
||||
// Validate airport codes
|
||||
validateAirportCode(params.origin);
|
||||
validateAirportCode(params.destination);
|
||||
|
||||
// Validate same origin/destination
|
||||
if (params.origin === params.destination) {
|
||||
throw new ValidationError('Origin and destination must be different');
|
||||
}
|
||||
|
||||
// Validate departure date
|
||||
validateDate(params.departureDate, 'departureDate');
|
||||
validateFutureDate(params.departureDate, 'departureDate');
|
||||
|
||||
// Validate cabin class if provided
|
||||
if (params.cabin) {
|
||||
validateCabin(params.cabin);
|
||||
}
|
||||
|
||||
// Validate passengers
|
||||
const passengers = params.passengers || { adults: 1, children: 0, infants: 0 };
|
||||
if (passengers.adults) {
|
||||
validateNumber(passengers.adults, 'passengers.adults', 1, 9);
|
||||
}
|
||||
if (passengers.children) {
|
||||
validateNumber(passengers.children, 'passengers.children', 0, 9);
|
||||
}
|
||||
if (passengers.infants) {
|
||||
validateNumber(passengers.infants, 'passengers.infants', 0, 9);
|
||||
}
|
||||
|
||||
// Generate flight results
|
||||
const flights = generateFlights({
|
||||
origin: params.origin.toUpperCase(),
|
||||
destination: params.destination.toUpperCase(),
|
||||
departureDate: params.departureDate,
|
||||
passengers,
|
||||
cabin: params.cabin || 'economy'
|
||||
});
|
||||
|
||||
// Increment search counter
|
||||
await sessionManager.incrementSearchCount(sessionId);
|
||||
|
||||
logger.info({
|
||||
type: 'flight_search',
|
||||
sessionId,
|
||||
origin: params.origin,
|
||||
destination: params.destination,
|
||||
resultCount: flights.length
|
||||
});
|
||||
|
||||
return {
|
||||
search: {
|
||||
origin: params.origin.toUpperCase(),
|
||||
destination: params.destination.toUpperCase(),
|
||||
departureDate: params.departureDate,
|
||||
passengers,
|
||||
cabin: params.cabin || 'economy'
|
||||
},
|
||||
results: flights,
|
||||
resultCount: flights.length,
|
||||
metadata: {
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Flight search failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Book a flight
|
||||
* @param {Object} params - Booking parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Booking confirmation
|
||||
*/
|
||||
export async function bookFlight(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate required fields
|
||||
validateRequired(params, ['flightId', 'passengers']);
|
||||
|
||||
// Validate passengers array
|
||||
validateArray(params.passengers, 'passengers', 1, 9);
|
||||
|
||||
// Validate each passenger
|
||||
for (const passenger of params.passengers) {
|
||||
validatePassenger(passenger);
|
||||
}
|
||||
|
||||
// Validate contact information
|
||||
if (params.contactEmail) {
|
||||
validateString(params.contactEmail, 'contactEmail', 5, 100);
|
||||
}
|
||||
if (params.contactPhone) {
|
||||
validateString(params.contactPhone, 'contactPhone', 10, 20);
|
||||
}
|
||||
|
||||
// Ensure at least one contact method
|
||||
if (!params.contactEmail && !params.contactPhone) {
|
||||
// Use first passenger's email if available
|
||||
if (params.passengers[0].email) {
|
||||
params.contactEmail = params.passengers[0].email;
|
||||
} else {
|
||||
throw new ValidationError('At least one contact method (email or phone) is required');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if adding to existing PNR
|
||||
let pnr;
|
||||
let existingBooking = null;
|
||||
|
||||
if (params.pnr) {
|
||||
// Validate PNR format
|
||||
validatePNR(params.pnr);
|
||||
|
||||
// Retrieve existing booking
|
||||
const bookingKey = sessionManager.getBookingKey(sessionId, params.pnr);
|
||||
const bookingData = await storage.get(bookingKey);
|
||||
|
||||
if (!bookingData) {
|
||||
throw new BookingError(`Booking ${params.pnr} not found in current session`);
|
||||
}
|
||||
|
||||
existingBooking = JSON.parse(bookingData);
|
||||
|
||||
if (existingBooking.status === 'cancelled') {
|
||||
throw new BookingError('Cannot add to cancelled booking', true);
|
||||
}
|
||||
|
||||
pnr = params.pnr;
|
||||
} else {
|
||||
// Generate new PNR
|
||||
pnr = generatePNR(sessionId);
|
||||
}
|
||||
|
||||
// Create flight segment from provided details or flight ID
|
||||
// For mock implementation, we expect the caller to provide the full flight object
|
||||
// In production, we would retrieve from a database or cache
|
||||
const flightSegment = params.flightDetails || {
|
||||
id: params.flightId,
|
||||
// Basic mock data if details not provided
|
||||
flightNumber: 'MOCK001',
|
||||
airlineCode: 'XX',
|
||||
airlineName: 'Mock Airline',
|
||||
originCode: 'XXX',
|
||||
originName: 'Unknown',
|
||||
destinationCode: 'YYY',
|
||||
destinationName: 'Unknown',
|
||||
departureTime: new Date().toISOString(),
|
||||
arrivalTime: new Date(Date.now() + 3600000).toISOString(),
|
||||
duration: 60,
|
||||
aircraftType: 'Mock Aircraft',
|
||||
cabin: 'economy',
|
||||
price: 20000, // $200.00
|
||||
seatsAvailable: 10,
|
||||
bookingClass: 'Y',
|
||||
status: 'available'
|
||||
};
|
||||
|
||||
// Create or update booking
|
||||
const booking = existingBooking || {
|
||||
pnr,
|
||||
sessionId,
|
||||
createdAt: Date.now(),
|
||||
lastModified: Date.now(),
|
||||
status: 'confirmed',
|
||||
passengers: params.passengers,
|
||||
flights: [],
|
||||
hotels: [],
|
||||
cars: [],
|
||||
totalPrice: 0,
|
||||
currency: 'USD',
|
||||
contactEmail: params.contactEmail || '',
|
||||
contactPhone: params.contactPhone || ''
|
||||
};
|
||||
|
||||
// Add flight segment
|
||||
booking.flights.push(flightSegment);
|
||||
booking.lastModified = Date.now();
|
||||
|
||||
// Calculate total price
|
||||
booking.totalPrice = calculateTotalPrice(booking);
|
||||
|
||||
// Store booking in both session-scoped AND global storage
|
||||
const bookingKey = sessionManager.getBookingKey(sessionId, pnr);
|
||||
await storage.set(bookingKey, JSON.stringify(booking));
|
||||
|
||||
// Store in global PNR namespace with TTL (1 hour default)
|
||||
const globalBookingKey = `gds:pnr:${pnr}`;
|
||||
const ttlSeconds = parseInt(process.env.PNR_TTL || '3600', 10);
|
||||
await storage.set(globalBookingKey, JSON.stringify(booking), ttlSeconds);
|
||||
|
||||
// Add to session bookings set
|
||||
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
|
||||
await storage.sadd(bookingsSetKey, pnr);
|
||||
|
||||
// Increment booking counter
|
||||
await sessionManager.incrementBookingCount(sessionId);
|
||||
|
||||
logger.info({
|
||||
type: 'flight_booked',
|
||||
sessionId,
|
||||
pnr,
|
||||
passengerCount: params.passengers.length
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pnr,
|
||||
status: booking.status,
|
||||
booking: {
|
||||
pnr,
|
||||
passengers: booking.passengers,
|
||||
flights: booking.flights,
|
||||
totalPrice: booking.totalPrice,
|
||||
currency: booking.currency,
|
||||
createdAt: booking.createdAt,
|
||||
contactEmail: booking.contactEmail,
|
||||
contactPhone: booking.contactPhone
|
||||
},
|
||||
message: `Flight booked successfully. PNR: ${pnr}`,
|
||||
metadata: {
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Flight booking failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total price across all booking segments
|
||||
* @param {Object} booking - Booking object
|
||||
* @returns {number} Total price in cents
|
||||
*/
|
||||
function calculateTotalPrice(booking: any) {
|
||||
let total = 0;
|
||||
|
||||
// Sum flight prices
|
||||
for (const flight of booking.flights) {
|
||||
total += flight.price || 0;
|
||||
}
|
||||
|
||||
// Sum hotel prices
|
||||
for (const hotel of booking.hotels) {
|
||||
total += hotel.price || 0;
|
||||
}
|
||||
|
||||
// Sum car rental prices
|
||||
for (const car of booking.cars) {
|
||||
total += car.totalPrice || 0;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
export default {
|
||||
searchFlights,
|
||||
bookFlight
|
||||
};
|
||||
290
src/tools/hotels.ts
Normal file
290
src/tools/hotels.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Hotel Search and Booking Tools
|
||||
* Implements searchHotels and bookHotel MCP tools
|
||||
*/
|
||||
|
||||
import { getHotelsByCity, getHotelById, generateHotelPrice } from '../data/hotels.js';
|
||||
import { generatePNR } from '../data/pnr.js';
|
||||
import { storage } from '../session/storage.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ValidationError, BookingError } from '../utils/errors.js';
|
||||
import {
|
||||
validateRequired,
|
||||
validateString,
|
||||
validateNumber,
|
||||
validateFutureDate
|
||||
} from '../validation/validators.js';
|
||||
|
||||
/**
|
||||
* Search for available hotels
|
||||
* @param {Object} params - Search parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Search results
|
||||
*/
|
||||
export async function searchHotels(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate required fields
|
||||
validateRequired(params.cityCode, 'cityCode');
|
||||
validateRequired(params.checkInDate, 'checkInDate');
|
||||
validateRequired(params.checkOutDate, 'checkOutDate');
|
||||
|
||||
validateString(params.cityCode, 'cityCode', { minLength: 3, maxLength: 3 });
|
||||
validateFutureDate(params.checkInDate, 'checkInDate');
|
||||
validateFutureDate(params.checkOutDate, 'checkOutDate');
|
||||
|
||||
if (params.guests) {
|
||||
validateNumber(params.guests, 'guests', { min: 1, max: 10 });
|
||||
}
|
||||
|
||||
const cityCode = params.cityCode.toUpperCase();
|
||||
const checkInDate = new Date(params.checkInDate);
|
||||
const checkOutDate = new Date(params.checkOutDate);
|
||||
const guests = params.guests || 1;
|
||||
|
||||
// Validate date range
|
||||
if (checkOutDate <= checkInDate) {
|
||||
throw new ValidationError('checkOutDate must be after checkInDate');
|
||||
}
|
||||
|
||||
const checkInDateObj = new Date(checkInDate);
|
||||
const checkOutDateObj = new Date(checkOutDate);
|
||||
const nights = Math.ceil((checkOutDateObj.getTime() - checkInDateObj.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (nights < 1) {
|
||||
throw new ValidationError('Minimum stay is 1 night');
|
||||
}
|
||||
|
||||
// Get hotels for city
|
||||
const cityHotels = getHotelsByCity(cityCode);
|
||||
|
||||
if (cityHotels.length === 0) {
|
||||
return {
|
||||
cityCode,
|
||||
checkInDate: params.checkInDate,
|
||||
checkOutDate: params.checkOutDate,
|
||||
nights,
|
||||
results: [],
|
||||
message: `No hotels found for city code ${cityCode}`
|
||||
};
|
||||
}
|
||||
|
||||
// Select 5-10 hotels and generate pricing
|
||||
const numResults = Math.min(cityHotels.length, Math.floor(Math.random() * 6) + 5);
|
||||
const selectedHotels = cityHotels
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, numResults);
|
||||
|
||||
const results = selectedHotels.map(hotel => {
|
||||
const pricing = generateHotelPrice(hotel.basePrice, nights, params.checkInDate);
|
||||
|
||||
return {
|
||||
hotelId: hotel.id,
|
||||
name: hotel.name,
|
||||
chain: hotel.chain,
|
||||
stars: hotel.stars,
|
||||
address: hotel.address,
|
||||
amenities: hotel.amenities,
|
||||
pricing: {
|
||||
pricePerNight: pricing.pricePerNight,
|
||||
nights: pricing.nights,
|
||||
totalPrice: pricing.totalPrice,
|
||||
currency: 'USD'
|
||||
},
|
||||
availability: 'available' // Mock: always available
|
||||
};
|
||||
});
|
||||
|
||||
logger.info({ sessionId, cityCode, nights, resultCount: results.length }, 'Hotel search completed');
|
||||
|
||||
return {
|
||||
cityCode,
|
||||
checkInDate: params.checkInDate,
|
||||
checkOutDate: params.checkOutDate,
|
||||
nights,
|
||||
guests,
|
||||
results
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Hotel search failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Book a hotel
|
||||
* @param {Object} params - Booking parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Booking confirmation
|
||||
*/
|
||||
export async function bookHotel(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate required fields
|
||||
validateRequired(params.hotelId, 'hotelId');
|
||||
validateRequired(params.checkInDate, 'checkInDate');
|
||||
validateRequired(params.checkOutDate, 'checkOutDate');
|
||||
validateRequired(params.guestName, 'guestName');
|
||||
validateRequired(params.guestEmail, 'guestEmail');
|
||||
|
||||
const { hotelId, checkInDate, checkOutDate, guestName, guestEmail, guestPhone, pnr: existingPnr, roomType } = params;
|
||||
|
||||
// Validate hotel exists
|
||||
const hotel = getHotelById(hotelId);
|
||||
if (!hotel) {
|
||||
throw new ValidationError(`Hotel not found: ${hotelId}`);
|
||||
}
|
||||
|
||||
// Validate dates
|
||||
validateFutureDate(checkInDate, 'checkInDate');
|
||||
validateFutureDate(checkOutDate, 'checkOutDate');
|
||||
|
||||
const checkIn = new Date(checkInDate);
|
||||
const checkOut = new Date(checkOutDate);
|
||||
|
||||
if (checkOut <= checkIn) {
|
||||
throw new ValidationError('checkOutDate must be after checkInDate');
|
||||
}
|
||||
|
||||
const checkInDateObj = new Date(params.checkInDate);
|
||||
const checkOutDateObj = new Date(params.checkOutDate);
|
||||
const nights = Math.ceil((checkOutDateObj.getTime() - checkInDateObj.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Calculate pricing
|
||||
const pricing = generateHotelPrice(hotel.basePrice, nights, checkInDate);
|
||||
|
||||
// Generate or use existing PNR
|
||||
const pnr = existingPnr || generatePNR();
|
||||
const client = storage.client;
|
||||
|
||||
// Check if this is adding to existing booking
|
||||
let booking;
|
||||
if (existingPnr) {
|
||||
const existingKey = `gds:session:${sessionId}:booking:${existingPnr}`;
|
||||
const existingData = await client.get(existingKey);
|
||||
|
||||
if (!existingData) {
|
||||
throw new BookingError(`Booking not found: ${existingPnr}`);
|
||||
}
|
||||
|
||||
booking = JSON.parse(existingData);
|
||||
|
||||
// Add hotel segment
|
||||
if (!booking.hotels) {
|
||||
booking.hotels = [];
|
||||
}
|
||||
|
||||
booking.hotels.push({
|
||||
hotelId: hotel.id,
|
||||
name: hotel.name,
|
||||
chain: hotel.chain,
|
||||
stars: hotel.stars,
|
||||
address: hotel.address,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
nights,
|
||||
roomType: roomType || 'Standard',
|
||||
guestName,
|
||||
guestEmail,
|
||||
guestPhone,
|
||||
pricing: {
|
||||
pricePerNight: pricing.pricePerNight,
|
||||
nights: pricing.nights,
|
||||
totalPrice: pricing.totalPrice,
|
||||
currency: 'USD'
|
||||
},
|
||||
status: 'confirmed',
|
||||
bookedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Recalculate total price
|
||||
let totalPrice = 0;
|
||||
if (booking.flights) {
|
||||
totalPrice += booking.flights.reduce((sum, f) => sum + (f.pricing?.totalPrice || 0), 0);
|
||||
}
|
||||
if (booking.hotels) {
|
||||
totalPrice += booking.hotels.reduce((sum, h) => sum + (h.pricing?.totalPrice || 0), 0);
|
||||
}
|
||||
if (booking.cars) {
|
||||
totalPrice += booking.cars.reduce((sum, c) => sum + (c.pricing?.totalPrice || 0), 0);
|
||||
}
|
||||
|
||||
booking.totalPrice = totalPrice;
|
||||
booking.updatedAt = new Date().toISOString();
|
||||
|
||||
} else {
|
||||
// Create new hotel-only booking
|
||||
booking = {
|
||||
pnr,
|
||||
type: 'hotel',
|
||||
status: 'confirmed',
|
||||
hotels: [{
|
||||
hotelId: hotel.id,
|
||||
name: hotel.name,
|
||||
chain: hotel.chain,
|
||||
stars: hotel.stars,
|
||||
address: hotel.address,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
nights,
|
||||
roomType: roomType || 'Standard',
|
||||
guestName,
|
||||
guestEmail,
|
||||
guestPhone,
|
||||
pricing: {
|
||||
pricePerNight: pricing.pricePerNight,
|
||||
nights: pricing.nights,
|
||||
totalPrice: pricing.totalPrice,
|
||||
currency: 'USD'
|
||||
},
|
||||
status: 'confirmed',
|
||||
bookedAt: new Date().toISOString()
|
||||
}],
|
||||
totalPrice: pricing.totalPrice,
|
||||
currency: 'USD',
|
||||
createdAt: new Date().toISOString(),
|
||||
sessionId
|
||||
};
|
||||
}
|
||||
|
||||
// Store booking
|
||||
const bookingKey = `gds:session:${sessionId}:booking:${pnr}`;
|
||||
await client.set(bookingKey, JSON.stringify(booking), 'EX', 3600);
|
||||
|
||||
// Update session bookings set
|
||||
await client.sadd(`gds:session:${sessionId}:bookings`, pnr);
|
||||
|
||||
logger.info({ sessionId, pnr, hotelId, nights }, 'Hotel booking created');
|
||||
|
||||
return {
|
||||
pnr,
|
||||
status: 'confirmed',
|
||||
hotel: {
|
||||
hotelId: hotel.id,
|
||||
name: hotel.name,
|
||||
chain: hotel.chain,
|
||||
stars: hotel.stars,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
nights,
|
||||
roomType: roomType || 'Standard'
|
||||
},
|
||||
pricing: {
|
||||
pricePerNight: pricing.pricePerNight,
|
||||
nights: pricing.nights,
|
||||
totalPrice: pricing.totalPrice,
|
||||
currency: 'USD'
|
||||
},
|
||||
guestName,
|
||||
guestEmail,
|
||||
message: existingPnr
|
||||
? `Hotel added to existing booking ${pnr}`
|
||||
: `Hotel booking confirmed with PNR ${pnr}`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Hotel booking failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default { searchHotels, bookHotel };
|
||||
161
src/tools/session.ts
Normal file
161
src/tools/session.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { storage } from '../session/storage.js';
|
||||
import { sessionManager } from '../session/manager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Get session information
|
||||
* @param {Object} params - Parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Session information
|
||||
*/
|
||||
export async function getSessionInfo(params: any, sessionId: any) {
|
||||
try {
|
||||
// Get session data
|
||||
const session = await sessionManager.getSession(sessionId);
|
||||
|
||||
// Calculate uptime
|
||||
const now = Date.now();
|
||||
const uptimeMs = now - session.createdAt;
|
||||
const uptime = formatUptime(uptimeMs);
|
||||
|
||||
// Get booking count from session bookings set
|
||||
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
|
||||
const pnrs = await storage.smembers(bookingsSetKey);
|
||||
const totalBookings = pnrs.length;
|
||||
|
||||
// Calculate time until expiry
|
||||
const ttlSeconds = Math.max(0, Math.floor((session.expiresAt - now) / 1000));
|
||||
|
||||
logger.info({
|
||||
type: 'session_info_retrieved',
|
||||
sessionId,
|
||||
bookingCount: totalBookings,
|
||||
uptime
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
session: {
|
||||
sessionId,
|
||||
createdAt: new Date(session.createdAt).toISOString(),
|
||||
lastActivity: new Date(session.lastActivity).toISOString(),
|
||||
expiresAt: new Date(session.expiresAt).toISOString(),
|
||||
ttlSeconds,
|
||||
uptime,
|
||||
uptimeMs,
|
||||
bookingCount: totalBookings,
|
||||
searchCount: session.searchCount
|
||||
},
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to retrieve session info');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session data (remove all bookings)
|
||||
* @param {Object} params - Parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @returns {Promise<Object>} Clear confirmation
|
||||
*/
|
||||
export async function clearSession(params: any, sessionId: any) {
|
||||
try {
|
||||
// Validate confirmation parameter
|
||||
const confirmed = params.confirm === true;
|
||||
|
||||
if (!confirmed) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Session clear requires confirmation. Set confirm=true to proceed.',
|
||||
warning: 'This will delete all bookings in the current session.',
|
||||
metadata: {
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get all booking PNRs for this session
|
||||
const bookingsSetKey = sessionManager.getBookingsKey(sessionId);
|
||||
const pnrs = await storage.smembers(bookingsSetKey);
|
||||
|
||||
let deletedCount = 0;
|
||||
|
||||
// Delete all bookings from both session-scoped and global storage
|
||||
for (const pnr of pnrs) {
|
||||
// Delete from session-scoped storage
|
||||
const sessionBookingKey = sessionManager.getBookingKey(sessionId, pnr);
|
||||
await storage.del(sessionBookingKey);
|
||||
|
||||
// Delete from global storage
|
||||
const globalBookingKey = `gds:pnr:${pnr}`;
|
||||
await storage.del(globalBookingKey);
|
||||
|
||||
deletedCount++;
|
||||
}
|
||||
|
||||
// Clear the bookings set
|
||||
await storage.del(bookingsSetKey);
|
||||
|
||||
// Reset booking count
|
||||
const sessionKey = sessionManager.getSessionKey(sessionId);
|
||||
await storage.hset(sessionKey, 'bookingCount', '0');
|
||||
|
||||
// Preserve sessionId and createdAt (don't delete session itself)
|
||||
|
||||
logger.info({
|
||||
type: 'session_cleared',
|
||||
sessionId,
|
||||
deletedBookings: deletedCount
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Session cleared successfully. ${deletedCount} booking(s) deleted.`,
|
||||
deletedBookings: deletedCount,
|
||||
preservedData: {
|
||||
sessionId,
|
||||
createdAt: 'preserved',
|
||||
searchCount: 'preserved'
|
||||
},
|
||||
metadata: {
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, sessionId }, 'Failed to clear session');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format uptime duration as human-readable string
|
||||
* @param {number} ms - Milliseconds
|
||||
* @returns {string} Formatted uptime
|
||||
*/
|
||||
function formatUptime(ms: any) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours % 24}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getSessionInfo,
|
||||
clearSession
|
||||
};
|
||||
15
src/transports/factory.ts
Normal file
15
src/transports/factory.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createHTTPServer } from './http-server.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Transport factory - creates Streamable HTTP transport
|
||||
* HTTP/1.1 + SSE per MCP specification 2025-06-18
|
||||
*/
|
||||
export async function createTransport(mcpServer, _options = {}) {
|
||||
logger.info({ transport: 'http' }, 'Creating MCP Streamable HTTP transport');
|
||||
|
||||
// Always use Streamable HTTP (HTTP/1.1 + SSE per MCP specification 2025-06-18)
|
||||
return await createHTTPServer(mcpServer);
|
||||
}
|
||||
|
||||
export default { createTransport };
|
||||
232
src/transports/http-server.ts
Normal file
232
src/transports/http-server.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { corsMiddleware } from '../middleware/cors.js';
|
||||
import { rateLimitMiddleware } from '../middleware/rate-limit.js';
|
||||
import { protocolVersionMiddleware } from '../middleware/protocol-version.js';
|
||||
import { requestLoggerMiddleware } from '../middleware/logger.js';
|
||||
import { messageNormalizationMiddleware } from '../middleware/message-normalization.js';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/server';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import { ValkeyEventStore } from '../session/valkey-event-store.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
|
||||
/**
|
||||
* Create and configure Streamable HTTP server per MCP specification 2025-06-18
|
||||
* Transport: HTTP/1.1 + Server-Sent Events (SSE)
|
||||
*
|
||||
* Uses STATELESS mode to support multiple concurrent client initializations.
|
||||
* Each client can send their own initialize request without server-side session tracking.
|
||||
*/
|
||||
export async function createHTTPServer(mcpServer: any) {
|
||||
const app = createMcpExpressApp();
|
||||
const transports: Record<string, any> = {};
|
||||
|
||||
// Apply CORS globally
|
||||
app.use(corsMiddleware);
|
||||
|
||||
// Health check endpoint - NO auth required
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'gds-mock-mcp',
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const mcpPostHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId) {
|
||||
logger.debug({ sessionId, body: req.body }, 'Received MCP request for session');
|
||||
} else {
|
||||
logger.info('No session ID provided in MCP request');
|
||||
}
|
||||
try {
|
||||
let transport: NodeStreamableHTTPServerTransport;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
// Reuse existing transport
|
||||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
// New initialization request
|
||||
const eventStore = new ValkeyEventStore();
|
||||
transport = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore, // Enable resumability
|
||||
onsessioninitialized: sessionId => {
|
||||
// Store the transport by session ID when session is initialized
|
||||
// This avoids race conditions where requests might come in before the session is stored
|
||||
logger.info(`Session initialized with ID: ${sessionId}`);
|
||||
transports[sessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up onclose handler to clean up transport when closed
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid]) {
|
||||
logger.info(`Transport closed for session ${sid}, removing from transports map`);
|
||||
delete transports[sid];
|
||||
}
|
||||
};
|
||||
|
||||
// Set up transport error handler to log SDK errors
|
||||
transport.onerror = (error) => {
|
||||
console.error('Transport error:', error);
|
||||
logger.error(
|
||||
{
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
},
|
||||
'MCP Transport error'
|
||||
);
|
||||
};
|
||||
|
||||
// Connect the transport to the MCP server BEFORE handling the request
|
||||
// so responses can flow back through the same transport
|
||||
const server = mcpServer;
|
||||
await server.connect(transport);
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return; // Already handled
|
||||
} else if (sessionId) {
|
||||
res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_001, message: 'Session not found' },
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: 'Bad Request: Session ID required' },
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the request with existing transport - no need to reconnect
|
||||
// The existing transport is already connected to the server
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Error handling MCP request:');
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32_603,
|
||||
message: 'Internal server error'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.post('/mcp', mcpPostHandler);
|
||||
|
||||
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
|
||||
const mcpGetHandler = async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (!sessionId) {
|
||||
res.status(400).send('Missing session ID');
|
||||
return;
|
||||
}
|
||||
if (!transports[sessionId]) {
|
||||
res.status(404).send('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Last-Event-ID header for resumability
|
||||
const lastEventId = req.headers['last-event-id'];
|
||||
if (lastEventId) {
|
||||
logger.info(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
|
||||
} else {
|
||||
logger.info(`Establishing new SSE stream for session ${sessionId}`);
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
};
|
||||
|
||||
app.get('/mcp', mcpGetHandler);
|
||||
|
||||
// Handle DELETE requests for session termination (according to MCP spec)
|
||||
const mcpDeleteHandler = async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (!sessionId) {
|
||||
res.status(400).send('Missing session ID');
|
||||
return;
|
||||
}
|
||||
if (!transports[sessionId]) {
|
||||
res.status(404).send('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Received session termination request for session ${sessionId}`);
|
||||
|
||||
try {
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined },
|
||||
'Error handling session termination:'
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error processing session termination');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.delete('/mcp', mcpDeleteHandler);
|
||||
|
||||
// Handle server shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('Shutting down server...');
|
||||
|
||||
// Close all active transports to properly clean up resources
|
||||
for (const sessionId in transports) {
|
||||
try {
|
||||
logger.info(`Closing transport for session ${sessionId}`);
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ sessionId, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined },
|
||||
`Error closing transport for session ${sessionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.info('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// MCP endpoint with middleware - applied to /mcp route specifically
|
||||
// Note: express.json() already parsed the body, so req.body is available
|
||||
app.use('/mcp', requestLoggerMiddleware);
|
||||
app.use('/mcp', messageNormalizationMiddleware); // Ensure params field exists
|
||||
app.use('/mcp', protocolVersionMiddleware);
|
||||
app.use('/mcp', rateLimitMiddleware);
|
||||
|
||||
// Start HTTP server
|
||||
const httpServer = app.listen(PORT, HOST, () => {
|
||||
logger.info({ host: HOST, port: PORT }, 'Streamable HTTP server listening');
|
||||
});
|
||||
|
||||
// Graceful shutdown handler
|
||||
const shutdown = async () => {
|
||||
logger.info('Shutting down HTTP server...');
|
||||
httpServer.close(() => {
|
||||
logger.info('HTTP server closed');
|
||||
});
|
||||
};
|
||||
|
||||
return { httpServer, shutdown };
|
||||
}
|
||||
|
||||
export default { createHTTPServer };
|
||||
203
src/utils/errors.ts
Normal file
203
src/utils/errors.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* MCP Error Codes and Error Handling Utilities
|
||||
* Based on JSON-RPC 2.0 and MCP specification
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard MCP/JSON-RPC 2.0 error codes
|
||||
*/
|
||||
export const ErrorCode = {
|
||||
// JSON-RPC 2.0 standard errors
|
||||
PARSE_ERROR: -32700,
|
||||
INVALID_REQUEST: -32600,
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
INVALID_PARAMS: -32602,
|
||||
INTERNAL_ERROR: -32603,
|
||||
|
||||
// MCP-specific errors (custom range -32000 to -32099)
|
||||
INVALID_SESSION: -32001,
|
||||
SESSION_EXPIRED: -32002,
|
||||
INVALID_AIRPORT_CODE: -32003,
|
||||
INVALID_DATE: -32004,
|
||||
BOOKING_NOT_FOUND: -32005,
|
||||
BOOKING_CANCELLED: -32006,
|
||||
INVALID_PASSENGER: -32007,
|
||||
STORAGE_ERROR: -32008,
|
||||
VALIDATION_ERROR: -32009,
|
||||
INVALID_PNR_FORMAT: -32010
|
||||
};
|
||||
|
||||
/**
|
||||
* Base MCP Error class
|
||||
*/
|
||||
export class MCPError extends Error {
|
||||
code: number;
|
||||
data: any;
|
||||
|
||||
constructor(message: string, code: number, data: any = null) {
|
||||
super(message);
|
||||
this.name = 'MCPError';
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation Error - Invalid input parameters
|
||||
*/
|
||||
export class ValidationError extends MCPError {
|
||||
constructor(message, data = null) {
|
||||
super(message, ErrorCode.VALIDATION_ERROR, data);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session Error - Invalid or expired session
|
||||
*/
|
||||
export class SessionError extends MCPError {
|
||||
constructor(message, expired = false) {
|
||||
const code = expired ? ErrorCode.SESSION_EXPIRED : ErrorCode.INVALID_SESSION;
|
||||
super(message, code);
|
||||
this.name = 'SessionError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking Error - Booking not found or already cancelled
|
||||
*/
|
||||
export class BookingError extends MCPError {
|
||||
constructor(message, cancelled = false) {
|
||||
const code = cancelled ? ErrorCode.BOOKING_CANCELLED : ErrorCode.BOOKING_NOT_FOUND;
|
||||
super(message, code);
|
||||
this.name = 'BookingError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage Error - Valkey/database operation failed
|
||||
*/
|
||||
export class StorageError extends MCPError {
|
||||
constructor(message, data = null) {
|
||||
super(message, ErrorCode.STORAGE_ERROR, data);
|
||||
this.name = 'StorageError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error response for MCP
|
||||
* @param {Error} error - Error object
|
||||
* @returns {Object} MCP error response
|
||||
*/
|
||||
export function formatErrorResponse(error: any) {
|
||||
if (error instanceof MCPError) {
|
||||
return {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
data: error.data
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown errors
|
||||
return {
|
||||
error: {
|
||||
code: ErrorCode.INTERNAL_ERROR,
|
||||
message: error.message || 'Internal server error',
|
||||
data: {
|
||||
type: error.name,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate airport code (3-letter IATA)
|
||||
* @param {string} code - Airport code
|
||||
* @throws {ValidationError} If code is invalid
|
||||
*/
|
||||
export function validateAirportCode(code: any) {
|
||||
if (!code || typeof code !== 'string' || !/^[A-Z]{3}$/.test(code)) {
|
||||
throw new ValidationError(
|
||||
`Invalid airport code '${code}': must be 3-letter IATA code (e.g., JFK, LAX)`,
|
||||
{ code, expected: 'XXX' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date format (ISO 8601: YYYY-MM-DD)
|
||||
* @param {string} date - Date string
|
||||
* @param {string} fieldName - Field name for error messages
|
||||
* @throws {ValidationError} If date is invalid
|
||||
*/
|
||||
export function validateDate(date, fieldName = 'date') {
|
||||
if (!date || typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new ValidationError(
|
||||
`Invalid ${fieldName} '${date}': must be ISO 8601 date (YYYY-MM-DD)`,
|
||||
{ date, expected: 'YYYY-MM-DD' }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = new Date(date);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new ValidationError(
|
||||
`Invalid ${fieldName} '${date}': not a valid date`,
|
||||
{ date }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
* @param {string} email - Email address
|
||||
* @throws {ValidationError} If email is invalid
|
||||
*/
|
||||
export function validateEmail(email: any) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!email || !emailRegex.test(email)) {
|
||||
throw new ValidationError(
|
||||
`Invalid email address '${email}'`,
|
||||
{ email }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PNR format (TEST-XXXXXX)
|
||||
* @param {string} pnr - PNR code
|
||||
* @throws {ValidationError} If PNR format is invalid
|
||||
*/
|
||||
export function validatePNR(pnr: any) {
|
||||
if (!pnr || typeof pnr !== 'string' || !/^TEST-[A-Z0-9]{6}$/.test(pnr)) {
|
||||
throw new ValidationError(
|
||||
`Invalid PNR format '${pnr}': must be TEST-XXXXXX (e.g., TEST-ABC123)`,
|
||||
{ pnr, expected: 'TEST-XXXXXX' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
ErrorCode,
|
||||
MCPError,
|
||||
ValidationError,
|
||||
SessionError,
|
||||
BookingError,
|
||||
StorageError,
|
||||
formatErrorResponse,
|
||||
validateAirportCode,
|
||||
validateDate,
|
||||
validateEmail,
|
||||
validatePNR
|
||||
};
|
||||
89
src/utils/logger.ts
Normal file
89
src/utils/logger.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import pino from 'pino';
|
||||
|
||||
/**
|
||||
* Configure and export Pino structured logger
|
||||
* LOG_LEVEL env var: silent, error, warn, info, debug, trace (default: info)
|
||||
*/
|
||||
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
|
||||
|
||||
export const logger = pino({
|
||||
level: LOG_LEVEL,
|
||||
transport: {
|
||||
target: 'pino/file',
|
||||
options: {
|
||||
destination: process.stdout.fd
|
||||
}
|
||||
},
|
||||
formatters: {
|
||||
level: (label) => {
|
||||
return { level: label.toUpperCase() };
|
||||
}
|
||||
},
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
base: {
|
||||
service: 'gds-mock-mcp',
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create child logger with additional context
|
||||
* @param {Object} bindings - Additional context fields
|
||||
* @returns {Object} Child logger instance
|
||||
*/
|
||||
export function createLogger(bindings: any) {
|
||||
return logger.child(bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log MCP tool call
|
||||
* @param {string} tool - Tool name
|
||||
* @param {Object} params - Tool parameters
|
||||
* @param {string} sessionId - Session identifier
|
||||
*/
|
||||
export function logToolCall(tool: any, params: any, sessionId: any) {
|
||||
logger.info({
|
||||
type: 'tool_call',
|
||||
tool,
|
||||
sessionId,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log MCP tool response
|
||||
* @param {string} tool - Tool name
|
||||
* @param {number} duration - Response time in ms
|
||||
* @param {string} sessionId - Session identifier
|
||||
* @param {boolean} success - Whether operation succeeded
|
||||
* @param {Object} metadata - Additional response metadata
|
||||
*/
|
||||
export function logToolResponse(tool, duration, sessionId, success, metadata = {}) {
|
||||
logger.info({
|
||||
type: 'tool_response',
|
||||
tool,
|
||||
sessionId,
|
||||
duration,
|
||||
success,
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with context
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} context - Error context
|
||||
*/
|
||||
export function logError(error, context = {}) {
|
||||
logger.error({
|
||||
type: 'error',
|
||||
error: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code
|
||||
},
|
||||
...context
|
||||
});
|
||||
}
|
||||
|
||||
export default logger;
|
||||
322
src/validation/schemas.ts
Normal file
322
src/validation/schemas.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* MCP Tool JSON Schemas
|
||||
* Based on contracts/mcp-tools.md
|
||||
*/
|
||||
|
||||
export const searchFlightsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
origin: {
|
||||
type: 'string',
|
||||
pattern: '^[A-Z]{3}$',
|
||||
description: 'Origin airport IATA code (3 letters, e.g., JFK)'
|
||||
},
|
||||
destination: {
|
||||
type: 'string',
|
||||
pattern: '^[A-Z]{3}$',
|
||||
description: 'Destination airport IATA code (3 letters, e.g., LAX)'
|
||||
},
|
||||
departureDate: {
|
||||
type: 'string',
|
||||
format: 'date',
|
||||
description: 'Departure date in ISO 8601 format (YYYY-MM-DD)'
|
||||
},
|
||||
passengers: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
adults: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 9,
|
||||
default: 1
|
||||
},
|
||||
children: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: 9,
|
||||
default: 0
|
||||
},
|
||||
infants: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: 9,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
required: ['adults']
|
||||
},
|
||||
cabin: {
|
||||
type: 'string',
|
||||
enum: ['economy', 'premium_economy', 'business', 'first'],
|
||||
default: 'economy'
|
||||
}
|
||||
},
|
||||
required: ['origin', 'destination', 'departureDate']
|
||||
};
|
||||
|
||||
export const bookFlightSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
flightId: {
|
||||
type: 'string',
|
||||
description: 'Flight identifier from search results'
|
||||
},
|
||||
passengers: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
firstName: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 50,
|
||||
pattern: '^[A-Za-z\\s\\-]+$'
|
||||
},
|
||||
lastName: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 50,
|
||||
pattern: '^[A-Za-z\\s\\-]+$'
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['adult', 'child', 'infant']
|
||||
},
|
||||
dateOfBirth: {
|
||||
type: 'string',
|
||||
format: 'date',
|
||||
description: 'Date of birth (optional)'
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
description: 'Email address (optional)'
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
description: 'Phone number (optional)'
|
||||
}
|
||||
},
|
||||
required: ['firstName', 'lastName', 'type']
|
||||
}
|
||||
},
|
||||
contactEmail: {
|
||||
type: 'string',
|
||||
format: 'email'
|
||||
},
|
||||
contactPhone: {
|
||||
type: 'string'
|
||||
},
|
||||
pnr: {
|
||||
type: 'string',
|
||||
description: 'Existing PNR to add flight to (optional)'
|
||||
}
|
||||
},
|
||||
required: ['flightId', 'passengers']
|
||||
};
|
||||
|
||||
export const searchHotelsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cityCode: {
|
||||
type: 'string',
|
||||
pattern: '^[A-Z]{3}$',
|
||||
description: 'City IATA code (3 letters)'
|
||||
},
|
||||
checkInDate: {
|
||||
type: 'string',
|
||||
format: 'date'
|
||||
},
|
||||
checkOutDate: {
|
||||
type: 'string',
|
||||
format: 'date'
|
||||
},
|
||||
guests: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
default: 1
|
||||
},
|
||||
starRating: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
description: 'Minimum star rating (optional)'
|
||||
}
|
||||
},
|
||||
required: ['cityCode', 'checkInDate', 'checkOutDate']
|
||||
};
|
||||
|
||||
export const bookHotelSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hotelId: {
|
||||
type: 'string',
|
||||
description: 'Hotel identifier from search results'
|
||||
},
|
||||
guests: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
firstName: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 50
|
||||
},
|
||||
lastName: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 50
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email'
|
||||
}
|
||||
},
|
||||
required: ['firstName', 'lastName']
|
||||
}
|
||||
},
|
||||
contactEmail: {
|
||||
type: 'string',
|
||||
format: 'email'
|
||||
},
|
||||
contactPhone: {
|
||||
type: 'string'
|
||||
},
|
||||
pnr: {
|
||||
type: 'string',
|
||||
description: 'Existing PNR to add hotel to (optional)'
|
||||
}
|
||||
},
|
||||
required: ['hotelId', 'guests']
|
||||
};
|
||||
|
||||
export const searchCarsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pickupLocation: {
|
||||
type: 'string',
|
||||
pattern: '^[A-Z]{3}$',
|
||||
description: 'Pickup location airport code'
|
||||
},
|
||||
pickupDate: {
|
||||
type: 'string',
|
||||
format: 'date-time'
|
||||
},
|
||||
dropoffLocation: {
|
||||
type: 'string',
|
||||
pattern: '^[A-Z]{3}$',
|
||||
description: 'Dropoff location airport code'
|
||||
},
|
||||
dropoffDate: {
|
||||
type: 'string',
|
||||
format: 'date-time'
|
||||
},
|
||||
vehicleClass: {
|
||||
type: 'string',
|
||||
enum: ['economy', 'compact', 'midsize', 'fullsize', 'suv', 'luxury'],
|
||||
description: 'Preferred vehicle class (optional)'
|
||||
}
|
||||
},
|
||||
required: ['pickupLocation', 'pickupDate', 'dropoffLocation', 'dropoffDate']
|
||||
};
|
||||
|
||||
export const bookCarSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
carId: {
|
||||
type: 'string',
|
||||
description: 'Car rental identifier from search results'
|
||||
},
|
||||
driver: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
firstName: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 50
|
||||
},
|
||||
lastName: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 50
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email'
|
||||
},
|
||||
phone: {
|
||||
type: 'string'
|
||||
},
|
||||
licenseNumber: {
|
||||
type: 'string',
|
||||
description: 'Driver license number (optional)'
|
||||
}
|
||||
},
|
||||
required: ['firstName', 'lastName']
|
||||
},
|
||||
pnr: {
|
||||
type: 'string',
|
||||
description: 'Existing PNR to add car to (optional)'
|
||||
}
|
||||
},
|
||||
required: ['carId', 'driver']
|
||||
};
|
||||
|
||||
export const retrieveBookingSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pnr: {
|
||||
type: 'string',
|
||||
pattern: '^TEST-[A-Z0-9]{6}$',
|
||||
description: 'Booking reference (e.g., TEST-ABC123)'
|
||||
}
|
||||
},
|
||||
required: ['pnr']
|
||||
};
|
||||
|
||||
export const cancelBookingSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pnr: {
|
||||
type: 'string',
|
||||
pattern: '^TEST-[A-Z0-9]{6}$',
|
||||
description: 'Booking reference to cancel'
|
||||
}
|
||||
},
|
||||
required: ['pnr']
|
||||
};
|
||||
|
||||
export const listBookingsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 10,
|
||||
description: 'Maximum number of bookings to return'
|
||||
},
|
||||
offset: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
description: 'Number of bookings to skip'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
searchFlightsSchema,
|
||||
bookFlightSchema,
|
||||
searchHotelsSchema,
|
||||
bookHotelSchema,
|
||||
searchCarsSchema,
|
||||
bookCarSchema,
|
||||
retrieveBookingSchema,
|
||||
cancelBookingSchema,
|
||||
listBookingsSchema
|
||||
};
|
||||
194
src/validation/validators.ts
Normal file
194
src/validation/validators.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { ValidationError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* JSON Schema Validators using native JavaScript validation
|
||||
* Based on contracts/mcp-tools.md specifications
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate required fields presence
|
||||
* @param {Object} obj - Object to validate
|
||||
* @param {string[]} requiredFields - Array of required field names
|
||||
* @throws {ValidationError} If any required field is missing
|
||||
*/
|
||||
export function validateRequired(obj: any, requiredFields: any) {
|
||||
for (const field of requiredFields) {
|
||||
if (obj[field] === undefined || obj[field] === null || obj[field] === '') {
|
||||
throw new ValidationError(`Missing required field: ${field} in ${JSON.stringify(obj)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate string field
|
||||
* @param {*} value - Value to validate
|
||||
* @param {string} fieldName - Field name for error messages
|
||||
* @param {number} minLength - Minimum length (optional)
|
||||
* @param {number} maxLength - Maximum length (optional)
|
||||
* @param {RegExp} pattern - Pattern to match (optional)
|
||||
* @throws {ValidationError} If validation fails
|
||||
*/
|
||||
export function validateString(value, fieldName, minLength = null, maxLength = null, pattern = null) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new ValidationError(`Field '${fieldName}' must be a string`);
|
||||
}
|
||||
|
||||
if (minLength !== null && value.length < minLength) {
|
||||
throw new ValidationError(`Field '${fieldName}' must be at least ${minLength} characters`);
|
||||
}
|
||||
|
||||
if (maxLength !== null && value.length > maxLength) {
|
||||
throw new ValidationError(`Field '${fieldName}' must be at most ${maxLength} characters`);
|
||||
}
|
||||
|
||||
if (pattern !== null && !pattern.test(value)) {
|
||||
throw new ValidationError(`Field '${fieldName}' does not match required pattern`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate number field
|
||||
* @param {*} value - Value to validate
|
||||
* @param {string} fieldName - Field name for error messages
|
||||
* @param {number} min - Minimum value (optional)
|
||||
* @param {number} max - Maximum value (optional)
|
||||
* @throws {ValidationError} If validation fails
|
||||
*/
|
||||
export function validateNumber(value, fieldName, min = null, max = null) {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
throw new ValidationError(`Field '${fieldName}' must be a number`);
|
||||
}
|
||||
|
||||
if (min !== null && value < min) {
|
||||
throw new ValidationError(`Field '${fieldName}' must be at least ${min}`);
|
||||
}
|
||||
|
||||
if (max !== null && value > max) {
|
||||
throw new ValidationError(`Field '${fieldName}' must be at most ${max}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate enum field
|
||||
* @param {*} value - Value to validate
|
||||
* @param {string} fieldName - Field name for error messages
|
||||
* @param {string[]} allowedValues - Array of allowed values
|
||||
* @throws {ValidationError} If validation fails
|
||||
*/
|
||||
export function validateEnum(value: any, fieldName: any, allowedValues: any) {
|
||||
if (!allowedValues.includes(value)) {
|
||||
throw new ValidationError(
|
||||
`Field '${fieldName}' must be one of: ${allowedValues.join(', ')}. Got: ${value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate array field
|
||||
* @param {*} value - Value to validate
|
||||
* @param {string} fieldName - Field name for error messages
|
||||
* @param {number} minItems - Minimum number of items (optional)
|
||||
* @param {number} maxItems - Maximum number of items (optional)
|
||||
* @throws {ValidationError} If validation fails
|
||||
*/
|
||||
export function validateArray(value, fieldName, minItems = null, maxItems = null) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new ValidationError(`Field '${fieldName}' must be an array`);
|
||||
}
|
||||
|
||||
if (minItems !== null && value.length < minItems) {
|
||||
throw new ValidationError(`Field '${fieldName}' must have at least ${minItems} items`);
|
||||
}
|
||||
|
||||
if (maxItems !== null && value.length > maxItems) {
|
||||
throw new ValidationError(`Field '${fieldName}' must have at most ${maxItems} items`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date comparison (date1 < date2)
|
||||
* @param {string} date1 - First date (ISO 8601)
|
||||
* @param {string} date2 - Second date (ISO 8601)
|
||||
* @param {string} field1Name - First field name
|
||||
* @param {string} field2Name - Second field name
|
||||
* @throws {ValidationError} If date1 >= date2
|
||||
*/
|
||||
export function validateDateOrder(date1: any, date2: any, field1Name: any, field2Name: any) {
|
||||
const d1 = new Date(date1);
|
||||
const d2 = new Date(date2);
|
||||
|
||||
if (d1 >= d2) {
|
||||
throw new ValidationError(
|
||||
`${field1Name} must be before ${field2Name} (${date1} >= ${date2})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date is not in the past
|
||||
* @param {string} date - Date to validate (ISO 8601)
|
||||
* @param {string} fieldName - Field name for error messages
|
||||
* @throws {ValidationError} If date is in the past
|
||||
*/
|
||||
export function validateFutureDate(date: any, fieldName: any) {
|
||||
const inputDate = new Date(date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (inputDate < today) {
|
||||
throw new ValidationError(
|
||||
`${fieldName} cannot be in the past (${date})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate passenger object
|
||||
* @param {Object} passenger - Passenger data
|
||||
* @throws {ValidationError} If validation fails
|
||||
*/
|
||||
export function validatePassenger(passenger: any) {
|
||||
validateRequired(passenger, ['firstName', 'lastName', 'type']);
|
||||
|
||||
validateString(passenger.firstName, 'firstName', 1, 50, /^[A-Za-z\s\-]+$/);
|
||||
validateString(passenger.lastName, 'lastName', 1, 50, /^[A-Za-z\s\-]+$/);
|
||||
validateEnum(passenger.type, 'type', ['adult', 'child', 'infant']);
|
||||
|
||||
if (passenger.email) {
|
||||
validateString(passenger.email, 'email', 5, 100);
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(passenger.email)) {
|
||||
throw new ValidationError('Invalid email format');
|
||||
}
|
||||
}
|
||||
|
||||
if (passenger.phone) {
|
||||
validateString(passenger.phone, 'phone', 10, 20);
|
||||
}
|
||||
|
||||
if (passenger.dateOfBirth) {
|
||||
validateString(passenger.dateOfBirth, 'dateOfBirth', 10, 10, /^\d{4}-\d{2}-\d{2}$/);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cabin class
|
||||
* @param {string} cabin - Cabin class
|
||||
* @throws {ValidationError} If validation fails
|
||||
*/
|
||||
export function validateCabin(cabin: any) {
|
||||
const validCabins = ['economy', 'premium_economy', 'business', 'first'];
|
||||
validateEnum(cabin, 'cabin', validCabins);
|
||||
}
|
||||
|
||||
export default {
|
||||
validateRequired,
|
||||
validateString,
|
||||
validateNumber,
|
||||
validateEnum,
|
||||
validateArray,
|
||||
validateDateOrder,
|
||||
validateFutureDate,
|
||||
validatePassenger,
|
||||
validateCabin
|
||||
};
|
||||
Reference in New Issue
Block a user