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

187 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +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.FormatConfig]] 

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( 

85 gws.ImageFormat( 

86 mimeTypes=[s.replace(' ', '') for s in cfg.get('mimeTypes', [])], 

87 options=cfg.get('options') or {}, 

88 ) 

89 ) 

90 return 

91 

92 self.imageFormats = [ 

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

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

95 ] 

96 

97 def configure_bounds(self): 

98 """Configure the supported bounds. 

99 

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

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

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

103 

104 Returns: 

105 Always ``True``. 

106 """ 

107 

108 p = self.cfg('supportedCrs') 

109 if p: 

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

111 elif self.project: 

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

113 else: 

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

115 

116 p = self.cfg('extent') 

117 if p: 

118 q = self.cfg('extentCrs') 

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

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

121 elif self.project: 

122 bounds = self.project.map.bounds 

123 else: 

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

125 

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

127 return True 

128 

129 def configure_templates(self): 

130 """Configure the service templates. 

131 

132 Returns: 

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

134 """ 

135 

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

137 

138 def configure_operations(self): 

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

140 pass 

141 

142 def available_formats(self, verb: gws.OwsVerb): 

143 """Return the formats available for an operation. 

144 

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

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

147 

148 Args: 

149 verb: Request type. 

150 

151 Returns: 

152 A sorted list of mime types. 

153 """ 

154 

155 fs = set() 

156 

157 if verb in core.IMAGE_VERBS: 

158 for fmt in self.imageFormats: 

159 fs.update(fmt.mimeTypes) 

160 else: 

161 for tpl in self.templates: 

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

163 fs.update(tpl.mimeTypes) 

164 

165 return sorted(fs) 

166 

167 def configure_metadata(self): 

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

169 

170 Returns: 

171 Always ``True``. 

172 """ 

173 

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

175 self.project.metadata if self.project else None, 

176 self.root.app.metadata, 

177 self.cfg('metadata'), 

178 ) 

179 return True 

180 

181 def post_configure(self): 

182 self.post_configure_root_layer() 

183 self.post_configure_host() 

184 

185 def post_configure_host(self): 

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

187 

188 site = self.root.app.webMgr.site 

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

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

191 

192 def post_configure_root_layer(self): 

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

194 

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

196 

197 Raises: 

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

199 """ 

200 

201 self.rootLayer = None 

202 

203 uid = self.cfg('rootLayerUid') 

204 if not uid: 

205 return 

206 

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

208 if not self.rootLayer: 

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

210 

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

212 if prj and not self.project: 

213 self.project = prj 

214 return 

215 

216 if self.project != prj: 

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

218 

219 ## 

220 

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

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

223 

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

225 

226 Args: 

227 sr: Service request. 

228 

229 Returns: 

230 The URL path. 

231 """ 

232 

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

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

235 else: 

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

237 

238 ## 

239 

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

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

242 

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

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

245 

246 Args: 

247 req: Web request. 

248 

249 Returns: 

250 The service request. 

251 

252 Raises: 

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

254 """ 

255 

256 if req.method == 'GET': 

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

258 

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

260 try: 

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

262 except gws.lib.xmlx.Error: 

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

264 

265 is_soap = False 

266 if xml.name == 'Envelope': 

267 is_soap = True 

268 body = xml.find('Body') 

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

270 if xml is None: 

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

272 

273 params = self.parse_xml_request(xml) 

274 if not params: 

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

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

277 

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

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

280 

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

282 """Convert an XML request to parameters. 

283 

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

285 XML requests override this. 

286 

287 Args: 

288 xml: Request element. 

289 

290 Returns: 

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

292 """ 

293 return {} 

294 

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

296 try: 

297 sr = self.init_request(req) 

298 return self.dispatch_request(sr) 

299 except Exception as exc: 

300 err = error.from_exception(exc) 

301 # @TODO INIMAGE Exceptions 

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

303 # if verb in core.IMAGE_VERBS: 

304 # return err.to_image_response() 

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

306 

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

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

309 

310 Args: 

311 sr: Service request. 

312 

313 Returns: 

314 The handler response. 

315 """ 

316 

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

318 return fn(sr) 

319 

320 def get_template(self, sr: request.Object, mime_type: str = '') -> Optional[gws.Template]: 

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

322 

323 Args: 

324 sr: Service request. 

325 mime_type: Output mime type. 

326 

327 Returns: 

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

329 """ 

330 

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

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

333 where=[self, sr.project], 

334 user=sr.req.user, 

335 mime_type=mime_type, 

336 ) 

337 

338 def template_response(self, sr: request.Object, mime_type: str = '', **kwargs) -> gws.ContentResponse: 

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

340 

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

342 

343 Args: 

344 sr: Service request. 

345 mime_type: Output mime type. 

346 **kwargs: Additional template arguments. 

347 

348 Returns: 

349 The rendered response. 

350 

351 Raises: 

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

353 """ 

354 

355 tpl = self.get_template(sr, mime_type=mime_type) 

356 if not tpl: 

357 # OGC 06-042, 7.2.3.1 

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

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

360 if mime_type == gws.lib.mime.XML: 

361 raise error.InvalidFormat() 

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

363 

364 args = request.TemplateArgs( 

365 serviceRequest=sr, 

366 service=self, 

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

368 url_for=sr.req.canonical_url_for, 

369 version=sr.version, 

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

371 **kwargs, 

372 ) 

373 

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

375 

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

377 """Create an XML response. 

378 

379 Args: 

380 el: Root element. 

381 opts: Serialization options. 

382 

383 Returns: 

384 The XML response. 

385 """ 

386 

387 xml = el.to_string(opts) 

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

389 

390 def image_response(self, sr: request.Object, img: Optional[gws.Image], mime_type: str) -> gws.ContentResponse: 

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

392 

393 Args: 

394 sr: Service request. 

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

396 mime_type: Output mime type. 

397 

398 Returns: 

399 The image response. 

400 

401 Raises: 

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

403 """ 

404 

405 ifmt = self.find_image_format(mime_type) 

406 if img: 

407 gws.log.debug(f'image_response: {img.mode()=} {img.size()=} {mime_type=} {ifmt.options}') 

408 content = img.to_bytes(mime_type, ifmt.options) if img else gws.lib.image.empty_pixel(mime_type) 

409 return gws.ContentResponse(mimeType=mime_type, content=content) 

410 

411 def find_image_format(self, mime_type: str) -> gws.ImageFormat: 

412 """Find an image format by mime type. 

413 

414 Args: 

415 mime_type: Mime type. If empty, the first format is returned. 

416 

417 Returns: 

418 The image format. 

419 

420 Raises: 

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

422 """ 

423 

424 if not mime_type: 

425 return self.imageFormats[0] 

426 for f in self.imageFormats: 

427 if mime_type in f.mimeTypes: 

428 return f 

429 raise error.InvalidFormat() 

430 

431 def render_legend(self, sr: request.Object, lcs: list[core.LayerCaps], mime_type: str) -> gws.ContentResponse: 

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

433 

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

435 

436 Args: 

437 sr: Service request. 

438 lcs: Layer caps. 

439 mime_type: Output mime type. 

440 

441 Returns: 

442 The image response. 

443 """ 

444 

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

446 cache_key = 'gws.base.ows.server.legend.' + gws.u.sha256(uids) + mime_type 

447 

448 def _get(): 

449 legend = cast( 

450 gws.Legend, 

451 self.root.create_temporary( 

452 gws.ext.object.legend, 

453 type='combined', 

454 layerUids=uids, 

455 ), 

456 ) 

457 lro = legend.render() 

458 return self.image_response(sr, lro.image if lro else None, mime_type) 

459 

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

461 

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

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

464 

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

466 

467 Args: 

468 sr: Service request. 

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

470 hits: Total number of matching features. 

471 results: Search results. 

472 

473 Returns: 

474 The feature collection. 

475 """ 

476 

477 fc = core.FeatureCollection( 

478 members=[], 

479 numMatched=hits, 

480 numReturned=len(results), 

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

482 ) 

483 

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

485 

486 for r in results: 

487 r.feature.transform_to(sr.targetCrs) 

488 fc.members.append( 

489 core.FeatureCollectionMember( 

490 feature=r.feature, 

491 layer=r.layer, 

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

493 ) 

494 ) 

495 

496 return fc