Coverage for gws-app/gws/base/ows/server/templatelib.py: 82%
132 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"""Helper functions for OWS service templates."""
3from typing import Optional, cast
5import gws
6import gws.base.metadata
7import gws.lib.gml
8import gws.lib.uom
9import gws.lib.datetimex as dtx
10import gws.lib.xmlx as xmlx
11import gws.base.ows.server as server
13from . import core, layer_caps, service
14from gws.lib.xmlx import tag
16# OGC 06-121r9 Table 34
17# Ordered sequence of two double values in decimal degrees, with longitude before latitude
18def ows_wgs84_bounding_box(lc: core.LayerCaps, ns: str = 'OWS_11'):
19 """Create a ``WGS84BoundingBox`` element for a layer.
21 Args:
22 lc: Layer caps.
23 ns: Namespace prefix of the OWS elements.
25 Returns:
26 The XML element.
27 """
29 return tag(
30 f'{ns}:WGS84BoundingBox',
31 tag(f'{ns}:LowerCorner', coord_dms(lc.layer.wgsExtent[0]), ' ', coord_dms(lc.layer.wgsExtent[1])),
32 tag(f'{ns}:UpperCorner', coord_dms(lc.layer.wgsExtent[2]), ' ', coord_dms(lc.layer.wgsExtent[3])),
33 )
36# OGC 06-121r3 sec 7.4.4
37def ows_service_identification(ta: server.TemplateArgs, ns: str = 'OWS_11'):
38 """Create a ``ServiceIdentification`` element from the service metadata.
40 Args:
41 ta: Template arguments.
42 ns: Namespace prefix of the OWS elements.
44 Returns:
45 The XML element.
46 """
48 md = ta.service.metadata
50 return tag(
51 f'{ns}:ServiceIdentification',
52 tag(f'{ns}:Title', md.title),
53 tag(f'{ns}:Abstract', md.abstract),
54 ows_keywords(md, ns),
55 tag(f'{ns}:ServiceType', ta.service.protocol),
56 tag(f'{ns}:ServiceTypeVersion', ta.version),
57 tag(f'{ns}:Fees', md.fees) if md.fees else None,
58 tag(f'{ns}:AccessConstraints', md.accessConstraints) if md.accessConstraints else None,
59 )
62# OGC 06-121r3 sec 7.4.5
63def ows_service_provider(ta: server.TemplateArgs, ns: str = 'OWS_11'):
64 """Create a ``ServiceProvider`` element from the service contact metadata.
66 Args:
67 ta: Template arguments.
68 ns: Namespace prefix of the OWS elements.
70 Returns:
71 The XML element.
72 """
74 md = ta.service.metadata
76 return tag(
77 f'{ns}:ServiceProvider',
78 tag(f'{ns}:ProviderName', md.contactProviderName),
79 tag(f'{ns}:ProviderSite', {'XLINK:href': md.contactProviderSite}),
80 tag(
81 f'{ns}:ServiceContact',
82 tag(f'{ns}:IndividualName', md.contactPerson),
83 tag(f'{ns}:PositionName', md.contactPosition),
84 tag(
85 f'{ns}:ContactInfo',
86 tag(
87 f'{ns}:Phone',
88 tag(f'{ns}:Voice', md.contactPhone),
89 tag(f'{ns}:Facsimile', md.contactFax),
90 ),
91 tag(
92 f'{ns}:Address',
93 tag(f'{ns}:DeliveryPoint', md.contactAddress),
94 tag(f'{ns}:City', md.contactCity),
95 tag(f'{ns}:AdministrativeArea', md.contactArea),
96 tag(f'{ns}:PostalCode', md.contactZip),
97 tag(f'{ns}:Country', md.contactCountry),
98 tag(f'{ns}:ElectronicMailAddress', md.contactEmail),
99 ),
100 tag(f'{ns}:OnlineResource', {'XLINK:href': md.contactUrl}),
101 ),
102 tag(f'{ns}:Role', md.contactRole),
103 ),
104 )
107# OGC 06-121r3 table 15,16,17
108# OGC 06-121r3 11.2:
109# A URL prefix is defined as a string including... mandatory question mark
112def ows_service_url(ta: server.TemplateArgs, get=True, post=False, ns: str = 'OWS_11'):
113 """Create ``DCP/HTTP/Get`` and ``DCP/HTTP/Post`` elements with the service URL.
115 Args:
116 ta: Template arguments.
117 get: Include the GET URL.
118 post: Include the POST URL.
119 ns: Namespace prefix of the OWS elements.
121 Yields:
122 XML elements.
123 """
125 if get:
126 yield tag(
127 f'{ns}:DCP/{ns}:HTTP/{ns}:Get',
128 {'XLINK:type': 'simple', 'XLINK:href': ta.serviceUrl + '?'},
129 )
130 if post:
131 yield tag(
132 f'{ns}:DCP/{ns}:HTTP/{ns}:Post',
133 {'XLINK:type': 'simple', 'XLINK:href': ta.serviceUrl},
134 )
137def ows_value(value, ns: str = 'OWS_11'):
138 """Create a ``Value`` element.
140 Args:
141 value: Element text.
142 ns: Namespace prefix of the OWS elements.
144 Returns:
145 The XML element.
146 """
148 return tag(f'{ns}:Value', value)
151def online_resource(url):
152 """Create an ``OnlineResource`` element with a simple xlink.
154 Args:
155 url: Link URL.
157 Returns:
158 The XML element.
159 """
161 return tag('OnlineResource', {'XLINK:type': 'simple', 'XLINK:href': url})
164# OGC 01-068r3, 6.2.2
165# The URL prefix shall end in either a '?' (in the absence of additional server-specific parameters) or a '&'.
166# OGC 06-042, 6.3.3
167# A URL prefix is defined... as a string including... mandatory question mark
170def dcp_service_url(ta: server.TemplateArgs):
171 """Create a ``DCPType/HTTP/Get`` element with the service URL (WMS style).
173 Args:
174 ta: Template arguments.
176 Returns:
177 The XML element.
178 """
180 return tag('DCPType/HTTP/Get', online_resource(ta.serviceUrl + '?'))
183def legend_url_nested(ta: server.TemplateArgs, lc: core.LayerCaps, size=None):
184 """Create a ``LegendURL`` element with nested ``Format`` and ``OnlineResource`` (WMS style).
186 Args:
187 ta: Template arguments.
188 lc: Layer caps.
189 size: Legend size as ``(width, height)``, optional.
191 Returns:
192 The XML element.
193 """
195 return tag(
196 'LegendURL',
197 {'width': size[0], 'height': size[1]} if size else {},
198 tag('Format', 'image/png'),
199 online_resource(f'{ta.serviceUrl}?request=GetLegendGraphic&layer={lc.layerName}'),
200 )
203def legend_url(ta: server.TemplateArgs, lc: core.LayerCaps, size=None):
204 """Create a ``LegendURL`` element with the format and URL as attributes (WMTS style).
206 Args:
207 ta: Template arguments.
208 lc: Layer caps.
209 size: Not used.
211 Returns:
212 The XML element.
213 """
215 return tag(
216 'LegendURL',
217 {
218 'format': 'image/png',
219 'XLINK:href': f'{ta.serviceUrl}?request=GetLegendGraphic&layer={lc.layerName}',
220 },
221 )
224def feature_name(ta: server.TemplateArgs, lc: core.LayerCaps) -> str:
225 """Return the qualified feature type name of a layer, with the requested custom prefixes applied.
227 Args:
228 ta: Template arguments.
229 lc: Layer caps.
231 Returns:
232 The name as ``prefix:name``, or the plain name if the layer has no namespace.
233 """
235 return layer_caps.qualified_feature_name(lc, ta.serviceRequest.customNamespacePrefixes)
238def ows_keywords(md: gws.Metadata, ns: str = 'OWS_11'):
239 """Create ``Keywords`` elements for the keyword groups in the metadata.
241 Args:
242 md: Metadata.
243 ns: Namespace prefix of the OWS elements.
245 Returns:
246 A list of XML elements.
247 """
249 return [_ows_keyword_group(kg, ns) for kg in gws.base.metadata.keyword_groups(md)]
252def _ows_keyword_group(kg: gws.base.metadata.KeywordGroup, ns: str):
253 """Create a ``Keywords`` element for a keyword group."""
255 tags = []
256 for kw in kg.keywords:
257 tags.append(tag(f'{ns}:Keyword', kw))
258 if kg.codeSpace:
259 tags.append(tag(f'{ns}:Type', {'codeSpace': kg.codeSpace}, kg.typeName))
260 return tag(f'{ns}:Keywords', tags)
263def wms_keywords(md: gws.Metadata, with_vocabulary: bool = False):
264 """Create a WMS ``KeywordList`` element.
266 Args:
267 md: Metadata.
268 with_vocabulary: Add the ``vocabulary`` attribute for keywords with a code space.
270 Returns:
271 The XML element.
272 """
274 tags = []
275 for kg in gws.base.metadata.keyword_groups(md):
276 for kw in kg.keywords:
277 if kg.codeSpace and with_vocabulary:
278 tags.append(tag('Keyword', kw, {'vocabulary': kg.codeSpace}))
279 else:
280 tags.append(tag('Keyword', kw))
281 return tag('KeywordList', tags)
284def lon_lat_envelope(lc: core.LayerCaps):
285 """Create a ``lonLatEnvelope`` element for a layer.
287 Args:
288 lc: Layer caps.
290 Returns:
291 The XML element.
292 """
294 return tag(
295 'lonLatEnvelope',
296 {'srsName': 'urn:ogc:def:crs:OGC:1.3:CRS84'},
297 tag('GML:pos', coord_dms(lc.layer.wgsExtent[0]), ' ', coord_dms(lc.layer.wgsExtent[1])),
298 tag('GML:pos', coord_dms(lc.layer.wgsExtent[2]), ' ', coord_dms(lc.layer.wgsExtent[3])),
299 )
302# Nested format (WFS 1, WMS):
303# OGC 06-042 7.2.4.6.11
304# The "type" attribute indicates the standard... The enclosed <Format> element... etc
307def meta_links_nested(ta: server.TemplateArgs, md: gws.Metadata):
308 """Create ``MetadataURL`` elements in the nested format for the metadata links.
310 Args:
311 ta: Template arguments.
312 md: Metadata.
314 Yields:
315 Generators of XML elements, one for each link.
316 """
318 if md.metaLinks:
319 for ml in md.metaLinks:
320 yield meta_url_nested(ta, ml, 'MetadataURL')
323def meta_url_nested(ta: server.TemplateArgs, ml: gws.MetadataLink, name: str):
324 """Create a metadata link element in the nested format, with ``Format`` and ``OnlineResource``.
326 Args:
327 ta: Template arguments.
328 ml: Metadata link, may be empty.
329 name: Element name.
331 Yields:
332 The XML element, if the link is not empty.
333 """
335 if ml:
336 yield tag(name, {'type': ml.type}, tag('Format', ml.format), online_resource(ta.url_for(ml.url)))
339# Simple format (WFS 2)
340# OGC 09-025r1 Table 11
341# The xlink:href element shall be used to reference any metadata.
342# The optional about attribute may be used to reference the aspect of the element which includes
343# this wfs:MetadataURL element that this metadata provides more information about.
344# (whatever that means)
347def meta_links_simple(ta: server.TemplateArgs, md: gws.Metadata):
348 """Create ``MetadataURL`` elements in the simple format for the metadata links.
350 Args:
351 ta: Template arguments.
352 md: Metadata.
354 Yields:
355 Generators of XML elements, one for each link.
356 """
358 if md.metaLinks:
359 for ml in md.metaLinks:
360 yield meta_url_simple(ta, ml, 'MetadataURL')
363def meta_url_simple(ta: server.TemplateArgs, ml: gws.MetadataLink, name: str):
364 """Create a metadata link element in the simple format, with ``xlink:href`` and ``about`` attributes.
366 Args:
367 ta: Template arguments.
368 ml: Metadata link, may be empty.
369 name: Element name.
371 Yields:
372 The XML element, if the link is not empty.
373 """
375 if ml:
376 yield tag(name, {'XLINK:href': ta.url_for(ml.url), 'about': ml.about})
379def wfs_feature_collection(ta: server.TemplateArgs):
380 """Create a ``wfs:FeatureCollection`` element from the feature collection in the template arguments.
382 Args:
383 ta: Template arguments.
385 Returns:
386 The XML element.
387 """
389 return tag(
390 'WFS:FeatureCollection',
391 wfs_feature_collection_attributes(ta),
392 [
393 tag(
394 'WFS:member',
395 tag(
396 xmlx.namespace.full_name(m.layerCaps.featureName, m.layerCaps.xmlNamespace) if m.layerCaps else 'WFS:feature',
397 {'GML:id': gml_format_uid(ta, m.feature.uid())},
398 wfs_feature_collection_member(ta, m),
399 ),
400 )
401 for m in ta.featureCollection.members
402 ],
403 )
406def wfs_value_collection(ta: server.TemplateArgs):
407 """Create a ``wfs:ValueCollection`` element from the values in the feature collection.
409 Args:
410 ta: Template arguments.
412 Returns:
413 The XML element.
414 """
416 return tag(
417 'WFS:ValueCollection',
418 wfs_feature_collection_attributes(ta),
419 [tag('WFS:member', gml_format_value(ta, val)) for val in ta.featureCollection.values],
420 )
423def wfs_feature_collection_attributes(ta):
424 """Return the attributes of a WFS feature or value collection.
426 Args:
427 ta: Template arguments.
429 Returns:
430 A dict with ``timeStamp``, ``numberMatched`` and ``numberReturned``.
431 """
433 return {
434 'timeStamp': ta.featureCollection.timestamp,
435 'numberMatched': ta.featureCollection.numMatched,
436 'numberReturned': ta.featureCollection.numReturned,
437 }
440def wfs_feature_collection_member(ta: server.TemplateArgs, m: server.FeatureCollectionMember):
441 """Create the attribute elements of a feature collection member.
443 Args:
444 ta: Template arguments.
445 m: Collection member.
447 Yields:
448 XML elements, one for each attribute; the geometry comes last.
449 """
451 geom = None
452 for name, val in m.feature.attributes.items():
453 if m.layerCaps:
454 name = xmlx.namespace.full_name(name, m.layerCaps.xmlNamespace)
455 if isinstance(val, gws.Shape):
456 geom = tag(name, gml_format_value(ta, val))
457 else:
458 yield tag(name, gml_format_value(ta, val))
459 # QGIS wants geometry as the last element
460 if geom:
461 yield geom
464def gml_format_uid(ta: server.TemplateArgs, uid):
465 """Format a feature uid as a valid ``gml:id``.
467 Args:
468 ta: Template arguments.
469 uid: Feature uid.
471 Returns:
472 The uid, prefixed with ``_`` if it starts with a digit, or ``_`` if it is empty.
473 """
475 if not uid:
476 return '_'
477 s = str(uid)
478 if s[0].isdigit():
479 return '_' + s
480 return s
483def gml_format_value(ta, val):
484 """Format an attribute value for GML output.
486 Args:
487 ta: Template arguments.
488 val: Attribute value.
490 Returns:
491 A string, or a GML element for shapes.
492 """
494 s, ok = xmlx.util.atom_to_string(val)
495 if ok:
496 return s
497 if isinstance(val, gws.Shape):
498 # NB Qgis wants inline gml xmlns for adhoc schemas
499 return gws.lib.gml.shape_to_element(
500 val,
501 version=ta.gmlVersion,
502 always_xy=ta.serviceRequest.alwaysXY,
503 with_inline_xmlns=True,
504 )
505 return str(val)
508# http://inspire.ec.europa.eu/schemas/common/1.0/network.xsd
509# Scenario 2: Mandatory (where appropriate) metadata elements not mapped to standard capabilities,
510# plus mandatory language parameters,
511# plus OPTIONAL MetadataUrl pointing to an INSPIRE Compliant ISO metadata document
514def inspire_extended_capabilities(ta: server.TemplateArgs):
515 """Create the INSPIRE extended capabilities elements from the service metadata.
517 Args:
518 ta: Template arguments.
520 Returns:
521 A list of XML elements.
522 """
524 md = ta.service.metadata
525 return [
526 tag(
527 'INSPIRE_COMMON:ResourceLocator',
528 tag('INSPIRE_COMMON:URL', ta.serviceUrl),
529 tag('INSPIRE_COMMON:MediaType', 'application/xml'),
530 ),
531 tag('INSPIRE_COMMON:ResourceType', md.inspireResourceType),
532 tag('INSPIRE_COMMON:TemporalReference/INSPIRE_COMMON:DateOfPublication', iso_date(md.dateCreated)),
533 tag(
534 'INSPIRE_COMMON:Conformity',
535 tag(
536 'INSPIRE_COMMON:Specification',
537 # {'XSI:type': 'inspire_common:citationInspireInteroperabilityRegulation'},
538 tag(
539 'INSPIRE_COMMON:Title',
540 'COMMISSION REGULATION (EU) No 1089/2010 of 23 November 2010 implementing Directive 2007/2/EC of the European Parliament and of the Council as regards interoperability of spatial data sets and services',
541 ),
542 tag('INSPIRE_COMMON:DateOfPublication', '2010-12-08'),
543 tag('INSPIRE_COMMON:URI', 'OJ:L:2010:323:0011:0102:EN:PDF'),
544 tag(
545 'INSPIRE_COMMON:ResourceLocator',
546 tag('INSPIRE_COMMON:URL', 'http://eur-lex.europa.eu/LexUriServ/LexUriServ.do?uri=OJ:L:2010:323:0011:0102:EN:PDF'),
547 tag('INSPIRE_COMMON:MediaType', 'application/pdf'),
548 ),
549 ),
550 tag('INSPIRE_COMMON:Degree', md.inspireDegreeOfConformity),
551 ),
552 tag(
553 'INSPIRE_COMMON:MetadataPointOfContact',
554 tag('INSPIRE_COMMON:OrganisationName', md.contactOrganization),
555 tag('INSPIRE_COMMON:EmailAddress', md.contactEmail),
556 ),
557 tag('INSPIRE_COMMON:MetadataDate', iso_date(md.dateCreated)),
558 tag('INSPIRE_COMMON:SpatialDataServiceType', md.inspireSpatialDataServiceType),
559 tag('INSPIRE_COMMON:MandatoryKeyword/INSPIRE_COMMON:KeywordValue', md.inspireMandatoryKeyword),
560 tag(
561 'INSPIRE_COMMON:Keyword',
562 tag(
563 'INSPIRE_COMMON:OriginatingControlledVocabulary',
564 tag('INSPIRE_COMMON:Title', 'INSPIRE themes'),
565 tag('INSPIRE_COMMON:DateOfPublication', '2008-06-01'),
566 ),
567 tag('INSPIRE_COMMON:KeywordValue', md.inspireThemeNameEn),
568 ),
569 tag(
570 'INSPIRE_COMMON:SupportedLanguages',
571 tag('INSPIRE_COMMON:DefaultLanguage/INSPIRE_COMMON:Language', md.languageBib),
572 tag('INSPIRE_COMMON:SupportedLanguage/INSPIRE_COMMON:Language', md.languageBib),
573 ),
574 tag('INSPIRE_COMMON:ResponseLanguage/INSPIRE_COMMON:Language', md.languageBib),
575 ]
578def coord_dms(n):
579 """Format a coordinate in degrees, with the default precision for degrees.
581 Args:
582 n: Coordinate value.
584 Returns:
585 The formatted string.
586 """
588 prec = gws.lib.uom.DEFAULT_PRECISION[gws.Uom.deg]
589 return f'{round(n, prec):.{prec}f}'
592def coord_m(n):
593 """Format a coordinate in meters, with the default precision for meters.
595 Args:
596 n: Coordinate value.
598 Returns:
599 The formatted string.
600 """
602 prec = gws.lib.uom.DEFAULT_PRECISION[gws.Uom.m]
603 return f'{round(n, prec):.{prec}f}'
606def iso_date(d):
607 """Format a date as an ISO date string.
609 Args:
610 d: Date value or string.
612 Returns:
613 The ISO date, or an empty string if the value cannot be parsed.
614 """
616 dd = dtx.parse(d)
617 return dtx.to_iso_date_string(dd) if dd else ''
620def iso_datetime(d):
621 """Format a date as an ISO date-time string with time zone.
623 Args:
624 d: Date value or string.
626 Returns:
627 The ISO date-time, or an empty string if the value cannot be parsed.
628 """
630 dd = dtx.parse(d)
631 return dtx.to_iso_string(dd, with_tz=':') if dd else ''
634def namespaces_from_caps(ta: server.TemplateArgs) -> list[gws.XmlNamespace]:
635 """Return the feature type namespaces, to declare in documents that reference feature types by QName.
637 Args:
638 ta: Template arguments.
640 Returns:
641 The distinct namespaces of the layers in ``layerCapsList``.
642 """
644 d = {}
645 for lc in ta.layerCapsList:
646 if lc.xmlNamespace:
647 d[lc.xmlNamespace.uri] = lc.xmlNamespace
648 return list(d.values())
651def to_xml_response(
652 ta: server.TemplateArgs,
653 el: gws.XmlElement,
654 default_namespace: Optional[gws.XmlNamespace] = None,
655 extra_namespaces: Optional[list[gws.XmlNamespace]] = None,
656 doctype: Optional[str] = None,
657) -> gws.ContentResponse:
658 """Create an XML response.
660 For SOAP requests, the element is wrapped in a SOAP envelope.
662 Args:
663 ta: Template arguments.
664 el: Root element.
665 default_namespace: Default namespace of the document.
666 extra_namespaces: Namespaces to declare on the root in addition to those used by elements and attributes,
667 e.g. for QName values.
668 doctype: DTD for DTD-based formats (WMS 1.1.x). These have no namespace declarations on the root;
669 ``xlink`` is declared inline where it is used (as per the DTD's #FIXED xmlns:xlink).
671 Returns:
672 The XML response.
673 """
675 if extra_namespaces:
676 el.declare(*extra_namespaces)
678 if doctype:
679 xlink = xmlx.namespace.c.XLINK
680 xlink_uri = '{' + xlink.uri + '}'
681 for e in el.iter():
682 if any(k.startswith(xlink_uri) for k in e.attrib):
683 e.declare(xlink)
685 if ta.serviceRequest.isSoap:
686 el = tag('SOAP:Envelope', tag('SOAP:Header'), tag('SOAP:Body', el))
688 opts = gws.XmlOptions(
689 doctype=doctype,
690 defaultNamespace=default_namespace,
691 withNamespaceDeclarations=not doctype,
692 withSchemaLocations=not doctype,
693 withXmlDeclaration=True,
694 customNamespacePrefixes=ta.serviceRequest.customNamespacePrefixes,
695 )
697 return cast(service.Object, ta.serviceRequest.service).xml_response(el, opts)