Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ class MSSharePointRetriever:
download or convert the underlying files. Compose a downstream fetcher/converter (such as
`MSSharePointFetcher`) when full content is needed.

The retriever takes a per-user `access_token` as a run input, typically wired
from an upstream `OAuthResolver`. The token must carry delegated Microsoft Graph permissions
(for example `Files.Read.All` and, for site/list scoping, `Sites.Read.All`). The Search API supports
delegated permissions only.
The retriever takes an `access_token` as a run input. For delegated (on-behalf-of) auth, pass a
per-user token wired from an upstream `OAuthResolver`; the token must carry delegated Microsoft Graph
permissions (for example `Files.Read.All` and, for site/list scoping, `Sites.Read.All`). For app-only
(client-credentials) auth, pass an application token and set the `region` parameter at init time.

### Usage example
```python
Expand Down Expand Up @@ -76,6 +76,7 @@ def __init__(
top_k: int = 10,
fields: list[str] | None = None,
query_template: str | None = None,
region: str | None = None,
graph_url: str = DEFAULT_GRAPH_URL,
timeout: float = 30.0,
max_retries: int = 3,
Expand All @@ -96,6 +97,10 @@ def __init__(
`'{searchTerms} path:"https://contoso.sharepoint.com/sites/Team"'`. The literal `{searchTerms}`
placeholder is replaced by the run-time query. The template uses
[Keyword Query Language (KQL)](https://learn.microsoft.com/en-us/sharepoint/dev/general-development/keyword-query-language-kql-syntax-reference).
:param region: The region code for the Microsoft Search index, for example `"NAM"`, `"EUR"`, or `"APC"`.
Required when using application permissions (app-only / client-credentials auth); omit for delegated
(on-behalf-of) tokens. See the `region` property of the
[searchRequest resource](https://learn.microsoft.com/en-us/graph/api/resources/searchrequest).
:param graph_url: The Microsoft Graph base URL. Defaults to `https://graph.microsoft.com/v1.0`.
Override for sovereign clouds.
:param timeout: The HTTP timeout in seconds for each request to Microsoft Graph.
Expand All @@ -121,6 +126,7 @@ def __init__(
self.top_k = top_k
self.fields = fields
self.query_template = query_template
self.region = region
self.graph_url = graph_url.rstrip("/")
self.timeout = timeout
self.max_retries = max_retries
Expand All @@ -134,9 +140,9 @@ def run(self, query: str, access_token: str | Secret, top_k: int | None = None)
operators directly in the query, for example `filetype:docx`, `author:"Jane Doe"`, or
`path:"https://contoso.sharepoint.com/sites/Team"`. See the
[KQL syntax reference](https://learn.microsoft.com/en-us/sharepoint/dev/general-development/keyword-query-language-kql-syntax-reference).
:param access_token: A delegated Microsoft Graph bearer token for the user whose content is searched,
typically wired from an upstream `OAuthResolver` (which emits a plain `str`). A `Secret` is also
accepted and resolved internally.
:param access_token: A Microsoft Graph bearer token. For delegated auth, pass a per-user token wired from
an upstream `OAuthResolver`. For app-only (client-credentials) auth, pass an application token and set
`region` at init time. A `Secret` is also accepted and resolved internally.
:param top_k: Overrides the `top_k` configured at initialization for this run.
:returns: A dictionary with a `documents` key holding the list of retrieved `Document` objects.
:raises SharePointConfigError: If `access_token` is a `Secret` that does not resolve to a string.
Expand Down Expand Up @@ -171,9 +177,9 @@ async def run_async(
operators directly in the query, for example `filetype:docx`, `author:"Jane Doe"`, or
`path:"https://contoso.sharepoint.com/sites/Team"`. See the
[KQL syntax reference](https://learn.microsoft.com/en-us/sharepoint/dev/general-development/keyword-query-language-kql-syntax-reference).
:param access_token: A delegated Microsoft Graph bearer token for the user whose content is searched,
typically wired from an upstream `OAuthResolver` (which emits a plain `str`). A `Secret` is also
accepted and resolved internally.
:param access_token: A Microsoft Graph bearer token. For delegated auth, pass a per-user token wired from
an upstream `OAuthResolver`. For app-only (client-credentials) auth, pass an application token and set
`region` at init time. A `Secret` is also accepted and resolved internally.
:param top_k: Overrides the `top_k` configured at initialization for this run.
:returns: A dictionary with a `documents` key holding the list of retrieved `Document` objects.
:raises SharePointConfigError: If `access_token` is a `Secret` that does not resolve to a string.
Expand Down Expand Up @@ -215,6 +221,8 @@ def _build_request_body(self, query: str, offset: int, size: int) -> dict[str, A
}
if self.fields:
request["fields"] = self.fields
if self.region:
request["region"] = self.region

return {"requests": [request]}

Expand Down Expand Up @@ -328,6 +336,7 @@ def to_dict(self) -> dict[str, Any]:
top_k=self.top_k,
fields=self.fields,
query_template=self.query_template,
region=self.region,
graph_url=self.graph_url,
timeout=self.timeout,
max_retries=self.max_retries,
Expand Down
71 changes: 71 additions & 0 deletions integrations/microsoft_sharepoint/tests/test_retriever.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import httpx
import pytest
import requests
from haystack import Document, Pipeline
from haystack.utils import Secret

Expand Down Expand Up @@ -100,6 +101,7 @@ def test_defaults(self):
assert retriever.top_k == 10
assert retriever.fields is None
assert retriever.query_template is None
assert retriever.region is None
assert retriever.graph_url == "https://graph.microsoft.com/v1.0"
assert retriever.timeout == 30.0
assert retriever.max_retries == 3
Expand Down Expand Up @@ -140,12 +142,18 @@ def test_to_dict(self):
"top_k": 5,
"fields": ["title"],
"query_template": '{searchTerms} path:"https://x"',
"region": None,
"graph_url": "https://graph.microsoft.us/v1.0",
"timeout": 10.0,
"max_retries": 1,
},
}

def test_to_dict_with_region(self):
retriever = MSSharePointRetriever(region="NAM")
data = retriever.to_dict()
assert data["init_parameters"]["region"] == "NAM"

def test_from_dict_round_trip(self):
retriever = MSSharePointRetriever(entity_types=["site"], top_k=7, query_template="{searchTerms}")
restored = MSSharePointRetriever.from_dict(retriever.to_dict())
Expand Down Expand Up @@ -241,6 +249,19 @@ def test_no_query_template_omits_key(self):
assert "queryTemplate" not in mock_post.call_args.kwargs["json"]["requests"][0]["query"]
assert "fields" not in mock_post.call_args.kwargs["json"]["requests"][0]

def test_region_included_in_request_body_when_set(self):
retriever = MSSharePointRetriever(region="NAM")
with patch.object(httpx.Client, "post", return_value=_make_response(json_body=EMPTY_RESPONSE)) as mock_post:
retriever.run(query="q", access_token="tok")
request = mock_post.call_args.kwargs["json"]["requests"][0]
assert request["region"] == "NAM"

def test_region_omitted_from_request_body_when_none(self):
retriever = MSSharePointRetriever()
with patch.object(httpx.Client, "post", return_value=_make_response(json_body=EMPTY_RESPONSE)) as mock_post:
retriever.run(query="q", access_token="tok")
assert "region" not in mock_post.call_args.kwargs["json"]["requests"][0]

def test_empty_results(self):
retriever = MSSharePointRetriever()
with patch.object(httpx.Client, "post", return_value=_make_response(json_body=EMPTY_RESPONSE)):
Expand Down Expand Up @@ -410,3 +431,53 @@ def test_run_against_microsoft_graph(self):
for doc in documents:
assert isinstance(doc, Document)
assert "web_url" in doc.meta


@pytest.mark.integration
@pytest.mark.skipif(
not all(os.environ.get(v) for v in ("MS_GRAPH_TENANT_ID", "MS_GRAPH_CLIENT_ID", "MS_GRAPH_CLIENT_SECRET")),
reason="MS_GRAPH_TENANT_ID / MS_GRAPH_CLIENT_ID / MS_GRAPH_CLIENT_SECRET not set",
)
class TestLiveAppOnly:
Comment thread
SyedShahmeerAli12 marked this conversation as resolved.
"""
End-to-end tests using app-only (client credentials) authentication.

Set these env vars before running:
MS_GRAPH_TENANT_ID — Azure AD tenant ID
MS_GRAPH_CLIENT_ID — App registration client ID
MS_GRAPH_CLIENT_SECRET — App registration client secret
MS_GRAPH_REGION — Search region, e.g. "NAM" (default: "DEU")

Run with:
hatch run test:integration
"""

@staticmethod
def _get_app_token() -> str:
tenant_id = os.environ["MS_GRAPH_TENANT_ID"]
client_id = os.environ["MS_GRAPH_CLIENT_ID"]
client_secret = os.environ["MS_GRAPH_CLIENT_SECRET"]
response = requests.post(
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
data={
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials",
},
timeout=30,
)
response.raise_for_status()
return response.json()["access_token"]

def test_app_only_search_with_region_succeeds(self):
token = self._get_app_token()
region = os.environ.get("MS_GRAPH_REGION", "DEU")

# With region, the request should succeed.
retriever = MSSharePointRetriever(top_k=3, region=region, max_retries=0)
documents = retriever.run(query="test", access_token=token)["documents"]
assert isinstance(documents, list)
for doc in documents:
assert isinstance(doc, Document)
assert "web_url" in doc.meta
Loading