Coverage for gws-app/gws/base/ows/server/service.py: 68%
187 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"""Base OWS service."""
3from typing import Optional, cast
5import gws
6import gws.base.web
7import gws.config.util
8import gws.lib.bounds
9import gws.lib.crs
10import gws.lib.datetimex
11import gws.lib.extent
12import gws.lib.image
13import gws.base.metadata
14import gws.lib.mime
15import gws.lib.xmlx
16from . import core, error, request
19class Config(gws.ConfigWithAccess):
20 """Configuration for OWS services."""
22 defaultFeatureCount: int = 1000
23 """Number of features per page when the request does not specify a count."""
24 extent: Optional[gws.Extent]
25 """Service extent."""
26 extentCrs: Optional[gws.CrsName]
27 """CRS of the service extent."""
28 imageFormats: Optional[list[gws.lib.image.FormatConfig]]
29 """Image formats for map and tile requests."""
30 maxFeatureCount: int = 10000
31 """Maximum number of features per page a request can ask for."""
32 metadata: Optional[gws.base.metadata.Config]
33 """Service metadata, overriding the project and application metadata."""
34 rootLayerUid: str = ''
35 """UID of the layer published as the service root."""
36 searchTolerance: gws.UomValueStr = '10px'
37 """Search tolerance for feature info requests."""
38 supportedCrs: Optional[list[gws.CrsName]]
39 """CRSs supported by the service."""
40 templates: Optional[list[gws.ext.config.template]]
41 """Templates for service responses."""
42 updateSequence: Optional[str]
43 """Value of updateSequence in capabilities, to signal changes to clients."""
44 withInspireMeta: bool = False
45 """Include INSPIRE extended capabilities in WMS and WFS capabilities."""
46 withStrictParams: bool = False
47 """Use strict parameter checking."""
50class Object(gws.OwsService):
51 """Base class for OWS services.
53 Configures supported CRSs and bounds, image formats, templates and metadata,
54 parses incoming requests and dispatches them to operation handlers. Provides
55 subclasses with helpers to create template, XML, image and legend responses.
56 Subclasses set ``protocol`` and ``supportedVersions``, implement
57 ``configure_operations`` and ``layer_is_compatible`` and provide a handler
58 method for each operation.
59 """
61 def configure(self):
62 self.project = self.find_closest(gws.ext.object.project)
64 self.updateSequence = self.cfg('updateSequence')
65 self.withInspireMeta = self.cfg('withInspireMeta')
66 self.withStrictParams = self.cfg('withStrictParams')
68 self.maxFeatureCount = self.cfg('maxFeatureCount')
69 self.defaultFeatureCount = self.cfg('defaultFeatureCount')
70 self.searchTolerance = self.cfg('searchTolerance')
72 self.configure_bounds()
73 self.configure_image_formats()
74 self.configure_templates()
75 self.configure_operations()
76 self.configure_metadata()
78 def configure_image_formats(self):
79 """Configure image formats from the configuration, or PNG and JPEG by default."""
80 p = self.cfg('imageFormats')
81 if p:
82 self.imageFormats = []
83 for cfg in p:
84 self.imageFormats.append(
85 gws.ImageFormat(
86 mimeTypes=[s.replace(' ', '') for s in cfg.get('mimeTypes', [])],
87 options=cfg.get('options') or {},
88 )
89 )
90 return
92 self.imageFormats = [
93 gws.ImageFormat(mimeTypes=[gws.lib.mime.PNG], options={}),
94 gws.ImageFormat(mimeTypes=[gws.lib.mime.JPEG], options={}),
95 ]
97 def configure_bounds(self):
98 """Configure the supported bounds.
100 The CRS list is taken from the configuration, or the project map CRS, or
101 Web Mercator and WGS84. The extent is taken from the configuration, or the
102 project map, or the first CRS. ``supportedBounds`` holds the extent in each CRS.
104 Returns:
105 Always ``True``.
106 """
108 p = self.cfg('supportedCrs')
109 if p:
110 crs_list = [gws.lib.crs.require(s) for s in p]
111 elif self.project:
112 crs_list = [self.project.map.bounds.crs]
113 else:
114 crs_list = [gws.lib.crs.WEBMERCATOR, gws.lib.crs.WGS84]
116 p = self.cfg('extent')
117 if p:
118 q = self.cfg('extentCrs')
119 crs = gws.lib.crs.require(q) if q else crs_list[0]
120 bounds = gws.Bounds(crs=crs, extent=gws.lib.extent.from_list(p))
121 elif self.project:
122 bounds = self.project.map.bounds
123 else:
124 bounds = gws.Bounds(crs=crs_list[0], extent=crs_list[0].extent)
126 self.supportedBounds = [gws.lib.bounds.transform(bounds, crs) for crs in crs_list]
127 return True
129 def configure_templates(self):
130 """Configure the service templates.
132 Returns:
133 ``True`` if any templates are configured.
134 """
136 return gws.config.util.configure_templates_for(self)
138 def configure_operations(self):
139 """Configure ``supportedOperations``. Must be implemented by subclasses."""
140 pass
142 def available_formats(self, verb: gws.OwsVerb):
143 """Return the formats available for an operation.
145 For image operations, these are the mime types of the image formats,
146 otherwise the mime types of the templates for the operation.
148 Args:
149 verb: Request type.
151 Returns:
152 A sorted list of mime types.
153 """
155 fs = set()
157 if verb in core.IMAGE_VERBS:
158 for fmt in self.imageFormats:
159 fs.update(fmt.mimeTypes)
160 else:
161 for tpl in self.templates:
162 if tpl.subject == f'ows.{verb}':
163 fs.update(tpl.mimeTypes)
165 return sorted(fs)
167 def configure_metadata(self):
168 """Configure the service metadata, merged from the project, the application and the configuration.
170 Returns:
171 Always ``True``.
172 """
174 self.metadata = gws.base.metadata.from_args(
175 self.project.metadata if self.project else None,
176 self.root.app.metadata,
177 self.cfg('metadata'),
178 )
179 return True
181 def post_configure(self):
182 self.post_configure_root_layer()
183 self.post_configure_host()
185 def post_configure_host(self):
186 """Warn if no canonical host or host names are configured for the site."""
188 site = self.root.app.webMgr.site
189 if not site.canonicalHost and not site.hostnames:
190 self.root.config_warning('neither "web.site.canonicalHost" nor "web.site.hostnames" is set, service urls will reflect the request host')
192 def post_configure_root_layer(self):
193 """Configure the root layer given by ``rootLayerUid``.
195 If the service has no project, the project of the root layer is used.
197 Raises:
198 ``gws.ConfigurationError``: If the layer is not found or belongs to a different project.
199 """
201 self.rootLayer = None
203 uid = self.cfg('rootLayerUid')
204 if not uid:
205 return
207 self.rootLayer = cast(gws.Layer, self.root.get(uid, gws.ext.object.layer))
208 if not self.rootLayer:
209 raise gws.ConfigurationError(f'root layer {uid!r} not found')
211 prj = cast(gws.Project, self.rootLayer.find_closest(gws.ext.object.project))
212 if prj and not self.project:
213 self.project = prj
214 return
216 if self.project != prj:
217 raise gws.ConfigurationError(f'root layer {uid!r} does not belong to {self.project!r}')
219 ##
221 def url_path(self, sr: request.Object) -> str:
222 """Return the URL path of the service.
224 The project uid is included if the request project is not the service project.
226 Args:
227 sr: Service request.
229 Returns:
230 The URL path.
231 """
233 if sr.project and sr.project != self.project:
234 return gws.u.action_url_path('owsService', serviceUid=self.uid, projectUid=sr.project.uid)
235 else:
236 return gws.u.action_url_path('owsService', serviceUid=self.uid)
238 ##
240 def init_request(self, req: gws.WebRequester) -> request.Object:
241 """Create a service request from a web request.
243 Supports GET requests and XML POST requests, optionally wrapped in a SOAP
244 envelope. XML requests are converted to parameters by ``parse_xml_request``.
246 Args:
247 req: Web request.
249 Returns:
250 The service request.
252 Raises:
253 ``gws.base.web.error.BadRequest``: If the request cannot be parsed.
254 """
256 if req.method == 'GET':
257 return request.Object(self, req, req.params())
259 if req.method == 'POST' and gws.lib.mime.get(req.contentType) == gws.lib.mime.XML:
260 try:
261 xml = gws.lib.xmlx.from_string(req.text(), gws.XmlOptions(removeNamespaces=True))
262 except gws.lib.xmlx.Error:
263 raise gws.base.web.error.BadRequest()
265 is_soap = False
266 if xml.name == 'Envelope':
267 is_soap = True
268 body = xml.find('Body')
269 xml = body.findfirst() if body else None
270 if xml is None:
271 raise gws.base.web.error.BadRequest()
273 params = self.parse_xml_request(xml)
274 if not params:
275 raise gws.base.web.error.BadRequest()
276 return request.Object(self, req, params, xml_element=xml, is_soap=is_soap)
278 # @TODO support application/x-www-form-urlencoded
279 raise gws.base.web.error.BadRequest()
281 def parse_xml_request(self, xml: gws.XmlElement) -> Optional[dict]:
282 """Convert an XML request to parameters.
284 The base implementation returns an empty dict; subclasses that support
285 XML requests override this.
287 Args:
288 xml: Request element.
290 Returns:
291 Request parameters, empty if the request is not supported.
292 """
293 return {}
295 def handle_request(self, req: gws.WebRequester) -> gws.ContentResponse:
296 try:
297 sr = self.init_request(req)
298 return self.dispatch_request(sr)
299 except Exception as exc:
300 err = error.from_exception(exc)
301 # @TODO INIMAGE Exceptions
302 # verb = req.param('REQUEST')
303 # if verb in core.IMAGE_VERBS:
304 # return err.to_image_response()
305 return err.to_xml_response('ows' if self.isOwsCommon else 'ogc')
307 def dispatch_request(self, sr: request.Object):
308 """Call the handler method of the requested operation.
310 Args:
311 sr: Service request.
313 Returns:
314 The handler response.
315 """
317 fn = getattr(self, sr.operation.handlerName)
318 return fn(sr)
320 def get_template(self, sr: request.Object, mime_type: str = '') -> Optional[gws.Template]:
321 """Find a template for the given service request.
323 Args:
324 sr: Service request.
325 mime_type: Output mime type.
327 Returns:
328 The template, or ``None`` if not found.
329 """
331 return self.root.app.templateMgr.find_template(
332 f'ows.{sr.operation.verb}',
333 where=[self, sr.project],
334 user=sr.req.user,
335 mime_type=mime_type,
336 )
338 def template_response(self, sr: request.Object, mime_type: str = '', **kwargs) -> gws.ContentResponse:
339 """Render a template for the given service request.
341 If no template is found for the mime type, the XML template is used.
343 Args:
344 sr: Service request.
345 mime_type: Output mime type.
346 **kwargs: Additional template arguments.
348 Returns:
349 The rendered response.
351 Raises:
352 ``error.InvalidFormat``: If there is no XML template either.
353 """
355 tpl = self.get_template(sr, mime_type=mime_type)
356 if not tpl:
357 # OGC 06-042, 7.2.3.1
358 # If the request specifies a format not supported by the server, the server shall respond with the default text/xml format.
359 gws.log.debug(f'no template: {sr.operation.verb=} {mime_type=}')
360 if mime_type == gws.lib.mime.XML:
361 raise error.InvalidFormat()
362 return self.template_response(sr, gws.lib.mime.XML, **kwargs)
364 args = request.TemplateArgs(
365 serviceRequest=sr,
366 service=self,
367 serviceUrl=sr.req.canonical_url_for(self.url_path(sr)),
368 url_for=sr.req.canonical_url_for,
369 version=sr.version,
370 intVersion=int(sr.version.replace('.', '')),
371 **kwargs,
372 )
374 return tpl.render(gws.TemplateRenderInput(args=args))
376 def xml_response(self, el: gws.XmlElement, opts: gws.XmlOptions = None) -> gws.ContentResponse:
377 """Create an XML response.
379 Args:
380 el: Root element.
381 opts: Serialization options.
383 Returns:
384 The XML response.
385 """
387 xml = el.to_string(opts)
388 return gws.ContentResponse(mimeType=gws.lib.mime.XML, content=xml)
390 def image_response(self, sr: request.Object, img: Optional[gws.Image], mime_type: str) -> gws.ContentResponse:
391 """Create an image response, encoded with the options of the matching image format.
393 Args:
394 sr: Service request.
395 img: Image, or ``None`` for an empty pixel.
396 mime_type: Output mime type.
398 Returns:
399 The image response.
401 Raises:
402 ``error.InvalidFormat``: If the mime type is not supported.
403 """
405 ifmt = self.find_image_format(mime_type)
406 if img:
407 gws.log.debug(f'image_response: {img.mode()=} {img.size()=} {mime_type=} {ifmt.options}')
408 content = img.to_bytes(mime_type, ifmt.options) if img else gws.lib.image.empty_pixel(mime_type)
409 return gws.ContentResponse(mimeType=mime_type, content=content)
411 def find_image_format(self, mime_type: str) -> gws.ImageFormat:
412 """Find an image format by mime type.
414 Args:
415 mime_type: Mime type. If empty, the first format is returned.
417 Returns:
418 The image format.
420 Raises:
421 ``error.InvalidFormat``: If the mime type is not supported.
422 """
424 if not mime_type:
425 return self.imageFormats[0]
426 for f in self.imageFormats:
427 if mime_type in f.mimeTypes:
428 return f
429 raise error.InvalidFormat()
431 def render_legend(self, sr: request.Object, lcs: list[core.LayerCaps], mime_type: str) -> gws.ContentResponse:
432 """Render a combined legend for the given layers.
434 The response is cached per layer set and mime type.
436 Args:
437 sr: Service request.
438 lcs: Layer caps.
439 mime_type: Output mime type.
441 Returns:
442 The image response.
443 """
445 uids = [lc.layer.uid for lc in lcs]
446 cache_key = 'gws.base.ows.server.legend.' + gws.u.sha256(uids) + mime_type
448 def _get():
449 legend = cast(
450 gws.Legend,
451 self.root.create_temporary(
452 gws.ext.object.legend,
453 type='combined',
454 layerUids=uids,
455 ),
456 )
457 lro = legend.render()
458 return self.image_response(sr, lro.image if lro else None, mime_type)
460 return gws.u.get_app_global(cache_key, _get)
462 def feature_collection(self, sr: request.Object, lcs: list[core.LayerCaps], hits: int, results: list[gws.SearchResult]) -> core.FeatureCollection:
463 """Create a feature collection from search results.
465 Features are transformed to the request ``targetCrs``.
467 Args:
468 sr: Service request.
469 lcs: Layer caps, used to link the members to their layers.
470 hits: Total number of matching features.
471 results: Search results.
473 Returns:
474 The feature collection.
475 """
477 fc = core.FeatureCollection(
478 members=[],
479 numMatched=hits,
480 numReturned=len(results),
481 timestamp=gws.lib.datetimex.to_iso_string(with_tz=':'),
482 )
484 lcs_map = {id(lc.layer): lc for lc in lcs}
486 for r in results:
487 r.feature.transform_to(sr.targetCrs)
488 fc.members.append(
489 core.FeatureCollectionMember(
490 feature=r.feature,
491 layer=r.layer,
492 layerCaps=lcs_map.get(id(r.layer)) if r.layer else None,
493 )
494 )
496 return fc