Initial SFERA platform baseline
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# sfera-integration-topology
|
||||
|
||||
Integration endpoint inventory stored as semantic nodes and dependencies.
|
||||
@@ -0,0 +1,11 @@
|
||||
[project]
|
||||
name = "sfera-integration-topology"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"pydantic>=2.0",
|
||||
"sfera-sir",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from sir import EdgeKind, NodeKind, SirSnapshot
|
||||
|
||||
|
||||
class IntegrationKind(str, Enum):
|
||||
HTTP_SERVICE = "HTTP_SERVICE"
|
||||
WEB_SERVICE = "WEB_SERVICE"
|
||||
EXCHANGE_PLAN = "EXCHANGE_PLAN"
|
||||
FILE_EXCHANGE = "FILE_EXCHANGE"
|
||||
COM_CONNECTOR = "COM_CONNECTOR"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class IntegrationEndpoint(BaseModel):
|
||||
endpoint_id: str
|
||||
name: str
|
||||
kind: IntegrationKind = IntegrationKind.UNKNOWN
|
||||
direction: str = "UNKNOWN"
|
||||
owner: str | None = None
|
||||
attributes: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class IntegrationTopology(BaseModel):
|
||||
project_id: str
|
||||
endpoints: list[IntegrationEndpoint] = Field(default_factory=list)
|
||||
|
||||
def by_kind(self, kind: IntegrationKind) -> list[IntegrationEndpoint]:
|
||||
return [endpoint for endpoint in self.endpoints if endpoint.kind == kind]
|
||||
|
||||
|
||||
_URL_RE = re.compile(r"https?://[^\"'\s;]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def build_integration_topology(snapshot: SirSnapshot) -> IntegrationTopology:
|
||||
endpoints: list[IntegrationEndpoint] = []
|
||||
nodes = {node.lineage_id: node for node in snapshot.nodes}
|
||||
graph_endpoint_lineages = set()
|
||||
for edge in snapshot.edges:
|
||||
if edge.kind != EdgeKind.USES_INTEGRATION or edge.target_lineage not in nodes:
|
||||
continue
|
||||
endpoint_node = nodes[edge.target_lineage]
|
||||
if endpoint_node.kind != NodeKind.INTEGRATION_ENDPOINT:
|
||||
continue
|
||||
owner = nodes.get(edge.source_lineage)
|
||||
graph_endpoint_lineages.add(endpoint_node.lineage_id)
|
||||
endpoints.append(
|
||||
IntegrationEndpoint(
|
||||
endpoint_id=f"integration.{endpoint_node.lineage_id}",
|
||||
name=endpoint_node.name,
|
||||
kind=IntegrationKind(endpoint_node.attributes.get("integration_kind", "UNKNOWN")),
|
||||
direction=str(endpoint_node.attributes.get("direction", "UNKNOWN")),
|
||||
owner=owner.qualified_name if owner is not None else None,
|
||||
attributes=endpoint_node.attributes,
|
||||
)
|
||||
)
|
||||
for node in snapshot.nodes:
|
||||
if node.kind == NodeKind.MODULE and not graph_endpoint_lineages:
|
||||
endpoints.extend(_module_integrations(node))
|
||||
elif node.kind == NodeKind.EXCHANGE_PLAN:
|
||||
endpoints.append(
|
||||
IntegrationEndpoint(
|
||||
endpoint_id=f"integration.{node.lineage_id}",
|
||||
name=node.name,
|
||||
kind=IntegrationKind.EXCHANGE_PLAN,
|
||||
direction="BIDIRECTIONAL",
|
||||
owner=node.qualified_name,
|
||||
attributes={"qualified_name": node.qualified_name},
|
||||
)
|
||||
)
|
||||
return IntegrationTopology(project_id=snapshot.project_id, endpoints=_dedupe_endpoints(endpoints))
|
||||
|
||||
|
||||
def _module_integrations(node) -> list[IntegrationEndpoint]:
|
||||
text = str(node.attributes.get("source_text", ""))
|
||||
if not text:
|
||||
return []
|
||||
endpoints: list[IntegrationEndpoint] = []
|
||||
for index, url in enumerate(_URL_RE.findall(text), start=1):
|
||||
endpoints.append(
|
||||
IntegrationEndpoint(
|
||||
endpoint_id=f"integration.{node.lineage_id}.url.{index}",
|
||||
name=url,
|
||||
kind=IntegrationKind.HTTP_SERVICE,
|
||||
direction="OUTBOUND",
|
||||
owner=node.qualified_name,
|
||||
attributes={"url": url},
|
||||
)
|
||||
)
|
||||
if "HTTPСоединение" in text or "HTTPConnection" in text:
|
||||
endpoints.append(_code_endpoint(node, "HTTPConnection", IntegrationKind.HTTP_SERVICE, "OUTBOUND"))
|
||||
if "WSПрокси" in text or "WSProxy" in text or "WSСсылка" in text:
|
||||
endpoints.append(_code_endpoint(node, "WSProxy", IntegrationKind.WEB_SERVICE, "OUTBOUND"))
|
||||
if "FTPСоединение" in text or "FTPConnection" in text:
|
||||
endpoints.append(_code_endpoint(node, "FTPConnection", IntegrationKind.FILE_EXCHANGE, "OUTBOUND"))
|
||||
if "COMОбъект" in text or "COMObject" in text:
|
||||
endpoints.append(_code_endpoint(node, "COMObject", IntegrationKind.COM_CONNECTOR, "OUTBOUND"))
|
||||
return endpoints
|
||||
|
||||
|
||||
def _code_endpoint(node, name: str, kind: IntegrationKind, direction: str) -> IntegrationEndpoint:
|
||||
return IntegrationEndpoint(
|
||||
endpoint_id=f"integration.{node.lineage_id}.{name.casefold()}",
|
||||
name=name,
|
||||
kind=kind,
|
||||
direction=direction,
|
||||
owner=node.qualified_name,
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_endpoints(endpoints: list[IntegrationEndpoint]) -> list[IntegrationEndpoint]:
|
||||
seen: dict[tuple[str, str, str | None], IntegrationEndpoint] = {}
|
||||
for endpoint in endpoints:
|
||||
seen.setdefault((endpoint.name, endpoint.kind.value, endpoint.owner), endpoint)
|
||||
return sorted(seen.values(), key=lambda endpoint: (endpoint.kind.value, endpoint.name))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IntegrationEndpoint",
|
||||
"IntegrationKind",
|
||||
"IntegrationTopology",
|
||||
"build_integration_topology",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
from integration_topology import IntegrationEndpoint, IntegrationKind, IntegrationTopology
|
||||
from integration_topology import build_integration_topology
|
||||
from semantic_kernel import index_project
|
||||
|
||||
|
||||
def test_integration_topology_filters_by_kind():
|
||||
topology = IntegrationTopology(
|
||||
project_id="demo",
|
||||
endpoints=[
|
||||
IntegrationEndpoint(
|
||||
endpoint_id="endpoint.1",
|
||||
name="OrdersApi",
|
||||
kind=IntegrationKind.HTTP_SERVICE,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert topology.by_kind(IntegrationKind.HTTP_SERVICE)[0].name == "OrdersApi"
|
||||
|
||||
|
||||
def test_build_integration_topology_from_bsl_and_exchange_plan(tmp_path):
|
||||
module = tmp_path / "integration.bsl"
|
||||
module.write_text(
|
||||
"""
|
||||
Процедура ОтправитьЗаказ()
|
||||
Соединение = Новый HTTPСоединение("api.example.local");
|
||||
Адрес = "https://api.example.local/orders";
|
||||
Объект = Новый COMОбъект("V83.Application");
|
||||
КонецПроцедуры
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "metadata.xml").write_text(
|
||||
"""
|
||||
<Configuration>
|
||||
<ExchangePlan name="ОбменСКассой" qualifiedName="ПланОбмена.ОбменСКассой" />
|
||||
</Configuration>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
snapshot = index_project(tmp_path, project_id="integrations")
|
||||
|
||||
topology = build_integration_topology(snapshot)
|
||||
|
||||
assert topology.by_kind(IntegrationKind.HTTP_SERVICE)
|
||||
assert topology.by_kind(IntegrationKind.COM_CONNECTOR)[0].name == "COMObject"
|
||||
assert topology.by_kind(IntegrationKind.EXCHANGE_PLAN)[0].name == "ОбменСКассой"
|
||||
Reference in New Issue
Block a user