diff --git a/README.md b/README.md index 332b108..036fa41 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,13 @@ This repository contains the common utility packages required for developing dar ## Available Packages -* Html Builder +* Body Parser * Code Buffer * Combinator +* Html Builder * Merge Map +* Pub Sub +* Range Header * Symbol Table +* User Agent \ No newline at end of file diff --git a/packages/body_parser/.travis.yml b/packages/body_parser/.travis.yml new file mode 100644 index 0000000..a9e2c10 --- /dev/null +++ b/packages/body_parser/.travis.yml @@ -0,0 +1,4 @@ +language: dart +dart: + - dev + - stable \ No newline at end of file diff --git a/packages/body_parser/AUTHORS.md b/packages/body_parser/AUTHORS.md new file mode 100644 index 0000000..ac95ab5 --- /dev/null +++ b/packages/body_parser/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/body_parser/CHANGELOG.md b/packages/body_parser/CHANGELOG.md new file mode 100644 index 0000000..d83faa8 --- /dev/null +++ b/packages/body_parser/CHANGELOG.md @@ -0,0 +1,32 @@ +# Change Log + +## 3.0.0 + +* Upgraded from `pendantic` to `lints` linter +* Published as `belatuk_body_parser` package +* Fixed linter warnings + +## 2.1.1 + +* Fixed calling deprecated methods in unit test + +## 2.1.0 + +* Replaced `http_server` with `belatuk_http_server` + +## 2.0.1 + +* Fixed source code formating warning +* Updated README + +## 2.0.0 + +* Migrated to support Dart SDK 2.12.x NNBD + +## 1.1.1 + +* Dart 2 updates; should fix Angel in Travis. + +## 1.1.0 + +* Add `parseBodyFromStream` diff --git a/packages/body_parser/LICENSE b/packages/body_parser/LICENSE new file mode 100644 index 0000000..e37a346 --- /dev/null +++ b/packages/body_parser/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/body_parser/README.md b/packages/body_parser/README.md new file mode 100644 index 0000000..ea47860 --- /dev/null +++ b/packages/body_parser/README.md @@ -0,0 +1,75 @@ +# Belatuk Body Parser + +[![version](https://img.shields.io/badge/pub-v3.0.0-brightgreen)](https://pub.dev/packages/belatuk_body_parser) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/body_parser/LICENSE) + +**Replacement of `package:body_parser` with breaking changes to support NNBD.** + +Parse request bodies and query strings in Dart, as well multipart/form-data uploads. No external dependencies required. + +This is the request body parser powering the [Angel3 framework](https://pub.dev/packages/angel3_framework). If you are looking for a server-side solution with dependency injection, WebSockets, and more, then I highly recommend it as your first choice. Bam! + +## Contents + +- [Belatuk Body Parser](#belatuk-body-parser) + - [Contents](#contents) + - [About](#about) + - [Installation](#installation) + - [Usage](#usage) + - [Custom Body Parsing](#custom-body-parsing) + +### About + +I needed something like Express.js's `body-parser` module, so I made it here. It fully supports JSON requests. x-www-form-urlencoded fully supported, as well as query strings. You can also include arrays in your query, in the same way you would for a PHP application. Full file upload support will also be present by the production 1.0.0 release. + +A benefit of this is that primitive types are automatically deserialized correctly. As in, if you have a `hello=1.5` request, then `body['hello']` will equal `1.5` and not `'1.5'`. A very semantic difference, yes, but it relieves stress in my head. + +### Installation + +To install Body Parser for your Dart project, simply add body_parser to your pub dependencies. + + dependencies: + belatuk_body_parser: ^3.0.0 + +### Usage + +Body Parser exposes a simple class called `BodyParseResult`. You can easily parse the query string and request body for a request by calling `Future parseBody`. + + ```dart + import 'dart:convert'; + import 'package:belatuk_body_parser/belatuk_body_parser.dart'; + + main() async { + // ... + await for (HttpRequest request in server) { + request.response.write(JSON.encode(await parseBody(request).body)); + await request.response.close(); + } + } + ``` + +You can also use `buildMapFromUri(Map, String)` to populate a map from a URL encoded string. + +This can easily be used with a library like [Angel3 JSON God](https://pub.dev/packages/angel3_json_god) to build structured JSON/REST APIs. Add validation and you've got an instant backend. + + ```dart + MyClass create(HttpRequest request) async { + return god.deserialize(await parseBody(request).body, MyClass); + } + ``` + +### Custom Body Parsing + +In cases where you need to parse unrecognized content types, `body_parser` won't be of any help to you on its own. However, you can use the `originalBuffer` property of a `BodyParseResult` to see the original request buffer. To get this functionality, pass `storeOriginalBuffer` as `true` when calling `parseBody`. + +For example, if you wanted to [parse GraphQL queries within your server](https://github.com/dukefirehawk/graphql_dart)... + + ```dart + app.get('/graphql', (req, res) async { + if (req.headers.contentType.mimeType == 'application/graphql') { + var graphQlString = String.fromCharCodes(req.originalBuffer); + // ... + } + }); + ``` diff --git a/packages/body_parser/analysis_options.yaml b/packages/body_parser/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/packages/body_parser/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/packages/body_parser/example/main.dart b/packages/body_parser/example/main.dart new file mode 100644 index 0000000..3d63482 --- /dev/null +++ b/packages/body_parser/example/main.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'package:http_parser/http_parser.dart'; +import 'package:belatuk_body_parser/belatuk_body_parser.dart'; + +void main() async { + var address = '127.0.0.1'; + var port = 3000; + var futures = []; + + for (var i = 1; i < Platform.numberOfProcessors; i++) { + futures.add(Isolate.spawn(start, [address, port, i])); + } + + await Future.wait(futures).then((_) { + print('All instances started.'); + print( + 'Test with "wrk -t12 -c400 -d30s -s ./example/post.lua http://localhost:3000" or similar'); + start([address, port, 0]); + }); +} + +void start(List args) { + var address = InternetAddress(args[0] as String); + var port = 8080; + if (args[1] is int) { + args[1]; + } + + var id = 0; + if (args[2] is int) { + args[2]; + } + + HttpServer.bind(address, port, shared: true).then((server) { + server.listen((request) async { + // ignore: deprecated_member_use + var body = await defaultParseBody(request); + request.response + ..headers.contentType = ContentType('application', 'json') + ..write(json.encode(body.body)); + await request.response.close(); + }); + + print( + 'Server #$id listening at http://${server.address.address}:${server.port}'); + }); +} + +Future defaultParseBody(HttpRequest request, + {bool storeOriginalBuffer = false}) { + return parseBodyFromStream( + request, + request.headers.contentType != null + ? MediaType.parse(request.headers.contentType.toString()) + : null, + request.uri, + storeOriginalBuffer: storeOriginalBuffer); +} diff --git a/packages/body_parser/example/post.lua b/packages/body_parser/example/post.lua new file mode 100644 index 0000000..524febc --- /dev/null +++ b/packages/body_parser/example/post.lua @@ -0,0 +1,6 @@ +-- example HTTP POST script which demonstrates setting the +-- HTTP method, body, and adding a header + +wrk.method = "POST" +wrk.body = "foo=bar&baz=quux" +wrk.headers["Content-Type"] = "application/x-www-form-urlencoded" \ No newline at end of file diff --git a/packages/body_parser/lib/belatuk_body_parser.dart b/packages/body_parser/lib/belatuk_body_parser.dart new file mode 100644 index 0000000..05a163e --- /dev/null +++ b/packages/body_parser/lib/belatuk_body_parser.dart @@ -0,0 +1,6 @@ +/// A library for parsing HTTP request bodies and queries. +library angel3_body_parser; + +export 'src/body_parse_result.dart'; +export 'src/file_upload_info.dart'; +export 'src/parse_body.dart'; diff --git a/packages/body_parser/lib/src/body_parse_result.dart b/packages/body_parser/lib/src/body_parse_result.dart new file mode 100644 index 0000000..e47fc89 --- /dev/null +++ b/packages/body_parser/lib/src/body_parse_result.dart @@ -0,0 +1,28 @@ +import 'file_upload_info.dart'; + +/// A representation of data from an incoming request. +abstract class BodyParseResult { + /// The parsed body. + Map get body; + + /// The parsed query string. + Map get query; + + /// All files uploaded within this request. + List get files; + + /// The original body bytes sent with this request. + /// + /// You must set [storeOriginalBuffer] to `true` to see this. + List? get originalBuffer; + + /// If an error was encountered while parsing the body, it will appear here. + /// + /// Otherwise, this is `null`. + dynamic get error; + + /// If an error was encountered while parsing the body, the call stack will appear here. + /// + /// Otherwise, this is `null`. + StackTrace? get stack; +} diff --git a/packages/body_parser/lib/src/chunk.dart b/packages/body_parser/lib/src/chunk.dart new file mode 100644 index 0000000..90c078b --- /dev/null +++ b/packages/body_parser/lib/src/chunk.dart @@ -0,0 +1,7 @@ +import 'file_upload_info.dart'; + +List getFileDataFromChunk( + String chunk, String boundary, String fileUploadName, Map body) { + var result = []; + return result; +} diff --git a/packages/body_parser/lib/src/file_upload_info.dart b/packages/body_parser/lib/src/file_upload_info.dart new file mode 100644 index 0000000..285d72d --- /dev/null +++ b/packages/body_parser/lib/src/file_upload_info.dart @@ -0,0 +1,17 @@ +/// Represents a file uploaded to the server. +class FileUploadInfo { + /// The MIME type of the uploaded file. + String? mimeType; + + /// The name of the file field from the request. + String? name; + + /// The filename of the file. + String? filename; + + /// The bytes that make up this file. + List data; + + FileUploadInfo( + {this.mimeType, this.name, this.filename, this.data = const []}); +} diff --git a/packages/body_parser/lib/src/get_value.dart b/packages/body_parser/lib/src/get_value.dart new file mode 100644 index 0000000..8d2106e --- /dev/null +++ b/packages/body_parser/lib/src/get_value.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; + +dynamic getValue(String value) { + try { + var numValue = num.parse(value); + if (!numValue.isNaN) { + return numValue; + } else { + return value; + } + } on FormatException { + if (value.startsWith('[') && value.endsWith(']')) { + return json.decode(value); + } else if (value.startsWith('{') && value.endsWith('}')) { + return json.decode(value); + } else if (value.trim().toLowerCase() == 'null') { + return null; + } else { + return value; + } + } +} diff --git a/packages/body_parser/lib/src/map_from_uri.dart b/packages/body_parser/lib/src/map_from_uri.dart new file mode 100644 index 0000000..d9ed8e2 --- /dev/null +++ b/packages/body_parser/lib/src/map_from_uri.dart @@ -0,0 +1,44 @@ +import 'get_value.dart'; + +/// Parses a URI-encoded string into real data! **Wow!** +/// +/// Whichever map you provide will be automatically populated from the urlencoded body string you provide. +void buildMapFromUri(Map map, String body) { + var parseArrayRgx = RegExp(r'^(.+)\[\]$'); + + for (var keyValuePair in body.split('&')) { + if (keyValuePair.contains('=')) { + var equals = keyValuePair.indexOf('='); + var key = Uri.decodeQueryComponent(keyValuePair.substring(0, equals)); + var value = Uri.decodeQueryComponent(keyValuePair.substring(equals + 1)); + + if (parseArrayRgx.hasMatch(key)) { + Match queryMatch = parseArrayRgx.firstMatch(key)!; + key = queryMatch.group(1)!; + if (map[key] is! List) { + map[key] = []; + } + + map[key].add(getValue(value)); + } else if (key.contains('.')) { + // i.e. map.foo.bar => [map, foo, bar] + var keys = key.split('.'); + + var targetMap = map[keys[0]] != null ? map[keys[0]] as Map? : {}; + map[keys[0]] = targetMap; + for (var i = 1; i < keys.length; i++) { + if (i < keys.length - 1) { + targetMap![keys[i]] = targetMap[keys[i]] ?? {}; + targetMap = targetMap[keys[i]] as Map?; + } else { + targetMap![keys[i]] = getValue(value); + } + } + } else { + map[key] = getValue(value); + } + } else { + map[Uri.decodeQueryComponent(keyValuePair)] = true; + } + } +} diff --git a/packages/body_parser/lib/src/parse_body.dart b/packages/body_parser/lib/src/parse_body.dart new file mode 100644 index 0000000..cb0588b --- /dev/null +++ b/packages/body_parser/lib/src/parse_body.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http_parser/http_parser.dart'; +import 'package:belatuk_http_server/belatuk_http_server.dart'; +import 'package:mime/mime.dart'; + +import 'body_parse_result.dart'; +import 'file_upload_info.dart'; +import 'map_from_uri.dart'; + +/// Forwards to [parseBodyFromStream]. +@deprecated +Future parseBody(HttpRequest request, + {bool storeOriginalBuffer = false}) { + return parseBodyFromStream( + request, + request.headers.contentType != null + ? MediaType.parse(request.headers.contentType.toString()) + : null, + request.uri, + storeOriginalBuffer: storeOriginalBuffer); +} + +/// Grabs data from an incoming request. +/// +/// Supports URL-encoded and JSON, as well as multipart/* forms. +/// On a file upload request, only fields with the name **'file'** are processed +/// as files. Anything else is put in the body. You can change the upload file name +/// via the *fileUploadName* parameter. :) +/// +/// Use [storeOriginalBuffer] to add the original request bytes to the result. +Future parseBodyFromStream( + Stream data, MediaType? contentType, Uri requestUri, + {bool storeOriginalBuffer = false}) async { + var result = _BodyParseResultImpl(); + + Future getBytes() { + return data + .fold(BytesBuilder(copy: false), (a, b) => a..add(b)) + .then((b) => b.takeBytes()); + } + + Future getBody() { + if (storeOriginalBuffer) { + return getBytes().then((bytes) { + result.originalBuffer = bytes; + return utf8.decode(bytes); + }); + } else { + return utf8.decoder.bind(data).join(); + } + } + + try { + if (contentType != null) { + if (contentType.type == 'multipart' && + contentType.parameters.containsKey('boundary')) { + Stream stream; + + if (storeOriginalBuffer) { + var bytes = result.originalBuffer = await getBytes(); + var ctrl = StreamController()..add(bytes); + await ctrl.close(); + stream = ctrl.stream; + } else { + stream = data; + } + + var parts = + MimeMultipartTransformer(contentType.parameters['boundary']!) + .bind(stream) + .map((part) => + HttpMultipartFormData.parse(part, defaultEncoding: utf8)); + + await for (HttpMultipartFormData part in parts) { + if (part.isBinary || + part.contentDisposition.parameters.containsKey('filename')) { + var builder = await part.fold( + BytesBuilder(copy: false), + (BytesBuilder b, d) => + b..add(d is! String ? (d as List?)! : d.codeUnits)); + var upload = FileUploadInfo( + mimeType: part.contentType!.mimeType, + name: part.contentDisposition.parameters['name'], + filename: + part.contentDisposition.parameters['filename'] ?? 'file', + data: builder.takeBytes()); + result.files.add(upload); + } else if (part.isText) { + var text = await part.join(); + buildMapFromUri(result.body, + '${part.contentDisposition.parameters["name"]}=$text'); + } + } + } else if (contentType.mimeType == 'application/json') { + result.body.addAll( + _foldToStringDynamic(json.decode(await getBody()) as Map?)!); + } else if (contentType.mimeType == 'application/x-www-form-urlencoded') { + var body = await getBody(); + buildMapFromUri(result.body, body); + } else if (storeOriginalBuffer == true) { + result.originalBuffer = await getBytes(); + } + } else { + if (requestUri.hasQuery) { + buildMapFromUri(result.query, requestUri.query); + } + + if (storeOriginalBuffer == true) { + result.originalBuffer = await getBytes(); + } + } + } catch (e, st) { + result.error = e; + result.stack = st; + } + + return result; +} + +class _BodyParseResultImpl implements BodyParseResult { + @override + Map body = {}; + + @override + List files = []; + + @override + List? originalBuffer; + + @override + Map query = {}; + + @override + var error; + + @override + StackTrace? stack; +} + +Map? _foldToStringDynamic(Map? map) { + return map?.keys.fold>( + {}, (out, k) => out..[k.toString()] = map[k]); +} diff --git a/packages/body_parser/pubspec.yaml b/packages/body_parser/pubspec.yaml new file mode 100644 index 0000000..43755b6 --- /dev/null +++ b/packages/body_parser/pubspec.yaml @@ -0,0 +1,14 @@ +name: belatuk_body_parser +version: 3.0.0 +description: Parse request bodies and query strings in Dart. Supports JSON, URL-encoded, and multi-part bodies. +homepage: https://github.com/dart-backend/belatuk-common-utilities/tree/main/packages/body_parser +environment: + sdk: '>=2.12.0 <3.0.0' +dependencies: + http_parser: ^4.0.0 + belatuk_http_server: ^2.0.0 + mime: ^1.0.0 +dev_dependencies: + http: ^0.13.0 + test: ^1.17.8 + lints: ^1.0.0 \ No newline at end of file diff --git a/packages/body_parser/test/form_data_test.dart b/packages/body_parser/test/form_data_test.dart new file mode 100644 index 0000000..87fb4bc --- /dev/null +++ b/packages/body_parser/test/form_data_test.dart @@ -0,0 +1,156 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:belatuk_body_parser/belatuk_body_parser.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:test/test.dart'; +import 'server_test.dart'; + +Future _parseBody(HttpRequest request) { + return parseBodyFromStream( + request, + request.headers.contentType != null + ? MediaType.parse(request.headers.contentType.toString()) + : null, + request.uri, + storeOriginalBuffer: false); +} + +void main() { + HttpServer? server; + String? url; + http.Client? client; + + setUp(() async { + server = await HttpServer.bind('127.0.0.1', 0); + server!.listen((HttpRequest request) async { + //Server will simply return a JSON representation of the parsed body + // ignore: deprecated_member_use + request.response.write(jsonEncodeBody(await _parseBody(request))); + await request.response.close(); + }); + url = 'http://localhost:${server!.port}'; + print('Test server listening on $url'); + client = http.Client(); + }); + + tearDown(() async { + await server!.close(force: true); + client!.close(); + server = null; + url = null; + client = null; + }); + + test('No upload', () async { + var boundary = 'myBoundary'; + var headers = { + 'content-type': 'multipart/form-data; boundary=$boundary' + }; + var postData = ''' +--$boundary +Content-Disposition: form-data; name="hello" + +world +--$boundary-- +''' + .replaceAll('\n', '\r\n'); + + print( + 'Form Data: \n${postData.replaceAll("\r", "\\r").replaceAll("\n", "\\n")}'); + var response = + await client!.post(Uri.parse(url!), headers: headers, body: postData); + print('Response: ${response.body}'); + var jsons = json.decode(response.body); + var files = jsons['files'].map((map) { + return map == null + ? null + : map.keys.fold>( + {}, (out, k) => out..[k.toString()] = map[k]); + }); + expect(files.length, equals(0)); + expect(jsons['body']['hello'], equals('world')); + }); + + test('Single upload', () async { + var boundary = 'myBoundary'; + var headers = { + 'content-type': ContentType('multipart', 'form-data', + parameters: {'boundary': boundary}).toString() + }; + var postData = ''' +--$boundary +Content-Disposition: form-data; name="hello" + +world +--$boundary +Content-Disposition: form-data; name="file"; filename="app.dart" +Content-Type: application/dart + +Hello world +--$boundary-- +''' + .replaceAll('\n', '\r\n'); + + print( + 'Form Data: \n${postData.replaceAll("\r", "\\r").replaceAll("\n", "\\n")}'); + var response = + await client!.post(Uri.parse(url!), headers: headers, body: postData); + print('Response: ${response.body}'); + var jsons = json.decode(response.body); + var files = jsons['files']; + expect(files.length, equals(1)); + expect(files[0]['name'], equals('file')); + expect(files[0]['mimeType'], equals('application/dart')); + expect(files[0]['data'].length, equals(11)); + expect(files[0]['filename'], equals('app.dart')); + expect(jsons['body']['hello'], equals('world')); + }); + + test('Multiple upload', () async { + var boundary = 'myBoundary'; + var headers = { + 'content-type': 'multipart/form-data; boundary=$boundary' + }; + var postData = ''' +--$boundary +Content-Disposition: form-data; name="json" + +god +--$boundary +Content-Disposition: form-data; name="num" + +14.50000 +--$boundary +Content-Disposition: form-data; name="file"; filename="app.dart" +Content-Type: text/plain + +Hello world +--$boundary +Content-Disposition: form-data; name="entry-point"; filename="main.js" +Content-Type: text/javascript + +function main() { + console.log("Hello, world!"); +} +--$boundary-- +''' + .replaceAll('\n', '\r\n'); + + print( + 'Form Data: \n${postData.replaceAll("\r", "\\r").replaceAll("\n", "\\n")}'); + var response = + await client!.post(Uri.parse(url!), headers: headers, body: postData); + print('Response: ${response.body}'); + var jsons = json.decode(response.body); + var files = jsons['files']; + expect(files.length, equals(2)); + expect(files[0]['name'], equals('file')); + expect(files[0]['mimeType'], equals('text/plain')); + expect(files[0]['data'].length, equals(11)); + expect(files[1]['name'], equals('entry-point')); + expect(files[1]['mimeType'], equals('text/javascript')); + expect(jsons['body']['json'], equals('god')); + expect(jsons['body']['num'], equals(14.5)); + }); +} diff --git a/packages/body_parser/test/server_test.dart b/packages/body_parser/test/server_test.dart new file mode 100644 index 0000000..ee0048b --- /dev/null +++ b/packages/body_parser/test/server_test.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io' show HttpRequest, HttpServer; + +import 'package:belatuk_body_parser/belatuk_body_parser.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:test/test.dart'; + +const TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiIxMjcuMC4wLjEiLCJleHAiOi0xLCJpYXQiOiIyMDE2LTEyLTIyVDEyOjQ5OjUwLjM2MTQ0NiIsImlzcyI6ImFuZ2VsX2F1dGgiLCJzdWIiOiIxMDY2OTQ4Mzk2MDIwMjg5ODM2NTYifQ==.PYw7yUb-cFWD7N0sSLztP7eeRvO44nu1J2OgDNyT060='; + +String jsonEncodeBody(BodyParseResult result) { + return json.encode({ + 'query': result.query, + 'body': result.body, + 'error': result.error?.toString(), + 'files': result.files.map((f) { + return { + 'name': f.name, + 'mimeType': f.mimeType, + 'filename': f.filename, + 'data': f.data, + }; + }).toList(), + 'originalBuffer': result.originalBuffer, + 'stack': null, //result.stack.toString(), + }); +} + +Future _parseBody(HttpRequest request) { + return parseBodyFromStream( + request, + request.headers.contentType != null + ? MediaType.parse(request.headers.contentType.toString()) + : null, + request.uri, + storeOriginalBuffer: true); +} + +void main() { + HttpServer? server; + String? url; + http.Client? client; + + setUp(() async { + server = await HttpServer.bind('127.0.0.1', 0); + server!.listen((HttpRequest request) async { + //Server will simply return a JSON representation of the parsed body + request.response.write( + // ignore: deprecated_member_use + jsonEncodeBody(await _parseBody(request))); + await request.response.close(); + }); + url = 'http://localhost:${server!.port}'; + print('Test server listening on $url'); + client = http.Client(); + }); + tearDown(() async { + await server!.close(force: true); + client!.close(); + server = null; + url = null; + client = null; + }); + + group('query string', () { + test('GET Simple', () async { + print('GET $url/?hello=world'); + var response = await client!.get(Uri.parse('$url/?hello=world')); + print('Response: ${response.body}'); + var result = json.decode(response.body); + expect(result['body'], equals({})); + expect(result['query'], equals({'hello': 'world'})); + expect(result['files'], equals([])); + //expect(result['originalBuffer'], isNull); + }); + + test('GET Complex', () async { + var postData = + 'hello=world&nums%5B%5D=1&nums%5B%5D=2.0&nums%5B%5D=${3 - 1}&map.foo.bar=baz'; + print('Body: $postData'); + var response = await client!.get(Uri.parse('$url/?$postData')); + print('Response: ${response.body}'); + var query = json.decode(response.body)['query']; + expect(query['hello'], equals('world')); + expect(query['nums'][2], equals(2)); + expect(query['map'] is Map, equals(true)); + expect(query['map']['foo'], equals({'bar': 'baz'})); + }); + + test('JWT', () async { + var postData = 'token=$TOKEN'; + print('Body: $postData'); + var response = await client!.get(Uri.parse('$url/?$postData')); + print('Response: ${response.body}'); + var query = json.decode(response.body)['query']; + expect(query['token'], equals(TOKEN)); + }); + }); + + group('urlencoded', () { + var headers = { + 'content-type': 'application/x-www-form-urlencoded' + }; + test('POST Simple', () async { + print('Body: hello=world'); + var response = await client! + .post(Uri.parse(url!), headers: headers, body: 'hello=world'); + print('Response: ${response.body}'); + var result = json.decode(response.body); + expect(result['query'], equals({})); + expect(result['body'], equals({'hello': 'world'})); + expect(result['files'], equals([])); + expect(result['originalBuffer'], isList); + expect(result['originalBuffer'], isNotEmpty); + }); + + test('Post Complex', () async { + var postData = + 'hello=world&nums%5B%5D=1&nums%5B%5D=2.0&nums%5B%5D=${3 - 1}&map.foo.bar=baz'; + var response = + await client!.post(Uri.parse(url!), headers: headers, body: postData); + print('Response: ${response.body}'); + var body = json.decode(response.body)['body']; + expect(body['hello'], equals('world')); + expect(body['nums'][2], equals(2)); + expect(body['map'] is Map, equals(true)); + expect(body['map']['foo'], equals({'bar': 'baz'})); + }); + + test('JWT', () async { + var postData = 'token=$TOKEN'; + var response = + await client!.post(Uri.parse(url!), headers: headers, body: postData); + var body = json.decode(response.body)['body']; + expect(body['token'], equals(TOKEN)); + }); + }); + + group('json', () { + var headers = {'content-type': 'application/json'}; + test('Post Simple', () async { + var postData = json.encode({'hello': 'world'}); + print('Body: $postData'); + var response = + await client!.post(Uri.parse(url!), headers: headers, body: postData); + print('Response: ${response.body}'); + var result = json.decode(response.body); + expect(result['body'], equals({'hello': 'world'})); + expect(result['query'], equals({})); + expect(result['files'], equals([])); + expect(result['originalBuffer'], allOf(isList, isNotEmpty)); + }); + + test('Post Complex', () async { + var postData = json.encode({ + 'hello': 'world', + 'nums': [1, 2.0, 3 - 1], + 'map': { + 'foo': {'bar': 'baz'} + } + }); + print('Body: $postData'); + var response = + await client!.post(Uri.parse(url!), headers: headers, body: postData); + print('Response: ${response.body}'); + var body = json.decode(response.body)['body']; + expect(body['hello'], equals('world')); + expect(body['nums'][2], equals(2)); + expect(body['map'] is Map, equals(true)); + expect(body['map']['foo'], equals({'bar': 'baz'})); + }); + }); +} diff --git a/packages/code_buffer/README.md b/packages/code_buffer/README.md index b779eab..06ae31a 100644 --- a/packages/code_buffer/README.md +++ b/packages/code_buffer/README.md @@ -1,6 +1,6 @@ # Belatuk Code Buffer -[![version](https://img.shields.io/badge/pub-v3.0.1-brightgreen)](https://pub.dartlang.org/packages/belatuk_code_buffer) +[![version](https://img.shields.io/badge/pub-v3.0.1-brightgreen)](https://pub.dev/packages/belatuk_code_buffer) [![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) [![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/code_buffer/LICENSE) diff --git a/packages/combinator/README.md b/packages/combinator/README.md index f1e543c..05fabd4 100644 --- a/packages/combinator/README.md +++ b/packages/combinator/README.md @@ -1,6 +1,6 @@ # Belatuk Combinator -[![version](https://img.shields.io/badge/pub-v3.0.0-brightgreen)](https://pub.dartlang.org/packages/belatuk_combinator) +[![version](https://img.shields.io/badge/pub-v3.0.0-brightgreen)](https://pub.dev/packages/belatuk_combinator) [![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) [![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/combinator/LICENSE) diff --git a/packages/html_builder/README.md b/packages/html_builder/README.md index 9d930a4..9f9a24a 100644 --- a/packages/html_builder/README.md +++ b/packages/html_builder/README.md @@ -1,6 +1,6 @@ # Betaluk Html Builder -[![version](https://img.shields.io/badge/pub-v3.0.1-brightgreen)](https://pub.dartlang.org/packages/belatuk_html_builder) +[![version](https://img.shields.io/badge/pub-v3.0.1-brightgreen)](https://pub.dev/packages/belatuk_html_builder) [![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) [![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/html_builder/LICENSE) diff --git a/packages/merge_map/README.md b/packages/merge_map/README.md index cf62b85..a12469b 100644 --- a/packages/merge_map/README.md +++ b/packages/merge_map/README.md @@ -1,6 +1,6 @@ # Belatuk Merge Map -[![version](https://img.shields.io/badge/pub-v3.0.1-brightgreen)](https://pub.dartlang.org/packages/belatuk_merge_map) +[![version](https://img.shields.io/badge/pub-v3.0.1-brightgreen)](https://pub.dev/packages/belatuk_merge_map) [![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) [![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/merge_map/LICENSE) diff --git a/packages/pub_sub/README.md b/packages/pub_sub/README.md index 22bb36b..abdd7c8 100644 --- a/packages/pub_sub/README.md +++ b/packages/pub_sub/README.md @@ -1,6 +1,6 @@ # Belatuk Pub Sub -[![version](https://img.shields.io/badge/pub-v4.0.2-brightgreen)](https://pub.dartlang.org/packages/belatuk_pub_sub) +[![version](https://img.shields.io/badge/pub-v4.0.2-brightgreen)](https://pub.dev/packages/belatuk_pub_sub) [![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) [![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/pub_sub/LICENSE) diff --git a/packages/range_header/AUTHORS.md b/packages/range_header/AUTHORS.md new file mode 100644 index 0000000..ac95ab5 --- /dev/null +++ b/packages/range_header/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/range_header/CHANGELOG.md b/packages/range_header/CHANGELOG.md new file mode 100644 index 0000000..ee096c7 --- /dev/null +++ b/packages/range_header/CHANGELOG.md @@ -0,0 +1,34 @@ +# Change Log + +## 4.0.0 + +* Upgraded from `pendantic` to `lints` linter +* Published as `belatuk_range_header` package + +## 3.0.2 + +* Updated README + +## 3.0.1 + +* Resolve static analysis warnings + +## 3.0.0 + +* Migrated to work with Dart SDK 2.12.x NNBD + +## 2.0.2 + +* Fix bug in `toContentRange` that printed invalid indices. +* Fold header items by default. + +## 2.0.1 + +* Adjust `RangeHeaderTransformer` to properly print the content range of each item, +when multiple are present. + +## 2.0.0 + +* Dart 2 update. +* Add `RangeHeaderTransformer`. +* Overall restructuring/refactoring. diff --git a/packages/range_header/LICENSE b/packages/range_header/LICENSE new file mode 100644 index 0000000..e37a346 --- /dev/null +++ b/packages/range_header/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/range_header/README.md b/packages/range_header/README.md new file mode 100644 index 0000000..066bd08 --- /dev/null +++ b/packages/range_header/README.md @@ -0,0 +1,40 @@ +# Belatuk Range Header + +[![version](https://img.shields.io/badge/pub-v3.0.0-brightgreen)](https://pub.dev/packages/belatuk_range_header) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/range_header/LICENSE) + +**Replacement of `package:range_header` with breaking changes to support NNBD.** + +Range header parser for belatuk. Can be used by any dart backend. + +## Installation + +In your `pubspec.yaml`: + +```yaml +dependencies: + belatuk_range_header: ^4.0.0 +``` + +## Usage + +```dart +handleRequest(HttpRequest request) async { + // Parse the header + var header = RangeHeader.parse(request.headers.value(HttpHeaders.rangeHeader)); + + // Optimize/canonicalize it + var items = RangeHeader.foldItems(header.items); + header = RangeHeader(items); + + // Get info + header.items; + header.rangeUnit; + print(header.items[0].toContentRange(fileSize)); + + // Serve the file + var transformer = RangeHeaderTransformer(header); + await file.openRead().transform(transformer).pipe(request.response); +} +``` diff --git a/packages/range_header/analysis_options.yaml b/packages/range_header/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/packages/range_header/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/packages/range_header/example/main.dart b/packages/range_header/example/main.dart new file mode 100644 index 0000000..b58239a --- /dev/null +++ b/packages/range_header/example/main.dart @@ -0,0 +1,28 @@ +import 'dart:io'; +import 'package:angel3_range_header/angel3_range_header.dart'; + +var file = File('some_video.mp4'); + +void handleRequest(HttpRequest request) async { + // Parse the header + var header = + RangeHeader.parse(request.headers.value(HttpHeaders.rangeHeader)!); + + // Optimize/canonicalize it + var items = RangeHeader.foldItems(header.items); + header = RangeHeader(items); + + // Get info + header.items; + header.rangeUnit; + header.items.forEach((item) => item.toContentRange(400)); + + // Serve the file + var transformer = + RangeHeaderTransformer(header, 'video/mp4', await file.length()); + await file + .openRead() + .cast>() + .transform(transformer) + .pipe(request.response); +} diff --git a/packages/range_header/example/server.dart b/packages/range_header/example/server.dart new file mode 100644 index 0000000..076acd8 --- /dev/null +++ b/packages/range_header/example/server.dart @@ -0,0 +1,103 @@ +//import 'package:angel_framework/angel_framework.dart'; +//import 'package:angel_framework/http.dart'; +//import 'package:angel_static/angel_static.dart'; + +void main() async { + /* + var app = new Angel(); + var http = new AngelHttp(app); + var fs = const LocalFileSystem(); + var vDir = new _RangingVirtualDirectory(app, fs.currentDirectory); + app.logger = new Logger('range_header') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + app.mimeTypeResolver + ..addExtension('dart', 'text/dart') + ..addExtension('lock', 'text/plain') + ..addExtension('md', 'text/plain') + ..addExtension('packages', 'text/plain') + ..addExtension('yaml', 'text/plain') + ..addExtension('yml', 'text/plain'); + app.fallback(vDir.handleRequest); + app.fallback((req, res) => throw new AngelHttpException.notFound()); + await http.startServer('127.0.0.1', 3000); + print('Listening at ${http.uri}'); + */ +} +/* +class _RangingVirtualDirectory extends VirtualDirectory { + _RangingVirtualDirectory(Angel app, Directory source) + : super(app, source.fileSystem, + source: source, allowDirectoryListing: true); + + @override + Future serveFile( + File file, FileStat stat, RequestContext req, ResponseContext res) async { + res.headers[HttpHeaders.acceptRangesHeader] = 'bytes'; + + if (req.headers.value(HttpHeaders.rangeHeader)?.startsWith('bytes') == + true) { + var header = + new RangeHeader.parse(req.headers.value(HttpHeaders.rangeHeader)); + header = new RangeHeader(RangeHeader.foldItems(header.items)); + + if (header.items.length == 1) { + var item = header.items[0]; + Stream stream; + int len = 0, total = await file.length(); + + if (item.start == -1) { + if (item.end == -1) { + len = total; + stream = file.openRead(); + } else { + len = item.end + 1; + stream = file.openRead(0, item.end + 1); + } + } else { + if (item.end == -1) { + len = total - item.start; + stream = file.openRead(item.start); + } else { + len = item.end - item.start + 1; + stream = file.openRead(item.start, item.end + 1); + } + } + + res.contentType = new MediaType.parse( + app.mimeTypeResolver.lookup(file.path) ?? + 'application/octet-stream'); + res.statusCode = HttpStatus.partialContent; + res.headers[HttpHeaders.contentLengthHeader] = len.toString(); + res.headers[HttpHeaders.contentRangeHeader] = + 'bytes ' + item.toContentRange(total); + await stream.cast>().pipe(res); + return false; + } else { + var totalFileSize = await file.length(); + var transformer = new RangeHeaderTransformer( + header, + app.mimeTypeResolver.lookup(file.path) ?? + 'application/octet-stream', + await file.length()); + res.statusCode = HttpStatus.partialContent; + res.headers[HttpHeaders.contentLengthHeader] = + transformer.computeContentLength(totalFileSize).toString(); + res.contentType = new MediaType( + 'multipart', 'byteranges', {'boundary': transformer.boundary}); + await file + .openRead() + .cast>() + .transform(transformer) + .pipe(res); + return false; + } + } else { + return await super.serveFile(file, stat, req, res); + } + } +} +*/ diff --git a/packages/range_header/lib/angel3_range_header.dart b/packages/range_header/lib/angel3_range_header.dart new file mode 100644 index 0000000..ea17d35 --- /dev/null +++ b/packages/range_header/lib/angel3_range_header.dart @@ -0,0 +1,4 @@ +export 'src/converter.dart'; +export 'src/exception.dart'; +export 'src/range_header.dart'; +export 'src/range_header_item.dart'; diff --git a/packages/range_header/lib/src/converter.dart b/packages/range_header/lib/src/converter.dart new file mode 100644 index 0000000..d5c5645 --- /dev/null +++ b/packages/range_header/lib/src/converter.dart @@ -0,0 +1,171 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io' show BytesBuilder; +import 'dart:math'; +import 'package:async/async.dart'; +import 'package:charcode/ascii.dart'; +import 'range_header.dart'; + +/// A [StreamTransformer] that uses a parsed [RangeHeader] and transforms an input stream +/// into one compatible with the `multipart/byte-ranges` specification. +class RangeHeaderTransformer + extends StreamTransformerBase, List> { + final RangeHeader header; + final String boundary, mimeType; + final int totalLength; + + RangeHeaderTransformer(this.header, this.mimeType, this.totalLength, + {String? boundary}) + : boundary = boundary ?? _randomString() { + if (header.items.isEmpty) { + throw ArgumentError('`header` cannot be null or empty.'); + } + } + + /// Computes the content length that will be written to a response, given a stream of the given [totalFileSize]. + int computeContentLength(int totalFileSize) { + var len = 0; + + for (var item in header.items) { + if (item.start == -1) { + if (item.end == -1) { + len += totalFileSize; + } else { + //len += item.end + 1; + len += item.end + 1; + } + } else if (item.end == -1) { + len += totalFileSize - item.start; + //len += totalFileSize - item.start - 1; + } else { + len += item.end - item.start; + } + + // Take into consideration the fact that delimiters are written. + len += utf8.encode('--$boundary\r\n').length; + len += utf8.encode('Content-Type: $mimeType\r\n').length; + len += utf8 + .encode( + 'Content-Range: ${header.rangeUnit} ${item.toContentRange(totalLength)}/$totalLength\r\n\r\n') + .length; + len += 2; // CRLF + } + + len += utf8.encode('--$boundary--\r\n').length; + + return len; + } + + @override + Stream> bind(Stream> stream) { + var ctrl = StreamController>(); + + Future(() async { + var index = 0; + var enqueued = Queue>(); + var q = StreamQueue(stream); + + Future> absorb(int length) async { + var out = BytesBuilder(); + + while (out.length < length) { + var remaining = length - out.length; + + while (out.length < length && enqueued.isNotEmpty) { + remaining = length - out.length; + var blob = enqueued.removeFirst(); + + if (blob.length > remaining) { + enqueued.addFirst(blob.skip(remaining).toList()); + blob = blob.take(remaining).toList(); + } + + out.add(blob); + index += blob.length; + } + + if (out.length < length && await q.hasNext) { + var blob = await q.next; + remaining = length - out.length; + + if (blob.length > remaining) { + enqueued.addFirst(blob.skip(remaining).toList()); + blob = blob.take(remaining).toList(); + } + + out.add(blob); + index += blob.length; + } + + // If we get this far, and the stream is EMPTY, the user requested + // too many bytes. + if (out.length < length && enqueued.isEmpty && !(await q.hasNext)) { + throw StateError( + 'The range denoted is bigger than the size of the input stream.'); + } + } + + return out.takeBytes(); + } + + for (var item in header.items) { + var chunk = BytesBuilder(); + + // Skip until we reach the start index. + while (index < item.start) { + var remaining = item.start - index; + await absorb(remaining); + } + + // Next, absorb until we reach the end. + if (item.end == -1) { + while (enqueued.isNotEmpty) { + chunk.add(enqueued.removeFirst()); + } + while (await q.hasNext) { + chunk.add(await q.next); + } + } else { + var remaining = item.end - index; + chunk.add(await absorb(remaining)); + } + + // Next, write the boundary and data. + ctrl.add(utf8.encode('--$boundary\r\n')); + ctrl.add(utf8.encode('Content-Type: $mimeType\r\n')); + ctrl.add(utf8.encode( + 'Content-Range: ${header.rangeUnit} ${item.toContentRange(totalLength)}/$totalLength\r\n\r\n')); + ctrl.add(chunk.takeBytes()); + ctrl.add(const [$cr, $lf]); + + // If this range was unbounded, don't bother looping any further. + if (item.end == -1) break; + } + + ctrl.add(utf8.encode('--$boundary--\r\n')); + + await ctrl.close(); + }).catchError((e) { + ctrl.addError(e as Object); + return null; + }); + + return ctrl.stream; + } +} + +var _rnd = Random(); +String _randomString( + {int length = 32, + String validChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'}) { + var len = _rnd.nextInt((length - 10)) + 10; + var buf = StringBuffer(); + + while (buf.length < len) { + buf.writeCharCode(validChars.codeUnitAt(_rnd.nextInt(validChars.length))); + } + + return buf.toString(); +} diff --git a/packages/range_header/lib/src/exception.dart b/packages/range_header/lib/src/exception.dart new file mode 100644 index 0000000..c6b3a7b --- /dev/null +++ b/packages/range_header/lib/src/exception.dart @@ -0,0 +1,18 @@ +class RangeHeaderParseException extends FormatException { + @override + final String message; + + RangeHeaderParseException(this.message); + + @override + String toString() => 'Range header parse exception: $message'; +} + +class InvalidRangeHeaderException implements Exception { + final String message; + + InvalidRangeHeaderException(this.message); + + @override + String toString() => 'Range header parse exception: $message'; +} diff --git a/packages/range_header/lib/src/parser.dart b/packages/range_header/lib/src/parser.dart new file mode 100644 index 0000000..ecc365c --- /dev/null +++ b/packages/range_header/lib/src/parser.dart @@ -0,0 +1,152 @@ +import 'package:charcode/charcode.dart'; +import 'package:source_span/source_span.dart'; +import 'package:string_scanner/string_scanner.dart'; +import 'exception.dart'; +import 'range_header.dart'; +import 'range_header_impl.dart'; +import 'range_header_item.dart'; + +final RegExp _rgxInt = RegExp(r'[0-9]+'); +final RegExp _rgxWs = RegExp(r'[ \n\r\t]'); + +enum TokenType { RANGE_UNIT, COMMA, INT, DASH, EQUALS } + +class Token { + final TokenType type; + final SourceSpan? span; + + Token(this.type, this.span); +} + +List scan(String text, List allowedRangeUnits) { + var tokens = []; + var scanner = SpanScanner(text); + + while (!scanner.isDone) { + // Skip whitespace + scanner.scan(_rgxWs); + + if (scanner.scanChar($comma)) { + tokens.add(Token(TokenType.COMMA, scanner.lastSpan)); + } else if (scanner.scanChar($dash)) { + tokens.add(Token(TokenType.DASH, scanner.lastSpan)); + } else if (scanner.scan(_rgxInt)) { + tokens.add(Token(TokenType.INT, scanner.lastSpan)); + } else if (scanner.scanChar($equal)) { + tokens.add(Token(TokenType.EQUALS, scanner.lastSpan)); + } else { + var matched = false; + + for (var unit in allowedRangeUnits) { + if (scanner.scan(unit)) { + tokens.add(Token(TokenType.RANGE_UNIT, scanner.lastSpan)); + matched = true; + break; + } + } + + if (!matched) { + var ch = scanner.readChar(); + throw RangeHeaderParseException( + 'Unexpected character: "${String.fromCharCode(ch)}"'); + } + } + } + + return tokens; +} + +class Parser { + Token? _current; + int _index = -1; + final List tokens; + + Parser(this.tokens); + + Token? get current => _current; + + bool get done => _index >= tokens.length - 1; + + RangeHeaderParseException _expected(String type) { + var offset = current?.span?.start.offset; + + if (offset == null) return RangeHeaderParseException('Expected $type.'); + + Token? peek; + + if (_index < tokens.length - 1) peek = tokens[_index + 1]; + + if (peek != null && peek.span != null) { + return RangeHeaderParseException( + 'Expected $type at offset $offset, found "${peek.span!.text}" instead. \nSource:\n${peek.span?.highlight() ?? peek.type}'); + } else { + return RangeHeaderParseException( + 'Expected $type at offset $offset, but the header string ended without one.\nSource:\n${current!.span?.highlight() ?? current!.type}'); + } + } + + bool next(TokenType type) { + if (done) return false; + var tok = tokens[_index + 1]; + if (tok.type == type) { + _index++; + _current = tok; + return true; + } else { + return false; + } + } + + RangeHeader? parseRangeHeader() { + if (next(TokenType.RANGE_UNIT)) { + var unit = current!.span!.text; + next(TokenType.EQUALS); // Consume =, if any. + + var items = []; + var item = parseHeaderItem(); + + while (item != null) { + items.add(item); + // Parse comma + if (next(TokenType.COMMA)) { + item = parseHeaderItem(); + } else { + item = null; + } + } + + if (items.isEmpty) { + throw _expected('range'); + } else { + return RangeHeaderImpl(unit, items); + } + } else { + return null; + } + } + + RangeHeaderItem? parseHeaderItem() { + if (next(TokenType.INT)) { + // i.e 500-544, or 600- + var start = int.parse(current!.span!.text); + if (next(TokenType.DASH)) { + if (next(TokenType.INT)) { + return RangeHeaderItem(start, int.parse(current!.span!.text)); + } else { + return RangeHeaderItem(start); + } + } else { + throw _expected('"-"'); + } + } else if (next(TokenType.DASH)) { + // i.e. -599 + if (next(TokenType.INT)) { + return RangeHeaderItem(-1, int.parse(current!.span!.text)); + } else { + throw _expected('integer'); + } + } else { + return null; + } + } +} diff --git a/packages/range_header/lib/src/range_header.dart b/packages/range_header/lib/src/range_header.dart new file mode 100644 index 0000000..ee0fa4d --- /dev/null +++ b/packages/range_header/lib/src/range_header.dart @@ -0,0 +1,68 @@ +import 'dart:collection'; +import 'exception.dart'; +import 'parser.dart'; +import 'range_header_item.dart'; +import 'range_header_impl.dart'; + +/// Represents the contents of a parsed `Range` header. +abstract class RangeHeader { + /// Returns an immutable list of the ranges that were parsed. + UnmodifiableListView get items; + + const factory RangeHeader(Iterable items, + {String? rangeUnit}) = _ConstantRangeHeader; + + /// Eliminates any overlapping [items], sorts them, and folds them all into the most efficient representation possible. + static UnmodifiableListView foldItems( + Iterable items) { + var out = {}; + + for (var item in items) { + // Remove any overlapping items, consolidate them. + while (out.any((x) => x.overlaps(item))) { + var f = out.firstWhere((x) => x.overlaps(item)); + out.remove(f); + item = item.consolidate(f); + } + + out.add(item); + } + + return UnmodifiableListView(out.toList()..sort()); + } + + /// Attempts to parse a [RangeHeader] from its [text] representation. + /// + /// You can optionally pass a custom list of [allowedRangeUnits]. + /// The default is `['bytes']`. + /// + /// If [fold] is `true`, the items will be folded into the most compact + /// possible representation. + /// + factory RangeHeader.parse(String text, + {Iterable? allowedRangeUnits, bool fold = true}) { + var tokens = scan(text, allowedRangeUnits?.toList() ?? ['bytes']); + var parser = Parser(tokens); + var header = parser.parseRangeHeader(); + if (header == null) { + throw InvalidRangeHeaderException('Header is null'); + } + var items = foldItems(header.items); + return RangeHeaderImpl(header.rangeUnit, items); + } + + /// Returns this header's range unit. Most commonly, this is `bytes`. + String? get rangeUnit; +} + +class _ConstantRangeHeader implements RangeHeader { + final Iterable items_; + @override + final String? rangeUnit; + + const _ConstantRangeHeader(this.items_, {this.rangeUnit = 'bytes'}); + + @override + UnmodifiableListView get items => + UnmodifiableListView(items_); +} diff --git a/packages/range_header/lib/src/range_header_impl.dart b/packages/range_header/lib/src/range_header_impl.dart new file mode 100644 index 0000000..865d387 --- /dev/null +++ b/packages/range_header/lib/src/range_header_impl.dart @@ -0,0 +1,20 @@ +import 'dart:collection'; +import 'range_header.dart'; +import 'range_header_item.dart'; + +/// Represents the contents of a parsed `Range` header. +class RangeHeaderImpl implements RangeHeader { + UnmodifiableListView? _cached; + final List _items = []; + + RangeHeaderImpl(this.rangeUnit, [List items = const []]) { + _items.addAll(items); + } + + @override + UnmodifiableListView get items => + _cached ??= UnmodifiableListView(_items); + + @override + final String? rangeUnit; +} diff --git a/packages/range_header/lib/src/range_header_item.dart b/packages/range_header/lib/src/range_header_item.dart new file mode 100644 index 0000000..f8e7170 --- /dev/null +++ b/packages/range_header/lib/src/range_header_item.dart @@ -0,0 +1,93 @@ +import 'dart:math'; + +import 'package:quiver/core.dart'; + +/// Represents an individual range, with an optional start index and optional end index. +class RangeHeaderItem implements Comparable { + /// The index at which this chunk begins. May be `-1`. + final int start; + + /// The index at which this chunk ends. May be `-1`. + final int end; + + const RangeHeaderItem([this.start = -1, this.end = -1]); + + /// Joins two items together into the largest possible range. + RangeHeaderItem consolidate(RangeHeaderItem other) { + if (!(other.overlaps(this))) { + throw ArgumentError('The two ranges do not overlap.'); + } + return RangeHeaderItem(min(start, other.start), max(end, other.end)); + } + + @override + int get hashCode => hash2(start, end); + + @override + bool operator ==(other) => + other is RangeHeaderItem && other.start == start && other.end == end; + + bool overlaps(RangeHeaderItem other) { + if (other.start <= start) { + return other.end < start; + } else if (other.start > start) { + return other.start <= end; + } + return false; + } + + @override + int compareTo(RangeHeaderItem other) { + if (other.start > start) { + return -1; + } else if (other.start == start) { + if (other.end == end) { + return 0; + } else if (other.end < end) { + return 1; + } else { + return -1; + } + } else if (other.start < start) { + return 1; + } else { + return -1; + } + } + + @override + String toString() { + if (start > -1 && end > -1) { + return '$start-$end'; + } else if (start > -1) { + return '$start-'; + } else { + return '-$end'; + } + } + + /// Creates a representation of this instance suitable for a `Content-Range` header. + /// + /// This can only be used if the user request only one range. If not, send a + /// `multipart/byteranges` response. + /// + /// Please adhere to the standard!!! + /// http://httpwg.org/specs/rfc7233.html + + String toContentRange([int? totalSize]) { + // var maxIndex = totalSize != null ? (totalSize - 1).toString() : '*'; + var s = start > -1 ? start : 0; + + if (end == -1) { + if (totalSize == null) { + throw UnsupportedError( + 'If the end of this range is unknown, `totalSize` must not be null.'); + } else { + // if (end == totalSize - 1) { + return '$s-${totalSize - 1}/$totalSize'; + } + } + + return '$s-$end/$totalSize'; + } +} diff --git a/packages/range_header/pubspec.yaml b/packages/range_header/pubspec.yaml new file mode 100644 index 0000000..25798b5 --- /dev/null +++ b/packages/range_header/pubspec.yaml @@ -0,0 +1,18 @@ +name: belatuk_range_header +version: 4.0.0 +description: Range header parser for Dart. Beyond parsing, a stream transformer is included. +homepage: https://github.com/dart-backend/belatuk-common-utilities/tree/main/packages/range_header +environment: + sdk: '>=2.12.0 <3.0.0' +dependencies: + async: ^2.6.0 + charcode: ^1.2.0 + quiver: ^3.0.1 + source_span: ^1.8.1 + string_scanner: ^1.1.0 +dev_dependencies: + file: ^6.1.0 + http_parser: ^4.0.0 + logging: ^1.0.1 + test: ^1.17.8 + lints: ^1.0.0 diff --git a/packages/range_header/test/all_test.dart b/packages/range_header/test/all_test.dart new file mode 100644 index 0000000..93c4e9f --- /dev/null +++ b/packages/range_header/test/all_test.dart @@ -0,0 +1,95 @@ +import 'package:angel3_range_header/angel3_range_header.dart'; +import 'package:test/test.dart'; + +final Matcher throwsRangeParseException = + throwsA(const TypeMatcher()); + +final Matcher throwsInvalidRangeHeaderException = + throwsA(const TypeMatcher()); + +void main() { + group('one item', () { + test('start and end', () { + var r = RangeHeader.parse('bytes 1-200'); + expect(r.items, hasLength(1)); + expect(r.items.first.start, 1); + expect(r.items.first.end, 200); + }); + + test('start only', () { + var r = RangeHeader.parse('bytes 1-'); + expect(r.items, hasLength(1)); + expect(r.items.first.start, 1); + expect(r.items.first.end, -1); + }); + + test('end only', () { + var r = RangeHeader.parse('bytes -200'); + print(r.items); + expect(r.items, hasLength(1)); + expect(r.items.first.start, -1); + expect(r.items.first.end, 200); + }); + }); + + group('multiple items', () { + test('three items', () { + var r = RangeHeader.parse('bytes 1-20, 21-40, 41-60'); + print(r.items); + expect(r.items, hasLength(3)); + expect(r.items[0].start, 1); + expect(r.items[0].end, 20); + expect(r.items[1].start, 21); + expect(r.items[1].end, 40); + expect(r.items[2].start, 41); + expect(r.items[2].end, 60); + }); + + test('one item without end', () { + var r = RangeHeader.parse('bytes 1-20, 21-'); + print(r.items); + expect(r.items, hasLength(2)); + expect(r.items[0].start, 1); + expect(r.items[0].end, 20); + expect(r.items[1].start, 21); + expect(r.items[1].end, -1); + }); + }); + + group('failures', () { + test('no start with no end', () { + expect(() => RangeHeader.parse('-'), throwsInvalidRangeHeaderException); + }); + }); + + group('exceptions', () { + test('invalid character', () { + expect(() => RangeHeader.parse('!!!'), throwsRangeParseException); + }); + + test('no ranges', () { + expect(() => RangeHeader.parse('bytes'), throwsRangeParseException); + }); + + test('no dash after int', () { + expect(() => RangeHeader.parse('bytes 3'), throwsRangeParseException); + expect(() => RangeHeader.parse('bytes 3,'), throwsRangeParseException); + expect(() => RangeHeader.parse('bytes 3 24'), throwsRangeParseException); + }); + + test('no int after dash', () { + expect(() => RangeHeader.parse('bytes -,'), throwsRangeParseException); + }); + }); + + group('complete coverage', () { + test('exception toString()', () { + var m = RangeHeaderParseException('hey'); + expect(m.toString(), contains('hey')); + }); + }); + + test('content-range', () { + expect(RangeHeader.parse('bytes 1-2').items[0].toContentRange(3), '1-2/3'); + }); +} diff --git a/packages/symbol_table/README.md b/packages/symbol_table/README.md index fafc033..f713dba 100644 --- a/packages/symbol_table/README.md +++ b/packages/symbol_table/README.md @@ -1,6 +1,6 @@ # Belatuk Symbol Table -[![version](https://img.shields.io/badge/pub-v3.0.1-brightgreen)](https://pub.dartlang.org/packages/belatuk_symbol_table) +[![version](https://img.shields.io/badge/pub-v3.0.1-brightgreen)](https://pub.dev/packages/belatuk_symbol_table) [![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) [![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/symbol_table/LICENSE) diff --git a/packages/user_agent/.travis.yml b/packages/user_agent/.travis.yml new file mode 100644 index 0000000..de2210c --- /dev/null +++ b/packages/user_agent/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file diff --git a/packages/user_agent/AUTHORS.md b/packages/user_agent/AUTHORS.md new file mode 100644 index 0000000..ac95ab5 --- /dev/null +++ b/packages/user_agent/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/user_agent/CHANGELOG.md b/packages/user_agent/CHANGELOG.md new file mode 100644 index 0000000..3b24416 --- /dev/null +++ b/packages/user_agent/CHANGELOG.md @@ -0,0 +1,22 @@ +# Change Log + +## 3.0.2 + +* Upgraded from `pendantic` to `lints` linter +* Fixed linter warnings + +## 3.0.1 + +* Updated to use non nullable results + +## 3.0.0 + +* Migrated to support Dart SDK 2.12.x NNBD + +## 2.0.0 + +* Dart 2 updates. + +## 0.0.1 + +* Initial version, created by Stagehand diff --git a/packages/user_agent/LICENSE b/packages/user_agent/LICENSE new file mode 100644 index 0000000..e37a346 --- /dev/null +++ b/packages/user_agent/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/user_agent/README.md b/packages/user_agent/README.md new file mode 100644 index 0000000..f01f839 --- /dev/null +++ b/packages/user_agent/README.md @@ -0,0 +1,26 @@ +# User Agent Analyzer + +[![version](https://img.shields.io/badge/pub-v3.0.2-brightgreen)](https://pub.dev/packages/user_agent_analyzer) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![License](https://img.shields.io/github/license/dart-backend/belatuk-common-utilities)](https://github.com/dart-backend/belatuk-common-utilities/packages/user_agent/LICENSE) + +**Replacement of `package:user_agent` with breaking changes to support NNBD.** + +A library to identify the type of devices and web browsers based on `User-Agent` string. + +Runs anywhere. + +```dart +void main() async { + app.get('/', (req, res) async { + var ua = UserAgent(req.headers.value('user-agent')); + + if (ua.isChrome) { + res.redirect('/upgrade-your-browser'); + return; + } else { + // ... + } + }); +} +``` diff --git a/packages/user_agent/analysis_options.yaml b/packages/user_agent/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/packages/user_agent/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/packages/user_agent/example/example.dart b/packages/user_agent/example/example.dart new file mode 100644 index 0000000..3918e9d --- /dev/null +++ b/packages/user_agent/example/example.dart @@ -0,0 +1,7 @@ +import 'package:user_agent_analyzer/user_agent_analyzer.dart'; + +void main() { + var ua = UserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36'); + print(ua.isChrome); +} diff --git a/packages/user_agent/lib/user_agent_analyzer.dart b/packages/user_agent/lib/user_agent_analyzer.dart new file mode 100644 index 0000000..f86757f --- /dev/null +++ b/packages/user_agent/lib/user_agent_analyzer.dart @@ -0,0 +1,235 @@ +library user_agent_analyzer; + +/// Utils for device detection. +class UserAgent { + bool _isChrome = false; + bool _isOpera = false; + bool _isIE = false; + bool _isFirefox = false; + bool _isWebKit = false; + String? _cachedCssPrefix; + String? _cachedPropertyPrefix; + + final String value, _lowerValue; + + static const List knownMobileUserAgentPrefixes = [ + 'w3c ', + 'w3c-', + 'acs-', + 'alav', + 'alca', + 'amoi', + 'audi', + 'avan', + 'benq', + 'bird', + 'blac', + 'blaz', + 'brew', + 'cell', + 'cldc', + 'cmd-', + 'dang', + 'doco', + 'eric', + 'hipt', + 'htc_', + 'inno', + 'ipaq', + 'ipod', + 'jigs', + 'kddi', + 'keji', + 'leno', + 'lg-c', + 'lg-d', + 'lg-g', + 'lge-', + 'lg/u', + 'maui', + 'maxo', + 'midp', + 'mits', + 'mmef', + 'mobi', + 'mot-', + 'moto', + 'mwbp', + 'nec-', + 'newt', + 'noki', + 'palm', + 'pana', + 'pant', + 'phil', + 'play', + 'port', + 'prox', + 'qwap', + 'sage', + 'sams', + 'sany', + 'sch-', + 'sec-', + 'send', + 'seri', + 'sgh-', + 'shar', + 'sie-', + 'siem', + 'smal', + 'smar', + 'sony', + 'sph-', + 'symb', + 't-mo', + 'teli', + 'tim-', + 'tosh', + 'tsm-', + 'upg1', + 'upsi', + 'vk-v', + 'voda', + 'wap-', + 'wapa', + 'wapi', + 'wapp', + 'wapr', + 'webc', + 'winw', + 'winw', + 'xda ', + 'xda-' + ]; + + static const List knownMobileUserAgentKeywords = [ + 'blackberry', + 'webos', + 'ipod', + 'lge vx', + 'midp', + 'maemo', + 'mmp', + 'mobile', + 'netfront', + 'hiptop', + 'nintendo DS', + 'novarra', + 'openweb', + 'opera mobi', + 'opera mini', + 'palm', + 'psp', + 'phone', + 'smartphone', + 'symbian', + 'up.browser', + 'up.link', + 'wap', + 'windows ce' + ]; + + static const List knownTabletUserAgentKeywords = [ + 'ipad', + 'playbook', + 'hp-tablet', + 'kindle' + ]; + + UserAgent(this.value) : _lowerValue = value.toLowerCase(); + + /// Determines if the user agent string contains the desired string. Case-insensitive. + bool contains(String needle) => _lowerValue.contains(needle.toLowerCase()); + + bool get isDesktop => isMacOS || (!isMobile && !isTablet); + + bool get isTablet => knownTabletUserAgentKeywords.any(contains); + + bool get isMobile => knownMobileUserAgentKeywords.any(contains); + + bool get isMacOS => contains('Macintosh') || contains('Mac OS X'); + + bool get isSafari => contains('Safari'); + + bool get isAndroid => contains('android'); + + bool get isAndroidPhone => contains('android') && contains('mobile'); + + bool get isAndroidTablet => contains('android') && !contains('mobile'); + + bool get isWindows => contains('windows'); + + bool get isWindowsPhone => isWindows && contains('phone'); + + bool get isWindowsTablet => isWindows && contains('touch'); + + bool get isBlackberry => + contains('blackberry') || contains('bb10') || contains('rim'); + + bool get isBlackberryPhone => isBlackberry && !contains('tablet'); + + bool get isBlackberryTablet => isBlackberry && contains('tablet'); + + /// Determines if the current device is running Chrome. + bool get isChrome { + _isChrome = value.contains('Chrome', 0); + return _isChrome; + } + + /// Determines if the current device is running Opera. + bool get isOpera { + _isOpera = value.contains('Opera', 0); + return _isOpera; + } + + /// Determines if the current device is running Internet Explorer. + bool get isIE { + _isIE = !isOpera && value.contains('Trident/', 0); + return _isIE; + } + + /// Determines if the current device is running Firefox. + bool get isFirefox { + _isFirefox = value.contains('Firefox', 0); + return _isFirefox; + } + + /// Determines if the current device is running WebKit. + bool get isWebKit { + _isWebKit = !isOpera && value.contains('WebKit', 0); + return _isWebKit; + } + + /// Gets the CSS property prefix for the current platform. + String get cssPrefix { + var prefix = _cachedCssPrefix; + if (prefix != null) return prefix; + if (isFirefox) { + prefix = '-moz-'; + } else if (isIE) { + prefix = '-ms-'; + } else if (isOpera) { + prefix = '-o-'; + } else { + prefix = '-webkit-'; + } + return _cachedCssPrefix = prefix; + } + + /// Prefix as used for JS property names. + String get propertyPrefix { + var prefix = _cachedPropertyPrefix; + if (prefix != null) return prefix; + if (isFirefox) { + prefix = 'moz'; + } else if (isIE) { + prefix = 'ms'; + } else if (isOpera) { + prefix = 'o'; + } else { + prefix = 'webkit'; + } + return _cachedPropertyPrefix = prefix; + } +} diff --git a/packages/user_agent/pubspec.yaml b/packages/user_agent/pubspec.yaml new file mode 100644 index 0000000..eca7dd5 --- /dev/null +++ b/packages/user_agent/pubspec.yaml @@ -0,0 +1,9 @@ +name: user_agent_analyzer +version: 3.0.2 +description: A library to identify the type of devices and web browsers based on User-Agent string. +homepage: https://github.com/dart-backend/belatuk-common-utilities/tree/main/packages/user_agent +environment: + sdk: '>=2.12.0 <3.0.0' +dev_dependencies: + test: ^1.17.8 + lints: ^1.0.0 diff --git a/packages/user_agent/test/user_agent_test.dart b/packages/user_agent/test/user_agent_test.dart new file mode 100644 index 0000000..4721765 --- /dev/null +++ b/packages/user_agent/test/user_agent_test.dart @@ -0,0 +1,30 @@ +// Copyright (c) 2017, thosakwe. All rights reserved. Use of this source code +// is governed by a BSD-style license that can be found in the LICENSE file. + +import 'package:user_agent_analyzer/user_agent_analyzer.dart'; +import 'package:test/test.dart'; + +void main() { + test('chrome', () { + var ua = UserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36'); + expect([ua.isChrome, ua.isWebKit, ua.isSafari, ua.isDesktop, ua.isMacOS], + everyElement(isTrue)); + expect([ua.isFirefox, ua.isIE, ua.isOpera, ua.isMobile, ua.isTablet], + everyElement(isFalse)); + expect([ + ua.isAndroid, + ua.isAndroidPhone, + ua.isAndroidTablet, + ua.isBlackberry, + ua.isBlackberryPhone, + ua.isBlackberryTablet, + ua.isWindows, + ua.isWindowsPhone, + ua.isWindowsTablet + ], everyElement(isFalse)); + + expect(ua.cssPrefix, equals('-webkit-')); + expect(ua.propertyPrefix, equals('webkit')); + }); +}