From 2121677a8ff65c74830e75dd8a0cfd42f685d5ec Mon Sep 17 00:00:00 2001 From: CasVT Date: Thu, 29 Jun 2023 17:37:22 +0200 Subject: [PATCH] Added system for automatic API docs --- .dockerignore | 1 + backend/custom_exceptions.py | 10 +- docs/api.md | 617 ++++++++++++++++++++++- docs/assets/css/extra.css | 12 +- frontend/api.py | 950 ++++++++++++----------------------- generate_api_docs.py | 116 +++++ mkdocs.yml | 1 + 7 files changed, 1086 insertions(+), 621 deletions(-) create mode 100644 generate_api_docs.py diff --git a/.dockerignore b/.dockerignore index fd03066..d1e217d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -157,3 +157,4 @@ release.sh docs/ docs-requirements.txt mkdocs.yml +generate_api_docs.py diff --git a/backend/custom_exceptions.py b/backend/custom_exceptions.py index 35b5d03..593c676 100644 --- a/backend/custom_exceptions.py +++ b/backend/custom_exceptions.py @@ -12,7 +12,7 @@ class UsernameInvalid(Exception): api_response = {'error': 'UsernameInvalid', 'result': {}, 'code': 400} class UserNotFound(Exception): - """The user requested by id or username can not be found""" + """The user requested can not be found""" api_response = {'error': 'UserNotFound', 'result': {}, 'code': 404} class AccessUnauthorized(Exception): @@ -65,3 +65,11 @@ class InvalidKeyValue(Exception): class TemplateNotFound(Exception): """The template was not found""" api_response = {'error': 'TemplateNotFound', 'result': {}, 'code': 404} + +class APIKeyInvalid(Exception): + """The API key is not correct""" + api_response = {'error': 'APIKeyInvalid', 'result': {}, 'code': 401} + +class APIKeyExpired(Exception): + """The API key has expired""" + api_response = {'error': 'APIKeyExpired', 'result': {}, 'code': 401} diff --git a/docs/api.md b/docs/api.md index 9f31430..5b56bb2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,3 +1,618 @@ # API +Below is the API documentation. Report an issue on [GitHub](https://github.com/Casvt/MIND/issues). + +All endpoints have the `/api` prefix. That means, for example, that `/auth/login` can be reached at `/api/auth/login`. + +## Authentication + +Authentication is done using an API key. To log in, make a POST request to the [`/api/auth/login`](#authlogin) endpoint. You'll receive an API key, which you can then use in your requests to authenticate. Supply it as a url parameter with the key `api_key`. This API key is valid for one hour, after which the key expires, any further requests return 401 'APIKeyExpired' and you are required to log in again. If no `api_key` is supplied or it is invalid, 401 `APIKeyInvalid` is returned. + +For example: +```bash +curl -sSL 'http://192.168.2.15:8080/api/reminders?api_key=ABCDEFG' +``` + +## Supplying data + +Often, data needs to be supplied with a request. If the parameters need to be supplied via `url`, add them to the url as url parameters. If the parameters need to be supplied via `body`, add them to the body as a json object and supply the `Content-Type: application/json` header. + +For example: +```bash +# URL parameter +curl -sSL 'http://192.168.2.15:8080/api/reminders/search?api_key=ABCDEFG&query=Fountain&sort_by=time_reversed' + +# Body parameter +curl -sSLX POST \ + -H 'Content-Type: application/json' \ + -d '{"title": "Test service", "url": "test://fake/url"}' \ + 'http://192.168.2.15:8080/api/notificationservices?api_key=ABCDEFG' +``` + +## Endpoints +The following is automatically generated. Please report any issues on [GitHub](https://github.com/Casvt/MIND/issues). +### `/auth/login` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| No | Login to a user account | + +??? POST + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | username | Yes | The username of the user account | N/A | + | password | Yes | The password of the user account | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 401 | AccessUnauthorized | The password given is not correct | + | 404 | UserNotFound | The user requested can not be found | + +### `/auth/logout` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Logout of a user account | + +??? POST + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + +### `/auth/status` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Get current status of login | + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + +### `/user/add` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| No | Create a new user account | + +??? POST + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | username | Yes | The username of the user account | N/A | + | password | Yes | The password of the user account | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 400 | UsernameInvalid | The username contains invalid characters | + | 400 | UsernameTaken | The username is already taken | + +### `/user` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a user account | + +??? PUT + + Change the password of the user account + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | new_password | Yes | The new password of the user account | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + +??? DELETE + + Delete the user account + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + +### `/notificationservices` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage the notification services | + +??? GET + + Get a list of all notification services + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + +??? POST + + Add a notification service + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | url | Yes | The Apprise URL of the notification service | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + +### `/notificationservices/` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a specific notification service | + +Replace `` with the ID of the entry. For example: `/notificationservices/2`. + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | NotificationServiceNotFound | The notification service was not found | + +??? PUT + + Edit the notification service + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | No | The title of the entry | N/A | + | url | No | The Apprise URL of the notification service | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 404 | NotificationServiceNotFound | The notification service was not found | + +??? DELETE + + Delete the notification service + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/reminders` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage the reminders | + +??? GET + + Get a list of all reminders + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `time`, `time_reversed`, `title`, `title_reversed`, `date_added`, `date_added_reversed` | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +??? POST + + Add a reminder + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | time | Yes | The UTC epoch timestamp that the reminder should be sent at | N/A | + | notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | repeat_quantity | No | The quantity of the repeat_interval | `years`, `months`, `weeks`, `days`, `hours`, `minutes` | + | repeat_interval | No | The number of the interval | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | InvalidTime | The time given is in the past | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/reminders/search` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Search through the list of reminders | + +??? GET + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `time`, `time_reversed`, `title`, `title_reversed`, `date_added`, `date_added_reversed` | + | query | Yes | The search term | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +### `/reminders/test` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Test send a reminder draft | + +??? POST + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/reminders/` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a specific reminder | + +Replace `` with the ID of the entry. For example: `/reminders/2`. + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? PUT + + Edit the reminder + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | No | The title of the entry | N/A | + | time | No | The UTC epoch timestamp that the reminder should be sent at | N/A | + | notification_services | No | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | repeat_quantity | No | The quantity of the repeat_interval | `years`, `months`, `weeks`, `days`, `hours`, `minutes` | + | repeat_interval | No | The number of the interval | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | InvalidTime | The time given is in the past | + | 404 | NotificationServiceNotFound | The notification service was not found | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? DELETE + + Delete the reminder + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +### `/templates` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage the templates | + +??? GET + + Get a list of all templates + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +??? POST + + Add a template + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/templates/search` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Search through the list of templates | + +??? GET + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` | + | query | Yes | The search term | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +### `/templates/` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a specific template | + +Replace `` with the ID of the entry. For example: `/templates/2`. + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | TemplateNotFound | The template was not found | + +??? PUT + + Edit the template + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | No | The title of the entry | N/A | + | notification_services | No | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 404 | NotificationServiceNotFound | The notification service was not found | + | 404 | TemplateNotFound | The template was not found | + +??? DELETE + + Delete the template + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | TemplateNotFound | The template was not found | + +### `/staticreminders` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage the static reminders | + +??? GET + + Get a list of all static reminders + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +??? POST + + Add a static reminder + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | Yes | The title of the entry | N/A | + | notification_services | Yes | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + | 404 | NotificationServiceNotFound | The notification service was not found | + +### `/staticreminders/search` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Search through the list of staticreminders | + +??? GET + + **Parameters (url)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | sort_by | No | How to sort the result | `title`, `title_reversed`, `date_added`, `date_added_reversed` | + | query | Yes | The search term | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + +### `/staticreminders/` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Manage a specific static reminder | + +Replace `` with the ID of the entry. For example: `/staticreminders/2`. + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? POST + + Trigger the static reminder + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? PUT + + Edit the static reminder + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | title | No | The title of the entry | N/A | + | notification_services | No | Array of the id's of the notification services to use to send the notification | N/A | + | text | No | The body of the entry | N/A | + | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 404 | NotificationServiceNotFound | The notification service was not found | + | 404 | ReminderNotFound | The reminder with the id can not be found | + +??? DELETE + + Delete the static reminder + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + | 404 | ReminderNotFound | The reminder with the id can not be found | -Coming soon \ No newline at end of file diff --git a/docs/assets/css/extra.css b/docs/assets/css/extra.css index 16ba9be..3ecc6b3 100644 --- a/docs/assets/css/extra.css +++ b/docs/assets/css/extra.css @@ -21,6 +21,14 @@ color: var(--color-light); } +.md-typeset table:not([class]) { + border-radius: 6px; +} + +.md-typeset a:not(.headerlink) { + text-decoration: underline; +} + /* */ /* Light mode */ /* */ @@ -76,7 +84,7 @@ --md-typeset-kbd-color: #fafafa; --md-typeset-kbd-accent-color: #fff; --md-typeset-kbd-border-color: #b8b8b8; - --md-typeset-table-color: #0000001f; + --md-typeset-table-color: var(--color-gray); --md-typeset-table-color--light: rgba(0,0,0,.035); --md-admonition-fg-color: var(--color-dark); @@ -180,7 +188,7 @@ --md-typeset-kbd-color: #fafafa; --md-typeset-kbd-accent-color: #fff; --md-typeset-kbd-border-color: #b8b8b8; - --md-typeset-table-color: #0000001f; + --md-typeset-table-color: var(--color-gray); --md-typeset-table-color--light: rgba(0,0,0,.035); --md-admonition-fg-color: var(--color-light); diff --git a/frontend/api.py b/frontend/api.py index 90a6712..7f1ac25 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -4,17 +4,20 @@ from abc import ABC, abstractmethod from os import urandom from re import compile from time import time as epoch_time -from typing import Any, Dict, List, Tuple +from typing import Any, Callable, Dict, List, Tuple, Union from apprise import Apprise from flask import Blueprint, g, request +from flask.scaffold import T_route -from backend.custom_exceptions import (AccessUnauthorized, InvalidKeyValue, +from backend.custom_exceptions import (AccessUnauthorized, APIKeyExpired, + APIKeyInvalid, InvalidKeyValue, InvalidTime, KeyNotFound, NotificationServiceInUse, NotificationServiceNotFound, - ReminderNotFound, UsernameInvalid, - UsernameTaken, UserNotFound) + ReminderNotFound, TemplateNotFound, + UsernameInvalid, UsernameTaken, + UserNotFound) from backend.notification_service import (NotificationService, NotificationServices) from backend.reminders import Reminders, reminder_handler @@ -22,64 +25,10 @@ from backend.static_reminders import StaticReminders from backend.templates import Template, Templates from backend.users import User, register_user -api_prefix = "/api" -api = Blueprint('api', __name__) -api_key_map = {} -color_regex = compile(r'#[0-9a-f]{6}') - -""" -AUTHENTICATION: - After making a POST /auth/login request, you'll receive an api_key in the output. - From then on, make all requests with the url parameter api_key, where the value is the string you received. - One hour after logging in, the api key expires and you are required to login again to get a new api_key. - - If no api key is supplied or it is invalid, 401 'ApiKeyInvalid' is returned. - If the api key supplied has expired, 401 'ApiKeyExpired' is returned. -""" - -def return_api(result: Any, error: str=None, code: int=200) -> Tuple[dict, int]: - return {'error': error, 'result': result}, code - -def auth(method): - """Used as decorator and, if applied to route, restricts the route to authorized users and supplies user specific info - """ - def wrapper(*args,**kwargs): - hashed_api_key = hash(request.values.get('api_key','')) - if not hashed_api_key in api_key_map: - return return_api({}, 'ApiKeyInvalid', 401) - - exp = api_key_map[hashed_api_key]['exp'] - if exp <= epoch_time(): - return return_api({}, 'ApiKeyExpired', 401) - - # Api key valid - g.hashed_api_key = hashed_api_key - g.exp = exp - g.user_data = api_key_map[hashed_api_key]['user_data'] - return method(*args, **kwargs) - - wrapper.__name__ = method.__name__ - return wrapper - -def error_handler(method): - """Catches the errors that can occur in the endpoint and returns the correct api error - """ - def wrapper(*args, **kwargs): - try: - return method(*args, **kwargs) - except (UsernameTaken, UsernameInvalid, UserNotFound, - AccessUnauthorized, - ReminderNotFound, NotificationServiceNotFound, - NotificationServiceInUse, InvalidTime, - KeyNotFound, InvalidKeyValue) as e: - return return_api(**e.api_response) - - wrapper.__name__ = method.__name__ - return wrapper - #=================== # Input validation #=================== +color_regex = compile(r'#[0-9a-f]{6}') class DataSource: DATA = 1 @@ -118,17 +67,26 @@ class InputVariable(ABC): @abstractmethod def description() -> str: pass + + @property + @abstractmethod + def related_exceptions() -> List[Exception]: + pass class DefaultInputVariable(InputVariable): source = DataSource.DATA required = True default = None + related_exceptions = [] def __init__(self, value: Any) -> None: self.value = value def validate(self) -> bool: return isinstance(self.value, str) and self.value + + def __repr__(self) -> str: + return f'| {self.name} | {"Yes" if self.required else "No"} | {self.description} | N/A |' class NonRequiredVersion(InputVariable): required = False @@ -139,48 +97,67 @@ class NonRequiredVersion(InputVariable): class UsernameVariable(DefaultInputVariable): name = 'username' description = 'The username of the user account' + related_exceptions = [KeyNotFound, UserNotFound] class PasswordVariable(DefaultInputVariable): name = 'password' description = 'The password of the user account' + related_exceptions = [KeyNotFound, AccessUnauthorized] class NewPasswordVariable(PasswordVariable): name = 'new_password' description = 'The new password of the user account' + related_exceptions = [KeyNotFound] + +class UsernameCreateVariable(UsernameVariable): + related_exceptions = [KeyNotFound, UsernameInvalid, UsernameTaken] + +class PasswordCreateVariable(PasswordVariable): + related_exceptions = [KeyNotFound] class TitleVariable(DefaultInputVariable): name = 'title' description = 'The title of the entry' + related_exceptions = [KeyNotFound] class URLVariable(DefaultInputVariable): name = 'url' description = 'The Apprise URL of the notification service' + related_exceptions = [KeyNotFound, InvalidKeyValue] def validate(self) -> bool: return Apprise().add(self.value) class EditTitleVariable(NonRequiredVersion, TitleVariable): - pass + related_exceptions = [] class EditURLVariable(NonRequiredVersion, URLVariable): - pass + related_exceptions = [InvalidKeyValue] class SortByVariable(DefaultInputVariable): name = 'sort_by' - description = "How to sort the result. Allowed values are 'title', 'title_reversed', 'time', 'time_reversed', 'date_added' and 'date_added_reversed'" + description = 'How to sort the result' required = False source = DataSource.VALUES _options = Reminders.sort_functions default = next(iter(Reminders.sort_functions)) + related_exceptions = [InvalidKeyValue] def __init__(self, value: str) -> None: self.value = value def validate(self) -> bool: return self.value in self._options + + def __repr__(self) -> str: + return '| {n} | {r} | {d} | {v} |'.format( + n=self.name, + r="Yes" if self.required else "No", + d=self.description, + v=", ".join(f'`{o}`' for o in self._options) + ) class TemplateSortByVariable(SortByVariable): - description = "How to sort the result. Allowed values are 'title', 'title_reversed', 'date_added' and 'date_added_reversed'" _options = Templates.sort_functions default = next(iter(Templates.sort_functions)) @@ -191,16 +168,18 @@ class StaticReminderSortByVariable(TemplateSortByVariable): class TimeVariable(DefaultInputVariable): name = 'time' description = 'The UTC epoch timestamp that the reminder should be sent at' + related_exceptions = [KeyNotFound, InvalidKeyValue, InvalidTime] def validate(self) -> bool: return isinstance(self.value, (float, int)) class EditTimeVariable(NonRequiredVersion, TimeVariable): - pass + related_exceptions = [InvalidKeyValue, InvalidTime] class NotificationServicesVariable(DefaultInputVariable): name = 'notification_services' description = "Array of the id's of the notification services to use to send the notification" + related_exceptions = [KeyNotFound, InvalidKeyValue, NotificationServiceNotFound] def validate(self) -> bool: if not isinstance(self.value, list): @@ -213,7 +192,7 @@ class NotificationServicesVariable(DefaultInputVariable): return True class EditNotificationServicesVariable(NonRequiredVersion, NotificationServicesVariable): - pass + related_exceptions = [InvalidKeyValue, NotificationServiceNotFound] class TextVariable(NonRequiredVersion, DefaultInputVariable): name = 'text' @@ -229,24 +208,35 @@ class RepeatQuantityVariable(DefaultInputVariable): required = False _options = ("years", "months", "weeks", "days", "hours", "minutes") default = None + related_exceptions = [InvalidKeyValue] def validate(self) -> bool: return self.value is None or self.value in self._options + + def __repr__(self) -> str: + return '| {n} | {r} | {d} | {v} |'.format( + n=self.name, + r="Yes" if self.required else "No", + d=self.description, + v=", ".join(f'`{o}`' for o in self._options) + ) class RepeatIntervalVariable(DefaultInputVariable): name = 'repeat_interval' description = 'The number of the interval' required = False default = None + related_exceptions = [InvalidKeyValue] def validate(self) -> bool: - return self.value is None or isinstance(self.value, int) + return self.value is None or (isinstance(self.value, int) and self.value > 0) class ColorVariable(DefaultInputVariable): name = 'color' description = 'The hex code of the color of the entry, which is shown in the web-ui' required = False default = None + related_exceptions = [InvalidKeyValue] def validate(self) -> None: return self.value is None or color_regex.search(self.value) @@ -256,96 +246,118 @@ class QueryVariable(DefaultInputVariable): description = 'The search term' source = DataSource.VALUES -endpoint_variables: Dict[str, Dict[str, List[InputVariable]]] = { - '/auth/login': { - 'POST': [UsernameVariable, PasswordVariable] - }, - '/user/add': { - 'POST': [UsernameVariable, PasswordVariable] - }, - '/user': { - 'PUT': [NewPasswordVariable] - }, - '/notificationservices': { - 'POST': [TitleVariable, URLVariable] - }, - '/notificationservices/': { - 'PUT': [EditTitleVariable, EditURLVariable] - }, - '/reminders': { - 'GET': [SortByVariable], - 'POST': [TitleVariable, TimeVariable, - NotificationServicesVariable, TextVariable, - RepeatQuantityVariable, RepeatIntervalVariable, - ColorVariable] - }, - '/reminders/search': { - 'GET': [SortByVariable, QueryVariable] - }, - '/reminders/test': { - 'POST': [TitleVariable, NotificationServicesVariable, - TextVariable] - }, - '/reminders/': { - 'PUT': [EditTitleVariable, EditTimeVariable, - EditNotificationServicesVariable, TextVariable, - RepeatQuantityVariable, RepeatIntervalVariable, - ColorVariable] - }, - '/templates': { - 'GET': [TemplateSortByVariable], - 'POST': [TitleVariable, NotificationServicesVariable, - TextVariable, ColorVariable] - }, - '/templates/search': { - 'GET': [TemplateSortByVariable, QueryVariable] - }, - '/templates/': { - 'PUT': [EditTitleVariable, EditNotificationServicesVariable, - TextVariable, ColorVariable] - }, - '/staticreminders': { - 'GET': [StaticReminderSortByVariable], - 'POST': [TitleVariable, NotificationServicesVariable, - TextVariable, ColorVariable] - }, - '/staticreminders/search': { - 'GET': [StaticReminderSortByVariable, QueryVariable] - }, - '/staticreminders/': { - 'PUT': [EditTitleVariable, EditNotificationServicesVariable, - TextVariable, ColorVariable] - } -} - -def input_validation(method): +def input_validation() -> Union[None, Dict[str, Any]]: """Checks, extracts and transforms inputs + + Raises: + KeyNotFound: A required key was not supplied + InvalidKeyValue: The value of a key is not valid + + Returns: + Union[None, Dict[str, Any]]: `None` if the endpoint + method doesn't require input variables. + Otherwise `Dict[str, Any]` with the input variables, checked and formatted. """ - def wrapper(*args, **kwargs): - inputs = {} - endpoint = request.url_rule.rule.split(api_prefix)[1] - input_variables = endpoint_variables.get(endpoint, {}).get(request.method) - if input_variables is not None: - given_variables = {} - given_variables[DataSource.DATA] = request.get_json() if request.data else {} - given_variables[DataSource.VALUES] = request.values + inputs = {} + input_variables = api_docs[request.url_rule.rule.split(api_prefix)[1]]['input_variables'] + if not input_variables: + return + if input_variables.get(request.method) is None: + return inputs + + given_variables = {} + given_variables[DataSource.DATA] = request.get_json() if request.data else {} + given_variables[DataSource.VALUES] = request.values + for input_variable in input_variables[request.method]: + if ( + input_variable.required and + not input_variable.name in given_variables[input_variable.source] + ): + raise KeyNotFound(input_variable.name) - for input_variable in input_variables: - if ( - input_variable.required and - not input_variable.name in given_variables[input_variable.source] - ): - raise KeyNotFound(input_variable.name) - - input_value = given_variables[input_variable.source].get(input_variable.name, input_variable.default) - - if not input_variable(input_value).validate(): - raise InvalidKeyValue(input_variable.name, input_value) - - inputs[input_variable.name] = input_value + input_value = given_variables[input_variable.source].get(input_variable.name, input_variable.default) - return method(inputs, *args, **kwargs) + if not input_variable(input_value).validate(): + raise InvalidKeyValue(input_variable.name, input_value) + + inputs[input_variable.name] = input_value + return inputs +#=================== +# General variables and functions +#=================== + +api_docs: Dict[str, Dict[str, Any]] = {} +class APIBlueprint(Blueprint): + def route( + self, + rule: str, + description: str = '', + input_variables: Dict[str, List[Union[List[InputVariable], str]]] = {}, + requires_auth: bool = True, + **options: Any + ) -> Callable[[T_route], T_route]: + + api_docs[rule] = { + 'endpoint': rule, + 'description': description, + 'requires_auth': requires_auth, + 'methods': options['methods'], + 'input_variables': {k: v[0] for k, v in input_variables.items() if v and v[0]}, + 'method_descriptions': {k: v[1] for k, v in input_variables.items() if v and len(v) == 2 and v[1]} + } + + return super().route(rule, **options) + +api_prefix = "/api" +api = APIBlueprint('api', __name__) +api_key_map = {} + +def return_api(result: Any, error: str=None, code: int=200) -> Tuple[dict, int]: + return {'error': error, 'result': result}, code + +def auth() -> None: + """Checks if the client is logged in + + Raises: + APIKeyInvalid: The api key supplied is invalid + APIKeyExpired: The api key supplied has expired + """ + hashed_api_key = hash(request.values.get('api_key','')) + if not hashed_api_key in api_key_map: + raise APIKeyInvalid + + exp = api_key_map[hashed_api_key]['exp'] + if exp <= epoch_time(): + raise APIKeyExpired + + # Api key valid + g.hashed_api_key = hashed_api_key + g.exp = exp + g.user_data = api_key_map[hashed_api_key]['user_data'] + return + +def endpoint_wrapper(method: Callable) -> Callable: + def wrapper(*args, **kwargs): + requires_auth = api_docs[request.url_rule.rule.split(api_prefix)[1]]['requires_auth'] + try: + if requires_auth: + auth() + + inputs = input_validation() + + if inputs is None: + return method(*args, **kwargs) + return method(inputs, *args, **kwargs) + + except (UsernameTaken, UsernameInvalid, UserNotFound, + AccessUnauthorized, + ReminderNotFound, NotificationServiceNotFound, + NotificationServiceInUse, InvalidTime, + KeyNotFound, InvalidKeyValue, + APIKeyInvalid, APIKeyExpired, + TemplateNotFound) as e: + return return_api(**e.api_response) + wrapper.__name__ = method.__name__ return wrapper @@ -353,30 +365,15 @@ def input_validation(method): # Authentication endpoints #=================== -@api.route('/auth/login', methods=['POST']) -@error_handler -@input_validation +@api.route( + '/auth/login', + 'Login to a user account', + {'POST': [[UsernameVariable, PasswordVariable]]}, + False, + methods=['POST'] +) +@endpoint_wrapper def api_login(inputs: Dict[str, str]): - """ - Endpoint: /auth/login - Description: Login to a user account - Requires being logged in: No - Methods: - POST: - Parameters (body): - username (required): the username of the user account - password (required): the password of the user account - Returns: - 200: - The apikey to use for further requests and expiration time (epoch) - 400: - KeyNotFound: One of the required parameters was not given - 401: - PasswordInvalid: The password given is not correct for the user account - 404: - UsernameNotFound: The username was not found - """ - user = User(inputs['username'], inputs['password']) # Generate an API key until one @@ -396,39 +393,25 @@ def api_login(inputs: Dict[str, str]): }) result = {'api_key': api_key, 'expires': exp} - return return_api(result) + return return_api(result, code=201) -@api.route('/auth/logout', methods=['POST']) -@error_handler -@auth +@api.route( + '/auth/logout', + 'Logout of a user account', + methods=['POST'] +) +@endpoint_wrapper def api_logout(): - """ - Endpoint: /auth/logout - Description: Logout of a user account - Requires being logged in: Yes - Methods: - POST: - Returns: - 200: - Logout successful - """ api_key_map.pop(g.hashed_api_key) - return return_api({}) + return return_api({}, code=201) -@api.route('/auth/status', methods=['GET']) -@error_handler -@auth +@api.route( + '/auth/status', + 'Get current status of login', + methods=['GET'] +) +@endpoint_wrapper def api_status(): - """ - Endpoint: /auth/status - Description: Get current status of login - Requires being logged in: Yes - Methods: - GET: - Returns: - 200: - The username of the logged in account and the expiration time of the api key (epoch) - """ result = { 'expires': api_key_map[g.hashed_api_key]['exp'], 'username': api_key_map[g.hashed_api_key]['user_data'].username @@ -439,55 +422,29 @@ def api_status(): # User endpoints #=================== -@api.route('/user/add', methods=['POST']) -@error_handler -@input_validation +@api.route( + '/user/add', + 'Create a new user account', + {'POST': [[UsernameCreateVariable, PasswordCreateVariable]]}, + False, + methods=['POST'] +) +@endpoint_wrapper def api_add_user(inputs: Dict[str, str]): - """ - Endpoint: /user/add - Description: Create a new user account - Requires being logged in: No - Methods: - POST: - Parameters (body): - username (required): the username of the new user account - password (required): the password of the new user account - Returns: - 201: - The user id of the new user account - 400: - KeyNotFound: One of the required parameters was not given - UsernameInvalid: The username given is not allowed - UsernameTaken: The username given is already in use - """ - user_id = register_user(inputs['username'], inputs['password']) - return return_api({'user_id': user_id}, code=201) + register_user(inputs['username'], inputs['password']) + return return_api({}, code=201) -@api.route('/user', methods=['PUT', 'DELETE']) -@error_handler -@auth -@input_validation +@api.route( + '/user', + 'Manage a user account', + {'PUT': [[NewPasswordVariable], + 'Change the password of the user account'], + 'DELETE': [[], + 'Delete the user account']}, + methods=['PUT', 'DELETE'] +) +@endpoint_wrapper def api_manage_user(inputs: Dict[str, str]): - """ - Endpoint: /user - Description: Manage a user account - Requires being logged in: Yes - Methods: - PUT: - Description: Change the password of the user account - Parameters (body): - new_password (required): the new password of the user account - Returns: - 200: - Password updated successfully - 400: - KeyNotFound: One of the required parameters was not given - DELETE: - Description: Delete the user account - Returns: - 200: - Account deleted successfully - """ if request.method == 'PUT': g.user_data.edit_password(inputs['new_password']) return return_api({}) @@ -501,32 +458,17 @@ def api_manage_user(inputs: Dict[str, str]): # Notification service endpoints #=================== -@api.route('/notificationservices', methods=['GET', 'POST']) -@error_handler -@auth -@input_validation +@api.route( + '/notificationservices', + 'Manage the notification services', + {'GET': [[], + 'Get a list of all notification services'], + 'POST': [[TitleVariable, URLVariable], + 'Add a notification service']}, + methods=['GET', 'POST'] +) +@endpoint_wrapper def api_notification_services_list(inputs: Dict[str, str]): - """ - Endpoint: /notificationservices - Description: Manage the notification services - Requires being logged in: Yes - Methods: - GET: - Description: Get a list of all notification services - Returns: - 200: - The id, title and url of every notification service - POST: - Description: Add a notification service - Parameters (body): - title (required): the title of the notification service - url (required): the apprise url of the notification service - Returns: - 200: - The id of the new notification service - 400: - KeyNotFound: One of the required parameters was not given - """ services: NotificationServices = g.user_data.notification_services if request.method == 'GET': @@ -538,45 +480,17 @@ def api_notification_services_list(inputs: Dict[str, str]): url=inputs['url']).get() return return_api(result, code=201) -@api.route('/notificationservices/', methods=['GET', 'PUT', 'DELETE']) -@error_handler -@auth -@input_validation +@api.route( + '/notificationservices/', + 'Manage a specific notification service', + {'PUT': [[EditTitleVariable, EditURLVariable], + 'Edit the notification service'], + 'DELETE': [[], + 'Delete the notification service']}, + methods=['GET', 'PUT', 'DELETE'] +) +@endpoint_wrapper def api_notification_service(inputs: Dict[str, str], n_id: int): - """ - Endpoint: /notificationservices/ - Description: Manage a specific notification service - Requires being logged in: Yes - URL Parameters: - : - The id of the notification service - Methods: - GET: - Returns: - 200: - All info about the notification service - 404: - No notification service found with the given id - PUT: - Description: Edit the notification service - Parameters (body): - title: The new title of the entry. - url: The new apprise url of the entry. - Returns: - 200: - Notification service updated successfully - 400: - The apprise url is invalid - 404: - No notification service found with the given id - DELETE: - Description: Delete the notification service - Returns: - 200: - Notification service deleted successfully - 404: - No notification service found with the given id - """ service: NotificationService = g.user_data.notification_services.fetchone(n_id) if request.method == 'GET': @@ -596,41 +510,21 @@ def api_notification_service(inputs: Dict[str, str], n_id: int): # Library endpoints #=================== -@api.route('/reminders', methods=['GET', 'POST']) -@error_handler -@auth -@input_validation +@api.route( + '/reminders', + 'Manage the reminders', + {'GET': [[SortByVariable], + 'Get a list of all reminders'], + 'POST': [[TitleVariable, TimeVariable, + NotificationServicesVariable, TextVariable, + RepeatQuantityVariable, RepeatIntervalVariable, + ColorVariable], + 'Add a reminder'] + }, + methods=['GET', 'POST'] +) +@endpoint_wrapper def api_reminders_list(inputs: Dict[str, Any]): - """ - Endpoint: /reminders - Description: Manage the reminders - Requires being logged in: Yes - Methods: - GET: - Description: Get a list of all reminders - Parameters (url): - sort_by: How to sort the result. Allowed values are 'title', 'title_reversed', 'time', 'time_reversed', 'date_added' and 'date_added_reversed' - Returns: - 200: - The id, title, text, time, repeat_quantity, repeat_interval and color of each reminder - POST: - Description: Add a reminder - Parameters (body): - title (required): the title of the reminder - time (required): the UTC epoch timestamp that the reminder should be sent at - notification_services (required): array of the id's of the notification services to use to send the notification - text: the body of the reminder - repeat_quantity ('years', 'months', 'weeks', 'days', 'hours', 'minutes'): The quantity of the repeat_interval - repeat_interval: The number of the interval - color: The hex code of the color of the reminder, which is shown in the web-ui - Returns: - 200: - The info about the new reminder entry - 400: - KeyNotFound: One of the required parameters was not given - 404: - NotificationServiceNotFound: One of the notification services was not found - """ reminders: Reminders = g.user_data.reminders if request.method == 'GET': @@ -647,98 +541,43 @@ def api_reminders_list(inputs: Dict[str, Any]): color=inputs['color']) return return_api(result.get(), code=201) -@api.route('/reminders/search', methods=['GET']) -@error_handler -@auth -@input_validation +@api.route( + '/reminders/search', + 'Search through the list of reminders', + {'GET': [[SortByVariable, QueryVariable]]}, + methods=['GET'] +) +@endpoint_wrapper def api_reminders_query(inputs: Dict[str, str]): - """ - Endpoint: /reminders/search - Description: Search through the list of reminders - Requires being logged in: Yes - Methods: - GET: - Parameters (url): - query (required): The search term - sort_by: How to sort the result. Allowed values are 'title', 'title_reversed', 'time', 'time_reversed', 'date_added' and 'date_added_reversed' - Returns: - 200: - The search results, listed like GET /reminders - 400: - KeyNotFound: One of the required parameters was not given - """ result = g.user_data.reminders.search(inputs['query'], inputs['sort_by']) return return_api(result) -@api.route('/reminders/test', methods=['POST']) -@error_handler -@auth -@input_validation +@api.route( + '/reminders/test', + 'Test send a reminder draft', + {'POST': [[TitleVariable, NotificationServicesVariable, + TextVariable]]}, + methods=['POST'] +) +@endpoint_wrapper def api_test_reminder(inputs: Dict[str, Any]): - """ - Endpoint: /reminders/test - Description: Test send a reminder draft - Requires being logged in: Yes - Methods: - GET: - Parameters (body): - title (required): The title of the entry. - notification_service (required): The new id of the notification service to use to send the reminder. - text: The body of the reminder. - Returns: - 201: - The reminder is sent (doesn't mean it works, just that it was sent) - 400: - KeyNotFound: One of the required parameters was not given - 404: - NotificationServiceNotFound: The notification service given was not found - """ g.user_data.reminders.test_reminder(inputs['title'], inputs['notification_services'], inputs['text']) return return_api({}, code=201) -@api.route('/reminders/', methods=['GET', 'PUT', 'DELETE']) -@error_handler -@auth -@input_validation +@api.route( + '/reminders/', + 'Manage a specific reminder', + {'PUT': [[EditTitleVariable, EditTimeVariable, + EditNotificationServicesVariable, TextVariable, + RepeatQuantityVariable, RepeatIntervalVariable, + ColorVariable], + 'Edit the reminder'], + 'DELETE': [[], + 'Delete the reminder']}, + methods=['GET', 'PUT', 'DELETE'] +) +@endpoint_wrapper def api_get_reminder(inputs: Dict[str, Any], r_id: int): - """ - Endpoint: /reminders/ - Description: Manage a specific reminder - Requires being logged in: Yes - URL Parameters: - : - The id of the reminder - Methods: - GET: - Returns: - 200: - All info about the reminder - 404: - No reminder found with the given id - PUT: - Description: Edit the reminder - Parameters (body): - title: The new title of the entry. - time: The new UTC epoch timestamp the the reminder should be send. - notification_services: Array of the new id's of the notification services to use to send the reminder. - text: The new body of the reminder. - repeat_quantity ('years', 'months', 'weeks', 'days', 'hours', 'minutes'): The new quantity of the repeat_interval. - repeat_interval: The new number of the interval. - color: The new hex code of the color of the reminder, which is shown in the web-ui. - Returns: - 200: - Reminder updated successfully - 404: - ReminderNotFound: No reminder found with the given id - NotificationServiceNotFound: One of the notification services was not found - DELETE: - Description: Delete the reminder - Returns: - 200: - Reminder deleted successfully - 404: - No reminder found with the given id - """ reminders: Reminders = g.user_data.reminders if request.method == 'GET': result = reminders.fetchone(r_id).get() @@ -762,38 +601,18 @@ def api_get_reminder(inputs: Dict[str, Any], r_id: int): # Template endpoints #=================== -@api.route('/templates', methods=['GET', 'POST']) -@error_handler -@auth -@input_validation +@api.route( + '/templates', + 'Manage the templates', + {'GET': [[TemplateSortByVariable], + 'Get a list of all templates'], + 'POST': [[TitleVariable, NotificationServicesVariable, + TextVariable, ColorVariable], + 'Add a template']}, + methods=['GET', 'POST'] +) +@endpoint_wrapper def api_get_templates(inputs: Dict[str, Any]): - """ - Endpoint: /templates - Description: Manage the templates - Requires being logged in: Yes - Methods: - GET: - Description: Get a list of all templates - Parameters (url): - sort_by: How to sort the result. Allowed values are 'title', 'title_reversed', 'date_added' and 'date_added_reversed' - Returns: - 200: - The id, title, text and color of every template - POST: - Description: Add a template - Parameters (body): - title (required): the title of the template - notification_services (required): array of the id's of the notification services to use to send the notification - text: the body of the template - color: the hex code of the color of the template, which is shown in the web-ui - Returns: - 200: - The info about the new template entry - 400: - KeyNotFound: One of the required parameters was not given - 404: - NotificationServiceNotFound: One of the notification services was not found - """ templates: Templates = g.user_data.templates if request.method == 'GET': @@ -807,69 +626,29 @@ def api_get_templates(inputs: Dict[str, Any]): color=inputs['color']) return return_api(result.get(), code=201) -@api.route('/templates/search', methods=['GET']) -@error_handler -@auth -@input_validation +@api.route( + '/templates/search', + 'Search through the list of templates', + {'GET': [[TemplateSortByVariable, QueryVariable]]}, + methods=['GET'] +) +@endpoint_wrapper def api_templates_query(inputs: Dict[str, str]): - """ - Endpoint: /templates/search - Description: Search through the list of templates - Requires being logged in: Yes - Methods: - GET: - Parameters (url): - query (required): The search term - sort_by: How to sort the result. Allowed values are 'title', 'title_reversed', 'date_added' and 'date_added_reversed' - Returns: - 200: - The search results, listed like GET /templates - 400: - KeyNotFound: One of the required parameters was not given - """ result = g.user_data.templates.search(inputs['query'], inputs['sort_by']) return return_api(result) -@api.route('/templates/', methods=['GET', 'PUT', 'DELETE']) -@error_handler -@auth -@input_validation +@api.route( + '/templates/', + 'Manage a specific template', + {'PUT': [[EditTitleVariable, EditNotificationServicesVariable, + TextVariable, ColorVariable], + 'Edit the template'], + 'DELETE': [[], + 'Delete the template']}, + methods=['GET', 'PUT', 'DELETE'] +) +@endpoint_wrapper def api_get_template(inputs: Dict[str, Any], t_id: int): - """ - Endpoint: /templates/ - Description: Manage a specific template - Requires being logged in: Yes - URL Parameters: - : - The id of the template - Methods: - GET: - Returns: - 200: - All info about the template - 404: - No template found with the given id - PUT: - Description: Edit the template - Parameters (body): - title: The new title of the entry. - notification_services: The new array of id's of the notification services to use to send the reminder. - text: The new body of the template. - color: The new hex code of the color of the template. - Returns: - 200: - Template updated successfully - 404: - TemplateNotFound: No template found with the given id - NotificationServiceNotFound: One of the notification services was not found - DELETE: - Description: Delete the template - Returns: - 200: - Template deleted successfully - 404: - No template found with the given id - """ template: Template = g.user_data.templates.fetchone(t_id) if request.method == 'GET': @@ -891,38 +670,18 @@ def api_get_template(inputs: Dict[str, Any], t_id: int): # Static reminder endpoints #=================== -@api.route('/staticreminders', methods=['GET', 'POST']) -@error_handler -@auth -@input_validation +@api.route( + '/staticreminders', + 'Manage the static reminders', + {'GET': [[StaticReminderSortByVariable], + 'Get a list of all static reminders'], + 'POST': [[TitleVariable, NotificationServicesVariable, + TextVariable, ColorVariable], + 'Add a static reminder']}, + methods=['GET', 'POST'] +) +@endpoint_wrapper def api_static_reminders_list(inputs: Dict[str, Any]): - """ - Endpoint: /staticreminders - Description: Manage the static reminders - Requires being logged in: Yes - Methods: - GET: - Description: Get a list of all static reminders - Parameters (url): - sort_by: How to sort the result. Allowed values are 'title', 'title_reversed', 'date_added' and 'date_added_reversed' - Returns: - 200: - The id, title, text and color of each static reminder - POST: - Description: Add a static reminder - Parameters (body): - title (required): the title of the static reminder - notification_services (required): array of the id's of the notification services to use to send the notification - text: the body of the static reminder - color: The hex code of the color of the static reminder, which is shown in the web-ui - Returns: - 200: - The info about the new static reminder entry - 400: - KeyNotFound: One of the required parameters was not given - 404: - NotificationServiceNotFound: One of the notification services was not found - """ reminders: StaticReminders = g.user_data.static_reminders if request.method == 'GET': @@ -936,90 +695,47 @@ def api_static_reminders_list(inputs: Dict[str, Any]): color=inputs['color']) return return_api(result.get(), code=201) -@api.route('/staticreminders/search', methods=['GET']) -@error_handler -@auth -@input_validation +@api.route( + '/staticreminders/search', + 'Search through the list of staticreminders', + {'GET': [[StaticReminderSortByVariable, QueryVariable]]}, + methods=['GET'] +) +@endpoint_wrapper def api_static_reminders_query(inputs: Dict[str, str]): - """ - Endpoint: /staticreminders/search - Description: Search through the list of staticreminders - Requires being logged in: Yes - Methods: - GET: - Parameters (url): - query (required): The search term - sort_by: How to sort the result. Allowed values are 'title', 'title_reversed', 'date_added' and 'date_added_reversed' - Returns: - 200: - The search results, listed like GET /staticreminders - 400: - KeyNotFound: One of the required parameters was not given - """ result = g.user_data.static_reminders.search(inputs['query'], inputs['sort_by']) return return_api(result) -@api.route('/staticreminders/', methods=['GET', 'POST', 'PUT', 'DELETE']) -@error_handler -@auth -@input_validation -def api_get_static_reminder(inputs: Dict[str, Any], r_id: int): - """ - Endpoint: /staticreminders/ - Description: Manage a specific static reminder - Requires being logged in: Yes - URL Parameters: - : - The id of the static reminder - Methods: - GET: - Returns: - 200: - All info about the static reminder - 404: - No static reminder found with the given id - POST: - Description: Trigger the static reminder - Returns: - 200: - Static reminder triggered successfully - PUT: - Description: Edit the static reminder - Parameters (body): - title: The new title of the static reminder. - notification_services: The new array of id's of the notification services to use to send the reminder. - text: The new body of the static reminder. - color: The new hex code of the color of the static reminder, which is shown in the web-ui. - Returns: - 200: - Static reminder updated successfully - 404: - ReminderNotFound: No static reminder found with the given id - NotificationServiceNotFound: One of the notification services was not found - DELETE: - Description: Delete the static reminder - Returns: - 200: - Static reminder deleted successfully - 404: - No static reminder found with the given id - """ +@api.route( + '/staticreminders/', + 'Manage a specific static reminder', + {'POST': [[], + 'Trigger the static reminder'], + 'PUT': [[EditTitleVariable, EditNotificationServicesVariable, + TextVariable, ColorVariable], + 'Edit the static reminder'], + 'DELETE': [[], + 'Delete the static reminder']}, + methods=['GET', 'POST', 'PUT', 'DELETE'] +) +@endpoint_wrapper +def api_get_static_reminder(inputs: Dict[str, Any], s_id: int): reminders: StaticReminders = g.user_data.static_reminders if request.method == 'GET': - result = reminders.fetchone(r_id).get() + result = reminders.fetchone(s_id).get() return return_api(result) elif request.method == 'POST': - reminders.trigger_reminder(r_id) - return return_api({}) + reminders.trigger_reminder(s_id) + return return_api({}, code=201) elif request.method == 'PUT': - result = reminders.fetchone(r_id).update(title=inputs['title'], + result = reminders.fetchone(s_id).update(title=inputs['title'], notification_services=inputs['notification_services'], text=inputs['text'], color=inputs['color']) return return_api(result) elif request.method == 'DELETE': - reminders.fetchone(r_id).delete() + reminders.fetchone(s_id).delete() return return_api({}) diff --git a/generate_api_docs.py b/generate_api_docs.py new file mode 100644 index 0000000..7daf5a9 --- /dev/null +++ b/generate_api_docs.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +#-*- coding: utf-8 -*- + +from typing import Union +from frontend.api import (DataSource, NotificationServiceNotFound, + ReminderNotFound, TemplateNotFound, api_docs) +from MIND import _folder_path, api_prefix + +url_var_map = { + 'int:n_id': NotificationServiceNotFound, + 'int:r_id': ReminderNotFound, + 'int:t_id': TemplateNotFound, + 'int:s_id': ReminderNotFound +} + +result = f"""# API +Below is the API documentation. Report an issue on [GitHub](https://github.com/Casvt/MIND/issues). + +All endpoints have the `{api_prefix}` prefix. That means, for example, that `/auth/login` can be reached at `{api_prefix}/auth/login`. + +## Authentication + +Authentication is done using an API key. To log in, make a POST request to the [`/api/auth/login`](#authlogin) endpoint. You'll receive an API key, which you can then use in your requests to authenticate. Supply it as a url parameter with the key `api_key`. This API key is valid for one hour, after which the key expires, any further requests return 401 'APIKeyExpired' and you are required to log in again. If no `api_key` is supplied or it is invalid, 401 `APIKeyInvalid` is returned. + +For example: +```bash +curl -sSL 'http://192.168.2.15:8080/api/reminders?api_key=ABCDEFG' +``` + +## Supplying data + +Often, data needs to be supplied with a request. If the parameters need to be supplied via `url`, add them to the url as url parameters. If the parameters need to be supplied via `body`, add them to the body as a json object and supply the `Content-Type: application/json` header. + +For example: +```bash +# URL parameter +curl -sSL 'http://192.168.2.15:8080/api/reminders/search?api_key=ABCDEFG&query=Fountain&sort_by=time_reversed' + +# Body parameter +curl -sSLX POST \\ + -H 'Content-Type: application/json' \\ + -d '{{"title": "Test service", "url": "test://fake/url"}}' \\ + 'http://192.168.2.15:8080/api/notificationservices?api_key=ABCDEFG' +``` + +## Endpoints +The following is automatically generated. Please report any issues on [GitHub](https://github.com/Casvt/MIND/issues). +""" + +for rule, data in api_docs.items(): + result += f"""### `{rule}` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| {'Yes' if data['requires_auth'] else 'No'} | {data['description']} | +""" + + url_var = rule.replace('<', '>').split('>') + url_var: Union[str, None] = None if len(url_var) == 1 else url_var[1] + + if url_var: + result += f""" +Replace `<{url_var}>` with the ID of the entry. For example: `{rule.replace(f'<{url_var}>', '2')}`. +""" + + for method in data['methods']: + result += f"\n??? {method}\n" + + if method in data['method_descriptions']: + result += f"\n {data['method_descriptions'][method]}\n" + + var_types = { + 'url': list(var for var in data['input_variables'].get(method, []) if var.source == DataSource.VALUES), + 'body': list(var for var in data['input_variables'].get(method, []) if var.source == DataSource.DATA) + } + + for var_type, entries in var_types.items(): + if entries: + entries = [e('') for e in entries] + result += f""" + **Parameters ({var_type})** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | +""" + for entry in entries: + result += f" {entry}\n" + + result += f""" + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | {201 if method == 'POST' else 200}| N/A | Success | +""" + + url_exception = [url_var_map[url_var]] if url_var in url_var_map else [] + variable_exceptions = [e for v in data['input_variables'].get(method, []) for e in v.related_exceptions] + related_exceptions = sorted( + (e() for e in set(variable_exceptions + url_exception)), + key=lambda e: (e.api_response['code'], e.api_response['error']) + ) + for related_exception in related_exceptions: + ar = related_exception.api_response + result += f" | {ar['code']} | {ar['error']} | {related_exception.__doc__} |\n" + + result += '\n' + +with open(_folder_path('docs', 'api.md'), 'r') as f: + current_content = f.read() + +if current_content == result: + print('Nothing changed') +else: + with open(_folder_path('docs', 'api.md'), 'w+') as f: + f.write(result) diff --git a/mkdocs.yml b/mkdocs.yml index e6eeccd..7f887a1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -63,6 +63,7 @@ markdown_extensions: - toc: permalink: true toc_depth: 5 + - tables # mkdocs function extensions plugins: