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

110 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +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``. 

24- ``templates/iso/getRecordById.cx.py``: ``ows.GetRecordById``. 

25 

26References: 

27 

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

29 

30Example:: 

31 

32 owsServices+ { 

33 type "csw" 

34 uid "my_csw" 

35 metadata { 

36 title "Catalog" 

37 abstract "Catalog of the geodata" 

38 } 

39 } 

40 

41 projects+ { 

42 access "allow all" 

43 metadata.catalogUid "my_project" 

44 } 

45""" 

46 

47from typing import cast 

48import gws 

49import gws.base.metadata 

50import gws.base.map 

51import gws.base.ows.server as server 

52import gws.base.search.filter 

53import gws.lib.shape 

54import gws.config.util 

55import gws.lib.crs 

56import gws.lib.datetimex 

57import gws.lib.extent 

58import gws.lib.mime 

59import gws.gis.zoom 

60 

61 

62_cdir = gws.u.dirname(__file__) 

63 

64_DEFAULT_TEMPLATES_ISO = [ 

65 gws.Config( 

66 type='py', 

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

68 subject='ows.GetCapabilities', 

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

70 ), 

71 gws.Config( 

72 type='py', 

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

74 subject='ows.DescribeRecord', 

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

76 ), 

77 gws.Config( 

78 type='py', 

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

80 subject='ows.GetRecords', 

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

82 ), 

83 gws.Config( 

84 type='py', 

85 path=f'{_cdir}/templates/iso/getRecordById.cx.py', 

86 subject='ows.GetRecordById', 

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

88 ), 

89] 

90 

91_DEFAULT_METADATA = dict( 

92 name='CSW', 

93 inspireMandatoryKeyword='infoMapAccessService', 

94 inspireDegreeOfConformity='notEvaluated', 

95 inspireResourceType='service', 

96 inspireSpatialDataServiceType='view', 

97 isoScope='dataset', 

98 isoServiceFunction='download', 

99 isoSpatialRepresentationType='vector', 

100) 

101 

102 

103class Profile(gws.Enum): 

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

105 

106 ISO = 'ISO' 

107 """ISO 19115 metadata profile.""" 

108 DCMI = 'DCMI' 

109 """Dublin Core metadata profile.""" 

110 

111 

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

113class Config(server.service.Config): 

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

115 

116 profile: Profile = Profile.ISO 

117 """Metadata profile.""" 

118 

119 

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

121class Object(server.service.Object): 

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

123 

124 protocol = gws.OwsProtocol.CSW 

125 supportedVersions = ['2.0.2'] 

126 

127 mdMap: dict[str, gws.Metadata] 

128 """Catalog records by catalog UID.""" 

129 profile: Profile 

130 """Metadata profile of the records.""" 

131 

132 def configure(self): 

133 self.mdMap = {} 

134 self.profile = Profile.ISO 

135 

136 def configure_templates(self): 

137 extra = _DEFAULT_TEMPLATES_ISO 

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

139 

140 def configure_metadata(self): 

141 super().configure_metadata() 

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

143 

144 def configure_operations(self): 

145 self.supportedOperations = [ 

146 gws.OwsOperation( 

147 verb=gws.OwsVerb.GetCapabilities, 

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

149 handlerName='handle_get_capabilities', 

150 ), 

151 gws.OwsOperation( 

152 verb=gws.OwsVerb.DescribeRecord, 

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

154 handlerName='handle_describe_record', 

155 ), 

156 gws.OwsOperation( 

157 verb=gws.OwsVerb.GetRecords, 

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

159 handlerName='handle_get_records', 

160 ), 

161 gws.OwsOperation( 

162 verb=gws.OwsVerb.GetRecordById, 

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

164 handlerName='handle_get_record_by_id', 

165 ), 

166 ] 

167 

168 def post_configure(self): 

169 self.collect_metadata() 

170 

171 ## 

172 

173 def parse_xml_request(self, xml): 

174 params = {} 

175 

176 params['REQUEST'] = xml.name 

177 return params 

178 

179 ## 

180 

181 def init_request(self, req): 

182 sr = super().init_request(req) 

183 sr.load_project() 

184 return sr 

185 

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

187 """Handle the GetCapabilities operation. 

188 

189 Args: 

190 sr: Service request. 

191 

192 Returns: 

193 The capabilities document. 

194 """ 

195 return self.template_response(sr) 

196 

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

198 """Handle the DescribeRecord operation. 

199 

200 Args: 

201 sr: Service request. 

202 

203 Returns: 

204 The record schema description. 

205 """ 

206 return self.template_response(sr) 

207 

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

209 """Handle the GetRecords operation. 

210 

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

212 

213 Args: 

214 sr: Service request. 

215 

216 Returns: 

217 The search results document. 

218 """ 

219 mds = self._find_metas(sr) 

220 

221 mdc = server.MetadataCollection( 

222 members=mds, 

223 numMatched=len(mds), 

224 numReturned=len(mds), 

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

226 ) 

227 

228 return self.template_response( 

229 sr, 

230 '', 

231 metadataCollection=mdc, 

232 next=0, 

233 ) 

234 

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

236 """Handle the GetRecordById operation. 

237 

238 Args: 

239 sr: Service request. 

240 

241 Returns: 

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

243 """ 

244 md = self._find_meta_by_id(sr) 

245 mds = [md] if md else [] 

246 

247 mdc = server.MetadataCollection( 

248 members=mds, 

249 numMatched=len(mds), 

250 numReturned=len(mds), 

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

252 ) 

253 

254 return self.template_response( 

255 sr, 

256 '', 

257 metadataCollection=mdc, 

258 next=0, 

259 ) 

260 

261 ## 

262 

263 def collect_metadata(self): 

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

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

266 # 

267 # - object should have `metadata` 

268 # - object must be public 

269 # - `metadata` should have `catalogUid` 

270 # - `metadata.metaLinks` should be empty 

271 # 

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

273 

274 self.mdMap = {} 

275 

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

277 self._collect_metadata_from_object(obj) 

278 

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

280 

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

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

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

284 

285 if not md: 

286 return 

287 

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

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

290 return 

291 

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

293 

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

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

296 return 

297 

298 extra = {} 

299 

300 extra['catalogUid'] = cid 

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

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

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

304 

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

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

307 if extent and crs: 

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

309 extra['crs'] = crs 

310 # @TODO get boundingPolygonElement somehow 

311 

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

313 if map: 

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

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

316 

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

318 

319 ## 

320 

321 def _make_link(self, cid): 

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

323 return gws.MetadataLink( 

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

325 format=gws.lib.mime.XML, 

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

327 function='download', 

328 ) 

329 

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

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

332 flt_el = None 

333 if sr.xmlElement: 

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

335 

336 if not flt_el: 

337 return self.mdMap.values() 

338 

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

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

341 

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

343 

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

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

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

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

348 return md