Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Initial import of code

  • Loading branch information...
commit 7da02026a686e79a2c0f18fe1d6572a6864b9221 0 parents
@theefer theefer authored
9 .gitignore
@@ -0,0 +1,9 @@
+# stuff to ignore from sbt
+target/
+
+# don't commit the intellij stuff, run "sbt idea" to generate
+*.ipr
+*.iml
+*.iws
+.idea/
+out/
11 README.md
@@ -0,0 +1,11 @@
+# Feature Switching
+
+A small library to allow features to be turned on and off.
+
+# Setup
+
+TODO: example of adding config to build.sbt
+
+# Usage
+
+TODO: example of switches in an app
31 build.sbt
@@ -0,0 +1,31 @@
+name := "feature-switching"
+
+version := "0.1-SNAPSHOT"
+
+organization := "com.gu"
+
+publishArtifact := false
+
+// scalaVersion := "2.9.1"
+
+crossScalaVersions := Seq("2.9.1")
+
+libraryDependencies ++= Seq(
+ "javax.servlet" % "servlet-api" % "2.5" % "provided",
+ "net.liftweb" %% "lift-json" % "2.4-M4",
+ "org.slf4j" % "slf4j-api" % "1.6.1",
+ "org.scalatra" %% "scalatra" % "2.0.2"
+)
+
+publishTo <<= (version) { version: String =>
+ val publishType = if (version.endsWith("SNAPSHOT")) "snapshots" else "releases"
+ Some(
+ Resolver.file(
+ "guardian github " + publishType,
+ file(System.getProperty("user.home") + "/guardian.github.com/maven/repo-" + publishType)
+ )
+ )
+}
+
+
+scalacOptions += "-deprecation"
2  project/build.properties
@@ -0,0 +1,2 @@
+sbt.version=0.11.3
+
54 src/main/scala/com/gu/featureswitching/CookieFeatureSwitchingOverrideStrategy.scala
@@ -0,0 +1,54 @@
+import collection.mutable.LinkedHashMap
+import com.gu.featureswitching.util.Loggable
+import com.gu.featureswitching.{FeatureSwitchingOverrideStrategy, FeatureSwitch}
+import org.scalatra._
+
+
+// TODO: generic cookie support?
+
+trait CookieFeatureSwitchingOverrideStrategy extends FeatureSwitchingOverrideStrategy
+ with ScalatraKernel with CookieSupport with Loggable {
+
+
+ lazy val featureSwitchOverrideKey = "features.override"
+
+ // TODO: clear obsolete keys for non-existing features, by reading the list of existing switches
+ private def rawValue = cookies.get(featureSwitchOverrideKey).getOrElse("")
+
+ private def cookieMap = rawValue.split(",").map(_.split("=").toList).flatMap {
+ case List(key, "true") => Some((key -> true))
+ case List(key, "false") => Some((key -> false))
+ case _ => None // invalid token, ignore
+ }.toMap
+
+ private var setterMap = new LinkedHashMap[String, Option[Boolean]]()
+ private def valueMap = setterMap.foldLeft(cookieMap) {
+ case (values, (key, Some(value))) => values + (key -> value)
+ case (values, (key, None)) => values - key
+ }
+
+ private def renderCookie = valueMap.map {
+ case (key, value) => "%s=%s".format(key, value)
+ }.toList.mkString(",")
+
+
+ after() {
+ val updatedValue = renderCookie
+ if (updatedValue != rawValue) {
+ logger.info("Updating %s cookie: %s".format(featureSwitchOverrideKey, updatedValue))
+ // TODO: is this actually working?
+ cookies.set(featureSwitchOverrideKey, updatedValue)
+ }
+ }
+
+
+ def featureIsOverridden(feature: FeatureSwitch): Option[Boolean] = {
+ valueMap.get(feature.key)
+ }
+ def featureSetOverride(feature: FeatureSwitch, overridden: Boolean) {
+ setterMap += (feature.key -> Some(overridden))
+ }
+ def featureResetOverride(feature: FeatureSwitch) {
+ setterMap += (feature.key -> None)
+ }
+}
23 src/main/scala/com/gu/featureswitching/FeatureSwitching.scala
@@ -0,0 +1,23 @@
+package com.gu.featureswitching
+
+case class FeatureSwitch(key: String, title: String, default: Boolean)
+
+trait FeatureSwitching extends FeatureSwitchingEnablingStrategy with FeatureSwitchingOverrideStrategy {
+ val features: List[FeatureSwitch]
+
+ def featureIsActive(feature: FeatureSwitch): Boolean = {
+ featureIsOverridden(feature) orElse featureIsEnabled(feature) getOrElse feature.default
+ }
+}
+
+trait FeatureSwitchingEnablingStrategy {
+ def featureIsEnabled(feature: FeatureSwitch): Option[Boolean]
+ def featureSetEnabled(feature: FeatureSwitch, enabled: Boolean)
+ def featureResetEnabled(feature: FeatureSwitch)
+}
+
+trait FeatureSwitchingOverrideStrategy {
+ def featureIsOverridden(feature: FeatureSwitch): Option[Boolean]
+ def featureSetOverride(feature: FeatureSwitch, overridden: Boolean)
+ def featureResetOverride(feature: FeatureSwitch)
+}
21 src/main/scala/com/gu/featureswitching/InMemoryFeatureSwitchEnablingStrategy.scala
@@ -0,0 +1,21 @@
+package com.gu.featureswitching
+
+import collection.mutable.LinkedHashMap
+
+// Simple in-memory implementation of feature state persistence.
+// Obviously, don't use this if you have multiple app servers!
+trait InMemoryFeatureSwitchEnablingStrategy extends FeatureSwitchingEnablingStrategy {
+ var persistence: LinkedHashMap[FeatureSwitch, Boolean] = new LinkedHashMap[FeatureSwitch, Boolean]()
+
+ def featureIsEnabled(feature: FeatureSwitch): Option[Boolean] = {
+ persistence.get(feature)
+ }
+
+ def featureSetEnabled(feature: FeatureSwitch, enabled: Boolean) {
+ persistence(feature) = enabled
+ }
+
+ def featureResetEnabled(feature: FeatureSwitch) {
+ persistence.remove(feature)
+ }
+}
119 src/main/scala/com/gu/featureswitching/dispatcher/FeatureSwitchDispatcher.scala
@@ -0,0 +1,119 @@
+package com.gu.featureswitching.dispatcher
+
+import org.scalatra.ScalatraServlet
+import com.gu.featureswitching.util.DeserialisationHelpers
+import com.gu.featureswitching.FeatureSwitching
+import net.liftweb.json.DefaultFormats
+
+trait FeatureSwitchDispatcher extends ScalatraServlet
+ with JsonDispatcher with DeserialisationHelpers with FeatureSwitching {
+
+ implicit val defaultFormats = DefaultFormats
+
+ lazy val errorInvalidFeature = ErrorEntity("invalid-feature")
+ lazy val errorFeatureNotSet = ErrorEntity("unset-feature")
+
+
+ // disable caching
+ after() {
+ response.setHeader("Cache-Control", "public, max-age=0")
+ }
+
+ def routeUri = {
+ request.getScheme + "://" + request.getServerName + ":" + request.getServerPort + request.getRequestURI;
+ }
+
+ def noContent = {
+ status(201)
+ }
+
+
+ get("/") {
+ val serverStates = features.map(feat => (feat.key -> featureIsActive(feat))).toMap
+
+ val switchesLink = LinkEntity("switches", routeUri + "/switches")
+ FeatureSwitchSummaryResponse(serverStates, List(switchesLink))
+ }
+
+ get("/switches") {
+ val featureResponses = features.map {
+ feature =>
+ val entity = FeatureSwitchEntity(
+ key = feature.key,
+ title = feature.title,
+ default = feature.default,
+ active = featureIsActive(feature),
+ enabled = featureIsEnabled(feature),
+ overridden = featureIsOverridden(feature)
+ )
+ val entityUri = routeUri + "/" + feature.key
+ FeatureSwitchResponse(Some(entityUri), entity)
+ }
+
+ FeatureSwitchIndexResponse(featureResponses, List(
+ LinkEntity("item:enabled", routeUri + "/{key}/enabled"),
+ LinkEntity("item:overridden", routeUri + "/{key}/overridden")
+ ))
+ }
+
+
+ // You probably only want to use this for the routes below
+ def getFeatureFromKeyParam = {
+ val featureKey = params("key")
+ features.find(_.key == featureKey) getOrElse halt(404, body = errorInvalidFeature)
+ }
+
+ get("/switches/:key") {
+ val feature = getFeatureFromKeyParam
+
+ val entity = FeatureSwitchEntity(
+ key = feature.key,
+ title = feature.title,
+ default = feature.default,
+ active = featureIsActive(feature),
+ enabled = featureIsEnabled(feature),
+ overridden = featureIsOverridden(feature)
+ )
+
+ FeatureSwitchResponse(None, entity,
+ LinkEntity("enabled", routeUri + "/enabled") ::
+ LinkEntity("overridden", routeUri + "/overridden") ::
+ Nil)
+ }
+
+ get("/switches/:key/enabled") {
+ val feature = getFeatureFromKeyParam
+ featureIsEnabled(feature) getOrElse halt(404, errorFeatureNotSet)
+ }
+
+ put("/switches/:key/enabled") {
+ val feature = getFeatureFromKeyParam
+ val value = parseBoolean(request.body)
+ featureSetEnabled(feature, value)
+ noContent
+ }
+
+ delete("/switches/:key/enabled") {
+ val feature = getFeatureFromKeyParam
+ featureResetEnabled(feature)
+ noContent
+ }
+
+ get("/switches/:key/overridden") {
+ val feature = getFeatureFromKeyParam
+ featureIsOverridden(feature) getOrElse halt(404, errorFeatureNotSet)
+ }
+
+ put("/switches/:key/overridden") {
+ val feature = getFeatureFromKeyParam
+ val value = parseBoolean(request.body)
+ featureSetOverride(feature, value)
+ noContent
+ }
+
+ delete("/switches/:key/overridden") {
+ val feature = getFeatureFromKeyParam
+ featureResetOverride(feature)
+ noContent
+ }
+}
18 src/main/scala/com/gu/featureswitching/dispatcher/JsonDispatcher.scala
@@ -0,0 +1,18 @@
+package com.gu.featureswitching.dispatcher
+
+import net.liftweb.json._
+import org.scalatra.{ScalatraServlet, RenderPipeline}
+
+trait JsonDispatcher extends ScalatraServlet {
+
+ override protected def renderPipeline = ({
+ case p: Product => {
+ implicit val formats = DefaultFormats
+ contentType = "application/json; charset=utf-8"
+ val decomposed = Extraction.decompose(p)
+ val rendered = JsonAST.render(decomposed)
+ net.liftweb.json.compact(rendered).getBytes("UTF-8")
+ }
+ }: RenderPipeline) orElse super.renderPipeline
+
+}
15 src/main/scala/com/gu/featureswitching/dispatcher/Responses.scala
@@ -0,0 +1,15 @@
+package com.gu.featureswitching.dispatcher
+
+case class FeatureSwitchEntity(key: String,
+ title: String,
+ default: Boolean,
+ active: Boolean,
+ enabled: Option[Boolean],
+ overridden: Option[Boolean])
+
+case class LinkEntity(rel: String, href: String)
+case class ErrorEntity(errorKey: String)
+
+case class FeatureSwitchSummaryResponse(data: Map[String, Boolean], links: List[LinkEntity] = List())
+case class FeatureSwitchIndexResponse(data: List[FeatureSwitchResponse], links: List[LinkEntity] = List())
+case class FeatureSwitchResponse(uri: Option[String], data: FeatureSwitchEntity, links: List[LinkEntity] = List())
30 src/main/scala/com/gu/featureswitching/util/DeserialisationHelpers.scala
@@ -0,0 +1,30 @@
+package com.gu.featureswitching.util
+
+import com.gu.featureswitching.dispatcher.ErrorEntity
+import org.scalatra.ScalatraServlet
+import net.liftweb.json.DefaultFormats
+import net.liftweb.json.JsonAST.{JBool, JArray}
+import net.liftweb.json.parse
+
+trait DeserialisationHelpers extends ScalatraServlet with Loggable {
+ lazy val errorInvalidJson = ErrorEntity("invalid-json")
+ lazy val errorInvalidData = ErrorEntity("invalid-data")
+
+ protected def parseBoolean(json: String): Boolean = {
+ implicit val formats = DefaultFormats
+ try {
+ // circumvent strict JSON requirement of values being an array or an object
+ parse("[%s]".format(json)) match {
+ case JArray(JBool(s) :: Nil) => s
+ case _ =>
+ logger.info("Deserialisation failed (invalid data)")
+ halt(status = 400, body = errorInvalidData)
+ }
+ }
+ catch {
+ case e: Exception =>
+ logger.info("Deserialisation failed (invalid json)")
+ halt(status = 400, body = errorInvalidJson)
+ }
+ }
+}
7 src/main/scala/com/gu/featureswitching/util/Loggable.scala
@@ -0,0 +1,7 @@
+package com.gu.featureswitching.util
+
+import org.slf4j.LoggerFactory
+
+trait Loggable {
+ protected lazy val logger = LoggerFactory.getLogger(getClass)
+}
Please sign in to comment.
Something went wrong with that request. Please try again.