1 package org
.argeo
.app
.geo
.http
;
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
;
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
;
17 import javax
.xml
.namespace
.QName
;
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
;
51 import com
.sun
.net
.httpserver
.HttpExchange
;
52 import com
.sun
.net
.httpserver
.HttpHandler
;
54 import jakarta
.json
.Json
;
55 import jakarta
.json
.stream
.JsonGenerator
;
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
;
62 private final Map
<QName
, FeatureAdapter
> featureAdapters
= new HashMap
<>();
65 public void handle(HttpExchange exchange
) throws IOException
{
67 ContentSession session
= HttpServerUtils
.getContentSession(contentRepository
, exchange
);
69 String path
= HttpServerUtils
.subPath(exchange
);
72 final String pathToUse
= path
;
73 String fileName
= null;
74 boolean zipped
= false;
76 Map
<String
, List
<String
>> parameters
= HttpServerUtils
.parseParameters(exchange
);
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";
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());
92 if (fileName
!= null && fileName
.endsWith(".zip"))
96 String bboxStr
= getKvpParameter(parameters
, WfsKvp
.BBOX
);
97 if (log
.isTraceEnabled())
100 if (bboxStr
!= null) {
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) {
114 if (srs
!= null && !srs
.equals(GeoUtils
.EPSG_4326
)) {
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
)),
123 } catch (FactoryException
| TransformException e
) {
124 throw new IllegalArgumentException("Cannot convert bounding box", e
);
128 bbox
= new Envelope(new Coordinate(minLat
, minLon
), new Coordinate(maxLat
, maxLon
));
135 exchange
.getResponseHeaders().set(HttpHeader
.DATE
.getHeaderName(),
136 Long
.toString(System
.currentTimeMillis()));
138 if (fileName
!= null) {
139 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_DISPOSITION
.getHeaderName(),
140 HttpHeader
.ATTACHMENT
+ ";" + HttpHeader
.FILENAME
+ "=\"" + fileName
+ "\"");
146 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/zip");
149 switch (outputFormat
) {
150 case "application/json" -> {
151 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/json");
154 // exchange.getResponseHeaders().set(HttpHeader.CONTENT_TYPE.getHeaderName(), "application/gml+xml");
155 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/xml");
158 default -> throw new IllegalArgumentException("Unexpected value: " + outputFormat
);
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
]));
169 typeNames
.add(EntityType
.local
.qName());
172 if (typeNames
.size() > 1)
173 throw new UnsupportedOperationException("Only one type name is currently supported");
176 Stream
<Content
> res
= session
.search((search
) -> {
178 CqlUtils
.filter(search
.from(pathToUse
), cql
);
180 search
.from(pathToUse
);
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
);
190 }, new RemoteAuthHttpExchange(exchange
));
194 search
.getWhere().any((or
) -> {
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
199 // x1 = entity, x2 = bbox
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());
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());
216 exchange
.sendResponseHeaders(HttpStatus
.OK
.getCode(), 0);
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
));
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
);
233 } catch (Exception e
) {
234 log
.error("Cannot process WFS request " + exchange
, e
);
236 exchange
.sendResponseHeaders(HttpStatus
.INTERNAL_SERVER_ERROR
.getCode(), -1);
237 } catch (IOException e1
) {
240 if (e
instanceof IOException
)
241 throw (IOException
) e
;
246 * Retrieve KVP (keyword-value pairs) parameters, which are lower case, as per
249 * @see https://docs.ogc.org/is/09-025r2/09-025r2.html#19
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
);
264 if (values
== null) // nothing was found
266 if (values
.size() != 1) {
267 // although not completely clear from the standard, we assume keys must be
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");
273 String value
= values
.get(0);
274 assert value
!= null;
278 protected void encodeCollectionAsGeoJSon(Stream
<Content
> features
, OutputStream out
, List
<QName
> typeNames
)
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
);
295 boolean geometryWritten
= false;
297 if (!geometryWritten
) {
299 Geometry defaultGeometry
= featureAdapter
!= null ? featureAdapter
.getDefaultGeometry(c
, typeName
)
300 : getDefaultGeometry(c
);
301 if (defaultGeometry
== null)
303 generator
.writeStartObject();
304 generator
.write("type", "Feature");
305 String featureId
= getFeatureId(c
);
306 if (featureId
!= null)
307 generator
.write("id", featureId
);
309 GeoJson
.writeBBox(generator
, defaultGeometry
);
310 generator
.writeStartObject(GeoJson
.GEOMETRY
);
311 GeoJson
.writeGeometry(generator
, defaultGeometry
);
312 generator
.writeEnd();// geometry object
314 generator
.writeStartObject(GeoJson
.PROPERTIES
);
315 AcrJsonUtils
.writeTimeProperties(generator
, c
);
316 if (featureAdapter
!= null)
317 featureAdapter
.writeProperties(generator
, c
, typeName
);
319 writeProperties(generator
, c
);
320 generator
.writeEnd();// properties object
322 generator
.writeEnd();// feature object
324 if (count
.incrementAndGet() % 10 == 0)
327 } catch (IOException e
) {
328 throw new UncheckedIOException(e
);
331 generator
.writeEnd();// features array
332 generator
.writeEnd().close();
334 if (log
.isTraceEnabled())
335 log
.trace("GeoJSon encoding took " + (System
.currentTimeMillis() - begin
) + " ms.");
338 protected Geometry
getDefaultGeometry(Content content
) {
339 if (content
.hasContentClass(EntityType
.geopoint
)) {
340 return GeoEntityUtils
.toPoint(content
);
345 protected String
getFeatureId(Content content
) {
346 String uuid
= content
.attr(LdapAttr
.entryUUID
);
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
);
357 List
<QName
> contentClasses
= content
.getContentClasses();
358 if (!contentClasses
.isEmpty()) {
359 generator
.write("type", NamespaceUtils
.toPrefixedName(contentClasses
.get(0)));
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";
370 // GML gml = new GML(Version.WFS1_1);
371 // gml.setCoordinateReferenceSystem(DefaultGeographicCRS.WGS84);
372 // gml.setNamespace("local", namespace);
374 // SimpleFeatureType featureType = gml.decodeSimpleFeatureType(schemaLocation,
375 // new NameImpl(namespace, entityType + "Feature"));
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);
383 // SimpleFeatureBuilder featureBuilder = new SimpleFeatureBuilder(featureType);
385 // DefaultFeatureCollection featureCollection = new DefaultFeatureCollection();
387 // features.forEach((c) -> {
388 //// boolean gpx = false;
389 // Geometry the_geom = null;
390 // Polygon the_area = null;
392 // Content area = c.getContent("gpx/area.gpx").orElse(null);
393 // if (area != null) {
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);
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();
406 // Coordinate coordinate = new Coordinate(longitude, latitude);
407 // the_geom = JTS.GEOMETRY_FACTORY.createPoint(coordinate);
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);
416 // List<AttributeDescriptor> attrDescs = featureType.getAttributeDescriptors();
417 // for (AttributeDescriptor attrDesc : attrDescs) {
418 // if (attrDesc instanceof GeometryAttribute)
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());
426 // if (value != null) {
427 // featureBuilder.set(name, value);
431 // String uuid = c.attr(LdapAttr.entryUUID);
433 // SimpleFeature feature = featureBuilder.buildFeature(uuid);
434 // featureCollection.add(feature);
437 // gml.encode(out, featureCollection);
443 * DEPENDENCY INJECTION
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...");
453 for (String tn
: typeNames
) {
454 QName typeName
= NamespaceUtils
.parsePrefixedName(tn
);
455 featureAdapters
.put(typeName
, featureAdapter
);
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
466 for (String tn
: typeNames
) {
467 QName typeName
= NamespaceUtils
.parsePrefixedName(tn
);
468 featureAdapters
.remove(typeName
);
472 public void setContentRepository(ProvidedRepository contentRepository
) {
473 this.contentRepository
= contentRepository
;