Coverage for ramose/html_documentation.py: 98%

126 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-24 21:59 +0000

1# SPDX-FileCopyrightText: 2018-2021 Silvio Peroni <silvio.peroni@unibo.it> 

2# SPDX-FileCopyrightText: 2020-2021 Marilena Daquino <marilena.daquino2@unibo.it> 

3# SPDX-FileCopyrightText: 2022 Davide Brembilla 

4# SPDX-FileCopyrightText: 2024 Ivan Heibi <ivan.heibi2@unibo.it> 

5# SPDX-FileCopyrightText: 2025 Sergei Slinkin 

6# SPDX-FileCopyrightText: 2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

7# 

8# SPDX-License-Identifier: ISC 

9 

10from __future__ import annotations 

11 

12import logging 

13from pathlib import Path 

14from re import findall, split, sub 

15from typing import TYPE_CHECKING 

16 

17from markdown import markdown 

18 

19from ramose._constants import FIELD_TYPE_RE, PARAM_NAME 

20from ramose.documentation import DocumentationHandler 

21from ramose.hash_format import parse_custom_params, parse_disable_params 

22 

23if TYPE_CHECKING: 

24 from ramose.api_manager import APIConfig 

25 

26 

27class HTMLDocumentationHandler(DocumentationHandler): 

28 # HTML documentation: START 

29 def __title(self, conf: APIConfig) -> str: 

30 """This method returns the title string defined in the API specification.""" 

31 return conf["conf_json"][0]["title"] 

32 

33 def __htmlmetadescription(self, conf: APIConfig) -> str: 

34 """This method returns the HTML meta-description tag defined in the API specification.""" 

35 desc = conf["conf_json"][0].get("html_meta_description") 

36 if desc: 

37 return f'<meta name="description" content="{desc}"/>' 

38 return "" # pragma: no cover 

39 

40 def __sidebar(self, conf: APIConfig) -> str: 

41 """This method builds the sidebar of the API documentation""" 

42 i = conf["conf_json"][0] 

43 ops_html = "".join( 

44 f"<li><a class='btn' href='#{op['url']}'>{op['url']}</a></li>" for op in conf["conf_json"][1:] 

45 ) 

46 params_html = ( 

47 '<li><a class="btn" href="#parameters">PARAMETERS</a></li>' if self.__builtin_param_docs(conf) else "" 

48 ) 

49 return f""" 

50 

51 <h4>{i["title"]}</h4> 

52 <ul id="sidebar_menu" class="sidebar_menu"> 

53 <li><a class="btn active" href="#description">DESCRIPTION</a></li> 

54 {params_html} 

55 <li><a class="btn" href="#operations">OPERATIONS</a> 

56 <ul class="sidebar_submenu">{ops_html}</ul> 

57 </li> 

58 <li><a class="btn active" href="/">HOME</a></li> 

59 </ul> 

60 """ 

61 

62 def __header(self, conf: APIConfig) -> str: 

63 """This method builds the header of the API documentation""" 

64 i = conf["conf_json"][0] 

65 api_url = i["base"] + i["url"] 

66 result = f""" 

67<a id='toc'></a> 

68# {i["title"]} 

69 

70**Version:** {i["version"]} <br/> 

71**API URL:** <a href="{i["url"]}">{api_url}</a><br/> 

72**Contact:** {i["contacts"]}<br/> 

73**License:** {i["license"]}<br/> 

74 

75 

76 

77## <a id="description"></a>Description [back to top](#toc) 

78 

79{i["description"]} 

80 

81{self.__parameters(conf)}""" 

82 return markdown(result) 

83 

84 _BUILTIN_PARAM_DOCS: tuple[tuple[str, str], ...] = ( 

85 ( 

86 "require", 

87 ( 

88 "`require=<field_name>`: all the rows that have an empty value in the `<field_name>` specified are " 

89 "removed from the result set - e.g. `require=given_name` removes all the rows that do not have any " 

90 "string specified in the `given_name` field." 

91 ), 

92 ), 

93 ( 

94 "filter", 

95 ( 

96 "`filter=<field_name>:<operator><value>`: only the rows compliant with `<value>` are kept in the " 

97 "result set. The parameter `<operation>` is not mandatory. If `<operation>` is not specified, " 

98 "`<value>` is interpreted as a regular expression, otherwise it is compared by means of the specified " 

99 'operation. Possible operators are "=", "<", and ">". For instance, `filter=title:semantics?` returns ' 

100 'all the rows that contain the string "semantic" or "semantics" in the field `title`, while ' 

101 "`filter=date:>2016-05` returns all the rows that have a `date` greater than May 2016." 

102 ), 

103 ), 

104 ( 

105 "sort", 

106 ( 

107 '`sort=<order>(<field_name>)`: sort in ascending (`<order>` set to "asc") or descending (`<order>` ' 

108 'set to "desc") order the rows in the result set according to the values in `<field_name>`. For ' 

109 "instance, `sort=desc(date)` sorts all the rows according to the value specified in the field `date` " 

110 "in descending order." 

111 ), 

112 ), 

113 ( 

114 "format", 

115 ( 

116 "`format=<format_type>`: the final table is returned in the format specified in `<format_type>` that " 

117 'can be either "csv" or "json" - e.g. `format=csv` returns the final table in CSV format. This ' 

118 'parameter has higher priority of the type specified through the "Accept" header of the request. Thus, ' 

119 "if the header of a request to the API specifies `Accept: text/csv` and the URL of such request " 

120 "includes `format=json`, the final table is returned in JSON." 

121 ), 

122 ), 

123 ( 

124 "json", 

125 ( 

126 '`json=<operation_type>("<separator>",<field>,<new_field_1>,<new_field_2>,...)`: in case a JSON format ' 

127 "is requested in return, tranform each row of the final JSON table according to the rule specified. " 

128 'If `<operation_type>` is set to "array", the string value associated to the field name `<field>` is ' 

129 "converted into an array by splitting the various textual parts by means of `<separator>`. For " 

130 'instance, considering the JSON table `[ { "names": "Doe, John; Doe, Jane" }, ... ]`, the execution ' 

131 'of `array("; ",names)` returns `[ { "names": [ "Doe, John", "Doe, Jane" ], ... ]`. Instead, if ' 

132 '`<operation_type>` is set to "dict", the string value associated to the field name `<field>` is ' 

133 "converted into a dictionary by splitting the various textual parts by means of `<separator>` and by " 

134 "associating the new fields `<new_field_1>`, `<new_field_2>`, etc., to these new parts. For instance, " 

135 'considering the JSON table `[ { "name": "Doe, John" }, ... ]`, the execution of ' 

136 '`dict(", ",name,fname,gname)` returns `[ { "name": { "fname": "Doe", "gname": "John" }, ... ]`.' 

137 ), 

138 ), 

139 ) 

140 

141 def __builtin_param_docs(self, conf: APIConfig) -> list[str]: 

142 overridden: set[str] = set() 

143 api_meta = conf["conf_json"][0] 

144 if "disable_params" in api_meta: 

145 overridden.update(parse_disable_params(api_meta["disable_params"])) 

146 for op in conf["conf_json"][1:]: 

147 if "custom_params" in op: 

148 overridden.update(parse_custom_params(op["custom_params"])) 

149 if "disable_params" in op: 

150 overridden.update(parse_disable_params(op["disable_params"])) 

151 

152 return [text for param_name, text in self._BUILTIN_PARAM_DOCS if param_name not in overridden] 

153 

154 def __parameters(self, conf: APIConfig) -> str: 

155 builtin_params = self.__builtin_param_docs(conf) 

156 if not builtin_params: 

157 return "" 

158 

159 items = "\n\n".join(f"{idx}. {text}" for idx, text in enumerate(builtin_params, 1)) 

160 params_intro = ( 

161 "Parameters can be used to filter and control the results returned by the API." 

162 " They are passed as normal HTTP parameters in the URL of the call. They are:" 

163 ) 

164 filtering_note = ( 

165 "It is possible to specify one or more filtering operation of the same kind" 

166 " (e.g. `require=given_name&require=family_name`). In addition, these filtering" 

167 " operations are applied in the order presented above - first all the `require`" 

168 " operation, then all the `filter` operations followed by all the `sort`" 

169 " operation, and finally the `format` and the `json` operation (if applicable)." 

170 " It is worth mentioning that each of the aforementioned rules is applied in" 

171 " order, and it works on the structure returned after the execution of the" 

172 " previous rule." 

173 ) 

174 result = f"""## <a id="parameters"></a>Parameters [back to top](#toc) 

175 

176{params_intro} 

177 

178{items} 

179 

180{filtering_note} 

181 

182Example: `<api_operation_url>?require=doi&filter=date:>2015&sort=desc(date)`.""" 

183 return markdown(result) 

184 

185 def __operations(self, conf: APIConfig) -> str: 

186 """This method returns the description of all the operations defined in the API.""" 

187 result = """## Operations [back to top](#toc) 

188The operations that this API implements are: 

189""" 

190 ops = "\n" 

191 

192 for op in conf["conf_json"][1:]: 

193 params = [] 

194 for p in findall(PARAM_NAME, op["url"]): 

195 p_type = "str" 

196 p_shape = ".+" 

197 if p in op: 

198 p_type, p_shape = findall(r"^\s*([^\(]+)\((.+)\)\s*$", op[p])[0] 

199 

200 params.append( 

201 f"<em>{p}</em>: type <code>{p_type}</code>, regular expression shape <code>{p_shape}</code>", 

202 ) 

203 

204 if "custom_params" in op: 

205 for param_name, param_conf in parse_custom_params(op["custom_params"]).items(): 

206 params.append( 

207 f"<em>{param_name}</em> (query, optional): {markdown(param_conf['description'])}", 

208 ) 

209 

210 op_url = op["url"] 

211 methods = ", ".join(split(r"\s+", op["method"].strip())) 

212 params_html = "<ul><li>" + "</li>\n<li>".join(params) + "</li></ul>" if params else "" 

213 has_custom_default = "default_format" in op and op["default_format"].strip().lower() not in ("csv", "json") 

214 fields_html = ( 

215 ", ".join(f"{f} <em>({t})</em>" for t, f in findall(FIELD_TYPE_RE, op["field_type"])) 

216 if "field_type" in op and not has_custom_default 

217 else "" 

218 ) 

219 example_url = conf["base_url"] + op["call"] 

220 

221 fields_type_html = ( 

222 '<p class="attr"><strong>Result fields type</strong>' 

223 '<span class="attr_val">' + fields_html + "</span></p>" 

224 if fields_html 

225 else "" 

226 ) 

227 exemplar_html = ( 

228 '<div class="json-output"><header class="json-output-header">' 

229 '<span>Exemplar output (in JSON)</span><button class="json-expand" type="button" ' 

230 'aria-label="Expand JSON" aria-haspopup="dialog" aria-controls="json-dialog">' 

231 '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' 

232 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' 

233 '<path d="M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5"/>' 

234 "</svg><span>Expand</span></button></header><pre><code>" + op["output_json"] + "</code></pre></div>" 

235 if "output_json" in op 

236 else "" 

237 ) 

238 example_link = f'<a target="_blank" href="{example_url}">{op["call"]}</a>' 

239 

240 result += f"\n* [{op_url}](#{op_url}): {op['description'].split(chr(10))[0]}" 

241 ops += f"""<div id="{op_url}"> 

242<h3>{op_url} <a href="#operations">back to operations</a></h3> 

243 

244{markdown(op["description"])} 

245 

246<p class="attr"><strong>Accepted HTTP method(s)</strong> <span class="attr_val method">{methods}</span></p> 

247<div class="attr params"><strong>Parameter(s)</strong>{params_html}</div> 

248{fields_type_html} 

249<p class="attr"><strong>Example</strong><span class="attr_val">{example_link}</span></p> 

250{exemplar_html}</div>""" 

251 return markdown(result) + ops 

252 

253 def __footer(self) -> str: 

254 """This method returns the footer of the API documentation.""" 

255 result = ( 

256 "This API and the related documentation has been created with" 

257 ' <a href="https://github.com/opencitations/ramose"' 

258 ' target="_blank">RAMOSE</a>, the *Restful API Manager Over' 

259 " SPARQL Endpoints*, developed by" 

260 ' <a href="https://orcid.org/0000-0003-0530-4305"' 

261 ' target="_blank">Silvio Peroni</a>,' 

262 ' <a href="https://orcid.org/0000-0002-1113-7550"' 

263 ' target="_blank">Marilena Daquino</a> and' 

264 ' <a href="https://orcid.org/0000-0002-8420-0696"' 

265 ' target="_blank">Arcangelo Massari</a>.' 

266 ) 

267 return markdown(result) 

268 

269 def __css(self) -> str: 

270 return ( 

271 """ 

272 @import url('https://fonts.googleapis.com/css2?family=Karla:wght@300;400&display=swap'); 

273 .menu-toggle { display: none; } 

274 

275 @media screen and (max-width: 850px) { 

276 aside { display: none; } 

277 .menu-toggle { 

278 display: flex; 

279 align-items: center; 

280 gap: 8px; 

281 position: sticky; 

282 top: 12px; 

283 z-index: 2; 

284 margin-left: auto; 

285 width: fit-content; 

286 min-height: 44px; 

287 padding: 10px 16px; 

288 border: 1px solid #e4d6ef; 

289 border-radius: 14px; 

290 background: white; 

291 color: #6524a4; 

292 box-shadow: 0 4px 16px rgba(65, 30, 90, 0.14); 

293 font-size: 1rem; 

294 cursor: pointer; 

295 } 

296 .menu-toggle:hover {background: #f5effb;} 

297 .menu-toggle:focus-visible {outline: 2px solid #6524a4; outline-offset: 3px;} 

298 .menu-toggle svg {width: 18px; height: 18px;} 

299 .menu-close-icon {display: none;} 

300 .menu-toggle[aria-expanded="true"] .menu-open-icon {display: none;} 

301 .menu-toggle[aria-expanded="true"] .menu-close-icon {display: block;} 

302 #documentation-navigation.is-open { 

303 display: block; 

304 top: 72px; 

305 bottom: 16px; 

306 left: auto; 

307 right: 16px; 

308 width: min(360px, calc(100% - 32px)); 

309 height: auto; 

310 overflow-y: auto; 

311 overscroll-behavior: contain; 

312 border-radius: 18px; 

313 box-shadow: 0 12px 40px rgba(65, 30, 90, 0.22); 

314 } 

315 #documentation-navigation h4 { 

316 padding: 24px 20px 20px; 

317 color: #6524a4; 

318 } 

319 #documentation-navigation .sidebar_menu {margin: 0; padding: 0 12px 12px !important;} 

320 #documentation-navigation .sidebar_menu > li {border-color: #eee6f4;} 

321 #documentation-navigation .sidebar_submenu {margin: 4px 0 8px;} 

322 #documentation-navigation .sidebar_submenu > li {background: transparent;} 

323 #documentation-navigation a {padding: 12px 8px; border-radius: 8px;} 

324 #documentation-navigation a:hover, 

325 #documentation-navigation a:focus-visible { 

326 background-color: #f5effb; 

327 color: #6524a4; 

328 box-shadow: none; 

329 } 

330 #documentation-navigation .sidebar_submenu a {font-family: monospace; font-size: 0.875rem;} 

331 html {scroll-padding-top: 80px;} 

332 body {margin-right: 16px !important;} 

333 main, #operations, .dashboard, body>footer {margin-left: 16px !important;} 

334 #operations > ul:nth-of-type(1) { 

335 grid-template-columns: minmax(0, 1fr) !important; 

336 } 

337 h3 a[href] {display:block !important; float: none !important; font-size: 1rem !important;} 

338 a {overflow: hidden; text-overflow: ellipsis;} 

339 .info_api, .api_calls {display: block !important; max-width: 100% !important;} 

340 } 

341 

342 * { 

343 font-family: 'Karla', Geneva, sans-serif; 

344 } 

345 

346 body { 

347 margin: 3% 15% 7% 0px; 

348 line-height: 1.5em; 

349 letter-spacing: 0.02em; 

350 font-size : 1em; 

351 font-weight:300; 

352 color: #303030; 

353 text-align: justify; 

354 background-color: #edf0f2; 

355 } 

356 

357 aside { 

358 height : 100%; 

359 width: 20%; 

360 position: fixed; 

361 z-index: 1; 

362 top: 0; 

363 left: 0; 

364 /*background-color: #404040;*/ 

365 overflow-x: hidden; 

366 background-color: white; 

367 box-shadow:0px 10px 30px 0px rgba(133,66,189,0.1); 

368 } 

369 p strong { 

370 text-transform: uppercase; 

371 font-size: 1rem; 

372 } 

373 aside h4 { 

374 padding: 20px 9%; 

375 margin: 0px !important; 

376 color: #9931FC; 

377 text-align: left !important; 

378 } 

379 

380 .sidebar_menu , .sidebar_submenu { 

381 list-style-type: none; 

382 padding-left:0px !important; 

383 margin-top: 10px; 

384 

385 } 

386 

387 .sidebar_menu > li { 

388 padding: 2% 0px; 

389 border-top : solid 0.7px #595959; 

390 } 

391 

392 .sidebar_menu a { 

393 padding: 1% 9%; 

394 background-image: none !important; 

395 color: #595959; 

396 display: block; 

397 overflow-wrap: break-word; 

398 word-break: break-word; 

399 } 

400 

401 .sidebar_menu a:hover { 

402 box-shadow: inset 5px 0 0 rgba(154, 49, 252,.5); 

403 color: #9931FC; 

404 } 

405 

406 .sidebar_submenu > li { 

407 padding-left:0px !important; 

408 background-color:#edf0f2; 

409 font-size: 1rem; 

410 } 

411 

412 main , #operations , .dashboard, body>footer { 

413 margin-left: 33%; 

414 } 

415 .dashboard {text-align: center;} 

416 main h1+p , .info_api{ 

417 

418 padding-left: 3%; 

419 font-size: 1rem; 

420 line-height: 1.4em; 

421 } 

422 

423 main h1+p {border-left: solid 5px rgba(154, 49, 252,.5);} 

424 

425 #operations h3 { 

426 color: #9931FC; 

427 margin-bottom: 0px; 

428 padding: 10px; 

429 } 

430 

431 #operations > ul:nth-of-type(1) { 

432 padding-left: 0px !important; 

433 display: grid; 

434 grid-template-columns: repeat(2, minmax(0, 1fr)); 

435 gap: 16px; 

436 list-style-type: none; 

437 } 

438 

439 #operations > ul:nth-of-type(1) li { 

440 background-color: white; 

441 text-align: left; 

442 overflow-wrap: break-word; 

443 word-break: break-word; 

444 padding: 16px; 

445 margin: 0; 

446 border-radius: 10px; 

447 box-shadow: 0px 10px 30px 0px rgba(133,66,189,0.1); 

448 } 

449 

450 #operations > div { 

451 background-color: white; 

452 margin-top: 20px; 

453 padding: 16px; 

454 border-radius: 18px; 

455 box-shadow: 0px 10px 30px 0px rgba(133,66,189,0.1); 

456 } 

457 

458 #operations > div > * { 

459 padding: 0; 

460 } 

461 

462 #operations > div ul, .params+ul{ 

463 list-style-type: none; 

464 font-size: 1rem; 

465 } 

466 #operations > div ul:nth-of-type(1) li, .params+ul li { 

467 margin: 10px 0px; 

468 } 

469 

470 #operations > div ul:nth-of-type(1) li em, .params+ul li em { 

471 font-style: normal; 

472 font-weight: 400; 

473 color: #9931FC; 

474 border-left: solid 2px #9931FC; 

475 padding:5px; 

476 } 

477 

478 .attr { 

479 border-top: solid 1px rgba(133,66,189,0.1); 

480 padding: 16px 0 !important; 

481 display:block; 

482 vertical-align: top; 

483 font-size: 1rem; 

484 text-align: left; 

485 } 

486 

487 .attr > strong { 

488 width: 30%; 

489 color: #595959; 

490 font-weight: 400; 

491 font-style: normal; 

492 display:inline-block; 

493 vertical-align: top; 

494 } 

495 

496 .attr_val { 

497 max-width: 50%; 

498 display:inline-table; 

499 height: 100%; 

500 vertical-align: top; 

501 overflow-wrap: break-word; 

502 word-break: break-word; 

503 } 

504 

505 @media screen and (max-width: 850px) { 

506 body { 

507 text-align: left; 

508 } 

509 

510 #operations > div ul { 

511 padding-left: 16px; 

512 } 

513 

514 .attr > strong { 

515 display: block; 

516 width: auto; 

517 } 

518 

519 .attr_val { 

520 display: block; 

521 max-width: none; 

522 margin-top: 8px; 

523 } 

524 } 

525 

526 .method { 

527 text-transform: uppercase; 

528 } 

529 

530 .params { 

531 margin-bottom: 0; 

532 } 

533 

534 pre { 

535 background-color: #f0f0f5; 

536 padding: 10px; 

537 margin-top: 0; 

538 margin-bottom: 0; 

539 border-radius: 0 0 14px 14px; 

540 font-family: monospace !important; 

541 overflow: scroll; 

542 line-height: 1.2em; 

543 height: 250px; 

544 } 

545 

546 code { 

547 text-transform: none; 

548 } 

549 

550 pre code { 

551 font-family: monospace !important; 

552 } 

553 

554 .json-expand, .json-close { 

555 padding: 8px 12px; 

556 min-height: 44px; 

557 border: 1px solid #e4d6ef; 

558 border-radius: 10px; 

559 background: white; 

560 color: #6524a4; 

561 font-size: 1rem; 

562 cursor: pointer; 

563 } 

564 

565 .json-expand:hover, .json-close:hover { 

566 background: #f5effb; 

567 } 

568 

569 .json-expand:focus-visible, .json-close:focus-visible { 

570 outline: 2px solid #6524a4; 

571 outline-offset: 3px; 

572 } 

573 

574 body:has(#json-dialog[open]) { 

575 overflow: hidden; 

576 } 

577 

578 #json-dialog { 

579 box-sizing: border-box; 

580 width: 100vw; 

581 height: 100dvh; 

582 max-width: none; 

583 max-height: none; 

584 margin: 0; 

585 padding: 16px; 

586 border: 0; 

587 background: #edf0f2; 

588 color: #333; 

589 } 

590 

591 #json-dialog[open] { 

592 display: flex; 

593 flex-direction: column; 

594 gap: 16px; 

595 } 

596 

597 #json-dialog header { 

598 display: flex; 

599 align-items: center; 

600 justify-content: space-between; 

601 gap: 16px; 

602 } 

603 

604 #json-dialog-title { 

605 margin: 0; 

606 overflow-wrap: anywhere; 

607 } 

608 

609 .json-close { 

610 flex-shrink: 0; 

611 } 

612 

613 #json-dialog pre { 

614 flex: 1; 

615 min-height: 0; 

616 padding: 16px; 

617 overflow: auto; 

618 white-space: pre; 

619 text-align: left; 

620 border-radius: 10px; 

621 } 

622 

623 .json-output { 

624 background: #f0f0f5; 

625 border-radius: 14px; 

626 overflow: hidden; 

627 } 

628 

629 .json-output-header { 

630 display: flex; 

631 align-items: center; 

632 justify-content: space-between; 

633 gap: 16px; 

634 padding: 16px; 

635 color: #595959; 

636 text-align: left; 

637 } 

638 

639 .json-expand { 

640 display: inline-flex; 

641 align-items: center; 

642 gap: 8px; 

643 flex-shrink: 0; 

644 } 

645 

646 .json-expand svg { 

647 width: 18px; 

648 height: 18px; 

649 } 

650 

651 .json-output pre { 

652 padding: 16px; 

653 overflow: auto; 

654 border-radius: 0; 

655 } 

656 

657 h2:first-of-type { 

658 margin-bottom: 15px; 

659 } 

660 

661 ol:first-of-type { 

662 margin-top: 0; 

663 } 

664 

665 :not(pre) > code { 

666 background-color: #f0f0f5; 

667 color: #54547a; 

668 padding: 0 2px 0 2px; 

669 border-radius: 3px; 

670 font-family : monospace; 

671 font-size: 1em !important; 

672 overflow-wrap: break-word; 

673 word-break: break-word; 

674 } 

675 

676 /**:not(div) > p { 

677 margin-left: 1.2%; 

678 }*/ 

679 

680 h1 {font-size: 2.5em;} 

681 h1, h2 { 

682 text-transform: uppercase; 

683 } 

684 

685 h1, h2, h3, h4, h5, h6 { 

686 overflow-wrap: anywhere; 

687 line-height: 1.2em; 

688 padding-top:1em; 

689 text-align: left !important; 

690 font-weight:400; 

691 } 

692 

693 h2 ~ h2, section > h2 { 

694 

695 padding-top: 5px; 

696 margin-top: 40px; 

697 } 

698 

699 h2 a[href], h3 a[href] { 

700 background-image: none; 

701 text-transform:uppercase; 

702 padding: 1px 3px 1px 3px; 

703 font-size: 1rem; 

704 float: right; 

705 position:relative; 

706 top: -3px; 

707 } 

708 

709 h2 a[href]::before , h3 a[href]::before { 

710 content: " \u2191"; 

711 width: 20px; 

712 height: 20px; 

713 display:inline-block; 

714 color: #9931FC; 

715 text-align:center; 

716 margin-right: 10px; 

717 } 

718 

719 /*h3 a[href] { 

720 color:white 

721 background-image: none; 

722 text-transform:uppercase; 

723 padding: 1px 3px 1px 3px; 

724 font-size: 8pt !important; 

725 border: 1px solid #9931FC; 

726 float: right; 

727 position:relative; 

728 top: -11px; 

729 right: -11px; 

730 border-radius: 0 14px 0 0; 

731 }*/ 

732 

733 p { 

734 overflow-wrap: break-word; 

735 word-wrap: break-word; 

736 } 

737 

738 a { 

739 color : black; 

740 text-decoration: none; 

741 background-image: -webkit-gradient(linear,""" 

742 "left top, left bottom," 

743 "color-stop(70%, transparent)," 

744 "color-stop(0, rgba(154, 49, 252,.5)));\n" 

745 """ background-image: linear-gradient(180deg,transparent 70%,rgba(154, 49, 252,.5) 0); 

746 background-position-y: 3px; 

747 background-position-x: 0px; 

748 background-repeat: no-repeat; 

749 -webkit-transition: .15s ease; 

750 transition: .15s ease; 

751 } 

752 

753 a:hover { 

754 color: #282828; 

755 background-image: -webkit-gradient(linear,""" 

756 "left top, left bottom," 

757 "color-stop(70%, transparent)," 

758 "color-stop(0, rgba(154, 49, 252,.25)));\n" 

759 """ background-image: linear-gradient(180deg,transparent 70%,rgba(154, 49, 252,.25) 0); 

760 } 

761 

762 footer { 

763 margin-top: 20px; 

764 border-top: 1px solid lightgrey; 

765 text-align: center; 

766 color: #595959; 

767 font-size: 1rem; 

768 } 

769 /* dashboard */ 

770 

771 .info_api { 

772 max-width: 35%; 

773 border-radius: 15px; 

774 text-align: left; 

775 vertical-align: top; 

776 background-color: #9931FC; 

777 color: white; 

778 } 

779 

780 .info_api, .api_calls { 

781 display: inline-block; 

782 text-align: left; 

783 height: 200px; 

784 padding:4%; 

785 margin: 1% 2% 1% 0px; 

786 border-radius: 10px; 

787 box-shadow: 0px 10px 30px 0px rgba(133,66,189,0.1); 

788 vertical-align:top; 

789 } 

790 

791 .api_calls { 

792 max-width: 40%; 

793 background-color: white; 

794 scroll-behavior: smooth; 

795 overflow: auto; 

796 overflow-y: scroll; 

797 scrollbar-color: #9931FC rgb(154, 49, 252); 

798 border-radius: 10px; 

799 overflow-wrap: break-word; 

800 word-break: break-word; 

801 } 

802 .api_calls div {padding-bottom:2%; overflow-wrap: break-word; word-break: break-word;} 

803 

804 .api_calls:hover { 

805 overflow-y: scroll; 

806 } 

807 .api_calls h4, .info_api h2 {padding-top: 0px !important; margin-top: 0px !important;} 

808 .api_calls div p { 

809 padding: 0.2em 0.5em; 

810 border-top: solid 1px #F8F8F8; 

811 } 

812 

813 .date_log , .method_log { 

814 color: #595959; 

815 font-size: 1rem; 

816 

817 } 

818 .method_log {margin-left: 15px;} 

819 .date_log {display:inline-grid;} 

820 

821 .group_log:nth-child(odd) { 

822 margin-right:5px; 

823 font-size: 1rem; 

824 } 

825 

826 .group_log:nth-child(even) { 

827 display: inline-grid; 

828 vertical-align: top; 

829 } 

830 .status_log {padding-right:15px;} 

831 .status_log::before { 

832 content: ''; 

833 display: inline-block; 

834 width: 1em; 

835 height: 1em; 

836 vertical-align: middle; 

837 -moz-border-radius: 50%; 

838 -webkit-border-radius: 50%; 

839 border-radius: 50%; 

840 background-color: #595959; 

841 margin-right: 0.8em; 

842 } 

843 

844 .code_200::before { 

845 background-color: #00cc00; 

846 } 

847 

848 .code_404::before { 

849 background-color: #cccc00; 

850 } 

851 

852 .code_500::before { 

853 background-color: #cc0000; 

854 } 

855 

856 """ 

857 ) 

858 

859 def __css_path(self, css_path: str | None = None) -> str: 

860 """Add link to a css file if specified in argument -css""" 

861 return f'<link rel="stylesheet" type="text/css" href=\'{css_path}\'>' if css_path else "" 

862 

863 def logger_ramose(self) -> None: # pragma: no cover 

864 """This method adds logging info to a local file""" 

865 # logging 

866 log_formatter = logging.Formatter("[%(asctime)s] [%(threadName)-12.12s] [%(levelname)-5.5s] %(message)s") 

867 root_logger = logging.getLogger() 

868 

869 file_handler = logging.FileHandler("ramose.log") 

870 file_handler.setFormatter(log_formatter) 

871 root_logger.addHandler(file_handler) 

872 

873 console_handler = logging.StreamHandler() 

874 console_handler.setFormatter(log_formatter) 

875 root_logger.addHandler(console_handler) 

876 

877 def __parse_logger_ramose(self) -> str: 

878 """This method reads logging info stored into a local file, so as to be browsed in the dashboard. 

879 Returns: the html including the list of URLs of current working APIs and basic logging info""" 

880 try: 

881 with Path("ramose.log").open() as l_f: 

882 logs = l_f.read() 

883 except FileNotFoundError: 

884 logs = "" 

885 

886 seen = set() 

887 rev_list = [] 

888 for x in reversed(logs.splitlines()): 

889 if x not in seen: 

890 seen.add(x) 

891 rev_list.append(x) 

892 

893 sidebar_items = "".join( 

894 f"\n " 

895 f'<li><a class="btn active" href="{api_url}">' 

896 f"{api_dict['conf_json'][0]['title']}" 

897 f"</a></li>\n " 

898 for api_url, api_dict in self.conf_doc.items() 

899 ) 

900 

901 html = f""" 

902 <p></p> 

903 <aside> 

904 <h4>RAMOSE API DASHBOARD</h4> 

905 <ul id="sidebar_menu" class="sidebar_menu">{sidebar_items} 

906 </ul> 

907 </aside> 

908 <header class="dashboard"> 

909 <h1>API MONITORING</h1>""" 

910 

911 for api_url, api_dict in self.conf_doc.items(): 

912 clean_list = [line for line in rev_list if api_url in line and "debug" not in line] 

913 api_logs_list = "".join( 

914 f"<p>{self.clean_log(line, api_url)}</p>" for line in clean_list if self.clean_log(line, api_url) != "" 

915 ) 

916 api_title = api_dict["conf_json"][0]["title"] 

917 html += f""" 

918 <div class="info_api"> 

919 <h2>{api_title}</h2> 

920 <a id="view_doc" href="{api_url}">VIEW DOCUMENTATION</a><br/> 

921 <a href="{api_dict["tp"]}" target="_blank">GO TO SPARQL ENDPOINT</a><br/> 

922 </div> 

923 <div class="api_calls"> 

924 <h4>Last calls</h4> 

925 <div> 

926 {api_logs_list} 

927 </div> 

928 

929 </div> 

930 """ 

931 return html 

932 

933 def get_documentation( 

934 self, 

935 css_path: str | None = None, 

936 base_url: str | None = None, 

937 *_args: object, 

938 **_dargs: object, 

939 ) -> tuple[int, str]: 

940 """This method generates the HTML documentation of an API described in configuration file.""" 

941 if base_url is None: 

942 first_key = next(iter(self.conf_doc)) 

943 conf = self.conf_doc[first_key] 

944 else: 

945 conf = self.conf_doc["/" + base_url] 

946 

947 return ( 

948 200, 

949 f"""<!DOCTYPE html> 

950<html lang="en" xmlns="http://www.w3.org/1999/xhtml"> 

951 <head> 

952 <title>{self.__title(conf)}</title> 

953 {self.__htmlmetadescription(conf)} 

954 <meta http-equiv="content-type" content="text/html; charset=utf-8"/> 

955 <meta name="viewport" content="width=device-width" /> 

956 <style id="documentation-styles">{self.__css()}</style> 

957 {self.__css_path(css_path)} 

958 </head> 

959 <body> 

960 <button class="menu-toggle" type="button" aria-expanded="false" 

961 aria-controls="documentation-navigation"> 

962 <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" 

963 stroke-width="1.8" stroke-linecap="round"> 

964 <path class="menu-open-icon" d="M4 6h16M4 12h16M4 18h16"/> 

965 <path class="menu-close-icon" d="m6 6 12 12M6 18 18 6"/> 

966 </svg> 

967 <span>Menu</span> 

968 </button> 

969 <aside id="documentation-navigation">{self.__sidebar(conf)}</aside> 

970 <main>{self.__header(conf)}</main> 

971 <section id="operations">{self.__operations(conf)}</section> 

972 <footer>{self.__footer()}</footer> 

973 <dialog id="json-dialog" aria-labelledby="json-dialog-title"> 

974 <header> 

975 <h2 id="json-dialog-title">JSON output</h2> 

976 <button class="json-close" type="button" autofocus>Close</button> 

977 </header> 

978 <pre tabindex="0" aria-label="JSON output"><code></code></pre> 

979 </dialog> 

980 <script> 

981 const jsonDialog = document.getElementById('json-dialog'); 

982 document.querySelectorAll('.json-expand').forEach(button => {{ 

983 button.addEventListener('click', () => {{ 

984 jsonDialog.querySelector('code').textContent = 

985 button.closest('.json-output').querySelector('code').textContent; 

986 jsonDialog.showModal(); 

987 jsonDialog.querySelector('pre').scrollTo(0, 0); 

988 }}); 

989 }}); 

990 jsonDialog.querySelector('.json-close').addEventListener('click', () => jsonDialog.close()); 

991 const menuButton = document.querySelector('.menu-toggle'); 

992 const navigation = document.getElementById('documentation-navigation'); 

993 const responsiveRules = [...document.getElementById('documentation-styles').sheet.cssRules] 

994 .find(rule => rule instanceof CSSMediaRule); 

995 function updateNavigationLayout() {{ 

996 responsiveRules.media.mediaText = 'not all'; 

997 const links = [...navigation.querySelectorAll('a')]; 

998 links.forEach(link => link.style.whiteSpace = 'nowrap'); 

999 const needsMenu = window.innerWidth <= 850 || links.some(link => {{ 

1000 const text = document.createRange(); 

1001 text.selectNodeContents(link); 

1002 const style = getComputedStyle(link); 

1003 const requiredWidth = text.getBoundingClientRect().width 

1004 + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); 

1005 return requiredWidth > link.clientWidth; 

1006 }}); 

1007 links.forEach(link => link.style.removeProperty('white-space')); 

1008 responsiveRules.media.mediaText = needsMenu ? 'screen' : 'screen and (max-width: 850px)'; 

1009 if (!needsMenu) setMenuOpen(false); 

1010 }} 

1011 window.addEventListener('resize', updateNavigationLayout); 

1012 document.fonts.ready.then(updateNavigationLayout); 

1013 updateNavigationLayout(); 

1014 function setMenuOpen(open) {{ 

1015 navigation.classList.toggle('is-open', open); 

1016 menuButton.setAttribute('aria-expanded', String(open)); 

1017 menuButton.querySelector('span').textContent = open ? 'Close menu' : 'Menu'; 

1018 }} 

1019 menuButton.addEventListener('click', () => {{ 

1020 setMenuOpen(menuButton.getAttribute('aria-expanded') === 'false'); 

1021 }}); 

1022 navigation.addEventListener('click', (event) => {{ 

1023 if (event.target.closest('a')) setMenuOpen(false); 

1024 }}); 

1025 document.addEventListener('keydown', (event) => {{ 

1026 if (event.key === 'Escape' && menuButton.getAttribute('aria-expanded') === 'true') {{ 

1027 setMenuOpen(false); 

1028 menuButton.focus(); 

1029 }} 

1030 }}); 

1031 </script> 

1032 </body> 

1033</html>""", 

1034 ) 

1035 

1036 def get_index(self, css_path: str | None = None, *_args: object, **_dargs: object) -> str: 

1037 """This method generates the index of all the HTML documentations that can be 

1038 created from the configuration file.""" 

1039 

1040 return f""" 

1041 <!doctype html> 

1042 <html lang="en"> 

1043 <head> 

1044 <meta charset="utf-8"> 

1045 <title>RAMOSE</title> 

1046 <meta name="description" content="Documentation of RAMOSE API Manager"> 

1047 <style>{self.__css()}</style> 

1048 {self.__css_path(css_path)} 

1049 </head> 

1050 <body> 

1051 {self.__parse_logger_ramose()} 

1052 <footer>{self.__footer()}</footer> 

1053 </body> 

1054 </html> 

1055 """ 

1056 

1057 def store_documentation( 

1058 self, 

1059 file_path: str, 

1060 css_path: str | None = None, 

1061 *_args: object, 

1062 **_dargs: object, 

1063 ) -> None: 

1064 """This method stores the HTML documentation of an API in a file.""" 

1065 _, html = self.get_documentation(css_path) 

1066 with Path(file_path).open("w") as f: 

1067 f.write(html) 

1068 

1069 def clean_log(self, log_line: str, api_url: str) -> str: 

1070 """This method parses logs lines into structured data.""" 

1071 if "- - " not in log_line: 

1072 return "" 

1073 s = log_line.split("- - ", 1)[1] 

1074 date = s[s.find("[") + 1 : s.find("]")] 

1075 method = s.split('"')[1::2][0].split()[0] 

1076 cur_call = s.split('"')[1::2][0].split()[1].strip() 

1077 status = sub(r"\D+", "", s.split('"', 2)[2]) 

1078 if cur_call != api_url + "/": 

1079 full_str = ( 

1080 "<span class='group_log'><span class='status_log code_" 

1081 + status 

1082 + "'>" 

1083 + status 

1084 + "</span>" 

1085 + "<span class='date_log'>" 

1086 + date 

1087 + "</span><span class='method_log'>" 

1088 + method 

1089 + "</span></span>" 

1090 + "<span class='group_log'><span class='call_log'><a href='" 

1091 + cur_call 

1092 + "' target='_blank'>" 

1093 + cur_call 

1094 + "</a></span></span>" 

1095 ) 

1096 else: 

1097 full_str = "" 

1098 return full_str