From 8d6a43a5705ece86858f236302e981723d9043d3 Mon Sep 17 00:00:00 2001 From: Dillon Nys <24740863+dnys1@users.noreply.github.com> Date: Mon, 23 Sep 2024 07:48:27 -0400 Subject: [PATCH] feat(http_sfv): HTTP structured field values (#36) Adds a Dart implementation of Structured Field Values for HTTP ([RFC8941](https://www.rfc-editor.org/rfc/rfc8941.html)) --- .github/workflows/http_sfv.yaml | 44 ++ .gitmodules | 3 + packages/http_sfv/.gitignore | 7 + packages/http_sfv/CHANGELOG.md | 3 + packages/http_sfv/README.md | 42 ++ packages/http_sfv/analysis_options.yaml | 1 + .../http_sfv/example/http_sfv_example.dart | 42 ++ packages/http_sfv/lib/http_sfv.dart | 9 + packages/http_sfv/lib/src/character.dart | 121 +++++ packages/http_sfv/lib/src/item_value.dart | 285 ++++++++++ packages/http_sfv/lib/src/key.dart | 34 ++ packages/http_sfv/lib/src/parser.dart | 474 +++++++++++++++++ packages/http_sfv/lib/src/token.dart | 26 + packages/http_sfv/lib/src/value.dart | 494 ++++++++++++++++++ packages/http_sfv/pubspec.yaml | 17 + packages/http_sfv/structured-field-tests | 1 + packages/http_sfv/test/binary_test.dart | 49 ++ packages/http_sfv/test/boolean_test.dart | 38 ++ packages/http_sfv/test/decimal_test.dart | 58 ++ packages/http_sfv/test/dictionary_test.dart | 87 +++ packages/http_sfv/test/inner_list_test.dart | 35 ++ packages/http_sfv/test/integer_test.dart | 37 ++ packages/http_sfv/test/item_test.dart | 56 ++ packages/http_sfv/test/item_value_test.dart | 45 ++ packages/http_sfv/test/key_test.dart | 50 ++ packages/http_sfv/test/list_test.dart | 98 ++++ packages/http_sfv/test/spec_test.dart | 143 +++++ packages/http_sfv/test/string_test.dart | 65 +++ packages/http_sfv/test/token_test.dart | 91 ++++ 29 files changed, 2455 insertions(+) create mode 100644 .github/workflows/http_sfv.yaml create mode 100644 .gitmodules create mode 100644 packages/http_sfv/.gitignore create mode 100644 packages/http_sfv/CHANGELOG.md create mode 100644 packages/http_sfv/README.md create mode 100644 packages/http_sfv/analysis_options.yaml create mode 100644 packages/http_sfv/example/http_sfv_example.dart create mode 100644 packages/http_sfv/lib/http_sfv.dart create mode 100644 packages/http_sfv/lib/src/character.dart create mode 100644 packages/http_sfv/lib/src/item_value.dart create mode 100644 packages/http_sfv/lib/src/key.dart create mode 100644 packages/http_sfv/lib/src/parser.dart create mode 100644 packages/http_sfv/lib/src/token.dart create mode 100644 packages/http_sfv/lib/src/value.dart create mode 100644 packages/http_sfv/pubspec.yaml create mode 160000 packages/http_sfv/structured-field-tests create mode 100644 packages/http_sfv/test/binary_test.dart create mode 100644 packages/http_sfv/test/boolean_test.dart create mode 100644 packages/http_sfv/test/decimal_test.dart create mode 100644 packages/http_sfv/test/dictionary_test.dart create mode 100644 packages/http_sfv/test/inner_list_test.dart create mode 100644 packages/http_sfv/test/integer_test.dart create mode 100644 packages/http_sfv/test/item_test.dart create mode 100644 packages/http_sfv/test/item_value_test.dart create mode 100644 packages/http_sfv/test/key_test.dart create mode 100644 packages/http_sfv/test/list_test.dart create mode 100644 packages/http_sfv/test/spec_test.dart create mode 100644 packages/http_sfv/test/string_test.dart create mode 100644 packages/http_sfv/test/token_test.dart diff --git a/.github/workflows/http_sfv.yaml b/.github/workflows/http_sfv.yaml new file mode 100644 index 0000000..2f8ca92 --- /dev/null +++ b/.github/workflows/http_sfv.yaml @@ -0,0 +1,44 @@ +name: http_sfv +on: + pull_request: + paths: + - ".github/workflows/http_sfv.yaml" + - "packages/http_sfv/**" + +# Prevent duplicate runs due to Graphite +# https://graphite.dev/docs/troubleshooting#why-are-my-actions-running-twice +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }}-${{ github.ref == 'refs/heads/main' && github.sha || ''}} + cancel-in-progress: true + +jobs: + check: + strategy: + fail-fast: true + matrix: + sdk: + - stable + - "3.3" + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Git Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # 4.1.7 + with: + submodules: recursive + - name: Setup Dart + uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 # 1.6.5 + with: + sdk: ${{ matrix.sdk }} + - name: Get Packages + working-directory: packages/http_sfv + run: dart pub get + - name: Analyze + working-directory: packages/http_sfv + run: dart analyze + - name: Format + working-directory: packages/http_sfv + run: dart format --set-exit-if-changed . + - name: Test + working-directory: packages/http_sfv + run: dart test diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3e62b2a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/http_sfv/structured-field-tests"] + path = packages/http_sfv/structured-field-tests + url = https://github.com/httpwg/structured-field-tests diff --git a/packages/http_sfv/.gitignore b/packages/http_sfv/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/http_sfv/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/http_sfv/CHANGELOG.md b/packages/http_sfv/CHANGELOG.md new file mode 100644 index 0000000..090fc36 --- /dev/null +++ b/packages/http_sfv/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release diff --git a/packages/http_sfv/README.md b/packages/http_sfv/README.md new file mode 100644 index 0000000..401232c --- /dev/null +++ b/packages/http_sfv/README.md @@ -0,0 +1,42 @@ +# http_sfv (Structured Field Values) + +A Dart implementation of the [Structured Field Values for HTTP (RFC 8941)](https://www.rfc-editor.org/rfc/rfc8941.html) specification. + +## Usage + +Use `StructuredFieldValue.decode` to parse a header string into a structured field value. + +```dart +import 'package:http_sfv/http_sfv.dart'; + +void main() { + const header = '"foo";bar;baz=tok, (foo bar);bat'; + final decoded = StructuredFieldValue.decode( + header, + type: StructuredFieldValueType.list, + ); + print(decoded); + // Prints: List(Item(foo, bar: true, baz: tok), InnerList([Item(foo), Item(bar)], bat: true)) +} +``` + +Use `StructuredFieldValue.encode` to convert a structured field value to a header string. + +```dart +import 'package:http_sfv/http_sfv.dart'; + +void main() { + final dictionary = StructuredFieldDictionary({ + 'a': false, + 'b': true, + 'c': StructuredFieldItem( + true, + parameters: { + 'foo': 'bar', + }, + ), + }); + print(dictionary.encode()); + // Prints: "a=?0, b, c;foo=bar" +} +``` diff --git a/packages/http_sfv/analysis_options.yaml b/packages/http_sfv/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/packages/http_sfv/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/http_sfv/example/http_sfv_example.dart b/packages/http_sfv/example/http_sfv_example.dart new file mode 100644 index 0000000..67b016c --- /dev/null +++ b/packages/http_sfv/example/http_sfv_example.dart @@ -0,0 +1,42 @@ +import 'package:http_sfv/http_sfv.dart'; + +void main() { + final dictionary = StructuredFieldDictionary({ + 'a': false, + 'b': true, + 'c': StructuredFieldItem( + true, + parameters: { + 'foo': 'bar', + }, + ), + }); + print('dictionary: ${dictionary.encode()}'); + // Prints: "a=?0, b, c;foo=bar" + + const header = '"foo";bar;baz=tok, (foo bar);bat'; + final decoded = StructuredFieldValue.decode( + header, + type: StructuredFieldValueType.list, + ); + print('list: $decoded'); + // Prints: List(Item(foo, bar: true, baz: tok), InnerList([Item(foo), Item(bar)], bat: true)) + + final list = StructuredFieldList([ + StructuredFieldItem( + 'foo', + parameters: { + 'bar': true, + 'baz': Token('tok'), + }, + ), + StructuredFieldInnerList( + [Token('foo'), Token('bar')], + parameters: { + 'bat': true, + }, + ), + ]); + print(decoded == list); + // Prints: true +} diff --git a/packages/http_sfv/lib/http_sfv.dart b/packages/http_sfv/lib/http_sfv.dart new file mode 100644 index 0000000..488c74a --- /dev/null +++ b/packages/http_sfv/lib/http_sfv.dart @@ -0,0 +1,9 @@ +/// Support for doing something awesome. +/// +/// More dartdocs go here. +library; + +export 'src/item_value.dart' show StructuredFieldItemValue; +export 'src/key.dart'; +export 'src/token.dart'; +export 'src/value.dart'; diff --git a/packages/http_sfv/lib/src/character.dart b/packages/http_sfv/lib/src/character.dart new file mode 100644 index 0000000..c73592c --- /dev/null +++ b/packages/http_sfv/lib/src/character.dart @@ -0,0 +1,121 @@ +import 'package:http_sfv/http_sfv.dart'; + +/// An ASCII character. +extension type const Character(int char) implements int { + static const Character space = Character(0x20); // ' ' + static const Character tab = Character(0x09); // '\t' + static const Character doubleQuote = Character(0x22); // '"' + static const Character questionMark = Character(0x3F); // '?' + static const Character star = Character(0x2A); // '*' + static const Character colon = Character(0x3A); // ':' + static const Character zero = Character(0x30); // '0' + static const Character one = Character(0x31); // '1' + static const Character nine = Character(0x39); // '9' + static const Character upperA = Character(0x41); // 'A' + static const Character upperZ = Character(0x5A); // 'Z' + static const Character lowerA = Character(0x61); // 'a' + static const Character lowerZ = Character(0x7A); // 'z' + static const Character exclamationMark = Character(0x21); // '!' + static const Character numberSign = Character(0x23); // '#' + static const Character dollarSign = Character(0x24); // '$' + static const Character percent = Character(0x25); // '%' + static const Character and = Character(0x26); // '&' + static const Character singleQuote = Character(0x27); // '\'' + static const Character plus = Character(0x2B); // '+' + static const Character minus = Character(0x2D); // '-' + static const Character dash = minus; // '-' + static const Character decimal = Character(0x2E); // '.' + static const Character caret = Character(0x5E); // '^' + static const Character underscore = Character(0x5F); // '_' + static const Character backtick = Character(0x60); // '`' + static const Character pipe = Character(0x7C); // '|' + static const Character tilde = Character(0x7E); // '~' + static const Character slash = Character(0x2F); // '/' + static const Character backslash = Character(0x5C); // '\' + static const Character semiColon = Character(0x3B); // ';' + static const Character equals = Character(0x3D); // '=' + static const Character comma = Character(0x2C); // ',' + static const Character openParen = Character(0x28); // '(' + static const Character closeParen = Character(0x29); // ')' + static const Character at = Character(0x40); // '@' + static const Character maxAscii = Character(0x7F); // '\x7F' + + static const Character lowerAlphaA = Character(0x61); // 'a' + static const Character lowerAlphaB = Character(0x62); // 'b' + static const Character lowerAlphaC = Character(0x63); // 'c' + static const Character lowerAlphaD = Character(0x64); // 'd' + static const Character lowerAlphaE = Character(0x65); // 'e' + static const Character lowerAlphaF = Character(0x66); // 'f' + static const Character upperAlphaA = Character(0x41); // 'A' + static const Character upperAlphaB = Character(0x42); // 'B' + static const Character upperAlphaC = Character(0x43); // 'C' + static const Character upperAlphaD = Character(0x44); // 'D' + static const Character upperAlphaE = Character(0x45); // 'E' + static const Character upperAlphaF = Character(0x46); // 'F' + + /// An alpha character, e.g. A-Z or a-z. + bool get isAlpha => + this >= upperA && this <= upperZ || this >= lowerA && this <= lowerZ; + + /// A lowercase alpha character, e.g. a-z. + bool get isLowerAlpha => this >= lowerA && this <= lowerZ; + + /// A digit character, e.g. 0-9. + bool get isDigit => this >= zero && this <= nine; + + /// A valid hex character, e.g. 0-9, A-F, or a-f. + bool get isValidHex => + isDigit || + this >= upperAlphaA && this <= upperAlphaF || + this >= lowerAlphaA && this <= lowerAlphaF; + + /// An optional whitespace character, e.g. ' ' or '\t'. + bool get isOptionalWhitespace => this == space || this == tab; + + /// Whether this is a valid [Token] character. + bool get isExtendedTokenCharacter { + if (isAlpha || isDigit) { + return true; + } + return this == exclamationMark || + this == numberSign || + this == dollarSign || + this == percent || + this == and || + this == singleQuote || + this == star || + this == plus || + this == minus || + this == decimal || + this == caret || + this == underscore || + this == backtick || + this == pipe || + this == tilde || + this == colon || + this == slash; + } + + /// Whether this is a valid [Key] character. + bool get isKeyCharacter { + if (isLowerAlpha || isDigit) { + return true; + } + return this == underscore || + this == minus || + this == decimal || + this == star; + } + + /// A visible ASCII character (VCHAR), e.g. 0x21 (!) to 0x7E (~). + /// + /// See: https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1 + bool get isVisibleAscii => this >= exclamationMark && this < maxAscii; + + /// An ASCII character which is not a VCHAR or SP. + bool get isInvalidAscii => !isVisibleAscii && this != space; + + /// Whether this is a valid base64 character. + bool get isValidBase64 => + isAlpha || isDigit || this == plus || this == slash || this == equals; +} diff --git a/packages/http_sfv/lib/src/item_value.dart b/packages/http_sfv/lib/src/item_value.dart new file mode 100644 index 0000000..b4111c7 --- /dev/null +++ b/packages/http_sfv/lib/src/item_value.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:http_sfv/http_sfv.dart'; +import 'package:http_sfv/src/character.dart'; +import 'package:http_sfv/src/parser.dart'; + +extension type const StructuredFieldItemValue._(Object value) + implements Object { + factory StructuredFieldItemValue(Object value) { + return switch (value) { + bool() => StructuredFieldItemValue.bool(value), + String() => StructuredFieldItemValue.string(value), + int() => StructuredFieldItemValue.integer(value), + double() => StructuredFieldItemValue.decimal(value), + Uint8List() => StructuredFieldItemValue.binary(value), + List() => StructuredFieldItemValue.binary(Uint8List.fromList(value)), + Token() => StructuredFieldItemValueToken._(value), + _ => throw FormatException( + 'Invalid bare item: "$value" (${value.runtimeType}). ', + 'Must be one of: bool, String, int, num, Uint8List, Token.', + ), + }; + } + + factory StructuredFieldItemValue.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseItemValue(); + } + + factory StructuredFieldItemValue.bool(bool value) = + StructureFieldItemValueBool; + factory StructuredFieldItemValue.string(String value) = + StructuredFieldItemValueString; + factory StructuredFieldItemValue.integer(int value) = + StructuredFieldItemValueInteger; + factory StructuredFieldItemValue.decimal(double value) = + StructuredFieldItemValueDecimal; + factory StructuredFieldItemValue.binary(Uint8List value) = + StructuredFieldItemValueBinary; + factory StructuredFieldItemValue.token(String value) = + StructuredFieldItemValueToken; + + String encode([StringBuffer? builder]) { + return switch (value) { + final StructureFieldItemValueBool value => value.encode(builder), + final StructuredFieldItemValueString value => value.encode(builder), + final StructuredFieldItemValueInteger value => value.encode(builder), + final StructuredFieldItemValueDecimal value => value.encode(builder), + final StructuredFieldItemValueBinary value => value.encode(builder), + final StructuredFieldItemValueToken value => value.encode(builder), + _ => throw FormatException( + 'Invalid bare item: $this ($runtimeType)', + ), + }; + } + + bool equals(Object other) { + if (identical(this, other)) { + return true; + } + if (this case final Uint8List bytes) { + if (other is! Uint8List) { + return false; + } + return bytes.equals(other); + } + return this == other; + } +} + +extension type const StructureFieldItemValueBool(bool value) + implements bool, StructuredFieldItemValue { + static const String $true = '?1'; + static const String $false = '?0'; + + factory StructureFieldItemValueBool.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseBool(); + } + + String encode([StringBuffer? builder]) { + final value = this ? $true : $false; + builder?.write(value); + return value; + } +} + +extension type const StructuredFieldItemValueString._(String value) + implements String, StructuredFieldItemValue { + factory StructuredFieldItemValueString(String value) { + final codeUnits = value.codeUnits as List; + for (final char in codeUnits) { + if (char.isInvalidAscii) { + throw FormatException('Invalid ASCII character: $char'); + } + } + return StructuredFieldItemValueString._(value); + } + + factory StructuredFieldItemValueString.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseString(); + } + + String encode([StringBuffer? builder]) { + final start = builder == null ? 0 : builder.length; + builder ??= StringBuffer(); + builder.writeCharCode(Character.doubleQuote); + final codeUnits = this.codeUnits as List; + for (final char in codeUnits) { + if (char == Character.doubleQuote || char == Character.backslash) { + builder.writeCharCode(Character.backslash); + } + builder.writeCharCode(char); + } + builder.writeCharCode(Character.doubleQuote); + return builder.toString().substring(start); + } +} + +extension type const StructuredFieldItemValueNumber(num value) + implements num, StructuredFieldItemValue {} + +extension type const StructuredFieldItemValueInteger._(int value) + implements int, StructuredFieldItemValueNumber { + factory StructuredFieldItemValueInteger(int value) { + if (value < $min || value > $max) { + throw RangeError.value(value, 'int', 'Out of range'); + } + return StructuredFieldItemValueInteger._(value); + } + + factory StructuredFieldItemValueInteger.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseInteger(); + } + + static const int $min = -999999999999999; + static const int $max = 999999999999999; + + String encode([StringBuffer? builder]) { + final value = toString(); + builder?.write(value); + return value; + } +} + +extension type const StructuredFieldItemValueDecimal._(double value) + implements double, StructuredFieldItemValueNumber { + factory StructuredFieldItemValueDecimal(double value) { + if (value.isNaN || value.isInfinite) { + throw FormatException('Invalid decimal value: $value'); + } + return StructuredFieldItemValueDecimal._(value); + } + + factory StructuredFieldItemValueDecimal.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseDecimal(); + } + + static const double $min = -999999999999; + static const double $max = 999999999999; + + String encode([StringBuffer? builder]) { + final encoded = _encoded; + builder?.write(encoded); + return encoded; + } + + String get _encoded { + const th = 0.001; + final rounded = (this / th).round() * th; + final (integer, fraction) = _mod(rounded); + + if (integer < $min || integer > $max) { + throw RangeError.value(integer, 'int', 'Out of range'); + } + + var str = rounded.toStringAsFixed(3); + final decimal = str.indexOf('.'); + if (decimal == -1) { + return '$str.0'; + } + if (fraction == 0) { + return '${str.substring(0, decimal + 1)}0'; + } + final codeUnits = str.codeUnits; + for (var i = str.length - 1; i > decimal; i--) { + if (codeUnits[i] != Character.zero) { + return str.substring(0, i + 1); + } + } + return str; + } + + (double integer, double fraction) _mod(double f) { + if (f < 0) { + final (integer, fraction) = _mod(-f); + return (-integer, -fraction); + } + if (f == 0) { + return (f, f); // Return (-0, -0) when f == -0 + } + if (f < 1) { + return (0, f); + } + + const shift = 64 - 11 - 1; + const mask = 0x7FF; + const bias = 1023; + + var x = f.bits; + final e = (x >>> shift & mask) - bias; + if (e < 64 - 12) { + // Keep the top 12+e bits, the integer part; clear the rest. + final clearFraction = (1 << (64 - 12 - e)) - 1; + x &= ~clearFraction; + } + final integer = _Float64.fromBits(x); + return (integer, f - integer); + } +} + +extension _Float64 on double { + static double fromBits(int bits) { + final typed = Uint64List(1)..[0] = bits; + return Float64List.view(typed.buffer)[0]; + } + + int get bits { + final typed = Float64List(1)..[0] = this; + return Uint64List.view(typed.buffer)[0]; + } +} + +extension type const StructuredFieldItemValueBinary(Uint8List value) + implements Uint8List, StructuredFieldItemValue { + factory StructuredFieldItemValueBinary.decode(String value) { + final parser = StructuredFieldValueParser(value); + return StructuredFieldItemValueBinary(parser.parseBytes()); + } + + String encode([StringBuffer? builder]) { + final start = builder == null ? 0 : builder.length; + builder ??= StringBuffer(); + builder + ..writeCharCode(Character.colon) + ..write(base64.encode(this)) + ..writeCharCode(Character.colon); + return builder.toString().substring(start); + } +} + +extension type const StructuredFieldItemValueToken._(Token value) + implements Token, StructuredFieldItemValue { + factory StructuredFieldItemValueToken(String value) { + if (value.isEmpty) { + throw FormatException('Token cannot be empty'); + } + final codeUnits = value.codeUnits as List; + if (!codeUnits[0].isAlpha && codeUnits[0] != Character.star) { + throw FormatException('Token must start with an alpha character or "*"'); + } + for (var index = 1; index < codeUnits.length; index++) { + final char = codeUnits[index]; + if (!char.isExtendedTokenCharacter) { + throw FormatException('Invalid character in token: $char'); + } + } + return StructuredFieldItemValueToken._(Token(value)); + } + + factory StructuredFieldItemValueToken.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseToken(); + } + + String encode([StringBuffer? builder]) { + builder?.write(value); + return value.toString(); + } +} diff --git a/packages/http_sfv/lib/src/key.dart b/packages/http_sfv/lib/src/key.dart new file mode 100644 index 0000000..41f5ef2 --- /dev/null +++ b/packages/http_sfv/lib/src/key.dart @@ -0,0 +1,34 @@ +import 'character.dart'; +import 'parser.dart'; + +extension type const Key._(String key) implements String { + factory Key(String key) { + if (key.isEmpty) { + throw FormatException('Key cannot be empty'); + } + final codeUnits = key.codeUnits as List; + final first = codeUnits.first; + if (!first.isLowerAlpha && first != Character.star) { + throw FormatException('Key must start with an alpha character or "*"'); + } + for (var index = 1; index < codeUnits.length; index++) { + final char = codeUnits[index]; + if (!char.isKeyCharacter) { + throw FormatException( + 'Invalid character in key: ${String.fromCharCode(char)}', + ); + } + } + return Key._(key); + } + + factory Key.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseKey(); + } + + String encode([StringBuffer? builder]) { + builder?.write(key); + return key; + } +} diff --git a/packages/http_sfv/lib/src/parser.dart b/packages/http_sfv/lib/src/parser.dart new file mode 100644 index 0000000..2cb19ad --- /dev/null +++ b/packages/http_sfv/lib/src/parser.dart @@ -0,0 +1,474 @@ +@internal +library; + +import 'dart:convert'; +import 'dart:math'; + +import 'package:http_sfv/http_sfv.dart'; +import 'package:http_sfv/src/character.dart'; +import 'package:http_sfv/src/item_value.dart'; +import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; + +final class StructuredFieldValueParser { + factory StructuredFieldValueParser(String value) { + return StructuredFieldValueParser._( + SourceSpan( + SourceLocation(0), + SourceLocation(value.length), + value, + ), + value.codeUnits as List, + ); + } + + StructuredFieldValueParser._(this._span, this._codeUnits); + + final SourceSpan _span; + String get _value => _span.text; + final List _codeUnits; + int _offset = 0; + + bool get isEof => _offset == _codeUnits.length; + + Never _unexpectedEof() => _fail('Unexpected end of input'); + + Never _unexpectedChar([String? message]) => + _fail(message ?? 'Unexpected character', _offset, 1); + + Never _fail(String message, [int? start, int? length]) { + int tokenStart; + if (start == null) { + tokenStart = _offset; + while (tokenStart.isWithin(0, _codeUnits.length) && + !_codeUnits[tokenStart].isOptionalWhitespace) { + tokenStart--; + } + } else { + tokenStart = start.clamp(0, _codeUnits.length); + } + assert(tokenStart >= 0); + int tokenEnd = tokenStart; + if (length == null) { + tokenEnd = _offset; + while (tokenEnd < _codeUnits.length && + !_codeUnits[tokenEnd].isOptionalWhitespace) { + tokenEnd++; + } + } else { + tokenEnd = min(tokenEnd + length, _codeUnits.length); + } + assert(tokenEnd <= _codeUnits.length); + final tokenSpan = SourceSpanWithContext( + SourceLocation(tokenStart), + SourceLocation(tokenEnd), + _value.substring(tokenStart, tokenEnd), + _value, + ); + throw FormatException('$message:\n${tokenSpan.highlight()}'); + } + + Character _peek() { + if (isEof) _unexpectedEof(); + return _codeUnits[_offset]; + } + + Character _take() { + if (isEof) _unexpectedEof(); + final char = _codeUnits[_offset]; + _offset++; + return char; + } + + void _skipSpaces() { + while (!isEof) { + if (_peek() != Character.space) { + return; + } + _offset++; + } + } + + void _skipOptionalWhitespace() { + while (!isEof) { + final char = _peek(); + if (char != Character.space && char != Character.tab) { + return; + } + _offset++; + } + } + + /// Parses a value with the given [type] as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#text-parse + StructuredFieldValue parseValue(StructuredFieldValueType type) { + _skipSpaces(); + final value = switch (type) { + StructuredFieldValueType.item => parseItem(), + StructuredFieldValueType.list => parseList(), + StructuredFieldValueType.dictionary => parseDictionary(), + }; + _skipSpaces(); + if (!isEof) { + _unexpectedChar('Unexpected trailing characters'); + } + return value; + } + + /// Parses a bare item as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-bare-item + StructuredFieldItemValue parseItemValue() { + final char = _peek(); + return switch (char) { + Character.minus => _parseNumber(), + _ when char.isDigit => _parseNumber(), + Character.doubleQuote => parseString(), + Character.star => parseToken() as StructuredFieldItemValue, + _ when char.isAlpha => parseToken() as StructuredFieldItemValue, + Character.colon => parseBytes(), + Character.questionMark => parseBool(), + _ => _unexpectedChar(), + }; + } + + /// Parses a string value as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-string + StructuredFieldItemValueString parseString() { + if (_take() != Character.doubleQuote) { + _unexpectedChar('Expected a double quote (")'); + } + + final s = StringBuffer(); + while (!isEof) { + final char = _take(); + switch (char) { + case Character.backslash: + final next = _take(); + if (next != Character.doubleQuote && next != Character.backslash) { + _unexpectedChar('Invalid escape sequence'); + } + s.writeCharCode(next); + case Character.doubleQuote: + return StructuredFieldItemValueString(s.toString()); + case _ when char.isInvalidAscii: + _unexpectedChar('Invalid ASCII character'); + default: + s.writeCharCode(char); + } + } + + _unexpectedChar('Missing closing double quote (")'); + } + + /// Parses a boolean value as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-boolean + StructureFieldItemValueBool parseBool() { + if (_take() != Character.questionMark) { + _unexpectedChar('Expected a question mark (?)'); + } + final next = _take(); + return switch (next) { + Character.one => const StructureFieldItemValueBool(true), + Character.zero => const StructureFieldItemValueBool(false), + _ => _unexpectedChar('Expected a 0 or 1'), + }; + } + + /// Parses a byte sequence as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-binary + StructuredFieldItemValueBinary parseBytes() { + if (_take() != Character.colon) { + _unexpectedChar('Expected a colon (:)'); + } + + final start = _offset; + while (!isEof) { + final char = _take(); + if (char == Character.colon) { + return StructuredFieldItemValueBinary( + base64.decode(_value.substring(start, _offset - 1)), + ); + } + if (!char.isValidBase64) { + _unexpectedChar('Invalid base64 character'); + } + } + + _unexpectedChar('Missing closing colon (:)'); + } + + StructuredFieldItemValueNumber _parseNumber() { + final (:value, :isNegative, :decimalOffset) = _parseNumberState(); + if (decimalOffset != null) { + return _parseDecimal(value, isNegative, decimalOffset); + } + return _parseInteger(value, isNegative); + } + + /// Parses an integer or decimal number as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-number + _NumberState _parseNumberState() { + final isNegative = _peek() == Character.minus; + if (isNegative) _offset++; + if (!_peek().isDigit) { + _unexpectedChar('Expected a digit'); + } + + final start = _offset; + + int? decimalOffset; + var isDecimal = false; + while (!isEof) { + final char = _peek(); + if (char.isDigit) { + _offset++; + continue; + } + + if (!isDecimal && char == Character.decimal) { + // The maximum number of characters for the input string. + const maxInputLength = 12; + final size = _offset - start; + if (size > maxInputLength) { + _unexpectedChar('Too many characters before the decimal point'); + } + + isDecimal = true; + decimalOffset = _offset; + _offset++; + continue; + } + + break; + } + + final value = _value.substring(start, _offset); + + final maxTotalLength = isDecimal ? 16 : 15; + if (value.length > maxTotalLength) { + _unexpectedChar('Input is too large: ${value.length} > $maxTotalLength'); + } + if (value.codeUnits.last == Character.decimal) { + _unexpectedChar('Unexpected decimal point'); + } + + return ( + value: value, + isNegative: isNegative, + decimalOffset: decimalOffset, + ); + } + + StructuredFieldItemValueInteger parseInteger() { + final (:value, :isNegative, :decimalOffset) = _parseNumberState(); + if (decimalOffset != null) { + _unexpectedChar('Expected an integer, but got a double'); + } + return _parseInteger(value, isNegative); + } + + StructuredFieldItemValueInteger _parseInteger(String value, bool isNegative) { + var integer = int.parse(value); + if (isNegative) { + integer = -integer; + } + if (integer < StructuredFieldItemValueInteger.$min || + integer > StructuredFieldItemValueInteger.$max) { + _unexpectedChar('Number is out of range'); + } + return StructuredFieldItemValueInteger(integer); + } + + StructuredFieldItemValueDecimal parseDecimal() { + final (:value, :isNegative, :decimalOffset) = _parseNumberState(); + if (decimalOffset == null) { + _unexpectedChar('Expected a digit after the decimal point'); + } + return _parseDecimal(value, isNegative, decimalOffset); + } + + StructuredFieldItemValueDecimal _parseDecimal( + String value, + bool isNegative, + int decimalOffset, + ) { + const maxDecimalDigits = 3; + if (_offset - (decimalOffset + 1) > maxDecimalDigits) { + _unexpectedChar('Too many digits after the decimal point'); + } + final decimal = double.parse(value); + return StructuredFieldItemValueDecimal(isNegative ? -decimal : decimal); + } + + /// Parses a token as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-token + StructuredFieldItemValueToken parseToken() { + final start = _offset; + final first = _take(); + if (!first.isAlpha && first != Character.star) { + _unexpectedChar('Token must start with an alpha character or "*"'); + } + + while (!isEof) { + if (!_peek().isExtendedTokenCharacter) { + break; + } + _offset++; + } + + return StructuredFieldItemValueToken(_value.substring(start, _offset)); + } + + /// Parses a key as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-key + Key parseKey() { + final start = _offset; + final first = _take(); + if (!first.isLowerAlpha && first != Character.star) { + _unexpectedChar('Key must start with an alpha character or "*"'); + } + + while (!isEof) { + if (!_peek().isKeyCharacter) { + break; + } + _offset++; + } + + return Key(_value.substring(start, _offset)); + } + + /// Parses a parameters map as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-param + StructuredFieldParameters parseParameters() { + final values = {}; + while (!isEof) { + if (_peek() != Character.semiColon) { + break; + } + _offset++; + _skipSpaces(); + + final key = parseKey(); + StructuredFieldItemValue value = const StructureFieldItemValueBool(true); + if (!isEof && _peek() == Character.equals) { + _offset++; + value = parseItemValue(); + } + values[key] = value; + } + return StructuredFieldParameters(values); + } + + /// Parses a list as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-list + StructuredFieldList parseList() { + final members = []; + while (!isEof) { + final member = parseMember(); + members.add(member); + _skipOptionalWhitespace(); + if (isEof) { + break; + } + if (_take() != Character.comma) { + _unexpectedChar('Expected a comma (,)'); + } + _skipOptionalWhitespace(); + if (isEof) { + _unexpectedChar('Unexpected trailing comma'); + } + } + return StructuredFieldList(members); + } + + /// Parses an inner list as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-innerlist + StructuredFieldInnerList parseInnerList() { + if (_take() != Character.openParen) { + _unexpectedChar('Expected an open parenthesis `(`'); + } + final innerList = []; + while (!isEof) { + _skipSpaces(); + if (_peek() == Character.closeParen) { + _offset++; + final parameters = parseParameters(); + return StructuredFieldInnerList(innerList, parameters: parameters); + } + final item = parseItem(); + innerList.add(item); + + final next = _peek(); + if (next != Character.space && next != Character.closeParen) { + _unexpectedChar('Expected a space or close parenthesis `)`'); + } + } + + _unexpectedChar('Missing closing parenthesis `)`'); + } + + /// Parses an item as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-item + StructuredFieldItem parseItem() { + final bareItem = parseItemValue(); + final parameters = parseParameters(); + return StructuredFieldItem( + bareItem, + parameters: parameters, + ); + } + + /// Parses an item or inner list as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-item-or-list + StructuredFieldMember parseMember() { + if (_peek() == Character.openParen) { + return parseInnerList(); + } + return parseItem(); + } + + /// Parses a dictionary as specified in RFC8941: + /// https://httpwg.org/specs/rfc8941.html#parse-dictionary + StructuredFieldDictionary parseDictionary() { + final values = {}; + while (!isEof) { + final key = parseKey(); + StructuredFieldMember value; + if (!isEof && _peek() == Character.equals) { + _offset++; + final member = parseMember(); + value = member; + } else { + final parameters = parseParameters(); + value = StructuredFieldItem( + const StructureFieldItemValueBool(true), + parameters: parameters, + ); + } + values[key] = value; + _skipOptionalWhitespace(); + if (isEof) { + break; + } + if (_take() != Character.comma) { + _unexpectedChar('Expected a comma (,)'); + } + _skipOptionalWhitespace(); + if (isEof) { + _unexpectedChar('Unexpected trailing comma'); + } + } + return StructuredFieldDictionary(values); + } +} + +typedef _NumberState = ({ + String value, + bool isNegative, + int? decimalOffset, +}); + +extension on int { + bool isWithin(int start, int end) => this >= start && this < end; +} diff --git a/packages/http_sfv/lib/src/token.dart b/packages/http_sfv/lib/src/token.dart new file mode 100644 index 0000000..8e65d17 --- /dev/null +++ b/packages/http_sfv/lib/src/token.dart @@ -0,0 +1,26 @@ +import 'package:meta/meta.dart'; + +/// {@template http_sfv.token} +/// Tokens are short, textual words which are identical to their serialized +/// value. +/// +/// The distinction between a token and a string is similar to that of a +/// [Symbol] and [String] in Dart. +/// {@endtemplate} +@immutable +final class Token { + /// {@macro http_sfv.token} + const Token(this._value); + + final String _value; + + @override + bool operator ==(Object other) => + identical(this, other) || other is Token && _value == other._value; + + @override + int get hashCode => Object.hash(Token, _value); + + @override + String toString() => _value; +} diff --git a/packages/http_sfv/lib/src/value.dart b/packages/http_sfv/lib/src/value.dart new file mode 100644 index 0000000..41bc757 --- /dev/null +++ b/packages/http_sfv/lib/src/value.dart @@ -0,0 +1,494 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:http_sfv/http_sfv.dart'; +import 'package:http_sfv/src/character.dart'; +import 'package:http_sfv/src/parser.dart'; +import 'package:meta/meta.dart'; + +enum StructuredFieldValueType { item, list, dictionary } + +@immutable +sealed class StructuredFieldValue { + factory StructuredFieldValue.decode( + String value, { + required StructuredFieldValueType type, + }) { + final parser = StructuredFieldValueParser(value); + return parser.parseValue(type); + } + + String encode([StringBuffer? builder]); +} + +/// A marker interface for members of dictionaries and lists, e.g. values +/// of type [StructuredFieldItem] or [StructuredFieldInnerList]. +sealed class StructuredFieldMember implements StructuredFieldValue { + factory StructuredFieldMember(Object value) { + return switch (value) { + StructuredFieldMember() => value, + List() => StructuredFieldInnerList(value), + List() => StructuredFieldInnerList(value.cast()), + _ => StructuredFieldItem(value), + }; + } +} + +/// {@template http_sfv.structured_field_list} +/// A list of zero or more [StructuredFieldMember], each of which can be a +/// [StructuredFieldItem] or [StructuredFieldInnerList]. +/// +/// https://www.rfc-editor.org/rfc/rfc8941.html#name-lists +/// {@endtemplate} +final class StructuredFieldList extends DelegatingList + implements StructuredFieldMember { + /// {@macro http_sfv.structured_field_list} + factory StructuredFieldList(List items) { + if (items is StructuredFieldList) { + return items; + } + return StructuredFieldList._( + items.map(StructuredFieldMember.new).toList(), + ); + } + + StructuredFieldList._(super.base); + + factory StructuredFieldList.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseValue(StructuredFieldValueType.list) + as StructuredFieldList; + } + + @override + String encode([StringBuffer? builder]) { + final start = builder == null ? 0 : builder.length; + builder ??= StringBuffer(); + for (var i = 0; i < length; i++) { + final member = this[i]; + member.encode(builder); + if (i != length - 1) { + builder.write(', '); + } + } + return builder.toString().substring(start); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! StructuredFieldList) { + return false; + } + if (length != other.length) { + return false; + } + for (var i = 0; i < length; i++) { + if (this[i] != other[i]) { + return false; + } + } + return true; + } + + @override + int get hashCode => Object.hashAll(this); + + @override + String toString() => 'List(${join(', ')})'; +} + +/// {@template http_sfv.structured_field_dictionary} +/// An ordered map of zero or more key-value pairs, where the keys are short +/// textual strings and the values are [StructuredFieldItem]s or arrays of +/// [StructuredFieldItem]s, e.g. [StructuredFieldInnerList]. +/// +/// https://www.rfc-editor.org/rfc/rfc8941.html#name-dictionaries +/// {@endtemplate} +final class StructuredFieldDictionary + with MapBase + implements StructuredFieldMember { + /// {@macro http_sfv.structured_field_dictionary} + factory StructuredFieldDictionary([ + Map? dictionary, + ]) { + if (dictionary is StructuredFieldDictionary) { + return dictionary; + } + final map = dictionary?.map( + (key, value) => MapEntry( + Key(key), + StructuredFieldMember(value), + ), + ); + return StructuredFieldDictionary._(map ?? {}); + } + + /// Decodes [value] as a dictionary. + factory StructuredFieldDictionary.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseValue(StructuredFieldValueType.dictionary) + as StructuredFieldDictionary; + } + + StructuredFieldDictionary._(this._map); + + final Map _map; + + @override + StructuredFieldMember? operator [](Object? key) { + if (key is! String) { + throw ArgumentError.value(key, 'key', 'must be a String'); + } + return _map[key as Key]; + } + + @override + void operator []=(String key, StructuredFieldMember value) { + _map[Key(key)] = value; + } + + @override + void clear() => _map.clear(); + + @override + Iterable get keys => _map.keys; + + @override + StructuredFieldMember? remove(Object? key) => _map.remove(key); + + @override + String encode([StringBuffer? builder]) { + final start = builder == null ? 0 : builder.length; + builder ??= StringBuffer(); + + var i = 0; + for (final key in keys) { + key.encode(builder); + + final value = this[key]!; + if (value case StructuredFieldItem(value: true)) { + value.parameters.encode(builder); + } else { + builder.writeCharCode(Character.equals); + value.encode(builder); + } + + if (i != length - 1) { + builder.write(', '); + } + i++; + } + + return builder.toString().substring(start); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! StructuredFieldDictionary) { + return false; + } + if (_map.length != other._map.length) { + return false; + } + for (final key in _map.keys) { + if (_map[key] != other._map[key]) { + return false; + } + } + return true; + } + + @override + int get hashCode => + const MapEquality().hash(_map); + + @override + String toString() { + final buf = StringBuffer('Dictionary('); + buf.writeAll(entries.map((e) => '${e.key}: ${e.value}'), ', '); + buf.write(')'); + return buf.toString(); + } +} + +/// {@template http_sfv.structured_field_inner_list} +/// An array of zero or more [StructuredFieldItem]s. +/// +/// Both the inner list and its [items] can be parameterized. +/// {@endtemplate} +final class StructuredFieldInnerList implements StructuredFieldMember { + /// {@macro http_sfv.structured_field_inner_list} + factory StructuredFieldInnerList( + List items, { + Map? parameters, + }) { + return StructuredFieldInnerList._( + items.map(StructuredFieldItem.new).toList(), + parameters: StructuredFieldParameters(parameters), + ); + } + + StructuredFieldInnerList._( + this.items, { + required this.parameters, + }); + + /// Decodes [value] as an inner list. + factory StructuredFieldInnerList.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseInnerList(); + } + + /// The items of the inner list. + final List items; + + /// The parameters of the inner list. + final StructuredFieldParameters parameters; + + @override + String encode([StringBuffer? builder]) { + final start = builder == null ? 0 : builder.length; + builder ??= StringBuffer(); + builder.writeCharCode(Character.openParen); + for (var i = 0; i < items.length; i++) { + final item = items[i]; + item.encode(builder); + if (i != items.length - 1) { + builder.writeCharCode(Character.space); + } + } + builder.writeCharCode(Character.closeParen); + parameters.encode(builder); + return builder.toString().substring(start); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! StructuredFieldInnerList) { + return false; + } + if (items.length != other.items.length) { + return false; + } + for (var i = 0; i < items.length; i++) { + if (items[i] != other.items[i]) { + return false; + } + } + return parameters == other.parameters; + } + + @override + int get hashCode { + var hash = 0; + for (var i = 0; i < items.length; i++) { + hash = Object.hash(hash, items[i]); + } + return Object.hash(hash, parameters); + } + + @override + String toString() { + final buf = StringBuffer('InnerList('); + buf + ..write('[') + ..write(items.join(', ')) + ..write(']'); + if (parameters.isNotEmpty) { + buf + ..write(', ') + ..writeAll(parameters.entries.map((e) => '${e.key}: ${e.value}'), ', '); + } + buf.write(')'); + return buf.toString(); + } +} + +/// {@template http_sfv.structured_field_item} +/// A parameterized [StructuredFieldItemValue]. +/// +/// Item values can be of type: +/// - [Token] +/// - [int] +/// - [double] +/// - [String] +/// - [Uint8List] +/// - [bool] +/// {@endtemplate} +final class StructuredFieldItem implements StructuredFieldMember { + /// {@macro http_sfv.structured_field_item} + factory StructuredFieldItem( + Object value, { + Map? parameters, + }) { + if (value is StructuredFieldItem) { + return value; + } + return StructuredFieldItem._( + StructuredFieldItemValue(value), + parameters: StructuredFieldParameters(parameters), + ); + } + + /// Decodes [value] as an item. + factory StructuredFieldItem.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseValue(StructuredFieldValueType.item) + as StructuredFieldItem; + } + + StructuredFieldItem._( + this.value, { + required this.parameters, + }); + + /// The value of the item. + final StructuredFieldItemValue value; + + /// The parameters of the item. + final StructuredFieldParameters parameters; + + @override + String encode([StringBuffer? builder]) { + final start = builder == null ? 0 : builder.length; + builder ??= StringBuffer(); + value.encode(builder); + parameters.encode(builder); + return builder.toString().substring(start); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StructuredFieldItem && + value.equals(other.value) && + parameters == other.parameters; + + @override + int get hashCode => Object.hash(value, parameters); + + @override + String toString() { + final buf = StringBuffer('Item('); + buf.write(value); + if (parameters.isNotEmpty) { + buf + ..write(', ') + ..writeAll(parameters.entries.map((e) => '${e.key}: ${e.value}'), ', '); + } + buf.write(')'); + return buf.toString(); + } +} + +/// {@template http_sfv.structured_field_parameters} +/// An ordered map of zero or more key-value pairs associated with a +/// [StructuredFieldItem]. +/// {@endtemplate} +final class StructuredFieldParameters + with MapBase + implements StructuredFieldValue { + /// {@macro http_sfv.structured_field_parameters} + factory StructuredFieldParameters([Map? parameters]) { + if (parameters is StructuredFieldParameters) { + return parameters; + } + final map = parameters?.map( + (key, value) => MapEntry( + Key(key), + StructuredFieldItemValue(value), + ), + ); + return StructuredFieldParameters._(map ?? {}); + } + + /// Decodes [value] as parameters. + factory StructuredFieldParameters.decode(String value) { + final parser = StructuredFieldValueParser(value); + return parser.parseParameters(); + } + + StructuredFieldParameters._(this._map); + + final Map _map; + + @override + StructuredFieldItemValue? operator [](Object? key) { + if (key is! String) { + throw ArgumentError.value(key, 'key', 'must be a String'); + } + return _map[key as Key]; + } + + @override + void operator []=(String key, StructuredFieldItemValue value) { + _map[Key(key)] = value; + } + + @override + void clear() => _map.clear(); + + @override + Iterable get keys => _map.keys; + + @override + StructuredFieldItemValue? remove(Object? key) => _map.remove(key); + + @override + String encode([StringBuffer? builder]) { + final start = builder == null ? 0 : builder.length; + builder ??= StringBuffer(); + for (final key in keys) { + final item = this[key]!; + builder.writeCharCode(Character.semiColon); + key.encode(builder); + if (item.value == true) { + continue; + } + builder.writeCharCode(Character.equals); + item.encode(builder); + } + return builder.toString().substring(start); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! StructuredFieldParameters) { + return false; + } + if (_map.length != other._map.length) { + return false; + } + for (final key in _map.keys) { + if (!_map[key]!.equals(other._map[key]!)) { + return false; + } + } + return true; + } + + @override + int get hashCode => + const MapEquality().hash(_map); + + @override + String toString() { + final buf = StringBuffer('Parameters('); + buf.writeAll(entries.map((e) => '${e.key}: ${e.value}'), ', '); + buf.write(')'); + return buf.toString(); + } +} diff --git a/packages/http_sfv/pubspec.yaml b/packages/http_sfv/pubspec.yaml new file mode 100644 index 0000000..01e8533 --- /dev/null +++ b/packages/http_sfv/pubspec.yaml @@ -0,0 +1,17 @@ +name: http_sfv +description: Dart implementation of Structured Field Values for HTTP (RFC8941) +version: 0.1.0 + +environment: + sdk: ^3.3.0 + +dependencies: + collection: ^1.18.0 + meta: ^1.10.0 + source_span: ^1.10.0 + +dev_dependencies: + base32: ^2.1.3 + lints: ^4.0.0 + path: ^1.9.0 + test: ^1.24.0 diff --git a/packages/http_sfv/structured-field-tests b/packages/http_sfv/structured-field-tests new file mode 160000 index 0000000..efc4461 --- /dev/null +++ b/packages/http_sfv/structured-field-tests @@ -0,0 +1 @@ +Subproject commit efc4461a8b67bf7856836ae8093cdb83e524b13e diff --git a/packages/http_sfv/test/binary_test.dart b/packages/http_sfv/test/binary_test.dart new file mode 100644 index 0000000..a13fb86 --- /dev/null +++ b/packages/http_sfv/test/binary_test.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:http_sfv/src/item_value.dart'; +import 'package:test/test.dart'; + +typedef _ValidBinaryTest = ( + String encoded, + StructuredFieldItemValueBinary value, +); + +final List<_ValidBinaryTest> _valid = [ + (':YWJj:', StructuredFieldItemValueBinary(utf8.encode('abc'))), + ( + ':YW55IGNhcm5hbCBwbGVhc3VyZQ==:', + StructuredFieldItemValueBinary(utf8.encode('any carnal pleasure')), + ), + ( + ':YW55IGNhcm5hbCBwbGVhc3Vy:', + StructuredFieldItemValueBinary(utf8.encode('any carnal pleasur')), + ), +]; + +const List _invalid = [ + '', + ':', + ':YW55IGNhcm5hbCBwbGVhc3Vy', + ':YW55IGNhcm5hbCBwbGVhc3Vy~', + ':YW55IGNhcm5hbCBwbGVhc3VyZQ=:', +]; + +void main() { + group('ByteSequence', () { + for (final (encoded, value) in _valid) { + test('isValid: "$encoded"', () { + expect(StructuredFieldItemValueBinary.decode(encoded), value); + expect(value.encode(), encoded); + }); + } + + for (final encoded in _invalid) { + test('isInvalid: "$encoded"', () { + expect( + () => StructuredFieldItemValueBinary.decode(encoded), + throwsFormatException, + ); + }); + } + }); +} diff --git a/packages/http_sfv/test/boolean_test.dart b/packages/http_sfv/test/boolean_test.dart new file mode 100644 index 0000000..fe3a958 --- /dev/null +++ b/packages/http_sfv/test/boolean_test.dart @@ -0,0 +1,38 @@ +import 'package:http_sfv/src/item_value.dart'; +import 'package:test/test.dart'; + +typedef _ValidBoolTest = ( + String encoded, + StructureFieldItemValueBool value, +); + +const List<_ValidBoolTest> _valid = [ + ('?1', StructureFieldItemValueBool(true)), + ('?0', StructureFieldItemValueBool(false)), +]; + +const List _invalid = [ + '?2', + '', + '?', +]; + +void main() { + group('Boolean', () { + for (final (encoded, value) in _valid) { + test('isValid: "$encoded"', () { + expect(StructureFieldItemValueBool.decode(encoded), value); + expect(value.encode(), encoded); + }); + } + + for (final encoded in _invalid) { + test('isInvalid: "$encoded"', () { + expect( + () => StructureFieldItemValueBool.decode(encoded), + throwsFormatException, + ); + }); + } + }); +} diff --git a/packages/http_sfv/test/decimal_test.dart b/packages/http_sfv/test/decimal_test.dart new file mode 100644 index 0000000..7feff24 --- /dev/null +++ b/packages/http_sfv/test/decimal_test.dart @@ -0,0 +1,58 @@ +import 'package:http_sfv/src/item_value.dart'; +import 'package:test/test.dart'; + +typedef _ValidDecimalTest = ( + String encoded, + StructuredFieldItemValueDecimal value, +); + +final List<_ValidDecimalTest> _validValues = [ + ('10.0', StructuredFieldItemValueDecimal(10.0)), + ('-10.123', StructuredFieldItemValueDecimal(-10.123)), + ('10.124', StructuredFieldItemValueDecimal(10.124)), + ('-10.0', StructuredFieldItemValueDecimal(-10.0)), + ('0.0', StructuredFieldItemValueDecimal(0.0)), + ('-999999999999.0', StructuredFieldItemValueDecimal(-999999999999.0)), + ('999999999999.0', StructuredFieldItemValueDecimal(999999999999.0)), + ('1.9', StructuredFieldItemValueDecimal(1.9)), +]; + +const List _invalidValues = [ + 9999999999999, + -9999999999999.0, + 9999999999999.0, +]; + +const List _invalid = [ + '10.12345', + '-10.12345', +]; + +void main() { + group('Decimal', () { + for (final (encoded, value) in _validValues) { + test('isValid: "$encoded"', () { + expect(StructuredFieldItemValueDecimal.decode(encoded), value); + expect(value.encode(), encoded); + }); + } + + for (final value in _invalidValues) { + test('isInvalidValue: "$value"', () { + expect( + () => StructuredFieldItemValueDecimal(value).encode(), + throwsRangeError, + ); + }); + } + + for (final encoded in _invalid) { + test('isInvalid: "$encoded"', () { + expect( + () => StructuredFieldItemValueDecimal.decode(encoded), + throwsFormatException, + ); + }); + } + }); +} diff --git a/packages/http_sfv/test/dictionary_test.dart b/packages/http_sfv/test/dictionary_test.dart new file mode 100644 index 0000000..736dfa7 --- /dev/null +++ b/packages/http_sfv/test/dictionary_test.dart @@ -0,0 +1,87 @@ +import 'package:http_sfv/http_sfv.dart'; +import 'package:test/test.dart'; + +const List _invalid = [ + 'é', + 'foo="é"', + 'foo;é', + 'f="foo" é', + 'f="foo",', + '0foo="bar"', + 'mAj="bar"', + '_foo="bar"', +]; + +void main() { + group('Dictionary', () { + test('empty is valid', () { + const encoded = ''; + final value = StructuredFieldDictionary({}); + expect(StructuredFieldDictionary.decode(encoded), value); + }); + + test('isValid', () { + final expected = StructuredFieldDictionary({ + 'a': StructuredFieldItem(StructuredFieldItemValue(false)), + 'b': StructuredFieldItem(StructuredFieldItemValue(true)), + 'c': StructuredFieldItem( + StructuredFieldItemValue(true), + parameters: StructuredFieldParameters({ + 'foo': StructuredFieldItemValue.token('bar'), + }), + ), + }); + + const encoded = 'a=?0, b, c; foo=bar'; + final value = StructuredFieldDictionary.decode(encoded); + expect(value, expected); + expect(value.encode(), 'a=?0, b, c;foo=bar'); + }); + + test('map operations', () { + final dictionary = StructuredFieldDictionary({ + 'f_o1o3-': StructuredFieldItem(StructuredFieldItemValue(10.0)), + 'deleteme': StructuredFieldItem(StructuredFieldItemValue('')), + '*f0.o*': StructuredFieldItem(StructuredFieldItemValue('')), + 't': StructuredFieldItem(StructuredFieldItemValue(true)), + 'f': StructuredFieldItem(StructuredFieldItemValue(false)), + 'b': StructuredFieldItem(StructuredFieldItemValue([0, 1])), + }); + + dictionary['f_o1o3-'] = + StructuredFieldItem(StructuredFieldItemValue(123.0)); + expect(dictionary['f_o1o3-'], + StructuredFieldItem(StructuredFieldItemValue(123.0))); + + expect(dictionary.remove('deleteme'), + StructuredFieldItem(StructuredFieldItemValue(''))); + expect(dictionary.remove('deleteme'), isNull); + + expect( + dictionary['*f0.o*'], + isA().having((i) => i.value, 'value', ''), + ); + + expect(dictionary['doesnotexist'], isNull); + + expect(dictionary, hasLength(5)); + + final value = dictionary['f_o1o3-'] as StructuredFieldItem; + value.parameters['foo'] = StructuredFieldItemValue(9.5); + + expect( + dictionary.encode(), + r'f_o1o3-=123.0;foo=9.5, *f0.o*="", t, f=?0, b=:AAE=:', + ); + }); + + for (final encoded in _invalid) { + test('isInvalid: $encoded', () { + expect( + () => StructuredFieldDictionary.decode(encoded), + throwsFormatException, + ); + }); + } + }); +} diff --git a/packages/http_sfv/test/inner_list_test.dart b/packages/http_sfv/test/inner_list_test.dart new file mode 100644 index 0000000..898df23 --- /dev/null +++ b/packages/http_sfv/test/inner_list_test.dart @@ -0,0 +1,35 @@ +import 'dart:typed_data'; + +import 'package:http_sfv/http_sfv.dart'; +import 'package:test/test.dart'; + +void main() { + group('InnerList', () { + test('valid list', () { + const encoded = '("foo";a;b=1936 bar;y=:AQMBAg==:);d=18.71'; + final value = StructuredFieldInnerList( + [ + StructuredFieldItem( + StructuredFieldItemValue('foo'), + parameters: StructuredFieldParameters({ + Key('a'): StructuredFieldItemValue(true), + Key('b'): StructuredFieldItemValue(1936), + }), + ), + StructuredFieldItem( + StructuredFieldItemValue.token('bar'), + parameters: StructuredFieldParameters({ + Key('y'): + StructuredFieldItemValue(Uint8List.fromList([1, 3, 1, 2])), + }), + ), + ], + parameters: StructuredFieldParameters({ + Key('d'): StructuredFieldItemValue(18.71), + }), + ); + expect(StructuredFieldInnerList.decode(encoded), value); + expect(value.encode(), encoded); + }); + }); +} diff --git a/packages/http_sfv/test/integer_test.dart b/packages/http_sfv/test/integer_test.dart new file mode 100644 index 0000000..22a6cfc --- /dev/null +++ b/packages/http_sfv/test/integer_test.dart @@ -0,0 +1,37 @@ +import 'package:http_sfv/src/item_value.dart'; +import 'package:test/test.dart'; + +typedef _ValidIntegerTest = ( + String encoded, + StructuredFieldItemValueInteger value, +); + +final List<_ValidIntegerTest> _validValues = [ + ('10', StructuredFieldItemValueInteger(10)), + ('-10', StructuredFieldItemValueInteger(-10)), + ('0', StructuredFieldItemValueInteger(0)), + ('-999999999999999', StructuredFieldItemValueInteger(-999999999999999)), + ('999999999999999', StructuredFieldItemValueInteger(999999999999999)), +]; + +const List _invalidValues = [ + 9999999999999999, + -9999999999999999, +]; + +void main() { + group('Integer', () { + for (final (encoded, value) in _validValues) { + test('isValid: "$encoded"', () { + expect(StructuredFieldItemValueInteger.decode(encoded), value); + expect(value.encode(), encoded); + }); + } + + for (final value in _invalidValues) { + test('isInvalidValue: "$value"', () { + expect(() => StructuredFieldItemValueInteger(value), throwsRangeError); + }); + } + }); +} diff --git a/packages/http_sfv/test/item_test.dart b/packages/http_sfv/test/item_test.dart new file mode 100644 index 0000000..a0f5c67 --- /dev/null +++ b/packages/http_sfv/test/item_test.dart @@ -0,0 +1,56 @@ +import 'dart:typed_data'; + +import 'package:http_sfv/http_sfv.dart'; +import 'package:http_sfv/src/item_value.dart'; +import 'package:test/test.dart'; + +typedef _ValidItemTest = ( + String encoded, + StructuredFieldItem value, +); + +final List<_ValidItemTest> _validValues = [ + ('0', StructuredFieldItem(StructuredFieldItemValue(0))), + ('-42', StructuredFieldItem(StructuredFieldItemValue(-42))), + ('42', StructuredFieldItem(StructuredFieldItemValue(42))), + ('1.1', StructuredFieldItem(StructuredFieldItemValue(1.1))), + ('foo', StructuredFieldItem(StructuredFieldItemValue.token('foo'))), + ( + ':AAE=:', + StructuredFieldItem(StructuredFieldItemValue(Uint8List.fromList([0, 1]))) + ), + ('?0', StructuredFieldItem(StructuredFieldItemValue(false))), +]; + +const List _invalid = [ + '?2', + '', + '?', +]; + +void main() { + group('Item', () { + for (final (encoded, value) in _validValues) { + test('isValid: "$encoded"', () { + expect(StructuredFieldItem.decode(encoded), value); + expect(value.encode(), encoded); + }); + } + + test('valid parameters', () { + final item = StructuredFieldItem(StructuredFieldItemValue.token('bar')); + item.parameters['foo'] = StructuredFieldItemValue(0.0); + item.parameters['baz'] = StructuredFieldItemValue(true); + expect(item.encode(), 'bar;foo=0.0;baz'); + }); + + for (final encoded in _invalid) { + test('isInvalid: "$encoded"', () { + expect( + () => StructureFieldItemValueBool.decode(encoded), + throwsFormatException, + ); + }); + } + }); +} diff --git a/packages/http_sfv/test/item_value_test.dart b/packages/http_sfv/test/item_value_test.dart new file mode 100644 index 0000000..d10d038 --- /dev/null +++ b/packages/http_sfv/test/item_value_test.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:http_sfv/http_sfv.dart'; +import 'package:test/test.dart'; + +typedef _ValidBareItemTest = ( + String encoded, + StructuredFieldItemValue value, +); + +final List<_ValidBareItemTest> _validValues = [ + ('?1', StructuredFieldItemValue(true)), + ('?0', StructuredFieldItemValue(false)), + ('22', StructuredFieldItemValue(22)), + ('-2.2', StructuredFieldItemValue(-2.2)), + ('"foo"', StructuredFieldItemValue('foo')), + ('abc', StructuredFieldItemValue.token('abc')), + ('*abc', StructuredFieldItemValue.token('*abc')), + (':YWJj:', StructuredFieldItemValue(utf8.encode('abc'))), +]; + +const List _invalid = [ + '', + '~', +]; + +void main() { + group('ItemValue', () { + for (final (encoded, value) in _validValues) { + test('isValid: "$encoded"', () { + expect(StructuredFieldItemValue.decode(encoded), value); + expect(value.encode(), encoded); + }); + } + + for (final encoded in _invalid) { + test('isInvalid: "$encoded"', () { + expect( + () => StructuredFieldItemValue.decode(encoded), + throwsFormatException, + ); + }); + } + }); +} diff --git a/packages/http_sfv/test/key_test.dart b/packages/http_sfv/test/key_test.dart new file mode 100644 index 0000000..6f195cc --- /dev/null +++ b/packages/http_sfv/test/key_test.dart @@ -0,0 +1,50 @@ +import 'package:http_sfv/http_sfv.dart'; +import 'package:test/test.dart'; + +typedef _ValidKeyTest = ( + String encoded, + Key value, +); + +final List<_ValidKeyTest> _validValues = [ + ('f1oo', Key('f1oo')), + ('*foo0', Key('*foo0')), + ('t', Key('t')), + ('tok', Key('tok')), + ('*k-.*', Key('*k-.*')), +]; + +const List _invalidValues = ['fOo']; + +const List _invalid = [ + '', + '1foo', + 'é', +]; + +void main() { + group('Key', () { + for (final (encoded, value) in _validValues) { + test('isValid: "$encoded"', () { + expect(Key.decode(encoded), value); + expect(value.encode(), encoded); + }); + } + + test('drops trailing non-key chars', () { + expect(Key.decode('k='), 'k'); + }); + + for (final value in _invalidValues) { + test('isInvalidValue: "$value"', () { + expect(() => Key(value).encode(), throwsFormatException); + }); + } + + for (final encoded in _invalid) { + test('isInvalid: "$encoded"', () { + expect(() => Key.decode(encoded), throwsFormatException); + }); + } + }); +} diff --git a/packages/http_sfv/test/list_test.dart b/packages/http_sfv/test/list_test.dart new file mode 100644 index 0000000..6087632 --- /dev/null +++ b/packages/http_sfv/test/list_test.dart @@ -0,0 +1,98 @@ +import 'package:http_sfv/http_sfv.dart'; +import 'package:test/test.dart'; + +typedef _ValidListTest = ( + String encoded, + StructuredFieldList value, +); + +final StructuredFieldList _fooBar = StructuredFieldList([ + StructuredFieldItem(StructuredFieldItemValue.token('foo')), + StructuredFieldItem(StructuredFieldItemValue.token('bar')), +]); + +final List<_ValidListTest> _valid = [ + ('', StructuredFieldList([])), + ("foo,bar", _fooBar), + ("foo, bar", _fooBar), + ("foo,\t bar", _fooBar), + ( + '"foo";bar;baz=tok', + StructuredFieldList([ + StructuredFieldItem( + StructuredFieldItemValue('foo'), + parameters: StructuredFieldParameters({ + Key('bar'): StructuredFieldItemValue(true), + Key('baz'): StructuredFieldItemValue.token('tok'), + }), + ), + ]), + ), + ( + '(foo bar);bat', + StructuredFieldList([ + StructuredFieldInnerList( + [ + StructuredFieldItem(StructuredFieldItemValue.token('foo')), + StructuredFieldItem(StructuredFieldItemValue.token('bar')), + ], + parameters: StructuredFieldParameters({ + Key('bat'): StructuredFieldItemValue(true), + }), + ), + ]), + ), + ('()', StructuredFieldList([StructuredFieldInnerList([])])), + ( + ' "foo";bar;baz=tok, (foo bar);bat ', + StructuredFieldList([ + StructuredFieldItem( + StructuredFieldItemValue('foo'), + parameters: StructuredFieldParameters({ + Key('bar'): StructuredFieldItemValue(true), + Key('baz'): StructuredFieldItemValue.token('tok') + }), + ), + StructuredFieldInnerList( + [ + StructuredFieldItem(StructuredFieldItemValue.token('foo')), + StructuredFieldItem(StructuredFieldItemValue.token('bar')), + ], + parameters: StructuredFieldParameters({ + Key('bat'): StructuredFieldItemValue(true), + }), + ), + ]), + ), +]; + +const List _invalid = [ + 'é', + 'foo,bar,', + 'foo,baré', + 'foo,"bar', + '(foo ', + '(foo);é', + '("é")', + '(""', + '(', +]; + +void main() { + group('List', () { + for (final (encoded, value) in _valid) { + test('isValid: $encoded', () { + expect(StructuredFieldList.decode(encoded), value); + }); + } + + for (final encoded in _invalid) { + test('isInvalid: $encoded', () { + expect( + () => StructuredFieldList.decode(encoded), + throwsFormatException, + ); + }); + } + }); +} diff --git a/packages/http_sfv/test/spec_test.dart b/packages/http_sfv/test/spec_test.dart new file mode 100644 index 0000000..bd26ade --- /dev/null +++ b/packages/http_sfv/test/spec_test.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:base32/base32.dart'; +import 'package:http_sfv/http_sfv.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + final testDir = Directory.current.uri.resolve( + 'structured-field-tests/', + ); + final testFiles = Directory.fromUri(testDir) + .listSync() + .whereType() + .where((f) => f.path.endsWith('.json')); + + // Only in draft spec: + // https://www.ietf.org/archive/id/draft-ietf-httpbis-sfbis-04.html#name-display-strings + const skipTests = [ + 'date', + 'display-string', + ]; + + group('HTTP WG', () { + for (final testFile in testFiles) { + final testName = p.basenameWithoutExtension(testFile.path); + if (skipTests.contains(testName)) { + continue; + } + final contents = testFile.readAsStringSync(); + final json = jsonDecode(contents) as List; + final testCases = + json.cast().map((el) => _TestCase.fromJson(el.cast())); + group(testName, () { + for (final testCase in testCases) { + test(testCase.name, () { + try { + final encoded = testCase.raw.join(','); + final value = StructuredFieldValue.decode( + encoded, + type: testCase.headerType, + ); + expect(value.toJson(), equals(testCase.expected)); + } on Object { + if (testCase.mustFail || testCase.canFail) { + return; + } + rethrow; + } + }); + } + }); + } + }); +} + +final class _TestCase { + const _TestCase({ + required this.name, + required this.raw, + required this.headerType, + this.expected, + required this.mustFail, + required this.canFail, + this.canonical, + }); + + factory _TestCase.fromJson(Map json) { + switch (json) { + case { + 'name': final String name, + 'raw': final List raw, + 'header_type': final String headerType, + }: + final mustFail = (json['must_fail'] as bool?) ?? false; + return _TestCase( + name: name, + raw: raw.cast(), + headerType: StructuredFieldValueType.values.byName(headerType), + expected: mustFail ? null : json['expected']!, + mustFail: mustFail, + canFail: (json['can_fail'] as bool?) ?? false, + canonical: (json['canonical'] as List?)?.cast(), + ); + default: + throw FormatException('Invalid test case: $json'); + } + } + + final String name; + final List raw; + final StructuredFieldValueType headerType; + final Object? expected; + final bool mustFail; + final bool canFail; + final List? canonical; +} + +extension on StructuredFieldValue { + Object toJson() => switch (this) { + final StructuredFieldList list => [ + for (final member in list) member.toJson(), + ], + final StructuredFieldDictionary dictionary => [ + for (final entry in dictionary.entries) + [entry.key, entry.value.toJson()], + ], + final StructuredFieldInnerList innerList => [ + innerList.items.map((item) => item.toJson()).toList(), + innerList.parameters.toJson(), + ], + final StructuredFieldItem item => [ + item.value.toJson(), + item.parameters.toJson(), + ], + final StructuredFieldParameters parameters => [ + for (final entry in parameters.entries) + [entry.key, entry.value.toJson()], + ], + }; +} + +extension on StructuredFieldItemValue { + Object toJson() => switch (value) { + final bool value => value, + final String value => value, + final int value => value, + final double value => value, + final Uint8List value => { + '__type': 'binary', + 'value': base32.encode(value), + }, + final Token token => { + '__type': 'token', + 'value': token.toString(), + }, + _ => throw FormatException( + 'Invalid bare item: $this ($runtimeType)', + ), + }; +} diff --git a/packages/http_sfv/test/string_test.dart b/packages/http_sfv/test/string_test.dart new file mode 100644 index 0000000..25b31f3 --- /dev/null +++ b/packages/http_sfv/test/string_test.dart @@ -0,0 +1,65 @@ +import 'package:http_sfv/src/character.dart'; +import 'package:http_sfv/src/item_value.dart'; +import 'package:test/test.dart'; + +typedef _ValidStringTest = ( + String encoded, + StructuredFieldItemValue expected, +); + +final List<_ValidStringTest> _valid = [ + (r'"foo"', StructuredFieldItemValue.string('foo')), + (r'"b\"a\\r"', StructuredFieldItemValue.string(r'b"a\r')), + (r'"f\"oo"', StructuredFieldItemValue.string(r'f"oo')), + (r'"f\\oo"', StructuredFieldItemValue.string(r'f\oo')), + (r'"f\\\"oo"', StructuredFieldItemValue.string(r'f\"oo')), + (r'""', StructuredFieldItemValue.string('')), + (r'"H3lLo"', StructuredFieldItemValue.string("H3lLo")), +]; + +const List _invalidValues = [ + "hel\tlo", + "hel\x1flo", + "hel\x7flo", + "Kévin", + '\t', +]; + +final List _invalid = [ + '', + 'a', + r'"\', + r'"\o', + '"\x00"', + '"${String.fromCharCode(Character.maxAscii)}"', + '"foo', +]; + +void main() { + group('String', () { + for (final (encoded, value) in _valid) { + test('isValid: "$encoded"', () { + expect(StructuredFieldItemValueString.decode(encoded), value); + expect(value.encode(), encoded); + }); + } + + for (final encoded in _invalid) { + test('isInvalid: "$encoded"', () { + expect( + () => StructuredFieldItemValueString.decode(encoded), + throwsFormatException, + ); + }); + } + + for (final value in _invalidValues) { + test('isInvalidValue: "$value"', () { + expect( + () => StructuredFieldItemValue.string(value), + throwsFormatException, + ); + }); + } + }); +} diff --git a/packages/http_sfv/test/token_test.dart b/packages/http_sfv/test/token_test.dart new file mode 100644 index 0000000..0ebda37 --- /dev/null +++ b/packages/http_sfv/test/token_test.dart @@ -0,0 +1,91 @@ +import 'package:http_sfv/http_sfv.dart'; +import 'package:http_sfv/src/item_value.dart'; +import 'package:test/test.dart'; + +typedef _ValidTokenTest = ( + String token, + Token expected, +); + +const List _validValues = [ + r"abc'!#$%*+-.^_|~:/`", + r"H3lLo", + r"a*foo", + r"a!1", + r"a#1", + r"a$1", + r"a%1", + r"a&1", + r"a'1", + r"a*1", + r"a+1", + r"a-1", + r"a.1", + r"a^1", + r"a_1", + r"a`1", + r"a|1", + r"a~1", + r"a:1", + r"a/1", +]; + +const List _invalidValues = [ + r"0foo", + r"!foo", + "1abc", + "", + "hel\tlo", + "hel\x1flo", + "hel\x7flo", + "Kévin", +]; + +final List<_ValidTokenTest> _valid = [ + ('t', Token('t')), + ('tok', Token('tok')), + ('*t!o&k', Token('*t!o&k')), + ('t=', Token('t')), +]; + +const List _invalid = ['', 'é']; + +void main() { + group('Token', () { + for (final token in _validValues) { + test('isValidValue: $token', () { + expect( + () => StructuredFieldItemValue.token(token), + returnsNormally, + ); + }); + } + + for (final token in _invalidValues) { + test('isInvalidValue: $token', () { + expect( + () => StructuredFieldItemValue.token(token), + throwsFormatException, + ); + }); + } + + for (final (encoded, expected) in _valid) { + test('isValid: "$encoded"', () { + expect( + StructuredFieldItemValueToken.decode(encoded), + equals(expected), + ); + }); + } + + for (final encoded in _invalid) { + test('isInvalid: "$encoded"', () { + expect( + () => StructuredFieldItemValueToken.decode(encoded), + throwsFormatException, + ); + }); + } + }); +}