diff options
author | 2020-12-17 09:30:48 +0200 | |
---|---|---|
committer | 2020-12-17 09:30:48 +0200 | |
commit | 3d4038b4d96fddaec545682fbb83481299c1029e (patch) | |
tree | 7fa8e7a118499d7020a80ff5074253a1973af488 /backend/routes | |
parent | Merge pull request #38 from python-discord/submission-timestamp (diff) | |
parent | Merge branch 'main' into docs/api-spec (diff) |
Merge pull request #37 from python-discord/docs/api-spec
Provide HTML documentation of endpoints
Diffstat (limited to 'backend/routes')
-rw-r--r-- | backend/routes/auth/authorize.py | 28 | ||||
-rw-r--r-- | backend/routes/forms/discover.py | 6 | ||||
-rw-r--r-- | backend/routes/forms/form.py | 7 | ||||
-rw-r--r-- | backend/routes/forms/index.py | 19 | ||||
-rw-r--r-- | backend/routes/forms/response.py | 14 | ||||
-rw-r--r-- | backend/routes/forms/responses.py | 8 | ||||
-rw-r--r-- | backend/routes/forms/submit.py | 25 | ||||
-rw-r--r-- | backend/routes/index.py | 18 |
8 files changed, 113 insertions, 12 deletions
diff --git a/backend/routes/auth/authorize.py b/backend/routes/auth/authorize.py index 41c0a0b..2509109 100644 --- a/backend/routes/auth/authorize.py +++ b/backend/routes/auth/authorize.py @@ -2,13 +2,26 @@ Use a token received from the Discord OAuth2 system to fetch user information. """ +import httpx import jwt +from pydantic.fields import Field +from pydantic.main import BaseModel +from spectree.response import Response from starlette.requests import Request from starlette.responses import JSONResponse from backend.constants import SECRET_KEY from backend.route import Route from backend.discord import fetch_bearer_token, fetch_user_details +from backend.validation import ErrorMessage, api + + +class AuthorizeRequest(BaseModel): + token: str = Field(description="The access token received from Discord.") + + +class AuthorizeResponse(BaseModel): + token: str = Field(description="A JWT token containing the user information") class AuthorizeRoute(Route): @@ -19,11 +32,22 @@ class AuthorizeRoute(Route): name = "authorize" path = "/authorize" + @api.validate( + json=AuthorizeRequest, + resp=Response(HTTP_200=AuthorizeResponse, HTTP_400=ErrorMessage), + tags=["auth"] + ) async def post(self, request: Request) -> JSONResponse: + """Generate an authorization token.""" data = await request.json() - bearer_token = await fetch_bearer_token(data["token"]) - user_details = await fetch_user_details(bearer_token["access_token"]) + try: + bearer_token = await fetch_bearer_token(data["token"]) + user_details = await fetch_user_details(bearer_token["access_token"]) + except httpx.HTTPStatusError: + return JSONResponse({ + "error": "auth_failure" + }, status_code=400) user_details["admin"] = await request.state.db.admins.find_one( {"_id": user_details["id"]} diff --git a/backend/routes/forms/discover.py b/backend/routes/forms/discover.py index bba6fd4..9400f05 100644 --- a/backend/routes/forms/discover.py +++ b/backend/routes/forms/discover.py @@ -1,11 +1,13 @@ """ Return a list of all publicly discoverable forms to unauthenticated users. """ +from spectree.response import Response from starlette.requests import Request from starlette.responses import JSONResponse -from backend.models import Form +from backend.models import Form, FormList from backend.route import Route +from backend.validation import api class DiscoverableFormsList(Route): @@ -16,7 +18,9 @@ class DiscoverableFormsList(Route): name = "discoverable_forms_list" path = "/discoverable" + @api.validate(resp=Response(HTTP_200=FormList), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: + """List all discoverable forms that should be shown on the homepage.""" forms = [] cursor = request.state.db.forms.find({"features": "DISCOVERABLE"}) diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index 8fdd8a2..c953135 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -1,12 +1,14 @@ """ Returns or deletes a single form given an ID. """ +from spectree.response import Response from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse from backend.route import Route from backend.models import Form +from backend.validation import OkayResponse, api, ErrorMessage class SingleForm(Route): @@ -19,6 +21,7 @@ class SingleForm(Route): name = "form" path = "/{form_id:str}" + @api.validate(resp=Response(HTTP_200=Form, HTTP_404=ErrorMessage), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Returns single form information by ID.""" admin = request.user.payload["admin"] if request.user.is_authenticated else False # noqa @@ -37,6 +40,10 @@ class SingleForm(Route): return JSONResponse({"error": "not_found"}, status_code=404) @requires(["authenticated", "admin"]) + @api.validate( + resp=Response(HTTP_200=OkayResponse, HTTP_404=ErrorMessage), + tags=["forms"] + ) async def delete(self, request: Request) -> JSONResponse: """Deletes form by ID.""" if not await request.state.db.forms.find_one( diff --git a/backend/routes/forms/index.py b/backend/routes/forms/index.py index bb2b299..d1373e4 100644 --- a/backend/routes/forms/index.py +++ b/backend/routes/forms/index.py @@ -1,13 +1,14 @@ """ Return a list of all forms to authenticated users. """ -from pydantic import ValidationError +from spectree.response import Response from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse from backend.route import Route -from backend.models import Form +from backend.models import Form, FormList +from backend.validation import ErrorMessage, OkayResponse, api class FormsList(Route): @@ -19,7 +20,9 @@ class FormsList(Route): path = "/" @requires(["authenticated", "admin"]) + @api.validate(resp=Response(HTTP_200=FormList), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: + """Return a list of all forms to authenticated users.""" forms = [] cursor = request.state.db.forms.find() @@ -34,12 +37,16 @@ class FormsList(Route): ) @requires(["authenticated", "admin"]) + @api.validate( + json=Form, + resp=Response(HTTP_200=OkayResponse, HTTP_400=ErrorMessage), + tags=["forms"] + ) async def post(self, request: Request) -> JSONResponse: + """Create a new form.""" form_data = await request.json() - try: - form = Form(**form_data) - except ValidationError as e: - return JSONResponse(e.errors()) + + form = Form(**form_data) if await request.state.db.forms.find_one({"_id": form.id}): return JSONResponse({ diff --git a/backend/routes/forms/response.py b/backend/routes/forms/response.py index acaa647..d8d8d17 100644 --- a/backend/routes/forms/response.py +++ b/backend/routes/forms/response.py @@ -1,12 +1,14 @@ """ Returns or deletes form response by ID. """ +from spectree import Response as RouteResponse from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse from backend.models import FormResponse from backend.route import Route +from backend.validation import ErrorMessage, OkayResponse, api class Response(Route): @@ -16,8 +18,12 @@ class Response(Route): path = "/{form_id:str}/responses/{response_id:str}" @requires(["authenticated", "admin"]) + @api.validate( + resp=RouteResponse(HTTP_200=FormResponse, HTTP_404=ErrorMessage), + tags=["forms", "responses"] + ) async def get(self, request: Request) -> JSONResponse: - """Returns single form response by ID.""" + """Return a single form response by ID.""" if raw_response := await request.state.db.responses.find_one( { "_id": request.path_params["response_id"], @@ -30,8 +36,12 @@ class Response(Route): return JSONResponse({"error": "not_found"}, status_code=404) @requires(["authenticated", "admin"]) + @api.validate( + resp=RouteResponse(HTTP_200=OkayResponse, HTTP_404=ErrorMessage), + tags=["forms", "responses"] + ) async def delete(self, request: Request) -> JSONResponse: - """Deletes form response by ID.""" + """Delete a form response by ID.""" if not await request.state.db.responses.find_one( { "_id": request.path_params["response_id"], diff --git a/backend/routes/forms/responses.py b/backend/routes/forms/responses.py index ee8ab84..54da246 100644 --- a/backend/routes/forms/responses.py +++ b/backend/routes/forms/responses.py @@ -1,12 +1,14 @@ """ Returns all form responses by form ID. """ +from spectree import Response from starlette.authentication import requires from starlette.requests import Request from starlette.responses import JSONResponse -from backend.models import FormResponse +from backend.models import FormResponse, ResponseList from backend.route import Route +from backend.validation import api, ErrorMessage class Responses(Route): @@ -18,6 +20,10 @@ class Responses(Route): path = "/{form_id:str}/responses" @requires(["authenticated", "admin"]) + @api.validate( + resp=Response(HTTP_200=ResponseList, HTTP_404=ErrorMessage), + tags=["forms", "responses"] + ) async def get(self, request: Request) -> JSONResponse: """Returns all form responses by form ID.""" if not await request.state.db.forms.find_one( diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 48ae4f6..dfa0de6 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -4,11 +4,14 @@ Submit a form. import binascii import hashlib +from typing import Any, Optional import uuid import httpx +from pydantic.main import BaseModel import pydnsbl from pydantic import ValidationError +from spectree import Response from starlette.requests import Request from starlette.responses import JSONResponse @@ -16,6 +19,7 @@ from starlette.responses import JSONResponse from backend.constants import HCAPTCHA_API_SECRET, FormFeatures from backend.models import Form, FormResponse from backend.route import Route +from backend.validation import AuthorizationHeaders, ErrorMessage, api HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify" HCAPTCHA_HEADERS = { @@ -23,6 +27,16 @@ HCAPTCHA_HEADERS = { } +class SubmissionResponse(BaseModel): + form: Form + response: FormResponse + + +class PartialSubmission(BaseModel): + response: dict[str, Any] + captcha: Optional[str] + + class SubmitForm(Route): """ Submit a form with the provided form ID. @@ -31,7 +45,18 @@ class SubmitForm(Route): name = "submit_form" path = "/submit/{form_id:str}" + @api.validate( + json=PartialSubmission, + resp=Response( + HTTP_200=SubmissionResponse, + HTTP_404=ErrorMessage, + HTTP_400=ErrorMessage + ), + headers=AuthorizationHeaders, + tags=["forms", "responses"] + ) async def post(self, request: Request) -> JSONResponse: + """Submit a response to the form.""" data = await request.json() data["timestamp"] = None diff --git a/backend/routes/index.py b/backend/routes/index.py index b37f381..dd40d01 100644 --- a/backend/routes/index.py +++ b/backend/routes/index.py @@ -1,10 +1,24 @@ """ Index route for the forms API. """ +from pydantic import BaseModel +from pydantic.fields import Field +from spectree import Response from starlette.requests import Request from starlette.responses import JSONResponse from backend.route import Route +from backend.validation import api + + +class IndexResponse(BaseModel): + message: str = Field(description="A hello message") + client: str = Field( + description=( + "The connecting client, in production this will" + " be an IP of our internal load balancer" + ) + ) class IndexRoute(Route): @@ -17,7 +31,11 @@ class IndexRoute(Route): name = "index" path = "/" + @api.validate(resp=Response(HTTP_200=IndexResponse)) def get(self, request: Request) -> JSONResponse: + """ + Return a hello from Python Discord forms! + """ response_data = { "message": "Hello, world!", "client": request.client.host, |