Repository: googleapis/google-api-python-client

Issue:
Dataproc API client fails to cancel due to wrong discovery of parameters
We (@adrianAhne and I) have found a small bug on the Python client code.


When using the Google API Python client to cancel a job, the Python code fails because it expects a body. However, the API documentation clearly states that there is no body to this request (https://cloud.google.com/dataproc/docs/reference/rest/v1beta2/projects.regions.jobs/cancel ). I do not know if the discovery process is wrong or it is something else.

Here is a simple excerpt to reproduce the problem:
```
from oauth2client.client import GoogleCredentials
from googleapiclient.discovery import build

credentials = GoogleCredentials.get_application_default()
service = build('dataproc', 'v1beta2', credentials=credentials)
request = service.projects().regions().jobs().cancel(
                  projectId='my_project_id',
                  region='global',  # cannot be anything else than global at the moment
                  jobId='my_job_id')
result = request.execute()
```

Which fails when creating the request with the following error:
```
TypeError: Missing required parameter "body"
```

There is a workaround, which is to remove the body from the request:
```
request = service.projects().regions().jobs().cancel(
                  projectId='my_project_id',
                  region='global',  # cannot be anything else than global at the moment
                  jobId='my_job_id',
                  body='')  # Set body to something, None does not work
request.body = None
request.body_size = 0
result = request.execute()
```


Discussion:
This is also the case when calling [projects.models.versions.setDefault](https://cloud.google.com/ml-engine/reference/rest/v1/projects.models.versions/setDefault):
```
credentials = GoogleCredentials.get_application_default()
ml = discovery.build('ml', 'v1', credentials=credentials)
ml.projects().models().versions().setDefault(
        name=model_version,
        body={} # Must be set, even if empty.
    )
```
It seems that body must be passed even if it is empty. In other methods, body is simply omitted if it is not required, but in setDefault, omitting the body parameter gives an error:
```
/Projects/pythonenv/lib/python3.6/site-packages/googleapiclient/discovery.py in method(self, **kwargs)
    734         if name not in _PAGE_TOKEN_NAMES or _findPageTokenName(
    735             _methodProperties(methodDesc, schema, 'response')):
--> 736           raise TypeError('Missing required parameter "%s"' % name)
    737 
    738     for name, regex in six.iteritems(parameters.pattern_params):

TypeError: Missing required parameter "body"
```

Resolving patch:
diff --git a/googleapiclient/discovery.py b/googleapiclient/discovery.py
--- a/googleapiclient/discovery.py
+++ b/googleapiclient/discovery.py
@@ -459,7 +459,7 @@ def _media_path_url_from_info(root_desc, path_url):
   }
 
 
-def _fix_up_parameters(method_desc, root_desc, http_method):
+def _fix_up_parameters(method_desc, root_desc, http_method, schema):
   """Updates parameters of an API method with values specific to this library.
 
   Specifically, adds whatever global parameters are specified by the API to the
@@ -477,6 +477,7 @@ def _fix_up_parameters(method_desc, root_desc, http_method):
     root_desc: Dictionary; the entire original deserialized discovery document.
     http_method: String; the HTTP method used to call the API method described
         in method_desc.
+    schema: Object, mapping of schema names to schema descriptions.
 
   Returns:
     The updated Dictionary stored in the 'parameters' key of the method
@@ -497,6 +498,9 @@ def _fix_up_parameters(method_desc, root_desc, http_method):
   if http_method in HTTP_PAYLOAD_METHODS and 'request' in method_desc:
     body = BODY_PARAMETER_DEFAULT_VALUE.copy()
     body.update(method_desc['request'])
+    # Make body optional for requests with no parameters.
+    if not _methodProperties(method_desc, schema, 'request'):
+      body['required'] = False
     parameters['body'] = body
 
   return parameters
@@ -547,7 +551,7 @@ def _fix_up_media_upload(method_desc, root_desc, path_url, parameters):
   return accept, max_size, media_path_url
 
 
-def _fix_up_method_description(method_desc, root_desc):
+def _fix_up_method_description(method_desc, root_desc, schema):
   """Updates a method description in a discovery document.
 
   SIDE EFFECTS: Changes the parameters dictionary in the method description with
@@ -558,6 +562,7 @@ def _fix_up_method_description(method_desc, root_desc):
         from the dictionary of methods stored in the 'methods' key in the
         deserialized discovery document.
     root_desc: Dictionary; the entire original deserialized discovery document.
+    schema: Object, mapping of schema names to schema descriptions.
 
   Returns:
     Tuple (path_url, http_method, method_id, accept, max_size, media_path_url)
@@ -582,7 +587,7 @@ def _fix_up_method_description(method_desc, root_desc):
   http_method = method_desc['httpMethod']
   method_id = method_desc['id']
 
-  parameters = _fix_up_parameters(method_desc, root_desc, http_method)
+  parameters = _fix_up_parameters(method_desc, root_desc, http_method, schema)
   # Order is important. `_fix_up_media_upload` needs `method_desc` to have a
   # 'parameters' key and needs to know if there is a 'body' parameter because it
   # also sets a 'media_body' parameter.
@@ -710,7 +715,7 @@ def createMethod(methodName, methodDesc, rootDesc, schema):
   """
   methodName = fix_method_name(methodName)
   (pathUrl, httpMethod, methodId, accept,
-   maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc)
+   maxSize, mediaPathUrl) = _fix_up_method_description(methodDesc, rootDesc, schema)
 
   parameters = ResourceMethodParameters(methodDesc)
 


Test patch:
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -75,6 +75,7 @@
 from googleapiclient.http import MediaUploadProgress
 from googleapiclient.http import tunnel_patch
 from googleapiclient.model import JsonModel
+from googleapiclient.schema import Schemas
 from oauth2client import GOOGLE_TOKEN_URI
 from oauth2client.client import OAuth2Credentials, GoogleCredentials
 
@@ -125,18 +126,21 @@ def setUp(self):
     self.zoo_get_method_desc = self.zoo_root_desc['methods']['query']
     self.zoo_animals_resource = self.zoo_root_desc['resources']['animals']
     self.zoo_insert_method_desc = self.zoo_animals_resource['methods']['insert']
+    self.zoo_schema = Schemas(self.zoo_root_desc)
 
   def test_key2param(self):
     self.assertEqual('max_results', key2param('max-results'))
     self.assertEqual('x007_bond', key2param('007-bond'))
 
-  def _base_fix_up_parameters_test(self, method_desc, http_method, root_desc):
+  def _base_fix_up_parameters_test(
+          self, method_desc, http_method, root_desc, schema):
     self.assertEqual(method_desc['httpMethod'], http_method)
 
     method_desc_copy = copy.deepcopy(method_desc)
     self.assertEqual(method_desc, method_desc_copy)
 
-    parameters = _fix_up_parameters(method_desc_copy, root_desc, http_method)
+    parameters = _fix_up_parameters(method_desc_copy, root_desc, http_method,
+                                    schema)
 
     self.assertNotEqual(method_desc, method_desc_copy)
 
@@ -150,14 +154,14 @@ def _base_fix_up_parameters_test(self, method_desc, http_method, root_desc):
     return parameters
 
   def test_fix_up_parameters_get(self):
-    parameters = self._base_fix_up_parameters_test(self.zoo_get_method_desc,
-                                                   'GET', self.zoo_root_desc)
+    parameters = self._base_fix_up_parameters_test(
+      self.zoo_get_method_desc, 'GET', self.zoo_root_desc, self.zoo_schema)
     # Since http_method is 'GET'
     self.assertFalse('body' in parameters)
 
   def test_fix_up_parameters_insert(self):
-    parameters = self._base_fix_up_parameters_test(self.zoo_insert_method_desc,
-                                                   'POST', self.zoo_root_desc)
+    parameters = self._base_fix_up_parameters_test(
+      self.zoo_insert_method_desc, 'POST', self.zoo_root_desc, self.zoo_schema)
     body = {
         'description': 'The request body.',
         'type': 'object',
@@ -168,35 +172,58 @@ def test_fix_up_parameters_insert(self):
 
   def test_fix_up_parameters_check_body(self):
     dummy_root_desc = {}
+    dummy_schema = {
+      'Request': {
+        'properties': {
+          "description": "Required. Dummy parameter.",
+          "type": "string"
+        }
+      }
+    }
     no_payload_http_method = 'DELETE'
     with_payload_http_method = 'PUT'
 
     invalid_method_desc = {'response': 'Who cares'}
-    valid_method_desc = {'request': {'key1': 'value1', 'key2': 'value2'}}
+    valid_method_desc = {
+      'request': {
+        'key1': 'value1',
+        'key2': 'value2',
+        '$ref': 'Request'
+      }
+    }
 
     parameters = _fix_up_parameters(invalid_method_desc, dummy_root_desc,
-                                    no_payload_http_method)
+                                    no_payload_http_method, dummy_schema)
     self.assertFalse('body' in parameters)
 
     parameters = _fix_up_parameters(valid_method_desc, dummy_root_desc,
-                                    no_payload_http_method)
+                                    no_payload_http_method, dummy_schema)
     self.assertFalse('body' in parameters)
 
     parameters = _fix_up_parameters(invalid_method_desc, dummy_root_desc,
-                                    with_payload_http_method)
+                                    with_payload_http_method, dummy_schema)
     self.assertFalse('body' in parameters)
 
     parameters = _fix_up_parameters(valid_method_desc, dummy_root_desc,
-                                    with_payload_http_method)
+                                    with_payload_http_method, dummy_schema)
     body = {
         'description': 'The request body.',
         'type': 'object',
         'required': True,
+        '$ref': 'Request',
         'key1': 'value1',
         'key2': 'value2',
     }
     self.assertEqual(parameters['body'], body)
 
+  def test_fix_up_parameters_optional_body(self):
+    # Request with no parameters
+    dummy_schema = {'Request': {'properties': {}}}
+    method_desc = {'request': {'$ref': 'Request'}}
+
+    parameters = _fix_up_parameters(method_desc, {}, 'POST', dummy_schema)
+    self.assertFalse(parameters['body']['required'])
+
   def _base_fix_up_method_description_test(
       self, method_desc, initial_parameters, final_parameters,
       final_accept, final_max_size, final_media_path_url):
@@ -263,7 +290,7 @@ def test_fix_up_media_upload_with_initial_valid_full(self):
 
   def test_fix_up_method_description_get(self):
     result = _fix_up_method_description(self.zoo_get_method_desc,
-                                        self.zoo_root_desc)
+                                        self.zoo_root_desc, self.zoo_schema)
     path_url = 'query'
     http_method = 'GET'
     method_id = 'bigquery.query'
@@ -275,7 +302,7 @@ def test_fix_up_method_description_get(self):
 
   def test_fix_up_method_description_insert(self):
     result = _fix_up_method_description(self.zoo_insert_method_desc,
-                                        self.zoo_root_desc)
+                                        self.zoo_root_desc, self.zoo_schema)
     path_url = 'animals'
     http_method = 'POST'
     method_id = 'zoo.animals.insert'