@1dex-fr/connector 0.2.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/src/index.js ADDED
@@ -0,0 +1,722 @@
1
+ const DEFAULT_BASE_URL = 'https://1dex.fr';
2
+ const RETRYABLE_STATUSES = new Set([202, 429, 503]);
3
+ const PUBLIC_MAP_LAYERS = new Set([
4
+ 'context',
5
+ 'iris',
6
+ 'parcelles',
7
+ 'parcelles_dvf',
8
+ 'parcelles_travaux',
9
+ 'parcelles_labels',
10
+ ]);
11
+ const PUBLIC_MAP_LAYER_ALIASES = Object.freeze({
12
+ dvf: 'parcelles_dvf',
13
+ travaux: 'parcelles_travaux',
14
+ labels: 'parcelles_labels',
15
+ });
16
+
17
+ export class OneDexApiError extends Error {
18
+ constructor(message, options = {}) {
19
+ super(message);
20
+ this.name = 'OneDexApiError';
21
+ this.status = options.status ?? 0;
22
+ this.body = options.body;
23
+ this.requestId = options.requestId ?? null;
24
+ this.headers = options.headers ?? {};
25
+ this.retryable = options.retryable ?? false;
26
+ this.retryAfterSeconds = options.retryAfterSeconds ?? null;
27
+ this.code = options.code ?? null;
28
+ }
29
+ }
30
+
31
+ function normalizeBaseUrl(baseUrl) {
32
+ const value = (baseUrl ?? DEFAULT_BASE_URL).trim();
33
+ if (!value) {
34
+ throw new TypeError('baseUrl must not be empty.');
35
+ }
36
+ return value.replace(/\/+$/, '').replace(/\/api\/v1$/, '');
37
+ }
38
+
39
+ function normalizeHeaders(headers) {
40
+ if (!headers) {
41
+ return {};
42
+ }
43
+ return Object.fromEntries(
44
+ Object.entries(headers)
45
+ .filter(([, value]) => value !== undefined && value !== null)
46
+ .map(([key, value]) => [key, String(value)]),
47
+ );
48
+ }
49
+
50
+ function readHeader(headers, name) {
51
+ const normalizedName = name.toLowerCase();
52
+ return Object.entries(headers).find(([key]) => key.toLowerCase() === normalizedName)?.[1];
53
+ }
54
+
55
+ function setHeader(headers, name, value) {
56
+ const normalizedName = name.toLowerCase();
57
+ for (const key of Object.keys(headers)) {
58
+ if (key.toLowerCase() === normalizedName) {
59
+ delete headers[key];
60
+ }
61
+ }
62
+ headers[name] = value;
63
+ }
64
+
65
+ function normalizeIdempotencyKey(value, name = 'idempotencyKey') {
66
+ if (typeof value !== 'string' || value.length === 0) {
67
+ throw new TypeError(`${name} must be a non-empty string.`);
68
+ }
69
+ if (value !== value.trim() || /[\u0000-\u001f\u007f]/u.test(value)) {
70
+ throw new TypeError(`${name} must not contain surrounding whitespace or control characters.`);
71
+ }
72
+ if (new TextEncoder().encode(value).byteLength > 255) {
73
+ throw new TypeError(`${name} must be at most 255 UTF-8 bytes.`);
74
+ }
75
+ return value;
76
+ }
77
+
78
+ function splitIdempotentInput(input, options, name) {
79
+ const {
80
+ idempotencyKey,
81
+ idempotency_key: idempotencyKeySnake,
82
+ ...payload
83
+ } = input;
84
+ const normalizedHeaders = normalizeHeaders(options.headers);
85
+ const headerKey = readHeader(normalizedHeaders, 'idempotency-key');
86
+ const candidates = [idempotencyKey, idempotencyKeySnake, options.idempotencyKey, headerKey]
87
+ .filter((value) => value !== undefined && value !== null)
88
+ .map((value) => normalizeIdempotencyKey(value, `${name} idempotency key`));
89
+ if (candidates.length === 0) {
90
+ throw new TypeError(`${name} requires idempotencyKey.`);
91
+ }
92
+ const [key] = candidates;
93
+ if (candidates.some((candidate) => candidate !== key)) {
94
+ throw new TypeError(`${name} received conflicting idempotency keys.`);
95
+ }
96
+ setHeader(normalizedHeaders, 'Idempotency-Key', key);
97
+ return {
98
+ payload,
99
+ options: {
100
+ ...options,
101
+ idempotencyKey: undefined,
102
+ headers: normalizedHeaders,
103
+ },
104
+ };
105
+ }
106
+
107
+ function normalizeRetryPolicy(retry) {
108
+ if (!retry) {
109
+ return { maxAttempts: 1, maxDelayMs: Number.POSITIVE_INFINITY };
110
+ }
111
+ const policy = retry === true ? {} : retry;
112
+ if (typeof policy !== 'object' || policy === null || Array.isArray(policy)) {
113
+ throw new TypeError('retry must be true or an options object.');
114
+ }
115
+ const maxAttempts = policy.maxAttempts ?? 3;
116
+ const maxDelayMs = policy.maxDelayMs ?? Number.POSITIVE_INFINITY;
117
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10) {
118
+ throw new TypeError('retry.maxAttempts must be an integer between 1 and 10.');
119
+ }
120
+ if (typeof maxDelayMs !== 'number' || Number.isNaN(maxDelayMs) || maxDelayMs < 0) {
121
+ throw new TypeError('retry.maxDelayMs must be a non-negative number.');
122
+ }
123
+ return { maxAttempts, maxDelayMs };
124
+ }
125
+
126
+ function parseRetryAfterSeconds(body, headers) {
127
+ const rawHeader = headers.get('retry-after');
128
+ if (rawHeader) {
129
+ const seconds = Number(rawHeader);
130
+ if (Number.isFinite(seconds) && seconds >= 0) {
131
+ return Math.ceil(seconds);
132
+ }
133
+ const retryAt = Date.parse(rawHeader);
134
+ if (Number.isFinite(retryAt)) {
135
+ return Math.max(0, Math.ceil((retryAt - Date.now()) / 1_000));
136
+ }
137
+ }
138
+ const bodySeconds = Number(body?.retry_after_seconds);
139
+ return Number.isFinite(bodySeconds) && bodySeconds >= 0 ? Math.ceil(bodySeconds) : null;
140
+ }
141
+
142
+ function retryDelayMs(error, policy) {
143
+ const requestedDelayMs = (error.retryAfterSeconds ?? 1) * 1_000;
144
+ // A client wait budget must never shorten the server's backoff deadline.
145
+ return requestedDelayMs <= Math.min(policy.maxDelayMs, 2_147_483_647) ? requestedDelayMs : null;
146
+ }
147
+
148
+ function waitForRetry(delayMs, signal) {
149
+ if (signal?.aborted) {
150
+ return Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
151
+ }
152
+ if (delayMs <= 0) {
153
+ return Promise.resolve();
154
+ }
155
+ return new Promise((resolve, reject) => {
156
+ const timer = setTimeout(() => {
157
+ signal?.removeEventListener('abort', abort);
158
+ resolve();
159
+ }, delayMs);
160
+ const abort = () => {
161
+ clearTimeout(timer);
162
+ signal?.removeEventListener('abort', abort);
163
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
164
+ };
165
+ signal?.addEventListener('abort', abort, { once: true });
166
+ });
167
+ }
168
+
169
+ function normalizeDetailsPath(baseUrl, detailsUrl) {
170
+ const value = assertNonEmptyString(detailsUrl, 'detailsUrl');
171
+ const base = new URL(baseUrl);
172
+ const resolved = new URL(value, `${baseUrl}/`);
173
+ if (resolved.origin !== base.origin || resolved.pathname !== '/api/v1/address-details') {
174
+ throw new TypeError('detailsUrl must target /api/v1/address-details on the configured 1dex origin.');
175
+ }
176
+ return `${resolved.pathname}${resolved.search}`;
177
+ }
178
+
179
+ function assertObject(value, name) {
180
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
181
+ throw new TypeError(`${name} must be an object.`);
182
+ }
183
+ }
184
+
185
+ function assertNonEmptyString(value, name) {
186
+ if (typeof value !== 'string' || value.trim() === '') {
187
+ throw new TypeError(`${name} must be a non-empty string.`);
188
+ }
189
+ return value.trim();
190
+ }
191
+
192
+ function hasCoordinates(query) {
193
+ return query.lon !== undefined && query.lon !== null && query.lat !== undefined && query.lat !== null;
194
+ }
195
+
196
+ function hasAddressLocator(query) {
197
+ return (
198
+ (typeof query.address === 'string' && query.address.trim() !== '')
199
+ || (typeof query.normalized_address_key === 'string' && query.normalized_address_key.trim() !== '')
200
+ || (typeof query.parcel_record_key === 'string' && query.parcel_record_key.trim() !== '')
201
+ || hasCoordinates(query)
202
+ );
203
+ }
204
+
205
+ function hasNormalizedAddressKey(query) {
206
+ return typeof query.normalized_address_key === 'string' && query.normalized_address_key.trim() !== '';
207
+ }
208
+
209
+ function hasResolvedAddressLocator(query) {
210
+ return (
211
+ (typeof query.address === 'string' && query.address.trim() !== '')
212
+ || (typeof query.parcel_record_key === 'string' && query.parcel_record_key.trim() !== '')
213
+ || hasCoordinates(query)
214
+ );
215
+ }
216
+
217
+ function assertNormalizedAddressKeyIsAlone(query, name) {
218
+ if (hasNormalizedAddressKey(query) && (hasResolvedAddressLocator(query) || query.city_code)) {
219
+ throw new TypeError(`${name} must use normalizedAddressKey alone, without address, cityCode, parcelRecordKey, or lon/lat.`);
220
+ }
221
+ }
222
+
223
+ function normalizeAddressLocator(input) {
224
+ const {
225
+ cityCode,
226
+ city_code: cityCodeSnake,
227
+ normalizedAddressKey,
228
+ normalized_address_key: normalizedAddressKeySnake,
229
+ parcelRecordKey,
230
+ parcel_record_key: parcelRecordKeySnake,
231
+ ...query
232
+ } = input;
233
+
234
+ return {
235
+ ...query,
236
+ city_code: cityCodeSnake ?? cityCode,
237
+ normalized_address_key: normalizedAddressKeySnake ?? normalizedAddressKey,
238
+ parcel_record_key: parcelRecordKeySnake ?? parcelRecordKey,
239
+ };
240
+ }
241
+
242
+ function normalizeCsvList(value, name) {
243
+ if (Array.isArray(value)) {
244
+ const items = value.map((item) => String(item).trim()).filter(Boolean);
245
+ if (items.length === 0) {
246
+ throw new TypeError(`${name} must not be empty.`);
247
+ }
248
+ return items.join(',');
249
+ }
250
+ return assertNonEmptyString(value, name);
251
+ }
252
+
253
+ function normalizeApiKeyHeader(apiKey) {
254
+ if (apiKey === undefined || apiKey === null || String(apiKey).trim() === '') {
255
+ return {};
256
+ }
257
+ return { authorization: `Bearer ${String(apiKey).trim()}` };
258
+ }
259
+
260
+ function appendQuery(path, query) {
261
+ const params = new URLSearchParams();
262
+ for (const [key, value] of Object.entries(query)) {
263
+ if (value === undefined || value === null || value === '') {
264
+ continue;
265
+ }
266
+ params.set(key, String(value));
267
+ }
268
+ const serialized = params.toString();
269
+ return serialized ? `${path}?${serialized}` : path;
270
+ }
271
+
272
+ function normalizeMapLayer(layer) {
273
+ const normalized = String(layer ?? '').trim();
274
+ const layerKey = PUBLIC_MAP_LAYER_ALIASES[normalized] ?? normalized;
275
+ if (!PUBLIC_MAP_LAYERS.has(layerKey)) {
276
+ throw new TypeError(`Unsupported public map layer: ${normalized || '(empty)'}.`);
277
+ }
278
+ return layerKey;
279
+ }
280
+
281
+ function toMapLayerQuery(input, defaultLayer = 'parcelles') {
282
+ assertObject(input, 'map layer input');
283
+ const {
284
+ address,
285
+ addressSlug,
286
+ address_slug: addressSlugSnake,
287
+ city_code: cityCodeSnake,
288
+ cityCode: cityCodeCamel,
289
+ layer,
290
+ layerKey,
291
+ layer_key: layerKeySnake,
292
+ lon,
293
+ lat,
294
+ ...query
295
+ } = input;
296
+ const normalizedLayer = normalizeMapLayer(layer ?? layerKey ?? layerKeySnake ?? defaultLayer);
297
+
298
+ const cityCode = cityCodeSnake ?? cityCodeCamel;
299
+ if (typeof address === 'string' && address.trim() !== '') {
300
+ return {
301
+ path: `/api/v1/map-layer/${encodeURIComponent(normalizedLayer)}`,
302
+ query: {
303
+ address: address.trim(),
304
+ city_code: cityCode,
305
+ lon,
306
+ lat,
307
+ ...query,
308
+ },
309
+ };
310
+ }
311
+
312
+ if ((lon !== undefined && lat !== undefined) || (typeof cityCode === 'string' && cityCode.trim() !== '')) {
313
+ return {
314
+ path: `/api/v1/map-layer/${encodeURIComponent(normalizedLayer)}`,
315
+ query: {
316
+ city_code: typeof cityCode === 'string' && cityCode.trim() !== '' ? cityCode.trim() : undefined,
317
+ lon,
318
+ lat,
319
+ ...query,
320
+ },
321
+ };
322
+ }
323
+
324
+ const slug = addressSlug ?? addressSlugSnake;
325
+ if (typeof slug !== 'string' || slug.trim() === '') {
326
+ throw new TypeError('map layer input requires address, city_code, lon/lat, or addressSlug.');
327
+ }
328
+ return {
329
+ path: `/adresse/${encodeURIComponent(slug.trim())}/explore/map-layer/${encodeURIComponent(normalizedLayer)}`,
330
+ query: {
331
+ lon,
332
+ lat,
333
+ ...query,
334
+ },
335
+ };
336
+ }
337
+
338
+ function readRequestId(body, headers) {
339
+ if (body && typeof body === 'object' && typeof body.request_id === 'string') {
340
+ return body.request_id;
341
+ }
342
+ return headers.get('x-request-id') ?? null;
343
+ }
344
+
345
+ function combineSignals(timeoutSignal, externalSignal) {
346
+ if (!externalSignal) {
347
+ return { signal: timeoutSignal, cleanup: () => {} };
348
+ }
349
+ if (typeof AbortSignal.any === 'function') {
350
+ return { signal: AbortSignal.any([externalSignal, timeoutSignal]), cleanup: () => {} };
351
+ }
352
+
353
+ const controller = new AbortController();
354
+ const abort = () => controller.abort();
355
+ externalSignal.addEventListener('abort', abort, { once: true });
356
+ timeoutSignal.addEventListener('abort', abort, { once: true });
357
+ return {
358
+ signal: controller.signal,
359
+ cleanup: () => {
360
+ externalSignal.removeEventListener('abort', abort);
361
+ timeoutSignal.removeEventListener('abort', abort);
362
+ },
363
+ };
364
+ }
365
+
366
+ function networkErrorMessage(error) {
367
+ return error instanceof Error && error.message ? error.message : String(error);
368
+ }
369
+
370
+ async function readJsonResponse(response) {
371
+ const text = await response.text();
372
+ if (!text) {
373
+ return null;
374
+ }
375
+ try {
376
+ return JSON.parse(text);
377
+ } catch {
378
+ if (!response.ok || response.status === 202) {
379
+ return text;
380
+ }
381
+ throw new OneDexApiError('1dex API returned invalid JSON.', {
382
+ status: response.status,
383
+ body: text,
384
+ requestId: response.headers.get('x-request-id'),
385
+ headers: Object.fromEntries(response.headers.entries()),
386
+ });
387
+ }
388
+ }
389
+
390
+ export class OneDexClient {
391
+ constructor(options = {}) {
392
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
393
+ this.fetch = options.fetch ?? globalThis.fetch;
394
+ this.defaultHeaders = {
395
+ ...normalizeApiKeyHeader(options.apiKey),
396
+ ...normalizeHeaders(options.headers),
397
+ };
398
+ this.timeoutMs = options.timeoutMs ?? 30_000;
399
+ this.retryPolicy = normalizeRetryPolicy(options.retry);
400
+
401
+ if (typeof this.fetch !== 'function') {
402
+ throw new TypeError('A fetch implementation is required.');
403
+ }
404
+
405
+ this.autocomplete = Object.freeze({
406
+ address: (input, requestOptions) => this.autocompleteAddress(input, requestOptions),
407
+ });
408
+ this.addressPages = Object.freeze({
409
+ state: (slug, requestOptions) => this.addressPageState(slug, requestOptions),
410
+ });
411
+ this.address = Object.freeze({
412
+ details: (input, requestOptions) => this.addressDetails(input, requestOptions),
413
+ detailsUrl: (detailsUrl, requestOptions) => this.addressDetailsUrl(detailsUrl, requestOptions),
414
+ unlock: (input, requestOptions) => this.addressUnlock(input, requestOptions),
415
+ });
416
+ this.account = Object.freeze({
417
+ usage: (requestOptions) => this.accountUsage(requestOptions),
418
+ });
419
+ this.communes = Object.freeze({
420
+ search: (input, requestOptions) => this.communeSearch(input, requestOptions),
421
+ });
422
+ this.map = Object.freeze({
423
+ parcelles: (input, requestOptions) => this.mapParcelles(input, requestOptions),
424
+ dvf: (input, requestOptions) => this.mapLayer({ ...input, layer: 'parcelles_dvf' }, requestOptions),
425
+ travaux: (input, requestOptions) => this.mapLayer({ ...input, layer: 'parcelles_travaux' }, requestOptions),
426
+ iris: (input, requestOptions) => this.mapLayer({ ...input, layer: 'iris' }, requestOptions),
427
+ context: (input, requestOptions) => this.mapLayer({ ...input, layer: 'context' }, requestOptions),
428
+ labels: (input, requestOptions) => this.mapLayer({ ...input, layer: 'parcelles_labels' }, requestOptions),
429
+ layer: (input, requestOptions) => this.mapLayer(input, requestOptions),
430
+ viewport: (input, requestOptions) => this.mapViewport(input, requestOptions),
431
+ focus: Object.freeze({
432
+ parcelle: (input, requestOptions) => this.mapFocusParcelle(input, requestOptions),
433
+ parcelles: (input, requestOptions) => this.mapFocusParcelles(input, requestOptions),
434
+ address: (input, requestOptions) => this.mapFocusAddress(input, requestOptions),
435
+ publicLocation: (input, requestOptions) => this.mapFocusPublicLocation(input, requestOptions),
436
+ feature: (input, requestOptions) => this.mapFocusFeature(input, requestOptions),
437
+ }),
438
+ });
439
+ this.overview = Object.freeze({
440
+ address: (input, requestOptions) => this.addressOverview(input, requestOptions),
441
+ });
442
+ this.preview = Object.freeze({
443
+ byPath: (input, requestOptions) => this.publicPreview(input, requestOptions),
444
+ });
445
+ this.score = Object.freeze({
446
+ address: (input, requestOptions) => this.scoreAddress(input, requestOptions),
447
+ compare: (input, requestOptions) => this.scoreCompare(input, requestOptions),
448
+ grid: (input, requestOptions) => this.scoreGrid(input, requestOptions),
449
+ addressSuggest: (input, requestOptions) => this.scoreAddressSuggest(input, requestOptions),
450
+ });
451
+ }
452
+
453
+ async request(method, path, options = {}) {
454
+ const headers = { accept: 'application/json' };
455
+ for (const source of [this.defaultHeaders, normalizeHeaders(options.headers)]) {
456
+ for (const [key, value] of Object.entries(source)) {
457
+ setHeader(headers, key, value);
458
+ }
459
+ }
460
+ if (options.idempotencyKey !== undefined) {
461
+ setHeader(headers, 'Idempotency-Key', normalizeIdempotencyKey(options.idempotencyKey));
462
+ }
463
+
464
+ let body;
465
+ if (options.body !== undefined) {
466
+ headers['content-type'] = headers['content-type'] ?? 'application/json';
467
+ body = JSON.stringify(options.body);
468
+ }
469
+
470
+ const retryPolicy = normalizeRetryPolicy(options.retry ?? this.retryPolicy);
471
+ for (let attempt = 1; attempt <= retryPolicy.maxAttempts; attempt += 1) {
472
+ const controller = new AbortController();
473
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
474
+ const timer = timeoutMs > 0
475
+ ? setTimeout(() => controller.abort(), timeoutMs)
476
+ : undefined;
477
+ const requestSignal = combineSignals(controller.signal, options.signal);
478
+
479
+ let response;
480
+ let responseBody;
481
+ try {
482
+ requestSignal.signal.throwIfAborted();
483
+ response = await this.fetch(`${this.baseUrl}${path}`, {
484
+ method,
485
+ headers,
486
+ body,
487
+ signal: requestSignal.signal,
488
+ redirect: 'error',
489
+ });
490
+ responseBody = await readJsonResponse(response);
491
+ } catch (error) {
492
+ if (requestSignal.signal.aborted || error?.name === 'AbortError') {
493
+ const abortedByCaller = options.signal?.aborted;
494
+ throw new OneDexApiError(abortedByCaller ? '1dex API request aborted.' : '1dex API request timed out.', { status: 0 });
495
+ }
496
+ if (error instanceof OneDexApiError) {
497
+ throw error;
498
+ }
499
+ throw new OneDexApiError(`Unable to reach 1dex API: ${networkErrorMessage(error)}`, { status: 0 });
500
+ } finally {
501
+ requestSignal.cleanup();
502
+ if (timer) {
503
+ clearTimeout(timer);
504
+ }
505
+ }
506
+
507
+ if (response.status === 202 || !response.ok) {
508
+ const requestId = readRequestId(responseBody, response.headers);
509
+ const warning = Array.isArray(responseBody?.warnings) ? responseBody.warnings[0] : undefined;
510
+ const message = warning?.message
511
+ ?? responseBody?.message
512
+ ?? (response.status === 202
513
+ ? '1dex API request is still in progress.'
514
+ : `1dex API request failed with HTTP ${response.status}.`);
515
+ const error = new OneDexApiError(message, {
516
+ status: response.status,
517
+ body: responseBody,
518
+ requestId,
519
+ headers: Object.fromEntries(response.headers.entries()),
520
+ retryable: RETRYABLE_STATUSES.has(response.status),
521
+ retryAfterSeconds: parseRetryAfterSeconds(responseBody, response.headers),
522
+ code: typeof responseBody?.error === 'string' ? responseBody.error : responseBody?.status,
523
+ });
524
+ if (!error.retryable || attempt >= retryPolicy.maxAttempts) {
525
+ throw error;
526
+ }
527
+ const delayMs = retryDelayMs(error, retryPolicy);
528
+ if (delayMs === null) {
529
+ throw error;
530
+ }
531
+ try {
532
+ await waitForRetry(delayMs, options.signal);
533
+ } catch (waitError) {
534
+ if (waitError?.name === 'AbortError') {
535
+ throw new OneDexApiError('1dex API request aborted.', { status: 0 });
536
+ }
537
+ throw waitError;
538
+ }
539
+ continue;
540
+ }
541
+
542
+ return responseBody;
543
+ }
544
+ throw new OneDexApiError('1dex API retry loop ended unexpectedly.', { status: 0 });
545
+ }
546
+
547
+ autocompleteAddress(input, options = {}) {
548
+ assertObject(input, 'autocomplete input');
549
+ const { q, ...query } = input;
550
+ if (typeof q !== 'string' || q.trim() === '') {
551
+ throw new TypeError('autocomplete input requires q.');
552
+ }
553
+ return this.request('GET', appendQuery('/api/v1/autocomplete/address', { q: q.trim(), ...query }), options);
554
+ }
555
+
556
+ addressPageState(slug, options = {}) {
557
+ if (typeof slug !== 'string' || slug.trim() === '') {
558
+ throw new TypeError('address page state requires slug.');
559
+ }
560
+ return this.request('GET', `/api/v1/address-pages/${encodeURIComponent(slug.trim())}/state`, options);
561
+ }
562
+
563
+ addressDetails(input, options = {}) {
564
+ assertObject(input, 'address details input');
565
+ const { payload, options: requestOptions } = splitIdempotentInput(input, options, 'address details input');
566
+ const { fields, ...locatorInput } = payload;
567
+ const query = normalizeAddressLocator(locatorInput);
568
+ const normalizedFields = normalizeCsvList(fields, 'address details fields');
569
+ assertNormalizedAddressKeyIsAlone(query, 'address details input');
570
+ if (!hasAddressLocator(query)) {
571
+ throw new TypeError('address details input requires address, normalizedAddressKey, parcelRecordKey, or lon/lat.');
572
+ }
573
+ return this.request('GET', appendQuery('/api/v1/address-details', {
574
+ ...query,
575
+ fields: normalizedFields,
576
+ }), requestOptions);
577
+ }
578
+
579
+ addressDetailsUrl(detailsUrl, options = {}) {
580
+ const { options: requestOptions } = splitIdempotentInput({}, options, 'address details URL request');
581
+ return this.request('GET', normalizeDetailsPath(this.baseUrl, detailsUrl), requestOptions);
582
+ }
583
+
584
+ addressUnlock(input, options = {}) {
585
+ assertObject(input, 'address unlock input');
586
+ const { payload, options: requestOptions } = splitIdempotentInput(input, options, 'address unlock input');
587
+ const body = normalizeAddressLocator(payload);
588
+ assertNormalizedAddressKeyIsAlone(body, 'address unlock input');
589
+ if (!hasAddressLocator(body)) {
590
+ throw new TypeError('address unlock input requires address, normalizedAddressKey, parcelRecordKey, or lon/lat.');
591
+ }
592
+ return this.request('POST', '/api/v1/address-unlocks', { ...requestOptions, body });
593
+ }
594
+
595
+ accountUsage(options = {}) {
596
+ return this.request('GET', '/api/v1/account/usage', options);
597
+ }
598
+
599
+ communeSearch(input, options = {}) {
600
+ assertObject(input, 'commune search input');
601
+ const { q, ...query } = input;
602
+ return this.request('GET', appendQuery('/api/v1/communes/search', {
603
+ q: assertNonEmptyString(q, 'commune search q'),
604
+ ...query,
605
+ }), options);
606
+ }
607
+
608
+ mapParcelles(input, options = {}) {
609
+ const { path, query } = toMapLayerQuery(input, 'parcelles');
610
+ return this.request('GET', appendQuery(path, query), options);
611
+ }
612
+
613
+ mapLayer(input, options = {}) {
614
+ const { path, query } = toMapLayerQuery(input);
615
+ return this.request('GET', appendQuery(path, query), options);
616
+ }
617
+
618
+ mapViewport(input, options = {}) {
619
+ assertObject(input, 'map viewport input');
620
+ const { address, city_code: cityCodeSnake, cityCode: cityCodeCamel, lon, lat, layers, ...query } = input;
621
+ if (typeof layers !== 'string' || layers.trim() === '') {
622
+ throw new TypeError('map viewport input requires layers.');
623
+ }
624
+ const cityCode = cityCodeSnake ?? cityCodeCamel;
625
+ if ((typeof address !== 'string' || address.trim() === '') && (lon === undefined || lat === undefined) && (typeof cityCode !== 'string' || cityCode.trim() === '')) {
626
+ throw new TypeError('map viewport input requires address, city_code, or lon/lat.');
627
+ }
628
+ return this.request('GET', appendQuery('/api/v1/map-viewport', {
629
+ address: typeof address === 'string' && address.trim() !== '' ? address.trim() : undefined,
630
+ city_code: typeof cityCode === 'string' && cityCode.trim() !== '' ? cityCode.trim() : undefined,
631
+ lon,
632
+ lat,
633
+ layers: layers.trim(),
634
+ ...query,
635
+ }), options);
636
+ }
637
+
638
+ mapFocusParcelle(input, options = {}) {
639
+ assertObject(input, 'map focus parcelle input');
640
+ const recordKey = input.record_key ?? input.recordKey;
641
+ return this.request('GET', appendQuery('/api/v1/map-focus/parcelle', {
642
+ record_key: assertNonEmptyString(recordKey, 'map focus parcelle record_key'),
643
+ }), options);
644
+ }
645
+
646
+ mapFocusParcelles(input, options = {}) {
647
+ assertObject(input, 'map focus parcelles input');
648
+ const recordKeys = input.record_keys ?? input.recordKeys;
649
+ return this.request('GET', appendQuery('/api/v1/map-focus/parcelles', {
650
+ record_keys: normalizeCsvList(recordKeys, 'map focus parcelles record_keys'),
651
+ }), options);
652
+ }
653
+
654
+ mapFocusAddress(input, options = {}) {
655
+ assertObject(input, 'map focus address input');
656
+ const { address, city_code: cityCodeSnake, cityCode: cityCodeCamel, ...query } = input;
657
+ return this.request('GET', appendQuery('/api/v1/map-focus/address', {
658
+ address: assertNonEmptyString(address, 'map focus address'),
659
+ city_code: cityCodeSnake ?? cityCodeCamel,
660
+ ...query,
661
+ }), options);
662
+ }
663
+
664
+ mapFocusPublicLocation(input, options = {}) {
665
+ assertObject(input, 'map focus public location input');
666
+ const { lon, lat, ...query } = input;
667
+ if (lon === undefined || lon === null || lat === undefined || lat === null) {
668
+ throw new TypeError('map focus public location input requires lon and lat.');
669
+ }
670
+ return this.request('GET', appendQuery('/api/v1/map-focus/public-location', {
671
+ lon,
672
+ lat,
673
+ ...query,
674
+ }), options);
675
+ }
676
+
677
+ mapFocusFeature(input, options = {}) {
678
+ assertObject(input, 'map focus feature input');
679
+ const layerKey = input.layer_key ?? input.layerKey ?? input.layer;
680
+ const featureKey = input.feature_key ?? input.featureKey;
681
+ return this.request('GET', appendQuery('/api/v1/map-focus/feature', {
682
+ layer_key: assertNonEmptyString(layerKey, 'map focus feature layer_key'),
683
+ feature_key: assertNonEmptyString(featureKey, 'map focus feature feature_key'),
684
+ }), options);
685
+ }
686
+
687
+ addressOverview(input, options = {}) {
688
+ assertObject(input, 'address overview input');
689
+ return this.request('GET', appendQuery('/api/v1/address-overview', input), options);
690
+ }
691
+
692
+ publicPreview(input, options = {}) {
693
+ const path = typeof input === 'string' ? input : input?.path;
694
+ return this.request('GET', appendQuery('/api/v1/public-preview', {
695
+ path: assertNonEmptyString(path, 'public preview path'),
696
+ }), options);
697
+ }
698
+
699
+ scoreAddress(input, options = {}) {
700
+ assertObject(input, 'score address input');
701
+ return this.request('POST', '/api/v1/score/address', { ...options, body: input });
702
+ }
703
+
704
+ scoreCompare(input, options = {}) {
705
+ assertObject(input, 'score compare input');
706
+ return this.request('POST', '/api/v1/score/compare', { ...options, body: input });
707
+ }
708
+
709
+ scoreGrid(input, options = {}) {
710
+ assertObject(input, 'score grid input');
711
+ return this.request('GET', appendQuery('/api/v1/score/grid', input), options);
712
+ }
713
+
714
+ scoreAddressSuggest(input, options = {}) {
715
+ assertObject(input, 'score address suggest input');
716
+ const { q, ...query } = input;
717
+ if (typeof q !== 'string' || q.trim() === '') {
718
+ throw new TypeError('score address suggest input requires q.');
719
+ }
720
+ return this.request('GET', appendQuery('/api/v1/score/address-suggest', { q: q.trim(), ...query }), options);
721
+ }
722
+ }