@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/dist/maps/mapStyle.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { noTokenAvailable } from '../transport/errors.js';
|
|
2
|
+
import { requestJson } from '../transport/http.js';
|
|
2
3
|
import { labelsByName, languageExpression } from './mapLanguage.js';
|
|
3
4
|
/**
|
|
4
5
|
* Build a map style descriptor URL for the Location Service API.
|
|
@@ -46,38 +47,42 @@ export function buildMapStyleUrl(apiUrl, mapStyle, options = {}) {
|
|
|
46
47
|
* @param mapStyle - Map style name (e.g. 'Standard', 'Monochrome', 'Satellite', 'Hybrid')
|
|
47
48
|
* @param getToken - Callback returning the current auth token
|
|
48
49
|
* @param options - Style options; `language` is applied to the descriptor, all others become URL params
|
|
50
|
+
* @param request - Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
|
|
49
51
|
* @returns Modified MapLibre StyleSpecification object
|
|
50
52
|
*
|
|
51
53
|
* @example
|
|
52
54
|
* const style = await fetchMapStyle(API_URL, 'Standard', getToken, { colorScheme: 'Dark', language: 'fr' })
|
|
53
55
|
* const map = new maplibregl.Map({ style, transformRequest: createTransformRequest(API_URL, getToken) })
|
|
54
56
|
*/
|
|
55
|
-
export async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}) {
|
|
57
|
+
export async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}, request = {}) {
|
|
56
58
|
const { language, ...styleOptions } = options;
|
|
57
59
|
const url = buildMapStyleUrl(apiUrl, mapStyle, styleOptions);
|
|
58
60
|
const token = getToken();
|
|
59
|
-
|
|
61
|
+
// `Bearer undefined` used to go out here, and came back as a 401 the caller
|
|
62
|
+
// had to work backwards from — a whole round trip for a request that was
|
|
63
|
+
// never going to succeed (#37).
|
|
64
|
+
if (!token) {
|
|
65
|
+
throw noTokenAvailable('getToken() returned nothing, so no style request was sent. Check the token provider has finished initialising.');
|
|
66
|
+
}
|
|
67
|
+
// Through the shared transport, not a bare fetch: this gets the same
|
|
68
|
+
// per-attempt timeout, overall budget, cancellation and retry as every other
|
|
69
|
+
// call in the package, and the same error type on the way out. It also keeps
|
|
70
|
+
// the API's own message, which is the whole point of reading the body — for
|
|
71
|
+
// a style request that sentence is Amazon's, forwarded verbatim by
|
|
72
|
+
// 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.
|
|
80
|
+
const style = await requestJson(url, {
|
|
60
81
|
headers: {
|
|
61
82
|
Authorization: `Bearer ${token}`,
|
|
62
83
|
Accept: 'application/json',
|
|
63
84
|
},
|
|
64
|
-
});
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
// Read the body. The API sends `{message, code, requestId}` and the message
|
|
67
|
-
// is the whole point of it — for a style request it is Amazon's own
|
|
68
|
-
// sentence, forwarded verbatim by location-service-api#89:
|
|
69
|
-
//
|
|
70
|
-
// 400 "Traffic is not supported for style."
|
|
71
|
-
// 400 "light is not a supported color scheme for style Standard."
|
|
72
|
-
//
|
|
73
|
-
// This used to throw `Failed to fetch map style: 400`, discarding all of it
|
|
74
|
-
// two lines before anyone could read it — the same defect #89 fixed in the
|
|
75
|
-
// API, one layer up. Reuses parseErrorResponse so a style failure arrives as
|
|
76
|
-
// the same LocationServiceException as every other call in this package,
|
|
77
|
-
// with `code`, `statusCode` and `requestId` intact.
|
|
78
|
-
throw parseErrorResponse(response.status, response.statusText, await response.text(), response.headers);
|
|
79
|
-
}
|
|
80
|
-
const style = (await response.json());
|
|
85
|
+
}, request);
|
|
81
86
|
if (language) {
|
|
82
87
|
applyLanguageToDescriptor(style, language);
|
|
83
88
|
}
|
package/dist/maps/staticMap.d.ts
CHANGED
|
@@ -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>;
|
package/dist/maps/staticMap.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { noTokenAvailable } from '../transport/errors.js';
|
|
2
|
+
import { requestBlob } from '../transport/http.js';
|
|
2
3
|
/**
|
|
3
4
|
* The Accept header this request must send.
|
|
4
5
|
*
|
|
@@ -50,6 +51,7 @@ export function buildStaticMapUrl(apiUrl, options) {
|
|
|
50
51
|
* @param apiUrl Base URL of the Location Service API
|
|
51
52
|
* @param options Render options; exactly one of center / boundingBox / boundedPositions
|
|
52
53
|
* @param getToken Callback returning the current auth token
|
|
54
|
+
* @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
|
|
53
55
|
*
|
|
54
56
|
* @example
|
|
55
57
|
* const blob = await fetchStaticMap(API_URL, {
|
|
@@ -58,19 +60,23 @@ export function buildStaticMapUrl(apiUrl, options) {
|
|
|
58
60
|
* }, getToken)
|
|
59
61
|
* const url = URL.createObjectURL(blob) // remember to revokeObjectURL
|
|
60
62
|
*/
|
|
61
|
-
export async function fetchStaticMap(apiUrl, options, getToken) {
|
|
62
|
-
const
|
|
63
|
+
export async function fetchStaticMap(apiUrl, options, getToken, request = {}) {
|
|
64
|
+
const token = getToken();
|
|
65
|
+
// The same guard as fetchMapStyle and the server connector: a render is not
|
|
66
|
+
// worth requesting without a token to send (#37).
|
|
67
|
+
if (!token) {
|
|
68
|
+
throw noTokenAvailable('getToken() returned nothing, so no static map was requested. Check the token provider has finished initialising.');
|
|
69
|
+
}
|
|
70
|
+
// Through the shared transport, so a static map gets the timeout, budget,
|
|
71
|
+
// cancellation and retry every other call has -- and its failures arrive as
|
|
72
|
+
// LocationServiceException rather than as a raw TypeError. The API's own
|
|
73
|
+
// {message, code, requestId} survives, which matters here: the messages are
|
|
74
|
+
// specific and actionable -- "'width' and 'height' are required", "Only one
|
|
75
|
+
// of center, bounding-box or bounded-positions may be set".
|
|
76
|
+
return requestBlob(buildStaticMapUrl(apiUrl, options), {
|
|
63
77
|
headers: {
|
|
64
|
-
Authorization: `Bearer ${
|
|
78
|
+
Authorization: `Bearer ${token}`,
|
|
65
79
|
Accept: staticMapAccept(options.style),
|
|
66
80
|
},
|
|
67
|
-
});
|
|
68
|
-
if (!response.ok) {
|
|
69
|
-
// Same treatment as fetchMapStyle: the API's {message, code, requestId} is
|
|
70
|
-
// the useful part, and a bare "failed: 400" throws it away. The messages
|
|
71
|
-
// here are specific and actionable -- "'width' and 'height' are required",
|
|
72
|
-
// "Only one of center, bounding-box or bounded-positions may be set".
|
|
73
|
-
throw parseErrorResponse(response.status, response.statusText, await response.text());
|
|
74
|
-
}
|
|
75
|
-
return response.blob();
|
|
81
|
+
}, request);
|
|
76
82
|
}
|
|
@@ -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
|
}
|
|
@@ -1,11 +1,74 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { LocationServiceException } from '../errors/LocationServiceException.js';
|
|
3
3
|
import { resolveEndpoint } from '../transport/endpoints.js';
|
|
4
|
+
import { isTokenRejected, noTokenAvailable } from '../transport/errors.js';
|
|
4
5
|
import { requestJson } from '../transport/http.js';
|
|
5
6
|
import { roundPositionFields } from '../utils/roundPosition.js';
|
|
6
7
|
import { readAppConfigClaims } from '../utils/tokenClaims.js';
|
|
7
|
-
import {
|
|
8
|
+
import { resolveApiUrl, serverTokenSource } from './getClientConfig.js';
|
|
8
9
|
const log = debug('location-client:connector');
|
|
10
|
+
/** Header lookup that does not care how the caller capitalised the name. */
|
|
11
|
+
function headerValue(headers, name) {
|
|
12
|
+
if (!headers)
|
|
13
|
+
return undefined;
|
|
14
|
+
const wanted = name.toLowerCase();
|
|
15
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
16
|
+
if (key.toLowerCase() === wanted)
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The headers `dispatch` sets itself, and which a caller therefore cannot
|
|
23
|
+
* supply. Lower-case, because that is how they are compared.
|
|
24
|
+
*
|
|
25
|
+
* Kept beside `withoutHeaders` so the list and the record in `dispatch` cannot
|
|
26
|
+
* drift apart: a header added there must be added here too, or the caller's own
|
|
27
|
+
* spelling of it survives the merge.
|
|
28
|
+
*/
|
|
29
|
+
const SYSTEM_HEADERS = ['origin', 'content-type', 'authorization'];
|
|
30
|
+
/**
|
|
31
|
+
* What to check when the token source comes up empty. The guard itself is
|
|
32
|
+
* shared with the map fetches (`noTokenAvailable`); only the advice differs,
|
|
33
|
+
* and on this path the answer is always the credentials.
|
|
34
|
+
*/
|
|
35
|
+
const NO_TOKEN_ADVICE = 'check clientId/clientSecret configuration';
|
|
36
|
+
/** The caller's headers minus `names`, however they capitalised them. */
|
|
37
|
+
function withoutHeaders(headers, names) {
|
|
38
|
+
if (!headers)
|
|
39
|
+
return {};
|
|
40
|
+
const wanted = new Set(names);
|
|
41
|
+
return Object.fromEntries(Object.entries(headers).filter(([key]) => !wanted.has(key.toLowerCase())));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Say why the 403 happened, when we know.
|
|
45
|
+
*
|
|
46
|
+
* `Origin not allowed` with no Origin sent is not an ambiguous failure — it is
|
|
47
|
+
* the documented backend path missing one piece of configuration (#45), and the
|
|
48
|
+
* API's own message cannot say so because from its side the header is simply
|
|
49
|
+
* absent. A new integrator following the README hit a bare "Origin not allowed"
|
|
50
|
+
* that named neither the cause nor the fix.
|
|
51
|
+
*/
|
|
52
|
+
function explainMissingOrigin(err, sentOrigin) {
|
|
53
|
+
if (sentOrigin)
|
|
54
|
+
return err;
|
|
55
|
+
if (!(err instanceof LocationServiceException))
|
|
56
|
+
return err;
|
|
57
|
+
if (err.code !== 'OriginNotAllowedException')
|
|
58
|
+
return err;
|
|
59
|
+
return new LocationServiceException({
|
|
60
|
+
code: err.code,
|
|
61
|
+
message: `${err.message} — this request carried no Origin header, and the API requires ` +
|
|
62
|
+
`one it recognises on every data request. Set \`origin\` on the ` +
|
|
63
|
+
`LocationServiceConnector, set LOCATION_ORIGIN, or pass an Origin in the ` +
|
|
64
|
+
`per-call headers; if the application has no allowed domain configured in ` +
|
|
65
|
+
`the developer portal yet, set that first.`,
|
|
66
|
+
statusCode: err.statusCode,
|
|
67
|
+
requestId: err.requestId,
|
|
68
|
+
details: err.details,
|
|
69
|
+
cause: err,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
9
72
|
/**
|
|
10
73
|
* LocationServiceConnector — server-side connector for the Location Service API.
|
|
11
74
|
*
|
|
@@ -13,28 +76,79 @@ const log = debug('location-client:connector');
|
|
|
13
76
|
* token management, and the same transport (timeout, cancellation, retry) as the
|
|
14
77
|
* browser client.
|
|
15
78
|
*
|
|
79
|
+
* Configuration is COMPLETED from the environment rather than replaced by it.
|
|
80
|
+
* The constructor used to be all-or-nothing — any argument at all took the
|
|
81
|
+
* "caller supplies everything" branch — so `new LocationServiceConnector()` had
|
|
82
|
+
* credentials but could never send an Origin (403 on every data request) and
|
|
83
|
+
* `new LocationServiceConnector({ origin })` had an Origin but no credentials
|
|
84
|
+
* (#45). Now an explicit `token`/`getToken` still wins outright, and anything
|
|
85
|
+
* short of that is filled in from `LOCATION_API_URL` / `LOCATION_CLIENT_ID` /
|
|
86
|
+
* `LOCATION_CLIENT_SECRET` / `LOCATION_ORIGIN`.
|
|
87
|
+
*
|
|
16
88
|
* @example
|
|
17
89
|
* ```typescript
|
|
90
|
+
* // Credentials and apiUrl from the environment, Origin supplied here
|
|
18
91
|
* const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
|
|
19
92
|
* const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
|
|
20
93
|
* ```
|
|
21
94
|
*/
|
|
22
95
|
export class LocationServiceConnector {
|
|
23
|
-
constructor(config) {
|
|
96
|
+
constructor(config = {}) {
|
|
24
97
|
this.serviceId = 'Geo Places';
|
|
25
|
-
this.
|
|
26
|
-
|
|
98
|
+
this.config = config;
|
|
99
|
+
// `||`, not `??`, to match the other four env-completed fields: an empty
|
|
100
|
+
// string is not a choice to send no Origin, it is a missing value, and the
|
|
101
|
+
// asymmetry meant `origin: ''` suppressed the environment and then produced
|
|
102
|
+
// an error telling the caller to set a variable they may already have set.
|
|
103
|
+
this.origin =
|
|
104
|
+
config.origin ||
|
|
105
|
+
process.env.LOCATION_ORIGIN ||
|
|
106
|
+
process.env.LOCATION_SERVICE_ORIGIN;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* One place that knows how a token is obtained, so nothing can drift.
|
|
110
|
+
*
|
|
111
|
+
* Built on first use rather than in the constructor: resolving the
|
|
112
|
+
* environment path used to start a `/auth/token` round trip from `new`, whose
|
|
113
|
+
* rejection nothing was awaiting yet — an unhandled rejection for a
|
|
114
|
+
* misconfigured process, before it had made a single request.
|
|
115
|
+
*/
|
|
116
|
+
source() {
|
|
117
|
+
return (this.tokenSource ?? (this.tokenSource = this.buildSource()));
|
|
27
118
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
119
|
+
buildSource() {
|
|
120
|
+
const { apiUrl, token, getToken, clientId, clientSecret } = this.config;
|
|
121
|
+
// An explicit token source wins outright. A caller that supplied one is
|
|
122
|
+
// managing credentials itself, and quietly reading the environment
|
|
123
|
+
// underneath it could send another application's token.
|
|
124
|
+
if (getToken) {
|
|
125
|
+
return {
|
|
126
|
+
apiUrl: () => requireApiUrl(apiUrl),
|
|
127
|
+
get: async (forceRefresh) => {
|
|
128
|
+
const result = await getToken(forceRefresh);
|
|
129
|
+
if (!result)
|
|
130
|
+
return undefined;
|
|
131
|
+
return typeof result === 'string' ? result : result.token;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (token) {
|
|
136
|
+
return {
|
|
137
|
+
apiUrl: () => requireApiUrl(apiUrl),
|
|
138
|
+
// A fixed string. Asking again returns the same one, which is how the
|
|
139
|
+
// retry guard knows there is nothing to retry with.
|
|
140
|
+
get: async () => token,
|
|
141
|
+
};
|
|
36
142
|
}
|
|
37
|
-
|
|
143
|
+
// Nothing but (at most) an apiUrl and an origin — complete it from the
|
|
144
|
+
// environment. This is the branch every sample and doc actually takes.
|
|
145
|
+
const env = serverTokenSource({ apiUrl, clientId, clientSecret });
|
|
146
|
+
return {
|
|
147
|
+
// Already validated by serverTokenSource, which cannot resolve
|
|
148
|
+
// credentials without it.
|
|
149
|
+
apiUrl: () => env.apiUrl,
|
|
150
|
+
get: async (forceRefresh) => (await env.getToken(forceRefresh)).token,
|
|
151
|
+
};
|
|
38
152
|
}
|
|
39
153
|
/**
|
|
40
154
|
* This application's own configuration, as carried on the access token
|
|
@@ -51,34 +165,98 @@ export class LocationServiceConnector {
|
|
|
51
165
|
* case until one is configured in the portal.
|
|
52
166
|
*/
|
|
53
167
|
async getAppConfig() {
|
|
54
|
-
return readAppConfigClaims(await this.
|
|
168
|
+
return readAppConfigClaims(await this.source().get());
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The Origin this request will actually carry.
|
|
172
|
+
*
|
|
173
|
+
* ONE definition, for the two readers that must never disagree about it: the
|
|
174
|
+
* header merge in `dispatch`, and the 403 explanation in `send`. A per-call
|
|
175
|
+
* header beats the connector default, whatever the caller capitalised.
|
|
176
|
+
*/
|
|
177
|
+
effectiveOrigin(options) {
|
|
178
|
+
// `||` for the same reason as the constructor: an empty per-call header is
|
|
179
|
+
// a missing value, not a decision to send no Origin.
|
|
180
|
+
return headerValue(options?.headers, 'origin') || this.origin;
|
|
55
181
|
}
|
|
56
182
|
async send(command, options) {
|
|
57
|
-
const
|
|
58
|
-
if (!token) {
|
|
59
|
-
throw new LocationServiceException({
|
|
60
|
-
code: 'InvalidCredentialsException',
|
|
61
|
-
message: 'No token available — check clientId/clientSecret configuration',
|
|
62
|
-
details: { source: 'client' },
|
|
63
|
-
});
|
|
64
|
-
}
|
|
183
|
+
const source = this.source();
|
|
65
184
|
const cmd = command;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
185
|
+
const url = `${source.apiUrl()}${resolveEndpoint(cmd)}`;
|
|
186
|
+
try {
|
|
187
|
+
return await this.dispatchWithRetry(source, url, cmd, options);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
throw explainMissingOrigin(err, this.effectiveOrigin(options));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async dispatchWithRetry(source, url, cmd, options) {
|
|
194
|
+
const token = await source.get();
|
|
195
|
+
if (!token)
|
|
196
|
+
throw noTokenAvailable(NO_TOKEN_ADVICE);
|
|
197
|
+
try {
|
|
198
|
+
return await this.dispatch(url, token, cmd, options);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (!isTokenRejected(err))
|
|
202
|
+
throw err;
|
|
203
|
+
// One retry, and only when the replacement is genuinely a different
|
|
204
|
+
// token. That single comparison covers every source: a fixed `token`
|
|
205
|
+
// string, a caller `getToken` that ignores `forceRefresh`, and a cached
|
|
206
|
+
// token the API has revoked before its `exp` all hand back what we
|
|
207
|
+
// already sent — and re-sending it would be a second doomed request for
|
|
208
|
+
// the same answer.
|
|
209
|
+
const fresh = await source.get(true);
|
|
210
|
+
if (!fresh || fresh === token)
|
|
211
|
+
throw err;
|
|
212
|
+
log('401 on a token the API no longer accepts — retrying once, refreshed');
|
|
213
|
+
return await this.dispatch(url, fresh, cmd, options);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
dispatch(url, token, cmd, options) {
|
|
217
|
+
// The token is resolved before the request is shaped, so the precision this
|
|
218
|
+
// application is entitled to is available (api#65). Absent claim -> the
|
|
219
|
+
// 3 dp floor, which is what every application gets until one is configured
|
|
220
|
+
// otherwise. Recomputed per attempt because a refreshed token may carry
|
|
221
|
+
// different claims.
|
|
71
222
|
const { biasDecimals } = readAppConfigClaims(token);
|
|
72
223
|
const input = roundPositionFields(cmd.input, biasDecimals);
|
|
73
|
-
//
|
|
74
|
-
//
|
|
224
|
+
// Every system header is set exactly ONCE, and the caller's own spelling of
|
|
225
|
+
// each is dropped first.
|
|
226
|
+
//
|
|
227
|
+
// Spreading them over the caller's record is not enough, because fetch's
|
|
228
|
+
// Headers fill APPENDS rather than replaces: a caller's lowercase key
|
|
229
|
+
// survives beside the canonical one and both go on the wire. For Origin
|
|
230
|
+
// that produced `Origin: default, per-call`, which the API's exact-match
|
|
231
|
+
// domain check rejects (observed: 403 OriginNotAllowedException) — and two
|
|
232
|
+
// keys with the SAME value fared no better, `Origin: x, x`. For
|
|
233
|
+
// Authorization it is worse than a failed override: `{ authorization:
|
|
234
|
+
// 'Bearer not-yours' }` went out as `Bearer not-yours, Bearer <real>`,
|
|
235
|
+
// corrupting the token rather than being ignored by it.
|
|
236
|
+
//
|
|
237
|
+
// Origin's value comes from `effectiveOrigin`, so a per-call header still
|
|
238
|
+
// beats the connector default — it is the DUPLICATE that is removed, not
|
|
239
|
+
// the caller's intent.
|
|
240
|
+
const origin = this.effectiveOrigin(options);
|
|
75
241
|
const headers = {
|
|
76
|
-
...(
|
|
77
|
-
...(
|
|
242
|
+
...withoutHeaders(options?.headers, SYSTEM_HEADERS),
|
|
243
|
+
...(origin ? { Origin: origin } : {}),
|
|
78
244
|
'Content-Type': 'application/json',
|
|
79
245
|
Authorization: `Bearer ${token}`,
|
|
80
246
|
};
|
|
81
|
-
log('Sending %s request to %s', cmd.constructor?.name,
|
|
82
|
-
return requestJson(
|
|
247
|
+
log('Sending %s request to %s', cmd.constructor?.name, url);
|
|
248
|
+
return requestJson(url, { method: 'POST', headers, body: JSON.stringify(input) }, options);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function requireApiUrl(explicit) {
|
|
252
|
+
const apiUrl = resolveApiUrl(explicit);
|
|
253
|
+
if (!apiUrl) {
|
|
254
|
+
throw new LocationServiceException({
|
|
255
|
+
code: 'ValidationException',
|
|
256
|
+
message: 'No API URL. Pass `apiUrl` to the LocationServiceConnector constructor ' +
|
|
257
|
+
'or set LOCATION_API_URL.',
|
|
258
|
+
details: { source: 'client' },
|
|
259
|
+
});
|
|
83
260
|
}
|
|
261
|
+
return apiUrl;
|
|
84
262
|
}
|
|
@@ -1,12 +1,71 @@
|
|
|
1
|
+
import type { TokenResponse } from '../auth/TokenProvider.js';
|
|
1
2
|
import type { ClientConfig } from '../types/index.js';
|
|
2
3
|
export interface ServerAuthConfig {
|
|
3
4
|
apiUrl?: string;
|
|
4
5
|
clientId?: string;
|
|
5
6
|
clientSecret?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Mint a new token instead of returning the cached one.
|
|
9
|
+
*
|
|
10
|
+
* For the case the cache cannot see: a token the API has stopped accepting
|
|
11
|
+
* before its `exp` — revoked in the portal, or issued against a secret that
|
|
12
|
+
* has since been rotated. `TokenProvider` judges freshness from `exp` alone,
|
|
13
|
+
* so without this a caller holding a dead token waits out its whole lifetime.
|
|
14
|
+
*/
|
|
15
|
+
forceRefresh?: boolean;
|
|
6
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* How many applications one process keeps token providers for.
|
|
19
|
+
*
|
|
20
|
+
* A provider holds a URL, a client id, a secret and one cached JWT, so the
|
|
21
|
+
* ceiling is about memory containment rather than a tuned working set — 64 is
|
|
22
|
+
* far above what a single-tenant service needs and far below anything worth
|
|
23
|
+
* worrying about. An agency past it pays a re-mint for the least recently used
|
|
24
|
+
* application, which is exactly the behaviour this replaced, but only for the
|
|
25
|
+
* coldest one instead of for every alternation.
|
|
26
|
+
*/
|
|
27
|
+
export declare const MAX_CACHED_PROVIDERS = 64;
|
|
7
28
|
export interface ServerClientConfig extends ClientConfig {
|
|
29
|
+
/**
|
|
30
|
+
* Narrowed back to required. `ClientConfig.token` is optional because a
|
|
31
|
+
* client can be driven by `getToken`/`refreshToken` alone, but this type is
|
|
32
|
+
* what `getClientConfig` RESOLVES — it either has a token or it threw — and
|
|
33
|
+
* widening it would push a needless `string | undefined` onto every consumer
|
|
34
|
+
* that reads `config.token`.
|
|
35
|
+
*/
|
|
36
|
+
token: string;
|
|
8
37
|
expiresAt?: number;
|
|
9
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Where the API lives, from the argument or the environment.
|
|
41
|
+
*
|
|
42
|
+
* Separate from the credentials because the two are independently overridable:
|
|
43
|
+
* a caller can hand `LocationServiceConnector` its own `token` and still expect
|
|
44
|
+
* `LOCATION_API_URL` to say where to send it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveApiUrl(explicit?: string): string | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* A LIVE token source: resolved credentials plus a `getToken` that re-mints
|
|
49
|
+
* when the cached token is spent.
|
|
50
|
+
*
|
|
51
|
+
* This is the seam `getClientConfig` and `LocationServiceConnector` share, and
|
|
52
|
+
* it exists because the two need different SHAPES of the same thing.
|
|
53
|
+
* `getClientConfig` has to return plain data — see the warning on its return
|
|
54
|
+
* value — so it can only ever hand back a snapshot. The connector is long-lived
|
|
55
|
+
* and needs the source itself, or it dies at the first `exp` (#36).
|
|
56
|
+
*
|
|
57
|
+
* Deliberately not exported from `./server`: it hands out a callable bound to
|
|
58
|
+
* the process-wide provider, and the public surface stays the two functions
|
|
59
|
+
* that were already there.
|
|
60
|
+
*/
|
|
61
|
+
export interface ServerTokenSource {
|
|
62
|
+
apiUrl: string;
|
|
63
|
+
/** Resolves with a token or rejects; it never resolves tokenless. */
|
|
64
|
+
getToken(forceRefresh?: boolean): Promise<TokenResponse & {
|
|
65
|
+
token: string;
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
export declare function serverTokenSource(config?: ServerAuthConfig): ServerTokenSource;
|
|
10
69
|
/**
|
|
11
70
|
* Get client configuration with OAuth2 authentication.
|
|
12
71
|
*
|
|
@@ -26,6 +85,21 @@ export interface ServerClientConfig extends ClientConfig {
|
|
|
26
85
|
* NEVER call from browser/client code as it exposes credentials.
|
|
27
86
|
* For SPA projects, create your own backend endpoint that calls this.
|
|
28
87
|
*
|
|
88
|
+
* ## The return value is PLAIN DATA, and has to stay that way
|
|
89
|
+
*
|
|
90
|
+
* `{ apiUrl, token, expiresAt }` — no methods, no closures. The reason is not
|
|
91
|
+
* style: the shape every sample uses is a Next.js Server Action that returns
|
|
92
|
+
* this straight to a Client Component (every `src/lib/actions/location.ts`
|
|
93
|
+
* under `location-service-samples/web`), and the RSC boundary
|
|
94
|
+
* serialises it. A function on this object is not serialisable and throws at
|
|
95
|
+
* the boundary, so "make getClientConfig return getToken" — which #36 proposed
|
|
96
|
+
* and this JSDoc used to promise two lines below — would break every Next.js
|
|
97
|
+
* consumer of the library.
|
|
98
|
+
*
|
|
99
|
+
* A caller that needs a token which REFRESHES wants one of:
|
|
100
|
+
* - `LocationServiceConnector`, which holds a live source internally (#36), or
|
|
101
|
+
* - `TokenProvider` directly, if it is managing its own lifecycle.
|
|
102
|
+
*
|
|
29
103
|
* @example
|
|
30
104
|
* // Auto-detect from environment
|
|
31
105
|
* const config = await getClientConfig()
|
|
@@ -33,8 +107,7 @@ export interface ServerClientConfig extends ClientConfig {
|
|
|
33
107
|
* // Or override specific values
|
|
34
108
|
* const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
|
|
35
109
|
*
|
|
36
|
-
* //
|
|
37
|
-
* const
|
|
38
|
-
* const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
|
|
110
|
+
* // The API rejected the token before its exp — revoked, or secret rotated
|
|
111
|
+
* const fresh = await getClientConfig({ forceRefresh: true })
|
|
39
112
|
*/
|
|
40
113
|
export declare function getClientConfig(config?: ServerAuthConfig): Promise<ServerClientConfig>;
|