Skip to content
This repository has been archived by the owner on May 5, 2023. It is now read-only.

Commit

Permalink
Refactor measurements API to use OpenAPI 2.0 (Swagger) to generate the
Browse files Browse the repository at this point in the history
public API endpoints

This achieves the following goals:

* It's possible to programmatically generate the docs from the swagger
spec (see: https://github.com/Rebilly/ReDoc)

* It's easier to enforce better request argument normalisation and avoid
stupid type casting bugs at the source

* It reduces the overall amount of boilerplate needed to do common API
tasks

Currently I use Open API 2.0 instead of 3.0 because the connexion (the
library that implements the swagger spec doesn't support 3.0 yet. See:
spec-first/connexion#420)
  • Loading branch information
hellais committed Sep 4, 2017
1 parent 5ea291c commit bcba8a6
Show file tree
Hide file tree
Showing 7 changed files with 508 additions and 90 deletions.
6 changes: 4 additions & 2 deletions measurements/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from .docs import api_docs_blueprint
from .private import api_private_blueprint
from .measurements import api_blueprint

from .measurements import get_version
from .measurements import list_files
from .measurements import list_measurements, get_measurement
132 changes: 48 additions & 84 deletions measurements/api/measurements.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import os
import math
import time

from dateutil.parser import parse as parse_date

import connexion
import requests
import lz4framed

Expand All @@ -19,69 +21,47 @@
from measurements.config import REPORT_INDEX_OFFSET
from measurements.models import Report, Input, Measurement, Autoclaved

# prefix: /api/v1
api_blueprint = Blueprint('api', 'measurements')

@api_blueprint.errorhandler(HTTPException)
@api_blueprint.errorhandler(BadRequest)
def api_error_handler(error):
response = jsonify({
'error_code': error.code,
'error_message': error.description
})
response.status_code = 400
return response


@api_blueprint.route('/version', methods=["GET"])
def api_get_version():
def get_version():
return jsonify({
"version": __version__
})


@api_blueprint.route('/files', methods=["GET"])
def api_list_report_files():
probe_cc = request.args.get("probe_cc")

probe_asn = request.args.get("probe_asn")
if probe_asn:
def list_files(
probe_asn=None,
probe_cc=None,
test_name=None,
since=None,
until=None,
since_index=None,
order_by='index',
order='desc',
offset=0,
limit=100
):

if probe_asn is not None:
if probe_asn.startswith('AS'):
probe_asn = probe_asn[2:]
probe_asn = int(probe_asn)

test_name = request.args.get("test_name")

since = request.args.get("since")
try:
if since:
if since is not None:
since = parse_date(since)
except ValueError:
raise BadRequest("Invalid since")

until = request.args.get("until")
try:
if until:
if until is not None:
until = parse_date(until)
except ValueError:
raise BadRequest("Invalid until")

since_index = request.args.get("since_index")
if since_index is not None:
since_index = int(since_index)
report_no = max(0, since_index - REPORT_INDEX_OFFSET)

order_by = request.args.get("order_by", "index")
if order_by in ("index", "idx"):
order_by = "report_no"

order = request.args.get("order", 'desc')

try:
offset = int(request.args.get("offset", "0"))
limit = int(request.args.get("limit", "100"))
except ValueError:
raise BadRequest("Invalid offset or limit")
if order_by in ('index', 'idx'):
order_by = 'report_no'

q = current_app.db_session.query(
Report.textname,
Expand All @@ -106,13 +86,6 @@ def api_list_report_files():
if since_index:
q = q.filter(Report.report_no > report_no)

# XXX these are duplicated above, refactor into function
if order.lower() not in ('asc', 'desc'):
raise BadRequest("Invalid order")
if order_by not in ('test_start_time', 'probe_cc', 'report_id',
'test_name', 'probe_asn', 'report_no'):
raise BadRequest("Invalid order_by")

q = q.order_by('{} {}'.format(order_by, order))
count = q.count()
pages = math.ceil(count / limit)
Expand Down Expand Up @@ -149,15 +122,14 @@ def api_list_report_files():
'probe_asn': "AS{}".format(row.probe_asn),
'test_name': row.test_name,
'index': int(row.report_no) + REPORT_INDEX_OFFSET,
'test_start_time': row.test_start_time.strftime('%Y-%m-%dT%H:%M:%SZ')
'test_start_time': row.test_start_time
})
return jsonify({
'metadata': metadata,
'results': results
})

@api_blueprint.route('/measurement/<measurement_id>', methods=["GET"])
def api_get_measurement(measurement_id):
def get_measurement(measurement_id):
# XXX this query is SUPER slow
q = current_app.db_session.query(
Measurement.id.label('m_id'),
Expand Down Expand Up @@ -191,49 +163,41 @@ def api_get_measurement(measurement_id):
mimetype=current_app.config['JSONIFY_MIMETYPE']
)

@api_blueprint.route('/measurements', methods=["GET"])
def api_list_measurements():
report_id = request.args.get("report_id")
def list_measurements(
report_id=None,
probe_asn=None,
probe_cc=None,
test_name=None,
since=None,
until=None,
since_index=None,
order_by=None,
order='desc',
offset=0,
limit=100
):
input_ = request.args.get("input")

probe_cc = request.args.get("probe_cc")
probe_asn = request.args.get("probe_asn")
if probe_asn:
if probe_asn is not None:
if probe_asn.startswith('AS'):
probe_asn = probe_asn[2:]
probe_asn = int(probe_asn)

test_name = request.args.get("test_name")

since = request.args.get("since")
try:
if since:
if since is not None:
since = parse_date(since)
except ValueError:
raise BadRequest("Invalid since")

until = request.args.get("until")
try:
if until:
if until is not None:
until = parse_date(until)
except ValueError:
raise BadRequest("Invalid until")

order_by = request.args.get("order_by")
if order_by and order_by not in ('measurement_start_time', 'probe_cc',
'report_id', 'test_name', 'probe_asn'):
raise BadRequest("Invalid order_by")

order = request.args.get("order", 'asc')
if order.lower() not in ('asc', 'desc'):
raise BadRequest("Invalid order")

try:
offset = int(request.args.get("offset", "0"))
limit = int(request.args.get("limit", "100"))
except ValueError:
raise BadRequest("Invalid offset or limit")

cols = [
Measurement.input_no.label('m_input_no'),
Measurement.measurement_start_time.label('measurement_start_time'),
Expand All @@ -253,23 +217,23 @@ def api_list_measurements():

q = current_app.db_session.query(*cols).join(Report, Report.report_no == Measurement.report_no)

if input_:
if input_ is not None:
q = q.join(Measurement, Measurement.input_no == Input.input_no)
q = q.filter(Input.input.like('%{}%'.format(input_)))
if report_id:
if report_id is not None:
q = q.filter(Report.report_id == report_id)
if probe_cc:
if probe_cc is not None:
q = q.filter(Report.probe_cc == probe_cc)
if probe_asn:
if probe_asn is not None:
q = q.filter(Report.probe_asn == probe_asn)
if test_name:
if test_name is not None:
q = q.filter(Report.test_name == test_name)
if since:
if since is not None:
q = q.filter(Measurement.measurement_start_time > since)
if until:
if until is not None:
q = q.filter(Measurement.measurement_start_time <= until)

if order_by:
if order_by is not None:
q = q.order_by('{} {}'.format(order_by, order))

query_time = 0
Expand All @@ -289,7 +253,7 @@ def api_list_measurements():
'probe_cc': row.probe_cc,
'probe_asn': "AS{}".format(row.probe_asn),
'test_name': row.test_name,
'measurement_start_time': row.measurement_start_time.strftime('%Y-%m-%dT%H:%M:%SZ'),
'measurement_start_time': row.measurement_start_time,
'input': row.input if row.m_input_no else None,
})

Expand Down
23 changes: 22 additions & 1 deletion measurements/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
from __future__ import unicode_literals

import logging
import datetime
import os

from flask import Flask
from flask import Flask, json
from flask_misaka import Misaka
from flask_cors import CORS
from flask_cache import Cache
Expand All @@ -18,6 +19,25 @@

cache = Cache()

class FlaskJSONEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, datetime.datetime):
if o.tzinfo:
# eg: '2015-09-25T23:14:42.588601+00:00'
return o.isoformat('T')
else:
# No timezone present - assume UTC.
# eg: '2015-09-25T23:14:42.588601Z'
return o.isoformat('T') + 'Z'

if isinstance(o, datetime.date):
return o.isoformat()

if isinstance(o, Decimal):
return float(o)

return json.JSONEncoder.default(self, o)

def init_app(app):
# We load configurations first from the config file (where some options
# are overridable via environment variables) or from the config file
Expand Down Expand Up @@ -59,6 +79,7 @@ def create_app(*args, **kw):
from measurements import views

app = Flask(__name__)
app.json_encoder = FlaskJSONEncoder

# Order matters
init_app(app)
Expand Down
Loading

0 comments on commit bcba8a6

Please sign in to comment.