Coverage for gws-app/gws/base/ows/server/request.py: 88%

210 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +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 MIME type, normalized. 

399 

400 Args: 

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

402 

403 Returns: 

404 The MIME type, the value as given if it is unknown, or an empty string if not requested. 

405 """ 

406 

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

408 if val: 

409 return gws.lib.mime.get(val) or val 

410 return '' 

411 

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

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

414 

415 Args: 

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

417 

418 Returns: 

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

420 

421 Raises: 

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

423 """ 

424 

425 s = self.int_param(param_names, default=0) 

426 if s <= 0: 

427 return self.service.defaultFeatureCount 

428 return min(self.service.maxFeatureCount, s) 

429 

430 def requested_xmlns_replacements(self): 

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

432 

433 Returns: 

434 A dict mapping namespace uris to prefixes. 

435 """ 

436 

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

438 if not s: 

439 return {} 

440 

441 # OGC 09-025r1, Table 7 

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

443 

444 d = {} 

445 

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

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

448 

449 return d 

450 

451 ## 

452 

453 def _get_param(self, param_names, default): 

454 """Return the first present parameter as ``(name, value)``; raise ``MissingParameterValue`` if absent and ``default`` is ``None``.""" 

455 names = gws.u.to_list(param_names.upper()) 

456 

457 for p in names: 

458 if p not in self.params: 

459 continue 

460 val = self.params[p] 

461 return p, val 

462 

463 if default is not None: 

464 return '', default 

465 

466 raise error.MissingParameterValue(names[0]) 

467 

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

469 """Return a string parameter. 

470 

471 Args: 

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

473 values: Allowed values, in lower case. If given, the value is lower-cased and checked. 

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

475 

476 Returns: 

477 The parameter value. 

478 

479 Raises: 

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

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

482 """ 

483 

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

485 if values: 

486 val = val.lower() 

487 if val not in values: 

488 raise error.InvalidParameterValue(p) 

489 return val 

490 

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

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

493 

494 Args: 

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

496 

497 Returns: 

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

499 """ 

500 

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

502 return gws.u.to_list(val) 

503 

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

505 """Return an integer parameter. 

506 

507 Args: 

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

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

510 

511 Returns: 

512 The parameter value. 

513 

514 Raises: 

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

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

517 """ 

518 

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

520 try: 

521 return int(val) 

522 except ValueError: 

523 raise error.InvalidParameterValue(p)