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