From 2a70e6e765a35097b4b3c111d387acb170103d53 Mon Sep 17 00:00:00 2001
From: Devananda van der Veen <devananda.vdv@gmail.com>
Date: Sat, 4 Apr 2015 08:44:22 -0700
Subject: [PATCH] Refactoring and adding Types

Some big changes here:

Rename connection.py to server.py

Refactor about half of server.py into a new types.py module which builds
classes for each resource type, and auto-builds links to fetch
sub-resources from each type.

Add examples/walk-chassis.py to demonstrate how to use the Root and
Chassis classes to walk all the objects returned from /rest/v1/chassis/

Import oslo_log and start using it (more to do here, it's not working
quite yet).
---
 examples/simple.py                   |   5 +-
 examples/walk-chassis.py             |  58 ++++++++
 redfish/__init__.py                  |   3 +
 redfish/exception.py                 |   6 +-
 redfish/{connection.py => server.py} | 125 ++++++-----------
 redfish/tests/test_redfish.py        |  29 ++--
 redfish/types.py                     | 197 +++++++++++++++++++++++++++
 requirements.txt                     |   1 +
 8 files changed, 322 insertions(+), 102 deletions(-)
 create mode 100644 examples/walk-chassis.py
 rename redfish/{connection.py => server.py} (78%)
 create mode 100644 redfish/types.py

diff --git a/examples/simple.py b/examples/simple.py
index 3d1efa3..91d34bd 100644
--- a/examples/simple.py
+++ b/examples/simple.py
@@ -1,3 +1,6 @@
 from redfish import connection
 
-server = connection
\ No newline at end of file
+host = '127.0.0.1'
+user_name = 'Admin'
+password = 'password'
+server = connection.RedfishConnection(host, user_name, password)
\ No newline at end of file
diff --git a/examples/walk-chassis.py b/examples/walk-chassis.py
new file mode 100644
index 0000000..900cfe6
--- /dev/null
+++ b/examples/walk-chassis.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python
+
+#import logging
+import sys
+from oslo_config import cfg
+from oslo_log import log as logging
+
+import redfish
+
+# Sets up basic logging for this module
+#log_root = logging.getLogger('redfish')
+#log_root.addHandler(logging.StreamHandler(sys.stdout))
+#log_root.setLevel(logging.DEBUG)
+
+CONF = cfg.CONF
+logging.set_defaults(['redfish=DEBUG'])
+logging.register_options(CONF)
+#logging.setup(CONF, "redfish")
+
+# Connect to a redfish API endpoint
+host = 'http://localhost'
+user_name = ''
+password = ''
+
+# This returns a RedfishConnection object, which implements
+# the low-level HTTP methods like GET, PUT, etc
+connection = redfish.server.connect(host, user_name, password)
+
+# From this connection, we can get the Root resource.
+# Note that the root resource is somewhat special - you create it from
+# the connection, but you create other resources from the root resource.
+# (You don't strictly have to do this, but it's simpler.)
+root = connection.get_root()
+
+print("\n")
+print("ROOT CONTROLLER")
+print("===============")
+print(root)
+
+
+# The Root class has well-defined top-level resources, such as
+# chassis, systems, managers, sessions, etc...
+chassis = root.get_chassis()
+
+print("\n")
+print("CHASSIS DATA")
+print("============")
+print(chassis)
+print("\n")
+print("WALKING CHASSIS")
+print("\n")
+print("CHASSIS contains %d items" % len(chassis))
+print("\n")
+for item in chassis:
+    print("SYSTEM")
+    print("======")
+    print(item)
+    print("\n")
diff --git a/redfish/__init__.py b/redfish/__init__.py
index 7290f2b..625d02f 100644
--- a/redfish/__init__.py
+++ b/redfish/__init__.py
@@ -14,6 +14,9 @@
 
 import pbr.version
 
+import redfish.server
+import redfish.types
+
 
 __version__ = pbr.version.VersionInfo(
     'redfish').version_string()
diff --git a/redfish/exception.py b/redfish/exception.py
index 99644c7..b8a00f5 100644
--- a/redfish/exception.py
+++ b/redfish/exception.py
@@ -24,4 +24,8 @@ class RedfishException(Exception):
             except Excetion as e:
                 LOG.exception('Error in string format operation')
                 message = self.message
-        super(RedfishException, self).__init__(message)
\ No newline at end of file
+        super(RedfishException, self).__init__(message)
+
+
+class ObjectLoadException(RedfishException):
+    pass
diff --git a/redfish/connection.py b/redfish/server.py
similarity index 78%
rename from redfish/connection.py
rename to redfish/server.py
index 3fe0b88..62f33f0 100644
--- a/redfish/connection.py
+++ b/redfish/server.py
@@ -120,17 +120,23 @@ import gzip
 import hashlib
 import httplib
 import json
-import logging
 import ssl
 import StringIO
 import sys
 import urllib2
 from urlparse import urlparse
 
-from redfish import exception
+from oslo_log import log as logging
 
-LOG = logging.getLogger(__name__)
-LOG.setLevel(logging.DEBUG)
+from redfish import exception
+from redfish import types
+
+
+LOG = logging.getLogger('redfish')
+
+
+def connect(host, user, password):
+    return RedfishConnection(host, user, password)
 
 
 class RedfishConnection(object):
@@ -146,10 +152,15 @@ class RedfishConnection(object):
         self.auth_token = auth_token
         self.enforce_SSL = enforce_SSL
 
+        # context for the last status and header returned from a call
+        self.status = None
+        self.headers = None
+
         # If the http schema wasn't specified, default to HTTPS
         if host[0:4] != 'http':
             host = 'https://' + host
         self.host = host
+
         self._connect()
 
         if not self.auth_token:
@@ -158,7 +169,7 @@ class RedfishConnection(object):
             # what we should do here.
             LOG.debug('Initiating session with host %s', self.host)
             auth_dict = {'Password': self.password, 'UserName': self.user_name}
-            (status, headers, response) = self.rest_post(
+            response = self.rest_post(
                     '/rest/v1/Sessions', None, json.dumps(auth_dict))
 
         # TODO: do some schema discovery here and cache the result
@@ -200,10 +211,14 @@ class RedfishConnection(object):
         :param request_headers: optional dict of headers
         :param request_body: optional JSON body
         """
-
+        # ensure trailing slash
+        if suburi[-1:] != '/':
+            suburi = suburi + '/'
         url = urlparse(self.host + suburi)
 
-        if not isinstance(request_headers, dict):  request_headers = dict()
+        if not isinstance(request_headers, dict):
+            request_headers = dict()
+        request_headers['Content-Type'] = 'application/json'
 
         # if X-Auth-Token specified, supply it instead of basic auth
         if self.auth_token is not None:
@@ -253,7 +268,9 @@ class RedfishConnection(object):
                         'Failed to parse response as a JSON document, '
                         'received "%s".' % body)
 
-        return resp.status, headers, response
+        self.status = resp.status
+        self.headers = headers
+        return response
 
     def rest_get(self, suburi, request_headers):
         """REST GET
@@ -261,8 +278,6 @@ class RedfishConnection(object):
         :param: suburi
         :param: request_headers
         """
-        if not isinstance(request_headers, dict):
-            request_headers = dict()
         # NOTE:  be prepared for various HTTP responses including 500, 404, etc
         return self._op('GET', suburi, request_headers, None)
 
@@ -276,9 +291,6 @@ class RedfishConnection(object):
               redfish does not follow IETF JSONPATCH standard
               https://tools.ietf.org/html/rfc6902
         """
-        if not isinstance(request_headers, dict):
-            request_headers = dict()
-        request_headers['Content-Type'] = 'application/json'
         # NOTE:  be prepared for various HTTP responses including 500, 404, 202
         return self._op('PATCH', suburi, request_headers, request_body)
 
@@ -289,9 +301,6 @@ class RedfishConnection(object):
         :param: request_headers
         :param: request_body
         """
-        if not isinstance(request_headers, dict):
-            request_headers = dict()
-        request_headers['Content-Type'] = 'application/json'
         # NOTE:  be prepared for various HTTP responses including 500, 404, 202
         return self._op('PUT', suburi, request_headers, request_body)
 
@@ -302,9 +311,6 @@ class RedfishConnection(object):
         :param: request_headers
         :param: request_body
         """
-        if not isinstance(request_headers, dict):
-            request_headers = dict()
-        request_headers['Content-Type'] = 'application/json'
         # NOTE:  don't assume any newly created resource is included in the
         # # response.  Only the Location header matters.
         # the response body may be the new resource, it may be an
@@ -317,80 +323,27 @@ class RedfishConnection(object):
         :param: suburi
         :param: request_headers
         """
-        if not isinstance(request_headers, dict):
-            request_headers = dict()
         # NOTE:  be prepared for various HTTP responses including 500, 404
         # NOTE:  response may be an ExtendedError or may be empty
         return self._op('DELETE', suburi, request_headers, None)
 
-    # this is a generator that returns collection members
-    def collection(self, collection_uri, request_headers):
-        """
-        collections are of two tupes:
-        - array of things that are fully expanded (details)
-        - array of URLs (links)
-        """
-        # get the collection
-        status, headers, thecollection = self.rest_get(
-                collection_uri, request_headers)
+    def get_root(self):
+        return types.Root(self.rest_get('/rest/v1', {}), connection=self)
 
-        # TODO: commment this
-        while status < 300:
-            # verify expected type
 
-            # NOTE:  Because of the Redfish standards effort, we have versioned
-            # many things at 0 in anticipation of them being ratified for
-            # version 1 at some point. So this code makes the (unguarranteed)
-            # assumption throughout that version 0 and 1 are both legitimate at
-            # this point. Don't write code requiring version 0 as  we will bump
-            # to version 1 at some point.
+class Version(object):
+    def __init__(self, string):
+        try:
+            buf = string.split('.')
+            if len(buf) < 2:
+                raise AttributeError
+        except AttributeError:
+            raise RedfishException(message="Failed to parse version string")
+        self.major = int(buf[0])
+        self.minor = int(buf[1])
 
-            # hint:  don't limit to version 0 here as we will rev to 1.0 at
-            # some point hopefully with minimal changes
-            assert(get_type(thecollection) == 'Collection.0' or 
-                   get_type(thecollection) == 'Collection.1')
-
-            # if this collection has inline items, return those
-
-            # NOTE:  Collections are very flexible in how the represent
-            # members.  They can be inline in the collection as members of the
-            # 'Items' array, or they may be href links in the links/Members
-            # array.  The could actually be both.  We have to render it with
-            # the href links when an array contains PATCHable items because its
-            # complex to PATCH inline collection members.  A client may wish
-            # to pass in a boolean flag favoring the href links vs. the Items in
-            # case a collection contains both.
-
-            if 'Items' in thecollection:
-                # iterate items
-                for item in thecollection['Items']:
-                    # if the item has a self uri pointer, supply that for convenience
-                    memberuri = None
-                    if 'links' in item and 'self' in item['links']:
-                        memberuri = item['links']['self']['href']
-
-                    # Read up on Python generator functions to understand what this does.
-                    yield 200, None, item, memberuri
-
-            # else walk the member links
-            elif 'links' in thecollection and 'Member' in thecollection['links']:
-                # iterate members
-                for memberuri in thecollection['links']['Member']:
-                    # for each member return the resource indicated by the member link
-                    status, headers, member = rest_get(
-                        host, memberuri['href'], request_headers, user_name, password)
-
-                    # Read up on Python generator functions to understand what this does.
-                    yield status, headers, member, memberuri['href']
-
-            # page forward if there are more pages in the collection
-            if 'links' in thecollection and 'NextPage' in thecollection['links']:
-                next_link_uri = collection_uri + '?page=' + str(thecollection['links']['NextPage']['page'])
-                status, headers, thecollection = rest_get(host, next_link_uri, request_headers, user_name, password)
-
-            # else we are finished iterating the collection
-            else:
-                break
+    def __repr__(self):
+        return str(self.major) + '.' + str(self.minor)
 
 
 # return the type of an object (down to the major version, skipping minor, and errata)
diff --git a/redfish/tests/test_redfish.py b/redfish/tests/test_redfish.py
index 6371f19..b70b43b 100644
--- a/redfish/tests/test_redfish.py
+++ b/redfish/tests/test_redfish.py
@@ -26,7 +26,8 @@ import mock
 import ssl
 
 from redfish.tests import base
-from redfish import connection
+from redfish import server
+from redfish import types
 
 
 def get_fake_params(host=None, user=None, pword=None):
@@ -69,20 +70,20 @@ class TestRedfishConnection(base.TestCase):
         self.addCleanup(self.https_mock.stop)
 
     def test_create_ok(self):
-        con = connection.RedfishConnection(*get_fake_params())
+        con = server.RedfishConnection(*get_fake_params())
         self.assertEqual(1, self.https_mock.call_count)
         self.assertEqual(0, self.http_mock.call_count)
 
     def test_create_calls_https_connect(self):
         self.https_mock.side_effect = TestException()
         self.assertRaises(TestException,
-                          connection.RedfishConnection,
+                          server.RedfishConnection,
                           *get_fake_params(host='https://fake'))
 
     def test_create_calls_http_connect(self):
         self.http_mock.side_effect = TestException()
         self.assertRaises(TestException,
-                          connection.RedfishConnection,
+                          server.RedfishConnection,
                           *get_fake_params(host='http://fake'))
 
     # TODO: add test for unknown connection schema (eg, ftp://)
@@ -96,14 +97,14 @@ class TestRedfishConnection(base.TestCase):
 #        ssl_mock.assert_called_once_with(ssl.PROTOCOL_TLSv1)
 
     def test_get_ok(self):
-        con = connection.RedfishConnection(*get_fake_params())
-        res = con.rest_get('/v1/test', '')
-        self.assertEqual(200, res[0])
+        con = server.RedfishConnection(*get_fake_params())
+        res = con.rest_get('/v1/test/', '')
+        self.assertEqual(200, con.status)
         # Headers ae lower cased when returned
-        self.assertIn('fake-header', res[1].keys())
-        self.assertIn('foo', res[2].keys())
+        self.assertIn('fake-header', con.headers.keys())
+        self.assertIn('foo', res.keys())
         self.con_mock.request.assert_called_with(
-                'GET', '/v1/test', body='null', headers=mock.ANY)
+                'GET', '/v1/test/', body='null', headers=mock.ANY)
 
     # TODO: add test for redirects
 
@@ -114,8 +115,8 @@ class TestRedfishConnection(base.TestCase):
     def test_post_ok(self):
         body = '{"fake": "body"}'
         json_body = json.dumps(body)
-        con = connection.RedfishConnection(*get_fake_params())
-        res = con.rest_post('/v1/test', '', body)
-        self.assertEqual(200, res[0])
+        con = server.RedfishConnection(*get_fake_params())
+        res = con.rest_post('/v1/test/', '', body)
+        self.assertEqual(200, con.status)
         self.con_mock.request.assert_called_with(
-                'POST', '/v1/test', body=json_body, headers=mock.ANY)
+                'POST', '/v1/test/', body=json_body, headers=mock.ANY)
diff --git a/redfish/types.py b/redfish/types.py
new file mode 100644
index 0000000..a31b8bc
--- /dev/null
+++ b/redfish/types.py
@@ -0,0 +1,197 @@
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+#
+# 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.
+
+
+"""
+Redfish Resource Types
+"""
+
+import base64
+import gzip
+import hashlib
+import httplib
+import json
+import ssl
+import StringIO
+import sys
+import urllib2
+from urlparse import urlparse
+
+from oslo_log import log as logging
+from redfish import exception
+
+LOG = logging.getLogger('redfish')
+
+
+class Base(object):
+    def __init__(self, obj, connection=None):
+        self._conn = connection
+        """handle to the redfish connection"""
+
+        self._attrs = []
+        """list of discovered attributes"""
+
+        self._links = []
+        """list of linked resources"""
+
+        # parse the individual resources, appending them to
+        # the list of object attributes
+        for k in obj.keys():
+            ref = k.lower()
+            if ref in ["links", "oem", "items"]:
+                continue
+            setattr(self, ref, obj[k])
+            self._attrs.append(ref)
+
+        # make sure the required attributes are present
+        if not getattr(self, 'name', False):
+            raise ObjectLoadException(
+                    "Failed to load object. Reason: could not determine name.")
+        if not getattr(self, 'type', False):
+            raise ObjectLoadException(
+                    "Failed to load object. Reason: could not determine type.")
+
+        if getattr(self, 'serviceversion', False):
+            self.type = self.type.replace('.' + self.serviceversion, '')
+        else:
+            # TODO: use a regex here to strip and store the version
+            # instead of assuming it is 7 chars long
+            self.type = self.type[:-7]
+
+        # Lastly, parse the 'links' resource.
+        # Note that this may have different nested structure, depending on
+        # what type of resource this is, or what vendor it is.
+        # subclasses may follow this by parsing other resources / collections
+        self._parse_links(obj)
+
+    def _parse_links(self, obj):
+        """Map linked resources to getter functions
+
+        The root resource returns a dict of links to top-level resources
+        """
+        def getter(connection, href):
+            def _get():
+                return connection.rest_get(href, {})
+            return _get
+
+        for k in obj['links']:
+            ref = "get_" + k.lower()
+            self._links.append(ref)
+            href = obj['links'][k]['href']
+            setattr(self, ref, getter(self._conn, href))
+
+    def __repr__(self):
+        """Return this object's _attrs as a dict"""
+        res = {}
+        for a in self._attrs:
+            res[a] = getattr(self, a)
+        return res
+
+    def __str__(self):
+        """Return the string representation of this object's _attrs"""
+        return json.dumps(self.__repr__())
+
+
+class BaseCollection(Base):
+    """Base class for collection types"""
+    def __init__(self, obj, connection=None):
+        super(BaseCollection, self).__init__(obj, connection=connection)
+        self._parse_items(obj)
+        self._attrs.append('items')
+
+    def _parse_links(self, obj):
+        """links are special on a chassis; dont parse them"""
+        pass
+
+    def _parse_items(self, obj):
+        """Map linked items to getter methods
+
+        The chassis resource returns a list of items and corresponding
+        link data in a separate entity.
+        """
+        def getter(connection, href):
+            def _get():
+                return connection.rest_get(href, {})
+            return _get
+
+        self.items = []
+        self._item_getters = []
+
+        if 'links' in obj and 'Member' in obj['links']:
+            # NOTE: this assumes the lists are ordered the same
+            counter = 0
+            for item in obj['links']['Member']:
+                self.items.append(obj['Items'][counter])
+                self._item_getters.append(
+                        getter(self._conn, item['href']))
+                counter+=1
+        elif 'Items' in obj:
+            # TODO: find an example of this format and make sure it works
+            for item in obj['Items']:
+                if 'links' in item and 'self' in item['links']:
+                    href = item['links']['self']['href']
+                    self.items.append(item)
+
+        # TODO: implement paging support
+        # if 'links' in obj and 'NextPage' in obj['links']:
+        #    next_page = THIS_URI + '?page=' + str(obj['links']['NextPage']['page'])
+        #    do something with next_page URI
+
+    def __iter__(self):
+        for getter in self._item_getters:
+            yield getter()
+
+
+class Root(Base):
+    """Root '/' resource class"""
+    def _parse_links(self, obj):
+        """Map linked resources to getter functions
+
+        The root resource returns a dict of links to top-level resources
+
+        TODO: continue implementing customizations for top-level resources
+
+        """
+        mapping = {
+                'Systems': Systems,
+                'Chassis': Chassis,
+                'Managers': Base,
+                'Schemas': Base,
+                'Registries': Base,
+                'Tasks': Base,
+                'AccountService': Base,
+                'Sessions': Base,
+                'EventService': Base,
+                }
+
+        def getter(connection, href, type):
+            def _get():
+                return mapping[type](connection.rest_get(href, {}), self._conn)
+            return _get
+
+        for k in obj['links']:
+            ref = "get_" + k.lower()
+            self._links.append(ref)
+            href = obj['links'][k]['href']
+            setattr(self, ref, getter(self._conn, href, k))
+
+
+class Chassis(BaseCollection):
+    """Chassis resource class"""
+    def __len__(self):
+        return len(self.items)
+
+
+class Systems(Base):
+    pass
diff --git a/requirements.txt b/requirements.txt
index 95137a6..1cbb598 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,4 +3,5 @@
 # process, which may cause wedges in the gate later.
 
 pbr>=0.6,!=0.7,<1.0
+oslo.log>=1.0,<2.0
 Babel>=1.3