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

Redesign the API of Client #4

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
66 changes: 38 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,48 @@ For full API documentation, see our [developer docs](https://github.com/Medium/m

## Usage

```python
from medium import Client

# Contact [email protected] to get your application_kd and application_secret.
client = Client(application_id="MY_APPLICATION_ID", application_secret="MY_APPLICATION_SECRET")

# Build the URL where you can send the user to obtain an authorization code.
auth_url = client.get_authorization_url("secretstate", "https://yoursite.com/callback/medium",
["basicProfile", "publishPost"])

# (Send the user to the authorization URL to obtain an authorization code.)

# Exchange the authorization code for an access token.
auth = client.exchange_authorization_code("YOUR_AUTHORIZATION_CODE",
"https://yoursite.com/callback/medium")

# The access token is automatically set on the client for you after
# a successful exchange, but if you already have a token, you can set it
# directly.
client.access_token = auth["access_token"]
To use the client, you first need to obtain an access token, which requires
an authorization code from the user. Send them to the URL given by
``Client.get_authorization_url()`` and then have them enter their
authorization code to exchange it for an access token.

# Get profile details of the user identified by the access token.
user = client.get_current_user()

# Create a draft post.
post = client.create_post(user_id=user["id"], title="Title", content="<h2>Title</h2><p>Content</p>",
content_format="html", publish_status="draft")

# When your access token expires, use the refresh token to get a new one.
client.exchange_refresh_token(auth["refresh_token"])
```python
from medium import Client

# Confirm everything went ok. post["url"] has the location of the created post.
# Contact [email protected] to get your application_kd and application_secret.
client = Client(application_id="MY_APPLICATION_ID",
application_secret="MY_APPLICATION_SECRET")

# Obtain an access token, by sending the user to the authorization URL and
# exchanging their authorization code for an access token.

redirect_url = "https://yoursite.com/callback/medium"
authorize_url = client.get_authorization_url(
state="secretstate",
redirect_url=redirect_url,
scopes=["basicProfile", "publishPost"]
)
print 'Go to: {}'.format(authorize_url)
print 'Copy the authorization code.'
authorization_code = raw_input('Enter the authorization code here: ')
client.exchange_authorization_code(authorization_code, redirect_url)

# Now that you have an access token, you can use the rest of the client's
# methods. For example, to get the profile details of user identified by the
# access token:

print client.get_current_user()

# And to create a draft post:

post = client.create_post(
user_id=user["id"],
title="Title",
content="<h2>Title</h2><p>Content</p>",
content_format="html", publish_status="draft"
)
print "My new post!", post["url"]
```

Expand Down
209 changes: 120 additions & 89 deletions medium/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,84 +7,139 @@

import requests

BASE_PATH = "https://api.medium.com"

def _request(method, path, access_token, json=None, form_data=None,
Copy link
Contributor

Choose a reason for hiding this comment

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

Now that I'm looking at this, I don't think I actually like _request as a standalone function — it reduces some complexity by giving it access to less info, but by that argument we could split out all the methods. And it's nice for it to just have access to access_token, since it's something it always needs but never changes from the caller's perspective.

Copy link
Author

Choose a reason for hiding this comment

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

The reduction in complexity is especially helpful for someone reading this code for the first time, even if it makes things more verbose, so I think we should fight for it if we can.

It does look like a slippery slope, but I don't think it really is. I'm certainly not advocating that every method replace its body with a call to a private function. Many can't do that anyway if they change the state of the object. And if the body is never duplicated, by splitting it out we're only adding an unhelpful level of indirection and making things more complex. But for cases where we're reusing logic and we aren't using inheritance, I think the reduction in complexity is worth the costs of verboseness and indirection.

In this case, I think it's especially right, since the idea of "how do I make a request to the Medium API" and "how do I request specific resources" are two separate responsibilities.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm ok, you've convinced me 👍

files=None):
"""Make a signed request to the given route."""
url = "https://api.medium.com/v1" + path
headers = {
"Accept": "application/json",
"Accept-Charset": "utf-8",
"Authorization": "Bearer {}".format(access_token),
}

resp = requests.request(method, url, json=json, data=form_data,
files=files, headers=headers)
json = resp.json()
if 200 <= resp.status_code < 300:
try:
return json["data"]
except KeyError:
return json

class Client(object):
"""A client for the Medium OAuth2 REST API."""
raise MediumError("API request failed", json)


class Client(object):
"""
A client for the Medium OAuth2 REST API.

>>> client = Client(application_id="MY_APPLICATION_ID",
... application_secret="MY_APPLICATION_SECRET")

To use the client, you first need to obtain an access token, which requires
an authorization code from the user. Send them to the URL given by
``Client.get_authorization_url()`` and then have them enter their
authorization code to exchange it for an access token.

>>> client.access_token
None
>>> redirect_url = "https://yoursite.com/callback/medium"
>>> authorize_url = client.get_authorization_url(
... state="secretstate",
... redirect_url=redirect_url,
... scopes=["basicProfile", "publishPost"]
... )
>>> print 'Go to: {}'.format(authorize_url)
Go to: https://medium.com/m/oauth/authorize?scope=basicProfile%2Cpublish...
>>> print 'Copy the authorization code.'
Copy the authorization code.
>>> authorization_code = raw_input('Enter the authorization code here: ')
>>> client.exchange_authorization_code(authorization_code, redirect_url)
>>> client.access_token
...

The access token will expire after some time. To refresh it:

>>> client.exchange_refresh_token()

Once you have an access token, you can use the rest of the client's
methods. For example, to get the profile details of user identified by the
access token:

>>> user = client.get_current_user()

And to create a draft post:

>>> post = client.create_post(
... user_id=user["id"],
... title="Title",
... content="<h2>Title</h2><p>Content</p>",
... content_format="html", publish_status="draft"
... )

"""
def __init__(self, application_id=None, application_secret=None,
access_token=None):
access_token=None, refresh_token=None):
self.application_id = application_id
self.application_secret = application_secret
self.access_token = access_token
self.refresh_token = refresh_token

def get_authorization_url(self, state, redirect_url, scopes):
"""Get a URL for users to authorize the application.

:param str state: A string that will be passed back to the redirect_url
:param str redirect_url: The URL to redirect after authorization
:param list scopes: The scopes to grant the application
:param str state:
A string that will be passed back to the redirect_url
:param str redirect_url:
The URL to redirect after authorization
:param list scopes:
The scopes to grant the application
:returns: str
"""
qs = {
return "https://medium.com/m/oauth/authorize?" + urlencode({
"client_id": self.application_id,
"scope": ",".join(scopes),
"state": state,
"response_type": "code",
"redirect_uri": redirect_url,
}
"scope": ",".join(scopes),
"state": state,
})

return "https://medium.com/m/oauth/authorize?" + urlencode(qs)
def _get_tokens(self, **kwargs):
Copy link
Contributor

Choose a reason for hiding this comment

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

Since _get_tokens only ever needs form_data, maybe you should have it just accept that as a named argument? I feel like accepting and just passing on kwargs can make things harder to follow

Copy link
Author

Choose a reason for hiding this comment

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

Agreed, must have missed that.

return _request('POST', '/tokens', self.access_token, **kwargs)

def exchange_authorization_code(self, code, redirect_url):
"""Exchange the authorization code for a long-lived access token, and
set the token on the current Client.

:param str code: The code supplied to the redirect URL after a user
authorizes the application
:param str redirect_url: The same redirect URL used for authorizing
the application
:returns: A dictionary with the new authorizations ::
{
'token_type': 'Bearer',
'access_token': '...',
'expires_at': 1449441560773,
'refresh_token': '...',
'scope': ['basicProfile', 'publishPost']
}
"""
data = {
"code": code,
Exchange the authorization code for a long-lived access token, and set
both the access token and refresh on the current Client.

:param str code:
The code supplied to the redirect URL after a user authorizes the
application.
:param str redirect_url:
The same redirect URL used for authorizing the application.
"""
tokens = self._get_tokens(form_data={
"client_id": self.application_id,
"client_secret": self.application_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_url,
}
return self._request_and_set_auth_code(data)

def exchange_refresh_token(self, refresh_token):
"""Exchange the supplied refresh token for a new access token, and
set the token on the current Client.
})
self.access_token = tokens['access_token']
self.refresh_token = tokens['refresh_token']

:param str refresh_token: The refresh token, as provided by
``exchange_authorization_code()``
:returns: A dictionary with the new authorizations ::
{
'token_type': 'Bearer',
'access_token': '...',
'expires_at': 1449441560773,
'refresh_token': '...',
'scope': ['basicProfile', 'publishPost']
}
def exchange_refresh_token(self):
"""
Exchange the supplied refresh token for a new access token, and set the
token on the current Client.
"""
data = {
"refresh_token": refresh_token,
self.access_token = self._get_tokens(form_data={
"client_id": self.application_id,
"client_secret": self.application_secret,
"grant_type": "refresh_token",
}
return self._request_and_set_auth_code(data)
"refresh_token": self.refresh_token,
})['access_token']
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you leave the assignment as a separate line, like in exchange_authorization_code? This makes for a lot going on in one statement

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, good call. I don't like the idea of assigning anything to a variable named data, but assigning to tokens would make this simpler.


def get_current_user(self):
"""Fetch the data for the currently authenticated user.
Expand All @@ -101,7 +156,7 @@ def get_current_user(self):
'name': 'Kyle Hardgrave'
}
"""
return self._request("GET", "/v1/me")
return _request("GET", "/me", self.access_token)

def create_post(self, user_id, title, content, content_format, tags=None,
canonical_url=None, publish_status=None, license=None):
Expand Down Expand Up @@ -144,68 +199,44 @@ def create_post(self, user_id, title, content, content_format, tags=None,
'id': '55050649c95'
}
"""
data = {
json = {
"title": title,
"content": content,
"contentFormat": content_format,
}
if tags is not None:
data["tags"] = tags
json["tags"] = tags
if canonical_url is not None:
data["canonicalUrl"] = canonical_url
json["canonicalUrl"] = canonical_url
if publish_status is not None:
data["publishStatus"] = publish_status
json["publishStatus"] = publish_status
if license is not None:
data["license"] = license
json["license"] = license

path = "/v1/users/%s/posts" % user_id
return self._request("POST", path, json=data)
return _request("POST", "/users/{}/posts".format(user_id),
self.access_token, json=json)

def upload_image(self, file_path, content_type):
"""Upload a local image to Medium for use in a post.

Requires the ``uploadImage`` scope.

:param str file_path: The file path of the image
:param str content_type: The type of the image. Valid values are
:param str file_path:
The file path of the image
:param str content_type:
The type of the image. Valid values are
``image/jpeg``, ``image/png``, ``image/gif``, and ``image/tiff``.
:returns: A dictionary with the image data ::
:returns: A dictionary with the image data::

{
'url': 'https://cdn-images-1.medium.com/0*dlkfjalksdjfl.jpg',
'md5': 'd87e1628ca597d386e8b3e25de3a18b8'
}
"""
with open(file_path, "rb") as f:
filename = basename(file_path)
files = {"image": (filename, f, content_type)}
return self._request("POST", "/v1/images", files=files)

def _request_and_set_auth_code(self, data):
"""Request an access token and set it on the current client."""
result = self._request("POST", "/v1/tokens", form_data=data)
self.access_token = result["access_token"]
return result

def _request(self, method, path, json=None, form_data=None, files=None):
"""Make a signed request to the given route."""
url = BASE_PATH + path
headers = {
"Accept": "application/json",
"Accept-Charset": "utf-8",
"Authorization": "Bearer %s" % self.access_token,
}

resp = requests.request(method, url, json=json, data=form_data,
files=files, headers=headers)
json = resp.json()
if 200 <= resp.status_code < 300:
try:
return json["data"]
except KeyError:
return json

raise MediumError("API request failed", json)
return _request("POST", "/images", self.access_token, files={
"image": (basename(file_path), f, content_type)
})


class MediumError(Exception):
Expand Down