1 package org
.argeo
.app
.geo
.http
;
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
;
9 import java
.util
.ArrayList
;
10 import java
.util
.HashMap
;
11 import java
.util
.List
;
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
;
19 import javax
.xml
.namespace
.QName
;
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
;
63 import com
.sun
.net
.httpserver
.HttpExchange
;
64 import com
.sun
.net
.httpserver
.HttpHandler
;
66 import jakarta
.json
.Json
;
67 import jakarta
.json
.stream
.JsonGenerator
;
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
;
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";
80 private final Map
<QName
, FeatureAdapter
> featureAdapters
= new HashMap
<>();
83 public void handle(HttpExchange exchange
) throws IOException
{
84 String path
= HttpServerUtils
.subPath(exchange
);
87 final String pathToUse
;
88 int lastSlash
= path
.lastIndexOf('/');
89 String fileName
= null;
91 fileName
= path
.substring(lastSlash
+ 1);
93 boolean zipped
= false;
94 if (fileName
!= null) {
95 pathToUse
= path
.substring(0, lastSlash
);
96 if (path
.endsWith(".zip")) {
103 ContentSession session
= HttpServerUtils
.getContentSession(contentRepository
, exchange
);
104 // Content content = session.get(path);
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";
114 String bboxStr
= getKvpParameter(parameters
, BBOX
);
115 if (log
.isTraceEnabled())
118 if (bboxStr
!= null) {
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) {
132 if (srs
!= null && !srs
.equals(GeoUtils
.EPSG_4326
)) {
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
);
145 bbox
= new Envelope(new Coordinate(minLat
, minLon
), new Coordinate(maxLat
, maxLon
));
152 exchange
.getResponseHeaders().set(HttpHeader
.DATE
.getHeaderName(), Long
.toString(System
.currentTimeMillis()));
154 if (fileName
!= null) {
155 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_DISPOSITION
.getHeaderName(),
156 HttpHeader
.ATTACHMENT
+ ";" + HttpHeader
.FILENAME
+ "=\"" + fileName
+ "\"");
162 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/zip");
165 switch (outputFormat
) {
166 case "application/json" -> {
167 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/json");
170 // exchange.getResponseHeaders().set(HttpHeader.CONTENT_TYPE.getHeaderName(), "application/gml+xml");
171 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/xml");
174 default -> throw new IllegalArgumentException("Unexpected value: " + outputFormat
);
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
]));
185 typeNames
.add(EntityType
.local
.qName());
188 if (typeNames
.size() > 1)
189 throw new UnsupportedOperationException("Only one type name is currently supported");
192 Stream
<Content
> res
= session
.search((search
) -> {
194 CqlUtils
.filter(search
.from(pathToUse
), cql
);
196 search
.from(pathToUse
);
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
);
207 search
.getWhere().any((or
) -> {
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());
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());
224 exchange
.sendResponseHeaders(200, 0);
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
));
234 if ("GML3".equals(outputFormat
)) {
235 encodeCollectionAsGML(res
, out
);
236 } else if ("application/json".equals(outputFormat
)) {
237 encodeCollectionAsGeoJSon(res
, out
, typeNames
);
243 * Retrieve KVP (keyword-value pairs) parameters, which are lower case, as per
246 * @see https://docs.ogc.org/is/09-025r2/09-025r2.html#19
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
);
261 if (values
== null) // nothing was found
263 if (values
.size() != 1) {
264 // although not completely clear from the standard, we assume keys must be
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");
270 String value
= values
.get(0);
271 assert value
!= null;
275 protected void encodeCollectionAsGeoJSon(Stream
<Content
> features
, OutputStream out
, List
<QName
> typeNames
)
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
);
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);
302 // generator.flush();
303 // try (InputStream in = area.open(InputStream.class)) {
304 // out.write(",\"geometry\":".getBytes());
305 // StreamUtils.copy(in, out);
307 // } catch (Exception e) {
308 // log.error(c.getPath() + " : " + e.getMessage());
311 // geometryWritten = true;
317 if (!geometryWritten
) {
319 Geometry defaultGeometry
= featureAdapter
!= null ? featureAdapter
.getDefaultGeometry(c
, typeName
)
320 : getDefaultGeometry(c
);
321 if (defaultGeometry
== null)
323 generator
.writeStartObject();
324 generator
.write("type", "Feature");
325 String featureId
= getFeatureId(c
);
326 if (featureId
!= null)
327 generator
.write("id", featureId
);
329 GeoJson
.writeBBox(generator
, defaultGeometry
);
330 generator
.writeStartObject(GeoJson
.GEOMETRY
);
331 GeoJson
.writeGeometry(generator
, defaultGeometry
);
332 generator
.writeEnd();// geometry object
334 generator
.writeStartObject(GeoJson
.PROPERTIES
);
335 AcrJsonUtils
.writeTimeProperties(generator
, c
);
336 if (featureAdapter
!= null)
337 featureAdapter
.writeProperties(generator
, c
, typeName
);
339 writeProperties(generator
, c
);
340 generator
.writeEnd();// properties object
342 generator
.writeEnd();// feature object
344 if (count
.incrementAndGet() % 10 == 0)
347 } catch (IOException e
) {
348 throw new UncheckedIOException(e
);
351 generator
.writeEnd();// features array
352 generator
.writeEnd().close();
354 log
.debug("GeoJSon encoding took " + (System
.currentTimeMillis() - begin
) + " ms.");
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();
362 Coordinate coordinate
= new Coordinate(longitude
, latitude
);
363 Point the_geom
= JTS
.GEOMETRY_FACTORY
.createPoint(coordinate
);
369 protected String
getFeatureId(Content content
) {
370 String uuid
= content
.attr(LdapAttr
.entryUUID
);
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
);
381 List
<QName
> contentClasses
= content
.getContentClasses();
382 if (!contentClasses
.isEmpty()) {
383 generator
.write("type", NamespaceUtils
.toPrefixedName(contentClasses
.get(0)));
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";
394 GML gml
= new GML(Version
.WFS1_1
);
395 gml
.setCoordinateReferenceSystem(DefaultGeographicCRS
.WGS84
);
396 gml
.setNamespace("local", namespace
);
398 SimpleFeatureType featureType
= gml
.decodeSimpleFeatureType(schemaLocation
,
399 new NameImpl(namespace
, entityType
+ "Feature"));
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);
407 SimpleFeatureBuilder featureBuilder
= new SimpleFeatureBuilder(featureType
);
409 DefaultFeatureCollection featureCollection
= new DefaultFeatureCollection();
411 features
.forEach((c
) -> {
412 // boolean gpx = false;
413 Geometry the_geom
= null;
414 Polygon the_area
= null;
416 Content area
= c
.getContent("gpx/area.gpx").orElse(null);
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
);
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();
430 Coordinate coordinate
= new Coordinate(longitude
, latitude
);
431 the_geom
= JTS
.GEOMETRY_FACTORY
.createPoint(coordinate
);
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
);
440 List
<AttributeDescriptor
> attrDescs
= featureType
.getAttributeDescriptors();
441 for (AttributeDescriptor attrDesc
: attrDescs
) {
442 if (attrDesc
instanceof GeometryAttribute
)
444 Name name
= attrDesc
.getName();
445 QName qName
= new QName(name
.getNamespaceURI(), name
.getLocalPart());
446 String value
= c
.attr(qName
);
448 value
= c
.attr(name
.getLocalPart());
451 featureBuilder
.set(name
, value
);
455 String uuid
= c
.attr(LdapAttr
.entryUUID
);
457 SimpleFeature feature
= featureBuilder
.buildFeature(uuid
);
458 featureCollection
.add(feature
);
461 gml
.encode(out
, featureCollection
);
467 * DEPENDENCY INJECTION
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...");
477 for (String tn
: typeNames
) {
478 QName typeName
= NamespaceUtils
.parsePrefixedName(tn
);
479 featureAdapters
.put(typeName
, featureAdapter
);
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
490 for (String tn
: typeNames
) {
491 QName typeName
= NamespaceUtils
.parsePrefixedName(tn
);
492 featureAdapters
.remove(typeName
);
496 public void setContentRepository(ProvidedRepository contentRepository
) {
497 this.contentRepository
= contentRepository
;