Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.70% covered (success)
97.70%
85 / 87
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
StreamConfig
97.70% covered (success)
97.70%
85 / 87
80.00% covered (warning)
80.00%
8 / 10
33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 stream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 topics
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 toArray
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 matches
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 matchesSettings
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 isPartialMatch
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
12.04
 arrayIsList
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isValidRegex
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validate
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace MediaWiki\Extension\EventStreamConfig;
4
5use Wikimedia\Assert\Assert;
6
7/**
8 * Configuration of single stream's settings.
9 */
10class StreamConfig {
11
12    /**
13     * Key of stream setting. Used to DRY references here.
14     * @var string
15     */
16    public const STREAM_SETTING = 'stream';
17
18    /**
19     * Streams can be made up of multiple topics.  If
20     * topic_prefixes are set, the topics will default to be
21     * the stream name with these prefixes.
22     * @var array
23     */
24    public const TOPIC_PREFIXES_SETTING = 'topic_prefixes';
25
26    /**
27     * Streams can be made up of multiple topics.
28     * @var array
29     */
30    public const TOPICS_SETTING = 'topics';
31
32    /**
33     * Wrapped array with stream config settings.  See $settings
34     * param doc for __construct().
35     * @var array
36     */
37    private $settings;
38
39    /**
40     * True if the stream setting is a regex, false otherwise.
41     * Used to avoid attempting to regex match with a non regex.
42     * @var bool
43     */
44    private $streamIsRegex;
45
46    /**
47     * @param string $stream stream name, e.g., "my.event.stream-name"
48     * @param array $settings
49     *        An array with stream config settings.
50     *        Must include a 'stream' setting with either the explicit stream
51     *        name, or a regex pattern (starting with '/') that will match
52     *        against stream names that this config should be used for.
53     *        Example:
54     *        [
55     *          "schema_title" => "my/event/schema",
56     *          "sample" => [
57     *            "rate" => 0.8,
58     *            "unit" => "session",
59     *          ],
60     *          "destination_event_service" => "eventgate-analytics-public",
61     *           ...
62     *        ]
63     * @param array $defaultSettings
64     *        An array with default stream config settings.
65     */
66    public function __construct( string $stream, array $settings, array $defaultSettings = [] ) {
67        $this->settings = $settings + $defaultSettings;
68
69        // To preserve backward compatibility, add the stream name or regex pattern to the config body
70        // as the 'stream' setting. This can likely be removed if we're sure nothing is relying on it.
71        // Background: T277193
72        $this->settings[self::STREAM_SETTING] = $stream;
73
74        self::validate( $this->settings );
75
76        $this->streamIsRegex = self::isValidRegex( $this->stream() );
77    }
78
79    /**
80     * Gets the stream setting
81     * @return string
82     */
83    public function stream() {
84        return $this->settings[self::STREAM_SETTING];
85    }
86
87    /**
88     * Returns a list of topics that compose the $stream.
89     * If a target $stream is not provided, $stream will default to the value of
90     * $this->stream(), which is this StreamConfig's stream setting.
91     *
92     * - If self::TOPICS_SETTING is set, this will be returned as is.
93     * - Else if self::TOPIC_PREFIXES_SETTING is not set, then [$stream] will be returned.
94     * - Else if $stream is a regex, it will be modified to include the prefixes in the regex.
95     * - Else a list of topics with the prefixes will be returned.
96     *
97     * @param string|null $stream
98     * @return array
99     */
100    public function topics( $stream = null ): array {
101        // if $stream was provided, it could be an explicit target stream name
102        // OR a regex stream setting.  $this->stream() could also be either, but in the
103        // case where it is a regex, a user might want to provide an explicit stream name
104        // to get topics rather than this StreamConfig's regex stream setting.
105        // Default to using the StreamConfig stream setting otherwise.
106        if ( !$stream ) {
107            $stream = $this->stream();
108        }
109
110        if ( isset( $this->settings[self::TOPICS_SETTING] ) ) {
111            // If this stream was configured with specific topics, just return those.
112            return $this->settings[self::TOPICS_SETTING];
113        } elseif ( !isset( $this->settings[self::TOPIC_PREFIXES_SETTING] ) ) {
114            // Else if this stream does not has topic prefixes, just return the
115            // stream name as the topic.
116            return [ $stream ];
117        } else {
118            $topicPrefixes = $this->settings[self::TOPIC_PREFIXES_SETTING];
119
120            if ( self::isValidRegex( $stream ) ) {
121                // This is a regex string stream name, return a regex with the prefixes.
122
123                // Remove the regex boundry chars.
124                $streamPattern = trim( $stream, '/' );
125
126                // If the regex starts with ^, save it for later.
127                $beginAnchor = '';
128                if ( substr( $streamPattern, 0, 1 ) === '^' ) {
129                    $beginAnchor = '^';
130                    $streamPattern = substr( $streamPattern, 1 );
131                }
132
133                // Escape any regex looking chars in the prefixes
134                $topicPrefixes = array_map( "preg_quote", $topicPrefixes );
135
136                // Reconstruct the regex with prefixes, e.g.
137                // /^(eqiad.|codfw.)
138                return [
139                    '/' . $beginAnchor .
140                    '(' . implode( '|', $topicPrefixes ) . ')' . $streamPattern .
141                    '/'
142                ];
143            } else {
144                // Else prefix the stream with each topic prefix.
145                // If $stream is a regex string, then we need to alter the
146                // regex to prefix safely inside the regex string.
147                return array_map(
148                    static function ( $topicPrefix ) use ( $stream ) {
149                        return $topicPrefix . $stream;
150                    },
151                    $topicPrefixes
152                );
153            }
154        }
155    }
156
157    /**
158     * Returns this StreamConfig as an array of settings.
159     * self::TOPICS_SETTING is set to the value returned by $this->topics($targetStream)
160     * if self::TOPICS_SETTING is not explicitly set.
161     *
162     * @param string|null $targetStream
163     *        If given, this will be used to get topics, otherwise $this->stream()
164     *        will be used (which could be a regex stream pattern).
165     *
166     * @return array
167     */
168    public function toArray( $targetStream = null ): array {
169        $settings = $this->settings;
170
171        // If TOPICS_SETTING is already set explicitly in the settings,
172        // $this->topics() will just return it.
173        $settings[self::TOPICS_SETTING] = $this->topics( $targetStream );
174
175        return $settings;
176    }
177
178    /**
179     * True if this StreamConfig applies for $stream, false otherwise.
180     *
181     * @param string $stream name of stream to match
182     * @return bool
183     */
184    public function matches( $stream ) {
185        return $this->streamIsRegex ?
186            preg_match( $this->stream(), $stream ) :
187            ( $this->stream() === $stream );
188    }
189
190    /**
191     * True if this StreamConfig has all of the given $settingsConstraints.
192     * If 'stream' is given as a constraint, it be matched against this
193     * StreamConfig's stream name via $this->matches.
194     *
195     * @param array $settingsConstraints
196     * @return bool
197     */
198    public function matchesSettings( $settingsConstraints ) {
199        if ( isset( $settingsConstraints[self::STREAM_SETTING] ) ) {
200            if ( !$this->matches( $settingsConstraints[self::STREAM_SETTING] ) ) {
201                return false;
202            }
203            // stream matching is special and can't use ::isPartialMatch(). Since we've already
204            // matched stream, remove it from the constraints now.
205            unset( $settingsConstraints[self::STREAM_SETTING] );
206        }
207
208        return $this->isPartialMatch( $this->settings, $settingsConstraints );
209    }
210
211    /**
212     * Partially matches two arrays, LHS and RHS, with one another up to a maximum depth. LHS and
213     * RHS are considered a partial match if LHS contains all of RHS.
214     *
215     * To maintain backwards compatibility, all non-array values are cast to strings before they
216     * are compared.
217     *
218     * @param array $lhs
219     * @param array $rhs
220     * @param int $maxDepth
221     * @return bool
222     */
223    private function isPartialMatch( $lhs, $rhs, $maxDepth = 10 ) {
224        if ( $maxDepth === 0 ) {
225            return true;
226        }
227
228        foreach ( $rhs as $key => $expected ) {
229            if ( !isset( $lhs[$key] ) ) {
230                return false;
231            }
232
233            $actual = $lhs[$key];
234
235            if ( is_array( $expected ) && is_array( $actual ) ) {
236                if ( $this->arrayIsList( $expected ) && $this->arrayIsList( $actual ) ) {
237                    return array_intersect( $expected, $actual ) === $expected;
238                }
239
240                if ( !$this->isPartialMatch( $actual, $expected, $maxDepth - 1 ) ) {
241                    return false;
242                }
243            } elseif (
244
245                // Avoid casting $expected or $actual to a string if either one of them is an
246                // array.
247                is_array( $expected ) ||
248                is_array( $actual ) ||
249
250                (string)$expected !== (string)$actual
251            ) {
252                return false;
253            }
254        }
255
256        return true;
257    }
258
259    /**
260     * Gets whether the array is a list, i.e. an integer-indexed array with indices starting at 0.
261     *
262     * As written, this method trades performance for elegance. This method should not be called on
263     * large arrays.
264     *
265     * TODO: Replace this with array_is_list when MediaWiki supports PHP >= 8.1
266     *
267     * @param array $array
268     * @return bool
269     */
270    private function arrayIsList( $array ) {
271        $array = array_keys( $array );
272
273        return $array === array_keys( $array );
274    }
275
276    /**
277     * Returns true if $string is a valid regex.
278     * It must start with '/' and preg_match must not return false.
279     *
280     * @param string $string
281     * @return bool
282     */
283    private static function isValidRegex( $string ) {
284        // FIXME: This is very ugly, and not very safe.
285        // Temporarily disable errors/warnings when checking if valid regex.
286        $errorLevel = error_reporting( E_ERROR );
287        // @phan-suppress-next-line PhanParamSuspiciousOrder false positive
288        $isValid = mb_substr( $string, 0, 1 ) === '/' && preg_match( $string, '' ) !== false;
289        error_reporting( $errorLevel );
290        return $isValid;
291    }
292
293    /**
294     * Validates that the stream config settings are valid.
295     *
296     * @param array $settings
297     * @throws \InvalidArgumentException if stream config settings are invalid
298     */
299    private static function validate( array $settings ) {
300        Assert::parameter(
301            isset( $settings[self::STREAM_SETTING] ),
302            self::STREAM_SETTING,
303            self::STREAM_SETTING . ' not set in stream config entry ' .
304                var_export( $settings, true )
305        );
306        $stream = $settings[self::STREAM_SETTING];
307
308        Assert::parameterType( 'string', $stream, self::STREAM_SETTING );
309        // If stream looks like a regex, make sure it is valid.
310        // (Yes, isValidRegex also checks that string starts with '/', but here we want
311        // to fail if the stream is not a valid regex.)
312        if ( substr( $stream, 0, 1 ) === '/' ) {
313            Assert::parameter(
314                self::isValidRegex( $stream ), self::STREAM_SETTING, "Invalid regex '$stream'"
315            );
316        }
317
318        if ( isset( $settings[self::TOPIC_PREFIXES_SETTING] ) ) {
319            Assert::parameterType(
320                'array',
321                $settings[self::TOPIC_PREFIXES_SETTING],
322                self::TOPIC_PREFIXES_SETTING
323            );
324        }
325
326        if ( isset( $settings[self::TOPICS_SETTING] ) ) {
327            Assert::parameterType(
328                'array',
329                $settings[self::TOPICS_SETTING],
330                self::TOPICS_SETTING
331            );
332        }
333    }
334}