From ea8721659ccfc6f1376c418c4f19c17664c82360 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Tue, 18 May 2021 23:12:00 +0200 Subject: [PATCH 1/6] First attempt at using blueprints --- Makefile | 4 ++-- app/__main__.py | 8 ++++++-- app/api/__init__.py | 8 ++++++++ app/api/search.py | 11 +++++++++++ .mocharc.yml => web/.mocharc.yml | 0 5 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 app/api/__init__.py create mode 100644 app/api/search.py rename .mocharc.yml => web/.mocharc.yml (100%) diff --git a/Makefile b/Makefile index 6b6e2c5..dcaf859 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ PYTHON := python3 # This can't contain spaces (I think) VENV := .venv # Minimum % coverage for tests to succeed -MIN_COV := 0 +MIN_COV := 50 # Directory name for the frontend WEB_DIR := web @@ -55,7 +55,7 @@ test: venv ## Starting the server ### Run the Quart server run: venv - @ '$(VENV)'/bin/python app + @ QUART_ENV=development '$(VENV)'/bin/python app .PHONY: run diff --git a/app/__main__.py b/app/__main__.py index 3848299..f2f91da 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -1,7 +1,9 @@ -"""Main entrypoint for the program.""" +"""Entrypoint for the program.""" from quart import Quart +from app.api import blueprint app = Quart("jos", static_folder="web/dist", static_url_path="/") +app.register_blueprint(blueprint) @app.route("/", methods=["GET"], defaults={"path": ""}) @@ -11,4 +13,6 @@ async def frontend(path): return await app.send_static_file("index.html") -app.run(host="0.0.0.0") +if __name__ == "__main__": + print(app.url_map) + app.run(host="0.0.0.0") diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..036ab13 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,8 @@ +"""Module containing all Flask-related code.""" +from quart import Blueprint +from .search import blueprint as search_blueprint + + +# Main blueprint exposing entire API +blueprint = Blueprint("api", __name__, url_prefix="/api") +blueprint.register_blueprint(search_blueprint) diff --git a/app/api/search.py b/app/api/search.py new file mode 100644 index 0000000..b1ca848 --- /dev/null +++ b/app/api/search.py @@ -0,0 +1,11 @@ +"""Handles the search endpoint.""" +from quart import Blueprint + + +blueprint = Blueprint("search", __name__, url_prefix="/search") + + +@blueprint.route("/", methods=["GET"]) +async def search_spotify(): + """Search the Spotify API.""" + return "yeet" diff --git a/.mocharc.yml b/web/.mocharc.yml similarity index 100% rename from .mocharc.yml rename to web/.mocharc.yml From bf3a8340daf8bf46d00b378ab0b0cdd26d87d4b5 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Tue, 18 May 2021 23:46:46 +0200 Subject: [PATCH 2/6] Updated ci python version to 3.9 --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index d6ffe9a..ccc0226 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -2,7 +2,7 @@ pipeline: # =====TESTING===== test-backend: # Alpine version doesn't have make - image: python:3.8 + image: python:3.9 pull: true group: test commands: @@ -23,7 +23,7 @@ pipeline: # =====LINTING===== lint-backend: - image: python:3.8 + image: python:3.9 group: lint commands: - make lint From 274a870a6abf1c03bab5ca6f289b5ea1ad68fd2e Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Tue, 18 May 2021 23:48:23 +0200 Subject: [PATCH 3/6] Switched to pure default Black config --- app/__main__.py | 1 - pyproject.toml | 2 -- 2 files changed, 3 deletions(-) delete mode 100644 pyproject.toml diff --git a/app/__main__.py b/app/__main__.py index f2f91da..5e7f0af 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -14,5 +14,4 @@ async def frontend(path): if __name__ == "__main__": - print(app.url_map) app.run(host="0.0.0.0") diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index a8f43fe..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.black] -line-length = 79 From 5d36a8e533f930774e6d3bc6dd9b7aa4a4f9cafb Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Wed, 19 May 2021 12:07:23 +0200 Subject: [PATCH 4/6] Moved url_prefix to bp mount instead of creation --- app/__main__.py | 4 ++-- app/api/__init__.py | 6 +++--- app/api/search.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/__main__.py b/app/__main__.py index 5e7f0af..0ae9974 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -1,9 +1,9 @@ """Entrypoint for the program.""" from quart import Quart -from app.api import blueprint +from app.api import api_bp app = Quart("jos", static_folder="web/dist", static_url_path="/") -app.register_blueprint(blueprint) +app.register_blueprint(api_bp, url_prefix="/api") @app.route("/", methods=["GET"], defaults={"path": ""}) diff --git a/app/api/__init__.py b/app/api/__init__.py index 036ab13..718ec88 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,8 +1,8 @@ """Module containing all Flask-related code.""" from quart import Blueprint -from .search import blueprint as search_blueprint +from .search import search_bp # Main blueprint exposing entire API -blueprint = Blueprint("api", __name__, url_prefix="/api") -blueprint.register_blueprint(search_blueprint) +api_bp = Blueprint("api", __name__) +api_bp.register_blueprint(search_bp, url_prefix="/search") diff --git a/app/api/search.py b/app/api/search.py index b1ca848..c0d32dc 100644 --- a/app/api/search.py +++ b/app/api/search.py @@ -2,10 +2,10 @@ from quart import Blueprint -blueprint = Blueprint("search", __name__, url_prefix="/search") +search_bp = Blueprint("search", __name__) -@blueprint.route("/", methods=["GET"]) +@search_bp.route("/", methods=["GET"]) async def search_spotify(): """Search the Spotify API.""" return "yeet" From 85f27b081ca6e1ea5b626346848c5bc4ae5ea9ee Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Wed, 19 May 2021 12:35:34 +0200 Subject: [PATCH 5/6] Added temporary first Spotify implementation --- app/spotify.py | 55 +++++++++++++++++++++++++++++++++++++++++++ tests/test_succeed.py | 3 --- 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 app/spotify.py delete mode 100644 tests/test_succeed.py diff --git a/app/spotify.py b/app/spotify.py new file mode 100644 index 0000000..a484646 --- /dev/null +++ b/app/spotify.py @@ -0,0 +1,55 @@ +import base64 +import requests + + +class CredentialError(Exception): + pass + + +class Spotify: + def __init__(self, client_id, client_secret): + self.client_id = client_id + self.client_secret = client_secret + self.access_token = "" + self.token_type = "" + self.expiration_time = 0 + self.scope = "" + + def request_token(self): + b64_encoded = base64.b64encode( + "{}:{}".format(self.client_id, self.client_secret).encode() + ) + headers = {"Authorization": "Basic {}".format(b64_encoded.decode("ascii"))} + + data = {"grant_type": "client_credentials"} + + post = requests.post( + "https://accounts.spotify.com/api/token", headers=headers, data=data + ) + status_code = post.status_code + + if status_code == 401: + raise CredentialError("Provided credentials are not correct.") + + if status_code == 200: + self.access_token = post.json()["access_token"] + self.token_type = post.json()["token_type"] + self.expiration_time = post.json()["expires_in"] + self.scope = post.json()["scope"] + + def search(self, search_term, types): + if not self.access_token: + self.request_token() + + encoded_search_term = urllib.parse.quote(search_term) + full_url = "https://api.spotify.com/v1/search?q={}&type={}".format( + encoded_search_term, ",".join(types) + ) + + get = requests.get( + full_url, headers={"Authorization": "Bearer " + self.access_token} + ) + + results = get.json() + + return results diff --git a/tests/test_succeed.py b/tests/test_succeed.py deleted file mode 100644 index 81db0b6..0000000 --- a/tests/test_succeed.py +++ /dev/null @@ -1,3 +0,0 @@ -def test_succeed(): - """Placeholder test to make CI succeed.""" - pass From f84bb63cb60512de1394b583fb0d44b4feaba251 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Wed, 19 May 2021 21:45:47 +0200 Subject: [PATCH 6/6] Added auto-refreshing Spotify token --- app/spotify.py | 91 +++++++++++++++++++++++++++++++++++++------------- pyproject.toml | 2 ++ 2 files changed, 69 insertions(+), 24 deletions(-) create mode 100644 pyproject.toml diff --git a/app/spotify.py b/app/spotify.py index a484646..2021498 100644 --- a/app/spotify.py +++ b/app/spotify.py @@ -1,31 +1,69 @@ +"""Handle connections with the Spotify API.""" import base64 import requests +import urllib +from datetime import datetime, timedelta +from typing import List + + +AUTH_URL = "https://accounts.spotify.com/api/token" +API_URL = "https://api.spotify.com/v1" + + +def requires_token(method: callable, retries: int = 2): + """Decorator that handles refreshing the token. + + Args: + method: function to decorate + retries: how many times to retry creating the token + """ + + def inner(ref, *args, **kwargs): + for _ in range(retries): + if ref._access_token and datetime.now() < ref._expiration_time: + break + + ref._refresh_token() + + method(ref, *args, **kwargs) + + return inner class CredentialError(Exception): + """Thrown when invalid credentials are passed to Spotify API.""" + pass class Spotify: - def __init__(self, client_id, client_secret): - self.client_id = client_id - self.client_secret = client_secret - self.access_token = "" - self.token_type = "" - self.expiration_time = 0 - self.scope = "" + """Represents a connection object with the Spotify API.""" - def request_token(self): + def __init__(self, client_id: str, client_secret: str): + """Initialize a new Spotify object. + + Args: + client_id: client id for the API + client_secret: client secret for the API + """ + self._client_id = client_id + self._client_secret = client_secret + + self._access_token = "" + self._token_type = "" + self._expiration_time = 0 + self._scope = "" + + def _refresh_token(self): + """Refresh the current token, or create a new one.""" b64_encoded = base64.b64encode( - "{}:{}".format(self.client_id, self.client_secret).encode() + f"{self.client_id}:{self.client_secret}".encode() ) - headers = {"Authorization": "Basic {}".format(b64_encoded.decode("ascii"))} + headers = {"Authorization": f"Basic {b64_encoded.decode('ascii')}"} data = {"grant_type": "client_credentials"} - post = requests.post( - "https://accounts.spotify.com/api/token", headers=headers, data=data - ) + post = requests.post(AUTH_URL, headers=headers, data=data) status_code = post.status_code if status_code == 401: @@ -34,22 +72,27 @@ class Spotify: if status_code == 200: self.access_token = post.json()["access_token"] self.token_type = post.json()["token_type"] - self.expiration_time = post.json()["expires_in"] + self.expiration_time = datetime.now() + timedelta(seconds=post.json()["expires_in"]) self.scope = post.json()["scope"] - def search(self, search_term, types): - if not self.access_token: - self.request_token() + # TODO raise errors on failure + @requires_token + def search(self, search_term: str, types: List[str]): + """Search the API for entries. + + Args: + search_term: the searm term to use + types: which object types to search for + """ encoded_search_term = urllib.parse.quote(search_term) - full_url = "https://api.spotify.com/v1/search?q={}&type={}".format( - encoded_search_term, ",".join(types) + types_str = ",".join(types) + + r = requests.get( + f"{API_URL}/search?q={encoded_search_term}&type={types_str}", + headers={"Authorization": "Bearer " + self.access_token}, ) - get = requests.get( - full_url, headers={"Authorization": "Bearer " + self.access_token} - ) - - results = get.json() + results = r.json() return results diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a8f43fe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 79