Coverage for gws-app/gws/plugin/ows_server/wfs/__init__.py: 82%
111 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""WFS service.
3Implements the WFS 2.0 "Basic" profile. Only ``GET`` requests with ``KVP``
4encoding are supported.
6The service publishes the project layers that are not groups, are searchable
7and have an XML namespace. Supported operations are GetCapabilities,
8DescribeFeatureType, GetFeature, GetPropertyValue, ListStoredQueries and
9DescribeStoredQueries.
11Supported ad hoc query parameters:
13- ``TYPENAMES``
14- ``SRSNAME``
15- ``BBOX``
16- ``STARTINDEX``
17- ``COUNT``
18- ``OUTPUTFORMAT``
19- ``RESULTTYPE``
21``FILTER`` and ``SORTBY`` are not supported yet.
23Supported stored queries:
25- ``urn:ogc:def:query:OGC-WFS::GetFeatureById``
27For ``GetPropertyValue`` only a simple ``VALUEREFERENCE`` (a field name) is supported.
29GetFeature runs a search with the ``BBOX`` of the request (or the map extent)
30in the requested layers and applies ``STARTINDEX`` and ``COUNT`` to the results.
31DescribeFeatureType returns an XML schema generated from the layers, unless
32a template is configured. Features can be returned as GML 3, GML 2 or GeoJSON.
34Each published layer needs an XML namespace, set with ``ows.featureName`` or
35``ows.xmlns``; custom namespaces must also be configured globally (see
36``gws.base.ows.server``).
38Templates:
40- ``templates/getCapabilities.cx.py``: ``ows.GetCapabilities``.
41- ``templates/getFeature3.cx.py``: ``ows.GetFeature``, GML 3.
42- ``templates/getFeatureGeoJson.cx.py``: ``ows.GetFeature``, GeoJSON.
43- ``templates/getFeature2.cx.py``: ``ows.GetFeature``, GML 2.
44- ``templates/getPropertyValue.cx.py``: ``ows.GetPropertyValue``.
45- ``templates/listStoredQueries.cx.py``: ``ows.ListStoredQueries``.
46- ``templates/describeStoredQueries.cx.py``: ``ows.DescribeStoredQueries``.
48References:
50- OGC 09-025r1 (https://portal.ogc.org/files/?artifact_id=39967)
51- https://mapserver.org/ogc/wfs_server.html
52- https://docs.geoserver.org/latest/en/user/services/wfs/reference.html
54Example::
56 projects+ {
57 owsServices+ {
58 type "wfs"
59 uid "my_wfs"
60 }
61 map.layers+ {
62 type "postgres"
63 tableName "public.districts"
64 ows.featureName "my:districts"
65 }
66 }
67"""
69import gws
70import gws.base.ows.server as server
71import gws.lib.shape
72import gws.base.web
73import gws.config.util
74import gws.lib.bounds
75import gws.lib.crs
76import gws.base.metadata
77import gws.lib.mime
80STORED_QUERY_GET_FEATURE_BY_ID = 'urn:ogc:def:query:OGC-WFS::GetFeatureById'
81"""Identifier of the only supported stored query."""
83_cdir = gws.u.dirname(__file__)
85_DEFAULT_TEMPLATES = [
86 gws.Config(
87 type='py',
88 path=f'{_cdir}/templates/getCapabilities.cx.py',
89 subject='ows.GetCapabilities',
90 mimeTypes=[gws.lib.mime.XML],
91 ),
92 gws.Config(
93 type='py',
94 path=f'{_cdir}/templates/getFeature3.cx.py',
95 subject='ows.GetFeature',
96 access=gws.c.PUBLIC,
97 mimeTypes=[gws.lib.mime.XML, gws.lib.mime.GML, gws.lib.mime.GML3],
98 ),
99 gws.Config(
100 type='py',
101 path=f'{_cdir}/templates/getFeatureGeoJson.cx.py',
102 subject='ows.GetFeature',
103 access=gws.c.PUBLIC,
104 mimeTypes=[gws.lib.mime.JSON, gws.lib.mime.GEOJSON],
105 ),
106 gws.Config(
107 type='py',
108 path=f'{_cdir}/templates/getFeature2.cx.py',
109 subject='ows.GetFeature',
110 mimeTypes=[gws.lib.mime.GML2],
111 ),
112 gws.Config(
113 type='py',
114 path=f'{_cdir}/templates/getPropertyValue.cx.py',
115 subject='ows.GetPropertyValue',
116 mimeTypes=[gws.lib.mime.XML, gws.lib.mime.GML, gws.lib.mime.GML3],
117 ),
118 gws.Config(
119 type='py',
120 path=f'{_cdir}/templates/listStoredQueries.cx.py',
121 subject='ows.ListStoredQueries',
122 mimeTypes=[gws.lib.mime.XML],
123 ),
124 gws.Config(
125 type='py',
126 path=f'{_cdir}/templates/describeStoredQueries.cx.py',
127 subject='ows.DescribeStoredQueries',
128 mimeTypes=[gws.lib.mime.XML],
129 ),
130]
132_DEFAULT_METADATA = gws.Metadata(
133 name='WFS',
134 inspireMandatoryKeyword='infoFeatureAccessService',
135 inspireResourceType='service',
136 inspireSpatialDataServiceType='download',
137 isoScope='dataset',
138 isoServiceFunction='download',
139 isoSpatialRepresentationType='vector',
140)
143@gws.ext.config.owsService('wfs')
144class Config(server.service.Config):
145 """WFS service that serves features of the project layers."""
147 pass
150@gws.ext.object.owsService('wfs')
151class Object(server.service.Object):
152 """WFS service that returns the features of the project layers."""
154 protocol = gws.OwsProtocol.WFS
155 supportedVersions = ['2.0.2', '2.0.1', '2.0.0']
156 isVectorService = True
157 isOwsCommon = True
159 def configure_templates(self):
160 return gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES)
162 def configure_metadata(self):
163 super().configure_metadata()
164 self.metadata = gws.base.metadata.from_args(_DEFAULT_METADATA, self.metadata)
166 def configure_operations(self):
167 self.supportedOperations = [
168 gws.OwsOperation(
169 verb=gws.OwsVerb.DescribeFeatureType,
170 formats=[gws.lib.mime.GML3],
171 handlerName='handle_describe_feature_type',
172 ),
173 gws.OwsOperation(
174 verb=gws.OwsVerb.DescribeStoredQueries,
175 formats=self.available_formats(gws.OwsVerb.DescribeStoredQueries),
176 handlerName='handle_describe_stored_queries',
177 ),
178 gws.OwsOperation(
179 verb=gws.OwsVerb.GetCapabilities,
180 formats=self.available_formats(gws.OwsVerb.GetCapabilities),
181 handlerName='handle_get_capabilities',
182 ),
183 gws.OwsOperation(
184 verb=gws.OwsVerb.GetFeature,
185 formats=self.available_formats(gws.OwsVerb.GetFeature),
186 handlerName='handle_get_feature',
187 ),
188 gws.OwsOperation(
189 verb=gws.OwsVerb.GetPropertyValue,
190 formats=self.available_formats(gws.OwsVerb.GetPropertyValue),
191 handlerName='handle_get_property_value',
192 ),
193 gws.OwsOperation(
194 verb=gws.OwsVerb.ListStoredQueries,
195 formats=self.available_formats(gws.OwsVerb.ListStoredQueries),
196 handlerName='handle_list_stored_queries',
197 ),
198 ]
200 ##
202 def init_request(self, req):
203 sr = super().init_request(req)
204 sr.require_project()
205 sr.crs = sr.requested_crs('CRSNAME,SRSNAME') or sr.project.map.bounds.crs
206 sr.targetCrs = sr.crs
207 sr.alwaysXY = False
208 if sr.req.has_param('BBOX'):
209 sr.bounds = gws.u.require(sr.requested_bounds('BBOX'))
210 else:
211 sr.bounds = gws.lib.bounds.transform(sr.project.map.bounds, sr.crs)
213 return sr
215 def layer_is_compatible(self, layer: gws.Layer):
216 return not layer.isGroup and layer.isSearchable and layer.ows.xmlNamespace
218 ##
220 def handle_get_capabilities(self, sr: server.request.Object):
221 """Handle the GetCapabilities operation.
223 Args:
224 sr: Service request.
226 Returns:
227 The capabilities document.
228 """
229 return self.template_response(
230 sr,
231 sr.requested_format('OUTPUTFORMAT'),
232 layerCapsList=sr.layerCapsList,
233 )
235 def handle_list_stored_queries(self, sr: server.request.Object):
236 """Handle the ListStoredQueries operation.
238 Args:
239 sr: Service request.
241 Returns:
242 The list of stored queries.
243 """
244 return self.template_response(
245 sr,
246 sr.requested_format('FORMAT'),
247 layerCapsList=sr.layerCapsList,
248 )
250 def handle_describe_stored_queries(self, sr: server.request.Object):
251 """Handle the DescribeStoredQueries operation.
253 Args:
254 sr: Service request.
256 Returns:
257 The description of the stored queries.
259 Raises:
260 ``server.error.InvalidParameterValue``: If ``STOREDQUERY_ID`` is not a supported query.
261 """
262 s = sr.string_param('STOREDQUERY_ID', default='')
263 if s and s != STORED_QUERY_GET_FEATURE_BY_ID:
264 raise server.error.InvalidParameterValue('STOREDQUERY_ID')
266 return self.template_response(
267 sr,
268 sr.requested_format('OUTPUTFORMAT'),
269 layerCapsList=sr.layerCapsList,
270 )
272 def handle_describe_feature_type(self, sr: server.request.Object):
273 """Handle the DescribeFeatureType operation.
275 If a template is configured, it is used. Otherwise, an XML schema is
276 generated for the requested layers.
278 Args:
279 sr: Service request.
281 Returns:
282 The XML schema of the requested feature types.
283 """
284 tpl = self.get_template(sr)
285 if tpl:
286 return self.template_response(sr)
288 # if no template is defined, we return the XML schema for the requested layers
290 lcs = self.requested_layer_caps(sr)
291 el, opts = server.layer_caps.xml_schema(lcs, sr.req.user)
293 opts.withNamespaceDeclarations = True
294 opts.withSchemaLocations = True
295 opts.withXmlDeclaration = True
297 return self.xml_response(el, opts)
299 def handle_get_feature(self, sr: server.request.Object):
300 """Handle the GetFeature operation.
302 Args:
303 sr: Service request.
305 Returns:
306 The feature collection in the requested ``OUTPUTFORMAT``.
307 """
308 fc = self.get_features(sr)
309 return self.template_response(sr, sr.requested_format('OUTPUTFORMAT'), featureCollection=fc)
311 def handle_get_property_value(self, sr: server.request.Object):
312 """Handle the GetPropertyValue operation.
314 Args:
315 sr: Service request.
317 Returns:
318 The values of the ``VALUEREFERENCE`` attribute of the found features.
319 """
320 value_ref = sr.string_param('VALUEREFERENCE')
321 fc = self.get_features(sr)
322 fc.values = [m.feature.get(value_ref) for m in fc.members]
323 return self.template_response(sr, sr.requested_format('OUTPUTFORMAT'), featureCollection=fc)
325 ##
327 def requested_layer_caps(self, sr: server.request.Object):
328 """Find the layer caps for the feature types in ``TYPENAME`` or ``TYPENAMES``.
330 Args:
331 sr: Service request.
333 Returns:
334 The matching layer caps without duplicates, or all layer caps if no feature types are requested.
336 Raises:
337 ``server.error.LayerNotDefined``: If none of the requested feature types is found.
338 """
339 tns = sr.list_param('TYPENAME,TYPENAMES')
340 if not tns:
341 return sr.layerCapsList
342 lcs = []
343 for name in tns:
344 for lc in sr.layerCapsList:
345 if server.layer_caps.feature_name_matches(lc, name, sr.customNamespacePrefixes):
346 lcs.append(lc)
347 if not lcs:
348 raise server.error.LayerNotDefined()
349 return gws.u.uniq(lcs)
351 SEARCH_MAX_TOTAL = 100_000
352 """Max. number of features a search can return, before paging."""
354 def get_features(self, sr: server.request.Object, value_ref: str = '') -> server.FeatureCollection:
355 """Search for the features requested by GetFeature or GetPropertyValue.
357 With ``RESULTTYPE=hits``, only the number of found features is returned.
358 Otherwise, ``STARTINDEX`` and ``COUNT`` (or ``MAXFEATURES``) are applied
359 to the results.
361 Args:
362 sr: Service request.
363 value_ref: If given, only features that have this attribute are returned.
365 Returns:
366 The feature collection.
367 """
368 # @TODO optimize paging for db-based layers
370 lcs = self.requested_layer_caps(sr)
371 search = self.make_search(sr, lcs)
373 results = self.root.app.searchMgr.run_search(search, sr.req.user)
375 if value_ref:
376 results = [r for r in results if r.feature.has(value_ref)]
378 hits = len(results)
380 result_type = sr.string_param('RESULTTYPE', values={'hits', 'results'}, default='results')
381 if result_type == 'hits':
382 return self.feature_collection(sr, lcs, hits, [])
384 limit = sr.requested_feature_count('COUNT,MAXFEATURES')
385 offset = sr.int_param('STARTINDEX', default=0)
387 if offset:
388 results = results[offset:]
389 if limit:
390 results = results[:limit]
392 return self.feature_collection(sr, lcs, hits, results)
394 def make_search(self, sr: server.request.Object, lcs):
395 """Create the search query for a request.
397 For the ``GetFeatureById`` stored query, the search looks up the feature
398 given by the ``id`` parameter. Otherwise, it searches within the bounds
399 of the request.
401 Args:
402 sr: Service request.
403 lcs: Layer caps to search.
405 Returns:
406 The search query.
408 Raises:
409 ``server.error.InvalidParameterValue``: If ``STOREDQUERY_ID`` is not a supported query.
410 """
411 search = gws.SearchQuery(
412 project=sr.project,
413 layers=[lc.layer for lc in lcs],
414 limit=self.SEARCH_MAX_TOTAL,
415 )
417 s = sr.string_param('STOREDQUERY_ID', default='')
418 if s:
419 if s != STORED_QUERY_GET_FEATURE_BY_ID:
420 raise server.error.InvalidParameterValue('STOREDQUERY_ID')
421 uid = sr.string_param('id')
422 search.uids = [uid]
423 return search
425 # @TODO filters
426 # flt: Optional[gws.SearchFilter] = None
427 # if sr.req.has_param('filter'):
428 # src = sr.req.param('filter')
429 # try:
430 # flt = gws.gis.ows.filter.from_fes_string(src)
431 # except gws.gis.ows.filter.Error as err:
432 # gws.log.error(f'FILTER ERROR: {err!r} filter={src!r}')
433 # raise gws.base.web.error.BadRequest('Invalid FILTER value')
435 search.shape = gws.lib.shape.from_bounds(sr.bounds)
436 return search