Permalink
Browse files

Initial commit.

  • Loading branch information...
0 parents commit 9f83f295f088ee9d1bbc68b1b2c0bcb7c400b400 Kyle Kingsbury committed Jul 28, 2012
Showing with 487 additions and 0 deletions.
  1. +9 −0 .gitignore
  2. +91 −0 README.md
  3. +12 −0 project.clj
  4. +273 −0 src/salesfear/client.clj
  5. +6 −0 src/salesfear/core.clj
  6. +96 −0 test/salesfear/client_test.clj
@@ -0,0 +1,9 @@
+pom.xml
+*jar
+/lib/
+/classes/
+target/
+.*.swp
+.lein-deps-sum
+cache.clj
+*.log
@@ -0,0 +1,91 @@
+# salesfear
+
+SalesFear aims to quell the insensate rage and terror which go hand in hand
+with the Salesforce API.
+
+It's a wrapper around http://blog.palominolabs.com/2011/03/03/a-new-java-salesforce-api-library/. [Their source](https://github.com/teamlazerbeez/sf-api-connector/tree/master/sf-rest-api-connector/src/main/java/com/teamlazerbeez/crm/sf/rest) will probably come in handy.
+
+Check http://clojars.org/salesfear for the latest version, then add to your
+project.clj's dependencies.
+
+## Usage
+
+``` clojure
+(use 'salesfear.client)
+
+; Connect to Salesforce with your credentials:
+(salesforce! {:org-id "00DE0000000b894" :username "foo@bar.com" :password (str "mypw" "mytoken")})
+
+; List all accounts.
+(find :Account)
+
+; Accounts are presented as immutable Maps. They implement lazerbees' SObject
+interface, so you can pass them back to any internal method.
+(first (find :Account))
+#salesfear.client.CSObject{:type "Account", :id "001E000000Jx2nb", :CreatedDate "2012-07-26T01:12:20.000+0000", :Name "GenePoint", :Sic "3712", :Website "www.genepoint.com", :SLA__c "Bronze", :Description "Genomics company engaged in mapping and sequencing of the human genome and developing gene-based drugs", :ShippingPostalCode nil, :LastModifiedById "005E0000000Kr6TIAS", :BillingPostalCode nil, :SLASerialNumber__c "7324", :NumberOfEmployees "265", :AccountNumber "CC978213", :OwnerId "005E0000000Kr6TIAS", :ShippingState nil, :TickerSymbol nil, :BillingCity "Mountain View", :Type "Customer - Channel", :Site nil, :UpsellOpportunity__c "Yes", :Rating "Cold", :ShippingCity nil, :SLAExpirationDate__c "2012-02-21", :LastActivityDate nil, :BillingStreet "345 Shoreline Park\nMountain View, CA 94043\nUSA", :CustomerPriority__c "Low", :LastModifiedDate "2012-07-26T01:12:20.000+0000", :MasterRecordId nil, :ShippingCountry nil, :IsDeleted "false", :BillingCountry nil, :CreatedById "005E0000000Kr6TIAS", :AnnualRevenue "3.0E7", :Active__c "Yes", :NumberofLocations__c "1.0", :ParentId nil, :BillingState "CA", :Phone "(650) 867-3450", :Ownership "Private", :ShippingStreet "345 Shoreline Park\nMountain View, CA 94043\nUSA", :Fax "(650) 867-9895", :SystemModstamp "2012-07-26T01:12:20.000+0000", :Industry "Biotechnology"}
+
+; Create an account. It'll return the ID.
+(def id (create :Account {:Name "cat" :Site "the moon"}))
+"001E000000JM7au"
+
+; Now we'll get the record we created:
+(:Name (get :Account id))
+"foo"
+
+; Making changes is easy
+(update :Account id {:Name "a new name"})
+
+(def account (get :Account id))
+(select-keys account [:Name :Site])
+{:Site "the moon", :Name "a new name"}
+
+; And delete the record. Most functions take either [type id data] or [sobject].
+(delete account)
+:deleted
+
+; Deletes are idempotent.
+(delete :Account id)
+:already-deleted
+
+; 404s translate to nil.
+(get :Account id)
+nil
+
+; SOQL queries
+(create :Account {:Name "o'brien" :Site "1"})
+(create :Account {:Name "o'brien" :Site "2"})
+(query "select site from account where name = 'o\\'brien'")
+(#salesfear.client.CSObject{:type "Account", :id "", :Site "1"} #salesfear.client.CSObject{:type "Account", :id "", :Site "2"})
+
+; You can parameterize as well.
+(query ["select site from ? where name = ?" :Account "o'brien"])
+
+; For building queries, the full list of fields for a type might come in handy.
+; Salesfear transparently caches this for each (salesforce ...) context.
+(sobject-field-names :Account)
+("Id" "IsDeleted" "MasterRecordId" "Name" "Type" "ParentId" "BillingStreet" "BillingCity" "BillingState" "BillingPostalCode" "BillingCountry" "ShippingStreet" "ShippingCity" "ShippingState" "ShippingPostalCode" "ShippingCountry" "Phone" "Fax" "AccountNumber" "Website" "Sic" "Industry" "AnnualRevenue" "NumberOfEmployees" "Ownership" "TickerSymbol" "Description" "Rating" "Site" "OwnerId" "CreatedDate" "CreatedById" "LastModifiedDate" "LastModifiedById" "SystemModstamp" "LastActivityDate" "CustomerPriority__c" "SLA__c" "Active__c" "NumberofLocations__c" "UpsellOpportunity__c" "SLASerialNumber__c" "SLAExpirationDate__c")
+
+; Introspection.
+(describe-global)
+(describe-sobject :Account)
+; I wouldn't rely on these methods; there's too much I haven't looked at and
+; clojurified. Consider (describe-global*) and friends, which return raw java
+; objects.
+```
+
+I recommend using the (salesforce) macro to wrap all your work with a
+particular SF connection pool. This binds the underlying connection pools to
+dynamic variables *soap-pool*, *rest-pool*, etc in salesfear.client.
+
+``` clojure
+(salesforce {:org-id o :username u :password u}
+ (create :Account ...)
+ (get :Account ...)
+ ...)
+```
+
+## License
+
+Copyright © 2012 Kyle Kingsbury <aphyr@aphyr.com>
+
+Distributed under the Eclipse Public License, the same as Clojure.
@@ -0,0 +1,12 @@
+(defproject salesfear "0.1.0-SNAPSHOT"
+ :description "Talk to the Salesforce API, via teamlazerbeez sf-api-connector."
+ :url "http://github.com/aphyr/salesfear"
+ :repositories {"boundary-site" "http://maven.boundary.com/artifactory/repo"}
+ :license {:name "Eclipse Public License"
+ :url "http://www.eclipse.org/legal/epl-v10.html"}
+ :dependencies [[org.clojure/clojure "1.3.0"]
+ [slingshot "0.10.3"]
+ [org.clojure/tools.logging "0.2.3"]
+ [clj-time "0.4.3"]
+ [com.teamlazerbeez/sf-rest-api-connector "trunk-SNAPSHOT"]
+ [com.teamlazerbeez/sf-soap-api-connector "trunk-SNAPSHOT"]])
@@ -0,0 +1,273 @@
+(ns salesfear.client
+ (:refer-clojure :exclude (get find))
+ (:use [clojure.string :only [join split escape]]
+ [slingshot.slingshot :only [throw+ try+]])
+ (:import (java.net URL)
+ (com.teamlazerbeez.crm.sf.core SObject
+ Id)
+ (com.teamlazerbeez.crm.sf.soap ConnectionPoolImpl)
+ (com.teamlazerbeez.crm.sf.rest RestConnectionPoolImpl)))
+
+; Records
+(defrecord CSObject [type id]
+ SObject
+ (getId [this] (Id. id))
+ (getType [this] type)
+ (getField [this k] (this (keyword k)))
+ (isFieldSet [this k] (contains? this (keyword k)))
+ (getAllFields [this] (into {} (for [[k v] this]
+ (when-not (or (= :type k)
+ (= :id k))
+ [(name k) v]))))
+ ; Fuck mutability
+ (setField [this k v] (throw RuntimeException))
+ (setAllFields [this m] (throw RuntimeException))
+ (removeField [this k] (throw RuntimeException)))
+
+(defn sobject
+ "Creates an immutable CSObject. Can take an SObject, or a type, id, and map
+ of fields to values. Ids will be converted to Id objects, types will be
+ converted to strings. (sobject nil) is nil."
+ ([type id fields]
+ (let [id (if (instance? Id id) (str id) id)]
+ (merge (CSObject. (name type) id)
+ fields)))
+ ([^SObject sobject]
+ (if-not sobject
+ nil
+ (into
+ (CSObject. (.getType sobject) (str (.getId sobject)))
+ (for [[k v] (.getAllFields sobject)]
+ [(keyword k) v])))))
+
+; Misc stuff
+(def client-id "joanna")
+(def cache-default {:sobject-field-names {}})
+(def ^:dynamic cache (atom cache-default))
+
+; Pools
+(def ^:dynamic *rest-pool*)
+(def ^:dynamic *soap-pool*)
+(def ^:dynamic *org-id*)
+
+(defn soap-pool
+ "Returns a SOAP connection pool."
+ [org-id username password threads]
+ (doto (ConnectionPoolImpl. client-id)
+ (.configureOrg org-id username password threads)))
+
+(defn rest-pool
+ "Returns a REST connection pool from a soap pool"
+ [soap-pool org-id]
+ (let [bc (.getBindingConfig (.getConnectionBundle soap-pool org-id))
+ host (.getHost (URL. (.getPartnerServerUrl bc)))
+ token (.getSessionId bc)]
+ (doto (RestConnectionPoolImpl.)
+ (.configureOrg org-id host token))))
+
+(defmacro salesforce
+ "Executes exprs with implicit credentials, organization, etc. Opts:
+ :user SF username
+ :password SF password
+ :orgid Organization ID
+ :threads Maximum number of concurrent operations"
+ [opts & exprs]
+ `(let [soap-pool# (soap-pool ~(:org-id opts)
+ ~(:username opts)
+ ~(:password opts)
+ ~(or (:threads opts) 8))
+ rest-pool# (rest-pool soap-pool# ~(:org-id opts))]
+ (binding [cache (atom cache-default)
+ *soap-pool* soap-pool#
+ *rest-pool* rest-pool#
+ *org-id* ~(:org-id opts)]
+ ~@exprs)))
+
+(defn salesforce!
+ "Like (salesforce), but redefines local vars destructively. Useful for REPL
+ testing."
+ [opts]
+ (let [soap-pool (soap-pool (:org-id opts)
+ (:username opts)
+ (:password opts)
+ (or (:threads opts) 8))
+ rest-pool (rest-pool soap-pool (:org-id opts))]
+ (def ^:dynamic cache (atom cache-default))
+ (def ^:dynamic *soap-pool* soap-pool)
+ (def ^:dynamic *rest-pool* rest-pool)
+ (def ^:dynamic *org-id* (:org-id opts))))
+
+
+(defn rest-conn []
+ (.getRestConnection *rest-pool* *org-id*))
+
+; Meat n potatoes
+(defn describe-global*
+ []
+ "Returns the raw GlobalSObjectDescription."
+ (.describeGlobal (rest-conn)))
+
+(defn describe-global
+ "Returns a list of beans corresponding to global sobject descriptions."
+ []
+ (map bean (.getBasicSObjectMetadatas (describe-global*))))
+
+(defn describe-sobject*
+ "Returns a java description of the given type of sobject."
+ [type]
+ (.describeSObject (rest-conn) (name type)))
+
+(defn describe-sobject
+ "Returns a map describing the given type of sobject. Lots of beans here.
+ Definitely wouldn't depend on this structure having this shape in the future.
+ Use describe-sobject* if you can. :-/"
+ [type]
+ (let [b (bean (describe-sobject* type))]
+ (merge (dissoc b :fields :childRelationships :recordTypeInfos)
+ {:fields (map bean (:fields b))
+ :childRelationships (map bean (:childRelationships b))
+ :recordTypeInfos (map bean (:recordTypeInfos b))})))
+
+(defn soql-quote
+ "Single-quotes a string. escaping single-quotes
+ within."
+ [string]
+ (str "'" (escape string {\' "\\'"}) "'"))
+
+(defn soql-literal
+ "Returns SOQL string fragments for values. Strings are quoted, keywords are
+ not quoted, numbers are converted with (str), true, false, and nil are
+ \"true\", \"false\", and \"null\".
+
+ I haven't actually verified soql's syntax, so this could bite you. :-D"
+ [x]
+ (cond (nil? x) "null"
+ (true? x) "true"
+ (false? x) "false"
+ (keyword? x) (name x)
+ (string? x) (soql-quote x)
+ (number? x) (str x)
+ true (str x)))
+
+(defn soql-where
+ "Given a map of fields to values, returns a string like field1 = \"value1\" AND field2 = 2.4"
+ [constraints]
+ (join " and " (map (fn [[k v]] (str (soql-literal k) " = " (soql-literal v)))
+ constraints)))
+
+(defn soql
+ "Constructs a soql query string. Passes through strings unchanged. With
+ vectors like [\"email = ?\" \"foo\"], replaces ? with corresponding strings
+ from the remainder of the vector, quoted."
+ [q]
+ (if (string? q)
+ q
+ (let [[string & values] q
+ fragments (split string #"\?" (inc (count values)))
+ ; We'll have one more fragment than value, so prepend an initial "".
+ values (concat [""] (map soql-literal values))]
+ (assert (= (count fragments) (count values)))
+ (apply str (interleave values fragments)))))
+
+(defn query
+ "Returns a collection of SOBjects matching the given query. e.g:
+ (query [\"SELECT id, name, email from Lead where Email = ?\" \"foo@bar.com\"])
+ (query \"SELECT id from Lead where Email = 'foo@bar.com'\")"
+ [q]
+ (let [res (-> (rest-conn) (.query (soql q)))
+ sobjects (map sobject (.getSObjects res))]
+ (vary-meta sobjects merge {:done (.isDone res)
+ :total (.getTotalSize res)
+ :query-locator (.getQueryLocator res)})))
+
+(defn sobject-field-names
+ "Returns a seq of field names on an sobject type."
+ [type]
+ (let [type (name type)]
+ (or ; Cached
+ (get-in @cache [:sobject-field-names type])
+ ; Uncached
+ (let [names (map #(.getName %) (.getFields (describe-sobject* type)))]
+ (swap! cache assoc-in [:sobject-field-names type] names)
+ names))))
+
+(defn create
+ "Creates an sobject. Returns id if created, throws errors. Might return false
+ if not successful, whenever that happens."
+ ([type fields] (create type nil fields))
+ ([type id fields] (create (sobject type id fields)))
+ ([sobject]
+ (let [res (.create (rest-conn) sobject)]
+ (if (.isSuccess res)
+ (str (.getId res))
+ false))))
+
+(defn delete
+ "Delete an sobject of type with the given id, or, deletes an sobject. Returns
+ :deleted or :already-deleted."
+ ([sobject]
+ (assert (instance? SObject sobject))
+ (assert (and (:type sobject) (:id sobject)))
+ (delete (:type sobject) (:id sobject)))
+ ([type id]
+ (let [id (if (string? id) (Id. id) id)]
+ (try+
+ (.delete (rest-conn) (name type) id)
+ :deleted
+ (catch (and (instance? com.teamlazerbeez.crm.sf.rest.ApiException
+ (.getCause %))
+ (= 404 (.getHttpResponseCode (.getCause %)))) e
+ :already-deleted)))
+ ))
+
+(defn find
+ "Finds SObjects of type given constraints, an (inline) map of fields to
+ values, which are implicitly ANDed together. No support for pagination, so if
+ you blow the limit, too bad. Example:
+
+ (find :Account) ; all accounts
+ (find :Account {:id \"1234\"})
+ (find :Account {:Name \"Joe\" :age 32)}"
+ ([type]
+ (query (str "select " (join ", "(sobject-field-names type))
+ " from " (soql-literal type))))
+ ([type constraints]
+ (query (str "select " (join ", "(sobject-field-names type))
+ " from " (soql-literal type)
+ " where " (soql-where constraints)))))
+
+(defn ffind
+ "Like find, but yields only one result."
+ [type constraints]
+ (first (query (str "select " (join ", " (sobject-field-names type))
+ " from " (soql-literal type)
+ " where " (soql-where constraints)
+ " limit 1"))))
+
+(defn get
+ "Gets a single SObject by type and ID. If fields is omitted, gets all
+ fields. If the object does not exist, returns nil."
+ ([type id] (get type id (sobject-field-names type)))
+ ([type id fields]
+ (let [id (if (string? id) (Id. id) id)]
+ (try+
+ (sobject (.retrieve (rest-conn) (name type) id fields))
+ (catch (and (instance? com.teamlazerbeez.crm.sf.rest.ApiException
+ (.getCause %))
+ (= 404 (.getHttpResponseCode (.getCause %)))) e)))))
+
+(def retrieve get)
+
+(defn update
+ "Updates an sobject. Returns sobject."
+ ([type id fields]
+ (update (sobject type id fields)))
+ ([sobject]
+ (.update (rest-conn) sobject)
+ sobject))
+
+(defn upsert
+ "Upserts an sobject, given an sobject and the field name of the external id
+ field."
+ [sobject external-id-field]
+ (.upsert SObject external-id-field))
@@ -0,0 +1,6 @@
+(ns salesfear.core)
+
+(defn -main
+ "I don't do a whole lot."
+ [& args]
+ (println "Hello, World!"))
Oops, something went wrong.

0 comments on commit 9f83f29

Please sign in to comment.