Skip to content

Commit

Permalink
feat: add projects hub endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
brucetony committed Mar 11, 2024
1 parent 6a781c1 commit d6d5204
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 103 deletions.
4 changes: 4 additions & 0 deletions gateway/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""string constants."""

CONTENT_TYPE = "Content-Type"
CONTENT_LENGTH = "Content-Length"
42 changes: 27 additions & 15 deletions gateway/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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",
Expand Down
168 changes: 95 additions & 73 deletions gateway/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
27 changes: 24 additions & 3 deletions gateway/routers/hub.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""EPs for Hub provided information."""
import uuid

from fastapi import APIRouter, Security
from starlette import status
Expand All @@ -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)],
Expand Down Expand Up @@ -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
Loading

0 comments on commit d6d5204

Please sign in to comment.