Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.

Commit c21a6e9

Browse files
committed
Implement method to create Cloudant client from VCAP services env
Add an additional method to parse a VCAP_SERVICES environment variable allowing easy binding of a service instance to a Cloud Foundry application. Fixes #254.
1 parent 57c67f4 commit c21a6e9

File tree

4 files changed

+307
-2
lines changed

4 files changed

+307
-2
lines changed

requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
requests >=2.7.0, <3.0.0
2-

src/cloudant/__init__.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import contextlib
2222
# pylint: disable=wrong-import-position
2323
from .client import Cloudant, CouchDB
24+
from ._common_util import CloudFoundryService
2425

2526
@contextlib.contextmanager
2627
def cloudant(user, passwd, **kwargs):
@@ -61,6 +62,58 @@ def cloudant(user, passwd, **kwargs):
6162
yield cloudant_session
6263
cloudant_session.disconnect()
6364

65+
@contextlib.contextmanager
66+
def cloudant_bluemix(bm_service_name=None, **kwargs):
67+
"""
68+
Provides a context manager to create a Cloudant session and provide access
69+
to databases, docs etc.
70+
71+
:param str bm_service_name: Optional Bluemix service instance name. Only
72+
required if multiple Cloudant services are available.
73+
:param str encoder: Optional json Encoder object used to encode
74+
documents for storage. Defaults to json.JSONEncoder.
75+
76+
Loads all configuration from the VCAP_SERVICES Cloud Foundry environment
77+
variable. The VCAP_SERVICES variable contains connection information to
78+
access a service instance. For example:
79+
80+
.. code-block:: json
81+
82+
{
83+
"VCAP_SERVICES": {
84+
"cloudantNoSQLDB": [
85+
{
86+
"credentials": {
87+
"username": "example",
88+
"password": "xxxxxxx",
89+
"host": "example.cloudant.com",
90+
"port": 443,
91+
"url": "https://example:[email protected]"
92+
},
93+
"syslog_drain_url": null,
94+
"label": "cloudantNoSQLDB",
95+
"provider": null,
96+
"plan": "Lite",
97+
"name": "Cloudant NoSQL DB"
98+
}
99+
]
100+
}
101+
}
102+
103+
See `Cloud Foundry Environment Variables <http://docs.cloudfoundry.org/
104+
devguide/deploy-apps/environment-variable.html#VCAP-SERVICES>`_.
105+
"""
106+
service = CloudFoundryService(bm_service_name)
107+
cloudant_session = Cloudant(
108+
username=service.username,
109+
password=service.password,
110+
url=service.url,
111+
**kwargs
112+
)
113+
cloudant_session.connect()
114+
yield cloudant_session
115+
cloudant_session.disconnect()
116+
64117
@contextlib.contextmanager
65118
def couchdb(user, passwd, **kwargs):
66119
"""

src/cloudant/_common_util.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@
1717
throughout the library.
1818
"""
1919

20+
import os
2021
import sys
2122
import platform
2223
from collections import Sequence
2324
import json
2425
from requests import Session
2526

2627
from ._2to3 import STRTYPE, NONETYPE, UNITYPE, iteritems_, url_parse
27-
from .error import CloudantArgumentError
28+
from .error import CloudantArgumentError, CloudantException
2829

2930
# Library Constants
3031

@@ -334,3 +335,69 @@ def request(self, method, url, **kwargs):
334335
resp = super(InfiniteSession, self).request(method, url, **kwargs)
335336

336337
return resp
338+
339+
340+
class CloudFoundryService(object):
341+
""" Manages Cloud Foundry service configuration. """
342+
343+
def __init__(self, name=None):
344+
try:
345+
services = json.loads(os.getenv('VCAP_SERVICES', '{}'))
346+
cloudant_services = services.get('cloudantNoSQLDB', [])
347+
348+
# use first service if no name given and only one service present
349+
use_first = name is None and len(cloudant_services) == 1
350+
for service in cloudant_services:
351+
if use_first or service.get('name') == name:
352+
credentials = service['credentials']
353+
self._host = credentials['host']
354+
self._name = service.get('name')
355+
self._password = credentials['password']
356+
self._port = credentials.get('port', 443)
357+
self._username = credentials['username']
358+
break
359+
else:
360+
raise CloudantException('Missing service in VCAP_SERVICES')
361+
362+
except KeyError as ex:
363+
raise CloudantException(
364+
"Invalid service: '{0}' missing".format(ex.args[0])
365+
)
366+
367+
except TypeError:
368+
raise CloudantException(
369+
'Failed to decode VCAP_SERVICES service credentials'
370+
)
371+
372+
except ValueError:
373+
raise CloudantException('Failed to decode VCAP_SERVICES JSON')
374+
375+
@property
376+
def host(self):
377+
""" Return service host. """
378+
return self._host
379+
380+
@property
381+
def name(self):
382+
""" Return service name. """
383+
return self._name
384+
385+
@property
386+
def password(self):
387+
""" Return service password. """
388+
return self._password
389+
390+
@property
391+
def port(self):
392+
""" Return service port. """
393+
return str(self._port)
394+
395+
@property
396+
def url(self):
397+
""" Return service url. """
398+
return 'https://{0}:{1}'.format(self._host, self._port)
399+
400+
@property
401+
def username(self):
402+
""" Return service username. """
403+
return self._username

tests/unit/cloud_foundry_tests.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env python
2+
# Copyright (c) 2016 IBM. All rights reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""
16+
_cloud_foundry_tests_
17+
18+
Unit tests for the CloudFoundryService class.
19+
"""
20+
21+
import json
22+
import mock
23+
import unittest
24+
25+
from cloudant._common_util import CloudFoundryService
26+
from cloudant.error import CloudantException
27+
28+
29+
class CloudFoundryServiceTests(unittest.TestCase):
30+
31+
def __init__(self, *args, **kwargs):
32+
super(CloudFoundryServiceTests, self).__init__(*args, **kwargs)
33+
self._test_vcap_services_single = json.dumps({'cloudantNoSQLDB': [
34+
{
35+
'name': 'Cloudant NoSQL DB 1', # valid service
36+
'credentials': {
37+
'host': 'example.cloudant.com',
38+
'password': 'pa$$w0rd01',
39+
'port': 1234,
40+
'username': 'example'
41+
}
42+
}
43+
]})
44+
self._test_vcap_services_multiple = json.dumps({'cloudantNoSQLDB': [
45+
{
46+
'name': 'Cloudant NoSQL DB 1', # valid service
47+
'credentials': {
48+
'host': 'example.cloudant.com',
49+
'password': 'pa$$w0rd01',
50+
'port': 1234,
51+
'username': 'example'
52+
}
53+
},
54+
{
55+
'name': 'Cloudant NoSQL DB 2', # valid service, default port
56+
'credentials': {
57+
'host': 'example.cloudant.com',
58+
'password': 'pa$$w0rd01',
59+
'username': 'example'
60+
}
61+
},
62+
{
63+
'name': 'Cloudant NoSQL DB 3', # missing host
64+
'credentials': {
65+
'password': 'pa$$w0rd01',
66+
'port': 1234,
67+
'username': 'example'
68+
}
69+
},
70+
{
71+
'name': 'Cloudant NoSQL DB 4', # missing password
72+
'credentials': {
73+
'host': 'example.cloudant.com',
74+
'port': 1234,
75+
'username': 'example'
76+
}
77+
},
78+
{
79+
'name': 'Cloudant NoSQL DB 5', # missing username
80+
'credentials': {
81+
'host': 'example.cloudant.com',
82+
'password': 'pa$$w0rd01',
83+
'port': 1234,
84+
}
85+
},
86+
{
87+
'name': 'Cloudant NoSQL DB 6', # invalid credentials type
88+
'credentials': [
89+
'example.cloudant.com',
90+
'pa$$w0rd01',
91+
'example'
92+
]
93+
}
94+
]})
95+
96+
@mock.patch('os.getenv')
97+
def test_get_vcap_service_default_success(self, m_getenv):
98+
m_getenv.return_value = self._test_vcap_services_single
99+
service = CloudFoundryService()
100+
self.assertEqual('Cloudant NoSQL DB 1', service.name)
101+
102+
@mock.patch('os.getenv')
103+
def test_get_vcap_service_default_failure_multiple_services(self, m_getenv):
104+
m_getenv.return_value = self._test_vcap_services_multiple
105+
with self.assertRaises(CloudantException) as cm:
106+
CloudFoundryService()
107+
self.assertEqual('Missing service in VCAP_SERVICES', str(cm.exception))
108+
109+
@mock.patch('os.getenv')
110+
def test_get_vcap_service_instance_host(self, m_getenv):
111+
m_getenv.return_value = self._test_vcap_services_multiple
112+
service = CloudFoundryService('Cloudant NoSQL DB 1')
113+
self.assertEqual('example.cloudant.com', service.host)
114+
115+
@mock.patch('os.getenv')
116+
def test_get_vcap_service_instance_password(self, m_getenv):
117+
m_getenv.return_value = self._test_vcap_services_multiple
118+
service = CloudFoundryService('Cloudant NoSQL DB 1')
119+
self.assertEqual('pa$$w0rd01', service.password)
120+
121+
@mock.patch('os.getenv')
122+
def test_get_vcap_service_instance_port(self, m_getenv):
123+
m_getenv.return_value = self._test_vcap_services_multiple
124+
service = CloudFoundryService('Cloudant NoSQL DB 1')
125+
self.assertEqual('1234', service.port)
126+
127+
@mock.patch('os.getenv')
128+
def test_get_vcap_service_instance_port_default(self, m_getenv):
129+
m_getenv.return_value = self._test_vcap_services_multiple
130+
service = CloudFoundryService('Cloudant NoSQL DB 2')
131+
self.assertEqual('443', service.port)
132+
133+
@mock.patch('os.getenv')
134+
def test_get_vcap_service_instance_url(self, m_getenv):
135+
m_getenv.return_value = self._test_vcap_services_multiple
136+
service = CloudFoundryService('Cloudant NoSQL DB 1')
137+
self.assertEqual('https://example.cloudant.com:1234', service.url)
138+
139+
@mock.patch('os.getenv')
140+
def test_get_vcap_service_instance_username(self, m_getenv):
141+
m_getenv.return_value = self._test_vcap_services_multiple
142+
service = CloudFoundryService('Cloudant NoSQL DB 1')
143+
self.assertEqual('example', service.username)
144+
145+
@mock.patch('os.getenv')
146+
def test_raise_error_for_missing_host(self, m_getenv):
147+
m_getenv.return_value = self._test_vcap_services_multiple
148+
with self.assertRaises(CloudantException):
149+
CloudFoundryService('Cloudant NoSQL DB 3')
150+
151+
@mock.patch('os.getenv')
152+
def test_raise_error_for_missing_password(self, m_getenv):
153+
m_getenv.return_value = self._test_vcap_services_multiple
154+
with self.assertRaises(CloudantException) as cm:
155+
CloudFoundryService('Cloudant NoSQL DB 4')
156+
self.assertEqual(
157+
"Invalid service: 'password' missing",
158+
str(cm.exception)
159+
)
160+
161+
@mock.patch('os.getenv')
162+
def test_raise_error_for_missing_username(self, m_getenv):
163+
m_getenv.return_value = self._test_vcap_services_multiple
164+
with self.assertRaises(CloudantException) as cm:
165+
CloudFoundryService('Cloudant NoSQL DB 5')
166+
self.assertEqual(
167+
"Invalid service: 'username' missing",
168+
str(cm.exception)
169+
)
170+
171+
@mock.patch('os.getenv')
172+
def test_raise_error_for_invalid_credentials_type(self, m_getenv):
173+
m_getenv.return_value = self._test_vcap_services_multiple
174+
with self.assertRaises(CloudantException) as cm:
175+
CloudFoundryService('Cloudant NoSQL DB 6')
176+
self.assertEqual(
177+
'Failed to decode VCAP_SERVICES service credentials',
178+
str(cm.exception)
179+
)
180+
181+
@mock.patch('os.getenv')
182+
def test_raise_error_for_missing_service(self, m_getenv):
183+
m_getenv.return_value = self._test_vcap_services_multiple
184+
with self.assertRaises(CloudantException) as cm:
185+
CloudFoundryService('Cloudant NoSQL DB 7')
186+
self.assertEqual('Missing service in VCAP_SERVICES', str(cm.exception))

0 commit comments

Comments
 (0)