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
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""CSW service.
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.
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.
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.
19Templates:
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``.
26References:
28- OpenGIS Catalogue Service Implementation Specification 2.0.2 (http://portal.opengeospatial.org/files/?artifact_id=20555)
30Example::
32 owsServices+ {
33 type "csw"
34 uid "my_csw"
35 metadata {
36 title "Catalog"
37 abstract "Catalog of the geodata"
38 }
39 }
41 projects+ {
42 access "allow all"
43 metadata.catalogUid "my_project"
44 }
45"""
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
62_cdir = gws.u.dirname(__file__)
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]
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)
103class Profile(gws.Enum):
104 """Metadata profile for the CSW service."""
106 ISO = 'ISO'
107 """ISO 19115 metadata profile."""
108 DCMI = 'DCMI'
109 """Dublin Core metadata profile."""
112@gws.ext.config.owsService('csw')
113class Config(server.service.Config):
114 """Catalogue service that publishes metadata records."""
116 profile: Profile = Profile.ISO
117 """Metadata profile."""
120@gws.ext.object.owsService('csw')
121class Object(server.service.Object):
122 """CSW service that publishes the metadata of public objects as catalog records."""
124 protocol = gws.OwsProtocol.CSW
125 supportedVersions = ['2.0.2']
127 mdMap: dict[str, gws.Metadata]
128 """Catalog records by catalog UID."""
129 profile: Profile
130 """Metadata profile of the records."""
132 def configure(self):
133 self.mdMap = {}
134 self.profile = Profile.ISO
136 def configure_templates(self):
137 extra = _DEFAULT_TEMPLATES_ISO
138 return gws.config.util.configure_templates_for(self, extra=extra)
140 def configure_metadata(self):
141 super().configure_metadata()
142 self.metadata = gws.base.metadata.from_args(_DEFAULT_METADATA, self.metadata)
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 ]
168 def post_configure(self):
169 self.collect_metadata()
171 ##
173 def parse_xml_request(self, xml):
174 params = {}
176 params['REQUEST'] = xml.name
177 return params
179 ##
181 def init_request(self, req):
182 sr = super().init_request(req)
183 sr.load_project()
184 return sr
186 def handle_get_capabilities(self, sr: server.request.Object):
187 """Handle the GetCapabilities operation.
189 Args:
190 sr: Service request.
192 Returns:
193 The capabilities document.
194 """
195 return self.template_response(sr)
197 def handle_describe_record(self, sr: server.request.Object):
198 """Handle the DescribeRecord operation.
200 Args:
201 sr: Service request.
203 Returns:
204 The record schema description.
205 """
206 return self.template_response(sr)
208 def handle_get_records(self, sr: server.request.Object):
209 """Handle the GetRecords operation.
211 Returns all records that match the filter of the request.
213 Args:
214 sr: Service request.
216 Returns:
217 The search results document.
218 """
219 mds = self._find_metas(sr)
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 )
228 return self.template_response(
229 sr,
230 '',
231 metadataCollection=mdc,
232 next=0,
233 )
235 def handle_get_record_by_id(self, sr: server.request.Object):
236 """Handle the GetRecordById operation.
238 Args:
239 sr: Service request.
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 []
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 )
254 return self.template_response(
255 sr,
256 '',
257 metadataCollection=mdc,
258 next=0,
259 )
261 ##
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
274 self.mdMap = {}
276 for obj in self.root.find_all():
277 self._collect_metadata_from_object(obj)
279 gws.log.info(f'CSW: configured with {len(self.mdMap)} records')
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'))
285 if not md:
286 return
288 if not md.get('catalogUid'):
289 # gws.log.debug(f'CSW: skip {obj.uid}: no catalogUid')
290 return
292 cid = gws.u.to_uid(md.get('catalogUid'))
294 if not self.root.app.authMgr.is_public_object(obj):
295 gws.log.debug(f'CSW: skip {obj.uid}: not public')
296 return
298 extra = {}
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))
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
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)
317 self.mdMap[cid] = gws.base.metadata.from_args(md, extra)
319 ##
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 )
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')
336 if not flt_el:
337 return self.mdMap.values()
339 flt = gws.base.search.filter.from_fes_element(flt_el)
340 m = gws.base.search.filter.Matcher()
342 return [md for md in self.mdMap.values() if m.matches(flt, md)]
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