Skip to content

Commit b51ac72

Browse files
committed
feat: add WFS layer support to Add Data dialog
Lets users load WFS features by requesting GeoJSON from a GetFeature service, with a dev-server proxy to avoid cross-origin failures and clearer errors when a service returns XML instead of GeoJSON.
1 parent 14ca916 commit b51ac72

3 files changed

Lines changed: 217 additions & 2 deletions

File tree

apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
export type AddDataKind =
6464
| "xyz"
6565
| "wms"
66+
| "wfs"
6667
| "wmts"
6768
| "vector"
6869
| "raster"
@@ -87,6 +88,7 @@ type SelectedRasterFile = {
8788
const KIND_LABELS: Record<AddDataKind, string> = {
8889
xyz: "Add XYZ Layer",
8990
wms: "Add WMS Layer",
91+
wfs: "Add WFS Layer",
9092
wmts: "Add WMTS Layer",
9193
vector: "Add Vector Layer",
9294
raster: "Add Raster Layer",
@@ -113,6 +115,8 @@ const DEFAULT_XYZ_URL =
113115
const DEFAULT_WMS_ENDPOINT =
114116
"https://imagery.nationalmap.gov/arcgis/services/USGSNAIPImagery/ImageServer/WMSServer";
115117
const DEFAULT_WMS_LAYERS = "USGSNAIPImagery:FalseColorComposite";
118+
const DEFAULT_WFS_ENDPOINT = "https://ahocevar.com/geoserver/wfs";
119+
const DEFAULT_WFS_TYPE_NAME = "topp:states";
116120
const DEFAULT_WMTS_URL =
117121
"https://wayback.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/119/{z}/{y}/{x}";
118122
const DEFAULT_RASTER_URL =
@@ -127,6 +131,7 @@ const DEFAULT_ARCGIS_URLS: Record<ArcGISLayerType, string> = {
127131
feature: DEFAULT_ARCGIS_FEATURE_URL,
128132
"vector-tile": DEFAULT_ARCGIS_VECTOR_TILE_URL,
129133
};
134+
const WFS_PROXY_PATH = "/__geolibre_wfs_proxy";
130135
const POSTGRES_CONNECTIONS_STORAGE_KEY =
131136
"geolibre.postgres.connectionStrings";
132137
const MAX_SAVED_POSTGRES_CONNECTIONS = 10;
@@ -203,6 +208,31 @@ function createWmsTileUrl(options: {
203208
]);
204209
}
205210

211+
function createWfsGetFeatureUrl(options: {
212+
endpoint: string;
213+
typeName: string;
214+
version: string;
215+
outputFormat: string;
216+
srsName: string;
217+
maxFeatures?: string;
218+
}): string {
219+
const isWfs2 = options.version.startsWith("2");
220+
const params: Array<[string, string]> = [
221+
["service", "WFS"],
222+
["request", "GetFeature"],
223+
["version", options.version],
224+
[isWfs2 ? "typeNames" : "typeName", options.typeName],
225+
["outputFormat", options.outputFormat],
226+
];
227+
228+
if (options.srsName) params.push(["srsName", options.srsName]);
229+
if (options.maxFeatures) {
230+
params.push([isWfs2 ? "count" : "maxFeatures", options.maxFeatures]);
231+
}
232+
233+
return appendQuery(options.endpoint, params);
234+
}
235+
206236
function parseRequiredNumber(value: string, label: string): number {
207237
const parsed = Number(value);
208238
if (!Number.isFinite(parsed)) {
@@ -270,12 +300,53 @@ function errorMessage(error: unknown, fallback: string): string {
270300
return fallback;
271301
}
272302

303+
function isViteDevServer(): boolean {
304+
return Boolean(
305+
(
306+
import.meta as ImportMeta & {
307+
env?: { DEV?: boolean };
308+
}
309+
).env?.DEV,
310+
);
311+
}
312+
313+
function proxyWfsRequestUrl(url: string): string {
314+
return isViteDevServer()
315+
? `${WFS_PROXY_PATH}?url=${encodeURIComponent(url)}`
316+
: url;
317+
}
318+
319+
function parseGeoJsonFeatureCollection(value: unknown): FeatureCollection {
320+
if (
321+
!value ||
322+
typeof value !== "object" ||
323+
!("type" in value) ||
324+
value.type !== "FeatureCollection" ||
325+
!("features" in value) ||
326+
!Array.isArray(value.features)
327+
) {
328+
throw new Error("The response is not a GeoJSON FeatureCollection.");
329+
}
330+
331+
return value as FeatureCollection;
332+
}
333+
273334
async function fetchGeoJson(url: string): Promise<FeatureCollection> {
274335
const response = await fetch(url);
336+
const text = await response.text();
275337
if (!response.ok) {
276338
throw new Error(`Request failed with status ${response.status}`);
277339
}
278-
return (await response.json()) as FeatureCollection;
340+
try {
341+
return parseGeoJsonFeatureCollection(JSON.parse(text));
342+
} catch (error) {
343+
if (/^\s*</.test(text)) {
344+
throw new Error(
345+
"The service returned XML instead of GeoJSON. Check the layer name and output format.",
346+
);
347+
}
348+
throw error;
349+
}
279350
}
280351

281352
export function AddDataDialog({
@@ -303,6 +374,12 @@ export function AddDataDialog({
303374
const [wmsFormat, setWmsFormat] = useState("image/png");
304375
const [wmsTransparent, setWmsTransparent] = useState(true);
305376
const [wmsTileSize, setWmsTileSize] = useState("256");
377+
const [wfsEndpoint, setWfsEndpoint] = useState(DEFAULT_WFS_ENDPOINT);
378+
const [wfsTypeName, setWfsTypeName] = useState(DEFAULT_WFS_TYPE_NAME);
379+
const [wfsVersion, setWfsVersion] = useState("2.0.0");
380+
const [wfsOutputFormat, setWfsOutputFormat] = useState("application/json");
381+
const [wfsSrsName, setWfsSrsName] = useState("EPSG:4326");
382+
const [wfsMaxFeatures, setWfsMaxFeatures] = useState("1000");
306383
const [wmtsUrl, setWmtsUrl] = useState(DEFAULT_WMTS_URL);
307384
const [wmtsTileSize, setWmtsTileSize] = useState("256");
308385

@@ -359,6 +436,7 @@ export function AddDataDialog({
359436
{
360437
xyz: "XYZ Layer",
361438
wms: "WMS Layer",
439+
wfs: "WFS Layer",
362440
wmts: "WMTS Layer",
363441
vector: "Vector Layer",
364442
raster: "Raster Layer",
@@ -377,6 +455,12 @@ export function AddDataDialog({
377455
setWmsFormat("image/png");
378456
setWmsTransparent(true);
379457
setWmsTileSize("256");
458+
setWfsEndpoint(DEFAULT_WFS_ENDPOINT);
459+
setWfsTypeName(DEFAULT_WFS_TYPE_NAME);
460+
setWfsVersion("2.0.0");
461+
setWfsOutputFormat("application/json");
462+
setWfsSrsName("EPSG:4326");
463+
setWfsMaxFeatures("1000");
380464
setWmtsUrl(DEFAULT_WMTS_URL);
381465
setWmtsTileSize("256");
382466
setVectorMode("geojson-url");
@@ -421,6 +505,9 @@ export function AddDataDialog({
421505
if (kind === "wms") {
422506
return "Add a WMS GetMap service as a tiled raster layer.";
423507
}
508+
if (kind === "wfs") {
509+
return "Add WFS features by requesting GeoJSON from a GetFeature service.";
510+
}
424511
if (kind === "wmts") {
425512
return "Add a WMTS tile URL template as a raster layer.";
426513
}
@@ -744,6 +831,53 @@ export function AddDataDialog({
744831
return;
745832
}
746833

834+
if (kind === "wfs") {
835+
if (!wfsEndpoint.trim()) throw new Error("Enter a WFS service URL.");
836+
if (!wfsTypeName.trim()) {
837+
throw new Error("Enter a WFS feature type name.");
838+
}
839+
if (!wfsOutputFormat.trim()) {
840+
throw new Error("Enter a WFS output format.");
841+
}
842+
843+
const featureUrl = createWfsGetFeatureUrl({
844+
endpoint: wfsEndpoint.trim(),
845+
typeName: wfsTypeName.trim(),
846+
version: wfsVersion,
847+
outputFormat: wfsOutputFormat.trim(),
848+
srsName: wfsSrsName.trim(),
849+
maxFeatures: wfsMaxFeatures.trim() || undefined,
850+
});
851+
const data = await fetchGeoJson(proxyWfsRequestUrl(featureUrl));
852+
addAndClose(
853+
{
854+
...createBaseLayer(
855+
name,
856+
"geojson",
857+
{
858+
type: "geojson",
859+
url: featureUrl,
860+
service: "wfs",
861+
typeName: wfsTypeName.trim(),
862+
version: wfsVersion,
863+
outputFormat: wfsOutputFormat.trim(),
864+
srsName: wfsSrsName.trim() || undefined,
865+
},
866+
{
867+
featureCount: data.features.length,
868+
service: "wfs",
869+
sourceKind: "wfs-getfeature",
870+
typeName: wfsTypeName.trim(),
871+
},
872+
),
873+
geojson: data,
874+
sourcePath: featureUrl,
875+
},
876+
{ fit: true },
877+
);
878+
return;
879+
}
880+
747881
if (kind === "wmts") {
748882
if (!wmtsUrl.trim()) {
749883
throw new Error("Enter a WMTS tile URL template.");
@@ -1069,6 +1203,72 @@ export function AddDataDialog({
10691203
</div>
10701204
)}
10711205

1206+
{kind === "wfs" && (
1207+
<div className="space-y-3">
1208+
<div className="space-y-1.5">
1209+
<Label htmlFor="wfs-endpoint">Service URL</Label>
1210+
<Input
1211+
id="wfs-endpoint"
1212+
placeholder="https://example.com/geoserver/wfs"
1213+
value={wfsEndpoint}
1214+
onChange={(event) => setWfsEndpoint(event.target.value)}
1215+
/>
1216+
</div>
1217+
<div className="grid gap-3 sm:grid-cols-2">
1218+
<div className="space-y-1.5">
1219+
<Label htmlFor="wfs-type-name">Feature type</Label>
1220+
<Input
1221+
id="wfs-type-name"
1222+
placeholder="workspace:layer"
1223+
value={wfsTypeName}
1224+
onChange={(event) => setWfsTypeName(event.target.value)}
1225+
/>
1226+
</div>
1227+
<div className="space-y-1.5">
1228+
<Label htmlFor="wfs-version">Version</Label>
1229+
<Select
1230+
id="wfs-version"
1231+
value={wfsVersion}
1232+
onChange={(event) => setWfsVersion(event.target.value)}
1233+
>
1234+
<option value="2.0.0">2.0.0</option>
1235+
<option value="1.1.0">1.1.0</option>
1236+
<option value="1.0.0">1.0.0</option>
1237+
</Select>
1238+
</div>
1239+
<div className="space-y-1.5">
1240+
<Label htmlFor="wfs-output-format">Output format</Label>
1241+
<Input
1242+
id="wfs-output-format"
1243+
value={wfsOutputFormat}
1244+
onChange={(event) =>
1245+
setWfsOutputFormat(event.target.value)
1246+
}
1247+
/>
1248+
</div>
1249+
<div className="space-y-1.5">
1250+
<Label htmlFor="wfs-srs-name">SRS name</Label>
1251+
<Input
1252+
id="wfs-srs-name"
1253+
placeholder="Optional"
1254+
value={wfsSrsName}
1255+
onChange={(event) => setWfsSrsName(event.target.value)}
1256+
/>
1257+
</div>
1258+
<div className="space-y-1.5">
1259+
<Label htmlFor="wfs-max-features">Max features</Label>
1260+
<Input
1261+
id="wfs-max-features"
1262+
inputMode="numeric"
1263+
placeholder="Optional"
1264+
value={wfsMaxFeatures}
1265+
onChange={(event) => setWfsMaxFeatures(event.target.value)}
1266+
/>
1267+
</div>
1268+
</div>
1269+
</div>
1270+
)}
1271+
10721272
{kind === "wmts" && (
10731273
<div className="grid gap-3 sm:grid-cols-[1fr_7rem]">
10741274
<div className="space-y-1.5">
@@ -1543,7 +1743,7 @@ export function AddDataDialog({
15431743
</Button>
15441744
<Button type="submit" disabled={addLayerDisabled}>
15451745
{!isSubmitting ? (
1546-
kind === "wms" || kind === "wmts" ? (
1746+
kind === "wms" || kind === "wfs" || kind === "wmts" ? (
15471747
<Globe2 className="mr-2 h-3.5 w-3.5" />
15481748
) : kind === "raster" ? (
15491749
<Image className="mr-2 h-3.5 w-3.5" />

apps/geolibre-desktop/src/components/layout/TopToolbar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,9 @@ export function TopToolbar({
502502
<DropdownMenuItem onSelect={() => setAddDataKind("wms")}>
503503
Add WMS Layer
504504
</DropdownMenuItem>
505+
<DropdownMenuItem onSelect={() => setAddDataKind("wfs")}>
506+
Add WFS Layer
507+
</DropdownMenuItem>
505508
<DropdownMenuItem onSelect={() => setAddDataKind("wmts")}>
506509
Add WMTS Layer
507510
</DropdownMenuItem>

apps/geolibre-desktop/vite.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const APP_VERSION = JSON.parse(
1717
readFileSync(new URL("./package.json", import.meta.url), "utf8"),
1818
).version as string;
1919
const WMS_PROXY_PATH = "/__geolibre_wms_proxy";
20+
const WFS_PROXY_PATH = "/__geolibre_wfs_proxy";
2021
const RASTER_PROXY_PATH = "/__geolibre_raster_proxy";
2122
const DUCKDB_WORKER_PATH_PART = "/@duckdb/duckdb-wasm/dist/";
2223
const DUCKDB_WORKER_SOURCE_MAP_RE =
@@ -79,6 +80,17 @@ function wmsProxyPlugin(): Plugin {
7980
res.end(message);
8081
}
8182
});
83+
server.middlewares.use(WFS_PROXY_PATH, async (req, res) => {
84+
try {
85+
await proxyBinaryRequest(req, res, WFS_PROXY_PATH);
86+
} catch (error) {
87+
const message =
88+
error instanceof Error ? error.message : "WFS proxy request failed";
89+
res.statusCode = 502;
90+
res.setHeader("content-type", "text/plain");
91+
res.end(message);
92+
}
93+
});
8294
server.middlewares.use(RASTER_PROXY_PATH, async (req, res) => {
8395
try {
8496
await proxyBinaryRequest(req, res, RASTER_PROXY_PATH);

0 commit comments

Comments
 (0)