blob: 9a94f67f828b579a3f6bc0aeff0b6599adb3dd19 [file] [log] [blame]
#!/usr/bin/env python3
import gen_universe
import json
import logging
import os
import re
from enum import Enum
from http import HTTPStatus
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.error import URLError, HTTPError
from urllib.parse import parse_qsl, urlparse
from urllib.request import Request, urlopen
# Binds to all available interfaces
HOST_NAME = ''
# Gets the port number from $PORT0 environment variable
PORT_NUMBER = int(os.environ['PORT_UNIVERSECONVERTER'])
MAX_REPO_SIZE = int(os.environ.get('MAX_REPO_SIZE', '20'))
# Constants
MAX_TIMEOUT = 60
MAX_BYTES = MAX_REPO_SIZE * 1024 * 1024
header_user_agent = 'User-Agent'
header_accept = 'Accept'
header_content_type = 'Content-Type'
header_content_length = 'Content-Length'
param_charset = 'charset'
default_charset = 'utf-8'
json_key_packages = 'packages'
param_url = 'url'
url_path = '/transform'
def run_server(server_class=HTTPServer):
"""Runs a builtin python server using the given server_class.
:param server_class: server
:type server_class: HTTPServer
:return: None
"""
server_address = (HOST_NAME, PORT_NUMBER)
httpd = server_class(server_address, Handler)
logger.warning('Server Starts on port - %s', PORT_NUMBER)
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.server_close()
logger.warning('Server Stops on port - %s', PORT_NUMBER)
class Handler(BaseHTTPRequestHandler):
def do_GET(s):
"""
Respond to the GET request. The expected format of this request is:
http://<host>:<port>/transform?url=<url> with `User-Agent`
and `Accept` headers
"""
errors = _validate_request(s)
if errors:
s.send_error(HTTPStatus.BAD_REQUEST, explain=errors)
return
query = dict(parse_qsl(urlparse(s.path).query))
if param_url not in query:
s.send_error(HTTPStatus.BAD_REQUEST,
explain=ErrorResponse.PARAM_NOT_PRESENT.to_msg(param_url))
return
logging.debug(">>>>>>>>>")
user_agent = s.headers.get(header_user_agent)
accept = s.headers.get(header_accept)
decoded_url = query.get(param_url)
try:
json_response = handle(decoded_url, user_agent, accept)
except Exception as e:
s.send_error(HTTPStatus.BAD_REQUEST, explain=str(e))
return
s.send_response(HTTPStatus.OK)
content_header = gen_universe.format_universe_repo_content_type(
_get_repo_version(accept))
s.send_header(header_content_type, content_header)
s.send_header(header_content_length, len(json_response))
s.end_headers()
s.wfile.write(json_response.encode())
def handle(decoded_url, user_agent, accept):
"""Returns the requested json data. May raise an error instead, if it fails.
:param decoded_url: The url to be fetched from
:type decoded_url: str
:param user_agent: User-Agent header value
:type user_agent: str
:param accept: Accept header value
:return Requested json data
:rtype str (a valid json object)
"""
logger.debug('Url : %s\n\tUser-Agent : %s\n\tAccept : %s',
decoded_url, user_agent, accept)
repo_version = _get_repo_version(accept)
dcos_version = _get_dcos_version(user_agent)
logger.debug('Version %s\nDC/OS %s', repo_version, dcos_version)
req = Request(decoded_url)
req.add_header(header_user_agent, user_agent)
req.add_header(header_accept, accept)
try:
with urlopen(req, timeout=MAX_TIMEOUT) as res:
charset = res.info().get_param(param_charset) or default_charset
if header_content_length not in res.headers:
raise ValueError(ErrorResponse.ENDPOINT_HEADER_MISS.to_msg())
if int(res.headers.get(header_content_length)) > MAX_BYTES:
raise ValueError(ErrorResponse.MAX_SIZE.to_msg())
raw_data = res.read()
packages = json.loads(raw_data.decode(charset)).get(json_key_packages)
except (HTTPError, URLError) as error:
logger.info("Request protocol error %s", decoded_url)
logger.exception(error)
raise error
return render_json(packages, dcos_version, repo_version)
def render_json(packages, dcos_version, repo_version):
"""Returns the json
:param packages: package dictionary
:type packages: dict
:param dcos_version: version of dcos
:type dcos_version: str
:param repo_version: version of universe repo
:type repo_version: str
:return filtered json data based on parameters
:rtype str
"""
processed_packages = gen_universe.filter_and_downgrade_packages_by_version(
packages,
dcos_version
)
packages_dict = {json_key_packages: processed_packages}
errors = gen_universe.validate_repo_with_schema(
packages_dict,
repo_version
)
if len(errors) != 0:
logger.error(errors)
raise ValueError(ErrorResponse.VALIDATION_ERROR.to_msg(errors))
return json.dumps(packages_dict)
def _validate_request(s):
"""
:param s: The in built base http request handler
:type s: BaseHTTPRequestHandler
:return Error message (if any)
:rtype String or None
"""
if not urlparse(s.path).path == url_path:
return ErrorResponse.INVALID_PATH.to_msg(s.path)
if header_user_agent not in s.headers:
return ErrorResponse.HEADER_NOT_PRESENT.to_msg(header_user_agent)
if header_accept not in s.headers:
return ErrorResponse.HEADER_NOT_PRESENT.to_msg(header_accept)
def _get_repo_version(accept_header):
"""Returns the version of the universe repo parsed.
:param accept_header: String
:return repo version as a string or raises Error
:rtype str or raises an Error
"""
result = re.findall(r'\bversion=v\d', accept_header)
if result is None or len(result) is 0:
raise ValueError(ErrorResponse.UNABLE_PARSE.to_msg(accept_header))
result.sort(reverse=True)
return str(result[0].split('=')[1])
def _get_dcos_version(user_agent_header):
"""Parses the version of dcos from the specified header.
:param user_agent_header: String
:return dcos version as a string or raises an Error
:rtype str or raises an Error
"""
result = re.search(r'\bdcos/\b\d\.\d{1,2}', user_agent_header)
if result is None:
raise ValueError(ErrorResponse.UNABLE_PARSE.to_msg(user_agent_header))
return str(result.group().split('/')[1])
class ErrorResponse(Enum):
INVALID_PATH = 'URL Path {} is invalid. Expected path /transform'
HEADER_NOT_PRESENT = 'Header {} is missing'
PARAM_NOT_PRESENT = 'Request parameter {} is missing'
UNABLE_PARSE = 'Unable to parse header {}'
VALIDATION_ERROR = 'Validation errors during processing {}'
MAX_SIZE = 'Endpoint response exceeds maximum content size'
ENDPOINT_HEADER_MISS = 'Endpoint doesn\'t return Content-Length header'
def to_msg(self, *args):
return self.value.format(args)
if __name__ == '__main__':
logger = logging.getLogger(__name__)
logging.basicConfig(
level=os.environ.get("LOGLEVEL", "INFO"),
format='%(asctime)s [%(levelname)s] %(message)s'
)
run_server()