]> git.argeo.org Git - gpl/argeo-suite.git/blob - geo/http/WfsHttpHandler.java
Prepare next development cycle
[gpl/argeo-suite.git] / 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 https://stackoverflow.com/questions/20925818/algorithm-to-check-if-two-boxes-overlap
220 // isOverlapping = (x1min < x2max AND x2min < x1max AND y1min < y2max AND y2min < y1max)
221 // x1 = entity, x2 = bbox
222 or.all((and) -> {
223 and.lte(EntityName.minLat, bbox.getMaxX());
224 and.gte(EntityName.maxLat, bbox.getMinX());
225 and.lte(EntityName.minLon, bbox.getMaxY());
226 and.gte(EntityName.maxLon, bbox.getMinY());
227 });
228 or.all((and) -> {
229 and.gte(WGS84PosName.lat, bbox.getMinX());
230 and.gte(WGS84PosName.lon, bbox.getMinY());
231 and.lte(WGS84PosName.lat, bbox.getMaxX());
232 and.lte(WGS84PosName.lon, bbox.getMaxY());
233 });
234 });
235 }
236 });
237
238 exchange.sendResponseHeaders(200, 0);
239
240 final int BUFFER_SIZE = 100 * 1024;
241 try (OutputStream out = zipped ? new ZipOutputStream(exchange.getResponseBody())
242 : new BufferedOutputStream(exchange.getResponseBody(), BUFFER_SIZE)) {
243 if (out instanceof ZipOutputStream zipOut) {
244 String unzippedFileName = fileName.substring(0, fileName.length() - ".zip".length());
245 zipOut.putNextEntry(new ZipEntry(unzippedFileName));
246 }
247
248 if ("GML3".equals(outputFormat)) {
249 encodeCollectionAsGML(res, out);
250 } else if ("application/json".equals(outputFormat)) {
251 encodeCollectionAsGeoJSon(res, out, typeNames);
252 }
253 }
254 }
255
256 /**
257 * Retrieve KVP (keyword-value pairs) parameters, which are lower case, as per
258 * specifications.
259 *
260 * @see https://docs.ogc.org/is/09-025r2/09-025r2.html#19
261 */
262 protected String getKvpParameter(Map<String, List<String>> parameters, WfsKvp key) {
263 Objects.requireNonNull(key, "KVP key cannot be null");
264 // let's first try the default (CAML case) which should be more efficient
265 List<String> values = parameters.get(key.getKey());
266 if (values == null) {
267 // then let's do an ignore case comparison of the key
268 keys: for (String k : parameters.keySet()) {
269 if (key.getKey().equalsIgnoreCase(k)) {
270 values = parameters.get(k);
271 break keys;
272 }
273 }
274 }
275 if (values == null) // nothing was found
276 return null;
277 if (values.size() != 1) {
278 // although not completely clear from the standard, we assume keys must be
279 // unique
280 // since lists are defined here
281 // https://docs.ogc.org/is/09-026r2/09-026r2.html#10
282 throw new IllegalArgumentException("Key " + key + " as multiple values");
283 }
284 String value = values.get(0);
285 assert value != null;
286 return value;
287 }
288
289 protected void encodeCollectionAsGeoJSon(Stream<Content> features, OutputStream out, List<QName> typeNames)
290 throws IOException {
291 long begin = System.currentTimeMillis();
292 AtomicLong count = new AtomicLong(0);
293 JsonGenerator generator = Json.createGenerator(out);
294 generator.writeStartObject();
295 generator.write("type", "FeatureCollection");
296 generator.writeStartArray("features");
297 features.forEach((c) -> {
298 // TODO deal with multiple type names
299 FeatureAdapter featureAdapter = null;
300 QName typeName = null;
301 if (!typeNames.isEmpty()) {
302 typeName = typeNames.get(0);
303 featureAdapter = featureAdapters.get(typeName);
304 }
305
306 boolean geometryWritten = false;
307 // if (typeName.getLocalPart().equals("fieldSimpleFeature")) {
308 // Content area = c.getContent("place.geom.json").orElse(null);
309 // if (area != null) {
310 // generator.writeStartObject();
311 // generator.write("type", "Feature");
312 // String featureId = getFeatureId(c);
313 // if (featureId != null)
314 // generator.write("id", featureId);
315 //
316 // generator.flush();
317 // try (InputStream in = area.open(InputStream.class)) {
318 // out.write(",\"geometry\":".getBytes());
319 // StreamUtils.copy(in, out);
320 // //out.flush();
321 // } catch (Exception e) {
322 // log.error(c.getPath() + " : " + e.getMessage());
323 // } finally {
324 // }
325 // geometryWritten = true;
326 // }else {
327 // return;
328 // }
329 // }
330
331 if (!geometryWritten) {
332
333 Geometry defaultGeometry = featureAdapter != null ? featureAdapter.getDefaultGeometry(c, typeName)
334 : getDefaultGeometry(c);
335 if (defaultGeometry == null)
336 return;
337 generator.writeStartObject();
338 generator.write("type", "Feature");
339 String featureId = getFeatureId(c);
340 if (featureId != null)
341 generator.write("id", featureId);
342
343 GeoJson.writeBBox(generator, defaultGeometry);
344 generator.writeStartObject(GeoJson.GEOMETRY);
345 GeoJson.writeGeometry(generator, defaultGeometry);
346 generator.writeEnd();// geometry object
347 }
348 generator.writeStartObject(GeoJson.PROPERTIES);
349 AcrJsonUtils.writeTimeProperties(generator, c);
350 if (featureAdapter != null)
351 featureAdapter.writeProperties(generator, c, typeName);
352 else
353 writeProperties(generator, c);
354 generator.writeEnd();// properties object
355
356 generator.writeEnd();// feature object
357
358 if (count.incrementAndGet() % 10 == 0)
359 try {
360 out.flush();
361 } catch (IOException e) {
362 throw new UncheckedIOException(e);
363 }
364 });
365 generator.writeEnd();// features array
366 generator.writeEnd().close();
367
368 log.debug("GeoJSon encoding took " + (System.currentTimeMillis() - begin) + " ms.");
369 }
370
371 protected Geometry getDefaultGeometry(Content content) {
372 if (content.hasContentClass(EntityType.geopoint)) {
373 return GeoEntityUtils.toPoint(content);
374 }
375 return null;
376 }
377
378 protected String getFeatureId(Content content) {
379 String uuid = content.attr(LdapAttr.entryUUID);
380 return uuid;
381 }
382
383 public void writeProperties(JsonGenerator generator, Content content) {
384 String path = content.getPath();
385 generator.write("path", path);
386 if (content.hasContentClass(EntityType.local)) {
387 String type = content.attr(EntityName.type);
388 generator.write("type", type);
389 } else {
390 List<QName> contentClasses = content.getContentClasses();
391 if (!contentClasses.isEmpty()) {
392 generator.write("type", NamespaceUtils.toPrefixedName(contentClasses.get(0)));
393 }
394 }
395
396 }
397
398 protected void encodeCollectionAsGML(Stream<Content> features, OutputStream out) throws IOException {
399 String entityType = "entity";
400 URL schemaLocation = getClass().getResource("/org/argeo/app/api/entity.xsd");
401 String namespace = "http://www.argeo.org/ns/entity";
402
403 GML gml = new GML(Version.WFS1_1);
404 gml.setCoordinateReferenceSystem(DefaultGeographicCRS.WGS84);
405 gml.setNamespace("local", namespace);
406
407 SimpleFeatureType featureType = gml.decodeSimpleFeatureType(schemaLocation,
408 new NameImpl(namespace, entityType + "Feature"));
409
410 // CoordinateReferenceSystem crs=DefaultGeographicCRS.WGS84;
411 // QName featureName = new QName(namespace,"apafFieldFeature");
412 // GMLConfiguration configuration = new GMLConfiguration();
413 // FeatureType parsed = GTXML.parseFeatureType(configuration, featureName, crs);
414 // SimpleFeatureType featureType = DataUtilities.simple(parsed);
415
416 SimpleFeatureBuilder featureBuilder = new SimpleFeatureBuilder(featureType);
417
418 DefaultFeatureCollection featureCollection = new DefaultFeatureCollection();
419
420 features.forEach((c) -> {
421 // boolean gpx = false;
422 Geometry the_geom = null;
423 Polygon the_area = null;
424 // if (gpx) {
425 Content area = c.getContent("gpx/area.gpx").orElse(null);
426 if (area != null) {
427
428 try (InputStream in = area.open(InputStream.class)) {
429 the_area = GpxUtils.parseGpxTrackTo(in, Polygon.class);
430 } catch (IOException e) {
431 throw new UncheckedIOException("Cannot parse " + c, e);
432 }
433 }
434 // } else {
435 if (c.hasContentClass(EntityType.geopoint)) {
436 double latitude = c.get(WGS84PosName.lat, Double.class).get();
437 double longitude = c.get(WGS84PosName.lon, Double.class).get();
438
439 Coordinate coordinate = new Coordinate(longitude, latitude);
440 the_geom = JTS.GEOMETRY_FACTORY.createPoint(coordinate);
441 }
442
443 // }
444 if (the_geom != null)
445 featureBuilder.set(new NameImpl(namespace, "geopoint"), the_geom);
446 if (the_area != null)
447 featureBuilder.set(new NameImpl(namespace, "area"), the_area);
448
449 List<AttributeDescriptor> attrDescs = featureType.getAttributeDescriptors();
450 for (AttributeDescriptor attrDesc : attrDescs) {
451 if (attrDesc instanceof GeometryAttribute)
452 continue;
453 Name name = attrDesc.getName();
454 QName qName = new QName(name.getNamespaceURI(), name.getLocalPart());
455 String value = c.attr(qName);
456 if (value == null) {
457 value = c.attr(name.getLocalPart());
458 }
459 if (value != null) {
460 featureBuilder.set(name, value);
461 }
462 }
463
464 String uuid = c.attr(LdapAttr.entryUUID);
465
466 SimpleFeature feature = featureBuilder.buildFeature(uuid);
467 featureCollection.add(feature);
468
469 });
470 gml.encode(out, featureCollection);
471 out.close();
472
473 }
474
475 /*
476 * DEPENDENCY INJECTION
477 */
478
479 public void addFeatureAdapter(FeatureAdapter featureAdapter, Map<String, Object> properties) {
480 List<String> typeNames = LangUtils.toStringList(properties.get(WfsKvp.TYPE_NAMES.getKey()));
481 if (typeNames.isEmpty()) {
482 log.warn("FeatureAdapter " + featureAdapter.getClass() + " does not declare type names. Ignoring it...");
483 return;
484 }
485
486 for (String tn : typeNames) {
487 QName typeName = NamespaceUtils.parsePrefixedName(tn);
488 featureAdapters.put(typeName, featureAdapter);
489 }
490 }
491
492 public void removeFeatureAdapter(FeatureAdapter featureAdapter, Map<String, Object> properties) {
493 List<String> typeNames = LangUtils.toStringList(properties.get(WfsKvp.TYPE_NAMES.getKey()));
494 if (!typeNames.isEmpty()) {
495 // ignore if noe type name declared
496 return;
497 }
498
499 for (String tn : typeNames) {
500 QName typeName = NamespaceUtils.parsePrefixedName(tn);
501 featureAdapters.remove(typeName);
502 }
503 }
504
505 public void setContentRepository(ProvidedRepository contentRepository) {
506 this.contentRepository = contentRepository;
507 }
508 }