/
FeatureFactory.scala
434 lines (372 loc) · 15.1 KB
/
FeatureFactory.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
/*
* Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/
package org.knora.webapi.feature
import akka.http.scaladsl.model.headers.RawHeader
import akka.http.scaladsl.model.{HttpHeader, HttpResponse}
import akka.http.scaladsl.server.RequestContext
import org.knora.webapi.exceptions.{BadRequestException, FeatureToggleException}
import org.knora.webapi.settings.KnoraSettings.FeatureToggleBaseConfig
import org.knora.webapi.settings.KnoraSettingsImpl
import scala.annotation.tailrec
import scala.util.control.Exception._
import scala.util.{Failure, Success, Try}
/**
* A tagging trait for module-specific factories that produce implementations of features.
*/
trait FeatureFactory
/**
* A tagging trait for classes that implement features returned by feature factories.
*/
trait Feature
/**
* A tagging trait for case objects representing feature versions.
*/
trait Version
/**
* A trait representing the state of a feature toggle.
*/
sealed trait FeatureToggleState
/**
* Indicates that a feature toggle is off.
*/
case object ToggleStateOff extends FeatureToggleState
/**
* Indicates that a feature toggle is on.
*
* @param version the configured version of the toggle.
*/
case class ToggleStateOn(version: Int) extends FeatureToggleState
/**
* Represents a feature toggle state, for use in match-case expressions.
*/
sealed trait MatchableState[+T]
/**
* A matchable object indicating that a feature toggle is off.
*/
case object Off extends MatchableState[Nothing]
/**
* A matchable object indicating that a feature toggle is on.
*
* @param versionObj a case object representing the enabled version of the toggle.
* @tparam T the type of the case object.
*/
case class On[T <: Version](versionObj: T) extends MatchableState[T]
/**
* Represents a feature toggle.
*
* @param featureName the name of the feature toggle.
* @param state the state of the feature toggle.
*/
case class FeatureToggle(featureName: String, state: FeatureToggleState) {
/**
* Returns `true` if this toggle is enabled.
*/
def isEnabled: Boolean =
state match {
case ToggleStateOn(_) => true
case ToggleStateOff => false
}
/**
* Returns a [[MatchableState]] indicating the state of this toggle, for use in match-case expressions.
*
* @param versionObjects case objects representing the supported versions of the feature, in ascending
* order by version number.
* @tparam T a sealed trait implemented by the version objects.
* @return one of the objects in `versionObjects`, or [[Off]].
*/
def getMatchableState[T <: Version](versionObjects: T*): MatchableState[T] =
state match {
case ToggleStateOn(version) =>
if (version < 1) {
// Shouldn't happen; this error should have been caught already.
throw FeatureToggleException(s"Invalid version number $version for toggle $featureName")
}
if (versionObjects.size < version) {
// The caller didn't pass enough version objects.
throw FeatureToggleException(s"Not enough version objects for $featureName")
}
// Return the version object whose position in the sequence corresponds to the configured version.
// This relies on the fact that version numbers must be an ascending sequence of consecutive
// integers starting from 1.
On(versionObjects(version - 1))
case ToggleStateOff => Off
}
}
object FeatureToggle {
/**
* The name of the HTTP request header containing feature toggles.
*/
val REQUEST_HEADER: String = "X-Knora-Feature-Toggles"
val REQUEST_HEADER_LOWERCASE: String = REQUEST_HEADER.toLowerCase
/**
* The name of the HTTP response header that lists configured feature toggles.
*/
val RESPONSE_HEADER: String = REQUEST_HEADER
val RESPONSE_HEADER_LOWERCASE: String = REQUEST_HEADER_LOWERCASE
/**
* Constructs a default [[FeatureToggle]] from a [[FeatureToggleBaseConfig]].
*
* @param baseConfig a feature toggle's base configuration.
* @return a [[FeatureToggle]] representing the feature's default setting.
*/
def fromBaseConfig(baseConfig: FeatureToggleBaseConfig): FeatureToggle =
FeatureToggle(
featureName = baseConfig.featureName,
state = if (baseConfig.enabledByDefault) {
ToggleStateOn(baseConfig.defaultVersion)
} else {
ToggleStateOff
}
)
/**
* Constructs a feature toggle from non-base configuration.
*
* @param featureName the name of the feature.
* @param isEnabled `true` if the feature should be enabled.
* @param maybeVersion the version of the feature that should be used.
* @param baseConfig the base configuration of the toggle.
* @return a [[FeatureToggle]] for the toggle.
*/
def apply(
featureName: String,
isEnabled: Boolean,
maybeVersion: Option[Int],
baseConfig: FeatureToggleBaseConfig
): FeatureToggle = {
if (!baseConfig.overrideAllowed) {
throw BadRequestException(s"Feature toggle $featureName cannot be overridden")
}
for (version: Int <- maybeVersion) {
if (!baseConfig.availableVersions.contains(version)) {
throw BadRequestException(s"Feature toggle $featureName has no version $version")
}
}
val state: FeatureToggleState = (isEnabled, maybeVersion) match {
case (true, Some(definedVersion)) => ToggleStateOn(definedVersion)
case (false, None) => ToggleStateOff
case (true, None) =>
throw BadRequestException(s"You must specify a version number to enable feature toggle $featureName")
case (false, Some(_)) =>
throw BadRequestException(s"You cannot specify a version number when disabling feature toggle $featureName")
}
FeatureToggle(
featureName = featureName,
state = state
)
}
}
/**
* An abstract class representing configuration for a [[FeatureFactory]] from a particular
* configuration source.
*
* @param maybeParent if this [[FeatureFactoryConfig]] has no setting for a particular
* feature toggle, it delegates to its parent.
*/
abstract class FeatureFactoryConfig(protected val maybeParent: Option[FeatureFactoryConfig]) {
/**
* Gets the base configuration for a feature toggle.
*
* @param featureName the name of the feature.
* @return the toggle's base configuration.
*/
protected[feature] def getBaseConfig(featureName: String): FeatureToggleBaseConfig
/**
* Gets the base configurations of all feature toggles.
*/
protected[feature] def getAllBaseConfigs: Set[FeatureToggleBaseConfig]
/**
* Returns a feature toggle in the configuration source of this [[FeatureFactoryConfig]].
*
* @param featureName the name of a feature.
* @return the configuration of the feature toggle in this [[FeatureFactoryConfig]]'s configuration
* source, or `None` if the source contains no configuration for that feature toggle.
*/
protected[feature] def getLocalConfig(featureName: String): Option[FeatureToggle]
/**
* Returns a string giving the state of all feature toggles.
*/
def makeToggleSettingsString: Option[String] = {
// Convert each toggle to its string representation.
val enabledToggles: Set[String] = getAllBaseConfigs.map { baseConfig: FeatureToggleBaseConfig =>
val featureToggle: FeatureToggle = getToggle(baseConfig.featureName)
val toggleStateStr: String = featureToggle.state match {
case ToggleStateOn(version) => s":$version=on"
case ToggleStateOff => s"=off"
}
s"${featureToggle.featureName}$toggleStateStr"
}
// Are any toggles enabled?
if (enabledToggles.nonEmpty) {
// Yes. Return a header.
Some(enabledToggles.mkString(","))
} else {
// No. Don't return a header.
None
}
}
/**
* Returns an [[HttpHeader]] giving the state of all feature toggles.
*/
def makeHttpResponseHeader: Option[HttpHeader] =
makeToggleSettingsString.map { settingsStr: String =>
RawHeader(FeatureToggle.RESPONSE_HEADER, settingsStr)
}
/**
* Adds an [[HttpHeader]] to an [[HttpResponse]] indicating which feature toggles are enabled.
*/
def addHeaderToHttpResponse(httpResponse: HttpResponse): HttpResponse =
makeHttpResponseHeader match {
case Some(header) => httpResponse.withHeaders(header)
case None => httpResponse
}
/**
* Returns a feature toggle, taking into account the base configuration
* and the parent configuration.
*
* @param featureName the name of the feature.
* @return the feature toggle.
*/
@tailrec
final def getToggle(featureName: String): FeatureToggle = {
// Get the base configuration for the feature.
val baseConfig: FeatureToggleBaseConfig = getBaseConfig(featureName)
// Do we represent the base configuration?
maybeParent match {
case None =>
// Yes. Return our setting.
FeatureToggle.fromBaseConfig(baseConfig)
case Some(parent) =>
// No. Can the default setting be overridden?
if (baseConfig.overrideAllowed) {
// Yes. Do we have a setting for this feature?
getLocalConfig(featureName) match {
case Some(setting) =>
// Yes. Return our setting.
setting
case None =>
// We don't have a setting for this feature. Delegate to the parent.
parent.getToggle(featureName)
}
} else {
// The default setting can't be overridden. Return it.
FeatureToggle.fromBaseConfig(baseConfig)
}
}
}
}
/**
* A [[FeatureFactoryConfig]] that reads configuration from the application's configuration file.
*
* @param knoraSettings a [[KnoraSettingsImpl]] representing the configuration in the application's
* configuration file.
*/
class KnoraSettingsFeatureFactoryConfig(knoraSettings: KnoraSettingsImpl) extends FeatureFactoryConfig(None) {
private val baseConfigs: Map[String, FeatureToggleBaseConfig] = knoraSettings.featureToggles.map { baseConfig =>
baseConfig.featureName -> baseConfig
}.toMap
override protected[feature] def getBaseConfig(featureName: String): FeatureToggleBaseConfig =
baseConfigs.getOrElse(featureName, throw BadRequestException(s"No such feature: $featureName"))
override protected[feature] def getAllBaseConfigs: Set[FeatureToggleBaseConfig] =
baseConfigs.values.toSet
override protected[feature] def getLocalConfig(featureName: String): Option[FeatureToggle] =
Some(FeatureToggle.fromBaseConfig(getBaseConfig(featureName)))
}
/**
* An abstract class for feature factory configs that don't represent the base configuration.
*
* @param parent the parent config.
*/
abstract class OverridingFeatureFactoryConfig(parent: FeatureFactoryConfig) extends FeatureFactoryConfig(Some(parent)) {
protected val featureToggles: Map[String, FeatureToggle]
override protected[feature] def getBaseConfig(featureName: String): FeatureToggleBaseConfig =
parent.getBaseConfig(featureName)
override protected[feature] def getAllBaseConfigs: Set[FeatureToggleBaseConfig] =
parent.getAllBaseConfigs
override protected[feature] def getLocalConfig(featureName: String): Option[FeatureToggle] =
featureToggles.get(featureName)
}
object RequestContextFeatureFactoryConfig {
// Strings that we accept as Boolean true values.
val TRUE_STRINGS: Set[String] = Set("true", "yes", "on")
// Strings that we accept as Boolean false values.
val FALSE_STRINGS: Set[String] = Set("false", "no", "off")
}
/**
* A [[FeatureFactoryConfig]] that reads configuration from a header in an HTTP request.
*
* @param requestContext the HTTP request context.
* @param parent the parent [[FeatureFactoryConfig]].
*/
class RequestContextFeatureFactoryConfig(requestContext: RequestContext, parent: FeatureFactoryConfig)
extends OverridingFeatureFactoryConfig(parent) {
import FeatureToggle._
import RequestContextFeatureFactoryConfig._
private def invalidHeaderValue: Nothing = throw BadRequestException(s"Invalid value for header $REQUEST_HEADER")
// Read feature toggles from an HTTP header.
protected override val featureToggles: Map[String, FeatureToggle] = Try {
// Was the feature toggle header submitted?
requestContext.request.headers.find(_.lowercaseName == REQUEST_HEADER_LOWERCASE) match {
case Some(featureToggleHeader: HttpHeader) =>
// Yes. Parse it into comma-separated key-value pairs, each representing a feature toggle.
val toggleSeq: Seq[(String, FeatureToggle)] = featureToggleHeader.value
.split(',')
.map { headerValueItem: String =>
headerValueItem.split('=').map(_.trim) match {
case Array(featureNameAndVersionStr: String, isEnabledStr: String) =>
val featureNameAndVersion: Array[String] = featureNameAndVersionStr.split(':').map(_.trim)
val featureName: String = featureNameAndVersion.head
// Accept the boolean values that are accepted in application.conf.
val isEnabled: Boolean = if (TRUE_STRINGS.contains(isEnabledStr.toLowerCase)) {
true
} else if (FALSE_STRINGS.contains(isEnabledStr.toLowerCase)) {
false
} else {
throw BadRequestException(s"Invalid boolean '$isEnabledStr' in feature toggle $featureName")
}
val maybeVersion: Option[Int] = featureNameAndVersion.drop(1).headOption.map { versionStr: String =>
allCatch
.opt(versionStr.toInt)
.getOrElse(
throw BadRequestException(s"Invalid version number '$versionStr' in feature toggle $featureName")
)
}
featureName -> FeatureToggle(
featureName = featureName,
isEnabled = isEnabled,
maybeVersion = maybeVersion,
baseConfig = parent.getBaseConfig(featureName)
)
case _ => invalidHeaderValue
}
}
.toSeq
if (toggleSeq.size > toggleSeq.map(_._1).toSet.size) {
throw BadRequestException(s"You cannot set the same feature toggle more than once per request")
}
toggleSeq.toMap
case None =>
// No feature toggle header was submitted.
Map.empty[String, FeatureToggle]
}
} match {
case Success(parsedToggles) => parsedToggles
case Failure(ex) =>
ex match {
case badRequest: BadRequestException => throw badRequest
case _ => invalidHeaderValue
}
}
}
/**
* A [[FeatureFactoryConfig]] with a fixed configuration, to be used in tests.
*
* @param testToggles the toggles to be used.
*/
class TestFeatureFactoryConfig(testToggles: Set[FeatureToggle], parent: FeatureFactoryConfig)
extends OverridingFeatureFactoryConfig(parent) {
protected override val featureToggles: Map[String, FeatureToggle] = testToggles.map { setting =>
setting.featureName -> setting
}.toMap
}