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

1"""WFS service. 

2 

3Implements the WFS 2.0 "Basic" profile. Only ``GET`` requests with ``KVP`` 

4encoding are supported. 

5 

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. 

10 

11Supported ad hoc query parameters: 

12 

13- ``TYPENAMES`` 

14- ``SRSNAME`` 

15- ``BBOX`` 

16- ``STARTINDEX`` 

17- ``COUNT`` 

18- ``OUTPUTFORMAT`` 

19- ``RESULTTYPE`` 

20 

21``FILTER`` and ``SORTBY`` are not supported yet. 

22 

23Supported stored queries: 

24 

25- ``urn:ogc:def:query:OGC-WFS::GetFeatureById`` 

26 

27For ``GetPropertyValue`` only a simple ``VALUEREFERENCE`` (a field name) is supported. 

28 

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. 

33 

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``). 

37 

38Templates: 

39 

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``. 

47 

48References: 

49 

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 

53 

54Example:: 

55 

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""" 

68 

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 

78 

79 

80STORED_QUERY_GET_FEATURE_BY_ID = 'urn:ogc:def:query:OGC-WFS::GetFeatureById' 

81"""Identifier of the only supported stored query.""" 

82 

83_cdir = gws.u.dirname(__file__) 

84 

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] 

131 

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) 

141 

142 

143@gws.ext.config.owsService('wfs') 

144class Config(server.service.Config): 

145 """WFS service that serves features of the project layers.""" 

146 

147 pass 

148 

149 

150@gws.ext.object.owsService('wfs') 

151class Object(server.service.Object): 

152 """WFS service that returns the features of the project layers.""" 

153 

154 protocol = gws.OwsProtocol.WFS 

155 supportedVersions = ['2.0.2', '2.0.1', '2.0.0'] 

156 isVectorService = True 

157 isOwsCommon = True 

158 

159 def configure_templates(self): 

160 return gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES) 

161 

162 def configure_metadata(self): 

163 super().configure_metadata() 

164 self.metadata = gws.base.metadata.from_args(_DEFAULT_METADATA, self.metadata) 

165 

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 ] 

199 

200 ## 

201 

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) 

212 

213 return sr 

214 

215 def layer_is_compatible(self, layer: gws.Layer): 

216 return not layer.isGroup and layer.isSearchable and layer.ows.xmlNamespace 

217 

218 ## 

219 

220 def handle_get_capabilities(self, sr: server.request.Object): 

221 """Handle the GetCapabilities operation. 

222 

223 Args: 

224 sr: Service request. 

225 

226 Returns: 

227 The capabilities document. 

228 """ 

229 return self.template_response( 

230 sr, 

231 sr.requested_format('OUTPUTFORMAT'), 

232 layerCapsList=sr.layerCapsList, 

233 ) 

234 

235 def handle_list_stored_queries(self, sr: server.request.Object): 

236 """Handle the ListStoredQueries operation. 

237 

238 Args: 

239 sr: Service request. 

240 

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 ) 

249 

250 def handle_describe_stored_queries(self, sr: server.request.Object): 

251 """Handle the DescribeStoredQueries operation. 

252 

253 Args: 

254 sr: Service request. 

255 

256 Returns: 

257 The description of the stored queries. 

258 

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') 

265 

266 return self.template_response( 

267 sr, 

268 sr.requested_format('OUTPUTFORMAT'), 

269 layerCapsList=sr.layerCapsList, 

270 ) 

271 

272 def handle_describe_feature_type(self, sr: server.request.Object): 

273 """Handle the DescribeFeatureType operation. 

274 

275 If a template is configured, it is used. Otherwise, an XML schema is 

276 generated for the requested layers. 

277 

278 Args: 

279 sr: Service request. 

280 

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) 

287 

288 # if no template is defined, we return the XML schema for the requested layers 

289 

290 lcs = self.requested_layer_caps(sr) 

291 el, opts = server.layer_caps.xml_schema(lcs, sr.req.user) 

292 

293 opts.withNamespaceDeclarations = True 

294 opts.withSchemaLocations = True 

295 opts.withXmlDeclaration = True 

296 

297 return self.xml_response(el, opts) 

298 

299 def handle_get_feature(self, sr: server.request.Object): 

300 """Handle the GetFeature operation. 

301 

302 Args: 

303 sr: Service request. 

304 

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) 

310 

311 def handle_get_property_value(self, sr: server.request.Object): 

312 """Handle the GetPropertyValue operation. 

313 

314 Args: 

315 sr: Service request. 

316 

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) 

324 

325 ## 

326 

327 def requested_layer_caps(self, sr: server.request.Object): 

328 """Find the layer caps for the feature types in ``TYPENAME`` or ``TYPENAMES``. 

329 

330 Args: 

331 sr: Service request. 

332 

333 Returns: 

334 The matching layer caps without duplicates, or all layer caps if no feature types are requested. 

335 

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) 

350 

351 SEARCH_MAX_TOTAL = 100_000 

352 """Max. number of features a search can return, before paging.""" 

353 

354 def get_features(self, sr: server.request.Object, value_ref: str = '') -> server.FeatureCollection: 

355 """Search for the features requested by GetFeature or GetPropertyValue. 

356 

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. 

360 

361 Args: 

362 sr: Service request. 

363 value_ref: If given, only features that have this attribute are returned. 

364 

365 Returns: 

366 The feature collection. 

367 """ 

368 # @TODO optimize paging for db-based layers 

369 

370 lcs = self.requested_layer_caps(sr) 

371 search = self.make_search(sr, lcs) 

372 

373 results = self.root.app.searchMgr.run_search(search, sr.req.user) 

374 

375 if value_ref: 

376 results = [r for r in results if r.feature.has(value_ref)] 

377 

378 hits = len(results) 

379 

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, []) 

383 

384 limit = sr.requested_feature_count('COUNT,MAXFEATURES') 

385 offset = sr.int_param('STARTINDEX', default=0) 

386 

387 if offset: 

388 results = results[offset:] 

389 if limit: 

390 results = results[:limit] 

391 

392 return self.feature_collection(sr, lcs, hits, results) 

393 

394 def make_search(self, sr: server.request.Object, lcs): 

395 """Create the search query for a request. 

396 

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. 

400 

401 Args: 

402 sr: Service request. 

403 lcs: Layer caps to search. 

404 

405 Returns: 

406 The search query. 

407 

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 ) 

416 

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 

424 

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') 

434 

435 search.shape = gws.lib.shape.from_bounds(sr.bounds) 

436 return search