-
Notifications
You must be signed in to change notification settings - Fork 109
Tokens API support #159
base: master
Are you sure you want to change the base?
Tokens API support #159
Changes from all commits
ca1f26e
44c0a16
ed271d8
fdc3425
5aa9707
bdebf61
07447ee
14367af
5ead778
023cba7
874b31f
df071fe
6c226fc
10a109d
55fe480
e0dd5b1
92639ce
6ccab3c
73a38a5
2f27603
f4fd833
6e2dfc7
05e4d3c
3d459fa
fe8f66f
70b564d
cd2a593
487be09
25a8be7
9e1c65c
544c7cc
6cc333d
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 |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| # Tokens | ||
|
|
||
| The `Tokens` class (`from mapbox import Tokens`) provides | ||
| access to the Mapbox Tokens API, allowing you to programmaticaly create | ||
| Mapbox access tokens to access Mapbox resources on behalf of a user. | ||
|
|
||
| ```python | ||
|
|
||
| >>> from mapbox import Tokens | ||
| >>> service = Tokens() | ||
|
|
||
| ``` | ||
|
|
||
| See https://www.mapbox.com/api-documentation/#tokens for general documentation of the API. | ||
|
|
||
| This API requires an **initial token** with the `tokens:write` scope. | ||
| Your Mapbox access token should be set in your environment; | ||
| see the [access tokens](access_tokens.md) documentation for more information. | ||
|
|
||
| The Mapbox username associated with each account is determined by the access_token by default. All of the methods also take an optional `username` keyword argument to override this default. | ||
|
|
||
| ## List tokens | ||
|
|
||
| ```python | ||
|
|
||
| >>> response = service.list_tokens() | ||
| >>> response.json() | ||
| [...] | ||
|
|
||
| ``` | ||
|
|
||
| ## Create temporary tokens | ||
|
|
||
| Generate a token for temporary access to mapbox APIs using the | ||
| `create_temp_token` method. Tokens can bet set to expire at any time up to one hour. | ||
|
|
||
| ```python | ||
|
|
||
| >>> response = service.create_temp_token( | ||
| ... scopes=['styles:read'], | ||
| ... expires=60) # seconds | ||
| >>> auth = response.json() | ||
| >>> auth['token'][:3] | ||
| 'tk.' | ||
|
|
||
| ``` | ||
|
|
||
|
|
||
| ## Create a permanent token | ||
|
|
||
|
|
||
| ```python | ||
|
|
||
| >>> response = service.create( | ||
| ... scopes=['styles:read'], | ||
| ... note='test-token') | ||
| >>> auth = response.json() | ||
| >>> auth['scopes'] | ||
| ['styles:read'] | ||
| >>> auth['token'][:3] | ||
| 'pk.' | ||
|
|
||
| ``` | ||
|
|
||
| If you create a token with public/read scopes, your token with be a public token, starting with `pk`. If the token has secret/write scopes, the token will be secret, starting with `sk`. | ||
|
|
||
| If you want to create a token that may contain secret/write scopes, you must create the token with at least one such scope initially. | ||
|
|
||
| ## Update a token | ||
|
|
||
| To update the scopes of a token | ||
|
|
||
| ```python | ||
|
|
||
| >>> response = service.update( | ||
| ... authorization_id=auth['id'], | ||
| ... scopes=['styles:read', 'datasets:read'], | ||
| ... note="updated") | ||
| >>> auth = response.json() | ||
| >>> assert response.status_code == 200 | ||
|
|
||
|
|
||
| ``` | ||
|
|
||
| ## Check validity of a token | ||
|
|
||
| ```python | ||
|
|
||
| >>> service.check_validity().json()['code'] | ||
| 'TokenValid' | ||
|
Contributor
Author
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. I'm confused about where the token gets passed to the method. I think it might be more clear like A quicker check for validity would be nice, too (like |
||
|
|
||
| ``` | ||
|
|
||
| Note that this applies only to the access token which is making the request. | ||
| If you want to check the validity of other tokens, you must make a separate instance of the `Tokens` service class using the desired `access_token`. | ||
|
Contributor
Author
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. Answers my question above. I still don't think the current implementation feels quite right.
Contributor
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 that the They fit the http-response-first style of the rest of this module but usability suffers a bit. Still 🤔 about potential solutions...
Contributor
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. re-reading the docs for this endpoint (https://www.mapbox.com/api-documentation/#retrieve-a-token) it occurs to me that
As such, I'm ok with leaving the current implementation as is for the purposes of this PR. But perhaps we need to rename it. Consistency with the HTTP api docs would suggest
Contributor
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. And regarding the funky implementation detail of needing to create an instance of
Contributor
Author
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. @perrygeo yes, that's the way to go. I think |
||
|
|
||
| ```python | ||
|
|
||
| >>> new_service = Tokens(access_token=auth['token']) | ||
|
|
||
| ``` | ||
|
|
||
| ## List the scopes of a token | ||
|
|
||
| ```python | ||
|
|
||
| >>> response = service.list_scopes() | ||
| >>> response.json() | ||
| [...] | ||
|
|
||
| ``` | ||
|
|
||
| As with checking validity, this method applies only to the access token which is making the request. | ||
|
|
||
|
|
||
| ## Delete a token | ||
|
|
||
| ```python | ||
|
|
||
| >>> response = service.delete( | ||
| ... authorization_id=auth['id']) | ||
| >>> assert response.status_code == 204 | ||
|
|
||
| ``` | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,15 @@ | ||
| # mapbox | ||
| __version__ = "0.14.0" | ||
|
|
||
| from .services.analytics import Analytics | ||
| from .services.datasets import Datasets | ||
| from .services.directions import Directions | ||
| from .services.distance import Distance | ||
| from .services.geocoding import ( | ||
| Geocoder, InvalidCountryCodeError, InvalidPlaceTypeError) | ||
| from .services.mapmatching import MapMatcher | ||
| from .services.surface import Surface | ||
| from .services.static import Static | ||
| from .services.static_style import StaticStyle | ||
| from .services.surface import Surface | ||
| from .services.tokens import Tokens | ||
| from .services.uploads import Uploader | ||
| from .services.analytics import Analytics |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| from datetime import datetime, timedelta | ||
|
|
||
| from uritemplate import URITemplate | ||
|
|
||
| from mapbox.errors import ValidationError | ||
| from mapbox.services.base import Service | ||
|
|
||
|
|
||
| class Tokens(Service): | ||
| """Access to the Tokens API.""" | ||
|
|
||
| @property | ||
| def baseuri(self): | ||
| return 'https://{0}/tokens/v2'.format(self.host) | ||
|
|
||
| def create(self, scopes, note=None, username=None): | ||
| """Create a permanent token | ||
|
|
||
| Parameters | ||
| ---------- | ||
| scopes: list | ||
| note: string | ||
| username: string, defaults to username in access token | ||
|
|
||
| Returns | ||
| ------- | ||
| requests.Response | ||
| """ | ||
| if username is None: | ||
| username = self.username | ||
| if not note: | ||
| note = "SDK generated note" | ||
|
|
||
| uri = URITemplate( | ||
| self.baseuri + '/{username}').expand(username=username) | ||
|
|
||
| payload = {'scopes': scopes, 'note': note} | ||
|
|
||
| res = self.session.post(uri, json=payload) | ||
| self.handle_http_error(res) | ||
| return res | ||
|
|
||
| def list_tokens(self, limit=None, username=None): | ||
| """List all permanent tokens | ||
|
|
||
| Parameters | ||
| ---------- | ||
| limit: int | ||
| username: string, defaults to username in access token | ||
|
|
||
| Returns | ||
| ------- | ||
| requests.Response | ||
| """ | ||
| if username is None: | ||
| username = self.username | ||
|
|
||
| uri = URITemplate( | ||
| self.baseuri + '/{username}').expand(username=username) | ||
|
|
||
| params = {} | ||
| if limit: | ||
| params['limit'] = int(limit) | ||
|
|
||
| res = self.session.get(uri, params=params) | ||
| self.handle_http_error(res) | ||
| return res | ||
|
|
||
| def create_temp_token(self, scopes, expires=3600, username=None): | ||
| """Create a temporary token | ||
|
|
||
| Parameters | ||
| ---------- | ||
| scopes: list | ||
| List of valid mapbox token scope strings | ||
| expires: int | ||
| seconds, defaults to 3600 (1 hr) | ||
| username: string | ||
| defaults to username in access token | ||
|
|
||
| Returns | ||
| ------- | ||
| requests.Response | ||
| """ | ||
| if username is None: | ||
| username = self.username | ||
|
|
||
| uri = URITemplate( | ||
| self.baseuri + '/{username}').expand(username=username) | ||
|
|
||
| payload = {'scopes': scopes} | ||
|
|
||
| if expires <= 0 or expires > 3600: | ||
| raise ValidationError("Expiry should be within 1 hour from now") | ||
| payload['expires'] = (datetime.utcnow() + timedelta(seconds=expires)).isoformat() | ||
|
|
||
| res = self.session.post(uri, json=payload) | ||
| self.handle_http_error(res) | ||
| return res | ||
|
|
||
| def update(self, authorization_id, scopes=None, note=None, username=None): | ||
| """Update a token's scopes or note | ||
|
|
||
| Parameters | ||
| ---------- | ||
| authorization_id: string | ||
| id of the token to update (not the token itself) | ||
| scopes: list | ||
| List of valid mapbox token scope strings | ||
| note: string | ||
| username: string | ||
| defaults to username in access token | ||
|
|
||
| Returns | ||
| ------- | ||
| requests.Response | ||
| """ | ||
| if username is None: | ||
| username = self.username | ||
| if not scopes and not note: | ||
| raise ValidationError("Provide either scopes or a note to update token") | ||
|
|
||
| uri = URITemplate( | ||
| self.baseuri + '/{username}/{authorization_id}').expand( | ||
| username=username, authorization_id=authorization_id) | ||
|
|
||
| payload = {} | ||
| if scopes: | ||
| payload['scopes'] = scopes | ||
| if note: | ||
| payload['note'] = note | ||
|
|
||
| res = self.session.patch(uri, json=payload) | ||
| self.handle_http_error(res) | ||
| return res | ||
|
|
||
| def delete(self, authorization_id, username=None): | ||
| """Delete a token | ||
|
|
||
| Parameters | ||
| ---------- | ||
| authorization_id: string | ||
| id of the token to update (not the token itself) | ||
| username: string | ||
| defaults to username in access token | ||
|
|
||
| Returns | ||
| ------- | ||
| requests.Response | ||
| """ | ||
| if username is None: | ||
| username = self.username | ||
|
|
||
| uri = URITemplate( | ||
| self.baseuri + '/{username}/{authorization_id}').expand( | ||
| username=username, authorization_id=authorization_id) | ||
|
|
||
| res = self.session.delete(uri) | ||
| self.handle_http_error(res) | ||
| return res | ||
|
|
||
| def check_validity(self): | ||
| """Check validity of the token | ||
|
|
||
| Returns | ||
| ------- | ||
| requests.Response | ||
| """ | ||
| uri = URITemplate(self.baseuri) | ||
|
|
||
| res = self.session.get(uri) | ||
| self.handle_http_error(res) | ||
| return res | ||
|
|
||
| def list_scopes(self, username=None): | ||
| """Delete a token | ||
|
|
||
| Parameters | ||
| ---------- | ||
| username: string | ||
| defaults to username in access token | ||
|
|
||
| Returns | ||
| ------- | ||
| requests.Response | ||
| """ | ||
| if username is None: | ||
| username = self.username | ||
|
|
||
| uri = URITemplate( | ||
| 'https://{host}/scopes/v1/{username}').expand( | ||
| host=self.host, username=username) | ||
|
|
||
| res = self.session.get(uri) | ||
| self.handle_http_error(res) | ||
| return res |
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.
@perrygeo I just noticed that we're using an
accountkeyword arg in uploads. Should we follow suit here?Uh oh!
There was an error while loading. Please reload this page.
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.
Yeah, we should make that consistent. But looks like the main API docs use
usernamethroughout; maybe the solution is to make the change to uploads,s/account/username/g?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.
Ticketed ➡️ #202
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.
Okay we'll follow up there.