-
Notifications
You must be signed in to change notification settings - Fork 26
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"] | ||
``` | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,84 +7,139 @@ | |
|
||
import requests | ||
|
||
BASE_PATH = "https://api.medium.com" | ||
|
||
def _request(method, path, access_token, json=None, form_data=None, | ||
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you leave the assignment as a separate line, like in There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
def get_current_user(self): | ||
"""Fetch the data for the currently authenticated user. | ||
|
@@ -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): | ||
|
@@ -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): | ||
|
There was a problem hiding this comment.
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 toaccess_token
, since it's something it always needs but never changes from the caller's perspective.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 👍