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 22:59 +0200

1"""WMS Service. 

2 

3Implements WMS 1.1.x and 1.3.0. 

4 

5Does not support SLD extensions except ``GetLegendGraphic``, for which only ``LAYERS`` is supported. 

6""" 

7 

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. 

12 

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. 

15 

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 

30 

31gws.ext.new.owsService('wms') 

32 

33_cdir = gws.u.dirname(__file__) 

34 

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] 

50 

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) 

61 

62_DEFAULT_MAX_PIXEL_SIZE = 2048 

63 

64 

65class Config(server.service.Config): 

66 """WMS Service configuration""" 

67 

68 layerLimit: int = 0 

69 """WMS LayerLimit.""" 

70 maxPixelSize: int = 0 

71 """WMS MaxWidth/MaxHeight value.""" 

72 

73 

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 

79 

80 layerLimit: int = 0 

81 maxPixelSize: int = 0 

82 

83 def configure(self): 

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

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

86 

87 def configure_templates(self): 

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

89 

90 def configure_metadata(self): 

91 super().configure_metadata() 

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

93 

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 ] 

117 

118 ## 

119 

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 

127 

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

129 return layer.isGroup or layer.canRenderBox 

130 

131 ## 

132 

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 ) 

139 

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

141 self.set_size_and_resolution(sr) 

142 

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

144 if not lcs: 

145 raise server.error.LayerNotDefined() 

146 

147 mime = sr.requested_format('FORMAT') 

148 

149 lcs = self.visible_layer_caps(sr, lcs) 

150 if not lcs: 

151 return self.image_response(sr, None, mime) 

152 

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

154 transparent = s == 'true' 

155 

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

157 

158 planes = [ 

159 gws.MapRenderInputPlane( 

160 type=gws.MapRenderInputPlaneType.imageLayer, 

161 layer=lc.layer, 

162 ) 

163 for lc in lcs 

164 ] 

165 

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 ) 

175 

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

177 

178 return self.image_response(sr, mro.planes[0].image, mime) 

179 

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

184 

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

186 self.set_size_and_resolution(sr) 

187 

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

193 

194 fc = self.get_features(sr, lcs) 

195 

196 return self.template_response( 

197 sr, 

198 sr.requested_format('INFO_FORMAT'), 

199 featureCollection=fc, 

200 ) 

201 

202 ## 

203 

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. 

210 

211 lcs = [] 

212 

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 

221 

222 for name in sr.list_param(param_name): 

223 if not add(name): 

224 raise server.error.LayerNotDefined(name) 

225 

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

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

228 if not lcs: 

229 raise server.error.LayerNotDefined() 

230 

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

232 

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

237 

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 

242 

243 # @TODO validate and raise InvalidPoint 

244 

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

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

247 

248 dx = ox * sr.resX 

249 dy = oy * sr.resY 

250 

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) 

254 

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

256 

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

258 

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 ) 

267 

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

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

270 

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 

276 

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

282 

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

284 

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] 

293 

294 # @TODO: is this correct? 

295 sr.resolution = sr.resX 

296 

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 ) 

300 

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