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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +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`` and ``ows.GetRecordById``.
25References:
27- OpenGIS Catalogue Service Implementation Specification 2.0.2 (http://portal.opengeospatial.org/files/?artifact_id=20555)
29Example::
31 owsServices+ {
32 type "csw"
33 uid "my_csw"
34 metadata {
35 title "Catalog"
36 abstract "Catalog of the geodata"
37 }
38 }
40 projects+ {
41 access "allow all"
42 metadata.catalogUid "my_project"
43 }
44"""
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
61_cdir = gws.u.dirname(__file__)
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]
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)
102class Profile(gws.Enum):
103 """Metadata profile for the CSW service."""
105 ISO = 'ISO'
106 """ISO 19115 metadata profile."""
107 DCMI = 'DCMI'
108 """Dublin Core metadata profile."""
111@gws.ext.config.owsService('csw')
112class Config(server.service.Config):
113 """Catalogue service that publishes metadata records."""
115 profile: Profile = Profile.ISO
116 """Metadata profile."""
119@gws.ext.object.owsService('csw')
120class Object(server.service.Object):
121 """CSW service that publishes the metadata of public objects as catalog records."""
123 protocol = gws.OwsProtocol.CSW
124 supportedVersions = ['2.0.2']
126 mdMap: dict[str, gws.Metadata]
127 """Catalog records by catalog UID."""
128 profile: Profile
129 """Metadata profile of the records."""
131 def configure(self):
132 self.mdMap = {}
133 self.profile = Profile.ISO
135 def configure_templates(self):
136 extra = _DEFAULT_TEMPLATES_ISO
137 return gws.config.util.configure_templates_for(self, extra=extra)
139 def configure_metadata(self):
140 super().configure_metadata()
141 self.metadata = gws.base.metadata.from_args(_DEFAULT_METADATA, self.metadata)
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 ]
167 def post_configure(self):
168 self.collect_metadata()
170 ##
172 def parse_xml_request(self, xml):
173 params = {}
175 params['REQUEST'] = xml.name
176 return params
178 ##
180 def init_request(self, req):
181 sr = super().init_request(req)
182 sr.load_project()
183 return sr
185 def handle_get_capabilities(self, sr: server.request.Object):
186 """Handle the GetCapabilities operation.
188 Args:
189 sr: Service request.
191 Returns:
192 The capabilities document.
193 """
194 return self.template_response(sr)
196 def handle_describe_record(self, sr: server.request.Object):
197 """Handle the DescribeRecord operation.
199 Args:
200 sr: Service request.
202 Returns:
203 The record schema description.
204 """
205 return self.template_response(sr)
207 def handle_get_records(self, sr: server.request.Object):
208 """Handle the GetRecords operation.
210 Returns all records that match the filter of the request.
212 Args:
213 sr: Service request.
215 Returns:
216 The search results document.
217 """
218 mds = self._find_metas(sr)
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 )
227 return self.template_response(
228 sr,
229 '',
230 metadataCollection=mdc,
231 next=0,
232 )
234 def handle_get_record_by_id(self, sr: server.request.Object):
235 """Handle the GetRecordById operation.
237 Args:
238 sr: Service request.
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 []
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 )
253 return self.template_response(
254 sr,
255 '',
256 metadataCollection=mdc,
257 next=0,
258 )
260 ##
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
273 self.mdMap = {}
275 for obj in self.root.find_all():
276 self._collect_metadata_from_object(obj)
278 gws.log.info(f'CSW: configured with {len(self.mdMap)} records')
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'))
284 if not md:
285 return
287 if not md.get('catalogUid'):
288 # gws.log.debug(f'CSW: skip {obj.uid}: no catalogUid')
289 return
291 cid = gws.u.to_uid(md.get('catalogUid'))
293 if not self.root.app.authMgr.is_public_object(obj):
294 gws.log.debug(f'CSW: skip {obj.uid}: not public')
295 return
297 extra = {}
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))
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
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)
316 self.mdMap[cid] = gws.base.metadata.from_args(md, extra)
318 ##
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 )
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')
335 if not flt_el:
336 return self.mdMap.values()
338 flt = gws.base.search.filter.from_fes_element(flt_el)
339 m = gws.base.search.filter.Matcher()
341 return [md for md in self.mdMap.values() if m.matches(flt, md)]
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