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
.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
;
66 import com
.sun
.net
.httpserver
.HttpExchange
;
67 import com
.sun
.net
.httpserver
.HttpHandler
;
69 import jakarta
.json
.Json
;
70 import jakarta
.json
.stream
.JsonGenerator
;
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
;
77 private final Map
<QName
, FeatureAdapter
> featureAdapters
= new HashMap
<>();
80 public void handle(HttpExchange exchange
) throws IOException
{
81 ContentSession session
= HttpServerUtils
.getContentSession(contentRepository
, exchange
);
83 String path
= HttpServerUtils
.subPath(exchange
);
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);
93 // if (fileName != null) {
94 // pathToUse = path.substring(0, lastSlash);
95 // if (path.endsWith(".zip")) {
102 Map
<String
, List
<String
>> parameters
= HttpServerUtils
.parseParameters(exchange
);
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";
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());
118 if (fileName
!= null && fileName
.endsWith(".zip"))
122 String bboxStr
= getKvpParameter(parameters
, WfsKvp
.BBOX
);
123 if (log
.isTraceEnabled())
126 if (bboxStr
!= null) {
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) {
140 if (srs
!= null && !srs
.equals(GeoUtils
.EPSG_4326
)) {
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
);
153 bbox
= new Envelope(new Coordinate(minLat
, minLon
), new Coordinate(maxLat
, maxLon
));
160 exchange
.getResponseHeaders().set(HttpHeader
.DATE
.getHeaderName(), Long
.toString(System
.currentTimeMillis()));
162 if (fileName
!= null) {
163 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_DISPOSITION
.getHeaderName(),
164 HttpHeader
.ATTACHMENT
+ ";" + HttpHeader
.FILENAME
+ "=\"" + fileName
+ "\"");
170 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/zip");
173 switch (outputFormat
) {
174 case "application/json" -> {
175 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/json");
178 // exchange.getResponseHeaders().set(HttpHeader.CONTENT_TYPE.getHeaderName(), "application/gml+xml");
179 exchange
.getResponseHeaders().set(HttpHeader
.CONTENT_TYPE
.getHeaderName(), "application/xml");
182 default -> throw new IllegalArgumentException("Unexpected value: " + outputFormat
);
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
]));
193 typeNames
.add(EntityType
.local
.qName());
196 if (typeNames
.size() > 1)
197 throw new UnsupportedOperationException("Only one type name is currently supported");
200 Stream
<Content
> res
= session
.search((search
) -> {
202 CqlUtils
.filter(search
.from(pathToUse
), cql
);
204 search
.from(pathToUse
);
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
);
214 }, new RemoteAuthHttpExchange(exchange
));
218 search
.getWhere().any((or
) -> {
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());
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());
235 exchange
.sendResponseHeaders(200, 0);
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
));
245 if ("GML3".equals(outputFormat
)) {
246 encodeCollectionAsGML(res
, out
);
247 } else if ("application/json".equals(outputFormat
)) {
248 encodeCollectionAsGeoJSon(res
, out
, typeNames
);
254 * Retrieve KVP (keyword-value pairs) parameters, which are lower case, as per
257 * @see https://docs.ogc.org/is/09-025r2/09-025r2.html#19
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
);
272 if (values
== null) // nothing was found
274 if (values
.size() != 1) {
275 // although not completely clear from the standard, we assume keys must be
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");
281 String value
= values
.get(0);
282 assert value
!= null;
286 protected void encodeCollectionAsGeoJSon(Stream
<Content
> features
, OutputStream out
, List
<QName
> typeNames
)
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
);
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);
313 // generator.flush();
314 // try (InputStream in = area.open(InputStream.class)) {
315 // out.write(",\"geometry\":".getBytes());
316 // StreamUtils.copy(in, out);
318 // } catch (Exception e) {
319 // log.error(c.getPath() + " : " + e.getMessage());
322 // geometryWritten = true;
328 if (!geometryWritten
) {
330 Geometry defaultGeometry
= featureAdapter
!= null ? featureAdapter
.getDefaultGeometry(c
, typeName
)
331 : getDefaultGeometry(c
);
332 if (defaultGeometry
== null)
334 generator
.writeStartObject();
335 generator
.write("type", "Feature");
336 String featureId
= getFeatureId(c
);
337 if (featureId
!= null)
338 generator
.write("id", featureId
);
340 GeoJson
.writeBBox(generator
, defaultGeometry
);
341 generator
.writeStartObject(GeoJson
.GEOMETRY
);
342 GeoJson
.writeGeometry(generator
, defaultGeometry
);
343 generator
.writeEnd();// geometry object
345 generator
.writeStartObject(GeoJson
.PROPERTIES
);
346 AcrJsonUtils
.writeTimeProperties(generator
, c
);
347 if (featureAdapter
!= null)
348 featureAdapter
.writeProperties(generator
, c
, typeName
);
350 writeProperties(generator
, c
);
351 generator
.writeEnd();// properties object
353 generator
.writeEnd();// feature object
355 if (count
.incrementAndGet() % 10 == 0)
358 } catch (IOException e
) {
359 throw new UncheckedIOException(e
);
362 generator
.writeEnd();// features array
363 generator
.writeEnd().close();
365 log
.debug("GeoJSon encoding took " + (System
.currentTimeMillis() - begin
) + " ms.");
368 protected Geometry
getDefaultGeometry(Content content
) {
369 if (content
.hasContentClass(EntityType
.geopoint
)) {
370 return GeoEntityUtils
.toPoint(content
);
375 protected String
getFeatureId(Content content
) {
376 String uuid
= content
.attr(LdapAttr
.entryUUID
);
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
);
387 List
<QName
> contentClasses
= content
.getContentClasses();
388 if (!contentClasses
.isEmpty()) {
389 generator
.write("type", NamespaceUtils
.toPrefixedName(contentClasses
.get(0)));
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";
400 GML gml
= new GML(Version
.WFS1_1
);
401 gml
.setCoordinateReferenceSystem(DefaultGeographicCRS
.WGS84
);
402 gml
.setNamespace("local", namespace
);
404 SimpleFeatureType featureType
= gml
.decodeSimpleFeatureType(schemaLocation
,
405 new NameImpl(namespace
, entityType
+ "Feature"));
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);
413 SimpleFeatureBuilder featureBuilder
= new SimpleFeatureBuilder(featureType
);
415 DefaultFeatureCollection featureCollection
= new DefaultFeatureCollection();
417 features
.forEach((c
) -> {
418 // boolean gpx = false;
419 Geometry the_geom
= null;
420 Polygon the_area
= null;
422 Content area
= c
.getContent("gpx/area.gpx").orElse(null);
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
);
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();
436 Coordinate coordinate
= new Coordinate(longitude
, latitude
);
437 the_geom
= JTS
.GEOMETRY_FACTORY
.createPoint(coordinate
);
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
);
446 List
<AttributeDescriptor
> attrDescs
= featureType
.getAttributeDescriptors();
447 for (AttributeDescriptor attrDesc
: attrDescs
) {
448 if (attrDesc
instanceof GeometryAttribute
)
450 Name name
= attrDesc
.getName();
451 QName qName
= new QName(name
.getNamespaceURI(), name
.getLocalPart());
452 String value
= c
.attr(qName
);
454 value
= c
.attr(name
.getLocalPart());
457 featureBuilder
.set(name
, value
);
461 String uuid
= c
.attr(LdapAttr
.entryUUID
);
463 SimpleFeature feature
= featureBuilder
.buildFeature(uuid
);
464 featureCollection
.add(feature
);
467 gml
.encode(out
, featureCollection
);
473 * DEPENDENCY INJECTION
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...");
483 for (String tn
: typeNames
) {
484 QName typeName
= NamespaceUtils
.parsePrefixedName(tn
);
485 featureAdapters
.put(typeName
, featureAdapter
);
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
496 for (String tn
: typeNames
) {
497 QName typeName
= NamespaceUtils
.parsePrefixedName(tn
);
498 featureAdapters
.remove(typeName
);
502 public void setContentRepository(ProvidedRepository contentRepository
) {
503 this.contentRepository
= contentRepository
;