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
« 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
10from __future__ import annotations
12import logging
13from pathlib import Path
14from re import findall, split, sub
15from typing import TYPE_CHECKING
17from markdown import markdown
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
23if TYPE_CHECKING:
24 from ramose.api_manager import APIConfig
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"]
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
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"""
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 """
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"]}
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/>
77## <a id="description"></a>Description [back to top](#toc)
79{i["description"]}
81{self.__parameters(conf)}"""
82 return markdown(result)
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 )
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"]))
152 return [text for param_name, text in self._BUILTIN_PARAM_DOCS if param_name not in overridden]
154 def __parameters(self, conf: APIConfig) -> str:
155 builtin_params = self.__builtin_param_docs(conf)
156 if not builtin_params:
157 return ""
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)
176{params_intro}
178{items}
180{filtering_note}
182Example: `<api_operation_url>?require=doi&filter=date:>2015&sort=desc(date)`."""
183 return markdown(result)
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"
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]
200 params.append(
201 f"<em>{p}</em>: type <code>{p_type}</code>, regular expression shape <code>{p_shape}</code>",
202 )
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 )
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"]
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>'
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>
244{markdown(op["description"])}
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
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)
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; }
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 }
342 * {
343 font-family: 'Karla', Geneva, sans-serif;
344 }
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 }
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 }
380 .sidebar_menu , .sidebar_submenu {
381 list-style-type: none;
382 padding-left:0px !important;
383 margin-top: 10px;
385 }
387 .sidebar_menu > li {
388 padding: 2% 0px;
389 border-top : solid 0.7px #595959;
390 }
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 }
401 .sidebar_menu a:hover {
402 box-shadow: inset 5px 0 0 rgba(154, 49, 252,.5);
403 color: #9931FC;
404 }
406 .sidebar_submenu > li {
407 padding-left:0px !important;
408 background-color:#edf0f2;
409 font-size: 1rem;
410 }
412 main , #operations , .dashboard, body>footer {
413 margin-left: 33%;
414 }
415 .dashboard {text-align: center;}
416 main h1+p , .info_api{
418 padding-left: 3%;
419 font-size: 1rem;
420 line-height: 1.4em;
421 }
423 main h1+p {border-left: solid 5px rgba(154, 49, 252,.5);}
425 #operations h3 {
426 color: #9931FC;
427 margin-bottom: 0px;
428 padding: 10px;
429 }
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 }
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 }
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 }
458 #operations > div > * {
459 padding: 0;
460 }
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 }
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 }
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 }
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 }
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 }
505 @media screen and (max-width: 850px) {
506 body {
507 text-align: left;
508 }
510 #operations > div ul {
511 padding-left: 16px;
512 }
514 .attr > strong {
515 display: block;
516 width: auto;
517 }
519 .attr_val {
520 display: block;
521 max-width: none;
522 margin-top: 8px;
523 }
524 }
526 .method {
527 text-transform: uppercase;
528 }
530 .params {
531 margin-bottom: 0;
532 }
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 }
546 code {
547 text-transform: none;
548 }
550 pre code {
551 font-family: monospace !important;
552 }
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 }
565 .json-expand:hover, .json-close:hover {
566 background: #f5effb;
567 }
569 .json-expand:focus-visible, .json-close:focus-visible {
570 outline: 2px solid #6524a4;
571 outline-offset: 3px;
572 }
574 body:has(#json-dialog[open]) {
575 overflow: hidden;
576 }
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 }
591 #json-dialog[open] {
592 display: flex;
593 flex-direction: column;
594 gap: 16px;
595 }
597 #json-dialog header {
598 display: flex;
599 align-items: center;
600 justify-content: space-between;
601 gap: 16px;
602 }
604 #json-dialog-title {
605 margin: 0;
606 overflow-wrap: anywhere;
607 }
609 .json-close {
610 flex-shrink: 0;
611 }
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 }
623 .json-output {
624 background: #f0f0f5;
625 border-radius: 14px;
626 overflow: hidden;
627 }
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 }
639 .json-expand {
640 display: inline-flex;
641 align-items: center;
642 gap: 8px;
643 flex-shrink: 0;
644 }
646 .json-expand svg {
647 width: 18px;
648 height: 18px;
649 }
651 .json-output pre {
652 padding: 16px;
653 overflow: auto;
654 border-radius: 0;
655 }
657 h2:first-of-type {
658 margin-bottom: 15px;
659 }
661 ol:first-of-type {
662 margin-top: 0;
663 }
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 }
676 /**:not(div) > p {
677 margin-left: 1.2%;
678 }*/
680 h1 {font-size: 2.5em;}
681 h1, h2 {
682 text-transform: uppercase;
683 }
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 }
693 h2 ~ h2, section > h2 {
695 padding-top: 5px;
696 margin-top: 40px;
697 }
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 }
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 }
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 }*/
733 p {
734 overflow-wrap: break-word;
735 word-wrap: break-word;
736 }
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 }
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 }
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 */
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 }
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 }
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;}
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 }
813 .date_log , .method_log {
814 color: #595959;
815 font-size: 1rem;
817 }
818 .method_log {margin-left: 15px;}
819 .date_log {display:inline-grid;}
821 .group_log:nth-child(odd) {
822 margin-right:5px;
823 font-size: 1rem;
824 }
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 }
844 .code_200::before {
845 background-color: #00cc00;
846 }
848 .code_404::before {
849 background-color: #cccc00;
850 }
852 .code_500::before {
853 background-color: #cc0000;
854 }
856 """
857 )
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 ""
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()
869 file_handler = logging.FileHandler("ramose.log")
870 file_handler.setFormatter(log_formatter)
871 root_logger.addHandler(file_handler)
873 console_handler = logging.StreamHandler()
874 console_handler.setFormatter(log_formatter)
875 root_logger.addHandler(console_handler)
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 = ""
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)
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 )
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>"""
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>
929 </div>
930 """
931 return html
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]
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 )
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."""
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 """
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)
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