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

1"""WMS service. 

2 

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. 

6 

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. 

14 

15Templates: 

16 

17- ``templates/getCapabilities.cx.py``: ``ows.GetCapabilities``. 

18- ``../wfs/templates/getFeature2.cx.py``: ``ows.GetFeatureInfo``. 

19 

20Example:: 

21 

22 projects+ { 

23 owsServices+ { 

24 type "wms" 

25 uid "my_wms" 

26 supportedCrs [3857 25832] 

27 layerLimit 20 

28 maxPixelSize 4096 

29 } 

30 } 

31""" 

32 

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. 

37 

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. 

40 

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 

56 

57 

58_cdir = gws.u.dirname(__file__) 

59 

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] 

75 

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) 

86 

87_DEFAULT_MAX_PIXEL_SIZE = 2048 

88 

89 

90@gws.ext.config.owsService('wms') 

91class Config(server.service.Config): 

92 """WMS service that renders the project layers.""" 

93 

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.""" 

98 

99 

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.""" 

103 

104 protocol = gws.OwsProtocol.WMS 

105 supportedVersions = ['1.3.0', '1.1.1', '1.1.0'] 

106 isRasterService = True 

107 isOwsCommon = False 

108 

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.""" 

113 

114 def configure(self): 

115 self.layerLimit = self.cfg('layerLimit') or 0 

116 self.maxPixelSize = self.cfg('maxPixelSize') or _DEFAULT_MAX_PIXEL_SIZE 

117 

118 def configure_templates(self): 

119 return gws.config.util.configure_templates_for(self, extra=_DEFAULT_TEMPLATES) 

120 

121 def configure_metadata(self): 

122 super().configure_metadata() 

123 self.metadata = gws.base.metadata.from_args(_DEFAULT_METADATA, self.metadata) 

124 

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 ] 

148 

149 ## 

150 

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 

158 

159 def layer_is_compatible(self, layer: gws.Layer): 

160 return layer.isGroup or layer.canRenderBox 

161 

162 ## 

163 

164 def handle_get_capabilities(self, sr: server.request.Object): 

165 """Handle the GetCapabilities operation. 

166 

167 Args: 

168 sr: Service request. 

169 

170 Returns: 

171 The capabilities document. 

172 """ 

173 return self.template_response( 

174 sr, 

175 sr.requested_format('FORMAT'), 

176 layerCapsList=sr.layerCapsList, 

177 ) 

178 

179 def handle_get_map(self, sr: server.request.Object): 

180 """Handle the GetMap operation. 

181 

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. 

185 

186 Args: 

187 sr: Service request. 

188 

189 Returns: 

190 The map image. 

191 

192 Raises: 

193 ``server.error.LayerNotDefined``: If no layers are requested. 

194 """ 

195 self.set_size_and_resolution(sr) 

196 

197 lcs = self.requested_layer_caps(sr, 'LAYER,LAYERS', bottom_first=True) 

198 if not lcs: 

199 raise server.error.LayerNotDefined() 

200 

201 mime_type = sr.requested_format('FORMAT') 

202 

203 lcs = self.visible_layer_caps(sr, lcs) 

204 if not lcs: 

205 return self.image_response(sr, None, mime_type) 

206 

207 s = sr.string_param('TRANSPARENT', values={'true', 'false'}, default='true') 

208 transparent = s == 'true' 

209 

210 gws.log.debug(f'get_map: layers={[lc.layer for lc in lcs]}') 

211 

212 planes = [ 

213 gws.MapRenderInputPlane( 

214 type=gws.MapRenderInputPlaneType.imageLayer, 

215 layer=lc.layer, 

216 ) 

217 for lc in lcs 

218 ] 

219 

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 ) 

229 

230 mro = gws.gis.render.render_map(mri) 

231 

232 return self.image_response(sr, mro.planes[0].image, mime_type) 

233 

234 def handle_get_legend_graphic(self, sr: server.request.Object): 

235 """Handle the GetLegendGraphic operation. 

236 

237 Args: 

238 sr: Service request. 

239 

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')) 

246 

247 def handle_get_feature_info(self, sr: server.request.Object): 

248 """Handle the GetFeatureInfo operation. 

249 

250 Args: 

251 sr: Service request. 

252 

253 Returns: 

254 The feature collection, rendered in the requested ``INFO_FORMAT``. 

255 

256 Raises: 

257 ``server.error.LayerNotQueryable``: If none of the requested layers is searchable. 

258 """ 

259 self.set_size_and_resolution(sr) 

260 

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() 

266 

267 fc = self.get_features(sr, lcs) 

268 

269 return self.template_response( 

270 sr, 

271 sr.requested_format('INFO_FORMAT'), 

272 featureCollection=fc, 

273 ) 

274 

275 ## 

276 

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. 

279 

280 A requested group is replaced by its leaf layers. Duplicates are removed. 

281 

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. 

287 

288 Returns: 

289 The layer caps. 

290 

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. 

300 

301 lcs = [] 

302 

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 

311 

312 for name in sr.list_param(param_name): 

313 if not add(name): 

314 raise server.error.LayerNotDefined(name) 

315 

316 if self.layerLimit and len(lcs) > self.layerLimit: 

317 raise server.error.InvalidParameterValue('LAYER') 

318 if not lcs: 

319 raise server.error.LayerNotDefined() 

320 

321 return gws.u.uniq(reversed(lcs) if bottom_first else lcs) 

322 

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. 

325 

326 Only layers visible at the requested resolution are searched. The search 

327 uses the search tolerance of the service. 

328 

329 Args: 

330 sr: Service request, with bounds and resolution already set. 

331 lcs: Layer caps to search. 

332 

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, []) 

339 

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 

344 

345 # @TODO validate and raise InvalidPoint 

346 

347 ox = sr.int_param('X,I') 

348 oy = sr.int_param('Y,J') 

349 

350 dx = ox * sr.resX 

351 dy = oy * sr.resY 

352 

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) 

356 

357 gws.log.debug(f'get_features: {ox=} {oy=} {dx=} {dy=} {xy=}') 

358 

359 point = gws.lib.shape.from_xy(xy[0], xy[1], sr.crs) 

360 

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 ) 

369 

370 results = self.root.app.searchMgr.run_search(search, sr.req.user) 

371 return self.feature_collection(sr, lcs, len(results), results) 

372 

373 def set_size_and_resolution(self, sr: server.request.Object): 

374 """Set the bounds, the pixel size and the resolution of the request. 

375 

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. 

379 

380 Args: 

381 sr: Service request. 

382 

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 

391 

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') 

397 

398 wh = sr.bounds.crs.extent_size_in_meters(sr.bounds.extent) 

399 

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] 

408 

409 # @TODO: is this correct? 

410 sr.resolution = sr.resX 

411 

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 ) 

415 

416 def visible_layer_caps(self, sr, lcs: list[server.LayerCaps]) -> list[server.LayerCaps]: 

417 """Filter layer caps by the resolution of the request. 

418 

419 Args: 

420 sr: Service request. 

421 lcs: Layer caps. 

422 

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)]