mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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
This commit is contained in:
@@ -382,6 +382,45 @@ For authentication details (PAT vs. OAuth M2M for Databricks; password vs. key-p
|
||||
|
||||
> **Security Note:** Never hardcode credentials (`token`, `password`, `private_key`) in production code; pass them via environment variables (e.g., `DATABRICKS_TOKEN`, `SNOWFLAKE_PASSWORD`) or a secrets manager.
|
||||
|
||||
## Source 7 — SAP OData
|
||||
|
||||
`SAPIngestor` ingests an Entity Set from a SAP OData service (S/4HANA Cloud, SuccessFactors, or an on-prem NetWeaver Gateway over its REST surface). It speaks OData v2 and v4, follows server-driven pagination automatically, and flattens each record into a document dict via `export_as_documents()` — the same structured "transform to text, then store" pattern as the other sources.
|
||||
|
||||
```python
|
||||
from semantica.ingest import SAPIngestor
|
||||
|
||||
ing = SAPIngestor(
|
||||
base_url="https://my-sap.example.com/sap/opu/odata/sap/API_BUSINESS_PARTNER",
|
||||
client_id="...", client_secret="...",
|
||||
token_url="https://my-sap.example.com/oauth/token", # OAuth2 client-credentials (BTP/S/4HANA Cloud)
|
||||
# On-prem NetWeaver often uses Basic auth instead — swap the block above for:
|
||||
# username="erp_user", password="...",
|
||||
)
|
||||
|
||||
# 1. Discover an unfamiliar service: entity sets + field types from $metadata
|
||||
sets = ing.discover_service() # [{"name": "A_BusinessPartnerSet", "fields": [...]}, ...]
|
||||
|
||||
# 2. Pull a page-walked Entity Set (v2/v4 next links handled for you)
|
||||
partners = ing.ingest_entity_set(
|
||||
entity_set="A_BusinessPartnerSet",
|
||||
select="BusinessPartner,BusinessPartnerFullName", # $select
|
||||
top=1000, # cap on total rows
|
||||
)
|
||||
|
||||
# 3. Flatten to document dicts, then build text for the graph
|
||||
docs = ing.export_as_documents(partners)
|
||||
partner_texts = [
|
||||
f"Business Partner {d['BusinessPartner']}: {d['BusinessPartnerFullName']}"
|
||||
for d in docs
|
||||
]
|
||||
```
|
||||
|
||||
- Use `expand="to_Item"` (e.g. on a sales-order header set) to pull nested line items in one request — handy for modeling order → line-item → material relationships.
|
||||
- Every outbound request, including the OAuth2 token exchange, is routed through the SSRF guard, so a user-supplied SAP URL can never reach private/loopback/link-local address space.
|
||||
- Install with `pip install 'semantica[ingest-sap]'`.
|
||||
|
||||
> **Security Note:** Never hardcode credentials (`client_secret`, `password`) in code; pass them via environment variables (e.g., `SAP_CLIENT_SECRET`, `SAP_PASSWORD`) or a secrets manager.
|
||||
|
||||
## Combining All Five Sources
|
||||
|
||||
Once you have text from each source, `AgentContext.store()` accepts a flat list of strings. Semantica embeds and indexes them together — the context graph has no concept of which string came from which source unless you add metadata explicitly.
|
||||
|
||||
@@ -28,6 +28,7 @@ icon: "database"
|
||||
| `DBIngestor` | SQL databases via SQLAlchemy: tables, views, and custom queries |
|
||||
| `SnowflakeIngestor` | Snowflake data warehouse queries and table exports |
|
||||
| `DatabricksIngestor` | Databricks Unity Catalog metadata, Delta table queries, and lineage |
|
||||
| `SAPIngestor` | SAP OData services (S/4HANA Cloud, SuccessFactors, NetWeaver Gateway): entity-set discovery and ingestion with v2/v4 pagination |
|
||||
| `ParquetIngestor` | Apache Parquet files and partitioned datasets with column selection |
|
||||
| `ArrowIngestor` | Apache Arrow IPC and Feather file processing |
|
||||
| `XMLIngestor` | XXE-safe XML parsing with optional XSD schema validation |
|
||||
|
||||
@@ -126,6 +126,7 @@ db-databricks = ["databricks-sdk>=0.60.0", "databricks-sql-connector>=4.0.0"]
|
||||
db-arrow = ["pyarrow>=24.0.0"]
|
||||
ingest-parquet = ["pyarrow>=24.0.0"]
|
||||
ingest-arrow = ["pyarrow>=24.0.0"]
|
||||
ingest-sap = ["requests>=2.28.0"]
|
||||
|
||||
db-all = [
|
||||
"semantica[db-snowflake,db-databricks,db-arrow]"
|
||||
|
||||
@@ -218,6 +218,10 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
"SnowflakeIngestor": (".snowflake_ingestor", "SnowflakeIngestor"),
|
||||
"SnowflakeData": (".snowflake_ingestor", "SnowflakeData"),
|
||||
"SnowflakeConnector": (".snowflake_ingestor", "SnowflakeConnector"),
|
||||
# SAP OData ingestion
|
||||
"SAPIngestor": (".sap_ingestor", "SAPIngestor"),
|
||||
"SAPODataEntity": (".sap_ingestor", "SAPODataEntity"),
|
||||
"SAPODataConnector": (".sap_ingestor", "SAPODataConnector"),
|
||||
# Databricks ingestion
|
||||
"DatabricksIngestor": (".databricks_ingestor", "DatabricksIngestor"),
|
||||
"DatabricksData": (".databricks_ingestor", "DatabricksData"),
|
||||
@@ -345,6 +349,10 @@ __all__ = [
|
||||
"SnowflakeIngestor",
|
||||
"SnowflakeData",
|
||||
"SnowflakeConnector",
|
||||
# SAP OData ingestion
|
||||
"SAPIngestor",
|
||||
"SAPODataEntity",
|
||||
"SAPODataConnector",
|
||||
# Databricks ingestion
|
||||
"DatabricksIngestor",
|
||||
"DatabricksData",
|
||||
|
||||
@@ -875,6 +875,57 @@ schema = connector.get_schema(engine)
|
||||
print(f" {table_name}: {[col['name'] for col in columns]}")
|
||||
```
|
||||
|
||||
## SAP OData Ingestion
|
||||
|
||||
`SAPIngestor` reads an Entity Set from a SAP OData service — S/4HANA Cloud,
|
||||
SuccessFactors, or an on-prem NetWeaver Gateway over its REST surface. It
|
||||
follows OData v2/v4 server-driven pagination and flattens each record into a
|
||||
document dict via `export_as_documents()`.
|
||||
|
||||
Install with `pip install 'semantica[ingest-sap]'`.
|
||||
|
||||
### Connector Construction & Authentication
|
||||
|
||||
```python
|
||||
from semantica.ingest import SAPIngestor
|
||||
|
||||
# OAuth2 client-credentials (BTP / S/4HANA Cloud)
|
||||
ing = SAPIngestor(
|
||||
base_url="https://my-sap.example.com/sap/opu/odata/sap/API_BUSINESS_PARTNER",
|
||||
client_id="...", client_secret="...",
|
||||
token_url="https://my-sap.example.com/oauth/token",
|
||||
)
|
||||
# On-prem NetWeaver often uses Basic auth instead — swap the block above for:
|
||||
# ing = SAPIngestor(base_url="...", username="erp_user", password="...")
|
||||
```
|
||||
|
||||
### Entity-Set Ingestion & Document Export
|
||||
|
||||
```python
|
||||
# 1. Discover entity sets + field types from $metadata
|
||||
sets = ing.discover_service()
|
||||
|
||||
# 2. Page-walk an Entity Set (v2/v4 next links handled automatically)
|
||||
partners = ing.ingest_entity_set(
|
||||
entity_set="A_BusinessPartnerSet",
|
||||
select="BusinessPartner,BusinessPartnerFullName",
|
||||
top=1000,
|
||||
)
|
||||
|
||||
# 3. Flatten to document dicts that GraphBuilder can consume directly
|
||||
docs = ing.export_as_documents(partners)
|
||||
```
|
||||
|
||||
- Use `expand="to_Item"` on a sales-order header set to pull nested line items
|
||||
in one request — handy for modeling order → line-item → material relations.
|
||||
- Every outbound request, including the OAuth2 token exchange, is routed through
|
||||
the SSRF guard, and pagination never follows a next link that points to a
|
||||
different host than the service root.
|
||||
|
||||
> **Security Note:** Never hardcode credentials (`client_secret`, `password`);
|
||||
> pass them via environment variables (`SAP_CLIENT_SECRET`, `SAP_PASSWORD`) or a
|
||||
> secrets manager.
|
||||
|
||||
## MCP Server Ingestion
|
||||
|
||||
**IMPORTANT**: This implementation supports **ONLY Python-based MCP servers and FastMCP servers**. Users can bring their own Python or FastMCP MCP servers via URL connections. JavaScript, TypeScript, C#, Java, and other language implementations are **NOT supported**.
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,663 @@
|
||||
"""Tests for the SAP OData ingestor.
|
||||
|
||||
The SAP connector never touches a live SAP system: every outbound request goes
|
||||
through ``semantica.ingest.ssrf.request_with_ssrf_guard`` (see
|
||||
``sap_ingestor.py``), so these tests patch that single entry point and drive
|
||||
the parser / connector / ingestor with canned responses.
|
||||
|
||||
Covered:
|
||||
- ``$metadata`` (CSDL XML) -> entity set discovery & property fields
|
||||
- OData v2 atom (``__next``) and OData v4 (``@odata.nextLink``) pagination
|
||||
- Basic and OAuth2 credential payloads propagated on the request
|
||||
- SSRF guard is used for data *and* token-exchange requests
|
||||
- ``export_as_documents`` yields the flat document shape GraphBuilder expects
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from semantica.ingest import SAPIngestor, SAPODataConnector, SAPODataEntity
|
||||
from semantica.utils.exceptions import ProcessingError, ValidationError
|
||||
|
||||
METADATA_V2 = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<edmx:Edmx Version="1.0" xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx">
|
||||
<edmx:DataServices
|
||||
xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
|
||||
<Schema Namespace="SAPService" xmlns="http://schemas.microsoft.com/ado/2008/09/edm">
|
||||
<EntityContainer Name="Default" m:IsDefaultEntityContainer="true">
|
||||
<EntitySet Name="SalesOrderSet" EntityType="SAPService.SalesOrder"/>
|
||||
<EntitySet Name="CustomerSet" EntityType="SAPService.Customer"/>
|
||||
</EntityContainer>
|
||||
<EntityType Name="SalesOrder">
|
||||
<Key><PropertyRef Name="SalesOrderID"/></Key>
|
||||
<Property Name="SalesOrderID" Type="Edm.String" Nullable="false"/>
|
||||
<Property Name="CustomerID" Type="Edm.String"/>
|
||||
<Property Name="GrossAmount" Type="Edm.Decimal"/>
|
||||
</EntityType>
|
||||
<EntityType Name="Customer">
|
||||
<Key><PropertyRef Name="CustomerID"/></Key>
|
||||
<Property Name="CustomerID" Type="Edm.String" Nullable="false"/>
|
||||
<Property Name="Name" Type="Edm.String"/>
|
||||
</EntityType>
|
||||
</Schema>
|
||||
</edmx:DataServices>
|
||||
</edmx:Edmx>
|
||||
"""
|
||||
|
||||
METADATA_V4 = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
|
||||
<edmx:DataServices>
|
||||
<Schema Namespace="SalesNs" xmlns="http://docs.oasis-open.org/odata/ns/edm">
|
||||
<EntityContainer Name="Svc">
|
||||
<EntitySet Name="SalesOrders" EntityType="SalesNs.SalesOrder"/>
|
||||
<EntitySet Name="Customers" EntityType="SalesNs.Customer"/>
|
||||
</EntityContainer>
|
||||
<EntityType Name="SalesOrder">
|
||||
<Property Name="ID" Type="Edm.String"/>
|
||||
<Property Name="Total" Type="Edm.Decimal" Nullable="false"/>
|
||||
</EntityType>
|
||||
<EntityType Name="Customer">
|
||||
<Property Name="ID" Type="Edm.String"/>
|
||||
<Property Name="Name" Type="Edm.String"/>
|
||||
</EntityType>
|
||||
</Schema>
|
||||
</edmx:DataServices>
|
||||
</edmx:Edmx>
|
||||
"""
|
||||
|
||||
METADATA_MULTI_SCHEMA = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<edmx:Edmx Version="1.0" xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx">
|
||||
<edmx:DataServices>
|
||||
<Schema Namespace="OrdersNs" xmlns="http://schemas.microsoft.com/ado/2008/09/edm">
|
||||
<EntityContainer Name="OrdersSvc">
|
||||
<EntitySet Name="OrdersNs" EntityType="OrdersNs.Row"/>
|
||||
</EntityContainer>
|
||||
<EntityType Name="Row">
|
||||
<Property Name="A" Type="Edm.String"/>
|
||||
</EntityType>
|
||||
</Schema>
|
||||
<Schema Namespace="InvoicesNs" xmlns="http://schemas.microsoft.com/ado/2008/09/edm">
|
||||
<EntityContainer Name="InvoicesSvc">
|
||||
<EntitySet Name="InvoicesNs" EntityType="InvoicesNs.Row"/>
|
||||
</EntityContainer>
|
||||
<EntityType Name="Row">
|
||||
<Property Name="B" Type="Edm.String"/>
|
||||
</EntityType>
|
||||
</Schema>
|
||||
</edmx:DataServices>
|
||||
</edmx:Edmx>
|
||||
"""
|
||||
|
||||
|
||||
def _fake_response(status_code=200, json_payload=None, text="", headers=None):
|
||||
"""requests.Response-like stand-in returned by the mocked guard."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.headers = headers or {}
|
||||
resp.text = text
|
||||
resp.url = "https://sap.example/odata/$metadata"
|
||||
if json_payload is not None:
|
||||
resp.json.return_value = json_payload
|
||||
else:
|
||||
resp.json.side_effect = ValueError("not json")
|
||||
if status_code >= 400:
|
||||
resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
f"{status_code} error"
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def _json_resp(payload, url):
|
||||
r = MagicMock()
|
||||
r.status_code = 200
|
||||
r.headers = {"content-type": "application/json"}
|
||||
r.text = ""
|
||||
r.url = url
|
||||
r.json.return_value = payload
|
||||
return r
|
||||
|
||||
|
||||
class TestDiscoverService:
|
||||
def test_metadata_parses_entity_sets_and_fields(self):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.headers = {"content-type": "application/xml"}
|
||||
resp.text = METADATA_V2
|
||||
resp.json.side_effect = ValueError("xml not json")
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/", username="u", password="p"
|
||||
)
|
||||
sets = ing.discover_service()
|
||||
|
||||
assert guard.call_count == 1
|
||||
assert [s["name"] for s in sets] == ["SalesOrderSet", "CustomerSet"]
|
||||
sales = next(s for s in sets if s["name"] == "SalesOrderSet")
|
||||
assert {f["name"] for f in sales["fields"]} == {
|
||||
"SalesOrderID",
|
||||
"CustomerID",
|
||||
"GrossAmount",
|
||||
}
|
||||
assert sales["fields"][0]["type"] == "Edm.String"
|
||||
|
||||
def test_discover_flow_routes_via_ssrf(self):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.headers = {}
|
||||
resp.text = METADATA_V2
|
||||
resp.json.side_effect = ValueError()
|
||||
|
||||
with patch("semantica.ingest.sap_ingestor.request_with_ssrf_guard") as guard:
|
||||
guard.return_value = resp
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/", username="u", password="p"
|
||||
)
|
||||
ing.discover_service()
|
||||
|
||||
method, url = guard.call_args[0]
|
||||
assert method == "GET"
|
||||
assert url.endswith("$metadata")
|
||||
|
||||
def test_metadata_url_keeps_last_segment_without_trailing_slash(self):
|
||||
"""base_url without a trailing '/' must not lose its last segment.
|
||||
|
||||
urljoin() replaces the final path segment when the base has no
|
||||
trailing slash, which would silently turn .../API_BUSINESS_PARTNER
|
||||
into .../$metadata against the wrong service root.
|
||||
"""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.headers = {}
|
||||
resp.text = METADATA_V2
|
||||
resp.json.side_effect = ValueError()
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/API_BUSINESS_PARTNER",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
ing.discover_service()
|
||||
|
||||
method, url = guard.call_args[0]
|
||||
assert url == "https://sap.example/odata/API_BUSINESS_PARTNER/$metadata"
|
||||
|
||||
def test_service_root_appends_metadata(self):
|
||||
"""service is a service root (same meaning as ingest_entity_set):
|
||||
$metadata is appended automatically, not treated as the full path.
|
||||
"""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.headers = {}
|
||||
resp.text = METADATA_V2
|
||||
resp.json.side_effect = ValueError()
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/sap/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
ing.discover_service("API_BUSINESS_PARTNER")
|
||||
|
||||
_, url = guard.call_args[0]
|
||||
assert url == "https://sap.example/odata/sap/API_BUSINESS_PARTNER/$metadata"
|
||||
|
||||
def test_absolute_service_root_appends_metadata(self):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.headers = {}
|
||||
resp.text = METADATA_V2
|
||||
resp.json.side_effect = ValueError()
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/sap/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
ing.discover_service("https://other.example/services/sap/")
|
||||
|
||||
_, url = guard.call_args[0]
|
||||
assert url == "https://other.example/services/sap/$metadata"
|
||||
|
||||
def test_v4_oasis_metadata_parses_with_nullable_defaults(self):
|
||||
resp = _fake_response(
|
||||
text=METADATA_V4, headers={"content-type": "application/xml"}
|
||||
)
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
sets = ing.discover_service()
|
||||
|
||||
by_name = {s["name"]: s for s in sets}
|
||||
assert "SalesOrders" in by_name and "Customers" in by_name
|
||||
sales_fields = {f["name"]: f for f in by_name["SalesOrders"]["fields"]}
|
||||
# Omitted Nullable -> nullable (CSDL default); explicit "false" honored.
|
||||
assert sales_fields["ID"]["nullable"] is True
|
||||
assert sales_fields["Total"]["nullable"] is False
|
||||
|
||||
def test_same_named_types_in_different_schemas_do_not_merge(self):
|
||||
resp = _fake_response(
|
||||
text=METADATA_MULTI_SCHEMA, headers={"content-type": "application/xml"}
|
||||
)
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
sets = ing.discover_service()
|
||||
|
||||
by_name = {s["name"]: s for s in sets}
|
||||
assert [f["name"] for f in by_name["OrdersNs"]["fields"]] == ["A"]
|
||||
assert [f["name"] for f in by_name["InvoicesNs"]["fields"]] == ["B"]
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_basic_auth_payload_attached(self):
|
||||
conn = SAPODataConnector(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="erp_user",
|
||||
# Non-functional test placeholder; the low-entropy value keeps the
|
||||
# secret scanner from treating it as a hardcoded credential.
|
||||
password="test",
|
||||
)
|
||||
session = conn.get_session()
|
||||
assert session.headers["Authorization"].startswith("Basic ")
|
||||
# base64("erp_user:test")
|
||||
assert session.headers["Authorization"].endswith("ZXJwX3VzZXI6dGVzdA==")
|
||||
|
||||
def test_oauth_token_exchange_goes_through_ssrf(self):
|
||||
token_resp = MagicMock()
|
||||
token_resp.status_code = 200
|
||||
token_resp.headers = {}
|
||||
token_resp.json.return_value = {"access_token": "tok-123"}
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=token_resp,
|
||||
) as guard:
|
||||
conn = SAPODataConnector(
|
||||
base_url="https://sap.example/odata/",
|
||||
token_url="https://auth.example/oauth/token",
|
||||
client_id="cid",
|
||||
client_secret="secret",
|
||||
)
|
||||
session = conn.get_session()
|
||||
|
||||
assert guard.call_count == 1
|
||||
method_tok, url = guard.call_args[0]
|
||||
assert method_tok == "POST"
|
||||
assert url == "https://auth.example/oauth/token"
|
||||
assert session.headers["Authorization"] == "Bearer tok-123"
|
||||
body = guard.call_args.kwargs.get("data", {})
|
||||
assert body["grant_type"] == "client_credentials"
|
||||
|
||||
def test_requires_auth(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SAPODataConnector(base_url="https://sap.example/odata/")
|
||||
|
||||
def test_requires_base_url(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SAPODataConnector(username="u", password="p")
|
||||
|
||||
|
||||
class TestIngestPagination:
|
||||
V4_PAGE = {
|
||||
"value": [{"SalesOrderID": "SO-1"}, {"SalesOrderID": "SO-2"}],
|
||||
"@odata.nextLink": "https://sap.example/odata/SalesOrderSet?$skiptoken=abc",
|
||||
}
|
||||
V4_LAST = {"value": [{"SalesOrderID": "SO-3"}]}
|
||||
|
||||
def test_v4_nextlink_paginates(self):
|
||||
calls = []
|
||||
|
||||
def fake_guard(method, url, **kw):
|
||||
page1 = _json_resp(self.V4_PAGE, url)
|
||||
page2 = _json_resp(self.V4_LAST, url)
|
||||
calls.append(url)
|
||||
return page1 if method == "GET" and len(calls) == 1 else page2
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
side_effect=fake_guard,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
result = ing.ingest_entity_set(entity_set="SalesOrderSet")
|
||||
|
||||
assert result.count == 3
|
||||
assert [r["SalesOrderID"] for r in result.records] == [
|
||||
"SO-1",
|
||||
"SO-2",
|
||||
"SO-3",
|
||||
]
|
||||
# Reached the second page's next link then stopped.
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_v4_nextlink_is_followed_past_first_page(self):
|
||||
calls = []
|
||||
|
||||
def fake_guard(method, url, **kw):
|
||||
calls.append(url)
|
||||
if len(calls) == 1:
|
||||
return _json_resp(self.V4_PAGE, url)
|
||||
return _json_resp(self.V4_LAST, url)
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
side_effect=fake_guard,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
ing.ingest_entity_set(entity_set="SalesOrderSet")
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_v2_atom_next_pagination(self):
|
||||
v2_first = {
|
||||
"d": {
|
||||
"results": [{"SalesOrderID": "A"}],
|
||||
"__next": {"__deferred": {"uri": "https://sap.example/odata/next2"}},
|
||||
},
|
||||
}
|
||||
v2_last = {"d": {"results": [{"SalesOrderID": "B"}]}}
|
||||
calls = []
|
||||
|
||||
def fake_guard(method, url, **kw):
|
||||
calls.append(url)
|
||||
return (
|
||||
_json_resp(v2_first, url)
|
||||
if len(calls) == 1
|
||||
else _json_resp(v2_last, url)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
side_effect=fake_guard,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
result = ing.ingest_entity_set(entity_set="SalesOrderSet")
|
||||
|
||||
assert [r["SalesOrderID"] for r in result.records] == ["A", "B"]
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_v2_next_as_plain_string_paginates(self):
|
||||
"""Canonical OData v2 JSON: ``__next`` is a plain string URL."""
|
||||
v2_first = {
|
||||
"d": {
|
||||
"results": [{"SalesOrderID": "A"}],
|
||||
"__next": "https://sap.example/odata/next2",
|
||||
},
|
||||
}
|
||||
v2_last = {"d": {"results": [{"SalesOrderID": "B"}]}}
|
||||
calls = []
|
||||
|
||||
def fake_guard(method, url, **kw):
|
||||
calls.append(url)
|
||||
if len(calls) == 1:
|
||||
return _json_resp(v2_first, url)
|
||||
return _json_resp(v2_last, url)
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
side_effect=fake_guard,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
result = ing.ingest_entity_set(entity_set="SalesOrderSet")
|
||||
|
||||
assert [r["SalesOrderID"] for r in result.records] == ["A", "B"]
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_relative_service_resolves_against_base(self):
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=_json_resp({"value": []}, "https://x/"),
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/sap/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
ing.ingest_entity_set(
|
||||
service="API_SALES_ORDER_SRV", entity_set="SalesOrderSet"
|
||||
)
|
||||
|
||||
method, url = guard.call_args[0]
|
||||
assert url == "https://sap.example/odata/sap/API_SALES_ORDER_SRV/SalesOrderSet"
|
||||
|
||||
def test_expand_passthrough_for_line_items(self):
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=_json_resp(
|
||||
{"value": [{"SalesOrderID": "SO-9"}]}, "https://x/"
|
||||
),
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/", username="u", password="p"
|
||||
)
|
||||
ing.ingest_entity_set(entity_set="SalesOrderSet", expand="to_Item")
|
||||
|
||||
params = guard.call_args_list[0].kwargs.get("params") or {}
|
||||
assert params.get("$expand") == "to_Item"
|
||||
|
||||
def test_select_filter_top_skip_objects_passthrough(self):
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=_json_resp({"value": []}, "https://x/"),
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/", username="u", password="p"
|
||||
)
|
||||
ing.ingest_entity_set(
|
||||
entity_set="SalesOrderSet",
|
||||
select="SalesOrderID,GrossAmount",
|
||||
filter="GrossAmount gt 100",
|
||||
top=5,
|
||||
skip=2,
|
||||
)
|
||||
params = guard.call_args_list[0].kwargs.get("params") or {}
|
||||
assert params["$select"] == "SalesOrderID,GrossAmount"
|
||||
assert params["$filter"] == "GrossAmount gt 100"
|
||||
assert params["$top"] == "5"
|
||||
assert params["$skip"] == "2"
|
||||
|
||||
|
||||
class TestErrorPaths:
|
||||
def test_http_error_on_entity_set_raises_processing_error(self):
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=_fake_response(status_code=403),
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
with pytest.raises(ProcessingError, match="403"):
|
||||
ing.ingest_entity_set(entity_set="SalesOrderSet")
|
||||
|
||||
def test_invalid_metadata_xml_raises_processing_error(self):
|
||||
resp = _fake_response(text="<not-xml")
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
with pytest.raises(ProcessingError, match="not valid XML"):
|
||||
ing.discover_service()
|
||||
|
||||
def test_non_json_entity_response_raises_processing_error(self):
|
||||
resp = _fake_response(text="plain text")
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
with pytest.raises(ProcessingError, match="not JSON"):
|
||||
ing.ingest_entity_set(entity_set="SalesOrderSet")
|
||||
|
||||
def test_cross_host_next_link_rejected(self):
|
||||
first = _json_resp(
|
||||
{
|
||||
"value": [{"SalesOrderID": "A"}],
|
||||
"@odata.nextLink": "https://evil.example/next",
|
||||
},
|
||||
"https://sap.example/odata/SalesOrderSet",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=first,
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
with pytest.raises(ProcessingError, match="different host"):
|
||||
ing.ingest_entity_set(entity_set="SalesOrderSet")
|
||||
# The credential-bearing session is never sent to the evil host.
|
||||
assert guard.call_count == 1
|
||||
|
||||
def test_top_zero_returns_no_rows_and_makes_no_request(self):
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
) as guard:
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
result = ing.ingest_entity_set(entity_set="SalesOrderSet", top=0)
|
||||
|
||||
assert result.count == 0
|
||||
assert result.records == []
|
||||
guard.assert_not_called()
|
||||
|
||||
def test_negative_top_rejected(self):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
with pytest.raises(ValidationError, match="must be >= 0"):
|
||||
ing.ingest_entity_set(entity_set="SalesOrderSet", top=-1)
|
||||
|
||||
def test_malformed_rows_container_rejected(self):
|
||||
resp = _json_resp(
|
||||
{"value": {"not": "a list"}},
|
||||
"https://sap.example/odata/SalesOrderSet",
|
||||
)
|
||||
with patch(
|
||||
"semantica.ingest.sap_ingestor.request_with_ssrf_guard",
|
||||
return_value=resp,
|
||||
):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
with pytest.raises(ProcessingError, match="no list of rows"):
|
||||
ing.ingest_entity_set(entity_set="SalesOrderSet")
|
||||
|
||||
|
||||
class TestExport:
|
||||
def test_export_as_documents_flattens_to_graph_builder_shape(self):
|
||||
ing = SAPIngestor(
|
||||
base_url="https://sap.example/odata/",
|
||||
username="u",
|
||||
password="p",
|
||||
)
|
||||
entity = SAPODataEntity(
|
||||
records=[{"SalesOrderID": "SO-1", "CustomerID": "C-1"}],
|
||||
entity_set="SalesOrderSet",
|
||||
count=1,
|
||||
service="https://sap.example/odata/",
|
||||
)
|
||||
docs = ing.export_as_documents(entity)
|
||||
assert docs[0]["SalesOrderID"] == "SO-1"
|
||||
assert docs[0]["id"] == "SO-1"
|
||||
assert docs[0]["source"] == "https://sap.example/odata/"
|
||||
|
||||
|
||||
class TestEntityDocument:
|
||||
def test_to_document_adds_source(self):
|
||||
ent = SAPODataEntity(
|
||||
records=[{"k": "v"}],
|
||||
entity_set="S",
|
||||
count=1,
|
||||
service="https://sap.example/odata/",
|
||||
)
|
||||
assert ent.to_documents()[0]["source"] == "https://sap.example/odata/"
|
||||
|
||||
def test_to_document_injects_graph_builder_identifier(self):
|
||||
ent = SAPODataEntity(
|
||||
records=[{"SalesOrderID": "SO-1", "CustomerID": "C-1"}],
|
||||
entity_set="SalesOrderSet",
|
||||
count=1,
|
||||
service="https://sap.example/odata/",
|
||||
)
|
||||
doc = ent.to_documents()[0]
|
||||
assert doc["id"] == "SO-1"
|
||||
assert doc["name"] == "SO-1"
|
||||
assert doc["SalesOrderID"] == "SO-1" # original field preserved
|
||||
|
||||
def test_to_document_falls_back_to_entity_set_index(self):
|
||||
ent = SAPODataEntity(
|
||||
records=[{"GrossAmount": "10.0"}],
|
||||
entity_set="SalesOrderSet",
|
||||
count=1,
|
||||
service="https://sap.example/odata/",
|
||||
)
|
||||
assert ent.to_documents()[0]["id"] == "SalesOrderSet:0"
|
||||
|
||||
def test_public_import_through_lazy_export(self):
|
||||
# __getattr__ should resolve the lazy export once the object is created
|
||||
assert callable(SAPODataConnector)
|
||||
Reference in New Issue
Block a user