diff --git a/gateway/constants.py b/gateway/constants.py new file mode 100644 index 0000000..964d6f3 --- /dev/null +++ b/gateway/constants.py @@ -0,0 +1,4 @@ +"""string constants.""" + +CONTENT_TYPE = "Content-Type" +CONTENT_LENGTH = "Content-Length" diff --git a/gateway/core.py b/gateway/core.py index 6883524..8d74370 100644 --- a/gateway/core.py +++ b/gateway/core.py @@ -2,23 +2,24 @@ from typing import Sequence import httpx -from aiohttp import JsonPayload, hdrs -from aiohttp.client_exceptions import ClientConnectorError, ContentTypeError from fastapi import HTTPException, params, status from fastapi.datastructures import Headers from fastapi.requests import Request from fastapi.responses import StreamingResponse, JSONResponse +from httpx import ConnectError, DecodingError from starlette.responses import Response -from gateway.models import GatewayFormData -from gateway.utils import unzip_form_params, unzip_body_object, create_request_data +from gateway.constants import CONTENT_TYPE +# from gateway.models import GatewayFormData +from gateway.utils import unzip_form_params, unzip_body_object, create_request_data, unzip_query_params async def make_request( url: str, method: str, headers: Headers | dict, - data: JsonPayload | dict | GatewayFormData | None = None, + query: dict | None = None, + data: dict | None = None, ) -> tuple[[JSONResponse | StreamingResponse], int]: """Make an asynchronous request by creating a temporary session. @@ -28,9 +29,11 @@ async def make_request( The URL of the forwarded microservice method : str HTTP method e.g. GET, POST, PUT, DELETE - headers : Union[Headers, dict] + headers : Headers | dict A dictionary-like object defining the request headers - data : Union[JsonPayload, dict] + query : dict | None + Serialized query parameters to be added to the request. + data : JsonPayload | dict | GatewayFormData | None A dictionary-like object defining the payload Returns @@ -42,8 +45,11 @@ async def make_request( if not data: # Always package data else error data = {} + if not query: + query = {} + async with httpx.AsyncClient(headers=headers) as client: - r = await client.request(url=url, method=method, data=data) + r = await client.request(url=url, method=method, params=query, data=data) resp_data = r.json() return resp_data, r.status_code @@ -75,14 +81,14 @@ def route( path: str, service_url: str, status_code: int | None = None, + query_params: list[str] | None = None, form_params: list[str] | None = None, body_params: list[str] | None = None, - response_model: str = None, + response_model: any = None, # TODO: Make specific for pydantic models tags: list[str] = None, dependencies: Sequence[params.Depends] | None = None, summary: str | None = None, description: str | None = None, - response_stream: bool = False, # params from fastapi http methods can be added here later and then added to `request_method()` ): """A decorator for the FastAPI router, its purpose is to make FastAPI @@ -98,6 +104,8 @@ def route( HTTP status code. service_url : str Root endpoint of the microservice for the forwarded request. + query_params : list[str] | None + Keys passed referencing query model parameters to be sent to downstream microservice form_params : list[str] | None Keys passed referencing form model parameters to be sent to downstream microservice body_params : list[str] | None @@ -112,8 +120,6 @@ def route( Summary of the method (usually short). description: str | None Longer explanation of the method. - response_stream: bool - Whether the expected response from the microservice is a StreamingResponse Returns @@ -141,7 +147,7 @@ async def inner(request: Request, response: Response, **kwargs): downstream_path = scope['path'] - content_type = str(request.headers.get(hdrs.CONTENT_TYPE)) + content_type = str(request.headers.get(CONTENT_TYPE)) www_request_form = await request.form() if 'x-www-form-urlencoded' in content_type else None # Prune headers @@ -150,6 +156,11 @@ async def inner(request: Request, response: Response, **kwargs): request_headers.pop("content-type", None) # Let aiohttp configure content-type request_headers.pop("host", None) + # Prepare query params + request_query = await unzip_query_params( + necessary_params=query_params, all_params=kwargs + ) + # Prepare body and form data request_body = await unzip_body_object( specified_params=body_params, @@ -170,18 +181,19 @@ async def inner(request: Request, response: Response, **kwargs): resp_data, status_code_from_service = await make_request( url=microsvc_path, method=method, + query=request_query, data=request_data, headers=request_headers, ) - except ClientConnectorError: + except ConnectError: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Service is unavailable", headers={"WWW-Authenticate": "Bearer"}, ) - except ContentTypeError: + except DecodingError: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Service error", diff --git a/gateway/models.py b/gateway/models.py index c6b5db8..ccc8d70 100644 --- a/gateway/models.py +++ b/gateway/models.py @@ -1,10 +1,11 @@ """Models for API.""" +import datetime +import uuid from typing import Optional from uuid import UUID -from aiohttp import FormData, multipart, hdrs, payload +# from aiohttp import FormData, multipart, hdrs, payload from pydantic import BaseModel -from starlette.datastructures import UploadFile # Needs to be from starlette else isinstance() fails # Method models @@ -39,77 +40,80 @@ class AuthConfiguration(BaseModel): issuer_url: str -class GatewayFormData(FormData): - """Specialized form model with methods for parsing field data as well as uploaded files.""" - - # This method is copied from a PR to fix form data being falsely reported as not processed during redirects - # https://github.com/aio-libs/aiohttp/pull/5583/files - def _gen_form_data(self) -> multipart.MultipartWriter: - """Encode a list of fields using the multipart/form-data MIME format""" - if self._is_processed: - return self._writer - for dispparams, headers, value in self._fields: - try: - if hdrs.CONTENT_TYPE in headers: - part = payload.get_payload( - value, - content_type=headers[hdrs.CONTENT_TYPE], - headers=headers, - encoding=self._charset, - ) - else: - part = payload.get_payload( - value, headers=headers, encoding=self._charset - ) - except Exception as exc: - raise TypeError( - "Can not serialize value type: %r\n " - "headers: %r\n value: %r" % (type(value), headers, value) - ) from exc - - if dispparams: - part.set_content_disposition( - "form-data", quote_fields=self._quote_fields, **dispparams - ) - # FIXME cgi.FieldStorage doesn't likes body parts with - # Content-Length which were sent via chunked transfer encoding - assert part.headers is not None - part.headers.popall(hdrs.CONTENT_LENGTH, None) - - self._writer.append_payload(part) - - self._is_processed = True - return self._writer - - def add_www_form(self, name: str, value: any): - """Add specific field to simple form data if needed.""" - self.add_field(name=name, value=value) - - def add_multipart_form( - self, - name: str, - filename: str | None, - value: any, - content_type: str | None = None, - ): - """Add specific field to multipart form data if needed.""" - self.add_field( - name=name, filename=filename, value=value, content_type=content_type - ) - - async def upload(self, key, value: UploadFile | str): - """Asynchronously upload and read file into bytes then add to form data.""" - if isinstance(value, UploadFile): - bytes_file = await value.read() - self.add_multipart_form( - name=key, - filename=value.filename, - value=bytes_file, - content_type=value.content_type, - ) - - elif isinstance(value, str): # If simply a string, then add to form - self.add_www_form(name=key, value=value) +# class GatewayFormData(FormData): +# """Specialized form model with methods for parsing field data as well as uploaded files.""" +# +# # This method is copied from a PR to fix form data being falsely reported as not processed during redirects +# # https://github.com/aio-libs/aiohttp/pull/5583/files +# def _gen_form_data(self) -> multipart.MultipartWriter: +# """Encode a list of fields using the multipart/form-data MIME format""" +# if self._is_processed: +# return self._writer +# +# for dispparams, headers, value in self._fields: +# try: +# if "Content-Type" in headers: +# part = payload.get_payload( +# value, +# content_type=headers["Content-Type"], +# headers=headers, +# encoding=self._charset, +# ) +# +# else: +# part = payload.get_payload( +# value, headers=headers, encoding=self._charset +# ) +# +# except Exception as exc: +# raise TypeError( +# "Can not serialize value type: %r\n " +# "headers: %r\n value: %r" % (type(value), headers, value) +# ) from exc +# +# if dispparams: +# part.set_content_disposition( +# "form-data", quote_fields=self._quote_fields, **dispparams +# ) +# # FIXME cgi.FieldStorage doesn't likes body parts with +# # Content-Length which were sent via chunked transfer encoding +# assert part.headers is not None +# part.headers.popall("Content-Length", None) +# +# self._writer.append_payload(part) +# +# self._is_processed = True +# return self._writer +# +# def add_www_form(self, name: str, value: any): +# """Add specific field to simple form data if needed.""" +# self.add_field(name=name, value=value) +# +# def add_multipart_form( +# self, +# name: str, +# filename: str | None, +# value: any, +# content_type: str | None = None, +# ): +# """Add specific field to multipart form data if needed.""" +# self.add_field( +# name=name, filename=filename, value=value, content_type=content_type +# ) +# +# async def upload(self, key, value: UploadFile | str): +# """Asynchronously upload and read file into bytes then add to form data.""" +# if isinstance(value, UploadFile): +# bytes_file = await value.read() +# self.add_multipart_form( +# name=key, +# filename=value.filename, +# value=bytes_file, +# content_type=value.content_type, +# ) +# +# elif isinstance(value, str): # If simply a string, then add to form +# self.add_www_form(name=key, value=value) # Metadata models @@ -162,3 +166,21 @@ class ImageDataResponse(BaseModel): """Response model for image call.""" pullImages: list[PulledImageData] pushImages: list[ToPushImageData] + + +# Hub Models +class ProjectResponse(BaseModel): + """Single project response model.""" + id: uuid.UUID + name: str + analyses: int + created_at: datetime.datetime + updated_at: datetime.datetime + realm_id: uuid.UUID + user_id: uuid.UUID + master_image_id: uuid.UUID | None = None + + +class AllProjects(BaseModel): + """List of all projects.""" + data: list[ProjectResponse] diff --git a/gateway/routers/hub.py b/gateway/routers/hub.py index 1323af1..e41ed6e 100644 --- a/gateway/routers/hub.py +++ b/gateway/routers/hub.py @@ -1,4 +1,5 @@ """EPs for Hub provided information.""" +import uuid from fastapi import APIRouter, Security from starlette import status @@ -8,7 +9,7 @@ from gateway.auth import hub_oauth2_scheme from gateway.conf import gateway_settings from gateway.core import route -from gateway.models import ImageDataResponse, ContainerResponse +from gateway.models import ImageDataResponse, ContainerResponse, ProjectResponse, AllProjects hub_router = APIRouter( # dependencies=[Security(oauth2_scheme)], @@ -89,13 +90,33 @@ async def get_vault_status(): path="/projects", status_code=status.HTTP_200_OK, service_url=gateway_settings.HUB_SERVICE_URL, - response_model=None, + response_model=AllProjects, dependencies=[ Security(hub_oauth2_scheme) # TODO: move to router definition ] ) -async def hub_projects( +async def list_all_projects( request: Request, response: Response, ): + """List all projects.""" + pass + + +@route( + request_method=hub_router.get, + path="/projects/{project_id}", + status_code=status.HTTP_200_OK, + service_url=gateway_settings.HUB_SERVICE_URL, + response_model=ProjectResponse, + dependencies=[ + Security(hub_oauth2_scheme) # TODO: move to router definition + ] +) +async def list_specific_project( + project_id: uuid.UUID, + request: Request, + response: Response, +): + """List project for a given UUID.""" pass diff --git a/gateway/utils.py b/gateway/utils.py index a82814e..6852753 100644 --- a/gateway/utils.py +++ b/gateway/utils.py @@ -1,53 +1,86 @@ """Utility methods.""" -import json import os -from aiohttp import JsonPayload from fastapi.routing import serialize_response from starlette.datastructures import FormData -from gateway.models import GatewayFormData + +# from gateway.models import GatewayFormData def create_request_data( - form: GatewayFormData | None, - body: JsonPayload | None -) -> GatewayFormData | JsonPayload | None: + form: dict | None, + body: dict | None +) -> dict | None: """Package data into JSON or form depending on what is present.""" return form or body # If form then return form else return body i.e. JSON +async def serialize_query_content(key, value) -> dict: + """For each key, value, serialize the content and return as such.""" + serialized_data = await serialize_response(response_content=value) + if isinstance(serialized_data, dict): + serialized = serialized_data + + else: + serialized = {key: serialized_data} + + return serialized + + +async def unzip_query_params( + all_params: dict[str, any], + necessary_params: list[str] | None = None, +) -> dict[str, any] | None: + """Prepare query parameters to be added to URL of downstream microservice.""" + if necessary_params: + response_query_params = {} + + for key in necessary_params: + value = all_params.get(key) + serialized_dict = await serialize_query_content(key=key, value=value) + response_query_params.update(serialized_dict) + + return response_query_params + + return + + async def unzip_body_object( additional_params: dict[str, any], specified_params: list[str] | None = None, -) -> JsonPayload | None: +) -> dict | None: """Gather body data and package for forwarding.""" if specified_params: response_body_dict = {} + for key in specified_params: value = additional_params.get(key) _body_dict = await serialize_response(response_content=value) response_body_dict.update(_body_dict) - return JsonPayload(value=response_body_dict, dumps=json.dumps) + + return response_body_dict async def unzip_form_params( additional_params: dict[str, any], specified_params: list[str] | None = None, request_form: FormData | None = None, -) -> GatewayFormData | None: +) -> dict | None: """Gather form data and package for forwarding.""" if specified_params or request_form: - body_form = GatewayFormData() + body_form = dict() if specified_params: for key in specified_params: value = additional_params.get(key) - await body_form.upload(key=key, value=value) + # await body_form.upload(key=key, value=value) + body_form[key] = value if request_form: for key in request_form: - await body_form.upload(key=key, value=request_form[key]) + # await body_form.upload(key=key, value=request_form[key]) + body_form[key] = request_form[key] return body_form