Coverage for gws-app/gws/base/ows/server/request.py: 83%
210 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"""OWS service request."""
3from typing import Optional, Callable, cast
4import re
6import gws
7import gws.base.layer.core
8import gws.base.legend
9import gws.base.model
10import gws.base.web
11import gws.lib.extent
12import gws.gis.render
13import gws.lib.mime
14import gws.lib.bounds
15import gws.lib.crs
16import gws.lib.image
17import gws.lib.uom
18import gws.lib.xmlx
20from . import core, layer_caps, error
23class TemplateArgs(gws.TemplateArgs):
24 """Arguments for service templates."""
26 featureCollection: core.FeatureCollection
27 """Search result, for feature requests."""
28 metadataCollection: core.MetadataCollection
29 """Search result, for catalog requests."""
30 operation: gws.OwsOperation
31 """Requested operation."""
32 project: gws.Project
33 """Project."""
34 request: 'Object'
35 """Service request."""
36 layerCapsList: list[core.LayerCaps]
37 """Layer caps to include in the response."""
38 serviceRequest: 'Object'
39 """Service request."""
40 service: gws.OwsService
41 """Service object."""
42 serviceUrl: str
43 """Canonical service URL."""
44 url_for: Callable
45 """Function that converts a URL or path to a canonical URL."""
46 gmlVersion: int
47 """GML version for geometries."""
48 version: str
49 """Requested service version."""
50 intVersion: int
51 """Requested service version as an integer, e.g. ``130`` for ``1.3.0``."""
52 tileMatrixSets: list[gws.TileMatrixSet]
53 """Tile matrix sets (WMTS)."""
56class Object:
57 """OWS service request.
59 Holds the request parameters and provides methods to read and validate them.
60 The constructor determines the operation and the version; the service
61 handlers fill in the other attributes (CRS, bounds, sizes) as needed.
62 """
64 alwaysXY: bool
65 """Use XY axis order for all CRSs."""
66 bounds: gws.Bounds
67 """Requested bounds."""
68 crs: gws.Crs
69 """Requested CRS."""
70 params: dict
71 """Request parameters, with upper-cased names."""
72 pxSize: gws.Size
73 """Requested image size in pixels."""
74 resolution: float
75 """Requested resolution."""
76 resX: float
77 """Requested horizontal resolution."""
78 resY: float
79 """Requested vertical resolution."""
80 isSoap: bool = False
81 """The request was sent as a SOAP envelope."""
82 layerCapsList: list[core.LayerCaps]
83 """Caps of all layers available to the user in this service and project."""
84 operation: gws.OwsOperation
85 """Requested operation."""
86 project: gws.Project
87 """Project."""
88 req: gws.WebRequester
89 """Web request."""
90 service: gws.OwsService
91 """Service object."""
92 targetCrs: gws.Crs
93 """CRS of the output."""
94 version: str
95 """Negotiated service version."""
96 xmlElement: Optional[gws.XmlElement]
97 """Request body for XML POST requests."""
98 customNamespacePrefixes: dict
99 """Custom namespace prefixes (uri -> prefix) requested with the ``NAMESPACES`` parameter."""
101 def __init__(
102 self,
103 service: gws.OwsService,
104 req: gws.WebRequester,
105 params: dict,
106 xml_element: gws.XmlElement = None,
107 is_soap=False,
108 ) -> None:
109 """Create a service request.
111 Args:
112 service: Service object.
113 req: Web request.
114 params: Request parameters.
115 xml_element: Request body for XML POST requests.
116 is_soap: The request was sent as a SOAP envelope.
118 Raises:
119 ``error.OperationNotSupported``: If the operation is not supported.
120 ``error.VersionNegotiationFailed``: If none of the requested versions is supported.
121 ``error.CurrentUpdateSequence``: If the requested update sequence equals the current one.
122 ``error.InvalidUpdateSequence``: If the requested update sequence is greater than the current one.
123 """
124 self.service = service
125 self.req = req
126 self.project = cast(gws.Project, None)
127 self.params = gws.u.to_upper_dict(params)
128 self.xmlElement = xml_element
129 self.isSoap = is_soap
131 self.operation = self.requested_operation('REQUEST')
132 self.version = self.requested_version('VERSION,ACCEPTVERSIONS')
134 self.alwaysXY = False
135 self.pxSize = 0, 0
136 self.resolution = 0
137 self.resX = 0
138 self.resY = 0
140 # OGC 06-042, 7.2.3.5
141 if self.service.updateSequence:
142 s = self.string_param('UPDATESEQUENCE', default='')
143 if s and s == self.service.updateSequence:
144 raise error.CurrentUpdateSequence()
145 if s and s > self.service.updateSequence:
146 raise error.InvalidUpdateSequence()
148 self.customNamespacePrefixes = self.requested_xmlns_replacements()
150 def require_project(self):
151 """Load the project and the layer caps, the project is required.
153 Raises:
154 ``gws.NotFoundError``: If the project is not found.
155 ``gws.ForbiddenError``: If the user cannot access the project.
156 """
158 return self.load_project(required=True)
160 def load_project(self, required=False):
161 """Load the project and the layer caps.
163 The project is the one given by the ``projectUid`` parameter or the
164 project the service is configured for. Sets ``project`` and
165 ``layerCapsList``. The layer caps are cached per service, project and user roles.
167 Args:
168 required: Raise an error if there is no project.
170 Raises:
171 ``gws.NotFoundError``: If the project is required and not found, or does not match the service project.
172 ``gws.ForbiddenError``: If the user cannot access the project.
173 """
175 # services can be configured globally (in which case, service.project == None)
176 # and applied to multiple projects with the projectUid param
177 # or, configured just for a single project (service.project != None)
179 p = self.req.param('projectUid')
180 project = None
182 if p:
183 project = self.req.user.require_project(p)
184 if self.service.project and project != self.service.project:
185 raise gws.NotFoundError(f'ows {self.service.uid}: wrong project={p!r}')
186 elif self.service.project:
187 # for in-project services, ensure the user can access the project
188 project = self.req.user.require_project(self.service.project.uid)
190 if not project:
191 if required:
192 raise gws.NotFoundError(f'ows {self.service.uid}: project not found')
193 return
195 self.project = project
196 cache_key = 'layer_caps_' + gws.u.sha256([self.service.uid, self.project.uid, sorted(self.req.user.roles)])
197 self.layerCapsList = gws.u.get_app_global(cache_key, self.enum_layer_caps)
199 def enum_layer_caps(self):
200 """Create the layer caps for the service root layer or the project root layer.
202 Only layers the user can read and which are enabled for this service are
203 included. Empty groups are skipped. Groups are listed before their children.
205 Returns:
206 A flat list of layer caps.
207 """
209 lcs = []
210 root_layer = self.service.rootLayer or self.project.map.rootLayer
211 self._enum_layer_caps(root_layer, lcs, [])
212 return lcs
214 def _enum_layer_caps(self, layer: gws.Layer, lcs: list[core.LayerCaps], stack: list[core.LayerCaps]):
215 """Add caps for a layer and its sub-layers to ``lcs``, linking them to the groups in ``stack``."""
216 if not self.req.user.can_read(layer) or not layer.isEnabledForOws:
217 return
219 ows = layer.ows
220 if ows and ows.allowedServiceUids and self.service.uid not in ows.allowedServiceUids:
221 return
222 if ows and ows.deniedServiceUids and self.service.uid in ows.deniedServiceUids:
223 return
225 is_compat = self.service.layer_is_compatible(layer)
226 if not is_compat and not layer.isGroup:
227 return
229 lc = layer_caps.for_layer(layer, self.req.user, self.service)
231 # NB groups must be inspected even if not 'compatible'
232 if layer.isGroup:
233 lc.isGroup = True
234 n = len(lcs)
235 for sub_layer in layer.layers:
236 self._enum_layer_caps(sub_layer, lcs, stack + [lc])
237 if not lc.children:
238 # no empty groups
239 return
240 if is_compat:
241 lc.hasLegend = any(c.hasLegend for c in lc.children)
242 lc.isSearchable = any(c.isSearchable for c in lc.children)
243 lcs.insert(n, lc)
244 else:
245 lc.isGroup = False
246 lcs.append(lc)
247 for sup_lc in stack:
248 sup_lc.leaves.append(lc)
250 if stack:
251 stack[-1].children.append(lc)
253 ##
255 def requested_version(self, param_names: str) -> str:
256 """Negotiate the service version.
258 Args:
259 param_names: Comma-separated parameter names to look for.
261 Returns:
262 The first supported version that starts with a requested version,
263 or the first supported version if no version is requested.
265 Raises:
266 ``error.VersionNegotiationFailed``: If none of the requested versions is supported.
267 """
269 p, val = self._get_param(param_names, '')
270 if not val:
271 # the first supported version is the default
272 return self.service.supportedVersions[0]
274 for v in gws.u.to_list(val):
275 for ver in self.service.supportedVersions:
276 if ver.startswith(v):
277 return ver
279 raise error.VersionNegotiationFailed()
281 _param2verb = {
282 'createstoredquery': gws.OwsVerb.CreateStoredQuery,
283 'describecoverage': gws.OwsVerb.DescribeCoverage,
284 'describefeaturetype': gws.OwsVerb.DescribeFeatureType,
285 'describelayer': gws.OwsVerb.DescribeLayer,
286 'describerecord': gws.OwsVerb.DescribeRecord,
287 'describestoredqueries': gws.OwsVerb.DescribeStoredQueries,
288 'dropstoredquery': gws.OwsVerb.DropStoredQuery,
289 'getcapabilities': gws.OwsVerb.GetCapabilities,
290 'getfeature': gws.OwsVerb.GetFeature,
291 'getfeatureinfo': gws.OwsVerb.GetFeatureInfo,
292 'getfeaturewithlock': gws.OwsVerb.GetFeatureWithLock,
293 'getlegendgraphic': gws.OwsVerb.GetLegendGraphic,
294 'getmap': gws.OwsVerb.GetMap,
295 'getprint': gws.OwsVerb.GetPrint,
296 'getpropertyvalue': gws.OwsVerb.GetPropertyValue,
297 'getrecordbyid': gws.OwsVerb.GetRecordById,
298 'getrecords': gws.OwsVerb.GetRecords,
299 'gettile': gws.OwsVerb.GetTile,
300 'liststoredqueries': gws.OwsVerb.ListStoredQueries,
301 'lockfeature': gws.OwsVerb.LockFeature,
302 'transaction': gws.OwsVerb.Transaction,
303 }
305 def requested_operation(self, param_names: str) -> gws.OwsOperation:
306 """Return the requested operation.
308 Args:
309 param_names: Comma-separated parameter names to look for.
311 Returns:
312 The operation.
314 Raises:
315 ``error.OperationNotSupported``: If the operation is not supported by the service.
316 """
318 _, val = self._get_param(param_names, '')
319 op = self.find_operation(val)
320 if op:
321 return op
322 raise error.OperationNotSupported(val)
324 def find_operation(self, param: str) -> Optional[gws.OwsOperation]:
325 """Find a supported operation by its name.
327 Args:
328 param: Operation name, case-insensitive.
330 Returns:
331 The operation, or ``None`` if the service does not support it.
332 """
334 verb = self._param2verb.get(param.lower())
335 if not verb:
336 return
338 for op in self.service.supportedOperations:
339 if op.verb == verb:
340 return op
342 def requested_crs(self, param_names: str) -> Optional[gws.Crs]:
343 """Return the requested CRS.
345 Args:
346 param_names: Comma-separated parameter names to look for.
348 Returns:
349 The CRS, or ``None`` if not requested.
351 Raises:
352 ``error.InvalidCRS``: If the CRS is unknown or not supported by the service.
353 """
355 _, val = self._get_param(param_names, '')
356 if not val:
357 return
359 crs = gws.lib.crs.get(val)
360 if not crs:
361 raise error.InvalidCRS()
363 for b in self.service.supportedBounds:
364 if crs == b.crs:
365 return crs
367 raise error.InvalidCRS()
369 def requested_bounds(self, param_names: str) -> Optional[gws.Bounds]:
370 """Return the requested bounding box, transformed to ``crs``.
372 Uses ``crs`` as the default CRS and ``alwaysXY`` for the axis order.
374 Args:
375 param_names: Comma-separated parameter names to look for.
377 Returns:
378 The bounds, or ``None`` if not requested.
380 Raises:
381 ``error.InvalidParameterValue``: If the bounding box is invalid.
382 """
384 # OGC 06-042, 7.2.3.5
385 # OGC 00-028, 6.2.8.2.3
387 p, val = self._get_param(param_names, '')
388 if not val:
389 return
391 bounds = gws.lib.bounds.from_request_bbox(val, default_crs=self.crs, always_xy=self.alwaysXY)
392 if bounds:
393 return gws.lib.bounds.transform(bounds, self.crs)
395 raise error.InvalidParameterValue(p)
397 def requested_format(self, param_names: str) -> str:
398 """Return the requested format with whitespace removed.
400 Args:
401 param_names: Comma-separated parameter names to look for.
403 Returns:
404 The format, or an empty string if not requested.
405 """
407 _, val = self._get_param(param_names, '')
408 if val:
409 # NB our mime types do not contain spaces
410 return ''.join(val.split())
411 return ''
413 def requested_feature_count(self, param_names: str) -> int:
414 """Return the requested feature count, limited by the service maximum.
416 Args:
417 param_names: Comma-separated parameter names to look for.
419 Returns:
420 The feature count, or the service default if not requested or not positive.
422 Raises:
423 ``error.InvalidParameterValue``: If the value is not an integer.
424 """
426 s = self.int_param(param_names, default=0)
427 if s <= 0:
428 return self.service.defaultFeatureCount
429 return min(self.service.maxFeatureCount, s)
431 def requested_xmlns_replacements(self):
432 """Read custom namespace prefixes from the ``NAMESPACES`` parameter.
434 Returns:
435 A dict mapping namespace uris to prefixes.
436 """
438 s = self.string_param('NAMESPACES', default='')
439 if not s:
440 return {}
442 # OGC 09-025r1, Table 7
443 # xmlns(xml,http://www.w3.org/XML/1998/namespace),xmlns(ns37,https://our-ns),xmlns(wfs ... etc
445 d = {}
447 for xmlns, uri in re.findall(r'xmlns\((.+?),(.+?)\)', s):
448 d[uri.strip()] = xmlns.strip()
450 return d
452 ##
454 def _get_param(self, param_names, default):
455 """Return the first present parameter as ``(name, value)``; raise ``MissingParameterValue`` if absent and ``default`` is ``None``."""
456 names = gws.u.to_list(param_names.upper())
458 for p in names:
459 if p not in self.params:
460 continue
461 val = self.params[p]
462 return p, val
464 if default is not None:
465 return '', default
467 raise error.MissingParameterValue(names[0])
469 def string_param(self, param_names: str, values: Optional[set[str]] = None, default: Optional[str] = None) -> str:
470 """Return a string parameter.
472 Args:
473 param_names: Comma-separated parameter names to look for.
474 values: Allowed values, in lower case. If given, the value is lower-cased and checked.
475 default: Default value. If ``None``, the parameter is required.
477 Returns:
478 The parameter value.
480 Raises:
481 ``error.MissingParameterValue``: If the parameter is required and missing.
482 ``error.InvalidParameterValue``: If the value is not allowed.
483 """
485 p, val = self._get_param(param_names, default)
486 if values:
487 val = val.lower()
488 if val not in values:
489 raise error.InvalidParameterValue(p)
490 return val
492 def list_param(self, param_names: str) -> list[str]:
493 """Return a comma-separated parameter as a list.
495 Args:
496 param_names: Comma-separated parameter names to look for.
498 Returns:
499 The list of values, empty if the parameter is missing.
500 """
502 _, val = self._get_param(param_names, '')
503 return gws.u.to_list(val)
505 def int_param(self, param_names: str, default: Optional[int] = None) -> int:
506 """Return an integer parameter.
508 Args:
509 param_names: Comma-separated parameter names to look for.
510 default: Default value. If ``None``, the parameter is required.
512 Returns:
513 The parameter value.
515 Raises:
516 ``error.MissingParameterValue``: If the parameter is required and missing.
517 ``error.InvalidParameterValue``: If the value is not an integer.
518 """
520 p, val = self._get_param(param_names, default)
521 try:
522 return int(val)
523 except ValueError:
524 raise error.InvalidParameterValue(p)