Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

{Graph} Make msgraph internal and add documentation for GraphClient #22560

Merged
merged 4 commits into from
May 24, 2022
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
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