Coverage for gws-app/gws/base/ows/server/service.py: 74%

184 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Base OWS service.""" 

2 

3from typing import Optional, cast 

4 

5import gws 

6import gws.base.web 

7import gws.config.util 

8import gws.lib.bounds 

9import gws.lib.crs 

10import gws.lib.datetimex 

11import gws.lib.extent 

12import gws.lib.image 

13import gws.base.metadata 

14import gws.lib.mime 

15import gws.lib.xmlx 

16from . import core, error, request 

17 

18 

19class Config(gws.ConfigWithAccess): 

20 """Configuration for OWS services.""" 

21 

22 defaultFeatureCount: int = 1000 

23 """Number of features per page when the request does not specify a count.""" 

24 extent: Optional[gws.Extent] 

25 """Service extent.""" 

26 extentCrs: Optional[gws.CrsName] 

27 """CRS of the service extent.""" 

28 imageFormats: Optional[list[gws.lib.image.format.Config]] 

29 """Image formats for map and tile requests.""" 

30 maxFeatureCount: int = 10000 

31 """Maximum number of features per page a request can ask for.""" 

32 metadata: Optional[gws.base.metadata.Config] 

33 """Service metadata, overriding the project and application metadata.""" 

34 rootLayerUid: str = '' 

35 """UID of the layer published as the service root.""" 

36 searchTolerance: gws.UomValueStr = '10px' 

37 """Search tolerance for feature info requests.""" 

38 supportedCrs: Optional[list[gws.CrsName]] 

39 """CRSs supported by the service.""" 

40 templates: Optional[list[gws.ext.config.template]] 

41 """Templates for service responses.""" 

42 updateSequence: Optional[str] 

43 """Value of updateSequence in capabilities, to signal changes to clients.""" 

44 withInspireMeta: bool = False 

45 """Include INSPIRE extended capabilities in WMS and WFS capabilities.""" 

46 withStrictParams: bool = False 

47 """Use strict parameter checking.""" 

48 

49 

50class Object(gws.OwsService): 

51 """Base class for OWS services. 

52 

53 Configures supported CRSs and bounds, image formats, templates and metadata, 

54 parses incoming requests and dispatches them to operation handlers. Provides 

55 subclasses with helpers to create template, XML, image and legend responses. 

56 Subclasses set ``protocol`` and ``supportedVersions``, implement 

57 ``configure_operations`` and ``layer_is_compatible`` and provide a handler 

58 method for each operation. 

59 """ 

60 

61 def configure(self): 

62 self.project = self.find_closest(gws.ext.object.project) 

63 

64 self.updateSequence = self.cfg('updateSequence') 

65 self.withInspireMeta = self.cfg('withInspireMeta') 

66 self.withStrictParams = self.cfg('withStrictParams') 

67 

68 self.maxFeatureCount = self.cfg('maxFeatureCount') 

69 self.defaultFeatureCount = self.cfg('defaultFeatureCount') 

70 self.searchTolerance = self.cfg('searchTolerance') 

71 

72 self.configure_bounds() 

73 self.configure_image_formats() 

74 self.configure_templates() 

75 self.configure_operations() 

76 self.configure_metadata() 

77 

78 def configure_image_formats(self): 

79 """Configure image formats from the configuration, or PNG and JPEG by default.""" 

80 p = self.cfg('imageFormats') 

81 if p: 

82 self.imageFormats = [] 

83 for cfg in p: 

84 self.imageFormats.append(gws.lib.image.format.from_config(cfg)) 

85 return 

86 

87 self.imageFormats = [ 

88 gws.ImageFormat(mimeTypes=[gws.lib.mime.PNG], options={}), 

89 gws.ImageFormat(mimeTypes=[gws.lib.mime.JPEG], options={}), 

90 ] 

91 

92 def configure_bounds(self): 

93 """Configure the supported bounds. 

94 

95 The CRS list is taken from the configuration, or the project map CRS, or 

96 Web Mercator and WGS84. The extent is taken from the configuration, or the 

97 project map, or the first CRS. ``supportedBounds`` holds the extent in each CRS. 

98 

99 Returns: 

100 Always ``True``. 

101 """ 

102 

103 p = self.cfg('supportedCrs') 

104 if p: 

105 crs_list = [gws.lib.crs.require(s) for s in p] 

106 elif self.project: 

107 crs_list = [self.project.map.bounds.crs] 

108 else: 

109 crs_list = [gws.lib.crs.WEBMERCATOR, gws.lib.crs.WGS84] 

110 

111 p = self.cfg('extent') 

112 if p: 

113 q = self.cfg('extentCrs') 

114 crs = gws.lib.crs.require(q) if q else crs_list[0] 

115 bounds = gws.Bounds(crs=crs, extent=gws.lib.extent.from_list(p)) 

116 elif self.project: 

117 bounds = self.project.map.bounds 

118 else: 

119 bounds = gws.Bounds(crs=crs_list[0], extent=crs_list[0].extent) 

120 

121 self.supportedBounds = [gws.lib.bounds.transform(bounds, crs) for crs in crs_list] 

122 return True 

123 

124 def configure_templates(self): 

125 """Configure the service templates. 

126 

127 Returns: 

128 ``True`` if any templates are configured. 

129 """ 

130 

131 return gws.config.util.configure_templates_for(self) 

132 

133 def configure_operations(self): 

134 """Configure ``supportedOperations``. Must be implemented by subclasses.""" 

135 pass 

136 

137 def available_mime_types(self, verb: gws.OwsVerb): 

138 """Return the MIME types available for an operation. 

139 

140 For image operations, these are the mime types of the image formats, 

141 otherwise the mime types of the templates for the operation. 

142 

143 Args: 

144 verb: Request type. 

145 

146 Returns: 

147 A sorted list of mime types. 

148 """ 

149 

150 fs = set() 

151 

152 if verb in core.IMAGE_VERBS: 

153 for fmt in self.imageFormats: 

154 fs.update(fmt.mimeTypes) 

155 else: 

156 for tpl in self.templates: 

157 if tpl.subject == f'ows.{verb}': 

158 fs.update(tpl.mimeTypes) 

159 

160 return sorted(fs) 

161 

162 def configure_metadata(self): 

163 """Configure the service metadata, merged from the project, the application and the configuration. 

164 

165 Returns: 

166 Always ``True``. 

167 """ 

168 

169 self.metadata = gws.base.metadata.from_args( 

170 self.project.metadata if self.project else self.root.app.metadata, 

171 self.cfg('metadata'), 

172 ) 

173 return True 

174 

175 def post_configure(self): 

176 self.post_configure_root_layer() 

177 self.post_configure_host() 

178 

179 def post_configure_host(self): 

180 """Warn if no canonical host or host names are configured for the site.""" 

181 

182 site = self.root.app.webMgr.site 

183 if not site.canonicalHost and not site.hostnames: 

184 self.root.config_warning('neither "web.site.canonicalHost" nor "web.site.hostnames" is set, service urls will reflect the request host') 

185 

186 def post_configure_root_layer(self): 

187 """Configure the root layer given by ``rootLayerUid``. 

188 

189 If the service has no project, the project of the root layer is used. 

190 

191 Raises: 

192 ``gws.ConfigurationError``: If the layer is not found or belongs to a different project. 

193 """ 

194 

195 self.rootLayer = None 

196 

197 uid = self.cfg('rootLayerUid') 

198 if not uid: 

199 return 

200 

201 self.rootLayer = cast(gws.Layer, self.root.get(uid, gws.ext.object.layer)) 

202 if not self.rootLayer: 

203 raise gws.ConfigurationError(f'root layer {uid!r} not found') 

204 

205 prj = cast(gws.Project, self.rootLayer.find_closest(gws.ext.object.project)) 

206 if prj and not self.project: 

207 self.project = prj 

208 return 

209 

210 if self.project != prj: 

211 raise gws.ConfigurationError(f'root layer {uid!r} does not belong to {self.project!r}') 

212 

213 ## 

214 

215 def url_path(self, sr: request.Object) -> str: 

216 """Return the URL path of the service. 

217 

218 The project uid is included if the request project is not the service project. 

219 

220 Args: 

221 sr: Service request. 

222 

223 Returns: 

224 The URL path. 

225 """ 

226 

227 if sr.project and sr.project != self.project: 

228 return gws.u.action_url_path('owsService', serviceUid=self.uid, projectUid=sr.project.uid) 

229 else: 

230 return gws.u.action_url_path('owsService', serviceUid=self.uid) 

231 

232 ## 

233 

234 def init_request(self, req: gws.WebRequester) -> request.Object: 

235 """Create a service request from a web request. 

236 

237 Supports GET requests and XML POST requests, optionally wrapped in a SOAP 

238 envelope. XML requests are converted to parameters by ``parse_xml_request``. 

239 

240 Args: 

241 req: Web request. 

242 

243 Returns: 

244 The service request. 

245 

246 Raises: 

247 ``gws.base.web.error.BadRequest``: If the request cannot be parsed. 

248 """ 

249 

250 if req.method == 'GET': 

251 return request.Object(self, req, req.params()) 

252 

253 if req.method == 'POST' and gws.lib.mime.get(req.contentType) == gws.lib.mime.XML: 

254 try: 

255 xml = gws.lib.xmlx.from_string(req.text(), gws.XmlOptions(removeNamespaces=True)) 

256 except gws.lib.xmlx.Error: 

257 raise gws.base.web.error.BadRequest() 

258 

259 is_soap = False 

260 if xml.name == 'Envelope': 

261 is_soap = True 

262 body = xml.find('Body') 

263 xml = body.findfirst() if body else None 

264 if xml is None: 

265 raise gws.base.web.error.BadRequest() 

266 

267 params = self.parse_xml_request(xml) 

268 if not params: 

269 raise gws.base.web.error.BadRequest() 

270 return request.Object(self, req, params, xml_element=xml, is_soap=is_soap) 

271 

272 # @TODO support application/x-www-form-urlencoded 

273 raise gws.base.web.error.BadRequest() 

274 

275 def parse_xml_request(self, xml: gws.XmlElement) -> Optional[dict]: 

276 """Convert an XML request to parameters. 

277 

278 The base implementation returns an empty dict; subclasses that support 

279 XML requests override this. 

280 

281 Args: 

282 xml: Request element. 

283 

284 Returns: 

285 Request parameters, empty if the request is not supported. 

286 """ 

287 return {} 

288 

289 def handle_request(self, req: gws.WebRequester) -> gws.ContentResponse: 

290 try: 

291 sr = self.init_request(req) 

292 return self.dispatch_request(sr) 

293 except Exception as exc: 

294 err = error.from_exception(exc) 

295 # @TODO INIMAGE Exceptions 

296 # verb = req.param('REQUEST') 

297 # if verb in core.IMAGE_VERBS: 

298 # return err.to_image_response() 

299 return err.to_xml_response('ows' if self.isOwsCommon else 'ogc') 

300 

301 def dispatch_request(self, sr: request.Object): 

302 """Call the handler method of the requested operation. 

303 

304 Args: 

305 sr: Service request. 

306 

307 Returns: 

308 The handler response. 

309 """ 

310 

311 fn = getattr(self, sr.operation.handlerName) 

312 return fn(sr) 

313 

314 def get_template(self, sr: request.Object, mime: gws.MimeType = '') -> Optional[gws.Template]: 

315 """Find a template for the given service request. 

316 

317 Args: 

318 sr: Service request. 

319 mime: Output mime type. 

320 

321 Returns: 

322 The template, or ``None`` if not found. 

323 """ 

324 

325 return self.root.app.templateMgr.find_template( 

326 f'ows.{sr.operation.verb}', 

327 where=[self, sr.project], 

328 user=sr.req.user, 

329 mime=mime, 

330 ) 

331 

332 def template_response(self, sr: request.Object, mime: gws.MimeType = '', **kwargs) -> gws.ContentResponse: 

333 """Render a template for the given service request. 

334 

335 If no template is found for the mime type, the XML template is used. 

336 

337 Args: 

338 sr: Service request. 

339 mime: Output mime type. 

340 **kwargs: Additional template arguments. 

341 

342 Returns: 

343 The rendered response. 

344 

345 Raises: 

346 ``error.InvalidFormat``: If there is no XML template either. 

347 """ 

348 

349 tpl = self.get_template(sr, mime=mime) 

350 if not tpl: 

351 # OGC 06-042, 7.2.3.1 

352 # If the request specifies a format not supported by the server, the server shall respond with the default text/xml format. 

353 gws.log.debug(f'no template: {sr.operation.verb=} {mime=}') 

354 if mime == gws.lib.mime.XML: 

355 raise error.InvalidFormat() 

356 return self.template_response(sr, gws.lib.mime.XML, **kwargs) 

357 

358 args = request.TemplateArgs( 

359 serviceRequest=sr, 

360 service=self, 

361 serviceUrl=sr.req.canonical_url_for(self.url_path(sr)), 

362 url_for=sr.req.canonical_url_for, 

363 version=sr.version, 

364 intVersion=int(sr.version.replace('.', '')), 

365 **kwargs, 

366 ) 

367 

368 return tpl.render(gws.TemplateRenderInput(args=args)) 

369 

370 def xml_response(self, el: gws.XmlElement, opts: gws.XmlOptions = None) -> gws.ContentResponse: 

371 """Create an XML response. 

372 

373 Args: 

374 el: Root element. 

375 opts: Serialization options. 

376 

377 Returns: 

378 The XML response. 

379 """ 

380 

381 xml = el.to_string(opts) 

382 return gws.ContentResponse(mimeType=gws.lib.mime.XML, content=xml) 

383 

384 def image_response(self, sr: request.Object, img: Optional[gws.Image], mime: gws.MimeType) -> gws.ContentResponse: 

385 """Create an image response, encoded with the options of the matching image format. 

386 

387 Args: 

388 sr: Service request. 

389 img: Image, or ``None`` for an empty pixel. 

390 mime: Output mime type. 

391 

392 Returns: 

393 The image response. 

394 

395 Raises: 

396 ``error.InvalidFormat``: If the mime type is not supported. 

397 """ 

398 

399 ifo = self.imageFormats[0] 

400 if mime: 

401 ifo = gws.lib.image.format.find_by_mime_type(mime, self.imageFormats) 

402 if not ifo: 

403 raise error.InvalidFormat() 

404 if img: 

405 gws.log.debug(f'image_response: {img.mode()=} {img.size()=} {mime=} {ifo.options}') 

406 return gws.ContentResponse(mimeType=mime, content=img.to_bytes(mime, ifo.options)) 

407 return gws.ContentResponse(mimeType=mime, content=gws.lib.image.util.empty_pixel(ifo)) 

408 

409 def render_legend(self, sr: request.Object, lcs: list[core.LayerCaps], mime: gws.MimeType) -> gws.ContentResponse: 

410 """Render a combined legend for the given layers. 

411 

412 The response is cached per layer set and mime type. 

413 

414 Args: 

415 sr: Service request. 

416 lcs: Layer caps. 

417 mime: Output mime type. 

418 

419 Returns: 

420 The image response. 

421 """ 

422 

423 uids = [lc.layer.uid for lc in lcs] 

424 cache_key = 'gws.base.ows.server.legend.' + gws.u.sha256(uids) + mime 

425 

426 def _get(): 

427 legend = cast( 

428 gws.Legend, 

429 self.root.create_temporary( 

430 gws.ext.object.legend, 

431 type='combined', 

432 layerUids=uids, 

433 ), 

434 ) 

435 lro = legend.render() 

436 return self.image_response(sr, lro.image if lro else None, mime) 

437 

438 return gws.u.get_app_global(cache_key, _get) 

439 

440 def feature_collection(self, sr: request.Object, lcs: list[core.LayerCaps], hits: int, results: list[gws.SearchResult]) -> core.FeatureCollection: 

441 """Create a feature collection from search results. 

442 

443 Features are transformed to the request ``targetCrs``. 

444 

445 Args: 

446 sr: Service request. 

447 lcs: Layer caps, used to link the members to their layers. 

448 hits: Total number of matching features. 

449 results: Search results. 

450 

451 Returns: 

452 The feature collection. 

453 """ 

454 

455 fc = core.FeatureCollection( 

456 members=[], 

457 numMatched=hits, 

458 numReturned=len(results), 

459 timestamp=gws.lib.datetimex.to_iso_string(with_tz=':'), 

460 ) 

461 

462 lcs_map = {id(lc.layer): lc for lc in lcs} 

463 

464 for r in results: 

465 r.feature.transform_to(sr.targetCrs) 

466 fc.members.append( 

467 core.FeatureCollectionMember( 

468 feature=r.feature, 

469 layer=r.layer, 

470 layerCaps=lcs_map.get(id(r.layer)) if r.layer else None, 

471 ) 

472 ) 

473 

474 return fc