import xml.etree.ElementTree as ET import re class COLORS: BLUE = "rgb(114,159,207)" GREEN = "rgb(165,214,165)" PINK = "rgb(230,156,190)" GREY = "rgb(224,224,224)" class Token: def __init__(self, name, method="GET", query=None, leaf=False): self.children: list["Token"] = [] self.name: str = name self.method: str = method self.query: dict[str, str] = query or {} self.force_leaf: bool = leaf self.func_name = "" def add_child(self, child): self.children.append(child) def find_child(self, name): for child in self.children: if child.name == name: return child return None def __repr__(self, level=0): ret = "\t" * level + f"({self.name})\n" for child in self.children: ret += child.__repr__(level + 1) return ret def is_leaf(self): return len(self.children) == 0 def get_height(self, y_step): # Calculer la hauteur totale des enfants children_height = sum(child.get_height(y_step) for child in self.children) # Calculer la hauteur des éléments de la query query_height = len(self.query) * (y_step * 1.33) # La hauteur totale est la somme de la hauteur des enfants et des éléments de la query height = children_height + query_height if height == 0: height = y_step return height def to_svg_group( self, x_offset=0, y_offset=0, x_step=150, y_step=50, parent_coords=None, parent_children_nb=0, ): group = ET.Element("g") color = COLORS.BLUE if self.is_leaf(): if self.method == "GET": color = COLORS.GREEN elif self.method == "POST": color = COLORS.PINK element = _create_svg_element(self.name, color) element.set("transform", f"translate({x_offset}, {y_offset})") current_start_coords, current_end_coords = _get_anchor_coords( element, x_offset, y_offset ) href = "#" + self.func_name.replace("_", "-") if self.query: href += "-query" question_mark_group = _create_question_mark_group(current_end_coords, href) group.append(element) # Add an arrow from the parent element to the current element if parent_coords and parent_children_nb > 1: arrow = _create_arrow(parent_coords, current_start_coords) group.append(arrow) if not self.is_leaf(): slash_group = _create_svg_element("/", COLORS.GREY) slash_group.set( "transform", f"translate({x_offset + _get_element_width(element)}, {y_offset})", ) group.append(slash_group) if self.is_leaf() and self.query: slash_group = _create_svg_element("?", COLORS.GREY) slash_group.set( "transform", f"translate({x_offset + _get_element_width(element)}, {y_offset})", ) group.append(slash_group) current_end_coords = _get_anchor_coords(group, 0, 0)[1] query_y_offset = y_offset query_sub_element = ET.Element("g") for key, value in self.query.items(): sub_group = ET.Element("g") # = translate_x = x_offset + x_step # param_el = _create_svg_element(key, COLORS.BLUE) param_el.set( "transform", f"translate({translate_x}, {query_y_offset})", ) sub_group.append(param_el) # add Arrow from "query" to element coords = ( current_end_coords, _get_anchor_coords(param_el, translate_x, query_y_offset)[0], ) sub_group.append(_create_arrow(*coords)) # = equal_el = _create_svg_element("=", COLORS.GREY) translate_x = x_offset + x_step + _get_element_width(param_el) equal_el.set( "transform", f"translate({translate_x}, {query_y_offset})", ) sub_group.append(equal_el) # value_el = _create_svg_element(value, COLORS.GREEN) translate_x = ( x_offset + x_step + sum(_get_element_width(el) for el in [param_el, equal_el]) ) value_el.set( "transform", f"translate({translate_x}, {query_y_offset})", ) sub_group.append(value_el) if len(self.query) == 1: continue ampersand_group = _create_svg_element("&", "rgb(224,224,224)") translate_x = ( x_offset + x_step + sum(_get_element_width(el) for el in [param_el, equal_el, value_el]) ) ampersand_group.set( "transform", f"translate({translate_x}, {query_y_offset})", ) sub_group.append(ampersand_group) query_y_offset += y_step * 1.33 query_sub_element.append(sub_group) group.append(query_sub_element) y_offset = query_y_offset current_y_offset = y_offset for child in self.children: rel_x_offset = x_offset + _get_group_width(group) if len(self.children) > 1: rel_x_offset += x_step child_group = child.to_svg_group( rel_x_offset, current_y_offset, x_step, y_step, parent_coords=current_end_coords, parent_children_nb=len(self.children), ) group.append(child_group) current_y_offset += child.get_height(y_step) # add `?` circle a:href to element if self.force_leaf or self.is_leaf(): group.append(question_mark_group) return group def _create_svg_element(text, color="rgb(230,156,190)"): # Dimensions and styling padding = 5 font_size = 16 rect_height = 30 rect_x = 10 rect_y = 20 # Estimate the text width text_width = ( len(text) * font_size * 0.6 ) # Estimate based on average character width rect_width = text_width + padding * 2 # Create the SVG group element group = ET.Element("g") # Create the rectangle ET.SubElement( group, "rect", { "x": str(rect_x), "y": str(rect_y), "width": str(rect_width), "height": str(rect_height), "style": f"fill:{color};stroke:black;stroke-width:2;fill-opacity:1;stroke-opacity:1", }, ) # Create the text element text_element = ET.SubElement( group, "text", { "x": str(rect_x + padding), "y": str( rect_y + rect_height / 2 + font_size / 2.5 ), # Adjust to vertically center the text "font-family": "Courier New, monospace", "font-size": str(font_size), "fill": "black", "style": "white-space: pre;", }, ) text_element.text = text return group def _get_anchor_coords(element, x_offset, y_offset): bbox = _get_bbox(element, x_offset, y_offset) startX = bbox["x_min"] endX = bbox["x_max"] y = bbox["y_min"] + (bbox["y_max"] - bbox["y_min"]) / 2 return (startX, y), (endX, y) def _create_arrow(start_coords, end_coords): start_x, start_y = start_coords end_x, end_y = end_coords mid_x = (start_x + end_x) / 2 path_data = ( f"M {start_x},{start_y} L {mid_x},{start_y} L {mid_x},{end_y} L {end_x},{end_y}" ) path = ET.Element( "path", { "d": path_data, "style": "stroke:black;stroke-width:2;fill:none", "marker-end": "url(#arrowhead)", }, ) return path def _get_element_width(element): rect = element.find("rect") if rect is not None: return float(rect.get("width", 0)) return 0 def _get_group_width(group): return sum(_get_element_width(child) for child in group) def _create_question_mark_group(coords, href): x, y = coords radius = 10 # Radius of the circle y -= radius * 2 font_size = 17 # Font size of the question mark group = ET.Element("g") # Create the circle ET.SubElement( group, "circle", { "cx": str(x), "cy": str(y), "r": str(radius), "fill": COLORS.GREY, "stroke": "black", "stroke-width": "2", }, ) # Create the link element link = ET.Element("a", {"href": href}) # Create the text element text_element = ET.SubElement( link, "text", { "x": str(x + 1), "y": str(y + font_size / 3), # Adjust to vertically center the text "text-anchor": "middle", # Center the text horizontally "font-family": "Arial", "font-size": str(font_size), "fill": "black", }, ) text_element.text = "?" group.append(link) return group def gen_api_map(app): api_map = Token("") for rule in app.url_map.iter_rules(): # On ne garde que les routes de l'API / APIWEB if not rule.endpoint.lower().startswith("api"): continue segments = rule.rule.strip("/").split("/") current_token = api_map for i, segment in enumerate(segments): # Check if the segment already exists in the current level child = current_token.find_child(segment) if child is None: func = app.view_functions[rule.endpoint] # If it's the last segment if i == len(segments) - 1: child = Token( segment, leaf=True, query=parse_query_doc(func.__doc__ or ""), ) else: child = Token( segment, ) child.func_name = func.__name__ method: str = "POST" if "POST" in rule.methods else "GET" child.method = method current_token.add_child(child) current_token = child # Mark the last segment as a leaf node if it's not already marked if not current_token.is_leaf(): current_token.force_leaf = True generate_svg(api_map.to_svg_group(), "/tmp/api_map.svg") print( "La carte a été générée avec succès. " + "Vous pouvez la consulter à l'adresse suivante : /tmp/api_map.svg" ) def _get_bbox(element, x_offset=0, y_offset=0): # Helper function to calculate the bounding box of an SVG element bbox = { "x_min": float("inf"), "y_min": float("inf"), "x_max": float("-inf"), "y_max": float("-inf"), } for child in element: transform = child.get("transform") child_x_offset = x_offset child_y_offset = y_offset if transform: translate = transform.replace("translate(", "").replace(")", "").split(",") if len(translate) == 2: child_x_offset += float(translate[0]) child_y_offset += float(translate[1]) if child.tag == "rect": x = child_x_offset + float(child.get("x", 0)) y = child_y_offset + float(child.get("y", 0)) width = float(child.get("width", 0)) height = float(child.get("height", 0)) bbox["x_min"] = min(bbox["x_min"], x) bbox["y_min"] = min(bbox["y_min"], y) bbox["x_max"] = max(bbox["x_max"], x + width) bbox["y_max"] = max(bbox["y_max"], y + height) if len(child): child_bbox = _get_bbox(child, child_x_offset, child_y_offset) bbox["x_min"] = min(bbox["x_min"], child_bbox["x_min"]) bbox["y_min"] = min(bbox["y_min"], child_bbox["y_min"]) bbox["x_max"] = max(bbox["x_max"], child_bbox["x_max"]) bbox["y_max"] = max(bbox["y_max"], child_bbox["y_max"]) return bbox def generate_svg(element, fname): bbox = _get_bbox(element) width = bbox["x_max"] - bbox["x_min"] + 80 # Add some padding height = bbox["y_max"] - bbox["y_min"] + 80 # Add some padding svg = ET.Element( "svg", { "width": str(width), "height": str(height), "xmlns": "http://www.w3.org/2000/svg", "viewBox": f"{bbox['x_min'] - 10} {bbox['y_min'] - 10} {width} {height}", }, ) # Define the marker for the arrowhead defs = ET.SubElement(svg, "defs") marker = ET.SubElement( defs, "marker", { "id": "arrowhead", "markerWidth": "10", "markerHeight": "7", "refX": "10", "refY": "3.5", "orient": "auto", }, ) ET.SubElement(marker, "polygon", {"points": "0 0, 10 3.5, 0 7"}) svg.append(element) tree = ET.ElementTree(svg) tree.write(fname, encoding="utf-8", xml_declaration=True) def parse_query_doc(doc): """ renvoie un dictionnaire {param: } (ex: {assiduite_id : }) La doc doit contenir des lignes de la forme: QUERY ----- param: param1: param2: Dès qu'une ligne ne respecte pas ce format (voir regex dans la fonction), on arrête de parser Attention, la ligne ----- doit être collée contre QUERY et contre le premier paramètre """ lines = [line.strip() for line in doc.split("\n")] try: query_index = lines.index("QUERY") if lines[query_index + 1] != "-----": return {} except ValueError: return {} query_lines = lines[query_index + 2 :] query = {} regex = re.compile(r"^(\w+):(<.+>)$") for line in query_lines: parts = regex.match(line) if not parts: break param, type_nom_param = parts.groups() query[param] = type_nom_param return query if __name__ == "__main__": root = Token("api") child1 = Token("assiduites", leaf=True) child1.func_name = "assiduites_get" child2 = Token("count") child22 = Token("all") child23 = Token( "query", query={ "etat": "", "moduleimpl_id": "", "count": "", "formsemestre_id": "", }, ) child3 = Token("justificatifs", "POST") child3.func_name = "justificatifs_post" root.add_child(child1) child1.add_child(child2) child2.add_child(child22) child2.add_child(child23) root.add_child(child3) group_element = root.to_svg_group() generate_svg(group_element, "/tmp/api_map.svg")