diff --git a/Documentation-develop/PARSER.md b/Documentation-develop/PARSER.md new file mode 100644 index 0000000000000000000000000000000000000000..6303f165397742e861471067ebdbbb9c2f2cd03b --- /dev/null +++ b/Documentation-develop/PARSER.md @@ -0,0 +1,119 @@ +# Parsers and Tokenizer + +## Motivation and use cases + +Parsing values for special QFQ columns starting from simple lists +of key-value pairs to enhanced JSON strings. + +## Overview of classes + +All classes are defined in the namespace `IMATHUZH\Qfq\Core\Parser`. + +### StringTokenizer + +This class provides a generator that iterates over a string +and returns tokens bound by predefined delimiters. The delimiters +are search in a smart way: +* delimiters escaped with a backslash are ignored in the search +* the parser can distinguish between escaping and escaped backslashes, + i.e. the colon (as a delimiter) is ignored in the string `ab\:cd` + but not in `ab\\:cd` +* a part of a string between quotes is treated as a plain text - all delimiters + are ignored (and the quote characters are removed). + +#### Examples with delimiters `:,|`: + +| Input string | Resulting sequence of tokens | +|-------------------|------------------------------| +| `ab:cd,ef\|gh` | `'ab' 'cd' 'ef' 'gh'` | +| `"ab:cd",ef\\|gh` | `'ab:cd' 'ef\|gh'` | + +#### Usage +<pre><code class="php">$tokenizer = new StringTokenizer(':,|'); +foreach ($tokenizer->tokenized('ab:cd,ef\|gh') as list($token, $delimiter)) { + // $token is an instance of Token class: + // $token->value is a string representation of the token + // $token->isString is true if the token is a string (quotes were used) + // $token->empty() is true for a token generated only from whitespace characters + // $delimiter === null when the end of the string is reached +} +</code></pre> + +### SimpleParser + +This class parses a string into a list of tokens separated by delimiters. +Comparing to `StringTokenizer`, the returned tokens literal values or special objects +the processing can be tweaked by options provided as an array in the second parameter. + +| Parameters key | Type | Meaning | +|------------------------|------|------------------------------------------------------------------------| +| `OPTION_PARSE_NUMBERS` | bool | Convert tokens to numbers when possible | +| `OPTION_KEEP_SIGN` | bool | Creates an instance of `SignedNumber` if a number has an explicit sign | +| `OPTION_KEY_IS_VALUE` | bool | Keys with no values are assigned its name as the value | +| `OPTION_EMPTY` | any | The value used for empty tokens | + +Note that the option `OPTION_KEY_IS_VALUE` is not used by `SimpleParser` but it is used +by derived classes. + +**Note**: the option `OPTION_KEEP_SIGN` is used by `jwt` column, so that claims +`exp` and `nbf` can be specified either with absolute (no plus) or relative +(with a plus) timestamps. + +#### Usage + +<pre><code class="php">$parser = new SimpleParser(":|"); +// By default five special values are configured: +// 'null' -> null +// 'true', 'yes' -> true +// 'false', 'no' -> false +// More can be defined by updating $specialValues property: +$parser->specialValues['qfq'] = 'QFQ is great'; + +// This returns an array ['abc', 'efg', 123, true, 'QFQ is great'] +$parser->parse("abc:efg|123|yes:qfq"); + +// The tokens can be iterated as follows +foreach($parser->iterate("abc:efg|123|yes") as $token) { + ... +} +</code></pre> + +### KVPairListParser + +This class parses a list of key-value pairs into an associative array. +It requires two arguments: the list separator and key-value separator. + +#### Usage +<pre><code class="php">// Default separators are , and : +$parser = new KVPairListParser("|", "="); +$parser->parse("a=43|b=false|xyz='a|b'"); +// result: [ 'a' => 43, 'b' => false, 'xyz' => 'a|b' ] + +foreach ($parser->iterate("a=43|b=false|xyz='a|b'") as $key => $value) { + ... +} +</code></pre> + +### MixedTypeParser + +This parser understands both lists and dictionaries and both structures can be nested. +The constructor must be provided six delimiters in one string: list separator, +key-value separator, list delimiters (begin and end), and dictionary delimiters +(begin and end). The default value is `,:[]{}`. It is also possible to replace +the list and dictionary delimiters with spaces, in which case the parser will +ignore it. For instance +* `new MixedTypeParser(',:[]')` can parse nested lists, but not dictionaries (the string is padded) +* `new MixedTypeParser(',: {}')` can parse nested dictionaries, but not lists + +This parser can be seen as an extension to a JSON parser: strings does not have +to be enclosed with quotes. + +#### Usage + +<pre><code class="php">$parser = new MixedTypeParser(',:[]{}', [ /* options */ ]); +$parser->parse('[0, { a: 14, b: 16 }, abc]'); +$parser->parseList('abc, [x, y, z], {a:15}, xyz'); +$parser->parseDictionary('num:15, arr:[x, y, z], dict:{a:15}, str:xyz'); +</code></pre> + +**Note**: there is no meaningful `iterate()` method. diff --git a/Documentation/Report.rst b/Documentation/Report.rst index 9ac07ad5b681bc94767079f34ef71e6206af5903..74ce355f6fb7c0863fe858f51f34db6ac3c785b7 100644 --- a/Documentation/Report.rst +++ b/Documentation/Report.rst @@ -779,6 +779,8 @@ Summary: +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |_decrypt |:ref:`column-decrypt` - Decrypt value. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +|_jwt |:ref:`column-jwt` - generates a json web token from the provided data. | ++------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ .. _column-link: @@ -1869,6 +1871,38 @@ Decrypting selected columns or strings which are encrypted with QFQ. 10.sql = SELECT secret AS _decrypt FROM Person WHERE id = 1 +.. _column-jwt: + +Column: _jwt +^^^^^^^^^^^^ + +Creates a `json web token <https://jwt.io/>`_ from the provided data. + +Supported options: + ++-----------+---------------+------------------------------------------------------+ +| Parameter | Default value | Note | ++===========+===============+======================================================+ +| `alg` | `HS256` | The signing algorithm - it is included in the header | +| `key` | (none) | The secret key used for signing the token | ++-----------+---------------+------------------------------------------------------+ + +Predefined claims: + ++-------+----------------+-------------------+-----------------------------------------------------------+ +| Claim | Present | Default value | Note | ++=======+================+===================+===========================================================+ +| `iss` | always | `qfq` | The default value might be also specified in QFQ settings | +| `iat` | always | current timestamp | Ignores any provided value | +| `exp` | when specified | none | Prefix with `+` to specify a relative timestamp | +| `nbf` | when specified | none | Prefix with `+` to specify a relative timestamp | ++-------+----------------+-------------------+-----------------------------------------------------------+ + +**Syntax** :: + + 10.sql = SELECT 'exp:+3600,data:{msg:default alg}|<secretKey>' AS _jwt + 20.sql = SELECT 'exp:+60,data:{msg:explicit agl}|<secretKey>|ES384' AS _jwt + .. _copyToClipboard: Copy to clipboard diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php index 3e93c4e96b0b10206d46428fad0254eb524be50f..baf66e0fa044c08463acc1e49b37ca6f43a8f1fa 100644 --- a/extension/Classes/Core/Constants.php +++ b/extension/Classes/Core/Constants.php @@ -1855,6 +1855,8 @@ const COLUMN_STORE_USER = '='; const COLUMN_FORM_JSON = 'formJson'; +const COLUMN_JWT = 'jwt'; + // Author: Enis Nuredini const COLUMN_ENCRYPT = 'encrypt'; const COLUMN_DECRYPT = 'decrypt'; diff --git a/extension/Classes/Core/Helper/OnString.php b/extension/Classes/Core/Helper/OnString.php index 4a22778881710a77760cad737a20bc87086a4bdb..87c099be40a5747bcaa64576a117903e7b74ba97 100644 --- a/extension/Classes/Core/Helper/OnString.php +++ b/extension/Classes/Core/Helper/OnString.php @@ -811,7 +811,7 @@ class OnString { return array(); } - // Search biggest element. + // Search the biggest element. foreach ($arr as $key => $value) { if (is_array($value)) { @@ -858,7 +858,7 @@ class OnString { ERROR_MESSAGE_TO_DEVELOPER => "max: $max, current: " . $currentLength . ", new reduced: " . $newLen]), ERROR_MISSING_OPEN_DELIMITER); } else { // Dive deeper to replace the next biggest element. - $arrNew = $self::limitSizeJsonEncode($arrNew, $newLen, $maxValue); + $arrNew = self::limitSizeJsonEncode($arrNew, $newLen, $maxValue); } } } diff --git a/extension/Classes/Core/Helper/Support.php b/extension/Classes/Core/Helper/Support.php index 3495cd3b8634bcd6ff3baa934aeab8b5711550e8..4dbcbc2f731b6f795ba2eef7f436bb0b12ff699d 100644 --- a/extension/Classes/Core/Helper/Support.php +++ b/extension/Classes/Core/Helper/Support.php @@ -152,7 +152,7 @@ class Support { public static function arrayToQueryString(array $queryArray) { $items = array(); - // CR/Workaround for broken behaviour: LINK() class expects 'id' always as first paramter + // CR/Workaround for broken behaviour: LINK() class expects 'id' always as first parameter // Take care that Parameter 'id' is the first one in the array: if (isset($queryArray[CLIENT_PAGE_ID])) { $id = $queryArray[CLIENT_PAGE_ID]; @@ -290,7 +290,7 @@ class Support { switch (strtolower($type)) { case 'size': case 'maxlength': - // empty or '0' for attributes of type 'size' or 'maxlength' result in unsuable input elements: skip this. + // empty or '0' for attributes of type 'size' or 'maxlength' result in unusable input elements: skip this. if ($value === '' || $value == 0) { return ''; } @@ -378,7 +378,7 @@ class Support { } /** - * Search for the parameter $needle in $haystack. The arguments has to be separated by ','. + * Search for the parameter $needle in $haystack. The arguments have to be separated by ','. * * Returns false if not found, or index (starting with 0) of found place. Be careful: use unary operator to compare for 'false' * @@ -630,7 +630,7 @@ class Support { } /** - * Returns a representation of 0 in a choosen variant. + * Returns a representation of 0 in a chosen variant. * * @param string $dateFormat FORMAT_DATE_INTERNATIONAL | FORMAT_DATE_GERMAN * @param string $showZero @@ -718,7 +718,7 @@ class Support { $placeholder = $timePattern; break; default: - throw new \UserFormException("Unexpected Formelement type: '" . $formElement[FE_TYPE] . "'", ERROR_FORMELEMENT_TYPE); + throw new \UserFormException("Unexpected form element type: '" . $formElement[FE_TYPE] . "'", ERROR_FORMELEMENT_TYPE); } return $placeholder; @@ -726,7 +726,7 @@ class Support { /** - * Encrypt curly braces by an uncommon string. Helps preventing unwished action on curly braces. + * Encrypt curly braces by an uncommon string. Helps to prevent unwanted actions on curly braces. * * @param string $text * @@ -740,7 +740,7 @@ class Support { } /** - * Decrypt curly braces by an uncommon string. Helps preventing unwished action on curly braces + * Decrypt curly braces by an uncommon string. Helps to prevent unwanted actions on curly braces * * @param string $text * @@ -760,7 +760,7 @@ class Support { /** * Creates a random string, starting with uniq microseconds timestamp. - * After a discussion of ME & CR, the uniqid() should be sufficient to guarantee uniqness. + * After a discussion of ME & CR, the uniqid() should be sufficient to guarantee uniqueness. * * @param int $length Length of the required hash string * @@ -782,7 +782,7 @@ class Support { } /** - * Concatenate URL and Parameter. Depending of if there is a '?' in URL or not, append the param with '?' or '&'.. + * Concatenate URL and Parameter. Depending on if there is a '?' in URL or not, append the param with '?' or '&'.. * * @param string $url * @param string|array $param @@ -1316,7 +1316,7 @@ class Support { /** * Check $arr, if there is an element $index. If not, set it to $value. - * If $overwriteThis!=false, replace the the original value with $value, if $arr[$index]==$overwriteThis. + * If $overwriteThis!=false, replace the original value with $value, if $arr[$index]==$overwriteThis. * * @param array $arr * @param string $index diff --git a/extension/Classes/Core/Parser/KVPairListParser.php b/extension/Classes/Core/Parser/KVPairListParser.php new file mode 100644 index 0000000000000000000000000000000000000000..ca04529d6b99f4d0842ff6761a1d7de307931712 --- /dev/null +++ b/extension/Classes/Core/Parser/KVPairListParser.php @@ -0,0 +1,92 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * Class KVPairListParser + * + * A parser for lists of key-value pairs with simple values. + * + * @package qfq + */ +class KVPairListParser extends SimpleParser { + + /** @var string the separator for different pairs */ + private string $listsep; + + /** @var string the character separating a key from its value */ + private string $kvsep; + + + public function __construct(string $listsep, string $kvsep, array $options = []) { + parent::__construct("$listsep$kvsep", $options); + $this->listsep = $listsep; + $this->kvsep = $kvsep; + } + + /** + * Iterates over the string and returns keys with values. Keys are always + * treated as strings and only values are processed and, when necessary, + * converted to numbers or special values. + * + * Usage: + * + * foreach($parser->iterate($input) as $key => $value) { + * ... + * } + * + * Examples + * pair separator: | + * key-value separator: = + * + * a=43|b=15 --> 'a'=>43 'b'=>15 + * a='x|y' | 13=87 --> 'a'=>'x|y' '13'=>87 + * + * @param string $data + * @return \Generator + */ + public function iterate(string $data): \Generator { + // Iterate over token provided by the base tokenizer class + $tokens = $this->tokenized($data); + while ($tokens->valid()) { + // Get the key first + list($keyToken, $delimiter) = $tokens->current(); + $key = strval($keyToken); + $tokens->next(); + if ($delimiter == $this->kvsep) { + // The key-value separator is found - find the corresponding value + if ($tokens->valid()) { + list($valueToken, $delimiter) = $tokens->current(); + $tokens->next(); + $empty = $valueToken->empty(); + $value = $this->process($valueToken); + } else { + // The end of the string - the value is empty + $delimiter = null; + $empty = true; + } + // Replace an empty token with the empty value + yield $key => $empty ? ( + $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE] + ) : $value; + } elseif ($key) { + // When no key-value separator, then do nothing it the key is empty + // In other words: ignore trailing commas + yield $key => $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE]; + } + // Check if the current delimiter is a correct one + if ($delimiter && $delimiter != $this->listsep) { + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->listsep + ); + } + }; + } +} \ No newline at end of file diff --git a/extension/Classes/Core/Parser/MixedTypeParser.php b/extension/Classes/Core/Parser/MixedTypeParser.php new file mode 100644 index 0000000000000000000000000000000000000000..fa72870883ae16a9c297f6e85720b1ab83edc131 --- /dev/null +++ b/extension/Classes/Core/Parser/MixedTypeParser.php @@ -0,0 +1,239 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + + +/** + * Class MixedTypeParser + * + * A parser for lists and dictionaries that can be nested. + * Requires a string of six delimiters provided in this order: + * - separator for list items + * - key-value separator + * - two delimiters (begin and end) for lists + * - two delimiters (begin and end) for dictionaries + * The parser can be restricted to only nested lists or only + * nested dictionaries by providing a space instead of the + * corresponding delimiters. + * + * @package qfq + */ +class MixedTypeParser extends SimpleParser { + + // Internally used constants + const SEP = 0; + const KVSEP = 1; + const LIST_START = 2; + const LIST_END = 3; + const DICT_START = 4; + const DICT_END = 5; + + /** @var string delimiters used by this parser */ + private string $delimiters = ',:[]{}'; + + + public function __construct(?string $delimiters = null, array $options = []) { + if ($delimiters) { + $this->delimiters = str_pad($delimiters, 6); + $delimiters = str_replace(' ', '', $delimiters); + } else { + $delimiters = $this->delimiters; + } + parent::__construct($delimiters, $options); + } + + /** + * Parses the provided string into a literal value, a non-associative array, + * or an associative array. The structures can be nested. + * + * @param string $data + * @return mixed + */ + public function parse(string $data) { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + list($data, $empty, $delimiter) = $this->parseImpl($tokens); + if ($delimiter) $this->raiseUnexpectedDelimiter($delimiter, $this->offset()); + return $data; + } else { + return $this->options[self::OPTION_EMPTY_VALUE]; + } + } + + /** + * Assumes the provided string is a list of items and parses + * it into a non-associative array (possibly empty or with only + * one element). + * + * @param string $data + * @return array + */ + public function parseList(string $data): array { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + return $this->parseListImpl($tokens, null)[0]; + } else { + return []; + } + } + + /** + * Assumes that the provided string is a dictionary (i.e. a list + * of key-value pairs) and parses it into an associative array. + * + * @param string $data + * @return array + */ + public function parseDictionary(string $data): array { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + return $this->parseDictionaryImpl($tokens, null)[0]; + } else { + return []; + } + } + + + /** + * The main method of the parser. It looks on the first token + * and decides on the following action based on the delimiter + * and the value of the token. + * + * @param \Generator $tokens + * @return array + */ + protected function parseImpl(\Generator $tokens): array { + // Get a token and the bounding delimiter + $tokenData = $tokens->current(); + $tokens->next(); + list($token, $delimiter) = $tokenData; + $empty = $token->empty(); + switch ($delimiter) { + case $this->delimiters[self::DICT_START]: + // The opening delimiter of a dictionary cannot be preceded by a nonempty token + $empty or $this->raiseUnexpectedToken($this->offset(), $delimiter, null); + // Start parsing the string as a dictionary + return $this->parseDictionaryImpl($tokens, $this->delimiters[self::DICT_END]); + case $this->delimiters[self::LIST_START]: + // The opening delimiter of a list cannot be preceded by a nonempty token + $empty or $this->raiseUnexpectedToken($this->offset(), $delimiter, null); + // Start parsing the string as a list + return $this->parseListImpl($tokens, $this->delimiters[self::LIST_END]); + default: + // Otherwise process the obtained token + return [$this->process($token), $empty, $delimiter]; + } + } + + /** + * A helper function that checks if a list of a dictionary is followed + * directly by another delimiter (or the end of the string) + * + * @param \Generator $tokens + * @param string|null $current + * @return string|null the next delimiter or null if none + */ + private function checkNextDelimiter(\Generator $tokens, ?string $current): ?string { + if ($current && $tokens->valid()) { + list($token, $next) = $tokens->current(); + $token->empty() or $this->raiseUnexpectedToken($this->offset(), $current, $next); + $tokens->next(); + return $next; + } else { + return null; + } + } + + /** + * Processes the tokens into a list + * + * @param \Generator $tokens + * @param string|null $endDelimiter + * @return array + */ + protected function parseListImpl(\Generator $tokens, ?string $endDelimiter): array { + $result = []; + do { + // Get a value to add to the list + list($value, $empty, $delimiter) = $this->parseImpl($tokens); + switch ($delimiter) { + case $this->delimiters[self::SEP]: + $result[] = $value; + break; + case $endDelimiter: + // Add an empty element only if there was a comma + if (!$empty || $result) { + $result[] = $value; + } + // The end of the list - check if not followed by a non-empty token + $delimiter = $this->checkNextDelimiter($tokens, $delimiter); + return [$result, false, $delimiter]; + default: + // Only list item separators are allowed here + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->delimiters[self::SEP] . $endDelimiter + ); + } + } while ($tokens->valid()); + // The string ended with a comma - append an empty string unless + // the list is expected to end with a delimiter + if ($endDelimiter) $this->raiseUnexpectedEnd($endDelimiter); + $result[] = $this->options['empty']; + return [$result, false, null]; + } + + /** + * Processes the tokens into a dictionary + * + * @param \Generator $tokens + * @param string|null $endDelimiter + * @return array + */ + protected function parseDictionaryImpl(\Generator $tokens, ?string $endDelimiter): array { + $result = []; + do { + // Get the key + list($key, $delimiter) = $tokens->current(); + $key = strval($key); + $tokens->next(); + if ($delimiter == $this->delimiters[self::KVSEP]) { + // The key-value separator is found - find the corresponding value + if ($tokens->valid()) { + list($value, $empty, $delimiter) = $this->parseImpl($tokens); + } else { + // The end of the string - the value is empty + $delimiter = null; + $empty = true; + } + // Replace an empty token with the empty value + $result[$key] = $empty ? ( + $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE] + ) : $value; + } elseif ($key) { + // When no key-value separator, then do nothing it the key is empty + // In other words: ignore trailing commas + $result[$key] = $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE]; + } + // Check if the current delimiter is a correct one + if ($delimiter == $endDelimiter) { + $delimiter = $this->checkNextDelimiter($tokens, $delimiter); + return [$result, false, $delimiter]; + } elseif ($delimiter != $this->delimiters[self::SEP]) { + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->delimiters[self::SEP] . $endDelimiter + ); + } + } while ($tokens->valid()); + // Trailing commas are ok for objects + return [$result, false, null]; + } +} diff --git a/extension/Classes/Core/Parser/SimpleParser.php b/extension/Classes/Core/Parser/SimpleParser.php new file mode 100644 index 0000000000000000000000000000000000000000..4981cd5f9fa0b4bfcfa31fb1460adff5dd6577bb --- /dev/null +++ b/extension/Classes/Core/Parser/SimpleParser.php @@ -0,0 +1,168 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * Represents a number prefixed with a sign. This class is used + * to treat such values differently from absolute numbers. + * This class implements `JsonSerializable`, so that it is + * treated nicely by `json_encode()`. + * @package qfq + */ +class SignedNumber implements \JsonSerializable { + + /** @var int|float the value of the number */ + public $value; + + public function __construct($value) { + $this->value = $value; + } + + public function __toString(): string { + return $this->value > 0 ? "+$this->value" : "$this->value"; + } + + public function jsonSerialize() { + return $this->value; + } +} + +/** + * Class SimpleParser + * + * A basic parser that splits the provided string at unescaped + * and unquoted delimiters. Token processing recognizes numbers + * and specials values. + * + * @package qfq + */ +class SimpleParser extends StringTokenizer { + + /** @var string Option key: replace numeric strings with numbers */ + const OPTION_PARSE_NUMBERS = 'parse-numbers'; + + /** @var string Option key: convert +num and -num to an instance of SignedNumber */ + const OPTION_KEEP_SIGN = 'keep-sign'; + + /** @var string Option key: empty keys will be assigned their names are values */ + const OPTION_KEY_IS_VALUE = 'key-is-value'; + + /** @var string Option key: the value assigned to empty tokens */ + const OPTION_EMPTY_VALUE = 'empty'; + + /** @var array a configuration of the parser */ + public array $options = [ + self::OPTION_KEEP_SIGN => false, // if true, tokens "+number" and "-number" are converted to instances of SignedNumber + self::OPTION_KEY_IS_VALUE => false, // if true, a key with no value is assigned its name + self::OPTION_EMPTY_VALUE => null, // the value used for empty tokens + self::OPTION_PARSE_NUMBERS => true // if true, tokens are replaced with numbers if possible + ]; + + /** + * @var array A dictionary for special values of tokens. These values are + * used only for tokens for which the property `isString` is false. + */ + public array $specialValues = [ + 'null' => null, + 'true' => true, + 'false' => false, + 'yes' => true, + 'no' => false + ]; + + + public function __construct(string $delimiters, array $options = []) { + parent::__construct($delimiters); + $this->options = array_merge($this->options, $options); + } + + /** + * Processes a token into a string, a number, or a special value. + * @return mixed + */ + protected function process(Token $token) { + $asString = strval($token); + if ($token->isString) { + return $asString; + } elseif ($asString === '') { + return $this->options[self::OPTION_EMPTY_VALUE]; + } elseif ($this->options[self::OPTION_PARSE_NUMBERS] && is_numeric($asString)) { + if (preg_match("/^[+-]?\d+$/", $asString)) { + $value = intval($asString); + } else { + $value = floatval($asString); + } + return ($this->options[self::OPTION_KEEP_SIGN] && ($asString[0] === '+' || $asString[0] === '-')) + ? new SignedNumber($value) : $value; + } elseif (array_key_exists($asString, $this->specialValues)) { + // isset() does not work, because the array has `null` as one of values + return $this->specialValues[$asString]; + } else { + return $asString; + } + } + + /** + * A helper method that throws an exception + * @param $delimiter + * @param $position + * @param $expected + */ + protected static function raiseUnexpectedDelimiter($delimiter, $position, $expected=null): void { + $msg = "An unexpected '$delimiter' at position $position"; + if ($expected) { + $msg .= " while expecting " . implode(' or ', str_split($expected)); + } + throw new \RuntimeException($msg); + } + + /** + * A helper method that throws an exception + * @param $expected + */ + protected static function raiseUnexpectedEnd($expected): void { + throw new \RuntimeException("An unexpected end while searching for '$expected'"); + } + + /** + * A helper method that throws an exception + * @param $position + * @param $before + * @param $after + */ + protected static function raiseUnexpectedToken($position, $before, $after): void { + $msg = "An unexpected token at $position"; + $extra = []; + if ($before) $extra[] = " before '$before'"; + if ($after) $extra[] = " after '$after'"; + $msg .= implode('and ', $extra); + throw new \RuntimeException($msg); + } + + /** + * Parses the provided string into a list of values separated by delimiters. + * + * @param string $data + * @return mixed + */ + public function parse(string $data) { + return iterator_to_array($this->iterate($data)); + } + + /** + * Iterates over pieces of a string separated by unescaped delimiters. + * + * @param string $data + * @return \Generator + */ + public function iterate(string $data): \Generator { + foreach ($this->tokenized($data) as $token) { + yield $this->process($token[0]); + } + } +} diff --git a/extension/Classes/Core/Parser/StringTokenizer.php b/extension/Classes/Core/Parser/StringTokenizer.php new file mode 100644 index 0000000000000000000000000000000000000000..be3bf3093eb68f6c9165890835f158a7d7911b2f --- /dev/null +++ b/extension/Classes/Core/Parser/StringTokenizer.php @@ -0,0 +1,162 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * Class StringTokenizer + * + * This class is used to parse a string into a sequence of tokens + * bound by provided delimiters. While parsing the string, pieces + * of a token are generated each time a delimiter, a quote or + * a backslash is found. They are joined together and yielded + * once an unescaped delimiter is encountered or the end of + * the string is reached. + * + * @project qfq + */ +class StringTokenizer { + + /** @var string a regexp pattern to match delimiters */ + private string $delimitersPattern; + + /** @var int the offset for the current token piece */ + private int $currentOffset = 0; + + /** @var TokenBuilder the object for building a token */ + protected TokenBuilder $tokenBuilder; + + + public function __construct(string $delimiters) { + $escapedDelimiters = str_replace( + ['[', ']', '/', '.'], + ['\\[', '\\]', '\\/', '\\.'], + $delimiters + ); + $this->delimitersPattern = "/[$escapedDelimiters\\\\\"']/"; + $this->tokenBuilder = new TokenBuilder(); + } + + /** + * The offset of the last found delimiter or -1 otherwise + * @return int + */ + public function offset(): int { + return $this->currentOffset-1; + } + + /** + * Iterates over unescaped delimiters and quotes from a provided string. + * At each iteration returns a delimiter or null when the end of the + * string is reached. + * + * Examples + * delimiters: ,:| + * ab:cd, ef|gh --> : , | (null) (token pieces: 'ab' 'cd' 'ef' 'gh') + * ab\:c d,"e:f" --> , " : " (null) (token pieces: 'ab:c d' '' 'e' 'f' '') + * ab\\:cd,' ' --> : , (null) (token pieces: 'ab\' 'cd' ' ') + * + * @param string $data the searched string + * @return \Generator delimiters + */ + protected function unescapedDelimitersAndQuotes(string $data): \Generator { + // Reset the token builder and offset + $this->tokenBuilder->reset(); + $this->currentOffset = 0; + // Match all delimiters, including escaped and inside quotes + if (preg_match_all($this->delimitersPattern, $data, $delimiters, PREG_OFFSET_CAPTURE)) { + // If non-empty, $delimiters is an array with one element: + // a list of pairs [delimiter, offset] + $delimiters = $delimiters[0]; + $tokenData = current($delimiters); + while ($tokenData) { + list($delimiter, $offset) = $tokenData; + if ($delimiter == '\\') { + // The next character is escaped. If it is a delimiter, + // then we will ignore it and continue the search. + $tokenData = next($delimiters); + if (!$tokenData) { + // No more delimiters - we have reached the end of the string. + // We return the remaining part of the string outside the loop. + break; + } elseif ($tokenData[1] == $offset + 1) { + // This delimiter or quote is escaped by the backslash + if ($tokenData[0] != '\\') { + $this->tokenBuilder->append(substr($data, $this->currentOffset, $offset-$this->currentOffset)); + $this->currentOffset = $tokenData[1]; + } + $tokenData = next($delimiters); + continue; + } + } + // An unescaped delimiter has been found + $this->tokenBuilder->append(substr($data, $this->currentOffset, $offset-$this->currentOffset)); + $this->currentOffset = $offset + 1; + yield $delimiter; + $tokenData = next($delimiters); + } + } + } + + /** + * Iterates over unescaped and unquoted delimiters from a provided string. + * Unescaped quotes must match and are not included in the resulting token. + * At each iteration returns a pair consisting of + * - the token generated from the substring bound by the previous + * and the current delimiter + * - the delimiter + * Note that the offset of the current delimiter is given by the offset() + * method. The delimiter is null when the end of the string is reached. + * + * Examples + * delimiters: ,:| + * ab:cd, ef|gh --> ['ab', ':'] ['cd', ','] ['ef', '|'] ['gh', (null)] + * ab\:c d,"e:f" --> ['ab:c d', ','] ['e f', (null)] + * ab\\:cd,' ' --> ['ab\', ':'] ['cd', ','] [' ', (null)] + * + * @param string $data the string to search for delimiters + * @return \Generator pairs [token, delimiter] + */ + public function tokenized(string $data): \Generator { + // Iterate over all unescaped delimiters + $delimitersAndQuotes = $this->unescapedDelimitersAndQuotes($data); + while ($delimitersAndQuotes->valid()) { + $delimiter = $delimitersAndQuotes->current(); + if ($delimiter === '"' || $delimiter === "'") { + // We will search for the matching quote and put everything + // in between to the token + $quote = $delimiter; + $this->tokenBuilder->markQuote(); + while (true) { + // Get next delimiter and check if it is a matching quote + $delimitersAndQuotes->next(); + if (!$delimitersAndQuotes->valid()) { + throw new \RuntimeException("An unexpected end while searching for '$delimiter'"); + } + $delimiter = $delimitersAndQuotes->current(); + if ($delimiter === $quote) { + // We have found a quote - break this loop and continue + // searching for delimiters + $this->tokenBuilder->markQuote(); + break; + } + // An quoted delimiter is a part of the token + $this->tokenBuilder->pieces[] = $delimiter; + } + } else { + // An unescaped delimiter: return the current token + // and start building the next one + yield [$this->tokenBuilder->process(), $delimiter]; + } + $delimitersAndQuotes->next(); + } + // No more delimiters: add the rest of the string and process the token + $this->tokenBuilder->pieces[] = substr($data, $this->currentOffset); + $token = $this->tokenBuilder->process(); + $token->empty() or yield [$token, null]; + } +} diff --git a/extension/Classes/Core/Parser/Token.php b/extension/Classes/Core/Parser/Token.php new file mode 100644 index 0000000000000000000000000000000000000000..fe9c43a28aea03bb77b4272576f17cbbd29c5f99 --- /dev/null +++ b/extension/Classes/Core/Parser/Token.php @@ -0,0 +1,45 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * A token returned by StringTokenizer when parsing a string. + * It represents a part of the input string except that unescaped quotes + * and backslashes before delimiters and quotes are removed. + * @package qfq + */ +class Token { + /** + * @var string The string wrapped by the token. + */ + public string $value; + + /** + * @var bool True if the token must be treated as a string + * (for instance it was surrounded with quotes) + */ + public bool $isString; + + public function __construct(string $value, bool $isString) { + $this->value = $value; + $this->isString = $isString; + } + + /** + * Returns true when the token is empty. In particular, this means + * that there were no quotes in the token. + * @return bool + */ + public function empty() : bool { + return $this->value === '' && !$this->isString; + } + + public function __toString(): string { + return $this->value; + } +} diff --git a/extension/Classes/Core/Parser/TokenBuilder.php b/extension/Classes/Core/Parser/TokenBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..92f714187c9b953b6feb5abf1e8e403237769714 --- /dev/null +++ b/extension/Classes/Core/Parser/TokenBuilder.php @@ -0,0 +1,101 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * A helper class used by StringTokenizer to build a token. It contains extra information + * that are necessary to properly process the token into a string. + * @package qfq + */ +class TokenBuilder { + + /** + * @var array A list of substrings that form the token. Splits occur at escaped + * delimiters and unescaped quotes so that they do not appear in a final string. + */ + public array $pieces = []; + + /** + * @var int The current total length of the token pieces + */ + public int $length = 0; + + /** + * @var int the offset of the first unescaped quote + */ + public int $firstQuoteOffset = -1; + + /** + * @var int the offset of the last unescaped quote + */ + public int $lastQuoteOffset = -1; + + /** + * Returns true when an unescaped quote has been found + * while creating the token. + * @return bool + */ + public function hasQuotes(): bool { + return $this->firstQuoteOffset >= 0; + } + + /** + * Resets the builder to its initial state + * @return void + */ + public function reset() + { + $this->pieces = []; + $this->length = 0; + $this->firstQuoteOffset = -1; + $this->lastQuoteOffset = -1; + } + + /** + * Processes the data to a token and resets the builder + * @return Token + */ + public function process(): Token { + // Combine all the pieces and trim the resulting string, + // but keep whitespaces that were surrounded by quotes + $value = implode('', $this->pieces); + if ($this->hasQuotes()) { + $value = ltrim(substr($value, 0, $this->firstQuoteOffset)) . + substr($value, $this->firstQuoteOffset, $this->lastQuoteOffset-$this->firstQuoteOffset) . + rtrim(substr($value, $this->lastQuoteOffset)); + } else { + $value = trim($value); + } + $token = new Token($value, $this->hasQuotes()); + $this->reset(); + return $token; + } + + /** + * Adds a piece of a token and updates the builder status + * @param string $data + * @return void + */ + public function append(string $data) { + $this->pieces[] = $data; + $this->length += strlen($data); + } + + /** + * Notifies the builder that a quote has been encountered. + * The builder updates offsets of quotes accoringly. + * @return void + */ + public function markQuote() { + if ($this->firstQuoteOffset < 0) { + $this->firstQuoteOffset = $this->length; + } else { + $this->lastQuoteOffset = $this->length; + } + } +} diff --git a/extension/Classes/Core/Report/Report.php b/extension/Classes/Core/Report/Report.php index 332b4c614c99065e6ccb0c6159252c80eef67cca..ce3b53ea6aa9d562efaae067468b4751d2cad035 100644 --- a/extension/Classes/Core/Report/Report.php +++ b/extension/Classes/Core/Report/Report.php @@ -34,10 +34,15 @@ use IMATHUZH\Qfq\Core\Helper\OnString; use IMATHUZH\Qfq\Core\Helper\Path; use IMATHUZH\Qfq\Core\Helper\Sanitize; use IMATHUZH\Qfq\Core\Helper\Support; +use IMATHUZH\Qfq\Core\Parser\MixedTypeParser; +use IMATHUZH\Qfq\Core\Parser\SignedNumber; +use IMATHUZH\Qfq\Core\Parser\SimpleParser; use IMATHUZH\Qfq\Core\Store\Sip; use IMATHUZH\Qfq\Core\Store\Store; use IMATHUZH\Qfq\Core\Typo3\T3Handler; +use Firebase\JWT\JWT; + const DEFAULT_QUESTION = 'question'; const DEFAULT_ICON = 'icon'; const DEFAULT_BOOTSTRAP_BUTTON = 'bootstrapButton'; @@ -1366,6 +1371,54 @@ class Report { $content .= Support::encryptDoubleCurlyBraces(FormAsFile::renderColumnFormJson($columnValue, $dbQfq)); break; + // Author: Krzysztof Putyra + case COLUMN_JWT: + /* Converts a string + * claim1:value, claim2:value, ... | key | alg + * into a json web token. Parameters: + * - alg the name of the signing algorithm (default: HS256) + * - key the secret key used by the signing algorithm + * Standard claims with an extended interpretation of values: + * - iss the issuer of the token (default: qfq) + * - iat the timestamp the token has been issued (default: current) + * - exp the expiration timestamp or the number of seconds till invalid (if prefixed with '+') + * - nbf the timestamp from when or (if prefixed with '+') the number of seconds after which the token is valid + */ + + // Split the column into |-separated sections + $parser = new SimpleParser('|', [ + SimpleParser::OPTION_EMPTY_VALUE => '' + ]); + $splitContent = $parser->parse($columnValue); + // Check that key is provided + if (count($splitContent) < 2) { + throw new \UserReportException("JWT requires a secret key, but it is missing"); + } + + // Parse the payload + $currentTime = time(); + $parser = new MixedTypeParser(null, [ + SimpleParser::OPTION_KEEP_SIGN => true + ]); + $claims = array_merge( + ['iss' => 'qfq', 'iat' => $currentTime], + $parser->parseDictionary($splitContent[0]) + ); + foreach (['exp', 'nbf', 'iat'] as $claim) { + $value = $claims[$claim] ?? 0; + if ($value instanceof SignedNumber) { + $claims[$claim] = $value->value + $currentTime; + } + } + + // Create the token + $content .= JWT::encode( + $claims, + $splitContent[1], + $splitContent[2] ?? 'HS256' + ); + break; + // Author: Enis Nuredini case COLUMN_ENCRYPT: $encryptionMethodColumn = $this->store->getVar(SYSTEM_ENCRYPTION_METHOD, STORE_SYSTEM, SANITIZE_ALLOW_ALL); diff --git a/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php b/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ae87a6c8b81293ef5dfc1493010e7e04b8ae069b --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php @@ -0,0 +1,79 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\KVPairListParser; +use IMATHUZH\Qfq\Core\Parser\SimpleParser; +use PHPUnit\Framework\TestCase; + +class KVPairListParserTest extends TestCase { + public function testEmptyString() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + [], + $parser->parse('') + ); + } + + public function testSinglePair() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals(['a' => 42], $parser->parse('a:42')); + } + + public function testSingleKey() { + $parser = new KVPairListParser('|', ':', [ + SimpleParser::OPTION_KEY_IS_VALUE => true + ]); + $this->assertEquals(['a' => 'a'], $parser->parse('a:')); + + $parser = new KVPairListParser('|', ':', [ + SimpleParser::OPTION_KEY_IS_VALUE => false, + SimpleParser::OPTION_EMPTY_VALUE => 18 + ]); + $this->assertEquals(['a' => 18], $parser->parse('a:')); + $this->assertEquals(['a' => 18], $parser->parse('a')); + } + + public function testSimpleList() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab'=>'x','cd'=>'y','ef'=>'z'], + $parser->parse('ab:x|cd:y|ef:z') + ); + } + + public function testEscapedSeparators() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab' => 'x','cd:y|ef' => 'z:a'], + $parser->parse('ab:x|cd\\:y\\|ef:z\\:a') + ); + } + + public function testQuotedSeparators() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab' => 'x','cd:y| ef' => 'z:a'], + $parser->parse('ab:x|"cd:y| ef":z":a"') + ); + } + + public function testIterate() { + $parser = new KVPairListParser('|', ':'); + $iterator = $parser->iterate('a:1|b:2|c:3'); + $expected = ['a' => 1, 'b' => 2, 'c' => 3]; + foreach($iterator as $key => $value) { + $expectedKey = key($expected); + $expectedValue = current($expected); + $this->assertSame($expectedKey, $key); + $this->assertSame($expectedValue, $value); + next($expected); + } + $this->assertFalse(current($expected)); + } +} diff --git a/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php b/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..66f9f69b78e6afdf219e41fc62e395cf6f167b5c --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php @@ -0,0 +1,192 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\MixedTypeParser; +use PHPUnit\Framework\TestCase; + +/** + * Class MixedTypeParserTest + * @package qfq + */ +class MixedTypeParserTest extends TestCase { + + protected function assertGeneratorOutput($generator, $output) { + foreach ($output as $value) { + $data = $generator->current(); + $data[0] = strval($data[0]); + $this->assertSame($value, $data, "Expecting " . json_encode($value)); + $generator->next(); + } + $this->assertNull($generator->current()); + } + + public function testLiteralValues() { + $parser = new MixedTypeParser(null, ['empty' => 18]); + $data = [ + 'abc' => 'abc', ' x\\:y ' => 'x:y', + '' => 18, '""' => '', + ' ' => 18, '" "' => ' ', + '123' => 123, '"123"' => '123', + '12.5' => 12.5, '"12.5"' => '12.5', + 'false' => false, '"false"' => 'false', + 'true' => true, '"true"' => 'true', + 'yes' => true, '"yes"' => 'yes', + 'no' => false, '"no"' => 'no', + 'null' => null, '"null"' => 'null' + ]; + foreach ($data as $input => $expected) + $this->assertSame($expected, $parser->parse($input)); + } + + public function testParseFlatList() { + $parser = new MixedTypeParser(); + // Empty list + $this->assertEquals([], $parser->parseList('')); + $this->assertEquals([], $parser->parseList(' ')); + // Compact list + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parseList("a,b,c,d") + ); + // Spaces are stripped + $this->assertEquals( + ['a', 'b', 'c'], + $parser->parseList("a, b, c ") + ); + // Internal spaces are preserved + $this->assertEquals( + ['a', 'b c', 'd'], + $parser->parseList("a, b c , d") + ); + // Escaped commas are ignored + $this->assertEquals( + ['a,b', 'c'], + $parser->parseList("a\\,b,c") + ); + // Quoted commas are ignored + $this->assertEquals( + ['a, b', 'c'], + $parser->parseList("'a, b', c") + ); + // Trailing comma adds an empty element + $this->assertEquals( + [null, null, 'c', null], + $parser->parseList(",,c,") + ); + } + + public function testParseListValues() { + $parser = new MixedTypeParser(); + $this->assertEquals([], $parser->parse("[]")); + $this->assertEquals([], $parser->parse("[ ]")); + + // $this->assertEquals([''], $parser->parse("['']")); + + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parse("[a,b,c,d]") + ); + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parse(" [a, b, c ,d] ") + ); + + } + + public function testParseFlatDictionary() { + $parser = new MixedTypeParser(); + // Empty object + $this->assertEquals([], $parser->parseDictionary('')); + $this->assertEquals([], $parser->parseDictionary(' ')); + // Compact dictionary + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parseDictionary("a:1,b:2,c:3,d:4") + ); + // Spaces are stripped + $this->assertEquals( + ['a'=>'1', 'b'=>'2'], + $parser->parseDictionary(" a : 1, b: 2\n\t ") + ); + // Internal spaces are preserved + $this->assertEquals( + ['a b'=>'1', 'b'=>'2 3 4'], + $parser->parseDictionary(" a b: 1, b: 2 3 4") + ); + // Escaped delimiters are ignored + $this->assertEquals( + ['a,b'=>'1', 'b'=>'2:3,4'], + $parser->parseDictionary("a\\,b:1,b:2\\:3\\,4") + ); + // Quoted delimiters are ignored + $this->assertGeneratorOutput( + $parser->tokenized("'a:1,b':\"23,4\""), + [['a:1,b',':'],['23,4',null]] + ); + $this->assertEquals( + ['a:1,b' => '23,4'], + $parser->parseDictionary("'a:1,b':\"23,4\"") + ); + // Trailing commas are ignored + $this->assertEquals( + ['a' => 1, 'b' => 2], + $parser->parseDictionary('a:1, b:2, ') + ); + } + + public function testParseDictValues() { + $parser = new MixedTypeParser(); + $this->assertEquals([], $parser->parse("{}")); + $this->assertEquals([], $parser->parse("{ }")); + + $this->assertEquals(['a' => null], $parser->parse("{a:}")); + + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parse("{a:1,b:2,c:3,d:4}") + ); + + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parse(" { a:1, b:2, c : 3 ,d:4 } ") + ); + } + + public function testParseNestedStructures() { + $parser = new MixedTypeParser(); + // Nested lists + $this->assertEquals( + ['a', 'b', ['ca', 'cb'], 'd'], + $parser->parse("[a, b, [ca, cb], d]") + ); + // Dictionary nested in a list + $this->assertEquals( + ['a', 'b', ['ca'=>'0', 'cb'=>'1'], 'd'], + $parser->parse("[a, b, {ca:0, cb:1}, d]") + ); + // List nested in a dictionary + $this->assertEquals( + ['a'=>'0', 'b'=>'1', 'c'=>['ca', 'cb'], 'd'=>'3'], + $parser->parse("{a:0, b:1, c:[ca, cb], d:3}") + ); + // Nested dictionaries + $this->assertEquals( + ['a'=>'0', 'b'=>'1', 'c'=>['ca'=>'5', 'cb'=>'6'], 'd'=>'3'], + $parser->parse("{a:0, b:1, c:{ca:5, cb:6}, d:3}") + ); + } + + public function testRestrictedParser() { + $parser = new MixedTypeParser(',: {}'); + $this->assertEquals( + ['a' => '[b]', 'c' => '[d', 'e]' => ''], + $parser->parse('{a:[b],c:[d,e]}') + ); + } +} \ No newline at end of file diff --git a/extension/Tests/Unit/Core/Parser/SimpleParserTest.php b/extension/Tests/Unit/Core/Parser/SimpleParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e6de69da32215b9fe1ce2860f6e97358739928f5 --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/SimpleParserTest.php @@ -0,0 +1,75 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\SimpleParser; +use PHPUnit\Framework\TestCase; + +class SimpleParserTest extends TestCase { + + public function testEmptyString() { + $parser = new SimpleParser(','); + $this->assertEquals( + [], + $parser->parse('') + ); + } + + public function testSingletons() { + $parser = new SimpleParser(','); + $this->assertEquals(['abcd'], $parser->parse('abcd')); + $this->assertEquals([42], $parser->parse('42')); + } + + public function testSimpleList() { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd','ef'], + $parser->parse('ab,cd,ef') + ); + $parser = new SimpleParser('|'); + $this->assertEquals( + ['ab','cd','ef'], + $parser->parse('ab|cd|ef') + ); + $parser = new SimpleParser('|,.'); + $this->assertEquals( + ['ab','cd','ef','gh'], + $parser->parse('ab|cd,ef.gh') + ); + } + + public function testEscapedSeparators() { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd,ef'], + $parser->parse('ab,cd\\,ef') + ); + } + + public function testQuotedSeparators() { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd, ef'], + $parser->parse('ab,"cd, ef"') + ); + } + + public function testIterate() { + $parser = new SimpleParser(','); + $iterator = $parser->iterate('a,b,c'); + $expected = ['a', 'b', 'c']; + foreach($iterator as $value) { + $this->assertSame(current($expected), $value); + next($expected); + } + $this->assertFalse(current($expected)); + + } +} + diff --git a/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php b/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..cdf586cf755c735473f9f1a9a2b889a70db83653 --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php @@ -0,0 +1,132 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\StringTokenizer; +use PHPUnit\Framework\TestCase; + +class StringTokenizerTest extends TestCase { + + protected function assertTokenizer($delimiters, $data, $expected) { + $parser = new StringTokenizer($delimiters); + $generator = $parser->tokenized($data); + foreach ($expected as $data) { + list($token, $delimiter) = $generator->current(); + if (is_array($data)) { + $this->assertSame($data[0], $token->value, "Expecting $data[0]"); + $this->assertSame($data[1], $delimiter, "Expecting '$data[1]'"); + } else { + $this->assertSame($data, $token->value, "Expecting $data"); + } + $generator->next(); + } + $this->assertNull($generator->current()); + } + + public function testOffset() { + $parser = new StringTokenizer(':|'); + $input = 'a:bc:de|f'; + $offsets = [1,4,7,null]; + $tokens = $parser->tokenized($input); + foreach($offsets as $value) { + $tokens->current(); + if (!is_null($value)) $this->assertSame($value, $parser->offset()); + $tokens->next(); + } + $this->assertFalse($tokens->valid()); + } + + public function testEmptyString() { + $this->assertTokenizer(':,[]{}', '', []); + } + + public function testSimpleString() { + $this->assertTokenizer(':,[]{}', 'abc', [['abc',null]]); + } + + public function testUnescapedDelimiters() { + $this->assertTokenizer( + ':,./]{', + "x:7,y.z/24]{x", + [[ 'x', ':'], ['7', ','], ['y', '.'], ['z', '/'], + ['24', ']'], ['','{'], ['x',null]] + ); + } + + public function testEscapedDelimiters() { + $this->assertTokenizer( + ':,./]{', + "x\\:7\\,y\\.z\\/24\\]\\{x", + [[ "x:7,y.z/24]{x", null]] + ); + } + + public function testEscapedNonDelimiters() { + $this->assertTokenizer( + ':', + 'x\\\\y\\n', + ['x\\\\y\\n'] + ); + } + + public function testTokensAreTrimmed() { + $this->assertTokenizer( + '|', + ' a|b | cd ', + ['a', 'b', 'cd'] + ); + } + + public function testSpacesInside() { + $this->assertTokenizer( + ':', + "x y: sy ca : f\t\ng", + ['x y', 'sy ca', "f\t\ng"] + ); + } + + public function testQuotesAreRemoved() { + $this->assertTokenizer( + ':', + '"x y":ab\'x\'cd', + ['x y', 'abxcd'] + ); + } + + public function testQuotedSpacesArePreserved() { + $this->assertTokenizer( + ':', + '" "x:ab" x ":\' \'', + [' x', 'ab x ', ' '] + ); + } + + public function testEscapedQuotesArePreserved() { + $this->assertTokenizer( + ':', + '\"x y\":ab\\\'cd', + ['"x y"', "ab'cd"] + ); + } + + public function testNestedQuotesAreNotParsed() { + $this->assertTokenizer( + ':', + '"\'":\'"\'', + ["'", '"'] + ); + } + + public function testQuotedDelimitersAreIgnored() { + $this->assertTokenizer( + ':,|', + 'x:a\\|b|c\\,d\\:e:24', + ['x', 'a|b', 'c,d:e', '24'] + ); + } +} diff --git a/extension/composer.json b/extension/composer.json index 848142983e29bb2e9ddcc9e0b9ab647c57ce8e58..be32013f560e6fa6bf04c5bedcc9ae2fdf879032 100644 --- a/extension/composer.json +++ b/extension/composer.json @@ -3,7 +3,8 @@ "phpoffice/phpspreadsheet": "^1.3", "ext-json": "*", "twig/twig": "^2.0", - "ezyang/htmlpurifier": "^4.15" + "ezyang/htmlpurifier": "^4.15", + "firebase/php-jwt": "^6.9" }, "require-dev": { "phpunit/phpunit": "^9"