https://maths.tmos.es/add/5/7
HTTP/1.1 200 OK
Content-Type: text/plain
12
https://maths.tmos.es/
{
"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
{
"operation": "subtract",
"arguments": [ 10, 38 ]
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"result": -28
}
https://maths.tmos.es/foo/9/8
HTTP/1.1 400 BAD REQUEST
Content-Type: text/plain
operation was not valid
https://maths.tmos.es/dinner
{
"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
HTTP/1.1 422 UNPROCESSABLE ENTITY
Content-Type: text/plain
cannot divide by 0
https://maths.tmos.es/add/100000000000000000000000000000/1000000000000000001
HTTP/1.1 200 OK
Content-Type: text/plain
100000000001000000000000000001
https://maths.tmos.es/add/0.1/0.2
HTTP/1.1 200 OK
Content-Type: text/plain
0.3
https://maths.tmos.es/divide/1/3
HTTP/1.1 200 OK
Content-Type: text/plain
0.33333333333333333333
https://maths.tmos.es/add/0x10/1
HTTP/1.1 200 OK
Content-Type: text/plain
17
This is the 2021 version of this project: Flask on Google App Engine, with Firestore for storage. It is kept here unedited as the original artifact of the university assignment - the notes below describe how it worked at the time, not how the service works today.
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
The service today is a single Cloudflare Worker that dispatches on the request's hostname, rather than several subdomain-matched Flask routes on a single App Engine instance. The maths API itself is a fairly direct port of the original: same routes, same request/response contracts, but with exact decimal arithmetic via big.js in place of Python's Decimal/float round trip, so results no longer pick up floating-point rounding artifacts and are never truncated to double precision on the way out for GET requests.
import Big from "big.js"; import { Hono } from "hono"; import type { Env } from "../env"; import { wantsHtml } from "../templates/notFound"; import tutorialHtml from "../templates/tutorial.html"; import tailwindCss from "../templates/tailwind.css"; // The exponent thresholds at/beyond which big.js's toString() switches to // exponential notation. Set to big.js's documented maxima (PE: 0 to 1e6, // NE: -1e6 to 0) so results always print in plain notation - the owner wants // no "1e+309" or "1e-20" anywhere the app prints a number. Big.PE = 1e6; Big.NE = -1e6; const OPERATIONS = ["add", "subtract", "multiply", "divide"] as const; type Operation = (typeof OPERATIONS)[number]; function isOperation(value: unknown): value is Operation { return typeof value === "string" && (OPERATIONS as readonly string[]).includes(value); } // Big only understands decimal literals, so permissive formats it can't parse // itself (hex, binary, octal, leading "+", ...) fall back to the value Number() // already extracted, which keeps them working without big.js needing to know // about them. function parseBig(raw: string, value: number): Big { try { return new Big(raw); } catch { return new Big(String(value)); } } function calculate(operation: Operation, first: Big, second: Big): Big { switch (operation) { case "add": return first.plus(second); case "subtract": return first.minus(second); case "multiply": return first.times(second); case "divide": { // Big.DP is DECIMAL PLACES, not significant digits: a fixed DP=20 // gives ~20 significant digits near 1 (e.g. 1/3) but silently rounds // a small quotient to 0 once its magnitude drops below 1e-20 (e.g. // 1/1e21 needs 21+ decimal places just to reach its first significant // digit). Derive DP from the operands' exponents so the quotient keeps // roughly 20 significant digits regardless of magnitude, clamped to // big.js's documented maximum DP of 1e6. Big.DP = Math.min(1e6, Math.max(20, 20 - (first.e - second.e))); return first.div(second); } } } const mathsApp = new Hono<{ Bindings: Env }>(); mathsApp.on(["GET", "HEAD"], "/", (c) => { if (wantsHtml(c.req.header("Accept"))) return c.html(tutorialHtml); return c.text("invalid request, revisit this url in a browser for help", 406); }); mathsApp.on(["GET", "HEAD"], "/tailwind.css", (c) => c.body(tailwindCss, 200, { "content-type": "text/css; charset=utf-8" }), ); mathsApp.on(["GET", "HEAD"], "*", (c) => { const parts = c.req.path.slice(1).split("/"); if (parts.length !== 3) return c.text("expected 3 parts in the path", 400); const [operation, ...rawArgs] = parts; if (!isOperation(operation)) return c.text("operation was not valid", 400); const args: Big[] = []; for (let i = 0; i < 2; i++) { const raw = rawArgs[i]!.trim(); const value = Number(raw); if (raw === "" || !Number.isFinite(value)) { return c.text(`item at position ${i + 1} was not a number`, 400); } // GET receives the original string, so it can stay exact end to end. args.push(parseBig(raw, value)); } if (operation === "divide" && args[1]!.eq(0)) return c.text("cannot divide by 0", 422); return c.text(calculate(operation, args[0]!, args[1]!).toString()); }); mathsApp.post("*", async (c) => { const body = await c.req.json().catch(() => null); if (!body || typeof body !== "object") return c.text("no json was sent in the request", 400); const { operation, arguments: rawArgs } = body as Record<string, unknown>; if (!isOperation(operation)) return c.text("valid operation was not in the request json", 400); if (!Array.isArray(rawArgs) || rawArgs.length !== 2) return c.text("2 arguments were not in the request json", 400); const args: Big[] = []; for (const arg of rawArgs) { if (typeof arg !== "number" || !Number.isFinite(arg)) return c.text("an argument was not a number", 400); // JSON.parse already collapsed this to a double before we ever saw it, so // unlike GET this can only ever be as exact as double precision allows. // String(arg) is JS's shortest round-tripping representation, so it hands // Big every digit the double actually carries - no more, no less. args.push(new Big(String(arg))); } if (operation === "divide" && args[1]!.eq(0)) return c.text("cannot divide by 0", 422); const result = calculate(operation, args[0]!, args[1]!); // Build the JSON body directly from the Big's plain-notation string rather // than round-tripping through Number(...): that round trip both truncates // precision (doubles cap out around 15-17 significant digits) and could // reintroduce exponential notation via JSON.stringify for large values. // toString() here is always plain notation (Big.PE/NE above) and is // therefore always a valid JSON number literal, so `result` stays a JSON // number and the documented `{"result": 73.2}` contract is unchanged. return new Response(`{"result":${result.toString()}}`, { status: 200, headers: { "content-type": "application/json" }, }); }); mathsApp.all("*", (c) => c.text("method not allowed", 405)); export default mathsApp;