@chaosity/location-client 0.6.0 → 0.8.0
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/README.md +107 -4
- package/dist/cjs/client/GeoPlacesClient.d.ts +13 -2
- package/dist/cjs/client/GeoPlacesClient.js +59 -9
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/maps/createTransformRequest.d.ts +2 -0
- package/dist/cjs/maps/createTransformRequest.js +45 -1
- package/dist/cjs/maps/mapStyle.d.ts +3 -1
- package/dist/cjs/maps/mapStyle.js +24 -19
- package/dist/cjs/maps/staticMap.d.ts +3 -1
- package/dist/cjs/maps/staticMap.js +18 -12
- package/dist/cjs/server/LocationServiceConnector.d.ts +56 -7
- package/dist/cjs/server/LocationServiceConnector.js +210 -32
- package/dist/cjs/server/getClientConfig.d.ts +76 -3
- package/dist/cjs/server/getClientConfig.js +152 -74
- package/dist/cjs/transport/errors.d.ts +30 -0
- package/dist/cjs/transport/errors.js +40 -0
- package/dist/cjs/transport/http.d.ts +36 -0
- package/dist/cjs/transport/http.js +118 -15
- package/dist/cjs/types/index.d.ts +37 -1
- package/dist/client/GeoPlacesClient.d.ts +13 -2
- package/dist/client/GeoPlacesClient.js +59 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/maps/createTransformRequest.d.ts +2 -0
- package/dist/maps/createTransformRequest.js +45 -1
- package/dist/maps/mapStyle.d.ts +3 -1
- package/dist/maps/mapStyle.js +25 -20
- package/dist/maps/staticMap.d.ts +3 -1
- package/dist/maps/staticMap.js +19 -13
- package/dist/server/LocationServiceConnector.d.ts +56 -7
- package/dist/server/LocationServiceConnector.js +211 -33
- package/dist/server/getClientConfig.d.ts +76 -3
- package/dist/server/getClientConfig.js +149 -74
- package/dist/transport/errors.d.ts +30 -0
- package/dist/transport/errors.js +38 -0
- package/dist/transport/http.d.ts +36 -0
- package/dist/transport/http.js +116 -14
- package/dist/types/index.d.ts +37 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,10 @@ Set these environment variables:
|
|
|
69
69
|
LOCATION_API_URL=https://api.chaosity.cloud
|
|
70
70
|
LOCATION_CLIENT_ID=your-client-id
|
|
71
71
|
LOCATION_CLIENT_SECRET=your-client-secret
|
|
72
|
+
|
|
73
|
+
# Only for LocationServiceConnector: the Origin sent on every data request.
|
|
74
|
+
# The API answers 403 without one it recognises — see below.
|
|
75
|
+
LOCATION_ORIGIN=https://your-allowed-domain.example
|
|
72
76
|
```
|
|
73
77
|
|
|
74
78
|
Or pass credentials explicitly:
|
|
@@ -207,6 +211,7 @@ const client = new GeoPlacesClient({
|
|
|
207
211
|
apiUrl: string,
|
|
208
212
|
token: string,
|
|
209
213
|
getToken?: () => string | undefined, // Optional: dynamic token getter
|
|
214
|
+
refreshToken?: () => Promise<string | undefined>, // Optional: 401 self-heal
|
|
210
215
|
})
|
|
211
216
|
|
|
212
217
|
await client.send(command)
|
|
@@ -214,6 +219,64 @@ await client.send(command)
|
|
|
214
219
|
|
|
215
220
|
When `getToken` is provided, it is called on every request so token updates are reflected without recreating the client.
|
|
216
221
|
|
|
222
|
+
`refreshToken` covers the case `getToken` cannot. `getToken` is synchronous —
|
|
223
|
+
MapLibre's `transformRequest` requires that — so it can only ever return the
|
|
224
|
+
token already in hand, and a token the API stops accepting **before** its `exp`
|
|
225
|
+
(revoked, or issued against a since-rotated secret) fails every request until
|
|
226
|
+
the refresh buffer elapses. `refreshToken` is awaited after a 401, and the
|
|
227
|
+
request is retried **once** with what it returns. Return the same token, or
|
|
228
|
+
nothing, and no retry is sent — a request that is going to fail again is not
|
|
229
|
+
worth a second round trip. A 403 is never retried: a new token cannot fix an
|
|
230
|
+
`Origin` the application does not allow.
|
|
231
|
+
|
|
232
|
+
#### Request options
|
|
233
|
+
|
|
234
|
+
Every call in this package takes the same options object — `client.send`,
|
|
235
|
+
`connector.send`, `fetchMapStyle` and `fetchStaticMap` — and every failure
|
|
236
|
+
arrives as a `LocationServiceException`.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
await client.send(command, {
|
|
240
|
+
signal, // AbortSignal — cancels mid-flight AND mid-backoff
|
|
241
|
+
timeoutMs: 10_000, // per ATTEMPT
|
|
242
|
+
overallTimeoutMs: 30_000, // the whole call, waits between attempts included
|
|
243
|
+
retry: { maxAttempts: 3 }, // or `false` for none
|
|
244
|
+
})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`overallTimeoutMs` is the one worth setting deliberately, and it defaults to
|
|
248
|
+
30 s. `timeoutMs` bounds an attempt, not a call: the API answers a spent quota
|
|
249
|
+
with `Retry-After: 60`, and honouring that literally across two retries blocked
|
|
250
|
+
the caller for about two minutes — past any Lambda budget. Now no attempt gets
|
|
251
|
+
more time than the call has left, and a retry that would have to wait longer
|
|
252
|
+
than the remaining budget is not made at all. You get the API's own error back
|
|
253
|
+
instead, `retryAfterMs` intact, so you can queue the work rather than guess.
|
|
254
|
+
|
|
255
|
+
The map helpers take them as a trailing argument:
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
const style = await fetchMapStyle(
|
|
259
|
+
apiUrl,
|
|
260
|
+
'Standard',
|
|
261
|
+
getToken,
|
|
262
|
+
{ language: 'fr' },
|
|
263
|
+
{ signal },
|
|
264
|
+
)
|
|
265
|
+
const blob = await fetchStaticMap(
|
|
266
|
+
apiUrl,
|
|
267
|
+
{ width: 640, height: 400, center },
|
|
268
|
+
getToken,
|
|
269
|
+
{ signal },
|
|
270
|
+
)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**A call with no token is refused locally rather than sent.** Every path that
|
|
274
|
+
builds an `Authorization` header checks first, so a token source that has not
|
|
275
|
+
produced one yet raises `InvalidCredentialsException` instead of putting
|
|
276
|
+
`Bearer undefined` on the wire — which could only ever come back a 401, a round
|
|
277
|
+
trip spent to be told what you already know. `GeoPlacesClient` asks `refreshToken` first, so a
|
|
278
|
+
client whose token simply has not arrived yet still works.
|
|
279
|
+
|
|
217
280
|
#### GeoPlaces Adapter
|
|
218
281
|
|
|
219
282
|
Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
|
|
@@ -351,8 +414,19 @@ import { getClientConfig } from '@chaosity/location-client/server'
|
|
|
351
414
|
|
|
352
415
|
const config = await getClientConfig()
|
|
353
416
|
// { apiUrl: string, token: string, expiresAt?: number }
|
|
417
|
+
|
|
418
|
+
// The API has rejected a token that has not reached its exp — revoked in the
|
|
419
|
+
// portal, or issued against a client secret that has since been rotated.
|
|
420
|
+
const replacement = await getClientConfig({ forceRefresh: true })
|
|
354
421
|
```
|
|
355
422
|
|
|
423
|
+
The return value is **plain data** — no methods, no closures — so it can be
|
|
424
|
+
returned straight out of a Next.js Server Action to a Client Component. It is
|
|
425
|
+
therefore a snapshot: the token in it stops working at its `expiresAt`, and the
|
|
426
|
+
caller asks for another. For a long-lived server process that should just keep
|
|
427
|
+
working, use `LocationServiceConnector`, which holds a live token source and
|
|
428
|
+
refreshes for you.
|
|
429
|
+
|
|
356
430
|
#### TokenProvider
|
|
357
431
|
|
|
358
432
|
Lower-level token management with caching and deduplication.
|
|
@@ -371,14 +445,18 @@ const { success, token, expiresAt } = await provider.getToken()
|
|
|
371
445
|
|
|
372
446
|
#### LocationServiceConnector
|
|
373
447
|
|
|
374
|
-
Server-side connector for backend-to-backend API calls.
|
|
448
|
+
Server-side connector for backend-to-backend API calls. It **completes** its
|
|
449
|
+
configuration from the environment rather than replacing it, so you supply only
|
|
450
|
+
the parts the environment does not have:
|
|
375
451
|
|
|
376
452
|
```typescript
|
|
377
453
|
import { LocationServiceConnector } from '@chaosity/location-client/server'
|
|
378
454
|
|
|
455
|
+
// apiUrl + credentials from LOCATION_API_URL / LOCATION_CLIENT_ID /
|
|
456
|
+
// LOCATION_CLIENT_SECRET; Origin from here. Set LOCATION_ORIGIN as well and
|
|
457
|
+
// `new LocationServiceConnector()` needs no arguments at all.
|
|
379
458
|
const connector = new LocationServiceConnector({
|
|
380
|
-
|
|
381
|
-
token: config.token,
|
|
459
|
+
origin: 'https://your-allowed-domain.example',
|
|
382
460
|
})
|
|
383
461
|
|
|
384
462
|
const result = await connector.send(
|
|
@@ -386,9 +464,34 @@ const result = await connector.send(
|
|
|
386
464
|
)
|
|
387
465
|
```
|
|
388
466
|
|
|
467
|
+
**Every data request needs an `Origin` the service recognises, and the API
|
|
468
|
+
answers 403 without one** — server-to-server calls included, not just browsers.
|
|
469
|
+
In a browser the browser sets it; here you do, with `origin` above,
|
|
470
|
+
`LOCATION_ORIGIN`, or a per-call header (`send(cmd, { headers: { Origin } })`,
|
|
471
|
+
which wins over both). `/auth/token` is the one endpoint exempt.
|
|
472
|
+
|
|
473
|
+
A connector configured this way keeps working indefinitely: it holds a live
|
|
474
|
+
token source, refreshes before expiry, and retries once with a new token if the
|
|
475
|
+
API rejects the one it sent. Pass an explicit `token` instead and you opt out of
|
|
476
|
+
all of that — it is a fixed string, and it dies at its own `exp`:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// Managing credentials yourself: an explicit token source wins outright, and
|
|
480
|
+
// the environment is not consulted.
|
|
481
|
+
const connector = new LocationServiceConnector({
|
|
482
|
+
apiUrl,
|
|
483
|
+
origin: 'https://your-allowed-domain.example',
|
|
484
|
+
getToken: (forceRefresh) => provider.getToken(forceRefresh),
|
|
485
|
+
})
|
|
486
|
+
```
|
|
487
|
+
|
|
389
488
|
## Cache-Friendly Position Rounding
|
|
390
489
|
|
|
391
|
-
`BiasPosition` coordinates are automatically rounded
|
|
490
|
+
`BiasPosition` coordinates are automatically rounded before each API request, to
|
|
491
|
+
whatever precision your application is entitled to — a `biasDecimals` claim on
|
|
492
|
+
the access token, defaulting to **3 decimal places** (~110 m) when the token
|
|
493
|
+
carries none. This maximizes cache hits across nearby users without affecting
|
|
494
|
+
result quality — bias is approximate by nature.
|
|
392
495
|
|
|
393
496
|
`QueryPosition` (reverse geocode) retains full precision since it represents an exact point the user selected.
|
|
394
497
|
|
|
@@ -31,9 +31,20 @@ export declare class GeoPlacesClient {
|
|
|
31
31
|
* case until one is configured in the portal.
|
|
32
32
|
*/
|
|
33
33
|
getAppConfig(): AppConfigClaims;
|
|
34
|
+
/** Prefer the getToken callback (live ref) over a static token string. */
|
|
35
|
+
private currentToken;
|
|
34
36
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
+
* A token to send, or a refusal — never `undefined`.
|
|
38
|
+
*
|
|
39
|
+
* `refreshToken` is asked only when there is nothing at all in hand, so a
|
|
40
|
+
* client configured the ordinary way pays nothing for this.
|
|
41
|
+
*/
|
|
42
|
+
private ensureToken;
|
|
43
|
+
/**
|
|
44
|
+
* @param options `signal` to cancel, `timeoutMs` per attempt,
|
|
45
|
+
* `overallTimeoutMs` for the whole call, `retry: false` to disable the
|
|
46
|
+
* retry loop. Every failure throws LocationServiceException.
|
|
37
47
|
*/
|
|
38
48
|
send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
|
|
49
|
+
private dispatch;
|
|
39
50
|
}
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.GeoPlacesClient = void 0;
|
|
7
7
|
const debug_1 = __importDefault(require("debug"));
|
|
8
8
|
const endpoints_js_1 = require("../transport/endpoints.js");
|
|
9
|
+
const errors_js_1 = require("../transport/errors.js");
|
|
9
10
|
const http_js_1 = require("../transport/http.js");
|
|
10
11
|
const roundPosition_js_1 = require("../utils/roundPosition.js");
|
|
11
12
|
const tokenClaims_js_1 = require("../utils/tokenClaims.js");
|
|
@@ -38,24 +39,73 @@ class GeoPlacesClient {
|
|
|
38
39
|
* case until one is configured in the portal.
|
|
39
40
|
*/
|
|
40
41
|
getAppConfig() {
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
return (0, tokenClaims_js_1.readAppConfigClaims)(this.currentToken());
|
|
43
|
+
}
|
|
44
|
+
/** Prefer the getToken callback (live ref) over a static token string. */
|
|
45
|
+
currentToken() {
|
|
46
|
+
return this.clientConfig.getToken?.() ?? this.clientConfig.token;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A token to send, or a refusal — never `undefined`.
|
|
50
|
+
*
|
|
51
|
+
* `refreshToken` is asked only when there is nothing at all in hand, so a
|
|
52
|
+
* client configured the ordinary way pays nothing for this.
|
|
53
|
+
*/
|
|
54
|
+
async ensureToken() {
|
|
55
|
+
// `||`, not `??`: an empty string is a token source with nothing to give,
|
|
56
|
+
// not a decision to send an empty one. With `??` it survived the coalesce,
|
|
57
|
+
// skipped `refreshToken`, and then failed the check two lines below — so
|
|
58
|
+
// `getToken: () => undefined` got the refresh ask and `getToken: () => ''`
|
|
59
|
+
// did not, which is a distinction no caller means to draw.
|
|
60
|
+
const token = this.currentToken() || (await this.clientConfig.refreshToken?.());
|
|
61
|
+
if (!token) {
|
|
62
|
+
throw (0, errors_js_1.noTokenAvailable)('the client has no token yet. Pass `token`, or a `getToken`/`refreshToken` that has one.');
|
|
63
|
+
}
|
|
64
|
+
return token;
|
|
43
65
|
}
|
|
44
66
|
/**
|
|
45
|
-
* @param options `signal` to cancel, `timeoutMs` per attempt,
|
|
46
|
-
*
|
|
67
|
+
* @param options `signal` to cancel, `timeoutMs` per attempt,
|
|
68
|
+
* `overallTimeoutMs` for the whole call, `retry: false` to disable the
|
|
69
|
+
* retry loop. Every failure throws LocationServiceException.
|
|
47
70
|
*/
|
|
48
71
|
async send(command, options) {
|
|
49
72
|
const cmd = command;
|
|
50
|
-
const
|
|
51
|
-
//
|
|
52
|
-
|
|
73
|
+
const url = `${this.clientConfig.apiUrl}${(0, endpoints_js_1.resolveEndpoint)(cmd)}`;
|
|
74
|
+
// The fifth and last place in this package that turns a token into an
|
|
75
|
+
// `Authorization` header, and the last one that would send `Bearer
|
|
76
|
+
// undefined` (#37). The 401 self-heal below cannot cover this case — it
|
|
77
|
+
// needs a request to have been rejected first — so a client whose token
|
|
78
|
+
// source has not produced one yet spent a whole round trip to learn
|
|
79
|
+
// something it already knew. Ask the refresh source instead, and refuse if
|
|
80
|
+
// there is still nothing.
|
|
81
|
+
const token = await this.ensureToken();
|
|
82
|
+
try {
|
|
83
|
+
return await this.dispatch(url, token, cmd, options);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (!(0, errors_js_1.isTokenRejected)(err))
|
|
87
|
+
throw err;
|
|
88
|
+
// One shot. `refreshToken` is the only way to actually obtain a new
|
|
89
|
+
// token here — `getToken` is synchronous and returns the one already in
|
|
90
|
+
// hand — but it is re-read as a fallback because a provider that
|
|
91
|
+
// refreshes in the background may have landed a new one while this
|
|
92
|
+
// request was in flight.
|
|
93
|
+
const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
|
|
94
|
+
// Nothing new to send. Repeating the request would fail identically — a
|
|
95
|
+
// second round trip for the same 401.
|
|
96
|
+
if (!fresh || fresh === token)
|
|
97
|
+
throw err;
|
|
98
|
+
log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
|
|
99
|
+
return await this.dispatch(url, fresh, cmd, options);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
dispatch(url, token, cmd, options) {
|
|
53
103
|
// Resolve the token BEFORE rounding: the precision this application is
|
|
54
104
|
// entitled to is a claim on it (api#65). Absent claim -> the 3 dp floor.
|
|
55
105
|
const { biasDecimals } = (0, tokenClaims_js_1.readAppConfigClaims)(token);
|
|
56
106
|
const input = (0, roundPosition_js_1.roundPositionFields)(cmd.input, biasDecimals);
|
|
57
|
-
log('Sending %s to %s', cmd.constructor?.name,
|
|
58
|
-
return (0, http_js_1.requestJson)(
|
|
107
|
+
log('Sending %s to %s', cmd.constructor?.name, url);
|
|
108
|
+
return (0, http_js_1.requestJson)(url, {
|
|
59
109
|
method: 'POST',
|
|
60
110
|
headers: {
|
|
61
111
|
'Content-Type': 'application/json',
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { GeoPlacesClient } from './client/GeoPlacesClient.js';
|
|
2
2
|
export type { SendOptions } from './client/GeoPlacesClient.js';
|
|
3
|
-
export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http.js';
|
|
3
|
+
export { DEFAULT_MAX_ATTEMPTS, DEFAULT_OVERALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, } from './transport/http.js';
|
|
4
4
|
export type { RequestOptions } from './transport/http.js';
|
|
5
5
|
export { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './auth/tokenRefresh.js';
|
|
6
6
|
export { LocationServiceException } from './errors/LocationServiceException.js';
|
package/dist/cjs/index.js
CHANGED
|
@@ -14,13 +14,14 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.transformRequest = exports.TRAVEL_MODES = exports.TRAFFIC_MODES = exports.TERRAINS = exports.STATIC_MAP_STYLES = exports.SPRITE_VARIANTS = exports.SCALE_BAR_UNITS = exports.MAP_STYLES = exports.MAP_FEATURE_MODES = exports.LABEL_SIZES = exports.CONTOUR_DENSITIES = exports.COLOR_SCHEMES = exports.BUILDINGS = exports.staticMapAccept = exports.fetchStaticMap = exports.buildStaticMapUrl = exports.fetchMapStyle = exports.buildMapStyleUrl = exports.setPoiVisibility = exports.setAllPoiVisibility = exports.POI_CATEGORIES = exports.applyMapLanguage = exports.createTransformRequest = exports.GeoPlaces = exports.LocationServiceException = exports.readTokenExpiry = exports.TOKEN_REFRESH_BUFFER_SECONDS = exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_MAX_ATTEMPTS = exports.GeoPlacesClient = void 0;
|
|
17
|
+
exports.transformRequest = exports.TRAVEL_MODES = exports.TRAFFIC_MODES = exports.TERRAINS = exports.STATIC_MAP_STYLES = exports.SPRITE_VARIANTS = exports.SCALE_BAR_UNITS = exports.MAP_STYLES = exports.MAP_FEATURE_MODES = exports.LABEL_SIZES = exports.CONTOUR_DENSITIES = exports.COLOR_SCHEMES = exports.BUILDINGS = exports.staticMapAccept = exports.fetchStaticMap = exports.buildStaticMapUrl = exports.fetchMapStyle = exports.buildMapStyleUrl = exports.setPoiVisibility = exports.setAllPoiVisibility = exports.POI_CATEGORIES = exports.applyMapLanguage = exports.createTransformRequest = exports.GeoPlaces = exports.LocationServiceException = exports.readTokenExpiry = exports.TOKEN_REFRESH_BUFFER_SECONDS = exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_OVERALL_TIMEOUT_MS = exports.DEFAULT_MAX_ATTEMPTS = exports.GeoPlacesClient = void 0;
|
|
18
18
|
// Client (Custom - uses our auth instead of AWS SigV4)
|
|
19
19
|
var GeoPlacesClient_js_1 = require("./client/GeoPlacesClient.js");
|
|
20
20
|
Object.defineProperty(exports, "GeoPlacesClient", { enumerable: true, get: function () { return GeoPlacesClient_js_1.GeoPlacesClient; } });
|
|
21
|
-
// Transport options — cancellation, per-attempt timeout, retry
|
|
21
|
+
// Transport options — cancellation, per-attempt timeout, overall budget, retry
|
|
22
22
|
var http_js_1 = require("./transport/http.js");
|
|
23
23
|
Object.defineProperty(exports, "DEFAULT_MAX_ATTEMPTS", { enumerable: true, get: function () { return http_js_1.DEFAULT_MAX_ATTEMPTS; } });
|
|
24
|
+
Object.defineProperty(exports, "DEFAULT_OVERALL_TIMEOUT_MS", { enumerable: true, get: function () { return http_js_1.DEFAULT_OVERALL_TIMEOUT_MS; } });
|
|
24
25
|
Object.defineProperty(exports, "DEFAULT_TIMEOUT_MS", { enumerable: true, get: function () { return http_js_1.DEFAULT_TIMEOUT_MS; } });
|
|
25
26
|
// Token refresh policy — shared by the server provider and the React provider
|
|
26
27
|
var tokenRefresh_js_1 = require("./auth/tokenRefresh.js");
|
|
@@ -3,6 +3,8 @@ import type { RequestTransformFunction } from 'maplibre-gl';
|
|
|
3
3
|
* Creates a transformRequest function for MapLibre that adds authentication
|
|
4
4
|
* and proper Accept headers for AWS Location Service API requests.
|
|
5
5
|
*
|
|
6
|
+
* The token is attached to our own API and nowhere else — see isOurApi.
|
|
7
|
+
*
|
|
6
8
|
* @param apiUrl - Base URL of the Location Service API
|
|
7
9
|
* @param getToken - Callback function that returns the current auth token
|
|
8
10
|
* @returns MapLibre transformRequest function
|
|
@@ -1,17 +1,61 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createTransformRequest = createTransformRequest;
|
|
4
|
+
/**
|
|
5
|
+
* Does this URL belong to our API?
|
|
6
|
+
*
|
|
7
|
+
* This decides who receives the customer's bearer token, and it used to be
|
|
8
|
+
* `url.startsWith(apiUrl)` — a string test standing in for a URL test. For
|
|
9
|
+
* `apiUrl = "https://api.example.com"`, the host `api.example.com.evil.test`
|
|
10
|
+
* is a prefix match, so a style referencing
|
|
11
|
+
* `https://api.example.com.evil.test/tiles/1/2/3` was handed
|
|
12
|
+
* `Authorization: Bearer <token>` and the token left the building (#34).
|
|
13
|
+
*
|
|
14
|
+
* That is reachable because a style descriptor is DATA: its `sprite`, `glyphs`
|
|
15
|
+
* and `sources` entries are URLs the style author chose, and MapLibre asks
|
|
16
|
+
* transformRequest about every one of them. Any style not wholly ours — a
|
|
17
|
+
* customer's own, or one edited through a tool — can name a host it likes.
|
|
18
|
+
*
|
|
19
|
+
* Compared as URLs instead, which also gets host case-folding, default ports
|
|
20
|
+
* (`https://api.test:443` === `https://api.test`) and userinfo right for free,
|
|
21
|
+
* and adds a path check so a shared host serving another tenant under a
|
|
22
|
+
* different base path is not "ours" either.
|
|
23
|
+
*
|
|
24
|
+
* Fails CLOSED: anything that will not parse gets no token. The only way to
|
|
25
|
+
* reach that is a relative `apiUrl` in a runtime with no `location` to resolve
|
|
26
|
+
* it against — i.e. not a browser, which is the only place MapLibre runs.
|
|
27
|
+
*/
|
|
28
|
+
function isOurApi(url, apiUrl) {
|
|
29
|
+
const base = typeof location === 'undefined' ? undefined : location.href;
|
|
30
|
+
let ours;
|
|
31
|
+
let theirs;
|
|
32
|
+
try {
|
|
33
|
+
ours = new URL(apiUrl, base);
|
|
34
|
+
theirs = new URL(url, base);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (theirs.origin !== ours.origin)
|
|
40
|
+
return false;
|
|
41
|
+
// Trailing slashes normalised so `/v1` and `/v1/` behave the same; the `/`
|
|
42
|
+
// boundary is what stops `/v1` from matching `/v1-internal`.
|
|
43
|
+
const basePath = ours.pathname.replace(/\/+$/, '');
|
|
44
|
+
return (theirs.pathname === basePath || theirs.pathname.startsWith(`${basePath}/`));
|
|
45
|
+
}
|
|
4
46
|
/**
|
|
5
47
|
* Creates a transformRequest function for MapLibre that adds authentication
|
|
6
48
|
* and proper Accept headers for AWS Location Service API requests.
|
|
7
49
|
*
|
|
50
|
+
* The token is attached to our own API and nowhere else — see isOurApi.
|
|
51
|
+
*
|
|
8
52
|
* @param apiUrl - Base URL of the Location Service API
|
|
9
53
|
* @param getToken - Callback function that returns the current auth token
|
|
10
54
|
* @returns MapLibre transformRequest function
|
|
11
55
|
*/
|
|
12
56
|
function createTransformRequest(apiUrl, getToken) {
|
|
13
57
|
return (url, _resourceType) => {
|
|
14
|
-
if (url
|
|
58
|
+
if (isOurApi(url, apiUrl)) {
|
|
15
59
|
const token = getToken();
|
|
16
60
|
if (!token) {
|
|
17
61
|
console.warn('[createTransformRequest] No token available');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StyleSpecification } from 'maplibre-gl';
|
|
2
|
+
import type { RequestOptions } from '../transport/http.js';
|
|
2
3
|
import type { Buildings, ColorScheme, ContourDensity, MapStyle, Terrain, TrafficMode, TravelMode } from './mapEnums.js';
|
|
3
4
|
/**
|
|
4
5
|
* Options for building an AWS Location Service map style URL.
|
|
@@ -66,6 +67,7 @@ export declare function buildMapStyleUrl(apiUrl: string, mapStyle: MapStyle, opt
|
|
|
66
67
|
* @param mapStyle - Map style name (e.g. 'Standard', 'Monochrome', 'Satellite', 'Hybrid')
|
|
67
68
|
* @param getToken - Callback returning the current auth token
|
|
68
69
|
* @param options - Style options; `language` is applied to the descriptor, all others become URL params
|
|
70
|
+
* @param request - Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
|
|
69
71
|
* @returns Modified MapLibre StyleSpecification object
|
|
70
72
|
*
|
|
71
73
|
* @example
|
|
@@ -74,4 +76,4 @@ export declare function buildMapStyleUrl(apiUrl: string, mapStyle: MapStyle, opt
|
|
|
74
76
|
*/
|
|
75
77
|
export declare function fetchMapStyle(apiUrl: string, mapStyle: MapStyle, getToken: () => string | undefined, options?: MapStyleOptions & {
|
|
76
78
|
language?: string;
|
|
77
|
-
}): Promise<StyleSpecification>;
|
|
79
|
+
}, request?: RequestOptions): Promise<StyleSpecification>;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.buildMapStyleUrl = buildMapStyleUrl;
|
|
4
4
|
exports.fetchMapStyle = fetchMapStyle;
|
|
5
5
|
const errors_js_1 = require("../transport/errors.js");
|
|
6
|
+
const http_js_1 = require("../transport/http.js");
|
|
6
7
|
const mapLanguage_js_1 = require("./mapLanguage.js");
|
|
7
8
|
/**
|
|
8
9
|
* Build a map style descriptor URL for the Location Service API.
|
|
@@ -50,38 +51,42 @@ function buildMapStyleUrl(apiUrl, mapStyle, options = {}) {
|
|
|
50
51
|
* @param mapStyle - Map style name (e.g. 'Standard', 'Monochrome', 'Satellite', 'Hybrid')
|
|
51
52
|
* @param getToken - Callback returning the current auth token
|
|
52
53
|
* @param options - Style options; `language` is applied to the descriptor, all others become URL params
|
|
54
|
+
* @param request - Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
|
|
53
55
|
* @returns Modified MapLibre StyleSpecification object
|
|
54
56
|
*
|
|
55
57
|
* @example
|
|
56
58
|
* const style = await fetchMapStyle(API_URL, 'Standard', getToken, { colorScheme: 'Dark', language: 'fr' })
|
|
57
59
|
* const map = new maplibregl.Map({ style, transformRequest: createTransformRequest(API_URL, getToken) })
|
|
58
60
|
*/
|
|
59
|
-
async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}) {
|
|
61
|
+
async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}, request = {}) {
|
|
60
62
|
const { language, ...styleOptions } = options;
|
|
61
63
|
const url = buildMapStyleUrl(apiUrl, mapStyle, styleOptions);
|
|
62
64
|
const token = getToken();
|
|
63
|
-
|
|
65
|
+
// `Bearer undefined` used to go out here, and came back as a 401 the caller
|
|
66
|
+
// had to work backwards from — a whole round trip for a request that was
|
|
67
|
+
// never going to succeed (#37).
|
|
68
|
+
if (!token) {
|
|
69
|
+
throw (0, errors_js_1.noTokenAvailable)('getToken() returned nothing, so no style request was sent. Check the token provider has finished initialising.');
|
|
70
|
+
}
|
|
71
|
+
// Through the shared transport, not a bare fetch: this gets the same
|
|
72
|
+
// per-attempt timeout, overall budget, cancellation and retry as every other
|
|
73
|
+
// call in the package, and the same error type on the way out. It also keeps
|
|
74
|
+
// the API's own message, which is the whole point of reading the body — for
|
|
75
|
+
// a style request that sentence is Amazon's, forwarded verbatim by
|
|
76
|
+
// location-service-api#89:
|
|
77
|
+
//
|
|
78
|
+
// 400 "Traffic is not supported for style."
|
|
79
|
+
// 400 "light is not a supported color scheme for style Standard."
|
|
80
|
+
//
|
|
81
|
+
// This used to throw `Failed to fetch map style: 400`, discarding all of it
|
|
82
|
+
// two lines before anyone could read it — the same defect #89 fixed in the
|
|
83
|
+
// API, one layer up.
|
|
84
|
+
const style = await (0, http_js_1.requestJson)(url, {
|
|
64
85
|
headers: {
|
|
65
86
|
Authorization: `Bearer ${token}`,
|
|
66
87
|
Accept: 'application/json',
|
|
67
88
|
},
|
|
68
|
-
});
|
|
69
|
-
if (!response.ok) {
|
|
70
|
-
// Read the body. The API sends `{message, code, requestId}` and the message
|
|
71
|
-
// is the whole point of it — for a style request it is Amazon's own
|
|
72
|
-
// sentence, forwarded verbatim by location-service-api#89:
|
|
73
|
-
//
|
|
74
|
-
// 400 "Traffic is not supported for style."
|
|
75
|
-
// 400 "light is not a supported color scheme for style Standard."
|
|
76
|
-
//
|
|
77
|
-
// This used to throw `Failed to fetch map style: 400`, discarding all of it
|
|
78
|
-
// two lines before anyone could read it — the same defect #89 fixed in the
|
|
79
|
-
// API, one layer up. Reuses parseErrorResponse so a style failure arrives as
|
|
80
|
-
// the same LocationServiceException as every other call in this package,
|
|
81
|
-
// with `code`, `statusCode` and `requestId` intact.
|
|
82
|
-
throw (0, errors_js_1.parseErrorResponse)(response.status, response.statusText, await response.text(), response.headers);
|
|
83
|
-
}
|
|
84
|
-
const style = (await response.json());
|
|
89
|
+
}, request);
|
|
85
90
|
if (language) {
|
|
86
91
|
applyLanguageToDescriptor(style, language);
|
|
87
92
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RequestOptions } from '../transport/http.js';
|
|
1
2
|
import type { ColorScheme, LabelSize, MapFeatureMode, ScaleBarUnit, StaticMapStyle } from './mapEnums.js';
|
|
2
3
|
/**
|
|
3
4
|
* Static maps: build the URL, send the right headers, get a Blob.
|
|
@@ -74,6 +75,7 @@ export declare function buildStaticMapUrl(apiUrl: string, options: StaticMapOpti
|
|
|
74
75
|
* @param apiUrl Base URL of the Location Service API
|
|
75
76
|
* @param options Render options; exactly one of center / boundingBox / boundedPositions
|
|
76
77
|
* @param getToken Callback returning the current auth token
|
|
78
|
+
* @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
|
|
77
79
|
*
|
|
78
80
|
* @example
|
|
79
81
|
* const blob = await fetchStaticMap(API_URL, {
|
|
@@ -82,4 +84,4 @@ export declare function buildStaticMapUrl(apiUrl: string, options: StaticMapOpti
|
|
|
82
84
|
* }, getToken)
|
|
83
85
|
* const url = URL.createObjectURL(blob) // remember to revokeObjectURL
|
|
84
86
|
*/
|
|
85
|
-
export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions, getToken: () => string | undefined): Promise<Blob>;
|
|
87
|
+
export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions, getToken: () => string | undefined, request?: RequestOptions): Promise<Blob>;
|
|
@@ -4,6 +4,7 @@ exports.staticMapAccept = staticMapAccept;
|
|
|
4
4
|
exports.buildStaticMapUrl = buildStaticMapUrl;
|
|
5
5
|
exports.fetchStaticMap = fetchStaticMap;
|
|
6
6
|
const errors_js_1 = require("../transport/errors.js");
|
|
7
|
+
const http_js_1 = require("../transport/http.js");
|
|
7
8
|
/**
|
|
8
9
|
* The Accept header this request must send.
|
|
9
10
|
*
|
|
@@ -55,6 +56,7 @@ function buildStaticMapUrl(apiUrl, options) {
|
|
|
55
56
|
* @param apiUrl Base URL of the Location Service API
|
|
56
57
|
* @param options Render options; exactly one of center / boundingBox / boundedPositions
|
|
57
58
|
* @param getToken Callback returning the current auth token
|
|
59
|
+
* @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
|
|
58
60
|
*
|
|
59
61
|
* @example
|
|
60
62
|
* const blob = await fetchStaticMap(API_URL, {
|
|
@@ -63,19 +65,23 @@ function buildStaticMapUrl(apiUrl, options) {
|
|
|
63
65
|
* }, getToken)
|
|
64
66
|
* const url = URL.createObjectURL(blob) // remember to revokeObjectURL
|
|
65
67
|
*/
|
|
66
|
-
async function fetchStaticMap(apiUrl, options, getToken) {
|
|
67
|
-
const
|
|
68
|
+
async function fetchStaticMap(apiUrl, options, getToken, request = {}) {
|
|
69
|
+
const token = getToken();
|
|
70
|
+
// The same guard as fetchMapStyle and the server connector: a render is not
|
|
71
|
+
// worth requesting without a token to send (#37).
|
|
72
|
+
if (!token) {
|
|
73
|
+
throw (0, errors_js_1.noTokenAvailable)('getToken() returned nothing, so no static map was requested. Check the token provider has finished initialising.');
|
|
74
|
+
}
|
|
75
|
+
// Through the shared transport, so a static map gets the timeout, budget,
|
|
76
|
+
// cancellation and retry every other call has -- and its failures arrive as
|
|
77
|
+
// LocationServiceException rather than as a raw TypeError. The API's own
|
|
78
|
+
// {message, code, requestId} survives, which matters here: the messages are
|
|
79
|
+
// specific and actionable -- "'width' and 'height' are required", "Only one
|
|
80
|
+
// of center, bounding-box or bounded-positions may be set".
|
|
81
|
+
return (0, http_js_1.requestBlob)(buildStaticMapUrl(apiUrl, options), {
|
|
68
82
|
headers: {
|
|
69
|
-
Authorization: `Bearer ${
|
|
83
|
+
Authorization: `Bearer ${token}`,
|
|
70
84
|
Accept: staticMapAccept(options.style),
|
|
71
85
|
},
|
|
72
|
-
});
|
|
73
|
-
if (!response.ok) {
|
|
74
|
-
// Same treatment as fetchMapStyle: the API's {message, code, requestId} is
|
|
75
|
-
// the useful part, and a bare "failed: 400" throws it away. The messages
|
|
76
|
-
// here are specific and actionable -- "'width' and 'height' are required",
|
|
77
|
-
// "Only one of center, bounding-box or bounded-positions may be set".
|
|
78
|
-
throw (0, errors_js_1.parseErrorResponse)(response.status, response.statusText, await response.text());
|
|
79
|
-
}
|
|
80
|
-
return response.blob();
|
|
86
|
+
}, request);
|
|
81
87
|
}
|
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
import type { RequestOptions } from '../transport/http.js';
|
|
2
2
|
import type { AppConfigClaims } from '../utils/tokenClaims.js';
|
|
3
3
|
export interface ConnectorConfig {
|
|
4
|
+
/** Falls back to `LOCATION_API_URL` / `LOCATION_SERVICE_API_URL`. */
|
|
4
5
|
apiUrl?: string;
|
|
6
|
+
/**
|
|
7
|
+
* A fixed token. Nothing can refresh it, so it dies at its own `exp` — pass
|
|
8
|
+
* `getToken` instead, or nothing at all, for a connector that keeps working.
|
|
9
|
+
*/
|
|
5
10
|
token?: string;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Where a token comes from. Called for every request, so this is what makes
|
|
13
|
+
* a long-lived connector survive expiry.
|
|
14
|
+
*
|
|
15
|
+
* `forceRefresh` is passed as `true` when the API has just rejected the token
|
|
16
|
+
* this returned — the signature is `TokenProvider.getToken`'s exactly, so
|
|
17
|
+
* `getToken: (f) => provider.getToken(f)` is a complete implementation.
|
|
18
|
+
*/
|
|
19
|
+
getToken?: (forceRefresh?: boolean) => Promise<string | {
|
|
20
|
+
token?: string;
|
|
21
|
+
} | undefined>;
|
|
22
|
+
/** Falls back to `LOCATION_CLIENT_ID` / `LOCATION_SERVICE_CLIENT_ID`. */
|
|
23
|
+
clientId?: string;
|
|
24
|
+
/** Falls back to `LOCATION_CLIENT_SECRET` / `LOCATION_SERVICE_CLIENT_SECRET`. */
|
|
25
|
+
clientSecret?: string;
|
|
9
26
|
/**
|
|
10
27
|
* Sent as the `Origin` header on every request. The API requires an Origin it
|
|
11
28
|
* recognises on every data request and answers 403 without one, so every
|
|
12
29
|
* caller was setting it by hand on each `send`; this does it once.
|
|
30
|
+
*
|
|
31
|
+
* Falls back to `LOCATION_ORIGIN` / `LOCATION_SERVICE_ORIGIN`, so the
|
|
32
|
+
* zero-argument connector can be made to work from the environment alone.
|
|
13
33
|
*/
|
|
14
34
|
origin?: string;
|
|
15
35
|
}
|
|
@@ -23,19 +43,38 @@ export interface SendOptions extends RequestOptions {
|
|
|
23
43
|
* token management, and the same transport (timeout, cancellation, retry) as the
|
|
24
44
|
* browser client.
|
|
25
45
|
*
|
|
46
|
+
* Configuration is COMPLETED from the environment rather than replaced by it.
|
|
47
|
+
* The constructor used to be all-or-nothing — any argument at all took the
|
|
48
|
+
* "caller supplies everything" branch — so `new LocationServiceConnector()` had
|
|
49
|
+
* credentials but could never send an Origin (403 on every data request) and
|
|
50
|
+
* `new LocationServiceConnector({ origin })` had an Origin but no credentials
|
|
51
|
+
* (#45). Now an explicit `token`/`getToken` still wins outright, and anything
|
|
52
|
+
* short of that is filled in from `LOCATION_API_URL` / `LOCATION_CLIENT_ID` /
|
|
53
|
+
* `LOCATION_CLIENT_SECRET` / `LOCATION_ORIGIN`.
|
|
54
|
+
*
|
|
26
55
|
* @example
|
|
27
56
|
* ```typescript
|
|
57
|
+
* // Credentials and apiUrl from the environment, Origin supplied here
|
|
28
58
|
* const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
|
|
29
59
|
* const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
|
|
30
60
|
* ```
|
|
31
61
|
*/
|
|
32
62
|
export declare class LocationServiceConnector {
|
|
33
|
-
private
|
|
34
|
-
private
|
|
63
|
+
private readonly config;
|
|
64
|
+
private tokenSource?;
|
|
65
|
+
private readonly origin?;
|
|
35
66
|
readonly serviceId: string;
|
|
36
67
|
constructor(config?: ConnectorConfig);
|
|
37
|
-
/**
|
|
38
|
-
|
|
68
|
+
/**
|
|
69
|
+
* One place that knows how a token is obtained, so nothing can drift.
|
|
70
|
+
*
|
|
71
|
+
* Built on first use rather than in the constructor: resolving the
|
|
72
|
+
* environment path used to start a `/auth/token` round trip from `new`, whose
|
|
73
|
+
* rejection nothing was awaiting yet — an unhandled rejection for a
|
|
74
|
+
* misconfigured process, before it had made a single request.
|
|
75
|
+
*/
|
|
76
|
+
private source;
|
|
77
|
+
private buildSource;
|
|
39
78
|
/**
|
|
40
79
|
* This application's own configuration, as carried on the access token
|
|
41
80
|
* (api#65) — bias precision, and the countries it is scoped to.
|
|
@@ -51,5 +90,15 @@ export declare class LocationServiceConnector {
|
|
|
51
90
|
* case until one is configured in the portal.
|
|
52
91
|
*/
|
|
53
92
|
getAppConfig(): Promise<AppConfigClaims>;
|
|
93
|
+
/**
|
|
94
|
+
* The Origin this request will actually carry.
|
|
95
|
+
*
|
|
96
|
+
* ONE definition, for the two readers that must never disagree about it: the
|
|
97
|
+
* header merge in `dispatch`, and the 403 explanation in `send`. A per-call
|
|
98
|
+
* header beats the connector default, whatever the caller capitalised.
|
|
99
|
+
*/
|
|
100
|
+
private effectiveOrigin;
|
|
54
101
|
send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
|
|
102
|
+
private dispatchWithRetry;
|
|
103
|
+
private dispatch;
|
|
55
104
|
}
|