Coverage for gws-app/gws/plugin/ows_server/wfs/__init__.py: 74%
108 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
1"""WFS Service.
3Implements WFS 2.0 "Basic" profile.
4This implementation only supports ``GET`` requests with ``KVP`` encoding.
6Supported ad hoc query parameters:
8- ``TYPENAMES``
9- ``SRSNAME``
10- ``BBOX``
11- ``STARTINDEX``
12- ``COUNT``
13- ``OUTPUTFORMAT``
14- ``RESULTTYPE``
16@TODO: FILTER, SORTBY
18Supported stored queries:
20- ``urn:ogc:def:query:OGC-WFS::GetFeatureById``
22For ``GetPropertyValue`` only simple ``VALUEREFERENCE`` (field name) is supported.
24References:
25 - OGC 09-025r1 (https://portal.ogc.org/files/?artifact_id=39967)
26 - https://mapserver.org/ogc/wfs_server.html
27 - https://docs.geoserver.org/latest/en/user/services/wfs/reference.html
29"""
31import gws
32import gws.base.ows.server as server
33import gws.base.shape
34import gws.base.web
35import gws.config.util
36import gws.lib.bounds
37import gws.lib.crs
38import gws.base.metadata
39import gws.lib.mime
41gws.ext.new.owsService('wfs')
43STORED_QUERY_GET_FEATURE_BY_ID = 'urn:ogc:def:query:OGC-WFS::GetFeatureById'
45_cdir = gws.u.dirname(__file__)
47_DEFAULT_TEMPLATES = [
48 gws.Config(
49 type='py',
50 path=f'{_cdir}/templates/getCapabilities.cx.py',
51 subject='ows.GetCapabilities',
52 mimeTypes=[gws.lib.mime.XML],
53 ),
54 gws.Config(
55 type='py',
56 path=f'{_cdir}/templates/getFeature3.cx.py',
57 subject='ows.GetFeature',
58 access=gws.c.PUBLIC,
59 mimeTypes=[gws.lib.mime.XML, gws.lib.mime.GML, gws.lib.mime.GML3],
60 ),
61 gws.Config(
62 type='py',
63 path=f'{_cdir}/templates/getFeatureGeoJson.cx.py',
64 subject='ows.GetFeature',
65 access=gws.c.PUBLIC,
66 mimeTypes=[gws.lib.mime.JSON, gws.lib.mime.GEOJSON],
67 ),
68 gws.Config(
69 type='py',
70 path=f'{_cdir}/templates/getFeature2.cx.py',
71 subject='ows.GetFeature',
72 mimeTypes=[gws.lib.mime.GML2],
73 ),
74 gws.Config(
75 type='py',
76 path=f'{_cdir}/templates/getPropertyValue.cx.py',
77 subject='ows.GetPropertyValue',
78 mimeTypes=[gws.lib.mime.XML, gws.lib.mime.GML, gws.lib.mime.GML3],
79 ),
80 gws.Config(
81 type='py',
82 path=f'{_cdir}/templates/listStoredQueries.cx.py',
83 subject='ows.ListStoredQueries',
84 mimeTypes=[gws.lib.mime.XML],
85 ),
86 gws.Config(
87 type='py',
88 path=f'{_cdir}/templates/describeStoredQueries.cx.py',
89 subject='ows.DescribeStoredQueries',
90 mimeTypes=[gws.lib.mime.XML],
91 ),
92]
94_DEFAULT_METADATA = gws.Metadata(
95 name='WFS',
96 inspireMandatoryKeyword='infoFeatureAccessService',
97 inspireResourceType='service',
98 inspireSpatialDataServiceType='download',
99 isoScope='dataset',
100 isoServiceFunction='download',
101 isoSpatialRepresentationType='vector',
102)
105class Config(server.service.Config):
106 """WFS Service configuration"""
108 pass
111class Object(server.service.Object):
112 protocol = gws.OwsProtocol.WFS
113 supportedVersions = ['2.0.2', '2.0.1', '2.0.0']
114 isVectorService = True
115 isOwsCommon = True
117 def configure_templates(self):
118 return gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES)
120 def configure_metadata(self):
121 super().configure_metadata()
122 self.metadata = gws.base.metadata.from_args(_DEFAULT_METADATA, self.metadata)
124 def configure_operations(self):
125 self.supportedOperations = [
126 gws.OwsOperation(
127 verb=gws.OwsVerb.DescribeFeatureType,
128 formats=[gws.lib.mime.GML3],
129 handlerName='handle_describe_feature_type',
130 ),
131 gws.OwsOperation(
132 verb=gws.OwsVerb.DescribeStoredQueries,
133 formats=self.available_formats(gws.OwsVerb.DescribeStoredQueries),
134 handlerName='handle_describe_stored_queries',
135 ),
136 gws.OwsOperation(
137 verb=gws.OwsVerb.GetCapabilities,
138 formats=self.available_formats(gws.OwsVerb.GetCapabilities),
139 handlerName='handle_get_capabilities',
140 ),
141 gws.OwsOperation(
142 verb=gws.OwsVerb.GetFeature,
143 formats=self.available_formats(gws.OwsVerb.GetFeature),
144 handlerName='handle_get_feature',
145 ),
146 gws.OwsOperation(
147 verb=gws.OwsVerb.GetPropertyValue,
148 formats=self.available_formats(gws.OwsVerb.GetPropertyValue),
149 handlerName='handle_get_property_value',
150 ),
151 gws.OwsOperation(
152 verb=gws.OwsVerb.ListStoredQueries,
153 formats=self.available_formats(gws.OwsVerb.ListStoredQueries),
154 handlerName='handle_list_stored_queries',
155 ),
156 ]
158 ##
160 def init_request(self, req):
161 sr = super().init_request(req)
162 sr.require_project()
163 sr.crs = sr.requested_crs('CRSNAME,SRSNAME') or sr.project.map.bounds.crs
164 sr.targetCrs = sr.crs
165 sr.alwaysXY = False
166 if sr.req.has_param('BBOX'):
167 sr.bounds = gws.u.require(sr.requested_bounds('BBOX'))
168 else:
169 sr.bounds = gws.lib.bounds.transform(sr.project.map.bounds, sr.crs)
171 return sr
173 def layer_is_compatible(self, layer: gws.Layer):
174 return not layer.isGroup and layer.isSearchable and layer.ows.xmlNamespace
176 ##
178 def handle_get_capabilities(self, sr: server.request.Object):
179 return self.template_response(
180 sr,
181 sr.requested_format('OUTPUTFORMAT'),
182 layerCapsList=sr.layerCapsList,
183 )
185 def handle_list_stored_queries(self, sr: server.request.Object):
186 return self.template_response(
187 sr,
188 sr.requested_format('FORMAT'),
189 layerCapsList=sr.layerCapsList,
190 )
192 def handle_describe_stored_queries(self, sr: server.request.Object):
193 s = sr.string_param('STOREDQUERY_ID', default='')
194 if s and s != STORED_QUERY_GET_FEATURE_BY_ID:
195 raise server.error.InvalidParameterValue('STOREDQUERY_ID')
197 return self.template_response(
198 sr,
199 sr.requested_format('OUTPUTFORMAT'),
200 layerCapsList=sr.layerCapsList,
201 )
203 def handle_describe_feature_type(self, sr: server.request.Object):
204 tpl = self.get_template(sr)
205 if tpl:
206 return self.template_response(sr)
208 # if no template is defined, we return the XML schema for the requested layers
210 lcs = self.requested_layer_caps(sr)
211 el, opts = server.layer_caps.xml_schema(lcs, sr.req.user)
213 opts.withNamespaceDeclarations = True
214 opts.withSchemaLocations = True
215 opts.withXmlDeclaration = True
217 return self.xml_response(el, opts)
219 def handle_get_feature(self, sr: server.request.Object):
220 fc = self.get_features(sr)
221 return self.template_response(sr, sr.requested_format('OUTPUTFORMAT'), featureCollection=fc)
223 def handle_get_property_value(self, sr: server.request.Object):
224 value_ref = sr.string_param('VALUEREFERENCE')
225 fc = self.get_features(sr)
226 fc.values = [m.feature.get(value_ref) for m in fc.members]
227 return self.template_response(sr, sr.requested_format('OUTPUTFORMAT'), featureCollection=fc)
229 ##
231 def requested_layer_caps(self, sr: server.request.Object):
232 tns = sr.list_param('TYPENAME,TYPENAMES')
233 if not tns:
234 return sr.layerCapsList
235 lcs = []
236 for name in tns:
237 for lc in sr.layerCapsList:
238 if server.layer_caps.feature_name_matches(lc, name, sr.customXmlns):
239 lcs.append(lc)
240 if not lcs:
241 raise server.error.LayerNotDefined()
242 return gws.u.uniq(lcs)
244 SEARCH_MAX_TOTAL = 100_000
246 def get_features(self, sr: server.request.Object, value_ref: str = '') -> server.FeatureCollection:
247 # @TODO optimize paging for db-based layers
249 lcs = self.requested_layer_caps(sr)
250 search = self.make_search(sr, lcs)
252 results = self.root.app.searchMgr.run_search(search, sr.req.user)
254 if value_ref:
255 results = [r for r in results if r.feature.has(value_ref)]
257 hits = len(results)
259 result_type = sr.string_param('RESULTTYPE', values={'hits', 'results'}, default='results')
260 if result_type == 'hits':
261 return self.feature_collection(sr, lcs, hits, [])
263 limit = sr.requested_feature_count('COUNT,MAXFEATURES')
264 offset = sr.int_param('STARTINDEX', default=0)
266 if offset:
267 results = results[offset:]
268 if limit:
269 results = results[:limit]
271 return self.feature_collection(sr, lcs, hits, results)
273 def make_search(self, sr: server.request.Object, lcs):
274 search = gws.SearchQuery(
275 project=sr.project,
276 layers=[lc.layer for lc in lcs],
277 limit=self.SEARCH_MAX_TOTAL,
278 )
280 s = sr.string_param('STOREDQUERY_ID', default='')
281 if s:
282 if s != STORED_QUERY_GET_FEATURE_BY_ID:
283 raise server.error.InvalidParameterValue('STOREDQUERY_ID')
284 uid = sr.string_param('id')
285 search.uids = [uid]
286 return search
288 # @TODO filters
289 # flt: Optional[gws.SearchFilter] = None
290 # if sr.req.has_param('filter'):
291 # src = sr.req.param('filter')
292 # try:
293 # flt = gws.gis.ows.filter.from_fes_string(src)
294 # except gws.gis.ows.filter.Error as err:
295 # gws.log.error(f'FILTER ERROR: {err!r} filter={src!r}')
296 # raise gws.base.web.error.BadRequest('Invalid FILTER value')
298 search.shape = gws.base.shape.from_bounds(sr.bounds)
299 return search