Files
semantica/semantica/ingest/sap_ingestor.py
T
Kevin Zhang 100e95a098 feat(ingest): add SAP OData ingestor (#1228) (#1234)
Adds `SAPODataConnector`, `SAPODataEntity`, and `SAPIngestor` for ingesting master and transactional data from SAP OData services, mainly things like Business Partners and Sales Orders.

Tested around S/4HANA Cloud, SuccessFactors, and on-prem NetWeaver Gateway style OData endpoints.

Main pieces included:

* OAuth2 client credentials and Basic auth support. Both go through the existing `ssrf.py` checks, including the OAuth token request.
* Small EDMX parser used by `discover_service()` so we don't need to pull in `pyodata`.
* Server-side pagination support for both OData versions:

  * v2: `__next`, including plain string and `__deferred` formats
  * v4: `@odata.nextLink`
* Keeps the service path in the base URL correctly whether the URL has a trailing slash or not. This is normalized in `SAPIngestor.__init__`.
* Adds an `ingest-sap` extra with just `requests`, so there is no SAP/proprietary SDK dependency.

This is meant to be a fairly small first version of the connector without adding a lot of SAP-specific dependencies.

Closes #1228
2026-08-28 14:54:02 +05:00

618 lines
25 KiB
Python

"""SAP OData ingestion module.
Pulls an Entity Set from a SAP OData service (S/4HANA and on-prem NetWeaver
REST surfaces) and flattens it into document dicts that the pipeline can feed
to ``GraphBuilder``.
Why this exists
---------------
Semantica ingests from many sources; SAP is the ERP backbone of finance and
regulated industries, and its master/transactional data (customers, vendors,
sales orders) is exactly the "context" a Context Graph wants. SAP exposes that
data over OData (v2 on many on-prem NetWeaver systems, v4 on BTP / S/4HANA
Cloud). This connector speaks the REST surface of OData only.
Design notes
------------
Three classes, matching the Snowflake/Databricks ingestors:
- ``SAPODataEntity``: a collection fetch from one Entity Set (``records``,
``count``, ``service``, ``metadata``), flattened to document dicts for
``GraphBuilder`` via ``export_as_documents``.
- ``SAPODataConnector``: auth (OAuth2 client-credentials or Basic) + the
shared, SSRF-guarded :mod:`requests` session. *Every* outbound request,
including the OAuth2 token exchange, goes through
``request_with_ssrf_guard`` so user-supplied endpoints can not reach
private/loopback/link-local address space.
- ``SAPIngestor``: the three methods the issue requested —
``discover_service``, ``ingest_entity_set`` and ``export_as_documents``
(plus ``close`` for symmetry with the SQL connectors).
EDMX
----
``$metadata`` is plain CSDL XML in *both* OData v2 and v4, so we hand-roll a
minimal parser with :mod:`xml.etree` instead of pulling in ``pyodata``. That
keeps phase 1 ``requests``-only, exactly as scoped in the issue.
Pagination
----------
OData uses a server-driven "next link": OData v2 surfaces it as the atom
``__next`` element, OData v4 as the ``@odata.nextLink`` field on the JSON
payload. ``ingest_entity_set`` follows whichever it sees until the set is
exhausted.
"""
from __future__ import annotations
import base64
import os
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin, urlparse
import requests
from requests.adapters import HTTPAdapter
try:
from urllib3.util.retry import Retry
except (ImportError, OSError): # pragma: no cover - old urllib3 layout
from requests.packages.urllib3.util.retry import Retry # type: ignore
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from .ssrf import parse_bool, request_with_ssrf_guard
__all__ = [
"SAPODataEntity",
"SAPODataConnector",
"SAPIngestor",
]
_logger = get_logger("sap_ingestor")
def _prop_is_nullable(prop: Any) -> bool:
"""CSDL structural properties default to nullable=True when omitted."""
val = prop.get("Nullable", prop.get("nullable"))
return True if val is None else val.strip().lower() == "true"
def _match(elem: Any, localname: str) -> bool:
"""True if *elem* has the given local name in any namespace."""
return elem.tag.rsplit("}", 1)[-1] == localname
@dataclass
class SAPODataEntity:
"""A collection fetch from a SAP OData Entity Set.
Holds the rows pulled from one Entity Set (all paging fan-in'd), with the
shape the issue specifies: ``records`` (the row data), ``count``,
``service`` (the resolved service root), optional ``metadata`` (entity-set
schema from ``$metadata``) and ``ingested_at``.
"""
records: List[Dict[str, Any]]
entity_set: str
count: int
service: str
metadata: Optional[Dict[str, Any]] = None
ingested_at: datetime = field(default_factory=datetime.now)
def to_documents(self) -> List[Dict[str, Any]]:
"""Flatten each record to a document dict ``GraphBuilder`` can consume.
GraphBuilder only treats a dict as an entity when it carries
``id``/``entity_id``/``name`` (or ``text``+``type``); SAP records have
none of those, so they would be silently dropped. We inject an
identifier resolved from each record's primary-key-like field, falling
back to ``entity_set:index``, and expose it under both ``id`` and
``name``.
"""
docs: List[Dict[str, Any]] = []
for index, record in enumerate(self.records):
doc = dict(record)
key_value = self._id_value(record)
doc.setdefault("id", key_value or f"{self.entity_set}:{index}")
doc.setdefault("name", key_value or self.entity_set)
doc.setdefault("source", self.service)
docs.append(doc)
return docs
@staticmethod
def _id_value(record: Dict[str, Any]) -> str:
for key, value in record.items():
if "id" in key.lower() and value not in (None, ""):
return str(value)
return ""
class SAPODataConnector:
"""Connection + authentication management for a SAP OData REST service.
Supports the two auth landscapes called out in the issue:
- **OAuth2 client-credentials** (BTP / S/4HANA Cloud). The token URL is
user supplied; both the token exchange *and* every subsequent data
request are validated through the SSRF guard.
- **Basic** (on-prem NetWeaver). Username/password passed through as an
``Authorization: Basic`` header, also through the guard.
Example usage::
>>> connector = SAPODataConnector(
... base_url="https://myhost/sap/opu/odata/sap/",
... token_url="https://myhost/oauth/token",
... client_id="cid", client_secret="secret",
... )
>>> session = connector.get_session()
"""
def __init__(
self,
base_url: Optional[str] = None,
*,
auth: Optional[str] = None,
token_url: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
allow_private_ips: bool = False,
**config: Any,
) -> None:
"""Initialize the SAP OData connector.
Args:
base_url: Base OData service URL, e.g. ``https://host/sap/opu
/odata/sap/``. The issue's ``service`` value.
auth: Explicit auth flow, ``"oauth2"`` or ``"basic"``. When
omitted, the flow is inferred from which credentials are set.
token_url: OAuth2 token endpoint. Required only for OAuth2 flow.
client_id: OAuth2 client id (OAuth2 flow).
client_secret: OAuth2 client secret (OAuth2 flow).
username: Basic-auth username (on-prem flow).
password: Basic-auth password (on-prem flow).
allow_private_ips: Opt into private/loopback/link-local endpoints.
Defaults to False (SSRF-safe).
**config: Extra options, notably ``timeout``, ``max_retries``,
``backoff_factor``, ``headers``.
"""
self.logger = _logger
self.base_url = base_url or os.getenv("SAP_BASE_URL")
self.auth = (auth or os.getenv("SAP_AUTH") or "").lower()
self.token_url = token_url or os.getenv("SAP_TOKEN_URL")
self.client_id = client_id or os.getenv("SAP_CLIENT_ID")
self.client_secret = client_secret or os.getenv("SAP_CLIENT_SECRET")
self.username = username or os.getenv("SAP_USERNAME")
self.password = password or os.getenv("SAP_PASSWORD")
self.allow_private_ips = parse_bool(
config.pop("allow_private_ips", allow_private_ips), default=False
)
self.config = config
if not self.base_url:
raise ValidationError(
"SAP base_url is required. Provide via 'base_url' or "
"SAP_BASE_URL environment variable."
)
oauth_configured = bool(self.client_id or self.client_secret or self.token_url)
if self.auth in ("oauth2", "oauth"):
if not (self.client_id and self.client_secret and self.token_url):
raise ValidationError(
"SAP OAuth2 flow requires client_id, client_secret and "
"token_url all set."
)
elif self.auth == "basic":
if not (self.username and self.password):
raise ValidationError("SAP Basic flow requires username and password.")
elif oauth_configured:
if not (self.client_id and self.client_secret and self.token_url):
raise ValidationError(
"SAP OAuth2 flow requires client_id, client_secret and "
"token_url all set."
)
elif not self.username:
raise ValidationError(
"SAP authentication requires either (username/password) or "
"(client_id/client_secret + token_url)."
)
self.session = requests.Session()
retry_strategy = Retry(
total=self.config.get("max_retries", 3),
backoff_factor=self.config.get("backoff_factor", 1),
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
default_headers = self.config.get("headers", {})
if default_headers:
self.session.headers.update(default_headers)
self._token: Optional[str] = None
self.logger.debug(
"SAP OData connector initialized (base_url=%s, allow_private_ips=%s)",
self.base_url,
self.allow_private_ips,
)
def get_session(self) -> requests.Session:
"""Return an authenticated session for data requests.
For the Basic flow the credentials are attached eagerly; for the
OAuth2 flow a token is fetched (and cached) on first use. The token
is never refreshed, so a job that runs past the token TTL (typically
3600s on SAP) will fail with 401 -- re-create the connector instead.
"""
if self.username:
self.session.headers["Authorization"] = "Basic " + self._basic_header()
return self.session
if self._token is None:
self._token = self._fetch_token()
self.session.headers["Authorization"] = "Bearer " + self._token
return self.session
def _basic_header(self) -> str:
pair = f"{self.username}:{self.password or ''}".encode("utf-8")
return base64.b64encode(pair).decode("ascii")
def _fetch_token(self) -> str:
"""Perform the OAuth2 client-credentials token exchange (SSRF-guarded)."""
if not self.token_url or not self.client_id:
raise ProcessingError("OAuth2 flow requires token_url and client_id.")
body = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret or "",
}
resp = request_with_ssrf_guard(
"POST",
self.token_url,
session=self.session,
allow_private_ips=self.allow_private_ips,
data=body,
timeout=self.config.get("timeout", 30),
)
try:
resp.raise_for_status()
except requests.exceptions.RequestException as exc:
raise ProcessingError(f"SAP OAuth2 token exchange failed: {exc}") from exc
try:
payload = resp.json()
except ValueError as exc:
raise ProcessingError(
"SAP OAuth2 token endpoint did not return JSON."
) from exc
token = payload.get("access_token")
if not token:
raise ProcessingError("SAP OAuth2 token response missing 'access_token'.")
return str(token)
def close(self) -> None:
"""Close the underlying :mod:`requests` session."""
self.session.close()
class SAPIngestor:
"""Ingest an Entity Set from a SAP OData service.
Example usage::
>>> from semantica.ingest import SAPIngestor
>>> ing = SAPIngestor(
... base_url="https://host/sap/opu/odata/sap/",
... username="u", password="p", # or client_id/client_secret/token_url
... )
>>> sets = ing.discover_service()
... # -> [{"name": "SalesOrderSet", "fields": [...]}, ...]
>>> docs = ing.export_as_documents(
... ing.ingest_entity_set(entity_set="SalesOrderSet", expand="to_Item"))
"""
def __init__(
self,
base_url: Optional[str] = None,
connector: Optional[SAPODataConnector] = None,
**config: Any,
) -> None:
"""Initialize the SAP ingestor.
Args:
base_url: Base OData service URL. Mutually exclusive with
``connector``; ignored if a connector is given.
connector: An existing :class:`SAPODataConnector`. When provided,
its session and base URL are reused.
**config: Passed to :class:`SAPODataConnector` when one is created.
"""
self.logger = _logger
self.connector = connector or SAPODataConnector(base_url=base_url, **config)
# urljoin() replaces the last path segment unless the base ends in '/',
# so normalize once here: .../API_BUSINESS_PARTNER -> .../$metadata would
# silently drop the service segment.
self._base_url = self.connector.base_url
if not self._base_url.endswith("/"):
self._base_url += "/"
def discover_service(self, service: Optional[str] = None) -> List[Dict[str, Any]]:
"""Fetch and parse ``$metadata`` into the service's entity sets.
Args:
service: Service root URL (absolute) or path suffix resolved
against the base URL. Defaults to the base URL. ``$metadata``
is appended automatically — same meaning as in
:meth:`ingest_entity_set`.
Returns:
List of dicts, one per EntitySet, each with ``name`` and ``fields``
(a list of ``{name, type, nullable}`` parsed from the CSDL).
"""
metadata_url = self._metadata_url(service)
session = self.connector.get_session()
resp = request_with_ssrf_guard(
"GET",
metadata_url,
session=session,
allow_private_ips=self.connector.allow_private_ips,
headers={"Accept": "application/xml"},
timeout=self.connector.config.get("timeout", 30),
)
try:
resp.raise_for_status()
except requests.exceptions.RequestException as exc:
self.logger.error("Failed to fetch SAP metadata %s: %s", metadata_url, exc)
raise ProcessingError(f"Failed to fetch SAP $metadata: {exc}") from exc
return self._parse_metadata(resp.text)
def _metadata_url(self, service: Optional[str]) -> str:
"""Build the ``$metadata`` URL for a service root.
``service`` has the same meaning as in :meth:`ingest_entity_set` —
a service root (absolute URL or path suffix resolved against the
base URL). ``$metadata`` is appended here, so callers pass the root
the same way for both discovery and ingestion. A value already
ending in ``$metadata`` is used as-is.
"""
if not service:
return urljoin(self._base_url, "$metadata")
if "://" not in service:
service = urljoin(self._base_url, service)
if service.endswith("$metadata"):
return service
if not service.endswith("/"):
service += "/"
return urljoin(service, "$metadata")
def _parse_metadata(self, metadata_xml: str) -> List[Dict[str, Any]]:
"""Minimal CSDL/EDMX parser -> entity set name + property fields.
Element local names (``Schema``/``EntitySet``/``EntityType``/
``Property``) are stable across OData v2 (Microsoft ns) and v4 (OASIS
ns), so we match them by local name instead of hard-coding one
namespace. Entity-Type references are resolved per-schema, so
same-named types in different schemas cannot bleed fields into each
other.
"""
try:
root = ET.fromstring(metadata_xml)
except ET.ParseError as exc:
raise ProcessingError(f"SAP $metadata is not valid XML: {exc}") from exc
# Index fully-qualified type name -> property fields, per schema.
schema_types: Dict[str, List[Dict[str, Any]]] = {}
for schema in (e for e in root.iter() if _match(e, "Schema")):
ns = (schema.get("Namespace") or schema.get("namespace") or "").rstrip(".")
for entity_type in (e for e in schema.iter() if _match(e, "EntityType")):
tname = entity_type.get("Name") or entity_type.get("name")
if not tname:
continue
fq = f"{ns}.{tname}" if ns else tname
schema_types[fq] = [
{
"name": prop.get("Name") or prop.get("name"),
"type": prop.get("Type") or prop.get("type"),
"nullable": _prop_is_nullable(prop),
}
for prop in (e for e in entity_type.iter() if _match(e, "Property"))
]
entity_sets: List[Dict[str, Any]] = []
for schema in (e for e in root.iter() if _match(e, "Schema")):
ns = (schema.get("Namespace") or schema.get("namespace") or "").rstrip(".")
for entity_set in (e for e in schema.iter() if _match(e, "EntitySet")):
name = entity_set.get("Name") or entity_set.get("name")
ref = entity_set.get("EntityType") or entity_set.get("entityType") or ""
qualified = ref if "." in ref else (f"{ns}.{ref}" if ns else ref)
fields = schema_types.get(qualified) or schema_types.get(ref) or []
entity_sets.append({"name": name, "fields": fields})
return entity_sets
def ingest_entity_set(
self,
service: Optional[str] = None,
entity_set: Optional[str] = None,
*,
select: Optional[str] = None,
filter: Optional[str] = None,
expand: Optional[str] = None,
top: Optional[int] = None,
skip: Optional[int] = None,
batch_size: int = 100,
) -> SAPODataEntity:
"""Fetch pages of *entity_set* from the OData service.
Args:
service: Service root URL (absolute) or path suffix resolved
against the base URL. Defaults to the base URL. Same meaning
as in :meth:`discover_service`.
entity_set: Entity set name, e.g. ``"SalesOrderSet"``.
select: Optional ``$select`` comma string.
filter: Optional ``$filter`` expression.
expand: Optional ``$expand`` expression (e.g. ``"to_Item"`` for
use case 2's sales-order headers -> line items).
top: Maximum number of rows to return.
skip: Number of leading rows to skip.
batch_size: ``$top`` pagination size per request.
Returns:
A single :class:`SAPODataEntity` holding every fetched record
(server-driven pagination is followed to completion).
"""
if service is None:
base = self._base_url
elif "://" in service:
base = service
else:
base = urljoin(self._base_url, service)
if not base.endswith("/"):
base += "/"
if not entity_set:
raise ValidationError("SAP 'entity_set' is required.")
session = self.connector.get_session()
records: List[Dict[str, Any]] = []
next_link: Optional[str] = urljoin(base, entity_set)
params = self._query_params(select, filter, expand, top, skip, batch_size)
if top is not None and top < 0:
raise ValidationError("SAP 'top' must be >= 0 (got %r)" % top)
if top == 0:
return SAPODataEntity(
records=[], entity_set=entity_set, count=0, service=base
)
original_host = (urlparse(base).hostname or "").lower()
while next_link:
# Server-provided next links may point anywhere; never send the
# session credentials (Basic/Bearer) to a different origin than
# the service root. Legit SAP pagination stays on the same host.
next_host = (urlparse(next_link).hostname or "").lower()
if next_host != original_host:
raise ProcessingError(
f"SAP next link '{next_link}' points to a different host "
f"than service root '{base}'"
)
resp = request_with_ssrf_guard(
"GET",
next_link,
session=session,
allow_private_ips=self.connector.allow_private_ips,
headers={"Accept": "application/json"},
params=params,
timeout=self.connector.config.get("timeout", 30),
)
try:
resp.raise_for_status()
except requests.exceptions.RequestException as exc:
self.logger.error(
"Failed to fetch SAP entity set %s: %s", entity_set, exc
)
raise ProcessingError(
f"Failed to fetch SAP entity set {entity_set}: {exc}"
) from exc
payload = self._parse_page(resp)
rows, next_link = payload["rows"], payload["next_link"]
for raw_row in rows:
records.append(self._flatten_row(raw_row))
self.logger.debug(
"Fetched %d rows from %s (next=%s)",
len(rows),
entity_set,
bool(next_link),
)
if top is not None and len(records) >= top:
break
params = None # query params already baked into the server next link
# Refresh next_link against base in case it's a relative pointer.
if next_link and not next_link.startswith("http"):
next_link = urljoin(resp.url, next_link)
return SAPODataEntity(
records=records,
entity_set=entity_set,
count=len(records),
service=base,
)
def _query_params(
self,
select: Optional[str],
filter: Optional[str],
expand: Optional[str],
top_value: Optional[int],
skip: Optional[int],
batch_size: int,
) -> Dict[str, str]:
params: Dict[str, str] = {}
if batch_size > 0:
if top_value is not None:
params["$top"] = str(min(batch_size, top_value))
else:
params["$top"] = str(batch_size)
if select:
params["$select"] = select
if filter:
params["$filter"] = filter
if expand:
params["$expand"] = expand
if skip is not None:
params["$skip"] = str(skip)
return params
def _parse_page(self, resp: requests.Response) -> Dict[str, Any]:
try:
payload = resp.json()
except ValueError as exc:
raise ProcessingError(f"SAP OData response is not JSON: {exc}") from exc
rows: Any
next_link: Optional[str] = None
if isinstance(payload, list):
rows = payload
elif isinstance(payload, dict):
d = payload.get("d")
if isinstance(d, dict):
# OData v2 atom: {"d": {"results": [...], "__next": ...}}
rows = d.get("results")
nxt = d.get("__next") or payload.get("@odata.nextLink")
else:
# OData v4 JSON: {"value": [...], "@odata.nextLink": ...}
rows = payload.get("value", d)
nxt = payload.get("@odata.nextLink")
if isinstance(nxt, dict):
nxt = nxt.get("__deferred", {}).get("uri")
next_link = nxt
else:
rows = None
if not isinstance(rows, list):
raise ProcessingError(
"SAP OData payload has no list of rows (got %s)" % type(rows).__name__
)
return {"rows": rows, "next_link": next_link}
def _flatten_row(self, row: Any) -> Dict[str, Any]:
if isinstance(row, dict):
# v2 wraps items in "__metadata"; keep it but expose plain keys.
return {k: v for k, v in row.items() if k != "__metadata"}
return {"value": row}
def export_as_documents(self, data: SAPODataEntity) -> List[Dict[str, Any]]:
"""Convert an ingested entity set to flat document dicts.
Normalizes every records held by ``data`` into a list of dicts with an
injected ``id``/``name``/``source``, ready to hand to ``GraphBuilder``.
"""
return data.to_documents()
def close(self) -> None:
"""Close the underlying connector's session."""
self.connector.close()