[LNT] r306596 - New REST API format
Chris Matthews via llvm-commits
llvm-commits at lists.llvm.org
Wed Jun 28 13:45:03 PDT 2017
Author: cmatthews
Date: Wed Jun 28 13:45:03 2017
New Revision: 306596
URL: http://llvm.org/viewvc/llvm-project?rev=306596&view=rev
Log:
New REST API format
After much feedback and iteration, this is a restructured API. Docs
will be updated with API details in a later commit. I also dropped the
flask restful marshaler, it is too simple for our format. We now use
python's json serializaer + a custom SQLAlchemy object visitor.
Modified:
lnt/trunk/lnt/server/ui/api.py
lnt/trunk/lnt/server/ui/app.py
lnt/trunk/tests/server/ui/test_api.py
Modified: lnt/trunk/lnt/server/ui/api.py
URL: http://llvm.org/viewvc/llvm-project/lnt/trunk/lnt/server/ui/api.py?rev=306596&r1=306595&r2=306596&view=diff
==============================================================================
--- lnt/trunk/lnt/server/ui/api.py (original)
+++ lnt/trunk/lnt/server/ui/api.py Wed Jun 28 13:45:03 2017
@@ -1,18 +1,14 @@
-import json
-
import sqlalchemy
from flask import current_app, g
+from flask import jsonify
from flask import request
-from flask_restful import Resource, reqparse, fields, marshal_with, abort
+from flask_restful import Resource, abort
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.exc import NoResultFound
from lnt.server.ui.util import convert_revision
from lnt.testing import PASS
-parser = reqparse.RequestParser()
-parser.add_argument('db', type=str)
-
def in_db(func):
"""Extract the database information off the request and attach to
@@ -36,11 +32,6 @@ def in_db(func):
return wrap
-def ts_path(path):
- """Make a URL path with a database and test suite embedded in them."""
- return "/api/db_<string:db>/v4/<string:ts>/" + path
-
-
def with_ts(obj):
"""For Url type fields to work, the objects we return must have a test-suite
and database attribute set, the function attempts to set them."""
@@ -63,74 +54,91 @@ def with_ts(obj):
return new_obj
-# This date format is what the JavaScript in the LNT frontend likes.
-DATE_FORMAT = "iso8601"
-
-machines_fields = {
- 'id': fields.Integer,
- 'name': fields.String,
- 'os': fields.String,
- 'hardware': fields.String,
-}
+def common_fields_factory():
+ """Get a dict with all the common fields filled in."""
+ common_data = {'generated_by': 'LNT Server v{}'.format(current_app.version)}
+ return common_data
+
+
+def add_common_fields(to_update):
+ """Update a dict with the common fields."""
+ to_update.update(common_fields_factory())
+
+
+def common_machine_format(machine):
+ serializable = machine.__json__()
+ del serializable['parameters_data']
+ final = machine.parameters.copy()
+ final.update(serializable)
+ return final
class Machines(Resource):
"""List all the machines and give summary information."""
method_decorators = [in_db]
- @marshal_with(machines_fields)
- def get(self):
+ @staticmethod
+ def get():
ts = request.get_testsuite()
- changes = ts.query(ts.Machine).all()
- return changes
+ machine_infos = ts.query(ts.Machine).all()
+ serializable_machines = []
-order_fields = {
- 'id': fields.Integer,
- 'name': fields.String,
- 'next_order_id': fields.Integer,
- 'previous_order_id': fields.Integer,
- 'parts': fields.List(fields.Integer),
-}
-
-
-class ParameterItem(fields.Raw):
- def output(self, key, value):
- return dict(json.loads(value['parameters_data']))
-
-
-machine_run_fields = {'id': fields.Integer,
- 'start_time': fields.DateTime(dt_format=DATE_FORMAT),
- 'end_time': fields.DateTime(dt_format=DATE_FORMAT),
- 'machine_id': fields.Integer,
- 'machine': fields.Url("machine"),
- 'order_id': fields.Integer,
- 'order_name': fields.String,
- 'order_url': fields.Url("order"),
- 'parameters': ParameterItem}
-
-machine_run_fields['order'] = fields.Nested(order_fields)
-
-machine_fields = {
- 'id': fields.Integer,
- 'name': fields.String,
- 'os': fields.String,
- 'hardware': fields.String,
- 'runs': fields.List(fields.Nested(machine_run_fields)),
- 'parameters': ParameterItem
-}
+ for machine in machine_infos:
+ serializable_machines.append(common_machine_format(machine))
-run_fields = {'run': fields.Nested(machine_run_fields),
- 'samples': fields.List(fields.Raw)}
+ machines = common_fields_factory()
+ machines['machines'] = serializable_machines
+ return jsonify(machines)
+
+
+def common_run_format(run):
+ serializable_run = run.__json__()
+ # Replace orders with text order.
+ serializable_run['order'] = run.order.name
+ # Embed the parameters right into the run dict.
+ serializable_run.update(run.parameters)
+ del serializable_run['machine']
+ del serializable_run['parameters_data']
+ return serializable_run
+
+
+class Machine(Resource):
+ """Detailed results about a particular machine, including runs on it."""
+ method_decorators = [in_db]
+
+ @staticmethod
+ def get(machine_id):
+ ts = request.get_testsuite()
+ try:
+ this_machine = ts.query(ts.Machine).filter(
+ ts.Machine.id == machine_id).one()
+ except NoResultFound:
+ return abort(404, message="Invalid machine: {}".format(machine_id))
+ machine = common_fields_factory()
+ machine['machines'] = [common_machine_format(this_machine)]
+ machine_runs = ts.query(ts.Run) \
+ .join(ts.Machine) \
+ .join(ts.Order) \
+ .filter(ts.Machine.id == machine_id) \
+ .options(joinedload('order')) \
+ .all()
+
+ runs = []
+ for run in machine_runs:
+ runs.append(common_run_format(run))
+ machine['runs'] = runs
+
+ return jsonify(machine)
class Runs(Resource):
method_decorators = [in_db]
- @marshal_with(run_fields)
- def get(self, run_id):
+ @staticmethod
+ def get(run_id):
ts = request.get_testsuite()
- full_run = dict()
+ full_run = common_fields_factory()
try:
run = ts.query(ts.Run) \
@@ -142,10 +150,7 @@ class Runs(Resource):
except sqlalchemy.orm.exc.NoResultFound:
return abort(404, msg="Did not find run " + str(run_id))
- full_run['run'] = with_ts(run)
- full_run['run']['order']['parts'] = convert_revision(run.order.name)
- full_run['run']['order']['name'] = run.order.name
- full_run['run']['parts'] = run.order.name
+ full_run['runs'] = [common_run_format(run)]
to_get = [ts.Sample.id, ts.Sample.run_id, ts.Test.name,
ts.Order.fields[0].column]
@@ -162,57 +167,30 @@ class Runs(Resource):
ret = [sample._asdict() for sample in q.all()]
full_run['samples'] = ret
- return with_ts(full_run)
-
-
-class Machine(Resource):
- """Detailed results about a particular machine, including runs on it."""
- method_decorators = [in_db]
-
- @marshal_with(machine_fields)
- def get(self, machine_id):
-
- ts = request.get_testsuite()
- try:
- machine = ts.query(ts.Machine).filter(
- ts.Machine.id == machine_id).one()
- except NoResultFound:
- return abort(404, message="Invalid machine.")
-
- machine = with_ts(machine)
- machine_runs = ts.query(ts.Run) \
- .join(ts.Machine) \
- .join(ts.Order) \
- .filter(ts.Machine.id == machine_id) \
- .options(joinedload('order')) \
- .all()
-
- machine['runs'] = with_ts(machine_runs)
-
- return machine
+ return jsonify(full_run)
class Order(Resource):
method_decorators = [in_db]
- @marshal_with(order_fields)
- def get(self, order_id):
+ @staticmethod
+ def get(order_id):
ts = request.get_testsuite()
try:
order = ts.query(ts.Order).filter(ts.Order.id == order_id).one()
except NoResultFound:
return abort(404, message="Invalid order.")
- order_output = with_ts(order)
- order_output['parts'] = convert_revision(order.name)
- order_output['name'] = order.name
- return order_output
+ order_output = common_fields_factory()
+ order_output['orders'] = [order]
+ return jsonify(order_output)
class SampleData(Resource):
"""List all the machines and give summary information."""
method_decorators = [in_db]
- def get(self):
+ @staticmethod
+ def get():
"""Get the data for a particular line in a graph."""
ts = request.get_testsuite()
args = request.args.to_dict(flat=False)
@@ -236,18 +214,19 @@ class SampleData(Resource):
.join(ts.Run) \
.join(ts.Order) \
.filter(ts.Sample.run_id.in_(run_ids))
-
+ output_samples = common_fields_factory()
# noinspection PyProtectedMember
- ret = [sample._asdict() for sample in q.all()]
+ output_samples['samples'] = [sample._asdict() for sample in q.all()]
- return ret
+ return output_samples
class Graph(Resource):
"""List all the machines and give summary information."""
method_decorators = [in_db]
- def get(self, machine_id, test_id, field_index):
+ @staticmethod
+ def get(machine_id, test_id, field_index):
"""Get the data for a particular line in a graph."""
ts = request.get_testsuite()
# Maybe we don't need to do this?
@@ -291,7 +270,8 @@ class Regression(Resource):
"""List all the machines and give summary information."""
method_decorators = [in_db]
- def get(self, machine_id, test_id, field_index):
+ @staticmethod
+ def get(machine_id, test_id, field_index):
"""Get the regressions for a particular line in a graph."""
ts = request.get_testsuite()
field = ts.sample_fields[field_index]
@@ -330,6 +310,11 @@ class Regression(Resource):
return results
+def ts_path(path):
+ """Make a URL path with a database and test suite embedded in them."""
+ return "/api/db_<string:db>/v4/<string:ts>/" + path
+
+
def load_api_resources(api):
api.add_resource(Machines, ts_path("machines/"))
api.add_resource(Machine, ts_path("machines/<machine_id>"))
Modified: lnt/trunk/lnt/server/ui/app.py
URL: http://llvm.org/viewvc/llvm-project/lnt/trunk/lnt/server/ui/app.py?rev=306596&r1=306595&r2=306596&view=diff
==============================================================================
--- lnt/trunk/lnt/server/ui/app.py (original)
+++ lnt/trunk/lnt/server/ui/app.py Wed Jun 28 13:45:03 2017
@@ -1,4 +1,5 @@
import StringIO
+import json
import logging
import logging.handlers
import sys
@@ -6,6 +7,7 @@ import time
import traceback
from logging import Formatter
+import datetime
import flask
import jinja2
from flask import current_app
@@ -13,6 +15,7 @@ from flask import g
from flask import session
from flask_restful import Api
from sqlalchemy.exc import DatabaseError
+from sqlalchemy.ext.declarative import DeclarativeMeta
import lnt
import lnt.server.db.rules_manager
@@ -38,6 +41,34 @@ class RootSlashPatchMiddleware(object):
return self.app(environ, start_response)
+class LNTObjectJSONEncoder(json.JSONEncoder):
+ """Take SQLAlchemy objects and jsonify them. If the object has an __json__ method, use that instead."""
+ def default(self, obj):
+ if hasattr(obj, '__json__'):
+ return obj.__json__()
+ if type(obj) is datetime.datetime:
+ return obj.isoformat()
+ if isinstance(obj.__class__, DeclarativeMeta):
+ fields = {}
+ for field in [x for x in dir(obj) if not x.startswith('_') and x != 'metadata']:
+ data = obj.__getattribute__(field)
+ if isinstance(data, datetime.datetime):
+ fields[field] = data.isoformat()
+ else:
+ try:
+ json.dumps(data)
+ fields[field] = data
+ except TypeError:
+ fields[field] = None
+
+ return fields
+
+ return json.JSONEncoder.default(self, obj)
+
+
+
+
+
class Request(flask.Request):
def __init__(self, *args, **kwargs):
super(Request, self).__init__(*args, **kwargs)
@@ -113,6 +144,7 @@ class App(LNTExceptionLoggerFlask):
# Construct the application.
app = App(__name__)
+ app.json_encoder = LNTObjectJSONEncoder
# Register additional filters.
create_jinja_environment(app.jinja_env)
Modified: lnt/trunk/tests/server/ui/test_api.py
URL: http://llvm.org/viewvc/llvm-project/lnt/trunk/tests/server/ui/test_api.py?rev=306596&r1=306595&r2=306596&view=diff
==============================================================================
--- lnt/trunk/tests/server/ui/test_api.py (original)
+++ lnt/trunk/tests/server/ui/test_api.py Wed Jun 28 13:45:03 2017
@@ -20,7 +20,9 @@ logging.basicConfig(level=logging.DEBUG)
machines_expected_response = [{u'hardware': u'x86_64',
u'os': u'Darwin 11.3.0',
u'id': 1,
- u'name': u'localhost__clang_DEV__x86_64'},
+ u'name': u'localhost__clang_DEV__x86_64',
+ u'uname': u'Darwin localhost 11.3.0 Darwin Kernel Version 11.3.0: Thu Jan 12'
+ u' 18:47:41 PST 2012; root:xnu-1699.24.23~1/RELEASE_X86_64 x86_64'},
{u'hardware': u'AArch64',
u'os': u'linux',
u'id': 2,
@@ -30,11 +32,9 @@ machines_expected_response = [{u'hardwar
u'id': 3,
u'name': u'machine3'}]
-order_expected_response = {u'id': 1,
- u'name': "154331",
- u'next_order_id': 0,
- u'previous_order_id': 2,
- u'parts': [154331]}
+order_expected_response = {u'llvm_project_revision': u'154331',
+ u'id': 1,
+ u'name': u'154331'}
graph_data = [[[152292], 1.0,
{u'date': u'2012-05-01 16:28:23',
@@ -50,6 +50,56 @@ graph_data2 = [[[152293], 10.0,
u'label': u'152293',
u'runID': u'6'}]]
+possible_run_keys = {u'__report_version__',
+ u'inferred_run_order',
+ u'test_suite_revision',
+ u'simple_run_id',
+ u'cc_alt_src_revision',
+ u'USE_REFERENCE_OUTPUT',
+ u'cc_as_version',
+ u'DISABLE_CBE',
+ u'ENABLE_OPTIMIZED',
+ u'ARCH',
+ u'id',
+ u'DISABLE_JIT',
+ u'TARGET_CXX',
+ u'TARGET_CC',
+ u'TARGET_LLVMGXX',
+ u'cc_build',
+ u'sw_vers',
+ u'cc_src_branch',
+ u'cc1_exec_hash',
+ u'TARGET_FLAGS',
+ u'CC_UNDER_TEST_TARGET_IS_X86_64',
+ u'ENABLE_HASHED_PROGRAM_OUTPUT',
+ u'cc_name',
+ u'order_id',
+ u'start_time',
+ u'cc_version_number',
+ u'cc_alt_src_branch',
+ u'cc_version',
+ u'OPTFLAGS',
+ u'cc_ld_version',
+ u'imported_from',
+ u'TARGET_LLVMGCC',
+ u'LLI_OPTFLAGS',
+ u'cc_target',
+ u'CC_UNDER_TEST_IS_CLANG',
+ u'cc_src_revision',
+ u'end_time',
+ u'TEST',
+ u'LLC_OPTFLAGS',
+ u'machine_id',
+ u'order',
+ u'cc_exec_hash',
+ }
+
+possible_machine_keys = {u'name',
+ u'hardware',
+ u'os',
+ u'id',
+ u'uname'}
+
class JSONAPITester(unittest.TestCase):
"""Test the REST api."""
@@ -61,55 +111,80 @@ class JSONAPITester(unittest.TestCase):
app.testing = True
self.client = app.test_client()
+ def _check_response_is_wellformed(self, response):
+ """API Should always return the generated by field in the top level dict."""
+ # All API calls should return a top level dict.
+ self.assertEqual(type(response), dict)
+
+ # There should be no unexpected top level keys.
+ all_top_level_keys = {'generated_by', 'machines', 'runs', 'orders', 'samples'}
+ self.assertTrue(set(response.keys()).issubset(all_top_level_keys))
+ # All API calls should return as generated by.
+ self.assertIn("LNT Server v", response['generated_by'])
+
def test_machine_api(self):
- """Check /machines and /machine/n return expected results from
- testdb.
+ """Check /machines/ and /machines/n return expected results from testdb.
"""
client = self.client
- j = check_json(client, 'api/db_default/v4/nts/machines')
- self.assertEquals(j, machines_expected_response)
- j = check_json(client, 'api/db_default/v4/nts/machine/1')
- self.assertEqual(j.keys(), [u'runs', u'name', u'parameters',
- u'hardware', u'os', u'id'])
+
+ # All machines returns the list of machines with parameters, but no runs.
+ j = check_json(client, 'api/db_default/v4/nts/machines/')
+ self._check_response_is_wellformed(j)
+ self.assertEquals(j['machines'], machines_expected_response)
+ self.assertIsNone(j.get('runs'))
+
+ # Machine + properties + run information.
+ j = check_json(client, 'api/db_default/v4/nts/machines/1')
+ self._check_response_is_wellformed(j)
+ self.assertEqual(len(j['machines']), 1)
+ for machine in j['machines']:
+ self.assertSetEqual(set(machine.keys()), possible_machine_keys)
expected = {"hardware": "x86_64", "os": "Darwin 11.3.0", "id": 1}
- self.assertDictContainsSubset(expected, j)
+ self.assertDictContainsSubset(expected, j['machines'][0])
+
+ self.assertEqual(len(j['runs']), 2)
+ self.assertSetEqual(set(j['runs'][0].keys()), possible_run_keys)
+
+ # Invalid machine ids are 404.
+ check_json(client, 'api/db_default/v4/nts/machines/99', expected_code=404)
+ check_json(client, 'api/db_default/v4/nts/machines/foo', expected_code=404)
def test_run_api(self):
- """Check /run/n returns expected run information."""
+ """Check /runs/n returns expected run information."""
client = self.client
- j = check_json(client, 'api/db_default/v4/nts/run/1')
- expected = {"machine": "/api/db_default/v4/nts/machine/1",
- "order_url": "/api/db_default/v4/nts/order/1",
- "end_time": "2012-04-11T16:28:58",
+ j = check_json(client, 'api/db_default/v4/nts/runs/1')
+ self._check_response_is_wellformed(j)
+ expected = {"end_time": "2012-04-11T16:28:58",
"order_id": 1,
"start_time": "2012-04-11T16:28:23",
"machine_id": 1,
"id": 1,
- "order": {u'previous_order_id': 2, u'next_order_id': 0,
- u'parts': [154331], u'name': u'154331', u'id': 1}
-
- }
- self.assertDictContainsSubset(expected, j['run'])
+ "order": u'154331'}
+ self.assertDictContainsSubset(expected, j['runs'][0])
self.assertEqual(len(j['samples']), 2)
# This should not be a run.
- check_json(client, 'api/db_default/v4/nts/run/100', expected_code=404)
+ check_json(client, 'api/db_default/v4/nts/runs/100', expected_code=404)
def test_order_api(self):
- """ Check /order/n returns the expected order information."""
+ """ Check /orders/n returns the expected order information."""
client = self.client
- j = check_json(client, 'api/db_default/v4/nts/order/1')
- self.assertEquals(j, order_expected_response)
- check_json(client, 'api/db_default/v4/nts/order/100', expected_code=404)
+ j = check_json(client, 'api/db_default/v4/nts/orders/1')
+ self._check_response_is_wellformed(j)
+ self.assertEquals(j['orders'][0], order_expected_response)
+ check_json(client, 'api/db_default/v4/nts/orders/100', expected_code=404)
def test_graph_api(self):
"""Check that /graph/x/y/z returns what we expect."""
client = self.client
j = check_json(client, 'api/db_default/v4/nts/graph/2/4/3')
+ # TODO: Graph API needs redesign to be well formed.
+ # self._check_response_is_wellformed(j)
self.assertEqual(graph_data, j)
# Now check that limit works.
j2 = check_json(client, 'api/db_default/v4/nts/graph/2/4/3?limit=1')
+ # self._check_response_is_wellformed(j)
self.assertEqual(graph_data2, j2)
def test_samples_api(self):
@@ -121,6 +196,7 @@ class JSONAPITester(unittest.TestCase):
# Simple single run.
j = check_json(client, 'api/db_default/v4/nts/samples?runid=1')
+ self._check_response_is_wellformed(j)
expected = [
{u'compile_time': 0.007, u'llvm_project_revision': u'154331',
u'hash': None,
@@ -138,15 +214,17 @@ class JSONAPITester(unittest.TestCase):
u'score': None, u'hash_status': None, u'code_size': None,
u'id': 2}]
- self.assertEqual(j, expected)
+ self.assertEqual(j['samples'], expected)
# Check that other args are ignored.
extra_param = check_json(client,
'api/db_default/v4/nts/samples?runid=1&foo=bar')
+ self._check_response_is_wellformed(extra_param)
self.assertEqual(j, extra_param)
# There is only one run in the DB.
two_runs = check_json(client,
'api/db_default/v4/nts/samples?runid=1&runid=2')
+ self._check_response_is_wellformed(two_runs)
self.assertEqual(j, two_runs)
More information about the llvm-commits
mailing list