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 // https://stackoverflow.com/questions/20925818/algorithm-to-check-if-two-boxes-overlap
221 // isOverlapping = (x1min < x2max AND x2min < x1max AND y1min < y2max AND y2min
223 // x1 = entity, x2 = bbox
225 and
.lte(EntityName
.minLat
, bbox
.getMaxX());
226 and
.gte(EntityName
.maxLat
, bbox
.getMinX());
227 and
.lte(EntityName
.minLon
, bbox
.getMaxY());
228 and
.gte(EntityName
.maxLon
, bbox
.getMinY());
231 and
.gte(WGS84PosName
.lat
, bbox
.getMinX());
232 and
.gte(WGS84PosName
.lon
, bbox
.getMinY());
233 and
.lte(WGS84PosName
.lat
, bbox
.getMaxX());
234 and
.lte(WGS84PosName
.lon
, bbox
.getMaxY());
240 exchange
.sendResponseHeaders(200, 0);
242 final int BUFFER_SIZE
= 100 * 1024;
243 try (OutputStream out
= zipped ?
new ZipOutputStream(exchange
.getResponseBody())
244 : new BufferedOutputStream(exchange
.getResponseBody(), BUFFER_SIZE
)) {
245 if (out
instanceof ZipOutputStream zipOut
) {
246 String unzippedFileName
= fileName
.substring(0, fileName
.length() - ".zip".length());
247 zipOut
.putNextEntry(new ZipEntry(unzippedFileName
));
250 if ("GML3".equals(outputFormat
)) {
251 encodeCollectionAsGML(res
, out
);
252 } else if ("application/json".equals(outputFormat
)) {
253 encodeCollectionAsGeoJSon(res
, out
, typeNames
);
259 * Retrieve KVP (keyword-value pairs) parameters, which are lower case, as per
262 * @see https://docs.ogc.org/is/09-025r2/09-025r2.html#19
264 protected String
getKvpParameter(Map
<String
, List
<String
>> parameters
, WfsKvp key
) {
265 Objects
.requireNonNull(key
, "KVP key cannot be null");
266 // let's first try the default (CAML case) which should be more efficient
267 List
<String
> values
= parameters
.get(key
.getKey());
268 if (values
== null) {
269 // then let's do an ignore case comparison of the key
270 keys
: for (String k
: parameters
.keySet()) {
271 if (key
.getKey().equalsIgnoreCase(k
)) {
272 values
= parameters
.get(k
);
277 if (values
== null) // nothing was found
279 if (values
.size() != 1) {
280 // although not completely clear from the standard, we assume keys must be
282 // since lists are defined here
283 // https://docs.ogc.org/is/09-026r2/09-026r2.html#10
284 throw new IllegalArgumentException("Key " + key
+ " as multiple values");
286 String value
= values
.get(0);
287 assert value
!= null;
291 protected void encodeCollectionAsGeoJSon(Stream
<Content
> features
, OutputStream out
, List
<QName
> typeNames
)
293 long begin
= System
.currentTimeMillis();
294 AtomicLong count
= new AtomicLong(0);
295 JsonGenerator generator
= Json
.createGenerator(out
);
296 generator
.writeStartObject();
297 generator
.write("type", "FeatureCollection");
298 generator
.writeStartArray("features");
299 features
.forEach((c
) -> {
300 // TODO deal with multiple type names
301 FeatureAdapter featureAdapter
= null;
302 QName typeName
= null;
303 if (!typeNames
.isEmpty()) {
304 typeName
= typeNames
.get(0);
305 featureAdapter
= featureAdapters
.get(typeName
);
308 boolean geometryWritten
= false;
309 // if (typeName.getLocalPart().equals("fieldSimpleFeature")) {
310 // Content area = c.getContent("place.geom.json").orElse(null);
311 // if (area != null) {
312 // generator.writeStartObject();
313 // generator.write("type", "Feature");
314 // String featureId = getFeatureId(c);
315 // if (featureId != null)
316 // generator.write("id", featureId);
318 // generator.flush();
319 // try (InputStream in = area.open(InputStream.class)) {
320 // out.write(",\"geometry\":".getBytes());
321 // StreamUtils.copy(in, out);
323 // } catch (Exception e) {
324 // log.error(c.getPath() + " : " + e.getMessage());
327 // geometryWritten = true;
333 if (!geometryWritten
) {
335 Geometry defaultGeometry
= featureAdapter
!= null ? featureAdapter
.getDefaultGeometry(c
, typeName
)
336 : getDefaultGeometry(c
);
337 if (defaultGeometry
== null)
339 generator
.writeStartObject();
340 generator
.write("type", "Feature");
341 String featureId
= getFeatureId(c
);
342 if (featureId
!= null)
343 generator
.write("id", featureId
);
345 GeoJson
.writeBBox(generator
, defaultGeometry
);
346 generator
.writeStartObject(GeoJson
.GEOMETRY
);
347 GeoJson
.writeGeometry(generator
, defaultGeometry
);
348 generator
.writeEnd();// geometry object
350 generator
.writeStartObject(GeoJson
.PROPERTIES
);
351 AcrJsonUtils
.writeTimeProperties(generator
, c
);
352 if (featureAdapter
!= null)
353 featureAdapter
.writeProperties(generator
, c
, typeName
);
355 writeProperties(generator
, c
);
356 generator
.writeEnd();// properties object
358 generator
.writeEnd();// feature object
360 if (count
.incrementAndGet() % 10 == 0)
363 } catch (IOException e
) {
364 throw new UncheckedIOException(e
);
367 generator
.writeEnd();// features array
368 generator
.writeEnd().close();
370 if (log
.isTraceEnabled())
371 log
.trace("GeoJSon encoding took " + (System
.currentTimeMillis() - begin
) + " ms.");
374 protected Geometry
getDefaultGeometry(Content content
) {
375 if (content
.hasContentClass(EntityType
.geopoint
)) {
376 return GeoEntityUtils
.toPoint(content
);
381 protected String
getFeatureId(Content content
) {
382 String uuid
= content
.attr(LdapAttr
.entryUUID
);
386 public void writeProperties(JsonGenerator generator
, Content content
) {
387 String path
= content
.getPath();
388 generator
.write("path", path
);
389 if (content
.hasContentClass(EntityType
.local
)) {
390 String type
= content
.attr(EntityName
.type
);
391 generator
.write("type", type
);
393 List
<QName
> contentClasses
= content
.getContentClasses();
394 if (!contentClasses
.isEmpty()) {
395 generator
.write("type", NamespaceUtils
.toPrefixedName(contentClasses
.get(0)));
401 protected void encodeCollectionAsGML(Stream
<Content
> features
, OutputStream out
) throws IOException
{
402 String entityType
= "entity";
403 URL schemaLocation
= getClass().getResource("/org/argeo/app/api/entity.xsd");
404 String namespace
= "http://www.argeo.org/ns/entity";
406 GML gml
= new GML(Version
.WFS1_1
);
407 gml
.setCoordinateReferenceSystem(DefaultGeographicCRS
.WGS84
);
408 gml
.setNamespace("local", namespace
);
410 SimpleFeatureType featureType
= gml
.decodeSimpleFeatureType(schemaLocation
,
411 new NameImpl(namespace
, entityType
+ "Feature"));
413 // CoordinateReferenceSystem crs=DefaultGeographicCRS.WGS84;
414 // QName featureName = new QName(namespace,"apafFieldFeature");
415 // GMLConfiguration configuration = new GMLConfiguration();
416 // FeatureType parsed = GTXML.parseFeatureType(configuration, featureName, crs);
417 // SimpleFeatureType featureType = DataUtilities.simple(parsed);
419 SimpleFeatureBuilder featureBuilder
= new SimpleFeatureBuilder(featureType
);
421 DefaultFeatureCollection featureCollection
= new DefaultFeatureCollection();
423 features
.forEach((c
) -> {
424 // boolean gpx = false;
425 Geometry the_geom
= null;
426 Polygon the_area
= null;
428 Content area
= c
.getContent("gpx/area.gpx").orElse(null);
431 try (InputStream in
= area
.open(InputStream
.class)) {
432 the_area
= GpxUtils
.parseGpxTrackTo(in
, Polygon
.class);
433 } catch (IOException e
) {
434 throw new UncheckedIOException("Cannot parse " + c
, e
);
438 if (c
.hasContentClass(EntityType
.geopoint
)) {
439 double latitude
= c
.get(WGS84PosName
.lat
, Double
.class).get();
440 double longitude
= c
.get(WGS84PosName
.lon
, Double
.class).get();
442 Coordinate coordinate
= new Coordinate(longitude
, latitude
);
443 the_geom
= JTS
.GEOMETRY_FACTORY
.createPoint(coordinate
);
447 if (the_geom
!= null)
448 featureBuilder
.set(new NameImpl(namespace
, "geopoint"), the_geom
);
449 if (the_area
!= null)
450 featureBuilder
.set(new NameImpl(namespace
, "area"), the_area
);
452 List
<AttributeDescriptor
> attrDescs
= featureType
.getAttributeDescriptors();
453 for (AttributeDescriptor attrDesc
: attrDescs
) {
454 if (attrDesc
instanceof GeometryAttribute
)
456 Name name
= attrDesc
.getName();
457 QName qName
= new QName(name
.getNamespaceURI(), name
.getLocalPart());
458 String value
= c
.attr(qName
);
460 value
= c
.attr(name
.getLocalPart());
463 featureBuilder
.set(name
, value
);
467 String uuid
= c
.attr(LdapAttr
.entryUUID
);
469 SimpleFeature feature
= featureBuilder
.buildFeature(uuid
);
470 featureCollection
.add(feature
);
473 gml
.encode(out
, featureCollection
);
479 * DEPENDENCY INJECTION
482 public void addFeatureAdapter(FeatureAdapter featureAdapter
, Map
<String
, Object
> properties
) {
483 List
<String
> typeNames
= LangUtils
.toStringList(properties
.get(WfsKvp
.TYPE_NAMES
.getKey()));
484 if (typeNames
.isEmpty()) {
485 log
.warn("FeatureAdapter " + featureAdapter
.getClass() + " does not declare type names. Ignoring it...");
489 for (String tn
: typeNames
) {
490 QName typeName
= NamespaceUtils
.parsePrefixedName(tn
);
491 featureAdapters
.put(typeName
, featureAdapter
);
495 public void removeFeatureAdapter(FeatureAdapter featureAdapter
, Map
<String
, Object
> properties
) {
496 List
<String
> typeNames
= LangUtils
.toStringList(properties
.get(WfsKvp
.TYPE_NAMES
.getKey()));
497 if (!typeNames
.isEmpty()) {
498 // ignore if noe type name declared
502 for (String tn
: typeNames
) {
503 QName typeName
= NamespaceUtils
.parsePrefixedName(tn
);
504 featureAdapters
.remove(typeName
);
508 public void setContentRepository(ProvidedRepository contentRepository
) {
509 this.contentRepository
= contentRepository
;