Skip to content

Commit

Permalink
{Graph} Make msgraph internal and add documentation for `GraphClien…
Browse files Browse the repository at this point in the history
…t` (#22560)
  • Loading branch information
jiasli authored May 24, 2022
1 parent a75dcca commit 710c821
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 8 deletions.
127 changes: 127 additions & 0 deletions doc/microsoft_graph_client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# `GraphClient` - Microsoft Graph API client

Azure CLI has been migrated to Microsoft Graph API for Azure Active Directory operations. A lightweight client `azure.cli.command_modules.role._msgrpah.GraphClient` is developed for calling Microsoft Graph API.

## Create a `GraphClient` instance

`GraphClient` should NEVER be instantiated directly, but always through the client factory `azure.cli.command_modules.role.graph_client_factory`.

```py
# Correct!
from azure.cli.command_modules.role import graph_client_factory
graph_client = graph_client_factory(cli_ctx)

# Wrong!
from azure.cli.command_modules.role._msgrpah import GraphClient
graph_client = GraphClient(cli_ctx)
```

## Request

The underlying Microsoft Graph API is exposed as `GraphClient` methods. For example, [Create application](https://docs.microsoft.com/en-us/graph/api/application-post-applications) API corresponds to `application_create`.

The order of the verb ("create") and the noun ("application") is inverted to keep alignment with Azure CLI commands like `az ad app create` and old Python SDK `azure-graphrbac` methods like `graph_client.applications.create`. This makes it easier to find a method/API from the new `GraphClient` and migrate to it:

```diff
- graph_client.applications.create(app_create_param)
+ graph_client.application_create(body)
```

The `body` argument for the request should be a `dict` object as defined by the underlying API. For example, [Create application](https://docs.microsoft.com/en-us/graph/api/application-post-applications) takes a `dict` object defined by [application resource type](https://docs.microsoft.com/en-us/graph/api/resources/application).

For example, to create an application with certain `displayName`:

```py
body = {"displayName": display_name}
app = graph_client.application_create(body)
```

## Response

Like `body`, the response is also a `dict` object, returned by the underlying API. The `dict` object is deserialized from the JSON response, **unchanged**, meaning any client-side manipulation defined by REST API spec (such as flattening) doesn't take place.

For example, to get an application's object ID:

```py
app_object_id = app['id']
```

## Error

All `GraphClient` methods raise an `azure.cli.command_modules.role.GraphError` exception if the underlying APIs return a status code >= 400.

Say we catch the exception as `ex`. `str(ex)` gives the `error.message` field of the response JSON. `ex.response` gives the raw response, as a `requests.models.Response` object.

For example, to retrieve the error message and status code:

```py
message = str(ex)
status_code = ex.response.status_code
```

## A full example

Here is a full example `graph_demo.py`. It does several tasks:

1. Create an application and a service principal for this application.
2. Resolve service principal's object ID from its service principal name.
3. Delete the application.

```py
from azure.cli.core import get_default_cli

from knack.log import get_logger

logger = get_logger(__name__)


def create_application_and_service_principal(cli_ctx, display_name):
"""Create an application with display_name. Then create a service principal for this application."""
from azure.cli.command_modules.role import graph_client_factory, GraphError
graph_client = graph_client_factory(cli_ctx)
try:
body = {"displayName": display_name}
app = graph_client.application_create(body)
sp = graph_client.service_principal_create({"appId": app['appId']})
return app, sp
except GraphError as ex:
logger.exception(ex)


def delete_application(cli_ctx, object_id):
"""Delete an application specified by its object ID."""
from azure.cli.command_modules.role import graph_client_factory, GraphError
graph_client = graph_client_factory(cli_ctx)
try:
graph_client.application_delete(object_id)
except GraphError as ex:
logger.exception(ex)


def resolve_service_principal_id(cli_ctx, service_principal_name):
"""Resolve service principal's object ID from its service principal name."""
from azure.cli.command_modules.role import graph_client_factory, GraphError
graph_client = graph_client_factory(cli_ctx)
try:
service_principals = graph_client.service_principal_list(
filter="servicePrincipalNames/any(c:c eq '{}')".format(service_principal_name))
return service_principals[0]['id'] if service_principals else None
except GraphError as ex:
logger.exception(ex)


def main():
cli_ctx = get_default_cli()

app, sp = create_application_and_service_principal(cli_ctx, 'azure-cli-test')
print('Created application {} and service principal {}'.format(app['id'], sp['id']))

resolved_sp_object_id = resolve_service_principal_id(cli_ctx, sp['appId'])
print('appId {} is resolved to service principal {}'.format(app['appId'], resolved_sp_object_id))

delete_application(cli_ctx, app['id'])


if __name__ == "__main__":
main()
```
2 changes: 1 addition & 1 deletion src/azure-cli/azure/cli/command_modules/role/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from azure.cli.core import AzCommandsLoader
from azure.cli.core.profiles import ResourceType
from ._client_factory import _graph_client_factory as graph_client_factory
from .msgrpah import GraphError
from ._msgrpah import GraphError


class RoleCommandsLoader(AzCommandsLoader):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ def _auth_client_factory(cli_ctx, scope=None):


def _graph_client_factory(cli_ctx, **_):
from .msgrpah import GraphClient
from ._msgrpah import GraphClient
client = GraphClient(cli_ctx)
return client
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
# pylint: disable=redefined-builtin, too-many-public-methods

class GraphClient:
"""A lightweight Microsoft Graph API client.
GraphClient should NEVER be instantiated directly, but always through the client factory
azure.cli.command_modules.role.graph_client_factory.
For full documentation, see doc/microsoft_graph_client.md in this repo.
"""
def __init__(self, cli_ctx):
self.cli_ctx = cli_ctx
self.tenant = Profile(cli_ctx).get_login_credentials()[2]
Expand Down
2 changes: 1 addition & 1 deletion src/azure-cli/azure/cli/command_modules/role/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def _transform_graph_object(result):

def graph_err_handler(ex):
# Convert GraphError to CLIError that can be printed
from .msgrpah import GraphError
from ._msgrpah import GraphError
if isinstance(ex, GraphError):
from knack.util import CLIError
raise CLIError(ex)
Expand Down
2 changes: 1 addition & 1 deletion src/azure-cli/azure/cli/command_modules/role/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from azure.cli.core.util import get_file_json, shell_safe_json_parse, is_guid
from ._client_factory import _auth_client_factory, _graph_client_factory
from ._multi_api_adaptor import MultiAPIAdaptor
from .msgrpah import GraphError, set_object_properties
from ._msgrpah import GraphError, set_object_properties

# ARM RBAC's principalType
USER = 'User'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from azure.cli.testsdk.scenario_tests import AllowLargeResponse
from azure.cli.core.profiles import ResourceType, get_sdk
from azure.cli.testsdk import ScenarioTest, LiveScenarioTest, ResourceGroupPreparer, KeyVaultPreparer
from azure.cli.command_modules.role.msgrpah import GraphError
from azure.cli.command_modules.role._msgrpah import GraphError
from ..util import retry
from .test_graph import GraphScenarioTestBase

Expand Down Expand Up @@ -352,7 +352,7 @@ def _test_role_assignment(assignee_object_id, assignee_principal_type=None, grap

if assignee_principal_type:
# No graph call
with mock.patch('azure.cli.command_modules.role.msgrpah._graph_client.GraphClient.directory_object_get_by_ids') \
with mock.patch('azure.cli.command_modules.role._msgrpah._graph_client.GraphClient.directory_object_get_by_ids') \
as directory_object_get_by_ids_mock:
self.cmd(
'role assignment create --assignee-object-id {object_id} '
Expand All @@ -367,7 +367,7 @@ def _test_role_assignment(assignee_object_id, assignee_principal_type=None, grap
mock_response.status_code = 403
mock_response.reason = 'Forbidden for url: https://...'
with mock.patch(
'azure.cli.command_modules.role.msgrpah._graph_client.GraphClient.directory_object_get_by_ids',
'azure.cli.command_modules.role._msgrpah._graph_client.GraphClient.directory_object_get_by_ids',
side_effect=GraphError('403', mock_response)):
self.cmd('role assignment create --assignee-object-id {object_id} --role Reader -g {rg}')
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
list_service_principal_owners,
list_application_owners,
delete_role_assignments)
from azure.cli.command_modules.role.msgrpah import GraphError
from azure.cli.command_modules.role._msgrpah import GraphError
from azure.cli.core.mock import DummyCli

# pylint: disable=line-too-long
Expand Down

0 comments on commit 710c821

Please sign in to comment.