Skip to content

Commit de34c2f

Browse files
authored
Merge pull request #82 from microsoftgraph/feat/graph-client-factory
Feat/graph client factory
2 parents 5f4d295 + b56f603 commit de34c2f

18 files changed

+773
-324
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@ jobs:
3838
pipenv run pylint msgraphcore --disable=W --rcfile=.pylintrc
3939
- name: Test with unittest
4040
run: |
41-
pipenv run coverage run -m unittest discover tests/unit
42-
pipenv run coverage run -m unittest discover tests/integration
41+
pipenv run coverage run -m pytest tests/unit
42+
pipenv run coverage run -m pytest tests/integration
4343
pipenv run coverage html

.pre-commit-config.yaml

+14
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,17 @@ repos:
2323
language: system
2424
entry: pipenv run pylint msgraphcore --disable=W --rcfile=.pylintrc
2525
types: [python]
26+
27+
- id: pytest-unit
28+
name: pytest-unit
29+
stages: [commit]
30+
language: system
31+
entry: pipenv run coverage run -m pytest tests/unit
32+
types: [python]
33+
34+
- id: pytest-integration
35+
name: pytest-integration
36+
stages: [commit]
37+
language: system
38+
entry: pipenv run coverage run -m pytest tests/integration
39+
types: [python]

Pipfile

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ pre-commit = "==2.10.1"
99

1010
[dev-packages] # Packages required to develop the application
1111
coverage = "==5.0.3"
12-
pylint = "==2.6.0"
1312
responses = "==0.10.12"
1413
flit = "==2.2.0"
1514
azure-identity = "==1.5.0"
1615
isort = "==5.7.0"
1716
yapf = "==0.30.0"
1817
mypy = "==0.800"
18+
pylint = "==2.7.4"
19+
pytest = "*"

Pipfile.lock

+173-96
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/design/client_factory.puml

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@startuml ClientFactory
2+
enum NationalClouds {
3+
+GERMANY
4+
+PUBLIC
5+
+US_GOV
6+
+CHINA
7+
}
8+
9+
class HttpClientFactory {
10+
-TIMEOUT: string
11+
-SDK_VERSION: string
12+
-BASE_URL: string
13+
-pipeline: MiddlewarePipeline
14+
15+
+__init__(session: Session, cloud: NationalClouds)
16+
+with_default_middleware(auth_provider: TokenCredential): Session
17+
+with_user_middleware(middleware: [Middleware]): Session
18+
}
19+
20+
21+
class Session {}
22+
23+
class GraphClient {
24+
-session: Session
25+
26+
+__init__(session: Session, credential: TokenCredential,
27+
version: ApiVersion, cloud: NationalClouds)
28+
+get()
29+
+post()
30+
+put()
31+
+patch()
32+
+delete()
33+
}
34+
35+
package "middleware" {
36+
class MiddlewarePipeline {}
37+
}
38+
39+
HttpClientFactory --> NationalClouds
40+
HttpClientFactory -right-> middleware
41+
HttpClientFactory --> Session
42+
43+
GraphClient -right-> HttpClientFactory
44+
45+
note right of Session: HTTPClient imported from requests
46+
@enduml

msgraphcore/__init__.py

-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
"""msgraph-core"""
2-
3-
from msgraphcore.graph_session import GraphSession
4-
51
from .constants import SDK_VERSION
62

73
__version__ = SDK_VERSION

msgraphcore/client_factory.py

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import functools
2+
from typing import Optional
3+
4+
from requests import Session
5+
6+
from msgraphcore.constants import CONNECTION_TIMEOUT, REQUEST_TIMEOUT
7+
from msgraphcore.enums import APIVersion, NationalClouds
8+
from msgraphcore.middleware.abc_token_credential import TokenCredential
9+
from msgraphcore.middleware.authorization import AuthorizationHandler
10+
from msgraphcore.middleware.middleware import BaseMiddleware, MiddlewarePipeline
11+
12+
13+
class HTTPClientFactory:
14+
"""Constructs native HTTP Client(session) instances configured with either custom or default
15+
pipeline of middleware.
16+
17+
:func: Class constructor accepts a user provided session object and kwargs to configure the
18+
request handling behaviour of the client
19+
:keyword enum api_version: The Microsoft Graph API version to be used, for example
20+
`APIVersion.v1` (default). This value is used in setting the base url for all requests for
21+
that session.
22+
:class:`~msgraphcore.enums.APIVersion` defines valid API versions.
23+
:keyword enum cloud: a supported Microsoft Graph cloud endpoint.
24+
Defaults to `NationalClouds.Global`
25+
:class:`~msgraphcore.enums.NationalClouds` defines supported sovereign clouds.
26+
:keyword tuple timeout: Default connection and read timeout values for all session requests.
27+
Specify a tuple in the form of Tuple(connect_timeout, read_timeout) if you would like to set
28+
the values separately. If you specify a single value for the timeout, the timeout value will
29+
be applied to both the connect and the read timeouts.
30+
:keyword obj session: A custom Session instance from the python requests library
31+
"""
32+
def __init__(self, **kwargs):
33+
"""Class constructor that accepts a user provided session object and kwargs
34+
to configure the request handling behaviour of the client"""
35+
self.api_version = kwargs.get('api_version', APIVersion.v1)
36+
self.endpoint = kwargs.get('cloud', NationalClouds.Global)
37+
self.timeout = kwargs.get('timeout', (CONNECTION_TIMEOUT, REQUEST_TIMEOUT))
38+
self.session = kwargs.get('session', Session())
39+
40+
self._set_base_url()
41+
self._set_default_timeout()
42+
43+
def create_with_default_middleware(self, credential: TokenCredential, **kwargs) -> Session:
44+
"""Applies the default middleware chain to the HTTP Client
45+
46+
:param credential: TokenCredential used to acquire an access token for the Microsoft
47+
Graph API. Created through one of the credential classes from `azure.identity`
48+
"""
49+
middleware = [
50+
AuthorizationHandler(credential, **kwargs),
51+
]
52+
self._register(middleware)
53+
return self.session
54+
55+
def create_with_custom_middleware(self, middleware: [BaseMiddleware]) -> Session:
56+
"""Applies a custom middleware chain to the HTTP Client
57+
58+
:param list middleware: Custom middleware(HTTPAdapter) list that will be used to create
59+
a middleware pipeline. The middleware should be arranged in the order in which they will
60+
modify the request.
61+
"""
62+
if not middleware:
63+
raise ValueError("Please provide a list of custom middleware")
64+
self._register(middleware)
65+
return self.session
66+
67+
def _set_base_url(self):
68+
"""Helper method to set the base url"""
69+
base_url = self.endpoint + '/' + self.api_version
70+
self.session.base_url = base_url
71+
72+
def _set_default_timeout(self):
73+
"""Helper method to set a default timeout for the session
74+
Reference: https://github.com/psf/requests/issues/2011
75+
"""
76+
self.session.request = functools.partial(self.session.request, timeout=self.timeout)
77+
78+
def _register(self, middleware: [BaseMiddleware]) -> None:
79+
"""
80+
Helper method that constructs a middleware_pipeline with the specified middleware
81+
"""
82+
if middleware:
83+
middleware_pipeline = MiddlewarePipeline()
84+
for ware in middleware:
85+
middleware_pipeline.add_middleware(ware)
86+
87+
self.session.mount('https://', middleware_pipeline)

msgraphcore/enums.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#pylint: disable=invalid-name
2+
3+
from enum import Enum
4+
5+
6+
class APIVersion(str, Enum):
7+
"""Enumerated list of supported API Versions"""
8+
beta = 'beta'
9+
v1 = 'v1.0'
10+
11+
12+
class NationalClouds(str, Enum):
13+
"""Enumerated list of supported sovereign clouds"""
14+
15+
China = 'https://microsoftgraph.chinacloudapi.cn'
16+
Germany = 'https://graph.microsoft.de'
17+
Global = 'https://graph.microsoft.com'
18+
US_DoD = 'https://dod-graph.microsoft.us'
19+
US_GOV = 'https://graph.microsoft.us'

msgraphcore/graph_client.py

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from typing import List, Optional
2+
3+
from requests import Session
4+
5+
from msgraphcore.client_factory import HTTPClientFactory
6+
from msgraphcore.middleware.abc_token_credential import TokenCredential
7+
from msgraphcore.middleware.middleware import BaseMiddleware
8+
from msgraphcore.middleware.options.middleware_control import middleware_control
9+
10+
11+
class GraphClient:
12+
"""Constructs a custom HTTPClient to be used for requests against Microsoft Graph
13+
14+
:keyword credential: TokenCredential used to acquire an access token for the Microsoft
15+
Graph API. Created through one of the credential classes from `azure.identity`
16+
:keyword list middleware: Custom middleware(HTTPAdapter) list that will be used to create
17+
a middleware pipeline. The middleware should be arranged in the order in which they will
18+
modify the request.
19+
:keyword enum api_version: The Microsoft Graph API version to be used, for example
20+
`APIVersion.v1` (default). This value is used in setting the base url for all requests for
21+
that session.
22+
:class:`~msgraphcore.enums.APIVersion` defines valid API versions.
23+
:keyword enum cloud: a supported Microsoft Graph cloud endpoint.
24+
Defaults to `NationalClouds.Global`
25+
:class:`~msgraphcore.enums.NationalClouds` defines supported sovereign clouds.
26+
:keyword tuple timeout: Default connection and read timeout values for all session requests.
27+
Specify a tuple in the form of Tuple(connect_timeout, read_timeout) if you would like to set
28+
the values separately. If you specify a single value for the timeout, the timeout value will
29+
be applied to both the connect and the read timeouts.
30+
:keyword obj session: A custom Session instance from the python requests library
31+
"""
32+
__instance = None
33+
34+
def __new__(cls, *args, **kwargs):
35+
if not GraphClient.__instance:
36+
GraphClient.__instance = object.__new__(cls)
37+
return GraphClient.__instance
38+
39+
def __init__(self, **kwargs):
40+
"""
41+
Class constructor that accepts a session object and kwargs to
42+
be passed to the HTTPClientFactory
43+
"""
44+
self.graph_session = self.get_graph_session(**kwargs)
45+
46+
@middleware_control.get_middleware_options
47+
def get(self, url: str, **kwargs):
48+
r"""Sends a GET request. Returns :class:`Response` object.
49+
:param url: URL for the new :class:`Request` object.
50+
:param \*\*kwargs: Optional arguments that ``request`` takes.
51+
:rtype: requests.Response
52+
"""
53+
return self.graph_session.get(self._graph_url(url), **kwargs)
54+
55+
@middleware_control.get_middleware_options
56+
def post(self, url, data=None, json=None, **kwargs):
57+
r"""Sends a POST request. Returns :class:`Response` object.
58+
:param url: URL for the new :class:`Request` object.
59+
:param data: (optional) Dictionary, list of tuples, bytes, or file-like
60+
object to send in the body of the :class:`Request`.
61+
:param json: (optional) json to send in the body of the :class:`Request`.
62+
:param \*\*kwargs: Optional arguments that ``request`` takes.
63+
:rtype: requests.Response
64+
"""
65+
return self.graph_session.post(self._graph_url(url), data, json, **kwargs)
66+
67+
@middleware_control.get_middleware_options
68+
def put(self, url, data=None, **kwargs):
69+
r"""Sends a PUT request. Returns :class:`Response` object.
70+
:param url: URL for the new :class:`Request` object.
71+
:param data: (optional) Dictionary, list of tuples, bytes, or file-like
72+
object to send in the body of the :class:`Request`.
73+
:param \*\*kwargs: Optional arguments that ``request`` takes.
74+
:rtype: requests.Response
75+
"""
76+
return self.graph_session.put(self._graph_url(url), data, **kwargs)
77+
78+
@middleware_control.get_middleware_options
79+
def patch(self, url, data=None, **kwargs):
80+
r"""Sends a PATCH request. Returns :class:`Response` object.
81+
:param url: URL for the new :class:`Request` object.
82+
:param data: (optional) Dictionary, list of tuples, bytes, or file-like
83+
object to send in the body of the :class:`Request`.
84+
:param \*\*kwargs: Optional arguments that ``request`` takes.
85+
:rtype: requests.Response
86+
"""
87+
return self.graph_session.patch(self._graph_url(url), data, **kwargs)
88+
89+
@middleware_control.get_middleware_options
90+
def delete(self, url, **kwargs):
91+
r"""Sends a DELETE request. Returns :class:`Response` object.
92+
:param url: URL for the new :class:`Request` object.
93+
:param \*\*kwargs: Optional arguments that ``request`` takes.
94+
:rtype: requests.Response
95+
"""
96+
return self.graph_session.delete(self._graph_url(url), **kwargs)
97+
98+
def _graph_url(self, url: str) -> str:
99+
"""Appends BASE_URL to user provided path
100+
:param url: user provided path
101+
:return: graph_url
102+
"""
103+
return self.graph_session.base_url + url if (url[0] == '/') else url
104+
105+
@staticmethod
106+
def get_graph_session(**kwargs):
107+
"""Method to always return a single instance of a HTTP Client"""
108+
109+
credential = kwargs.get('credential')
110+
middleware = kwargs.get('middleware')
111+
112+
if credential and middleware:
113+
raise ValueError(
114+
"Invalid parameters! Both TokenCredential and middleware cannot be passed"
115+
)
116+
if not credential and not middleware:
117+
raise ValueError("Invalid parameters!. Missing TokenCredential or middleware")
118+
119+
if credential:
120+
return HTTPClientFactory(**kwargs).create_with_default_middleware(credential)
121+
return HTTPClientFactory(**kwargs).create_with_custom_middleware(middleware)

0 commit comments

Comments
 (0)