├── project ├── build.properties └── plugins.sbt ├── sbt-launch.jar ├── public └── images │ └── favicon.png ├── app ├── assets │ ├── stylesheets │ │ └── main.css │ └── javascripts │ │ └── app.js ├── apextemplates │ ├── triggers │ │ └── WebhookTrigger.scala.txt │ └── classes │ │ ├── Webhook.scala.txt │ │ └── TriggerTest.scala.txt ├── core │ ├── TriggerMetadata.scala │ └── TriggerEvent.scala ├── views │ ├── index.scala.html │ └── app.scala.html ├── controllers │ └── Application.scala └── utils │ └── ForceUtil.scala ├── .gitignore ├── conf ├── application.conf ├── logback.xml └── routes ├── sbt ├── app.json ├── LICENSE ├── README.md ├── test ├── ForceUtilSpec.scala └── ApplicationSpec.scala └── sbt.cmd /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.6.1 2 | -------------------------------------------------------------------------------- /sbt-launch.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesward/salesforce-webhook-creator/HEAD/sbt-launch.jar -------------------------------------------------------------------------------- /public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesward/salesforce-webhook-creator/HEAD/public/images/favicon.png -------------------------------------------------------------------------------- /app/assets/stylesheets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 60px; 3 | } 4 | 5 | [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { 6 | display: none !important; 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | project/project 3 | project/target 4 | target 5 | tmp 6 | .history 7 | dist 8 | /.idea 9 | /*.iml 10 | /out 11 | /.idea_modules 12 | /.classpath 13 | /.project 14 | /RUNNING_PID 15 | /.settings 16 | .DS_Store 17 | /dev.env 18 | /.bsp/ 19 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.13") 2 | 3 | addSbtPlugin("com.typesafe.sbt" % "sbt-jshint" % "1.0.6") 4 | 5 | addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.4") 6 | 7 | addSbtPlugin("com.typesafe.sbt" % "sbt-gzip" % "1.0.2") 8 | -------------------------------------------------------------------------------- /app/apextemplates/triggers/WebhookTrigger.scala.txt: -------------------------------------------------------------------------------- 1 | @(name: String, sobject: String, events: List[String], url: String)trigger @{name}WebhookTrigger on @{sobject} (@{events.mkString(",")}) { 2 | 3 | String url = '@url'; 4 | 5 | String content = Webhook.jsonContent(Trigger.new, Trigger.old); 6 | 7 | Webhook.callout(url, content); 8 | 9 | } -------------------------------------------------------------------------------- /app/core/TriggerMetadata.scala: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import core.TriggerEvent.TriggerEvent 4 | import play.api.libs.json.Json 5 | 6 | case class TriggerMetadata(name: String, sobject: String, events: List[TriggerEvent], url: String) 7 | 8 | object TriggerMetadata { 9 | implicit val reads = Json.reads[TriggerMetadata].map { triggerMetadata => 10 | triggerMetadata.copy(name = triggerMetadata.name.replace(" ", "")) 11 | } 12 | implicit val writes = Json.writes[TriggerMetadata] 13 | } 14 | -------------------------------------------------------------------------------- /conf/application.conf: -------------------------------------------------------------------------------- 1 | play.filters.enabled += play.filters.https.RedirectHttpsFilter 2 | play.filters.enabled += play.filters.gzip.GzipFilter 3 | play.filters.disabled += play.filters.hosts.AllowedHostsFilter 4 | 5 | play.http.forwarded.trustedProxies=["0.0.0.0/0", "::/0"] 6 | 7 | play.http.secret.key=${?APPLICATION_SECRET} 8 | 9 | play.application.langs=["en"] 10 | 11 | force.oauth.consumer-key=${FORCE_CONSUMER_KEY} 12 | force.oauth.consumer-secret=${FORCE_CONSUMER_SECRET} 13 | 14 | webjars.use-cdn=true 15 | play.filters.csp.CSPFilter="default-src 'self' 'unsafe-inline' cdn.jsdelivr.net" 16 | -------------------------------------------------------------------------------- /app/core/TriggerEvent.scala: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import play.api.libs.json._ 4 | 5 | object TriggerEvent extends Enumeration { 6 | type TriggerEvent = Value 7 | val BeforeInsert = Value("before insert") 8 | val BeforeUpdate = Value("before update") 9 | val BeforeDelete = Value("before delete") 10 | val AfterInsert = Value("after insert") 11 | val AfterUpdate = Value("after update") 12 | val AfterDelete = Value("after delete") 13 | val AfterUndelete = Value("after undelete") 14 | 15 | implicit val jsonFormat = new Format[TriggerEvent] { 16 | def reads(json: JsValue) = JsSuccess(TriggerEvent.withName(json.as[String])) 17 | def writes(triggerEvent: TriggerEvent) = JsString(triggerEvent.toString) 18 | } 19 | } -------------------------------------------------------------------------------- /sbt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | get_java_cmd() { 4 | if [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then 5 | echo "$JAVA_HOME/bin/java" 6 | else 7 | echo "java" 8 | fi 9 | } 10 | 11 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 12 | move_to_project_dir() { 13 | if [[ "$(uname)" == "Darwin" ]] && [[ "$HOME" == "$PWD" ]]; then 14 | cd $(dirname $0) 15 | fi 16 | } 17 | 18 | move_to_project_dir 19 | 20 | SBT_LAUNCHER="$(dirname $0)/sbt-launch.jar" 21 | 22 | SBT_OPTS="-Xms512M -Xmx4048M -Xss4M -XX:+CMSClassUnloadingEnabled" 23 | 24 | # todo: check java cmd 25 | 26 | # todo: help text 27 | 28 | $(get_java_cmd) ${SBT_OPTS} -jar ${SBT_LAUNCHER} "$@" 29 | -------------------------------------------------------------------------------- /conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %coloredLevel %logger{15} - %message%n%xException{10} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Salesforce Webhook Creator", 3 | "description": "Create Webhooks on Salesforce", 4 | "repository": "https://github.com/jamesward/salesforce-webhook-creator", 5 | "website": "http://www.jamesward.com/", 6 | "keywords": ["salesforce"], 7 | "env": { 8 | "APPLICATION_SECRET": { 9 | "description": "A secret key for crypto and signed cookies.", 10 | "generator": "secret" 11 | }, 12 | "ASSETS_URL": { 13 | "description": "The WebJar CDN URL", 14 | "value": "//cdn.jsdelivr.net" 15 | }, 16 | "FORCE_CONSUMER_KEY": { 17 | "description": "The Salesforce OAuth Consumer Key", 18 | "required": true 19 | }, 20 | "FORCE_CONSUMER_SECRET": { 21 | "description": "The Salesforce OAuth Consumer Secret", 22 | "required": true 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /conf/routes: -------------------------------------------------------------------------------- 1 | # Routes 2 | # This file defines all application routes (Higher priority routes first) 3 | # ~~~~ 4 | 5 | # Home page 6 | GET / controllers.Application.index 7 | 8 | GET /logout controllers.Application.logout 9 | 10 | GET /app controllers.Application.app(debug: Boolean ?= false) 11 | 12 | GET /sobjects controllers.Application.getSobjects 13 | 14 | GET /webhooks controllers.Application.getWebhooks 15 | POST /webhooks controllers.Application.createWebhook 16 | 17 | GET /_oauth_callback controllers.Application.oauthCallback(code, state) 18 | 19 | # Map static resources from the /public folder to the /assets URL path 20 | GET /vassets/*file controllers.Assets.versioned(path="/public", file: Asset) 21 | GET /assets/*file controllers.Assets.at(path="/public", file) 22 | 23 | -> /webjars webjars.Routes 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 James Ward 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/views/index.scala.html: -------------------------------------------------------------------------------- 1 | @this(forceUtil: utils.ForceUtil, webJarsUtil: org.webjars.play.WebJarsUtil) 2 | @(implicit request: RequestHeader) 3 | 4 | 5 | 6 | Salesforce Webhook Creator 7 | @webJarsUtil.locate("bootstrap.min.css").css() 8 | 9 | 10 | 11 | 12 | 19 | 20 |
21 |
22 | Login via Salesforce - Normal Instance 23 | Login via Salesforce - Sandbox Instance 24 |
25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Salesforce Webhook Creator 2 | 3 | This is a simple web app that makes it easy to create Webhooks on Salesforce. For usage info see: http://www.jamesward.com/2014/06/30/create-webhooks-on-salesforce-com 4 | 5 | Either use a shared instance of this app: https://salesforce-webhook-creator.herokuapp.com/ 6 | 7 | Or deploy your own instance on Heroku: 8 | 9 | 1. Create a new Connected App in Salesforce: 10 | 11 | 1. [Create a Connected App](https://login.salesforce.com/app/mgmt/forceconnectedapps/forceAppEdit.apexp) 12 | 1. Check `Enable OAuth Settings` 13 | 1. Set the `Callback URL` to `http://localhost:9000/_oauth_callback` 14 | 1. In `Available OAuth Scopes` select `Full access (full)` and click `Add` 15 | 1. Save the new Connected App and keep track of the Consumer Key & Consumer Secret for later use 16 | 17 | 1. Deploy this app on Heroku: [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 18 | 1. Edit the Connected App on Salesforce and update the `Callback URL` to be `https://YOUR_APP_NAME.herokuapp.com/_oauth_callback` 19 | 20 | 21 | ## Local Dev 22 | 23 | Run Locally: 24 | ``` 25 | export FORCE_CONSUMER_KEY=YOUR_CONSUMER_KEY 26 | export FORCE_CONSUMER_SECRET=YOUR_CONSUMER_SECRET 27 | 28 | ./sbt run 29 | ``` 30 | 31 | Test: 32 | ``` 33 | export FORCE_CONSUMER_KEY=YOUR_CONSUMER_KEY 34 | export FORCE_CONSUMER_SECRET=YOUR_CONSUMER_SECRET 35 | export FORCE_USERNAME=YOUR_SALESFORCE_USERNAME 36 | export FORCE_PASSWORD=YOUR_SALESFORCE_PASSWORD 37 | 38 | ./sbt test 39 | ``` -------------------------------------------------------------------------------- /app/apextemplates/classes/Webhook.scala.txt: -------------------------------------------------------------------------------- 1 | public class Webhook implements HttpCalloutMock { 2 | 3 | public static HttpRequest request; 4 | public static HttpResponse response; 5 | 6 | public HTTPResponse respond(HTTPRequest req) { 7 | request = req; 8 | response = new HttpResponse(); 9 | response.setStatusCode(200); 10 | return response; 11 | } 12 | 13 | public static String jsonContent(List triggerNew, List triggerOld) { 14 | String newObjects = '[]'; 15 | if (triggerNew != null) { 16 | newObjects = JSON.serialize(triggerNew); 17 | } 18 | 19 | String oldObjects = '[]'; 20 | if (triggerOld != null) { 21 | oldObjects = JSON.serialize(triggerOld); 22 | } 23 | 24 | String userId = JSON.serialize(UserInfo.getUserId()); 25 | 26 | String content = '{"new": ' + newObjects + ', "old": ' + oldObjects + ', "userId": ' + userId + '}'; 27 | return content; 28 | } 29 | 30 | @@future(callout=true) 31 | public static void callout(String url, String content) { 32 | 33 | if (Test.isRunningTest()) { 34 | Test.setMock(HttpCalloutMock.class, new Webhook()); 35 | } 36 | 37 | Http h = new Http(); 38 | 39 | HttpRequest req = new HttpRequest(); 40 | req.setEndpoint(url); 41 | req.setMethod('POST'); 42 | req.setHeader('Content-Type', 'application/json'); 43 | req.setBody(content); 44 | 45 | h.send(req); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /test/ForceUtilSpec.scala: -------------------------------------------------------------------------------- 1 | import play.api.Application 2 | import utils.ForceUtil 3 | import play.api.test._ 4 | 5 | import scala.util.Random 6 | 7 | class ForceUtilSpec extends PlaySpecification { 8 | 9 | def forceUtil (implicit app: Application): ForceUtil = { 10 | app.injector.instanceOf[ForceUtil] 11 | } 12 | 13 | lazy val name = Random.alphanumeric.take(8).mkString 14 | 15 | "ForceUtil" should { 16 | 17 | "login" in new WithApplication { 18 | val loginResult = await(forceUtil.login(forceUtil.ENV_PROD, sys.env("FORCE_USERNAME"), sys.env("FORCE_PASSWORD"))) 19 | (loginResult \ "access_token").asOpt[String] must beSome[String] 20 | } 21 | 22 | "getSobjects" in new WithApplication { 23 | val loginResult = await(forceUtil.login(forceUtil.ENV_PROD, sys.env("FORCE_USERNAME"), sys.env("FORCE_PASSWORD"))) 24 | val accessToken = (loginResult \ "access_token").as[String] 25 | val result = await(forceUtil.getSobjects(forceUtil.ENV_PROD, accessToken)) 26 | result must not (beNull) 27 | } 28 | 29 | "createApexClass" in new WithApplication { 30 | val loginResult = await(forceUtil.login(forceUtil.ENV_PROD, sys.env("FORCE_USERNAME"), sys.env("FORCE_PASSWORD"))) 31 | val accessToken = (loginResult \ "access_token").as[String] 32 | val result = await(forceUtil.createApexClass(forceUtil.ENV_PROD, accessToken, s"Fooo$name", s"public class Fooo$name { }")) 33 | result must not (beNull) 34 | } 35 | 36 | "createApexTrigger" in new WithApplication { 37 | val loginResult = await(forceUtil.login(forceUtil.ENV_PROD, sys.env("FORCE_USERNAME"), sys.env("FORCE_PASSWORD"))) 38 | val accessToken = (loginResult \ "access_token").as[String] 39 | val result = await(forceUtil.createApexTrigger(forceUtil.ENV_PROD, accessToken, "Barr", s"trigger Barr$name on Account (before insert) { }", "Account")) 40 | result must not (beNull) 41 | } 42 | 43 | "getApexTriggers" in new WithApplication { 44 | val loginResult = await(forceUtil.login(forceUtil.ENV_PROD, sys.env("FORCE_USERNAME"), sys.env("FORCE_PASSWORD"))) 45 | val accessToken = (loginResult \ "access_token").as[String] 46 | val result = await(forceUtil.getApexTriggers(forceUtil.ENV_PROD, accessToken)) 47 | result must not (beNull) 48 | } 49 | 50 | "createRemoteSite" in new WithApplication { 51 | val loginResult = await(forceUtil.login(forceUtil.ENV_PROD, sys.env("FORCE_USERNAME"), sys.env("FORCE_PASSWORD"))) 52 | val accessToken = (loginResult \ "access_token").as[String] 53 | val result = await(forceUtil.createRemoteSite(forceUtil.ENV_PROD, accessToken, s"FooSite$name", "https://foo.com")) 54 | result must not (beNull) 55 | } 56 | 57 | // todo: cleanup 58 | 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /app/apextemplates/classes/TriggerTest.scala.txt: -------------------------------------------------------------------------------- 1 | @(name: String, sobject: String, events: List[String], url: String)@@isTest 2 | public class @{name}WebhookTriggerTest { 3 | 4 | static SObject mock(String sobjectName) { 5 | SObjectType t = Schema.getGlobalDescribe().get(sobjectName); 6 | 7 | SObject o = t.newSobject(); 8 | 9 | Map m = t.getDescribe().fields.getMap(); 10 | 11 | for (String fieldName : m.keySet()) { 12 | DescribeFieldResult f = m.get(fieldName).getDescribe(); 13 | if (!f.isNillable() && f.isCreateable() && !f.isDefaultedOnCreate()) { 14 | if (f.getType() == DisplayType.Boolean) { 15 | o.put(f.getName(), false); 16 | } 17 | else if (f.getType() == DisplayType.Currency) { 18 | o.put(f.getName(), 0); 19 | } 20 | else if (f.getType() == DisplayType.Date) { 21 | o.put(f.getName(), Date.today()); 22 | } 23 | else if (f.getType() == DisplayType.DateTime) { 24 | o.put(f.getName(), System.now()); 25 | } 26 | else if (f.getType() == DisplayType.Double) { 27 | o.put(f.getName(), 0.0); 28 | } 29 | else if (f.getType() == DisplayType.Email) { 30 | o.put(f.getName(), 'foo@@foo.com'); 31 | } 32 | else if (f.getType() == DisplayType.Integer) { 33 | o.put(f.getName(), 0); 34 | } 35 | else if (f.getType() == DisplayType.Percent) { 36 | o.put(f.getName(), 0); 37 | } 38 | else if (f.getType() == DisplayType.Phone) { 39 | o.put(f.getName(), '555-555-1212'); 40 | } 41 | else if (f.getType() == DisplayType.String) { 42 | o.put(f.getName(), 'TEST'); 43 | } 44 | else if (f.getType() == DisplayType.TextArea) { 45 | o.put(f.getName(), 'TEST'); 46 | } 47 | else if (f.getType() == DisplayType.Time) { 48 | o.put(f.getName(), System.now().time()); 49 | } 50 | else if (f.getType() == DisplayType.URL) { 51 | o.put(f.getName(), 'http://foo.com'); 52 | } 53 | else if (f.getType() == DisplayType.PickList) { 54 | o.put(f.getName(), f.getPicklistValues()[0].getValue()); 55 | } 56 | } 57 | } 58 | return o; 59 | } 60 | 61 | @@isTest static void testTrigger() { 62 | SObject o = mock('@sobject'); 63 | 64 | Test.startTest(); 65 | insert o; 66 | update o; 67 | delete o; 68 | Test.stopTest(); 69 | 70 | System.assertEquals(200, Webhook.response.getStatusCode()); 71 | System.assertEquals('@url', Webhook.request.getEndpoint()); 72 | 73 | if (Webhook.request != null) { 74 | Map jsonResponse = (Map) JSON.deserializeUntyped(Webhook.request.getBody()); 75 | System.assertNotEquals(null, jsonResponse.get('userId')); 76 | } 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /app/assets/javascripts/app.js: -------------------------------------------------------------------------------- 1 | require(["angular"], function(angular) { 2 | 3 | angular.module('myApp', []). 4 | controller('WebhooksController', function($scope, $http) { 5 | 6 | var fetchWebhooks = function() { 7 | $http.get('/webhooks'). 8 | success(function(data) { 9 | $scope.webhooks = data; 10 | }). 11 | error(function(data) { 12 | $scope.errorMessage = data.error.message; 13 | }); 14 | }; 15 | 16 | var createWebhook = function(name, sobject, events, url, csrfToken) { 17 | var data = { 18 | name: name, 19 | sobject: sobject, 20 | events: events, 21 | url: url, 22 | csrfToken: csrfToken 23 | }; 24 | 25 | $http.post('/webhooks?csrfToken=' + csrfToken, data). 26 | success(function() { 27 | initForm(); 28 | fetchWebhooks(); 29 | }). 30 | error(function(data) { 31 | $scope.working = false; 32 | $scope.errorMessage = data.error.message; 33 | }); 34 | }; 35 | 36 | var fetchSobjects = function() { 37 | $http.get('/sobjects'). 38 | success(function(data) { 39 | $scope.sobjects = data; 40 | }). 41 | error(function(data) { 42 | $scope.errorMessage = data.error.message; 43 | }); 44 | }; 45 | 46 | var initForm = function() { 47 | if ($scope.errorMessage === undefined) { 48 | $scope.errorMessage = ""; 49 | } 50 | 51 | $scope.working = false; 52 | $scope.name = ""; 53 | $scope.sobject = null; 54 | $scope.events = {}; 55 | $scope.url = ""; 56 | }; 57 | 58 | var selectedEvents = function() { 59 | var selectedEvents = []; 60 | for (var event in $scope.events) { 61 | if($scope.events.hasOwnProperty(event)) { 62 | if ($scope.events[event]) { 63 | selectedEvents.push(event); 64 | } 65 | } 66 | } 67 | return selectedEvents; 68 | }; 69 | 70 | fetchWebhooks(); 71 | fetchSobjects(); 72 | initForm(); 73 | 74 | $scope.createWebhook = function() { 75 | if ($scope.webhookForm.name.$valid && $scope.webhookForm.sobject.$valid && selectedEvents().length > 0 && $scope.webhookForm.url.$valid) { 76 | $scope.errorMessage = ""; 77 | $scope.working = true; 78 | createWebhook($scope.name, $scope.sobject, selectedEvents(), $scope.url, $scope.csrfToken); 79 | } 80 | else { 81 | var error = ""; 82 | if ($scope.webhookForm.name.$invalid) { 83 | error += "Name must contain only letters. "; 84 | } 85 | if ($scope.webhookForm.sobject.$invalid) { 86 | error += "An SObject must be selected. "; 87 | } 88 | if (selectedEvents().length < 1) { 89 | error += "At least one event must be selected. "; 90 | } 91 | if ($scope.webhookForm.url.$invalid) { 92 | error += "A URL must be specified. "; 93 | } 94 | $scope.errorMessage = error; 95 | } 96 | }; 97 | }); 98 | 99 | }); 100 | -------------------------------------------------------------------------------- /test/ApplicationSpec.scala: -------------------------------------------------------------------------------- 1 | import core.{TriggerEvent, TriggerMetadata} 2 | import play.api.Application 3 | import play.api.http.HeaderNames 4 | import play.api.libs.json.Json 5 | import play.api.test.{FakeRequest, PlaySpecification, WithApplication} 6 | import utils.ForceUtil 7 | 8 | import scala.util.Random 9 | 10 | class ApplicationSpec extends PlaySpecification { 11 | 12 | def forceUtil(implicit app: Application): ForceUtil = { 13 | app.injector.instanceOf[ForceUtil] 14 | } 15 | 16 | def accessToken(implicit app: Application): String = { 17 | val loginResult = await(forceUtil(app).login(forceUtil(app).ENV_PROD, sys.env("FORCE_USERNAME"), sys.env("FORCE_PASSWORD"))) 18 | (loginResult \ "access_token").as[String] 19 | } 20 | 21 | lazy val name = Random.alphanumeric.take(8).mkString 22 | 23 | "Application" should { 24 | 25 | "createWebhook with valid credentials" in new WithApplication { 26 | 27 | val request = FakeRequest(POST, controllers.routes.Application.createWebhook.url) 28 | .withJsonBody(Json.toJson(TriggerMetadata(s"Contact$name", "Contact", List(TriggerEvent.BeforeInsert, TriggerEvent.AfterUpdate), "http://localhost/foo"))) 29 | .withSession( 30 | "oauthAccessToken" -> accessToken, 31 | "env" -> forceUtil.ENV_PROD 32 | ) 33 | 34 | val Some(result) = route(app, request) 35 | 36 | status(result) must equalTo(OK) 37 | } 38 | 39 | "createWebhook with invalid credentials is a bad request" in new WithApplication { 40 | val request = FakeRequest(POST, controllers.routes.Application.createWebhook.url) 41 | .withJsonBody(Json.toJson(TriggerMetadata("Foo", "Contact", List(TriggerEvent.BeforeInsert), "http://localhost/foo"))) 42 | .withSession( 43 | "oauthAccessToken" -> "FOO", 44 | "env" -> forceUtil.ENV_PROD 45 | ) 46 | 47 | val Some(result) = route(app, request) 48 | 49 | status(result) must equalTo(BAD_REQUEST) 50 | (contentAsJson(result) \ "error" \ "message").as[String] must equalTo("Bad_OAuth_Token") 51 | } 52 | 53 | "createWebhook without credentials is a redirect" in new WithApplication { 54 | val request = FakeRequest(POST, controllers.routes.Application.createWebhook.url).withJsonBody(Json.obj()) 55 | 56 | val Some(result) = route(app, request) 57 | 58 | status(result) must equalTo(SEE_OTHER) 59 | } 60 | 61 | "getWebhooks with valid credentials" in new WithApplication { 62 | 63 | val requestWithSession = FakeRequest(GET, controllers.routes.Application.getWebhooks.url) 64 | .withSession( 65 | "oauthAccessToken" -> accessToken, 66 | "env" -> forceUtil.ENV_PROD 67 | ) 68 | 69 | val Some(resultWithSession) = route(app, requestWithSession) 70 | 71 | status(resultWithSession) must equalTo(OK) 72 | (contentAsJson(resultWithSession) \\ "name").map(_.as[String]) must contain(s"Contact${name}WebhookTrigger") 73 | (contentAsJson(resultWithSession) \\ "sobject").map(_.as[String]) must contain("Contact") 74 | (contentAsJson(resultWithSession) \\ "url").map(_.as[String]) must contain("http://localhost/foo") 75 | 76 | val requestWithHeaders = FakeRequest(GET, controllers.routes.Application.getWebhooks.url) 77 | .withHeaders(HeaderNames.AUTHORIZATION -> s"Bearer $accessToken", "Env" -> forceUtil.ENV_PROD) 78 | 79 | val Some(resultWithHeaders) = route(app, requestWithHeaders) 80 | 81 | status(resultWithHeaders) must equalTo(OK) 82 | } 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /sbt.cmd: -------------------------------------------------------------------------------- 1 | @REM sbt launcher script 2 | @setlocal enabledelayedexpansion 3 | 4 | @echo off 5 | 6 | set ERROR_CODE=0 7 | set SBT_LAUNCH_JAR=%~dp0%sbt-launch.jar 8 | 9 | @REM Detect if we were double clicked, although theoretically A user could manually run cmd /c 10 | for %%x in (%cmdcmdline%) do if %%~x==/c set DOUBLECLICKED=1 11 | 12 | @REM We use the value of the JAVACMD environment variable if defined 13 | set _JAVACMD=%JAVACMD% 14 | 15 | if "%_JAVACMD%"=="" ( 16 | if not "%JAVA_HOME%"=="" ( 17 | if exist "%JAVA_HOME%\bin\java.exe" set "_JAVACMD=%JAVA_HOME%\bin\java.exe" 18 | 19 | @@REM if there is a java home set we make sure it is the first picked up when invoking 'java' 20 | SET "PATH=%JAVA_HOME%\bin;%PATH%" 21 | ) 22 | ) 23 | 24 | if "%_JAVACMD%"=="" set _JAVACMD=java 25 | 26 | @REM Detect if this java is ok to use. 27 | for /F %%j in ('"%_JAVACMD%" -version 2^>^&1') do ( 28 | if %%~j==java set JAVAINSTALLED=1 29 | if %%~j==openjdk set JAVAINSTALLED=1 30 | ) 31 | 32 | @REM Detect the same thing about javac 33 | if "%_JAVACCMD%"=="" ( 34 | if not "%JAVA_HOME%"=="" ( 35 | if exist "%JAVA_HOME%\bin\javac.exe" set "_JAVACCMD=%JAVA_HOME%\bin\javac.exe" 36 | ) 37 | ) 38 | if "%_JAVACCMD%"=="" set _JAVACCMD=javac 39 | for /F %%j in ('"%_JAVACCMD%" -version 2^>^&1') do ( 40 | if %%~j==javac set JAVACINSTALLED=1 41 | ) 42 | 43 | @REM Check what Java version is being used 44 | for /f "tokens=3" %%g in ('java -version 2^>^&1 ^| findstr /i "version"') do ( 45 | set JAVA_VERSION=%%g 46 | ) 47 | 48 | @REM Strips away the " characters 49 | set JAVA_VERSION=%JAVA_VERSION:"=% 50 | 51 | @REM Make sure Java 8 is installed 52 | for /f "delims=. tokens=1-3" %%v in ("%JAVA_VERSION%") do ( 53 | set MAJOR=%%v 54 | set MINOR=%%w 55 | set BUILD=%%x 56 | 57 | if "!MINOR!" GEQ "8" ( 58 | set HASJAVA8=true 59 | ) 60 | ) 61 | 62 | @REM BAT has no logical or, so we do it OLD SCHOOL! Oppan Redmond Style 63 | set JAVAOK=true 64 | if not defined JAVAINSTALLED set JAVAOK=false 65 | if not defined JAVACINSTALLED set JAVAOK=false 66 | if not defined HASJAVA8 set JAVAOK=false 67 | 68 | if "%JAVAOK%"=="false" ( 69 | echo. 70 | echo A Java 8 JDK is not installed or can't be found. 71 | if not "%JAVA_HOME%"=="" ( 72 | echo JAVA_HOME = "%JAVA_HOME%" 73 | ) 74 | echo. 75 | echo Please go to 76 | echo http://www.oracle.com/technetwork/java/javase/downloads/index.html 77 | echo and download a valid Java 8 JDK and install before running sbt. 78 | echo. 79 | echo If you think this message is in error, please check 80 | echo your environment variables to see if "java.exe" and "javac.exe" are 81 | echo available via JAVA_HOME or PATH. 82 | echo. 83 | if defined DOUBLECLICKED pause 84 | exit /B 1 85 | ) 86 | 87 | if "%~1"=="shell" ( 88 | set CMDS= 89 | ) else ( 90 | if "%~1"=="" ( 91 | set CMDS=default 92 | ) else ( 93 | set CMDS=%~1 94 | ) 95 | ) 96 | 97 | set SBT_OPTS=-Xms512M -Xmx1024M -Xss1M -XX:MetaspaceSize=64M -XX:MaxMetaspaceSize=256M -XX:+CMSClassUnloadingEnabled 98 | 99 | @REM Checks if the command contains spaces to know if it should be wrapped in quotes or not 100 | set NON_SPACED_CMD=%_JAVACMD: =% 101 | 102 | @REM Run sbt 103 | if "%_JAVACMD%"=="%NON_SPACED_CMD%" %_JAVACMD% %SBT_OPTS% -jar "%SBT_LAUNCH_JAR%" %CMDS% 104 | if NOT "%_JAVACMD%"=="%NON_SPACED_CMD%" "%_JAVACMD%" %SBT_OPTS% -jar "%SBT_LAUNCH_JAR%" %CMDS% 105 | 106 | if ERRORLEVEL 1 goto error 107 | goto end 108 | 109 | :error 110 | set ERROR_CODE=1 111 | 112 | :end 113 | 114 | @endlocal 115 | 116 | exit /B %ERROR_CODE% -------------------------------------------------------------------------------- /app/views/app.scala.html: -------------------------------------------------------------------------------- 1 | @this(webJarsUtil: org.webjars.play.WebJarsUtil) 2 | 3 | @(implicit requestHeader: RequestHeader) 4 | 5 | @import helper._ 6 | 7 | 8 | 9 | 10 | Salesforce Webhook Creator 11 | @webJarsUtil.locate("bootstrap.min.css").css() 12 | 13 | 14 | 15 | @webJarsUtil.requireJs(routes.Assets.versioned("javascripts/app.js")) 16 | 17 | 18 | 26 | 27 |
28 |
29 |

Your Webhooks

30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
NameSObjectEventsURL
{{webhook.name}}{{webhook.sobject}}{{webhook.events.join(", ")}}{{webhook.url}}
48 |

Create a new Webhook

49 |
50 |
51 |
52 | 53 |
54 | 55 |
56 |
57 |
58 | 59 |
60 | 63 |
64 |
65 |
66 | 67 |
68 | 71 |
72 |
73 |
74 | 75 |
76 | 77 |
78 |
79 |
80 |
{{errorMessage}}
81 |
82 |
83 |
84 | 85 |
86 |
87 |
88 |
89 |
90 |
91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /app/controllers/Application.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import javax.inject.Inject 4 | import core.TriggerEvent.TriggerEvent 5 | import core.{TriggerEvent, TriggerMetadata} 6 | import play.api.http.{HeaderNames, HttpVerbs, Writeable} 7 | import play.api.libs.json._ 8 | import play.api.libs.ws.WSClient 9 | import play.api.mvc._ 10 | import utils.ForceUtil 11 | 12 | import scala.concurrent.{ExecutionContext, Future} 13 | 14 | class Application @Inject() (forceUtil: ForceUtil, ws: WSClient) 15 | (indexView: views.html.index, appView: views.html.app) 16 | (implicit ec: ExecutionContext) extends InjectedController { 17 | 18 | case class Error(message: String, code: Option[String] = None) 19 | object Error { 20 | implicit def jsonFormat: Format[Error] = Json.format[Error] 21 | } 22 | 23 | case class ErrorResponse(error: Error) 24 | 25 | object ErrorResponse { 26 | 27 | def fromThrowable(t: Throwable): ErrorResponse = { 28 | ErrorResponse(Error(t.getMessage)) 29 | } 30 | 31 | implicit def jsonFormat: Format[ErrorResponse] = Json.format[ErrorResponse] 32 | } 33 | 34 | implicit def jsWriteable[A](implicit wa: Writes[A], wjs: Writeable[JsValue]): Writeable[A] = wjs.map(Json.toJson(_)) 35 | 36 | 37 | def index = Action { request => 38 | if (request.session.get("oauthAccessToken").isDefined && request.session.get("env").isDefined) { 39 | Redirect(routes.Application.app()) 40 | } 41 | else { 42 | Ok(indexView(request)) 43 | } 44 | } 45 | 46 | def logout = Action { 47 | Redirect(routes.Application.index).withNewSession 48 | } 49 | 50 | def getSobjects = ConnectionAction.async { request => 51 | val debug = request.session.get("debug").flatMap(_.toBooleanOption).getOrElse(false) 52 | 53 | forceUtil.getSobjects(request.env, request.sessionId, debug).map { sobjects => 54 | val triggerables = sobjects.filter(_.\("triggerable").asOpt[Boolean].contains(true)).map(_.\("name").as[String]) 55 | if (triggerables.isEmpty) { 56 | BadRequest(ErrorResponse(Error("No triggerable SObjects found"))) 57 | } 58 | else { 59 | Ok(triggerables) 60 | } 61 | } recover { 62 | case e: Exception => BadRequest(ErrorResponse(Error(e.getMessage))) 63 | } 64 | } 65 | 66 | def getWebhooks = ConnectionAction.async { request => 67 | forceUtil.getApexTriggers(request.env, request.sessionId).map { triggers => 68 | val rawWebhooks = triggers.filter(_.\("Name").as[String].endsWith("WebhookTrigger")) 69 | 70 | val webhooks = rawWebhooks.map { webhook => 71 | val name = (webhook \ "Name").as[String] 72 | val body = (webhook \ "Body").as[String] 73 | val firstLine = body.linesIterator.next() 74 | val sobject = firstLine.split(" ")(3) 75 | val eventsString = firstLine.substring(firstLine.indexOf("(") + 1, firstLine.indexOf(")")) 76 | val events: List[TriggerEvent] = eventsString.split(",").map(TriggerEvent.withName).toList 77 | val url = body.substring(body.indexOf("String url = '") + 14, body.indexOf("';")) 78 | TriggerMetadata(name, sobject, events, url) 79 | } 80 | 81 | Ok(webhooks) 82 | } recover { 83 | case e: Exception => BadRequest(ErrorResponse(Error(e.getMessage))) 84 | } 85 | } 86 | 87 | def createWebhook = ConnectionAction.async(parse.json) { request => 88 | 89 | val maybeTriggerMetadata = request.body.asOpt[TriggerMetadata] 90 | 91 | maybeTriggerMetadata.fold(Future.successful(BadRequest(ErrorResponse(Error("Missing required fields"))))) { triggerMetadata => 92 | 93 | // gonna do these sequentially because the apex is dependent 94 | val webhookCreateFuture = for { 95 | webhookCreate <- forceUtil.createApexClass(request.env, request.sessionId, "Webhook", apextemplates.classes.txt.Webhook().body).recover { 96 | // ignore failure due to duplicate 97 | // todo: update 98 | case e: forceUtil.DuplicateException => Json.obj() 99 | } 100 | 101 | remoteSiteSettingCreate <- forceUtil.createRemoteSite(request.env, request.sessionId, triggerMetadata.name + "RemoteSiteSetting", triggerMetadata.url) 102 | 103 | triggerBody = apextemplates.triggers.txt.WebhookTrigger(triggerMetadata.name, triggerMetadata.sobject, triggerMetadata.events.map(_.toString), triggerMetadata.url).body 104 | triggerCreate <- forceUtil.createApexTrigger(request.env, request.sessionId, triggerMetadata.name, triggerBody, triggerMetadata.sobject) 105 | 106 | triggerTestBody = apextemplates.classes.txt.TriggerTest(triggerMetadata.name, triggerMetadata.sobject, triggerMetadata.events.map(_.toString), triggerMetadata.url).body 107 | triggerTestCreate <- forceUtil.createApexClass(request.env, request.sessionId, triggerMetadata.name, triggerTestBody) 108 | } yield (webhookCreate, remoteSiteSettingCreate, triggerCreate, triggerTestCreate) 109 | 110 | webhookCreateFuture.map(_ => Ok(Results.EmptyContent())).recover { 111 | case e: Exception => BadRequest(ErrorResponse(Error(e.getMessage))) 112 | } 113 | } 114 | } 115 | 116 | def app(debug: Boolean) = ConnectionAction { implicit request => 117 | Ok(appView(request)).addingToSession("debug" -> debug.toString) 118 | } 119 | 120 | def oauthCallback(code: String, env: String) = Action.async { implicit request => 121 | val url = forceUtil.tokenUrl(env) 122 | 123 | val wsFuture = ws.url(url).withQueryStringParameters( 124 | "grant_type" -> "authorization_code", 125 | "client_id" -> forceUtil.consumerKey, 126 | "client_secret" -> forceUtil.consumerSecret, 127 | "redirect_uri" -> forceUtil.redirectUri, 128 | "code" -> code 129 | ).execute(HttpVerbs.POST) 130 | 131 | wsFuture.map { response => 132 | 133 | val maybeAppResponse = for { 134 | accessToken <- (response.json \ "access_token").asOpt[String] 135 | // todo? instanceUrl <- (response.json \ "instance_url").asOpt[String] 136 | } yield { 137 | Redirect(routes.Application.app()).withSession("oauthAccessToken" -> accessToken, "env" -> env) 138 | } 139 | 140 | maybeAppResponse.getOrElse(Redirect(routes.Application.index).flashing("error" -> "Could not authenticate")) 141 | } 142 | } 143 | 144 | 145 | class ConnectionRequest[A](val sessionId: String, val env: String, request: Request[A]) extends WrappedRequest[A](request) 146 | 147 | def ConnectionAction = new ActionBuilder[ConnectionRequest, AnyContent] with ActionRefiner[Request, ConnectionRequest] { 148 | override protected def executionContext: ExecutionContext = ec 149 | 150 | override def parser: BodyParser[AnyContent] = parse.anyContent 151 | 152 | override protected def refine[A](request: Request[A]): Future[Either[Result, ConnectionRequest[A]]] = Future.successful { 153 | 154 | val maybeSessionId = request.session.get("oauthAccessToken").orElse(request.headers.get(HeaderNames.AUTHORIZATION).map(_.stripPrefix("Bearer "))) 155 | val maybeEnv = request.session.get("env").orElse(request.headers.get("Env")) 156 | 157 | val maybeSessionIdAndEnv = for { 158 | sessionId <- maybeSessionId 159 | env <- maybeEnv 160 | } yield new ConnectionRequest(sessionId, env, request) 161 | 162 | maybeSessionIdAndEnv.toRight(Redirect(routes.Application.index).withNewSession) 163 | } 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /app/utils/ForceUtil.scala: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import javax.inject.Inject 4 | import play.api.{Configuration, Logger} 5 | import play.api.http.{HeaderNames, Status} 6 | import play.api.libs.json.{JsObject, JsValue, Json} 7 | import play.api.libs.ws.{WSClient, WSRequest, WSResponse} 8 | import play.api.mvc.RequestHeader 9 | 10 | import scala.concurrent.{ExecutionContext, Future} 11 | import scala.util.Try 12 | import scala.xml.Elem 13 | 14 | class ForceUtil @Inject()(configuration: Configuration, ws: WSClient)(implicit ec: ExecutionContext) { 15 | 16 | val logger = Logger(this.getClass) 17 | 18 | val API_VERSION = "49.0" 19 | 20 | val consumerKey = configuration.get[String]("force.oauth.consumer-key") 21 | val consumerSecret = configuration.get[String]("force.oauth.consumer-secret") 22 | 23 | val ENV_PROD = "prod" 24 | val ENV_SANDBOX = "sandbox" 25 | val SALESFORCE_ENV = "salesforce-env" 26 | 27 | def redirectUri(implicit request: RequestHeader): String = { 28 | controllers.routes.Application.oauthCallback("", "").absoluteURL().replace("?code=&state=", "") 29 | } 30 | 31 | def loginUrl(env: String)(implicit request: RequestHeader): String = { 32 | env match { 33 | case env @ ENV_PROD => s"https://login.salesforce.com/services/oauth2/authorize?response_type=code&client_id=$consumerKey&redirect_uri=$redirectUri&state=$env" 34 | case env @ ENV_SANDBOX => s"https://test.salesforce.com/services/oauth2/authorize?response_type=code&client_id=$consumerKey&redirect_uri=$redirectUri&state=$env" 35 | } 36 | } 37 | 38 | def login(env: String, username: String, password: String): Future[JsValue] = { 39 | val body = Map( 40 | "grant_type" -> "password", 41 | "client_id" -> consumerKey, 42 | "client_secret" -> consumerSecret, 43 | "username" -> username, 44 | "password" -> password 45 | ) 46 | 47 | ws.url(tokenUrl(env)).post(body).flatMap { response => 48 | response.status match { 49 | case Status.OK => Future.successful(response.json) 50 | case _ => Future.failed(new Exception(response.body)) 51 | } 52 | } 53 | } 54 | 55 | def tokenUrl(env: String): String = env match { 56 | case ENV_PROD => "https://login.salesforce.com/services/oauth2/token" 57 | case ENV_SANDBOX => "https://test.salesforce.com/services/oauth2/token" 58 | } 59 | 60 | def userinfoUrl(env: String): String = env match { 61 | case ENV_PROD => "https://login.salesforce.com/services/oauth2/userinfo" 62 | case ENV_SANDBOX => "https://test.salesforce.com/services/oauth2/userinfo" 63 | } 64 | 65 | def ws(url: String, sessionId: String): WSRequest = { 66 | ws.url(url).withHttpHeaders(HeaderNames.AUTHORIZATION -> s"Bearer $sessionId", "Sforce-Query-Options" -> "batchSize=2000") 67 | } 68 | 69 | def userinfo(env: String, sessionId: String, debug: Boolean = false): Future[JsValue] = { 70 | ws(userinfoUrl(env), sessionId).get().flatMap { response => 71 | if (debug) { 72 | logger.info(response.body) 73 | } 74 | response.status match { 75 | case Status.OK => Future.successful(response.json) 76 | case _ => Future.failed(new Exception(response.body)) 77 | } 78 | } 79 | } 80 | 81 | def apiUrl(env: String, sessionId: String, path: String): Future[String] = { 82 | userinfo(env, sessionId).flatMap { userinfo => 83 | (userinfo \ "urls" \ path).asOpt[String].map(_.replace("{version}", API_VERSION)).fold { 84 | Future.failed[String](new Exception(s"Could not get the $path URL")) 85 | } (Future.successful) 86 | } 87 | } 88 | 89 | def restUrl(env: String, sessionId: String): Future[String] = { 90 | apiUrl(env, sessionId, "rest") 91 | } 92 | 93 | def metadataUrl(env: String, sessionId: String): Future[String] = { 94 | apiUrl(env, sessionId, "metadata") 95 | } 96 | 97 | def getSobjects(env: String, sessionId: String, debug: Boolean = false): Future[Seq[JsObject]] = { 98 | restUrl(env, sessionId).flatMap { restUrl => 99 | ws(restUrl + "sobjects", sessionId).get().flatMap { response => 100 | if (debug) { 101 | logger.info(response.body) 102 | } 103 | response.status match { 104 | case Status.OK => Future.successful((response.json \ "sobjects").as[Seq[JsObject]]) 105 | case _ => Future.failed(new Exception(response.body)) 106 | } 107 | } 108 | } 109 | } 110 | 111 | private def createdResponseToJson(response: WSResponse): Future[JsValue] = { 112 | def message(json: JsValue): String = (json \\ "message").map(_.as[String]).mkString("\n") 113 | 114 | response.status match { 115 | case Status.CREATED => 116 | Future.successful(response.json) 117 | 118 | case Status.BAD_REQUEST if (response.json \\ "errorCode").headOption.flatMap(_.asOpt[String]).contains("DUPLICATE_VALUE") => 119 | Future.failed(DuplicateException(message(response.json))) 120 | 121 | case Status.BAD_REQUEST if (response.json \\ "errorCode").nonEmpty => 122 | Future.failed(ErrorException(message(response.json))) 123 | 124 | case _ => 125 | Future.failed { 126 | val errorMessage = Try(message(response.json)).getOrElse(response.body) 127 | new Exception(errorMessage) 128 | } 129 | } 130 | } 131 | 132 | def createApexClass(env: String, sessionId: String, name: String, body: String): Future[JsValue] = { 133 | restUrl(env, sessionId).flatMap { restUrl => 134 | val json = Json.obj( 135 | "ApiVersion" -> API_VERSION, 136 | "Body" -> body, 137 | "Name" -> name 138 | ) 139 | ws(restUrl + "tooling/sobjects/ApexClass", sessionId).post(json).flatMap(createdResponseToJson) 140 | } 141 | } 142 | 143 | def createApexTrigger(env: String, sessionId: String, name: String, body: String, sobject: String): Future[JsValue] = { 144 | restUrl(env, sessionId).flatMap { restUrl => 145 | val json = Json.obj( 146 | "ApiVersion" -> API_VERSION, 147 | "Name" -> name, 148 | "TableEnumOrId" -> sobject, 149 | "Body" -> body 150 | ) 151 | ws(restUrl + "tooling/sobjects/ApexTrigger", sessionId).post(json).flatMap(createdResponseToJson) 152 | } 153 | } 154 | 155 | def getApexTriggers(env: String, sessionId: String): Future[Seq[JsObject]] = { 156 | restUrl(env, sessionId).flatMap { restUrl => 157 | ws(restUrl + "tooling/query", sessionId).withQueryStringParameters("q" -> "SELECT Name, Body from ApexTrigger").get().flatMap { response => 158 | response.status match { 159 | case Status.OK => Future.successful((response.json \ "records").as[Seq[JsObject]]) 160 | case _ => Future.failed(new Exception(response.body)) 161 | } 162 | } 163 | } 164 | } 165 | 166 | // this happens via the SOAP API because it isn't exposed via the REST API 167 | def createRemoteSite(env: String, sessionId: String, name: String, url: String): Future[Elem] = { 168 | metadataUrl(env, sessionId).flatMap { metadataUrl => 169 | val xml = 170 | 171 | 172 | {sessionId} 173 | 174 | 175 | 176 | 177 | 178 | {name} 179 | true 180 | {url} 181 | 182 | 183 | 184 | 185 | 186 | ws(metadataUrl, sessionId).withHttpHeaders("SOAPAction" -> "RemoteSiteSetting", "Content-type" -> "text/xml").post(xml).flatMap { response => 187 | response.status match { 188 | case Status.OK => Future.successful(response.xml) 189 | case _ => Future.failed(new Exception(response.body)) 190 | } 191 | } 192 | } 193 | } 194 | 195 | case class DuplicateException(message: String) extends Exception { 196 | override def getMessage = message 197 | } 198 | 199 | case class ErrorException(message: String) extends Exception { 200 | override def getMessage = message 201 | } 202 | 203 | } 204 | --------------------------------------------------------------------------------