Build API: Add mock-call functionality.

The core functionality for making mock calls to the API.
Support added for success, error, and invalid responses.
Allows consumers to test their implementations against the
potentially branched API without needing to run the full
endpoint.

BUG=chromium:999178
TEST=run_tests

Change-Id: I5de9c37f8a759c175627b6db5e9696533aada031
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/1787836
Tested-by: Alex Klein <saklein@chromium.org>
Commit-Queue: Alex Klein <saklein@chromium.org>
Reviewed-by: Evan Hernandez <evanhernandez@chromium.org>
Reviewed-by: David Burger <dburger@chromium.org>
diff --git a/scripts/build_api.py b/scripts/build_api.py
index 4983198..b962d81 100644
--- a/scripts/build_api.py
+++ b/scripts/build_api.py
@@ -11,6 +11,7 @@
 import sys
 
 from chromite.api import api_config as api_config_lib
+from chromite.api import controller
 from chromite.api import router as router_lib
 from chromite.lib import commandline
 from chromite.lib import cros_build_lib
@@ -21,23 +22,78 @@
   """Build the argument parser."""
   parser = commandline.ArgumentParser(description=__doc__)
 
-  parser.add_argument('service_method', nargs='?',
-                      help='The "chromite.api.Service/Method" that is being '
-                           'called.')
-  parser.add_argument(
-      '--input-json', type='path',
+  call_group = parser.add_argument_group(
+      'API Call Options',
+      'These options are used to execute an endpoint. When making a call every '
+      'argument in this group is required.')
+  call_group.add_argument(
+      'service_method',
+      nargs='?',
+      help='The "chromite.api.Service/Method" that is being called.')
+  call_group.add_argument(
+      '--input-json',
+      type='path',
       help='Path to the JSON serialized input argument protobuf message.')
-  parser.add_argument(
-      '--output-json', type='path',
+  call_group.add_argument(
+      '--output-json',
+      type='path',
       help='The path to which the result protobuf message should be written.')
-  parser.add_argument(
-      '--validate-only', action='store_true', default=False,
-      help='When set, only runs the argument validation logic. Calls produce'
-           'a return code of 0 iff the arguments comprise a valid call to the'
-           'endpoint, or 1 otherwise.')
-  parser.add_argument(
-      '--list-services', action='store_true',
-      help='List the names of the registered services.')
+
+  ux_group = parser.add_argument_group('Developer Options',
+                                       'Options to help developers.')
+  # Lists the full chromite.api.Service/Method, has both names to match
+  # whichever mental model people prefer.
+  ux_group.add_argument(
+      '--list-methods',
+      '--list-services',
+      action='store_true',
+      dest='list_services',
+      help='List the name of each registered "chromite.api.Service/Method".')
+
+  # Run configuration options.
+  test_group = parser.add_argument_group(
+      'Testing Options',
+      'These options are used to execute various tests against the API. These '
+      'options are mutually exclusive. Calling code can use these options to '
+      'validate inputs and test their handling of each return code case for '
+      'each endpoint.')
+  call_modifications = test_group.add_mutually_exclusive_group()
+  call_modifications.add_argument(
+      '--validate-only',
+      action='store_true',
+      default=False,
+      help='When set, only runs the argument validation logic. Calls produce '
+           'a return code of 0 iff the input proto comprises arguments that '
+           'are a valid call to the endpoint, or 1 otherwise.')
+  # See: api/faux.py for the mock call and error implementations.
+  call_modifications.add_argument(
+      '--mock-call',
+      action='store_true',
+      default=False,
+      help='When set, returns a valid, mock response rather than running the '
+           'endpoint. This allows API consumers to more easily test their '
+           'implementations against the version of the API being called. '
+           'This argument will always result in a return code of 0.')
+  call_modifications.add_argument(
+      '--mock-error',
+      action='store_true',
+      default=False,
+      help='When set, return a valid, mock error response rather than running '
+           'the endpoint. This allows API consumers to test their error '
+           'handling semantics against the version of the API being called. '
+           'This argument will always result in a return code of 2 iff the '
+           'endpoint ever produces a return code of 2, otherwise will always'
+           'produce a return code of 1.')
+  call_modifications.add_argument(
+      '--mock-invalid',
+      action='store_true',
+      default=False,
+      help='When set, return a mock validation error response rather than '
+           'running the endpoint. This allows API consumers to test their '
+           'validation error handling semantics against the version of the API '
+           'being called without having to understand how to construct an '
+           'invalid request. '
+           'This argument will always result in a return code of 1.')
 
   return parser
 
@@ -50,26 +106,26 @@
   methods = router.ListMethods()
 
   if opts.list_services:
+    # We just need to print the methods and we're done.
     for method in methods:
       print(method)
     sys.exit(0)
 
+  # Positional service_method argument validation.
   if not opts.service_method:
     parser.error('Must pass "Service/Method".')
 
   parts = opts.service_method.split('/')
   if len(parts) != 2:
     parser.error(
-        'Must pass the correct format: (i.e. chromite.api.SdkService/Create)')
-
-  if not opts.input_json or not opts.output_json:
-    parser.error('--input-json and --output-json are both required.')
+        'Must pass the correct format: (e.g. chromite.api.SdkService/Create).'
+        'Use --list-methods to see a full list.')
 
   if opts.service_method not in methods:
     # Unknown method, try to match against known methods and make a suggestion.
     # This is just for developer sanity, e.g. misspellings when testing.
-    matched = matching.GetMostLikelyMatchedObject(methods, opts.service_method,
-                                                  matched_score_threshold=0.6)
+    matched = matching.GetMostLikelyMatchedObject(
+        methods, opts.service_method, matched_score_threshold=0.6)
     error = 'Unrecognized service name.'
     if matched:
       error += '\nDid you mean: \n%s' % '\n'.join(matched)
@@ -78,10 +134,18 @@
   opts.service = parts[0]
   opts.method = parts[1]
 
+  # --input-json and --output-json validation.
+  if not opts.input_json or not opts.output_json:
+    parser.error('--input-json and --output-json are both required.')
+
   if not os.path.exists(opts.input_json):
     parser.error('Input file does not exist.')
 
-  opts.config = api_config_lib.ApiConfig(validate_only=opts.validate_only)
+  # Build the config object from the options.
+  opts.config = api_config_lib.ApiConfig(
+      validate_only=opts.validate_only,
+      mock_call=opts.mock_call,
+      mock_error=opts.mock_error)
 
   opts.Freeze()
   return opts
@@ -92,6 +156,12 @@
 
   opts = _ParseArgs(argv, router)
 
+  if opts.mock_invalid:
+    # --mock-invalid handling. We print error messages, but no output is ever
+    # set for validation errors, so we can handle it by just giving back the
+    # correct return code here.
+    return controller.RETURN_CODE_INVALID_INPUT
+
   try:
     return router.Route(opts.service, opts.method, opts.input_json,
                         opts.output_json, opts.config)