Java Connector to SugarCRM Webservices

This would a small post will less explanation and more of code snippets (Too  busy to write description and code has proper comments).

Here is my effort to minimize research work in getting started with sugar CRM webservice invocation in java

Original code was taken from https:/ /github.com /amusarra/SugarCRMJavaSOAPClient  and added more functions to it

/**
 *
 */
package com.northalley.sugarcrm;

import java.math.BigInteger;
import java.net.MalformedURLException;
import java.net.URL;
import java.rmi.RemoteException;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map.Entry;

import javax.xml.rpc.Service;
import javax.xml.rpc.ServiceException;
import javax.xml.rpc.ServiceFactory;

import org.apache.axis.AxisFault;

import com.sugarcrm.www.sugarcrm.Entry_value;
import com.sugarcrm.www.sugarcrm.Field;
import com.sugarcrm.www.sugarcrm.Get_entry_list_result_version2;
import com.sugarcrm.www.sugarcrm.Get_entry_result_version2;
import com.sugarcrm.www.sugarcrm.Link_field;
import com.sugarcrm.www.sugarcrm.Link_name_to_fields_array;
import com.sugarcrm.www.sugarcrm.Module_list;
import com.sugarcrm.www.sugarcrm.Name_value;
import com.sugarcrm.www.sugarcrm.New_module_fields;
import com.sugarcrm.www.sugarcrm.New_set_entry_result;
import com.sugarcrm.www.sugarcrm.SugarsoapBindingStub;
import com.sugarcrm.www.sugarcrm.SugarsoapLocator;
import com.sugarcrm.www.sugarcrm.User_auth;

/**
 * This implements basic methods which are used commonly
 *
 * @author ashwin kumar
 */
public class SugarCRMSoapDemoClient {
	private static final String END_POINT_URL = "http://localhost/SugarCE/service/v2/soap.php?wsdl";
	private static final String USER_NAME = "admin";
	private static final String USER_PASSWORD = "sugarcrm";
	private static final String APPLICATION_NAME = Class.class.getName();
	private static final Integer TIMEOUT = 6000;

	/**
	 * Main Program
	 *
	 * @param args
	 */
	public static void main(String[] args) throws Exception {
		String sessionID = null;

		try {
			// Create a URL end point for the client
			URL wsdlUrl = null;
			if (END_POINT_URL.isEmpty()) {
				wsdlUrl = new URL(new SugarsoapLocator()
						.getsugarsoapPortAddress()
						+ "?wsdl");
			} else {
				wsdlUrl = new URL(END_POINT_URL);
			}

			System.out.println("URL endpoint created successfully!");

			// Create Service for test configuration
			ServiceFactory serviceFactory = ServiceFactory.newInstance();
			Service service = serviceFactory.createService(wsdlUrl,
					new SugarsoapLocator().getServiceName());

			System.out.println("Service created successfully");
			System.out.println("Service Name:"
					+ service.getServiceName().toString());
			System.out.println("Service WSDL:"
					+ service.getWSDLDocumentLocation().toString());

			// Trying to create a stub
			SugarsoapBindingStub binding = new SugarsoapBindingStub(wsdlUrl,
					service);
			binding.setTimeout(TIMEOUT);
			System.out.println("Stub created successfully!");

			/**
			 * Try to login on SugarCRM
			 *
			 * 1) Prepare a MD5 hash password 2) Prepare a User Auth object 3)
			 * Execute login
			 */

			// 1. Prepare a MD5 hash password
			MessageDigest messageDiget = MessageDigest.getInstance("MD5");
			messageDiget.update(USER_PASSWORD.getBytes());

			// 2. Prepare a User Auth object
			User_auth userAuthInfo = new User_auth();
			userAuthInfo.setUser_name(USER_NAME);
			userAuthInfo.setPassword((new BigInteger(1, messageDiget.digest()))
					.toString(16));

			try {
				// 3. Execute login
				Entry_value loginResult = binding.login(userAuthInfo,
						APPLICATION_NAME, null);
				System.out.println("Login Successfully for " + USER_NAME);
				System.out.println("Your session Id: " + loginResult.getId());
				sessionID = loginResult.getId();
			} catch (RemoteException ex) {
				System.out.println("Login failed. Message: " + ex.getMessage());
				ex.printStackTrace();
			}
			// binding.
			// binding.get_available_modules(session);
			// retreiveEntriesByModule(sessionID, binding, "Accounts",new
			// String[]{"name","description"},0,10);
			retreiveEntriesByModule(sessionID, binding, "Leads", new String[] {
					"name", "description", "account_name", "campaign_name",
					"salutation", "first_name", "last_name", "full_name",
					"title", "department", "email1", "email2" }, 0, 10);
			retreiveEntriesByModule(sessionID, binding, "Campaigns",
					new String[] { "name", "description", "assigned_user_name",
							"status", "campaign_type", "expected_cost" }, 0, 10);
			// retreiveModuleFields(sessionID, binding);
			// retreiveModules(sessionID, binding);
			// createAndRetreiveContact(sessionID, binding);

			/**
			 * Logout
			 */
			try {
				binding.logout(sessionID);
				System.out.println("Logout Successfully for " + USER_NAME);
				sessionID = null;
			} catch (RemoteException ex) {
				System.out.println("Login failed. Message: " + ex.getMessage());
				ex.printStackTrace();
			}

		} catch (MalformedURLException ex) {
			System.out.println("URL endpoing creation failed. Message: "
					+ ex.getMessage());
			ex.printStackTrace();
		} catch (ServiceException ex) {
			System.out.println("Service creation failed. Message: "
					+ ex.getMessage());
			ex.printStackTrace();
		} catch (AxisFault ex) {
			System.out.println("AxisFault. Message: " + ex.getMessage());
			ex.printStackTrace();
		}
	}

	/**
	 * Sample to show how to do creation operation using webservice
	 *
	 * @param sessionID
	 * @param binding
	 */
	private static void createAndRetreiveContact(String sessionID,
			SugarsoapBindingStub binding) {
		/**
		 * Create a new Contact
		 *
		 * 1) Setting a new entry 2) Setting up parameters for set_entry call 3)
		 * Creating a name value list array from a hash map. This is not
		 * necessary just more elegant way to initialize and add name values to
		 * an array
		 */
		HashMap<String, String> nameValueMap = new HashMap<String, String>();
		nameValueMap.put("first_name", "Suresh");
		nameValueMap.put("last_name", "Paladugu");
		nameValueMap.put("title", "IT Senior Consultant");
		nameValueMap.put("description", "Test Client SOAP Java");
		nameValueMap.put("email1", "suresh.paladugu@gmail.com");

		// Creating a new Name_value array and adding each map entry as 'name'
		// and 'value'
		Name_value nameValueListSetEntry[] = new Name_value[nameValueMap.size()];
		int i = 0;
		for (Entry<String, String> entry : nameValueMap.entrySet()) {
			Name_value nameValue = new Name_value();
			nameValue.setName(entry.getKey());
			nameValue.setValue(entry.getValue());
			nameValueListSetEntry[i] = nameValue;
			i++;
		}

		// Trying to set a new entry
		New_set_entry_result setEntryResponse = null;
		try {
			setEntryResponse = binding.set_entry(sessionID, "Contacts",
					nameValueListSetEntry);
		} catch (RemoteException e) {
			System.out.println("Set entry failed. Message: " + e.getMessage());
			e.printStackTrace();
		}
		System.out.println("Set entry was successful! Contacts Id: "
				+ setEntryResponse.getId());

		/**
		 * Getting an Contacts Entry (the one we just set)
		 */
		Link_name_to_fields_array[] link_name_to_fields_array = null;
		String[] select_fields = null;

		Get_entry_result_version2 getEntryResponse = null;

		// Trying to get entry
		try {
			getEntryResponse = binding.get_entry(sessionID, "Contacts",
					setEntryResponse.getId(), select_fields,
					link_name_to_fields_array);
		} catch (RemoteException e) {
			System.out.println("Get entry failed. Message: " + e.getMessage());
			e.printStackTrace();
		}
		System.out.println("Get entry was successful! Response: ");

		// Getting the fields for entry we got.
		Entry_value[] entryList = getEntryResponse.getEntry_list();
		for (int k = 0; k < entryList.length; k++) {
			Entry_value entry = entryList[k];
			Name_value[] entryNameValueList = entry.getName_value_list();
			for (int j = 0; j < entryNameValueList.length; j++) {
				Name_value entryNameValue = entryNameValueList[j];
				// Outputting only non empty fields
				if (!entryNameValue.getValue().isEmpty()) {
					System.out.println("Attribute Name: '"
							+ entryNameValue.getName() + "' Attribute Value: '"
							+ entryNameValue.getValue() + "'");
				}
			}
		}
	}

	/**
	 *
	 * TO get list of values /rows for a given modules based on selection
	 * criteria
	 *
	 * @param sessionID
	 * @param binding
	 * @param moduleName
	 *            TODO
	 * @param select_fields
	 * @param l
	 * @param offset
	 */
	private static void retreiveEntriesByModule(String sessionID,
			SugarsoapBindingStub binding, String moduleName,
			String[] select_fields, int offset, int rowCount) {
		Link_name_to_fields_array[] link_name_to_fields_array = null;

		Get_entry_result_version2 getEntryResponse = null;
		Get_entry_list_result_version2 entryListResultVersion2 = null;

		// Trying to get entry
		try {
			/*
			 * getEntryResponse = binding.get_entry(sessionID,moduleName, null,
			 * select_fields, link_name_to_fields_array);
			 */
			entryListResultVersion2 = binding.get_entry_list(sessionID,
					moduleName, "", "", offset, select_fields,
					link_name_to_fields_array, rowCount, 0);
			// getEntryResponse = binding.get_entries(sessionID, moduleName,
			// null, new String[]{"name","description"},
			// link_name_to_fields_array);

		} catch (RemoteException e) {
			System.out.println("Get entry failed. Message: " + e.getMessage());
			e.printStackTrace();
		}
		System.out.println("Get entry was successful! Response: ");

		// Getting the fields for entry we got.
		Entry_value[] entryList = entryListResultVersion2.getEntry_list();
		for (int k = 0; k < entryList.length; k++) {
			Entry_value entry = entryList[k];
			Name_value[] entryNameValueList = entry.getName_value_list();
			System.out.println();
			for (int j = 0; j < entryNameValueList.length; j++) {
				Name_value entryNameValue = entryNameValueList[j];
				// Outputting only non empty fields
				if (!entryNameValue.getValue().isEmpty()) {
					System.out.print(entryNameValue.getName() + ":"
							+ entryNameValue.getValue() + "    ;   ");
				}
			}
		}
	}

	/**
	 *
	 * To list out all fields of a given module
	 *
	 * @param sessionID
	 * @param binding
	 * @param moduleName
	 */
	private static void retreiveModuleFields(String sessionID,
			SugarsoapBindingStub binding, String moduleName) {
		/**
		 * Create a new Contact
		 *
		 * 1) Setting a new entry 2) Setting up parameters for set_entry call 3)
		 * Creating a name value list array from a hash map. This is not
		 * necessary just more elegant way to initialize and add name values to
		 * an array
		 */

		/**
		 * Getting an Contacts Entry (the one we just set)
		 */
		Link_name_to_fields_array[] link_name_to_fields_array = null;
		String[] select_fields = null;

		Get_entry_result_version2 getEntryResponse = null;
		New_module_fields moduleFields = null;
		// Trying to get entry
		try {
			moduleFields = binding.get_module_fields(sessionID, moduleName,
					select_fields);
		} catch (RemoteException e) {
			// System.out.println("Get entry failed. Message: " +
			// e.getMessage());
			// e.printStackTrace();
		}
		// System.out.println("Get entry was successful! Response: ");

		if (moduleFields != null) {
			for (Field field : moduleFields.getModule_fields()) {
				System.out.print(field.getName() + ",");
			}

			System.out.println("--Link Fields");
			/**
			 * Now get Link Fields
			 */

			for (Link_field linkField : moduleFields.getLink_fields()) {
				System.out.print("[" + linkField.getName() + " : "
						+ linkField.getBean_name() + " : "
						+ linkField.getRelationship() + "],");
			}
		}

	}

	/**
	 * To list out all modules aavailable to given user
	 *
	 * @param sessionID
	 * @param binding
	 */
	private static void retreiveModules(String sessionID,
			SugarsoapBindingStub binding) {
		/**
		 * Create a new Contact
		 *
		 * 1) Setting a new entry 2) Setting up parameters for set_entry call 3)
		 * Creating a name value list array from a hash map. This is not
		 * necessary just more elegant way to initialize and add name values to
		 * an array
		 */

		/**
		 * Getting an Contacts Entry (the one we just set)
		 */
		Link_name_to_fields_array[] link_name_to_fields_array = null;
		String[] select_fields = null;

		Get_entry_result_version2 getEntryResponse = null;
		Module_list moduleList = null;
		// Trying to get entry
		try {
			moduleList = binding.get_available_modules(sessionID);
		} catch (RemoteException e) {
			System.out.println("Get entry failed. Message: " + e.getMessage());
			e.printStackTrace();
		}
		// System.out.println("Get entry was successful! Response: ");

		for (String moduleName : moduleList.getModules()) {
			System.out.println("\n" + moduleName + "{");
			retreiveModuleFields(sessionID, binding, moduleName);
			System.out.println("}");
		}

	}

}

Project code has been checked into

https://linkwithweb.googlecode.com/svn/trunk/CRM/SugarCRM/JavaSOAPClient/SugarCRMClient

List of all fields by module is also located at

https://linkwithweb.googlecode.com/svn/trunk/CRM/SugarCRM/ModuleWiseFieldsAndLinkedFieldsList.txt

Same can also be extracted by running above program

Njoy working with SugarCRM..

Fell free to contact me if any questions

Amazon ECS Webservice Sign

I spent a good part of today trying to learn how to do a simple Java-based query using Amazon’s Product Advertising API.  This entire exercise could have been finished within an hour had there been clear, concise documentation.

Note: My code was working 6 months back and now when i try to work with it it dosen’t work. Below are the reason

In hopes of saving others from encountering these difficulties, I present you with my findings.

Lesson 1 – Don’t Trust the Getting Started Guide

Admittedly the getting started guide gives a useful overview of the functionality and the approach to using the API. However, following the instructions step by step will only lead to frustration. The Java example for
Implementing a Product Advertising API Request DOES NOT WORK!. One would think that simply swapping in the proper access key where it says “YOUR ID” would be all that is needed, but upon execution I found it yields the following:

Exception in thread "main" com.sun.xml.internal.ws.client.ClientTransportException:
The server sent HTTP status code 400: Bad Request

Thinking I had omitted something small, I looked into resolving this error only to discover:

Lesson 2 – APA API’s Authentication has changed

As of August 15, 2009, the API requires a signature mechanism for authentication. In addition to invalidating all the code from the getting started guide, it also adds additional poorly documented steps to the process. Amazon does provide some detail, but it’s probably not the quickest path to get up and running.

Lesson 3 – There are some semi-functional examples

After digging around in the documentation, I found these two examples: Product Advertising API Signed Requests Sample Code – Java REST/QUERY and Product Advertising API Signed Requests Sample Code – Java SOAP. Since everything I had tried up until this point had been SOAP-centric, I decided to try the SOAP example first. Upon the code into Eclipse, I found that this example was fraught with too many errors and dependencies, so I turned to the REST example.

The REST code was clear and mostly error free. The few errors I saw were caused by the absence of the Apache Commons Codec library. After downloading this jar and adding it to my classpath, the example code finally compiled. Unfortunately, when I went to run it, I was greeted with this exception:

Server returned HTTP response code: 403 for URL: http://ecs.amazonaws.com/onca/xml?....

Lesson 4 – Apache Commons Codec 1.3 and 1.4 are different

After crawling through the forums looking for answers, I found out that the REST example above depended on Apache Commons Codec version 1.3, whereas the version I downloaded was 1.4. It turns out the old version appended extra CRLF (\r\n) characters onto the authentication signature, and the workaround is to force the new codec to exhibit the same behavior. If you read the codec’s documentation, you’ll see that the default behavior comes when you set the line length to 76 characters. To fix the REST example change method hmac of SignatureRequestHelper to:

Base64 encoder = new Base64(76, new byte[0]);

After doing all this, I finally got a small victory in the form of real output:

Map form example:
Signed Request is "http://ecs.amazonaws.com/onca/xml?AWSAccessKeyId=...."
Signed Title is "Harry Potter and the Deathly Hallows (Book 7)"

String form example:
Request is "http://ecs.amazonaws.com/onca/xml?AWSAccessKeyId=...."
Title is "Harry Potter and the Deathly Hallows (Book 7)"

Below is SignedRequestHelper that works

package com.northalley.amazon;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.SortedMap;
import java.util.TimeZone;
import java.util.TreeMap;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;

/**
 * This class contains all the logic for signing requests
 * to the Amazon Product Advertising API.
 */
public class SignedRequestsHelper {
    /**
     * All strings are handled as UTF-8
     */
    private static final String UTF8_CHARSET = "UTF-8";

    /**
     * The HMAC algorithm required by Amazon
     */
    private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256";

    /**
     * This is the URI for the service, don't change unless you really know
     * what you're doing.
     */
    private static final String REQUEST_URI = "/onca/xml";

    /**
     * The sample uses HTTP GET to fetch the response. If you changed the sample
     * to use HTTP POST instead, change the value below to POST.
     */
    private static final String REQUEST_METHOD = "GET";

    private String endpoint = null;
    private String awsAccessKeyId = null;
    private String awsSecretKey = null;

    private SecretKeySpec secretKeySpec = null;
    private Mac mac = null;

    static {
    	java.security.Security.addProvider(new com.sun.crypto.provider.SunJCE());
    }

    /**
     * You must provide the three values below to initialize the helper.
     *
     * @param endpoint          Destination for the requests.
     * @param awsAccessKeyId    Your AWS Access Key ID
     * @param awsSecretKey      Your AWS Secret Key
     */
    public static SignedRequestsHelper getInstance(
            String endpoint,
            String awsAccessKeyId,
            String awsSecretKey
    ) throws IllegalArgumentException, UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException
    {
        if (null == endpoint || endpoint.length() == 0)
            { throw new IllegalArgumentException("endpoint is null or empty"); }
        if (null == awsAccessKeyId || awsAccessKeyId.length() == 0)
            { throw new IllegalArgumentException("awsAccessKeyId is null or empty"); }
        if (null == awsSecretKey || awsSecretKey.length() == 0)
            { throw new IllegalArgumentException("awsSecretKey is null or empty"); }
        SignedRequestsHelper instance = new SignedRequestsHelper();
        instance.endpoint = endpoint.toLowerCase();
        instance.awsAccessKeyId = awsAccessKeyId;
        instance.awsSecretKey = awsSecretKey;

        byte[] secretyKeyBytes = instance.awsSecretKey.getBytes(UTF8_CHARSET);
        instance.secretKeySpec = new SecretKeySpec(secretyKeyBytes, HMAC_SHA256_ALGORITHM);
        instance.mac = Mac.getInstance(HMAC_SHA256_ALGORITHM);
        instance.mac.init(instance.secretKeySpec);

        return instance;
    }

    /**
     * The construct is private since we'd rather use getInstance()
     */
    private SignedRequestsHelper() {}

    /**
     * This method signs requests in hashmap form. It returns a URL that should
     * be used to fetch the response. The URL returned should not be modified in
     * any way, doing so will invalidate the signature and Amazon will reject
     * the request.
     */
    public String sign(Map params) {
        // Let's add the AWSAccessKeyId and Timestamp parameters to the request.
        params.put("AWSAccessKeyId", this.awsAccessKeyId);
        params.put("Timestamp", this.timestamp());

        // The parameters need to be processed in lexicographical order, so we'll
        // use a TreeMap implementation for that.
        SortedMap sortedParamMap = new TreeMap(params);

        // get the canonical form the query string
        String canonicalQS = this.canonicalize(sortedParamMap);

        // create the string upon which the signature is calculated
        String toSign =
            REQUEST_METHOD + "\n"
            + this.endpoint + "\n"
            + REQUEST_URI + "\n"
            + canonicalQS;

        // get the signature
        String hmac = this.hmac(toSign);
        String sig = this.percentEncodeRfc3986(hmac);

        // construct the URL
        String url =
            "http://" + this.endpoint + REQUEST_URI + "?" + canonicalQS + "&Signature=" + sig;

        return url;
    }

    /**
     * This method signs requests in query-string form. It returns a URL that
     * should be used to fetch the response. The URL returned should not be
     * modified in any way, doing so will invalidate the signature and Amazon
     * will reject the request.
     */
    public String sign(String queryString) {
        // let's break the query string into it's constituent name-value pairs
        Map params = this.createParameterMap(queryString);

        // then we can sign the request as before
        return this.sign(params);
    }

    /**
     * Compute the HMAC.
     *
     * @param stringToSign  String to compute the HMAC over.
     * @return              base64-encoded hmac value.
     */
    private String hmac(String stringToSign) {
        String signature = null;
        byte[] data;
        byte[] rawHmac;
        try {
            data = stringToSign.getBytes(UTF8_CHARSET);
            rawHmac = mac.doFinal(data);
            Base64 encoder = new Base64(76, new byte[0]);
            signature = new String(encoder.encode(rawHmac));
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(UTF8_CHARSET + " is unsupported!", e);
        }
        return signature;
    }

    /**
     * Generate a ISO-8601 format timestamp as required by Amazon.
     *
     * @return  ISO-8601 format timestamp.
     */
    private String timestamp() {
        String timestamp = null;
        Calendar cal = Calendar.getInstance();
        DateFormat dfm = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
        dfm.setTimeZone(TimeZone.getTimeZone("GMT"));
        timestamp = dfm.format(cal.getTime());
        return timestamp;
    }

    /**
     * Canonicalize the query string as required by Amazon.
     *
     * @param sortedParamMap    Parameter name-value pairs in lexicographical order.
     * @return                  Canonical form of query string.
     */
    private String canonicalize(SortedMap sortedParamMap) {
        if (sortedParamMap.isEmpty()) {
            return "";
        }

        StringBuffer buffer = new StringBuffer();
        Iterator<Map.Entry> iter = sortedParamMap.entrySet().iterator();

        while (iter.hasNext()) {
            Map.Entry kvpair = iter.next();
            buffer.append(percentEncodeRfc3986(kvpair.getKey()));
            buffer.append("=");
            buffer.append(percentEncodeRfc3986(kvpair.getValue()));
            if (iter.hasNext()) {
                buffer.append("&");
            }
        }
        String cannoical = buffer.toString();
        return cannoical;
    }

    /**
     * Percent-encode values according the RFC 3986. The built-in Java
     * URLEncoder does not encode according to the RFC, so we make the
     * extra replacements.
     *
     * @param s decoded string
     * @return  encoded string per RFC 3986
     */
    private String percentEncodeRfc3986(String s) {
        String out;
        try {
            out = URLEncoder.encode(s, UTF8_CHARSET)
                .replace("+", "%20")
                .replace("*", "%2A")
                .replace("%7E", "~");
        } catch (UnsupportedEncodingException e) {
            out = s;
        }
        return out;
    }

    /**
     * Takes a query string, separates the constituent name-value pairs
     * and stores them in a hashmap.
     *
     * @param queryString
     * @return
     */
    private Map createParameterMap(String queryString) {
        Map map = new HashMap();
        String[] pairs = queryString.split("&");

        for (String pair: pairs) {
            if (pair.length() < 1) {
                continue;
            }

            String[] tokens = pair.split("=",2);
            for(int j=0; j<tokens.length; j++)
            {
                try {
                    tokens[j] = URLDecoder.decode(tokens[j], UTF8_CHARSET);
                } catch (UnsupportedEncodingException e) {
                }
            }
            switch (tokens.length) {
                case 1: {
                    if (pair.charAt(0) == =) {
                        map.put("", tokens[0]);
                    } else {
                        map.put(tokens[0], "");
                    }
                    break;
                }
                case 2: {
                    map.put(tokens[0], tokens[1]);
                    break;
                }
            }
        }
        return map;
    }
}
Follow

Get every new post delivered to your Inbox.

Join 28 other followers