Coverage for gws-app/gws/plugin/ows_server/csw/__init__.py: 81%

110 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""CSW service. 

2 

3Basic implementation of the OGC Catalogue Service for the Web (CSW) 2.0.2. 

4Only a small subset of the standard is supported: the operations 

5GetCapabilities, DescribeRecord, GetRecords and GetRecordById, with records 

6in the ISO 19115 profile. The ``profile`` option is not evaluated; the profile 

7is always ISO. 

8 

9The catalog is collected after the configuration: every object in the tree 

10that has metadata with a ``catalogUid`` and is public becomes a record. A link 

11to the GetRecordById URL of the record is added to its ``metaLinks``. If the 

12object or its map has an extent and a CRS, the WGS84 extent is added, and the 

13initial scale of the closest map becomes the spatial resolution. 

14 

15GetRecords supports an FES filter in ``Query/Constraint/Filter`` of an XML 

16request; without a filter all records are returned. GetRecordById looks up 

17a record by the ``id`` parameter. 

18 

19Templates: 

20 

21- ``templates/iso/getCapabilities.cx.py``: ``ows.GetCapabilities``. 

22- ``templates/iso/describeRecord.cx.py``: ``ows.DescribeRecord``. 

23- ``templates/iso/getRecords.cx.py``: ``ows.GetRecords`` and ``ows.GetRecordById``. 

24 

25References: 

26 

27- OpenGIS Catalogue Service Implementation Specification 2.0.2 (http://portal.opengeospatial.org/files/?artifact_id=20555) 

28 

29Example:: 

30 

31 owsServices+ { 

32 type "csw" 

33 uid "my_csw" 

34 metadata { 

35 title "Catalog" 

36 abstract "Catalog of the geodata" 

37 } 

38 } 

39 

40 projects+ { 

41 access "allow all" 

42 metadata.catalogUid "my_project" 

43 } 

44""" 

45 

46from typing import cast 

47import gws 

48import gws.base.metadata 

49import gws.base.map 

50import gws.base.ows.server as server 

51import gws.base.search.filter 

52import gws.lib.shape 

53import gws.config.util 

54import gws.lib.crs 

55import gws.lib.datetimex 

56import gws.lib.extent 

57import gws.lib.mime 

58import gws.gis.zoom 

59 

60 

61_cdir = gws.u.dirname(__file__) 

62 

63_DEFAULT_TEMPLATES_ISO = [ 

64 gws.Config( 

65 type='py', 

66 path=f'{_cdir}/templates/iso/getCapabilities.cx.py', 

67 subject='ows.GetCapabilities', 

68 mimeTypes=[gws.lib.mime.XML], 

69 ), 

70 gws.Config( 

71 type='py', 

72 path=f'{_cdir}/templates/iso/describeRecord.cx.py', 

73 subject='ows.DescribeRecord', 

74 mimeTypes=[gws.lib.mime.XML], 

75 ), 

76 gws.Config( 

77 type='py', 

78 path=f'{_cdir}/templates/iso/getRecords.cx.py', 

79 subject='ows.GetRecords', 

80 mimeTypes=[gws.lib.mime.XML], 

81 ), 

82 gws.Config( 

83 type='py', 

84 path=f'{_cdir}/templates/iso/getRecords.cx.py', 

85 subject='ows.GetRecordById', 

86 mimeTypes=[gws.lib.mime.XML], 

87 ), 

88] 

89 

90_DEFAULT_METADATA = dict( 

91 name='CSW', 

92 inspireMandatoryKeyword='infoMapAccessService', 

93 inspireDegreeOfConformity='notEvaluated', 

94 inspireResourceType='service', 

95 inspireSpatialDataServiceType='view', 

96 isoScope='dataset', 

97 isoServiceFunction='download', 

98 isoSpatialRepresentationType='vector', 

99) 

100 

101 

102class Profile(gws.Enum): 

103 """Metadata profile for the CSW service.""" 

104 

105 ISO = 'ISO' 

106 """ISO 19115 metadata profile.""" 

107 DCMI = 'DCMI' 

108 """Dublin Core metadata profile.""" 

109 

110 

111@gws.ext.config.owsService('csw') 

112class Config(server.service.Config): 

113 """Catalogue service that publishes metadata records.""" 

114 

115 profile: Profile = Profile.ISO 

116 """Metadata profile.""" 

117 

118 

119@gws.ext.object.owsService('csw') 

120class Object(server.service.Object): 

121 """CSW service that publishes the metadata of public objects as catalog records.""" 

122 

123 protocol = gws.OwsProtocol.CSW 

124 supportedVersions = ['2.0.2'] 

125 

126 mdMap: dict[str, gws.Metadata] 

127 """Catalog records by catalog UID.""" 

128 profile: Profile 

129 """Metadata profile of the records.""" 

130 

131 def configure(self): 

132 self.mdMap = {} 

133 self.profile = Profile.ISO 

134 

135 def configure_templates(self): 

136 extra = _DEFAULT_TEMPLATES_ISO 

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

138 

139 def configure_metadata(self): 

140 super().configure_metadata() 

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

142 

143 def configure_operations(self): 

144 self.supportedOperations = [ 

145 gws.OwsOperation( 

146 verb=gws.OwsVerb.GetCapabilities, 

147 formats=[gws.lib.mime.XML], 

148 handlerName='handle_get_capabilities', 

149 ), 

150 gws.OwsOperation( 

151 verb=gws.OwsVerb.DescribeRecord, 

152 formats=[gws.lib.mime.XML], 

153 handlerName='handle_describe_record', 

154 ), 

155 gws.OwsOperation( 

156 verb=gws.OwsVerb.GetRecords, 

157 formats=[gws.lib.mime.XML], 

158 handlerName='handle_get_records', 

159 ), 

160 gws.OwsOperation( 

161 verb=gws.OwsVerb.GetRecordById, 

162 formats=[gws.lib.mime.XML], 

163 handlerName='handle_get_record_by_id', 

164 ), 

165 ] 

166 

167 def post_configure(self): 

168 self.collect_metadata() 

169 

170 ## 

171 

172 def parse_xml_request(self, xml): 

173 params = {} 

174 

175 params['REQUEST'] = xml.name 

176 return params 

177 

178 ## 

179 

180 def init_request(self, req): 

181 sr = super().init_request(req) 

182 sr.load_project() 

183 return sr 

184 

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

186 """Handle the GetCapabilities operation. 

187 

188 Args: 

189 sr: Service request. 

190 

191 Returns: 

192 The capabilities document. 

193 """ 

194 return self.template_response(sr) 

195 

196 def handle_describe_record(self, sr: server.request.Object): 

197 """Handle the DescribeRecord operation. 

198 

199 Args: 

200 sr: Service request. 

201 

202 Returns: 

203 The record schema description. 

204 """ 

205 return self.template_response(sr) 

206 

207 def handle_get_records(self, sr: server.request.Object): 

208 """Handle the GetRecords operation. 

209 

210 Returns all records that match the filter of the request. 

211 

212 Args: 

213 sr: Service request. 

214 

215 Returns: 

216 The search results document. 

217 """ 

218 mds = self._find_metas(sr) 

219 

220 mdc = server.MetadataCollection( 

221 members=mds, 

222 numMatched=len(mds), 

223 numReturned=len(mds), 

224 timestamp=gws.lib.datetimex.to_iso_string(with_tz=':'), 

225 ) 

226 

227 return self.template_response( 

228 sr, 

229 '', 

230 metadataCollection=mdc, 

231 next=0, 

232 ) 

233 

234 def handle_get_record_by_id(self, sr: server.request.Object): 

235 """Handle the GetRecordById operation. 

236 

237 Args: 

238 sr: Service request. 

239 

240 Returns: 

241 A response with the record given by the ``id`` parameter, or with no records. 

242 """ 

243 md = self._find_meta_by_id(sr) 

244 mds = [md] if md else [] 

245 

246 mdc = server.MetadataCollection( 

247 members=mds, 

248 numMatched=len(mds), 

249 numReturned=len(mds), 

250 timestamp=gws.lib.datetimex.to_iso_string(with_tz=':'), 

251 ) 

252 

253 return self.template_response( 

254 sr, 

255 '', 

256 metadataCollection=mdc, 

257 next=0, 

258 ) 

259 

260 ## 

261 

262 def collect_metadata(self): 

263 """Collect the catalog records from all objects in the tree into ``mdMap``.""" 

264 # collect objects whose metadata should be published in the catalog 

265 # 

266 # - object should have `metadata` 

267 # - object must be public 

268 # - `metadata` should have `catalogUid` 

269 # - `metadata.metaLinks` should be empty 

270 # 

271 # `metadata.metaLinks[0]` will be set to our csw url 

272 

273 self.mdMap = {} 

274 

275 for obj in self.root.find_all(): 

276 self._collect_metadata_from_object(obj) 

277 

278 gws.log.info(f'CSW: configured with {len(self.mdMap)} records') 

279 

280 def _collect_metadata_from_object(self, obj: gws.Node): 

281 """Add a catalog record for an object, if it has a catalog UID and is public.""" 

282 md: gws.Metadata = cast(gws.Metadata, gws.u.get(obj, 'metadata')) 

283 

284 if not md: 

285 return 

286 

287 if not md.get('catalogUid'): 

288 # gws.log.debug(f'CSW: skip {obj.uid}: no catalogUid') 

289 return 

290 

291 cid = gws.u.to_uid(md.get('catalogUid')) 

292 

293 if not self.root.app.authMgr.is_public_object(obj): 

294 gws.log.debug(f'CSW: skip {obj.uid}: not public') 

295 return 

296 

297 extra = {} 

298 

299 extra['catalogUid'] = cid 

300 extra['catalogCitationUid'] = md.get('catalogCitationUid') or cid 

301 extra['metaLinks'] = list(md.get('metaLinks') or []) 

302 extra['metaLinks'].append(self._make_link(cid)) 

303 

304 extent = gws.u.get(obj, 'extent') or gws.u.get(obj, 'map.extent') 

305 crs = gws.u.get(obj, 'crs') or gws.u.get(obj, 'map.crs') 

306 if extent and crs: 

307 extra['wgsExtent'] = gws.lib.extent.transform_to_wgs(extent, crs) 

308 extra['crs'] = crs 

309 # @TODO get boundingPolygonElement somehow 

310 

311 map = obj.find_closest(gws.ext.object.map) 

312 if map: 

313 m = cast(gws.base.map.Object, map) 

314 extra['isoSpatialResolution'] = gws.gis.zoom.res_to_scale(m.initResolution, m.bounds.crs) 

315 

316 self.mdMap[cid] = gws.base.metadata.from_args(md, extra) 

317 

318 ## 

319 

320 def _make_link(self, cid): 

321 """Create a metadata link to the GetRecordById URL of a record.""" 

322 return gws.MetadataLink( 

323 url=gws.u.action_url_path('owsService', serviceUid=self.uid, request='GetRecordById', id=cid), 

324 format=gws.lib.mime.XML, 

325 type='TC211' if self.profile == 'ISO' else 'DCMI', 

326 function='download', 

327 ) 

328 

329 def _find_metas(self, sr: server.request.Object): 

330 """Return the records that match the filter of an XML request, or all records.""" 

331 flt_el = None 

332 if sr.xmlElement: 

333 flt_el = sr.xmlElement.findfirst('Query/Constraint/Filter') 

334 

335 if not flt_el: 

336 return self.mdMap.values() 

337 

338 flt = gws.base.search.filter.from_fes_element(flt_el) 

339 m = gws.base.search.filter.Matcher() 

340 

341 return [md for md in self.mdMap.values() if m.matches(flt, md)] 

342 

343 def _find_meta_by_id(self, sr: server.request.Object): 

344 """Return the record with the catalog UID given by the ``id`` parameter.""" 

345 for md in self.mdMap.values(): 

346 if md.catalogUid == sr.req.param('id'): 

347 return md