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

feat: Implements OAuth association API resource #286

Merged
merged 9 commits into from
Sep 12, 2024

Conversation

zackverham
Copy link
Collaborator

@zackverham zackverham commented Sep 11, 2024

Adds support for requesting and updating oauth associations through the Connect API.

Copy link

github-actions bot commented Sep 11, 2024

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
1097 1051 96% 0% 🟢

New Files

File Coverage Status
src/posit/connect/oauth/associations.py 100% 🟢
TOTAL 100% 🟢

Modified Files

File Coverage Status
src/posit/connect/client.py 99% 🟢
src/posit/connect/content.py 100% 🟢
src/posit/connect/oauth/integrations.py 100% 🟢
TOTAL 100% 🟢

updated for commit: 808ca7c by action🐍

@zackverham zackverham changed the title implements oauth association resource feat: Implements OAuth association API resource Sep 11, 2024
]


def update(self, integration_guid: None | str) -> None:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbkegley @tdstein would love thoughts on the best way to represent this API interaction through the SDK. I don't think this is precisely what we want, but its a little ambiguous.

The actual API endpoint accepts a list of json objects - this is designed with future flexibility in mind, but for now, the only valid inputs are:

[{"oauth_integration_guid": }] to associate a content item with an integration
[] to dissociate the content item with all integrations

Given that currently you can only set one association per content item, I think it makes sense to support a function signature that accepts a single integration guid outside of a list. This breaks the semantics of PUTting an empty list to dissociate the content from integrations, though.

I put None in as a placeholder for now to facilitate discussion, but I don't really like it all that much. I'm not sure what a better alternative would be.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like the correct expression is content.associations.create(integration_guid), which should return a simple object that maintains the integration_guid and provides a delete method. The delete method would need to remove the association from the list.

class Association(Resource):
    def delete(self) -> None:
        # call `v1/content/{self.content_guid}/oauth/integrations/association` to remove the association.

fwiw, here is how I am thinking about the REST API implementation in my head, even though I know this doesn't represent the actual implementation.

Create an association

POST .../oauth/integrations/associations
{
    'integration_guid': 'whatever..."
}

Response:
{
    'guid': 'the-association-guid',
    'integration_guid': 'whatever...',
    'content_guid': 'the-content-guid'
}

Delete an association

DELETE .../the-content-guid/oauth/integrations/associations/the-association-guid

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the current API implementation, you can't create / delete singular oauth associations - there aren't public CRUD operations that let you use an association in isolation. So I don't think your proposed pattern would be possible.

You can only PUT a list of oauth integration guids to the<content_guid>oauth/integrations/associations endpoint - any PUTs completely wipe the current set of associations and replace it with a new set of associations.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. When support for multiple associations is added, will the PUT operation still be an overwrite? If so, how should multiple people coordinate the modification when they each want to add a single association to the list simultaneously?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its a good question. I don't know if we have an answer to this - currently, the asnwer is "whoever PUTs last wins."

Really it should only be publishers / admins who use this endpoint to interact with content that they own, so I wouldn't expect there to be many use cases where there's parallel requests coming from many clients. We really built the API with the expected client primarily being the dashboard.

@@ -27,6 +29,13 @@ def __getitem__(self, key: Any) -> Any:
return ContentItemOwner(params=self.params, **v)
return v

@property
def oauth_associations(self) -> ContentItemAssociations:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be expressed as content.oauth.associations?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that alot better, yeah. Would the oauth property just be a passthrough class that we hang the associations resource off of?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that sounds like the right approach.

@zackverham zackverham marked this pull request as ready for review September 12, 2024 18:47
@zackverham zackverham requested a review from tdstein September 12, 2024 18:47
@zackverham
Copy link
Collaborator Author

Test Notes

Here's a test script illustrating / manually validating the behavior of the associations resources:

from posit.connect.client import Client
import json


c = Client("http://localhost:3939", "<redacted>")

integration_a_guid = "22644575-a27b-4118-ad06-e24459b05126"
integration_b_guid = "9dd7ef9d-ccd4-46f0-b76c-12b00f1ff594"
content_guid = "9dfa33ee-62ea-4a69-8b1a-51735c70dba9"

# get content item
content_item = c.content.get(content_guid)

# set initial state for testing
content_item.oauth.associations.update(integration_a_guid)

# associated with integration_a
association_list = content_item.oauth.associations.find()
print(json.dumps(association_list))

# Output:
# [{"app_guid": "9dfa33ee-62ea-4a69-8b1a-51735c70dba9", "oauth_integration_guid": "22644575-a27b-4118-ad06-e24459b05126", "oauth_integration_name": "keycloak", "oauth_integration_description": "", "oauth_integration_template": "custom", "created_time": "2024-09-12T19:05:11Z"}]

# clear associations
content_item.oauth.associations.delete()
association_list = content_item.oauth.associations.find()
print(json.dumps(association_list))

# Output: 
# []

# associated with integration_b
content_item.oauth.associations.update(integration_b_guid)
association_list = content_item.oauth.associations.find()
print(json.dumps(association_list))

# Output: 
# [{"app_guid": "9dfa33ee-62ea-4a69-8b1a-51735c70dba9", "oauth_integration_guid": "9dd7ef9d-ccd4-46f0-b76c-12b00f1ff594", "oauth_integration_name": "default-test", "oauth_integration_description": "desc", "oauth_integration_template": "custom", "created_time": "2024-09-12T19:05:11Z"}]

# integration_b also sees the association
integration = c.oauth.integrations.get(integration_b_guid)
association_list = integration.associations.find()
print(json.dumps(association_list))

# Output: 
# [{"app_guid": "9dfa33ee-62ea-4a69-8b1a-51735c70dba9", "oauth_integration_guid": "9dd7ef9d-ccd4-46f0-b76c-12b00f1ff594", "oauth_integration_name": "default-test", "oauth_integration_description": "desc", "oauth_integration_template": "custom", "created_time": "2024-09-12T19:05:11Z"}]

# integration_a does not see the integration
integration = c.oauth.integrations.get(integration_a_guid)
association_list = integration.associations.find()
print(json.dumps(association_list))

# Output: 
# []

Copy link
Collaborator

@tdstein tdstein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great! I have a few minor nits on exception handling, but I'm ok with merging the pull request as is or with changes.

Comment on lines 46 to 47
if "guid" not in self:
raise ValueError("ContentItemOAuth requires content guid")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can remove this. If 'guid' is missing, we have bigger issues. And I believe the default KeyError thrown by self['guid'] if 'guid' is missing is sufficient.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, yep - makes sense. I was getting type checking errors when we were using property-based fields because the guids were str|None types, but the change to a self['guid'] lookup removes that issue entirely.

I'll make that change and then merge when CI passes.

Comment on lines 15 to 18
if "guid" not in self:
raise ValueError(
"IntegrationAssociations requires integration guid"
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

@zackverham zackverham merged commit 3832b43 into main Sep 12, 2024
32 checks passed
@zackverham zackverham deleted the zack-oauth-integrations-api-associations branch September 12, 2024 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants