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

1"""OWS service request.""" 

2 

3from typing import Optional, Callable, cast 

4import re 

5 

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 

19 

20from . import core, layer_caps, error 

21 

22 

23class TemplateArgs(gws.TemplateArgs): 

24 """Arguments for service templates.""" 

25 

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

54 

55 

56class Object: 

57 """OWS service request. 

58 

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

63 

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

100 

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. 

110 

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. 

117 

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 

130 

131 self.operation = self.requested_operation('REQUEST') 

132 self.version = self.requested_version('VERSION,ACCEPTVERSIONS') 

133 

134 self.alwaysXY = False 

135 self.pxSize = 0, 0 

136 self.resolution = 0 

137 self.resX = 0 

138 self.resY = 0 

139 

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

147 

148 self.customNamespacePrefixes = self.requested_xmlns_replacements() 

149 

150 def require_project(self): 

151 """Load the project and the layer caps, the project is required. 

152 

153 Raises: 

154 ``gws.NotFoundError``: If the project is not found. 

155 ``gws.ForbiddenError``: If the user cannot access the project. 

156 """ 

157 

158 return self.load_project(required=True) 

159 

160 def load_project(self, required=False): 

161 """Load the project and the layer caps. 

162 

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. 

166 

167 Args: 

168 required: Raise an error if there is no project. 

169 

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

174 

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) 

178 

179 p = self.req.param('projectUid') 

180 project = None 

181 

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) 

189 

190 if not project: 

191 if required: 

192 raise gws.NotFoundError(f'ows {self.service.uid}: project not found') 

193 return 

194 

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) 

198 

199 def enum_layer_caps(self): 

200 """Create the layer caps for the service root layer or the project root layer. 

201 

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. 

204 

205 Returns: 

206 A flat list of layer caps. 

207 """ 

208 

209 lcs = [] 

210 root_layer = self.service.rootLayer or self.project.map.rootLayer 

211 self._enum_layer_caps(root_layer, lcs, []) 

212 return lcs 

213 

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 

218 

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 

224 

225 is_compat = self.service.layer_is_compatible(layer) 

226 if not is_compat and not layer.isGroup: 

227 return 

228 

229 lc = layer_caps.for_layer(layer, self.req.user, self.service) 

230 

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) 

249 

250 if stack: 

251 stack[-1].children.append(lc) 

252 

253 ## 

254 

255 def requested_version(self, param_names: str) -> str: 

256 """Negotiate the service version. 

257 

258 Args: 

259 param_names: Comma-separated parameter names to look for. 

260 

261 Returns: 

262 The first supported version that starts with a requested version, 

263 or the first supported version if no version is requested. 

264 

265 Raises: 

266 ``error.VersionNegotiationFailed``: If none of the requested versions is supported. 

267 """ 

268 

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] 

273 

274 for v in gws.u.to_list(val): 

275 for ver in self.service.supportedVersions: 

276 if ver.startswith(v): 

277 return ver 

278 

279 raise error.VersionNegotiationFailed() 

280 

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 } 

304 

305 def requested_operation(self, param_names: str) -> gws.OwsOperation: 

306 """Return the requested operation. 

307 

308 Args: 

309 param_names: Comma-separated parameter names to look for. 

310 

311 Returns: 

312 The operation. 

313 

314 Raises: 

315 ``error.OperationNotSupported``: If the operation is not supported by the service. 

316 """ 

317 

318 _, val = self._get_param(param_names, '') 

319 op = self.find_operation(val) 

320 if op: 

321 return op 

322 raise error.OperationNotSupported(val) 

323 

324 def find_operation(self, param: str) -> Optional[gws.OwsOperation]: 

325 """Find a supported operation by its name. 

326 

327 Args: 

328 param: Operation name, case-insensitive. 

329 

330 Returns: 

331 The operation, or ``None`` if the service does not support it. 

332 """ 

333 

334 verb = self._param2verb.get(param.lower()) 

335 if not verb: 

336 return 

337 

338 for op in self.service.supportedOperations: 

339 if op.verb == verb: 

340 return op 

341 

342 def requested_crs(self, param_names: str) -> Optional[gws.Crs]: 

343 """Return the requested CRS. 

344 

345 Args: 

346 param_names: Comma-separated parameter names to look for. 

347 

348 Returns: 

349 The CRS, or ``None`` if not requested. 

350 

351 Raises: 

352 ``error.InvalidCRS``: If the CRS is unknown or not supported by the service. 

353 """ 

354 

355 _, val = self._get_param(param_names, '') 

356 if not val: 

357 return 

358 

359 crs = gws.lib.crs.get(val) 

360 if not crs: 

361 raise error.InvalidCRS() 

362 

363 for b in self.service.supportedBounds: 

364 if crs == b.crs: 

365 return crs 

366 

367 raise error.InvalidCRS() 

368 

369 def requested_bounds(self, param_names: str) -> Optional[gws.Bounds]: 

370 """Return the requested bounding box, transformed to ``crs``. 

371 

372 Uses ``crs`` as the default CRS and ``alwaysXY`` for the axis order. 

373 

374 Args: 

375 param_names: Comma-separated parameter names to look for. 

376 

377 Returns: 

378 The bounds, or ``None`` if not requested. 

379 

380 Raises: 

381 ``error.InvalidParameterValue``: If the bounding box is invalid. 

382 """ 

383 

384 # OGC 06-042, 7.2.3.5 

385 # OGC 00-028, 6.2.8.2.3 

386 

387 p, val = self._get_param(param_names, '') 

388 if not val: 

389 return 

390 

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) 

394 

395 raise error.InvalidParameterValue(p) 

396 

397 def requested_format(self, param_names: str) -> str: 

398 """Return the requested format with whitespace removed. 

399 

400 Args: 

401 param_names: Comma-separated parameter names to look for. 

402 

403 Returns: 

404 The format, or an empty string if not requested. 

405 """ 

406 

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

412 

413 def requested_feature_count(self, param_names: str) -> int: 

414 """Return the requested feature count, limited by the service maximum. 

415 

416 Args: 

417 param_names: Comma-separated parameter names to look for. 

418 

419 Returns: 

420 The feature count, or the service default if not requested or not positive. 

421 

422 Raises: 

423 ``error.InvalidParameterValue``: If the value is not an integer. 

424 """ 

425 

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) 

430 

431 def requested_xmlns_replacements(self): 

432 """Read custom namespace prefixes from the ``NAMESPACES`` parameter. 

433 

434 Returns: 

435 A dict mapping namespace uris to prefixes. 

436 """ 

437 

438 s = self.string_param('NAMESPACES', default='') 

439 if not s: 

440 return {} 

441 

442 # OGC 09-025r1, Table 7 

443 # xmlns(xml,http://www.w3.org/XML/1998/namespace),xmlns(ns37,https://our-ns),xmlns(wfs ... etc 

444 

445 d = {} 

446 

447 for xmlns, uri in re.findall(r'xmlns\((.+?),(.+?)\)', s): 

448 d[uri.strip()] = xmlns.strip() 

449 

450 return d 

451 

452 ## 

453 

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

457 

458 for p in names: 

459 if p not in self.params: 

460 continue 

461 val = self.params[p] 

462 return p, val 

463 

464 if default is not None: 

465 return '', default 

466 

467 raise error.MissingParameterValue(names[0]) 

468 

469 def string_param(self, param_names: str, values: Optional[set[str]] = None, default: Optional[str] = None) -> str: 

470 """Return a string parameter. 

471 

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. 

476 

477 Returns: 

478 The parameter value. 

479 

480 Raises: 

481 ``error.MissingParameterValue``: If the parameter is required and missing. 

482 ``error.InvalidParameterValue``: If the value is not allowed. 

483 """ 

484 

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 

491 

492 def list_param(self, param_names: str) -> list[str]: 

493 """Return a comma-separated parameter as a list. 

494 

495 Args: 

496 param_names: Comma-separated parameter names to look for. 

497 

498 Returns: 

499 The list of values, empty if the parameter is missing. 

500 """ 

501 

502 _, val = self._get_param(param_names, '') 

503 return gws.u.to_list(val) 

504 

505 def int_param(self, param_names: str, default: Optional[int] = None) -> int: 

506 """Return an integer parameter. 

507 

508 Args: 

509 param_names: Comma-separated parameter names to look for. 

510 default: Default value. If ``None``, the parameter is required. 

511 

512 Returns: 

513 The parameter value. 

514 

515 Raises: 

516 ``error.MissingParameterValue``: If the parameter is required and missing. 

517 ``error.InvalidParameterValue``: If the value is not an integer. 

518 """ 

519 

520 p, val = self._get_param(param_names, default) 

521 try: 

522 return int(val) 

523 except ValueError: 

524 raise error.InvalidParameterValue(p)