├── typesafe └── src │ ├── test │ ├── resources │ │ └── application.conf │ └── scala │ │ └── TypesafeSpec.scala │ └── main │ └── scala │ └── solicitor │ └── backend │ └── Typesafe.scala ├── .gitignore ├── http └── src │ ├── main │ ├── resources │ │ └── application.conf │ └── scala │ │ └── solicitor │ │ └── backend │ │ └── HTTP.scala │ └── test │ └── scala │ └── HTTPSpec.scala ├── zk └── src │ ├── main │ ├── resources │ │ └── log4j.properties │ └── scala │ │ └── solicitor │ │ └── backend │ │ └── Zk.scala │ └── test │ └── scala │ └── ZkSpec.scala ├── consul └── src │ ├── test │ └── scala │ │ └── ConsulSpec.scala │ └── main │ └── scala │ └── solicitor │ └── backend │ └── Consul.scala ├── core └── src │ ├── main │ └── scala │ │ └── solicitor │ │ ├── backend │ │ └── Static.scala │ │ ├── Backend.scala │ │ └── Client.scala │ └── test │ └── scala │ ├── ClientSpec.scala │ ├── StaticSpec.scala │ └── ConversionSpec.scala ├── LICENSE ├── project └── Build.scala └── README.md /typesafe/src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | foo=42 2 | dev.foo=57 3 | prod.foo=10 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | project/project 2 | project/target 3 | target 4 | tmp 5 | .idea/ 6 | .idea_modules/ 7 | -------------------------------------------------------------------------------- /http/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | log-dead-letters = off 3 | log-dead-letters-during-shutdown = off 4 | } 5 | spray.can { 6 | client { 7 | parsing { 8 | illegal-header-warnings = off 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /zk/src/main/resources/log4j.properties: -------------------------------------------------------------------------------- 1 | log4j.propertieslog4j.rootLogger=DEBUG, STDOUT 2 | log4j.logger.deng=INFO 3 | log4j.appender.STDOUT=org.apache.log4j.ConsoleAppender 4 | log4j.appender.STDOUT.layout=org.apache.log4j.PatternLayout 5 | log4j.appender.STDOUT.layout.ConversionPattern=%5p [%t] (%F:%L) - %m%n 6 | -------------------------------------------------------------------------------- /typesafe/src/test/scala/TypesafeSpec.scala: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import org.specs2.mutable.Specification 4 | import solicitor.Client 5 | import solicitor.backend.Typesafe 6 | 7 | class TypesafeSpec extends Specification { 8 | 9 | 10 | "Typesafe backend" should { 11 | 12 | "load files and work" in { 13 | val client = new Client( 14 | // Note that the URL doesn't matter here. Typesafe Config 15 | // picks up application.conf from resources, as it hunts 16 | // for that via Class.getResource 17 | backend = new Typesafe() 18 | ) 19 | client.getDouble("foo") must beSome 20 | client.getDouble("foo").get must beEqualTo(42D) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /consul/src/test/scala/ConsulSpec.scala: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import org.specs2.mutable.Specification 4 | import org.specs2.specification.Scope 5 | import solicitor.backend.Consul 6 | import solicitor.Client 7 | 8 | class ConsulSpec extends Specification { 9 | 10 | sequential 11 | 12 | "Consul Backend" should { 13 | 14 | "handle 200" in new httpBin { 15 | 16 | val res = client.getString("poop") 17 | res must beSome 18 | res.get must contain("butt") 19 | } 20 | 21 | "handle 404" in new httpBin { 22 | 23 | client.getString("getXXX") must beNone 24 | } 25 | } 26 | } 27 | 28 | trait httpBin extends Scope { 29 | val client = new Client(backend = new Consul( 30 | hosts = Seq(("localhost", 8500)) 31 | )) 32 | } 33 | -------------------------------------------------------------------------------- /core/src/main/scala/solicitor/backend/Static.scala: -------------------------------------------------------------------------------- 1 | package solicitor.backend 2 | 3 | import scala.concurrent._ 4 | import scala.concurrent.ExecutionContext.Implicits.global 5 | import solicitor.Backend 6 | 7 | /** 8 | * Provides a simple Map of keys and values for testing or mocking up 9 | * Solicitor. Not advised for production use unless you are stubbing 10 | * out code and need to provide a safe intermediary configuration before 11 | * choosing a real backend. 12 | * 13 | * Note that this uses String,String because all other backends will likely 14 | * do string conversion. 15 | */ 16 | class Static(values: Map[String,String]) extends Backend { 17 | 18 | /** 19 | * Gets a value from the Static map. No type converstion takes place. 20 | */ 21 | def getString(name: String): Future[Option[String]] = future { values.get(name) } 22 | } -------------------------------------------------------------------------------- /http/src/test/scala/HTTPSpec.scala: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import org.specs2.mutable.Specification 4 | import org.specs2.specification.Scope 5 | import solicitor.backend.HTTP 6 | import solicitor.Client 7 | 8 | class HTTPSpec extends Specification { 9 | 10 | sequential 11 | 12 | "HTTP Backend" should { 13 | 14 | "handle 200" in new httpBin { 15 | 16 | val res = client.getString("foo/bar") 17 | res must beSome 18 | res.get must contain("123") 19 | } 20 | 21 | "handle 404" in new httpBin { 22 | 23 | client.getString("getXXX") must beNone 24 | } 25 | 26 | "handle no answer" in new httpNope { 27 | 28 | client.getString("getXXX") must beNone 29 | } 30 | } 31 | } 32 | 33 | trait httpBin extends Scope { 34 | val client = new Client(backend = new HTTP( 35 | hosts = Seq(("localhost", 8000)) 36 | )) 37 | } 38 | 39 | trait httpNope extends Scope { 40 | val client = new Client(backend = new HTTP( 41 | hosts = Seq(("thisdomainprobablywonteverexist.com", 80)) 42 | )) 43 | } -------------------------------------------------------------------------------- /zk/src/test/scala/ZkSpec.scala: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import org.specs2.mutable.Specification 4 | import org.specs2.specification.Scope 5 | import solicitor.backend.Zk 6 | import solicitor.Client 7 | 8 | class ZkSpec extends Specification { 9 | 10 | sequential 11 | 12 | "Zk Backend" should { 13 | 14 | "handle zk connect string" in { 15 | val zk = new Zk( 16 | hosts = Seq( 17 | ("localhost", 8500), 18 | ("localhost2", 8501) 19 | ), 20 | path = "/poop" 21 | ) 22 | zk.getZkConnectionString must beEqualTo("localhost:8500,localhost2:8501/poop") 23 | } 24 | } 25 | 26 | "handle get of string" in new realZk { 27 | client.getString("foo") must beNone 28 | Thread.sleep(5000) 29 | client.getString("foo") must beSome("bar") 30 | client.getString("foo") must beSome("bar") // again! 31 | } 32 | } 33 | 34 | trait realZk extends Scope { 35 | val client = new Client(backend = new Zk( 36 | hosts = Seq( 37 | ("localhost", 2181) 38 | ) 39 | )) 40 | } 41 | -------------------------------------------------------------------------------- /typesafe/src/main/scala/solicitor/backend/Typesafe.scala: -------------------------------------------------------------------------------- 1 | package solicitor.backend 2 | 3 | import com.typesafe.config.ConfigFactory 4 | import java.net.URL 5 | import scala.concurrent._ 6 | import scala.concurrent.ExecutionContext.Implicits.global 7 | import scala.util.Try 8 | import solicitor.Backend 9 | 10 | // XXX A reload time, perhaps? 11 | class Typesafe(url: Option[String] = None) extends Backend { 12 | 13 | // If we didn't get a URL, use load to pick things up 14 | // from the classpath. 15 | val config = url.map({ 16 | u => ConfigFactory.parseURL(new URL(u)) 17 | }).getOrElse(ConfigFactory.load) 18 | 19 | override def getString(name: String): Future[Option[String]] = { 20 | future { 21 | Try(Some(config.getString(name))).getOrElse(None) 22 | } 23 | } 24 | 25 | override def getBoolean(name: String): Future[Option[Boolean]] = { 26 | future { 27 | Try(Some(config.getBoolean(name))).getOrElse(None) 28 | } 29 | } 30 | 31 | override def getDouble(name: String): Future[Option[Double]] = { 32 | future { 33 | Try(Some(config.getDouble(name))).getOrElse(None) 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Cory G Watson 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 | -------------------------------------------------------------------------------- /core/src/test/scala/ClientSpec.scala: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import org.specs2.mutable.Specification 4 | import org.specs2.specification.Scope 5 | import scala.concurrent._ 6 | import scala.concurrent.duration._ 7 | import scala.concurrent.ExecutionContext.Implicits.global 8 | import solicitor.backend.Static 9 | import solicitor.{Backend,Client} 10 | 11 | class ClientSpec extends Specification { 12 | 13 | sequential 14 | 15 | "Client" should { 16 | 17 | "handle missing values" in new badBackend { 18 | client.getString("three") must beNone 19 | } 20 | 21 | "handle defaults" in new badBackend { 22 | client.getBoolean("three", Some(true)).get must beEqualTo(true) 23 | client.getBoolean("three", Some(false)).get must beEqualTo(false) 24 | 25 | client.getDouble("three", Some(1D)).get must beEqualTo(1D) 26 | 27 | client.getString("foo", Some("bar")).get must beEqualTo("bar") 28 | } 29 | } 30 | } 31 | 32 | class FailingBackend extends Backend { 33 | def getString(name: String): Future[Option[String]] = future { 34 | throw new RuntimeException("BLAH BLAAH") 35 | // return None 36 | } 37 | } 38 | 39 | trait badBackend extends Scope { 40 | val client = new Client(backend = new FailingBackend()) 41 | } 42 | -------------------------------------------------------------------------------- /core/src/test/scala/StaticSpec.scala: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import org.specs2.mutable.Specification 4 | import org.specs2.specification.Scope 5 | import scala.concurrent.Await 6 | import scala.concurrent.duration._ 7 | import solicitor.backend.Static 8 | import solicitor.Client 9 | 10 | class StaticSpec extends Specification { 11 | 12 | sequential 13 | 14 | "Static Backend" should { 15 | 16 | "handle present" in new staticMap { 17 | 18 | val res = client.getString("foo") 19 | res must beSome 20 | res.get must contain("bar") 21 | } 22 | 23 | "handle absent" in new staticMap { 24 | 25 | client.getString("getXXX") must beNone 26 | } 27 | 28 | "handle boolean" in new staticMap { 29 | client.isEnabled("yes") must beTrue 30 | client.isEnabled("no") must beFalse 31 | client.isDisabled("no") must beTrue 32 | client.isDisabled("yes") must beFalse 33 | } 34 | 35 | "handle deciding percentages" in new staticMap { 36 | client.decideEnabled("gorch") must beTrue 37 | client.decideEnabled("glom") must beFalse 38 | client.decideEnabled("glump") must beOneOf(true, false) 39 | } 40 | } 41 | } 42 | 43 | trait staticMap extends Scope { 44 | val client = new Client(backend = new Static( 45 | Map( 46 | "foo" -> "bar", 47 | "yes" -> "true", 48 | "no" -> "false", 49 | "gorch" -> "1.0", 50 | "glom" -> "0.0", 51 | "glup" -> "0.5" 52 | ) 53 | )) 54 | } 55 | -------------------------------------------------------------------------------- /core/src/test/scala/ConversionSpec.scala: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import org.specs2.mutable.Specification 4 | import org.specs2.specification.Scope 5 | import scala.concurrent.Await 6 | import scala.concurrent.duration._ 7 | import solicitor.backend.Static 8 | import solicitor.Client 9 | 10 | class ConversionSpec extends Specification { 11 | 12 | sequential 13 | 14 | "Conversions" should { 15 | 16 | "handle string" in new exampleMap { 17 | 18 | val res = client.getString("string") 19 | res must beSome 20 | res.get must contain("asdasdasd") 21 | } 22 | 23 | "handle double" in new exampleMap { 24 | 25 | val res = client.getDouble("double") 26 | res must beSome 27 | res.get must beEqualTo(0.34D) 28 | } 29 | 30 | "handle booleans" in new exampleMap { 31 | client.getBoolean("one").get must beEqualTo(false) 32 | client.getBoolean("zero").get must beEqualTo(false) 33 | client.getBoolean("booleanTrue").get must beEqualTo(true) 34 | client.getBoolean("booleanFalse").get must beEqualTo(false) 35 | client.getBoolean("booleanTRue").get must beEqualTo(true) 36 | client.getBoolean("booleanFAlse").get must beEqualTo(false) 37 | } 38 | } 39 | } 40 | 41 | trait exampleMap extends Scope { 42 | val client = new Client(backend = new Static(Map( 43 | "one" -> "1", 44 | "zero" -> "0", 45 | "longzero" -> "000000", 46 | "double" -> "0.34", 47 | "string" -> "asdasdasd", 48 | "booleanTrue" -> "true", 49 | "booleanFalse" -> "false", 50 | "booleanTRue" -> "TRue", 51 | "booleanFAlse" -> "FAlse" 52 | ))) 53 | } 54 | -------------------------------------------------------------------------------- /consul/src/main/scala/solicitor/backend/Consul.scala: -------------------------------------------------------------------------------- 1 | package solicitor.backend 2 | 3 | import org.apache.commons.codec.binary.Base64 4 | import scala.concurrent._ 5 | import scala.concurrent.ExecutionContext.Implicits.global 6 | import spray.json._ 7 | import spray.json.DefaultJsonProtocol._ 8 | 9 | /** 10 | * Provides a backend for fetching information from [[Consul's http://www.consul.io/]] 11 | * KV HTTP API. All values are assumed to be UTF-8 strings and any status code 12 | * other than 200 is considered a failure and returns None. 13 | * {{{ 14 | * val client = new Client(backend = HTTP(hosts = Consul("www.example.com", 8500))) 15 | * // Fetches http://www.example.com/v1/kv/foo/bar 16 | * val fooBar = client.getValue("foo/bar") // String 17 | * }}} 18 | * 19 | * This backend uses the Solicitior HTTP backend and follows the same behaviors. 20 | */ 21 | class Consul(hosts: Seq[(String, Int)]) extends HTTP(hosts) { 22 | 23 | case class V1ConsulBody( 24 | CreateIndex: Long, 25 | ModifyIndex: Long, 26 | Key: String, 27 | Flags: Long, 28 | Value: String 29 | ) 30 | 31 | object ConsulJsonProtocol extends DefaultJsonProtocol { 32 | implicit val v1ConsulFormat = jsonFormat5(V1ConsulBody) 33 | } 34 | 35 | import ConsulJsonProtocol._ 36 | 37 | val v1kvPath = "v1/kv/" 38 | 39 | override def getString(name: String): Future[Option[String]] = { 40 | debug("Fetching path from consul: " + v1kvPath + name) 41 | super.getString(v1kvPath + name) map { maybeBody => 42 | maybeBody map { body => 43 | // Consul returns a list of one element. Take the first one! 44 | val consulValue = body.parseJson.convertTo[Seq[V1ConsulBody]] 45 | consulValue.headOption map { item => 46 | // Consul Base64 encodes its strings. We'll assume it's UTF-8 47 | new String(Base64.decodeBase64(item.Value), "UTF-8") 48 | } 49 | } getOrElse(None) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /core/src/main/scala/solicitor/Backend.scala: -------------------------------------------------------------------------------- 1 | package solicitor 2 | 3 | import scala.concurrent.ExecutionContext.Implicits.global 4 | import scala.concurrent.Future 5 | import scala.util.Try 6 | 7 | /** 8 | * Trait for implementing backends for Solicitor. Implementations need 9 | * only implement the getValue method and return a Future[Option[String]]. 10 | * 11 | * ==Types== 12 | * Types in Solicitor are very naive. You may convert a string to a Boolean, 13 | * in which case a case-insensitive check is done for "true" or "false". 14 | * 15 | * Any number is converted to a double if it can be successfully converted 16 | * via toDouble. Any failure to convert will be translated as a None. 17 | * 18 | * Otherwise, getValue can return a string and the user of Solicitor can 19 | * do their own conversion. Double was chosen for easy implementation of 20 | * percentages and boolean for obvious reasons. 21 | */ 22 | trait Backend { 23 | 24 | /** 25 | * Get a value from the backend. 26 | * 27 | * @param name Name of key 28 | */ 29 | def getString(name: String): Future[Option[String]] 30 | 31 | /** 32 | * Get a value and convert the result to boolean. Works only with 33 | * case-insensitive comparisons to "true" and "false". 34 | * 35 | * @param name Name of key 36 | */ 37 | def getBoolean(name: String): Future[Option[Boolean]] = { 38 | getString(name).map({ maybeValue => 39 | maybeValue.map({ v => 40 | stringToBoolean(v) 41 | }) 42 | }) 43 | } 44 | 45 | /** 46 | * Get a value and convert the result to double. Works only if 47 | * String's toDouble works. 48 | * 49 | * @param name Name of key 50 | */ 51 | def getDouble(name: String): Future[Option[Double]] = { 52 | getString(name).map({ maybeValue => 53 | // Use a flatMap here because our Try returns an Option. 54 | maybeValue.flatMap({ v => 55 | Try({ Some(v.toDouble) }).getOrElse(None) 56 | }) 57 | }) 58 | } 59 | 60 | /** 61 | * Get a value. 62 | * 63 | * @param name Name of key 64 | */ 65 | def stringToBoolean(v: String): Boolean = { 66 | v match { 67 | // Handle the two "string boolean" cases 68 | case s: String if v.equalsIgnoreCase("true") => true 69 | case s: String if v.equalsIgnoreCase("false") => false 70 | case _ => false 71 | } 72 | } 73 | 74 | def shutdown: Unit = { 75 | // Nothing by default. 76 | } 77 | } -------------------------------------------------------------------------------- /http/src/main/scala/solicitor/backend/HTTP.scala: -------------------------------------------------------------------------------- 1 | package solicitor.backend 2 | 3 | import akka.io.IO 4 | import akka.pattern.ask 5 | import akka.util.Timeout 6 | import grizzled.slf4j.Logging 7 | import java.util.concurrent.TimeUnit 8 | import scala.concurrent._ 9 | import scala.util.Random 10 | import solicitor.Backend 11 | import spray.can.Http 12 | import spray.client.pipelining._ 13 | import spray.http._ 14 | import spray.http.HttpMethods._ 15 | 16 | /** 17 | * Provides an HTTP backend for fetching information from a remote URL. All 18 | * responses are assumed to be plain text and any status code other than 200 19 | * is considered a failure and returns None. 20 | * {{{ 21 | * val client = new Client(backend = HTTP(hosts = Seq("www.example.com", 80))) 22 | * // Fetches http://www.example.com/foo/bar 23 | * val fooBar = client.getValue("foo/bar") // String 24 | * }}} 25 | * 26 | * This backend uses the Spray Host-level API and randomly chooses one 27 | * of the supplied hosts for each GET request. 28 | */ 29 | class HTTP(hosts: Seq[(String, Int)]) extends Backend with Logging { 30 | 31 | import akka.actor.ActorSystem 32 | implicit val system = ActorSystem() 33 | import system.dispatcher // execution context for futures 34 | implicit val timeout = Timeout(10, TimeUnit.SECONDS) // XXX 35 | 36 | // Iterate through the passed in hosts & ports and ask spray to create 37 | // a SendReceive function for each host requested. 38 | val pipes: Seq[Future[SendReceive]] = hosts.map({ h: (String, Int) => 39 | for( 40 | Http.HostConnectorInfo(connector, _) <- 41 | (IO(Http) ? Http.HostConnectorSetup(host = h._1, port = h._2)) 42 | ) yield sendReceive(connector) 43 | }) 44 | 45 | // Get a count of pipes we have for later. 46 | val pipeCount = pipes.size 47 | 48 | /** 49 | * Get a value via HTTP. The name is appended to the baseURL 50 | * provided to the constructor. 51 | * 52 | * @param name The name to fetch. 53 | */ 54 | override def getString(name: String): Future[Option[String]] = { 55 | 56 | val request = Get("/" + name) 57 | // Randomly select a pipelined host from the initial list we were 58 | // given and use it. 59 | pipes(Random.nextInt(pipeCount)).flatMap(_(request)).map({ response => 60 | response.status.intValue match { 61 | case 200 => Some(response.entity.asString) 62 | case _ => { 63 | warn("Bad HTTP Code: " + response.status.intValue) 64 | warn("Reason: " + response.entity.asString) 65 | None 66 | } 67 | } 68 | }) 69 | } 70 | 71 | override def shutdown = Http.CloseAll 72 | } 73 | -------------------------------------------------------------------------------- /project/Build.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import Keys._ 3 | 4 | object Build extends Build { 5 | 6 | lazy val solicitorSettings = Defaults.defaultSettings ++ Seq( 7 | crossScalaVersions := Seq("2.10.4"), 8 | scalaVersion <<= (crossScalaVersions) { versions => versions.head }, 9 | scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature"), 10 | publishTo := Some(Resolver.file("file", new File("/Users/gphat/src/mvn-repo/releases"))), 11 | resolvers ++= Seq( 12 | "spray repo" at "http://repo.spray.io", 13 | "Typesafe Repository" at "http://repo.typesafe.com/typesafe/releases/" 14 | ), 15 | libraryDependencies ++= Seq( 16 | "org.clapper" %% "grizzled-slf4j" % "1.0.1", 17 | "org.specs2" %% "specs2" % "1.14" % "test", 18 | "org.slf4j" % "slf4j-simple" % "1.7.5" % "test" 19 | ) 20 | ) 21 | 22 | lazy val root = Project( 23 | id = "solicitor", 24 | base = file("core"), 25 | settings = solicitorSettings ++ Seq( 26 | description := "Core Solicitor", 27 | version := "1.0" 28 | ) 29 | ) 30 | 31 | lazy val http = Project( 32 | id = "solicitor-http", 33 | base = file("http"), 34 | settings = solicitorSettings ++ Seq( 35 | description := "HTTP Config Backend", 36 | version := "1.0", 37 | libraryDependencies ++= Seq( 38 | "com.typesafe.akka" %% "akka-actor" % "2.3.0", 39 | "io.spray" % "spray-can" % "1.3.0", 40 | "io.spray" % "spray-client" % "1.3.0" 41 | ) 42 | ) 43 | ) dependsOn( 44 | root 45 | ) 46 | 47 | lazy val consul = Project( 48 | id = "solicitor-consul", 49 | base = file("consul"), 50 | settings = solicitorSettings ++ Seq( 51 | description := "Consul KV Backend", 52 | version := "1.0", 53 | libraryDependencies ++= Seq( 54 | "commons-codec" % "commons-codec" % "1.9", 55 | "io.spray" %% "spray-json" % "1.2.6" 56 | ) 57 | ) 58 | ) dependsOn( 59 | http 60 | ) 61 | 62 | 63 | lazy val typesafe = Project( 64 | id = "solicitor-typesafe", 65 | base = file("typesafe"), 66 | settings = solicitorSettings ++ Seq( 67 | description := "Typesafe Config Backend", 68 | version := "1.0", 69 | libraryDependencies ++= Seq( 70 | "com.typesafe" % "config" % "1.2.0" 71 | ) 72 | ) 73 | ) dependsOn( 74 | root 75 | ) 76 | 77 | lazy val zk = Project( 78 | id = "solicitor-zk", 79 | base = file("zk"), 80 | settings = solicitorSettings ++ Seq( 81 | description := "Zookeeper Backend", 82 | version := "1.0", 83 | libraryDependencies ++= Seq( 84 | "org.apache.curator" % "curator-recipes" % "2.4.2" 85 | ) 86 | ) 87 | ) dependsOn( 88 | root 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /zk/src/main/scala/solicitor/backend/Zk.scala: -------------------------------------------------------------------------------- 1 | package solicitor.backend 2 | 3 | import grizzled.slf4j.Logging 4 | import org.apache.curator.framework.CuratorFrameworkFactory 5 | import org.apache.curator.framework.recipes.cache.{NodeCache,NodeCacheListener} 6 | import org.apache.curator.retry.BoundedExponentialBackoffRetry 7 | import scala.concurrent._ 8 | import scala.concurrent.ExecutionContext.Implicits.global 9 | import solicitor.Backend 10 | 11 | /** 12 | * Provides a backend for fetching information from a path in Zookeeper. Uses 13 | * a non-blocking backend that is not guaranteed to be accurate or to be 14 | * in sync transactionally with Zk. It gets there eventually. :) 15 | 16 | * Takes a list of hostname and port pairs and optional path which are converted 17 | * into a Zookeeper connection string (e.g. host1:2181,host2:2181/solicitor). 18 | * {{{ 19 | * val client = new Client(backend = new Zk( 20 | * hosts = Seq( 21 | * ("localhost", 2181) 22 | * ) 23 | * )) 24 | * }}} 25 | */ 26 | class Zk(hosts: Seq[(String, Int)], path: String = "/solicitor") extends Backend with Logging { 27 | 28 | val values = scala.collection.mutable.HashMap.empty[String,NodeCache] 29 | 30 | lazy val zkConnectString = hosts.map({ 31 | case (k,v) => k + ":" + v.toString 32 | }).toSeq.mkString(",") + path 33 | 34 | lazy val curator = CuratorFrameworkFactory.newClient( 35 | zkConnectString, 36 | new BoundedExponentialBackoffRetry(100, 1000, 5) 37 | ); 38 | 39 | // For test purposes 40 | def getZkConnectionString = zkConnectString 41 | 42 | def getString(name: String): Future[Option[String]] = future { 43 | if(values.isEmpty) { 44 | // If we get here then the cache is empty and we should start 45 | // the client 46 | curator.start 47 | curator.getZookeeperClient.blockUntilConnectedOrTimedOut; 48 | } 49 | val zkValue = values.get(name) getOrElse { 50 | // Create a new node cache and return it since we didn't already 51 | // have this one. 52 | debug("First time seeing this key, creating NodeCache") 53 | val nc = new NodeCache(curator, "/" + name) // Append the / 54 | values += name -> nc 55 | nc.start 56 | nc 57 | } 58 | val bites = try { 59 | val current = zkValue.getCurrentData 60 | // currentData can be null… 61 | if(current != null) { 62 | current.getData 63 | } else { 64 | null 65 | } 66 | } catch { 67 | case e: Exception => { 68 | warn("Error fetching value: " + e.getMessage) 69 | null 70 | } 71 | } 72 | // Check if we got anything back 73 | if(bites == null) { 74 | None 75 | } else { 76 | Some(new String(bites)) 77 | } 78 | } 79 | 80 | override def shutdown = { 81 | values.foreach { case (k, nc) => 82 | nc.close 83 | } 84 | curator.close 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /core/src/main/scala/solicitor/Client.scala: -------------------------------------------------------------------------------- 1 | package solicitor 2 | 3 | import scala.concurrent.duration._ 4 | import scala.concurrent.{Await,Future} 5 | import scala.util.{Random,Try} 6 | 7 | class Client(backend: Backend, timeout: Duration = Duration(1, SECONDS)) { 8 | 9 | val rng = new Random(System.currentTimeMillis) 10 | 11 | /** 12 | * Returns true if the name supplied returns a true value. Merely a flattened 13 | * wrapper around getValueAsBoolean. 14 | * 15 | * @param name The name to check 16 | * @param default A default value in the event of a failure to retrieve. 17 | */ 18 | def isEnabled(name: String, default: Boolean = false): Boolean = 19 | getBoolean(name, Some(default)).getOrElse(false) 20 | 21 | /** 22 | * Returns false if the name supplied returns a true value. 23 | * 24 | * @param name The name to check 25 | * @param default A default value in the event of a failure to retrieve. 26 | */ 27 | def isDisabled(name: String): Boolean = !isEnabled(name) 28 | 29 | /** 30 | * Randomly decides if a name is enabled using a percentage chance. Values 31 | * should be a number between 0 and 1. In the event that a value cannot 32 | * be retrieved the default is used. 33 | * 34 | * @param name The name to fetch. 35 | * @param default A default value in the event of a failure to retrieve. 36 | */ 37 | def decideEnabled(name: String, default: Boolean = false): Boolean = { 38 | getDouble(name).map({ chance => 39 | // Fetch a double from the config. 40 | if(chance <= rng.nextDouble) { 41 | false 42 | } else { 43 | true 44 | } 45 | }).getOrElse(default) 46 | } 47 | 48 | /** 49 | * Return a value for the given name. 50 | * 51 | * @param name The name to fetch. 52 | * @param default A default value in the event of a failure to retrieve. 53 | */ 54 | def getString(name: String, default: Option[String] = None): Option[String] = 55 | Try(Await.result(backend.getString(name), timeout)).getOrElse(default) 56 | 57 | /** 58 | * Return a value, converted to Boolean, for the given name. 59 | * 60 | * @param name The name to fetch 61 | * @param default A default value in the event of a failure to retrieve. 62 | */ 63 | def getBoolean(name: String, default: Option[Boolean] = None): Option[Boolean] = 64 | Try(Await.result(backend.getBoolean(name), timeout)).getOrElse(default) 65 | 66 | /** 67 | * Return a value, converted to Double, for the given name. 68 | * 69 | * @param name The name to fetch 70 | * @param default A default value in the event of a failure to retrieve. 71 | */ 72 | def getDouble(name: String, default: Option[Double] = None): Option[Double] = 73 | Try(Await.result(backend.getDouble(name), timeout)).getOrElse(default) 74 | 75 | /** 76 | * Closes any resources allocated by Solicitor and it's backends. 77 | */ 78 | def shutdown { 79 | backend.shutdown 80 | } 81 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solicitor 2 | 3 | Solicitor is small library for feature flags and simple configuration. Feature flags 4 | are a way of controlling the availaibility of features in your application. You 5 | can learn more about the [idea of feature flags](http://code.flickr.net/2009/12/02/flipping-out/). 6 | 7 | Your flag names are expected to be in the form of `foo/bar/baz`. The slashes 8 | promote namespacing and lend themselves to clever use in backends. 9 | 10 | # Features 11 | 12 | * Boolean flags 13 | * Percentage chance (i.e. on for 10% of users) 14 | * Multiple backends 15 | - Static (for testing and stubbing) 16 | - HTTP paths with support for multiple, random host pools (e.g. "foo/bar" fetches a value from a example.com/foo/bar) 17 | - [Typesafe config](https://github.com/typesafehub/config) 18 | - [Consul](http://www.consul.io/)'s KV [HTTP API](http://www.consul.io/docs/agent/http.html) 19 | - [Zookeeper](http://zookeeper.apache.org/) 20 | 21 | # Status 22 | 23 | Solicitor is new and likely has some flaws and missing pieces. Here are some 24 | known TODOs: 25 | 26 | * Caching for the HTTP backend 27 | * Ignore comment and empty lines for HTTP backend (documentation in the files is the idea) 28 | * Zookeeper is experimental 29 | 30 | # Goals 31 | 32 | Solicitor's goals are to enable runtime modification of features in a backend 33 | application or service. You might also be able to use this for front end 34 | decisions if your application can use Scala libaries in it's templates like the 35 | [Play Framework](http://www.playframework.com/). 36 | 37 | With the ability to adjust your features on the fly it becomes less necessary 38 | for you to deploy new code for small changes. In addition to just enabling or 39 | disabling a feature you can adjust values by fetching either numeric or string 40 | values from the backend. Examples: 41 | 42 | * Adusting numeric values such as cache durations or concurrency 43 | * Disabling performance features via booleans during problems 44 | * Fetching lists of hosts to use for connecting to other backend services 45 | 46 | These are powerful features that, if implemented, can significantly reduce 47 | your time to resolution for production issues by avoiding the latency and 48 | complexity of deployments. 49 | 50 | *Note:* This library isn't meant to be configuration for your _entire_ application, 51 | as doing dynamic configuration is really hard. *This is for smaller, critical 52 | values that you can easily reason about being able to change at any time.* 53 | 54 | # Types 55 | 56 | The content of the response is expected to be 57 | plain text. The following rules are used to interprest the strings into Scala types: 58 | 59 | Values | Type | Notes 60 | -------|------|------ 61 | true, false | Boolean | Case insensitive 62 | numbers | Double | No special cases for integers 63 | everything else | String! | n/a 64 | 65 | ## isEnabled and isDisabled 66 | 67 | Note that any non boolean value will be considered false by default. Values like 68 | 1, 1.0 are not "truthy" in this implementation. 69 | 70 | # Defaults 71 | 72 | You can also provide defaults in the event that your backend fails to retrieve 73 | a value. 74 | 75 | # Backends 76 | 77 | Solicitor has pluggable backends that allow you define different ways of retrieving 78 | values. 79 | 80 | ## HTTP 81 | 82 | The HTTP backend uses [Spray's HTTP Client](http://spray.io/documentation/spray-can/http-client/) 83 | to make HTTP requests to url in the form of: `host + "/" + key`. 84 | Therefore if your `host` is `www.example.com` then a request for 85 | `foo/bar` will result in a GET request to `http://www.example.com/foo/bar`. 86 | 87 | ### Notes 88 | 89 | The original idea behind this backend was to expose a simple directory of files. 90 | More specifically, a Git repository of files containing simple values to enable 91 | a combination of simple retrieval and simple auditing of the configuration 92 | information. Git provides history and accountability and hooks can notify 93 | third parties of value changes. 94 | 95 | ### Example 96 | 97 | ```scala 98 | import solicitor.Client 99 | import solicitor.backend.HTTP 100 | 101 | val solicitor = new Client( 102 | backend = new HTTP(hosts = Seq(("example.com", 80))) 103 | ) 104 | ``` 105 | 106 | ## Static 107 | 108 | ### Example 109 | 110 | ``` 111 | import solicitor.Client 112 | import solicitor.backend.Static 113 | 114 | val solicitor = new Client( 115 | backend = new Static(Map( 116 | "foo" -> 1, 117 | "bar" -> true 118 | "baz" -> "gorch" 119 | )) 120 | ) 121 | ``` 122 | 123 | ## Consul 124 | 125 | The Consul backend leverages the existing HTTP backend but formats it's requests 126 | to match [Consul's API for KV storage](http://www.consul.io/docs/agent/http.html). 127 | 128 | Just like the HTTP backend you can specify multiple hosts. You should do that 129 | to take advantage of Consul's distributed KV. 130 | 131 | ### Notes 132 | 133 | * It is assumed that all values are Base64 encoded UTF-8 strings. 134 | * There is no facility for adding a `dc` parameter to the query. Requests will use the agent's DC as per Consul's documentation. 135 | 136 | ### Example 137 | 138 | ```scala 139 | import solicitor.Client 140 | import solicitor.backend.Consul 141 | 142 | val solicitor = new Client( 143 | background = new Consul(hosts = Seq( 144 | "agent1.example.com", 8500, 145 | "agent2.example.com", 8500 146 | )) 147 | ) 148 | ``` 149 | 150 | ## Zookeeper 151 | 152 | The Zookeeper backend uses [Apache Curator](http://curator.apache.org/)'s 153 | [NodeCache](http://curator.apache.org/curator-recipes/node-cache.html) recipe. 154 | 155 | ### Notes 156 | 157 | The data is not guaranteed to be in sync with the store, but will eventually 158 | be as data propogates through the ZK cluster and the NodeCache gets the new 159 | information. Due to latency in establishing the NodeCache the first request for 160 | a key seems to always return a None and therefore your defaults will be important! 161 | 162 | ### Example 163 | ```scala 164 | import solicitor.Client 165 | import solicitor.backend.Zk 166 | 167 | val solicitor = new Client( 168 | background = new Zk(hosts = Seq( 169 | "zk1.example.com", 2181, 170 | "zk1.example.com", 2181 171 | )) 172 | ) 173 | ``` 174 | 175 | # Using Solicitor 176 | 177 | Here are some examples of common use cases of Solicitor. 178 | 179 | ## Instantiation 180 | 181 | ```scala 182 | import solicitor.Client 183 | import solicitor.backend.Static 184 | 185 | val solicitor = new Client( 186 | backend = new Static(Map( 187 | "foo" -> "true", 188 | "bar" -> "0.5" 189 | )) 190 | ) 191 | ``` 192 | 193 | ## Simple Feature Activation 194 | 195 | ```scala 196 | if(solicitor.isEnabled("foo")) { 197 | // Do something! 198 | } 199 | 200 | if(solicitor.isDisabled("foo")) { 201 | // Do something! 202 | } 203 | ``` 204 | 205 | ## Percentage Chance of Activation 206 | 207 | ```scala 208 | if(solicitor.decideEnabled("bar")) { 209 | // Should happen about 50% of the time! 210 | } 211 | ``` 212 | 213 | ## Raw Value Methods 214 | 215 | ```scala 216 | val t1 = solicitor.getString("baz") // String! 217 | 218 | // These methods default to None if unparseable. See Types above. 219 | val t2 = solicitor.getBoolean("bar") // Option[Boolean] 220 | val t3 = solicitor.getDouble("foo") // Option[Double] 221 | 222 | // With Defaults 223 | val t4 = solicitor.getBoolean("bar", Some(true)) // Option[Boolean] 224 | val t5 = solicitor.getDouble("foo", Some(100)) // Option[Double] 225 | 226 | ``` 227 | 228 | # Testing 229 | 230 | Note that tests expect a local HTTP and Consul instance that respond to 231 | queries: 232 | 233 | * HTTP: Port 8000, /foo/bar should contain 123 234 | * Consul: KV "poop" should return "butt" 235 | --------------------------------------------------------------------------------