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