Coverage for gws-app/gws/base/ows/server/service.py: 74%
184 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"""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.format.Config]]
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(gws.lib.image.format.from_config(cfg))
85 return
87 self.imageFormats = [
88 gws.ImageFormat(mimeTypes=[gws.lib.mime.PNG], options={}),
89 gws.ImageFormat(mimeTypes=[gws.lib.mime.JPEG], options={}),
90 ]
92 def configure_bounds(self):
93 """Configure the supported bounds.
95 The CRS list is taken from the configuration, or the project map CRS, or
96 Web Mercator and WGS84. The extent is taken from the configuration, or the
97 project map, or the first CRS. ``supportedBounds`` holds the extent in each CRS.
99 Returns:
100 Always ``True``.
101 """
103 p = self.cfg('supportedCrs')
104 if p:
105 crs_list = [gws.lib.crs.require(s) for s in p]
106 elif self.project:
107 crs_list = [self.project.map.bounds.crs]
108 else:
109 crs_list = [gws.lib.crs.WEBMERCATOR, gws.lib.crs.WGS84]
111 p = self.cfg('extent')
112 if p:
113 q = self.cfg('extentCrs')
114 crs = gws.lib.crs.require(q) if q else crs_list[0]
115 bounds = gws.Bounds(crs=crs, extent=gws.lib.extent.from_list(p))
116 elif self.project:
117 bounds = self.project.map.bounds
118 else:
119 bounds = gws.Bounds(crs=crs_list[0], extent=crs_list[0].extent)
121 self.supportedBounds = [gws.lib.bounds.transform(bounds, crs) for crs in crs_list]
122 return True
124 def configure_templates(self):
125 """Configure the service templates.
127 Returns:
128 ``True`` if any templates are configured.
129 """
131 return gws.config.util.configure_templates_for(self)
133 def configure_operations(self):
134 """Configure ``supportedOperations``. Must be implemented by subclasses."""
135 pass
137 def available_mime_types(self, verb: gws.OwsVerb):
138 """Return the MIME types available for an operation.
140 For image operations, these are the mime types of the image formats,
141 otherwise the mime types of the templates for the operation.
143 Args:
144 verb: Request type.
146 Returns:
147 A sorted list of mime types.
148 """
150 fs = set()
152 if verb in core.IMAGE_VERBS:
153 for fmt in self.imageFormats:
154 fs.update(fmt.mimeTypes)
155 else:
156 for tpl in self.templates:
157 if tpl.subject == f'ows.{verb}':
158 fs.update(tpl.mimeTypes)
160 return sorted(fs)
162 def configure_metadata(self):
163 """Configure the service metadata, merged from the project, the application and the configuration.
165 Returns:
166 Always ``True``.
167 """
169 self.metadata = gws.base.metadata.from_args(
170 self.project.metadata if self.project else self.root.app.metadata,
171 self.cfg('metadata'),
172 )
173 return True
175 def post_configure(self):
176 self.post_configure_root_layer()
177 self.post_configure_host()
179 def post_configure_host(self):
180 """Warn if no canonical host or host names are configured for the site."""
182 site = self.root.app.webMgr.site
183 if not site.canonicalHost and not site.hostnames:
184 self.root.config_warning('neither "web.site.canonicalHost" nor "web.site.hostnames" is set, service urls will reflect the request host')
186 def post_configure_root_layer(self):
187 """Configure the root layer given by ``rootLayerUid``.
189 If the service has no project, the project of the root layer is used.
191 Raises:
192 ``gws.ConfigurationError``: If the layer is not found or belongs to a different project.
193 """
195 self.rootLayer = None
197 uid = self.cfg('rootLayerUid')
198 if not uid:
199 return
201 self.rootLayer = cast(gws.Layer, self.root.get(uid, gws.ext.object.layer))
202 if not self.rootLayer:
203 raise gws.ConfigurationError(f'root layer {uid!r} not found')
205 prj = cast(gws.Project, self.rootLayer.find_closest(gws.ext.object.project))
206 if prj and not self.project:
207 self.project = prj
208 return
210 if self.project != prj:
211 raise gws.ConfigurationError(f'root layer {uid!r} does not belong to {self.project!r}')
213 ##
215 def url_path(self, sr: request.Object) -> str:
216 """Return the URL path of the service.
218 The project uid is included if the request project is not the service project.
220 Args:
221 sr: Service request.
223 Returns:
224 The URL path.
225 """
227 if sr.project and sr.project != self.project:
228 return gws.u.action_url_path('owsService', serviceUid=self.uid, projectUid=sr.project.uid)
229 else:
230 return gws.u.action_url_path('owsService', serviceUid=self.uid)
232 ##
234 def init_request(self, req: gws.WebRequester) -> request.Object:
235 """Create a service request from a web request.
237 Supports GET requests and XML POST requests, optionally wrapped in a SOAP
238 envelope. XML requests are converted to parameters by ``parse_xml_request``.
240 Args:
241 req: Web request.
243 Returns:
244 The service request.
246 Raises:
247 ``gws.base.web.error.BadRequest``: If the request cannot be parsed.
248 """
250 if req.method == 'GET':
251 return request.Object(self, req, req.params())
253 if req.method == 'POST' and gws.lib.mime.get(req.contentType) == gws.lib.mime.XML:
254 try:
255 xml = gws.lib.xmlx.from_string(req.text(), gws.XmlOptions(removeNamespaces=True))
256 except gws.lib.xmlx.Error:
257 raise gws.base.web.error.BadRequest()
259 is_soap = False
260 if xml.name == 'Envelope':
261 is_soap = True
262 body = xml.find('Body')
263 xml = body.findfirst() if body else None
264 if xml is None:
265 raise gws.base.web.error.BadRequest()
267 params = self.parse_xml_request(xml)
268 if not params:
269 raise gws.base.web.error.BadRequest()
270 return request.Object(self, req, params, xml_element=xml, is_soap=is_soap)
272 # @TODO support application/x-www-form-urlencoded
273 raise gws.base.web.error.BadRequest()
275 def parse_xml_request(self, xml: gws.XmlElement) -> Optional[dict]:
276 """Convert an XML request to parameters.
278 The base implementation returns an empty dict; subclasses that support
279 XML requests override this.
281 Args:
282 xml: Request element.
284 Returns:
285 Request parameters, empty if the request is not supported.
286 """
287 return {}
289 def handle_request(self, req: gws.WebRequester) -> gws.ContentResponse:
290 try:
291 sr = self.init_request(req)
292 return self.dispatch_request(sr)
293 except Exception as exc:
294 err = error.from_exception(exc)
295 # @TODO INIMAGE Exceptions
296 # verb = req.param('REQUEST')
297 # if verb in core.IMAGE_VERBS:
298 # return err.to_image_response()
299 return err.to_xml_response('ows' if self.isOwsCommon else 'ogc')
301 def dispatch_request(self, sr: request.Object):
302 """Call the handler method of the requested operation.
304 Args:
305 sr: Service request.
307 Returns:
308 The handler response.
309 """
311 fn = getattr(self, sr.operation.handlerName)
312 return fn(sr)
314 def get_template(self, sr: request.Object, mime: gws.MimeType = '') -> Optional[gws.Template]:
315 """Find a template for the given service request.
317 Args:
318 sr: Service request.
319 mime: Output mime type.
321 Returns:
322 The template, or ``None`` if not found.
323 """
325 return self.root.app.templateMgr.find_template(
326 f'ows.{sr.operation.verb}',
327 where=[self, sr.project],
328 user=sr.req.user,
329 mime=mime,
330 )
332 def template_response(self, sr: request.Object, mime: gws.MimeType = '', **kwargs) -> gws.ContentResponse:
333 """Render a template for the given service request.
335 If no template is found for the mime type, the XML template is used.
337 Args:
338 sr: Service request.
339 mime: Output mime type.
340 **kwargs: Additional template arguments.
342 Returns:
343 The rendered response.
345 Raises:
346 ``error.InvalidFormat``: If there is no XML template either.
347 """
349 tpl = self.get_template(sr, mime=mime)
350 if not tpl:
351 # OGC 06-042, 7.2.3.1
352 # If the request specifies a format not supported by the server, the server shall respond with the default text/xml format.
353 gws.log.debug(f'no template: {sr.operation.verb=} {mime=}')
354 if mime == gws.lib.mime.XML:
355 raise error.InvalidFormat()
356 return self.template_response(sr, gws.lib.mime.XML, **kwargs)
358 args = request.TemplateArgs(
359 serviceRequest=sr,
360 service=self,
361 serviceUrl=sr.req.canonical_url_for(self.url_path(sr)),
362 url_for=sr.req.canonical_url_for,
363 version=sr.version,
364 intVersion=int(sr.version.replace('.', '')),
365 **kwargs,
366 )
368 return tpl.render(gws.TemplateRenderInput(args=args))
370 def xml_response(self, el: gws.XmlElement, opts: gws.XmlOptions = None) -> gws.ContentResponse:
371 """Create an XML response.
373 Args:
374 el: Root element.
375 opts: Serialization options.
377 Returns:
378 The XML response.
379 """
381 xml = el.to_string(opts)
382 return gws.ContentResponse(mimeType=gws.lib.mime.XML, content=xml)
384 def image_response(self, sr: request.Object, img: Optional[gws.Image], mime: gws.MimeType) -> gws.ContentResponse:
385 """Create an image response, encoded with the options of the matching image format.
387 Args:
388 sr: Service request.
389 img: Image, or ``None`` for an empty pixel.
390 mime: Output mime type.
392 Returns:
393 The image response.
395 Raises:
396 ``error.InvalidFormat``: If the mime type is not supported.
397 """
399 ifo = self.imageFormats[0]
400 if mime:
401 ifo = gws.lib.image.format.find_by_mime_type(mime, self.imageFormats)
402 if not ifo:
403 raise error.InvalidFormat()
404 if img:
405 gws.log.debug(f'image_response: {img.mode()=} {img.size()=} {mime=} {ifo.options}')
406 return gws.ContentResponse(mimeType=mime, content=img.to_bytes(mime, ifo.options))
407 return gws.ContentResponse(mimeType=mime, content=gws.lib.image.util.empty_pixel(ifo))
409 def render_legend(self, sr: request.Object, lcs: list[core.LayerCaps], mime: gws.MimeType) -> gws.ContentResponse:
410 """Render a combined legend for the given layers.
412 The response is cached per layer set and mime type.
414 Args:
415 sr: Service request.
416 lcs: Layer caps.
417 mime: Output mime type.
419 Returns:
420 The image response.
421 """
423 uids = [lc.layer.uid for lc in lcs]
424 cache_key = 'gws.base.ows.server.legend.' + gws.u.sha256(uids) + mime
426 def _get():
427 legend = cast(
428 gws.Legend,
429 self.root.create_temporary(
430 gws.ext.object.legend,
431 type='combined',
432 layerUids=uids,
433 ),
434 )
435 lro = legend.render()
436 return self.image_response(sr, lro.image if lro else None, mime)
438 return gws.u.get_app_global(cache_key, _get)
440 def feature_collection(self, sr: request.Object, lcs: list[core.LayerCaps], hits: int, results: list[gws.SearchResult]) -> core.FeatureCollection:
441 """Create a feature collection from search results.
443 Features are transformed to the request ``targetCrs``.
445 Args:
446 sr: Service request.
447 lcs: Layer caps, used to link the members to their layers.
448 hits: Total number of matching features.
449 results: Search results.
451 Returns:
452 The feature collection.
453 """
455 fc = core.FeatureCollection(
456 members=[],
457 numMatched=hits,
458 numReturned=len(results),
459 timestamp=gws.lib.datetimex.to_iso_string(with_tz=':'),
460 )
462 lcs_map = {id(lc.layer): lc for lc in lcs}
464 for r in results:
465 r.feature.transform_to(sr.targetCrs)
466 fc.members.append(
467 core.FeatureCollectionMember(
468 feature=r.feature,
469 layer=r.layer,
470 layerCaps=lcs_map.get(id(r.layer)) if r.layer else None,
471 )
472 )
474 return fc