Coverage for gws-app/gws/plugin/ows_server/wms/__init__.py: 44%
133 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
1"""WMS Service.
3Implements WMS 1.1.x and 1.3.0.
5Does not support SLD extensions except ``GetLegendGraphic``, for which only ``LAYERS`` is supported.
6"""
8# @TODO strict mode
9#
10# OGC 06-042 7.2.4.7.2
11# A server shall issue a service exception (code="LayerNotQueryable") if GetFeatureInfo is requested on a Layer that is not queryable.
13# OGC 06-042 7.2.4.6.3
14# A server shall throw a service exception (code="LayerNotDefined") if an invalid layer is requested.
16import gws
17import gws.base.legend
18import gws.base.ows.server as server
19import gws.base.shape
20import gws.base.web
21import gws.config.util
22import gws.lib.bounds
23import gws.lib.extent
24import gws.lib.crs
25import gws.gis.render
26import gws.lib.image
27import gws.base.metadata
28import gws.lib.mime
29import gws.lib.uom
31gws.ext.new.owsService('wms')
33_cdir = gws.u.dirname(__file__)
35_DEFAULT_TEMPLATES = [
36 gws.Config(
37 type='py',
38 path=f'{_cdir}/templates/getCapabilities.cx.py',
39 subject='ows.GetCapabilities',
40 mimeTypes=[gws.lib.mime.XML],
41 ),
42 # NB use the wfs template with GML2 (qgis doesn't understand GML3 for WMS)
43 gws.Config(
44 type='py',
45 path=f'{_cdir}/../wfs/templates/getFeature2.cx.py',
46 subject='ows.GetFeatureInfo',
47 mimeTypes=[gws.lib.mime.GML2, gws.lib.mime.GML, gws.lib.mime.XML],
48 ),
49]
51_DEFAULT_METADATA = gws.Metadata(
52 name='WMS',
53 inspireDegreeOfConformity='notEvaluated',
54 inspireMandatoryKeyword='infoMapAccessService',
55 inspireResourceType='service',
56 inspireSpatialDataServiceType='view',
57 isoServiceFunction='search',
58 isoScope='dataset',
59 isoSpatialRepresentationType='vector',
60)
62_DEFAULT_MAX_PIXEL_SIZE = 2048
65class Config(server.service.Config):
66 """WMS Service configuration"""
68 layerLimit: int = 0
69 """WMS LayerLimit."""
70 maxPixelSize: int = 0
71 """WMS MaxWidth/MaxHeight value."""
74class Object(server.service.Object):
75 protocol = gws.OwsProtocol.WMS
76 supportedVersions = ['1.3.0', '1.1.1', '1.1.0']
77 isRasterService = True
78 isOwsCommon = False
80 layerLimit: int = 0
81 maxPixelSize: int = 0
83 def configure(self):
84 self.layerLimit = self.cfg('layerLimit') or 0
85 self.maxPixelSize = self.cfg('maxPixelSize') or _DEFAULT_MAX_PIXEL_SIZE
87 def configure_templates(self):
88 return gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES)
90 def configure_metadata(self):
91 super().configure_metadata()
92 self.metadata = gws.base.metadata.from_args(_DEFAULT_METADATA, self.metadata)
94 def configure_operations(self):
95 self.supportedOperations = [
96 gws.OwsOperation(
97 verb=gws.OwsVerb.GetCapabilities,
98 formats=self.available_formats(gws.OwsVerb.GetCapabilities),
99 handlerName='handle_get_capabilities',
100 ),
101 gws.OwsOperation(
102 verb=gws.OwsVerb.GetMap,
103 formats=self.available_formats(gws.OwsVerb.GetMap),
104 handlerName='handle_get_map',
105 ),
106 gws.OwsOperation(
107 verb=gws.OwsVerb.GetFeatureInfo,
108 formats=self.available_formats(gws.OwsVerb.GetFeatureInfo),
109 handlerName='handle_get_feature_info',
110 ),
111 gws.OwsOperation(
112 verb=gws.OwsVerb.GetLegendGraphic,
113 formats=self.available_formats(gws.OwsVerb.GetLegendGraphic),
114 handlerName='handle_get_legend_graphic',
115 ),
116 ]
118 ##
120 def init_request(self, req):
121 sr = super().init_request(req)
122 sr.require_project()
123 sr.crs = sr.requested_crs('CRS,SRS') or sr.project.map.bounds.crs
124 sr.targetCrs = sr.crs
125 sr.alwaysXY = sr.version < '1.3'
126 return sr
128 def layer_is_compatible(self, layer: gws.Layer):
129 return layer.isGroup or layer.canRenderBox
131 ##
133 def handle_get_capabilities(self, sr: server.request.Object):
134 return self.template_response(
135 sr,
136 sr.requested_format('FORMAT'),
137 layerCapsList=sr.layerCapsList,
138 )
140 def handle_get_map(self, sr: server.request.Object):
141 self.set_size_and_resolution(sr)
143 lcs = self.requested_layer_caps(sr, 'LAYER,LAYERS', bottom_first=True)
144 if not lcs:
145 raise server.error.LayerNotDefined()
147 mime = sr.requested_format('FORMAT')
149 lcs = self.visible_layer_caps(sr, lcs)
150 if not lcs:
151 return self.image_response(sr, None, mime)
153 s = sr.string_param('TRANSPARENT', values={'true', 'false'}, default='true')
154 transparent = s == 'true'
156 gws.log.debug(f'get_map: layers={[lc.layer for lc in lcs]}')
158 planes = [
159 gws.MapRenderInputPlane(
160 type=gws.MapRenderInputPlaneType.imageLayer,
161 layer=lc.layer,
162 )
163 for lc in lcs
164 ]
166 mri = gws.MapRenderInput(
167 backgroundColor=None if transparent else 0,
168 bbox=sr.bounds.extent,
169 crs=sr.bounds.crs,
170 mapSize=(sr.pxSize[0], sr.pxSize[1], gws.Uom.px),
171 planes=planes,
172 project=self.project,
173 user=sr.req.user,
174 )
176 mro = gws.gis.render.render_map(mri)
178 return self.image_response(sr, mro.planes[0].image, mime)
180 def handle_get_legend_graphic(self, sr: server.request.Object):
181 # @TODO currently only support 'layer'
182 lcs = self.requested_layer_caps(sr, 'LAYER,LAYERS', bottom_first=False)
183 return self.render_legend(sr, lcs, sr.requested_format('FORMAT'))
185 def handle_get_feature_info(self, sr: server.request.Object):
186 self.set_size_and_resolution(sr)
188 # @TODO top-first or bottom-first?
189 lcs = self.requested_layer_caps(sr, 'QUERY_LAYERS', bottom_first=False)
190 lcs = [lc for lc in lcs if lc.isSearchable]
191 if not lcs:
192 raise server.error.LayerNotQueryable()
194 fc = self.get_features(sr, lcs)
196 return self.template_response(
197 sr,
198 sr.requested_format('INFO_FORMAT'),
199 featureCollection=fc,
200 )
202 ##
204 def requested_layer_caps(self, sr: server.request.Object, param_name: str, bottom_first=False) -> list[server.LayerCaps]:
205 # Order for GetMap is bottom-first (OGC 06-042 7.3.3.3):
206 # A WMS shall render the requested layers by drawing the leftmost in the list bottommost, the next one over that, and so on.
207 #
208 # Our layers are always top-first. So, for each requested layer, if it is a leaf, we add it to a lcs, otherwise,
209 # add group leaves in _reversed_ order. Finally, reverse the lcs list.
211 lcs = []
213 def add(name):
214 for lc in sr.layerCapsList:
215 if server.layer_caps.layer_name_matches(lc, name):
216 if lc.isGroup:
217 lcs.extend(reversed(lc.leaves) if bottom_first else lc.leaves)
218 else:
219 lcs.append(lc)
220 return True
222 for name in sr.list_param(param_name):
223 if not add(name):
224 raise server.error.LayerNotDefined(name)
226 if self.layerLimit and len(lcs) > self.layerLimit:
227 raise server.error.InvalidParameterValue('LAYER')
228 if not lcs:
229 raise server.error.LayerNotDefined()
231 return gws.u.uniq(reversed(lcs) if bottom_first else lcs)
233 def get_features(self, sr: server.request.Object, lcs: list[server.LayerCaps]):
234 lcs = self.visible_layer_caps(sr, lcs)
235 if not lcs:
236 return self.feature_collection(sr, lcs, 0, [])
238 # OGC 06-042, 7.4.3.7
239 # - the point I=0, J=0 indicates the pixel at the upper left corner of the map;
240 # - I increases to the right and J increases downward.
241 # similar OGC 01-068r3, 7.3.3.8
243 # @TODO validate and raise InvalidPoint
245 ox = sr.int_param('X,I')
246 oy = sr.int_param('Y,J')
248 dx = ox * sr.resX
249 dy = oy * sr.resY
251 xy = sr.bounds.extent[0], sr.bounds.extent[3]
252 xy = sr.bounds.crs.point_offset_in_meters(xy, dx, az=90)
253 xy = sr.bounds.crs.point_offset_in_meters(xy, dy, az=180)
255 gws.log.debug(f'get_features: {ox=} {oy=} {dx=} {dy=} {xy=}')
257 point = gws.base.shape.from_xy(xy[0], xy[1], sr.crs)
259 search = gws.SearchQuery(
260 project=sr.project,
261 layers=[lc.layer for lc in lcs],
262 limit=sr.requested_feature_count('FEATURE_COUNT'),
263 resolution=sr.resolution,
264 shape=point,
265 tolerance=self.searchTolerance,
266 )
268 results = self.root.app.searchMgr.run_search(search, sr.req.user)
269 return self.feature_collection(sr, lcs, len(results), results)
271 def set_size_and_resolution(self, sr: server.request.Object):
272 b = sr.requested_bounds('BBOX')
273 if not b:
274 raise server.error.MissingParameterValue('BBOX')
275 sr.bounds = b
277 sr.pxSize = sr.int_param('WIDTH'), sr.int_param('HEIGHT')
278 if sr.pxSize[0] > self.maxPixelSize:
279 raise server.error.InvalidParameterValue('WIDTH')
280 if sr.pxSize[1] > self.maxPixelSize:
281 raise server.error.InvalidParameterValue('HEIGHT')
283 wh = sr.bounds.crs.extent_size_in_meters(sr.bounds.extent)
285 dpi = sr.int_param('DPI', default=0) or sr.int_param('MAP_RESOLUTION', default=0)
286 if dpi:
287 # honor the dpi setting - compute the scale with "their" dpi and convert to "our" resolution
288 sr.resX = gws.lib.uom.scale_to_res(gws.lib.uom.mm_to_px(1000.0 * wh[0] / sr.pxSize[0], dpi))
289 sr.resY = gws.lib.uom.scale_to_res(gws.lib.uom.mm_to_px(1000.0 * wh[1] / sr.pxSize[1], dpi))
290 else:
291 sr.resX = wh[0] / sr.pxSize[0]
292 sr.resY = wh[1] / sr.pxSize[1]
294 # @TODO: is this correct?
295 sr.resolution = sr.resX
297 gws.log.debug(
298 f'set_size_and_resolution: {wh=} px={sr.pxSize} {dpi=} resX={sr.resX} resY={sr.resY} 1:{gws.lib.uom.res_to_scale(sr.resolution)}'
299 )
301 def visible_layer_caps(self, sr, lcs: list[server.LayerCaps]) -> list[server.LayerCaps]:
302 return [lc for lc in lcs if min(lc.layer.resolutions) <= sr.resolution <= max(lc.layer.resolutions)]