@honua/sdk-esri-compat 0.0.6-alpha.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 +5 -0
- package/contract/edit-session.d.ts +203 -0
- package/contract/edit-session.d.ts.map +1 -0
- package/contract/edit-session.js +717 -0
- package/contract/index.d.ts +20 -0
- package/contract/index.d.ts.map +1 -0
- package/contract/index.js +15 -0
- package/contract/jobs.d.ts +115 -0
- package/contract/jobs.d.ts.map +1 -0
- package/contract/jobs.js +22 -0
- package/contract/source.d.ts +138 -0
- package/contract/source.d.ts.map +1 -0
- package/contract/source.js +3364 -0
- package/contract/spatial-aggregation.d.ts +312 -0
- package/contract/spatial-aggregation.d.ts.map +1 -0
- package/contract/spatial-aggregation.js +196 -0
- package/contract/types.d.ts +542 -0
- package/contract/types.d.ts.map +1 -0
- package/contract/types.js +171 -0
- package/core/batch.d.ts +37 -0
- package/core/batch.d.ts.map +1 -0
- package/core/batch.js +78 -0
- package/core/cache-state.d.ts +76 -0
- package/core/cache-state.d.ts.map +1 -0
- package/core/cache-state.js +84 -0
- package/core/client.d.ts +289 -0
- package/core/client.d.ts.map +1 -0
- package/core/client.js +2625 -0
- package/core/errors.d.ts +68 -0
- package/core/errors.d.ts.map +1 -0
- package/core/errors.js +116 -0
- package/core/grpc-adapter.d.ts +23 -0
- package/core/grpc-adapter.d.ts.map +1 -0
- package/core/grpc-adapter.js +704 -0
- package/core/odata.d.ts +347 -0
- package/core/odata.d.ts.map +1 -0
- package/core/odata.js +837 -0
- package/core/ogc-conformance.d.ts +35 -0
- package/core/ogc-conformance.d.ts.map +1 -0
- package/core/ogc-conformance.js +121 -0
- package/core/ogc-maps.d.ts +45 -0
- package/core/ogc-maps.d.ts.map +1 -0
- package/core/ogc-maps.js +53 -0
- package/core/ogc-processes.d.ts +92 -0
- package/core/ogc-processes.d.ts.map +1 -0
- package/core/ogc-processes.js +325 -0
- package/core/ogc-tiles.d.ts +55 -0
- package/core/ogc-tiles.d.ts.map +1 -0
- package/core/ogc-tiles.js +81 -0
- package/core/pbf-decoder.d.ts +28 -0
- package/core/pbf-decoder.d.ts.map +1 -0
- package/core/pbf-decoder.js +627 -0
- package/core/query-builder.d.ts +261 -0
- package/core/query-builder.d.ts.map +1 -0
- package/core/query-builder.js +538 -0
- package/core/spatial-filter.d.ts +93 -0
- package/core/spatial-filter.d.ts.map +1 -0
- package/core/spatial-filter.js +153 -0
- package/core/stac.d.ts +43 -0
- package/core/stac.d.ts.map +1 -0
- package/core/stac.js +116 -0
- package/core/surfaces.d.ts +464 -0
- package/core/surfaces.d.ts.map +1 -0
- package/core/surfaces.js +1322 -0
- package/core/types.d.ts +1238 -0
- package/core/types.d.ts.map +1 -0
- package/core/types.js +13 -0
- package/core/wfs-capabilities.d.ts +143 -0
- package/core/wfs-capabilities.d.ts.map +1 -0
- package/core/wfs-capabilities.js +478 -0
- package/core/wfs-filter.d.ts +118 -0
- package/core/wfs-filter.d.ts.map +1 -0
- package/core/wfs-filter.js +586 -0
- package/core/wfs.d.ts +219 -0
- package/core/wfs.d.ts.map +1 -0
- package/core/wfs.js +406 -0
- package/core/wms-capabilities.d.ts +110 -0
- package/core/wms-capabilities.d.ts.map +1 -0
- package/core/wms-capabilities.js +566 -0
- package/core/wms-types.d.ts +128 -0
- package/core/wms-types.d.ts.map +1 -0
- package/core/wms-types.js +32 -0
- package/core/wms.d.ts +108 -0
- package/core/wms.d.ts.map +1 -0
- package/core/wms.js +182 -0
- package/core/wmts-capabilities.d.ts +82 -0
- package/core/wmts-capabilities.d.ts.map +1 -0
- package/core/wmts-capabilities.js +409 -0
- package/core/wmts.d.ts +80 -0
- package/core/wmts.d.ts.map +1 -0
- package/core/wmts.js +173 -0
- package/esri-compat/basemap-gallery.d.ts +38 -0
- package/esri-compat/basemap-gallery.d.ts.map +1 -0
- package/esri-compat/basemap-gallery.js +159 -0
- package/esri-compat/basemap-layer-list.d.ts +35 -0
- package/esri-compat/basemap-layer-list.d.ts.map +1 -0
- package/esri-compat/basemap-layer-list.js +139 -0
- package/esri-compat/basemap.d.ts +32 -0
- package/esri-compat/basemap.d.ts.map +1 -0
- package/esri-compat/basemap.js +84 -0
- package/esri-compat/bookmarks.d.ts +36 -0
- package/esri-compat/bookmarks.d.ts.map +1 -0
- package/esri-compat/bookmarks.js +108 -0
- package/esri-compat/class-breaks-renderer.d.ts +50 -0
- package/esri-compat/class-breaks-renderer.d.ts.map +1 -0
- package/esri-compat/class-breaks-renderer.js +164 -0
- package/esri-compat/color.d.ts +22 -0
- package/esri-compat/color.d.ts.map +1 -0
- package/esri-compat/color.js +106 -0
- package/esri-compat/controls.d.ts +174 -0
- package/esri-compat/controls.d.ts.map +1 -0
- package/esri-compat/controls.js +479 -0
- package/esri-compat/coordinate-conversion.d.ts +41 -0
- package/esri-compat/coordinate-conversion.d.ts.map +1 -0
- package/esri-compat/coordinate-conversion.js +125 -0
- package/esri-compat/directions.d.ts +45 -0
- package/esri-compat/directions.d.ts.map +1 -0
- package/esri-compat/directions.js +123 -0
- package/esri-compat/editor.d.ts +47 -0
- package/esri-compat/editor.d.ts.map +1 -0
- package/esri-compat/editor.js +170 -0
- package/esri-compat/esri-config.d.ts +16 -0
- package/esri-compat/esri-config.d.ts.map +1 -0
- package/esri-compat/esri-config.js +23 -0
- package/esri-compat/esri-request.d.ts +17 -0
- package/esri-compat/esri-request.d.ts.map +1 -0
- package/esri-compat/esri-request.js +66 -0
- package/esri-compat/event-bus.d.ts +106 -0
- package/esri-compat/event-bus.d.ts.map +1 -0
- package/esri-compat/event-bus.js +90 -0
- package/esri-compat/expand.d.ts +36 -0
- package/esri-compat/expand.d.ts.map +1 -0
- package/esri-compat/expand.js +97 -0
- package/esri-compat/extent.d.ts +43 -0
- package/esri-compat/extent.d.ts.map +1 -0
- package/esri-compat/extent.js +140 -0
- package/esri-compat/feature-form.d.ts +64 -0
- package/esri-compat/feature-form.d.ts.map +1 -0
- package/esri-compat/feature-form.js +137 -0
- package/esri-compat/feature-layer.d.ts +173 -0
- package/esri-compat/feature-layer.d.ts.map +1 -0
- package/esri-compat/feature-layer.js +655 -0
- package/esri-compat/feature-set.d.ts +32 -0
- package/esri-compat/feature-set.d.ts.map +1 -0
- package/esri-compat/feature-set.js +80 -0
- package/esri-compat/feature-table.d.ts +141 -0
- package/esri-compat/feature-table.d.ts.map +1 -0
- package/esri-compat/feature-table.js +500 -0
- package/esri-compat/feature-templates.d.ts +41 -0
- package/esri-compat/feature-templates.d.ts.map +1 -0
- package/esri-compat/feature-templates.js +97 -0
- package/esri-compat/feature.d.ts +33 -0
- package/esri-compat/feature.d.ts.map +1 -0
- package/esri-compat/feature.js +83 -0
- package/esri-compat/graphic.d.ts +46 -0
- package/esri-compat/graphic.d.ts.map +1 -0
- package/esri-compat/graphic.js +103 -0
- package/esri-compat/graphics-layer.d.ts +49 -0
- package/esri-compat/graphics-layer.d.ts.map +1 -0
- package/esri-compat/graphics-layer.js +185 -0
- package/esri-compat/group-layer.d.ts +48 -0
- package/esri-compat/group-layer.d.ts.map +1 -0
- package/esri-compat/group-layer.js +212 -0
- package/esri-compat/identify.d.ts +71 -0
- package/esri-compat/identify.d.ts.map +1 -0
- package/esri-compat/identify.js +299 -0
- package/esri-compat/identity-manager.d.ts +31 -0
- package/esri-compat/identity-manager.d.ts.map +1 -0
- package/esri-compat/identity-manager.js +115 -0
- package/esri-compat/label-class.d.ts +31 -0
- package/esri-compat/label-class.d.ts.map +1 -0
- package/esri-compat/label-class.js +102 -0
- package/esri-compat/layer-list.d.ts +77 -0
- package/esri-compat/layer-list.d.ts.map +1 -0
- package/esri-compat/layer-list.js +342 -0
- package/esri-compat/legend.d.ts +53 -0
- package/esri-compat/legend.d.ts.map +1 -0
- package/esri-compat/legend.js +254 -0
- package/esri-compat/map-image-layer.d.ts +162 -0
- package/esri-compat/map-image-layer.d.ts.map +1 -0
- package/esri-compat/map-image-layer.js +704 -0
- package/esri-compat/map-view.d.ts +315 -0
- package/esri-compat/map-view.d.ts.map +1 -0
- package/esri-compat/map-view.js +1110 -0
- package/esri-compat/map.d.ts +47 -0
- package/esri-compat/map.d.ts.map +1 -0
- package/esri-compat/map.js +209 -0
- package/esri-compat/measurement-2d.d.ts +61 -0
- package/esri-compat/measurement-2d.d.ts.map +1 -0
- package/esri-compat/measurement-2d.js +184 -0
- package/esri-compat/measurement.d.ts +45 -0
- package/esri-compat/measurement.d.ts.map +1 -0
- package/esri-compat/measurement.js +193 -0
- package/esri-compat/oauth-info.d.ts +35 -0
- package/esri-compat/oauth-info.d.ts.map +1 -0
- package/esri-compat/oauth-info.js +115 -0
- package/esri-compat/picture-marker-symbol.d.ts +35 -0
- package/esri-compat/picture-marker-symbol.d.ts.map +1 -0
- package/esri-compat/picture-marker-symbol.js +126 -0
- package/esri-compat/point.d.ts +31 -0
- package/esri-compat/point.d.ts.map +1 -0
- package/esri-compat/point.js +102 -0
- package/esri-compat/polygon.d.ts +31 -0
- package/esri-compat/polygon.d.ts.map +1 -0
- package/esri-compat/polygon.js +107 -0
- package/esri-compat/polyline.d.ts +31 -0
- package/esri-compat/polyline.d.ts.map +1 -0
- package/esri-compat/polyline.js +107 -0
- package/esri-compat/popup-template.d.ts +55 -0
- package/esri-compat/popup-template.d.ts.map +1 -0
- package/esri-compat/popup-template.js +128 -0
- package/esri-compat/popup.d.ts +54 -0
- package/esri-compat/popup.d.ts.map +1 -0
- package/esri-compat/popup.js +312 -0
- package/esri-compat/print.d.ts +47 -0
- package/esri-compat/print.d.ts.map +1 -0
- package/esri-compat/print.js +105 -0
- package/esri-compat/query.d.ts +46 -0
- package/esri-compat/query.d.ts.map +1 -0
- package/esri-compat/query.js +119 -0
- package/esri-compat/reactive-utils.d.ts +23 -0
- package/esri-compat/reactive-utils.d.ts.map +1 -0
- package/esri-compat/reactive-utils.js +178 -0
- package/esri-compat/request.d.ts +41 -0
- package/esri-compat/request.d.ts.map +1 -0
- package/esri-compat/request.js +253 -0
- package/esri-compat/route-layer.d.ts +59 -0
- package/esri-compat/route-layer.d.ts.map +1 -0
- package/esri-compat/route-layer.js +223 -0
- package/esri-compat/route-task.d.ts +88 -0
- package/esri-compat/route-task.d.ts.map +1 -0
- package/esri-compat/route-task.js +225 -0
- package/esri-compat/scene-view.d.ts +41 -0
- package/esri-compat/scene-view.d.ts.map +1 -0
- package/esri-compat/scene-view.js +68 -0
- package/esri-compat/search.d.ts +112 -0
- package/esri-compat/search.d.ts.map +1 -0
- package/esri-compat/search.js +820 -0
- package/esri-compat/simple-fill-symbol.d.ts +27 -0
- package/esri-compat/simple-fill-symbol.d.ts.map +1 -0
- package/esri-compat/simple-fill-symbol.js +85 -0
- package/esri-compat/simple-line-symbol.d.ts +27 -0
- package/esri-compat/simple-line-symbol.d.ts.map +1 -0
- package/esri-compat/simple-line-symbol.js +85 -0
- package/esri-compat/simple-marker-symbol.d.ts +29 -0
- package/esri-compat/simple-marker-symbol.d.ts.map +1 -0
- package/esri-compat/simple-marker-symbol.js +92 -0
- package/esri-compat/simple-renderer.d.ts +29 -0
- package/esri-compat/simple-renderer.d.ts.map +1 -0
- package/esri-compat/simple-renderer.js +92 -0
- package/esri-compat/sketch.d.ts +64 -0
- package/esri-compat/sketch.d.ts.map +1 -0
- package/esri-compat/sketch.js +227 -0
- package/esri-compat/spatial-reference.d.ts +31 -0
- package/esri-compat/spatial-reference.d.ts.map +1 -0
- package/esri-compat/spatial-reference.js +102 -0
- package/esri-compat/swipe.d.ts +34 -0
- package/esri-compat/swipe.d.ts.map +1 -0
- package/esri-compat/swipe.js +93 -0
- package/esri-compat/table-list.d.ts +36 -0
- package/esri-compat/table-list.d.ts.map +1 -0
- package/esri-compat/table-list.js +126 -0
- package/esri-compat/text-symbol.d.ts +37 -0
- package/esri-compat/text-symbol.d.ts.map +1 -0
- package/esri-compat/text-symbol.js +123 -0
- package/esri-compat/tile-layer.d.ts +52 -0
- package/esri-compat/tile-layer.d.ts.map +1 -0
- package/esri-compat/tile-layer.js +167 -0
- package/esri-compat/time-slider.d.ts +70 -0
- package/esri-compat/time-slider.d.ts.map +1 -0
- package/esri-compat/time-slider.js +208 -0
- package/esri-compat/track.d.ts +51 -0
- package/esri-compat/track.d.ts.map +1 -0
- package/esri-compat/track.js +174 -0
- package/esri-compat/unique-value-renderer.d.ts +41 -0
- package/esri-compat/unique-value-renderer.d.ts.map +1 -0
- package/esri-compat/unique-value-renderer.js +127 -0
- package/esri-compat/url.d.ts +12 -0
- package/esri-compat/url.d.ts.map +1 -0
- package/esri-compat/url.js +54 -0
- package/esri-compat/web-map.d.ts +14 -0
- package/esri-compat/web-map.d.ts.map +1 -0
- package/esri-compat/web-map.js +24 -0
- package/gen/honua/v1/feature_service_pb.d.ts +918 -0
- package/gen/honua/v1/feature_service_pb.d.ts.map +1 -0
- package/gen/honua/v1/feature_service_pb.js +375 -0
- package/index.d.ts +130 -0
- package/index.js +65 -0
- package/package.json +22 -0
package/core/client.js
ADDED
|
@@ -0,0 +1,2625 @@
|
|
|
1
|
+
import { createHonuaCacheState, honuaCacheValidatorFromHeaders, honuaMetadataRequestHeaders, normalizeHonuaMetadataRequestOptions, withHonuaCacheState, withoutHonuaCacheState, } from "./cache-state.js";
|
|
2
|
+
import { HonuaAbortError, HonuaHttpError, HonuaNetworkError, HonuaTimeoutError } from "./errors.js";
|
|
3
|
+
import { HonuaOgcMaps } from "./ogc-maps.js";
|
|
4
|
+
import { HonuaOgcProcesses } from "./ogc-processes.js";
|
|
5
|
+
import { HonuaOgcTiles } from "./ogc-tiles.js";
|
|
6
|
+
import { decodePbfQueryResponse, isPbfResponse } from "./pbf-decoder.js";
|
|
7
|
+
import { HonuaStacSearch } from "./stac.js";
|
|
8
|
+
import { HonuaFeatureLayer, HonuaMapLayer, HonuaMapService, HonuaOgcFeatures, HonuaService } from "./surfaces.js";
|
|
9
|
+
import { parseWmsCapabilities } from "./wms-capabilities.js";
|
|
10
|
+
import { wmtsExtensionForFormat, } from "./wms-types.js";
|
|
11
|
+
import { HonuaWms } from "./wms.js";
|
|
12
|
+
import { parseWmtsCapabilities } from "./wmts-capabilities.js";
|
|
13
|
+
import { HonuaWmts } from "./wmts.js";
|
|
14
|
+
function normalizeBaseUrl(baseUrl) {
|
|
15
|
+
return baseUrl.replace(/\/+$/, "");
|
|
16
|
+
}
|
|
17
|
+
function normalizePath(path) {
|
|
18
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
22
|
+
}
|
|
23
|
+
function isAbsoluteHttpUrl(value) {
|
|
24
|
+
return value.startsWith("http://") || value.startsWith("https://");
|
|
25
|
+
}
|
|
26
|
+
function resolveRequestUrl(baseUrl, path) {
|
|
27
|
+
if (isAbsoluteHttpUrl(path)) {
|
|
28
|
+
if (!isAbsoluteHttpUrl(baseUrl)) {
|
|
29
|
+
throw new Error("Absolute request URLs are not allowed when baseUrl is relative.");
|
|
30
|
+
}
|
|
31
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
32
|
+
const requestUrl = new URL(path);
|
|
33
|
+
if (requestUrl.origin !== baseOrigin) {
|
|
34
|
+
throw new Error(`Cross-origin request URL is not allowed: ${path}`);
|
|
35
|
+
}
|
|
36
|
+
return requestUrl.toString();
|
|
37
|
+
}
|
|
38
|
+
return `${baseUrl}${path}`;
|
|
39
|
+
}
|
|
40
|
+
function normalizeInterceptorRequestUrl(baseUrl, url) {
|
|
41
|
+
if (isAbsoluteHttpUrl(url)) {
|
|
42
|
+
return resolveRequestUrl(baseUrl, url);
|
|
43
|
+
}
|
|
44
|
+
if (!isAbsoluteHttpUrl(baseUrl)) {
|
|
45
|
+
return url;
|
|
46
|
+
}
|
|
47
|
+
return resolveRequestUrl(baseUrl, normalizePath(url));
|
|
48
|
+
}
|
|
49
|
+
function normalizeOutFields(outFields) {
|
|
50
|
+
if (outFields === undefined) {
|
|
51
|
+
return "*";
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(outFields)) {
|
|
54
|
+
return outFields.length > 0 ? outFields.join(",") : "";
|
|
55
|
+
}
|
|
56
|
+
return outFields;
|
|
57
|
+
}
|
|
58
|
+
function encodeFormValue(value) {
|
|
59
|
+
if (typeof value === "string") {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
63
|
+
return String(value);
|
|
64
|
+
}
|
|
65
|
+
return JSON.stringify(value);
|
|
66
|
+
}
|
|
67
|
+
function encodeDeletesValue(value) {
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
return value.join(",");
|
|
70
|
+
}
|
|
71
|
+
return String(value);
|
|
72
|
+
}
|
|
73
|
+
function normalizeBBox(bbox) {
|
|
74
|
+
return Array.isArray(bbox) ? bbox.join(",") : bbox;
|
|
75
|
+
}
|
|
76
|
+
function normalizeSize(size) {
|
|
77
|
+
return Array.isArray(size) ? size.join(",") : size;
|
|
78
|
+
}
|
|
79
|
+
function normalizeLegendSize(size) {
|
|
80
|
+
if (typeof size === "number") {
|
|
81
|
+
return String(size);
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(size)) {
|
|
84
|
+
return size.join(",");
|
|
85
|
+
}
|
|
86
|
+
return size;
|
|
87
|
+
}
|
|
88
|
+
function normalizeIdentifyGeometry(geometry) {
|
|
89
|
+
return typeof geometry === "string" ? geometry : JSON.stringify(geometry);
|
|
90
|
+
}
|
|
91
|
+
function normalizeMapExtent(mapExtent) {
|
|
92
|
+
return Array.isArray(mapExtent) ? mapExtent.join(",") : mapExtent;
|
|
93
|
+
}
|
|
94
|
+
function normalizeImageDisplay(imageDisplay) {
|
|
95
|
+
return Array.isArray(imageDisplay) ? imageDisplay.join(",") : imageDisplay;
|
|
96
|
+
}
|
|
97
|
+
function normalizeSearchFields(searchFields) {
|
|
98
|
+
if (!searchFields) {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
return Array.isArray(searchFields) ? searchFields.join(",") : searchFields;
|
|
102
|
+
}
|
|
103
|
+
const DEFAULT_RETRY_STATUSES = new Set([429, 502, 503, 504]);
|
|
104
|
+
const DEFAULT_RETRY_METHODS = new Set(["GET", "PUT", "DELETE"]);
|
|
105
|
+
const DEFAULT_AUTH_REFRESH_SKEW_MS = 60_000;
|
|
106
|
+
const SUPPORTED_CONTROL_PLANE_API_MAJOR = 1;
|
|
107
|
+
const SUPPORTED_CONTROL_PLANE_API_BASE_PATH = "/api/v1/admin";
|
|
108
|
+
const MINIMUM_SUPPORTED_SERVER_RELEASE_CHANNEL = "preview";
|
|
109
|
+
export const HONUA_MINIMUM_SUPPORTED_SERVER_VERSION = "1.0.0";
|
|
110
|
+
export class HonuaClient {
|
|
111
|
+
static minimumSupportedServerVersion = HONUA_MINIMUM_SUPPORTED_SERVER_VERSION;
|
|
112
|
+
static minimumSupportedServerReleaseChannel = MINIMUM_SUPPORTED_SERVER_RELEASE_CHANNEL;
|
|
113
|
+
baseUrl;
|
|
114
|
+
fetchFn;
|
|
115
|
+
defaultHeaders;
|
|
116
|
+
authProvider;
|
|
117
|
+
authRefreshSkewMs;
|
|
118
|
+
interceptors;
|
|
119
|
+
timeoutMs;
|
|
120
|
+
retryOptions;
|
|
121
|
+
preferBinary;
|
|
122
|
+
transport;
|
|
123
|
+
serverCompatibilityCache;
|
|
124
|
+
authCredentialsCache;
|
|
125
|
+
authRefreshPromise;
|
|
126
|
+
connectClient;
|
|
127
|
+
metadataCache = new Map();
|
|
128
|
+
constructor(options) {
|
|
129
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
130
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
131
|
+
const headers = {};
|
|
132
|
+
if (options.apiKey) {
|
|
133
|
+
headers["X-API-Key"] = options.apiKey;
|
|
134
|
+
}
|
|
135
|
+
if (options.bearerToken) {
|
|
136
|
+
headers.Authorization = `Bearer ${options.bearerToken}`;
|
|
137
|
+
}
|
|
138
|
+
this.defaultHeaders = headers;
|
|
139
|
+
this.authProvider = normalizeAuthProvider(options.auth);
|
|
140
|
+
this.authRefreshSkewMs = normalizeAuthRefreshSkewMs(options.authRefreshSkewMs);
|
|
141
|
+
this.interceptors = options.interceptors ?? [];
|
|
142
|
+
this.timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
143
|
+
this.retryOptions = normalizeRetryOptions(options.retry);
|
|
144
|
+
this.preferBinary = options.preferBinary === true;
|
|
145
|
+
this.transport = options.transport ?? "rest";
|
|
146
|
+
if (this.transport === "grpc-web") {
|
|
147
|
+
this.initConnectClient();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
initConnectClient() {
|
|
151
|
+
// Dynamic imports are used to avoid bundling Connect dependencies
|
|
152
|
+
// when only the REST transport is used. The imports are resolved
|
|
153
|
+
// at module level since they are static ESM imports.
|
|
154
|
+
import("@connectrpc/connect").then(({ createClient }) => import("@connectrpc/connect-web").then(({ createGrpcWebTransport }) => import("../gen/honua/v1/feature_service_pb.js").then(({ FeatureService }) => {
|
|
155
|
+
const transport = createGrpcWebTransport({
|
|
156
|
+
baseUrl: this.baseUrl,
|
|
157
|
+
fetch: this.fetchFn,
|
|
158
|
+
});
|
|
159
|
+
this.connectClient = createClient(FeatureService, transport);
|
|
160
|
+
})));
|
|
161
|
+
}
|
|
162
|
+
async ensureConnectClient() {
|
|
163
|
+
if (this.connectClient) {
|
|
164
|
+
return this.connectClient;
|
|
165
|
+
}
|
|
166
|
+
// If initConnectClient hasn't resolved yet, wait for it
|
|
167
|
+
const { createClient } = await import("@connectrpc/connect");
|
|
168
|
+
const { createGrpcWebTransport } = await import("@connectrpc/connect-web");
|
|
169
|
+
const { FeatureService } = await import("../gen/honua/v1/feature_service_pb.js");
|
|
170
|
+
const transport = createGrpcWebTransport({
|
|
171
|
+
baseUrl: this.baseUrl,
|
|
172
|
+
fetch: this.fetchFn,
|
|
173
|
+
});
|
|
174
|
+
this.connectClient = createClient(FeatureService, transport);
|
|
175
|
+
return this.connectClient;
|
|
176
|
+
}
|
|
177
|
+
static async loadGrpcAdapter() {
|
|
178
|
+
return import("./grpc-adapter.js");
|
|
179
|
+
}
|
|
180
|
+
get isGrpcWeb() {
|
|
181
|
+
return this.transport === "grpc-web";
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Normalized base URL the client was constructed with (trailing slashes
|
|
185
|
+
* trimmed). Helpers that build absolute URLs without going through
|
|
186
|
+
* `request()` (e.g. tile URL generators) read this so they produce the
|
|
187
|
+
* same origin and base path the server actually serves from.
|
|
188
|
+
*/
|
|
189
|
+
get serverBaseUrl() {
|
|
190
|
+
return this.baseUrl;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Force-refresh credentials from the configured auth provider. The SDK
|
|
194
|
+
* keeps the result in memory only; callers own durable and secure storage.
|
|
195
|
+
*/
|
|
196
|
+
async refreshAuthCredentials(reason = "manual") {
|
|
197
|
+
const cached = await this.resolveAuthCredentials({
|
|
198
|
+
forceRefresh: true,
|
|
199
|
+
reason,
|
|
200
|
+
});
|
|
201
|
+
return cached?.credentials;
|
|
202
|
+
}
|
|
203
|
+
/** Drop cached provider credentials without calling a revocation endpoint. */
|
|
204
|
+
clearAuthCredentials() {
|
|
205
|
+
this.authCredentialsCache = undefined;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Revoke the currently cached credentials through the provider, when it
|
|
209
|
+
* exposes a revocation hook, then clear the SDK's in-memory cache.
|
|
210
|
+
*/
|
|
211
|
+
async revokeAuthCredentials(context = { reason: "manual" }) {
|
|
212
|
+
const cached = this.authCredentialsCache;
|
|
213
|
+
this.authCredentialsCache = undefined;
|
|
214
|
+
if (cached && this.authProvider?.revokeCredentials) {
|
|
215
|
+
await this.authProvider.revokeCredentials(cached.credentials, context);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async *queryFeaturesStream(request) {
|
|
219
|
+
const client = await this.ensureConnectClient();
|
|
220
|
+
const grpcAdapter = await HonuaClient.loadGrpcAdapter();
|
|
221
|
+
const protoRequest = grpcAdapter.toProtoQueryRequest(request);
|
|
222
|
+
yield* grpcAdapter.streamProtoPages(client.queryFeaturesStream(protoRequest));
|
|
223
|
+
}
|
|
224
|
+
service(serviceId) {
|
|
225
|
+
return new HonuaService({
|
|
226
|
+
client: this,
|
|
227
|
+
serviceId,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
featureLayer(serviceId, layerId) {
|
|
231
|
+
return new HonuaFeatureLayer({
|
|
232
|
+
client: this,
|
|
233
|
+
serviceId,
|
|
234
|
+
layerId,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
mapService(serviceId) {
|
|
238
|
+
return new HonuaMapService({
|
|
239
|
+
client: this,
|
|
240
|
+
serviceId,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
mapLayer(serviceId, layerId) {
|
|
244
|
+
return new HonuaMapLayer({
|
|
245
|
+
client: this,
|
|
246
|
+
serviceId,
|
|
247
|
+
layerId,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
ogcFeatures() {
|
|
251
|
+
return new HonuaOgcFeatures({
|
|
252
|
+
client: this,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
ogcTiles() {
|
|
256
|
+
return new HonuaOgcTiles({ client: this });
|
|
257
|
+
}
|
|
258
|
+
ogcMaps() {
|
|
259
|
+
return new HonuaOgcMaps({ client: this });
|
|
260
|
+
}
|
|
261
|
+
wms(serviceId) {
|
|
262
|
+
return new HonuaWms({ client: this, serviceId });
|
|
263
|
+
}
|
|
264
|
+
wmts(serviceId) {
|
|
265
|
+
return new HonuaWmts({ client: this, serviceId });
|
|
266
|
+
}
|
|
267
|
+
ogcProcesses() {
|
|
268
|
+
return new HonuaOgcProcesses({ client: this });
|
|
269
|
+
}
|
|
270
|
+
stac() {
|
|
271
|
+
return new HonuaStacSearch({ client: this });
|
|
272
|
+
}
|
|
273
|
+
clearMetadataCache(options = {}) {
|
|
274
|
+
if (!options.keyPrefix) {
|
|
275
|
+
this.metadataCache.clear();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const prefix = `metadata:${options.keyPrefix}`;
|
|
279
|
+
for (const key of this.metadataCache.keys()) {
|
|
280
|
+
if (key.startsWith(prefix)) {
|
|
281
|
+
this.metadataCache.delete(key);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async listServices(formatOrOptions = "json", options = {}) {
|
|
286
|
+
const format = typeof formatOrOptions === "string" ? formatOrOptions : "json";
|
|
287
|
+
const metadataOptions = typeof formatOrOptions === "string" ? options : formatOrOptions;
|
|
288
|
+
const query = new URLSearchParams({ f: format });
|
|
289
|
+
const path = `/rest/services?${query.toString()}`;
|
|
290
|
+
return this.requestCachedMetadataJson(`geoservices:services:${format}`, path, metadataOptions);
|
|
291
|
+
}
|
|
292
|
+
async getCompatibility(options = {}) {
|
|
293
|
+
if (!options.refresh && this.serverCompatibilityCache) {
|
|
294
|
+
return this.serverCompatibilityCache;
|
|
295
|
+
}
|
|
296
|
+
const response = (await this.requestJson("GET", "/api/v1/admin/capabilities", undefined, options.signal));
|
|
297
|
+
const compatibility = parseCompatibilityEnvelope(response);
|
|
298
|
+
this.serverCompatibilityCache = compatibility;
|
|
299
|
+
return compatibility;
|
|
300
|
+
}
|
|
301
|
+
async checkCompatibility(options = {}) {
|
|
302
|
+
try {
|
|
303
|
+
const compatibility = await this.getCompatibility(options);
|
|
304
|
+
const reasons = evaluateCompatibility(compatibility);
|
|
305
|
+
return {
|
|
306
|
+
supported: reasons.length === 0,
|
|
307
|
+
minimumSupportedServerVersion: HonuaClient.minimumSupportedServerVersion,
|
|
308
|
+
compatibility,
|
|
309
|
+
reasons,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
return {
|
|
314
|
+
supported: false,
|
|
315
|
+
minimumSupportedServerVersion: HonuaClient.minimumSupportedServerVersion,
|
|
316
|
+
reasons: [describeCompatibilityError(error)],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async supportsFeature(feature, options = {}) {
|
|
321
|
+
const compatibility = await this.getCompatibility(options);
|
|
322
|
+
return compatibility.features[feature];
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Scan a supported migration source through the admin import scanner.
|
|
326
|
+
*
|
|
327
|
+
* A successful HTTP response means the server returned a deterministic
|
|
328
|
+
* inventory artifact; callers still need to inspect
|
|
329
|
+
* `scanCompleteness.status`, which can be `"failed"` on `200 OK`.
|
|
330
|
+
*/
|
|
331
|
+
async scanMigrationSource(request) {
|
|
332
|
+
const { signal, exportJson, ...body } = request;
|
|
333
|
+
const path = `/api/v1/admin/import/scan${exportJson ? "?export=json" : ""}`;
|
|
334
|
+
return this.requestJson("POST", path, {
|
|
335
|
+
headers: { "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify(body),
|
|
337
|
+
}, signal);
|
|
338
|
+
}
|
|
339
|
+
async request(request) {
|
|
340
|
+
const method = request.method ?? "GET";
|
|
341
|
+
const params = new URLSearchParams();
|
|
342
|
+
params.set("f", request.responseFormat ?? "json");
|
|
343
|
+
if (request.query) {
|
|
344
|
+
for (const [key, value] of Object.entries(request.query)) {
|
|
345
|
+
params.set(key, String(value));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const normalizedPath = normalizePath(request.path);
|
|
349
|
+
const pathWithQuery = mergePathWithQueryParams(normalizedPath, params);
|
|
350
|
+
return this.requestJson(method, pathWithQuery, {
|
|
351
|
+
headers: request.headers,
|
|
352
|
+
body: request.body,
|
|
353
|
+
}, request.signal);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Pipeline-aware JSON request that bypasses the GeoServices `f=json`
|
|
357
|
+
* convention used by {@link request}. Adapters whose protocols do not
|
|
358
|
+
* model `f=` (OData, OGC API, …) call this directly so they keep the
|
|
359
|
+
* shared auth / retry / timeout / interceptor pipeline without sending
|
|
360
|
+
* a query parameter the server would reject as `InvalidQueryOption`.
|
|
361
|
+
*
|
|
362
|
+
* Caller-supplied query parameters belong on `path` itself; `init`
|
|
363
|
+
* carries the body, headers, and abort signal. The default `Accept`
|
|
364
|
+
* header is `application/json`; pass an explicit `Accept` in
|
|
365
|
+
* `init.headers` to override.
|
|
366
|
+
*/
|
|
367
|
+
async pipelineRequestJson(method, path, init, signal) {
|
|
368
|
+
return this.requestJson(method, path, init, signal);
|
|
369
|
+
}
|
|
370
|
+
async composeHeaders(...headersList) {
|
|
371
|
+
const authHeaders = await this.resolveAuthHeaders();
|
|
372
|
+
return mergeHeaders(this.defaultHeaders, authHeaders, ...headersList);
|
|
373
|
+
}
|
|
374
|
+
async resolveAuthHeaders() {
|
|
375
|
+
const cached = await this.resolveAuthCredentials({ forceRefresh: false });
|
|
376
|
+
if (!cached)
|
|
377
|
+
return undefined;
|
|
378
|
+
return authHeadersFromCredentials(cached.credentials);
|
|
379
|
+
}
|
|
380
|
+
async resolveAuthCredentials(options) {
|
|
381
|
+
if (!this.authProvider) {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
const cached = this.authCredentialsCache;
|
|
385
|
+
if (!options.forceRefresh && cached && !isAuthCredentialsExpiring(cached, this.authRefreshSkewMs)) {
|
|
386
|
+
return cached;
|
|
387
|
+
}
|
|
388
|
+
if (this.authRefreshPromise) {
|
|
389
|
+
return this.authRefreshPromise;
|
|
390
|
+
}
|
|
391
|
+
const reason = options.reason ?? resolveAuthRefreshReason(cached);
|
|
392
|
+
this.authRefreshPromise = this.loadAuthCredentials(reason, options.forceRefresh, cached?.credentials);
|
|
393
|
+
try {
|
|
394
|
+
return await this.authRefreshPromise;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
this.authRefreshPromise = undefined;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async loadAuthCredentials(reason, forceRefresh, previousCredentials) {
|
|
401
|
+
const credentials = normalizeAuthCredentials(await this.authProvider?.getCredentials({
|
|
402
|
+
reason,
|
|
403
|
+
forceRefresh,
|
|
404
|
+
...(previousCredentials ? { previousCredentials } : {}),
|
|
405
|
+
}));
|
|
406
|
+
if (!credentials) {
|
|
407
|
+
this.authCredentialsCache = undefined;
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
const cached = {
|
|
411
|
+
credentials,
|
|
412
|
+
expiresAtMs: normalizeAuthExpiresAtMs(credentials.expiresAt),
|
|
413
|
+
};
|
|
414
|
+
this.authCredentialsCache = cached;
|
|
415
|
+
return cached;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Pipeline-aware request that returns the raw `Response` after the
|
|
419
|
+
* shared auth / retry / timeout / interceptor pipeline finishes
|
|
420
|
+
* successfully. Used by adapters that need to consume non-JSON bodies
|
|
421
|
+
* (OData `$metadata` XML, raw passthrough) without inheriting the
|
|
422
|
+
* `Accept: application/json` default of {@link pipelineRequestJson}.
|
|
423
|
+
*
|
|
424
|
+
* The returned `Response` is unconsumed — the caller picks `.json()`,
|
|
425
|
+
* `.text()`, or `.arrayBuffer()`. Non-2xx responses still throw the
|
|
426
|
+
* normalized `HonuaHttpError` (and trigger retries) so error handling
|
|
427
|
+
* matches every other client method.
|
|
428
|
+
*/
|
|
429
|
+
async pipelineFetch(method, path, init, callerSignal, options = {}) {
|
|
430
|
+
let request = {
|
|
431
|
+
url: resolveRequestUrl(this.baseUrl, path),
|
|
432
|
+
path,
|
|
433
|
+
method,
|
|
434
|
+
init: {
|
|
435
|
+
method,
|
|
436
|
+
headers: await this.composeHeaders(init?.headers),
|
|
437
|
+
body: init?.body ?? null,
|
|
438
|
+
...(init?.signal ? { signal: init.signal } : {}),
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
request = await this.applyBeforeInterceptors(request);
|
|
442
|
+
for (let attempt = 0;; attempt += 1) {
|
|
443
|
+
let response;
|
|
444
|
+
const timeout = createTimeoutSignal(callerSignal ?? request.init.signal, this.timeoutMs);
|
|
445
|
+
const startTime = performance.now();
|
|
446
|
+
try {
|
|
447
|
+
response = await this.fetchFn(request.url, {
|
|
448
|
+
...request.init,
|
|
449
|
+
method: request.method,
|
|
450
|
+
signal: timeout.signal,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
const durationMs = performance.now() - startTime;
|
|
455
|
+
const normalizedError = timeout.didTimeout
|
|
456
|
+
? new HonuaTimeoutError(this.timeoutMs ?? 0)
|
|
457
|
+
: normalizeNetworkError(error);
|
|
458
|
+
if (this.shouldRetryRequest(request.method, attempt, undefined, normalizedError)) {
|
|
459
|
+
await sleep(this.resolveRetryDelayMs(attempt));
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
await this.applyErrorInterceptors({
|
|
463
|
+
request: cloneRequestContext(request),
|
|
464
|
+
error: normalizedError,
|
|
465
|
+
durationMs,
|
|
466
|
+
});
|
|
467
|
+
throw normalizedError;
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
timeout.dispose();
|
|
471
|
+
}
|
|
472
|
+
const durationMs = performance.now() - startTime;
|
|
473
|
+
if (!response.ok && !options.okStatuses?.includes(response.status)) {
|
|
474
|
+
const body = await parseResponseBody(response.clone());
|
|
475
|
+
const httpError = this.toHttpError(response.status, body);
|
|
476
|
+
if (this.shouldRetryRequest(request.method, attempt, response.status, httpError)) {
|
|
477
|
+
await sleep(this.resolveRetryDelayMs(attempt, response));
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error: httpError, durationMs });
|
|
481
|
+
throw httpError;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
await this.applyAfterInterceptors(cloneRequestContext(request), response, durationMs);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error, durationMs });
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
return response;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async requestCachedMetadataJson(cacheKey, path, options = {}) {
|
|
494
|
+
const metadataOptions = normalizeHonuaMetadataRequestOptions(options);
|
|
495
|
+
const keyFingerprint = `metadata:${cacheKey}`;
|
|
496
|
+
const cached = this.metadataCache.get(keyFingerprint);
|
|
497
|
+
const bypass = metadataOptions.cache === "bypass";
|
|
498
|
+
if (!bypass && !metadataOptions.refresh && cached) {
|
|
499
|
+
return withHonuaCacheState(cached.body, createMetadataCacheState(cached, "hit", {
|
|
500
|
+
now: Date.now(),
|
|
501
|
+
ttlMs: metadataOptions.ttlMs,
|
|
502
|
+
staleIfErrorMs: metadataOptions.staleIfErrorMs,
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
let request = {
|
|
506
|
+
url: resolveRequestUrl(this.baseUrl, path),
|
|
507
|
+
path,
|
|
508
|
+
method: "GET",
|
|
509
|
+
init: {
|
|
510
|
+
method: "GET",
|
|
511
|
+
headers: await this.composeHeaders(honuaMetadataRequestHeaders({
|
|
512
|
+
accept: "application/json",
|
|
513
|
+
refresh: metadataOptions.refresh,
|
|
514
|
+
bypass,
|
|
515
|
+
validator: cached?.validator,
|
|
516
|
+
})),
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
request = await this.applyBeforeInterceptors(request);
|
|
520
|
+
for (let attempt = 0;; attempt += 1) {
|
|
521
|
+
let response;
|
|
522
|
+
const timeout = createTimeoutSignal(metadataOptions.signal ?? request.init.signal, this.timeoutMs);
|
|
523
|
+
const startTime = performance.now();
|
|
524
|
+
try {
|
|
525
|
+
response = await this.fetchFn(request.url, {
|
|
526
|
+
...request.init,
|
|
527
|
+
method: request.method,
|
|
528
|
+
signal: timeout.signal,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
const durationMs = performance.now() - startTime;
|
|
533
|
+
const normalizedError = timeout.didTimeout
|
|
534
|
+
? new HonuaTimeoutError(this.timeoutMs ?? 0)
|
|
535
|
+
: normalizeNetworkError(error);
|
|
536
|
+
if (this.shouldRetryRequest(request.method, attempt, undefined, normalizedError)) {
|
|
537
|
+
await sleep(this.resolveRetryDelayMs(attempt));
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const stale = this.staleMetadataFallback(cached, metadataOptions, normalizedError);
|
|
541
|
+
if (stale)
|
|
542
|
+
return stale;
|
|
543
|
+
await this.applyErrorInterceptors({
|
|
544
|
+
request: cloneRequestContext(request),
|
|
545
|
+
error: normalizedError,
|
|
546
|
+
durationMs,
|
|
547
|
+
});
|
|
548
|
+
throw normalizedError;
|
|
549
|
+
}
|
|
550
|
+
finally {
|
|
551
|
+
timeout.dispose();
|
|
552
|
+
}
|
|
553
|
+
const durationMs = performance.now() - startTime;
|
|
554
|
+
if (response.status === 304 && cached && !bypass) {
|
|
555
|
+
try {
|
|
556
|
+
await this.applyAfterInterceptors(cloneRequestContext(request), response, durationMs);
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error, durationMs });
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
const updatedEntry = {
|
|
563
|
+
...cached,
|
|
564
|
+
cachedAtMs: Date.now(),
|
|
565
|
+
...((honuaCacheValidatorFromHeaders(response.headers) ?? cached.validator)
|
|
566
|
+
? { validator: honuaCacheValidatorFromHeaders(response.headers) ?? cached.validator }
|
|
567
|
+
: {}),
|
|
568
|
+
};
|
|
569
|
+
this.metadataCache.set(keyFingerprint, updatedEntry);
|
|
570
|
+
return withHonuaCacheState(updatedEntry.body, createMetadataCacheState(updatedEntry, "refreshed", {
|
|
571
|
+
now: Date.now(),
|
|
572
|
+
ttlMs: metadataOptions.ttlMs,
|
|
573
|
+
staleIfErrorMs: metadataOptions.staleIfErrorMs,
|
|
574
|
+
revalidatedAt: new Date().toISOString(),
|
|
575
|
+
}));
|
|
576
|
+
}
|
|
577
|
+
const body = await parseResponseBody(response.clone());
|
|
578
|
+
if (!response.ok) {
|
|
579
|
+
const httpError = this.toHttpError(response.status, body);
|
|
580
|
+
if (this.shouldRetryRequest(request.method, attempt, response.status, httpError)) {
|
|
581
|
+
await sleep(this.resolveRetryDelayMs(attempt, response));
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const stale = this.staleMetadataFallback(cached, metadataOptions, httpError);
|
|
585
|
+
if (stale)
|
|
586
|
+
return stale;
|
|
587
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error: httpError, durationMs });
|
|
588
|
+
throw httpError;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
await this.applyAfterInterceptors(cloneRequestContext(request), response, durationMs);
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error, durationMs });
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
const cleanBody = withoutHonuaCacheState(body);
|
|
598
|
+
const validator = honuaCacheValidatorFromHeaders(response.headers);
|
|
599
|
+
const sourceUpdatedAt = response.headers.get("last-modified") ?? undefined;
|
|
600
|
+
const entry = {
|
|
601
|
+
body: cleanBody,
|
|
602
|
+
cachedAtMs: Date.now(),
|
|
603
|
+
keyFingerprint,
|
|
604
|
+
...(validator ? { validator } : {}),
|
|
605
|
+
...(sourceUpdatedAt ? { sourceUpdatedAt } : {}),
|
|
606
|
+
};
|
|
607
|
+
const status = bypass ? "bypass" : cached ? "refreshed" : "miss";
|
|
608
|
+
if (!bypass) {
|
|
609
|
+
this.metadataCache.set(keyFingerprint, entry);
|
|
610
|
+
}
|
|
611
|
+
return withHonuaCacheState(cleanBody, createMetadataCacheState(entry, status, {
|
|
612
|
+
now: Date.now(),
|
|
613
|
+
ttlMs: metadataOptions.ttlMs,
|
|
614
|
+
staleIfErrorMs: metadataOptions.staleIfErrorMs,
|
|
615
|
+
...(status === "refreshed" ? { revalidatedAt: new Date().toISOString() } : {}),
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
staleMetadataFallback(cached, options, error) {
|
|
620
|
+
if (!cached || options.cache === "bypass" || !options.staleIfError) {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
return withHonuaCacheState(cached.body, createMetadataCacheState(cached, "stale", {
|
|
624
|
+
now: Date.now(),
|
|
625
|
+
ttlMs: options.ttlMs,
|
|
626
|
+
staleIfErrorMs: options.staleIfErrorMs,
|
|
627
|
+
refreshErrorId: metadataRefreshErrorId(error),
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
async getLayerMetadata(serviceId, layerId, options = {}) {
|
|
631
|
+
const query = new URLSearchParams({ f: "json" });
|
|
632
|
+
const path = `/rest/services/${encodeURIComponent(serviceId)}/FeatureServer/${layerId}?${query.toString()}`;
|
|
633
|
+
return this.requestCachedMetadataJson(`geoservices-feature:${serviceId}:${layerId}`, path, options);
|
|
634
|
+
}
|
|
635
|
+
async getFeatureServiceMetadata(serviceId, options = {}) {
|
|
636
|
+
const query = new URLSearchParams({ f: "json" });
|
|
637
|
+
const path = `/rest/services/${encodeURIComponent(serviceId)}/FeatureServer?${query.toString()}`;
|
|
638
|
+
return this.requestCachedMetadataJson(`geoservices-feature:${serviceId}:service`, path, options);
|
|
639
|
+
}
|
|
640
|
+
async getOgcFeaturesLanding(request = {}) {
|
|
641
|
+
const params = createOgcMetadataParams(request);
|
|
642
|
+
return this.requestCachedMetadataJson(`ogc-features:landing:${params.toString()}`, `/ogc/features?${params.toString()}`, request);
|
|
643
|
+
}
|
|
644
|
+
async getOgcFeaturesConformance(request = {}) {
|
|
645
|
+
const params = createOgcMetadataParams(request);
|
|
646
|
+
return this.requestCachedMetadataJson(`ogc-features:conformance:${params.toString()}`, `/ogc/features/conformance?${params.toString()}`, request);
|
|
647
|
+
}
|
|
648
|
+
async listOgcCollections(request = {}) {
|
|
649
|
+
const params = createOgcMetadataParams(request);
|
|
650
|
+
return this.requestCachedMetadataJson(`ogc-features:collections:${params.toString()}`, `/ogc/features/collections?${params.toString()}`, request);
|
|
651
|
+
}
|
|
652
|
+
async getOgcCollection(request) {
|
|
653
|
+
const params = createOgcMetadataParams(request);
|
|
654
|
+
const path = `/ogc/features/collections/${encodeURIComponent(String(request.collectionId))}`;
|
|
655
|
+
return this.requestCachedMetadataJson(`ogc-features:collection:${request.collectionId}:${params.toString()}`, `${path}?${params.toString()}`, request);
|
|
656
|
+
}
|
|
657
|
+
async getOgcQueryables(request) {
|
|
658
|
+
const params = createOgcMetadataParams(request);
|
|
659
|
+
const path = `/ogc/features/collections/${encodeURIComponent(String(request.collectionId))}/queryables`;
|
|
660
|
+
return this.requestCachedMetadataJson(`ogc-features:queryables:${request.collectionId}:${params.toString()}`, `${path}?${params.toString()}`, request);
|
|
661
|
+
}
|
|
662
|
+
async listOgcItems(request) {
|
|
663
|
+
const params = createOgcMetadataParams(request);
|
|
664
|
+
if (request.limit !== undefined) {
|
|
665
|
+
params.set("limit", String(request.limit));
|
|
666
|
+
}
|
|
667
|
+
if (request.offset !== undefined) {
|
|
668
|
+
params.set("offset", String(request.offset));
|
|
669
|
+
}
|
|
670
|
+
if (request.bbox !== undefined) {
|
|
671
|
+
params.set("bbox", request.bbox);
|
|
672
|
+
}
|
|
673
|
+
if (request.datetime !== undefined) {
|
|
674
|
+
params.set("datetime", request.datetime);
|
|
675
|
+
}
|
|
676
|
+
if (request.filter !== undefined) {
|
|
677
|
+
params.set("filter", request.filter);
|
|
678
|
+
}
|
|
679
|
+
if (request.ids !== undefined) {
|
|
680
|
+
params.set("ids", normalizeCsv(request.ids));
|
|
681
|
+
}
|
|
682
|
+
if (request.properties !== undefined) {
|
|
683
|
+
params.set("properties", normalizeCsv(request.properties));
|
|
684
|
+
}
|
|
685
|
+
if (request.sortby !== undefined) {
|
|
686
|
+
params.set("sortby", request.sortby);
|
|
687
|
+
}
|
|
688
|
+
if (request.crs !== undefined) {
|
|
689
|
+
params.set("crs", request.crs);
|
|
690
|
+
}
|
|
691
|
+
const path = `/ogc/features/collections/${encodeURIComponent(String(request.collectionId))}/items`;
|
|
692
|
+
return this.requestJson("GET", `${path}?${params.toString()}`, undefined, request.signal);
|
|
693
|
+
}
|
|
694
|
+
async getOgcItem(request) {
|
|
695
|
+
const params = createOgcMetadataParams(request);
|
|
696
|
+
if (request.crs !== undefined) {
|
|
697
|
+
params.set("crs", request.crs);
|
|
698
|
+
}
|
|
699
|
+
const path = `/ogc/features/collections/${encodeURIComponent(String(request.collectionId))}` +
|
|
700
|
+
`/items/${encodeURIComponent(String(request.featureId))}`;
|
|
701
|
+
return this.requestJson("GET", `${path}?${params.toString()}`, undefined, request.signal);
|
|
702
|
+
}
|
|
703
|
+
async createOgcItem(request) {
|
|
704
|
+
const params = createOgcMetadataParams(request);
|
|
705
|
+
const path = `/ogc/features/collections/${encodeURIComponent(String(request.collectionId))}/items`;
|
|
706
|
+
return this.requestJson("POST", `${path}?${params.toString()}`, {
|
|
707
|
+
headers: mergeHeaders({ "Content-Type": "application/geo+json" }, request.headers),
|
|
708
|
+
body: JSON.stringify(request.feature),
|
|
709
|
+
}, request.signal);
|
|
710
|
+
}
|
|
711
|
+
async replaceOgcItem(request) {
|
|
712
|
+
const params = createOgcMetadataParams(request);
|
|
713
|
+
if (request.crs !== undefined) {
|
|
714
|
+
params.set("crs", request.crs);
|
|
715
|
+
}
|
|
716
|
+
const path = `/ogc/features/collections/${encodeURIComponent(String(request.collectionId))}` +
|
|
717
|
+
`/items/${encodeURIComponent(String(request.featureId))}`;
|
|
718
|
+
return this.requestJson("PUT", `${path}?${params.toString()}`, {
|
|
719
|
+
headers: mergeHeaders({ "Content-Type": "application/geo+json" }, request.headers),
|
|
720
|
+
body: JSON.stringify(request.feature),
|
|
721
|
+
}, request.signal);
|
|
722
|
+
}
|
|
723
|
+
async patchOgcItem(request) {
|
|
724
|
+
const params = createOgcMetadataParams(request);
|
|
725
|
+
if (request.crs !== undefined) {
|
|
726
|
+
params.set("crs", request.crs);
|
|
727
|
+
}
|
|
728
|
+
const path = `/ogc/features/collections/${encodeURIComponent(String(request.collectionId))}` +
|
|
729
|
+
`/items/${encodeURIComponent(String(request.featureId))}`;
|
|
730
|
+
return this.requestJson("PATCH", `${path}?${params.toString()}`, {
|
|
731
|
+
headers: mergeHeaders({ "Content-Type": "application/merge-patch+json" }, request.headers),
|
|
732
|
+
body: JSON.stringify(request.patch),
|
|
733
|
+
}, request.signal);
|
|
734
|
+
}
|
|
735
|
+
async deleteOgcItem(request) {
|
|
736
|
+
const params = createOgcMetadataParams(request);
|
|
737
|
+
if (request.crs !== undefined) {
|
|
738
|
+
params.set("crs", request.crs);
|
|
739
|
+
}
|
|
740
|
+
const path = `/ogc/features/collections/${encodeURIComponent(String(request.collectionId))}` +
|
|
741
|
+
`/items/${encodeURIComponent(String(request.featureId))}`;
|
|
742
|
+
await this.requestJson("DELETE", `${path}?${params.toString()}`, undefined, request.signal);
|
|
743
|
+
}
|
|
744
|
+
// ── OGC API Tiles ───────────────────────────────────────────
|
|
745
|
+
async getOgcTilesLanding(request = {}) {
|
|
746
|
+
const params = createOgcMetadataParams(request);
|
|
747
|
+
return this.requestCachedMetadataJson(`ogc-tiles:landing:${params.toString()}`, `/ogc/tiles?${params.toString()}`, request);
|
|
748
|
+
}
|
|
749
|
+
async getOgcTilesConformance(request = {}) {
|
|
750
|
+
const params = createOgcMetadataParams(request);
|
|
751
|
+
return this.requestCachedMetadataJson(`ogc-tiles:conformance:${params.toString()}`, `/ogc/tiles/conformance?${params.toString()}`, request);
|
|
752
|
+
}
|
|
753
|
+
async listOgcTileMatrixSets(request = {}) {
|
|
754
|
+
const params = createOgcMetadataParams(request);
|
|
755
|
+
return this.requestCachedMetadataJson(`ogc-tiles:tile-matrix-sets:${params.toString()}`, `/ogc/tiles/tileMatrixSets?${params.toString()}`, request);
|
|
756
|
+
}
|
|
757
|
+
async getOgcTileMatrixSet(request) {
|
|
758
|
+
const params = createOgcMetadataParams(request);
|
|
759
|
+
return this.requestCachedMetadataJson(`ogc-tiles:tile-matrix-set:${request.tileMatrixSetId}:${params.toString()}`, `/ogc/tiles/tileMatrixSets/${encodeURIComponent(request.tileMatrixSetId)}?${params.toString()}`, request);
|
|
760
|
+
}
|
|
761
|
+
async listOgcCollectionTilesets(request) {
|
|
762
|
+
const params = createOgcMetadataParams(request);
|
|
763
|
+
const path = `/ogc/tiles/collections/${encodeURIComponent(String(request.collectionId))}/tiles`;
|
|
764
|
+
return this.requestCachedMetadataJson(`ogc-tiles:tilesets:${request.collectionId}:${params.toString()}`, `${path}?${params.toString()}`, request);
|
|
765
|
+
}
|
|
766
|
+
async getOgcCollectionTileset(request) {
|
|
767
|
+
const params = createOgcMetadataParams(request);
|
|
768
|
+
const path = `/ogc/tiles/collections/${encodeURIComponent(String(request.collectionId))}` +
|
|
769
|
+
`/tiles/${encodeURIComponent(request.tileMatrixSetId)}`;
|
|
770
|
+
return this.requestCachedMetadataJson(`ogc-tiles:tileset:${request.collectionId}:${request.tileMatrixSetId}:${params.toString()}`, `${path}?${params.toString()}`, request);
|
|
771
|
+
}
|
|
772
|
+
async fetchOgcTile(request) {
|
|
773
|
+
const params = new URLSearchParams();
|
|
774
|
+
if (request.extraParams) {
|
|
775
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
776
|
+
params.set(key, String(value));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const collection = encodeURIComponent(String(request.collectionId));
|
|
780
|
+
const matrixSet = encodeURIComponent(request.tileMatrixSetId);
|
|
781
|
+
const matrix = encodeURIComponent(String(request.tileMatrix));
|
|
782
|
+
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
783
|
+
const path = `/ogc/tiles/collections/${collection}/tiles/${matrixSet}/${matrix}/${request.tileRow}/${request.tileCol}${query}`;
|
|
784
|
+
return this.requestBytes("GET", path, request.accept, undefined, request.signal);
|
|
785
|
+
}
|
|
786
|
+
// ── OGC API Maps ────────────────────────────────────────────
|
|
787
|
+
async getOgcMapsLanding(request = {}) {
|
|
788
|
+
const params = createOgcMetadataParams(request);
|
|
789
|
+
return this.requestCachedMetadataJson(`ogc-maps:landing:${params.toString()}`, `/ogc/maps?${params.toString()}`, request);
|
|
790
|
+
}
|
|
791
|
+
async getOgcMapsConformance(request = {}) {
|
|
792
|
+
const params = createOgcMetadataParams(request);
|
|
793
|
+
return this.requestCachedMetadataJson(`ogc-maps:conformance:${params.toString()}`, `/ogc/maps/conformance?${params.toString()}`, request);
|
|
794
|
+
}
|
|
795
|
+
async getOgcMapImage(request) {
|
|
796
|
+
const params = serializeOgcMapImageParams(request);
|
|
797
|
+
const collectionPart = request.collectionId !== undefined ? `/collections/${encodeURIComponent(String(request.collectionId))}` : "";
|
|
798
|
+
const stylePart = request.styleId ? `/styles/${encodeURIComponent(request.styleId)}` : "";
|
|
799
|
+
const path = `/ogc/maps${collectionPart}${stylePart}/map${params.size > 0 ? `?${params.toString()}` : ""}`;
|
|
800
|
+
const accept = ogcMapAcceptHeader(request.format) ?? "image/png";
|
|
801
|
+
const response = await this.requestBytes("GET", path, accept, undefined, request.signal);
|
|
802
|
+
return { bytes: response.bytes, contentType: response.contentType };
|
|
803
|
+
}
|
|
804
|
+
// ── WMS 1.3 ─────────────────────────────────────────────────
|
|
805
|
+
/**
|
|
806
|
+
* Fetch and parse a WMS `GetCapabilities` document for the addressed
|
|
807
|
+
* service. The XML body decodes through `requestText`; the parsed
|
|
808
|
+
* shape is the typed `WmsCapabilities` envelope (no XML node leaks
|
|
809
|
+
* through the public surface).
|
|
810
|
+
*/
|
|
811
|
+
async getWmsCapabilities(request) {
|
|
812
|
+
const params = new URLSearchParams();
|
|
813
|
+
params.set("SERVICE", "WMS");
|
|
814
|
+
params.set("REQUEST", "GetCapabilities");
|
|
815
|
+
params.set("VERSION", request.version ?? "1.3.0");
|
|
816
|
+
if (request.extraParams) {
|
|
817
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
818
|
+
params.set(key, String(value));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const path = `${wmsBasePath(request.serviceId)}?${params.toString()}`;
|
|
822
|
+
const { text: xml } = await this.requestText("GET", path, {
|
|
823
|
+
accept: "text/xml,application/xml",
|
|
824
|
+
signal: request.signal,
|
|
825
|
+
});
|
|
826
|
+
return parseWmsCapabilities(xml);
|
|
827
|
+
}
|
|
828
|
+
/** Render a WMS `GetMap`. Returns the raw image bytes. */
|
|
829
|
+
async getWmsMap(request) {
|
|
830
|
+
const params = serializeWmsMapParams(request);
|
|
831
|
+
params.set("REQUEST", "GetMap");
|
|
832
|
+
const path = `${wmsBasePath(request.serviceId)}?${params.toString()}`;
|
|
833
|
+
const accept = request.format ?? "image/png";
|
|
834
|
+
const response = await this.requestBytes("GET", path, accept, undefined, request.signal);
|
|
835
|
+
return { bytes: response.bytes, contentType: response.contentType };
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Issue a WMS `GetFeatureInfo`. When `INFO_FORMAT=application/json`
|
|
839
|
+
* the JSON body decodes into the canonical `HonuaTypedFeature[]`
|
|
840
|
+
* shape; non-JSON formats round-trip on `bytes` so callers retain the
|
|
841
|
+
* raw payload behind the protocol escape hatch.
|
|
842
|
+
*/
|
|
843
|
+
async getWmsFeatureInfo(request) {
|
|
844
|
+
const params = serializeWmsMapParams(request);
|
|
845
|
+
params.set("REQUEST", "GetFeatureInfo");
|
|
846
|
+
params.set("QUERY_LAYERS", request.queryLayers.join(","));
|
|
847
|
+
params.set("I", String(Math.trunc(request.i)));
|
|
848
|
+
params.set("J", String(Math.trunc(request.j)));
|
|
849
|
+
const infoFormat = request.infoFormat ?? "application/json";
|
|
850
|
+
params.set("INFO_FORMAT", infoFormat);
|
|
851
|
+
if (request.featureCount !== undefined) {
|
|
852
|
+
params.set("FEATURE_COUNT", String(Math.trunc(request.featureCount)));
|
|
853
|
+
}
|
|
854
|
+
const path = `${wmsBasePath(request.serviceId)}?${params.toString()}`;
|
|
855
|
+
const response = await this.requestBytes("GET", path, infoFormat, undefined, request.signal);
|
|
856
|
+
return decodeWmsFeatureInfoResponse(response.bytes, response.contentType, infoFormat);
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Fetch a WMS `GetLegendGraphic`. honua-server does not implement
|
|
860
|
+
* GetLegendGraphic today; callers should branch on
|
|
861
|
+
* `WmsCapabilities.request.getLegendGraphic` before invoking. When
|
|
862
|
+
* the wire returns 5xx the underlying `HonuaHttpError` flows through.
|
|
863
|
+
*/
|
|
864
|
+
async getWmsLegend(request) {
|
|
865
|
+
const params = new URLSearchParams();
|
|
866
|
+
params.set("SERVICE", "WMS");
|
|
867
|
+
params.set("VERSION", "1.3.0");
|
|
868
|
+
params.set("REQUEST", "GetLegendGraphic");
|
|
869
|
+
params.set("LAYER", request.layer);
|
|
870
|
+
if (request.style)
|
|
871
|
+
params.set("STYLE", request.style);
|
|
872
|
+
const format = request.format ?? "image/png";
|
|
873
|
+
params.set("FORMAT", format);
|
|
874
|
+
if (request.width !== undefined)
|
|
875
|
+
params.set("WIDTH", String(Math.trunc(request.width)));
|
|
876
|
+
if (request.height !== undefined)
|
|
877
|
+
params.set("HEIGHT", String(Math.trunc(request.height)));
|
|
878
|
+
if (request.extraParams) {
|
|
879
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
880
|
+
params.set(key, String(value));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const path = `${wmsBasePath(request.serviceId)}?${params.toString()}`;
|
|
884
|
+
const response = await this.requestBytes("GET", path, format, undefined, request.signal);
|
|
885
|
+
return { bytes: response.bytes, contentType: response.contentType };
|
|
886
|
+
}
|
|
887
|
+
// ── WMTS 1.0 ────────────────────────────────────────────────
|
|
888
|
+
async getWmtsCapabilities(request) {
|
|
889
|
+
const params = new URLSearchParams();
|
|
890
|
+
params.set("SERVICE", "WMTS");
|
|
891
|
+
params.set("REQUEST", "GetCapabilities");
|
|
892
|
+
params.set("VERSION", "1.0.0");
|
|
893
|
+
const path = `${wmtsBasePath(request.serviceId)}?${params.toString()}`;
|
|
894
|
+
const { text: xml } = await this.requestText("GET", path, {
|
|
895
|
+
accept: "text/xml,application/xml",
|
|
896
|
+
signal: request.signal,
|
|
897
|
+
});
|
|
898
|
+
return parseWmtsCapabilities(xml);
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Fetch a single WMTS tile. `mode` selects between KVP
|
|
902
|
+
* (`?REQUEST=GetTile&...`) and the RESTful path
|
|
903
|
+
* (`/{layer}/{style}/{tms}/{z}/{y}/{x}.{ext}`). honua-server
|
|
904
|
+
* advertises both; the SDK defaults to RESTful because the wire path
|
|
905
|
+
* is a single string substitution per tile and skips
|
|
906
|
+
* URLSearchParams serialisation on the hot path.
|
|
907
|
+
*/
|
|
908
|
+
async fetchWmtsTile(request) {
|
|
909
|
+
const mode = request.mode ?? "rest";
|
|
910
|
+
const format = request.format ?? "image/png";
|
|
911
|
+
const style = request.style ?? "default";
|
|
912
|
+
const tileMatrixSet = request.tileMatrixSet ?? "WebMercatorQuad";
|
|
913
|
+
if (mode === "kvp") {
|
|
914
|
+
const params = new URLSearchParams();
|
|
915
|
+
params.set("SERVICE", "WMTS");
|
|
916
|
+
params.set("VERSION", "1.0.0");
|
|
917
|
+
params.set("REQUEST", "GetTile");
|
|
918
|
+
params.set("LAYER", request.layer);
|
|
919
|
+
params.set("STYLE", style);
|
|
920
|
+
params.set("FORMAT", format);
|
|
921
|
+
params.set("TILEMATRIXSET", tileMatrixSet);
|
|
922
|
+
params.set("TILEMATRIX", String(request.tileMatrix));
|
|
923
|
+
params.set("TILEROW", String(request.tileRow));
|
|
924
|
+
params.set("TILECOL", String(request.tileCol));
|
|
925
|
+
if (request.extraParams) {
|
|
926
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
927
|
+
params.set(key, String(value));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const path = `${wmtsBasePath(request.serviceId)}?${params.toString()}`;
|
|
931
|
+
return this.requestBytes("GET", path, format, undefined, request.signal);
|
|
932
|
+
}
|
|
933
|
+
const ext = wmtsExtensionForFormat(format);
|
|
934
|
+
const base = wmtsBasePath(request.serviceId);
|
|
935
|
+
const layer = encodeURIComponent(request.layer);
|
|
936
|
+
const styleSeg = encodeURIComponent(style);
|
|
937
|
+
const tmsSeg = encodeURIComponent(tileMatrixSet);
|
|
938
|
+
const tm = encodeURIComponent(String(request.tileMatrix));
|
|
939
|
+
const tr = encodeURIComponent(String(request.tileRow));
|
|
940
|
+
const tc = encodeURIComponent(String(request.tileCol));
|
|
941
|
+
const extra = wmtsRestExtraParamsSuffix(request.extraParams);
|
|
942
|
+
const path = `${base}/${layer}/${styleSeg}/${tmsSeg}/${tm}/${tr}/${tc}.${ext}${extra}`;
|
|
943
|
+
return this.requestBytes("GET", path, format, undefined, request.signal);
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* WMTS GetFeatureInfo. honua-server accepts both KVP and RESTful
|
|
947
|
+
* routing; mode default mirrors `fetchWmtsTile`.
|
|
948
|
+
*/
|
|
949
|
+
async getWmtsFeatureInfo(request) {
|
|
950
|
+
const mode = request.mode ?? "rest";
|
|
951
|
+
const infoFormat = request.infoFormat ?? "application/json";
|
|
952
|
+
const format = request.format ?? "image/png";
|
|
953
|
+
const style = request.style ?? "default";
|
|
954
|
+
const tileMatrixSet = request.tileMatrixSet ?? "WebMercatorQuad";
|
|
955
|
+
if (mode === "kvp") {
|
|
956
|
+
const params = new URLSearchParams();
|
|
957
|
+
params.set("SERVICE", "WMTS");
|
|
958
|
+
params.set("VERSION", "1.0.0");
|
|
959
|
+
params.set("REQUEST", "GetFeatureInfo");
|
|
960
|
+
params.set("LAYER", request.layer);
|
|
961
|
+
params.set("STYLE", style);
|
|
962
|
+
params.set("FORMAT", format);
|
|
963
|
+
params.set("TILEMATRIXSET", tileMatrixSet);
|
|
964
|
+
params.set("TILEMATRIX", String(request.tileMatrix));
|
|
965
|
+
params.set("TILEROW", String(request.tileRow));
|
|
966
|
+
params.set("TILECOL", String(request.tileCol));
|
|
967
|
+
params.set("I", String(Math.trunc(request.i)));
|
|
968
|
+
params.set("J", String(Math.trunc(request.j)));
|
|
969
|
+
params.set("INFOFORMAT", infoFormat);
|
|
970
|
+
if (request.extraParams) {
|
|
971
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
972
|
+
params.set(key, String(value));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const path = `${wmtsBasePath(request.serviceId)}?${params.toString()}`;
|
|
976
|
+
const response = await this.requestBytes("GET", path, infoFormat, undefined, request.signal);
|
|
977
|
+
return decodeWmsFeatureInfoResponse(response.bytes, response.contentType, infoFormat);
|
|
978
|
+
}
|
|
979
|
+
const ext = wmtsFeatureInfoExtensionForFormat(infoFormat);
|
|
980
|
+
const base = wmtsBasePath(request.serviceId);
|
|
981
|
+
const layer = encodeURIComponent(request.layer);
|
|
982
|
+
const styleSeg = encodeURIComponent(style);
|
|
983
|
+
const tmsSeg = encodeURIComponent(tileMatrixSet);
|
|
984
|
+
const tm = encodeURIComponent(String(request.tileMatrix));
|
|
985
|
+
const tr = encodeURIComponent(String(request.tileRow));
|
|
986
|
+
const tc = encodeURIComponent(String(request.tileCol));
|
|
987
|
+
const jSeg = encodeURIComponent(String(Math.trunc(request.j)));
|
|
988
|
+
const iSeg = encodeURIComponent(String(Math.trunc(request.i)));
|
|
989
|
+
const extra = wmtsRestExtraParamsSuffix(request.extraParams);
|
|
990
|
+
const path = `${base}/${layer}/${styleSeg}/${tmsSeg}/${tm}/${tr}/${tc}/${jSeg}/${iSeg}.${ext}${extra}`;
|
|
991
|
+
const response = await this.requestBytes("GET", path, infoFormat, undefined, request.signal);
|
|
992
|
+
return decodeWmsFeatureInfoResponse(response.bytes, response.contentType, infoFormat);
|
|
993
|
+
}
|
|
994
|
+
// ── OGC API Processes ───────────────────────────────────────
|
|
995
|
+
async getOgcProcessesLanding(request = {}) {
|
|
996
|
+
const params = createOgcMetadataParams(request);
|
|
997
|
+
return this.requestCachedMetadataJson(`ogc-processes:landing:${params.toString()}`, `/ogc/processes?${params.toString()}`, request);
|
|
998
|
+
}
|
|
999
|
+
async getOgcProcessesConformance(request = {}) {
|
|
1000
|
+
const params = createOgcMetadataParams(request);
|
|
1001
|
+
return this.requestCachedMetadataJson(`ogc-processes:conformance:${params.toString()}`, `/ogc/processes/conformance?${params.toString()}`, request);
|
|
1002
|
+
}
|
|
1003
|
+
async listOgcProcesses(request = {}) {
|
|
1004
|
+
const params = createOgcMetadataParams(request);
|
|
1005
|
+
return this.requestCachedMetadataJson(`ogc-processes:processes:${params.toString()}`, `/ogc/processes/processes?${params.toString()}`, request);
|
|
1006
|
+
}
|
|
1007
|
+
async getOgcProcess(request) {
|
|
1008
|
+
const params = createOgcMetadataParams(request);
|
|
1009
|
+
return this.requestCachedMetadataJson(`ogc-processes:process:${request.processId}:${params.toString()}`, `/ogc/processes/processes/${encodeURIComponent(request.processId)}?${params.toString()}`, request);
|
|
1010
|
+
}
|
|
1011
|
+
async executeOgcProcess(request) {
|
|
1012
|
+
const headers = mergeHeaders({ "Content-Type": "application/json", Accept: "application/json" }, request.headers, preferHeaderForExecute(request));
|
|
1013
|
+
const path = `/ogc/processes/processes/${encodeURIComponent(request.processId)}/execution`;
|
|
1014
|
+
// honua-server only supports `response: "document"` and rejects "raw"
|
|
1015
|
+
// with HTTP 501; the SDK pins the supported value here.
|
|
1016
|
+
const body = JSON.stringify({
|
|
1017
|
+
inputs: request.inputs ?? {},
|
|
1018
|
+
outputs: request.outputs,
|
|
1019
|
+
response: "document",
|
|
1020
|
+
});
|
|
1021
|
+
return this.requestJson("POST", path, { headers, body }, request.signal);
|
|
1022
|
+
}
|
|
1023
|
+
async getOgcProcessJob(request) {
|
|
1024
|
+
const params = createOgcMetadataParams(request);
|
|
1025
|
+
return this.requestJson("GET", `/ogc/processes/jobs/${encodeURIComponent(request.jobId)}?${params.toString()}`, undefined, request.signal);
|
|
1026
|
+
}
|
|
1027
|
+
async getOgcProcessJobResults(request) {
|
|
1028
|
+
const params = createOgcMetadataParams(request);
|
|
1029
|
+
return this.requestJson("GET", `/ogc/processes/jobs/${encodeURIComponent(request.jobId)}/results?${params.toString()}`, undefined, request.signal);
|
|
1030
|
+
}
|
|
1031
|
+
async cancelOgcProcessJob(request) {
|
|
1032
|
+
const params = createOgcMetadataParams(request);
|
|
1033
|
+
return this.requestJson("DELETE", `/ogc/processes/jobs/${encodeURIComponent(request.jobId)}?${params.toString()}`, undefined, request.signal);
|
|
1034
|
+
}
|
|
1035
|
+
// ── STAC API ────────────────────────────────────────────────
|
|
1036
|
+
async getStacLanding(request = {}) {
|
|
1037
|
+
const params = createOgcMetadataParams(request);
|
|
1038
|
+
return this.requestCachedMetadataJson(`stac:landing:${params.toString()}`, `/stac?${params.toString()}`, request);
|
|
1039
|
+
}
|
|
1040
|
+
async listStacCollections(request = {}) {
|
|
1041
|
+
const params = createOgcMetadataParams(request);
|
|
1042
|
+
return this.requestCachedMetadataJson(`stac:collections:${params.toString()}`, `/stac/collections?${params.toString()}`, request);
|
|
1043
|
+
}
|
|
1044
|
+
async getStacCollection(request) {
|
|
1045
|
+
const params = createOgcMetadataParams(request);
|
|
1046
|
+
return this.requestCachedMetadataJson(`stac:collection:${request.collectionId}:${params.toString()}`, `/stac/collections/${encodeURIComponent(String(request.collectionId))}?${params.toString()}`, request);
|
|
1047
|
+
}
|
|
1048
|
+
async getStacItem(request) {
|
|
1049
|
+
const params = createOgcMetadataParams(request);
|
|
1050
|
+
const path = `/stac/collections/${encodeURIComponent(String(request.collectionId))}` +
|
|
1051
|
+
`/items/${encodeURIComponent(String(request.itemId))}`;
|
|
1052
|
+
return this.requestJson("GET", `${path}?${params.toString()}`, undefined, request.signal);
|
|
1053
|
+
}
|
|
1054
|
+
async searchStac(request = {}) {
|
|
1055
|
+
if (request.usePost) {
|
|
1056
|
+
return this.requestJson("POST", "/stac/search", {
|
|
1057
|
+
headers: mergeHeaders({ "Content-Type": "application/json", Accept: "application/json" }),
|
|
1058
|
+
body: JSON.stringify(stacSearchBody(request)),
|
|
1059
|
+
}, request.signal);
|
|
1060
|
+
}
|
|
1061
|
+
const params = serializeStacSearchParams(request);
|
|
1062
|
+
return this.requestJson("GET", `/stac/search?${params.toString()}`, undefined, request.signal);
|
|
1063
|
+
}
|
|
1064
|
+
async getMapServiceMetadata(serviceId, options = {}) {
|
|
1065
|
+
const query = new URLSearchParams({ f: "json" });
|
|
1066
|
+
const path = `/rest/services/${encodeURIComponent(serviceId)}/MapServer?${query.toString()}`;
|
|
1067
|
+
return this.requestCachedMetadataJson(`geoservices-map:${serviceId}:service`, path, options);
|
|
1068
|
+
}
|
|
1069
|
+
async getMapLayerMetadata(serviceId, layerId, options = {}) {
|
|
1070
|
+
const query = new URLSearchParams({ f: "json" });
|
|
1071
|
+
const path = `/rest/services/${encodeURIComponent(serviceId)}/MapServer/${layerId}?${query.toString()}`;
|
|
1072
|
+
return this.requestCachedMetadataJson(`geoservices-map:${serviceId}:${layerId}`, path, options);
|
|
1073
|
+
}
|
|
1074
|
+
async queryFeatures(request) {
|
|
1075
|
+
if (this.transport === "grpc-web") {
|
|
1076
|
+
const client = await this.ensureConnectClient();
|
|
1077
|
+
const grpcAdapter = await HonuaClient.loadGrpcAdapter();
|
|
1078
|
+
const protoRequest = grpcAdapter.toProtoQueryRequest(request);
|
|
1079
|
+
try {
|
|
1080
|
+
const response = await client.queryFeatures(protoRequest);
|
|
1081
|
+
return grpcAdapter.fromProtoQueryResponse(response);
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
throw grpcAdapter.wrapConnectError(error);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const method = request.method ?? "GET";
|
|
1088
|
+
const usePbf = this.preferBinary && method === "GET";
|
|
1089
|
+
const params = new URLSearchParams();
|
|
1090
|
+
params.set("f", usePbf ? "pbf" : "json");
|
|
1091
|
+
params.set("where", request.where ?? "1=1");
|
|
1092
|
+
params.set("outFields", normalizeOutFields(request.outFields));
|
|
1093
|
+
params.set("returnGeometry", String(request.returnGeometry ?? true));
|
|
1094
|
+
serializeQueryParams(params, request);
|
|
1095
|
+
appendQueryExtraParams(params, request);
|
|
1096
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}/FeatureServer/${request.layerId}/query`;
|
|
1097
|
+
if (usePbf) {
|
|
1098
|
+
return this.requestBinaryWithJsonFallback("GET", `${path}?${params.toString()}`, params, request.signal);
|
|
1099
|
+
}
|
|
1100
|
+
if (method === "GET") {
|
|
1101
|
+
return this.requestJson("GET", `${path}?${params.toString()}`, undefined, request.signal);
|
|
1102
|
+
}
|
|
1103
|
+
return this.requestJson("POST", path, {
|
|
1104
|
+
headers: {
|
|
1105
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1106
|
+
},
|
|
1107
|
+
body: params.toString(),
|
|
1108
|
+
}, request.signal);
|
|
1109
|
+
}
|
|
1110
|
+
async queryMapLayer(request) {
|
|
1111
|
+
const method = request.method ?? "GET";
|
|
1112
|
+
const params = new URLSearchParams();
|
|
1113
|
+
params.set("f", "json");
|
|
1114
|
+
params.set("where", request.where ?? "1=1");
|
|
1115
|
+
params.set("outFields", normalizeOutFields(request.outFields));
|
|
1116
|
+
params.set("returnGeometry", String(request.returnGeometry ?? true));
|
|
1117
|
+
serializeQueryParams(params, request);
|
|
1118
|
+
appendQueryExtraParams(params, request);
|
|
1119
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}/MapServer/${request.layerId}/query`;
|
|
1120
|
+
if (method === "GET") {
|
|
1121
|
+
return this.requestJson("GET", `${path}?${params.toString()}`, undefined, request.signal);
|
|
1122
|
+
}
|
|
1123
|
+
return this.requestJson("POST", path, {
|
|
1124
|
+
headers: {
|
|
1125
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1126
|
+
},
|
|
1127
|
+
body: params.toString(),
|
|
1128
|
+
}, request.signal);
|
|
1129
|
+
}
|
|
1130
|
+
async applyEdits(request) {
|
|
1131
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}/FeatureServer/${request.layerId}/applyEdits`;
|
|
1132
|
+
const params = new URLSearchParams();
|
|
1133
|
+
params.set("f", "json");
|
|
1134
|
+
params.set("rollbackOnFailure", String(request.rollbackOnFailure ?? true));
|
|
1135
|
+
if (request.adds !== undefined) {
|
|
1136
|
+
params.set("adds", encodeFormValue(request.adds));
|
|
1137
|
+
}
|
|
1138
|
+
if (request.updates !== undefined) {
|
|
1139
|
+
params.set("updates", encodeFormValue(request.updates));
|
|
1140
|
+
}
|
|
1141
|
+
if (request.deletes !== undefined) {
|
|
1142
|
+
params.set("deletes", encodeDeletesValue(request.deletes));
|
|
1143
|
+
}
|
|
1144
|
+
return this.requestJson("POST", path, {
|
|
1145
|
+
headers: {
|
|
1146
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1147
|
+
},
|
|
1148
|
+
body: params.toString(),
|
|
1149
|
+
}, request.signal);
|
|
1150
|
+
}
|
|
1151
|
+
async queryRelatedRecords(request) {
|
|
1152
|
+
const method = request.method ?? "GET";
|
|
1153
|
+
const params = new URLSearchParams();
|
|
1154
|
+
params.set("f", "json");
|
|
1155
|
+
params.set("relationshipId", String(request.relationshipId));
|
|
1156
|
+
if (request.objectIds !== undefined) {
|
|
1157
|
+
params.set("objectIds", Array.isArray(request.objectIds) ? request.objectIds.join(",") : String(request.objectIds));
|
|
1158
|
+
}
|
|
1159
|
+
params.set("where", request.where ?? "1=1");
|
|
1160
|
+
params.set("outFields", normalizeOutFields(request.outFields));
|
|
1161
|
+
params.set("returnGeometry", String(request.returnGeometry ?? true));
|
|
1162
|
+
if (request.extraParams) {
|
|
1163
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
1164
|
+
params.set(key, String(value));
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}` +
|
|
1168
|
+
`/FeatureServer/${request.layerId}/queryRelatedRecords`;
|
|
1169
|
+
if (method === "GET") {
|
|
1170
|
+
return this.requestJson("GET", `${path}?${params.toString()}`, undefined, request.signal);
|
|
1171
|
+
}
|
|
1172
|
+
return this.requestJson("POST", path, {
|
|
1173
|
+
headers: {
|
|
1174
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1175
|
+
},
|
|
1176
|
+
body: params.toString(),
|
|
1177
|
+
}, request.signal);
|
|
1178
|
+
}
|
|
1179
|
+
async queryMapRelatedRecords(request) {
|
|
1180
|
+
const method = request.method ?? "GET";
|
|
1181
|
+
const params = new URLSearchParams();
|
|
1182
|
+
params.set("f", "json");
|
|
1183
|
+
params.set("relationshipId", String(request.relationshipId));
|
|
1184
|
+
if (request.objectIds !== undefined) {
|
|
1185
|
+
params.set("objectIds", Array.isArray(request.objectIds) ? request.objectIds.join(",") : String(request.objectIds));
|
|
1186
|
+
}
|
|
1187
|
+
params.set("where", request.where ?? "1=1");
|
|
1188
|
+
params.set("outFields", normalizeOutFields(request.outFields));
|
|
1189
|
+
params.set("returnGeometry", String(request.returnGeometry ?? true));
|
|
1190
|
+
if (request.extraParams) {
|
|
1191
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
1192
|
+
params.set(key, String(value));
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}` + `/MapServer/${request.layerId}/queryRelatedRecords`;
|
|
1196
|
+
if (method === "GET") {
|
|
1197
|
+
return this.requestJson("GET", `${path}?${params.toString()}`, undefined, request.signal);
|
|
1198
|
+
}
|
|
1199
|
+
return this.requestJson("POST", path, {
|
|
1200
|
+
headers: {
|
|
1201
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1202
|
+
},
|
|
1203
|
+
body: params.toString(),
|
|
1204
|
+
}, request.signal);
|
|
1205
|
+
}
|
|
1206
|
+
async exportMap(request) {
|
|
1207
|
+
const method = request.method ?? "GET";
|
|
1208
|
+
const params = new URLSearchParams();
|
|
1209
|
+
params.set("f", request.responseFormat ?? "json");
|
|
1210
|
+
params.set("bbox", normalizeBBox(request.bbox));
|
|
1211
|
+
params.set("size", normalizeSize(request.size));
|
|
1212
|
+
if (request.format !== undefined) {
|
|
1213
|
+
params.set("format", request.format);
|
|
1214
|
+
}
|
|
1215
|
+
if (request.dpi !== undefined) {
|
|
1216
|
+
params.set("dpi", String(request.dpi));
|
|
1217
|
+
}
|
|
1218
|
+
if (request.transparent !== undefined) {
|
|
1219
|
+
params.set("transparent", String(request.transparent));
|
|
1220
|
+
}
|
|
1221
|
+
if (request.layers !== undefined) {
|
|
1222
|
+
params.set("layers", request.layers);
|
|
1223
|
+
}
|
|
1224
|
+
if (request.bboxSr !== undefined) {
|
|
1225
|
+
params.set("bboxSR", String(request.bboxSr));
|
|
1226
|
+
}
|
|
1227
|
+
if (request.imageSr !== undefined) {
|
|
1228
|
+
params.set("imageSR", String(request.imageSr));
|
|
1229
|
+
}
|
|
1230
|
+
if (request.backgroundColor !== undefined) {
|
|
1231
|
+
params.set("backgroundColor", request.backgroundColor);
|
|
1232
|
+
}
|
|
1233
|
+
if (request.extraParams) {
|
|
1234
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
1235
|
+
params.set(key, String(value));
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}/MapServer/export`;
|
|
1239
|
+
if (method === "GET") {
|
|
1240
|
+
return this.requestJson("GET", `${path}?${params.toString()}`);
|
|
1241
|
+
}
|
|
1242
|
+
return this.requestJson("POST", path, {
|
|
1243
|
+
headers: {
|
|
1244
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1245
|
+
},
|
|
1246
|
+
body: params.toString(),
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
async getMapLegend(request) {
|
|
1250
|
+
const params = new URLSearchParams();
|
|
1251
|
+
params.set("f", request.responseFormat ?? "json");
|
|
1252
|
+
if (request.size !== undefined) {
|
|
1253
|
+
params.set("size", normalizeLegendSize(request.size));
|
|
1254
|
+
}
|
|
1255
|
+
if (request.dynamicLayers !== undefined) {
|
|
1256
|
+
params.set("dynamicLayers", request.dynamicLayers);
|
|
1257
|
+
}
|
|
1258
|
+
if (request.extraParams) {
|
|
1259
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
1260
|
+
params.set(key, String(value));
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}/MapServer/legend`;
|
|
1264
|
+
return this.requestJson("GET", `${path}?${params.toString()}`);
|
|
1265
|
+
}
|
|
1266
|
+
async identifyMap(request) {
|
|
1267
|
+
const method = request.method ?? "GET";
|
|
1268
|
+
const params = new URLSearchParams();
|
|
1269
|
+
params.set("f", request.responseFormat ?? "json");
|
|
1270
|
+
params.set("geometry", normalizeIdentifyGeometry(request.geometry));
|
|
1271
|
+
params.set("geometryType", request.geometryType ?? "esriGeometryPoint");
|
|
1272
|
+
params.set("mapExtent", normalizeMapExtent(request.mapExtent));
|
|
1273
|
+
params.set("imageDisplay", normalizeImageDisplay(request.imageDisplay));
|
|
1274
|
+
params.set("returnGeometry", String(request.returnGeometry ?? true));
|
|
1275
|
+
params.set("tolerance", String(request.tolerance ?? 3));
|
|
1276
|
+
if (request.sr !== undefined) {
|
|
1277
|
+
params.set("sr", String(request.sr));
|
|
1278
|
+
}
|
|
1279
|
+
if (request.layers !== undefined) {
|
|
1280
|
+
params.set("layers", request.layers);
|
|
1281
|
+
}
|
|
1282
|
+
if (request.maxAllowableOffset !== undefined) {
|
|
1283
|
+
params.set("maxAllowableOffset", String(request.maxAllowableOffset));
|
|
1284
|
+
}
|
|
1285
|
+
if (request.layerDefs !== undefined) {
|
|
1286
|
+
params.set("layerDefs", request.layerDefs);
|
|
1287
|
+
}
|
|
1288
|
+
if (request.dynamicLayers !== undefined) {
|
|
1289
|
+
params.set("dynamicLayers", request.dynamicLayers);
|
|
1290
|
+
}
|
|
1291
|
+
if (request.time !== undefined) {
|
|
1292
|
+
params.set("time", request.time);
|
|
1293
|
+
}
|
|
1294
|
+
if (request.extraParams) {
|
|
1295
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
1296
|
+
params.set(key, String(value));
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}/MapServer/identify`;
|
|
1300
|
+
if (method === "GET") {
|
|
1301
|
+
return this.requestJson("GET", `${path}?${params.toString()}`);
|
|
1302
|
+
}
|
|
1303
|
+
return this.requestJson("POST", path, {
|
|
1304
|
+
headers: {
|
|
1305
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1306
|
+
},
|
|
1307
|
+
body: params.toString(),
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
async findMap(request) {
|
|
1311
|
+
const method = request.method ?? "GET";
|
|
1312
|
+
const params = new URLSearchParams();
|
|
1313
|
+
params.set("f", request.responseFormat ?? "json");
|
|
1314
|
+
params.set("searchText", request.searchText);
|
|
1315
|
+
params.set("contains", String(request.contains ?? true));
|
|
1316
|
+
if (request.searchFields !== undefined) {
|
|
1317
|
+
params.set("searchFields", normalizeSearchFields(request.searchFields));
|
|
1318
|
+
}
|
|
1319
|
+
if (request.layers !== undefined) {
|
|
1320
|
+
params.set("layers", request.layers);
|
|
1321
|
+
}
|
|
1322
|
+
if (request.sr !== undefined) {
|
|
1323
|
+
params.set("sr", String(request.sr));
|
|
1324
|
+
}
|
|
1325
|
+
if (request.layerDefs !== undefined) {
|
|
1326
|
+
params.set("layerDefs", request.layerDefs);
|
|
1327
|
+
}
|
|
1328
|
+
if (request.returnGeometry !== undefined) {
|
|
1329
|
+
params.set("returnGeometry", String(request.returnGeometry));
|
|
1330
|
+
}
|
|
1331
|
+
if (request.maxAllowableOffset !== undefined) {
|
|
1332
|
+
params.set("maxAllowableOffset", String(request.maxAllowableOffset));
|
|
1333
|
+
}
|
|
1334
|
+
if (request.dynamicLayers !== undefined) {
|
|
1335
|
+
params.set("dynamicLayers", request.dynamicLayers);
|
|
1336
|
+
}
|
|
1337
|
+
if (request.returnZ !== undefined) {
|
|
1338
|
+
params.set("returnZ", String(request.returnZ));
|
|
1339
|
+
}
|
|
1340
|
+
if (request.returnM !== undefined) {
|
|
1341
|
+
params.set("returnM", String(request.returnM));
|
|
1342
|
+
}
|
|
1343
|
+
if (request.gdbVersion !== undefined) {
|
|
1344
|
+
params.set("gdbVersion", request.gdbVersion);
|
|
1345
|
+
}
|
|
1346
|
+
if (request.time !== undefined) {
|
|
1347
|
+
params.set("time", request.time);
|
|
1348
|
+
}
|
|
1349
|
+
if (request.relationParam !== undefined) {
|
|
1350
|
+
params.set("relationParam", request.relationParam);
|
|
1351
|
+
}
|
|
1352
|
+
if (request.extraParams) {
|
|
1353
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
1354
|
+
params.set(key, String(value));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const path = `/rest/services/${encodeURIComponent(request.serviceId)}/MapServer/find`;
|
|
1358
|
+
if (method === "GET") {
|
|
1359
|
+
return this.requestJson("GET", `${path}?${params.toString()}`);
|
|
1360
|
+
}
|
|
1361
|
+
return this.requestJson("POST", path, {
|
|
1362
|
+
headers: {
|
|
1363
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1364
|
+
},
|
|
1365
|
+
body: params.toString(),
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
async requestJson(method, path, init, callerSignal) {
|
|
1369
|
+
let request = {
|
|
1370
|
+
url: resolveRequestUrl(this.baseUrl, path),
|
|
1371
|
+
path,
|
|
1372
|
+
method,
|
|
1373
|
+
init: {
|
|
1374
|
+
method,
|
|
1375
|
+
headers: await this.composeHeaders({ Accept: "application/json" }, init?.headers),
|
|
1376
|
+
body: init?.body,
|
|
1377
|
+
},
|
|
1378
|
+
};
|
|
1379
|
+
request = await this.applyBeforeInterceptors(request);
|
|
1380
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1381
|
+
let response;
|
|
1382
|
+
const timeout = createTimeoutSignal(callerSignal ?? request.init.signal, this.timeoutMs);
|
|
1383
|
+
const startTime = performance.now();
|
|
1384
|
+
try {
|
|
1385
|
+
response = await this.fetchFn(request.url, {
|
|
1386
|
+
...request.init,
|
|
1387
|
+
method: request.method,
|
|
1388
|
+
signal: timeout.signal,
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
catch (error) {
|
|
1392
|
+
const durationMs = performance.now() - startTime;
|
|
1393
|
+
const normalizedError = timeout.didTimeout
|
|
1394
|
+
? new HonuaTimeoutError(this.timeoutMs ?? 0)
|
|
1395
|
+
: normalizeNetworkError(error);
|
|
1396
|
+
if (this.shouldRetryRequest(request.method, attempt, undefined, normalizedError)) {
|
|
1397
|
+
await sleep(this.resolveRetryDelayMs(attempt));
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
await this.applyErrorInterceptors({
|
|
1401
|
+
request: cloneRequestContext(request),
|
|
1402
|
+
error: normalizedError,
|
|
1403
|
+
durationMs,
|
|
1404
|
+
});
|
|
1405
|
+
throw normalizedError;
|
|
1406
|
+
}
|
|
1407
|
+
finally {
|
|
1408
|
+
timeout.dispose();
|
|
1409
|
+
}
|
|
1410
|
+
const durationMs = performance.now() - startTime;
|
|
1411
|
+
const body = await parseResponseBody(response.clone());
|
|
1412
|
+
if (!response.ok) {
|
|
1413
|
+
const httpError = this.toHttpError(response.status, body);
|
|
1414
|
+
if (this.shouldRetryRequest(request.method, attempt, response.status, httpError)) {
|
|
1415
|
+
await sleep(this.resolveRetryDelayMs(attempt, response));
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error: httpError, durationMs });
|
|
1419
|
+
throw httpError;
|
|
1420
|
+
}
|
|
1421
|
+
try {
|
|
1422
|
+
await this.applyAfterInterceptors(cloneRequestContext(request), response, durationMs);
|
|
1423
|
+
}
|
|
1424
|
+
catch (error) {
|
|
1425
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error, durationMs });
|
|
1426
|
+
throw error;
|
|
1427
|
+
}
|
|
1428
|
+
return body;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Request a PBF binary response and decode it. Falls back to JSON on failure.
|
|
1433
|
+
*/
|
|
1434
|
+
async requestBinaryWithJsonFallback(method, path, params, callerSignal) {
|
|
1435
|
+
let request = {
|
|
1436
|
+
url: resolveRequestUrl(this.baseUrl, path),
|
|
1437
|
+
path,
|
|
1438
|
+
method,
|
|
1439
|
+
init: {
|
|
1440
|
+
method,
|
|
1441
|
+
headers: await this.composeHeaders({ Accept: "application/x-protobuf, application/json;q=0.9" }),
|
|
1442
|
+
},
|
|
1443
|
+
};
|
|
1444
|
+
request = await this.applyBeforeInterceptors(request);
|
|
1445
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1446
|
+
let response;
|
|
1447
|
+
const timeout = createTimeoutSignal(callerSignal ?? request.init.signal, this.timeoutMs);
|
|
1448
|
+
const startTime = performance.now();
|
|
1449
|
+
try {
|
|
1450
|
+
response = await this.fetchFn(request.url, {
|
|
1451
|
+
...request.init,
|
|
1452
|
+
method: request.method,
|
|
1453
|
+
signal: timeout.signal,
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
catch (error) {
|
|
1457
|
+
const durationMs = performance.now() - startTime;
|
|
1458
|
+
const normalizedError = timeout.didTimeout
|
|
1459
|
+
? new HonuaTimeoutError(this.timeoutMs ?? 0)
|
|
1460
|
+
: normalizeNetworkError(error);
|
|
1461
|
+
if (this.shouldRetryRequest(request.method, attempt, undefined, normalizedError)) {
|
|
1462
|
+
await sleep(this.resolveRetryDelayMs(attempt));
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
await this.applyErrorInterceptors({
|
|
1466
|
+
request: cloneRequestContext(request),
|
|
1467
|
+
error: normalizedError,
|
|
1468
|
+
durationMs,
|
|
1469
|
+
});
|
|
1470
|
+
throw normalizedError;
|
|
1471
|
+
}
|
|
1472
|
+
finally {
|
|
1473
|
+
timeout.dispose();
|
|
1474
|
+
}
|
|
1475
|
+
const durationMs = performance.now() - startTime;
|
|
1476
|
+
if (!response.ok) {
|
|
1477
|
+
const body = await parseResponseBody(response.clone());
|
|
1478
|
+
const httpError = this.toHttpError(response.status, body);
|
|
1479
|
+
if (this.shouldRetryRequest(request.method, attempt, response.status, httpError)) {
|
|
1480
|
+
await sleep(this.resolveRetryDelayMs(attempt, response));
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error: httpError, durationMs });
|
|
1484
|
+
throw httpError;
|
|
1485
|
+
}
|
|
1486
|
+
try {
|
|
1487
|
+
await this.applyAfterInterceptors(cloneRequestContext(request), response, durationMs);
|
|
1488
|
+
}
|
|
1489
|
+
catch (error) {
|
|
1490
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error, durationMs });
|
|
1491
|
+
throw error;
|
|
1492
|
+
}
|
|
1493
|
+
// If server returned PBF, decode it
|
|
1494
|
+
if (isPbfResponse(response)) {
|
|
1495
|
+
try {
|
|
1496
|
+
const buffer = await response.arrayBuffer();
|
|
1497
|
+
return decodePbfQueryResponse(buffer);
|
|
1498
|
+
}
|
|
1499
|
+
catch {
|
|
1500
|
+
// PBF decode failed — fall back to JSON request
|
|
1501
|
+
params.set("f", "json");
|
|
1502
|
+
const jsonPath = `${path.replace(/\?.*$/, "")}?${params.toString()}`;
|
|
1503
|
+
return this.requestJson("GET", jsonPath, undefined, callerSignal ?? request.init.signal ?? undefined);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
// Server returned JSON despite PBF request (e.g. error or unsupported)
|
|
1507
|
+
return parseResponseBody(response);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Fetch a text response (e.g. XML / JSON / plain) with an explicit Accept
|
|
1512
|
+
* negotiation. Used by the WFS adapter for `GetCapabilities`,
|
|
1513
|
+
* `Transaction` responses, and ExceptionReport bodies, and by the
|
|
1514
|
+
* WMS / WMTS Capabilities pipelines. Routes through the same
|
|
1515
|
+
* interceptor / retry / abort pipeline as `requestJson` /
|
|
1516
|
+
* `requestBytes`, so adapter callers do not need to bypass `HonuaClient`
|
|
1517
|
+
* to reach `fetch` directly.
|
|
1518
|
+
*/
|
|
1519
|
+
async requestText(method, path, options) {
|
|
1520
|
+
const acceptHeader = options?.accept ?? "*/*";
|
|
1521
|
+
const headers = { Accept: acceptHeader };
|
|
1522
|
+
if (options?.contentType)
|
|
1523
|
+
headers["Content-Type"] = options.contentType;
|
|
1524
|
+
let request = {
|
|
1525
|
+
url: resolveRequestUrl(this.baseUrl, path),
|
|
1526
|
+
path,
|
|
1527
|
+
method,
|
|
1528
|
+
init: {
|
|
1529
|
+
method,
|
|
1530
|
+
headers: await this.composeHeaders(headers),
|
|
1531
|
+
...(options?.body !== undefined ? { body: options.body } : {}),
|
|
1532
|
+
},
|
|
1533
|
+
};
|
|
1534
|
+
request = await this.applyBeforeInterceptors(request);
|
|
1535
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1536
|
+
let response;
|
|
1537
|
+
const timeout = createTimeoutSignal(options?.signal ?? request.init.signal, this.timeoutMs);
|
|
1538
|
+
const startTime = performance.now();
|
|
1539
|
+
try {
|
|
1540
|
+
response = await this.fetchFn(request.url, {
|
|
1541
|
+
...request.init,
|
|
1542
|
+
method: request.method,
|
|
1543
|
+
signal: timeout.signal,
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
catch (error) {
|
|
1547
|
+
const durationMs = performance.now() - startTime;
|
|
1548
|
+
const normalizedError = timeout.didTimeout
|
|
1549
|
+
? new HonuaTimeoutError(this.timeoutMs ?? 0)
|
|
1550
|
+
: normalizeNetworkError(error);
|
|
1551
|
+
if (this.shouldRetryRequest(request.method, attempt, undefined, normalizedError)) {
|
|
1552
|
+
await sleep(this.resolveRetryDelayMs(attempt));
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
await this.applyErrorInterceptors({
|
|
1556
|
+
request: cloneRequestContext(request),
|
|
1557
|
+
error: normalizedError,
|
|
1558
|
+
durationMs,
|
|
1559
|
+
});
|
|
1560
|
+
throw normalizedError;
|
|
1561
|
+
}
|
|
1562
|
+
finally {
|
|
1563
|
+
timeout.dispose();
|
|
1564
|
+
}
|
|
1565
|
+
const durationMs = performance.now() - startTime;
|
|
1566
|
+
const text = await response.clone().text();
|
|
1567
|
+
const contentType = response.headers.get("content-type") ?? acceptHeader;
|
|
1568
|
+
if (!response.ok) {
|
|
1569
|
+
const httpError = this.toHttpError(response.status, text ? { raw: text, contentType } : {});
|
|
1570
|
+
if (this.shouldRetryRequest(request.method, attempt, response.status, httpError)) {
|
|
1571
|
+
await sleep(this.resolveRetryDelayMs(attempt, response));
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error: httpError, durationMs });
|
|
1575
|
+
throw httpError;
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
await this.applyAfterInterceptors(cloneRequestContext(request), response, durationMs);
|
|
1579
|
+
}
|
|
1580
|
+
catch (error) {
|
|
1581
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error, durationMs });
|
|
1582
|
+
throw error;
|
|
1583
|
+
}
|
|
1584
|
+
return { text, contentType, status: response.status };
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Fetch a binary response (raw bytes plus content type). Used by the
|
|
1589
|
+
* OGC API Tiles and OGC API Maps wire methods, both of which negotiate
|
|
1590
|
+
* non-JSON output formats. The interceptor / retry / abort plumbing
|
|
1591
|
+
* mirrors `requestJson`.
|
|
1592
|
+
*/
|
|
1593
|
+
async requestBytes(method, path, accept, init, callerSignal) {
|
|
1594
|
+
const acceptHeader = accept ?? "application/octet-stream";
|
|
1595
|
+
let request = {
|
|
1596
|
+
url: resolveRequestUrl(this.baseUrl, path),
|
|
1597
|
+
path,
|
|
1598
|
+
method,
|
|
1599
|
+
init: {
|
|
1600
|
+
method,
|
|
1601
|
+
headers: await this.composeHeaders({ Accept: acceptHeader }, init?.headers),
|
|
1602
|
+
body: init?.body,
|
|
1603
|
+
},
|
|
1604
|
+
};
|
|
1605
|
+
request = await this.applyBeforeInterceptors(request);
|
|
1606
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1607
|
+
let response;
|
|
1608
|
+
const timeout = createTimeoutSignal(callerSignal ?? request.init.signal, this.timeoutMs);
|
|
1609
|
+
const startTime = performance.now();
|
|
1610
|
+
try {
|
|
1611
|
+
response = await this.fetchFn(request.url, {
|
|
1612
|
+
...request.init,
|
|
1613
|
+
method: request.method,
|
|
1614
|
+
signal: timeout.signal,
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
catch (error) {
|
|
1618
|
+
const durationMs = performance.now() - startTime;
|
|
1619
|
+
const normalizedError = timeout.didTimeout
|
|
1620
|
+
? new HonuaTimeoutError(this.timeoutMs ?? 0)
|
|
1621
|
+
: normalizeNetworkError(error);
|
|
1622
|
+
if (this.shouldRetryRequest(request.method, attempt, undefined, normalizedError)) {
|
|
1623
|
+
await sleep(this.resolveRetryDelayMs(attempt));
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
await this.applyErrorInterceptors({
|
|
1627
|
+
request: cloneRequestContext(request),
|
|
1628
|
+
error: normalizedError,
|
|
1629
|
+
durationMs,
|
|
1630
|
+
});
|
|
1631
|
+
throw normalizedError;
|
|
1632
|
+
}
|
|
1633
|
+
finally {
|
|
1634
|
+
timeout.dispose();
|
|
1635
|
+
}
|
|
1636
|
+
const durationMs = performance.now() - startTime;
|
|
1637
|
+
if (!response.ok) {
|
|
1638
|
+
const body = await parseResponseBody(response.clone());
|
|
1639
|
+
const httpError = this.toHttpError(response.status, body);
|
|
1640
|
+
if (this.shouldRetryRequest(request.method, attempt, response.status, httpError)) {
|
|
1641
|
+
await sleep(this.resolveRetryDelayMs(attempt, response));
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error: httpError, durationMs });
|
|
1645
|
+
throw httpError;
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
await this.applyAfterInterceptors(cloneRequestContext(request), response, durationMs);
|
|
1649
|
+
}
|
|
1650
|
+
catch (error) {
|
|
1651
|
+
await this.applyErrorInterceptors({ request: cloneRequestContext(request), error, durationMs });
|
|
1652
|
+
throw error;
|
|
1653
|
+
}
|
|
1654
|
+
const buffer = await response.arrayBuffer();
|
|
1655
|
+
const bytes = new Uint8Array(buffer);
|
|
1656
|
+
return {
|
|
1657
|
+
bytes,
|
|
1658
|
+
contentType: response.headers.get("content-type") ?? acceptHeader,
|
|
1659
|
+
empty: response.status === 204 || bytes.byteLength === 0,
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
async applyBeforeInterceptors(request) {
|
|
1664
|
+
let next = cloneRequestContext(request);
|
|
1665
|
+
next = {
|
|
1666
|
+
...next,
|
|
1667
|
+
url: normalizeInterceptorRequestUrl(this.baseUrl, next.url),
|
|
1668
|
+
};
|
|
1669
|
+
for (const interceptor of this.interceptors) {
|
|
1670
|
+
const mutation = await interceptor.before?.(cloneRequestContext(next));
|
|
1671
|
+
if (!mutation) {
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
next = applyRequestMutation(next, mutation);
|
|
1675
|
+
next = {
|
|
1676
|
+
...next,
|
|
1677
|
+
url: normalizeInterceptorRequestUrl(this.baseUrl, next.url),
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
return next;
|
|
1681
|
+
}
|
|
1682
|
+
async applyAfterInterceptors(request, response, durationMs) {
|
|
1683
|
+
for (const interceptor of this.interceptors) {
|
|
1684
|
+
const context = {
|
|
1685
|
+
request: cloneRequestContext(request),
|
|
1686
|
+
response: response.clone(),
|
|
1687
|
+
durationMs,
|
|
1688
|
+
};
|
|
1689
|
+
await interceptor.after?.(context);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
async applyErrorInterceptors(context) {
|
|
1693
|
+
for (const interceptor of this.interceptors) {
|
|
1694
|
+
try {
|
|
1695
|
+
await interceptor.error?.(context);
|
|
1696
|
+
}
|
|
1697
|
+
catch {
|
|
1698
|
+
// Preserve original request failure; interceptor failures should not mask it.
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
shouldRetryRequest(method, attempt, statusCode, error) {
|
|
1703
|
+
if (!this.retryOptions || attempt >= this.retryOptions.maxRetries) {
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
if (!DEFAULT_RETRY_METHODS.has(method)) {
|
|
1707
|
+
return false;
|
|
1708
|
+
}
|
|
1709
|
+
if (error instanceof HonuaAbortError) {
|
|
1710
|
+
return false;
|
|
1711
|
+
}
|
|
1712
|
+
if (statusCode !== undefined) {
|
|
1713
|
+
return this.retryOptions.retryStatuses.has(statusCode);
|
|
1714
|
+
}
|
|
1715
|
+
return error instanceof HonuaNetworkError || error instanceof HonuaTimeoutError;
|
|
1716
|
+
}
|
|
1717
|
+
resolveRetryDelayMs(attempt, response) {
|
|
1718
|
+
const retryAfterMs = response ? parseRetryAfterMs(response) : undefined;
|
|
1719
|
+
if (retryAfterMs !== undefined) {
|
|
1720
|
+
return retryAfterMs;
|
|
1721
|
+
}
|
|
1722
|
+
if (!this.retryOptions) {
|
|
1723
|
+
return 0;
|
|
1724
|
+
}
|
|
1725
|
+
const exponentialDelay = this.retryOptions.baseDelayMs * 2 ** attempt;
|
|
1726
|
+
const cappedDelay = Math.min(this.retryOptions.maxDelayMs, exponentialDelay);
|
|
1727
|
+
return cappedDelay * (0.5 + Math.random() * 0.5);
|
|
1728
|
+
}
|
|
1729
|
+
toHttpError(statusCode, body) {
|
|
1730
|
+
const fallback = "Request failed";
|
|
1731
|
+
if (isObject(body)) {
|
|
1732
|
+
const error = body.error;
|
|
1733
|
+
if (isObject(error) && typeof error.message === "string") {
|
|
1734
|
+
return new HonuaHttpError(statusCode, error.message, body);
|
|
1735
|
+
}
|
|
1736
|
+
if (typeof body.message === "string") {
|
|
1737
|
+
return new HonuaHttpError(statusCode, body.message, body);
|
|
1738
|
+
}
|
|
1739
|
+
if (typeof body.detail === "string") {
|
|
1740
|
+
return new HonuaHttpError(statusCode, body.detail, body);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
return new HonuaHttpError(statusCode, fallback, body);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
async function parseResponseBody(response) {
|
|
1747
|
+
const text = await response.text();
|
|
1748
|
+
if (!text) {
|
|
1749
|
+
return {};
|
|
1750
|
+
}
|
|
1751
|
+
try {
|
|
1752
|
+
return JSON.parse(text);
|
|
1753
|
+
}
|
|
1754
|
+
catch {
|
|
1755
|
+
return { raw: text };
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
function isObject(value) {
|
|
1759
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1760
|
+
}
|
|
1761
|
+
function parseCompatibilityEnvelope(payload) {
|
|
1762
|
+
if (!isObject(payload)) {
|
|
1763
|
+
throw new TypeError("Server capabilities response must be a JSON object.");
|
|
1764
|
+
}
|
|
1765
|
+
if (payload.success === false) {
|
|
1766
|
+
const message = typeof payload.message === "string" ? payload.message : "Server capabilities request failed.";
|
|
1767
|
+
throw new Error(message);
|
|
1768
|
+
}
|
|
1769
|
+
if (!isObject(payload.data)) {
|
|
1770
|
+
throw new TypeError("Server capabilities response is missing a data object.");
|
|
1771
|
+
}
|
|
1772
|
+
return parseCompatibilityContract(payload.data.compatibility);
|
|
1773
|
+
}
|
|
1774
|
+
function parseCompatibilityContract(payload) {
|
|
1775
|
+
if (!isObject(payload)) {
|
|
1776
|
+
throw new TypeError("Server capabilities response is missing data.compatibility.");
|
|
1777
|
+
}
|
|
1778
|
+
return {
|
|
1779
|
+
serverVersion: requireNonEmptyString(payload.serverVersion, "data.compatibility.serverVersion"),
|
|
1780
|
+
releaseChannel: requireNonEmptyString(payload.releaseChannel, "data.compatibility.releaseChannel"),
|
|
1781
|
+
controlPlaneApi: parseControlPlaneApi(payload.controlPlaneApi),
|
|
1782
|
+
metadataSchemas: parseMetadataSchemas(payload.metadataSchemas),
|
|
1783
|
+
features: parseCompatibilityFeatures(payload.features),
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
function parseControlPlaneApi(payload) {
|
|
1787
|
+
if (!isObject(payload)) {
|
|
1788
|
+
throw new TypeError("Server capabilities response is missing data.compatibility.controlPlaneApi.");
|
|
1789
|
+
}
|
|
1790
|
+
return {
|
|
1791
|
+
major: requireInteger(payload.major, "data.compatibility.controlPlaneApi.major"),
|
|
1792
|
+
basePath: requireNonEmptyString(payload.basePath, "data.compatibility.controlPlaneApi.basePath"),
|
|
1793
|
+
deprecated: requireBoolean(payload.deprecated, "data.compatibility.controlPlaneApi.deprecated"),
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
function parseMetadataSchemas(payload) {
|
|
1797
|
+
if (!Array.isArray(payload)) {
|
|
1798
|
+
throw new TypeError("Server capabilities response is missing data.compatibility.metadataSchemas.");
|
|
1799
|
+
}
|
|
1800
|
+
return payload.map((entry, index) => {
|
|
1801
|
+
if (!isObject(entry)) {
|
|
1802
|
+
throw new TypeError(`Server capabilities response metadataSchemas[${index}] must be an object.`);
|
|
1803
|
+
}
|
|
1804
|
+
return {
|
|
1805
|
+
version: requireNonEmptyString(entry.version, `data.compatibility.metadataSchemas[${index}].version`),
|
|
1806
|
+
deprecated: requireBoolean(entry.deprecated, `data.compatibility.metadataSchemas[${index}].deprecated`),
|
|
1807
|
+
};
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
function parseCompatibilityFeatures(payload) {
|
|
1811
|
+
if (!isObject(payload)) {
|
|
1812
|
+
throw new TypeError("Server capabilities response is missing data.compatibility.features.");
|
|
1813
|
+
}
|
|
1814
|
+
return {
|
|
1815
|
+
metadataResources: requireBoolean(payload.metadataResources, "data.compatibility.features.metadataResources"),
|
|
1816
|
+
manifestExport: requireBoolean(payload.manifestExport, "data.compatibility.features.manifestExport"),
|
|
1817
|
+
manifestApply: requireBoolean(payload.manifestApply, "data.compatibility.features.manifestApply"),
|
|
1818
|
+
manifestDryRun: requireBoolean(payload.manifestDryRun, "data.compatibility.features.manifestDryRun"),
|
|
1819
|
+
manifestPrune: requireBoolean(payload.manifestPrune, "data.compatibility.features.manifestPrune"),
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
function requireNonEmptyString(value, fieldName) {
|
|
1823
|
+
if (typeof value !== "string") {
|
|
1824
|
+
throw new TypeError(`${fieldName} must be a string.`);
|
|
1825
|
+
}
|
|
1826
|
+
const trimmed = value.trim();
|
|
1827
|
+
if (trimmed.length === 0) {
|
|
1828
|
+
throw new TypeError(`${fieldName} must not be empty.`);
|
|
1829
|
+
}
|
|
1830
|
+
return trimmed;
|
|
1831
|
+
}
|
|
1832
|
+
function requireBoolean(value, fieldName) {
|
|
1833
|
+
if (typeof value !== "boolean") {
|
|
1834
|
+
throw new TypeError(`${fieldName} must be a boolean.`);
|
|
1835
|
+
}
|
|
1836
|
+
return value;
|
|
1837
|
+
}
|
|
1838
|
+
function requireInteger(value, fieldName) {
|
|
1839
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
1840
|
+
throw new TypeError(`${fieldName} must be an integer.`);
|
|
1841
|
+
}
|
|
1842
|
+
return value;
|
|
1843
|
+
}
|
|
1844
|
+
function evaluateCompatibility(compatibility) {
|
|
1845
|
+
const reasons = [];
|
|
1846
|
+
const minimumVersion = parseVersion(HONUA_MINIMUM_SUPPORTED_SERVER_VERSION);
|
|
1847
|
+
const serverVersion = parseVersion(compatibility.serverVersion);
|
|
1848
|
+
if (!minimumVersion) {
|
|
1849
|
+
reasons.push(`SDK minimum supported version '${HONUA_MINIMUM_SUPPORTED_SERVER_VERSION}' is not parseable for compatibility checks.`);
|
|
1850
|
+
return reasons;
|
|
1851
|
+
}
|
|
1852
|
+
if (!serverVersion) {
|
|
1853
|
+
reasons.push(`Server version '${compatibility.serverVersion}' is not parseable for compatibility checks.`);
|
|
1854
|
+
}
|
|
1855
|
+
else if (compareVersions(serverVersion, minimumVersion) < 0) {
|
|
1856
|
+
reasons.push(`Server version ${compatibility.serverVersion} is older than the minimum supported ${HONUA_MINIMUM_SUPPORTED_SERVER_VERSION}.`);
|
|
1857
|
+
}
|
|
1858
|
+
if (compatibility.controlPlaneApi.major !== SUPPORTED_CONTROL_PLANE_API_MAJOR) {
|
|
1859
|
+
reasons.push(`Control-plane API major ${compatibility.controlPlaneApi.major} is unsupported; expected ${SUPPORTED_CONTROL_PLANE_API_MAJOR}.`);
|
|
1860
|
+
}
|
|
1861
|
+
if (normalizePathValue(compatibility.controlPlaneApi.basePath) !== SUPPORTED_CONTROL_PLANE_API_BASE_PATH) {
|
|
1862
|
+
reasons.push(`Control-plane API base path ${compatibility.controlPlaneApi.basePath} is unsupported; expected ${SUPPORTED_CONTROL_PLANE_API_BASE_PATH}.`);
|
|
1863
|
+
}
|
|
1864
|
+
if (compatibility.controlPlaneApi.deprecated) {
|
|
1865
|
+
reasons.push(`Control-plane API major ${compatibility.controlPlaneApi.major} is marked deprecated by the server.`);
|
|
1866
|
+
}
|
|
1867
|
+
const actualReleaseChannelRank = getReleaseChannelRank(compatibility.releaseChannel);
|
|
1868
|
+
const minimumReleaseChannelRank = getReleaseChannelRank(MINIMUM_SUPPORTED_SERVER_RELEASE_CHANNEL) ?? 0;
|
|
1869
|
+
if (actualReleaseChannelRank === undefined) {
|
|
1870
|
+
reasons.push(`Server release channel '${compatibility.releaseChannel}' is not recognized by this SDK baseline.`);
|
|
1871
|
+
}
|
|
1872
|
+
else if (actualReleaseChannelRank < minimumReleaseChannelRank) {
|
|
1873
|
+
reasons.push(`Server release channel '${compatibility.releaseChannel}' is below the minimum supported '${MINIMUM_SUPPORTED_SERVER_RELEASE_CHANNEL}'.`);
|
|
1874
|
+
}
|
|
1875
|
+
return reasons;
|
|
1876
|
+
}
|
|
1877
|
+
function parseVersion(version) {
|
|
1878
|
+
const normalized = version.trim().replace(/^v/i, "");
|
|
1879
|
+
if (normalized.length === 0) {
|
|
1880
|
+
return undefined;
|
|
1881
|
+
}
|
|
1882
|
+
const coreAndPrerelease = normalized.split("+", 1)[0] ?? normalized;
|
|
1883
|
+
const [core, prerelease = ""] = coreAndPrerelease.split("-", 2);
|
|
1884
|
+
const numbers = core.split(".").map((segment) => Number.parseInt(segment, 10));
|
|
1885
|
+
if (numbers.length === 0 || numbers.some((segment) => !Number.isFinite(segment))) {
|
|
1886
|
+
return undefined;
|
|
1887
|
+
}
|
|
1888
|
+
const prereleaseParts = prerelease.length > 0
|
|
1889
|
+
? prerelease
|
|
1890
|
+
.split(".")
|
|
1891
|
+
.map((segment) => segment.trim())
|
|
1892
|
+
.filter((segment) => segment.length > 0)
|
|
1893
|
+
: [];
|
|
1894
|
+
return {
|
|
1895
|
+
numbers,
|
|
1896
|
+
prerelease: prereleaseParts,
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
function compareVersions(left, right) {
|
|
1900
|
+
const length = Math.max(left.numbers.length, right.numbers.length);
|
|
1901
|
+
for (let index = 0; index < length; index += 1) {
|
|
1902
|
+
const leftPart = left.numbers[index] ?? 0;
|
|
1903
|
+
const rightPart = right.numbers[index] ?? 0;
|
|
1904
|
+
if (leftPart !== rightPart) {
|
|
1905
|
+
return leftPart < rightPart ? -1 : 1;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
if (left.prerelease.length === 0 && right.prerelease.length === 0) {
|
|
1909
|
+
return 0;
|
|
1910
|
+
}
|
|
1911
|
+
if (left.prerelease.length === 0) {
|
|
1912
|
+
return 1;
|
|
1913
|
+
}
|
|
1914
|
+
if (right.prerelease.length === 0) {
|
|
1915
|
+
return -1;
|
|
1916
|
+
}
|
|
1917
|
+
const prereleaseLength = Math.max(left.prerelease.length, right.prerelease.length);
|
|
1918
|
+
for (let index = 0; index < prereleaseLength; index += 1) {
|
|
1919
|
+
const leftPart = left.prerelease[index];
|
|
1920
|
+
const rightPart = right.prerelease[index];
|
|
1921
|
+
if (leftPart === undefined) {
|
|
1922
|
+
return -1;
|
|
1923
|
+
}
|
|
1924
|
+
if (rightPart === undefined) {
|
|
1925
|
+
return 1;
|
|
1926
|
+
}
|
|
1927
|
+
if (leftPart === rightPart) {
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
const leftNumeric = Number.parseInt(leftPart, 10);
|
|
1931
|
+
const rightNumeric = Number.parseInt(rightPart, 10);
|
|
1932
|
+
const leftIsNumeric = String(leftNumeric) === leftPart;
|
|
1933
|
+
const rightIsNumeric = String(rightNumeric) === rightPart;
|
|
1934
|
+
if (leftIsNumeric && rightIsNumeric) {
|
|
1935
|
+
return leftNumeric < rightNumeric ? -1 : 1;
|
|
1936
|
+
}
|
|
1937
|
+
if (leftIsNumeric) {
|
|
1938
|
+
return -1;
|
|
1939
|
+
}
|
|
1940
|
+
if (rightIsNumeric) {
|
|
1941
|
+
return 1;
|
|
1942
|
+
}
|
|
1943
|
+
return leftPart < rightPart ? -1 : 1;
|
|
1944
|
+
}
|
|
1945
|
+
return 0;
|
|
1946
|
+
}
|
|
1947
|
+
function describeCompatibilityError(error) {
|
|
1948
|
+
if (error instanceof HonuaHttpError && error.statusCode === 404) {
|
|
1949
|
+
return "Server does not expose GET /api/v1/admin/capabilities.";
|
|
1950
|
+
}
|
|
1951
|
+
if (error instanceof Error) {
|
|
1952
|
+
return error.message;
|
|
1953
|
+
}
|
|
1954
|
+
return String(error);
|
|
1955
|
+
}
|
|
1956
|
+
function normalizePathValue(path) {
|
|
1957
|
+
const trimmed = path.trim();
|
|
1958
|
+
if (trimmed.length === 0) {
|
|
1959
|
+
return "";
|
|
1960
|
+
}
|
|
1961
|
+
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
1962
|
+
return prefixed.replace(/\/+$/, "");
|
|
1963
|
+
}
|
|
1964
|
+
function getReleaseChannelRank(releaseChannel) {
|
|
1965
|
+
switch (releaseChannel.trim().toLowerCase()) {
|
|
1966
|
+
case "nightly":
|
|
1967
|
+
return 0;
|
|
1968
|
+
case "dev":
|
|
1969
|
+
return 1;
|
|
1970
|
+
case "alpha":
|
|
1971
|
+
return 2;
|
|
1972
|
+
case "preview":
|
|
1973
|
+
return 3;
|
|
1974
|
+
case "beta":
|
|
1975
|
+
return 4;
|
|
1976
|
+
case "rc":
|
|
1977
|
+
return 5;
|
|
1978
|
+
case "stable":
|
|
1979
|
+
return 6;
|
|
1980
|
+
case "lts":
|
|
1981
|
+
return 7;
|
|
1982
|
+
default:
|
|
1983
|
+
return undefined;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
function applyRequestMutation(request, mutation) {
|
|
1987
|
+
const nextInit = mutation.init === undefined
|
|
1988
|
+
? request.init
|
|
1989
|
+
: {
|
|
1990
|
+
...request.init,
|
|
1991
|
+
...mutation.init,
|
|
1992
|
+
headers: mergeHeaders(request.init.headers, mutation.init.headers),
|
|
1993
|
+
};
|
|
1994
|
+
return {
|
|
1995
|
+
url: mutation.url ?? request.url,
|
|
1996
|
+
path: request.path,
|
|
1997
|
+
method: mutation.method ?? request.method,
|
|
1998
|
+
init: {
|
|
1999
|
+
...nextInit,
|
|
2000
|
+
method: mutation.method ?? request.method,
|
|
2001
|
+
},
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
function cloneRequestContext(request) {
|
|
2005
|
+
return {
|
|
2006
|
+
...request,
|
|
2007
|
+
init: {
|
|
2008
|
+
...request.init,
|
|
2009
|
+
headers: cloneHeadersInit(request.init.headers),
|
|
2010
|
+
},
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
function cloneHeadersInit(headers) {
|
|
2014
|
+
return mergeHeaders(headers);
|
|
2015
|
+
}
|
|
2016
|
+
function mergeHeaders(...headersList) {
|
|
2017
|
+
const merged = {};
|
|
2018
|
+
for (const headers of headersList) {
|
|
2019
|
+
if (!headers) {
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
if (headers instanceof Headers) {
|
|
2023
|
+
for (const [key, value] of headers.entries()) {
|
|
2024
|
+
merged[key] = value;
|
|
2025
|
+
}
|
|
2026
|
+
continue;
|
|
2027
|
+
}
|
|
2028
|
+
if (Array.isArray(headers)) {
|
|
2029
|
+
for (const [key, value] of headers) {
|
|
2030
|
+
merged[key] = value;
|
|
2031
|
+
}
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
2035
|
+
if (value === undefined || value === null) {
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
merged[key] = String(value);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return merged;
|
|
2042
|
+
}
|
|
2043
|
+
function normalizeAuthProvider(auth) {
|
|
2044
|
+
if (!auth) {
|
|
2045
|
+
return undefined;
|
|
2046
|
+
}
|
|
2047
|
+
if (typeof auth === "function") {
|
|
2048
|
+
return { getCredentials: auth };
|
|
2049
|
+
}
|
|
2050
|
+
return {
|
|
2051
|
+
getCredentials: (context) => auth.getCredentials(context),
|
|
2052
|
+
...(auth.revokeCredentials ? { revokeCredentials: auth.revokeCredentials.bind(auth) } : {}),
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
function normalizeAuthRefreshSkewMs(value) {
|
|
2056
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2057
|
+
return DEFAULT_AUTH_REFRESH_SKEW_MS;
|
|
2058
|
+
}
|
|
2059
|
+
return Math.max(0, Math.trunc(value));
|
|
2060
|
+
}
|
|
2061
|
+
function normalizeAuthCredentials(value) {
|
|
2062
|
+
if (typeof value === "string") {
|
|
2063
|
+
return value.length > 0 ? { bearerToken: value } : undefined;
|
|
2064
|
+
}
|
|
2065
|
+
if (!value) {
|
|
2066
|
+
return undefined;
|
|
2067
|
+
}
|
|
2068
|
+
const credentials = {};
|
|
2069
|
+
if (typeof value.apiKey === "string" && value.apiKey.length > 0) {
|
|
2070
|
+
credentials.apiKey = value.apiKey;
|
|
2071
|
+
}
|
|
2072
|
+
if (typeof value.bearerToken === "string" && value.bearerToken.length > 0) {
|
|
2073
|
+
credentials.bearerToken = value.bearerToken;
|
|
2074
|
+
}
|
|
2075
|
+
if (typeof value.authorization === "string" && value.authorization.length > 0) {
|
|
2076
|
+
credentials.authorization = value.authorization;
|
|
2077
|
+
}
|
|
2078
|
+
if (value.expiresAt !== undefined) {
|
|
2079
|
+
credentials.expiresAt = value.expiresAt;
|
|
2080
|
+
}
|
|
2081
|
+
if (!credentials.apiKey && !credentials.bearerToken && !credentials.authorization) {
|
|
2082
|
+
return undefined;
|
|
2083
|
+
}
|
|
2084
|
+
return credentials;
|
|
2085
|
+
}
|
|
2086
|
+
function normalizeAuthExpiresAtMs(expiresAt) {
|
|
2087
|
+
if (expiresAt === undefined) {
|
|
2088
|
+
return undefined;
|
|
2089
|
+
}
|
|
2090
|
+
if (expiresAt instanceof Date) {
|
|
2091
|
+
return Number.isFinite(expiresAt.getTime()) ? expiresAt.getTime() : undefined;
|
|
2092
|
+
}
|
|
2093
|
+
if (typeof expiresAt === "number") {
|
|
2094
|
+
return Number.isFinite(expiresAt) ? expiresAt : undefined;
|
|
2095
|
+
}
|
|
2096
|
+
const parsed = Date.parse(expiresAt);
|
|
2097
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
2098
|
+
}
|
|
2099
|
+
function isAuthCredentialsExpiring(cached, skewMs) {
|
|
2100
|
+
if (cached.expiresAtMs === undefined) {
|
|
2101
|
+
return false;
|
|
2102
|
+
}
|
|
2103
|
+
return cached.expiresAtMs - Date.now() <= skewMs;
|
|
2104
|
+
}
|
|
2105
|
+
function resolveAuthRefreshReason(cached) {
|
|
2106
|
+
return cached ? "expired" : "initial";
|
|
2107
|
+
}
|
|
2108
|
+
function authHeadersFromCredentials(credentials) {
|
|
2109
|
+
const headers = {};
|
|
2110
|
+
if (credentials.apiKey) {
|
|
2111
|
+
headers["X-API-Key"] = credentials.apiKey;
|
|
2112
|
+
}
|
|
2113
|
+
if (credentials.authorization) {
|
|
2114
|
+
headers.Authorization = credentials.authorization;
|
|
2115
|
+
}
|
|
2116
|
+
else if (credentials.bearerToken) {
|
|
2117
|
+
headers.Authorization = `Bearer ${credentials.bearerToken}`;
|
|
2118
|
+
}
|
|
2119
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
2120
|
+
}
|
|
2121
|
+
function normalizeTimeoutMs(timeoutMs) {
|
|
2122
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
|
|
2123
|
+
return undefined;
|
|
2124
|
+
}
|
|
2125
|
+
return Math.max(1, Math.trunc(timeoutMs));
|
|
2126
|
+
}
|
|
2127
|
+
function normalizeRetryOptions(options) {
|
|
2128
|
+
if (!options) {
|
|
2129
|
+
return undefined;
|
|
2130
|
+
}
|
|
2131
|
+
const maxRetries = typeof options.maxRetries === "number" && Number.isFinite(options.maxRetries)
|
|
2132
|
+
? Math.max(0, Math.trunc(options.maxRetries))
|
|
2133
|
+
: 0;
|
|
2134
|
+
if (maxRetries < 1) {
|
|
2135
|
+
return undefined;
|
|
2136
|
+
}
|
|
2137
|
+
const baseDelayMs = typeof options.baseDelayMs === "number" && Number.isFinite(options.baseDelayMs)
|
|
2138
|
+
? Math.max(1, Math.trunc(options.baseDelayMs))
|
|
2139
|
+
: 100;
|
|
2140
|
+
const maxDelayMs = typeof options.maxDelayMs === "number" && Number.isFinite(options.maxDelayMs)
|
|
2141
|
+
? Math.max(baseDelayMs, Math.trunc(options.maxDelayMs))
|
|
2142
|
+
: 2_000;
|
|
2143
|
+
const retryStatuses = new Set((options.retryStatuses ?? Array.from(DEFAULT_RETRY_STATUSES))
|
|
2144
|
+
.map((status) => Math.trunc(status))
|
|
2145
|
+
.filter((status) => Number.isFinite(status) && status >= 100 && status <= 599));
|
|
2146
|
+
if (retryStatuses.size === 0) {
|
|
2147
|
+
for (const status of DEFAULT_RETRY_STATUSES) {
|
|
2148
|
+
retryStatuses.add(status);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
return {
|
|
2152
|
+
maxRetries,
|
|
2153
|
+
baseDelayMs,
|
|
2154
|
+
maxDelayMs,
|
|
2155
|
+
retryStatuses,
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
function parseRetryAfterMs(response) {
|
|
2159
|
+
const value = response.headers.get("retry-after");
|
|
2160
|
+
if (!value) {
|
|
2161
|
+
return undefined;
|
|
2162
|
+
}
|
|
2163
|
+
const seconds = Number.parseInt(value, 10);
|
|
2164
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
2165
|
+
return seconds * 1_000;
|
|
2166
|
+
}
|
|
2167
|
+
const targetTime = Date.parse(value);
|
|
2168
|
+
if (!Number.isFinite(targetTime)) {
|
|
2169
|
+
return undefined;
|
|
2170
|
+
}
|
|
2171
|
+
return Math.max(0, targetTime - Date.now());
|
|
2172
|
+
}
|
|
2173
|
+
async function sleep(ms) {
|
|
2174
|
+
if (ms <= 0) {
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
await new Promise((resolve) => {
|
|
2178
|
+
setTimeout(resolve, ms);
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
function normalizeNetworkError(error) {
|
|
2182
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
2183
|
+
return new HonuaAbortError();
|
|
2184
|
+
}
|
|
2185
|
+
if (error instanceof Error) {
|
|
2186
|
+
return new HonuaNetworkError(error.message, error);
|
|
2187
|
+
}
|
|
2188
|
+
return new HonuaNetworkError(String(error), error);
|
|
2189
|
+
}
|
|
2190
|
+
function createTimeoutSignal(existingSignal, timeoutMs) {
|
|
2191
|
+
if (timeoutMs === undefined) {
|
|
2192
|
+
return {
|
|
2193
|
+
signal: existingSignal ?? undefined,
|
|
2194
|
+
didTimeout: false,
|
|
2195
|
+
dispose: () => undefined,
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
const controller = new AbortController();
|
|
2199
|
+
let didTimeout = false;
|
|
2200
|
+
let timer;
|
|
2201
|
+
let onAbort;
|
|
2202
|
+
timer = setTimeout(() => {
|
|
2203
|
+
didTimeout = true;
|
|
2204
|
+
controller.abort();
|
|
2205
|
+
}, timeoutMs);
|
|
2206
|
+
if (existingSignal) {
|
|
2207
|
+
if (existingSignal.aborted) {
|
|
2208
|
+
controller.abort();
|
|
2209
|
+
}
|
|
2210
|
+
else {
|
|
2211
|
+
onAbort = () => {
|
|
2212
|
+
controller.abort();
|
|
2213
|
+
};
|
|
2214
|
+
existingSignal.addEventListener("abort", onAbort, { once: true });
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
return {
|
|
2218
|
+
signal: controller.signal,
|
|
2219
|
+
get didTimeout() {
|
|
2220
|
+
return didTimeout;
|
|
2221
|
+
},
|
|
2222
|
+
dispose: () => {
|
|
2223
|
+
if (timer) {
|
|
2224
|
+
clearTimeout(timer);
|
|
2225
|
+
timer = undefined;
|
|
2226
|
+
}
|
|
2227
|
+
if (existingSignal && onAbort) {
|
|
2228
|
+
existingSignal.removeEventListener("abort", onAbort);
|
|
2229
|
+
onAbort = undefined;
|
|
2230
|
+
}
|
|
2231
|
+
},
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
function createOgcMetadataParams(request) {
|
|
2235
|
+
const params = new URLSearchParams();
|
|
2236
|
+
params.set("f", request.responseFormat ?? "json");
|
|
2237
|
+
if (request.extraParams) {
|
|
2238
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
2239
|
+
params.set(key, String(value));
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
return params;
|
|
2243
|
+
}
|
|
2244
|
+
function createMetadataCacheState(entry, status, options) {
|
|
2245
|
+
return createHonuaCacheState({
|
|
2246
|
+
scope: "metadata",
|
|
2247
|
+
status,
|
|
2248
|
+
keyFingerprint: entry.keyFingerprint,
|
|
2249
|
+
ageMs: Math.max(0, options.now - entry.cachedAtMs),
|
|
2250
|
+
...(options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}),
|
|
2251
|
+
...(options.staleIfErrorMs !== undefined ? { staleIfErrorMs: options.staleIfErrorMs } : {}),
|
|
2252
|
+
...(options.revalidatedAt ? { revalidatedAt: options.revalidatedAt } : {}),
|
|
2253
|
+
...(entry.sourceUpdatedAt ? { sourceUpdatedAt: entry.sourceUpdatedAt } : {}),
|
|
2254
|
+
...(entry.validator ? { validator: entry.validator } : {}),
|
|
2255
|
+
...(options.refreshErrorId ? { refreshErrorId: options.refreshErrorId } : {}),
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
function metadataRefreshErrorId(error) {
|
|
2259
|
+
if (error instanceof HonuaHttpError) {
|
|
2260
|
+
return `http-${error.statusCode}`;
|
|
2261
|
+
}
|
|
2262
|
+
if (error instanceof HonuaTimeoutError) {
|
|
2263
|
+
return "timeout";
|
|
2264
|
+
}
|
|
2265
|
+
if (error instanceof HonuaNetworkError) {
|
|
2266
|
+
return "network";
|
|
2267
|
+
}
|
|
2268
|
+
if (error instanceof Error && error.name) {
|
|
2269
|
+
return error.name;
|
|
2270
|
+
}
|
|
2271
|
+
return "unknown";
|
|
2272
|
+
}
|
|
2273
|
+
function mergePathWithQueryParams(path, additionalParams) {
|
|
2274
|
+
if (additionalParams.size === 0) {
|
|
2275
|
+
return path;
|
|
2276
|
+
}
|
|
2277
|
+
const hashIndex = path.indexOf("#");
|
|
2278
|
+
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
|
|
2279
|
+
const withoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
|
|
2280
|
+
const queryIndex = withoutHash.indexOf("?");
|
|
2281
|
+
const basePath = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash;
|
|
2282
|
+
const existingQuery = queryIndex >= 0 ? withoutHash.slice(queryIndex + 1) : "";
|
|
2283
|
+
const merged = new URLSearchParams(existingQuery);
|
|
2284
|
+
for (const [key, value] of additionalParams.entries()) {
|
|
2285
|
+
merged.set(key, value);
|
|
2286
|
+
}
|
|
2287
|
+
const nextQuery = merged.toString();
|
|
2288
|
+
const withQuery = nextQuery.length > 0 ? `${basePath}?${nextQuery}` : basePath;
|
|
2289
|
+
return `${withQuery}${hash}`;
|
|
2290
|
+
}
|
|
2291
|
+
function serializeQueryParams(params, request) {
|
|
2292
|
+
if (request.outSr !== undefined) {
|
|
2293
|
+
params.set("outSR", typeof request.outSr === "object" && request.outSr !== null
|
|
2294
|
+
? JSON.stringify(request.outSr)
|
|
2295
|
+
: String(request.outSr));
|
|
2296
|
+
}
|
|
2297
|
+
if (request.orderByFields !== undefined) {
|
|
2298
|
+
params.set("orderByFields", request.orderByFields);
|
|
2299
|
+
}
|
|
2300
|
+
if (request.objectIds !== undefined) {
|
|
2301
|
+
params.set("objectIds", Array.isArray(request.objectIds) ? request.objectIds.join(",") : String(request.objectIds));
|
|
2302
|
+
}
|
|
2303
|
+
if (request.geometry !== undefined) {
|
|
2304
|
+
params.set("geometry", typeof request.geometry === "object" && request.geometry !== null
|
|
2305
|
+
? JSON.stringify(request.geometry)
|
|
2306
|
+
: String(request.geometry));
|
|
2307
|
+
}
|
|
2308
|
+
if (request.geometryType !== undefined) {
|
|
2309
|
+
params.set("geometryType", request.geometryType);
|
|
2310
|
+
}
|
|
2311
|
+
if (request.spatialRel !== undefined) {
|
|
2312
|
+
params.set("spatialRel", request.spatialRel);
|
|
2313
|
+
}
|
|
2314
|
+
if (request.returnDistinctValues !== undefined) {
|
|
2315
|
+
params.set("returnDistinctValues", String(request.returnDistinctValues));
|
|
2316
|
+
}
|
|
2317
|
+
if (request.returnCentroid !== undefined) {
|
|
2318
|
+
params.set("returnCentroid", String(request.returnCentroid));
|
|
2319
|
+
}
|
|
2320
|
+
if (request.groupByFieldsForStatistics !== undefined) {
|
|
2321
|
+
params.set("groupByFieldsForStatistics", request.groupByFieldsForStatistics);
|
|
2322
|
+
}
|
|
2323
|
+
if (request.outStatistics !== undefined) {
|
|
2324
|
+
params.set("outStatistics", Array.isArray(request.outStatistics) ? JSON.stringify(request.outStatistics) : String(request.outStatistics));
|
|
2325
|
+
}
|
|
2326
|
+
if (request.resultOffset !== undefined) {
|
|
2327
|
+
params.set("resultOffset", String(request.resultOffset));
|
|
2328
|
+
}
|
|
2329
|
+
if (request.resultRecordCount !== undefined) {
|
|
2330
|
+
params.set("resultRecordCount", String(request.resultRecordCount));
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
function appendQueryExtraParams(params, request) {
|
|
2334
|
+
if (!request.extraParams) {
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
2338
|
+
if (request.outSr !== undefined && (key === "outSr" || key === "outSR")) {
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
params.set(key, String(value));
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
function normalizeCsv(value) {
|
|
2345
|
+
if (typeof value === "string") {
|
|
2346
|
+
return value;
|
|
2347
|
+
}
|
|
2348
|
+
return Array.from(value).join(",");
|
|
2349
|
+
}
|
|
2350
|
+
function serializeOgcMapImageParams(request) {
|
|
2351
|
+
const params = new URLSearchParams();
|
|
2352
|
+
const f = ogcMapShortFormat(request.format);
|
|
2353
|
+
if (f !== undefined)
|
|
2354
|
+
params.set("f", f);
|
|
2355
|
+
if (request.width !== undefined)
|
|
2356
|
+
params.set("width", String(request.width));
|
|
2357
|
+
if (request.height !== undefined)
|
|
2358
|
+
params.set("height", String(request.height));
|
|
2359
|
+
if (request.bbox !== undefined) {
|
|
2360
|
+
params.set("bbox", typeof request.bbox === "string" ? request.bbox : request.bbox.join(","));
|
|
2361
|
+
}
|
|
2362
|
+
if (request.bboxCrs !== undefined)
|
|
2363
|
+
params.set("bbox-crs", request.bboxCrs);
|
|
2364
|
+
if (request.crs !== undefined)
|
|
2365
|
+
params.set("crs", request.crs);
|
|
2366
|
+
if (request.collections !== undefined && request.collections.length > 0) {
|
|
2367
|
+
params.set("collections", request.collections.join(","));
|
|
2368
|
+
}
|
|
2369
|
+
if (request.transparent !== undefined)
|
|
2370
|
+
params.set("transparent", String(request.transparent));
|
|
2371
|
+
if (request.extraParams) {
|
|
2372
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
2373
|
+
params.set(key, String(value));
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
return params;
|
|
2377
|
+
}
|
|
2378
|
+
const OGC_MAP_FORMAT_TO_SHORT = new Map([
|
|
2379
|
+
["image/png", "png"],
|
|
2380
|
+
["image/jpeg", "jpeg"],
|
|
2381
|
+
["image/jpg", "jpg"],
|
|
2382
|
+
["image/tiff", "tiff"],
|
|
2383
|
+
["image/tif", "tif"],
|
|
2384
|
+
]);
|
|
2385
|
+
const OGC_MAP_SHORT_TO_MEDIA = new Map([
|
|
2386
|
+
["png", "image/png"],
|
|
2387
|
+
["jpeg", "image/jpeg"],
|
|
2388
|
+
["jpg", "image/jpeg"],
|
|
2389
|
+
["tiff", "image/tiff"],
|
|
2390
|
+
["tif", "image/tiff"],
|
|
2391
|
+
]);
|
|
2392
|
+
function ogcMapShortFormat(format) {
|
|
2393
|
+
if (format === undefined)
|
|
2394
|
+
return undefined;
|
|
2395
|
+
const lower = format.toLowerCase();
|
|
2396
|
+
return OGC_MAP_FORMAT_TO_SHORT.get(lower) ?? lower;
|
|
2397
|
+
}
|
|
2398
|
+
function ogcMapAcceptHeader(format) {
|
|
2399
|
+
if (format === undefined)
|
|
2400
|
+
return undefined;
|
|
2401
|
+
const lower = format.toLowerCase();
|
|
2402
|
+
return OGC_MAP_SHORT_TO_MEDIA.get(lower) ?? format;
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Build the `Prefer` header for an OGC API Processes execution request.
|
|
2406
|
+
* honua-server is async-only: `mode: "async"` sends an explicit
|
|
2407
|
+
* `Prefer: respond-async` so OGC-conformance checkers see the header;
|
|
2408
|
+
* `mode: "auto"` (or unset) omits it and lets the server default apply.
|
|
2409
|
+
*/
|
|
2410
|
+
function preferHeaderForExecute(request) {
|
|
2411
|
+
if (request.mode === "async") {
|
|
2412
|
+
return { Prefer: "respond-async" };
|
|
2413
|
+
}
|
|
2414
|
+
return undefined;
|
|
2415
|
+
}
|
|
2416
|
+
function serializeStacSearchParams(request) {
|
|
2417
|
+
const params = new URLSearchParams();
|
|
2418
|
+
if (request.bbox !== undefined)
|
|
2419
|
+
params.set("bbox", request.bbox.join(","));
|
|
2420
|
+
if (request.datetime !== undefined)
|
|
2421
|
+
params.set("datetime", request.datetime);
|
|
2422
|
+
if (request.ids !== undefined && request.ids.length > 0)
|
|
2423
|
+
params.set("ids", request.ids.join(","));
|
|
2424
|
+
if (request.collections !== undefined && request.collections.length > 0) {
|
|
2425
|
+
params.set("collections", request.collections.join(","));
|
|
2426
|
+
}
|
|
2427
|
+
// honua-server accepts `intersects` on GET as a JSON-encoded geometry
|
|
2428
|
+
// and `fields` as a CSV with `-` prefix marking excludes (matches
|
|
2429
|
+
// STAC Item Search GET conventions and the server's parser).
|
|
2430
|
+
if (request.intersects !== undefined)
|
|
2431
|
+
params.set("intersects", JSON.stringify(request.intersects));
|
|
2432
|
+
const fields = stacFieldsCsv(request.fields);
|
|
2433
|
+
if (fields !== undefined)
|
|
2434
|
+
params.set("fields", fields);
|
|
2435
|
+
if (request.filter !== undefined)
|
|
2436
|
+
params.set("filter", request.filter);
|
|
2437
|
+
if (request.filterLang !== undefined)
|
|
2438
|
+
params.set("filter-lang", request.filterLang);
|
|
2439
|
+
if (request.limit !== undefined)
|
|
2440
|
+
params.set("limit", String(request.limit));
|
|
2441
|
+
// honua-server uses numeric `offset` paging on GET. `next` is kept as
|
|
2442
|
+
// optional support for STAC servers that advertise an opaque token.
|
|
2443
|
+
if (request.offset !== undefined)
|
|
2444
|
+
params.set("offset", String(request.offset));
|
|
2445
|
+
if (request.next !== undefined)
|
|
2446
|
+
params.set("next", request.next);
|
|
2447
|
+
if (request.sortby !== undefined)
|
|
2448
|
+
params.set("sortby", request.sortby);
|
|
2449
|
+
return params;
|
|
2450
|
+
}
|
|
2451
|
+
function stacSearchBody(request) {
|
|
2452
|
+
const out = {};
|
|
2453
|
+
if (request.bbox !== undefined)
|
|
2454
|
+
out.bbox = request.bbox;
|
|
2455
|
+
if (request.datetime !== undefined)
|
|
2456
|
+
out.datetime = request.datetime;
|
|
2457
|
+
if (request.intersects !== undefined)
|
|
2458
|
+
out.intersects = request.intersects;
|
|
2459
|
+
if (request.ids !== undefined)
|
|
2460
|
+
out.ids = request.ids;
|
|
2461
|
+
if (request.collections !== undefined)
|
|
2462
|
+
out.collections = request.collections;
|
|
2463
|
+
if (request.filter !== undefined)
|
|
2464
|
+
out.filter = request.filter;
|
|
2465
|
+
if (request.filterLang !== undefined)
|
|
2466
|
+
out["filter-lang"] = request.filterLang;
|
|
2467
|
+
if (request.limit !== undefined)
|
|
2468
|
+
out.limit = request.limit;
|
|
2469
|
+
if (request.offset !== undefined)
|
|
2470
|
+
out.offset = request.offset;
|
|
2471
|
+
if (request.next !== undefined)
|
|
2472
|
+
out.next = request.next;
|
|
2473
|
+
if (request.sortby !== undefined)
|
|
2474
|
+
out.sortby = request.sortby;
|
|
2475
|
+
if (request.fields !== undefined)
|
|
2476
|
+
out.fields = request.fields;
|
|
2477
|
+
return out;
|
|
2478
|
+
}
|
|
2479
|
+
function stacFieldsCsv(fields) {
|
|
2480
|
+
if (!fields)
|
|
2481
|
+
return undefined;
|
|
2482
|
+
const parts = [];
|
|
2483
|
+
if (fields.include) {
|
|
2484
|
+
for (const f of fields.include) {
|
|
2485
|
+
if (typeof f === "string" && f.length > 0)
|
|
2486
|
+
parts.push(f);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
if (fields.exclude) {
|
|
2490
|
+
for (const f of fields.exclude) {
|
|
2491
|
+
if (typeof f === "string" && f.length > 0)
|
|
2492
|
+
parts.push(`-${f}`);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
return parts.length > 0 ? parts.join(",") : undefined;
|
|
2496
|
+
}
|
|
2497
|
+
// ── WMS / WMTS helpers ──────────────────────────────────────────
|
|
2498
|
+
/** Canonical WMS endpoint path. honua-server publishes both `/rest/services/{id}/MapServer/WMS` and `/ogc/services/{id}/wms`; the SDK targets the GeoServices-aliased path because every Honua deployment exposes it. */
|
|
2499
|
+
function wmsBasePath(serviceId) {
|
|
2500
|
+
return `/rest/services/${encodeURIComponent(serviceId)}/MapServer/WMS`;
|
|
2501
|
+
}
|
|
2502
|
+
/** Canonical WMTS endpoint path. */
|
|
2503
|
+
function wmtsBasePath(serviceId) {
|
|
2504
|
+
return `/rest/services/${encodeURIComponent(serviceId)}/MapServer/WMTS`;
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* CRS table for WMS 1.3 axis order. WMS 1.3 honors authority axis order,
|
|
2508
|
+
* which means `EPSG:4326` is `(lat, lon)` and `CRS:84` / `EPSG:3857` are
|
|
2509
|
+
* `(x, y)`. The `BBOX` envelope tuple supplied by callers is the
|
|
2510
|
+
* canonical `[minx, miny, maxx, maxy]`; this map decides whether the
|
|
2511
|
+
* tuple is swapped on the wire.
|
|
2512
|
+
*/
|
|
2513
|
+
const WMS_AXIS_SWAP_CRS = new Set(["EPSG:4326"]);
|
|
2514
|
+
function serializeWmsMapParams(request) {
|
|
2515
|
+
const params = new URLSearchParams();
|
|
2516
|
+
params.set("SERVICE", "WMS");
|
|
2517
|
+
params.set("VERSION", "1.3.0");
|
|
2518
|
+
params.set("LAYERS", request.layers.join(","));
|
|
2519
|
+
params.set("STYLES", request.styles ? request.styles.join(",") : "");
|
|
2520
|
+
const crs = request.crs ?? "EPSG:3857";
|
|
2521
|
+
params.set("CRS", crs);
|
|
2522
|
+
const [minx, miny, maxx, maxy] = request.bbox;
|
|
2523
|
+
const wireBbox = WMS_AXIS_SWAP_CRS.has(crs) ? [miny, minx, maxy, maxx] : [minx, miny, maxx, maxy];
|
|
2524
|
+
params.set("BBOX", wireBbox.join(","));
|
|
2525
|
+
params.set("WIDTH", String(Math.trunc(request.width)));
|
|
2526
|
+
params.set("HEIGHT", String(Math.trunc(request.height)));
|
|
2527
|
+
params.set("FORMAT", request.format ?? "image/png");
|
|
2528
|
+
params.set("TRANSPARENT", String(request.transparent ?? true).toUpperCase());
|
|
2529
|
+
if (request.bgcolor !== undefined)
|
|
2530
|
+
params.set("BGCOLOR", request.bgcolor);
|
|
2531
|
+
if (request.time !== undefined)
|
|
2532
|
+
params.set("TIME", request.time);
|
|
2533
|
+
if (request.elevation !== undefined)
|
|
2534
|
+
params.set("ELEVATION", request.elevation);
|
|
2535
|
+
if (request.extraParams) {
|
|
2536
|
+
for (const [key, value] of Object.entries(request.extraParams)) {
|
|
2537
|
+
params.set(key, String(value));
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
return params;
|
|
2541
|
+
}
|
|
2542
|
+
function decodeWmsFeatureInfoResponse(bytes, contentType, requestedFormat) {
|
|
2543
|
+
const isJson = contentType.toLowerCase().includes("application/json") ||
|
|
2544
|
+
requestedFormat.toLowerCase().includes("application/json");
|
|
2545
|
+
if (!isJson) {
|
|
2546
|
+
return { contentType, bytes };
|
|
2547
|
+
}
|
|
2548
|
+
const text = new TextDecoder("utf-8").decode(bytes);
|
|
2549
|
+
if (text.length === 0) {
|
|
2550
|
+
return { contentType, features: [] };
|
|
2551
|
+
}
|
|
2552
|
+
let parsed;
|
|
2553
|
+
try {
|
|
2554
|
+
parsed = JSON.parse(text);
|
|
2555
|
+
}
|
|
2556
|
+
catch {
|
|
2557
|
+
// Server emitted a non-JSON body despite the JSON Accept; preserve as bytes.
|
|
2558
|
+
return { contentType, bytes };
|
|
2559
|
+
}
|
|
2560
|
+
const features = extractFeatureInfoFeatures(parsed);
|
|
2561
|
+
return { contentType, features };
|
|
2562
|
+
}
|
|
2563
|
+
function extractFeatureInfoFeatures(parsed) {
|
|
2564
|
+
if (!parsed || typeof parsed !== "object")
|
|
2565
|
+
return [];
|
|
2566
|
+
// honua-server emits `{ type: "FeatureInfoResponse", features: [{ layer, attributes }, ...] }`
|
|
2567
|
+
// and a fallback GeoJSON `{ type: "FeatureCollection", features: [...] }`; both decode here.
|
|
2568
|
+
const obj = parsed;
|
|
2569
|
+
const featuresRaw = Array.isArray(obj.features) ? obj.features : [];
|
|
2570
|
+
const out = [];
|
|
2571
|
+
for (const raw of featuresRaw) {
|
|
2572
|
+
if (!raw || typeof raw !== "object")
|
|
2573
|
+
continue;
|
|
2574
|
+
const feat = raw;
|
|
2575
|
+
const attributes = (feat.attributes ?? feat.properties ?? {});
|
|
2576
|
+
const geometry = feat.geometry ?? null;
|
|
2577
|
+
out.push({ attributes, geometry });
|
|
2578
|
+
}
|
|
2579
|
+
return out;
|
|
2580
|
+
}
|
|
2581
|
+
const WMTS_FEATURE_INFO_FORMAT_TO_EXTENSION = new Map([
|
|
2582
|
+
["application/json", "json"],
|
|
2583
|
+
["text/plain", "txt"],
|
|
2584
|
+
["text/html", "html"],
|
|
2585
|
+
["application/geo+json", "geojson"],
|
|
2586
|
+
]);
|
|
2587
|
+
function wmtsFeatureInfoExtensionForFormat(format) {
|
|
2588
|
+
return WMTS_FEATURE_INFO_FORMAT_TO_EXTENSION.get(format.toLowerCase()) ?? "txt";
|
|
2589
|
+
}
|
|
2590
|
+
const WMTS_REST_RESERVED_KEYS = new Set([
|
|
2591
|
+
"service",
|
|
2592
|
+
"version",
|
|
2593
|
+
"request",
|
|
2594
|
+
"layer",
|
|
2595
|
+
"style",
|
|
2596
|
+
"format",
|
|
2597
|
+
"infoformat",
|
|
2598
|
+
"tilematrixset",
|
|
2599
|
+
"tilematrix",
|
|
2600
|
+
"tilerow",
|
|
2601
|
+
"tilecol",
|
|
2602
|
+
"i",
|
|
2603
|
+
"j",
|
|
2604
|
+
]);
|
|
2605
|
+
/**
|
|
2606
|
+
* Serialize `extraParams` for the RESTful WMTS routes. Path-encoded WMTS
|
|
2607
|
+
* keys take precedence — any extraParams whose key (case-insensitively)
|
|
2608
|
+
* matches a path-derived value is dropped so the same URL never carries
|
|
2609
|
+
* the value twice. Returns the query-string suffix to append (empty
|
|
2610
|
+
* string when there is nothing left after filtering).
|
|
2611
|
+
*/
|
|
2612
|
+
function wmtsRestExtraParamsSuffix(extraParams) {
|
|
2613
|
+
if (!extraParams)
|
|
2614
|
+
return "";
|
|
2615
|
+
const params = new URLSearchParams();
|
|
2616
|
+
for (const [key, value] of Object.entries(extraParams)) {
|
|
2617
|
+
if (value === undefined || value === null)
|
|
2618
|
+
continue;
|
|
2619
|
+
if (WMTS_REST_RESERVED_KEYS.has(key.toLowerCase()))
|
|
2620
|
+
continue;
|
|
2621
|
+
params.set(key, String(value));
|
|
2622
|
+
}
|
|
2623
|
+
const serialized = params.toString();
|
|
2624
|
+
return serialized.length > 0 ? `?${serialized}` : "";
|
|
2625
|
+
}
|