From b3bc7a8e748327280fc17f24731699bac68616ef Mon Sep 17 00:00:00 2001
From: Heather Schovanec <hschovanec@usgs.gov>
Date: Thu, 24 Aug 2017 09:13:38 -0600
Subject: [PATCH] Add WebServiceError class

Added a class to create error messages and wrapped parse, fetch,
and format_data in try/except statement to catch raised exceptions
and generate error pages.
---
 geomagio/WebService.py | 410 ++++++++++++++++++++++++++---------------
 1 file changed, 261 insertions(+), 149 deletions(-)

diff --git a/geomagio/WebService.py b/geomagio/WebService.py
index c634fd47..a9344e40 100644
--- a/geomagio/WebService.py
+++ b/geomagio/WebService.py
@@ -2,42 +2,126 @@
 """
 
 from __future__ import print_function
-from builtins import str
 from cgi import parse_qs, escape
 from datetime import datetime
+import sys
 
 from geomagio.edge import EdgeFactory
 from geomagio.iaga2002 import IAGA2002Writer
+from geomagio.ObservatoryMetadata import ObservatoryMetadata
 from obspy.core import UTCDateTime
 
 
+DEFAULT_DATA_TYPE = 'variation'
 DEFAULT_ELEMENTS = ('X', 'Y', 'Z', 'F')
-DEFAULT_PERIOD = '60'
-DEFAULT_TYPE = 'variation'
-VALID_TYPES = [
+DEFAULT_OUTPUT_FORMAT = 'iaga2002'
+DEFAULT_SAMPLING_PERIOD = '60'
+VALID_DATA_TYPES = [
         'variation',
         'adjusted',
         'quasi-definitive',
         'definitive'
 ]
-VALID_PERIODS = ['1', '60']
-VALID_FORMATS = ['iaga2002']
+VALID_OUTPUT_FORMATS = ['iaga2002']
+VALID_SAMPLING_PERIODS = ['1', '60']
+
+
+def _get_param(params, key, required=False):
+    """Get parameter from dictionary.
+
+    Parameters
+    ----------
+    params : dict
+        parameters dictionary.
+    key : str
+        parameter name.
+    required : bool
+        if required parameter.
+
+    Returns
+    -------
+    value : str
+        value from dictionary.
+
+    Raises
+    ------
+    WebServiceError
+        if the parameter is specified more than once
+        or if required paramenter is not specified.
+    """
+    value = params.get(key)
+    if type(value) in (list, tuple):
+        if len(value) > 1:
+            raise WebServiceException('"' + key +
+                    '" may only be specified once.')
+        value = escape(value[0])
+    if value is None:
+        if required:
+            raise WebServiceException('"' + key +
+                    '" is a required parameter.')
+    return value
+
+
+def _verify_parameters(query):
+    """Verify that parameters are valid.
+
+    Parameters
+    ----------
+    query : WebServiceQuery
+        parsed query object.
+
+    Raises
+    ------
+    WebServiceError
+        if any parameters are not supported.
+    """
+    if len(query.elements) > 4 and query.output_format == 'iaga2002':
+        raise WebServiceException(
+                'No more than 4 elements allowed for iaga2002 format.')
+    if query.starttime > query.endtime:
+        raise WebServiceException(
+                'Starttime must be before endtime.')
+    if query.data_type not in VALID_DATA_TYPES:
+        raise WebServiceException(
+                'Bad type value "%s".'
+                ' Valid values are: %s' % (query.data_type, VALID_DATA_TYPES))
+    if query.sampling_period not in VALID_SAMPLING_PERIODS:
+        raise WebServiceException(
+                'Bad sampling_period value "%s".'
+                ' Valid values are: %s'
+                % (query.sampling_period, VALID_SAMPLING_PERIODS))
+    if query.output_format not in VALID_OUTPUT_FORMATS:
+        raise WebServiceException(
+                'Bad format value "%s".'
+                ' Valid values are: %s'
+                % (query.output_format, VALID_OUTPUT_FORMATS))
 
 
 class WebService(object):
-    def __init__(self, factory):
+    def __init__(self, factory, metadata=None):
         self.factory = factory
+        self.metadata = metadata or ObservatoryMetadata().metadata
 
     def __call__(self, environ, start_response):
         """Implement WSGI interface"""
-        # parse params
-        query = WebServiceQuery.parse(environ['QUERY_STRING'])
-        # fetch data
-        data = self.fetch(query)
-        # format data
-        data_string = self.format(query, data)
-        if isinstance(data_string, str):
-            data_string = data_string.encode('utf8')
+        try:
+            # parse params
+            query = self.parse(parse_qs(environ['QUERY_STRING']))
+            # fetch data
+            data = self.fetch(query)
+            # format data
+            data_string = self.format_data(query, data)
+            if isinstance(data_string, str):
+                data_string = data_string.encode('utf8')
+        except Exception:
+            exception = sys.exc_info()[1]
+            message = exception.args[0]
+            error = WebServiceError('BAD_REQUEST', message, environ)
+            start_response(error.status,
+                    [
+                        ("Content-Type", "text/plain")
+                    ])
+            return [error.error_body]
         # send response
         start_response('200 OK',
                 [
@@ -57,16 +141,21 @@ class WebService(object):
         obspy.core.Stream
             timeseries object with requested data.
         """
+        _verify_parameters(query)
+        if query.sampling_period == '1':
+            query.sampling_period = 'second'
+        if query.sampling_period == '60':
+            query.sampling_period = 'minute'
         data = self.factory.get_timeseries(
-                observatory=query.id,
+                observatory=query.observatory_id,
                 channels=query.elements,
                 starttime=query.starttime,
                 endtime=query.endtime,
-                type=query.type,
+                type=query.data_type,
                 interval=query.sampling_period)
         return data
 
-    def format(self, query, data):
+    def format_data(self, query, data):
         """Format requested data.
 
         Parameters
@@ -78,53 +167,19 @@ class WebService(object):
         Returns
         -------
         unicode
-          IMFJSON or IAGA2002 formatted string.
+          IAGA2002 formatted string.
         """
         # TODO: Add option for json format
         data_string = IAGA2002Writer.format(data, query.elements)
         return data_string
 
-
-class WebServiceQuery(object):
-    """Query parameters for a web service request.
-
-    Parameters
-    ----------
-    id : str
-        observatory
-    starttime : obspy.core.UTCDateTime
-        time of first requested sample
-    endtime : obspy.core.UTCDateTime
-        time of last requested sample
-    elements : array_like
-        list of requested elements
-    sampling_period : int
-        period between samples in seconds
-        default 60.
-    type : {'variation', 'adjusted', 'quasi-definitive', 'definitive'}
-        data type
-        default 'variation'.
-    format : {'iaga2002', 'json'}
-        output format.
-        default 'iaga2002'.
-    """
-    def __init__(self, id=None, starttime=None, endtime=None, elements=None,
-            sampling_period=60, type='variation', format='iaga2002'):
-        self.id = id
-        self.starttime = starttime
-        self.endtime = endtime
-        self.elements = elements
-        self.sampling_period = sampling_period
-        self.type = type
-        self.format = format
-
-    @classmethod
-    def parse(cls, params):
+    def parse(self, params):
         """Parse query string parameters and set defaults.
 
         Parameters
         ----------
-        params : query string
+        params : dictionary
+            parameters dictionary.
 
         Returns
         -------
@@ -133,132 +188,189 @@ class WebServiceQuery(object):
 
         Raises
         ------
-        TimeseriesFactoryException
-            if id, type, sampling_period, or format are not supported.
+        WebServiceError
+            if any parameters are not supported.
         """
-        # Create dictionary of lists
-        dict = parse_qs(params)
         # Get values
-        if len(dict.get('id', [])) <= 1:
-            id = dict.get('id', [''])[0]
-        else:
-            raise WebServiceException(
-                '"id" accepts only one value')
-        if len(dict.get('starttime', [])) <= 1:
-            starttime = dict.get('starttime', [''])[0]
-        else:
-            raise WebServiceException(
-                '"starttime" accepts only one value')
-        if len(dict.get('endtime', [])) <= 1:
-            endtime = dict.get('endtime', [''])[0]
-        else:
-            raise WebServiceException(
-                '"endtime" accepts only one value')
-        if len(dict.get('elements', [])) <= 1:
-            elements = dict.get('elements', [''])[0]
+        observatory_id = _get_param(params, 'id', required=True)
+        starttime = _get_param(params, 'starttime')
+        endtime = _get_param(params, 'endtime')
+        elements = _get_param(params, 'elements')
+        sampling_period = _get_param(params, 'sampling_period')
+        data_type = _get_param(params, 'type')
+        output_format = _get_param(params, 'format')
+        # Assign values or defaults
+        if not output_format:
+            output_format = DEFAULT_OUTPUT_FORMAT
         else:
+            output_format = output_format.lower()
+        observatory_id = observatory_id.upper()
+        if observatory_id not in self.metadata.keys():
             raise WebServiceException(
-                '"elements" accepts only one set of values')
-        if len(dict.get('sampling_period', [])) <= 1:
-            sampling_period = dict.get('sampling_period', [''])[0]
-        else:
-            raise WebServiceException(
-                '"sampling_period" accepts only one value')
-        if len(dict.get('type', [])) <= 1:
-            type = dict.get('type', [''])[0]
-        else:
-            raise WebServiceException(
-                '"type" accepts only one value')
-        if len(dict.get('format', [])) <= 1:
-            format = dict.get('format', [''])[0]
+                   'Bad id value "%s".'
+                   ' Valid values are: %s'
+                   % (observatory_id, self.metadata.keys()))
+        if not starttime:
+            now = datetime.now()
+            today = UTCDateTime(
+                    year=now.year,
+                    month=now.month,
+                    day=now.day,
+                    hour=0)
+            starttime = today
         else:
-            raise WebServiceException(
-                '"format" accepts only one value')
-        # Escape to avoid script injection
-        id = escape(id)
-        starttime = escape(starttime)
-        endtime = escape(endtime)
-        elements = escape(elements)
-        sampling_period = escape(sampling_period)
-        type = escape(type).lower()
-        format = escape(format)
-        # Check for parameters and set defaults
-        if not id:
-            raise WebServiceException(
-                '"id" is a required parameter')
-        now = datetime.now()
-        if starttime:
             try:
                 starttime = UTCDateTime(starttime)
             except:
                 raise WebServiceException(
-                        'Invalid starttime "%s"' % starttime)
+                        'Bad starttime value "%s".'
+                        ' Valid values are ISO-8601 timestamps.' % starttime)
+        if not endtime:
+            endtime = starttime + (24 * 60 * 60 - 1)
         else:
-            starttime = UTCDateTime(
-                    year=now.year,
-                    month=now.month,
-                    day=now.day,
-                    hour=0)
-        if endtime:
             try:
                 endtime = UTCDateTime(endtime)
             except:
                 raise WebServiceException(
-                        'Invalid endtime "%s"' % endtime)
+                        'Bad endtime value "%s".'
+                        ' Valid values are ISO-8601 timestamps.' % endtime)
+        if not elements:
+            elements = DEFAULT_ELEMENTS
         else:
-            endtime = starttime + (24 * 60 * 60 - 1)
-        if starttime > endtime:
-            raise WebServiceException(
-                    'Starttime before endtime "%s" "%s"'
-                     % (starttime, endtime))
-        if elements:
             elements = [el.strip().upper() for el in elements.split(',')]
-        else:
-            elements = DEFAULT_ELEMENTS
         if not sampling_period:
-            sampling_period = DEFAULT_PERIOD
-        if sampling_period not in VALID_PERIODS:
-            raise WebServiceException(
-                    'Invalid sampling period.'
-                    ' Valid sampling periods: %s' % VALID_PERIODS)
-        # TODO: Add hourly option
-        if sampling_period == '1':
-            sampling_period = 'second'
-        if sampling_period == '60':
-            sampling_period = 'minute'
-        if not type:
-            type = DEFAULT_TYPE
-        if type not in VALID_TYPES:
-            raise WebServiceException(
-                'Invalid data type.'
-                ' Valid data types: %s' % VALID_TYPES)
-        # TODO: Add json to valid formats
-        if not format:
-            format = 'iaga2002'
-        if format not in VALID_FORMATS:
-            raise WebServiceException(
-                'Invalid format.'
-                ' Valid formats: %s' % VALID_FORMATS)
+            sampling_period = DEFAULT_SAMPLING_PERIOD
+        else:
+            sampling_period = sampling_period
+        if not data_type:
+            data_type = DEFAULT_DATA_TYPE
+        else:
+            data_type = data_type.lower()
         # Create WebServiceQuery object and set properties
         query = WebServiceQuery
-        query.id = id
+        query.observatory_id = observatory_id
         query.starttime = starttime
         query.endtime = endtime
         query.elements = elements
         query.sampling_period = sampling_period
-        query.type = type
-        query.format = format
+        query.data_type = data_type
+        query.output_format = output_format
         return query
 
 
+class WebServiceQuery(object):
+    """Query parameters for a web service request.
+
+    Parameters
+    ----------
+    observatory_id : str
+        observatory
+    starttime : obspy.core.UTCDateTime
+        time of first requested sample
+    endtime : obspy.core.UTCDateTime
+        time of last requested sample
+    elements : array_like
+        list of requested elements
+    sampling_period : int
+        period between samples in seconds
+        default 60.
+    data_type : {'variation', 'adjusted', 'quasi-definitive', 'definitive'}
+        data type
+        default 'variation'.
+    output_format : {'iaga2002', 'json'}
+        output format.
+        default 'iaga2002'.
+    """
+    def __init__(self, observatory_id=None, starttime=None, endtime=None,
+            elements=None, sampling_period=60, data_type='variation',
+            output_format='iaga2002'):
+        self.observatory_id = observatory_id
+        self.starttime = starttime
+        self.endtime = endtime
+        self.elements = elements
+        self.sampling_period = sampling_period
+        self.data_type = data_type
+        self.output_format = output_format
+
+
 class WebServiceException(Exception):
     """Base class for exceptions thrown by web services."""
     pass
 
 
+class WebServiceError(object):
+    """Base class for creating error pages."""
+    def __init__(self, code, message, environ):
+        self.code = code
+        self.message = message
+        self.environ = environ
+        self.error_body = None
+        self.error_types = {
+                'NO_DATA': {
+                        'code': 204,
+                        'status': 'No Data'
+                },
+                'BAD_REQUEST': {
+                        'code': 400,
+                        'status': 'Bad Request'
+                },
+                'NOT_FOUND': {
+                        'code': 404,
+                        'status': 'Not Found'
+                },
+                'CONFLICT': {
+                        'code': 409,
+                        'status': 'Conflict'
+                },
+                'SERVER_ERROR': {
+                        'code': 500,
+                        'status': 'Internal Server Error'
+                },
+                'NOT_IMPLEMENTED': {
+                        'code': 501,
+                        'status': 'Not Implemented'
+                },
+                'SERVICE_UNAVAILABLE': {
+                        'code': 503,
+                        'status': 'Service Unavailable'
+                },
+        }
+        self.status = None
+        self.error()
+
+    def error(self):
+        """Assign error_body value based on error format."""
+        # TODO: Add option for json formatted error
+        self.error_body = self.http_error()
+
+    def http_error(self):
+        """Format http error message.
+
+        Returns
+        -------
+        error_body : str
+            body of http error message.
+        """
+        code_message = str(self.error_types[self.code]['code'])
+        status_message = self.error_types[self.code]['status']
+        self.status = code_message + ' ' + status_message
+        server_software = self.environ['SERVER_SOFTWARE'].split(' ')
+        error_body = 'Error ' + code_message + ': ' + status_message + \
+                '\n\n' + self.message + '\n\n' + \
+                'Usage details are available from ' + \
+                'http://geomag.usgs.gov/ws/edge/ \n\n' + \
+                'Request:\n' + \
+                self.environ['QUERY_STRING'] + '\n\n' + \
+                'Request Submitted:\n' + \
+                datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + '\n\n' + \
+                'Service version:\n' + \
+                server_software[0]
+        return error_body
+
+
 if __name__ == '__main__':
     from wsgiref.simple_server import make_server
 
     app = WebService(EdgeFactory())
-    httpd = make_server('', 8080, app)
+    httpd = make_server('', 7981, app)
     httpd.serve_forever()
-- 
GitLab