@forwardslashns/fws-geo-location-api 1.0.0 → 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.
|
@@ -9,6 +9,10 @@ export declare class GeoLocationClient {
|
|
|
9
9
|
private readonly restrictions;
|
|
10
10
|
/** In-memory cache: ISO2 countryCode → Country. Populated on first full country fetch. */
|
|
11
11
|
private readonly countryCache;
|
|
12
|
+
/** True once the full country list has been fetched and cached — avoids re-fetching on every getCountries() call. */
|
|
13
|
+
private countriesFullyCached;
|
|
14
|
+
/** In-memory cache: ISO2 countryCode → full sorted PostalCode[]. Populated by getAllPostalCodes(). */
|
|
15
|
+
private readonly postalCodeCache;
|
|
12
16
|
constructor(options: GeoLocationClientOptions);
|
|
13
17
|
/**
|
|
14
18
|
* Returns a list of countries.
|
|
@@ -61,10 +65,10 @@ export declare class GeoLocationClient {
|
|
|
61
65
|
*
|
|
62
66
|
* Because GeoNames requires at least one search term, a bare country-only
|
|
63
67
|
* query always returns status 15. This method works around that constraint
|
|
64
|
-
* transparently: it fires
|
|
65
|
-
* with `postalcode_startsWith=<char
|
|
66
|
-
*
|
|
67
|
-
* (
|
|
68
|
+
* transparently: it fires all 36 prefix bucket requests in **parallel**,
|
|
69
|
+
* each with `postalcode_startsWith=<char>`. Within each bucket, pages are
|
|
70
|
+
* fetched sequentially until exhausted. This cuts total wall-clock time from
|
|
71
|
+
* `sum(bucket times)` down to `max(bucket time)`.
|
|
68
72
|
*
|
|
69
73
|
* The results are deduplicated and returned as a single flat array sorted by
|
|
70
74
|
* postal code.
|
|
@@ -72,11 +76,16 @@ export declare class GeoLocationClient {
|
|
|
72
76
|
* **API credit cost**: roughly 1 request per 1 000 postal codes in the
|
|
73
77
|
* country, spread across up to 36 prefix buckets. For small countries
|
|
74
78
|
* (< 5 000 codes) expect 10–20 requests; for large ones (US ~43 000) expect
|
|
75
|
-
* ~80 requests.
|
|
79
|
+
* ~80 requests — all fired concurrently.
|
|
76
80
|
*
|
|
77
81
|
* @param countryCode - ISO 3166-1 alpha-2 country code, e.g. `"US"`.
|
|
78
82
|
*/
|
|
79
83
|
getAllPostalCodes(countryCode: string, options?: GetAllPostalCodesOptions): Promise<PostalCode[]>;
|
|
84
|
+
/**
|
|
85
|
+
* Fetches all postal codes for a single alphanumeric prefix bucket,
|
|
86
|
+
* paginating until exhausted.
|
|
87
|
+
*/
|
|
88
|
+
private fetchAllForPrefix;
|
|
80
89
|
/**
|
|
81
90
|
* Returns one page of postal codes for a country, working around the GeoNames
|
|
82
91
|
* per-country query limit that prevents retrieving all codes for large
|
|
@@ -113,7 +122,6 @@ export declare class GeoLocationClient {
|
|
|
113
122
|
*/
|
|
114
123
|
findPostalCode(postalCode: string, countryCode: string): Promise<PostalCode | null>;
|
|
115
124
|
private fetchAllCountries;
|
|
116
|
-
private fetchCountriesByContinent;
|
|
117
125
|
private cacheCountries;
|
|
118
126
|
private resolveCountryName;
|
|
119
127
|
private assertNonEmptyCountryCode;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"geo-location.client.d.ts","sourceRoot":"","sources":["../../src/client/geo-location.client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,wBAAwB,EAExB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,wBAAwB,EACxB,wBAAwB,EACxB,cAAc,EACf,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"geo-location.client.d.ts","sourceRoot":"","sources":["../../src/client/geo-location.client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,wBAAwB,EAExB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,wBAAwB,EACxB,wBAAwB,EACxB,cAAc,EACf,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAsBhE,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAc;IAC1C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA0B;IACvD,0FAA0F;IAC1F,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmC;IAChE,qHAAqH;IACrH,OAAO,CAAC,oBAAoB,CAAS;IACrC,sGAAsG;IACtG,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAwC;gBAErD,OAAO,EAAE,wBAAwB;IASpD;;;;;OAKG;IACU,YAAY,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAyB5E;;;;;;OAMG;IACU,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;IAsC3G;;;;;;OAMG;IACU,SAAS,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;IA+C/G;;;;;;;;;;;;;;;;;;;;OAoBG;IACU,cAAc,CACzB,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;IAuDvC;;;;;;;;;;;;;;;;;;;;OAoBG;IACU,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IAwE9G;;;OAGG;YACW,iBAAiB;IA6B/B;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACU,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,cAAc,CAAC;IAmEjH;;;;;;OAMG;IACU,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;YA8BlF,iBAAiB;IAc/B,OAAO,CAAC,cAAc;YAMR,kBAAkB;IAUhC,OAAO,CAAC,yBAAyB;CAKlC"}
|
|
@@ -14,6 +14,10 @@ class GeoLocationClient {
|
|
|
14
14
|
restrictions;
|
|
15
15
|
/** In-memory cache: ISO2 countryCode → Country. Populated on first full country fetch. */
|
|
16
16
|
countryCache = new Map();
|
|
17
|
+
/** True once the full country list has been fetched and cached — avoids re-fetching on every getCountries() call. */
|
|
18
|
+
countriesFullyCached = false;
|
|
19
|
+
/** In-memory cache: ISO2 countryCode → full sorted PostalCode[]. Populated by getAllPostalCodes(). */
|
|
20
|
+
postalCodeCache = new Map();
|
|
17
21
|
constructor(options) {
|
|
18
22
|
this.httpService = new http_service_js_1.HttpService(options.usernames);
|
|
19
23
|
this.restrictions = options.restrictions ?? {};
|
|
@@ -30,13 +34,12 @@ class GeoLocationClient {
|
|
|
30
34
|
async getCountries(options) {
|
|
31
35
|
const continents = options?.continents ?? this.restrictions.continents;
|
|
32
36
|
const countryCodes = options?.countryCodes ?? this.restrictions.countries;
|
|
33
|
-
|
|
37
|
+
// Always fetch the full list (single request, result is cached).
|
|
38
|
+
// Client-side continent filtering is more reliable than depending on GeoNames
|
|
39
|
+
// honouring the `continent` query parameter for countryInfoJSON.
|
|
40
|
+
let countries = await this.fetchAllCountries();
|
|
34
41
|
if (continents && continents.length > 0) {
|
|
35
|
-
|
|
36
|
-
countries = results.flat();
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
countries = await this.fetchAllCountries();
|
|
42
|
+
countries = countries.filter((c) => continents.includes(c.continent));
|
|
40
43
|
}
|
|
41
44
|
if (countryCodes && countryCodes.length > 0) {
|
|
42
45
|
const upper = countryCodes.map((c) => c.toUpperCase());
|
|
@@ -193,10 +196,10 @@ class GeoLocationClient {
|
|
|
193
196
|
*
|
|
194
197
|
* Because GeoNames requires at least one search term, a bare country-only
|
|
195
198
|
* query always returns status 15. This method works around that constraint
|
|
196
|
-
* transparently: it fires
|
|
197
|
-
* with `postalcode_startsWith=<char
|
|
198
|
-
*
|
|
199
|
-
* (
|
|
199
|
+
* transparently: it fires all 36 prefix bucket requests in **parallel**,
|
|
200
|
+
* each with `postalcode_startsWith=<char>`. Within each bucket, pages are
|
|
201
|
+
* fetched sequentially until exhausted. This cuts total wall-clock time from
|
|
202
|
+
* `sum(bucket times)` down to `max(bucket time)`.
|
|
200
203
|
*
|
|
201
204
|
* The results are deduplicated and returned as a single flat array sorted by
|
|
202
205
|
* postal code.
|
|
@@ -204,51 +207,97 @@ class GeoLocationClient {
|
|
|
204
207
|
* **API credit cost**: roughly 1 request per 1 000 postal codes in the
|
|
205
208
|
* country, spread across up to 36 prefix buckets. For small countries
|
|
206
209
|
* (< 5 000 codes) expect 10–20 requests; for large ones (US ~43 000) expect
|
|
207
|
-
* ~80 requests.
|
|
210
|
+
* ~80 requests — all fired concurrently.
|
|
208
211
|
*
|
|
209
212
|
* @param countryCode - ISO 3166-1 alpha-2 country code, e.g. `"US"`.
|
|
210
213
|
*/
|
|
211
214
|
async getAllPostalCodes(countryCode, options) {
|
|
212
215
|
this.assertNonEmptyCountryCode(countryCode);
|
|
213
216
|
const upper = countryCode.toUpperCase();
|
|
217
|
+
// Return cached result immediately — zero API requests on repeat calls.
|
|
218
|
+
if (this.postalCodeCache.has(upper)) {
|
|
219
|
+
const cached = this.postalCodeCache.get(upper);
|
|
220
|
+
if (options?.onProgress)
|
|
221
|
+
options.onProgress(cached.slice());
|
|
222
|
+
return cached.slice();
|
|
223
|
+
}
|
|
214
224
|
const countryName = await this.resolveCountryName(upper);
|
|
225
|
+
// ── Fast path: try a single unfiltered request first ──────────────────────
|
|
226
|
+
// GeoNames accepts bare country queries for countries with small datasets
|
|
227
|
+
// (roughly < 1 000 postal codes). When the response is smaller than
|
|
228
|
+
// MAX_ROWS_LIMIT we have the complete set in one request instead of 36.
|
|
229
|
+
// For large countries GeoNames returns status 15 — we fall through to the
|
|
230
|
+
// parallel prefix-bucket strategy in that case.
|
|
231
|
+
try {
|
|
232
|
+
const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.POSTAL_CODE_SEARCH}`, { country: upper, maxRows: feature_constants_js_1.MAX_ROWS_LIMIT });
|
|
233
|
+
if (raw.postalCodes.length < feature_constants_js_1.MAX_ROWS_LIMIT) {
|
|
234
|
+
// Got the complete set in a single request.
|
|
235
|
+
const result = raw.postalCodes.map((pc) => (0, postal_code_mapper_js_1.mapPostalCode)(pc, countryName));
|
|
236
|
+
result.sort((a, b) => a.postalCode.localeCompare(b.postalCode, undefined, { numeric: true }));
|
|
237
|
+
this.postalCodeCache.set(upper, result);
|
|
238
|
+
if (options?.onProgress)
|
|
239
|
+
options.onProgress(result.slice());
|
|
240
|
+
return result.slice();
|
|
241
|
+
}
|
|
242
|
+
// Exactly MAX_ROWS_LIMIT results — dataset is larger than one page.
|
|
243
|
+
// Fall through to prefix iteration.
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (!(err instanceof geo_location_error_js_1.GeoLocationError && err.code === api_constants_js_1.GEONAMES_ERROR_CODES.NO_RESULT_FOUND)) {
|
|
247
|
+
throw err; // unexpected error
|
|
248
|
+
}
|
|
249
|
+
// status 15: country dataset is too large for bare query — use prefix iteration.
|
|
250
|
+
}
|
|
251
|
+
// ── Slow path: parallel prefix-bucket iteration ───────────────────────────
|
|
215
252
|
const PREFIXES = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
|
216
253
|
const seen = new Set();
|
|
217
254
|
const all = [];
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
255
|
+
// Fire all prefix buckets in parallel. Each bucket handles its own pagination.
|
|
256
|
+
// onProgress is called (in completion order) as each bucket resolves.
|
|
257
|
+
const bucketPromises = PREFIXES.map((prefix) => this.fetchAllForPrefix(upper, prefix, countryName).then((bucket) => {
|
|
258
|
+
for (const pc of bucket) {
|
|
259
|
+
const key = `${pc.postalCode}|${pc.placeName}|${pc.adminName1}`;
|
|
260
|
+
if (!seen.has(key)) {
|
|
261
|
+
seen.add(key);
|
|
262
|
+
all.push(pc);
|
|
225
263
|
}
|
|
226
|
-
catch (err) {
|
|
227
|
-
if (err instanceof geo_location_error_js_1.GeoLocationError && err.code === api_constants_js_1.GEONAMES_ERROR_CODES.NO_RESULT_FOUND) {
|
|
228
|
-
break; // this prefix has no results — move on
|
|
229
|
-
}
|
|
230
|
-
throw err;
|
|
231
|
-
}
|
|
232
|
-
for (const pc of page) {
|
|
233
|
-
const key = `${pc.postalCode}|${pc.placeName}|${pc.adminName1}`;
|
|
234
|
-
if (!seen.has(key)) {
|
|
235
|
-
seen.add(key);
|
|
236
|
-
all.push(pc);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
if (page.length < feature_constants_js_1.MAX_ROWS_LIMIT)
|
|
240
|
-
break; // last page for this prefix
|
|
241
|
-
startRow += feature_constants_js_1.MAX_ROWS_LIMIT;
|
|
242
264
|
}
|
|
243
|
-
// Notify caller of progress after each prefix bucket
|
|
244
265
|
if (options?.onProgress && all.length > 0) {
|
|
245
266
|
all.sort((a, b) => a.postalCode.localeCompare(b.postalCode, undefined, { numeric: true }));
|
|
246
267
|
options.onProgress(all.slice());
|
|
247
268
|
}
|
|
248
|
-
}
|
|
269
|
+
}));
|
|
270
|
+
await Promise.all(bucketPromises);
|
|
249
271
|
all.sort((a, b) => a.postalCode.localeCompare(b.postalCode, undefined, { numeric: true }));
|
|
272
|
+
this.postalCodeCache.set(upper, all);
|
|
250
273
|
return all;
|
|
251
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Fetches all postal codes for a single alphanumeric prefix bucket,
|
|
277
|
+
* paginating until exhausted.
|
|
278
|
+
*/
|
|
279
|
+
async fetchAllForPrefix(countryCode, prefix, countryName) {
|
|
280
|
+
const bucket = [];
|
|
281
|
+
let startRow = 0;
|
|
282
|
+
while (true) {
|
|
283
|
+
let page;
|
|
284
|
+
try {
|
|
285
|
+
const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.POSTAL_CODE_SEARCH}`, { country: countryCode, postalcode_startsWith: prefix, maxRows: feature_constants_js_1.MAX_ROWS_LIMIT, startRow });
|
|
286
|
+
page = raw.postalCodes.map((pc) => (0, postal_code_mapper_js_1.mapPostalCode)(pc, countryName));
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
if (err instanceof geo_location_error_js_1.GeoLocationError && err.code === api_constants_js_1.GEONAMES_ERROR_CODES.NO_RESULT_FOUND) {
|
|
290
|
+
break; // this prefix has no results — done
|
|
291
|
+
}
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
bucket.push(...page);
|
|
295
|
+
if (page.length < feature_constants_js_1.MAX_ROWS_LIMIT)
|
|
296
|
+
break; // last page for this prefix
|
|
297
|
+
startRow += feature_constants_js_1.MAX_ROWS_LIMIT;
|
|
298
|
+
}
|
|
299
|
+
return bucket;
|
|
300
|
+
}
|
|
252
301
|
/**
|
|
253
302
|
* Returns one page of postal codes for a country, working around the GeoNames
|
|
254
303
|
* per-country query limit that prevents retrieving all codes for large
|
|
@@ -356,15 +405,13 @@ class GeoLocationClient {
|
|
|
356
405
|
// Private helpers
|
|
357
406
|
// ---------------------------------------------------------------------------
|
|
358
407
|
async fetchAllCountries() {
|
|
408
|
+
if (this.countriesFullyCached) {
|
|
409
|
+
return Array.from(this.countryCache.values());
|
|
410
|
+
}
|
|
359
411
|
const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.COUNTRY_INFO}`, {});
|
|
360
412
|
const countries = raw.geonames.map(country_mapper_js_1.mapCountry);
|
|
361
413
|
this.cacheCountries(countries);
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
async fetchCountriesByContinent(continent) {
|
|
365
|
-
const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.COUNTRY_INFO}`, { continent });
|
|
366
|
-
const countries = raw.geonames.map(country_mapper_js_1.mapCountry);
|
|
367
|
-
this.cacheCountries(countries);
|
|
414
|
+
this.countriesFullyCached = true;
|
|
368
415
|
return countries;
|
|
369
416
|
}
|
|
370
417
|
cacheCountries(countries) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardslashns/fws-geo-location-api",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "FWS company dedicated TypeScript wrapper around the GeoNames geolocation API. Provides countries, regions, cities, and postal codes with username rotation and restriction support.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|