]> git.argeo.org Git - gpl/argeo-suite.git/blob - org.argeo.app.geo/src/org/argeo/app/geo/http/WfsHttpHandler.java
Merge tag 'v2.3.23' into testing
[gpl/argeo-suite.git] / org.argeo.app.geo / src / org / argeo / app / geo / http / WfsHttpHandler.java
1 package org.argeo.app.geo.http;
2
3 import java.io.BufferedOutputStream;
4 import java.io.IOException;
5 import java.io.InputStream;
6 import java.io.OutputStream;
7 import java.io.UncheckedIOException;
8 import java.net.URL;
9 import java.util.ArrayList;
10 import java.util.HashMap;
11 import java.util.List;
12 import java.util.Map;
13 import java.util.Objects;
14 import java.util.concurrent.atomic.AtomicLong;
15 import java.util.stream.Stream;
16 import java.util.zip.ZipEntry;
17 import java.util.zip.ZipOutputStream;
18
19 import javax.xml.namespace.QName;
20
21 import org.argeo.api.acr.Content;
22 import org.argeo.api.acr.ContentSession;
23 import org.argeo.api.acr.NamespaceUtils;
24 import org.argeo.api.acr.ldap.LdapAttr;
25 import org.argeo.api.acr.search.AndFilter;
26 import org.argeo.api.acr.spi.ProvidedRepository;
27 import org.argeo.api.cms.CmsLog;
28 import org.argeo.app.api.EntityName;
29 import org.argeo.app.api.EntityType;
30 import org.argeo.app.api.WGS84PosName;
31 import org.argeo.app.api.geo.FeatureAdapter;
32 import org.argeo.app.api.geo.WfsKvp;
33 import org.argeo.app.geo.CqlUtils;
34 import org.argeo.app.geo.GeoJson;
35 import org.argeo.app.geo.GeoUtils;
36 import org.argeo.app.geo.GpxUtils;
37 import org.argeo.app.geo.JTS;
38 import org.argeo.app.geo.acr.GeoEntityUtils;
39 import org.argeo.cms.acr.json.AcrJsonUtils;
40 import org.argeo.cms.auth.RemoteAuthUtils;
41 import org.argeo.cms.http.HttpHeader;
42 import org.argeo.cms.http.RemoteAuthHttpExchange;
43 import org.argeo.cms.http.server.HttpServerUtils;
44 import org.argeo.cms.util.LangUtils;
45 import org.geotools.api.feature.GeometryAttribute;
46 import org.geotools.api.feature.simple.SimpleFeature;
47 import org.geotools.api.feature.simple.SimpleFeatureType;
48 import org.geotools.api.feature.type.AttributeDescriptor;
49 import org.geotools.api.feature.type.Name;
50 import org.geotools.api.referencing.FactoryException;
51 import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
52 import org.geotools.api.referencing.operation.MathTransform;
53 import org.geotools.api.referencing.operation.TransformException;
54 import org.geotools.feature.DefaultFeatureCollection;
55 import org.geotools.feature.NameImpl;
56 import org.geotools.feature.simple.SimpleFeatureBuilder;
57 import org.geotools.referencing.CRS;
58 import org.geotools.referencing.crs.DefaultGeographicCRS;
59 import org.geotools.wfs.GML;
60 import org.geotools.wfs.GML.Version;
61 import org.locationtech.jts.geom.Coordinate;
62 import org.locationtech.jts.geom.Envelope;
63 import org.locationtech.jts.geom.Geometry;
64 import org.locationtech.jts.geom.Polygon;
65
66 import com.sun.net.httpserver.HttpExchange;
67 import com.sun.net.httpserver.HttpHandler;
68
69 import jakarta.json.Json;
70 import jakarta.json.stream.JsonGenerator;
71
72 /** A partially implemented WFS 2.0 server. */
73 public class WfsHttpHandler implements HttpHandler {
74 private final static CmsLog log = CmsLog.getLog(WfsHttpHandler.class);
75 private ProvidedRepository contentRepository;
76
77 private final Map<QName, FeatureAdapter> featureAdapters = new HashMap<>();
78
79 @Override
80 public void handle(HttpExchange exchange) throws IOException {
81 ContentSession session = HttpServerUtils.getContentSession(contentRepository, exchange);
82
83 String path = HttpServerUtils.subPath(exchange);
84
85 // content path
86 final String pathToUse = path;
87 String fileName = null;
88 boolean zipped = false;
89 // int lastSlash = path.lastIndexOf('/');
90 // if (lastSlash > 0) {
91 // fileName = path.substring(lastSlash + 1);
92 // }
93 // if (fileName != null) {
94 // pathToUse = path.substring(0, lastSlash);
95 // if (path.endsWith(".zip")) {
96 // zipped = true;
97 // }
98 // } else {
99 // pathToUse = path;
100 // }
101
102 Map<String, List<String>> parameters = HttpServerUtils.parseParameters(exchange);
103
104 // PARAMETERS
105 String cql = getKvpParameter(parameters, WfsKvp.CQL_FILTER);
106 String typeNamesStr = getKvpParameter(parameters, WfsKvp.TYPE_NAMES);
107 String outputFormat = getKvpParameter(parameters, WfsKvp.OUTPUT_FORMAT);
108 if (outputFormat == null) {
109 outputFormat = "application/json";
110 }
111
112 // TODO deal with multiple
113 String formatOption = getKvpParameter(parameters, WfsKvp.FORMAT_OPTIONS);
114 if (formatOption != null) {
115 if (formatOption.startsWith(WfsKvp.FILENAME_))
116 fileName = formatOption.substring(WfsKvp.FILENAME_.length());
117 }
118 if (fileName != null && fileName.endsWith(".zip"))
119 zipped = true;
120
121 // bbox
122 String bboxStr = getKvpParameter(parameters, WfsKvp.BBOX);
123 if (log.isTraceEnabled())
124 log.trace(bboxStr);
125 final Envelope bbox;
126 if (bboxStr != null) {
127 String srs;
128 String[] arr = bboxStr.split(",");
129 // TODO check SRS and convert to WGS84
130 double minLat = Double.parseDouble(arr[0]);
131 double minLon = Double.parseDouble(arr[1]);
132 double maxLat = Double.parseDouble(arr[2]);
133 double maxLon = Double.parseDouble(arr[3]);
134 if (arr.length == 5) {
135 srs = arr[4];
136 } else {
137 srs = null;
138 }
139
140 if (srs != null && !srs.equals(GeoUtils.EPSG_4326)) {
141 try {
142 // TODO optimise
143 CoordinateReferenceSystem sourceCRS = CRS.decode(srs);
144 CoordinateReferenceSystem targetCRS = CRS.decode(GeoUtils.EPSG_4326);
145 MathTransform transform = CRS.findMathTransform(sourceCRS, targetCRS, true);
146 bbox = org.geotools.geometry.jts.JTS.transform(
147 new Envelope(new Coordinate(minLat, minLon), new Coordinate(maxLat, maxLon)), transform);
148 } catch (FactoryException | TransformException e) {
149 throw new IllegalArgumentException("Cannot convert bounding box", e);
150 // bbox = null;
151 }
152 } else {
153 bbox = new Envelope(new Coordinate(minLat, minLon), new Coordinate(maxLat, maxLon));
154 }
155 } else {
156 bbox = null;
157 }
158
159 // response headers
160 exchange.getResponseHeaders().set(HttpHeader.DATE.getHeaderName(), Long.toString(System.currentTimeMillis()));
161
162 if (fileName != null) {
163 exchange.getResponseHeaders().set(HttpHeader.CONTENT_DISPOSITION.getHeaderName(),
164 HttpHeader.ATTACHMENT + ";" + HttpHeader.FILENAME + "=\"" + fileName + "\"");
165
166 }
167
168 // content type
169 if (zipped) {
170 exchange.getResponseHeaders().set(HttpHeader.CONTENT_TYPE.getHeaderName(), "application/zip");
171
172 } else {
173 switch (outputFormat) {
174 case "application/json" -> {
175 exchange.getResponseHeaders().set(HttpHeader.CONTENT_TYPE.getHeaderName(), "application/json");
176 }
177 case "GML3" -> {
178 // exchange.getResponseHeaders().set(HttpHeader.CONTENT_TYPE.getHeaderName(), "application/gml+xml");
179 exchange.getResponseHeaders().set(HttpHeader.CONTENT_TYPE.getHeaderName(), "application/xml");
180 }
181
182 default -> throw new IllegalArgumentException("Unexpected value: " + outputFormat);
183 }
184 }
185
186 List<QName> typeNames = new ArrayList<>();
187 if (typeNamesStr != null) {
188 String[] arr = typeNamesStr.split(",");
189 for (int i = 0; i < arr.length; i++) {
190 typeNames.add(NamespaceUtils.parsePrefixedName(arr[i]));
191 }
192 } else {
193 typeNames.add(EntityType.local.qName());
194 }
195
196 if (typeNames.size() > 1)
197 throw new UnsupportedOperationException("Only one type name is currently supported");
198
199 // QUERY
200 Stream<Content> res = session.search((search) -> {
201 if (cql != null) {
202 CqlUtils.filter(search.from(pathToUse), cql);
203 } else {
204 search.from(pathToUse);
205 }
206 for (QName typeName : typeNames) {
207 FeatureAdapter featureAdapter = featureAdapters.get(typeName);
208 if (featureAdapter == null)
209 throw new IllegalStateException("No feature adapter found for " + typeName);
210 // f.isContentClass(typeName);
211 RemoteAuthUtils.doAs(() -> {
212 featureAdapter.addConstraintsForFeature((AndFilter) search.getWhere(), typeName);
213 return null;
214 }, new RemoteAuthHttpExchange(exchange));
215 }
216
217 if (bbox != null) {
218 search.getWhere().any((or) -> {
219 // box overlap, see
220 // https://stackoverflow.com/questions/20925818/algorithm-to-check-if-two-boxes-overlap
221 // isOverlapping = (x1min < x2max AND x2min < x1max AND y1min < y2max AND y2min
222 // < y1max)
223 // x1 = entity, x2 = bbox
224 or.all((and) -> {
225 and.lte(EntityName.minLat, bbox.getMaxX());
226 and.gte(EntityName.maxLat, bbox.getMinX());
227 and.lte(EntityName.minLon, bbox.getMaxY());
228 and.gte(EntityName.maxLon, bbox.getMinY());
229 });
230 or.all((and) -> {
231 and.gte(WGS84PosName.lat, bbox.getMinX());
232 and.gte(WGS84PosName.lon, bbox.getMinY());
233 and.lte(WGS84PosName.lat, bbox.getMaxX());
234 and.lte(WGS84PosName.lon, bbox.getMaxY());
235 });
236 });
237 }
238 });
239
240 exchange.sendResponseHeaders(200, 0);
241
242 final int BUFFER_SIZE = 100 * 1024;
243 try (OutputStream out = zipped ? new ZipOutputStream(exchange.getResponseBody())
244 : new BufferedOutputStream(exchange.getResponseBody(), BUFFER_SIZE)) {
245 if (out instanceof ZipOutputStream zipOut) {
246 String unzippedFileName = fileName.substring(0, fileName.length() - ".zip".length());
247 zipOut.putNextEntry(new ZipEntry(unzippedFileName));
248 }
249
250 if ("GML3".equals(outputFormat)) {
251 encodeCollectionAsGML(res, out);
252 } else if ("application/json".equals(outputFormat)) {
253 encodeCollectionAsGeoJSon(res, out, typeNames);
254 }
255 }
256 }
257
258 /**
259 * Retrieve KVP (keyword-value pairs) parameters, which are lower case, as per
260 * specifications.
261 *
262 * @see https://docs.ogc.org/is/09-025r2/09-025r2.html#19
263 */
264 protected String getKvpParameter(Map<String, List<String>> parameters, WfsKvp key) {
265 Objects.requireNonNull(key, "KVP key cannot be null");
266 // let's first try the default (CAML case) which should be more efficient
267 List<String> values = parameters.get(key.getKey());
268 if (values == null) {
269 // then let's do an ignore case comparison of the key
270 keys: for (String k : parameters.keySet()) {
271 if (key.getKey().equalsIgnoreCase(k)) {
272 values = parameters.get(k);
273 break keys;
274 }
275 }
276 }
277 if (values == null) // nothing was found
278 return null;
279 if (values.size() != 1) {
280 // although not completely clear from the standard, we assume keys must be
281 // unique
282 // since lists are defined here
283 // https://docs.ogc.org/is/09-026r2/09-026r2.html#10
284 throw new IllegalArgumentException("Key " + key + " as multiple values");
285 }
286 String value = values.get(0);
287 assert value != null;
288 return value;
289 }
290
291 protected void encodeCollectionAsGeoJSon(Stream<Content> features, OutputStream out, List<QName> typeNames)
292 throws IOException {
293 long begin = System.currentTimeMillis();
294 AtomicLong count = new AtomicLong(0);
295 JsonGenerator generator = Json.createGenerator(out);
296 generator.writeStartObject();
297 generator.write("type", "FeatureCollection");
298 generator.writeStartArray("features");
299 features.forEach((c) -> {
300 // TODO deal with multiple type names
301 FeatureAdapter featureAdapter = null;
302 QName typeName = null;
303 if (!typeNames.isEmpty()) {
304 typeName = typeNames.get(0);
305 featureAdapter = featureAdapters.get(typeName);
306 }
307
308 boolean geometryWritten = false;
309 // if (typeName.getLocalPart().equals("fieldSimpleFeature")) {
310 // Content area = c.getContent("place.geom.json").orElse(null);
311 // if (area != null) {
312 // generator.writeStartObject();
313 // generator.write("type", "Feature");
314 // String featureId = getFeatureId(c);
315 // if (featureId != null)
316 // generator.write("id", featureId);
317 //
318 // generator.flush();
319 // try (InputStream in = area.open(InputStream.class)) {
320 // out.write(",\"geometry\":".getBytes());
321 // StreamUtils.copy(in, out);
322 // //out.flush();
323 // } catch (Exception e) {
324 // log.error(c.getPath() + " : " + e.getMessage());
325 // } finally {
326 // }
327 // geometryWritten = true;
328 // }else {
329 // return;
330 // }
331 // }
332
333 if (!geometryWritten) {
334
335 Geometry defaultGeometry = featureAdapter != null ? featureAdapter.getDefaultGeometry(c, typeName)
336 : getDefaultGeometry(c);
337 if (defaultGeometry == null)
338 return;
339 generator.writeStartObject();
340 generator.write("type", "Feature");
341 String featureId = getFeatureId(c);
342 if (featureId != null)
343 generator.write("id", featureId);
344
345 GeoJson.writeBBox(generator, defaultGeometry);
346 generator.writeStartObject(GeoJson.GEOMETRY);
347 GeoJson.writeGeometry(generator, defaultGeometry);
348 generator.writeEnd();// geometry object
349 }
350 generator.writeStartObject(GeoJson.PROPERTIES);
351 AcrJsonUtils.writeTimeProperties(generator, c);
352 if (featureAdapter != null)
353 featureAdapter.writeProperties(generator, c, typeName);
354 else
355 writeProperties(generator, c);
356 generator.writeEnd();// properties object
357
358 generator.writeEnd();// feature object
359
360 if (count.incrementAndGet() % 10 == 0)
361 try {
362 out.flush();
363 } catch (IOException e) {
364 throw new UncheckedIOException(e);
365 }
366 });
367 generator.writeEnd();// features array
368 generator.writeEnd().close();
369
370 if (log.isTraceEnabled())
371 log.trace("GeoJSon encoding took " + (System.currentTimeMillis() - begin) + " ms.");
372 }
373
374 protected Geometry getDefaultGeometry(Content content) {
375 if (content.hasContentClass(EntityType.geopoint)) {
376 return GeoEntityUtils.toPoint(content);
377 }
378 return null;
379 }
380
381 protected String getFeatureId(Content content) {
382 String uuid = content.attr(LdapAttr.entryUUID);
383 return uuid;
384 }
385
386 public void writeProperties(JsonGenerator generator, Content content) {
387 String path = content.getPath();
388 generator.write("path", path);
389 if (content.hasContentClass(EntityType.local)) {
390 String type = content.attr(EntityName.type);
391 generator.write("type", type);
392 } else {
393 List<QName> contentClasses = content.getContentClasses();
394 if (!contentClasses.isEmpty()) {
395 generator.write("type", NamespaceUtils.toPrefixedName(contentClasses.get(0)));
396 }
397 }
398
399 }
400
401 protected void encodeCollectionAsGML(Stream<Content> features, OutputStream out) throws IOException {
402 String entityType = "entity";
403 URL schemaLocation = getClass().getResource("/org/argeo/app/api/entity.xsd");
404 String namespace = "http://www.argeo.org/ns/entity";
405
406 GML gml = new GML(Version.WFS1_1);
407 gml.setCoordinateReferenceSystem(DefaultGeographicCRS.WGS84);
408 gml.setNamespace("local", namespace);
409
410 SimpleFeatureType featureType = gml.decodeSimpleFeatureType(schemaLocation,
411 new NameImpl(namespace, entityType + "Feature"));
412
413 // CoordinateReferenceSystem crs=DefaultGeographicCRS.WGS84;
414 // QName featureName = new QName(namespace,"apafFieldFeature");
415 // GMLConfiguration configuration = new GMLConfiguration();
416 // FeatureType parsed = GTXML.parseFeatureType(configuration, featureName, crs);
417 // SimpleFeatureType featureType = DataUtilities.simple(parsed);
418
419 SimpleFeatureBuilder featureBuilder = new SimpleFeatureBuilder(featureType);
420
421 DefaultFeatureCollection featureCollection = new DefaultFeatureCollection();
422
423 features.forEach((c) -> {
424 // boolean gpx = false;
425 Geometry the_geom = null;
426 Polygon the_area = null;
427 // if (gpx) {
428 Content area = c.getContent("gpx/area.gpx").orElse(null);
429 if (area != null) {
430
431 try (InputStream in = area.open(InputStream.class)) {
432 the_area = GpxUtils.parseGpxTrackTo(in, Polygon.class);
433 } catch (IOException e) {
434 throw new UncheckedIOException("Cannot parse " + c, e);
435 }
436 }
437 // } else {
438 if (c.hasContentClass(EntityType.geopoint)) {
439 double latitude = c.get(WGS84PosName.lat, Double.class).get();
440 double longitude = c.get(WGS84PosName.lon, Double.class).get();
441
442 Coordinate coordinate = new Coordinate(longitude, latitude);
443 the_geom = JTS.GEOMETRY_FACTORY.createPoint(coordinate);
444 }
445
446 // }
447 if (the_geom != null)
448 featureBuilder.set(new NameImpl(namespace, "geopoint"), the_geom);
449 if (the_area != null)
450 featureBuilder.set(new NameImpl(namespace, "area"), the_area);
451
452 List<AttributeDescriptor> attrDescs = featureType.getAttributeDescriptors();
453 for (AttributeDescriptor attrDesc : attrDescs) {
454 if (attrDesc instanceof GeometryAttribute)
455 continue;
456 Name name = attrDesc.getName();
457 QName qName = new QName(name.getNamespaceURI(), name.getLocalPart());
458 String value = c.attr(qName);
459 if (value == null) {
460 value = c.attr(name.getLocalPart());
461 }
462 if (value != null) {
463 featureBuilder.set(name, value);
464 }
465 }
466
467 String uuid = c.attr(LdapAttr.entryUUID);
468
469 SimpleFeature feature = featureBuilder.buildFeature(uuid);
470 featureCollection.add(feature);
471
472 });
473 gml.encode(out, featureCollection);
474 out.close();
475
476 }
477
478 /*
479 * DEPENDENCY INJECTION
480 */
481
482 public void addFeatureAdapter(FeatureAdapter featureAdapter, Map<String, Object> properties) {
483 List<String> typeNames = LangUtils.toStringList(properties.get(WfsKvp.TYPE_NAMES.getKey()));
484 if (typeNames.isEmpty()) {
485 log.warn("FeatureAdapter " + featureAdapter.getClass() + " does not declare type names. Ignoring it...");
486 return;
487 }
488
489 for (String tn : typeNames) {
490 QName typeName = NamespaceUtils.parsePrefixedName(tn);
491 featureAdapters.put(typeName, featureAdapter);
492 }
493 }
494
495 public void removeFeatureAdapter(FeatureAdapter featureAdapter, Map<String, Object> properties) {
496 List<String> typeNames = LangUtils.toStringList(properties.get(WfsKvp.TYPE_NAMES.getKey()));
497 if (!typeNames.isEmpty()) {
498 // ignore if noe type name declared
499 return;
500 }
501
502 for (String tn : typeNames) {
503 QName typeName = NamespaceUtils.parsePrefixedName(tn);
504 featureAdapters.remove(typeName);
505 }
506 }
507
508 public void setContentRepository(ProvidedRepository contentRepository) {
509 this.contentRepository = contentRepository;
510 }
511 }