Config::variant()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 6
rs 9.4286
cc 1
eloc 4
nc 1
nop 0
1
<?php
2
3
namespace Feature;
4
5
use Feature\Contracts\User;
6
use Feature\Contracts\World;
7
8
/**
9
 * Class Config
10
 * @package Feature
11
 */
12
class Config
13
{
14
    /* Keys used in a feature configuration. */
15
    const DESCRIPTION         = 'description';
16
    const ENABLED             = 'enabled';
17
    const USERS               = 'users';
18
    const GROUPS              = 'groups';
19
    const ADMIN               = 'admin';
20
    const INTERNAL            = 'internal';
21
    const PUBLIC_URL_OVERRIDE = 'public_url_override';
22
    const BUCKETING           = 'bucketing';
23
24
    /* Special values for enabled property. */
25
    const ON  = 'on';  /* Feature is fully enabled. */
26
    const OFF = 'off'; /* Feature is fully disabled. */
27
28
    /* Bucketing schemes. */
29
    const UAID   = 'uaid';
30
    const USER   = 'user';
31
    const RANDOM = 'random';
32
33
    /**
34
     * @var string
35
     */
36
    private $name;
37
38
    /**
39
     * @var array
40
     */
41
    private $cache;
42
43
    /**
44
     * @var World
45
     */
46
    private $world;
47
48
    private $description;
49
    private $enabled;
50
    private $users;
51
    private $groups;
52
    private $adminVariant;
53
    private $internalVariant;
54
    private $publicUrlOverride;
55
    private $bucketing;
56
57
    private $percentages;
58
59
    /**
60
     * @param string $name
61
     * @param string|array $stanza
62
     * @param World $world
63
     */
64
    public function __construct($name, $stanza, World $world)
65
    {
66
        $this->name  = $name;
67
        $this->cache = array();
68
        $this->world = $world;
69
70
        // Special case to save some memory--if the value is just a
71
        // string that is the same as setting enabled to that variant
72
        // (typically 'on' or 'off' but possibly another variant
73
        // name). This reduces the number of array objects we have to
74
        // create when reading the config file.
75
        if (is_null($stanza)) {
76
            $stanza = array(self::ENABLED => self::OFF);
77
        } elseif (is_string($stanza)) {
78
            $stanza = array(self::ENABLED => $stanza);
79
        }
80
81
        // Pull stuff from the config stanza.
82
        $this->description         = $this->parseDescription($stanza);
83
        $this->enabled             = $this->parseEnabled($stanza);
84
        $this->users               = $this->parseUsersOrGroups($stanza, self::USERS);
85
        $this->groups              = $this->parseUsersOrGroups($stanza, self::GROUPS);
86
        $this->adminVariant        = $this->parseVariantName($stanza, self::ADMIN);
87
        $this->internalVariant     = $this->parseVariantName($stanza, self::INTERNAL);
88
        $this->publicUrlOverride   = $this->parsePublicURLOverride($stanza);
89
        $this->bucketing           = $this->parseBucketBy($stanza);
90
91
        // Put the _enabled value into a more useful form for actually doing bucketing.
92
        $this->percentages = $this->computePercentages();
93
    }
94
95
    ////////////////////////////////////////////////////////////////////////
96
    // Public API, though note that Feature.php is the only code that
97
    // should be using this class directly.
98
99
    /*
100
     * Is this feature enabled for the default id and the logged in
101
     * user, if any?
102
     */
103
    public function isEnabled()
104
    {
105
        $bucketingId = $this->bucketingId();
106
        $userId      = $this->world->userId();
107
        return $this->chooseVariant($bucketingId, $userId, false) !== self::OFF;
108
    }
109
110
    /*
111
     * What variant is enabled for the default id and the logged in
112
     * user, if any?
113
     */
114
    public function variant()
115
    {
116
        $bucketingId = $this->bucketingId();
117
        $userId      = $this->world->userId();
118
        return $this->chooseVariant($bucketingId, $userId, true);
119
    }
120
121
    /*
122
     * Is this feature enabled for the given user?
123
     */
124
    public function isEnabledFor(User $user)
125
    {
126
        return $this->chooseVariant($user->getId(), $user->getId(), false) !== self::OFF;
127
    }
128
129
    /*
130
     * Is this feature enabled, bucketing on the given bucketing
131
     * ID? (Other methods of enabling a feature and specifying a
132
     * variant such as users, groups, and query parameters, will still
133
     * work.)
134
     */
135
    public function isEnabledBucketingBy($bucketingId)
136
    {
137
        $userId = $this->world->userId();
138
        return $this->chooseVariant($bucketingId, $userId, false) !== self::OFF;
139
    }
140
141
    /*
142
     * What variant is enabled for the given user?
143
     */
144
    public function variantFor(User $user)
145
    {
146
        return $this->chooseVariant($user->getId(), $user->getId(), true);
147
    }
148
149
    /*
150
     * What variant is enabled, bucketing on the given bucketing ID,
151
     * if any?
152
     */
153
    public function variantBucketingBy($bucketingId)
154
    {
155
        $userId = $this->world->userId();
156
        return $this->chooseVariant($bucketingId, $userId, true);
157
    }
158
159
    /*
160
     * Description of the feature.
161
     */
162
    public function description()
163
    {
164
        return $this->description;
165
    }
166
167
168
    ////////////////////////////////////////////////////////////////////////
169
    // Internals
170
171
    /*
172
     * Get the name of the variant we should use. Returns OFF if the
173
     * feature is not enabled for $id. When $inVariantMethod is
174
     * true will also check the conditions that should hold for a
175
     * correct call to variant or variantFor: they should not be
176
     * called for features that are completely enabled (i.e. 'enabled'
177
     * => 'on') since all such variant-specific code should have been
178
     * cleaned up before changing the config and they should not be
179
     * called if the feature is, in fact, disabled for the given id
180
     * since those two methods should always be guarded by an
181
     * isEnabled/isEnabledFor call.
182
     *
183
     * @param $bucketingID the id used to assign a variant based on
184
     * the percentage of users that should see different variants.
185
     *
186
     * @param $userID the identity of the user to be used for the
187
     * special 'admin', 'users', and 'groups' access checks.
188
     *
189
     * @param $inVariantMethod were we called from variant or
190
     * variantFor, in which case we want to perform some certain
191
     * sanity checks to make sure the code is being used correctly.
192
     */
193
    private function chooseVariant($bucketingId, $userId, $inVariantMethod)
194
    {
195
        if ($inVariantMethod && $this->enabled === self::ON) {
196
            $this->error("Variant check when fully enabled");
197
        }
198
199
        if (is_string($this->enabled)) {
200
            // When enabled is on, off, or a variant name, that's the
201
            // end of the story.
202
            return $this->enabled;
203
        } else {
204
            if (is_null($bucketingId)) {
205
                throw new \InvalidArgumentException(
206
                    "no bucketing ID supplied. if testing, configure feature " .
207
                    "with enabled => 'on' or 'off', feature name = " .
208
                    $this->name
209
                );
210
            }
211
212
            $bucketingId = (string)$bucketingId;
213
            if (array_key_exists($bucketingId, $this->cache)) {
214
                // Note that this caching is not just an optimization:
215
                // it prevents us from double logging a single
216
                // feature--we only want to log each distinct checked
217
                // feature once.
218
                //
219
                // The caching also affects the semantics when we use
220
                // random bucketing (rather than hashing the id), i.e.
221
                // 'random' => 'true', by making the variant and
222
                // enabled status stable within a request.
223
                return $this->cache[$bucketingId];
224
            } else {
225
                list($v, $selector) =
226
                    $this->variantFromURL($userId) ?:
227
                        $this->variantForUser($userId) ?:
228
                            $this->variantForGroup($userId) ?:
229
                                $this->variantForAdmin($userId) ?:
230
                                    $this->variantForInternal() ?:
231
                                        $this->variantByPercentage($bucketingId) ?:
232
                                            array(self::OFF, 'w');
233
234
                if ($inVariantMethod && $v === self::OFF) {
235
                    $this->error("Variant check outside enabled check");
236
                }
237
238
                $this->world->log($this->name, $v, $selector);
239
240
                return $this->cache[$bucketingId] = $v;
241
            }
242
        }
243
    }
244
245
    /*
246
     * Return the globally accessible ID used by the one-arg isEnabled
247
     * and variant methods based on the feature's bucketing property.
248
     */
249
    private function bucketingId()
250
    {
251
        switch ($this->bucketing) {
252
            case self::UAID:
253
            case self::RANDOM:
254
                // In the RANDOM case we still need a bucketing id to keep
255
                // the assignment stable within a request.
256
                // Note that when being run from outside of a web request (e.g. crons),
257
                // there is no UAID, so we default to a static string
258
                $uaid = $this->world->uaid();
259
                return $uaid ? $uaid : "no uaid";
260
            case self::USER:
261
                $userId = $this->world->userId();
262
                // Not clear if this is right. There's an argument to be
263
                // made that if we're bucketing by userId and the user is
264
                // not logged in we should treat the feature as disabled.
265
                return !is_null($userId) ? $userId : $this->world->uaid();
266
            default:
267
                throw new \InvalidArgumentException("Bad bucketing: $this->bucketing");
268
        }
269
    }
270
271
    /*
272
     * For internal requests or if the feature has public_url_override
273
     * set to true, a specific variant can be specified in the
274
     * 'features' query parameter. In all other cases return false,
275
     * meaning nothing was specified. Note that foo:off will turn off
276
     * the 'foo' feature.
277
     */
278
    private function variantFromURL($userId)
279
    {
280
        if ($this->publicUrlOverride or
0 ignored issues
show
Comprehensibility Best Practice introduced by
Using logical operators such as or instead of || is generally not recommended.

PHP has two types of connecting operators (logical operators, and boolean operators):

  Logical Operators Boolean Operator
AND - meaning and &&
OR - meaning or ||

The difference between these is the order in which they are executed. In most cases, you would want to use a boolean operator like &&, or ||.

Let’s take a look at a few examples:

// Logical operators have lower precedence:
$f = false or true;

// is executed like this:
($f = false) or true;


// Boolean operators have higher precedence:
$f = false || true;

// is executed like this:
$f = (false || true);

Logical Operators are used for Control-Flow

One case where you explicitly want to use logical operators is for control-flow such as this:

$x === 5
    or die('$x must be 5.');

// Instead of
if ($x !== 5) {
    die('$x must be 5.');
}

Since die introduces problems of its own, f.e. it makes our code hardly testable, and prevents any kind of more sophisticated error handling; you probably do not want to use this in real-world code. Unfortunately, logical operators cannot be combined with throw at this point:

// The following is currently a parse error.
$x === 5
    or throw new RuntimeException('$x must be 5.');

These limitations lead to logical operators rarely being of use in current PHP code.

Loading history...
281
            $this->world->isInternalRequest() or
0 ignored issues
show
Comprehensibility Best Practice introduced by
Using logical operators such as or instead of || is generally not recommended.

PHP has two types of connecting operators (logical operators, and boolean operators):

  Logical Operators Boolean Operator
AND - meaning and &&
OR - meaning or ||

The difference between these is the order in which they are executed. In most cases, you would want to use a boolean operator like &&, or ||.

Let’s take a look at a few examples:

// Logical operators have lower precedence:
$f = false or true;

// is executed like this:
($f = false) or true;


// Boolean operators have higher precedence:
$f = false || true;

// is executed like this:
$f = (false || true);

Logical Operators are used for Control-Flow

One case where you explicitly want to use logical operators is for control-flow such as this:

$x === 5
    or die('$x must be 5.');

// Instead of
if ($x !== 5) {
    die('$x must be 5.');
}

Since die introduces problems of its own, f.e. it makes our code hardly testable, and prevents any kind of more sophisticated error handling; you probably do not want to use this in real-world code. Unfortunately, logical operators cannot be combined with throw at this point:

// The following is currently a parse error.
$x === 5
    or throw new RuntimeException('$x must be 5.');

These limitations lead to logical operators rarely being of use in current PHP code.

Loading history...
282
            $this->world->isAdmin($userId)
283
        ) {
284
            $urlFeatures = $this->world->urlFeatures();
285
            if ($urlFeatures) {
286
                foreach (explode(',', $urlFeatures) as $f) {
287
                    $parts = explode(':', $f);
288
                    if ($parts[0] === $this->name) {
289
                        return array(isset($parts[1]) ? $parts[1] : self::ON, 'o');
290
                    }
291
                }
292
            }
293
        }
294
        return false;
295
    }
296
297
    /*
298
     * Get the variant this user should see, if one was configured,
299
     * false otherwise.
300
     */
301
    private function variantForUser($userId)
302
    {
303
        if ($this->users) {
304
            $name = $this->world->userName($userId);
305
            if ($name && array_key_exists($name, $this->users)) {
306
                return array($this->users[$name], 'u');
307
            }
308
        }
309
        return false;
310
    }
311
312
    /*
313
     * Get the variant this user should see based on their group
314
     * memberships, if one was configured, false otherwise. N.B. If
315
     * the user is in multiple groups that are configured to see
316
     * different variants, they'll get the variant for one of their
317
     * groups but there's no saying which one. If this is a problem in
318
     * practice we could make the configuration more complex. Or you
319
     * can just provide a specific variant via the 'users' property.
320
     */
321
    private function variantForGroup($userId)
322
    {
323
        if ($userId) {
324
            foreach ($this->groups as $groupId => $variant) {
325
                if ($this->world->inGroup($userId, $groupId)) {
326
                    return array($variant, 'g');
327
                }
328
            }
329
        }
330
        return false;
331
    }
332
333
    /*
334
     * What variant, if any, should we return if the current user is
335
     * an admin.
336
     */
337
    private function variantForAdmin($userId)
338
    {
339
        if ($userId && $this->adminVariant) {
340
            if ($this->world->isAdmin($userId)) {
341
                return array($this->adminVariant, 'a');
342
            }
343
        }
344
        return false;
345
    }
346
347
    /*
348
     * What variant, if any, should we return for internal requests.
349
     */
350
    private function variantForInternal()
351
    {
352
        if ($this->internalVariant) {
353
            if ($this->world->isInternalRequest()) {
354
                return array($this->internalVariant, 'i');
355
            }
356
        }
357
        return false;
358
    }
359
360
    /*
361
     * Finally, the normal case: use the percentage of users who
362
     * should see each variant to map a randomish number to a
363
     * particular variant.
364
     */
365
    private function variantByPercentage($id)
366
    {
367
        $n = 100 * $this->randomish($id);
368
        foreach ($this->percentages as $v) {
369
            // === 100 check may not be necessary but I'm not good
370
            // enough numerical analyst to be sure.
371
            if ($n < $v[0] || $v[0] === 100) {
372
                return array($v[1], 'w');
373
            }
374
        }
375
        return false;
376
    }
377
378
    /*
379
     * A randomish number in [0, 1) based on the feature name and $id
380
     * unless we are bucketing completely at random.
381
     */
382
    private function randomish($id)
383
    {
384
        return $this->bucketing === self::RANDOM
385
            ? Util::random() : Util::randomById($this->name . '-' . $id);
386
    }
387
388
    ////////////////////////////////////////////////////////////////////////
389
    // Configuration parsing
390
391
    private function parseDescription($stanza)
392
    {
393
        return Util::arrayGet($stanza, self::DESCRIPTION, 'No description.');
394
    }
395
396
    /*
397
     * Parse the 'enabled' property of the feature's config stanza.
398
     */
399
    private function parseEnabled($stanza)
400
    {
401
        $enabled = Util::arrayGet($stanza, self::ENABLED, 0);
402
403
        if (is_numeric($enabled)) {
404
            if ($enabled < 0) {
405
                $this->error("enabled ($enabled) < 0");
406
                $enabled = 0;
407
            } elseif ($enabled > 100) {
408
                $this->error("enabled ($enabled) > 100");
409
                $enabled = 100;
410
            }
411
            return array('on' => $enabled);
412
413
        } elseif (is_string($enabled) or is_array($enabled)) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
Using logical operators such as or instead of || is generally not recommended.

PHP has two types of connecting operators (logical operators, and boolean operators):

  Logical Operators Boolean Operator
AND - meaning and &&
OR - meaning or ||

The difference between these is the order in which they are executed. In most cases, you would want to use a boolean operator like &&, or ||.

Let’s take a look at a few examples:

// Logical operators have lower precedence:
$f = false or true;

// is executed like this:
($f = false) or true;


// Boolean operators have higher precedence:
$f = false || true;

// is executed like this:
$f = (false || true);

Logical Operators are used for Control-Flow

One case where you explicitly want to use logical operators is for control-flow such as this:

$x === 5
    or die('$x must be 5.');

// Instead of
if ($x !== 5) {
    die('$x must be 5.');
}

Since die introduces problems of its own, f.e. it makes our code hardly testable, and prevents any kind of more sophisticated error handling; you probably do not want to use this in real-world code. Unfortunately, logical operators cannot be combined with throw at this point:

// The following is currently a parse error.
$x === 5
    or throw new RuntimeException('$x must be 5.');

These limitations lead to logical operators rarely being of use in current PHP code.

Loading history...
414
            return $enabled;
415
        }
416
        $this->error("Malformed enabled property");
417
    }
418
419
    /*
420
     * Returns an array of pairs with the first element of the pair
421
     * being the upper-boundary of the variants percentage and the
422
     * second element being the name of the variant.
423
     */
424
    private function computePercentages()
425
    {
426
        $total = 0;
427
        $percentages = array();
428
        if (is_array($this->enabled)) {
429
            foreach ($this->enabled as $variant => $percentage) {
430
                if (!is_numeric($percentage) || $percentage < 0 || $percentage > 100) {
431
                    $this->error("Bad percentage $percentage");
432
                }
433
                if ($percentage > 0) {
434
                    $total += $percentage;
435
                    $percentages[] = array($total, $variant);
436
                }
437
                if ($total > 100) {
438
                    $this->error("Total of percentages > 100: $total");
439
                }
440
            }
441
        }
442
        return $percentages;
443
    }
444
445
    /*
446
     * Parse the value of the 'users' and 'groups' properties of the
447
     * feature's config stanza, returning an array mappinng the user
448
     * or group names to they variant they should see.
449
     */
450
    private function parseUsersOrGroups($stanza, $what)
451
    {
452
        $value = Util::arrayGet($stanza, $what);
453
        if (is_string($value) || is_numeric($value)) {
454
            // Users are configrued with their user names. Groups as
455
            // numeric ids. (Not sure if that's a great idea.)
456
            return array($value => self::ON);
457
458
        } elseif (self::isList($value)) {
459
            $result = array();
460
            foreach ($value as $who) {
0 ignored issues
show
Bug introduced by
The expression $value of type object|null|array|boolean is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
461
                $result[strtolower($who)] = self::ON;
462
            }
463
            return $result;
464
465
        } elseif (is_array($value)) {
466
            $result = array();
467
            $bad_keys = is_array($this->enabled) ?
468
                array_keys(array_diff_key($value, $this->enabled)) :
469
                array();
470
            if (!$bad_keys) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $bad_keys of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
471
                foreach ($value as $variant => $whos) {
472
                    foreach (self::asArray($whos) as $who) {
473
                        $result[strtolower($who)] = $variant;
474
                    }
475
                }
476
                return $result;
477
478
            } else {
479
                $this->error("Unknown variants " . implode(', ', $bad_keys));
480
            }
481
        }
482
        return array();
483
    }
484
485
    /*
486
     * Parse the variant name value for the 'admin' and 'internal'
487
     * properties. If non-falsy, must be one of the keys in the
488
     * enabled map unless enabled is 'on' or 'off'.
489
     */
490
    private function parseVariantName($stanza, $what)
491
    {
492
        $value = Util::arrayGet($stanza, $what);
493
        if ($value) {
494
            if (is_array($this->enabled)) {
495
                if (array_key_exists($value, $this->enabled)) {
496
                    return $value;
497
                } else {
498
                    $this->error("Unknown variant $value");
499
                }
500
            } else {
501
                return $value;
502
            }
503
        }
504
        return false;
505
    }
506
507
    /**
508
     * @param array $stanza
509
     * @return mixed|null
510
     */
511
    private function parsePublicURLOverride($stanza)
512
    {
513
        return Util::arrayGet($stanza, self::PUBLIC_URL_OVERRIDE, false);
514
    }
515
516
    /**
517
     * @param array $stanza
518
     * @return mixed|null
519
     */
520
    private function parseBucketBy($stanza)
521
    {
522
        return Util::arrayGet($stanza, self::BUCKETING, self::UAID);
523
    }
524
525
    ////////////////////////////////////////////////////////////////////////
526
    // Genericish utilities
527
528
    /*
529
     * Is the given object an array value that could have been created
530
     * with array(...) with no =>'s in the ...?
531
     */
532
    private static function isList($a)
533
    {
534
        return is_array($a) and array_keys($a) === range(0, count($a) - 1);
0 ignored issues
show
Comprehensibility Best Practice introduced by
Using logical operators such as and instead of && is generally not recommended.

PHP has two types of connecting operators (logical operators, and boolean operators):

  Logical Operators Boolean Operator
AND - meaning and &&
OR - meaning or ||

The difference between these is the order in which they are executed. In most cases, you would want to use a boolean operator like &&, or ||.

Let’s take a look at a few examples:

// Logical operators have lower precedence:
$f = false or true;

// is executed like this:
($f = false) or true;


// Boolean operators have higher precedence:
$f = false || true;

// is executed like this:
$f = (false || true);

Logical Operators are used for Control-Flow

One case where you explicitly want to use logical operators is for control-flow such as this:

$x === 5
    or die('$x must be 5.');

// Instead of
if ($x !== 5) {
    die('$x must be 5.');
}

Since die introduces problems of its own, f.e. it makes our code hardly testable, and prevents any kind of more sophisticated error handling; you probably do not want to use this in real-world code. Unfortunately, logical operators cannot be combined with throw at this point:

// The following is currently a parse error.
$x === 5
    or throw new RuntimeException('$x must be 5.');

These limitations lead to logical operators rarely being of use in current PHP code.

Loading history...
535
    }
536
537
    /**
538
     * @param mixed $x
539
     * @return array
540
     */
541
    private static function asArray($x)
542
    {
543
        return is_array($x) ? $x : array($x);
544
    }
545
546
    /**
547
     * @param $message
548
     */
549
    private function error($message)
550
    {
551
        throw new \InvalidArgumentException($message);
552
    }
553
}
554