[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