diff options
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,  |