From f8fc37dddb6b6ef10a82b74626261bb91082a273 Mon Sep 17 00:00:00 2001 From: Robert Marks Date: Thu, 4 Apr 2019 20:53:47 +0000 Subject: [PATCH] Add initial Python examples --- .gitignore | 2 + README.md | 3 + python-examples/README.md | 16 ++ python-examples/basic/messagepack.py | 41 ++++ python-examples/basic/simple-rest-usage.py | 117 +++++++++++ python-examples/demo-app/README.md | 5 + .../demo-app/asrestclient/__init__.py | 0 .../demo-app/asrestclient/constants.py | 16 ++ .../asrestclient/restclientconnector.py | 188 ++++++++++++++++++ python-examples/demo-app/asrestclient/user.py | 21 ++ .../demo-app/asrestclient/user_connector.py | 125 ++++++++++++ python-examples/demo-app/rc_users_demo.py | 44 ++++ 12 files changed, 578 insertions(+) create mode 100644 .gitignore create mode 100644 python-examples/README.md create mode 100644 python-examples/basic/messagepack.py create mode 100644 python-examples/basic/simple-rest-usage.py create mode 100644 python-examples/demo-app/README.md create mode 100644 python-examples/demo-app/asrestclient/__init__.py create mode 100644 python-examples/demo-app/asrestclient/constants.py create mode 100644 python-examples/demo-app/asrestclient/restclientconnector.py create mode 100644 python-examples/demo-app/asrestclient/user.py create mode 100644 python-examples/demo-app/asrestclient/user_connector.py create mode 100644 python-examples/demo-app/rc_users_demo.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a295864 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__ diff --git a/README.md b/README.md index 3c5b506..edf2480 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ # rest-client-examples + Example Usage of Aerospike REST Client + +This repository Contains sample code utilizing the [Aerospike Rest Client](https://www.aerospike.com/docs/client/rest/index.html) . \ No newline at end of file diff --git a/python-examples/README.md b/python-examples/README.md new file mode 100644 index 0000000..1e05521 --- /dev/null +++ b/python-examples/README.md @@ -0,0 +1,16 @@ +# Python Examples + +These examples utilize the Requests library to + +## Prerequisites + +* Install the Aerospike Rest Client +* Install requests: `pip install requests` + +## The Examples + +The files contained in the `basic/` folder are standalone single file demo applications demonstrating basic usage of some of the REST Client features. + +The code in `demo-app/` presents a simplified example of building a more complex application around the functionality of the REST Client. + +The examples assume that the REST Client is listening on `localhost:8080` if it is running elsewhere, the code snippets will need to be modified. \ No newline at end of file diff --git a/python-examples/basic/messagepack.py b/python-examples/basic/messagepack.py new file mode 100644 index 0000000..944f3d1 --- /dev/null +++ b/python-examples/basic/messagepack.py @@ -0,0 +1,41 @@ +import requests +import msgpack + + +def pack(obj): + ''' + msgpack an object using the binary type + ''' + return msgpack.packb(obj, use_bin_type=True) + + +def unpack(obj): + ''' + Unpack object turning message pack strings into unicode + ''' + return msgpack.unpackb(obj, encoding='UTF-8') + +request_uri = 'http://localhost:8080/v1/kvs/test/demo/mp' + +pack_request_header = {'Content-Type': "application/msgpack"} +pack_response_header = {'Accept': "application/msgpack"} + +# Python 3 +# For Python 27 specify 'ba' as bytearray('1234', 'UTF-8') +bins = {'ba': b'1234', 'a': {1: 2, 3: 4}} + +# Store the packed content +requests.post(request_uri, pack(bins), headers=pack_request_header) + +# The request without specifying msgpack return format. +# The map keys are converted to strings, and the bytte array is urlsafe base64 encoded. +print("content with Accept: application/json") +print(requests.get(request_uri).json()) +# {'ttl': 2591545, 'bins': {'ba': 'MTIzNA==', 'a': {'3': 4, '1': 2}}, 'generation': 1} + + +response = requests.get(request_uri, headers=pack_response_header) +print("Content with Accept: application/msgpack") +print(unpack(response.content)) + +# {'ttl': 2591958, 'bins': {'ba': b'1234', 'a': {1: 2, 3: 4}}, 'generation': 1} diff --git a/python-examples/basic/simple-rest-usage.py b/python-examples/basic/simple-rest-usage.py new file mode 100644 index 0000000..4db5246 --- /dev/null +++ b/python-examples/basic/simple-rest-usage.py @@ -0,0 +1,117 @@ +import requests + +REST_BASE = 'http://localhost:8080/v1' +KVS_ENDPOINT = REST_BASE + '/kvs' +OPERATE_ENDPOINT = REST_BASE + '/operate' + +# Components of the Key +namespace = 'test' +setname = 'users' +userkey = 'bob' + +record_uri = '{base}/{ns}/{setname}/{userkey}'.format( + base=KVS_ENDPOINT, ns=namespace, setname=setname, userkey=userkey) + +# The content to be stored into Aerospike. +bins = { + 'name': 'Bob', + 'id': 123, + 'color': 'Purple', + 'languages': ['Python', 'Java', 'C'], +} + + +# Store the record +res = requests.post(record_uri, json=bins) + +# Get the record +# It is a map: { +# 'bins': {}, +# 'generation': #, +# 'ttl': # +# } +response = requests.get(record_uri) +print("*** The Original Record ***") +print(response.json()) + +# Change the value of the 'color' bin +update_bins = {'color': 'Orange'} +requests.patch(record_uri, json=update_bins) + +# Get the updated Record. Only the 'color' bin has changed +response = requests.get(record_uri) +print("*** The updated Record ***") +print(response.json()) + +# Replace the record with a new version +replacement_bins = {'single': 'bin'} +requests.put(record_uri, json=replacement_bins) + +# Get the new Record. +response = requests.get(record_uri) +print("*** The Replaced Record ***") +print(response.json()) + +# Delete the record. +response = requests.delete(record_uri) + +# Try to get the deleted . We will receive a 404. +response = requests.get(record_uri) + +print('*** The response code for a GET on a non existent record is {} ***'.format(response.status_code)) + +# The response also includes a JSON error object +print("*** The Error object is: ***") +print(response.json()) + +# The rest client also supports more complicated operations. +# For example, suppose that we are storing information about users. +# Initially we store their name, and the length of their name. +# Our first user is 'Bob' +base_record = {'name': 'Bob', 'name_length': 3} +requests.post(record_uri, json=base_record) + +# Suppose we want to append some characters to the name +# Now we want to add to the name of our user, keep the name length field accurate, +# and add a new bin containing the company for which the user works. + +# We can specify the operations. As an array of JSON objects. +# These operations change the user's name to 'Bob Roberts', update the length +# Of the name accordingly, and indicate that he works for Aerospike. +operations = [ + { + 'operation': 'APPEND', + 'opValues': { + 'bin': 'name', + 'value': ' Roberts' + } + }, + { + 'operation': 'ADD', + 'opValues': { + 'bin': 'name_length', + 'incr': len(' Roberts') + } + }, + { + 'operation': 'PUT', + 'opValues': { + 'bin': 'company', + 'value': 'Aerospike' + } + } +] + +operate_record_uri = '{base}/{ns}/{setname}/{userkey}'.format( + base=OPERATE_ENDPOINT, ns=namespace, setname=setname, userkey=userkey) + +# Perform the operations on the record. +requests.post(operate_record_uri, json=operations) + +# Fetch the updated record. +response = requests.get(record_uri) +print("*** The Record after applying operations ***") + +print(response.json()) + +# For a list of operations, and example usage see link diff --git a/python-examples/demo-app/README.md b/python-examples/demo-app/README.md new file mode 100644 index 0000000..62d8e99 --- /dev/null +++ b/python-examples/demo-app/README.md @@ -0,0 +1,5 @@ +# Demo application + +## Usage + +Running `python rc_users_demo.py` will demonstrate the usage of the demo app. \ No newline at end of file diff --git a/python-examples/demo-app/asrestclient/__init__.py b/python-examples/demo-app/asrestclient/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-examples/demo-app/asrestclient/constants.py b/python-examples/demo-app/asrestclient/constants.py new file mode 100644 index 0000000..e2a5769 --- /dev/null +++ b/python-examples/demo-app/asrestclient/constants.py @@ -0,0 +1,16 @@ +''' +Simple file containing Aerospike Rest Client constants +''' + +INTEGER_KEYTYPE = 'INTEGER' +BYTES_KEYTYPE = 'BYTES' +DIGEST_KEYTYPE = 'DIGEST' + +CREATE_ONLY = 'CREATE_ONLY' +UPDATE_ONLY = 'UPDATE_ONLY' + +OPERATION_NAME = 'operation' +OPERATION_VALUES = 'opValues' + +LIST_APPEND_OP = 'LIST_APPEND' +READ_OP = 'READ' \ No newline at end of file diff --git a/python-examples/demo-app/asrestclient/restclientconnector.py b/python-examples/demo-app/asrestclient/restclientconnector.py new file mode 100644 index 0000000..f3b9778 --- /dev/null +++ b/python-examples/demo-app/asrestclient/restclientconnector.py @@ -0,0 +1,188 @@ +import requests +import base64 + +class RestClientAPIError(Exception): + pass + + +class RecordNotFoundError(RestClientAPIError): + pass + + +class RecordExistsError(RestClientAPIError): + pass + + +class ASRestClientConnector(object): + ''' + Class used to talk to Aerospike Rest Client + ''' + + def __init__(self, base_uri='http://localhost:8080'): + '''constructor + The constructor builds a base endpoint for RestClient operations of the form: + base_uri/v1 + Args: + base_uri (str) optional: The address on which the Rest client is listening. + Default: `'http://localhost:8080'` + ''' + + # Build the base rest endpoint: base_uri/v1 + base_uri = base_uri + '/' if base_uri[-1] != '/' else base_uri + self.rest_endpoint = base_uri + 'v1' + self.kvs_endpoint = self.rest_endpoint + '/kvs' + self.operate_endpoint = self.rest_endpoint + '/operate' + + def get_record(self, namespace, setname, userkey, **query_params): + '''Retrieve a map representation of a record stored in aerospike + + Args: + namespace (str): The namespace for the record. + setname (str, int): The setname for the record. + userkey (str) optional: The userkey of the record. + query_params (Map[str:str]) optional: A Map of query params. + Returns: + dict: A dictionary containing entries for 'bins', 'generation' and 'ttl' + example: {'bins': {'a': 1, 'b': 'c'}, 'generation': 2, 'ttl': 1234} + Raises: + RecordNotFoundError: If the specified record does not exist. + RestClientAPIError: If an error is encountered communicating with the Endpoint. + ''' + record_uri = self._get_record_uri(self.kvs_endpoint, namespace, setname, userkey) + response = requests.get(record_uri, params=query_params) + + if response.ok: + return response.json() + + self.raise_from_response(response, msg='Get record failed: ') + + def create_record(self, namespace, setname, userkey, bins, **query_params): + '''Store a new record in the Aerospike database. + + Args: + namespace (str): The namespace for the record. + setname (str, int): The setname for the record. + userkey (str) optional: The userkey of the record. + bins (dict[str:any]): A dictionary containing the bins to store in the record + query_params (Map[str:str]) optional: A Map of query params. + Raises: + RecordExistsError: If the specified record already exists. + RestClientAPIError: If an error is encountered communicating with the Endpoint. + ''' + record_uri = self._get_record_uri(self.kvs_endpoint, namespace, setname, userkey) + response = requests.post(record_uri, json=bins, params=query_params) + + if response.ok: + return + + self.raise_from_response(response, msg='Create record failed: ') + + def update_record(self, namespace, setname, userkey, bins, **query_params): + '''Update an existing record + Args: + namespace (str): The namespace for the record. + setname (str, int): The setname for the record. + userkey (str) optional: The userkey of the record. + bins (dict[str:any]): A dictionary containing the bins to update in the record. + These may also contain bins which do not yet exist. + query_params (Map[str:str]) optional: A Map of query params. + Raises: + RecordNotFoundError: If the specified record does not exist. + RestClientAPIError: If an error is encountered communicating with the Endpoint. + ''' + record_uri = self._get_record_uri(self.kvs_endpoint, namespace, setname, userkey) + response = requests.patch(record_uri, json=bins, params=query_params) + + if response.ok: + return + + self.raise_from_response(response, msg='Update record failed: ') + + def replace_record(self, namespace, setname, userkey, bins, **query_params): + '''Replace an existing record in the Aerospike database. + + Args: + namespace (str): The namespace for the record. + setname (str, int): The setname for the record. + userkey (str) optional: The userkey of the record. + bins (dict[str:any]): A dictionary containing the bins to store in the record + query_params (Map[str:str]) optional: A Map of query params. + Raises: + RecordNotFoundError: If the specified record does not yet exist. + RestClientAPIError: If an error is encountered communicating with the Endpoint. + ''' + record_uri = self._get_record_uri(self.kvs_endpoint, namespace, setname, userkey) + response = requests.put(record_uri, json=bins, params=query_params) + + if response.ok: + return + + self.raise_from_response(response, msg='Replace record failed: ') + + def delete_record(self, namespace, setname, userkey, **query_params): + '''Delete a record from the Aerospike database + + Args: + namespace (str): The namespace for the record. + setname (str, int): The setname for the record. + userkey (str) optional: The userkey of the record. + query_params (Map[str:str]) optional: A Map of query params. + Raises: + RecordNotFoundError: If the specified record does not exist. + RestClientAPIError: If an error is encountered communicating with the Endpoint. + ''' + record_uri = self._get_record_uri(self.kvs_endpoint, namespace, setname, userkey) + response = requests.delete(record_uri, params=query_params) + + if response.ok: + return + + self.raise_from_response(response, msg='Delete record failed: ') + + def operate_record(self, namespace, setname, userkey, operations, **query_params): + '''Perform a series of operations on the specified record. + + Args: + namespace (str): The namespace for the record. + setname (str, int): The setname for the record. + userkey (str) optional: The userkey of the record. + operations (list[dict[str:any]]): A list of operation dicts. + query_params (Map[str:str]) optional: A Map of query params. + Example: + ops = [{'operation': 'READ', 'opValues': {'bin': 'b1'}}] + returned_rec = client.operate_record('test', 'demo', '1' ops) + Returns: + dict: A dictionary containing entries for 'bins', 'generation' and 'ttl' + example: {'bins': {'b1': 12345}, 'generation': 2, 'ttl': 1234} + Raises: + RestClientAPIError: If an error is encountered when performing the operations + ''' + operate_uri = self._get_record_uri(self.operate_endpoint, namespace, setname, userkey) + response = requests.post(operate_uri, json=operations, params=query_params) + + if response.ok: + return response.json() + + self.raise_from_response(response, msg='Operate on record failed: ') + + @staticmethod + def _get_record_uri(endpoint, namespace, setname, userkey): + + return '{endpoint}/{ns}/{setname}/{key}'.format( + endpoint=endpoint, ns=namespace, setname=setname, key=str(userkey) + ) + + @staticmethod + def raise_from_response(response, msg=''): + + if response.status_code == 404: + raise RecordNotFound(msg + response.text) + + if response.status_code == 409: + raise RecordExistsError(response.text) + + raise RestClientAPIError(msg + response.text) + + @staticmethod + def encode_bytes_key(bytes_key): + return base64.urlsafe_b64encode(bytes_key) diff --git a/python-examples/demo-app/asrestclient/user.py b/python-examples/demo-app/asrestclient/user.py new file mode 100644 index 0000000..c9ee399 --- /dev/null +++ b/python-examples/demo-app/asrestclient/user.py @@ -0,0 +1,21 @@ + +class User(object): + ''' + A basic class representing a user + ''' + def __init__(self, id, name, email, interests=None): + + interests = [] if not interests else interests + + self.id = id + self.name = name + self.email = email + self.interests = interests + + def __repr__(self): + return 'User({id}, {name}, {email}, {interests})'.format( + id=repr(self.id), + name=repr(self.name), + email=repr(self.email), + interests=('[' + ', '.join([repr(interest) for interest in self.interests]) + ']') + ) diff --git a/python-examples/demo-app/asrestclient/user_connector.py b/python-examples/demo-app/asrestclient/user_connector.py new file mode 100644 index 0000000..b8e4003 --- /dev/null +++ b/python-examples/demo-app/asrestclient/user_connector.py @@ -0,0 +1,125 @@ +from . import restclientconnector +from . import user +from . import constants + +class UserConnector(object): + + ''' + User Connector class. Provides an interface to store + User objects into the aerospike database + ''' + + def __init__(self, client, namespace, setname): + '''constructor + + Args: + namespace (String): The Aerospike Namespace to be used to store users. + setname (String): The Aerospike Set to be used to store users + client (ASRestClientConnector): A connector instance which will be utilized to perform + REST operations. + ''' + self.namespace = namespace + self.setname = setname + self.client = client + + def create_user(self, user, errror_if_exists=True): + ''' + Description: + Store a user into the aerospike database. It will not update an existing user. + Args: + user (User): A user object to be stored into the Aerospike database. The `user.id` + field will be cast to a string before being used as the key. + error_if_exists (bool): A flag indicating whether an exception should be raised if + a user with a matching id already exists in the Database. Default: `True` + + Raises: + RestClientAPIError: If an error occurs when speaking to the API. + ''' + + userkey = user.id + bins = { + 'id': user.id, + 'name': user.name, + 'email': user.email, + 'interests': user.interests, + } + + try: + self.client.create_record(self.namespace, self.setname, userkey, bins) + except restclientconnector.RecordExistsError as ree: + if errror_if_exists: + raise ree + + def get_user(self, user_id): + ''' + Description + Retrieves a User instance populated with information stored in the Aerospike Database. If + a user is not found None will be returned. + Args: + user_id: A unique id for a user. It will be converted to a String before being used to look up + a user. + Returns: + User, None: Returns a new User instance if the user is found in Aerospike, else None + + Raises: + RestClientAPIError: If an error occurs when speaking to the API. + ''' + + try: + user_details = self.client.get_record(self.namespace, self.setname, user_id)['bins'] + return user.User( + user_details['id'], user_details['name'], + user_details['name'], user_details['interests']) + except restclientconnector.RecordNotFoundError as ree: + return None + + def add_interest(self, user_id, interest): + ''' + Description + Adds an interest to the list of interests for a User stored in the database. This will not create a + new user. + Args: + user_id: A unique id for a user. It will be converted to a String before being used to look up + a user. + interest (string): An interest to append to the list of interestss for the user + Returns: + list[string]: The updated list of interests for the user. + + Raises: + RestClientAPIError: If an error occurs when speaking to the API. + ''' + add_interest_ops = self._add_interest_and_retrieve_ops(interest) + + # Add update only to prevent creation of a new user + response = self.client.operate_record( + self.namespace, self.setname, user_id, + add_interest_ops, recordExistsAction=constants.UPDATE_ONLY) + new_interests = response['bins']['interests'] + # The response contains one entry for the length of interests, the second is the new list of interests + return new_interests[1] + + @staticmethod + def _add_interest_and_retrieve_ops(interest): + '''Build a list of operations to add an interest to a user's list of interests in the Aerospike Database + + Args: + interest (str): The interest to add to the users list + Returns: + list[map] : A list of operations to be passed to the ASRestClientConnector.operate_record method + ''' + ops = [ + { + constants.OPERATION_NAME: constants.LIST_APPEND_OP, + constants.OPERATION_VALUES: { + 'bin': 'interests', + 'value': interest + } + }, + { + constants.OPERATION_NAME: constants.READ_OP, + constants.OPERATION_VALUES: { + 'bin': 'interests' + } + } + ] + return ops diff --git a/python-examples/demo-app/rc_users_demo.py b/python-examples/demo-app/rc_users_demo.py new file mode 100644 index 0000000..4d17b7e --- /dev/null +++ b/python-examples/demo-app/rc_users_demo.py @@ -0,0 +1,44 @@ +import sys + +from asrestclient.restclientconnector import ASRestClientConnector as ASRC +from asrestclient.restclientconnector import RecordExistsError +from asrestclient.user_connector import UserConnector +from asrestclient.user import User + +def main(interest='aerospike'): + # Our Aerospike RestClient is running at "http://localhost:8080" + user_connector = UserConnector(ASRC('http://localhost:8080'), 'test', 'users') + + user1 = User('123456', 'Bob Roberts', 'Bob@NotAValid.com.email.com', ['cooking', 'gardening', 'sewing']) + user2 = User('6545321', 'Alice Allison', 'Alice@NotAValid.com.email.com', ['programming', 'gardening', 'mathematics']) + + try: + user_connector.create_user(user1) + # If the user already existed, just ignore it + except RecordExistsError as ree: + pass + + try: + user_connector.create_user(user2) + # If the user already existed, just ignore it + except RecordExistsError as ree: + pass + + retrieved_user1 = user_connector.get_user(user1.id) + print("***The first user retrieved from the database is***") + print(retrieved_user1) + + retrieved_user2 = user_connector.get_user(user2.id) + print("\n***The second user retrieved from the database is***") + print(retrieved_user2) + + new_interests = user_connector.add_interest(user1.id, interest) + print("\n***Updated interests are:***") + print(new_interests) + + retrieved_user1 = user_connector.get_user(user1.id) + print("\n***The first user retrieved from the database is***") + print(retrieved_user1) + +if __name__ == '__main__': + main()