@xenterprises/fastify-xgeocode 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +144 -0
- package/QUICK_START.md +126 -0
- package/README.md +126 -0
- package/package.json +40 -0
- package/src/xGeocode.js +363 -0
- package/test/xGeocode.test.js +901 -0
package/src/xGeocode.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import fp from "fastify-plugin";
|
|
2
|
+
|
|
3
|
+
async function xGeocode(fastify, options) {
|
|
4
|
+
const { active = true, apiKey } = options;
|
|
5
|
+
|
|
6
|
+
if (active === false) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (!apiKey) {
|
|
11
|
+
throw new Error("xGeocode: apiKey is required in options");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const geocodioEndpoint = 'https://api.geocod.io/v1.7/geocode';
|
|
15
|
+
const reverseEndpoint = 'https://api.geocod.io/v1.7/reverse';
|
|
16
|
+
|
|
17
|
+
// Helper to validate zip code format
|
|
18
|
+
function validateZipCode(zipCode) {
|
|
19
|
+
if (!zipCode || typeof zipCode !== 'string') {
|
|
20
|
+
throw new Error('xGeocode: Invalid input - zipCode must be a non-empty string');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Match 5-digit or 9-digit (ZIP+4) format
|
|
24
|
+
if (!/^\d{5}(-\d{4})?$/.test(zipCode.trim())) {
|
|
25
|
+
throw new Error('xGeocode: Invalid zip code format - must be 5 digits or 9 digits (ZIP+4)');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return zipCode.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Helper to validate address string
|
|
32
|
+
function validateAddress(address) {
|
|
33
|
+
if (!address || typeof address !== 'string') {
|
|
34
|
+
throw new Error('xGeocode: Invalid input - address must be a non-empty string');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (address.trim().length < 3) {
|
|
38
|
+
throw new Error('xGeocode: Invalid address - minimum 3 characters required');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return address.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Helper to validate coordinates
|
|
45
|
+
function validateCoordinates(lat, lng) {
|
|
46
|
+
const latitude = parseFloat(lat);
|
|
47
|
+
const longitude = parseFloat(lng);
|
|
48
|
+
|
|
49
|
+
if (isNaN(latitude) || isNaN(longitude)) {
|
|
50
|
+
throw new Error('xGeocode: Invalid coordinates - latitude and longitude must be numbers');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (latitude < -90 || latitude > 90) {
|
|
54
|
+
throw new Error('xGeocode: Invalid latitude - must be between -90 and 90');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (longitude < -180 || longitude > 180) {
|
|
58
|
+
throw new Error('xGeocode: Invalid longitude - must be between -180 and 180');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { latitude, longitude };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Helper to parse API response
|
|
65
|
+
function parseGeocodeResult(result) {
|
|
66
|
+
if (!result.results || result.results.length === 0) {
|
|
67
|
+
throw new Error('xGeocode: No results found');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const location = result.results[0].location;
|
|
71
|
+
const addressComponents = result.results[0].address_components;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
lat: location.lat,
|
|
75
|
+
lng: location.lng,
|
|
76
|
+
formatted_address: result.results[0].formatted_address || null,
|
|
77
|
+
city: addressComponents.city || null,
|
|
78
|
+
county: addressComponents.county || null,
|
|
79
|
+
country: addressComponents.country || null,
|
|
80
|
+
state: addressComponents.state || null,
|
|
81
|
+
zip: addressComponents.zip || null,
|
|
82
|
+
addressComponents
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Haversine formula to calculate distance between two points
|
|
87
|
+
function calculateHaversineDistance(lat1, lng1, lat2, lng2) {
|
|
88
|
+
const R = 6371; // Earth's radius in kilometers
|
|
89
|
+
const dLat = (lat2 - lat1) * (Math.PI / 180);
|
|
90
|
+
const dLng = (lng2 - lng1) * (Math.PI / 180);
|
|
91
|
+
|
|
92
|
+
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
93
|
+
Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) *
|
|
94
|
+
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
|
95
|
+
|
|
96
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
97
|
+
const km = R * c;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
kilometers: Math.round(km * 100) / 100,
|
|
101
|
+
miles: Math.round((km * 0.621371) * 100) / 100,
|
|
102
|
+
meters: Math.round(km * 1000)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fastify.decorate('xGeocode', {
|
|
107
|
+
/**
|
|
108
|
+
* Get latitude, longitude, and address components from a zip code
|
|
109
|
+
* @param {string} zipCode - The zip code to geocode (5 or 9 digits)
|
|
110
|
+
* @returns {Promise<Object>} Object with zip, lat, lng, city, county, country, state, and addressComponents
|
|
111
|
+
*/
|
|
112
|
+
async getLatLongByZip(zipCode) {
|
|
113
|
+
try {
|
|
114
|
+
const validZip = validateZipCode(zipCode);
|
|
115
|
+
const url = `${geocodioEndpoint}?q=${encodeURIComponent(validZip)}&fields=cd,stateleg&api_key=${apiKey}`;
|
|
116
|
+
const response = await fetch(url);
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const error = process.env.NODE_ENV === 'production'
|
|
120
|
+
? 'Geocoding service unavailable'
|
|
121
|
+
: `Geocoding API returned ${response.status}`;
|
|
122
|
+
throw new Error(error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const result = await response.json();
|
|
126
|
+
const parsed = parseGeocodeResult(result);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
zip: validZip,
|
|
130
|
+
...parsed
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const message = error.message || 'Unknown error in geocoding';
|
|
134
|
+
const sanitizedMessage = process.env.NODE_ENV === 'production'
|
|
135
|
+
? 'Failed to geocode address'
|
|
136
|
+
: message;
|
|
137
|
+
|
|
138
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
139
|
+
console.error('xGeocode error:', message);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
throw new Error(sanitizedMessage);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get latitude, longitude, and address components from a street address
|
|
148
|
+
* @param {string} address - The street address to geocode (e.g., "123 Main St, New York, NY")
|
|
149
|
+
* @returns {Promise<Object>} Object with lat, lng, formatted_address, city, state, etc.
|
|
150
|
+
*/
|
|
151
|
+
async getLatLongByAddress(address) {
|
|
152
|
+
try {
|
|
153
|
+
const validAddress = validateAddress(address);
|
|
154
|
+
const url = `${geocodioEndpoint}?q=${encodeURIComponent(validAddress)}&fields=cd,stateleg&api_key=${apiKey}`;
|
|
155
|
+
const response = await fetch(url);
|
|
156
|
+
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
const error = process.env.NODE_ENV === 'production'
|
|
159
|
+
? 'Geocoding service unavailable'
|
|
160
|
+
: `Geocoding API returned ${response.status}`;
|
|
161
|
+
throw new Error(error);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const result = await response.json();
|
|
165
|
+
return parseGeocodeResult(result);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const message = error.message || 'Unknown error in geocoding';
|
|
168
|
+
const sanitizedMessage = process.env.NODE_ENV === 'production'
|
|
169
|
+
? 'Failed to geocode address'
|
|
170
|
+
: message;
|
|
171
|
+
|
|
172
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
173
|
+
console.error('xGeocode error:', message);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw new Error(sanitizedMessage);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get address from latitude and longitude (reverse geocoding)
|
|
182
|
+
* @param {number} lat - Latitude coordinate
|
|
183
|
+
* @param {number} lng - Longitude coordinate
|
|
184
|
+
* @returns {Promise<Object>} Object with formatted_address, city, state, zip, etc.
|
|
185
|
+
*/
|
|
186
|
+
async getReverseGeocode(lat, lng) {
|
|
187
|
+
try {
|
|
188
|
+
const { latitude, longitude } = validateCoordinates(lat, lng);
|
|
189
|
+
const url = `${reverseEndpoint}?q=${latitude},${longitude}&fields=cd,stateleg&api_key=${apiKey}`;
|
|
190
|
+
const response = await fetch(url);
|
|
191
|
+
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const error = process.env.NODE_ENV === 'production'
|
|
194
|
+
? 'Reverse geocoding service unavailable'
|
|
195
|
+
: `Reverse geocoding API returned ${response.status}`;
|
|
196
|
+
throw new Error(error);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const result = await response.json();
|
|
200
|
+
return parseGeocodeResult(result);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const message = error.message || 'Unknown error in reverse geocoding';
|
|
203
|
+
const sanitizedMessage = process.env.NODE_ENV === 'production'
|
|
204
|
+
? 'Failed to reverse geocode coordinates'
|
|
205
|
+
: message;
|
|
206
|
+
|
|
207
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
208
|
+
console.error('xGeocode error:', message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
throw new Error(sanitizedMessage);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Calculate distance between two geographic points
|
|
217
|
+
* @param {number} lat1 - Origin latitude
|
|
218
|
+
* @param {number} lng1 - Origin longitude
|
|
219
|
+
* @param {number} lat2 - Destination latitude
|
|
220
|
+
* @param {number} lng2 - Destination longitude
|
|
221
|
+
* @returns {Object} Distance in kilometers, miles, and meters
|
|
222
|
+
*/
|
|
223
|
+
getDistance(lat1, lng1, lat2, lng2) {
|
|
224
|
+
try {
|
|
225
|
+
const origin = validateCoordinates(lat1, lng1);
|
|
226
|
+
const destination = validateCoordinates(lat2, lng2);
|
|
227
|
+
|
|
228
|
+
return calculateHaversineDistance(
|
|
229
|
+
origin.latitude,
|
|
230
|
+
origin.longitude,
|
|
231
|
+
destination.latitude,
|
|
232
|
+
destination.longitude
|
|
233
|
+
);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
const message = error.message || 'Unknown error calculating distance';
|
|
236
|
+
const sanitizedMessage = process.env.NODE_ENV === 'production'
|
|
237
|
+
? 'Failed to calculate distance'
|
|
238
|
+
: message;
|
|
239
|
+
|
|
240
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
241
|
+
console.error('xGeocode error:', message);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new Error(sanitizedMessage);
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Geocode multiple addresses or ZIP codes in a single operation
|
|
250
|
+
* @param {Array<string>} locations - Array of addresses or ZIP codes
|
|
251
|
+
* @returns {Promise<Array>} Array of geocoded results
|
|
252
|
+
*/
|
|
253
|
+
async batchGeocode(locations) {
|
|
254
|
+
try {
|
|
255
|
+
if (!Array.isArray(locations)) {
|
|
256
|
+
throw new Error('xGeocode: locations must be an array');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (locations.length === 0) {
|
|
260
|
+
throw new Error('xGeocode: locations array cannot be empty');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (locations.length > 100) {
|
|
264
|
+
throw new Error('xGeocode: batch size cannot exceed 100 locations');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const results = await Promise.all(
|
|
268
|
+
locations.map(async (location) => {
|
|
269
|
+
try {
|
|
270
|
+
// Try as ZIP code first, fall back to address
|
|
271
|
+
if (/^\d{5}(-\d{4})?$/.test(location.trim())) {
|
|
272
|
+
return await this.getLatLongByZip(location);
|
|
273
|
+
} else {
|
|
274
|
+
const result = await this.getLatLongByAddress(location);
|
|
275
|
+
return { original: location, ...result };
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return {
|
|
279
|
+
original: location,
|
|
280
|
+
error: error.message,
|
|
281
|
+
success: false
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
return results;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const message = error.message || 'Unknown error in batch geocoding';
|
|
290
|
+
const sanitizedMessage = process.env.NODE_ENV === 'production'
|
|
291
|
+
? 'Failed to batch geocode addresses'
|
|
292
|
+
: message;
|
|
293
|
+
|
|
294
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
295
|
+
console.error('xGeocode error:', message);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
throw new Error(sanitizedMessage);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Validate and get standardized format for an address
|
|
304
|
+
* @param {string} address - The address to validate
|
|
305
|
+
* @returns {Promise<Object>} Object with validity, formatted address, and components
|
|
306
|
+
*/
|
|
307
|
+
async validateAddress(address) {
|
|
308
|
+
try {
|
|
309
|
+
const validAddress = validateAddress(address);
|
|
310
|
+
|
|
311
|
+
// Use geocoding to validate the address
|
|
312
|
+
const url = `${geocodioEndpoint}?q=${encodeURIComponent(validAddress)}&fields=cd,stateleg&api_key=${apiKey}`;
|
|
313
|
+
const response = await fetch(url);
|
|
314
|
+
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
return {
|
|
317
|
+
valid: false,
|
|
318
|
+
input: address,
|
|
319
|
+
error: process.env.NODE_ENV === 'production'
|
|
320
|
+
? 'Validation service unavailable'
|
|
321
|
+
: `API returned ${response.status}`
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const result = await response.json();
|
|
326
|
+
|
|
327
|
+
if (!result.results || result.results.length === 0) {
|
|
328
|
+
return {
|
|
329
|
+
valid: false,
|
|
330
|
+
input: address,
|
|
331
|
+
error: 'Address not recognized'
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const parsed = parseGeocodeResult(result);
|
|
336
|
+
return {
|
|
337
|
+
valid: true,
|
|
338
|
+
input: address,
|
|
339
|
+
formatted: result.results[0].formatted_address,
|
|
340
|
+
confidence: result.results[0].accuracy || 'unknown',
|
|
341
|
+
...parsed
|
|
342
|
+
};
|
|
343
|
+
} catch (error) {
|
|
344
|
+
const message = error.message || 'Unknown error validating address';
|
|
345
|
+
const sanitizedMessage = process.env.NODE_ENV === 'production'
|
|
346
|
+
? 'Failed to validate address'
|
|
347
|
+
: message;
|
|
348
|
+
|
|
349
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
350
|
+
console.error('xGeocode error:', message);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
throw new Error(sanitizedMessage);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
console.info(" ✅ Geocoding Enabled");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export default fp(xGeocode, {
|
|
362
|
+
name: "xGeocode",
|
|
363
|
+
});
|