Coverage for gws-app/gws/plugin/ows_server/wms/__init__.py: 46%
137 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"""WMS service.
3Implements WMS 1.1.x and 1.3.0 with the operations GetCapabilities, GetMap,
4GetFeatureInfo and GetLegendGraphic. SLD extensions are not supported, except
5``GetLegendGraphic``, for which only the ``LAYER``/``LAYERS`` parameter is evaluated.
7The service publishes the project layers that are groups or can render a box.
8A requested group stands for its leaf layers. GetMap renders the requested
9layers that are visible at the requested resolution into one image.
10GetFeatureInfo runs a search at the clicked point in the queryable layers and
11returns the results in GML 2, using the GetFeature template of the WFS service.
12``DPI`` or ``MAP_RESOLUTION`` parameters are taken into account when the
13request resolution is computed.
15Templates:
17- ``templates/getCapabilities.cx.py``: ``ows.GetCapabilities``.
18- ``../wfs/templates/getFeature2.cx.py``: ``ows.GetFeatureInfo``.
20Example::
22 projects+ {
23 owsServices+ {
24 type "wms"
25 uid "my_wms"
26 supportedCrs [3857 25832]
27 layerLimit 20
28 maxPixelSize 4096
29 }
30 }
31"""
33# @TODO strict mode
34#
35# OGC 06-042 7.2.4.7.2
36# A server shall issue a service exception (code="LayerNotQueryable") if GetFeatureInfo is requested on a Layer that is not queryable.
38# OGC 06-042 7.2.4.6.3
39# A server shall throw a service exception (code="LayerNotDefined") if an invalid layer is requested.
41import gws
42import gws.base.legend
43import gws.base.ows.server as server
44import gws.lib.shape
45import gws.base.web
46import gws.config.util
47import gws.lib.bounds
48import gws.lib.extent
49import gws.lib.crs
50import gws.gis.render
51import gws.lib.image
52import gws.base.metadata
53import gws.lib.mime
54import gws.lib.uom
55import gws.gis.zoom
58_cdir = gws.u.dirname(__file__)
60_DEFAULT_TEMPLATES = [
61 gws.Config(
62 type='py',
63 path=f'{_cdir}/templates/getCapabilities.cx.py',
64 subject='ows.GetCapabilities',
65 mimeTypes=[gws.lib.mime.XML],
66 ),
67 # NB use the wfs template with GML2 (qgis doesn't understand GML3 for WMS)
68 gws.Config(
69 type='py',
70 path=f'{_cdir}/../wfs/templates/getFeature2.cx.py',
71 subject='ows.GetFeatureInfo',
72 mimeTypes=[gws.lib.mime.GML2, gws.lib.mime.GML, gws.lib.mime.XML],
73 ),
74]
76_DEFAULT_METADATA = gws.Metadata(
77 name='WMS',
78 inspireDegreeOfConformity='notEvaluated',
79 inspireMandatoryKeyword='infoMapAccessService',
80 inspireResourceType='service',
81 inspireSpatialDataServiceType='view',
82 isoServiceFunction='search',
83 isoScope='dataset',
84 isoSpatialRepresentationType='vector',
85)
87_DEFAULT_MAX_PIXEL_SIZE = 2048
90@gws.ext.config.owsService('wms')
91class Config(server.service.Config):
92 """WMS service that renders the project layers."""
94 layerLimit: int = 0
95 """Max. number of layers in a map request."""
96 maxPixelSize: int = 0
97 """Max. width and height of a map image in pixels."""
100@gws.ext.object.owsService('wms')
101class Object(server.service.Object):
102 """WMS service that renders the project layers as map images and returns feature info and legends."""
104 protocol = gws.OwsProtocol.WMS
105 supportedVersions = ['1.3.0', '1.1.1', '1.1.0']
106 isRasterService = True
107 isOwsCommon = False
109 layerLimit: int = 0
110 """Max. number of layers in a request, 0 for no limit."""
111 maxPixelSize: int = 0
112 """Max. width and height of a map image in pixels, 2048 by default."""
114 def configure(self):
115 self.layerLimit = self.cfg('layerLimit') or 0
116 self.maxPixelSize = self.cfg('maxPixelSize') or _DEFAULT_MAX_PIXEL_SIZE
118 def configure_templates(self):
119 return gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES)
121 def configure_metadata(self):
122 super().configure_metadata()
123 self.metadata = gws.base.metadata.from_args(_DEFAULT_METADATA, self.metadata)
125 def configure_operations(self):
126 self.supportedOperations = [
127 gws.OwsOperation(
128 verb=gws.OwsVerb.GetCapabilities,
129 formats=self.available_formats(gws.OwsVerb.GetCapabilities),
130 handlerName='handle_get_capabilities',
131 ),
132 gws.OwsOperation(
133 verb=gws.OwsVerb.GetMap,
134 formats=self.available_formats(gws.OwsVerb.GetMap),
135 handlerName='handle_get_map',
136 ),
137 gws.OwsOperation(
138 verb=gws.OwsVerb.GetFeatureInfo,
139 formats=self.available_formats(gws.OwsVerb.GetFeatureInfo),
140 handlerName='handle_get_feature_info',
141 ),
142 gws.OwsOperation(
143 verb=gws.OwsVerb.GetLegendGraphic,
144 formats=self.available_formats(gws.OwsVerb.GetLegendGraphic),
145 handlerName='handle_get_legend_graphic',
146 ),
147 ]
149 ##
151 def init_request(self, req):
152 sr = super().init_request(req)
153 sr.require_project()
154 sr.crs = sr.requested_crs('CRS,SRS') or sr.project.map.bounds.crs
155 sr.targetCrs = sr.crs
156 sr.alwaysXY = sr.version < '1.3'
157 return sr
159 def layer_is_compatible(self, layer: gws.Layer):
160 return layer.isGroup or layer.canRenderBox
162 ##
164 def handle_get_capabilities(self, sr: server.request.Object):
165 """Handle the GetCapabilities operation.
167 Args:
168 sr: Service request.
170 Returns:
171 The capabilities document.
172 """
173 return self.template_response(
174 sr,
175 sr.requested_format('FORMAT'),
176 layerCapsList=sr.layerCapsList,
177 )
179 def handle_get_map(self, sr: server.request.Object):
180 """Handle the GetMap operation.
182 Layers that are not visible at the requested resolution are skipped;
183 if none remain, an empty image is returned. The background is
184 transparent unless ``TRANSPARENT=false`` is requested.
186 Args:
187 sr: Service request.
189 Returns:
190 The map image.
192 Raises:
193 ``server.error.LayerNotDefined``: If no layers are requested.
194 """
195 self.set_size_and_resolution(sr)
197 lcs = self.requested_layer_caps(sr, 'LAYER,LAYERS', bottom_first=True)
198 if not lcs:
199 raise server.error.LayerNotDefined()
201 mime_type = sr.requested_format('FORMAT')
203 lcs = self.visible_layer_caps(sr, lcs)
204 if not lcs:
205 return self.image_response(sr, None, mime_type)
207 s = sr.string_param('TRANSPARENT', values={'true', 'false'}, default='true')
208 transparent = s == 'true'
210 gws.log.debug(f'get_map: layers={[lc.layer for lc in lcs]}')
212 planes = [
213 gws.MapRenderInputPlane(
214 type=gws.MapRenderInputPlaneType.imageLayer,
215 layer=lc.layer,
216 )
217 for lc in lcs
218 ]
220 mri = gws.MapRenderInput(
221 backgroundColor=None if transparent else 0,
222 bbox=sr.bounds.extent,
223 targetCrs=sr.bounds.crs,
224 mapSize=(sr.pxSize[0], sr.pxSize[1], gws.Uom.px),
225 planes=planes,
226 project=self.project,
227 user=sr.req.user,
228 )
230 mro = gws.gis.render.render_map(mri)
232 return self.image_response(sr, mro.planes[0].image, mime_type)
234 def handle_get_legend_graphic(self, sr: server.request.Object):
235 """Handle the GetLegendGraphic operation.
237 Args:
238 sr: Service request.
240 Returns:
241 The legend image of the requested layers.
242 """
243 # @TODO currently only support 'layer'
244 lcs = self.requested_layer_caps(sr, 'LAYER,LAYERS', bottom_first=False)
245 return self.render_legend(sr, lcs, sr.requested_format('FORMAT'))
247 def handle_get_feature_info(self, sr: server.request.Object):
248 """Handle the GetFeatureInfo operation.
250 Args:
251 sr: Service request.
253 Returns:
254 The feature collection, rendered in the requested ``INFO_FORMAT``.
256 Raises:
257 ``server.error.LayerNotQueryable``: If none of the requested layers is searchable.
258 """
259 self.set_size_and_resolution(sr)
261 # @TODO top-first or bottom-first?
262 lcs = self.requested_layer_caps(sr, 'QUERY_LAYERS', bottom_first=False)
263 lcs = [lc for lc in lcs if lc.isSearchable]
264 if not lcs:
265 raise server.error.LayerNotQueryable()
267 fc = self.get_features(sr, lcs)
269 return self.template_response(
270 sr,
271 sr.requested_format('INFO_FORMAT'),
272 featureCollection=fc,
273 )
275 ##
277 def requested_layer_caps(self, sr: server.request.Object, param_name: str, bottom_first=False) -> list[server.LayerCaps]:
278 """Find the layer caps for the layer names in a request parameter.
280 A requested group is replaced by its leaf layers. Duplicates are removed.
282 Args:
283 sr: Service request.
284 param_name: Request parameter with the layer names, e.g. ``LAYERS``.
285 bottom_first: Return the layers bottom-first, as required for GetMap.
286 Otherwise, the layers are returned top-first.
288 Returns:
289 The layer caps.
291 Raises:
292 ``server.error.LayerNotDefined``: If a layer name is unknown or no layers are requested.
293 ``server.error.InvalidParameterValue``: If more layers than ``layerLimit`` are requested.
294 """
295 # Order for GetMap is bottom-first (OGC 06-042 7.3.3.3):
296 # A WMS shall render the requested layers by drawing the leftmost in the list bottommost, the next one over that, and so on.
297 #
298 # Our layers are always top-first. So, for each requested layer, if it is a leaf, we add it to a lcs, otherwise,
299 # add group leaves in _reversed_ order. Finally, reverse the lcs list.
301 lcs = []
303 def add(name):
304 for lc in sr.layerCapsList:
305 if server.layer_caps.layer_name_matches(lc, name):
306 if lc.isGroup:
307 lcs.extend(reversed(lc.leaves) if bottom_first else lc.leaves)
308 else:
309 lcs.append(lc)
310 return True
312 for name in sr.list_param(param_name):
313 if not add(name):
314 raise server.error.LayerNotDefined(name)
316 if self.layerLimit and len(lcs) > self.layerLimit:
317 raise server.error.InvalidParameterValue('LAYER')
318 if not lcs:
319 raise server.error.LayerNotDefined()
321 return gws.u.uniq(reversed(lcs) if bottom_first else lcs)
323 def get_features(self, sr: server.request.Object, lcs: list[server.LayerCaps]):
324 """Search for features at the point given by the ``I``/``J`` (or ``X``/``Y``) parameters.
326 Only layers visible at the requested resolution are searched. The search
327 uses the search tolerance of the service.
329 Args:
330 sr: Service request, with bounds and resolution already set.
331 lcs: Layer caps to search.
333 Returns:
334 The feature collection.
335 """
336 lcs = self.visible_layer_caps(sr, lcs)
337 if not lcs:
338 return self.feature_collection(sr, lcs, 0, [])
340 # OGC 06-042, 7.4.3.7
341 # - the point I=0, J=0 indicates the pixel at the upper left corner of the map;
342 # - I increases to the right and J increases downward.
343 # similar OGC 01-068r3, 7.3.3.8
345 # @TODO validate and raise InvalidPoint
347 ox = sr.int_param('X,I')
348 oy = sr.int_param('Y,J')
350 dx = ox * sr.resX
351 dy = oy * sr.resY
353 xy = sr.bounds.extent[0], sr.bounds.extent[3]
354 xy = sr.bounds.crs.point_offset_in_meters(xy, dx, az=90)
355 xy = sr.bounds.crs.point_offset_in_meters(xy, dy, az=180)
357 gws.log.debug(f'get_features: {ox=} {oy=} {dx=} {dy=} {xy=}')
359 point = gws.lib.shape.from_xy(xy[0], xy[1], sr.crs)
361 search = gws.SearchQuery(
362 project=sr.project,
363 layers=[lc.layer for lc in lcs],
364 limit=sr.requested_feature_count('FEATURE_COUNT'),
365 resolution=sr.resolution,
366 shape=point,
367 tolerance=self.searchTolerance,
368 )
370 results = self.root.app.searchMgr.run_search(search, sr.req.user)
371 return self.feature_collection(sr, lcs, len(results), results)
373 def set_size_and_resolution(self, sr: server.request.Object):
374 """Set the bounds, the pixel size and the resolution of the request.
376 The values are taken from the ``BBOX``, ``WIDTH`` and ``HEIGHT``
377 parameters. If ``DPI`` or ``MAP_RESOLUTION`` is given, the resolution
378 is converted from that DPI.
380 Args:
381 sr: Service request.
383 Raises:
384 ``server.error.MissingParameterValue``: If ``BBOX`` is missing.
385 ``server.error.InvalidParameterValue``: If the width or height is out of range.
386 """
387 b = sr.requested_bounds('BBOX')
388 if not b:
389 raise server.error.MissingParameterValue('BBOX')
390 sr.bounds = b
392 sr.pxSize = sr.int_param('WIDTH'), sr.int_param('HEIGHT')
393 if not (1 <= sr.pxSize[0] <= self.maxPixelSize):
394 raise server.error.InvalidParameterValue('WIDTH')
395 if not (1 <= sr.pxSize[1] <= self.maxPixelSize):
396 raise server.error.InvalidParameterValue('HEIGHT')
398 wh = sr.bounds.crs.extent_size_in_meters(sr.bounds.extent)
400 dpi = sr.int_param('DPI', default=0) or sr.int_param('MAP_RESOLUTION', default=0)
401 if dpi:
402 # honor the dpi setting - compute the scale with "their" dpi and convert to "our" resolution
403 sr.resX = gws.gis.zoom.scale_to_res(gws.lib.uom.mm_to_px(1000.0 * wh[0] / sr.pxSize[0], dpi), sr.bounds.crs)
404 sr.resY = gws.gis.zoom.scale_to_res(gws.lib.uom.mm_to_px(1000.0 * wh[1] / sr.pxSize[1], dpi), sr.bounds.crs)
405 else:
406 sr.resX = wh[0] / sr.pxSize[0]
407 sr.resY = wh[1] / sr.pxSize[1]
409 # @TODO: is this correct?
410 sr.resolution = sr.resX
412 gws.log.debug(
413 f'set_size_and_resolution: {wh=} px={sr.pxSize} {dpi=} resX={sr.resX} resY={sr.resY} 1:{gws.gis.zoom.res_to_scale(sr.resolution, sr.bounds.crs)}'
414 )
416 def visible_layer_caps(self, sr, lcs: list[server.LayerCaps]) -> list[server.LayerCaps]:
417 """Filter layer caps by the resolution of the request.
419 Args:
420 sr: Service request.
421 lcs: Layer caps.
423 Returns:
424 The layer caps whose layers are visible at the request resolution.
425 """
426 return [lc for lc in lcs if min(lc.layer.resolutions) <= sr.resolution <= max(lc.layer.resolutions)]