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