Build API: foundations

The foundations for the build api; the entrypoint itself, a common proto
definitions file, a script to compile the proto, and some scaffolding
to build out requests.

BUG=chromium:912361
TEST=manual, new tests
CQ-DEPEND=CL:1382945

Change-Id: Id3c8ae67b1d8f198f563977fe9c8543af7f41d9a
Reviewed-on: https://chromium-review.googlesource.com/1373996
Commit-Ready: Alex Klein <saklein@chromium.org>
Tested-by: Alex Klein <saklein@chromium.org>
Reviewed-by: Lann Martin <lannm@chromium.org>
diff --git a/api/build_api.py b/api/build_api.py
new file mode 100644
index 0000000..6b84099
--- /dev/null
+++ b/api/build_api.py
@@ -0,0 +1,228 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""The build API entry point."""
+
+from __future__ import print_function
+
+import importlib
+import os
+
+from google.protobuf import empty_pb2
+from google.protobuf import json_format
+from google.protobuf import symbol_database
+
+from chromite.api import service
+from chromite.api.gen import build_api_pb2
+from chromite.lib import commandline
+from chromite.lib import osutils
+
+
+class Error(Exception):
+  """Base error class for the module."""
+
+
+# API Service Errors.
+class UnknownServiceError(Error):
+  """Error raised when the requested service has not been registered."""
+
+
+class ServiceModuleNotDefinedError(Error):
+  """Error class for when no module is defined for a service."""
+
+
+class ServiceModuleNotFoundError(Error):
+  """Error raised when the service cannot be imported."""
+
+
+# API Method Errors.
+class UnknownMethodError(Error):
+  """The requested service exists but does not have the requested method."""
+
+
+class MethodNotFoundError(Error):
+  """Error raised when the method cannot be found in the service module."""
+
+
+def GetParser():
+  """Build the argument parser.
+
+  The API parser comprises a subparser hierarchy. The general form is:
+  `script service method`, e.g. `build_api image test`.
+  """
+  parser = commandline.ArgumentParser(description=__doc__)
+
+  parser.add_argument('service_method',
+                      help='The "chromite.api.Service/Method" that is being '
+                           'called.')
+
+  parser.add_argument(
+      '--input-json', type='path',
+      help='Path to the JSON serialized input argument protobuf message.')
+  parser.add_argument(
+      '--output-json', type='path',
+      help='The path to which the result protobuf message should be written.')
+
+  return parser
+
+
+def _ParseArgs(argv):
+  """Parse and validate arguments."""
+  parser = GetParser()
+  opts = parser.parse_args(argv)
+
+  parts = opts.service_method.split('/')
+
+  if len(parts) != 2:
+    parser.error('Must pass "Service/Method".')
+
+  opts.service = parts[0]
+  opts.method = parts[1]
+
+  opts.Freeze()
+  return opts
+
+
+class Router(object):
+  """Encapsulates the request dispatching logic."""
+
+  def __init__(self):
+    self._services = {}
+    self._aliases = {}
+    # All imported generated messages get added to this symbol db.
+    self._sym_db = symbol_database.Default()
+
+    extensions = build_api_pb2.DESCRIPTOR.extensions_by_name
+    self._service_options = extensions['service_options']
+    self._method_options = extensions['method_options']
+
+  def Register(self, proto_module):
+    """Register the services from a generated proto module.
+
+    Args:
+      proto_module (module): The generated proto module whose service is being
+        registered.
+
+    Raises:
+      ServiceModuleNotDefinedError when the service cannot be found in the
+        provided module.
+    """
+    services = proto_module.DESCRIPTOR.services_by_name
+    for service_name, svc in services.items():
+      module_name = svc.GetOptions().Extensions[self._service_options].module
+
+      if not module_name:
+        raise ServiceModuleNotDefinedError(
+            'The module must be defined in the service definition: %s.%s' %
+            (proto_module, service_name))
+
+      self._services[svc.full_name] = (svc, module_name)
+
+  def Route(self, service_name, method_name, input_json):
+    """Dispatch the request.
+
+    Args:
+      service_name (str): The fully qualified service name.
+      method_name (str): The name of the method being called.
+      input_json (str): The JSON encoded input message data.
+
+    Returns:
+      google.protobuf.message.Message: An instance of the method's output
+        message class.
+
+    Raises:
+      ServiceModuleNotFoundError when the service module cannot be imported.
+      MethodNotFoundError when the method cannot be retrieved from the module.
+    """
+    try:
+      svc, module_name = self._services[service_name]
+    except KeyError:
+      raise UnknownServiceError('The %s service has not been registered.'
+                                % service_name)
+
+    try:
+      method_desc = svc.methods_by_name[method_name]
+    except KeyError:
+      raise UnknownMethodError('The %s method has not been defined in the %s '
+                               'service.' % (method_name, service_name))
+
+    # Service method argument magic: do not pass the arguments when the method
+    # is expecting the Empty message. Additions of optional arguments/return
+    # values are still backwards compatible, but the implementation signature
+    # is simplified and more explicit about what its expecting.
+    args = []
+    # Parse the input file to build an instance of the input message.
+    input_msg = self._sym_db.GetPrototype(method_desc.input_type)()
+    if not isinstance(input_msg, empty_pb2.Empty):
+      json_format.Parse(input_json, input_msg, ignore_unknown_fields=True)
+      args.append(input_msg)
+
+    # Get an empty output message instance.
+    output_msg = self._sym_db.GetPrototype(method_desc.output_type)()
+    if not isinstance(output_msg, empty_pb2.Empty):
+      args.append(output_msg)
+
+    # TODO(saklein) Do we need this? Are aliases useful? Maybe dump it.
+    # Allow proto-based method name override.
+    method_options = method_desc.GetOptions().Extensions[self._method_options]
+    if method_options.HasField('implementation_name'):
+      method_name = method_options.implementation_name
+
+    # Import the module and get the method.
+    method_impl = self._GetMethod(module_name, method_name)
+
+    # Successfully located; call and return.
+    method_impl(*args)
+    return output_msg
+
+  def _GetMethod(self, module_name, method_name):
+    """Get the implementation of the method for the service module.
+
+    Args:
+      module_name (str): The name of the service module.
+      method_name (str): The name of the method.
+
+    Returns:
+      callable - The method.
+
+    Raises:
+      MethodNotFoundError when the method cannot be found in the module.
+      ServiceModuleNotFoundError when the service module cannot be imported.
+    """
+    try:
+      module = importlib.import_module(service.IMPORT_PATTERN % module_name)
+    except ImportError as e:
+      raise ServiceModuleNotFoundError(e.message)
+    try:
+      return getattr(module, method_name)
+    except AttributeError as e:
+      raise MethodNotFoundError(e.message)
+
+
+def RegisterServices(router):
+  """Register all the services.
+
+  Args:
+    router (Router): The router.
+  """
+  pass
+
+
+def main(argv):
+  opts = _ParseArgs(argv)
+
+  router = Router()
+  RegisterServices(router)
+
+  if os.path.exists(opts.input_json):
+    input_proto = osutils.ReadFile(opts.input_json)
+  else:
+    input_proto = None
+
+  output_msg = router.Route(opts.service, opts.method, input_proto)
+
+  if opts.output_json:
+    output_content = json_format.MessageToJson(output_msg)
+    osutils.WriteFile(opts.output_json, output_content)