HTTP Assignment - But now hosted on Google Cloud App Engine

- Tom Moses

Example Requests

https://maths.tmos.es/add/5/7

GET

HTTP/1.1 200 OK

Content-Type: text/plain

12

https://maths.tmos.es/

POST
JSON

{

"operation": "multiply",

"arguments": [ 12, 6.1 ]

}

HTTP/1.1 200 OK

Content-Type: application/json

{

"result": 73.2

}

https://maths.tmos.es/any/random/path/like/this

POST
JSON

{

"operation": "subtract",

"arguments": [ 10, 38 ]

}

HTTP/1.1 200 OK

Content-Type: application/json

{

"result": -28

}

https://maths.tmos.es/foo/9/8

GET

HTTP/1.1 400 BAD REQUEST

Content-Type: text/plain

operation was not valid

https://maths.tmos.es/dinner

POST
JSON

{

"operation": "multiply",

"arguments": [ "chicken", "pie" ]

}

HTTP/1.1 400 BAD REQUEST

Content-Type: text/plain

an argument was not a number

https://maths.tmos.es/divide/831/0

GET

HTTP/1.1 405 METHOD NOT ALLOWED

Content-Type: text/plain

cannot divide by 0

Server Code

This code is hosted on Google Cloud, in an App Engine instance.

It was initially hosted on PythonAnywhere which made this assignment surprisingly simple, so I decided to do a bit extra.

Written in Python using Flask. This web server take traffic for (almost) any subdomain of tmos.es with almost any path.

At the maths.tmos.es subomain I have the HTTP Assignemnt set up, and I have also built a URL shortener at tmos.es

Any GET requests sent to a subdomain which I don't have in use is forwarded to tmos.es with the same path.

The URL Shortener uses a Firestore database and sends 302 response forwarding to the set url. If not, then you have the option to give this path a destination.

To set a Short URL destination, my server accepts POST requests to the path (e.g. https://tmos.es/news) they want to set with a destination.

When visiting a short URL, http or https are accepted. When setting a destination, https must be used. When visiting any page (apart from a redirect), you are redirected to the https version.

For tmos.es and these main subdomains (preview.tmos.es & maths.tmos.es), I have Google-managed, auto-renewing SSL Security. However to redirect any random subdomain over https I have set up a manually created Wildcard certificate (*.tmos.es) from Lets Encrypt. The limitation of this is that second level subdomains will not work over https (as defined in RFC 2818)... but who is trying to visit https://random.sub.subdomain.tmos.es/news anyway when the whole point is a url shortener. Anyway, http://any.random.long.subdomain.like.this.tmos.es/news will work.

import logging
from flask import Flask, Response, request, render_template, json, redirect, send_from_directory
from decimal import Decimal
from werkzeug.exceptions import BadRequest
from google.cloud import firestore

app = Flask(__name__, subdomain_matching=True)
app.config['SERVER_NAME'] = "tmos.es"
db = firestore.Client()

#####################
#  URL SHORTENER    #
#####################

# home page
@app.route('/')
def home():
    # redirect to https for home page
    if not request.is_secure and request.method == 'GET':
        url = request.url.replace('http://', 'https://', 1)
        code = 301
        return redirect(url, code=code)
    return render_template('shorter-home.html')

# url redirector
@app.route('/<path:path>')
def apex(path):
    destination = getDestinationURL(path)
    if destination:
        return redirect(destination)
    else:
        if not request.is_secure and request.method == 'GET':
            url = request.url.replace('http://', 'https://', 1)
            code = 301
            return redirect(url, code=code)
        return render_template('shorter.html', data={'path': path})

# url redirect preview
@app.route('/<path:path>', subdomain="preview")
def preview(path):
    # redirect to https for preview
    if not request.is_secure and request.method == 'GET':
        url = request.url.replace('http://', 'https://', 1)
        code = 301
        return redirect(url, code=code)
    destination = getDestinationURL(path)
    if destination:
        return f'redirect to: {destination}'
    return f'there is no redirect for that path: tmos.es/{path}'

# create a short URL
@app.route('/<path:path>', methods=["POST"])
def shorter(path):
    # deal with post to create short url
    if not request.is_secure:
        return Response(
            'you need to post to https',
            status=400, mimetype='text/plain')
    if not getDestinationURL(path):
        reqJson = request.get_json()
        destination = reqJson['destination']
        setDestinationURL(path, destination)
        resp = Response(
            f'destination set to: {destination}',
            status=200, mimetype='text/plain')
        resp.headers['Access-Control-Allow-Origin'] = '*'
        return resp
    else:
        return Response(
            'that path already has a destination set',
            status=400, mimetype='text/plain')

@app.route('/tailwind.css')
def sendShorterCSS():
    return send_from_directory('css', 'tailwind.css')

# get destination url
def getDestinationURL(path):
    path = path.lower().replace('/','-')
    docRef = db.collection(u'redirects').document(path)
    doc = docRef.get().to_dict()
    if doc and doc['destination']:
        return doc['destination']
    else:
        return None

# set destination url
def setDestinationURL(path, destination):
    path = path.lower().replace('/','-')
    data = {'destination': destination}
    docRef = db.collection(u'redirects').document(path)
    doc = docRef.set(data)


##########################
# REDIRECT ANY SUBDOMAIN #
##########################

@app.route('/', defaults={'path': ''}, subdomain="<subdomain>")
@app.route('/<path:path>', subdomain="<subdomain>")
def any(path, subdomain):
    print(f'hello any: {subdomain}.tmos.es/{path}')
    return redirect(f'https://tmos.es/{path}')


#####################
#     MATHS         #
#####################

HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']

@app.route('/', defaults={'path': ''}, methods=HTTP_METHODS, subdomain="maths")
@app.route('/<path:path>', methods=HTTP_METHODS, subdomain="maths")
def math(path):
    # enforce https for maths subdomain
    if not request.is_secure and request.method == 'GET':
        url = request.url.replace('http://', 'https://', 1)
        code = 301
        return redirect(url, code=code)
    try:
        if request.method == 'POST':
            return handlePost()
        elif request.method == 'GET' or request.method == 'HEAD':
            return handleGet(path)
        else:
            # if not GET or POST, return method not allowed
            return Response(
                'method not allowed',
                status=405, mimetype='text/plain')
    except BadRequest:
        return Response(
            "bad request",
            status=400, mimetype='text/plain')
    except Exception as e:
        print(f'An exception of type {type(e).__name__} occurred. Arguments:\n{e.args}')
        print(e)
        logging.exception('An error occurred during a request.')
        return Response(
            "something went wrong on my end, this has been logged",
            status=500, mimetype='text/plain')

@app.route('/tailwind.css', subdomain="maths")
def sendCSS():
    return send_from_directory('css', 'tailwind.css')

def handlePost():
    # get and validate json data
    reqJson = request.get_json()
    if not reqJson:
        return Response(
            "no json was sent in the request",
            status=400, mimetype='text/plain')

    # check for valid operation
    operations = ['add','subtract','multiply','divide']
    if ('operation' not in reqJson.keys() or
        reqJson['operation'] not in operations):
        return Response(
            "valid operation was not in the request json",
            status=400, mimetype='text/plain')

    # check for valid argurments
    if ('arguments' not in reqJson.keys() or
        not isinstance(reqJson['arguments'], list) or
        len(reqJson['arguments']) != 2):
        return Response(
            "2 arguments were not in the request json",
            status=400, mimetype='text/plain')
    for i in [0,1]:
        if (not (isinstance(reqJson['arguments'][i], float) or
        isinstance(reqJson['arguments'][i], int))):
            return Response(
                "an argument was not a number",
                status=400, mimetype='text/plain')

    # check for divide by 0
    if reqJson['operation'] == 'divide' and reqJson['arguments'][1] == 0:
        return Response(
            'cannot divide by 0',
            status=422, mimetype='text/plain')

    # calculate and return result
    result = calculate(
        reqJson['operation'],
        reqJson['arguments'][0],
        reqJson['arguments'][1])
    return Response(
        json.dumps({'result': result}),
        status=200, mimetype='application/json')


def handleGet(path):
    # if no path given, show tutorial
    if path == '':
        # I am ignoring q-factor weighting as that seems a bit overkill
        if ('text/html' in request.headers["Accept"] or
            '*/*' in request.headers["Accept"] or
            'text/*' in request.headers["Accept"]):
            return render_template('tutorial.html')
        else:
            return Response(
                "invalid request, revisit this url in a browser for help",
                status=406, mimetype='text/plain')

    # get and validate path parts
    # check for valid operation
    operations = ['add','subtract','multiply','divide']
    parts = path.split('/')
    if len(parts) != 3:
        return Response(
            "expected 3 parts in the path",
            status=400, mimetype='text/plain')
    operation = parts[0]
    if operation not in operations:
        return Response(
            "operation was not valid",
            status=400, mimetype='text/plain')

    # check for valid arguments
    arguments=[]
    for pathPart in [1,2]:
        try:
            arguments.append(float(parts[pathPart]))
        except ValueError:
            return Response(
                f'item at position {pathPart} was not a number',
                status=400, mimetype='text/plain')

    # check for divide by 0
    if operation == 'divide' and arguments[1] == 0:
        return Response('cannot divide by 0', status=422, mimetype='text/plain')

    # calculate and return result
    result = calculate(operation, arguments[0], arguments[1])
    return Response(str(result), status=200, mimetype='text/plain')


def calculate(operation, first, second):
    first = Decimal(first)
    second = Decimal(second)
    result = 0
    if operation == 'add':
        result = first + second
    elif operation == 'subtract':
        result = first - second
    elif operation == 'multiply':
        result = first * second
    elif operation == 'divide':
        result = first / second
    result = float(result)
    if result.is_integer():
        result = int(result)
    return result