diff --git a/changelog.d/20230628_152745_sirosen_add_project_crud.rst b/changelog.d/20230628_152745_sirosen_add_project_crud.rst new file mode 100644 index 000000000..450b112ea --- /dev/null +++ b/changelog.d/20230628_152745_sirosen_add_project_crud.rst @@ -0,0 +1,5 @@ +Added +~~~~~ + +- Add ``AuthClient.create_project`` as a method for creating a new Project via + the Globus Auth Developer API (:pr:`NUMBER`) diff --git a/src/globus_sdk/_testing/data/auth/create_project.py b/src/globus_sdk/_testing/data/auth/create_project.py new file mode 100644 index 000000000..5e2a2b0f0 --- /dev/null +++ b/src/globus_sdk/_testing/data/auth/create_project.py @@ -0,0 +1,71 @@ +import uuid + +from globus_sdk._testing.models import RegisteredResponse, ResponseSet + +project_id = str(uuid.uuid1()) +star_lord = { + "identity_provider": str(uuid.uuid1()), + "identity_type": "login", + "organization": "Guardians of the Galaxy", + "status": "used", + "id": str(uuid.uuid1()), + "name": "Star Lord", + "username": "star.lord@guardians.galaxy", + "email": "star.lord2@guardians.galaxy", +} +guardians_group = { + "id": str(uuid.uuid1()), + "name": "Guardians of the Galaxy", + "description": "A group of heroes", + "organization": "Guardians of the Galaxy", +} + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="auth", + path="/v2/api/projects", + method="POST", + json={ + "project": { + "contact_email": "support@globus.org", + "id": project_id, + "admins": { + "identities": [star_lord], + "groups": [], + }, + "project_name": "Guardians of the Galaxy", + "admin_ids": [star_lord["id"]], + "admin_group_ids": None, + "display_name": "Guardians of the Galaxy", + } + }, + metadata={ + "id": project_id, + "admin_id": star_lord["id"], + }, + ), + admin_group=RegisteredResponse( + service="auth", + path="/v2/api/projects", + method="POST", + json={ + "project": { + "contact_email": "support@globus.org", + "id": project_id, + "admins": { + "identities": [], + "groups": [guardians_group], + }, + "project_name": "Guardians of the Galaxy", + "admin_ids": None, + "admin_group_ids": [guardians_group["id"]], + "display_name": "Guardians of the Galaxy", + } + }, + metadata={ + "id": project_id, + "admin_group_id": guardians_group["id"], + }, + ), +) diff --git a/src/globus_sdk/services/auth/client/base.py b/src/globus_sdk/services/auth/client/base.py index 34294fcc9..39b3e4380 100644 --- a/src/globus_sdk/services/auth/client/base.py +++ b/src/globus_sdk/services/auth/client/base.py @@ -318,6 +318,10 @@ def get_identity_providers( self.get("/v2/api/identity_providers", query_params=query_params) ) + # + # Developer APIs + # + def get_projects(self) -> IterableResponse: """ Look up projects on which the authenticated user is an admin. @@ -370,6 +374,74 @@ def get_projects(self) -> IterableResponse: """ # noqa: E501 return GetProjectsResponse(self.get("/v2/api/projects")) + def create_project( + self, + display_name: str, + contact_email: str, + admin_ids: UUIDLike | t.Iterable[UUIDLike] | None = None, + admin_group_ids: UUIDLike | t.Iterable[UUIDLike] | None = None, + ) -> GlobusHTTPResponse: + """ + Create a new project. Requires the ``manage_projects`` scope. + + At least one of ``admin_ids`` or ``admin_group_ids`` must be provided. + + :param display_name: The name of the project + :type display_name: str + :param contact_email: The email address of the project's point of contact + :type contact_email: str + :param admin_ids: A list of user IDs to be added as admins of the project + :type admin_ids: str or uuid or iterable of str or uuid, optional + :param admin_group_ids: A list of group IDs to be added as admins of the project + :type admin_group_ids: str or uuid or iterable of str or uuid, optional + + .. tab-set:: + + .. tab-item:: Example Usage + + When creating a project, your account is not necessarily included as an + admin. The following snippet uses the ``manage_projects`` scope as well + as the ``openid`` and ``email`` scopes to get the current user ID and + email address and use those data to setup the project. + + .. code-block:: pycon + + >>> ac = globus_sdk.AuthClient(...) + >>> userinfo = ac.oauth2_userinfo() + >>> identity_id = userinfo["sub"] + >>> email = userinfo["email"] + >>> r = ac.create_project( + ... "My Project", + ... contact_email=email, + ... admin_ids=identity_id, + ... ) + >>> project_id = r["project"]["id"] + + .. tab-item:: Example Response Data + + .. expandtestfixture:: auth.create_project + + .. tab-item:: API Info + + ``POST /v2/api/projects`` + + .. extdoclink:: Create Project + :ref: auth/reference/#create_project + """ + body: dict[str, t.Any] = { + "display_name": display_name, + "contact_email": contact_email, + } + if admin_ids is not None: + body["admin_ids"] = list(utils.safe_strseq_iter(admin_ids)) + if admin_group_ids is not None: + body["admin_group_ids"] = list(utils.safe_strseq_iter(admin_group_ids)) + return self.post("/v2/api/projects", data={"project": body}) + + # + # OAuth2 Behaviors & APIs + # + def oauth2_get_authorize_url( self, *, query_params: dict[str, t.Any] | None = None ) -> str: diff --git a/src/globus_sdk/utils.py b/src/globus_sdk/utils.py index b9ad9f113..a48df8c01 100644 --- a/src/globus_sdk/utils.py +++ b/src/globus_sdk/utils.py @@ -6,6 +6,7 @@ import os import sys import typing as t +import uuid from base64 import b64encode from enum import Enum @@ -50,7 +51,8 @@ def safe_strseq_iter( Given an Iterable (typically of strings), produce an iterator over it of strings. This is a passthrough with two caveats: - if the value is a solitary string, yield only that value - - str value in the iterable which is not a string + - if the value is a solitary UUID, yield only that value (as a string) + - str values in the iterable which are not strings This helps handle cases where a string is passed to a function expecting an iterable of strings, as well as cases where an iterable of UUID objects is accepted for a @@ -58,6 +60,8 @@ def safe_strseq_iter( """ if isinstance(value, str): yield value + elif isinstance(value, uuid.UUID): + yield str(value) else: for x in value: yield str(x) diff --git a/tests/functional/services/auth/base/test_create_project.py b/tests/functional/services/auth/base/test_create_project.py new file mode 100644 index 000000000..308df5bb8 --- /dev/null +++ b/tests/functional/services/auth/base/test_create_project.py @@ -0,0 +1,72 @@ +import json +import uuid + +import pytest + +from globus_sdk._testing import get_last_request, load_response + + +@pytest.mark.parametrize( + "admin_id_style", ("string", "list", "set", "uuid", "uuid_list") +) +def test_create_project_admin_id_styles(client, admin_id_style): + meta = load_response(client.create_project).metadata + + if admin_id_style == "string": + admin_ids = meta["admin_id"] + elif admin_id_style == "list": + admin_ids = [meta["admin_id"]] + elif admin_id_style == "set": + admin_ids = {meta["admin_id"]} + elif admin_id_style == "uuid": + admin_ids = uuid.UUID(meta["admin_id"]) + elif admin_id_style == "uuid_list": + admin_ids = [uuid.UUID(meta["admin_id"])] + else: + raise NotImplementedError(f"unknown admin_id_style {admin_id_style}") + + res = client.create_project("My Project", "support@globus.org", admin_ids=admin_ids) + + assert res["project"]["id"] == meta["id"] + + last_req = get_last_request() + data = json.loads(last_req.body) + assert list(data) == ["project"], data # 'project' is the only key + assert data["project"]["display_name"] == "My Project", data + assert data["project"]["contact_email"] == "support@globus.org", data + assert data["project"]["admin_ids"] == [meta["admin_id"]], data + assert "admin_group_ids" not in data["project"], data + + +@pytest.mark.parametrize( + "group_id_style", ("string", "list", "set", "uuid", "uuid_list") +) +def test_create_project_group_id_styles(client, group_id_style): + meta = load_response(client.create_project, case="admin_group").metadata + + if group_id_style == "string": + group_ids = meta["admin_group_id"] + elif group_id_style == "list": + group_ids = [meta["admin_group_id"]] + elif group_id_style == "set": + group_ids = {meta["admin_group_id"]} + elif group_id_style == "uuid": + group_ids = uuid.UUID(meta["admin_group_id"]) + elif group_id_style == "uuid_list": + group_ids = [uuid.UUID(meta["admin_group_id"])] + else: + raise NotImplementedError(f"unknown group_id_style {group_id_style}") + + res = client.create_project( + "My Project", "support@globus.org", admin_group_ids=group_ids + ) + + assert res["project"]["id"] == meta["id"] + + last_req = get_last_request() + data = json.loads(last_req.body) + assert list(data) == ["project"], data # 'project' is the only key + assert data["project"]["display_name"] == "My Project", data + assert data["project"]["contact_email"] == "support@globus.org", data + assert data["project"]["admin_group_ids"] == [meta["admin_group_id"]], data + assert "admin_ids" not in data["project"], data