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