417 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
	
		
		
			
		
	
	
			417 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
	
|  | # Copyright 2019 Google LLC | ||
|  | # | ||
|  | # Licensed under the Apache License, Version 2.0 (the "License"); | ||
|  | # you may not use this file except in compliance with the License. | ||
|  | # You may obtain a copy of the License at | ||
|  | # | ||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||
|  | # | ||
|  | # Unless required by applicable law or agreed to in writing, software | ||
|  | # distributed under the License is distributed on an "AS IS" BASIS, | ||
|  | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
|  | # See the License for the specific language governing permissions and | ||
|  | # limitations under the License. | ||
|  | """Wrapper script which makes a network request.
 | ||
|  | 
 | ||
|  | Basic Usage: network_request.py post | ||
|  |                                 --url <url> | ||
|  |                                 --header <header>  (optional, support multiple) | ||
|  |                                 --body <body>       (optional) | ||
|  |                                 --timeout <secs>    (optional) | ||
|  |                                 --verbose           (optional) | ||
|  | """
 | ||
|  | 
 | ||
|  | import argparse | ||
|  | import inspect | ||
|  | import logging | ||
|  | import socket | ||
|  | import sys | ||
|  | 
 | ||
|  | # pylint: disable=g-import-not-at-top | ||
|  | # pylint: disable=g-importing-member | ||
|  | try: | ||
|  |   from six.moves.http_client import HTTPSConnection | ||
|  |   from six.moves.http_client import HTTPConnection | ||
|  |   from six.moves.http_client import HTTPException | ||
|  | except ImportError: | ||
|  |   from http.client import HTTPSConnection | ||
|  |   from http.client import HTTPConnection | ||
|  |   from http.client import HTTPException | ||
|  | 
 | ||
|  | try: | ||
|  |   from six.moves.urllib.parse import urlparse | ||
|  | except ImportError: | ||
|  |   from urllib.parse import urlparse | ||
|  | # pylint: enable=g-import-not-at-top | ||
|  | # pylint: enable=g-importing-member | ||
|  | 
 | ||
|  | # Set up logger as soon as possible | ||
|  | formatter = logging.Formatter('[%(levelname)s] %(message)s') | ||
|  | 
 | ||
|  | handler = logging.StreamHandler(stream=sys.stdout) | ||
|  | handler.setFormatter(formatter) | ||
|  | handler.setLevel(logging.INFO) | ||
|  | 
 | ||
|  | logger = logging.getLogger(__name__) | ||
|  | logger.addHandler(handler) | ||
|  | logger.setLevel(logging.INFO) | ||
|  | 
 | ||
|  | # Custom exit codes for known issues. | ||
|  | # System exit codes in python are valid from 0 - 256, so we will map some common | ||
|  | # ones here to understand successes and failures. | ||
|  | # Uses lower ints to not collide w/ HTTP status codes that the script may return | ||
|  | EXIT_CODE_SUCCESS = 0 | ||
|  | EXIT_CODE_SYS_ERROR = 1 | ||
|  | EXIT_CODE_INVALID_REQUEST_VALUES = 2 | ||
|  | EXIT_CODE_GENERIC_HTTPLIB_ERROR = 3 | ||
|  | EXIT_CODE_HTTP_TIMEOUT = 4 | ||
|  | EXIT_CODE_HTTP_REDIRECT_ERROR = 5 | ||
|  | EXIT_CODE_HTTP_NOT_FOUND_ERROR = 6 | ||
|  | EXIT_CODE_HTTP_SERVER_ERROR = 7 | ||
|  | EXIT_CODE_HTTP_UNKNOWN_ERROR = 8 | ||
|  | 
 | ||
|  | MAX_EXIT_CODE = 8 | ||
|  | 
 | ||
|  | # All used http verbs | ||
|  | POST = 'POST' | ||
|  | 
 | ||
|  | 
 | ||
|  | def unwrap_kwarg_namespace(func): | ||
|  |   """Transform a Namespace object from argparse into proper args and kwargs.
 | ||
|  | 
 | ||
|  |   For a function that will be delegated to from argparse, inspect all of the | ||
|  |   argments and extract them from the Namespace object. | ||
|  | 
 | ||
|  |   Args: | ||
|  |     func: the function that we are wrapping to modify behavior | ||
|  | 
 | ||
|  |   Returns: | ||
|  |     a new function that unwraps all of the arguments in a namespace and then | ||
|  |     delegates to the passed function with those args. | ||
|  |   """
 | ||
|  |   # When we move to python 3, getfullargspec so that we can tell the | ||
|  |   # difference between args and kwargs -- then this could be used for functions | ||
|  |   # that have both args and kwargs | ||
|  |   if 'getfullargspec' in dir(inspect): | ||
|  |     argspec = inspect.getfullargspec(func) | ||
|  |   else: | ||
|  |     argspec = inspect.getargspec(func)  # Python 2 compatibility. | ||
|  | 
 | ||
|  |   def wrapped(argparse_namespace=None, **kwargs): | ||
|  |     """Take a Namespace object and map it to kwargs.
 | ||
|  | 
 | ||
|  |     Inspect the argspec of the passed function. Loop over all the args that | ||
|  |     are present in the function and try to map them by name to arguments in the | ||
|  |     namespace. For keyword arguments, we do not require that they be present | ||
|  |     in the Namespace. | ||
|  | 
 | ||
|  |     Args: | ||
|  |       argparse_namespace: an arparse.Namespace object, the result of calling | ||
|  |         argparse.ArgumentParser().parse_args() | ||
|  |       **kwargs: keyword arguments that may be passed to the original function | ||
|  |     Returns: | ||
|  |       The return of the wrapped function from the parent. | ||
|  | 
 | ||
|  |     Raises: | ||
|  |       ValueError in the event that an argument is passed to the cli that is not | ||
|  |       in the set of named kwargs | ||
|  |     """
 | ||
|  |     if not argparse_namespace: | ||
|  |       return func(**kwargs) | ||
|  | 
 | ||
|  |     reserved_namespace_keywords = ['func'] | ||
|  |     new_kwargs = {} | ||
|  | 
 | ||
|  |     args = argspec.args or [] | ||
|  |     for arg_name in args: | ||
|  |       passed_value = getattr(argparse_namespace, arg_name, None) | ||
|  |       if passed_value is not None: | ||
|  |         new_kwargs[arg_name] = passed_value | ||
|  | 
 | ||
|  |     for namespace_key in vars(argparse_namespace).keys(): | ||
|  |       # ignore namespace keywords that have been set not passed in via cli | ||
|  |       if namespace_key in reserved_namespace_keywords: | ||
|  |         continue | ||
|  | 
 | ||
|  |       # make sure that we haven't passed something we should be processing | ||
|  |       if namespace_key not in args: | ||
|  |         raise ValueError('CLI argument "{}" does not match any argument in ' | ||
|  |                          'function {}'.format(namespace_key, func.__name__)) | ||
|  | 
 | ||
|  |     return func(**new_kwargs) | ||
|  | 
 | ||
|  |   wrapped.__name__ = func.__name__ | ||
|  |   return wrapped | ||
|  | 
 | ||
|  | 
 | ||
|  | class NetworkRequest(object): | ||
|  |   """A container for an network request object.
 | ||
|  | 
 | ||
|  |   This class holds on to all of the attributes necessary for making a | ||
|  |   network request via httplib. | ||
|  |   """
 | ||
|  | 
 | ||
|  |   def __init__(self, url, method, headers, body, timeout): | ||
|  |     self.url = url.lower() | ||
|  |     self.parsed_url = urlparse(self.url) | ||
|  |     self.method = method | ||
|  |     self.headers = headers | ||
|  |     self.body = body | ||
|  |     self.timeout = timeout | ||
|  |     self.is_secure_connection = self.is_secure_connection() | ||
|  | 
 | ||
|  |   def execute_request(self): | ||
|  |     """"Execute the request, and get a response.
 | ||
|  | 
 | ||
|  |     Returns: | ||
|  |       an HttpResponse object from httplib | ||
|  |     """
 | ||
|  |     if self.is_secure_connection: | ||
|  |       conn = HTTPSConnection(self.get_hostname(), timeout=self.timeout) | ||
|  |     else: | ||
|  |       conn = HTTPConnection(self.get_hostname(), timeout=self.timeout) | ||
|  | 
 | ||
|  |     conn.request(self.method, self.url, self.body, self.headers) | ||
|  |     response = conn.getresponse() | ||
|  |     return response | ||
|  | 
 | ||
|  |   def get_hostname(self): | ||
|  |     """Return the hostname for the url.""" | ||
|  |     return self.parsed_url.netloc | ||
|  | 
 | ||
|  |   def is_secure_connection(self): | ||
|  |     """Checks for a secure connection of https.
 | ||
|  | 
 | ||
|  |     Returns: | ||
|  |       True if the scheme is "https"; False if "http" | ||
|  | 
 | ||
|  |     Raises: | ||
|  |       ValueError when the scheme does not match http or https | ||
|  |     """
 | ||
|  |     scheme = self.parsed_url.scheme | ||
|  | 
 | ||
|  |     if scheme == 'http': | ||
|  |       return False | ||
|  |     elif scheme == 'https': | ||
|  |       return True | ||
|  |     else: | ||
|  |       raise ValueError('The url scheme is not "http" nor "https"' | ||
|  |                        ': {}'.format(scheme)) | ||
|  | 
 | ||
|  | 
 | ||
|  | def parse_colon_delimited_options(option_args): | ||
|  |   """Parses a key value from a string.
 | ||
|  | 
 | ||
|  |   Args: | ||
|  |       option_args: Key value string delimited by a color, ex: ("key:value") | ||
|  | 
 | ||
|  |   Returns: | ||
|  |       Return an array with the key as the first element and value as the second | ||
|  | 
 | ||
|  |   Raises: | ||
|  |       ValueError: If the key value option is not formatted correctly | ||
|  |   """
 | ||
|  |   options = {} | ||
|  | 
 | ||
|  |   if not option_args: | ||
|  |     return options | ||
|  | 
 | ||
|  |   for single_arg in option_args: | ||
|  |     values = single_arg.split(':') | ||
|  |     if len(values) != 2: | ||
|  |       raise ValueError('An option arg must be a single key/value pair ' | ||
|  |                        'delimited by a colon - ex: "thing_key:thing_value"') | ||
|  | 
 | ||
|  |     key = values[0].strip() | ||
|  |     value = values[1].strip() | ||
|  |     options[key] = value | ||
|  | 
 | ||
|  |   return options | ||
|  | 
 | ||
|  | 
 | ||
|  | def make_request(request): | ||
|  |   """Makes a synchronous network request and return the HTTP status code.
 | ||
|  | 
 | ||
|  |   Args: | ||
|  |     request: a well formulated request object | ||
|  | 
 | ||
|  |   Returns: | ||
|  |     The HTTP status code of the network request. | ||
|  |     '1' maps to invalid request headers. | ||
|  |   """
 | ||
|  | 
 | ||
|  |   logger.info('Sending network request -') | ||
|  |   logger.info('\tUrl: %s', request.url) | ||
|  |   logger.debug('\tMethod: %s', request.method) | ||
|  |   logger.debug('\tHeaders: %s', request.headers) | ||
|  |   logger.debug('\tBody: %s', request.body) | ||
|  | 
 | ||
|  |   try: | ||
|  |     response = request.execute_request() | ||
|  |   except socket.timeout: | ||
|  |     logger.exception( | ||
|  |         'Timed out post request to %s in %d seconds for request body: %s', | ||
|  |         request.url, request.timeout, request.body) | ||
|  |     return EXIT_CODE_HTTP_TIMEOUT | ||
|  |   except (HTTPException, socket.error): | ||
|  |     logger.exception( | ||
|  |         'Encountered generic exception in posting to %s with request body %s', | ||
|  |         request.url, request.body) | ||
|  |     return EXIT_CODE_GENERIC_HTTPLIB_ERROR | ||
|  | 
 | ||
|  |   status = response.status | ||
|  |   headers = response.getheaders() | ||
|  |   logger.info('Received Network response -') | ||
|  |   logger.info('\tStatus code: %d', status) | ||
|  |   logger.debug('\tResponse headers: %s', headers) | ||
|  | 
 | ||
|  |   if status < 200 or status > 299: | ||
|  |     logger.error('Request (%s) failed with status code %d\n', request.url, | ||
|  |                  status) | ||
|  | 
 | ||
|  |   # If we wanted this script to support get, we need to | ||
|  |   # figure out what mechanism we intend for capturing the response | ||
|  |   return status | ||
|  | 
 | ||
|  | 
 | ||
|  | @unwrap_kwarg_namespace | ||
|  | def post(url=None, header=None, body=None, timeout=5, verbose=False): | ||
|  |   """Sends a post request.
 | ||
|  | 
 | ||
|  |   Args: | ||
|  |       url: The url of the request | ||
|  |       header: A list of headers for the request | ||
|  |       body: The body for the request | ||
|  |       timeout: Timeout in seconds for the request | ||
|  |       verbose: Should debug logs be displayed | ||
|  | 
 | ||
|  |   Returns: | ||
|  |       Return an array with the key as the first element and value as the second | ||
|  |   """
 | ||
|  | 
 | ||
|  |   if verbose: | ||
|  |     handler.setLevel(logging.DEBUG) | ||
|  |     logger.setLevel(logging.DEBUG) | ||
|  | 
 | ||
|  |   try: | ||
|  |     logger.info('Parsing headers: %s', header) | ||
|  |     headers = parse_colon_delimited_options(header) | ||
|  |   except ValueError: | ||
|  |     logging.exception('Could not parse the parameters with "--header": %s', | ||
|  |                       header) | ||
|  |     return EXIT_CODE_INVALID_REQUEST_VALUES | ||
|  | 
 | ||
|  |   try: | ||
|  |     request = NetworkRequest(url, POST, headers, body, float(timeout)) | ||
|  |   except ValueError: | ||
|  |     logger.exception('Invalid request values passed into the script.') | ||
|  |     return EXIT_CODE_INVALID_REQUEST_VALUES | ||
|  | 
 | ||
|  |   status = make_request(request) | ||
|  | 
 | ||
|  |   # View exit code after running to get the http status code: 'echo $?' | ||
|  |   return status | ||
|  | 
 | ||
|  | 
 | ||
|  | def get_argsparser(): | ||
|  |   """Returns the argument parser.
 | ||
|  | 
 | ||
|  |   Returns: | ||
|  |     Argument parser for the script. | ||
|  |   """
 | ||
|  | 
 | ||
|  |   parser = argparse.ArgumentParser( | ||
|  |       description='The script takes in the arguments of a network request. ' | ||
|  |       'The network request is sent and the http status code will be' | ||
|  |       'returned as the exit code.') | ||
|  |   subparsers = parser.add_subparsers(help='Commands:') | ||
|  |   post_parser = subparsers.add_parser( | ||
|  |       post.__name__, help='{} help'.format(post.__name__)) | ||
|  |   post_parser.add_argument( | ||
|  |       '--url', | ||
|  |       help='Request url. Ex: https://www.google.com/somePath/', | ||
|  |       required=True, | ||
|  |       dest='url') | ||
|  |   post_parser.add_argument( | ||
|  |       '--header', | ||
|  |       help='Request headers as a space delimited list of key ' | ||
|  |       'value pairs. Ex: "key1:value1 key2:value2"', | ||
|  |       action='append', | ||
|  |       required=False, | ||
|  |       dest='header') | ||
|  |   post_parser.add_argument( | ||
|  |       '--body', | ||
|  |       help='The body of the network request', | ||
|  |       required=True, | ||
|  |       dest='body') | ||
|  |   post_parser.add_argument( | ||
|  |       '--timeout', | ||
|  |       help='The timeout in seconds', | ||
|  |       default=10.0, | ||
|  |       required=False, | ||
|  |       dest='timeout') | ||
|  |   post_parser.add_argument( | ||
|  |       '--verbose', | ||
|  |       help='Should verbose logging be outputted', | ||
|  |       action='store_true', | ||
|  |       default=False, | ||
|  |       required=False, | ||
|  |       dest='verbose') | ||
|  |   post_parser.set_defaults(func=post) | ||
|  |   return parser | ||
|  | 
 | ||
|  | 
 | ||
|  | def map_http_status_to_exit_code(status_code): | ||
|  |   """Map an http status code to the appropriate exit code.
 | ||
|  | 
 | ||
|  |   Exit codes in python are valid from 0-256, so we want to map these to | ||
|  |   predictable exit codes within range. | ||
|  | 
 | ||
|  |   Args: | ||
|  |     status_code: the input status code that was output from the network call | ||
|  |                  function | ||
|  | 
 | ||
|  |   Returns: | ||
|  |     One of our valid exit codes declared at the top of the file or a generic | ||
|  |     unknown error code | ||
|  |   """
 | ||
|  |   if status_code <= MAX_EXIT_CODE: | ||
|  |     return status_code | ||
|  | 
 | ||
|  |   if status_code > 199 and status_code < 300: | ||
|  |     return EXIT_CODE_SUCCESS | ||
|  | 
 | ||
|  |   if status_code == 302: | ||
|  |     return EXIT_CODE_HTTP_REDIRECT_ERROR | ||
|  | 
 | ||
|  |   if status_code == 404: | ||
|  |     return EXIT_CODE_HTTP_NOT_FOUND_ERROR | ||
|  | 
 | ||
|  |   if status_code > 499: | ||
|  |     return EXIT_CODE_HTTP_SERVER_ERROR | ||
|  | 
 | ||
|  |   return EXIT_CODE_HTTP_UNKNOWN_ERROR | ||
|  | 
 | ||
|  | 
 | ||
|  | def main(): | ||
|  |   """Main function to run the program.
 | ||
|  | 
 | ||
|  |   Parse system arguments and delegate to the appropriate function. | ||
|  | 
 | ||
|  |   Returns: | ||
|  |     A status code - either an http status code or a custom error code | ||
|  |   """
 | ||
|  |   parser = get_argsparser() | ||
|  |   subparser_action = parser.parse_args() | ||
|  |   try: | ||
|  |     return subparser_action.func(subparser_action) | ||
|  |   except ValueError: | ||
|  |     logger.exception('Invalid arguments passed.') | ||
|  |     parser.print_help(sys.stderr) | ||
|  |     return EXIT_CODE_INVALID_REQUEST_VALUES | ||
|  |   return EXIT_CODE_GENERIC_HTTPLIB_ERROR | ||
|  | 
 | ||
|  | if __name__ == '__main__': | ||
|  |   exit_code = map_http_status_to_exit_code(main()) | ||
|  |   sys.exit(exit_code) |