|
|
@@ -0,0 +1,147 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+import xml.etree.ElementTree as ET
|
|
|
+
|
|
|
+from app.core.data_research.ontology.models import GRAPH_SECTIONS, GraphDocument
|
|
|
+
|
|
|
+
|
|
|
+class ExchangeInvalid(ValueError):
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+SECRET_KEYS = {"password", "secret", "token", "api_key", "authorization", "credentials"}
|
|
|
+OWL_NS = "http://www.w3.org/2002/07/owl#"
|
|
|
+RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
|
+DATAOPS_NS = "https://dataops.local/ontology#"
|
|
|
+
|
|
|
+
|
|
|
+def _safe(value, key=""):
|
|
|
+ if key.casefold() in SECRET_KEYS:
|
|
|
+ return "[redacted]"
|
|
|
+ if isinstance(value, dict):
|
|
|
+ return {name: _safe(item, name) for name, item in sorted(value.items())}
|
|
|
+ if isinstance(value, list):
|
|
|
+ return [_safe(item) for item in value]
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+class OntologyExchange:
|
|
|
+ def __init__(self, *, max_import_bytes=5 * 1024 * 1024):
|
|
|
+ self.max_import_bytes = int(max_import_bytes)
|
|
|
+
|
|
|
+ def _bounded(self, content):
|
|
|
+ if len(content) > self.max_import_bytes:
|
|
|
+ raise ExchangeInvalid("ontology import exceeds size limit")
|
|
|
+
|
|
|
+ def export_json(self, ontology_uid, version, graph):
|
|
|
+ payload = {
|
|
|
+ "format": "dataops-ontology-json-v1",
|
|
|
+ "ontology_uid": str(ontology_uid),
|
|
|
+ "version": int(version),
|
|
|
+ "graph_document": _safe(GraphDocument.from_dict(graph).to_dict()),
|
|
|
+ }
|
|
|
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
|
+
|
|
|
+ def import_json(self, content):
|
|
|
+ self._bounded(content)
|
|
|
+ try:
|
|
|
+ payload = json.loads(content.decode("utf-8"))
|
|
|
+ return {
|
|
|
+ "ontology_uid": str(payload["ontology_uid"]),
|
|
|
+ "version": int(payload["version"]),
|
|
|
+ "graph_document": GraphDocument.from_dict(payload["graph_document"]).to_dict(),
|
|
|
+ }
|
|
|
+ except (KeyError, TypeError, ValueError, UnicodeDecodeError) as exc:
|
|
|
+ raise ExchangeInvalid("invalid ontology JSON") from exc
|
|
|
+
|
|
|
+ def export_owl(self, ontology_uid, version, graph):
|
|
|
+ ET.register_namespace("owl", OWL_NS)
|
|
|
+ ET.register_namespace("rdf", RDF_NS)
|
|
|
+ ET.register_namespace("dataops", DATAOPS_NS)
|
|
|
+ root = ET.Element(f"{{{RDF_NS}}}RDF")
|
|
|
+ ontology = ET.SubElement(root, f"{{{OWL_NS}}}Ontology")
|
|
|
+ ontology.set(f"{{{RDF_NS}}}about", f"urn:dataops:ontology:{ontology_uid}")
|
|
|
+ ontology.set(f"{{{DATAOPS_NS}}}uid", str(ontology_uid))
|
|
|
+ ontology.set(f"{{{DATAOPS_NS}}}version", str(int(version)))
|
|
|
+ safe_graph = _safe(GraphDocument.from_dict(graph).to_dict())
|
|
|
+ for section in GRAPH_SECTIONS:
|
|
|
+ node = ET.SubElement(ontology, f"{{{DATAOPS_NS}}}{section}")
|
|
|
+ node.text = json.dumps(safe_graph[section], ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
|
+ return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
|
+
|
|
|
+ def import_owl(self, content):
|
|
|
+ self._bounded(content)
|
|
|
+ upper = content.upper()
|
|
|
+ if b"<!DOCTYPE" in upper or b"<!ENTITY" in upper:
|
|
|
+ raise ExchangeInvalid("unsafe XML declarations are forbidden")
|
|
|
+ try:
|
|
|
+ root = ET.fromstring(content)
|
|
|
+ ontology = root.find(f"{{{OWL_NS}}}Ontology")
|
|
|
+ if ontology is None:
|
|
|
+ raise ValueError("missing ontology")
|
|
|
+ graph = {}
|
|
|
+ for section in GRAPH_SECTIONS:
|
|
|
+ node = ontology.find(f"{{{DATAOPS_NS}}}{section}")
|
|
|
+ graph[section] = json.loads(node.text or "[]") if node is not None else []
|
|
|
+ return {
|
|
|
+ "ontology_uid": str(ontology.attrib[f"{{{DATAOPS_NS}}}uid"]),
|
|
|
+ "version": int(ontology.attrib[f"{{{DATAOPS_NS}}}version"]),
|
|
|
+ "graph_document": GraphDocument.from_dict(graph).to_dict(),
|
|
|
+ }
|
|
|
+ except (ET.ParseError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
|
+ raise ExchangeInvalid("invalid ontology RDF/OWL XML") from exc
|
|
|
+
|
|
|
+
|
|
|
+class OntologyExchangeService:
|
|
|
+ def __init__(self, repository, *, exchange=None, commit=lambda: None, rollback=lambda: None):
|
|
|
+ self.repository = repository
|
|
|
+ self.exchange = exchange or OntologyExchange()
|
|
|
+ self.commit = commit
|
|
|
+ self.rollback = rollback
|
|
|
+
|
|
|
+ def export(self, ontology_uid, format_name):
|
|
|
+ version = self.repository.latest_version(ontology_uid)
|
|
|
+ if version is None or version.status not in {"published", "superseded"}:
|
|
|
+ raise LookupError("published ontology version was not found")
|
|
|
+ graph = version.graph_document.to_dict()
|
|
|
+ if format_name == "json":
|
|
|
+ content = self.exchange.export_json(ontology_uid, version.version, graph)
|
|
|
+ return content, "application/json", f"{ontology_uid}-v{version.version}.json"
|
|
|
+ if format_name in {"rdf", "owl"}:
|
|
|
+ content = self.exchange.export_owl(ontology_uid, version.version, graph)
|
|
|
+ return content, "application/rdf+xml", f"{ontology_uid}-v{version.version}.owl"
|
|
|
+ raise ExchangeInvalid("unsupported ontology exchange format")
|
|
|
+
|
|
|
+ def import_document(self, content, format_name, actor_uid):
|
|
|
+ try:
|
|
|
+ imported = (
|
|
|
+ self.exchange.import_json(content)
|
|
|
+ if format_name == "json"
|
|
|
+ else self.exchange.import_owl(content)
|
|
|
+ )
|
|
|
+ code = "IMPORTED_" + imported["ontology_uid"].replace("-", "_").upper()
|
|
|
+ ontology = self.repository.create(
|
|
|
+ code=code,
|
|
|
+ name=code,
|
|
|
+ owner_uid=actor_uid,
|
|
|
+ domain_links=imported["graph_document"].get("domain_links") or [],
|
|
|
+ created_by=actor_uid,
|
|
|
+ )
|
|
|
+ version = self.repository.save_draft(
|
|
|
+ ontology.uid,
|
|
|
+ imported["graph_document"],
|
|
|
+ expected_revision=0,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ )
|
|
|
+ self.commit()
|
|
|
+ return {
|
|
|
+ "ontology_uid": ontology.uid,
|
|
|
+ "version_uid": version.uid,
|
|
|
+ "version": version.version,
|
|
|
+ "format": format_name,
|
|
|
+ "actor_uid": actor_uid,
|
|
|
+ }
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|