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

1"""WFS Service. 

2 

3Implements WFS 2.0 "Basic" profile. 

4This implementation only supports ``GET`` requests with ``KVP`` encoding. 

5 

6Supported ad hoc query parameters: 

7 

8- ``TYPENAMES`` 

9- ``SRSNAME`` 

10- ``BBOX`` 

11- ``STARTINDEX`` 

12- ``COUNT`` 

13- ``OUTPUTFORMAT`` 

14- ``RESULTTYPE`` 

15 

16@TODO: FILTER, SORTBY 

17 

18Supported stored queries: 

19 

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

21 

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

23 

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 

28 

29""" 

30 

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 

40 

41gws.ext.new.owsService('wfs') 

42 

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

44 

45_cdir = gws.u.dirname(__file__) 

46 

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] 

93 

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) 

103 

104 

105class Config(server.service.Config): 

106 """WFS Service configuration""" 

107 

108 pass 

109 

110 

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 

116 

117 def configure_templates(self): 

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

119 

120 def configure_metadata(self): 

121 super().configure_metadata() 

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

123 

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 ] 

157 

158 ## 

159 

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) 

170 

171 return sr 

172 

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

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

175 

176 ## 

177 

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 ) 

184 

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 ) 

191 

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

196 

197 return self.template_response( 

198 sr, 

199 sr.requested_format('OUTPUTFORMAT'), 

200 layerCapsList=sr.layerCapsList, 

201 ) 

202 

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) 

207 

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

209 

210 lcs = self.requested_layer_caps(sr) 

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

212 

213 opts.withNamespaceDeclarations = True 

214 opts.withSchemaLocations = True 

215 opts.withXmlDeclaration = True 

216 

217 return self.xml_response(el, opts) 

218 

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) 

222 

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) 

228 

229 ## 

230 

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) 

243 

244 SEARCH_MAX_TOTAL = 100_000 

245 

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

247 # @TODO optimize paging for db-based layers 

248 

249 lcs = self.requested_layer_caps(sr) 

250 search = self.make_search(sr, lcs) 

251 

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

253 

254 if value_ref: 

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

256 

257 hits = len(results) 

258 

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

262 

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

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

265 

266 if offset: 

267 results = results[offset:] 

268 if limit: 

269 results = results[:limit] 

270 

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

272 

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 ) 

279 

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 

287 

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

297 

298 search.shape = gws.base.shape.from_bounds(sr.bounds) 

299 return search