Source code for openapi_diagram.openapi_to_plantuml

"""Module to check and fetch openapi_to_plantuml."""

from __future__ import annotations

import os
import subprocess
from hashlib import md5
from pathlib import Path
from shutil import which
from typing import Literal
from typing import TypeAlias
from typing import cast
from warnings import warn

import httpx
from bs4 import BeautifulSoup
from pydantic import TypeAdapter

from openapi_diagram import CACHE_DIR
from openapi_diagram import OPENAPI_TO_PLANTUML_DEFAULT_VERSION
from openapi_diagram.utils import openapi_3_dot_1_compat

OPENAPI_TO_PLANTUML_MAVEN_URL = (
    "https://repo1.maven.org/maven2/com/github/davidmoten/openapi-to-plantuml"
)


OpenapiToPlantumlModes: TypeAlias = Literal["single", "split"]
# Commented out formats are format that are technically supported by openapi-to-plantuml
# But tests crash in the dev container
# Ref. https://github.com/davidmoten/openapi-to-plantuml/blob/f00c03f7d7687e2b6d74fb726c02515c7197ebf0/src/main/java/com/github/davidmoten/oas3/puml/ConverterMain.java#L39
OpenapiToPlantumlFormats: TypeAlias = Literal[
    "puml",
    "eps",
    "eps_text",
    "atxt",
    "utxt",
    "xmi_standard",
    "xmi_star",
    "xmi_argo",
    # "scxml",
    # "graphml",
    # "pdf",
    # "mjpeg",
    # "animated_gif",
    # "html",
    # "html5",
    "vdx",
    "latex",
    "latex_no_preamble",
    # "base64",
    "braille_png",
    # "preproc",
    "debug",
    "png",
    "raw",
    "svg",
]


[docs] class DownloadVerificationError(Exception): """Error thrown if download does not match hash."""
[docs] class MissingDependecyError(RuntimeError): """Error thrown when an essential dependency is missing."""
[docs] class MissingDependecyWarning(UserWarning): """Warn when a non essential dependency is missing."""
[docs] def _get_latest_openapi_to_plantuml_version() -> str: """Get latest `openapi-to-plantuml` version on maven repo. Returns ------- str Version string of latest `openapi-to-plantuml` release. Raises ------ RuntimeError If version could not be found. """ resp = httpx.get(f"{OPENAPI_TO_PLANTUML_MAVEN_URL}/maven-metadata.xml", follow_redirects=True) soup = BeautifulSoup(resp.content, features="xml") version = soup.find("latest") if version is None: msg = ( "Could not find openapi-to-plantuml version in 'maven-metadata.xml':\n" f"{resp.content.decode()}" ) raise RuntimeError(msg) return version.text
[docs] def _get_openapi_to_plantuml_download_url(version: str) -> str: """Get latest `openapi-to-plantuml` version on maven repo. Parameters ---------- version : str Version to download. Defaults to "0.1.28" Returns ------- str Version string of latest `openapi-to-plantuml` release. Raises ------ RuntimeError If version could not be found. """ release_url = f"{OPENAPI_TO_PLANTUML_MAVEN_URL}/{version}" resp = httpx.get(release_url, follow_redirects=True) soup = BeautifulSoup(resp.content, features="lxml") links = soup.find_all("a") for link in links: if link["href"].endswith("with-dependencies.jar"): return f'{release_url}/{link["href"]}' msg = f"Could not find openapi-to-plantuml download link in:\n{resp.content.decode()}" raise RuntimeError(msg)
[docs] def get_openapi_to_plantuml_path(version: str = OPENAPI_TO_PLANTUML_DEFAULT_VERSION) -> Path: """Get path to cached ``openapi-to-plantuml`` file. Parameters ---------- version : str Version to download. Defaults to "0.1.28" Returns ------- Path """ return CACHE_DIR / f"openapi-to-plantuml-{version}.jar"
[docs] def download_openapi_to_plantuml(version: str = OPENAPI_TO_PLANTUML_DEFAULT_VERSION) -> Path: """Download ``openapi-to-plantuml`` jar file with dependencies to cache folder. Parameters ---------- version : str Version to download. Defaults to "0.1.28" Returns ------- Path Path to jar file. Raises ------ DownloadVerificationError Error thrown if downloaded file does not match expected md5 hash. """ jar_path = get_openapi_to_plantuml_path(version) if jar_path.is_file() is True: return jar_path download_url = _get_openapi_to_plantuml_download_url(version) download_response = httpx.get(download_url, follow_redirects=True) expected_md5_hash = httpx.get(f"{download_url}.md5", follow_redirects=True) if md5(download_response.content).hexdigest() != expected_md5_hash.text: msg = "Downloaded openapi-to-plantuml jar has invalid hash." raise DownloadVerificationError(msg) jar_path.write_bytes(download_response.content) return jar_path
[docs] def _find_java_executable() -> Path: """Find java executable using lookup order ``JAVA_HOME``, ``PATH``. Returns ------- Path Path to the java executable. Raises ------ MissingDependecyError If no java executable could be found via JAVA_HOME or on the path. """ java_home = os.getenv("JAVA_HOME", None) if java_home is not None: return Path(java_home) / "bin/java" java_path = which("java") if java_path is not None: return Path(java_path) msg = ( "Can not run openapi-to-plantuml without java installed." "Couldn't find the 'JAVA_HOME' environment variable or java on the PATH." ) raise MissingDependecyError(msg)
[docs] def run_openapi_to_plantuml( openapi_spec: Path, output_path: Path, mode: OpenapiToPlantumlModes, diagram_format: OpenapiToPlantumlFormats, version: str = OPENAPI_TO_PLANTUML_DEFAULT_VERSION, ) -> list[Path]: """Run ``openapi-to-plantuml``. Parameters ---------- openapi_spec : Path Spec file to use (only JSON and YAML) are supported. output_path : Path File (``mode='single'``) or folder (``mode='split'``) to write the output to. mode : OpenapiToPlantumlModes Mode to run diagram_format : OpenapiToPlantumlFormats Format the diagram/-s should be in. version : str Version of ``openapi-to-plantuml`` to use. Defaults to "0.1.28" Returns ------- list[Path] List of created output files. Raises ------ MissingDependecyError If the java installation can not be found. """ mode = cast(OpenapiToPlantumlModes, TypeAdapter(OpenapiToPlantumlModes).validate_python(mode)) diagram_format = cast( OpenapiToPlantumlFormats, TypeAdapter(OpenapiToPlantumlFormats).validate_python(diagram_format), ) if which("dot") is None: msg = "Graphviz installation not found, some output formats might not be available." warn(MissingDependecyWarning(msg), stacklevel=2) java_executable = _find_java_executable() jar_path = download_openapi_to_plantuml(version) if mode == "single": output_path.parent.mkdir(parents=True, exist_ok=True) with openapi_3_dot_1_compat(openapi_spec) as spec_file: subprocess.run( [ java_executable.resolve().as_posix(), "-jar", jar_path.resolve().as_posix(), mode, spec_file.resolve().as_posix(), diagram_format.upper(), output_path.resolve().as_posix(), ], check=True, ) if mode == "single": return list(output_path.parent.glob(f"*.{diagram_format}")) return list(output_path.glob(f"*.{diagram_format}"))