diff --git a/docs/tokens.md b/docs/tokens.md new file mode 100644 index 0000000..89bf273 --- /dev/null +++ b/docs/tokens.md @@ -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' + +``` + +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`. + +```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 + +``` + + diff --git a/mapbox/__init__.py b/mapbox/__init__.py index 1fb1819..85db59f 100644 --- a/mapbox/__init__.py +++ b/mapbox/__init__.py @@ -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 diff --git a/mapbox/services/tokens.py b/mapbox/services/tokens.py new file mode 100644 index 0000000..0405807 --- /dev/null +++ b/mapbox/services/tokens.py @@ -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 diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 0000000..96605c5 --- /dev/null +++ b/tests/test_tokens.py @@ -0,0 +1,226 @@ +import responses +import base64 + +import pytest + +from mapbox.services.tokens import Tokens +from mapbox.errors import ValidationError + + +token = 'sk.{0}.test'.format( + base64.b64encode(b'{"u":"testuser"}').decode('utf-8')) + + +@responses.activate +def test_token_create(): + """Token creation works""" + responses.add( + responses.POST, + 'https://api.mapbox.com/tokens/v2/testuser?access_token={0}'.format(token), + match_querystring=True, + body='{"scopes": ["styles:read", "fonts:read"]}', + status=200, + content_type='application/json') + + response = Tokens(access_token=token).create( + ["styles:read", "fonts:read"], note="new token") + assert response.status_code == 200 + + +@responses.activate +def test_token_create_username(): + responses.add( + responses.POST, + 'https://api.mapbox.com/tokens/v2/testuser?access_token={0}'.format(token), + match_querystring=True, + body='{"scopes": ["styles:read", "fonts:read"]}', + status=200, + content_type='application/json') + + response = Tokens(access_token=token).create( + ["styles:read", "fonts:read"], username='testuser') + assert response.status_code == 200 + + +@responses.activate +def test_token_list(): + """Token listing works""" + responses.add( + responses.GET, + 'https://api.mapbox.com/tokens/v2/testuser?access_token={0}'.format(token), + match_querystring=True, + status=200, + content_type='application/json') + + response = Tokens(access_token=token).list_tokens() + assert response.status_code == 200 + + +@responses.activate +def test_token_list_username(): + responses.add( + responses.GET, + 'https://api.mapbox.com/tokens/v2/testuser?access_token={0}'.format(token), + match_querystring=True, + status=200, + content_type='application/json') + + response = Tokens(access_token=token).list_tokens(username='testuser') + assert response.status_code == 200 + + +@responses.activate +def test_token_list_limit(): + responses.add( + responses.GET, + 'https://api.mapbox.com/tokens/v2/testuser?access_token={0}&limit=5'.format(token), + match_querystring=True, + status=200, + content_type='application/json') + + response = Tokens(access_token=token).list_tokens(limit=5) + assert response.status_code == 200 + + +@responses.activate +def test_temp_token_create(): + """Temporary token creation works""" + responses.add( + responses.POST, + 'https://api.mapbox.com/tokens/v2/testuser?access_token={0}'.format(token), + match_querystring=True, + body='{"scopes": ["styles:read", "fonts:read"]}', + status=200, + content_type='application/json') + + response = Tokens(access_token=token).create_temp_token( + ["styles:read", "fonts:read"]) + assert response.status_code == 200 + + +@responses.activate +def test_temp_token_create_username(): + """Temporary token creation works""" + responses.add( + responses.POST, + 'https://api.mapbox.com/tokens/v2/testuser?access_token={0}'.format(token), + match_querystring=True, + body='{"scopes": ["styles:read", "fonts:read"]}', + status=200, + content_type='application/json') + + response = Tokens(access_token=token).create_temp_token( + ["styles:read", "fonts:read"], username='testuser') + assert response.status_code == 200 + + +def test_temp_token_expire(): + with pytest.raises(ValidationError): + Tokens(access_token=token).create_temp_token( + ["styles:read"], expires=3601) + + +@responses.activate +def test_update_token(): + """Token updation works""" + responses.add( + responses.PATCH, + 'https://api.mapbox.com/tokens/v2/testuser/auth_id?access_token={0}'.format(token), + match_querystring=True, + body='{"scopes": ["styles:read", "fonts:read"]}', + status=200, + content_type='application/json') + + response = Tokens(access_token=token).update( + 'auth_id', note="updated token") + assert response.status_code == 200 + + +@responses.activate +def test_update_token_username(): + """Token updation works""" + responses.add( + responses.PATCH, + 'https://api.mapbox.com/tokens/v2/testuser/auth_id?access_token={0}'.format(token), + match_querystring=True, + body='{"scopes": ["styles:read", "fonts:read"]}', + status=200, + content_type='application/json') + + response = Tokens(access_token=token).update( + 'auth_id', ["styles:read", "fonts:read"], + username='testuser') + assert response.status_code == 200 + + +def test_temp_token_update_error(): + """update requires scope or notes""" + with pytest.raises(ValidationError): + Tokens(access_token=token).update('auth_id') + + +@responses.activate +def test_delete(): + """Token authorization deletion works""" + responses.add( + responses.DELETE, + 'https://api.mapbox.com/tokens/v2/testuser/auth_id?access_token={0}'.format(token), + match_querystring=True, + status=204) + + response = Tokens(access_token=token).delete('auth_id') + assert response.status_code == 204 + + +@responses.activate +def test_delete_username(): + responses.add( + responses.DELETE, + 'https://api.mapbox.com/tokens/v2/testuser/auth_id?access_token={0}'.format(token), + match_querystring=True, + status=204) + + response = Tokens(access_token=token).delete( + 'auth_id', username='testuser') + assert response.status_code == 204 + + +@responses.activate +def test_check_validity(): + """Token checking validation works""" + responses.add( + responses.GET, + 'https://api.mapbox.com/tokens/v2?access_token={0}'.format(token), + match_querystring=True, + status=200, + content_type='application/json') + + response = Tokens(access_token=token).check_validity() + assert response.status_code == 200 + + +@responses.activate +def test_list_scopes(): + """Listing of scopes for a token works""" + responses.add( + responses.GET, + 'https://api.mapbox.com/scopes/v1/testuser?access_token={0}'.format(token), + match_querystring=True, + status=200, + content_type='application/json') + + response = Tokens(access_token=token).list_scopes() + assert response.status_code == 200 + + +@responses.activate +def test_list_scopes_username(): + responses.add( + responses.GET, + 'https://api.mapbox.com/scopes/v1/testuser?access_token={0}'.format(token), + match_querystring=True, + status=200, + content_type='application/json') + + response = Tokens(access_token=token).list_scopes(username='testuser') + assert response.status_code == 200