diff --git a/framework/.gitignore b/framework/.gitignore new file mode 100644 index 00000000..0104b702 --- /dev/null +++ b/framework/.gitignore @@ -0,0 +1,66 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +### Dart template +# Don’t commit the following directories created by pub. +.buildlog +.pub/ +build/ +packages +.packages + +# Or the files created by dart2js. +*.dart.js +*.js_ +*.js.deps +*.js.map + +# Include when developing application packages. +pubspec.lock + +doc/api +.dart_tool \ No newline at end of file diff --git a/framework/.idea/dbnavigator.xml b/framework/.idea/dbnavigator.xml new file mode 100644 index 00000000..11ef66c4 --- /dev/null +++ b/framework/.idea/dbnavigator.xml @@ -0,0 +1,451 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework/.idea/encodings.xml b/framework/.idea/encodings.xml new file mode 100644 index 00000000..97626ba4 --- /dev/null +++ b/framework/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/framework/.idea/jsLibraryMappings.xml b/framework/.idea/jsLibraryMappings.xml new file mode 100644 index 00000000..f3e502d1 --- /dev/null +++ b/framework/.idea/jsLibraryMappings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/framework/.idea/libraries/Dart_Packages.xml b/framework/.idea/libraries/Dart_Packages.xml new file mode 100644 index 00000000..088c8509 --- /dev/null +++ b/framework/.idea/libraries/Dart_Packages.xml @@ -0,0 +1,525 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework/.idea/libraries/Dart_SDK.xml b/framework/.idea/libraries/Dart_SDK.xml new file mode 100644 index 00000000..ed937c05 --- /dev/null +++ b/framework/.idea/libraries/Dart_SDK.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/framework/.idea/misc.xml b/framework/.idea/misc.xml new file mode 100644 index 00000000..1719e444 --- /dev/null +++ b/framework/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/modules.xml b/framework/.idea/modules.xml new file mode 100644 index 00000000..e6494b43 --- /dev/null +++ b/framework/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/All_Tests.xml b/framework/.idea/runConfigurations/All_Tests.xml new file mode 100644 index 00000000..dcd3564d --- /dev/null +++ b/framework/.idea/runConfigurations/All_Tests.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/All_Tests__PRODUCTION_.xml b/framework/.idea/runConfigurations/All_Tests__PRODUCTION_.xml new file mode 100644 index 00000000..09ab904c --- /dev/null +++ b/framework/.idea/runConfigurations/All_Tests__PRODUCTION_.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/All_Tests__for_coverage_.xml b/framework/.idea/runConfigurations/All_Tests__for_coverage_.xml new file mode 100644 index 00000000..c5727acc --- /dev/null +++ b/framework/.idea/runConfigurations/All_Tests__for_coverage_.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/Controller_Tests.xml b/framework/.idea/runConfigurations/Controller_Tests.xml new file mode 100644 index 00000000..16c24846 --- /dev/null +++ b/framework/.idea/runConfigurations/Controller_Tests.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/DI_Tests.xml b/framework/.idea/runConfigurations/DI_Tests.xml new file mode 100644 index 00000000..002c3572 --- /dev/null +++ b/framework/.idea/runConfigurations/DI_Tests.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/Hooked_Tests.xml b/framework/.idea/runConfigurations/Hooked_Tests.xml new file mode 100644 index 00000000..592565a1 --- /dev/null +++ b/framework/.idea/runConfigurations/Hooked_Tests.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/Match_routes__even_with_query_params_in_routing_test_dart.xml b/framework/.idea/runConfigurations/Match_routes__even_with_query_params_in_routing_test_dart.xml new file mode 100644 index 00000000..3df4dee3 --- /dev/null +++ b/framework/.idea/runConfigurations/Match_routes__even_with_query_params_in_routing_test_dart.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/Middleware_via_metadata_in_routing_test_dart.xml b/framework/.idea/runConfigurations/Middleware_via_metadata_in_routing_test_dart.xml new file mode 100644 index 00000000..41e6ad8d --- /dev/null +++ b/framework/.idea/runConfigurations/Middleware_via_metadata_in_routing_test_dart.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/Routing_Tests.xml b/framework/.idea/runConfigurations/Routing_Tests.xml new file mode 100644 index 00000000..3790ba95 --- /dev/null +++ b/framework/.idea/runConfigurations/Routing_Tests.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/can_fetch_data_in_services_test_dart.xml b/framework/.idea/runConfigurations/can_fetch_data_in_services_test_dart.xml new file mode 100644 index 00000000..5dbab496 --- /dev/null +++ b/framework/.idea/runConfigurations/can_fetch_data_in_services_test_dart.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/cannot_write_after_close_in_streaming_test_dart.xml b/framework/.idea/runConfigurations/cannot_write_after_close_in_streaming_test_dart.xml new file mode 100644 index 00000000..e0ee83ca --- /dev/null +++ b/framework/.idea/runConfigurations/cannot_write_after_close_in_streaming_test_dart.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/encoding_in_encoders_buffer_test_dart.xml b/framework/.idea/runConfigurations/encoding_in_encoders_buffer_test_dart.xml new file mode 100644 index 00000000..f13ca17d --- /dev/null +++ b/framework/.idea/runConfigurations/encoding_in_encoders_buffer_test_dart.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/encoding_in_streaming_test_dart.xml b/framework/.idea/runConfigurations/encoding_in_streaming_test_dart.xml new file mode 100644 index 00000000..f3885511 --- /dev/null +++ b/framework/.idea/runConfigurations/encoding_in_streaming_test_dart.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/handle_error_dart.xml b/framework/.idea/runConfigurations/handle_error_dart.xml new file mode 100644 index 00000000..5df40720 --- /dev/null +++ b/framework/.idea/runConfigurations/handle_error_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/injects_header_or_throws_in_parameter_meta_test_dart.xml b/framework/.idea/runConfigurations/injects_header_or_throws_in_parameter_meta_test_dart.xml new file mode 100644 index 00000000..92114127 --- /dev/null +++ b/framework/.idea/runConfigurations/injects_header_or_throws_in_parameter_meta_test_dart.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/injects_session_or_throws_in_parameter_meta_test_dart.xml b/framework/.idea/runConfigurations/injects_session_or_throws_in_parameter_meta_test_dart.xml new file mode 100644 index 00000000..17a6c854 --- /dev/null +++ b/framework/.idea/runConfigurations/injects_session_or_throws_in_parameter_meta_test_dart.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/json_dart.xml b/framework/.idea/runConfigurations/json_dart.xml new file mode 100644 index 00000000..0c6db2aa --- /dev/null +++ b/framework/.idea/runConfigurations/json_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/main_dart.xml b/framework/.idea/runConfigurations/main_dart.xml new file mode 100644 index 00000000..750f7262 --- /dev/null +++ b/framework/.idea/runConfigurations/main_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/metadata_in_hooked_test_dart.xml b/framework/.idea/runConfigurations/metadata_in_hooked_test_dart.xml new file mode 100644 index 00000000..618cdc73 --- /dev/null +++ b/framework/.idea/runConfigurations/metadata_in_hooked_test_dart.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/only_match_route_with_matching_method_in_routing_test_dart.xml b/framework/.idea/runConfigurations/only_match_route_with_matching_method_in_routing_test_dart.xml new file mode 100644 index 00000000..f030feb7 --- /dev/null +++ b/framework/.idea/runConfigurations/only_match_route_with_matching_method_in_routing_test_dart.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/performance__hello__DEV_.xml b/framework/.idea/runConfigurations/performance__hello__DEV_.xml new file mode 100644 index 00000000..1925a88c --- /dev/null +++ b/framework/.idea/runConfigurations/performance__hello__DEV_.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/performance__hello__PRODUCTION_.xml b/framework/.idea/runConfigurations/performance__hello__PRODUCTION_.xml new file mode 100644 index 00000000..56888d49 --- /dev/null +++ b/framework/.idea/runConfigurations/performance__hello__PRODUCTION_.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/performance__hello__raw.xml b/framework/.idea/runConfigurations/performance__hello__raw.xml new file mode 100644 index 00000000..a13c2280 --- /dev/null +++ b/framework/.idea/runConfigurations/performance__hello__raw.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/tests_in_find_one_test_dart.xml b/framework/.idea/runConfigurations/tests_in_find_one_test_dart.xml new file mode 100644 index 00000000..ad42b586 --- /dev/null +++ b/framework/.idea/runConfigurations/tests_in_find_one_test_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/tests_in_framework.xml b/framework/.idea/runConfigurations/tests_in_framework.xml new file mode 100644 index 00000000..4278d0e6 --- /dev/null +++ b/framework/.idea/runConfigurations/tests_in_framework.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/tests_in_framework__PRODUCTION_.xml b/framework/.idea/runConfigurations/tests_in_framework__PRODUCTION_.xml new file mode 100644 index 00000000..263eac1e --- /dev/null +++ b/framework/.idea/runConfigurations/tests_in_framework__PRODUCTION_.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/tests_in_server_test_dart__PRODUCTION_.xml b/framework/.idea/runConfigurations/tests_in_server_test_dart__PRODUCTION_.xml new file mode 100644 index 00000000..06ec9c23 --- /dev/null +++ b/framework/.idea/runConfigurations/tests_in_server_test_dart__PRODUCTION_.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/framework/.idea/runConfigurations/view_dart.xml b/framework/.idea/runConfigurations/view_dart.xml new file mode 100644 index 00000000..de62a90c --- /dev/null +++ b/framework/.idea/runConfigurations/view_dart.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/framework/.idea/vcs.xml b/framework/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/framework/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/framework/.idea/workspace.xml b/framework/.idea/workspace.xml new file mode 100644 index 00000000..4109bc84 --- /dev/null +++ b/framework/.idea/workspace.xml @@ -0,0 +1,1616 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + handleRe + get container + Parameter + log + mirrors + dart:mirror + dart:io + _Par + waterfall + wtf + errorHan + nested + _parent = + headers['content + skip + Provi + contentTy + abc + /chained + flatten + sendRe + enableB + Hooks + Middleware + /meta + complet + parseI + read( + as + serviceP + + + FutureOr + var body = await getBody(rs); + + = + { + 'accept' + 'content-type' + 403 + (RequestContext req, res) + headers: headers.cast<String, String>(), + this. + 'text/html' + 'application/json' + as $1 + as Map$1 + IsInstanceOf + [Iterable] + _foldStringDynamic(result) + Angel(MirrorsReflector()) + json.encode + Angel(reflector: M + dart:convert + dart:io + json.decode + rawRequest. + rawResponse. + )); + req + req.container + close() + + + C:\Users\thosa\Source\Angel\framework\lib + C:\Users\thosa\Source\Angel\framework\lib\src\http + C:\Users\thosa\Source\Angel\framework\test + $PROJECT_DIR$/lib/src + $PROJECT_DIR$/lib + $PROJECT_DIR$/test + $PROJECT_DIR$ + $PROJECT_DIR$/lib/src/core + + + + + + + + + + + + true + DEFINITION_ORDER + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + project + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1481236071442 + + + 1534732811445 + + + 1534732848038 + + + 1534732927698 + + + 1534733134792 + + + 1534733155846 + + + 1534733208240 + + + 1534733242302 + + + 1534733436519 + + + 1534733754509 + + + 1534733780470 + + + 1534734389546 + + + 1534735099537 + + + 1534735212786 + + + 1534735654118 + + + 1534735707304 + + + 1534735919739 + + + 1534736377619 + + + 1534736459394 + + + 1534736798816 + + + 1534737069329 + + + 1534738194991 + + + 1534738209023 + + + 1534794125343 + + + 1534794207777 + + + 1534794372882 + + + 1534794629985 + + + 1534794764995 + + + 1534794944288 + + + 1534795218407 + + + 1534796466854 + + + 1534797818435 + + + 1534798410411 + + + 1534798717707 + + + 1534812824990 + + + 1534813505067 + + + 1534813932227 + + + 1534814859355 + + + 1534815148128 + + + 1534815933898 + + + 1534816646962 + + + 1534819472503 + + + 1534861361523 + + + 1534861733989 + + + 1534877443455 + + + 1534877529522 + + + 1536696871863 + + + 1536697507787 + + + 1536698069495 + + + 1536698423774 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + No facets are configured + + + + + + + + jquery + + + + + + + + 1.8 + + + + + + + + framework + + + + + + + + Dart SDK + + + + + + + + \ No newline at end of file diff --git a/framework/.travis.yml b/framework/.travis.yml new file mode 100644 index 00000000..14148ad6 --- /dev/null +++ b/framework/.travis.yml @@ -0,0 +1,6 @@ +language: dart +dart: + - dev + - stable +before_script: chmod +x ./tool/travis.sh +script: ./tool/travis.sh \ No newline at end of file diff --git a/framework/.vscode/settings.json b/framework/.vscode/settings.json new file mode 100644 index 00000000..20af2f68 --- /dev/null +++ b/framework/.vscode/settings.json @@ -0,0 +1,3 @@ +// Place your settings in this file to overwrite default and user settings. +{ +} \ No newline at end of file diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md new file mode 100644 index 00000000..a3b932db --- /dev/null +++ b/framework/CHANGELOG.md @@ -0,0 +1,282 @@ +# 2.1.1 +* `AngelHttp.uri` now returns an empty `Uri` if the server is not listening. + +# 2.1.0 +* This release was originally planned to be `2.0.5`, but it adds several features, and has +therefore been bumped to `2.1.0`. +* Fix a new (did not appear before 2.6/2.7) type error causing compilation to fail. +https://github.com/angel-dart/framework/issues/249 + +# 2.0.5-beta +* Make `@Expose()` in `Controller` optional. https://github.com/angel-dart/angel/issues/107 +* Add `allowHttp1` to `AngelHttp2` constructors. https://github.com/angel-dart/angel/issues/108 +* Add `deserializeBody` and `decodeBody` to `RequestContext`. https://github.com/angel-dart/angel/issues/109 +* Add `HostnameRouter`, which allows for routing based on hostname. https://github.com/angel-dart/angel/issues/110 +* Default to using `ThrowingReflector`, instead of `EmptyReflector`. This will give a more descriptive +error when trying to use controllers, etc. without reflection enabled. +* `mountController` returns the mounted controller. + +# 2.0.4+1 +* Run `Controller.configureRoutes` before mounting `@Expose` routes. +* Make `Controller.configureServer` always return a `Future`. + +# 2.0.4 +* Prepare for Dart SDK change to `Stream>` that are now + `Stream`. +* Accept any content type if accept header is missing. See +[this PR](https://github.com/angel-dart/framework/pull/239). + +# 2.0.3 +* Patch up a bug caused by an upstream change to Dart's stream semantics. +See more: https://github.com/angel-dart/angel/issues/106#issuecomment-499564485 + +# 2.0.2+1 +* Fix a bug in the implementation of `Controller.applyRoutes`. + +# 2.0.2 +* Make `ResponseContext` *explicitly* implement `StreamConsumer` (though technically it already did???) +* Split `Controller.configureServer` to create `Controller.applyRoutes`. + +# 2.0.1 +* Tracked down a bug in `Driver.runPipeline` that allowed fallback +handlers to run, even after the response was closed. +* Add `RequestContext.shutdownHooks`. +* Call `RequestContext.close` in `Driver.sendResponse`. +* AngelConfigurer is now `FutureOr`, instead of just `FutureOr`. +* Use a `Container.has` check in `Driver.sendResponse`. +* Remove unnecessary `new` and `const`. + +# 2.0.0 +* Angel 2! :angel: :rocket: + +# 2.0.0-rc.10 +* Fix an error that prevented `AngelHttp2.custom` from working properly. +* Add `startSharedHttp2`. + +# 2.0.0-rc.9 +* Fix some bugs in the `HookedService` implementation that skipped +the outputs of `before` events. + +# 2.0.0-rc.8 +* Fix `MapService` flaw where clients could remove all records, even if `allowRemoveAll` were `false`. + +# 2.0.0-rc.7 +* `AnonymousService` can override `readData`. +* `Service.map` now overrides `readData`. +* `HookedService.readData` forwards to `inner`. + +# 2.0.0-rc.6 +* Make `redirect` and `download` methods asynchronous. + +# 2.0.0-rc.5 +* Make `serializer` `FutureOr Function(Object)`. +* Make `ResponseContext.serialize` return `Future`. + +# 2.0.0-rc.4 +* Support resolution of asynchronous injections in controllers and `ioc`. +* Inject `RequestContext` and `ResponseContext` into requests. + +# 2.0.0-rc.3 +* `MapService.modify` was not actually modifying items. + +# 2.0.0-rc.2 +* Fixes Pub analyzer lints (see `angel_route@3.0.6`) + +# 2.0.0-rc.1 +* Fix logic error that allowed content to be written to streaming responses after `close` was closed. + +# 2.0.0-rc.0 +* Log a warning when no `reflector` is provided. +* Add `AngelEnvironment` class. + * Add `Angel.environment`. + * Deprecated `app.isProduction` in favor of `app.environment.isProduction`. +* Allow setting of `bodyAsObject`, `bodyAsMap`, or `bodyAsList` **exactly once**. +* Resolve named singletons in `resolveInjection`. +* Fix a bug where `Service.parseId` would attempt to parse an `int`. +* Replace as Data cast in Service.dart with a method that throws a 400 on error. + +# 2.0.0-alpha.24 +* Add `AngelEnv` class to `core`. +* Deprecate `Angel.isProduction`, in favor of `AngelEnv`. + +# 2.0.0-alpha.23 +* `ResponseContext.render` sets `charset` to `utf8` in `contentType`. + +# 2.0.0-alpha.22 +* Update pipeline handling mechanism, and inject a `MiddlewarePipelineIterator`. + * This allows routes to know where in the resolution process they exist, at runtime. + +# 2.0.0-alpha.21 +* Update for `angel_route@3.0.4` compatibility. +* Add `readAsBytes` and `readAsString` to `UploadedFile`. +* URI-decode path components in HTTP2. + +# 2.0.0-alpha.20 +* Inject the `MiddlewarePipeline` into requests. + +# 2.0.0-alpha.19 +* `parseBody` checks for null content type, and throws a `400` if none was given. +* Add `ResponseContext.contentLength`. +* Update `streamFile` to set content length, and also to work on `HEAD` requests. + +# 2.0.0-alpha.18 +* Upgrade `http2` dependency. +* Upgrade `uuid` dependency. +* Fixed a bug that prevented body parsing from ever completing with `http2`. +* Add `Providers.hashCode`. + +# 2.0.0-alpha.17 +* Revert the migration to `lumberjack` for now. In the future, when it's more +stable, there'll be a conversion, perhaps. + +# 2.0.0-alpha.16 +* Use `package:lumberjack` for logging. + +# 2.0.0-alpha.15 +* Remove dependency on `body_parser`. +* `RequestContext` now exposes a `Stream> get body` getter. + * Calling `RequestContext.parseBody()` parses its contents. + * Added `bodyAsMap`, `bodyAsList`, `bodyAsObject`, and `uploadedFiles` to `RequestContext`. + * Removed `Angel.keepRawRequestBuffers` and anything that had to do with buffering request bodies. + +# 2.0.0-alpha.14 +* Patch `HttpResponseContext._openStream` to send content-length. + +# 2.0.0-alpha.13 + +- Fixed a logic error in `HttpResponseContext` that prevented status codes from being sent. + +# 2.0.0-alpha.12 + +- Remove `ResponseContext.sendFile`. +- Add `Angel.mimeTypeResolver`. +- Fix a bug where an unknown MIME type on `streamFile` would return a 500. + +# 2.0.0-alpha.11 + +- Add `readMany` to `Service`. +- Allow `ResponseContext.redirect` to take a `Uri`. +- Add `Angel.mountController`. +- Add `Angel.findServiceOf`. +- Roll in HTTP/2. See `pkg:angel_framework/http2.dart`. + +# 2.0.0-alpha.10 + +- All calls to `Service.parseId` are now affixed with the `` argument. +- Added `uri` getter to `AngelHttp`. +- The default for `parseQuery` now wraps query parameters in `Map.from`. + This resolves a bug in `package:angel_validate`. + +# 2.0.0-alpha.9 + +- Add `Service.map`. + +# 2.0.0-alpha.8 + +- No longer export HTTP-specific code from `angel_framework.dart`. + An import of `import 'package:angel_framework/http.dart';` will be necessary in most cases now. + +# 2.0.0-alpha.7 + +- Force a tigher contract on services. They now must return `Data` on all + methods except for `index`, which returns a `List`. + +# 2.0.0-alpha.6 + +- Allow passing a custom `Container` to `handleContained` and co. + +# 2.0.0-alpha.5 + +- `MapService` methods now explicitly return `Map`. + +# 2.0.0-alpha.4 + +- Renamed `waterfall` to `chain`. +- Renamed `Routable.service` to `Routable.findService`. + - Also `Routable.findHookedService`. + +# 2.0.0-alpha.3 + +- Added `` type parameters to `Service`. +- `HookedService` now follows suit, and takes a third parameter, pointing to the inner service. +- `Routable.use` now uses the generic parameters added to `Service`. +- Added generic usage to `HookedServiceListener`, etc. +- All service methods take `Map` as `params` now. + +# 2.0.0-alpha.2 + +- Added `ResponseContext.detach`. + +# 2.0.0-alpha.1 + +- Removed `Angel.injectEncoders`. +- Added `Providers.toJson`. +- Moved `Providers.graphql` to `Providers.graphQL`. +- `Angel.optimizeForProduction` no longer calls `preInject`, + as it does not need to. +- Rename `ResponseContext.enableBuffer` to `ResponseContext.useBuffer`. + +# 2.0.0-alpha + +- Removed `random_string` dependency. +- Moved reflection to `package:angel_container`. +- Upgraded `package:file` to `5.0.0`. +- `ResponseContext.sendFile` now uses `package:file`. +- Abandon `ContentType` in favor of `MediaType`. +- Changed view engine to use `Map`. +- Remove dependency on `package:json_god` by default. +- Remove dependency on `package:dart2_constant`. +- Moved `lib/hooks.dart` into `package:angel_hooks`. +- Moved `TypedService` into `package:angel_typed_service`. +- Completely removed the `AngelBase` class. +- Removed all `@deprecated` symbols. +- `Service.toId` was renamed to `Service.parseId`; it also now uses its + single type argument to determine how to parse a value. \* In addition, this method was also made `static`. +- `RequestContext` and `ResponseContext` are now generic, and take a + single type argument pointing to the underlying request/response type, + respectively. +- `RequestContext.io` and `ResponseContext.io` are now permanently + gone. +- `HttpRequestContextImpl` and `HttpResponseContextImpl` were renamed to + `HttpRequestContext` and `HttpResponseContext`. +- Lazy-parsing request bodies is now the default; `Angel.lazyParseBodies` was replaced + with `Angel.eagerParseRequestBodies`. +- `Angel.storeOriginalBuffer` -> `Angel.storeRawRequestBuffers`. +- The methods `lazyBody`, `lazyFiles`, and `lazyOriginalBuffer` on `ResponseContext` were all + replaced with `parseBody`, `parseUploadedFiles`, and `parseRawRequestBuffer`, respectively. +- Removed the synchronous equivalents of the above methods (`body`, `files`, and `originalBuffer`), + as well as `query`. +- Removed `Angel.injections` and `RequestContext.injections`. +- Removed `Angel.inject` and `RequestContext.inject`. +- Removed a dependency on `package:pool`, which also meant removing `AngelHttp.throttle`. +- Remove the `RequestMiddleware` typedef; from now on, one should use `ResponseContext.end` + exclusively to close responses. +- `waterfall` will now only accept `RequestHandler`. +- `Routable`, and all of its subclasses, now extend `Router`, and therefore only + take routes in the form of `FutureOr myFunc(RequestContext, ResponseContext res)`. +- `@Middleware` now takes an `Iterable` of `RequestHandler`s. +- `@Expose.path` now _must_ be a `String`, not just any `Pattern`. +- `@Expose.middleware` now takes `Iterable`, instead of just `List`. +- `createDynamicHandler` was renamed to `ioc`, and is now used to run IoC-aware handlers in a + type-safe manner. +- `RequestContext.params` is now a `Map`, rather than just a `Map`. +- Removed `RequestContext.grab`. +- Removed `RequestContext.properties`. +- Removed the defunct `debug` property where it still existed. +- `Routable.use` now only accepts a `Service`. +- Removed `Angel.createZoneForRequest`. +- Removed `Angel.defaultZoneCreator`. +- Added all flags to the `Angel` constructor, ex. `Angel.eagerParseBodies`. +- Fix a bug where synchronous errors in `handleRequest` would not be caught. +- `AngelHttp.useZone` now defaults to `false`. +- `ResponseContext` now starts in streaming mode by default; the response buffer is opt-in, + as in many cases it is unnecessary and slows down response time. +- `ResponseContext.streaming` was replaced by `ResponseContext.isBuffered`. +- Made `LockableBytesBuilder` public. +- Removed the now-obsolete `ResponseContext.willCloseItself`. +- Removed `ResponseContext.dispose`. +- Removed the now-obsolete `ResponseContext.end`. +- Removed the now-obsolete `ResponseContext.releaseCorrespondingRequest`. +- `preInject` now takes a `Reflector` as its second argument. +- `Angel.reflector` defaults to `const EmptyReflector()`, disabling + reflection out-of-the-box. diff --git a/framework/LICENSE b/framework/LICENSE new file mode 100644 index 00000000..32e25c1c --- /dev/null +++ b/framework/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 The Angel Framework + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/framework/README.md b/framework/README.md new file mode 100644 index 00000000..7aecfc36 --- /dev/null +++ b/framework/README.md @@ -0,0 +1,61 @@ +# angel_framework + +[![Pub](https://img.shields.io/pub/v/angel_framework.svg)](https://pub.dartlang.org/packages/angel_framework) +[![build status](https://travis-ci.org/angel-dart/framework.svg)](https://travis-ci.org/angel-dart/framework) + +A high-powered HTTP server with support for dependency injection, sophisticated routing and more. + +This is the core of the [Angel](https://github.com/angel-dart/angel) framework. +To build real-world applications, please see the [homepage](https://angel-dart.dev). + +```dart +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; + +main() async { + var app = Angel(reflector: MirrorsReflector()); + + // Index route. Returns JSON. + app.get('/', (req, res) => res.write('Welcome to Angel!')); + + // Accepts a URL like /greet/foo or /greet/bob. + app.get( + '/greet/:name', + (req, res) { + var name = req.params['name']; + res + ..write('Hello, $name!') + ..close(); + }, + ); + + // Pattern matching - only call this handler if the query value of `name` equals 'emoji'. + app.get( + '/greet', + ioc((@Query('name', match: 'emoji') String name) => '😇🔥🔥🔥'), + ); + + // Handle any other query value of `name`. + app.get( + '/greet', + ioc((@Query('name') String name) => 'Hello, $name!'), + ); + + // Simple fallback to throw a 404 on unknown paths. + app.fallback((req, res) { + throw AngelHttpException.notFound( + message: 'Unknown path: "${req.uri.path}"', + ); + }); + + var http = AngelHttp(app); + var server = await http.startServer('127.0.0.1', 3000); + var url = 'http://${server.address.address}:${server.port}'; + print('Listening at $url'); + print('Visit these pages to see Angel in action:'); + print('* $url/greet/bob'); + print('* $url/greet/?name=emoji'); + print('* $url/greet/?name=jack'); + print('* $url/nonexistent_page'); +} +``` diff --git a/framework/TODO.md b/framework/TODO.md new file mode 100644 index 00000000..3381eeda --- /dev/null +++ b/framework/TODO.md @@ -0,0 +1,7 @@ +* Support for [Trestle](https://github.com/dart-bridge/trestle), use this as default, set up migration system around this +* Angel CLI +* Angel bootstrap project +* More docs +* Make tutorials, videos +* Launch! +* Get a nice launch process, so we can pre-compile things before running. Also support a sort of hot-reload diff --git a/framework/analysis_options.yaml b/framework/analysis_options.yaml new file mode 100644 index 00000000..42d44a85 --- /dev/null +++ b/framework/analysis_options.yaml @@ -0,0 +1,15 @@ +# include: package:pedantic/analysis_options.yaml +analyzer: + errors: + always_declare_return_types: ignore + omit_local_variable_types: ignore + prefer_single_quotes: ignore + prefer_spread_collections: ignore + strong-mode: + implicit-casts: false +linter: + rules: + - avoid_slow_async_io + - curly_braces_in_flow_control_structures + - unnecessary_const + - unnecessary_new \ No newline at end of file diff --git a/framework/dev.key b/framework/dev.key new file mode 100644 index 00000000..5d49ae7e --- /dev/null +++ b/framework/dev.key @@ -0,0 +1,29 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIE5DAcBgoqhkiG9w0BDAEBMA4ECL7L6rj6uEHGAgIIAASCBMLbucyfqAkgCbhP +xNSHYllPMAv/dsIjtnsBwepCXPGkCBCuOAw/2FaCHjN9hBqL5V7fkrKeaemhm2YE +ycPtlHJYPDf3kEkyMjdZ9rIY6kePGfQizs2uJPcXj4YPyQ4HsfVXpOicKfQrouf5 +Mze9bGzeMN065q3iP4dYUMwHAyZYteXCsanQNHlqvsWli0W+H8St8fdsXefZhnv1 +qVatKWdNdWQ9t5MuljgNU2Vv56sHKEYXI0yLxk2QUMk8KlJfnmt8foYUsnPUXHmc +gIjLKwwVkpdololnEHSNu0cEOUPowjgJru+uMpn7vdNl7TPEQ9jbEgdNg4JwoYzU +0nao8WzjaSp7kzvZz0VFwKnk5AjstGvvuAWckADdq23QElbn/mF7AG1m/TBpYxzF +gTt37UdndS/AcvVznWVVrRP5iTSIawdIwvqI4s7rqsoE0GCcak+RhchgAz2gWKkS +oODUo0JL6pPVbJ3l4ebbaO6c99nDVc8dViPtc1EkStJEJ2O4kI4xgLSCr4Y9ahKn +oAaoSkX7Xxq3aQm+BzqSpLjdGL8atsqR/YVOIHYIl3gThvP0NfZGx1xHyvO5mCdZ +kHxSA7tKWxauZ3eQ2clbnzeRsl4El0WMHy/5K1ovene4v7sunmoXVtghBC8hK6eh +zMO9orex2PNQ/VQC7HCvtytunOVx1lkSBoNo7hR70igg6rW9H7UyoAoBOwMpT1xa +J6V62nqruTKOqFNfur7aHJGpHGtDb5/ickHeYCyPTvmGp67u4wChzKReeg02oECe +d1E5FKAcIa8s9TVOB6Z+HvTRNQZu2PsI6TJnjQRowvY9DAHiWTlJZBBY/pko3hxX +TsIeybpvRdEHpDWv86/iqtw1hv9CUxS/8ZTWUgBo+osShHW79FeDASr9FC4/Zn76 +ZDERTgV4YWlW/klVWcG2lFo7jix+OPXAB+ZQavLhlN1xdWBcIz1AUWjAM4hdPylW +HCX4PB9CQIPl2E7F+Y2p6nMcMWSJVBi5UIH7E9LfaBguXSzMmTk2Fw5p1aOQ6wfN +goVAMVwi8ppAVs741PfHdZ295xMmK/1LCxz5DeAdD/tsA/SYfT753GotioDuC7im +EyJ5JyvTr5I6RFFBuqt3NlUb3Hp16wP3B2x9DZiB6jxr0l341/NHgsyeBXkuIy9j +ON2mvpBPCJhS8kgWo3G0UyyKnx64tcgpGuSvZhGwPz843B6AbYyE6pMRfSWRMkMS +YZYa+VNKhR4ixdj07ocFZEWLVjCH7kxkE8JZXKt8jKYmkWd0lS1QVjgaKlO6lRa3 +q6SPJkhW6pvqobvcqVNXwi1XuzpZeEbuh0B7OTekFTTxx5g9XeDl56M8SVQ1KEhT +Q1t7H2Nba18WCB7cf+6PN0F0K0Jz1Kq7ZWaqEI/grX1m4RQuvNF5807sB/QKMO/Z +Gz3NXvHg5xTJRd/567lxPGkor0cE7qD1EZfmJ2HrBYXQ91bhgA7LToBuMZo6ZRXH +QfsanjbP4FPLMiGdQigLjj3A35L/f4sQOOVac/sRaFnm7pzcxsMvyVU/YtvGcjYE +xaOOVnamg661Wo0wksXoDjeSz/JIyyKO3Gwp1FSm2wGLjjy/Ehmqcqy8rvHuf07w +AUukhVtTNn4= +-----END ENCRYPTED PRIVATE KEY----- \ No newline at end of file diff --git a/framework/dev.pem b/framework/dev.pem new file mode 100644 index 00000000..01756b25 --- /dev/null +++ b/framework/dev.pem @@ -0,0 +1,57 @@ +-----BEGIN CERTIFICATE----- +MIIDKTCCAhGgAwIBAgIJAOWmjTS+OnTEMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV +BAMMDGludGVybWVkaWF0ZTAeFw0xNTA1MTgwOTAwNDBaFw0yMzA4MDQwOTAwNDBa +MBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBALlcwQJuzd+xH8QFgfJSn5tRlvhkldSX98cE7NiA602NBbnAVyUrkRXq +Ni75lgt0kwjYfA9z674m8WSVbgpLPintPCla9CYky1TH0keIs8Rz6cGWHryWEHiu +EDuljQynu2b3sAFuHu9nfWurbJwZnFakBKpdQ9m4EyOZCHC/jHYY7HacKSXg1Cki +we2ca0BWDrcqy8kLy0dZ5oC6IZG8O8drAK8f3f44CRYw59D3sOKBrKXaabpvyEcb +N7Wk2HDBVwHpUJo1reVwtbM8dhqQayYSD8oXnGpP3RQNu/e2rzlXRyq/BfcDY1JI +7TbC4t/7/N4EcPSpGsTcSOC9A7FpzvECAwEAAaN7MHkwCQYDVR0TBAIwADAsBglg +hkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0O +BBYEFCnwiEMMFZh7NhCr+qA8K0w4Q+AOMB8GA1UdIwQYMBaAFB0h1Evsaw2vfrmS +YuoCTmC4EE6ZMA0GCSqGSIb3DQEBCwUAA4IBAQAcFmHMaXRxyoNaeOowQ6iQWoZd +AUbvG7SHr7I6Pi2aqdqofsKWts7Ytm5WsS0M2nN+sW504houu0iCPeJJX8RQw2q4 +CCcNOs9IXk+2uMzlpocHpv+yYoUiD5DxgWh7eghQMLyMpf8FX3Gy4VazeuXznHOM +4gE4L417xkDzYOzqVTp0FTyAPUv6G2euhNCD6TMru9REcRhYul+K9kocjA5tt2KG +MH6y28LXbLyq4YJUxSUU9gY/xlnbbZS48KDqEcdYC9zjW9nQ0qS+XQuQuFIcwjJ5 +V4kAUYxDu6FoTpyQjgsrmBbZlKNxH7Nj4NDlcdJhp/zeSKHqWa5hSWjjKIxp +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDAjCCAeqgAwIBAgIJAOWmjTS+OnTDMA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV +BAMMDXJvb3RhdXRob3JpdHkwHhcNMTUwNTE4MDkwMDQwWhcNMjMwODA0MDkwMDQw +WjAXMRUwEwYDVQQDDAxpbnRlcm1lZGlhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDSrAO1CoPvUllgLOzDm5nG0skDF7vh1DUgAIDVGz0ecD0JFbQx +EF79pju/6MbtpTW2FYvRp11t/G7rGtX923ybOHY/1MNFQrdIvPlO1VV7IGKjoMwP +DNeb0fIGjHoE9QxaDxR8NX8xQbItpsw+TUtRfc9SLkR+jaYJfVRoM21BOncZbSHE +YKiZlEbpecB/+EtwVpgvl+8mPD5U07Fi4fp/lza3WXInXQPyiTVllIEJCt4PKmlu +MocNaJOW38bysL7i0PzDpVZtOxLHOTaW68yF3FckIHNCaA7k1ABEEEegjFMmIao7 +B9w7A0jvr4jZVvNmui5Djjn+oJxwEVVgyf8LAgMBAAGjUDBOMB0GA1UdDgQWBBQd +IdRL7GsNr365kmLqAk5guBBOmTAfBgNVHSMEGDAWgBRk81s9d0ZbiZhh44KckwPb +oTc0XzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBZQTK0plfdB5PC +cC5icut4EmrByJa1RbU7ayuEE70e7hla6KVmVjVdCBGltI4jBYwfhKbRItHiAJ/8 +x+XZKBG8DLPFuDb7lAa1ObhAYF7YThUFPQYaBhfzKcWrdmWDBFpvNv6E0Mm364dZ +e7Yxmbe5S4agkYPoxEzgEYmcUk9jbjdR6eTbs8laG169ljrECXfEU9RiAcqz5iSX +NLSewqB47hn3B9qgKcQn+PsgO2j7M+rfklhNgeGJeWmy7j6clSOuCsIjWHU0RLQ4 +0W3SB/rpEAJ7fgQbYUPTIUNALSOWi/o1tDX2mXPRjBoxqAv7I+vYk1lZPmSzkyRh +FKvRDxsW +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIJAJ0MomS4Ck+8MA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV +BAMMDXJvb3RhdXRob3JpdHkwHhcNMTUwNTE4MDkwMDQwWhcNMjMwODA0MDkwMDQw +WjAYMRYwFAYDVQQDDA1yb290YXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAts1ijtBV92S2cOvpUMOSTp9c6A34nIGr0T5Nhz6XiqRVT+gv +dQgmkdKJQjbvR60y6jzltYFsI2MpGVXY8h/oAL81D/k7PDB2aREgyBfTPAhBHyGw +siR+2xYt5b/Zs99q5RdRqQNzNpLPJriIKvUsRyQWy1UiG2s7pRXQeA8qB0XtJdCj +kFIi+G2bDsaffspGeDOCqt7t+yqvRXfSES0c/l7DIHaiMbbp4//ZNML3RNgAjPz2 +hCezZ+wOYajOIyoSPK8IgICrhYFYxvgWxwbLDBEfC5B3jOQsySe10GoRAKZz1gBV +DmgReu81tYJmdgkc9zknnQtIFdA0ex+GvZlfWQIDAQABo1AwTjAdBgNVHQ4EFgQU +ZPNbPXdGW4mYYeOCnJMD26E3NF8wHwYDVR0jBBgwFoAUZPNbPXdGW4mYYeOCnJMD +26E3NF8wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATzkZ97K777uZ +lQcduNX3ey4IbCiEzFA2zO5Blj+ilfIwNbZXNOgm/lqNvVGDYs6J1apJJe30vL3X +J+t2zsZWzzQzb9uIU37zYemt6m0fHrSrx/iy5lGNqt3HMfqEcOqSCOIK3PCTMz2/ +uyGe1iw33PVeWsm1JUybQ9IrU/huJjbgOHU4wab+8SJCM49ipArp68Fr6j4lcEaE +4rfRg1ZsvxiOyUB3qPn6wyL/JB8kOJ+QCBe498376eaem8AEFk0kQRh6hDaWtq/k +t6IIXQLjx+EBDVP/veK0UnVhKRP8YTOoV8ZiG1NcdlJmX/Uk7iAfevP7CkBfSN8W +r6AL284qtw== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/framework/example/controller.dart b/framework/example/controller.dart new file mode 100644 index 00000000..1e0da928 --- /dev/null +++ b/framework/example/controller.dart @@ -0,0 +1,59 @@ +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:logging/logging.dart'; + +main() async { + // Logging set up/boilerplate + Logger.root.onRecord.listen(print); + + // Create our server. + var app = Angel(logger: Logger('angel'), reflector: MirrorsReflector()); + var http = AngelHttp(app); + + await app.mountController(); + + // Simple fallback to throw a 404 on unknown paths. + app.fallback((req, res) { + throw AngelHttpException.notFound( + message: 'Unknown path: "${req.uri.path}"', + ); + }); + + app.errorHandler = (e, req, res) => e.toJson(); + + await http.startServer('127.0.0.1', 3000); + print('Listening at ${http.uri}'); + app.dumpTree(); +} + +class ArtistsController extends Controller { + List index() { + return ['Elvis', 'Stevie', 'Van Gogh']; + } + + String getById(int id, RequestContext req) { + return 'You fetched ID: $id from IP: ${req.ip}'; + } + + @Expose.post + form(RequestContext req) async { + // Deserialize the body into an artist. + var artist = await req.deserializeBody((m) { + return Artist(name: m['name'] as String ?? '(unknown name)'); + }); + + // Return it (it will be serialized to JSON). + return artist; + } +} + +class Artist { + final String name; + + Artist({this.name}); + + Map toJson() { + return {'name': name}; + } +} diff --git a/framework/example/handle_error.dart b/framework/example/handle_error.dart new file mode 100644 index 00000000..e159a3ce --- /dev/null +++ b/framework/example/handle_error.dart @@ -0,0 +1,25 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:logging/logging.dart'; + +main() async { + var app = Angel(reflector: MirrorsReflector()) + ..logger = (Logger('angel') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + })) + ..encoders.addAll({'gzip': gzip.encoder}); + + app.fallback( + (req, res) => Future.error('Throwing just because I feel like!')); + + var http = AngelHttp(app); + var server = await http.startServer('127.0.0.1', 3000); + var url = 'http://${server.address.address}:${server.port}'; + print('Listening at $url'); +} diff --git a/framework/example/hostname.dart b/framework/example/hostname.dart new file mode 100644 index 00000000..80628642 --- /dev/null +++ b/framework/example/hostname.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:logging/logging.dart'; +import 'package:pretty_logging/pretty_logging.dart'; + +Future apiConfigurer(Angel app) async { + app.get('/', (req, res) => 'Hello, API!'); + app.fallback((req, res) { + return 'fallback on ${req.uri} (within the API)'; + }); +} + +Future frontendConfigurer(Angel app) async { + app.fallback((req, res) => '(usually an index page would be shown here.)'); +} + +main() async { + // Logging set up/boilerplate + hierarchicalLoggingEnabled = true; + Logger.root.onRecord.listen(prettyLog); + + var app = Angel(logger: Logger('angel')); + var http = AngelHttp(app); + var multiHost = HostnameRouter.configure({ + 'api.localhost:3000': apiConfigurer, + 'localhost:3000': frontendConfigurer, + }); + + app + ..fallback(multiHost.handleRequest) + ..fallback((req, res) { + res.write('Uncaught hostname: ${req.hostname}'); + }); + + app.errorHandler = (e, req, res) { + print(e.message ?? e.error ?? e); + print(e.stackTrace); + return e.toJson(); + }; + + await http.startServer('127.0.0.1', 3000); + print('Listening at ${http.uri}'); + print('See what happens when you visit http://localhost:3000 instead ' + 'of http://127.0.0.1:3000. Then, try ' + 'http://api.localhost:3000.'); +} diff --git a/framework/example/http2/body_parsing.dart b/framework/example/http2/body_parsing.dart new file mode 100644 index 00000000..a0c27a9c --- /dev/null +++ b/framework/example/http2/body_parsing.dart @@ -0,0 +1,46 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_framework/http2.dart'; +import 'package:file/local.dart'; +import 'package:logging/logging.dart'; + +main() async { + var app = Angel(); + app.logger = Logger('angel') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + + var publicDir = Directory('example/public'); + var indexHtml = + const LocalFileSystem().file(publicDir.uri.resolve('body_parsing.html')); + + app.get('/', (req, res) => res.streamFile(indexHtml)); + + app.post('/', (req, res) => req.parseBody().then((_) => req.bodyAsMap)); + + var ctx = SecurityContext() + ..useCertificateChain('dev.pem') + ..usePrivateKey('dev.key', password: 'dartdart'); + + try { + ctx.setAlpnProtocols(['h2'], true); + } catch (e, st) { + app.logger.severe( + 'Cannot set ALPN protocol on server to `h2`. The server will only serve HTTP/1.x.', + e, + st); + } + + var http1 = AngelHttp(app); + var http2 = AngelHttp2(app, ctx); + + // HTTP/1.x requests will fallback to `AngelHttp` + http2.onHttp1.listen(http1.handleRequest); + + var server = await http2.startServer('127.0.0.1', 3000); + print('Listening at https://${server.address.address}:${server.port}'); +} diff --git a/framework/example/http2/common.dart b/framework/example/http2/common.dart new file mode 100644 index 00000000..b937b43f --- /dev/null +++ b/framework/example/http2/common.dart @@ -0,0 +1,7 @@ +import 'package:logging/logging.dart'; + +void dumpError(LogRecord rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); +} diff --git a/framework/example/http2/dev.key b/framework/example/http2/dev.key new file mode 100644 index 00000000..5d49ae7e --- /dev/null +++ b/framework/example/http2/dev.key @@ -0,0 +1,29 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIE5DAcBgoqhkiG9w0BDAEBMA4ECL7L6rj6uEHGAgIIAASCBMLbucyfqAkgCbhP +xNSHYllPMAv/dsIjtnsBwepCXPGkCBCuOAw/2FaCHjN9hBqL5V7fkrKeaemhm2YE +ycPtlHJYPDf3kEkyMjdZ9rIY6kePGfQizs2uJPcXj4YPyQ4HsfVXpOicKfQrouf5 +Mze9bGzeMN065q3iP4dYUMwHAyZYteXCsanQNHlqvsWli0W+H8St8fdsXefZhnv1 +qVatKWdNdWQ9t5MuljgNU2Vv56sHKEYXI0yLxk2QUMk8KlJfnmt8foYUsnPUXHmc +gIjLKwwVkpdololnEHSNu0cEOUPowjgJru+uMpn7vdNl7TPEQ9jbEgdNg4JwoYzU +0nao8WzjaSp7kzvZz0VFwKnk5AjstGvvuAWckADdq23QElbn/mF7AG1m/TBpYxzF +gTt37UdndS/AcvVznWVVrRP5iTSIawdIwvqI4s7rqsoE0GCcak+RhchgAz2gWKkS +oODUo0JL6pPVbJ3l4ebbaO6c99nDVc8dViPtc1EkStJEJ2O4kI4xgLSCr4Y9ahKn +oAaoSkX7Xxq3aQm+BzqSpLjdGL8atsqR/YVOIHYIl3gThvP0NfZGx1xHyvO5mCdZ +kHxSA7tKWxauZ3eQ2clbnzeRsl4El0WMHy/5K1ovene4v7sunmoXVtghBC8hK6eh +zMO9orex2PNQ/VQC7HCvtytunOVx1lkSBoNo7hR70igg6rW9H7UyoAoBOwMpT1xa +J6V62nqruTKOqFNfur7aHJGpHGtDb5/ickHeYCyPTvmGp67u4wChzKReeg02oECe +d1E5FKAcIa8s9TVOB6Z+HvTRNQZu2PsI6TJnjQRowvY9DAHiWTlJZBBY/pko3hxX +TsIeybpvRdEHpDWv86/iqtw1hv9CUxS/8ZTWUgBo+osShHW79FeDASr9FC4/Zn76 +ZDERTgV4YWlW/klVWcG2lFo7jix+OPXAB+ZQavLhlN1xdWBcIz1AUWjAM4hdPylW +HCX4PB9CQIPl2E7F+Y2p6nMcMWSJVBi5UIH7E9LfaBguXSzMmTk2Fw5p1aOQ6wfN +goVAMVwi8ppAVs741PfHdZ295xMmK/1LCxz5DeAdD/tsA/SYfT753GotioDuC7im +EyJ5JyvTr5I6RFFBuqt3NlUb3Hp16wP3B2x9DZiB6jxr0l341/NHgsyeBXkuIy9j +ON2mvpBPCJhS8kgWo3G0UyyKnx64tcgpGuSvZhGwPz843B6AbYyE6pMRfSWRMkMS +YZYa+VNKhR4ixdj07ocFZEWLVjCH7kxkE8JZXKt8jKYmkWd0lS1QVjgaKlO6lRa3 +q6SPJkhW6pvqobvcqVNXwi1XuzpZeEbuh0B7OTekFTTxx5g9XeDl56M8SVQ1KEhT +Q1t7H2Nba18WCB7cf+6PN0F0K0Jz1Kq7ZWaqEI/grX1m4RQuvNF5807sB/QKMO/Z +Gz3NXvHg5xTJRd/567lxPGkor0cE7qD1EZfmJ2HrBYXQ91bhgA7LToBuMZo6ZRXH +QfsanjbP4FPLMiGdQigLjj3A35L/f4sQOOVac/sRaFnm7pzcxsMvyVU/YtvGcjYE +xaOOVnamg661Wo0wksXoDjeSz/JIyyKO3Gwp1FSm2wGLjjy/Ehmqcqy8rvHuf07w +AUukhVtTNn4= +-----END ENCRYPTED PRIVATE KEY----- \ No newline at end of file diff --git a/framework/example/http2/dev.pem b/framework/example/http2/dev.pem new file mode 100644 index 00000000..01756b25 --- /dev/null +++ b/framework/example/http2/dev.pem @@ -0,0 +1,57 @@ +-----BEGIN CERTIFICATE----- +MIIDKTCCAhGgAwIBAgIJAOWmjTS+OnTEMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV +BAMMDGludGVybWVkaWF0ZTAeFw0xNTA1MTgwOTAwNDBaFw0yMzA4MDQwOTAwNDBa +MBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBALlcwQJuzd+xH8QFgfJSn5tRlvhkldSX98cE7NiA602NBbnAVyUrkRXq +Ni75lgt0kwjYfA9z674m8WSVbgpLPintPCla9CYky1TH0keIs8Rz6cGWHryWEHiu +EDuljQynu2b3sAFuHu9nfWurbJwZnFakBKpdQ9m4EyOZCHC/jHYY7HacKSXg1Cki +we2ca0BWDrcqy8kLy0dZ5oC6IZG8O8drAK8f3f44CRYw59D3sOKBrKXaabpvyEcb +N7Wk2HDBVwHpUJo1reVwtbM8dhqQayYSD8oXnGpP3RQNu/e2rzlXRyq/BfcDY1JI +7TbC4t/7/N4EcPSpGsTcSOC9A7FpzvECAwEAAaN7MHkwCQYDVR0TBAIwADAsBglg +hkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0O +BBYEFCnwiEMMFZh7NhCr+qA8K0w4Q+AOMB8GA1UdIwQYMBaAFB0h1Evsaw2vfrmS +YuoCTmC4EE6ZMA0GCSqGSIb3DQEBCwUAA4IBAQAcFmHMaXRxyoNaeOowQ6iQWoZd +AUbvG7SHr7I6Pi2aqdqofsKWts7Ytm5WsS0M2nN+sW504houu0iCPeJJX8RQw2q4 +CCcNOs9IXk+2uMzlpocHpv+yYoUiD5DxgWh7eghQMLyMpf8FX3Gy4VazeuXznHOM +4gE4L417xkDzYOzqVTp0FTyAPUv6G2euhNCD6TMru9REcRhYul+K9kocjA5tt2KG +MH6y28LXbLyq4YJUxSUU9gY/xlnbbZS48KDqEcdYC9zjW9nQ0qS+XQuQuFIcwjJ5 +V4kAUYxDu6FoTpyQjgsrmBbZlKNxH7Nj4NDlcdJhp/zeSKHqWa5hSWjjKIxp +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDAjCCAeqgAwIBAgIJAOWmjTS+OnTDMA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV +BAMMDXJvb3RhdXRob3JpdHkwHhcNMTUwNTE4MDkwMDQwWhcNMjMwODA0MDkwMDQw +WjAXMRUwEwYDVQQDDAxpbnRlcm1lZGlhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDSrAO1CoPvUllgLOzDm5nG0skDF7vh1DUgAIDVGz0ecD0JFbQx +EF79pju/6MbtpTW2FYvRp11t/G7rGtX923ybOHY/1MNFQrdIvPlO1VV7IGKjoMwP +DNeb0fIGjHoE9QxaDxR8NX8xQbItpsw+TUtRfc9SLkR+jaYJfVRoM21BOncZbSHE +YKiZlEbpecB/+EtwVpgvl+8mPD5U07Fi4fp/lza3WXInXQPyiTVllIEJCt4PKmlu +MocNaJOW38bysL7i0PzDpVZtOxLHOTaW68yF3FckIHNCaA7k1ABEEEegjFMmIao7 +B9w7A0jvr4jZVvNmui5Djjn+oJxwEVVgyf8LAgMBAAGjUDBOMB0GA1UdDgQWBBQd +IdRL7GsNr365kmLqAk5guBBOmTAfBgNVHSMEGDAWgBRk81s9d0ZbiZhh44KckwPb +oTc0XzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBZQTK0plfdB5PC +cC5icut4EmrByJa1RbU7ayuEE70e7hla6KVmVjVdCBGltI4jBYwfhKbRItHiAJ/8 +x+XZKBG8DLPFuDb7lAa1ObhAYF7YThUFPQYaBhfzKcWrdmWDBFpvNv6E0Mm364dZ +e7Yxmbe5S4agkYPoxEzgEYmcUk9jbjdR6eTbs8laG169ljrECXfEU9RiAcqz5iSX +NLSewqB47hn3B9qgKcQn+PsgO2j7M+rfklhNgeGJeWmy7j6clSOuCsIjWHU0RLQ4 +0W3SB/rpEAJ7fgQbYUPTIUNALSOWi/o1tDX2mXPRjBoxqAv7I+vYk1lZPmSzkyRh +FKvRDxsW +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIJAJ0MomS4Ck+8MA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV +BAMMDXJvb3RhdXRob3JpdHkwHhcNMTUwNTE4MDkwMDQwWhcNMjMwODA0MDkwMDQw +WjAYMRYwFAYDVQQDDA1yb290YXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAts1ijtBV92S2cOvpUMOSTp9c6A34nIGr0T5Nhz6XiqRVT+gv +dQgmkdKJQjbvR60y6jzltYFsI2MpGVXY8h/oAL81D/k7PDB2aREgyBfTPAhBHyGw +siR+2xYt5b/Zs99q5RdRqQNzNpLPJriIKvUsRyQWy1UiG2s7pRXQeA8qB0XtJdCj +kFIi+G2bDsaffspGeDOCqt7t+yqvRXfSES0c/l7DIHaiMbbp4//ZNML3RNgAjPz2 +hCezZ+wOYajOIyoSPK8IgICrhYFYxvgWxwbLDBEfC5B3jOQsySe10GoRAKZz1gBV +DmgReu81tYJmdgkc9zknnQtIFdA0ex+GvZlfWQIDAQABo1AwTjAdBgNVHQ4EFgQU +ZPNbPXdGW4mYYeOCnJMD26E3NF8wHwYDVR0jBBgwFoAUZPNbPXdGW4mYYeOCnJMD +26E3NF8wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATzkZ97K777uZ +lQcduNX3ey4IbCiEzFA2zO5Blj+ilfIwNbZXNOgm/lqNvVGDYs6J1apJJe30vL3X +J+t2zsZWzzQzb9uIU37zYemt6m0fHrSrx/iy5lGNqt3HMfqEcOqSCOIK3PCTMz2/ +uyGe1iw33PVeWsm1JUybQ9IrU/huJjbgOHU4wab+8SJCM49ipArp68Fr6j4lcEaE +4rfRg1ZsvxiOyUB3qPn6wyL/JB8kOJ+QCBe498376eaem8AEFk0kQRh6hDaWtq/k +t6IIXQLjx+EBDVP/veK0UnVhKRP8YTOoV8ZiG1NcdlJmX/Uk7iAfevP7CkBfSN8W +r6AL284qtw== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/framework/example/http2/main.dart b/framework/example/http2/main.dart new file mode 100644 index 00000000..d8218ae2 --- /dev/null +++ b/framework/example/http2/main.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_framework/http2.dart'; +import 'package:logging/logging.dart'; +import 'common.dart'; + +main() async { + var app = Angel() + ..encoders.addAll({ + 'gzip': gzip.encoder, + 'deflate': zlib.encoder, + }); + app.logger = Logger('angel')..onRecord.listen(dumpError); + + app.get('/', (req, res) => 'Hello HTTP/2!!!'); + + app.fallback((req, res) => throw AngelHttpException.notFound( + message: 'No file exists at ${req.uri}')); + + var ctx = SecurityContext() + ..useCertificateChain('dev.pem') + ..usePrivateKey('dev.key', password: 'dartdart'); + + try { + ctx.setAlpnProtocols(['h2'], true); + } catch (e, st) { + app.logger.severe( + 'Cannot set ALPN protocol on server to `h2`. The server will only serve HTTP/1.x.', + e, + st, + ); + } + + var http1 = AngelHttp(app); + var http2 = AngelHttp2(app, ctx); + + // HTTP/1.x requests will fallback to `AngelHttp` + http2.onHttp1.listen(http1.handleRequest); + + await http2.startServer('127.0.0.1', 3000); + print('Listening at ${http2.uri}'); +} diff --git a/framework/example/http2/pretty_logging.dart b/framework/example/http2/pretty_logging.dart new file mode 100644 index 00000000..14f55e3f --- /dev/null +++ b/framework/example/http2/pretty_logging.dart @@ -0,0 +1,9 @@ +import 'package:logging/logging.dart'; + +/// Prints the contents of a [LogRecord] with pretty colors. +void prettyLog(LogRecord record) { + print(record.toString()); + + if (record.error != null) print(record.error.toString()); + if (record.stackTrace != null) print(record.stackTrace.toString()); +} diff --git a/framework/example/http2/public/app.js b/framework/example/http2/public/app.js new file mode 100644 index 00000000..036c6dc3 --- /dev/null +++ b/framework/example/http2/public/app.js @@ -0,0 +1,27 @@ +window.onload = function() { + var $app = document.getElementById('app'); + var $loading = document.getElementById('loading'); + $app.removeChild($loading); + var $button = document.createElement('button'); + var $h1 = document.createElement('h1'); + $app.appendChild($h1); + $app.appendChild($button); + + $h1.textContent = '~Angel HTTP/2 server push~'; + + $button.textContent = 'Change color'; + $button.onclick = function() { + var color = Math.floor(Math.random() * 0xffffff); + $h1.style.color = '#' + color.toString(16); + }; + + $button.onclick(); + + window.setInterval($button.onclick, 2000); + + var rotation = 0; + window.setInterval(function() { + rotation += .6; + $button.style.transform = 'rotate(' + rotation + 'deg)'; + }, 10); +}; \ No newline at end of file diff --git a/framework/example/http2/public/body_parsing.html b/framework/example/http2/public/body_parsing.html new file mode 100644 index 00000000..941d21c1 --- /dev/null +++ b/framework/example/http2/public/body_parsing.html @@ -0,0 +1,21 @@ + + + + + Angel HTTP/2 + + + +
+ + + + + +
+ + \ No newline at end of file diff --git a/framework/example/http2/public/index.html b/framework/example/http2/public/index.html new file mode 100644 index 00000000..f0fe7160 --- /dev/null +++ b/framework/example/http2/public/index.html @@ -0,0 +1,12 @@ + + + + + Angel HTTP/2 + + + +
Loading...
+ + + \ No newline at end of file diff --git a/framework/example/http2/public/style.css b/framework/example/http2/public/style.css new file mode 100644 index 00000000..e4348132 --- /dev/null +++ b/framework/example/http2/public/style.css @@ -0,0 +1,20 @@ +button { + margin-top: 2em; +} + +html, body { + background-color: #000; +} + +#app { + text-align: center; +} + +#app h1 { + font-style: italic; + text-decoration: underline; +} + +#loading { + color: red; +} \ No newline at end of file diff --git a/framework/example/http2/server_push.dart b/framework/example/http2/server_push.dart new file mode 100644 index 00000000..41a1170f --- /dev/null +++ b/framework/example/http2/server_push.dart @@ -0,0 +1,62 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_framework/http2.dart'; +import 'package:file/local.dart'; +import 'package:logging/logging.dart'; + +main() async { + var app = Angel(); + app.logger = Logger('angel') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + + var publicDir = Directory('example/http2/public'); + var indexHtml = + const LocalFileSystem().file(publicDir.uri.resolve('index.html')); + var styleCss = + const LocalFileSystem().file(publicDir.uri.resolve('style.css')); + var appJs = const LocalFileSystem().file(publicDir.uri.resolve('app.js')); + + // Send files when requested + app + ..get('/style.css', (req, res) => res.streamFile(styleCss)) + ..get('/app.js', (req, res) => res.streamFile(appJs)); + + app.get('/', (req, res) async { + // Regardless of whether we pushed other resources, let's still send /index.html. + await res.streamFile(indexHtml); + + // If the client is HTTP/2 and supports server push, let's + // send down /style.css and /app.js as well, to improve initial load time. + if (res is Http2ResponseContext && res.canPush) { + await res.push('/style.css').streamFile(styleCss); + await res.push('/app.js').streamFile(appJs); + } + }); + + var ctx = SecurityContext() + ..useCertificateChain('dev.pem') + ..usePrivateKey('dev.key', password: 'dartdart'); + + try { + ctx.setAlpnProtocols(['h2'], true); + } catch (e, st) { + app.logger.severe( + 'Cannot set ALPN protocol on server to `h2`. The server will only serve HTTP/1.x.', + e, + st); + } + + var http1 = AngelHttp(app); + var http2 = AngelHttp2(app, ctx); + + // HTTP/1.x requests will fallback to `AngelHttp` + http2.onHttp1.listen(http1.handleRequest); + + var server = await http2.startServer('127.0.0.1', 3000); + print('Listening at https://${server.address.address}:${server.port}'); +} diff --git a/framework/example/json.dart b/framework/example/json.dart new file mode 100644 index 00000000..f71a48dc --- /dev/null +++ b/framework/example/json.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; + +main() async { + int x = 0; + var c = Completer(); + var exit = ReceivePort(); + List isolates = []; + + exit.listen((_) { + if (++x >= 50) { + c.complete(); + } + }); + + for (int i = 1; i < Platform.numberOfProcessors; i++) { + var isolate = await Isolate.spawn(serverMain, null); + isolates.add(isolate); + print('Spawned isolate #${i + 1}...'); + + isolate.addOnExitListener(exit.sendPort); + } + + serverMain(null); + + print('Angel listening at http://localhost:3000'); + await c.future; +} + +serverMain(_) async { + var app = Angel(); + var http = + AngelHttp.custom(app, startShared, useZone: false); // Run a cluster + + app.get('/', (req, res) { + return res.serialize({ + "foo": "bar", + "one": [2, "three"], + "bar": {"baz": "quux"} + }); + }); + + app.errorHandler = (e, req, res) { + print(e.message ?? e.error ?? e); + print(e.stackTrace); + }; + + var server = await http.startServer('127.0.0.1', 3000); + print('Listening at http://${server.address.address}:${server.port}'); +} diff --git a/framework/example/main.dart b/framework/example/main.dart new file mode 100644 index 00000000..f500270b --- /dev/null +++ b/framework/example/main.dart @@ -0,0 +1,59 @@ +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:logging/logging.dart'; +import 'package:pretty_logging/pretty_logging.dart'; + +main() async { + // Logging set up/boilerplate + Logger.root.onRecord.listen(prettyLog); + + // Create our server. + var app = Angel( + logger: Logger('angel'), + reflector: MirrorsReflector(), + ); + + // Index route. Returns JSON. + app.get('/', (req, res) => 'Welcome to Angel!'); + + // Accepts a URL like /greet/foo or /greet/bob. + app.get( + '/greet/:name', + (req, res) { + var name = req.params['name']; + res + ..write('Hello, $name!') + ..close(); + }, + ); + + // Pattern matching - only call this handler if the query value of `name` equals 'emoji'. + app.get( + '/greet', + ioc((@Query('name', match: 'emoji') String name) => '😇🔥🔥🔥'), + ); + + // Handle any other query value of `name`. + app.get( + '/greet', + ioc((@Query('name') String name) => 'Hello, $name!'), + ); + + // Simple fallback to throw a 404 on unknown paths. + app.fallback((req, res) { + throw AngelHttpException.notFound( + message: 'Unknown path: "${req.uri.path}"', + ); + }); + + var http = AngelHttp(app); + var server = await http.startServer('127.0.0.1', 3000); + var url = 'http://${server.address.address}:${server.port}'; + print('Listening at $url'); + print('Visit these pages to see Angel in action:'); + print('* $url/greet/bob'); + print('* $url/greet/?name=emoji'); + print('* $url/greet/?name=jack'); + print('* $url/nonexistent_page'); +} diff --git a/framework/example/map_service.dart b/framework/example/map_service.dart new file mode 100644 index 00000000..4650dd09 --- /dev/null +++ b/framework/example/map_service.dart @@ -0,0 +1,22 @@ +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:logging/logging.dart'; + +main() async { + // Logging set up/boilerplate + Logger.root.onRecord.listen(print); + + // Create our server. + var app = Angel( + logger: Logger('angel'), + reflector: MirrorsReflector(), + ); + + // Create a RESTful service that manages an in-memory collection. + app.use('/api/todos', MapService()); + + var http = AngelHttp(app); + await http.startServer('127.0.0.1', 0); + print('Listening at ${http.uri}'); +} diff --git a/framework/example/status.dart b/framework/example/status.dart new file mode 100644 index 00000000..a434dd90 --- /dev/null +++ b/framework/example/status.dart @@ -0,0 +1,14 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; + +main() async { + var app = Angel(); + var http = AngelHttp(app); + + app.fallback((req, res) { + res.statusCode = 304; + }); + + await http.startServer('127.0.0.1', 3000); + print('Listening at ${http.uri}'); +} diff --git a/framework/example/view.dart b/framework/example/view.dart new file mode 100644 index 00000000..c90fd3cd --- /dev/null +++ b/framework/example/view.dart @@ -0,0 +1,18 @@ +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; + +main() async { + var app = Angel(reflector: MirrorsReflector()); + + app.viewGenerator = (name, [data]) async => + 'View generator invoked with name $name and data: $data'; + + // Index route. Returns JSON. + app.get('/', (req, res) => res.render('index', {'foo': 'bar'})); + + var http = AngelHttp(app); + var server = await http.startServer('127.0.0.1', 3000); + var url = 'http://${server.address.address}:${server.port}'; + print('Listening at $url'); +} diff --git a/framework/example/views/index.jl b/framework/example/views/index.jl new file mode 100644 index 00000000..53ac639b --- /dev/null +++ b/framework/example/views/index.jl @@ -0,0 +1,9 @@ + + + + Title + + +

Hello!

+ + \ No newline at end of file diff --git a/framework/lib/angel_framework.dart b/framework/lib/angel_framework.dart new file mode 100644 index 00000000..60a495e6 --- /dev/null +++ b/framework/lib/angel_framework.dart @@ -0,0 +1,7 @@ +/// An easily-extensible web server framework in Dart. +library angel_framework; + +export 'package:angel_http_exception/angel_http_exception.dart'; +export 'package:angel_model/angel_model.dart'; +export 'package:angel_route/angel_route.dart'; +export 'src/core/core.dart'; diff --git a/framework/lib/http.dart b/framework/lib/http.dart new file mode 100644 index 00000000..5f00efff --- /dev/null +++ b/framework/lib/http.dart @@ -0,0 +1 @@ +export 'src/http/http.dart'; diff --git a/framework/lib/http2.dart b/framework/lib/http2.dart new file mode 100644 index 00000000..e21891f2 --- /dev/null +++ b/framework/lib/http2.dart @@ -0,0 +1,3 @@ +export 'src/http2/angel_http2.dart'; +export 'src/http2/http2_request_context.dart'; +export 'src/http2/http2_response_context.dart'; diff --git a/framework/lib/src/core/anonymous_service.dart b/framework/lib/src/core/anonymous_service.dart new file mode 100644 index 00000000..f4d86f72 --- /dev/null +++ b/framework/lib/src/core/anonymous_service.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'request_context.dart'; +import 'response_context.dart'; +import 'service.dart'; + +/// An easy helper class to create one-off services without having to create an entire class. +/// +/// Well-suited for testing. +class AnonymousService extends Service { + FutureOr> Function([Map]) _index; + FutureOr Function(Id, [Map]) _read, _remove; + FutureOr Function(Data, [Map]) _create; + FutureOr Function(Id, Data, [Map]) _modify, _update; + + AnonymousService( + {FutureOr> index([Map params]), + FutureOr read(Id id, [Map params]), + FutureOr create(Data data, [Map params]), + FutureOr modify(Id id, Data data, [Map params]), + FutureOr update(Id id, Data data, [Map params]), + FutureOr remove(Id id, [Map params]), + FutureOr Function(RequestContext, ResponseContext) readData}) + : super(readData: readData) { + _index = index; + _read = read; + _create = create; + _modify = modify; + _update = update; + _remove = remove; + } + + @override + index([Map params]) => + Future.sync(() => _index != null ? _index(params) : super.index(params)); + + @override + read(Id id, [Map params]) => Future.sync( + () => _read != null ? _read(id, params) : super.read(id, params)); + + @override + create(Data data, [Map params]) => Future.sync(() => + _create != null ? _create(data, params) : super.create(data, params)); + + @override + modify(Id id, Data data, [Map params]) => + Future.sync(() => _modify != null + ? _modify(id, data, params) + : super.modify(id, data, params)); + + @override + update(Id id, Data data, [Map params]) => + Future.sync(() => _update != null + ? _update(id, data, params) + : super.update(id, data, params)); + + @override + remove(Id id, [Map params]) => Future.sync( + () => _remove != null ? _remove(id, params) : super.remove(id, params)); +} diff --git a/framework/lib/src/core/controller.dart b/framework/lib/src/core/controller.dart new file mode 100644 index 00000000..e531b12b --- /dev/null +++ b/framework/lib/src/core/controller.dart @@ -0,0 +1,233 @@ +library angel_framework.http.controller; + +import 'dart:async'; +import 'package:angel_container/angel_container.dart'; +import 'package:angel_route/angel_route.dart'; +import 'package:meta/meta.dart'; +import 'package:recase/recase.dart'; +import '../core/core.dart'; + +/// Supports grouping routes with shared functionality. +class Controller { + Angel _app; + + /// The [Angel] application powering this controller. + Angel get app => _app; + + /// If `true` (default), this class will inject itself as a singleton into the [app]'s container when bootstrapped. + final bool injectSingleton; + + /// Middleware to run before all handlers in this class. + List middleware = []; + + /// A mapping of route paths to routes, produced from the [Expose] annotations on this class. + Map routeMappings = {}; + + SymlinkRoute _mountPoint; + + /// The route at which this controller is mounted on the server. + SymlinkRoute get mountPoint => _mountPoint; + + Controller({this.injectSingleton = true}); + + /// Applies routes, DI, and other configuration to an [app]. + @mustCallSuper + Future configureServer(Angel app) async { + _app = app; + + if (injectSingleton != false) { + if (!app.container.has(runtimeType)) { + _app.container.registerSingleton(this, as: runtimeType); + } + } + + var name = await applyRoutes(app, app.container.reflector); + app.controllers[name] = this; + return null; + } + + /// Applies the routes from this [Controller] to some [router]. + Future applyRoutes( + Router router, Reflector reflector) async { + // Load global expose decl + var classMirror = reflector.reflectClass(this.runtimeType); + Expose exposeDecl = findExpose(reflector); + + if (exposeDecl == null) { + throw Exception("All controllers must carry an @Expose() declaration."); + } + + var routable = Routable(); + _mountPoint = router.mount(exposeDecl.path, routable); + var typeMirror = reflector.reflectType(this.runtimeType); + + // Pre-reflect methods + var instanceMirror = reflector.reflectInstance(this); + final handlers = [] + ..addAll(exposeDecl.middleware) + ..addAll(middleware); + final routeBuilder = + _routeBuilder(reflector, instanceMirror, routable, handlers); + await configureRoutes(routable); + classMirror.declarations.forEach(routeBuilder); + + // Return the name. + return exposeDecl.as?.isNotEmpty == true ? exposeDecl.as : typeMirror.name; + } + + void Function(ReflectedDeclaration) _routeBuilder( + Reflector reflector, + ReflectedInstance instanceMirror, + Routable routable, + Iterable handlers) { + return (ReflectedDeclaration decl) { + var methodName = decl.name; + + // Ignore built-in methods. + if (methodName != 'toString' && + methodName != 'noSuchMethod' && + methodName != 'call' && + methodName != 'equals' && + methodName != '==') { + var exposeDecl = decl.function.annotations + .map((m) => m.reflectee) + .firstWhere((r) => r is Expose, orElse: () => null) as Expose; + + if (exposeDecl == null) { + // If this has a @noExpose, return null. + if (decl.function.annotations.any((m) => m.reflectee is NoExpose)) { + return; + } else { + // Otherwise, create an @Expose. + exposeDecl = Expose(null); + } + } + + var reflectedMethod = + instanceMirror.getField(methodName).reflectee as Function; + var middleware = [] + ..addAll(handlers) + ..addAll(exposeDecl.middleware); + String name = + exposeDecl.as?.isNotEmpty == true ? exposeDecl.as : methodName; + + // Check if normal + var method = decl.function; + if (method.parameters.length == 2 && + method.parameters[0].type.reflectedType == RequestContext && + method.parameters[1].type.reflectedType == ResponseContext) { + // Create a regular route + routeMappings[name] = routable + .addRoute(exposeDecl.method, exposeDecl.path, + (RequestContext req, ResponseContext res) { + var result = reflectedMethod(req, res); + return result is RequestHandler ? result(req, res) : result; + }, middleware: middleware); + return; + } + + var injection = preInject(reflectedMethod, reflector); + + if (exposeDecl?.allowNull?.isNotEmpty == true) { + injection.optional?.addAll(exposeDecl.allowNull); + } + + // If there is no path, reverse-engineer one. + var path = exposeDecl.path; + var httpMethod = exposeDecl.method ?? 'GET'; + if (path == null) { + // Try to build a route path by finding all potential + // path segments, and then joining them. + var parts = []; + + // If the name starts with get/post/patch, etc., then that + // should be the path. + var methodMatch = _methods.firstMatch(method.name); + if (methodMatch != null) { + var rest = method.name.replaceAll(_methods, ''); + var restPath = ReCase(rest.isEmpty ? 'index' : rest) + .snakeCase + .replaceAll(_rgxMultipleUnderscores, '_'); + httpMethod = methodMatch[1].toUpperCase(); + + if (['index', 'by_id'].contains(restPath)) { + parts.add('/'); + } else { + parts.add(restPath); + } + } + // If the name does NOT start with get/post/patch, etc. then + // snake_case-ify the name, and add it to the list of segments. + // If the name is index, though, add "/". + else { + if (method.name == 'index') { + parts.add('/'); + } else { + parts.add(ReCase(method.name) + .snakeCase + .replaceAll(_rgxMultipleUnderscores, '_')); + } + } + + // Try to infer String, int, or double. We called + // preInject() earlier, so we can figure out the types + // of required parameters, and add those to the path. + for (var p in injection.required) { + if (p is List && p.length == 2 && p[0] is String && p[1] is Type) { + var name = p[0] as String; + var type = p[1] as Type; + if (type == String) { + parts.add(':$name'); + } else if (type == int) { + parts.add('int:$name'); + } else if (type == double) { + parts.add('double:$name'); + } + } + } + + path = parts.join('/'); + if (!path.startsWith('/')) path = '/$path'; + } + + routeMappings[name] = routable.addRoute( + httpMethod, path, handleContained(reflectedMethod, injection), + middleware: middleware); + } + }; + } + + /// Used to add additional routes or middlewares to the router from within + /// a [Controller]. + /// + /// ```dart + /// @override + /// FutureOr configureRoutes(Routable routable) { + /// routable.all('*', myMiddleware); + /// } + /// ``` + FutureOr configureRoutes(Routable routable) {} + + static final RegExp _methods = RegExp(r'^(get|post|patch|delete)'); + static final RegExp _rgxMultipleUnderscores = RegExp(r'__+'); + + /// Finds the [Expose] declaration for this class. + /// + /// If [concreteOnly] is `false`, then if there is no actual + /// [Expose], one will be automatically created. + Expose findExpose(Reflector reflector, {bool concreteOnly = false}) { + var existing = reflector + .reflectClass(runtimeType) + .annotations + .map((m) => m.reflectee) + .firstWhere((r) => r is Expose, orElse: () => null) as Expose; + return existing ?? + (concreteOnly + ? null + : Expose(ReCase(runtimeType.toString()) + .snakeCase + .replaceAll('_controller', '') + .replaceAll('_ctrl', '') + .replaceAll(_rgxMultipleUnderscores, '_'))); + } +} diff --git a/framework/lib/src/core/core.dart b/framework/lib/src/core/core.dart new file mode 100644 index 00000000..a7a0da4f --- /dev/null +++ b/framework/lib/src/core/core.dart @@ -0,0 +1,14 @@ +export 'anonymous_service.dart'; +export 'controller.dart'; +export 'driver.dart'; +export 'env.dart'; +export 'hooked_service.dart'; +export 'hostname_parser.dart'; +export 'hostname_router.dart'; +export 'map_service.dart'; +export 'metadata.dart'; +export 'request_context.dart'; +export 'response_context.dart'; +export 'routable.dart'; +export 'server.dart'; +export 'service.dart'; diff --git a/framework/lib/src/core/driver.dart b/framework/lib/src/core/driver.dart new file mode 100644 index 00000000..b15df8e5 --- /dev/null +++ b/framework/lib/src/core/driver.dart @@ -0,0 +1,372 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show stderr, Cookie; +import 'package:angel_http_exception/angel_http_exception.dart'; +import 'package:angel_route/angel_route.dart'; +import 'package:combinator/combinator.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:tuple/tuple.dart'; +import 'core.dart'; + +/// Base driver class for Angel implementations. +/// +/// Powers both AngelHttp and AngelHttp2. +abstract class Driver< + Request, + Response, + Server extends Stream, + RequestContextType extends RequestContext, + ResponseContextType extends ResponseContext> { + final Angel app; + final bool useZone; + bool _closed = false; + Server _server; + StreamSubscription _sub; + + /// The function used to bind this instance to a server.. + final Future Function(dynamic, int) serverGenerator; + + Driver(this.app, this.serverGenerator, {this.useZone = true}); + + /// The path at which this server is listening for requests. + Uri get uri; + + /// The native server running this instance. + Server get server => _server; + + Future generateServer(address, int port) => + serverGenerator(address, port); + + /// Starts, and returns the server. + Future startServer([address, int port]) { + var host = address ?? '127.0.0.1'; + return generateServer(host, port ?? 0).then((server) { + _server = server; + return Future.wait(app.startupHooks.map(app.configure)).then((_) { + app.optimizeForProduction(); + _sub = server.listen((request) { + var stream = createResponseStreamFromRawRequest(request); + stream.listen((response) { + return handleRawRequest(request, response); + }); + }); + return _server; + }); + }); + } + + /// Shuts down the underlying server. + Future close() { + if (_closed) return Future.value(_server); + _closed = true; + _sub?.cancel(); + return app.close().then((_) => + Future.wait(app.shutdownHooks.map(app.configure)).then((_) => _server)); + } + + Future createRequestContext( + Request request, Response response); + + Future createResponseContext( + Request request, Response response, + [RequestContextType correspondingRequest]); + + void setHeader(Response response, String key, String value); + + void setContentLength(Response response, int length); + + void setChunkedEncoding(Response response, bool value); + + void setStatusCode(Response response, int value); + + void addCookies(Response response, Iterable cookies); + + void writeStringToResponse(Response response, String value); + + void writeToResponse(Response response, List data); + + Future closeResponse(Response response); + + Stream createResponseStreamFromRawRequest(Request request); + + /// Handles a single request. + Future handleRawRequest(Request request, Response response) { + return createRequestContext(request, response).then((req) { + return createResponseContext(request, response, req).then((res) { + handle() { + var path = req.path; + if (path == '/') path = ''; + + Tuple4, ParseResult, + MiddlewarePipeline> resolveTuple() { + var r = app.optimizedRouter; + var resolved = + r.resolveAbsolute(path, method: req.method, strip: false); + var pipeline = MiddlewarePipeline(resolved); + return Tuple4( + pipeline.handlers, + resolved.fold>( + {}, (out, r) => out..addAll(r.allParams)), + resolved.isEmpty ? null : resolved.first.parseResult, + pipeline, + ); + } + + var cacheKey = req.method + path; + var tuple = app.environment.isProduction + ? app.handlerCache.putIfAbsent(cacheKey, resolveTuple) + : resolveTuple(); + var line = tuple.item4 as MiddlewarePipeline; + var it = MiddlewarePipelineIterator(line); + + req.params.addAll(tuple.item2); + + req.container + ..registerSingleton(req) + ..registerSingleton(res) + ..registerSingleton(tuple.item4) + ..registerSingleton>(line) + ..registerSingleton(it) + ..registerSingleton>(it) + ..registerSingleton>(tuple.item3) + ..registerSingleton(tuple.item3); + + if (!app.environment.isProduction && app.logger != null) { + req.container.registerSingleton(Stopwatch()..start()); + } + + return runPipeline(it, req, res, app) + .then((_) => sendResponse(request, response, req, res)); + } + + if (useZone == false) { + Future f; + + try { + f = handle(); + } catch (e, st) { + f = Future.error(e, st); + } + + return f.catchError((e, StackTrace st) { + if (e is FormatException) { + throw AngelHttpException.badRequest(message: e.message) + ..stackTrace = st; + } + throw AngelHttpException(e, + stackTrace: st, + statusCode: 500, + message: e?.toString() ?? '500 Internal Server Error'); + }, test: (e) => e is! AngelHttpException).catchError( + (ee, StackTrace st) { + var e = ee as AngelHttpException; + + if (app.logger != null) { + var error = e.error ?? e; + var trace = Trace.from(e.stackTrace ?? StackTrace.current).terse; + app.logger.severe(e.message ?? e.toString(), error, trace); + } + + return handleAngelHttpException( + e, e.stackTrace ?? st, req, res, request, response); + }); + } else { + var zoneSpec = ZoneSpecification( + print: (self, parent, zone, line) { + if (app.logger != null) { + app.logger.info(line); + } else { + parent.print(zone, line); + } + }, + handleUncaughtError: (self, parent, zone, error, stackTrace) { + var trace = Trace.from(stackTrace ?? StackTrace.current).terse; + + return Future(() { + AngelHttpException e; + + if (error is FormatException) { + e = AngelHttpException.badRequest(message: error.message); + } else if (error is AngelHttpException) { + e = error; + } else { + e = AngelHttpException(error, + stackTrace: stackTrace, + message: + error?.toString() ?? '500 Internal Server Error'); + } + + if (app.logger != null) { + app.logger.severe(e.message ?? e.toString(), error, trace); + } + + return handleAngelHttpException( + e, trace, req, res, request, response); + }).catchError((e, StackTrace st) { + var trace = Trace.from(st ?? StackTrace.current).terse; + closeResponse(response); + // Ideally, we won't be in a position where an absolutely fatal error occurs, + // but if so, we'll need to log it. + if (app.logger != null) { + app.logger.severe( + 'Fatal error occurred when processing $uri.', e, trace); + } else { + stderr + ..writeln('Fatal error occurred when processing ' + '${req.uri}:') + ..writeln(e) + ..writeln(trace); + } + }); + }, + ); + + var zone = Zone.current.fork(specification: zoneSpec); + req.container.registerSingleton(zone); + req.container.registerSingleton(zoneSpec); + + // If a synchronous error is thrown, it's not caught by `zone.run`, + // so use a try/catch, and recover when need be. + + try { + return zone.run(handle); + } catch (e, st) { + zone.handleUncaughtError(e, st); + return Future.value(); + } + } + }); + }); + } + + /// Handles an [AngelHttpException]. + Future handleAngelHttpException( + AngelHttpException e, + StackTrace st, + RequestContext req, + ResponseContext res, + Request request, + Response response, + {bool ignoreFinalizers = false}) { + if (req == null || res == null) { + try { + app.logger?.severe(null, e, st); + setStatusCode(response, 500); + writeStringToResponse(response, '500 Internal Server Error'); + closeResponse(response); + } finally { + return null; + } + } + + Future handleError; + + if (!res.isOpen) { + handleError = Future.value(); + } else { + res.statusCode = e.statusCode; + handleError = + Future.sync(() => app.errorHandler(e, req, res)).then((result) { + return app.executeHandler(result, req, res).then((_) => res.close()); + }); + } + + return handleError.then((_) => sendResponse(request, response, req, res, + ignoreFinalizers: ignoreFinalizers == true)); + } + + /// Sends a response. + Future sendResponse(Request request, Response response, RequestContext req, + ResponseContext res, + {bool ignoreFinalizers = false}) { + Future _cleanup(_) { + if (!app.environment.isProduction && + app.logger != null && + req.container.has()) { + var sw = req.container.make(); + app.logger.info( + "${res.statusCode} ${req.method} ${req.uri} (${sw?.elapsedMilliseconds ?? 'unknown'} ms)"); + } + return req.close(); + } + + if (!res.isBuffered) return res.close().then(_cleanup); + + Future finalizers = ignoreFinalizers == true + ? Future.value() + : Future.forEach(app.responseFinalizers, (f) => f(req, res)); + + return finalizers.then((_) { + //if (res.isOpen) res.close(); + + for (var key in res.headers.keys) { + setHeader(response, key, res.headers[key]); + } + + setContentLength(response, res.buffer.length); + setChunkedEncoding(response, res.chunked ?? true); + + List outputBuffer = res.buffer.toBytes(); + + if (res.encoders.isNotEmpty) { + var allowedEncodings = req.headers + .value('accept-encoding') + ?.split(',') + ?.map((s) => s.trim()) + ?.where((s) => s.isNotEmpty) + ?.map((str) { + // Ignore quality specifications in accept-encoding + // ex. gzip;q=0.8 + if (!str.contains(';')) return str; + return str.split(';')[0]; + }); + + if (allowedEncodings != null) { + for (var encodingName in allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (res.encoders.containsKey(encodingName)) { + encoder = res.encoders[encodingName]; + } else if (encodingName == '*') { + encoder = res.encoders[key = res.encoders.keys.first]; + } + + if (encoder != null) { + setHeader(response, 'content-encoding', key); + outputBuffer = res.encoders[key].convert(outputBuffer); + setContentLength(response, outputBuffer.length); + break; + } + } + } + } + + setStatusCode(response, res.statusCode); + addCookies(response, res.cookies); + writeToResponse(response, outputBuffer); + return closeResponse(response).then(_cleanup); + }); + } + + /// Runs a [MiddlewarePipeline]. + static Future runPipeline( + MiddlewarePipelineIterator it, + RequestContextType req, + ResponseContextType res, + Angel app) async { + var broken = false; + while (it.moveNext()) { + var current = it.current.handlers.iterator; + + while (!broken && current.moveNext()) { + var result = await app.executeHandler(current.current, req, res); + if (result != true) { + broken = true; + break; + } + } + } + } +} diff --git a/framework/lib/src/core/env.dart b/framework/lib/src/core/env.dart new file mode 100644 index 00000000..a513c93a --- /dev/null +++ b/framework/lib/src/core/env.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +/// A constant instance of [AngelEnv]. +const AngelEnvironment angelEnv = AngelEnvironment(); + +/// Queries the environment's `ANGEL_ENV` value. +class AngelEnvironment { + final String _customValue; + + /// You can optionally provide a custom value, in order to override the system's + /// value. + const AngelEnvironment([this._customValue]); + + /// Returns the value of the `ANGEL_ENV` variable; defaults to `'development'`. + String get value => + (_customValue ?? Platform.environment['ANGEL_ENV'] ?? 'development') + .toLowerCase(); + + /// Returns whether the [value] is `'development'`. + bool get isDevelopment => value == 'development'; + + /// Returns whether the [value] is `'production'`. + bool get isProduction => value == 'production'; + + /// Returns whether the [value] is `'staging'`. + bool get isStaging => value == 'staging'; +} diff --git a/framework/lib/src/core/hooked_service.dart b/framework/lib/src/core/hooked_service.dart new file mode 100644 index 00000000..91e6b99f --- /dev/null +++ b/framework/lib/src/core/hooked_service.dart @@ -0,0 +1,594 @@ +library angel_framework.core.hooked_service; + +import 'dart:async'; + +import '../util.dart'; +import 'metadata.dart'; +import 'request_context.dart'; +import 'response_context.dart'; +import 'routable.dart'; +import 'server.dart'; +import 'service.dart'; + +/// Wraps another service in a service that broadcasts events on actions. +class HookedService> + extends Service { + final List> _ctrl = []; + + /// Tbe service that is proxied by this hooked one. + final T inner; + + final HookedServiceEventDispatcher beforeIndexed = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher beforeRead = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher beforeCreated = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher beforeModified = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher beforeUpdated = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher beforeRemoved = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher afterIndexed = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher afterRead = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher afterCreated = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher afterModified = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher afterUpdated = + HookedServiceEventDispatcher(); + final HookedServiceEventDispatcher afterRemoved = + HookedServiceEventDispatcher(); + + HookedService(this.inner) { + // Clone app instance + if (inner.app != null) this.app = inner.app; + } + + @override + FutureOr Function(RequestContext, ResponseContext) get readData => + inner.readData; + + RequestContext _getRequest(Map params) { + if (params == null) return null; + return params['__requestctx'] as RequestContext; + } + + ResponseContext _getResponse(Map params) { + if (params == null) return null; + return params['__responsectx'] as ResponseContext; + } + + Map _stripReq(Map params) { + if (params == null) { + return params; + } else { + return params.keys + .where((key) => key != '__requestctx' && key != '__responsectx') + .fold>( + {}, (map, key) => map..[key] = params[key]); + } + } + + /// Closes any open [StreamController]s on this instance. **Internal use only**. + @override + Future close() { + _ctrl.forEach((c) => c.close()); + beforeIndexed._close(); + beforeRead._close(); + beforeCreated._close(); + beforeModified._close(); + beforeUpdated._close(); + beforeRemoved._close(); + afterIndexed._close(); + afterRead._close(); + afterCreated._close(); + afterModified._close(); + afterUpdated._close(); + afterRemoved._close(); + inner.close(); + return Future.value(); + } + + /// Adds hooks to this instance. + void addHooks(Angel app) { + var hooks = getAnnotation(inner, app.container.reflector); + List> before = [], after = []; + + if (hooks != null) { + before.addAll(hooks.before.cast()); + after.addAll(hooks.after.cast()); + } + + void applyListeners( + Function fn, HookedServiceEventDispatcher dispatcher, + [bool isAfter]) { + Hooks hooks = getAnnotation(fn, app.container.reflector); + final listeners = >[] + ..addAll(isAfter == true ? after : before); + + if (hooks != null) { + listeners.addAll((isAfter == true ? hooks.after : hooks.before).cast()); + } + + listeners.forEach(dispatcher.listen); + } + + applyListeners(inner.index, beforeIndexed); + applyListeners(inner.read, beforeRead); + applyListeners(inner.create, beforeCreated); + applyListeners(inner.modify, beforeModified); + applyListeners(inner.update, beforeUpdated); + applyListeners(inner.remove, beforeRemoved); + applyListeners(inner.index, afterIndexed, true); + applyListeners(inner.read, afterRead, true); + applyListeners(inner.create, afterCreated, true); + applyListeners(inner.modify, afterModified, true); + applyListeners(inner.update, afterUpdated, true); + applyListeners(inner.remove, afterRemoved, true); + } + + List get bootstrappers => + List.from(super.bootstrappers) + ..add((RequestContext req, ResponseContext res) { + req.serviceParams + ..['__requestctx'] = req + ..['__responsectx'] = res; + return true; + }); + + void addRoutes([Service s]) { + super.addRoutes(s ?? inner); + } + + /// Runs the [listener] before every service method specified. + void before(Iterable eventNames, + HookedServiceEventListener listener) { + eventNames.map((name) { + switch (name) { + case HookedServiceEvent.indexed: + return beforeIndexed; + case HookedServiceEvent.read: + return beforeRead; + case HookedServiceEvent.created: + return beforeCreated; + case HookedServiceEvent.modified: + return beforeModified; + case HookedServiceEvent.updated: + return beforeUpdated; + case HookedServiceEvent.removed: + return beforeRemoved; + default: + throw ArgumentError('Invalid service method: ${name}'); + } + }).forEach((HookedServiceEventDispatcher dispatcher) => + dispatcher.listen(listener)); + } + + /// Runs the [listener] after every service method specified. + void after(Iterable eventNames, + HookedServiceEventListener listener) { + eventNames.map((name) { + switch (name) { + case HookedServiceEvent.indexed: + return afterIndexed; + case HookedServiceEvent.read: + return afterRead; + case HookedServiceEvent.created: + return afterCreated; + case HookedServiceEvent.modified: + return afterModified; + case HookedServiceEvent.updated: + return afterUpdated; + case HookedServiceEvent.removed: + return afterRemoved; + default: + throw ArgumentError('Invalid service method: ${name}'); + } + }).forEach((HookedServiceEventDispatcher dispatcher) => + dispatcher.listen(listener)); + } + + /// Runs the [listener] before every service method. + void beforeAll(HookedServiceEventListener listener) { + beforeIndexed.listen(listener); + beforeRead.listen(listener); + beforeCreated.listen(listener); + beforeModified.listen(listener); + beforeUpdated.listen(listener); + beforeRemoved.listen(listener); + } + + /// Runs the [listener] after every service method. + void afterAll(HookedServiceEventListener listener) { + afterIndexed.listen(listener); + afterRead.listen(listener); + afterCreated.listen(listener); + afterModified.listen(listener); + afterUpdated.listen(listener); + afterRemoved.listen(listener); + } + + /// Returns a [Stream] of all events fired before every service method. + /// + /// *NOTE*: Only use this if you do not plan to modify events. There is no guarantee + /// that events coming out of this [Stream] will see changes you make within the [Stream] + /// callback. + Stream> beforeAllStream() { + var ctrl = StreamController>(); + _ctrl.add(ctrl); + before(HookedServiceEvent.all, ctrl.add); + return ctrl.stream; + } + + /// Returns a [Stream] of all events fired after every service method. + /// + /// *NOTE*: Only use this if you do not plan to modify events. There is no guarantee + /// that events coming out of this [Stream] will see changes you make within the [Stream] + /// callback. + Stream> afterAllStream() { + var ctrl = StreamController>(); + _ctrl.add(ctrl); + before(HookedServiceEvent.all, ctrl.add); + return ctrl.stream; + } + + /// Returns a [Stream] of all events fired before every service method specified. + /// + /// *NOTE*: Only use this if you do not plan to modify events. There is no guarantee + /// that events coming out of this [Stream] will see changes you make within the [Stream] + /// callback. + Stream> beforeStream( + Iterable eventNames) { + var ctrl = StreamController>(); + _ctrl.add(ctrl); + before(eventNames, ctrl.add); + return ctrl.stream; + } + + /// Returns a [Stream] of all events fired AFTER every service method specified. + /// + /// *NOTE*: Only use this if you do not plan to modify events. There is no guarantee + /// that events coming out of this [Stream] will see changes you make within the [Stream] + /// callback. + Stream> afterStream( + Iterable eventNames) { + var ctrl = StreamController>(); + _ctrl.add(ctrl); + after(eventNames, ctrl.add); + return ctrl.stream; + } + + /// Runs the [listener] before [create], [modify] and [update]. + void beforeModify(HookedServiceEventListener listener) { + beforeCreated.listen(listener); + beforeModified.listen(listener); + beforeUpdated.listen(listener); + } + + @override + Future> index([Map _params]) { + var params = _stripReq(_params); + return beforeIndexed + ._emit(HookedServiceEvent(false, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.indexed, + params: params)) + .then((before) { + if (before._canceled) { + return afterIndexed + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.indexed, + params: params, result: before.result)) + .then((after) => after.result as List); + } + + return inner.index(params).then((result) { + return afterIndexed + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.indexed, + params: params, result: result)) + .then((after) => after.result as List); + }); + }); + } + + @override + Future read(Id id, [Map _params]) { + var params = _stripReq(_params); + return beforeRead + ._emit(HookedServiceEvent(false, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.read, + id: id, params: params)) + .then((before) { + if (before._canceled) { + return afterRead + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.read, + id: id, params: params, result: before.result)) + .then((after) => after.result as Data); + } + + return inner.read(id, params).then((result) { + return afterRead + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.read, + id: id, params: params, result: result)) + .then((after) => after.result as Data); + }); + }); + } + + @override + Future create(Data data, [Map _params]) { + var params = _stripReq(_params); + return beforeCreated + ._emit(HookedServiceEvent(false, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.created, + data: data, params: params)) + .then((before) { + if (before._canceled) { + return afterCreated + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.created, + data: before.data, params: params, result: before.result)) + .then((after) => after.result as Data); + } + + return inner.create(before.data, params).then((result) { + return afterCreated + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.created, + data: before.data, params: params, result: result)) + .then((after) => after.result as Data); + }); + }); + } + + @override + Future modify(Id id, Data data, [Map _params]) { + var params = _stripReq(_params); + return beforeModified + ._emit(HookedServiceEvent(false, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.modified, + id: id, data: data, params: params)) + .then((before) { + if (before._canceled) { + return afterModified + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.modified, + id: id, + data: before.data, + params: params, + result: before.result)) + .then((after) => after.result as Data); + } + + return inner.modify(id, before.data, params).then((result) { + return afterModified + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.created, + id: id, data: before.data, params: params, result: result)) + .then((after) => after.result as Data); + }); + }); + } + + @override + Future update(Id id, Data data, [Map _params]) { + var params = _stripReq(_params); + return beforeUpdated + ._emit(HookedServiceEvent(false, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.updated, + id: id, data: data, params: params)) + .then((before) { + if (before._canceled) { + return afterUpdated + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.updated, + id: id, + data: before.data, + params: params, + result: before.result)) + .then((after) => after.result as Data); + } + + return inner.update(id, before.data, params).then((result) { + return afterUpdated + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.updated, + id: id, data: before.data, params: params, result: result)) + .then((after) => after.result as Data); + }); + }); + } + + @override + Future remove(Id id, [Map _params]) { + var params = _stripReq(_params); + return beforeRemoved + ._emit(HookedServiceEvent(false, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.removed, + id: id, params: params)) + .then((before) { + if (before._canceled) { + return afterRemoved + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.removed, + id: id, params: params, result: before.result)) + .then((after) => after.result) as Data; + } + + return inner.remove(id, params).then((result) { + return afterRemoved + ._emit(HookedServiceEvent(true, _getRequest(_params), + _getResponse(_params), inner, HookedServiceEvent.removed, + id: id, params: params, result: result)) + .then((after) => after.result as Data); + }); + }); + } + + /// Fires an `after` event. This will not be propagated to clients, + /// but will be broadcasted to WebSockets, etc. + Future> fire(String eventName, result, + [HookedServiceEventListener callback]) { + HookedServiceEventDispatcher dispatcher; + + switch (eventName) { + case HookedServiceEvent.indexed: + dispatcher = afterIndexed; + break; + case HookedServiceEvent.read: + dispatcher = afterRead; + break; + case HookedServiceEvent.created: + dispatcher = afterCreated; + break; + case HookedServiceEvent.modified: + dispatcher = afterModified; + break; + case HookedServiceEvent.updated: + dispatcher = afterUpdated; + break; + case HookedServiceEvent.removed: + dispatcher = afterRemoved; + break; + default: + throw ArgumentError("Invalid service event name: '$eventName'"); + } + + var ev = + HookedServiceEvent(true, null, null, inner, eventName); + return fireEvent(dispatcher, ev, callback); + } + + /// Sends an arbitrary event down the hook chain. + Future> fireEvent( + HookedServiceEventDispatcher dispatcher, + HookedServiceEvent event, + [HookedServiceEventListener callback]) { + Future f; + if (callback != null && event?._canceled != true) { + f = Future.sync(() => callback(event)); + } + f ??= Future.value(); + return f.then((_) => dispatcher._emit(event)); + } +} + +/// Fired when a hooked service is invoked. +class HookedServiceEvent> { + static const String indexed = 'indexed'; + static const String read = 'read'; + static const String created = 'created'; + static const String modified = 'modified'; + static const String updated = 'updated'; + static const String removed = 'removed'; + + static const List all = [ + indexed, + read, + created, + modified, + updated, + removed + ]; + + /// Use this to end processing of an event. + void cancel([result]) { + _canceled = true; + this.result = result ?? this.result; + } + + /// Resolves a service from the application. + /// + /// Shorthand for `e.service.app.service(...)`. + Service getService(Pattern path) => service.app.findService(path); + + bool _canceled = false; + String _eventName; + Id _id; + bool _isAfter; + Data data; + Map _params; + RequestContext _request; + ResponseContext _response; + var result; + + String get eventName => _eventName; + + Id get id => _id; + + bool get isAfter => _isAfter == true; + + bool get isBefore => !isAfter; + + Map get params => _params; + + RequestContext get request => _request; + + ResponseContext get response => _response; + + /// The inner service whose method was hooked. + T service; + + HookedServiceEvent(this._isAfter, this._request, this._response, this.service, + this._eventName, + {Id id, this.data, Map params, this.result}) { + _id = id; + _params = params ?? {}; + } +} + +/// Triggered on a hooked service event. +typedef FutureOr HookedServiceEventListener>(HookedServiceEvent event); + +/// Can be listened to, but events may be canceled. +class HookedServiceEventDispatcher> { + final List>> _ctrl = []; + final List> listeners = []; + + void _close() { + _ctrl.forEach((c) => c.close()); + listeners.clear(); + } + + /// Fires an event, and returns it once it is either canceled, or all listeners have run. + Future> _emit( + HookedServiceEvent event) { + if (event?._canceled == true || event == null || listeners.isEmpty) { + return Future.value(event); + } + + var f = Future>.value(event); + + for (var listener in listeners) { + f = f.then((event) { + if (event._canceled) return event; + return Future.sync(() => listener(event)).then((_) => event); + }); + } + + return f; + } + + /// Returns a [Stream] containing all events fired by this dispatcher. + /// + /// *NOTE*: Callbacks on the returned [Stream] cannot be guaranteed to run before other [listeners]. + /// Use this only if you need a read-only stream of events. + Stream> asStream() { + var ctrl = StreamController>(); + _ctrl.add(ctrl); + listen(ctrl.add); + return ctrl.stream; + } + + /// Registers the listener to be called whenever an event is triggered. + void listen(HookedServiceEventListener listener) { + listeners.add(listener); + } +} diff --git a/framework/lib/src/core/hostname_parser.dart b/framework/lib/src/core/hostname_parser.dart new file mode 100644 index 00000000..5b3540fb --- /dev/null +++ b/framework/lib/src/core/hostname_parser.dart @@ -0,0 +1,82 @@ +import 'dart:collection'; +import 'package:string_scanner/string_scanner.dart'; + +/// Parses a string into a [RegExp] that is matched against hostnames. +class HostnameSyntaxParser { + final SpanScanner _scanner; + var _safe = RegExp(r"[0-9a-zA-Z-_:]+"); + + HostnameSyntaxParser(String hostname) + : _scanner = SpanScanner(hostname, sourceUrl: hostname); + + FormatException _formatExc(String message) { + var span = _scanner.lastSpan ?? _scanner.emptySpan; + return FormatException( + '${span.start.toolString}: $message\n' + span.highlight(color: true)); + } + + RegExp parse() { + var b = StringBuffer(); + var parts = Queue(); + + while (!_scanner.isDone) { + if (_scanner.scan('|')) { + if (parts.isEmpty) { + throw _formatExc('No hostname parts found before "|".'); + } else { + var next = _parseHostnamePart(); + if (next == null) { + throw _formatExc('No hostname parts found after "|".'); + } else { + var prev = parts.removeLast(); + parts.addLast('(($prev)|($next))'); + } + } + } else { + var part = _parseHostnamePart(); + if (part != null) { + if (_scanner.scan('.')) { + var subPart = _parseHostnamePart(shouldThrow: false); + while (subPart != null) { + part += '\\.' + subPart; + if (_scanner.scan('.')) { + subPart = _parseHostnamePart(shouldThrow: false); + } else { + break; + } + } + } + + parts.add(part); + } + } + } + + while (parts.isNotEmpty) { + b.write(parts.removeFirst()); + } + + if (b.isEmpty) { + throw _formatExc('Invalid or empty hostname.'); + } else { + return RegExp('^$b\$', caseSensitive: false); + } + } + + String _parseHostnamePart({bool shouldThrow = true}) { + if (_scanner.scan('*.')) { + return r'([^$.]+\.)?'; + } else if (_scanner.scan('*')) { + return r'[^$]*'; + } else if (_scanner.scan('+')) { + return r'[^$]+'; + } else if (_scanner.scan(_safe)) { + return _scanner.lastMatch[0]; + } else if (!_scanner.isDone && shouldThrow) { + var s = String.fromCharCode(_scanner.peekChar()); + throw _formatExc('Unexpected character "$s".'); + } else { + return null; + } + } +} diff --git a/framework/lib/src/core/hostname_router.dart b/framework/lib/src/core/hostname_router.dart new file mode 100644 index 00000000..b896a895 --- /dev/null +++ b/framework/lib/src/core/hostname_router.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'package:angel_container/angel_container.dart'; +import 'package:angel_route/angel_route.dart'; +import 'package:logging/logging.dart'; +import 'env.dart'; +import 'hostname_parser.dart'; +import 'request_context.dart'; +import 'response_context.dart'; +import 'routable.dart'; +import 'server.dart'; + +/// A utility that allows requests to be handled based on their +/// origin's hostname. +/// +/// For example, an application could handle example.com and api.example.com +/// separately. +/// +/// The provided patterns can be any `Pattern`. If a `String` is provided, a simple +/// grammar (see [HostnameSyntaxParser]) is used to create [RegExp]. +/// +/// For example: +/// * `example.com` -> `/example\.com/` +/// * `*.example.com` -> `/([^$.]\.)?example\.com/` +/// * `example.*` -> `/example\./[^$]*` +/// * `example.+` -> `/example\./[^$]+` +class HostnameRouter { + final Map _apps = {}; + final Map Function()> _creators = {}; + final List _patterns = []; + + HostnameRouter( + {Map apps = const {}, + Map Function()> creators = const {}}) { + Map _parseMap(Map map) { + return map.map((p, c) { + Pattern pp; + + if (p is String) { + pp = HostnameSyntaxParser(p).parse(); + } else { + pp = p; + } + + return MapEntry(pp, c); + }); + } + + apps ??= {}; + creators ??= {}; + apps = _parseMap(apps); + creators = _parseMap(creators); + var patterns = apps.keys.followedBy(creators.keys).toSet().toList(); + _apps.addAll(apps); + _creators.addAll(creators); + _patterns.addAll(patterns); + // print(_creators); + } + + factory HostnameRouter.configure( + Map Function(Angel)> configurers, + {Reflector reflector = const EmptyReflector(), + AngelEnvironment environment = angelEnv, + Logger logger, + bool allowMethodOverrides = true, + FutureOr Function(dynamic) serializer, + ViewGenerator viewGenerator}) { + var creators = configurers.map((p, c) { + return MapEntry(p, () async { + var app = Angel( + reflector: reflector, + environment: environment, + logger: logger, + allowMethodOverrides: allowMethodOverrides, + serializer: serializer, + viewGenerator: viewGenerator); + await app.configure(c); + return app; + }); + }); + return HostnameRouter(creators: creators); + } + + /// Attempts to handle a request, according to its hostname. + /// + /// If none is matched, then `true` is returned. + /// Also returns `true` if all of the sub-app's handlers returned + /// `true`. + Future handleRequest(RequestContext req, ResponseContext res) async { + if (req.hostname != null) { + for (var pattern in _patterns) { + // print('${req.hostname} vs $_creators'); + if (pattern.allMatches(req.hostname).isNotEmpty) { + // Resolve the entire pipeline within the context of the selected app. + var app = _apps[pattern] ??= (await _creators[pattern]()); + // print('App for ${req.hostname} = $app from $pattern'); + // app.dumpTree(); + + var r = app.optimizedRouter; + var resolved = r.resolveAbsolute(req.path, method: req.method); + var pipeline = MiddlewarePipeline(resolved); + // print('Pipeline: $pipeline'); + for (var handler in pipeline.handlers) { + // print(handler); + // Avoid stack overflow. + if (handler == handleRequest) { + continue; + } else if (!await app.executeHandler(handler, req, res)) { + // print('$handler TERMINATED'); + return false; + } else { + // print('$handler CONTINUED'); + } + } + } + } + } + + // Otherwise, return true. + return true; + } +} diff --git a/framework/lib/src/core/injection.dart b/framework/lib/src/core/injection.dart new file mode 100644 index 00000000..1f08de21 --- /dev/null +++ b/framework/lib/src/core/injection.dart @@ -0,0 +1,207 @@ +part of angel_framework.http.request_context; + +const List _primitiveTypes = [String, int, num, double, Null]; + +/// Shortcut for calling [preInject], and then [handleContained]. +/// +/// Use this to instantly create a request handler for a DI-enabled method. +/// +/// Calling [ioc] also auto-serializes the result of a [handler]. +RequestHandler ioc(Function handler, {Iterable optional = const []}) { + InjectionRequest injection; + RequestHandler contained; + + return (req, res) { + if (injection == null) { + injection = preInject(handler, req.app.container.reflector); + injection.optional.addAll(optional ?? []); + contained = handleContained(handler, injection); + } + + return req.app.executeHandler(contained, req, res); + }; +} + +resolveInjection(requirement, InjectionRequest injection, RequestContext req, + ResponseContext res, bool throwOnUnresolved, + [Container container]) async { + var propFromApp; + container ??= req?.container ?? res?.app?.container; + + if (requirement == RequestContext) { + return req; + } else if (requirement == ResponseContext) { + return res; + } else if (requirement is String && + injection.parameters.containsKey(requirement)) { + var param = injection.parameters[requirement]; + var value = param.getValue(req); + if (value == null && param.required != false) throw param.error; + return value; + } else if (requirement is String) { + if (req.container.hasNamed(requirement)) { + return req.container.findByName(requirement); + } + if (req.params.containsKey(requirement)) { + return req.params[requirement]; + } else if ((propFromApp = req.app.findProperty(requirement)) != null) { + return propFromApp; + } else if (injection.optional.contains(requirement)) { + return null; + } else if (throwOnUnresolved) { + throw ArgumentError( + "Cannot resolve parameter '$requirement' within handler."); + } + } else if (requirement is List && + requirement.length == 2 && + requirement.first is String && + requirement.last is Type) { + var key = requirement.first; + var type = requirement.last; + if (req.params.containsKey(key) || + req.app.configuration.containsKey(key) || + _primitiveTypes.contains(type)) { + return await resolveInjection( + key, injection, req, res, throwOnUnresolved, container); + } else { + return await resolveInjection( + type, injection, req, res, throwOnUnresolved, container); + } + } else if (requirement is Type && requirement != dynamic) { + try { + var futureType = container.reflector.reflectFutureOf(requirement); + if (container.has(futureType.reflectedType)) { + return await container.make(futureType.reflectedType); + } + } on UnsupportedError { + // Ignore. + } + + return await container.make(requirement); + } else if (throwOnUnresolved) { + throw ArgumentError( + '$requirement cannot be injected into a request handler.'); + } +} + +/// Checks if an [InjectionRequest] can be sufficiently executed within the current request/response context. +bool suitableForInjection( + RequestContext req, ResponseContext res, InjectionRequest injection) { + return injection.parameters.values.any((p) { + if (p.match == null) return false; + var value = p.getValue(req); + return value == p.match; + }); +} + +/// Handles a request with a DI-enabled handler. +RequestHandler handleContained(Function handler, InjectionRequest injection, + [Container container]) { + return (RequestContext req, ResponseContext res) async { + if (injection.parameters.isNotEmpty && + injection.parameters.values.any((p) => p.match != null) && + !suitableForInjection(req, res, injection)) return Future.value(true); + + List args = []; + + Map named = {}; + + for (var r in injection.required) { + args.add(await resolveInjection(r, injection, req, res, true, container)); + } + + for (var entry in injection.named.entries) { + var name = Symbol(entry.key); + named[name] = await resolveInjection( + [entry.key, entry.value], injection, req, res, false, container); + } + + return Function.apply(handler, args, named); + }; +} + +/// Contains a list of the data required for a DI-enabled method to run. +/// +/// This improves performance by removing the necessity to reflect a method +/// every time it is requested. +/// +/// Regular request handlers can also skip DI entirely, lowering response time +/// and memory use. +class InjectionRequest { + /// Optional, typed data that can be passed to a DI-enabled method. + final Map named; + + /// A list of the arguments required for a DI-enabled method to run. + final List required; + + /// A list of the arguments that can be null in a DI-enabled method. + final List optional; + + /// Extended parameter definitions. + final Map parameters; + + const InjectionRequest.constant( + {this.named = const {}, + this.required = const [], + this.optional = const [], + this.parameters = const {}}); + + InjectionRequest() + : named = {}, + required = [], + optional = [], + parameters = {}; +} + +/// Predetermines what needs to be injected for a handler to run. +InjectionRequest preInject(Function handler, Reflector reflector) { + var injection = InjectionRequest(); + + var closureMirror = reflector.reflectFunction(handler); + + if (closureMirror.parameters.isEmpty) return injection; + + // Load parameters + for (var parameter in closureMirror.parameters) { + var name = parameter.name; + var type = parameter.type.reflectedType; + + var _Parameter = reflector.reflectType(Parameter); + + var p = parameter.annotations + .firstWhere((m) => m.type.isAssignableTo(_Parameter), + orElse: () => null) + ?.reflectee as Parameter; + //print(p); + if (p != null) { + injection.parameters[name] = Parameter( + cookie: p.cookie, + header: p.header, + query: p.query, + session: p.session, + match: p.match, + defaultValue: p.defaultValue, + required: parameter.isNamed ? false : p.required != false, + ); + } + + if (!parameter.isNamed) { + if (!parameter.isRequired) injection.optional.add(name); + + if (type == RequestContext || type == ResponseContext) { + injection.required.add(type); + } else if (name == 'req') { + injection.required.add(RequestContext); + } else if (name == 'res') { + injection.required.add(ResponseContext); + } else if (type == dynamic) { + injection.required.add(name); + } else { + injection.required.add([name, type]); + } + } else { + injection.named[name] = type; + } + } + return injection; +} diff --git a/framework/lib/src/core/map_service.dart b/framework/lib/src/core/map_service.dart new file mode 100644 index 00000000..a7b50915 --- /dev/null +++ b/framework/lib/src/core/map_service.dart @@ -0,0 +1,175 @@ +import 'dart:async'; + +import 'package:angel_http_exception/angel_http_exception.dart'; + +import 'service.dart'; + +/// A basic service that manages an in-memory list of maps. +class MapService extends Service> { + /// If set to `true`, clients can remove all items by passing a `null` `id` to `remove`. + /// + /// `false` by default. + final bool allowRemoveAll; + + /// If set to `true`, parameters in `req.query` are applied to the database query. + final bool allowQuery; + + /// If set to `true` (default), then the service will manage an `id` string and `createdAt` and `updatedAt` fields. + final bool autoIdAndDateFields; + + /// If set to `true` (default), then the keys `created_at` and `updated_at` will automatically be snake_cased. + final bool autoSnakeCaseNames; + + final List> items = []; + + MapService( + {this.allowRemoveAll = false, + this.allowQuery = true, + this.autoIdAndDateFields = true, + this.autoSnakeCaseNames = true}) + : super(); + + String get createdAtKey => + autoSnakeCaseNames == false ? 'createdAt' : 'created_at'; + + String get updatedAtKey => + autoSnakeCaseNames == false ? 'updatedAt' : 'updated_at'; + + bool Function(Map) _matchesId(id) { + return (Map item) { + if (item['id'] == null) { + return false; + } else if (autoIdAndDateFields != false) { + return item['id'] == id?.toString(); + } else { + return item['id'] == id; + } + }; + } + + @override + Future>> index([Map params]) { + if (allowQuery == false || params == null || params['query'] is! Map) { + return Future.value(items); + } else { + var query = params['query'] as Map; + + return Future.value(items.where((item) { + for (var key in query.keys) { + if (!item.containsKey(key)) { + return false; + } else if (item[key] != query[key]) return false; + } + + return true; + }).toList()); + } + } + + @override + Future> read(String id, [Map params]) { + return Future.value(items.firstWhere(_matchesId(id), + orElse: () => throw AngelHttpException.notFound( + message: 'No record found for ID $id'))); + } + + @override + Future> create(Map data, + [Map params]) { + if (data is! Map) { + throw AngelHttpException.badRequest( + message: + 'MapService does not support `create` with ${data.runtimeType}.'); + } + var now = DateTime.now().toIso8601String(); + var result = Map.from(data); + + if (autoIdAndDateFields == true) { + result + ..['id'] = items.length.toString() + ..[autoSnakeCaseNames == false ? 'createdAt' : 'created_at'] = now + ..[autoSnakeCaseNames == false ? 'updatedAt' : 'updated_at'] = now; + } + items.add(result); + return Future.value(result); + } + + @override + Future> modify(String id, Map data, + [Map params]) { + if (data is! Map) { + throw AngelHttpException.badRequest( + message: + 'MapService does not support `modify` with ${data.runtimeType}.'); + } + if (!items.any(_matchesId(id))) return create(data, params); + + return read(id).then((item) { + var idx = items.indexOf(item); + if (idx < 0) return create(data, params); + var result = Map.from(item)..addAll(data); + + if (autoIdAndDateFields == true) { + result + ..[autoSnakeCaseNames == false ? 'updatedAt' : 'updated_at'] = + DateTime.now().toIso8601String(); + } + return Future.value(items[idx] = result); + }); + } + + @override + Future> update(String id, Map data, + [Map params]) { + if (data is! Map) { + throw AngelHttpException.badRequest( + message: + 'MapService does not support `update` with ${data.runtimeType}.'); + } + if (!items.any(_matchesId(id))) return create(data, params); + + return read(id).then((old) { + if (!items.remove(old)) { + throw AngelHttpException.notFound( + message: 'No record found for ID $id'); + } + + var result = Map.from(data); + if (autoIdAndDateFields == true) { + result + ..['id'] = id?.toString() + ..[autoSnakeCaseNames == false ? 'createdAt' : 'created_at'] = + old[autoSnakeCaseNames == false ? 'createdAt' : 'created_at'] + ..[autoSnakeCaseNames == false ? 'updatedAt' : 'updated_at'] = + DateTime.now().toIso8601String(); + } + items.add(result); + return Future.value(result); + }); + } + + @override + Future> remove(String id, + [Map params]) { + if (id == null || id == 'null') { + // Remove everything... + if (!(allowRemoveAll == true || + params?.containsKey('provider') != true)) { + throw AngelHttpException.forbidden( + message: 'Clients are not allowed to delete all items.'); + } else { + items.clear(); + return Future.value({}); + } + } + + return read(id, params).then((result) { + if (items.remove(result)) { + return result; + } else { + throw AngelHttpException.notFound( + message: 'No record found for ID $id'); + } + }); + } +} diff --git a/framework/lib/src/core/metadata.dart b/framework/lib/src/core/metadata.dart new file mode 100644 index 00000000..0670f414 --- /dev/null +++ b/framework/lib/src/core/metadata.dart @@ -0,0 +1,179 @@ +library angel_framework.http.metadata; + +import 'package:angel_http_exception/angel_http_exception.dart'; + +import 'hooked_service.dart' show HookedServiceEventListener; +import 'request_context.dart'; +import 'routable.dart'; + +/// Annotation to map middleware onto a handler. +class Middleware { + final Iterable handlers; + + const Middleware(this.handlers); +} + +/// Attaches hooks to a [HookedService]. +class Hooks { + final List before; + final List after; + + const Hooks({this.before = const [], this.after = const []}); +} + +/// Specifies to NOT expose a method to the Internet. +class NoExpose { + const NoExpose(); +} + +const NoExpose noExpose = NoExpose(); + +/// Exposes a [Controller] or a [Controller] method to the Internet. +/// Example: +/// +/// ```dart +/// @Expose('/elements') +/// class ElementController extends Controller { +/// +/// @Expose('/') +/// List getList() => someComputationHere(); +/// +/// @Expose('/int:elementId') +/// getElement(int elementId) => someOtherComputation(); +/// +/// } +/// ``` +class Expose { + final String method; + final String path; + final Iterable middleware; + final String as; + final List allowNull; + + static const Expose get = Expose(null, method: 'GET'), + post = Expose(null, method: 'POST'), + patch = Expose(null, method: 'PATCH'), + put = Expose(null, method: 'PUT'), + delete = Expose(null, method: 'DELETE'), + head = Expose(null, method: 'HEAD'); + + const Expose(this.path, + {this.method = "GET", + this.middleware = const [], + this.as, + this.allowNull = const []}); + + const Expose.method(this.method, + {this.middleware, this.as, this.allowNull = const []}) + : path = null; +} + +/// Used to apply special dependency injections or functionality to a function parameter. +class Parameter { + /// Inject the value of a request cookie. + final String cookie; + + /// Inject the value of a request header. + final String header; + + /// Inject the value of a key from the session. + final String session; + + /// Inject the value of a key from the query. + final String query; + + /// Only execute the handler if the value of this parameter matches the given value. + final match; + + /// Specify a default value. + final defaultValue; + + /// If `true` (default), then an error will be thrown if this parameter is not present. + final bool required; + + const Parameter( + {this.cookie, + this.query, + this.header, + this.session, + this.match, + this.defaultValue, + this.required}); + + /// Returns an error that can be thrown when the parameter is not present. + get error { + if (cookie?.isNotEmpty == true) { + return AngelHttpException.badRequest( + message: 'Missing required cookie "$cookie".'); + } + if (header?.isNotEmpty == true) { + return AngelHttpException.badRequest( + message: 'Missing required header "$header".'); + } + if (query?.isNotEmpty == true) { + return AngelHttpException.badRequest( + message: 'Missing required query parameter "$query".'); + } + if (session?.isNotEmpty == true) { + return StateError('Session does not contain required key "$session".'); + } + } + + /// Obtains a value for this parameter from a [RequestContext]. + getValue(RequestContext req) { + if (cookie?.isNotEmpty == true) { + return req.cookies.firstWhere((c) => c.name == cookie)?.value ?? + defaultValue; + } + if (header?.isNotEmpty == true) { + return req.headers.value(header) ?? defaultValue; + } + if (session?.isNotEmpty == true) { + return req.session[session] ?? defaultValue; + } + if (query?.isNotEmpty == true) { + return req.uri.queryParameters[query] ?? defaultValue; + } + return defaultValue; + } +} + +/// Shortcut for declaring a request header [Parameter]. +class Header extends Parameter { + const Header(String header, {match, defaultValue, bool required = true}) + : super( + header: header, + match: match, + defaultValue: defaultValue, + required: required); +} + +/// Shortcut for declaring a request session [Parameter]. +class Session extends Parameter { + const Session(String session, {match, defaultValue, bool required = true}) + : super( + session: session, + match: match, + defaultValue: defaultValue, + required: required); +} + +/// Shortcut for declaring a request query [Parameter]. +class Query extends Parameter { + const Query(String query, {match, defaultValue, bool required = true}) + : super( + query: query, + match: match, + defaultValue: defaultValue, + required: required); +} + +/// Shortcut for declaring a request cookie [Parameter]. +class CookieValue extends Parameter { + const CookieValue(String cookie, {match, defaultValue, bool required = true}) + : super( + cookie: cookie, + match: match, + defaultValue: defaultValue, + required: required); +} diff --git a/framework/lib/src/core/request_context.dart b/framework/lib/src/core/request_context.dart new file mode 100644 index 00000000..0d9c072b --- /dev/null +++ b/framework/lib/src/core/request_context.dart @@ -0,0 +1,350 @@ +library angel_framework.http.request_context; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' + show + BytesBuilder, + Cookie, + HeaderValue, + HttpHeaders, + HttpSession, + InternetAddress; + +import 'package:angel_container/angel_container.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:http_server/http_server.dart'; +import 'package:meta/meta.dart'; +import 'package:mime/mime.dart'; +import 'package:path/path.dart' as p; + +import 'metadata.dart'; +import 'response_context.dart'; +import 'routable.dart'; +import 'server.dart' show Angel; + +part 'injection.dart'; + +/// A convenience wrapper around an incoming [RawRequest]. +abstract class RequestContext { + /// Similar to [Angel.shutdownHooks], allows for logic to be executed + /// when a [RequestContext] is done being processed. + final List Function()> shutdownHooks = []; + + String _acceptHeaderCache, _extensionCache; + bool _acceptsAllCache, _hasParsedBody = false, _closed = false; + Map _bodyFields, _queryParameters; + List _bodyList; + Object _bodyObject; + List _uploadedFiles; + MediaType _contentType; + + /// The underlying [RawRequest] provided by the driver. + RawRequest get rawRequest; + + /// Additional params to be passed to services. + final Map serviceParams = {}; + + /// The [Angel] instance that is responding to this request. + Angel app; + + /// Any cookies sent with this request. + List get cookies; + + /// All HTTP headers sent with this request. + HttpHeaders get headers; + + /// The requested hostname. + String get hostname; + + /// The IoC container that can be used to provide functionality to produce + /// objects of a given type. + /// + /// This is a *child* of the container found in `app`. + Container get container; + + /// The user's IP. + String get ip => remoteAddress.address; + + /// This request's HTTP method. + /// + /// This may have been processed by an override. See [originalMethod] to get the real method. + String get method; + + /// The original HTTP verb sent to the server. + String get originalMethod; + + /// The content type of an incoming request. + MediaType get contentType => + _contentType ??= MediaType.parse(headers.contentType.toString()); + + /// The URL parameters extracted from the request URI. + Map params = {}; + + /// The requested path. + String get path; + + /// Is this an **XMLHttpRequest**? + bool get isXhr { + return headers.value("X-Requested-With")?.trim()?.toLowerCase() == + 'xmlhttprequest'; + } + + /// The remote address requesting this resource. + InternetAddress get remoteAddress; + + /// The user's HTTP session. + HttpSession get session; + + /// The [Uri] instance representing the path this request is responding to. + Uri get uri; + + /// The [Stream] of incoming binary data sent from the client. + Stream> get body; + + /// Returns `true` if [parseBody] has been called so far. + bool get hasParsedBody => _hasParsedBody; + + /// Returns a *mutable* [Map] of the fields parsed from the request [body]. + /// + /// Note that [parseBody] must be called first. + Map get bodyAsMap { + if (!hasParsedBody) { + throw StateError('The request body has not been parsed yet.'); + } else if (_bodyFields == null) { + throw StateError('The request body, $_bodyObject, is not a Map.'); + } + + return _bodyFields; + } + + /// This setter allows you to explicitly set the request body **exactly once**. + /// + /// Use this if the format of the body is not natively parsed by Angel. + set bodyAsMap(Map value) => bodyAsObject = value; + + /// Returns a *mutable* [List] parsed from the request [body]. + /// + /// Note that [parseBody] must be called first. + List get bodyAsList { + if (!hasParsedBody) { + throw StateError('The request body has not been parsed yet.'); + } else if (_bodyList == null) { + throw StateError('The request body, $_bodyObject, is not a List.'); + } + + return _bodyList; + } + + /// This setter allows you to explicitly set the request body **exactly once**. + /// + /// Use this if the format of the body is not natively parsed by Angel. + set bodyAsList(List value) => bodyAsObject = value; + + /// Returns the parsed request body, whatever it may be (typically a [Map] or [List]). + /// + /// Note that [parseBody] must be called first. + Object get bodyAsObject { + if (!hasParsedBody) { + throw StateError('The request body has not been parsed yet.'); + } + + return _bodyObject; + } + + /// This setter allows you to explicitly set the request body **exactly once**. + /// + /// Use this if the format of the body is not natively parsed by Angel. + set bodyAsObject(value) { + if (_bodyObject != null) { + throw StateError( + 'The request body has already been parsed/set, and cannot be overwritten.'); + } else { + if (value is List) _bodyList = value; + if (value is Map) _bodyFields = value; + _bodyObject = value; + _hasParsedBody = true; + } + } + + /// Returns a *mutable* map of the files parsed from the request [body]. + /// + /// Note that [parseBody] must be called first. + List get uploadedFiles { + if (!hasParsedBody) { + throw StateError('The request body has not been parsed yet.'); + } + + return _uploadedFiles; + } + + /// Returns a *mutable* map of the fields contained in the query. + Map get queryParameters => + _queryParameters ??= Map.from(uri.queryParameters); + + /// Returns the file extension of the requested path, if any. + /// + /// Includes the leading `.`, if there is one. + String get extension => _extensionCache ??= p.extension(uri.path); + + /// Returns `true` if the client's `Accept` header indicates that the given [contentType] is considered a valid response. + /// + /// You cannot provide a `null` [contentType]. + /// If the `Accept` header's value is `*/*`, this method will always return `true`. + /// To ignore the wildcard (`*/*`), pass [strict] as `true`. + /// + /// [contentType] can be either of the following: + /// * A [ContentType], in which case the `Accept` header will be compared against its `mimeType` property. + /// * Any other Dart value, in which case the `Accept` header will be compared against the result of a `toString()` call. + bool accepts(contentType, {bool strict = false}) { + var contentTypeString = contentType is MediaType + ? contentType.mimeType + : contentType?.toString(); + + // Change to assert + if (contentTypeString == null) { + throw ArgumentError( + 'RequestContext.accepts expects the `contentType` parameter to NOT be null.'); + } + + _acceptHeaderCache ??= headers.value('accept'); + + if (_acceptHeaderCache == null) { + return true; + } else if (strict != true && _acceptHeaderCache.contains('*/*')) { + return true; + } else { + return _acceptHeaderCache.contains(contentTypeString); + } + } + + /// Returns as `true` if the client's `Accept` header indicates that it will accept any response content type. + bool get acceptsAll => _acceptsAllCache ??= accepts('*/*'); + + /// Shorthand for deserializing [bodyAsMap], using some transformer function [f]. + Future deserializeBody(FutureOr Function(Map) f, + {Encoding encoding = utf8}) async { + await parseBody(encoding: encoding); + return await f(bodyAsMap); + } + + /// Shorthand for decoding [bodyAsMap], using some [codec]. + Future decodeBody(Codec codec, {Encoding encoding = utf8}) => + deserializeBody(codec.decode, encoding: encoding); + + /// Manually parses the request body, if it has not already been parsed. + Future parseBody({Encoding encoding = utf8}) async { + if (contentType == null) { + throw FormatException('Missing "content-type" header.'); + } + + if (!_hasParsedBody) { + _hasParsedBody = true; + + if (contentType.type == 'application' && contentType.subtype == 'json') { + _uploadedFiles = []; + + var parsed = _bodyObject = + await encoding.decoder.bind(body).join().then(json.decode); + + if (parsed is Map) { + _bodyFields = Map.from(parsed); + } else if (parsed is List) { + _bodyList = parsed; + } + } else if (contentType.type == 'application' && + contentType.subtype == 'x-www-form-urlencoded') { + _uploadedFiles = []; + var parsed = await encoding.decoder + .bind(body) + .join() + .then((s) => Uri.splitQueryString(s, encoding: encoding)); + _bodyFields = Map.from(parsed); + } else if (contentType.type == 'multipart' && + contentType.subtype == 'form-data' && + contentType.parameters.containsKey('boundary')) { + var boundary = contentType.parameters['boundary']; + var transformer = MimeMultipartTransformer(boundary); + var parts = transformer.bind(body).map((part) => + HttpMultipartFormData.parse(part, defaultEncoding: encoding)); + _bodyFields = {}; + _uploadedFiles = []; + + await for (var part in parts) { + if (part.isBinary) { + _uploadedFiles.add(UploadedFile(part)); + } else if (part.isText && + part.contentDisposition.parameters.containsKey('name')) { + // If there is no name, then don't parse it. + var key = part.contentDisposition.parameters['name']; + var value = await part.join(); + _bodyFields[key] = value; + } + } + } else { + _bodyFields = {}; + _uploadedFiles = []; + } + } + } + + /// Disposes of all resources. + @mustCallSuper + Future close() async { + if (!_closed) { + _closed = true; + _acceptsAllCache = null; + _acceptHeaderCache = null; + serviceParams.clear(); + params.clear(); + await Future.forEach(shutdownHooks, (hook) => hook()); + } + } +} + +/// Reads information about a binary chunk uploaded to the server. +class UploadedFile { + /// The underlying `form-data` item. + final HttpMultipartFormData formData; + + MediaType _contentType; + + UploadedFile(this.formData); + + /// Returns the binary stream from [formData]. + Stream> get data => formData.cast>(); + + /// The filename associated with the data on the user's system. + /// Returns [:null:] if not present. + String get filename => formData.contentDisposition.parameters['filename']; + + /// The name of the field associated with this data. + /// Returns [:null:] if not present. + String get name => formData.contentDisposition.parameters['name']; + + /// The parsed [:Content-Type:] header of the [:HttpMultipartFormData:]. + /// Returns [:null:] if not present. + MediaType get contentType => _contentType ??= (formData.contentType == null + ? null + : MediaType.parse(formData.contentType.toString())); + + /// The parsed [:Content-Transfer-Encoding:] header of the + /// [:HttpMultipartFormData:]. This field is used to determine how to decode + /// the data. Returns [:null:] if not present. + HeaderValue get contentTransferEncoding => formData.contentTransferEncoding; + + /// Reads the contents of the file into a single linear buffer. + /// + /// Note that this leads to holding the whole file in memory, which might + /// not be ideal for large files.w + Future> readAsBytes() { + return data + .fold(BytesBuilder(), (bb, out) => bb..add(out)) + .then((bb) => bb.takeBytes()); + } + + /// Reads the contents of the file as [String], using the given [encoding]. + Future readAsString({Encoding encoding = utf8}) { + return encoding.decoder.bind(data).join(); + } +} diff --git a/framework/lib/src/core/response_context.dart b/framework/lib/src/core/response_context.dart new file mode 100644 index 00000000..2110dc0e --- /dev/null +++ b/framework/lib/src/core/response_context.dart @@ -0,0 +1,446 @@ +library angel_framework.http.response_context; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:convert' as c show json; +import 'dart:io' show BytesBuilder, Cookie; +import 'dart:typed_data'; + +import 'package:angel_route/angel_route.dart'; +import 'package:file/file.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:mime/mime.dart'; + +import 'controller.dart'; +import 'request_context.dart'; +import 'server.dart' show Angel; + +final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + +/// A convenience wrapper around an outgoing HTTP request. +abstract class ResponseContext + implements StreamConsumer>, StreamSink>, StringSink { + final Map properties = {}; + final CaseInsensitiveMap _headers = CaseInsensitiveMap.from({ + 'content-type': 'text/plain', + 'server': 'angel', + }); + + Completer _done; + int _statusCode = 200; + + /// The [Angel] instance that is sending a response. + Angel app; + + /// Is `Transfer-Encoding` chunked? + bool chunked; + + /// Any and all cookies to be sent to the user. + final List cookies = []; + + /// A set of [Converter] objects that can be used to encode response data. + /// + /// At most one encoder will ever be used to convert data. + final Map, List>> encoders = {}; + + /// A [Map] of data to inject when `res.render` is called. + /// + /// This can be used to reduce boilerplate when using templating engines. + final Map renderParams = {}; + + /// Points to the [RequestContext] corresponding to this response. + RequestContext get correspondingRequest; + + @override + Future get done => (_done ?? Completer()).future; + + /// Headers that will be sent to the user. + /// + /// Note that if you have already started writing to the underlying stream, headers will not persist. + CaseInsensitiveMap get headers => _headers; + + /// Serializes response data into a String. + /// + /// The default is conversion into JSON via `json.encode`. + /// + /// If you are 100% sure that your response handlers will only + /// be JSON-encodable objects (i.e. primitives, `List`s and `Map`s), + /// then consider setting [serializer] to `JSON.encode`. + /// + /// To set it globally for the whole [app], use the following helper: + /// ```dart + /// app.injectSerializer(JSON.encode); + /// ``` + FutureOr Function(dynamic) serializer = c.json.encode; + + /// This response's status code. + int get statusCode => _statusCode; + + set statusCode(int value) { + if (!isOpen) { + throw closed(); + } else { + _statusCode = value ?? 200; + } + } + + /// Returns `true` if the response is still available for processing by Angel. + /// + /// If it is `false`, then Angel will stop executing handlers, and will only run + /// response finalizers if the response [isBuffered]. + bool get isOpen; + + /// Returns `true` if response data is being written to a buffer, rather than to the underlying stream. + bool get isBuffered; + + /// A set of UTF-8 encoded bytes that will be written to the response. + BytesBuilder get buffer; + + /// The underlying [RawResponse] under this instance. + RawResponse get rawResponse; + + /// Signals Angel that the response is being held alive deliberately, and that the framework should not automatically close it. + /// + /// This is mostly used in situations like WebSocket handlers, where the connection should remain + /// open indefinitely. + FutureOr detach(); + + /// Gets or sets the content length to send back to a client. + /// + /// Returns `null` if the header is invalidly formatted. + int get contentLength { + return int.tryParse(headers['content-length']); + } + + /// Gets or sets the content length to send back to a client. + /// + /// If [value] is `null`, then the header will be removed. + set contentLength(int value) { + if (value == null) { + headers.remove('content-length'); + } else { + headers['content-length'] = value.toString(); + } + } + + /// Gets or sets the content type to send back to a client. + MediaType get contentType { + try { + return MediaType.parse(headers['content-type']); + } catch (_) { + return MediaType('text', 'plain'); + } + } + + /// Gets or sets the content type to send back to a client. + set contentType(MediaType value) { + headers['content-type'] = value.toString(); + } + + static StateError closed() => StateError('Cannot modify a closed response.'); + + /// Sends a download as a response. + Future download(File file, {String filename}) async { + if (!isOpen) throw closed(); + + headers["Content-Disposition"] = + 'attachment; filename="${filename ?? file.path}"'; + contentType = MediaType.parse(lookupMimeType(file.path)); + headers['content-length'] = file.lengthSync().toString(); + + if (!isBuffered) { + await file.openRead().cast>().pipe(this); + } else { + buffer.add(file.readAsBytesSync()); + await close(); + } + } + + /// Prevents more data from being written to the response, and locks it entire from further editing. + Future close() { + if (buffer is LockableBytesBuilder) { + (buffer as LockableBytesBuilder).lock(); + } + + if (_done?.isCompleted == false) _done.complete(); + return Future.value(); + } + + /// Serializes JSON to the response. + void json(value) => this + ..contentType = MediaType('application', 'json') + ..serialize(value); + + /// Returns a JSONP response. + /// + /// You can override the [contentType] sent; by default it is `application/javascript`. + Future jsonp(value, + {String callbackName = "callback", MediaType contentType}) { + if (!isOpen) throw closed(); + this.contentType = contentType ?? MediaType('application', 'javascript'); + write("$callbackName(${serializer(value)})"); + return close(); + } + + /// Renders a view to the response stream, and closes the response. + Future render(String view, [Map data]) { + if (!isOpen) throw closed(); + contentType = MediaType('text', 'html', {'charset': 'utf-8'}); + return Future.sync(() => app.viewGenerator( + view, + Map.from(renderParams) + ..addAll(data ?? {}))).then((content) { + write(content); + return close(); + }); + } + + /// Redirects to user to the given URL. + /// + /// [url] can be a `String`, or a `List`. + /// If it is a `List`, a URI will be constructed + /// based on the provided params. + /// + /// See [Router]#navigate for more. :) + Future redirect(url, {bool absolute = true, int code = 302}) { + if (!isOpen) throw closed(); + headers + ..['content-type'] = 'text/html' + ..['location'] = (url is String || url is Uri) + ? url.toString() + : app.navigate(url as Iterable, absolute: absolute); + statusCode = code ?? 302; + write(''' + + + + Redirecting... + + + +

Currently redirecting you...

+
+ Click here if you are not automatically redirected... + + + + '''); + return close(); + } + + /// Redirects to the given named [Route]. + Future redirectTo(String name, [Map params, int code]) async { + if (!isOpen) throw closed(); + Route _findRoute(Router r) { + for (Route route in r.routes) { + if (route is SymlinkRoute) { + final m = _findRoute(route.router); + + if (m != null) return m; + } else if (route.name == name) return route; + } + + return null; + } + + Route matched = _findRoute(app); + + if (matched != null) { + await redirect( + matched.makeUri(params.keys.fold>({}, (out, k) { + return out..[k.toString()] = params[k]; + })), + code: code); + return; + } + + throw ArgumentError.notNull('Route to redirect to ($name)'); + } + + /// Redirects to the given [Controller] action. + Future redirectToAction(String action, [Map params, int code]) { + if (!isOpen) throw closed(); + // UserController@show + List split = action.split("@"); + + if (split.length < 2) { + throw Exception( + "Controller redirects must take the form of 'Controller@action'. You gave: $action"); + } + + Controller controller = + app.controllers[split[0].replaceAll(_straySlashes, '')]; + + if (controller == null) { + throw Exception("Could not find a controller named '${split[0]}'"); + } + + Route matched = controller.routeMappings[split[1]]; + + if (matched == null) { + throw Exception( + "Controller '${split[0]}' does not contain any action named '${split[1]}'"); + } + + final head = controller + .findExpose(app.container.reflector) + .path + .toString() + .replaceAll(_straySlashes, ''); + final tail = matched + .makeUri(params.keys.fold>({}, (out, k) { + return out..[k.toString()] = params[k]; + })) + .replaceAll(_straySlashes, ''); + + return redirect('$head/$tail'.replaceAll(_straySlashes, ''), code: code); + } + + /// Serializes data to the response. + Future serialize(value, {MediaType contentType}) async { + if (!isOpen) throw closed(); + this.contentType = contentType ?? MediaType('application', 'json'); + var text = await serializer(value); + if (text.isEmpty) return true; + write(text); + await close(); + return false; + } + + /// Streams a file to this response. + /// + /// `HEAD` responses will not actually write data. + Future streamFile(File file) async { + if (!isOpen) throw closed(); + var mimeType = app.mimeTypeResolver.lookup(file.path); + contentLength = await file.length(); + contentType = mimeType == null + ? MediaType('application', 'octet-stream') + : MediaType.parse(mimeType); + + if (correspondingRequest.method != 'HEAD') { + return this + .addStream(file.openRead().cast>()) + .then((_) => this.close()); + } + } + + /// Configure the response to write to an intermediate response buffer, rather than to the stream directly. + void useBuffer(); + + /// Adds a stream directly the underlying response. + /// + /// If this instance has access to a [correspondingRequest], then it will attempt to transform + /// the content using at most one of the response [encoders]. + @override + Future addStream(Stream> stream); + + @override + void addError(Object error, [StackTrace stackTrace]) { + if (_done?.isCompleted == false) { + _done.completeError(error, stackTrace); + } else if (_done == null) { + Zone.current.handleUncaughtError(error, stackTrace); + } + } + + /// Writes data to the response. + void write(value, {Encoding encoding}) { + encoding ??= utf8; + + if (!isOpen && isBuffered) { + throw closed(); + } else if (!isBuffered) { + add(encoding.encode(value.toString())); + } else { + buffer.add(encoding.encode(value.toString())); + } + } + + @override + void writeCharCode(int charCode) { + if (!isOpen && isBuffered) { + throw closed(); + } else if (!isBuffered) { + add([charCode]); + } else { + buffer.addByte(charCode); + } + } + + @override + void writeln([Object obj = ""]) { + write(obj.toString()); + write('\r\n'); + } + + @override + void writeAll(Iterable objects, [String separator = ""]) { + write(objects.join(separator)); + } +} + +abstract class LockableBytesBuilder extends BytesBuilder { + factory LockableBytesBuilder() { + return _LockableBytesBuilderImpl(); + } + + void lock(); +} + +class _LockableBytesBuilderImpl implements LockableBytesBuilder { + final BytesBuilder _buf = BytesBuilder(copy: false); + bool _closed = false; + + StateError _deny() => + StateError('Cannot modified a closed response\'s buffer.'); + + @override + void lock() { + _closed = true; + } + + @override + void add(List bytes) { + if (_closed) { + throw _deny(); + } else { + _buf.add(bytes); + } + } + + @override + void addByte(int byte) { + if (_closed) { + throw _deny(); + } else { + _buf.addByte(byte); + } + } + + @override + void clear() { + _buf.clear(); + } + + @override + bool get isEmpty => _buf.isEmpty; + + @override + bool get isNotEmpty => _buf.isNotEmpty; + + @override + int get length => _buf.length; + + @override + Uint8List takeBytes() { + return _buf.takeBytes(); + } + + @override + Uint8List toBytes() { + return _buf.toBytes(); + } +} diff --git a/framework/lib/src/core/routable.dart b/framework/lib/src/core/routable.dart new file mode 100644 index 00000000..a497c4d0 --- /dev/null +++ b/framework/lib/src/core/routable.dart @@ -0,0 +1,133 @@ +library angel_framework.http.routable; + +import 'dart:async'; + +import 'package:angel_container/angel_container.dart'; +import 'package:angel_route/angel_route.dart'; + +import '../util.dart'; +import 'hooked_service.dart'; +import 'metadata.dart'; +import 'request_context.dart'; +import 'response_context.dart'; +import 'service.dart'; + +final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + +/// A function that receives an incoming [RequestContext] and responds to it. +typedef FutureOr RequestHandler(RequestContext req, ResponseContext res); + +/// Sequentially runs a list of [handlers] of middleware, and returns early if any does not +/// return `true`. Works well with [Router].chain. +RequestHandler chain(Iterable handlers) { + return (req, res) { + Future Function() runPipeline; + + for (var handler in handlers) { + if (handler == null) break; + + if (runPipeline == null) { + runPipeline = () => Future.sync(() => handler(req, res)); + } else { + var current = runPipeline; + runPipeline = () => current().then((result) => !res.isOpen + ? Future.value(result) + : req.app.executeHandler(handler, req, res)); + } + } + + runPipeline ??= () => Future.value(); + return runPipeline(); + }; +} + +/// A routable server that can handle dynamic requests. +class Routable extends Router { + final Map _services = {}; + final Map _serviceLookups = {}; + final Map configuration = {}; + + final Container _container; + + Routable([Reflector reflector]) + : _container = reflector == null ? null : Container(reflector), + super(); + + /// A [Container] used to inject dependencies. + Container get container => _container; + + void close() { + _services.clear(); + configuration.clear(); + _onService.close(); + } + + /// A set of [Service] objects that have been mapped into routes. + Map get services => _services; + + StreamController _onService = StreamController.broadcast(); + + /// Fired whenever a service is added to this instance. + /// + /// **NOTE**: This is a broadcast stream. + Stream get onService => _onService.stream; + + /// Retrieves the service assigned to the given path. + T findService(Pattern path) { + return _serviceLookups.putIfAbsent(path, () { + return _services[path] ?? + _services[path.toString().replaceAll(_straySlashes, '')]; + }) as T; + } + + /// Shorthand for finding a [Service] in a statically-typed manner. + Service findServiceOf(Pattern path) { + return findService>(path); + } + + /// Shorthand for finding a [HookedService] in a statically-typed manner. + HookedService findHookedService( + Pattern path) { + return findService(path) as HookedService; + } + + @override + Route addRoute( + String method, String path, RequestHandler handler, + {Iterable middleware}) { + middleware ??= []; + final handlers = []; + // Merge @Middleware declaration, if any + var reflector = _container?.reflector; + if (reflector != null && reflector is! ThrowingReflector) { + Middleware middlewareDeclaration = + getAnnotation(handler, _container?.reflector); + if (middlewareDeclaration != null) { + handlers.addAll(middlewareDeclaration.handlers); + } + } + + final handlerSequence = []; + handlerSequence.addAll(middleware ?? []); + handlerSequence.addAll(handlers); + + return super.addRoute(method, path.toString(), handler, + middleware: handlerSequence); + } + + /// Mounts a [service] at the given [path]. + /// + /// Returns a [HookedService] that can be used to hook into + /// events dispatched by this service. + HookedService use>( + String path, T service) { + var hooked = HookedService(service); + _services[path.toString().trim().replaceAll(RegExp(r'(^/+)|(/+$)'), '')] = + hooked; + hooked.addRoutes(); + mount(path.toString(), hooked); + service.onHooked(hooked); + _onService.add(hooked); + return hooked; + } +} diff --git a/framework/lib/src/core/server.dart b/framework/lib/src/core/server.dart new file mode 100644 index 00000000..6ddd2ed5 --- /dev/null +++ b/framework/lib/src/core/server.dart @@ -0,0 +1,389 @@ +library angel_framework.http.server; + +import 'dart:async'; +import 'dart:collection' show HashMap; +import 'dart:convert'; +import 'package:angel_container/angel_container.dart'; +import 'package:angel_http_exception/angel_http_exception.dart'; +import 'package:angel_route/angel_route.dart'; +import 'package:combinator/combinator.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:logging/logging.dart'; +import 'package:mime/mime.dart'; +import 'package:tuple/tuple.dart'; +import 'controller.dart'; +import 'env.dart'; +import 'hooked_service.dart'; +import 'request_context.dart'; +import 'response_context.dart'; +import 'routable.dart'; +import 'service.dart'; + +//final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + +/// A function that configures an [Angel] server in some way. +typedef FutureOr AngelConfigurer(Angel app); + +/// A function that asynchronously generates a view from the given path and data. +typedef FutureOr ViewGenerator(String path, + [Map data]); + +/// A powerful real-time/REST/MVC server class. +class Angel extends Routable { + static ViewGenerator noViewEngineConfigured = + (String view, [Map data]) => 'No view engine has been configured yet.'; + + final List _children = []; + final Map< + String, + Tuple4, ParseResult, + MiddlewarePipeline>> handlerCache = HashMap(); + + Router _flattened; + Angel _parent; + + /// A global Map of converters that can transform responses bodies. + final Map, List>> encoders = {}; + + final Map _preContained = {}; + + /// A [MimeTypeResolver] that can be used to specify the MIME types of files not known by `package:mime`. + final MimeTypeResolver mimeTypeResolver = MimeTypeResolver(); + + /// A middleware to inject a serialize on every request. + FutureOr Function(dynamic) serializer; + + /// A [Map] of dependency data obtained via reflection. + /// + /// You may modify this [Map] yourself if you intend to avoid reflection entirely. + Map get preContained => _preContained; + + /// Returns the [flatten]ed version of this router in production. + Router get optimizedRouter => _flattened ?? this; + + /// Determines whether to allow HTTP request method overrides. + bool allowMethodOverrides = true; + + /// All child application mounted on this instance. + List get children => List.unmodifiable(_children); + + final Map _controllers = {}; + + /// A set of [Controller] objects that have been loaded into the application. + Map get controllers => _controllers; + + /// Now *deprecated*, in favor of [AngelEnv] and [angelEnv]. Use `app.environment.isProduction` + /// instead. + /// + /// Indicates whether the application is running in a production environment. + /// + /// The criteria for this is the `ANGEL_ENV` environment variable being set to + /// `'production'`. + /// + /// This value is memoized the first time you call it, so do not change environment + /// configuration at runtime! + @deprecated + bool get isProduction => environment.isProduction; + + /// The [AngelEnvironment] in which the application is running. + /// + /// By default, it is automatically inferred. + final AngelEnvironment environment; + + /// Returns the parent instance of this application, if any. + Angel get parent => _parent; + + /// Outputs diagnostics and debug messages. + Logger logger; + + /// Plug-ins to be called right before server startup. + /// + /// If the server is never started, they will never be called. + final List startupHooks = []; + + /// Plug-ins to be called right before server shutdown. + /// + /// If the server is never [close]d, they will never be called. + final List shutdownHooks = []; + + /// Always run before responses are sent. + /// + /// These will only not run if a response's `willCloseItself` is set to `true`. + final List responseFinalizers = []; + + /// A [Map] of application-specific data that can be accessed by any + /// piece of code that can see this [Angel] instance. + /// + /// Packages like `package:angel_configuration` populate this map + /// for you. + final Map configuration = {}; + + /// A function that renders views. + /// + /// Called by [ResponseContext]@`render`. + ViewGenerator viewGenerator = noViewEngineConfigured; + + /// The handler currently configured to run on [AngelHttpException]s. + Function(AngelHttpException e, RequestContext req, ResponseContext res) + errorHandler = + (AngelHttpException e, RequestContext req, ResponseContext res) { + if (!req.accepts('text/html', strict: true) && + (req.accepts('application/json') || + req.accepts('application/javascript'))) { + res.json(e.toJson()); + return; + } + + res.contentType = MediaType('text', 'html', {'charset': 'utf8'}); + res.statusCode = e.statusCode; + res.write("${e.message}"); + res.write("

${e.message}

    "); + + for (String error in e.errors) { + res.write("
  • $error
  • "); + } + + res.write("
"); + res.close(); + }; + + @override + Route addRoute( + String method, String path, RequestHandler handler, + {Iterable middleware}) { + middleware ??= []; + if (_flattened != null) { + logger?.warning( + 'WARNING: You added a route ($method $path) to the router, after it had been optimized.'); + logger?.warning( + 'This route will be ignored, and no requests will ever reach it.'); + } + + return super.addRoute(method, path, handler, middleware: middleware ?? []); + } + + @override + mount(String path, Router router) { + if (_flattened != null) { + logger?.warning( + 'WARNING: You added mounted a child router ($path) on the router, after it had been optimized.'); + logger?.warning( + 'This route will be ignored, and no requests will ever reach it.'); + } + + if (router is Angel) { + router._parent = this; + _children.add(router); + } + + return super.mount(path.toString(), router); + } + + /// Loads some base dependencies into the service container. + void bootstrapContainer() { + if (runtimeType != Angel) { + container.registerSingleton(this); + } + + container.registerSingleton(this); + container.registerSingleton(this); + container.registerSingleton(this); + } + + /// Shuts down the server, and closes any open [StreamController]s. + /// + /// The server will be **COMPLETELY DEFUNCT** after this operation! + Future close() { + Future.forEach(services.values, (Service service) { + service.close(); + }); + + super.close(); + viewGenerator = noViewEngineConfigured; + _preContained.clear(); + handlerCache.clear(); + encoders.clear(); + //_serializer = json.encode; + _children.clear(); + _parent = null; + logger = null; + startupHooks.clear(); + shutdownHooks.clear(); + responseFinalizers.clear(); + _flattened = null; + return Future.value(); + } + + @override + void dumpTree( + {callback(String tree), + String header = 'Dumping route tree:', + String tab = ' ', + bool showMatchers = false}) { + if (environment.isProduction) { + _flattened ??= flatten(this); + + _flattened.dumpTree( + callback: callback, + header: header?.isNotEmpty == true + ? header + : (environment.isProduction + ? 'Dumping flattened route tree:' + : 'Dumping route tree:'), + tab: tab ?? ' '); + } else { + super.dumpTree( + callback: callback, + header: header?.isNotEmpty == true + ? header + : (environment.isProduction + ? 'Dumping flattened route tree:' + : 'Dumping route tree:'), + tab: tab ?? ' '); + } + } + + Future getHandlerResult(handler, RequestContext req, ResponseContext res) { + if (handler is RequestHandler) { + var result = handler(req, res); + return getHandlerResult(result, req, res); + } + + if (handler is Future) { + return handler.then((result) => getHandlerResult(result, req, res)); + } + + if (handler is Function) { + var result = runContained(handler, req, res); + return getHandlerResult(result, req, res); + } + + if (handler is Stream) { + return getHandlerResult(handler.toList(), req, res); + } + + return Future.value(handler); + } + + /// Runs some [handler]. Returns `true` if request execution should continue. + Future executeHandler( + handler, RequestContext req, ResponseContext res) { + return getHandlerResult(handler, req, res).then((result) { + if (result == null) { + return false; + } else if (result is bool) { + return result; + } else if (result != null) { + return res.serialize(result); + } else { + return res.isOpen; + } + }); + } + + /// Attempts to find a property by the given name within this application. + findProperty(key) { + if (configuration.containsKey(key)) return configuration[key]; + return parent != null ? parent.findProperty(key) : null; + } + + /// Runs several optimizations, *if* [angelEnv.isProduction] is `true`. + /// + /// * Preprocesses all dependency injection, and eliminates the burden of reflecting handlers + /// at run-time. + /// * [flatten]s the route tree into a linear one. + /// + /// You may [force] the optimization to run, if you are not running in production. + void optimizeForProduction({bool force = false}) { + if (environment.isProduction || force == true) { + _flattened ??= flatten(this); + logger?.info('Angel is running in production mode.'); + } + } + + /// Run a function after injecting from service container. + /// If this function has been reflected before, then + /// the execution will be faster, as the injection requirements were stored beforehand. + Future runContained(Function handler, RequestContext req, ResponseContext res, + [Container container]) { + return Future.sync(() { + if (_preContained.containsKey(handler)) { + return handleContained(handler, _preContained[handler], container)( + req, res); + } + + return runReflected(handler, req, res, container); + }); + } + + /// Runs with DI, and *always* reflects. Prefer [runContained]. + Future runReflected(Function handler, RequestContext req, ResponseContext res, + [Container container]) { + container ??= req?.container ?? res?.app?.container; + var h = handleContained( + handler, + _preContained[handler] = preInject(handler, container.reflector), + container); + return Future.sync(() => h(req, res)); + // return closureMirror.apply(args).reflectee; + } + + /// Applies an [AngelConfigurer] to this instance. + Future configure(AngelConfigurer configurer) { + return Future.sync(() => configurer(this)); + } + + /// Shorthand for using the [container] to instantiate, and then mount a [Controller]. + /// Returns the created controller. + /// + /// Just like [Container].make, in contexts without properly-reified generics (dev releases of Dart 2), + /// provide a [type] argument as well. + /// + /// If you are on `Dart >=2.0.0`, simply call `mountController()`. + Future mountController([Type type]) { + var controller = container.make(type); + return configure(controller.configureServer).then((_) => controller); + } + + /// Shorthand for calling `all('*', handler)`. + Route fallback(RequestHandler handler) { + return all('*', handler); + } + + @override + HookedService use>( + String path, T service) { + service.app = this; + return super.use(path, service)..app = this; + } + + static const String _reflectionErrorMessage = + ThrowingReflector.defaultErrorMessage + ' ' + _reflectionInfo; + + static const String _reflectionInfo = + 'Features like controllers, constructor dependency injection, and `ioc` require reflection, ' + 'and will not work without it.\n\n' + 'For more, see the documentation:\n' + 'https://docs.angel-dart.dev/guides/dependency-injection#enabling-dart-mirrors-or-other-reflection'; + + Angel( + {Reflector reflector = + const ThrowingReflector(errorMessage: _reflectionErrorMessage), + this.environment = angelEnv, + this.logger, + this.allowMethodOverrides = true, + this.serializer, + this.viewGenerator}) + : super(reflector) { + if (reflector is EmptyReflector || reflector is ThrowingReflector) { + var msg = + 'No `reflector` was passed to the Angel constructor, so reflection will not be available.\n' + + _reflectionInfo; + logger?.warning(msg); + } + + bootstrapContainer(); + viewGenerator ??= noViewEngineConfigured; + serializer ??= json.encode; + } +} diff --git a/framework/lib/src/core/service.dart b/framework/lib/src/core/service.dart new file mode 100644 index 00000000..7592c48d --- /dev/null +++ b/framework/lib/src/core/service.dart @@ -0,0 +1,373 @@ +library angel_framework.http.service; + +import 'dart:async'; +import 'package:angel_http_exception/angel_http_exception.dart'; +import 'package:merge_map/merge_map.dart'; +import 'package:quiver_hashcode/hashcode.dart'; +import '../util.dart'; +import 'anonymous_service.dart'; +import 'hooked_service.dart' show HookedService; +import 'metadata.dart'; +import 'request_context.dart'; +import 'response_context.dart'; +import 'routable.dart'; +import 'server.dart'; + +/// Indicates how the service was accessed. +/// +/// This will be passed to the `params` object in a service method. +/// When requested on the server side, this will be null. +class Providers { + /// The transport through which the client is accessing this service. + final String via; + + const Providers(this.via); + + static const String viaRest = "rest"; + static const String viaWebsocket = "websocket"; + static const String viaGraphQL = "graphql"; + + /// Represents a request via REST. + static const Providers rest = Providers(viaRest); + + /// Represents a request over WebSockets. + static const Providers websocket = Providers(viaWebsocket); + + /// Represents a request parsed from GraphQL. + static const Providers graphQL = Providers(viaGraphQL); + + @override + int get hashCode => hashObjects([via]); + + @override + bool operator ==(other) => other is Providers && other.via == via; + + Map toJson() { + return {'via': via}; + } + + @override + String toString() { + return 'via:$via'; + } +} + +/// A front-facing interface that can present data to and operate on data on behalf of the user. +/// +/// Heavily inspired by FeathersJS. <3 +class Service extends Routable { + /// A [List] of keys that services should ignore, should they see them in the query. + static const List specialQueryKeys = [ + r'$limit', + r'$sort', + 'page', + 'token' + ]; + + /// Handlers that must run to ensure this service's functionality. + List get bootstrappers => []; + + /// The [Angel] app powering this service. + Angel app; + + /// Closes this service, including any database connections or stream controllers. + void close() {} + + /// An optional [readData] function can be passed to handle non-map/non-json bodies. + Service({FutureOr Function(RequestContext, ResponseContext) readData}) { + _readData = readData ?? + (req, res) { + if (req.bodyAsObject is! Data) { + throw AngelHttpException.badRequest( + message: + 'Invalid request body. Expected $Data; found ${req.bodyAsObject} instead.'); + } else { + return req.bodyAsObject as Data; + } + }; + } + + FutureOr Function(RequestContext, ResponseContext) _readData; + + /// A [Function] that reads the request body and converts it into [Data]. + FutureOr Function(RequestContext, ResponseContext) get readData => + _readData; + + /// Retrieves the first object from the result of calling [index] with the given [params]. + /// + /// If the result of [index] is `null`, OR an empty [Iterable], a 404 `AngelHttpException` will be thrown. + /// + /// If the result is both non-null and NOT an [Iterable], it will be returned as-is. + /// + /// If the result is a non-empty [Iterable], [findOne] will return `it.first`, where `it` is the aforementioned [Iterable]. + /// + /// A custom [errorMessage] may be provided. + Future findOne( + [Map params, + String errorMessage = 'No record was found matching the given query.']) { + return index(params).then((result) { + if (result == null) { + throw AngelHttpException.notFound(message: errorMessage); + } else { + if (result.isEmpty) { + throw AngelHttpException.notFound(message: errorMessage); + } else { + return result.first; + } + } + }); + } + + /// Retrieves all resources. + Future> index([Map params]) { + throw AngelHttpException.methodNotAllowed(); + } + + /// Retrieves the desired resource. + Future read(Id id, [Map params]) { + throw AngelHttpException.methodNotAllowed(); + } + + /// Reads multiple resources at once. + /// + /// Service implementations should override this to ensure data is fetched within a + /// single round trip. + Future> readMany(List ids, [Map params]) { + return Future.wait(ids.map((id) => read(id, params))); + } + + /// Creates a resource. + Future create(Data data, [Map params]) { + throw AngelHttpException.methodNotAllowed(); + } + + /// Modifies a resource. + Future modify(Id id, Data data, [Map params]) { + throw AngelHttpException.methodNotAllowed(); + } + + /// Overwrites a resource. + Future update(Id id, Data data, [Map params]) { + throw AngelHttpException.methodNotAllowed(); + } + + /// Removes the given resource. + Future remove(Id id, [Map params]) { + throw AngelHttpException.methodNotAllowed(); + } + + /// Creates an [AnonymousService] that wraps over this one, and maps input and output + /// using two converter functions. + /// + /// Handy utility for handling data in a type-safe manner. + Service map(U Function(Data) encoder, Data Function(U) decoder, + {FutureOr Function(RequestContext, ResponseContext) readData}) { + readData ??= (req, res) async { + var inner = await this.readData(req, res); + return encoder(inner); + }; + + return AnonymousService( + readData: readData, + index: ([params]) { + return index(params).then((it) => it.map(encoder).toList()); + }, + read: (id, [params]) { + return read(id, params).then(encoder); + }, + create: (data, [params]) { + return create(decoder(data), params).then(encoder); + }, + modify: (id, data, [params]) { + return modify(id, decoder(data), params).then(encoder); + }, + update: (id, data, [params]) { + return update(id, decoder(data), params).then(encoder); + }, + remove: (id, [params]) { + return remove(id, params).then(encoder); + }, + ); + } + + /// Transforms an [id] (whether it is a String, num, etc.) into one acceptable by a service. + /// + /// The single type argument, [T], is used to determine how to parse the [id]. + /// + /// For example, `parseId` attempts to parse the value as a [bool]. + static T parseId(id) { + if (id == 'null' || id == null) { + return null; + } else if (T == String) { + return id.toString() as T; + } else if (T == int) { + return int.parse(id.toString()) as T; + } else if (T == bool) { + return (id == true || id?.toString() == 'true') as T; + } else if (T == double) { + return double.parse(id.toString()) as T; + } else if (T == num) { + return num.parse(id.toString()) as T; + } else { + return id as T; + } + } + + /// Generates RESTful routes pointing to this class's methods. + void addRoutes([Service service]) { + _addRoutesInner(service ?? this, bootstrappers); + } + + void _addRoutesInner(Service service, Iterable handlerss) { + var restProvider = {'provider': Providers.rest}; + var handlers = List.from(handlerss); + + // Add global middleware if declared on the instance itself + Middleware before = + getAnnotation(service, app.container.reflector); + + if (before != null) handlers.addAll(before.handlers); + + Middleware indexMiddleware = + getAnnotation(service.index, app.container.reflector); + get('/', (req, res) { + return this.index(mergeMap([ + {'query': req.queryParameters}, + restProvider, + req.serviceParams + ])); + }, + middleware: [] + ..addAll(handlers) + ..addAll((indexMiddleware == null) ? [] : indexMiddleware.handlers)); + + Middleware createMiddleware = + getAnnotation(service.create, app.container.reflector); + post('/', (req, ResponseContext res) { + return req.parseBody().then((_) async { + return await this + .create( + await readData(req, res), + mergeMap([ + {'query': req.queryParameters}, + restProvider, + req.serviceParams + ])) + .then((r) { + res.statusCode = 201; + return r; + }); + }); + }, + middleware: [] + ..addAll(handlers) + ..addAll( + (createMiddleware == null) ? [] : createMiddleware.handlers)); + + Middleware readMiddleware = + getAnnotation(service.read, app.container.reflector); + + get('/:id', (req, res) { + return this.read( + parseId(req.params['id']), + mergeMap([ + {'query': req.queryParameters}, + restProvider, + req.serviceParams + ])); + }, + middleware: [] + ..addAll(handlers) + ..addAll((readMiddleware == null) ? [] : readMiddleware.handlers)); + + Middleware modifyMiddleware = + getAnnotation(service.modify, app.container.reflector); + patch('/:id', (req, res) { + return req.parseBody().then((_) async { + return await this.modify( + parseId(req.params['id']), + await readData(req, res), + mergeMap([ + {'query': req.queryParameters}, + restProvider, + req.serviceParams + ])); + }); + }, + middleware: [] + ..addAll(handlers) + ..addAll( + (modifyMiddleware == null) ? [] : modifyMiddleware.handlers)); + + Middleware updateMiddleware = + getAnnotation(service.update, app.container.reflector); + post('/:id', (req, res) { + return req.parseBody().then((_) async { + return await this.update( + parseId(req.params['id']), + await readData(req, res), + mergeMap([ + {'query': req.queryParameters}, + restProvider, + req.serviceParams + ])); + }); + }, + middleware: [] + ..addAll(handlers) + ..addAll( + (updateMiddleware == null) ? [] : updateMiddleware.handlers)); + put('/:id', (req, res) { + return req.parseBody().then((_) async { + return await this.update( + parseId(req.params['id']), + await readData(req, res), + mergeMap([ + {'query': req.queryParameters}, + restProvider, + req.serviceParams + ])); + }); + }, + middleware: [] + ..addAll(handlers) + ..addAll( + (updateMiddleware == null) ? [] : updateMiddleware.handlers)); + + Middleware removeMiddleware = + getAnnotation(service.remove, app.container.reflector); + delete('/', (req, res) { + return this.remove( + null, + mergeMap([ + {'query': req.queryParameters}, + restProvider, + req.serviceParams + ])); + }, + middleware: [] + ..addAll(handlers) + ..addAll( + (removeMiddleware == null) ? [] : removeMiddleware.handlers)); + delete('/:id', (req, res) { + return this.remove( + parseId(req.params['id']), + mergeMap([ + {'query': req.queryParameters}, + restProvider, + req.serviceParams + ])); + }, + middleware: [] + ..addAll(handlers) + ..addAll( + (removeMiddleware == null) ? [] : removeMiddleware.handlers)); + + // REST compliance + put('/', (req, res) => throw AngelHttpException.notFound()); + patch('/', (req, res) => throw AngelHttpException.notFound()); + } + + /// Invoked when this service is wrapped within a [HookedService]. + void onHooked(HookedService hookedService) {} +} diff --git a/framework/lib/src/fast_name_from_symbol.dart b/framework/lib/src/fast_name_from_symbol.dart new file mode 100644 index 00000000..adca8e9b --- /dev/null +++ b/framework/lib/src/fast_name_from_symbol.dart @@ -0,0 +1,10 @@ +final Map _cache = {}; + +String fastNameFromSymbol(Symbol s) { + return _cache.putIfAbsent(s, () { + String str = s.toString(); + int open = str.indexOf('"'); + int close = str.lastIndexOf('"'); + return str.substring(open + 1, close); + }); +} diff --git a/framework/lib/src/http/angel_http.dart b/framework/lib/src/http/angel_http.dart new file mode 100644 index 00000000..bf77bfc8 --- /dev/null +++ b/framework/lib/src/http/angel_http.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' + show + Cookie, + HttpRequest, + HttpResponse, + HttpServer, + Platform, + SecurityContext; +import 'package:angel_framework/angel_framework.dart'; +import '../core/core.dart'; +import 'http_request_context.dart'; +import 'http_response_context.dart'; + +final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + +/// Adapts `dart:io`'s [HttpServer] to serve Angel. +class AngelHttp extends Driver { + @override + Uri get uri => server == null + ? Uri() + : Uri(scheme: 'http', host: server.address.address, port: server.port); + + AngelHttp._(Angel app, + Future Function(dynamic, int) serverGenerator, bool useZone) + : super(app, serverGenerator, useZone: useZone); + + factory AngelHttp(Angel app, {bool useZone = true}) { + return AngelHttp._(app, HttpServer.bind, useZone); + } + + /// An instance mounted on a server started by the [serverGenerator]. + factory AngelHttp.custom( + Angel app, Future Function(dynamic, int) serverGenerator, + {bool useZone = true}) { + return AngelHttp._(app, serverGenerator, useZone); + } + + factory AngelHttp.fromSecurityContext(Angel app, SecurityContext context, + {bool useZone = true}) { + return AngelHttp._(app, (address, int port) { + return HttpServer.bindSecure(address, port, context); + }, useZone); + } + + /// Creates an HTTPS server. + /// + /// Provide paths to a certificate chain and server key (both .pem). + /// If no password is provided, a random one will be generated upon running + /// the server. + factory AngelHttp.secure( + Angel app, String certificateChainPath, String serverKeyPath, + {String password, bool useZone = true}) { + var certificateChain = + Platform.script.resolve(certificateChainPath).toFilePath(); + var serverKey = Platform.script.resolve(serverKeyPath).toFilePath(); + var serverContext = SecurityContext(); + serverContext.useCertificateChain(certificateChain, password: password); + serverContext.usePrivateKey(serverKey, password: password); + return AngelHttp.fromSecurityContext(app, serverContext, useZone: useZone); + } + + /// Use [server] instead. + @deprecated + HttpServer get httpServer => server; + + Future handleRequest(HttpRequest request) => + handleRawRequest(request, request.response); + + @override + void addCookies(HttpResponse response, Iterable cookies) => + response.cookies.addAll(cookies); + + @override + Future close() async { + await server?.close(); + return await super.close(); + } + + @override + Future closeResponse(HttpResponse response) => response.close(); + + @override + Future createRequestContext( + HttpRequest request, HttpResponse response) { + var path = request.uri.path.replaceAll(_straySlashes, ''); + if (path.isEmpty) path = '/'; + return HttpRequestContext.from(request, app, path); + } + + @override + Future createResponseContext( + HttpRequest request, HttpResponse response, + [HttpRequestContext correspondingRequest]) { + return Future.value( + HttpResponseContext(response, app, correspondingRequest) + ..serializer = (app.serializer ?? json.encode) + ..encoders.addAll(app.encoders ?? {})); + } + + @override + Stream createResponseStreamFromRawRequest( + HttpRequest request) => + Stream.fromIterable([request.response]); + + @override + void setChunkedEncoding(HttpResponse response, bool value) => + response.headers.chunkedTransferEncoding = value; + + @override + void setContentLength(HttpResponse response, int length) => + response.headers.contentLength = length; + + @override + void setHeader(HttpResponse response, String key, String value) => + response.headers.set(key, value); + + @override + void setStatusCode(HttpResponse response, int value) => + response.statusCode = value; + + @override + void writeStringToResponse(HttpResponse response, String value) => + response.write(value); + + @override + void writeToResponse(HttpResponse response, List data) => + response.add(data); +} diff --git a/framework/lib/src/http/http.dart b/framework/lib/src/http/http.dart new file mode 100644 index 00000000..44ab4cdb --- /dev/null +++ b/framework/lib/src/http/http.dart @@ -0,0 +1,19 @@ +/// Various libraries useful for creating highly-extensible servers. +library angel_framework.http; + +import 'dart:async'; +import 'dart:io'; +export 'angel_http.dart'; +export 'http_request_context.dart'; +export 'http_response_context.dart'; + +/// Boots a shared server instance. Use this if launching multiple isolates. +Future startShared(address, int port) => + HttpServer.bind(address ?? '127.0.0.1', port ?? 0, shared: true); + +Future Function(dynamic, int) startSharedSecure( + SecurityContext securityContext) { + return (address, int port) => HttpServer.bindSecure( + address ?? '127.0.0.1', port ?? 0, securityContext, + shared: true); +} diff --git a/framework/lib/src/http/http_request_context.dart b/framework/lib/src/http/http_request_context.dart new file mode 100644 index 00000000..42538ce1 --- /dev/null +++ b/framework/lib/src/http/http_request_context.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:angel_container/angel_container.dart'; +import 'package:http_parser/http_parser.dart'; + +import '../core/core.dart'; + +/// An implementation of [RequestContext] that wraps a [HttpRequest]. +class HttpRequestContext extends RequestContext { + Container _container; + MediaType _contentType; + HttpRequest _io; + String _override, _path; + + @override + Container get container => _container; + + @override + MediaType get contentType { + return _contentType; + } + + @override + List get cookies { + return rawRequest.cookies; + } + + @override + HttpHeaders get headers { + return rawRequest.headers; + } + + @override + String get hostname { + return rawRequest.headers.value('host'); + } + + /// The underlying [HttpRequest] instance underneath this context. + HttpRequest get rawRequest => _io; + + @override + Stream> get body => _io; + + @override + String get method { + return _override ?? originalMethod; + } + + @override + String get originalMethod { + return rawRequest.method; + } + + @override + String get path { + return _path; + } + + @override + InternetAddress get remoteAddress { + return rawRequest.connectionInfo.remoteAddress; + } + + @override + HttpSession get session { + return rawRequest.session; + } + + @override + Uri get uri { + return rawRequest.uri; + } + + /// Magically transforms an [HttpRequest] into a [RequestContext]. + static Future from( + HttpRequest request, Angel app, String path) { + HttpRequestContext ctx = HttpRequestContext() + .._container = app.container.createChild(); + + String override = request.method; + + if (app.allowMethodOverrides == true) { + override = + request.headers.value('x-http-method-override')?.toUpperCase() ?? + request.method; + } + + ctx.app = app; + ctx._contentType = request.headers.contentType == null + ? null + : MediaType.parse(request.headers.contentType.toString()); + ctx._override = override; + + /* + // Faster way to get path + List _path = []; + + // Go up until we reach a ? + for (int ch in request.uri.toString().codeUnits) { + if (ch != $question) + _path.add(ch); + else + break; + } + + // Remove trailing slashes + int lastSlash = -1; + + for (int i = _path.length - 1; i >= 0; i--) { + if (_path[i] == $slash) + lastSlash = i; + else + break; + } + + if (lastSlash > -1) + ctx._path = String.fromCharCodes(_path.take(lastSlash)); + else + ctx._path = String.fromCharCodes(_path); + */ + + ctx._path = path; + ctx._io = request; + + return Future.value(ctx); + } + + @override + Future close() { + _contentType = null; + _io = null; + _override = _path = null; + return super.close(); + } +} diff --git a/framework/lib/src/http/http_response_context.dart b/framework/lib/src/http/http_response_context.dart new file mode 100644 index 00000000..8903d245 --- /dev/null +++ b/framework/lib/src/http/http_response_context.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http_parser/http_parser.dart'; + +import '../core/core.dart'; +import 'http_request_context.dart'; + +/// An implementation of [ResponseContext] that abstracts over an [HttpResponse]. +class HttpResponseContext extends ResponseContext { + /// The underlying [HttpResponse] under this instance. + @override + final HttpResponse rawResponse; + Angel app; + + LockableBytesBuilder _buffer; + + final HttpRequestContext _correspondingRequest; + bool _isDetached = false, _isClosed = false, _streamInitialized = false; + + HttpResponseContext(this.rawResponse, this.app, [this._correspondingRequest]); + + @override + HttpResponse detach() { + _isDetached = true; + return rawResponse; + } + + @override + RequestContext get correspondingRequest { + return _correspondingRequest; + } + + @override + bool get isOpen { + return !_isClosed && !_isDetached; + } + + @override + bool get isBuffered => _buffer != null; + + @override + BytesBuilder get buffer => _buffer; + + @override + void addError(Object error, [StackTrace stackTrace]) { + rawResponse.addError(error, stackTrace); + super.addError(error, stackTrace); + } + + @override + void useBuffer() { + _buffer = LockableBytesBuilder(); + } + + Iterable __allowedEncodings; + + Iterable get _allowedEncodings { + return __allowedEncodings ??= correspondingRequest.headers + .value('accept-encoding') + ?.split(',') + ?.map((s) => s.trim()) + ?.where((s) => s.isNotEmpty) + ?.map((str) { + // Ignore quality specifications in accept-encoding + // ex. gzip;q=0.8 + if (!str.contains(';')) return str; + return str.split(';')[0]; + }); + } + + @override + set contentType(MediaType value) { + super.contentType = value; + if (!_streamInitialized) { + rawResponse.headers.contentType = + ContentType(value.type, value.subtype, parameters: value.parameters); + } + } + + bool _openStream() { + if (!_streamInitialized) { + // If this is the first stream added to this response, + // then add headers, status code, etc. + rawResponse + ..statusCode = statusCode + ..cookies.addAll(cookies); + headers.forEach(rawResponse.headers.set); + + if (headers.containsKey('content-length')) { + rawResponse.contentLength = int.tryParse(headers['content-length']) ?? + rawResponse.contentLength; + } + + rawResponse.headers.contentType = ContentType( + contentType.type, contentType.subtype, + charset: contentType.parameters['charset'], + parameters: contentType.parameters); + + if (encoders.isNotEmpty && correspondingRequest != null) { + if (_allowedEncodings != null) { + for (var encodingName in _allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + encoder = encoders[encodingName]; + } else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + rawResponse.headers.set('content-encoding', key); + break; + } + } + } + } + + //_isClosed = true; + return _streamInitialized = true; + } + + return false; + } + + @override + Future addStream(Stream> stream) { + if (_isClosed && isBuffered) throw ResponseContext.closed(); + _openStream(); + + Stream> output = stream; + + if (encoders.isNotEmpty && correspondingRequest != null) { + if (_allowedEncodings != null) { + for (var encodingName in _allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + encoder = encoders[encodingName]; + } else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + output = encoders[key].bind(output); + break; + } + } + } + } + + return rawResponse.addStream(output); + } + + @override + void add(List data) { + if (_isClosed && isBuffered) { + throw ResponseContext.closed(); + } else if (!isBuffered) { + if (!_isClosed) { + _openStream(); + + if (encoders.isNotEmpty && correspondingRequest != null) { + if (_allowedEncodings != null) { + for (var encodingName in _allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + encoder = encoders[encodingName]; + } else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + data = encoders[key].convert(data); + break; + } + } + } + } + + rawResponse.add(data); + } + } else { + buffer.add(data); + } + } + + @override + Future close() { + if (!_isDetached) { + if (!_isClosed) { + if (!isBuffered) { + try { + _openStream(); + rawResponse.close(); + } catch (_) { + // This only seems to occur on `MockHttpRequest`, but + // this try/catch prevents a crash. + } + } else { + _buffer.lock(); + } + + _isClosed = true; + } + + super.close(); + } + return Future.value(); + } +} diff --git a/framework/lib/src/http2/angel_http2.dart b/framework/lib/src/http2/angel_http2.dart new file mode 100644 index 00000000..94ed6a66 --- /dev/null +++ b/framework/lib/src/http2/angel_http2.dart @@ -0,0 +1,237 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart' hide Header; +import 'package:angel_framework/http.dart'; +import 'package:http2/transport.dart'; +import 'package:mock_request/mock_request.dart'; +import 'http2_request_context.dart'; +import 'http2_response_context.dart'; +import 'package:uuid/uuid.dart'; + +/// Boots a shared server instance. Use this if launching multiple isolates. +Future startSharedHttp2( + address, int port, SecurityContext ctx) { + return SecureServerSocket.bind(address, port, ctx, shared: true); +} + +/// Adapts `package:http2`'s [ServerTransportConnection] to serve Angel. +class AngelHttp2 extends Driver { + final ServerSettings settings; + AngelHttp _http; + final StreamController _onHttp1 = StreamController(); + final Map _sessions = {}; + final Uuid _uuid = Uuid(); + _AngelHttp2ServerSocket _artificial; + + SecureServerSocket get socket => _artificial; + + AngelHttp2._( + Angel app, + Future Function(dynamic, int) serverGenerator, + bool useZone, + bool allowHttp1, + this.settings) + : super( + app, + serverGenerator, + useZone: useZone, + ) { + if (allowHttp1) { + _http = AngelHttp(app, useZone: useZone); + onHttp1.listen(_http.handleRequest); + } + } + + factory AngelHttp2(Angel app, SecurityContext securityContext, + {bool useZone = true, bool allowHttp1 = false, ServerSettings settings}) { + return AngelHttp2.custom(app, securityContext, SecureServerSocket.bind, + allowHttp1: allowHttp1, settings: settings); + } + + factory AngelHttp2.custom( + Angel app, + SecurityContext ctx, + Future serverGenerator( + address, int port, SecurityContext ctx), + {bool useZone = true, + bool allowHttp1 = false, + ServerSettings settings}) { + return AngelHttp2._(app, (address, port) { + var addr = address is InternetAddress + ? address + : InternetAddress(address.toString()); + return Future.sync(() => serverGenerator(addr, port, ctx)); + }, useZone, allowHttp1, settings); + } + + /// Fires when an HTTP/1.x request is received. + Stream get onHttp1 => _onHttp1.stream; + + @override + Future generateServer([address, int port]) async { + var s = await serverGenerator(address ?? '127.0.0.1', port ?? 0); + return _artificial = _AngelHttp2ServerSocket(s, this); + } + + @override + Future close() async { + await _artificial.close(); + await _http?.close(); + return await super.close(); + } + + @override + void addCookies(ServerTransportStream response, Iterable cookies) { + var headers = + cookies.map((cookie) => Header.ascii('set-cookie', cookie.toString())); + response.sendHeaders(headers.toList()); + } + + @override + Future closeResponse(ServerTransportStream response) { + response.terminate(); + return Future.value(); + } + + @override + Future createRequestContext( + Socket request, ServerTransportStream response) { + return Http2RequestContext.from(response, request, app, _sessions, _uuid); + } + + @override + Future createResponseContext( + Socket request, ServerTransportStream response, + [Http2RequestContext correspondingRequest]) async { + return Http2ResponseContext(app, response, correspondingRequest) + ..encoders.addAll(app.encoders); + } + + @override + Stream createResponseStreamFromRawRequest( + Socket request) { + var connection = + ServerTransportConnection.viaSocket(request, settings: settings); + return connection.incomingStreams; + } + + @override + void setChunkedEncoding(ServerTransportStream response, bool value) { + // Do nothing in HTTP/2 + } + + @override + void setContentLength(ServerTransportStream response, int length) { + setHeader(response, 'content-length', length.toString()); + } + + @override + void setHeader(ServerTransportStream response, String key, String value) { + response.sendHeaders([Header.ascii(key, value)]); + } + + @override + void setStatusCode(ServerTransportStream response, int value) { + response.sendHeaders([Header.ascii(':status', value.toString())]); + } + + @override + Uri get uri => Uri( + scheme: 'https', + host: server.address.address, + port: server.port != 443 ? server.port : null); + + @override + void writeStringToResponse(ServerTransportStream response, String value) { + writeToResponse(response, utf8.encode(value)); + } + + @override + void writeToResponse(ServerTransportStream response, List data) { + response.sendData(data); + } +} + +class _FakeServerSocket extends Stream implements ServerSocket { + final _AngelHttp2ServerSocket angel; + final _ctrl = StreamController(); + + _FakeServerSocket(this.angel); + + @override + InternetAddress get address => angel.address; + + @override + Future close() async { + (_ctrl.close()); + return this; + } + + @override + int get port => angel.port; + + @override + StreamSubscription listen(void Function(Socket event) onData, + {Function onError, void Function() onDone, bool cancelOnError}) { + return _ctrl.stream.listen(onData, + cancelOnError: cancelOnError, onError: onError, onDone: onDone); + } +} + +class _AngelHttp2ServerSocket extends Stream + implements SecureServerSocket { + final SecureServerSocket socket; + final AngelHttp2 driver; + final _ctrl = StreamController(); + _FakeServerSocket _fake; + StreamSubscription _sub; + + _AngelHttp2ServerSocket(this.socket, this.driver) { + _fake = _FakeServerSocket(this); + HttpServer.listenOn(_fake).pipe(driver._onHttp1); + _sub = socket.listen( + (socket) { + if (socket.selectedProtocol == null || + socket.selectedProtocol == 'http/1.0' || + socket.selectedProtocol == 'http/1.1') { + _fake._ctrl.add(socket); + } else if (socket.selectedProtocol == 'h2' || + socket.selectedProtocol == 'h2-14') { + _ctrl.add(socket); + } else { + socket.destroy(); + throw Exception( + 'AngelHttp2 does not support ${socket.selectedProtocol} as an ALPN protocol.'); + } + }, + onDone: _ctrl.close, + onError: (e, st) { + driver.app.logger.warning( + 'HTTP/2 incoming connection failure: ', e, st as StackTrace); + }, + ); + } + + InternetAddress get address => socket.address; + + int get port => socket.port; + + Future close() { + _sub?.cancel(); + _fake.close(); + _ctrl.close(); + return socket.close(); + } + + @override + StreamSubscription listen( + void Function(SecureSocket event) onData, + {Function onError, + void Function() onDone, + bool cancelOnError}) { + return _ctrl.stream.listen(onData, + cancelOnError: cancelOnError, onError: onError, onDone: onDone); + } +} diff --git a/framework/lib/src/http2/http2_request_context.dart b/framework/lib/src/http2/http2_request_context.dart new file mode 100644 index 00000000..de4953d8 --- /dev/null +++ b/framework/lib/src/http2/http2_request_context.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_container/src/container.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:http2/transport.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:uuid/uuid.dart'; + +final RegExp _comma = RegExp(r',\s*'); +final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + +class Http2RequestContext extends RequestContext { + final StreamController> _body = StreamController(); + final Container container; + List _cookies; + HttpHeaders _headers; + String _method, _override, _path; + HttpSession _session; + Socket _socket; + ServerTransportStream _stream; + Uri _uri; + + Http2RequestContext._(this.container); + + @override + Stream> get body => _body.stream; + + static Future from( + ServerTransportStream stream, + Socket socket, + Angel app, + Map sessions, + Uuid uuid) { + var c = Completer(); + var req = Http2RequestContext._(app.container.createChild()) + ..app = app + .._socket = socket + .._stream = stream; + + var headers = req._headers = MockHttpHeaders(); + // String scheme = 'https', host = socket.address.address, path = ''; + var uri = + Uri(scheme: 'https', host: socket.address.address, port: socket.port); + var cookies = []; + + void finalize() { + req + .._cookies = List.unmodifiable(cookies) + .._uri = uri; + if (!c.isCompleted) c.complete(req); + } + + void parseHost(String value) { + var inUri = Uri.tryParse(value); + if (inUri == null) return; + // if (uri == null || uri.scheme == 'localhost') return; + + if (inUri.hasScheme) uri = uri.replace(scheme: inUri.scheme); + + if (inUri.hasAuthority) { + uri = uri.replace(host: inUri.host, userInfo: inUri.userInfo); + } + + if (inUri.hasPort) uri = uri.replace(port: inUri.port); + } + + stream.incomingMessages.listen((msg) { + if (msg is DataStreamMessage) { + finalize(); + req._body.add(msg.bytes); + } else if (msg is HeadersStreamMessage) { + for (var header in msg.headers) { + var name = ascii.decode(header.name).toLowerCase(); + var value = Uri.decodeComponent(ascii.decode(header.value)); + + switch (name) { + case ':method': + req._method = value; + break; + case ':path': + var inUri = Uri.parse(value); + uri = uri.replace(path: inUri.path); + if (inUri.hasQuery) uri = uri.replace(query: inUri.query); + var path = uri.path.replaceAll(_straySlashes, ''); + req._path = path; + if (path.isEmpty) req._path = '/'; + break; + case ':scheme': + uri = uri.replace(scheme: value); + break; + case ':authority': + parseHost(value); + break; + case 'cookie': + var cookieStrings = value.split(';').map((s) => s.trim()); + + for (var cookieString in cookieStrings) { + try { + cookies.add(Cookie.fromSetCookieValue(cookieString)); + } catch (_) { + // Ignore malformed cookies, and just don't add them to the container. + } + } + break; + default: + var name = ascii.decode(header.name).toLowerCase(); + + if (name == 'host') { + parseHost(value); + } + + headers.add(name, value.split(_comma)); + break; + } + } + + if (msg.endStream) finalize(); + } + }, onDone: () { + finalize(); + req._body.close(); + }, cancelOnError: true, onError: c.completeError); + + // Apply session + var dartSessId = + cookies.firstWhere((c) => c.name == 'DARTSESSID', orElse: () => null); + + if (dartSessId == null) { + dartSessId = Cookie('DARTSESSID', uuid.v4()); + } + + req._session = sessions.putIfAbsent( + dartSessId.value, + () => MockHttpSession(id: dartSessId.value), + ); + + return c.future; + } + + @override + List get cookies => _cookies; + + /// The underlying HTTP/2 [ServerTransportStream]. + ServerTransportStream get stream => _stream; + + @override + Uri get uri => _uri; + + @override + HttpSession get session { + return _session; + } + + @override + InternetAddress get remoteAddress => _socket.remoteAddress; + + @override + String get path { + return _path; + } + + @override + String get originalMethod { + return _method; + } + + @override + String get method { + return _override ?? _method; + } + + @override + String get hostname => _headers.value('host'); + + @override + HttpHeaders get headers => _headers; + + @override + Future close() { + _body.close(); + return super.close(); + } + + @override + ServerTransportStream get rawRequest => _stream; +} diff --git a/framework/lib/src/http2/http2_response_context.dart b/framework/lib/src/http2/http2_response_context.dart new file mode 100644 index 00000000..55fb6d45 --- /dev/null +++ b/framework/lib/src/http2/http2_response_context.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart' hide Header; +import 'package:http2/transport.dart'; +import 'http2_request_context.dart'; + +class Http2ResponseContext extends ResponseContext { + final Angel app; + final ServerTransportStream stream; + + ServerTransportStream get rawResponse => stream; + + LockableBytesBuilder _buffer; + + final Http2RequestContext _req; + + bool _isDetached = false, + _isClosed = false, + _streamInitialized = false, + _isPush = false; + + Uri _targetUri; + + Http2ResponseContext(this.app, this.stream, this._req) { + _targetUri = _req.uri; + } + + final List _pushes = []; + + /// Returns `true` if an attempt to [push] a resource will succeed. + /// + /// See [ServerTransportStream].`push`. + bool get canPush => stream.canPush; + + /// Returns a [List] of all resources that have [push]ed to the client. + List get pushes => List.unmodifiable(_pushes); + + @override + ServerTransportStream detach() { + _isDetached = true; + return stream; + } + + @override + RequestContext get correspondingRequest => _req; + + Uri get targetUri => _targetUri; + + @override + bool get isOpen { + return !_isClosed && !_isDetached; + } + + @override + bool get isBuffered => _buffer != null; + + @override + BytesBuilder get buffer => _buffer; + + @override + void addError(Object error, [StackTrace stackTrace]) { + super.addError(error, stackTrace); + } + + @override + void useBuffer() { + _buffer = LockableBytesBuilder(); + } + + /// Write headers, status, etc. to the underlying [stream]. + bool _openStream() { + if (_isPush || _streamInitialized) return false; + + var headers =
[ + Header.ascii(':status', statusCode.toString()), + ]; + + if (encoders.isNotEmpty && correspondingRequest != null) { + if (_allowedEncodings != null) { + for (var encodingName in _allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + encoder = encoders[encodingName]; + } else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + this.headers['content-encoding'] = key; + break; + } + } + } + } + + // Add all normal headers + for (var key in this.headers.keys) { + headers.add(Header.ascii(key.toLowerCase(), this.headers[key])); + } + + // Persist session ID + cookies.add(Cookie('DARTSESSID', _req.session.id)); + + // Send all cookies + for (var cookie in cookies) { + headers.add(Header.ascii('set-cookie', cookie.toString())); + } + + stream.sendHeaders(headers); + return _streamInitialized = true; + } + + Iterable __allowedEncodings; + + Iterable get _allowedEncodings { + return __allowedEncodings ??= correspondingRequest.headers + .value('accept-encoding') + ?.split(',') + ?.map((s) => s.trim()) + ?.where((s) => s.isNotEmpty) + ?.map((str) { + // Ignore quality specifications in accept-encoding + // ex. gzip;q=0.8 + if (!str.contains(';')) return str; + return str.split(';')[0]; + }); + } + + @override + Future addStream(Stream> stream) { + if (!isOpen && isBuffered) throw ResponseContext.closed(); + _openStream(); + + Stream> output = stream; + + if (encoders.isNotEmpty && correspondingRequest != null) { + if (_allowedEncodings != null) { + for (var encodingName in _allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + encoder = encoders[encodingName]; + } else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + output = encoders[key].bind(output); + break; + } + } + } + } + + return output.forEach(this.stream.sendData); + } + + @override + void add(List data) { + if (!isOpen && isBuffered) { + throw ResponseContext.closed(); + } else if (!isBuffered) { + _openStream(); + + if (!_isClosed) { + if (encoders.isNotEmpty && correspondingRequest != null) { + if (_allowedEncodings != null) { + for (var encodingName in _allowedEncodings) { + Converter, List> encoder; + String key = encodingName; + + if (encoders.containsKey(encodingName)) { + encoder = encoders[encodingName]; + } else if (encodingName == '*') { + encoder = encoders[key = encoders.keys.first]; + } + + if (encoder != null) { + data = encoders[key].convert(data); + break; + } + } + } + } + + stream.sendData(data); + } + } else { + buffer.add(data); + } + } + + @override + Future close() async { + if (!_isDetached && !_isClosed && !isBuffered) { + _openStream(); + await stream.outgoingMessages.close(); + } + + _isClosed = true; + await super.close(); + } + + /// Pushes a resource to the client. + Http2ResponseContext push(String path, + {Map headers = const {}, String method = 'GET'}) { + var targetUri = _req.uri.replace(path: path); + + var h =
[ + Header.ascii(':authority', targetUri.authority), + Header.ascii(':method', method), + Header.ascii(':path', targetUri.path), + Header.ascii(':scheme', targetUri.scheme), + ]; + + for (var key in headers.keys) { + h.add(Header.ascii(key, headers[key])); + } + + var s = stream.push(h); + var r = Http2ResponseContext(app, s, _req) + .._isPush = true + .._targetUri = targetUri; + _pushes.add(r); + return r; + } +} diff --git a/framework/lib/src/safe_stream_controller.dart b/framework/lib/src/safe_stream_controller.dart new file mode 100644 index 00000000..bd2de680 --- /dev/null +++ b/framework/lib/src/safe_stream_controller.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +typedef void _InitCallback(); + +/// A [StreamController] boilerplate that prevents memory leaks. +abstract class SafeCtrl { + factory SafeCtrl() => _SingleSafeCtrl(); + + factory SafeCtrl.broadcast() => _BroadcastSafeCtrl(); + + Stream get stream; + + void add(T event); + + void addError(error, [StackTrace stackTrace]); + + Future close(); + + void whenInitialized(void callback()); +} + +class _SingleSafeCtrl implements SafeCtrl { + StreamController _stream; + bool _hasListener = false, _initialized = false; + _InitCallback _initializer; + + _SingleSafeCtrl() { + _stream = StreamController(onListen: () { + _hasListener = true; + + if (!_initialized && _initializer != null) { + _initializer(); + _initialized = true; + } + }, onPause: () { + _hasListener = false; + }, onResume: () { + _hasListener = true; + }, onCancel: () { + _hasListener = false; + }); + } + + @override + Stream get stream => _stream.stream; + + @override + void add(T event) { + if (_hasListener) _stream.add(event); + } + + @override + void addError(error, [StackTrace stackTrace]) { + if (_hasListener) _stream.addError(error, stackTrace); + } + + @override + Future close() { + return _stream.close(); + } + + @override + void whenInitialized(void callback()) { + if (!_initialized) { + if (!_hasListener) { + _initializer = callback; + } else { + _initializer(); + _initialized = true; + } + } + } +} + +class _BroadcastSafeCtrl implements SafeCtrl { + StreamController _stream; + int _listeners = 0; + bool _initialized = false; + _InitCallback _initializer; + + _BroadcastSafeCtrl() { + _stream = StreamController.broadcast(onListen: () { + _listeners++; + + if (!_initialized && _initializer != null) { + _initializer(); + _initialized = true; + } + }, onCancel: () { + _listeners--; + }); + } + + @override + Stream get stream => _stream.stream; + + @override + void add(T event) { + if (_listeners > 0) _stream.add(event); + } + + @override + void addError(error, [StackTrace stackTrace]) { + if (_listeners > 0) _stream.addError(error, stackTrace); + } + + @override + Future close() { + return _stream.close(); + } + + @override + void whenInitialized(void callback()) { + if (!_initialized) { + if (_listeners <= 0) { + _initializer = callback; + } else { + _initializer(); + _initialized = true; + } + } + } +} diff --git a/framework/lib/src/util.dart b/framework/lib/src/util.dart new file mode 100644 index 00000000..bda41d9e --- /dev/null +++ b/framework/lib/src/util.dart @@ -0,0 +1,27 @@ +import 'package:angel_container/angel_container.dart'; + +final RegExp straySlashes = RegExp(r'(^/+)|(/+$)'); + +T matchingAnnotation(List metadata) { + for (ReflectedInstance metaDatum in metadata) { + if (metaDatum.type.reflectedType == T) { + return metaDatum.reflectee as T; + } + } + + return null; +} + +T getAnnotation(obj, Reflector reflector) { + if (reflector == null) { + return null; + } else { + if (obj is Function) { + var methodMirror = reflector.reflectFunction(obj); + return matchingAnnotation(methodMirror.annotations); + } else { + var classMirror = reflector.reflectClass(obj.runtimeType as Type); + return matchingAnnotation(classMirror.annotations); + } + } +} diff --git a/framework/performance/hello/angel.md b/framework/performance/hello/angel.md new file mode 100644 index 00000000..e231e08c --- /dev/null +++ b/framework/performance/hello/angel.md @@ -0,0 +1,62 @@ +# Angel Results +5 consecutive trials run on a Windows 10 box with 4GB RAM, and several programs open in the background. + +Setup: +* Angel framework `1.0.8` +* Running `wrk` 4.0.2.2 +* 2 threads +* 256 connections +* 30 seconds + +Average: +* `11070.18` req/sec +* `11.86` ms latency + +``` +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 12.23ms 7.56ms 206.05ms 93.09% + Req/Sec 5.48k 761.94 7.18k 87.50% + 324822 requests in 30.06s, 62.88MB read +Requests/sec: 10806.24 +Transfer/sec: 2.09MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 11.06ms 4.88ms 134.86ms 78.68% + Req/Sec 5.98k 539.40 7.50k 91.40% + 356355 requests in 30.11s, 68.99MB read +Requests/sec: 11836.11 +Transfer/sec: 2.29MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 12.03ms 6.18ms 159.93ms 87.89% + Req/Sec 5.52k 0.88k 7.32k 90.31% + 327749 requests in 30.06s, 63.45MB read +Requests/sec: 10901.35 +Transfer/sec: 2.11MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 12.92ms 7.06ms 189.00ms 82.48% + Req/Sec 5.12k 1.00k 6.42k 75.59% + 302273 requests in 30.05s, 58.52MB read +Requests/sec: 10059.96 +Transfer/sec: 1.95MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 11.05ms 4.92ms 104.90ms 69.57% + Req/Sec 5.95k 0.87k 7.65k 76.80% + 352798 requests in 30.03s, 68.30MB read +Requests/sec: 11747.23 +Transfer/sec: 2.27MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ +``` \ No newline at end of file diff --git a/framework/performance/hello/main.dart b/framework/performance/hello/main.dart new file mode 100644 index 00000000..52358e9a --- /dev/null +++ b/framework/performance/hello/main.dart @@ -0,0 +1,23 @@ +/// A basic server that prints "Hello, world!" +library performance.hello; + +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; + +main() async { + var app = Angel(); + var http = AngelHttp.custom(app, startShared, useZone: false); + + app.get('/', (req, res) => res.write('Hello, world!')); + app.optimizeForProduction(force: true); + + var oldHandler = app.errorHandler; + app.errorHandler = (e, req, res) { + print('Oops: ${e.error ?? e}'); + print(e.stackTrace); + return oldHandler(e, req, res); + }; + + await http.startServer('127.0.0.1', 3000); + print('Listening at ${http.uri}'); +} diff --git a/framework/performance/hello/raw.dart b/framework/performance/hello/raw.dart new file mode 100644 index 00000000..423ba843 --- /dev/null +++ b/framework/performance/hello/raw.dart @@ -0,0 +1,18 @@ +/// A basic server that prints "Hello, world!" +library performance.hello; + +import 'dart:io'; + +main() { + return HttpServer.bind('127.0.0.1', 3000, shared: true).then((server) { + print('Listening at http://${server.address.address}:${server.port}'); + + server.listen((request) { + if (request.uri.path == '/') { + request.response.write('Hello, world!'); + } + + request.response.close(); + }); + }); +} diff --git a/framework/performance/hello/raw.md b/framework/performance/hello/raw.md new file mode 100644 index 00000000..0027da54 --- /dev/null +++ b/framework/performance/hello/raw.md @@ -0,0 +1,60 @@ +# `dart:io` Results +5 consecutive trials run on a Windows 10 box with 4GB RAM, and several programs open in the background. + +Setup: +* Running `wrk` 4.0.2.2 +* 2 threads +* 256 connections +* 30 seconds + +Average: +* `14598.16` req/sec +* `8.88` ms latency + +``` +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 9.67ms 8.19ms 202.28ms 96.17% + Req/Sec 7.15k 1.47k 9.97k 73.76% + 417716 requests in 30.07s, 82.06MB read +Requests/sec: 13892.50 +Transfer/sec: 2.73MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 8.47ms 3.14ms 100.77ms 65.40% + Req/Sec 7.61k 670.47 8.85k 73.88% + 453301 requests in 30.07s, 89.05MB read +Requests/sec: 15077.15 +Transfer/sec: 2.96MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 8.62ms 3.51ms 73.34ms 63.74% + Req/Sec 7.52k 650.22 8.91k 79.17% + 448445 requests in 30.07s, 88.10MB read +Requests/sec: 14911.53 +Transfer/sec: 2.93MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 8.75ms 3.51ms 70.50ms 64.53% + Req/Sec 7.41k 825.50 10.23k 72.24% + 441338 requests in 30.09s, 86.70MB read +Requests/sec: 14665.62 +Transfer/sec: 2.88MB +tobe@LAPTOP-VBHCSVRH:/mnt/c/Users/thosa$ wrk -c 256 -d 30 -t 2 http://localhost:3000 +Running 30s test @ http://localhost:3000 + 2 threads and 256 connections + Thread Stats Avg Stdev Max +/- Stdev + Latency 8.90ms 3.62ms 78.36ms 66.71% + Req/Sec 7.31k 742.11 10.79k 77.84% + 434674 requests in 30.09s, 85.39MB read +Requests/sec: 14443.98 +Transfer/sec: 2.84MB +``` \ No newline at end of file diff --git a/framework/pubspec.yaml b/framework/pubspec.yaml new file mode 100644 index 00000000..bdae6c0a --- /dev/null +++ b/framework/pubspec.yaml @@ -0,0 +1,37 @@ +name: angel_framework +version: 2.1.1 +description: A high-powered HTTP server with dependency injection, routing and much more. +author: Tobe O +homepage: https://github.com/angel-dart/angel_framework +environment: + sdk: ">=2.0.0-dev <3.0.0" +dependencies: + angel_container: ^1.0.4 + angel_http_exception: ^1.0.0 + angel_model: ^1.0.0 + angel_route: ^3.0.0 + charcode: ^1.0.0 + combinator: ^1.0.0 + file: ^5.0.0 + http_parser: ^3.0.0 + http_server: ^0.9.0 + http2: "^1.0.0" + logging: ">=0.11.3 <1.0.0" + matcher: ^0.12.0 + merge_map: ^1.0.0 + meta: ^1.0.0 + mime: ^0.9.3 + mock_request: ^1.0.0 + path: ^1.0.0 + # pedantic: ^1.0.0 + quiver_hashcode: ^2.0.0 + recase: ^2.0.0 + stack_trace: ^1.0.0 + string_scanner: ^1.0.0 + tuple: ^1.0.0 + uuid: ^2.0.0-rc.1 +dev_dependencies: + http: ^0.12.0 + io: ^0.3.0 + pretty_logging: ^1.0.0 + test: ^1.0.0 diff --git a/framework/test/404_hole_test.dart b/framework/test/404_hole_test.dart new file mode 100644 index 00000000..eaaf9702 --- /dev/null +++ b/framework/test/404_hole_test.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:charcode/ascii.dart'; +import 'package:http/io_client.dart' as http; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; +import 'pretty_log.dart'; + +void main() { + http.IOClient client; + AngelHttp driver; + Logger logger; + + setUp(() async { + client = http.IOClient(); + hierarchicalLoggingEnabled = true; + + logger = Logger.detached('404_hole') + ..level = Level.ALL + ..onRecord.listen(prettyLog); + + var app = Angel(logger: logger); + + app.fallback(hello); + app.fallback(throw404); + + // The error handler in the boilerplate. + var oldErrorHandler = app.errorHandler; + app.errorHandler = (e, req, res) async { + if (req.accepts('text/html', strict: true)) { + if (e.statusCode == 404 && req.accepts('text/html', strict: true)) { + await res + .render('error', {'message': 'No file exists at ${req.uri}.'}); + } else { + await res.render('error', {'message': e.message}); + } + } else { + return await oldErrorHandler(e, req, res); + } + }; + + driver = AngelHttp(app); + await driver.startServer(); + }); + + tearDown(() { + logger.clearListeners(); + client.close(); + scheduleMicrotask(driver.close); + }); + + test('does not continue processing after streaming', () async { + var url = driver.uri.replace(path: '/hey'); + for (int i = 0; i < 100; i++) { + var r = await client.get(url); + print('#$i: ${r.statusCode}: ${r.body}'); + expect(r.statusCode, 200); + expect(r.body, 'Hello!'); + } + }); +} + +/// Simulate angel_static +Future hello(RequestContext req, ResponseContext res) { + if (req.path == 'hey') { + var bytes = [$H, $e, $l, $l, $o, $exclamation]; + var s = Stream>.fromIterable([bytes]); + return s.pipe(res); + } else { + return Future.value(true); + } +} + +/// 404 +void throw404(RequestContext req, ResponseContext res) { + Zone.current + .handleUncaughtError('This 404 should not occur.', StackTrace.current); + throw AngelHttpException.notFound(); +} diff --git a/framework/test/accepts_test.dart b/framework/test/accepts_test.dart new file mode 100644 index 00000000..6e670e4c --- /dev/null +++ b/framework/test/accepts_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +final Uri ENDPOINT = Uri.parse('http://example.com/accept'); + +main() { + test('no content type', () async { + var req = await acceptContentTypes(); + expect(req.acceptsAll, isFalse); + //expect(req.accepts(ContentType.JSON), isFalse); + expect(req.accepts('application/json'), isFalse); + //expect(req.accepts(ContentType.HTML), isFalse); + expect(req.accepts('text/html'), isFalse); + }); + + test('wildcard', () async { + var req = await acceptContentTypes(['*/*']); + expect(req.acceptsAll, isTrue); + //expect(req.accepts(ContentType.JSON), isTrue); + expect(req.accepts('application/json'), isTrue); + //expect(req.accepts(ContentType.HTML), isTrue); + expect(req.accepts('text/html'), isTrue); + }); + + test('specific type', () async { + var req = await acceptContentTypes(['text/html']); + expect(req.acceptsAll, isFalse); + //expect(req.accepts(ContentType.JSON), isFalse); + expect(req.accepts('application/json'), isFalse); + //expect(req.accepts(ContentType.HTML), isTrue); + expect(req.accepts('text/html'), isTrue); + }); + + test('strict', () async { + var req = await acceptContentTypes(['text/html', "*/*"]); + expect(req.accepts('text/html'), isTrue); + //expect(req.accepts(ContentType.HTML), isTrue); + //expect(req.accepts(ContentType.JSON, strict: true), isFalse); + expect(req.accepts('application/json', strict: true), isFalse); + }); + + group('disallow null', () { + RequestContext req; + + setUp(() async { + req = await acceptContentTypes(); + }); + + test('throws error', () { + expect(() => req.accepts(null), throwsArgumentError); + }); + }); +} + +Future acceptContentTypes( + [Iterable contentTypes = const []]) { + var headerString = contentTypes.isEmpty ? null : contentTypes.join(','); + var rq = MockHttpRequest('GET', ENDPOINT); + rq.headers.set('accept', headerString); + rq.close(); + var app = Angel(reflector: MirrorsReflector()); + var http = AngelHttp(app); + return http.createRequestContext(rq, rq.response); +} diff --git a/framework/test/all.dart b/framework/test/all.dart new file mode 100644 index 00000000..5f517bbb --- /dev/null +++ b/framework/test/all.dart @@ -0,0 +1,62 @@ +import 'dart:io'; +import 'package:io/ansi.dart'; +import '404_hole_test.dart' as hole404; +import 'accepts_test.dart' as accepts; +import 'anonymous_service_test.dart' as anonymous_service; +import 'body_test.dart' as body; +import 'controller_test.dart' as controller; +import 'detach_test.dart' as detach; +import 'di_test.dart' as di; +import 'encoders_buffer_test.dart' as encoders_buffer; +import 'env_test.dart' as env; +import 'exception_test.dart' as exception; +import 'extension_test.dart' as extension_test; +import 'find_one_test.dart' as find_one; +import 'general_test.dart' as general; +import 'hooked_test.dart' as hooked; +import 'parameter_meta_test.dart' as parameter_meta; +import 'parse_id_test.dart' as parse_id; +import 'precontained_test.dart' as precontained; +import 'primitives_test.dart' as primitives; +import 'repeat_request_test.dart' as repeat_request; +import 'req_shutdown_test.dart' as req_shutdown; +import 'routing_test.dart' as routing; +import 'serialize_test.dart' as serialize; +import 'server_test.dart' as server; +import 'service_map_test.dart' as service_map; +import 'services_test.dart' as services; +import 'streaming_test.dart' as streaming; +import 'view_generator_test.dart' as view_generator; +import 'package:test/test.dart'; + +/// For running with coverage +main() { + print(cyan.wrap('Running tests on ${Platform.version}')); + group('404_hole', hole404.main); + group('accepts', accepts.main); + group('anonymous service', anonymous_service.main); + group('body', body.main); + group('controller', controller.main); + group('detach', detach.main); + group('di', di.main); + group('encoders_buffer', encoders_buffer.main); + group('env', env.main); + group('exception', exception.main); + group('extension', extension_test.main); + group('find_one', find_one.main); + group('general', general.main); + group('hooked', hooked.main); + group('parameter_meta', parameter_meta.main); + group('parse_id', parse_id.main); + group('precontained', precontained.main); + group('primitives', primitives.main); + group('repeat_request', repeat_request.main); + group('req_shutdown', req_shutdown.main); + group('routing', routing.main); + group('serialize', serialize.main); + group('server', server.main); + group('service_map', service_map.main); + group('services', services.main); + group('streaming', streaming.main); + group('view generator', view_generator.main); +} diff --git a/framework/test/anonymous_service_test.dart b/framework/test/anonymous_service_test.dart new file mode 100644 index 00000000..41456a15 --- /dev/null +++ b/framework/test/anonymous_service_test.dart @@ -0,0 +1,44 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:test/test.dart'; + +main() { + test('custom methods', () async { + var svc = AnonymousService( + index: ([p]) async => ['index'], + read: (id, [p]) async => 'read', + create: (data, [p]) async => 'create', + modify: (id, data, [p]) async => 'modify', + update: (id, data, [p]) async => 'update', + remove: (id, [p]) async => 'remove'); + expect(await svc.index(), ['index']); + expect(await svc.read(null), 'read'); + expect(await svc.create(null), 'create'); + expect(await svc.modify(null, null), 'modify'); + expect(await svc.update(null, null), 'update'); + expect(await svc.remove(null), 'remove'); + }); + + test('defaults to throwing', () async { + try { + var svc = AnonymousService(); + await svc.read(1); + throw 'Should have thrown 405!'; + } on AngelHttpException { + // print('Ok!'); + } + try { + var svc = AnonymousService(); + await svc.modify(2, null); + throw 'Should have thrown 405!'; + } on AngelHttpException { + // print('Ok!'); + } + try { + var svc = AnonymousService(); + await svc.update(3, null); + throw 'Should have thrown 405!'; + } on AngelHttpException { + // print('Ok!'); + } + }); +} diff --git a/framework/test/body_test.dart b/framework/test/body_test.dart new file mode 100644 index 00000000..45a64ba2 --- /dev/null +++ b/framework/test/body_test.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +void main() { + var app = Angel(); + var http = AngelHttp(app); + + Future request( + {bool asJson = true, + bool parse = true, + Map bodyFields, + List bodyList}) async { + var rq = MockHttpRequest('POST', Uri(path: '/')); + + if (bodyFields != null) { + if (asJson) { + rq + ..headers.contentType = ContentType('application', 'json') + ..write(json.encode(bodyFields)); + } else { + var b = StringBuffer(); + var i = 0; + for (var entry in bodyFields.entries) { + if (i++ > 0) b.write('&'); + b.write(entry.key); + b.write('='); + b.write(Uri.encodeComponent(entry.value.toString())); + } + + rq + ..headers.contentType = + ContentType('application', 'x-www-form-urlencoded') + ..write(json.encode(b.toString())); + } + } else if (bodyList != null) { + rq + ..headers.contentType = ContentType('application', 'json') + ..write(json.encode(bodyList)); + } + + await rq.close(); + var req = await http.createRequestContext(rq, rq.response); + if (parse) await req.parseBody(); + return req; + } + + test('parses json maps', () async { + var req = await request(bodyFields: {'hello': 'world'}); + expect(req.bodyAsObject, TypeMatcher>()); + expect(req.bodyAsMap, {'hello': 'world'}); + }); + + test('parses json lists', () async { + var req = await request(bodyList: ['foo', 'bar']); + expect(req.bodyAsObject, TypeMatcher()); + expect(req.bodyAsList, ['foo', 'bar']); + }); + + test('deserializeBody', () async { + var req = await request( + asJson: true, bodyFields: {'text': 'Hey', 'complete': false}); + var todo = await req.deserializeBody(Todo.fromMap); + expect(todo.text, 'Hey'); + expect(todo.completed, false); + }); + + test('decodeBody', () async { + var req = await request( + asJson: true, bodyFields: {'text': 'Hey', 'complete': false}); + var todo = await req.decodeBody(TodoCodec()); + expect(todo.text, 'Hey'); + expect(todo.completed, false); + }); + + test('throws when body has not been parsed', () async { + var req = await request(parse: false); + expect(() => req.bodyAsObject, throwsStateError); + expect(() => req.bodyAsMap, throwsStateError); + expect(() => req.bodyAsList, throwsStateError); + }); + + test('can set body object exactly once', () async { + var req = await request(parse: false); + req.bodyAsObject = 23; + expect(req.bodyAsObject, 23); + expect(() => req.bodyAsObject = {45.6: '34'}, throwsStateError); + }); + + test('can set body map exactly once', () async { + var req = await request(parse: false); + req.bodyAsMap = {'hey': 'yes'}; + expect(req.bodyAsMap, {'hey': 'yes'}); + expect(() => req.bodyAsMap = {'hm': 'ok'}, throwsStateError); + }); + + test('can set body list exactly once', () async { + var req = await request(parse: false); + req.bodyAsList = [ + {'hey': 'yes'} + ]; + expect(req.bodyAsList, [ + {'hey': 'yes'} + ]); + expect( + () => req.bodyAsList = [ + {'hm': 'ok'} + ], + throwsStateError); + }); +} + +class Todo { + String text; + bool completed; + + Todo({this.text, this.completed}); + + static Todo fromMap(Map m) => + Todo(text: m['text'] as String, completed: m['complete'] as bool); +} + +class TodoCodec extends Codec { + @override + Converter get decoder => TodoDecoder(); + + @override + Converter get encoder => throw UnsupportedError('no encoder'); +} + +class TodoDecoder extends Converter { + @override + Todo convert(Map input) => Todo.fromMap(input); +} diff --git a/framework/test/common.dart b/framework/test/common.dart new file mode 100644 index 00000000..84b98550 --- /dev/null +++ b/framework/test/common.dart @@ -0,0 +1,60 @@ +library angel_framework.test.common; + +import 'package:angel_framework/angel_framework.dart'; +import 'package:matcher/matcher.dart'; + +class Todo extends Model { + String text; + String over; + + Todo({this.text, this.over}); + + Map toJson() { + return { + 'text': text, + 'over': over, + }; + } +} + +class BookService extends Service { + @override + index([params]) async { + print('Book params: $params'); + + return [ + {'foo': 'bar'} + ]; + } +} + +incrementTodoTimes(e) { + IncrementService.TIMES++; +} + +@Hooks(before: [incrementTodoTimes]) +class IncrementService extends Service { + static int TIMES = 0; + + @override + @Hooks(after: [incrementTodoTimes]) + index([params]) async => []; +} + +class IsInstanceOf implements Matcher { + const IsInstanceOf(); + + @override + Description describeMismatch( + item, Description mismatchDescription, Map matchState, bool verbose) { + return mismatchDescription.add('$item is not an instance of $T'); + } + + @override + Description describe(Description description) { + return description.add('is an instance of $T'); + } + + @override + bool matches(item, Map matchState) => item is T; +} diff --git a/framework/test/controller_test.dart b/framework/test/controller_test.dart new file mode 100644 index 00000000..d5689c72 --- /dev/null +++ b/framework/test/controller_test.dart @@ -0,0 +1,207 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:http/http.dart' as http; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +@Expose("/todos", middleware: [foo]) +class TodoController extends Controller { + List todos = [Todo(text: "Hello", over: "world")]; + + @Expose("/:id", middleware: [bar]) + Future fetchTodo( + String id, RequestContext req, ResponseContext res) async { + expect(req, isNotNull); + expect(res, isNotNull); + return todos[int.parse(id)]; + } + + @Expose("/namedRoute/:foo", as: "foo") + Future someRandomRoute( + RequestContext req, ResponseContext res) async { + return "${req.params['foo']}!"; + } +} + +class NoExposeController extends Controller { + String getIndex() => 'Hey!'; + + int timesTwo(int n) => n * 2; + + String repeatName(String name, int times) { + var b = StringBuffer(); + for (int i = 0; i < times; i++) { + b.writeln(name); + } + return b.toString(); + } + + @Expose('/yellow', method: 'POST') + String someColor() => 'yellow'; + + @Expose.patch + int three() => 333; + + @noExpose + String hideThis() => 'Should not be exposed'; +} + +@Expose('/named', as: 'foo') +class NamedController extends Controller { + @Expose('/optional/:arg?', allowNull: ['arg']) + optional() => 2; +} + +bool foo(RequestContext req, ResponseContext res) { + res.write("Hello, "); + return true; +} + +bool bar(RequestContext req, ResponseContext res) { + res.write("world!"); + return true; +} + +main() { + Angel app; + TodoController todoController; + NoExposeController noExposeCtrl; + HttpServer server; + http.Client client = http.Client(); + String url; + + setUp(() async { + app = Angel(reflector: MirrorsReflector()); + app.get( + "/redirect", + (req, res) async => + res.redirectToAction("TodoController@foo", {"foo": "world"})); + + // Register as a singleton, just for the purpose of this test + if (!app.container.has()) { + app.container.registerSingleton(todoController = TodoController()); + } + + // Using mountController(); + await app.mountController(); + + noExposeCtrl = await app.mountController(); + + // Place controller in group. The applyRoutes() call, however, is async. + // Until https://github.com/angel-dart/route/issues/28 is closed, + // this will need to be done by manually mounting the router. + var subRouter = Router(); + await todoController.applyRoutes(subRouter, app.container.reflector); + app.mount('/ctrl_group', subRouter); + + print(app.controllers); + app.dumpTree(); + + server = await AngelHttp(app).startServer(); + url = 'http://${server.address.address}:${server.port}'; + }); + + tearDown(() async { + await server.close(force: true); + app = null; + url = null; + }); + + test('basic', () { + expect(todoController.app, app); + }); + + test('create dynamic handler', () async { + var app = Angel(reflector: MirrorsReflector()); + app.get( + '/foo', + ioc(({String bar}) { + return 2; + }, optional: ['bar'])); + var rq = MockHttpRequest('GET', Uri(path: 'foo')); + await AngelHttp(app).handleRequest(rq); + var body = await utf8.decoder.bind(rq.response).join(); + expect(json.decode(body), 2); + }); + + test('optional name', () async { + var app = Angel(reflector: MirrorsReflector()); + await app.configure(NamedController().configureServer); + expect(app.controllers['foo'], const IsInstanceOf()); + }); + + test("middleware", () async { + var rgx = RegExp("^Hello, world!"); + var response = await client.get("$url/todos/0"); + print('Response: ${response.body}'); + + expect(rgx.firstMatch(response.body)?.start, equals(0)); + + var todo = json.decode(response.body.replaceAll(rgx, "")) as Map; + print("Todo: $todo"); + expect(todo['text'], equals("Hello")); + expect(todo['over'], equals("world")); + }); + + test("controller in group", () async { + var rgx = RegExp("^Hello, world!"); + var response = await client.get("$url/ctrl_group/todos/0"); + print('Response: ${response.body}'); + + expect(rgx.firstMatch(response.body)?.start, equals(0)); + + var todo = json.decode(response.body.replaceAll(rgx, "")) as Map; + print("Todo: $todo"); + expect(todo['text'], equals("Hello")); + expect(todo['over'], equals("world")); + }); + + test("named actions", () async { + var response = await client.get("$url/redirect"); + print('Response: ${response.body}'); + expect(response.body, equals("Hello, \"world!\"")); + }); + + group('optional expose', () { + test('removes suffixes from controller names', () { + expect(noExposeCtrl.mountPoint.path, 'no_expose'); + }); + + test('mounts correct routes', () { + print(noExposeCtrl.routeMappings.keys); + expect(noExposeCtrl.routeMappings.keys.toList(), + ['getIndex', 'timesTwo', 'repeatName', 'someColor', 'three']); + }); + + test('mounts correct methods', () { + void expectMethod(String name, String method) { + expect(noExposeCtrl.routeMappings[name].method, method); + } + + expectMethod('getIndex', 'GET'); + expectMethod('timesTwo', 'GET'); + expectMethod('repeatName', 'GET'); + expectMethod('someColor', 'POST'); + expectMethod('three', 'PATCH'); + }); + + test('mounts correct paths', () { + void expectPath(String name, String path) { + expect(noExposeCtrl.routeMappings[name].path, path); + } + + expectPath('getIndex', '/'); + expectPath('timesTwo', '/times_two/int:n'); + expectPath('repeatName', '/repeat_name/:name/int:times'); + expectPath('someColor', '/yellow'); + expectPath('three', '/three'); + }); + }); +} diff --git a/framework/test/detach_test.dart b/framework/test/detach_test.dart new file mode 100644 index 00000000..fcd4af84 --- /dev/null +++ b/framework/test/detach_test.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +void main() { + AngelHttp http; + + setUp(() async { + var app = Angel(); + http = AngelHttp(app); + + app.get('/detach', (req, res) async { + if (res is HttpResponseContext) { + var io = await res.detach(); + io..write('Hey!'); + await io.close(); + } else { + throw StateError('This endpoint only supports HTTP/1.1.'); + } + }); + }); + + tearDown(() => http.close()); + + test('detach response', () async { + var rq = MockHttpRequest('GET', Uri.parse('/detach')); + await rq.close(); + var rs = rq.response; + await http.handleRequest(rq); + var body = await rs.transform(utf8.decoder).join(); + expect(body, 'Hey!'); + }); +} diff --git a/framework/test/di_test.dart b/framework/test/di_test.dart new file mode 100644 index 00000000..a0df1f36 --- /dev/null +++ b/framework/test/di_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_container/angel_container.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:http/http.dart' as http; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +final String TEXT = "make your bed"; +final String OVER = "never"; + +main() { + Angel app; + http.Client client; + HttpServer server; + String url; + + setUp(() async { + app = Angel(reflector: MirrorsReflector()); + client = http.Client(); + + // Inject some todos + app.container.registerSingleton(Todo(text: TEXT, over: OVER)); + app.container.registerFactory>((container) async { + var req = container.make(); + var text = await utf8.decoder.bind(req.body).join(); + return Foo(text); + }); + + app.get("/errands", ioc((Todo singleton) => singleton)); + app.get( + "/errands3", + ioc(({Errand singleton, Todo foo, RequestContext req}) => + singleton.text)); + app.post('/async', ioc((Foo foo) => {'baz': foo.bar})); + await app.configure(SingletonController().configureServer); + await app.configure(ErrandController().configureServer); + + server = await AngelHttp(app).startServer(); + url = "http://${server.address.host}:${server.port}"; + }); + + tearDown(() async { + app = null; + url = null; + client.close(); + client = null; + await server.close(force: true); + }); + + test('runContained with custom container', () async { + var app = Angel(); + var c = Container(const MirrorsReflector()); + c.registerSingleton(Todo(text: 'Hey!')); + + app.get('/', (req, res) async { + return app.runContained((Todo t) => t.text, req, res, c); + }); + + var rq = MockHttpRequest('GET', Uri(path: '/')); + await rq.close(); + var rs = rq.response; + await AngelHttp(app).handleRequest(rq); + var text = await rs.transform(utf8.decoder).join(); + expect(text, json.encode('Hey!')); + }); + + test("singleton in route", () async { + validateTodoSingleton(await client.get("$url/errands")); + }); + + test("singleton in controller", () async { + validateTodoSingleton(await client.get("$url/errands2")); + }); + + test("make in route", () async { + var response = await client.get("$url/errands3"); + var text = await json.decode(response.body) as String; + expect(text, equals(TEXT)); + }); + + test("make in controller", () async { + var response = await client.get("$url/errands4"); + var text = await json.decode(response.body) as String; + expect(text, equals(TEXT)); + }); + + test('resolve from future in controller', () async { + var response = await client.post('$url/errands4/async', body: 'hey'); + expect(response.body, json.encode({'bar': 'hey'})); + }); + + test('resolve from future in route', () async { + var response = await client.post('$url/async', body: 'yes'); + expect(response.body, json.encode({'baz': 'yes'})); + }); +} + +void validateTodoSingleton(response) { + var todo = json.decode(response.body.toString()) as Map; + expect(todo["id"], equals(null)); + expect(todo["text"], equals(TEXT)); + expect(todo["over"], equals(OVER)); +} + +@Expose("/errands2") +class SingletonController extends Controller { + @Expose("/") + todo(Todo singleton) => singleton; +} + +@Expose("/errands4") +class ErrandController extends Controller { + @Expose("/") + errand(Errand errand) { + return errand.text; + } + + @Expose('/async', method: 'POST') + asyncResolve(Foo foo) { + return {'bar': foo.bar}; + } +} + +class Foo { + final String bar; + + Foo(this.bar); +} + +class Errand { + Todo todo; + + String get text => todo.text; + + Errand(this.todo); +} diff --git a/framework/test/encoders_buffer_test.dart b/framework/test/encoders_buffer_test.dart new file mode 100644 index 00000000..1b41b9c3 --- /dev/null +++ b/framework/test/encoders_buffer_test.dart @@ -0,0 +1,112 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show BytesBuilder; +import 'dart:io'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +Future> getBody(MockHttpResponse rs) async { + var list = await rs.toList(); + var bb = BytesBuilder(); + list.forEach(bb.add); + return bb.takeBytes(); +} + +main() { + Angel app; + + setUp(() { + app = Angel(reflector: MirrorsReflector()); + app.encoders.addAll( + { + 'deflate': zlib.encoder, + 'gzip': gzip.encoder, + }, + ); + + app.get('/hello', (req, res) { + res + ..useBuffer() + ..write('Hello, world!'); + }); + }); + + tearDown(() => app.close()); + + encodingTests(() => app); +} + +void encodingTests(Angel getApp()) { + group('encoding', () { + Angel app; + AngelHttp http; + + setUp(() { + app = getApp(); + http = AngelHttp(app); + }); + + test('sends plaintext if no accept-encoding', () async { + var rq = MockHttpRequest('GET', Uri.parse('/hello')); + await rq.close(); + var rs = rq.response; + await http.handleRequest(rq); + + var body = await rs.transform(utf8.decoder).join(); + expect(body, 'Hello, world!'); + }); + + test('encodes if wildcard', () async { + var rq = MockHttpRequest('GET', Uri.parse('/hello')) + ..headers.set('accept-encoding', '*'); + await rq.close(); + var rs = rq.response; + await http.handleRequest(rq); + + var body = await getBody(rs); + //print(rs.headers); + expect(rs.headers.value('content-encoding'), 'deflate'); + expect(body, zlib.encode(utf8.encode('Hello, world!'))); + }); + + test('encodes if wildcard + multiple', () async { + var rq = MockHttpRequest('GET', Uri.parse('/hello')) + ..headers.set('accept-encoding', ['foo', 'bar', '*']); + await rq.close(); + var rs = rq.response; + await http.handleRequest(rq); + + var body = await getBody(rs); + expect(rs.headers.value('content-encoding'), 'deflate'); + expect(body, zlib.encode(utf8.encode('Hello, world!'))); + }); + + test('encodes if explicit', () async { + var rq = MockHttpRequest('GET', Uri.parse('/hello')) + ..headers.set('accept-encoding', 'gzip'); + await rq.close(); + var rs = rq.response; + await http.handleRequest(rq); + + var body = await getBody(rs); + expect(rs.headers.value('content-encoding'), 'gzip'); + expect(body, gzip.encode(utf8.encode('Hello, world!'))); + }); + + test('only uses one encoder', () async { + var rq = MockHttpRequest('GET', Uri.parse('/hello')) + ..headers.set('accept-encoding', ['gzip', 'deflate']); + await rq.close(); + var rs = rq.response; + await http.handleRequest(rq); + + var body = await getBody(rs); + expect(rs.headers.value('content-encoding'), 'gzip'); + expect(body, gzip.encode(utf8.encode('Hello, world!'))); + }); + }); +} diff --git a/framework/test/env_test.dart b/framework/test/env_test.dart new file mode 100644 index 00000000..c528e1c9 --- /dev/null +++ b/framework/test/env_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:test/test.dart'; + +void main() { + test('custom value', () => expect(AngelEnvironment('hey').value, 'hey')); + + test('lowercases', () => expect(AngelEnvironment('HeY').value, 'hey')); + test( + 'default to env or development', + () => expect(AngelEnvironment().value, + (Platform.environment['ANGEL_ENV'] ?? 'development').toLowerCase())); + test('isDevelopment', + () => expect(AngelEnvironment('development').isDevelopment, true)); + test('isStaging', () => expect(AngelEnvironment('staging').isStaging, true)); + test('isDevelopment', + () => expect(AngelEnvironment('production').isProduction, true)); +} diff --git a/framework/test/exception_test.dart b/framework/test/exception_test.dart new file mode 100644 index 00000000..ec08b3ef --- /dev/null +++ b/framework/test/exception_test.dart @@ -0,0 +1,70 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'dart:convert'; +import 'package:matcher/matcher.dart'; +import 'package:test/test.dart'; + +main() { + test('named constructors', () { + expect( + AngelHttpException.badRequest(), isException(400, '400 Bad Request')); + expect(AngelHttpException.notAuthenticated(), + isException(401, '401 Not Authenticated')); + expect(AngelHttpException.paymentRequired(), + isException(402, '402 Payment Required')); + expect(AngelHttpException.forbidden(), isException(403, '403 Forbidden')); + expect(AngelHttpException.notFound(), isException(404, '404 Not Found')); + expect(AngelHttpException.methodNotAllowed(), + isException(405, '405 Method Not Allowed')); + expect(AngelHttpException.notAcceptable(), + isException(406, '406 Not Acceptable')); + expect(AngelHttpException.methodTimeout(), isException(408, '408 Timeout')); + expect(AngelHttpException.conflict(), isException(409, '409 Conflict')); + expect(AngelHttpException.notProcessable(), + isException(422, '422 Not Processable')); + expect(AngelHttpException.notImplemented(), + isException(501, '501 Not Implemented')); + expect( + AngelHttpException.unavailable(), isException(503, '503 Unavailable')); + }); + + test('fromMap', () { + expect(AngelHttpException.fromMap({'status_code': -1, 'message': 'ok'}), + isException(-1, 'ok')); + }); + + test('toMap = toJson', () { + var exc = AngelHttpException.badRequest(); + expect(exc.toMap(), exc.toJson()); + var json_ = json.encode(exc.toJson()); + var exc2 = AngelHttpException.fromJson(json_); + expect(exc2.toJson(), exc.toJson()); + }); + + test('toString', () { + expect( + AngelHttpException(null, statusCode: 420, message: 'Blaze It') + .toString(), + '420: Blaze It'); + }); +} + +Matcher isException(int statusCode, String message) => + _IsException(statusCode, message); + +class _IsException extends Matcher { + final int statusCode; + final String message; + + _IsException(this.statusCode, this.message); + + @override + Description describe(Description description) => + description.add('has status code $statusCode and message "$message"'); + + @override + bool matches(item, Map matchState) { + return item is AngelHttpException && + item.statusCode == statusCode && + item.message == message; + } +} diff --git a/framework/test/extension_test.dart b/framework/test/extension_test.dart new file mode 100644 index 00000000..e061dc4e --- /dev/null +++ b/framework/test/extension_test.dart @@ -0,0 +1,32 @@ +import 'dart:async'; +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +final Uri ENDPOINT = Uri.parse('http://example.com'); + +main() { + test('single extension', () async { + var req = await makeRequest('foo.js'); + expect(req.extension, '.js'); + }); + + test('multiple extensions', () async { + var req = await makeRequest('foo.min.js'); + expect(req.extension, '.js'); + }); + + test('no extension', () async { + var req = await makeRequest('foo'); + expect(req.extension, ''); + }); +} + +Future makeRequest(String path) { + var rq = MockHttpRequest('GET', ENDPOINT.replace(path: path))..close(); + var app = Angel(reflector: MirrorsReflector()); + var http = AngelHttp(app); + return http.createRequestContext(rq, rq.response); +} diff --git a/framework/test/find_one_test.dart b/framework/test/find_one_test.dart new file mode 100644 index 00000000..5a576fd4 --- /dev/null +++ b/framework/test/find_one_test.dart @@ -0,0 +1,23 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +void main() { + var throwsAnAngelHttpException = + throwsA(const IsInstanceOf()); + + test('throw 404 on null', () { + var service = AnonymousService(index: ([p]) => null); + expect(() => service.findOne(), throwsAnAngelHttpException); + }); + + test('throw 404 on empty iterable', () { + var service = AnonymousService(index: ([p]) => []); + expect(() => service.findOne(), throwsAnAngelHttpException); + }); + + test('return first element of iterable', () async { + var service = AnonymousService(index: ([p]) => [2]); + expect(await service.findOne(), 2); + }); +} diff --git a/framework/test/general_test.dart b/framework/test/general_test.dart new file mode 100644 index 00000000..e2fd564d --- /dev/null +++ b/framework/test/general_test.dart @@ -0,0 +1,39 @@ +import 'dart:io'; +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +main() { + Angel app; + http.Client client; + HttpServer server; + String url; + + setUp(() async { + app = Angel(reflector: MirrorsReflector()) + ..post('/foo', (req, res) => res.serialize({'hello': 'world'})) + ..all('*', (req, res) => throw AngelHttpException.notFound()); + client = http.Client(); + + server = await AngelHttp(app).startServer(); + url = "http://${server.address.host}:${server.port}"; + }); + + tearDown(() async { + app = null; + url = null; + client.close(); + client = null; + await server.close(force: true); + }); + + test("allow override of method", () async { + var response = await client + .get('$url/foo', headers: {'X-HTTP-Method-Override': 'POST'}); + print('Response: ${response.body}'); + expect(json.decode(response.body), equals({'hello': 'world'})); + }); +} diff --git a/framework/test/hm.dart b/framework/test/hm.dart new file mode 100644 index 00000000..fb10cec0 --- /dev/null +++ b/framework/test/hm.dart @@ -0,0 +1,14 @@ +import 'dart:async'; +import 'package:io/ansi.dart'; +import 'all.dart' as hm; + +main() async { + var zone = Zone.current.fork( + specification: ZoneSpecification(print: (self, parent, zone, line) { + if (line == 'null') { + parent.print(zone, cyan.wrap(StackTrace.current.toString())); + } + }), + ); + return await zone.run(hm.main); +} diff --git a/framework/test/hooked_test.dart b/framework/test/hooked_test.dart new file mode 100644 index 00000000..38fa4a9e --- /dev/null +++ b/framework/test/hooked_test.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + Map headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + + Angel app; + HttpServer server; + String url; + http.Client client; + HookedService todoService; + + setUp(() async { + app = Angel(reflector: MirrorsReflector()); + client = http.Client(); + app.use('/todos', MapService()); + app.use('/books', BookService()); + + todoService = app.findHookedService('todos'); + + todoService.beforeAllStream().listen((e) { + print('Fired ${e.eventName}! Data: ${e.data}; Params: ${e.params}'); + }); + + app.errorHandler = (e, req, res) { + throw e.error; + }; + + server = await AngelHttp(app).startServer(); + url = "http://${server.address.host}:${server.port}"; + }); + + tearDown(() async { + await server.close(force: true); + app = null; + url = null; + client.close(); + client = null; + todoService = null; + }); + + test("listen before and after", () async { + int count = 0; + + todoService + ..beforeIndexed.listen((_) { + count++; + }) + ..afterIndexed.listen((_) { + count++; + }); + + var response = await client.get("$url/todos"); + print(response.body); + expect(count, equals(2)); + }); + + test("cancel before", () async { + todoService.beforeCreated + ..listen((HookedServiceEvent event) { + event.cancel({"hello": "hooked world"}); + }) + ..listen((HookedServiceEvent event) { + event.cancel({"this_hook": "should never run"}); + }); + + var response = await client.post("$url/todos", + body: json.encode({"arbitrary": "data"}), + headers: headers as Map); + print(response.body); + var result = json.decode(response.body) as Map; + expect(result["hello"], equals("hooked world")); + }); + + test("cancel after", () async { + todoService.afterIndexed + ..listen((HookedServiceEvent event) async { + // Hooks can be Futures ;) + event.cancel([ + {"angel": "framework"} + ]); + }) + ..listen((HookedServiceEvent event) { + event.cancel({"this_hook": "should never run either"}); + }); + + var response = await client.get("$url/todos"); + print(response.body); + var result = json.decode(response.body) as List; + expect(result[0]["angel"], equals("framework")); + }); + + test('asStream() fires', () async { + var stream = todoService.afterCreated.asStream(); + await todoService.create({'angel': 'framework'}); + expect(await stream.first.then((e) => e.result['angel']), 'framework'); + }); + + test('metadata', () async { + final service = HookedService(IncrementService())..addHooks(app); + expect(service.inner, isNot(const IsInstanceOf())); + IncrementService.TIMES = 0; + await service.index(); + expect(IncrementService.TIMES, equals(2)); + }); + + test('inject request + response', () async { + HookedService books = app.findService('books'); + + books.beforeIndexed.listen((e) { + expect([e.request, e.response], everyElement(isNotNull)); + print('Indexing books at path: ${e.request.path}'); + }); + + var response = await client.get('$url/books'); + print(response.body); + + var result = json.decode(response.body); + expect(result, isList); + expect(result, isNotEmpty); + expect(result[0], equals({'foo': 'bar'})); + }); + + test('contains provider in before and after', () async { + var svc = HookedService(AnonymousService(index: ([p]) async => [])); + + ensureProviderIsPresent(HookedServiceEvent e) { + var type = e.isBefore ? 'before' : 'after'; + print('Params to $type ${e.eventName}: ${e.params}'); + expect(e.params, isMap); + expect(e.params.keys, contains('provider')); + expect(e.params['provider'], const IsInstanceOf()); + } + + svc + ..beforeAll(ensureProviderIsPresent) + ..afterAll(ensureProviderIsPresent); + + await svc.index({'provider': const Providers('testing')}); + }); +} diff --git a/framework/test/http2/adapter_test.dart b/framework/test/http2/adapter_test.dart new file mode 100644 index 00000000..7773dc17 --- /dev/null +++ b/framework/test/http2/adapter_test.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart' hide Header; +import 'package:angel_framework/http2.dart'; +import 'package:http/src/multipart_file.dart' as http; +import 'package:http/src/multipart_request.dart' as http; +import 'package:http/io_client.dart'; +import 'package:http2/transport.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; +import 'http2_client.dart'; + +const String jfk = + 'Ask not what your country can do for you, but what you can do for your country.'; + +Stream> jfkStream() { + return Stream.fromIterable([utf8.encode(jfk)]); +} + +void main() { + var client = Http2Client(); + IOClient h1c; + Angel app; + AngelHttp2 http2; + Uri serverRoot; + + setUp(() async { + app = Angel(reflector: MirrorsReflector())..encoders['gzip'] = gzip.encoder; + hierarchicalLoggingEnabled = true; + app.logger = Logger.detached('angel.http2') + ..onRecord.listen((rec) { + print(rec); + if (rec.error == null) return; + print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + + app.get('/', (req, res) async { + res.write('Hello world'); + await res.close(); + }); + + app.all('/method', (req, res) => req.method); + + app.get('/json', (_, __) => {'foo': 'bar'}); + + app.get('/stream', (req, res) => jfkStream().pipe(res)); + + app.get('/headers', (req, res) async { + res.headers.addAll({'foo': 'bar', 'x-angel': 'http2'}); + await res.close(); + }); + + app.get('/status', (req, res) async { + res.statusCode = 1337; + await res.close(); + }); + + app.post('/body', (req, res) => req.parseBody().then((_) => req.bodyAsMap)); + + app.post('/upload', (req, res) async { + await req.parseBody(); + var body = req.bodyAsMap, files = req.uploadedFiles; + var file = files.firstWhere((f) => f.name == 'file'); + return [ + await file.data.map((l) => l.length).reduce((a, b) => a + b), + file.contentType.mimeType, + body + ]; + }); + + app.get('/push', (req, res) async { + res.write('ok'); + + if (res is Http2ResponseContext && res.canPush) { + var a = res.push('a')..write('a'); + await a.close(); + + var b = res.push('b')..write('b'); + await b.close(); + } + + await res.close(); + }); + + app.get('/param/:name', (req, res) => req.params); + + app.get('/query', (req, res) { + print('incoming URI: ${req.uri}'); + return req.queryParameters; + }); + + var ctx = SecurityContext() + ..useCertificateChain('dev.pem') + ..usePrivateKey('dev.key', password: 'dartdart') + ..setAlpnProtocols(['h2'], true); + + // Create an HTTP client that trusts our server. + h1c = IOClient(HttpClient()..badCertificateCallback = (_, __, ___) => true); + + http2 = AngelHttp2(app, ctx, allowHttp1: true); + + var server = await http2.startServer(); + serverRoot = Uri.parse('https://127.0.0.1:${server.port}'); + }); + + tearDown(() async { + await http2.close(); + await h1c.close(); + }); + + test('buffered response', () async { + var response = await client.get(serverRoot); + expect(response.body, 'Hello world'); + }); + + test('allowHttp1', () async { + var response = await h1c.get(serverRoot); + expect(response.body, 'Hello world'); + }); + + test('streamed response', () async { + var response = await client.get(serverRoot.replace(path: '/stream')); + expect(response.body, jfk); + }); + + group('gzip', () { + test('buffered response', () async { + var response = await client + .get(serverRoot, headers: {'accept-encoding': 'gzip, deflate, br'}); + expect(response.headers['content-encoding'], 'gzip'); + var decoded = gzip.decode(response.bodyBytes); + expect(utf8.decode(decoded), 'Hello world'); + }); + + test('streamed response', () async { + var response = await client.get(serverRoot.replace(path: '/stream'), + headers: {'accept-encoding': 'gzip'}); + expect(response.headers['content-encoding'], 'gzip'); + //print(response.body); + var decoded = gzip.decode(response.bodyBytes); + expect(utf8.decode(decoded), jfk); + }); + }); + + test('query uri decoded', () async { + var uri = + serverRoot.replace(path: '/query', queryParameters: {'foo!': 'bar?'}); + var response = await client.get(uri); + print('Sent $uri'); + expect(response.body, json.encode({'foo!': 'bar?'})); + }); + + test('params uri decoded', () async { + var response = await client.get(serverRoot.replace(path: '/param/foo!')); + expect(response.body, json.encode({'name': 'foo!'})); + }); + + test('method parsed', () async { + var response = await client.delete(serverRoot.replace(path: '/method')); + expect(response.body, json.encode('DELETE')); + }); + + test('json response', () async { + var response = await client.get(serverRoot.replace(path: '/json')); + expect(response.body, json.encode({'foo': 'bar'})); + expect(ContentType.parse(response.headers['content-type']).mimeType, + ContentType.json.mimeType); + }); + + test('status sent', () async { + var response = await client.get(serverRoot.replace(path: '/status')); + expect(response.statusCode, 1337); + }); + + test('headers sent', () async { + var response = await client.get(serverRoot.replace(path: '/headers')); + expect(response.headers['foo'], 'bar'); + expect(response.headers['x-angel'], 'http2'); + }); + + test('server push', () async { + var socket = await SecureSocket.connect( + serverRoot.host, + serverRoot.port ?? 443, + onBadCertificate: (_) => true, + supportedProtocols: ['h2'], + ); + + var connection = ClientTransportConnection.viaSocket( + socket, + settings: ClientSettings(allowServerPushes: true), + ); + + var headers =
[ + Header.ascii(':authority', serverRoot.authority), + Header.ascii(':method', 'GET'), + Header.ascii(':path', serverRoot.replace(path: '/push').path), + Header.ascii(':scheme', serverRoot.scheme), + ]; + + var stream = await connection.makeRequest(headers, endStream: true); + + var bb = await stream.incomingMessages + .where((s) => s is DataStreamMessage) + .cast() + .fold(BytesBuilder(), (out, msg) => out..add(msg.bytes)); + + // Check that main body was sent + expect(utf8.decode(bb.takeBytes()), 'ok'); + + var pushes = await stream.peerPushes.toList(); + expect(pushes, hasLength(2)); + + var pushA = pushes[0], pushB = pushes[1]; + + String getPath(TransportStreamPush p) => ascii.decode(p.requestHeaders + .firstWhere((h) => ascii.decode(h.name) == ':path') + .value); + + /* + Future getBody(ClientTransportStream stream) async { + await stream.outgoingMessages.close(); + var bb = await stream.incomingMessages + .map((s) { + if (s is HeadersStreamMessage) { + for (var h in s.headers) { + print('${ASCII.decode(h.name)}: ${ASCII.decode(h.value)}'); + } + } else if (s is DataStreamMessage) { + print(UTF8.decode(s.bytes)); + } + + return s; + }) + .where((s) => s is DataStreamMessage) + .cast() + .fold( + BytesBuilder(), (out, msg) => out..add(msg.bytes)); + return UTF8.decode(bb.takeBytes()); + } + */ + + expect(getPath(pushA), '/a'); + expect(getPath(pushB), '/b'); + + // However, Chrome, Firefox, Edge all can + //expect(await getBody(pushA.stream), 'a'); + //expect(await getBody(pushB.stream), 'b'); + }); + + group('body parsing', () { + test('urlencoded body parsed', () async { + var response = await client.post( + serverRoot.replace(path: '/body'), + headers: { + 'accept': 'application/json', + 'content-type': 'application/x-www-form-urlencoded' + }, + body: 'foo=bar', + ); + expect(response.body, json.encode({'foo': 'bar'})); + }); + + test('json body parsed', () async { + var response = await client.post(serverRoot.replace(path: '/body'), + headers: { + 'accept': 'application/json', + 'content-type': 'application/json' + }, + body: json.encode({'foo': 'bar'})); + expect(response.body, json.encode({'foo': 'bar'})); + }); + + test('multipart body parsed', () async { + var rq = + http.MultipartRequest('POST', serverRoot.replace(path: '/upload')); + rq.headers.addAll({'accept': 'application/json'}); + + rq.fields['foo'] = 'bar'; + rq.files.add(http.MultipartFile( + 'file', Stream.fromIterable([utf8.encode('hello world')]), 11, + contentType: MediaType('angel', 'framework'))); + + var response = await client.send(rq); + var responseBody = await response.stream.transform(utf8.decoder).join(); + + expect( + responseBody, + json.encode([ + 11, + 'angel/framework', + {'foo': 'bar'} + ])); + }); + }); +} diff --git a/framework/test/http2/http2_client.dart b/framework/test/http2/http2_client.dart new file mode 100644 index 00000000..efdea1f7 --- /dev/null +++ b/framework/test/http2/http2_client.dart @@ -0,0 +1,104 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart'; +import 'package:http2/transport.dart'; + +/// Simple HTTP/2 client +class Http2Client extends BaseClient { + static Future convertRequestToStream( + BaseRequest request) async { + // Connect a socket + var socket = await SecureSocket.connect( + request.url.host, + request.url.port ?? 443, + onBadCertificate: (_) => true, + supportedProtocols: ['h2'], + ); + + var connection = ClientTransportConnection.viaSocket(socket); + + var headers =
[ + Header.ascii(':authority', request.url.authority), + Header.ascii(':method', request.method), + Header.ascii( + ':path', + request.url.path + + (request.url.hasQuery ? ('?' + request.url.query) : '')), + Header.ascii(':scheme', request.url.scheme), + ]; + + var bb = await request + .finalize() + .fold(BytesBuilder(), (out, list) => out..add(list)); + var body = bb.takeBytes(); + + if (body.isNotEmpty) { + headers.add(Header.ascii('content-length', body.length.toString())); + } + + request.headers.forEach((k, v) { + headers.add(Header.ascii(k, v)); + }); + + var stream = await connection.makeRequest(headers, endStream: body.isEmpty); + + if (body.isNotEmpty) { + stream.sendData(body, endStream: true); + } else { + (stream.outgoingMessages.close()); + } + + return stream; + } + + /// Returns `true` if the response stream was closed. + static Future readResponse(ClientTransportStream stream, + Map headers, BytesBuilder body) { + var c = Completer(); + var closed = false; + + stream.incomingMessages.listen( + (msg) { + if (msg is HeadersStreamMessage) { + for (var header in msg.headers) { + var name = ascii.decode(header.name).toLowerCase(), + value = ascii.decode(header.value); + headers[name] = value; + //print('$name: $value'); + } + } else if (msg is DataStreamMessage) { + body.add(msg.bytes); + } + + if (!closed && msg.endStream) closed = true; + }, + cancelOnError: true, + onError: c.completeError, + onDone: () => c.complete(closed), + ); + + return c.future; + } + + @override + Future send(BaseRequest request) async { + var stream = await convertRequestToStream(request); + var headers = {}; + var body = BytesBuilder(); + var closed = await readResponse(stream, headers, body); + return StreamedResponse( + Stream.fromIterable([body.takeBytes()]), + int.parse(headers[':status']), + headers: headers, + isRedirect: headers.containsKey('location'), + contentLength: headers.containsKey('content-length') + ? int.parse(headers['content-length']) + : null, + request: request, + reasonPhrase: null, + // doesn't exist in HTTP/2 + persistentConnection: !closed, + ); + } +} diff --git a/framework/test/jsonp_test.dart b/framework/test/jsonp_test.dart new file mode 100644 index 00000000..1517619c --- /dev/null +++ b/framework/test/jsonp_test.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +void main() { + var app = Angel(); + var http = AngelHttp(app); + + app.get('/default', (req, res) => res.jsonp({'foo': 'bar'})); + + app.get('/callback', + (req, res) => res.jsonp({'foo': 'bar'}, callbackName: 'doIt')); + + app.get( + '/contentType', + (req, res) => + res.jsonp({'foo': 'bar'}, contentType: MediaType('foo', 'bar'))); + + Future getContentType(String path) async { + var rq = MockHttpRequest('GET', Uri(path: '/$path')); + await rq.close(); + await http.handleRequest(rq); + return MediaType.parse(rq.response.headers.contentType.toString()); + } + + Future getText(String path) async { + var rq = MockHttpRequest('GET', Uri(path: '/$path')); + await rq.close(); + await http.handleRequest(rq); + return await rq.response.transform(utf8.decoder).join(); + } + + test('default', () async { + var response = await getText('default'); + var contentType = await getContentType('default'); + expect(response, r'callback({"foo":"bar"})'); + expect(contentType.mimeType, 'application/javascript'); + }); + + test('callback', () async { + var response = await getText('callback'); + var contentType = await getContentType('callback'); + expect(response, r'doIt({"foo":"bar"})'); + expect(contentType.mimeType, 'application/javascript'); + }); + + test('content type', () async { + var response = await getText('contentType'); + var contentType = await getContentType('contentType'); + expect(response, r'callback({"foo":"bar"})'); + expect(contentType.mimeType, 'foo/bar'); + }); +} diff --git a/framework/test/parameter_meta_test.dart b/framework/test/parameter_meta_test.dart new file mode 100644 index 00000000..cb46f9d3 --- /dev/null +++ b/framework/test/parameter_meta_test.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; + +import 'package:test/test.dart'; + +Future readResponse(MockHttpResponse rs) { + return rs.transform(utf8.decoder).join(); +} + +Future printResponse(MockHttpResponse rs) { + return readResponse(rs).then((text) { + print(text.isEmpty ? '' : text); + }); +} + +void main() { + group('parameter_meta', parameterMetaTests); +} + +parameterMetaTests() { + Angel app; + AngelHttp http; + + setUp(() { + app = Angel(reflector: MirrorsReflector()); + http = AngelHttp(app); + + app.get('/cookie', ioc((@CookieValue('token') String jwt) { + return jwt; + })); + + app.get('/header', ioc((@Header('x-foo') String header) { + return header; + })); + + app.get('/query', ioc((@Query('q') String query) { + return query; + })); + + app.get('/session', ioc((@Session('foo') String foo) { + return foo; + })); + + app.get('/match', ioc((@Query('mode', match: 'pos') String mode) { + return 'YES $mode'; + })); + + app.get('/match', ioc((@Query('mode', match: 'neg') String mode) { + return 'NO $mode'; + })); + + app.get('/match', ioc((@Query('mode') String mode) { + return 'DEFAULT $mode'; + })); + + /*app.logger = Logger('parameter_meta_test') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + */ + }); + + test('injects header or throws', () async { + // Invalid request + var rq = MockHttpRequest('GET', Uri.parse('/header')); + (rq.close()); + var rs = rq.response; + (http.handleRequest(rq)); + + await printResponse(rs); + expect(rs.statusCode, 400); + + // Valid request + rq = MockHttpRequest('GET', Uri.parse('/header')) + ..headers.add('x-foo', 'bar'); + (rq.close()); + rs = rq.response; + await (http.handleRequest(rq)); + + var body = await readResponse(rs); + print('Body: $body'); + expect(rs.statusCode, 200); + expect(body, json.encode('bar')); + }); + + test('injects session or throws', () async { + // Invalid request + var rq = MockHttpRequest('GET', Uri.parse('/session')); + (rq.close()); + var rs = rq.response; + (http + .handleRequest(rq) + .timeout(const Duration(seconds: 5)) + .catchError((_) => null)); + + await printResponse(rs); + expect(rs.statusCode, 500); + + rq = MockHttpRequest('GET', Uri.parse('/session')); + rq.session['foo'] = 'bar'; + (rq.close()); + rs = rq.response; + (http.handleRequest(rq)); + + await printResponse(rs); + expect(rs.statusCode, 200); + }); + + // Originally, the plan was to test cookie, session, header, etc., + // but that behavior has been consolidated into `getValue`. Thus, + // they will all function the same way. + + test('pattern matching', () async { + var rq = MockHttpRequest('GET', Uri.parse('/match?mode=pos')); + (rq.close()); + var rs = rq.response; + (http.handleRequest(rq)); + var body = await readResponse(rs); + print('Body: $body'); + expect(rs.statusCode, 200); + expect(body, json.encode('YES pos')); + + rq = MockHttpRequest('GET', Uri.parse('/match?mode=neg')); + (rq.close()); + rs = rq.response; + (http.handleRequest(rq)); + body = await readResponse(rs); + print('Body: $body'); + expect(rs.statusCode, 200); + expect(body, json.encode('NO neg')); + + // Fallback + rq = MockHttpRequest('GET', Uri.parse('/match?mode=ambi')); + (rq.close()); + rs = rq.response; + (http.handleRequest(rq)); + body = await readResponse(rs); + print('Body: $body'); + expect(rs.statusCode, 200); + expect(body, json.encode('DEFAULT ambi')); + }); +} diff --git a/framework/test/parse_id_test.dart b/framework/test/parse_id_test.dart new file mode 100644 index 00000000..7e427ec3 --- /dev/null +++ b/framework/test/parse_id_test.dart @@ -0,0 +1,33 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:test/test.dart'; + +void main() { + test('null', () { + expect(Service.parseId('null'), null); + expect(Service.parseId(null), null); + }); + + test('String', () { + expect(Service.parseId('23'), '23'); + }); + + test('int', () { + expect(Service.parseId('23'), 23); + }); + + test('double', () { + expect(Service.parseId('23.4'), 23.4); + }); + + test('num', () { + expect(Service.parseId('23.4'), 23.4); + }); + + test('bool', () { + expect(Service.parseId('true'), true); + expect(Service.parseId(true), true); + expect(Service.parseId('false'), false); + expect(Service.parseId(false), false); + expect(Service.parseId('hmm'), false); + }); +} diff --git a/framework/test/precontained_test.dart b/framework/test/precontained_test.dart new file mode 100644 index 00000000..6c4abbe8 --- /dev/null +++ b/framework/test/precontained_test.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; + +import 'package:test/test.dart'; + +main() { + test('preinjects functions', () async { + var app = Angel(reflector: MirrorsReflector()) + ..configuration['foo'] = 'bar' + ..get('/foo', ioc(echoAppFoo)); + app.optimizeForProduction(force: true); + print(app.preContained); + expect(app.preContained.keys, contains(echoAppFoo)); + + var rq = MockHttpRequest('GET', Uri(path: '/foo')); + (rq.close()); + await AngelHttp(app).handleRequest(rq); + var rs = rq.response; + var body = await rs.transform(utf8.decoder).join(); + expect(body, json.encode('bar')); + }, skip: 'Angel no longer has to preinject functions'); +} + +echoAppFoo(String foo) => foo; diff --git a/framework/test/pretty_log.dart b/framework/test/pretty_log.dart new file mode 100644 index 00000000..83427f07 --- /dev/null +++ b/framework/test/pretty_log.dart @@ -0,0 +1,36 @@ +import 'package:logging/logging.dart'; +import 'package:io/ansi.dart'; + +/// Prints the contents of a [LogRecord] with pretty colors. +void prettyLog(LogRecord record) { + var code = chooseLogColor(record.level); + + if (record.error == null) print(code.wrap(record.toString())); + + if (record.error != null) { + var err = record.error; + print(code.wrap(record.toString() + '\n')); + print(code.wrap(err.toString())); + + if (record.stackTrace != null) { + print(code.wrap(record.stackTrace.toString())); + } + } +} + +/// Chooses a color based on the logger [level]. +AnsiCode chooseLogColor(Level level) { + if (level == Level.SHOUT) { + return backgroundRed; + } else if (level == Level.SEVERE) { + return red; + } else if (level == Level.WARNING) { + return yellow; + } else if (level == Level.INFO) { + return cyan; + } else if (level == Level.CONFIG || + level == Level.FINE || + level == Level.FINER || + level == Level.FINEST) return lightGray; + return resetAll; +} diff --git a/framework/test/primitives_test.dart b/framework/test/primitives_test.dart new file mode 100644 index 00000000..63126450 --- /dev/null +++ b/framework/test/primitives_test.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; +import 'dart:io' show stderr; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; + +import 'package:test/test.dart'; + +main() { + Angel app; + AngelHttp http; + + setUp(() { + app = Angel(reflector: MirrorsReflector()) + ..configuration['global'] = 305; // Pitbull! + http = AngelHttp(app); + + app.get('/string/:string', ioc((String string) => string)); + + app.get( + '/num/parsed/:num', + chain([ + (req, res) { + req.params['n'] = num.parse(req.params['num'].toString()); + return true; + }, + ioc((num n) => n), + ])); + + app.get('/num/global', ioc((num global) => global)); + + app.errorHandler = (e, req, res) { + stderr..writeln(e.error)..writeln(e.stackTrace); + }; + }); + + tearDown(() => app.close()); + + test('String type annotation', () async { + var rq = MockHttpRequest('GET', Uri.parse('/string/hello')); + (rq.close()); + await http.handleRequest(rq); + var rs = await rq.response.transform(utf8.decoder).join(); + expect(rs, json.encode('hello')); + }); + + test('Primitive after parsed param injection', () async { + var rq = MockHttpRequest('GET', Uri.parse('/num/parsed/24')); + (rq.close()); + await http.handleRequest(rq); + var rs = await rq.response.transform(utf8.decoder).join(); + expect(rs, json.encode(24)); + }); + + test('globally-injected primitive', () async { + var rq = MockHttpRequest('GET', Uri.parse('/num/global')); + (rq.close()); + await http.handleRequest(rq); + var rs = await rq.response.transform(utf8.decoder).join(); + expect(rs, json.encode(305)); + }); + + test('unparsed primitive throws error', () async { + try { + var rq = MockHttpRequest('GET', Uri.parse('/num/unparsed/32')); + (rq.close()); + var req = await http.createRequestContext(rq, rq.response); + var res = await http.createResponseContext(rq, rq.response, req); + await app.runContained((num unparsed) => unparsed, req, res); + throw StateError( + 'ArgumentError should be thrown if a parameter cannot be resolved.'); + } on ArgumentError { + // Success + } + }); +} diff --git a/framework/test/repeat_request_test.dart b/framework/test/repeat_request_test.dart new file mode 100644 index 00000000..5a60f80e --- /dev/null +++ b/framework/test/repeat_request_test.dart @@ -0,0 +1,33 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:mock_request/mock_request.dart'; +import 'package:test/test.dart'; + +main() { + MockHttpRequest mk(int id) { + return MockHttpRequest('GET', Uri.parse('/test/$id'))..close(); + } + + test('can request the same url twice', () async { + var app = Angel(reflector: MirrorsReflector()) + ..get('/test/:id', ioc((id) => 'Hello $id')); + var rq1 = mk(1), rq2 = mk(2), rq3 = mk(1); + await Future.wait([rq1, rq2, rq3].map(AngelHttp(app).handleRequest)); + var body1 = await rq1.response.transform(utf8.decoder).join(), + body2 = await rq2.response.transform(utf8.decoder).join(), + body3 = await rq3.response.transform(utf8.decoder).join(); + print('Response #1: $body1'); + print('Response #2: $body2'); + print('Response #3: $body3'); + expect( + body1, + allOf( + isNot(body2), + equals(body3), + )); + }); +} diff --git a/framework/test/req_shutdown_test.dart b/framework/test/req_shutdown_test.dart new file mode 100644 index 00000000..01b1e551 --- /dev/null +++ b/framework/test/req_shutdown_test.dart @@ -0,0 +1,46 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:http/io_client.dart' as http; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; +import 'pretty_log.dart'; + +void main() { + http.IOClient client; + AngelHttp driver; + Logger logger; + StringBuffer buf; + + setUp(() async { + buf = StringBuffer(); + client = http.IOClient(); + hierarchicalLoggingEnabled = true; + + logger = Logger.detached('req_shutdown') + ..level = Level.ALL + ..onRecord.listen(prettyLog); + + var app = Angel(logger: logger); + + app.fallback((req, res) { + req.shutdownHooks.add(() => buf.write('Hello, ')); + req.shutdownHooks.add(() => buf.write('world!')); + }); + + driver = AngelHttp(app); + await driver.startServer(); + }); + + tearDown(() { + logger.clearListeners(); + client.close(); + scheduleMicrotask(driver.close); + }); + + test('does not continue processing after streaming', () async { + await client.get(driver.uri); + await Future.delayed(Duration(milliseconds: 100)); + expect(buf.toString(), 'Hello, world!'); + }); +} diff --git a/framework/test/routing_test.dart b/framework/test/routing_test.dart new file mode 100644 index 00000000..3dfc3776 --- /dev/null +++ b/framework/test/routing_test.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:http/http.dart' as http; +import 'package:io/ansi.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +@Middleware([interceptor]) +testMiddlewareMetadata(RequestContext req, ResponseContext res) async { + return "This should not be shown."; +} + +@Middleware([interceptService]) +class QueryService extends Service { + @override + @Middleware([interceptor]) + read(id, [Map params]) async => params; +} + +void interceptor(RequestContext req, ResponseContext res) { + res + ..write('Middleware') + ..close(); +} + +bool interceptService(RequestContext req, ResponseContext res) { + res.write("Service with "); + return true; +} + +main() { + Angel app; + Angel nested; + Angel todos; + String url; + http.Client client; + + setUp(() async { + app = Angel(reflector: MirrorsReflector()); + nested = Angel(reflector: MirrorsReflector()); + todos = Angel(reflector: MirrorsReflector()); + + [app, nested, todos].forEach((Angel app) { + app.logger = Logger('routing_test') + ..onRecord.listen((rec) { + if (rec.error != null) { + stdout + ..writeln(cyan.wrap(rec.toString())) + ..writeln(cyan.wrap(rec.error.toString())) + ..writeln(cyan.wrap(rec.stackTrace.toString())); + } + }); + }); + + todos.get('/action/:action', (req, res) => res.json(req.params)); + + Route ted; + + ted = nested.post('/ted/:route', (RequestContext req, res) { + print('Params: ${req.params}'); + print('Path: ${ted.path}, uri: ${req.path}'); + print('matcher: ${ted.parser}'); + return req.params; + }); + + app.mount('/nes', nested); + app.get('/meta', testMiddlewareMetadata); + app.get('/intercepted', (req, res) => 'This should not be shown', + middleware: [interceptor]); + app.get('/hello', (req, res) => 'world'); + app.get('/name/:first/last/:last', (req, res) => req.params); + app.post( + '/lambda', + (RequestContext req, res) => + req.parseBody().then((_) => req.bodyAsMap)); + app.mount('/todos/:id', todos); + app + .get('/greet/:name', + (RequestContext req, res) async => "Hello ${req.params['name']}") + .name = 'Named routes'; + app.get('/named', (req, ResponseContext res) async { + await res.redirectTo('Named routes', {'name': 'tests'}); + }); + app.get('/log', (RequestContext req, res) async { + print("Query: ${req.queryParameters}"); + return "Logged"; + }); + + app.get('/method', (req, res) => 'Only GET'); + app.post('/method', (req, res) => 'Only POST'); + + app.use('/query', QueryService()); + + RequestHandler write(String message) { + return (req, res) { + res.write(message); + return true; + }; + } + + app.chain([write('a')]).chain([write('b'), write('c')]).get( + '/chained', (req, res) => res.close()); + + app.fallback((req, res) => 'MJ'); + + //app.dumpTree(header: "DUMPING ROUTES:", showMatchers: true); + + client = http.Client(); + var server = await AngelHttp(app).startServer('127.0.0.1', 0); + url = "http://${server.address.host}:${server.port}"; + }); + + tearDown(() async { + await app.close(); + app = null; + nested = null; + todos = null; + client.close(); + client = null; + url = null; + }); + + test('Can match basic url', () async { + var response = await client.get("$url/hello"); + expect(response.body, equals('"world"')); + }); + + test('Can match url with multiple parameters', () async { + var response = await client.get('$url/name/HELLO/last/WORLD'); + print('Response: ${response.body}'); + var json_ = json.decode(response.body); + expect(json_, const IsInstanceOf()); + expect(json_['first'], equals('HELLO')); + expect(json_['last'], equals('WORLD')); + }); + + test('Chained routes', () async { + var response = await client.get("$url/chained"); + expect(response.body, equals('abc')); + }); + + test('Can nest another Angel instance', () async { + var response = await client.post('$url/nes/ted/foo'); + var json_ = json.decode(response.body); + expect(json_['route'], equals('foo')); + }); + + test('Can parse parameters from a nested Angel instance', () async { + var response = await client.get('$url/todos/1337/action/test'); + var json_ = json.decode(response.body); + print('JSON: $json_'); + expect(json_['id'], equals('1337')); + expect(json_['action'], equals('test')); + }); + + test('Can add and use named middleware', () async { + var response = await client.get('$url/intercepted'); + expect(response.body, equals('Middleware')); + }); + + test('Middleware via metadata', () async { + // Metadata + var response = await client.get('$url/meta'); + expect(response.body, equals('Middleware')); + }); + + test('Can serialize function result as JSON', () async { + Map headers = {'Content-Type': 'application/json'}; + String postData = json.encode({'it': 'works'}); + var response = await client.post("$url/lambda", + headers: headers as Map, body: postData); + print('Response: ${response.body}'); + expect(json.decode(response.body)['it'], equals('works')); + }); + + test('Fallback routes', () async { + var response = await client.get('$url/my_favorite_artist'); + expect(response.body, equals('"MJ"')); + }); + + test('Can name routes', () { + Route foo = app.get('/framework/:id', null)..name = 'frm'; + print('Foo: $foo'); + String uri = foo.makeUri({'id': 'angel'}); + print(uri); + expect(uri, equals('framework/angel')); + }); + + test('Redirect to named routes', () async { + var response = await client.get('$url/named'); + print(response.body); + expect(json.decode(response.body), equals('Hello tests')); + }); + + test('Match routes, even with query params', () async { + var response = + await client.get("$url/log?foo=bar&bar=baz&baz.foo=bar&baz.bar=foo"); + print(response.body); + expect(json.decode(response.body), equals('Logged')); + + response = await client.get("$url/query/foo?bar=baz"); + print(response.body); + expect(response.body, equals("Service with Middleware")); + }); + + test('only match route with matching method', () async { + var response = await client.get("$url/method"); + print(response.body); + expect(response.body, '"Only GET"'); + + response = await client.post("$url/method"); + print(response.body); + expect(response.body, '"Only POST"'); + + response = await client.patch("$url/method"); + print(response.body); + expect(response.body, '"MJ"'); + }); +} diff --git a/framework/test/serialize_test.dart b/framework/test/serialize_test.dart new file mode 100644 index 00000000..764ad5ff --- /dev/null +++ b/framework/test/serialize_test.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:test/test.dart'; + +main() { + Angel app; + http.Client client; + HttpServer server; + String url; + + setUp(() async { + app = Angel(reflector: MirrorsReflector()) + ..get('/foo', ioc(() => {'hello': 'world'})) + ..get('/bar', (req, res) async { + await res.serialize({'hello': 'world'}, + contentType: MediaType('text', 'html')); + }); + client = http.Client(); + + server = await AngelHttp(app).startServer(); + url = "http://${server.address.host}:${server.port}"; + }); + + tearDown(() async { + app = null; + url = null; + client.close(); + client = null; + await server.close(force: true); + }); + + test("correct content-type", () async { + var response = await client.get('$url/foo'); + print('Response: ${response.body}'); + expect(response.headers['content-type'], contains('application/json')); + + response = await client.get('$url/bar'); + print('Response: ${response.body}'); + expect(response.headers['content-type'], contains('text/html')); + }); +} diff --git a/framework/test/server_test.dart b/framework/test/server_test.dart new file mode 100644 index 00000000..88b15343 --- /dev/null +++ b/framework/test/server_test.dart @@ -0,0 +1,224 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:matcher/matcher.dart'; +import 'package:mock_request/mock_request.dart'; + +import 'package:test/test.dart'; + +final Uri $foo = Uri.parse('http://localhost:3000/foo'); + +/// Additional tests to improve coverage of server.dart +main() { + group('scoping', () { + var parent = Angel(reflector: MirrorsReflector())..configuration['two'] = 2; + var child = Angel(reflector: MirrorsReflector()); + parent.mount('/child', child); + + test('sets children', () { + expect(parent.children, contains(child)); + }); + + test('sets parent', () { + expect(child.parent, parent); + }); + + test('properties can climb up hierarchy', () { + expect(child.findProperty('two'), 2); + }); + }); + + test('custom server generator', () { + var app = Angel(reflector: MirrorsReflector()); + var http = AngelHttp.custom(app, HttpServer.bind); + expect(http.serverGenerator, HttpServer.bind); + }); + + test('default error handler', () async { + var app = Angel(reflector: MirrorsReflector()); + var http = AngelHttp(app); + var rq = MockHttpRequest('GET', $foo); + (rq.close()); + var rs = rq.response; + var req = await http.createRequestContext(rq, rs); + var res = await http.createResponseContext(rq, rs); + var e = AngelHttpException(null, + statusCode: 321, message: 'Hello', errors: ['foo', 'bar']); + await app.errorHandler(e, req, res); + await http.sendResponse(rq, rs, req, res); + expect( + ContentType.parse(rs.headers.value('content-type')).mimeType, + 'text/html', + ); + expect(rs.statusCode, e.statusCode); + var body = await rs.transform(utf8.decoder).join(); + expect(body, contains('${e.message}')); + expect(body, contains('
  • foo
  • ')); + expect(body, contains('
  • bar
  • ')); + }); + + test('plug-ins run on startup', () async { + var app = Angel(reflector: MirrorsReflector()); + app.startupHooks.add((app) => app.configuration['two'] = 2); + + var http = AngelHttp(app); + await http.startServer(); + expect(app.configuration['two'], 2); + await app.close(); + await http.close(); + }); + + test('warning when adding routes to flattened router', () { + var app = Angel(reflector: MirrorsReflector()) + ..optimizeForProduction(force: true); + app.dumpTree(); + app.get('/', (req, res) => 2); + app.mount('/foo', Router()..get('/', (req, res) => 3)); + }); + + test('services close on close call', () async { + var app = Angel(reflector: MirrorsReflector()); + var svc = CustomCloseService(); + expect(svc.value, 2); + app.use('/', svc); + await app.close(); + expect(svc.value, 3); + }); + + test('global injection added to injection map', () async { + var app = Angel(reflector: MirrorsReflector())..configuration['a'] = 'b'; + var http = AngelHttp(app); + app.get('/', ioc((String a) => a)); + var rq = MockHttpRequest('GET', Uri.parse('/')); + (rq.close()); + await http.handleRequest(rq); + var body = await rq.response.transform(utf8.decoder).join(); + expect(body, json.encode('b')); + }); + + test('global injected serializer', () async { + var app = Angel(reflector: MirrorsReflector())..serializer = (_) => 'x'; + var http = AngelHttp(app); + app.get($foo.path, (req, ResponseContext res) => res.serialize(null)); + var rq = MockHttpRequest('GET', $foo); + (rq.close()); + await http.handleRequest(rq); + var body = await rq.response.transform(utf8.decoder).join(); + expect(body, 'x'); + }); + + group('handler results', () { + var app = Angel(reflector: MirrorsReflector()); + var http = AngelHttp(app); + app.responseFinalizers + .add((req, res) => throw AngelHttpException.forbidden()); + RequestContext req; + ResponseContext res; + + setUp(() async { + var rq = MockHttpRequest('GET', $foo); + (rq.close()); + req = await http.createRequestContext(rq, rq.response); + res = await http.createResponseContext(rq, rq.response); + }); + + group('getHandlerResult', () { + test('return request handler', () async { + var handler = (req, res) => (req, res) async { + return 2; + }; + var r = await app.getHandlerResult(handler, req, res); + expect(r, 2); + }); + + test('return future', () async { + var handler = Future.value(2); + expect(await app.getHandlerResult(handler, req, res), 2); + }); + }); + + group('executeHandler', () { + test('return Stream', () async { + var handler = (req, res) => Stream.fromIterable([2, 3]); + expect(await app.executeHandler(handler, req, res), isFalse); + }); + + test('end response', () async { + var handler = (req, ResponseContext res) => res.close(); + expect(await app.executeHandler(handler, req, res), isFalse); + }); + }); + }); + + group('handleAngelHttpException', () { + Angel app; + AngelHttp http; + + setUp(() async { + app = Angel(reflector: MirrorsReflector()); + app.get('/wtf', (req, res) => throw AngelHttpException.forbidden()); + app.get('/wtf2', (req, res) => throw AngelHttpException.forbidden()); + http = AngelHttp(app); + await http.startServer('127.0.0.1', 0); + + var oldHandler = app.errorHandler; + app.errorHandler = (e, req, res) { + print('FATAL: ${e.error ?? e}'); + print(e.stackTrace); + return oldHandler(e, req, res); + }; + }); + + tearDown(() => app.close()); + + test('can send json', () async { + var rq = MockHttpRequest('GET', Uri(path: 'wtf')) + ..headers.set('accept', 'application/json'); + (rq.close()); + (http.handleRequest(rq)); + await rq.response.toList(); + expect(rq.response.statusCode, 403); + expect(rq.response.headers.contentType.mimeType, 'application/json'); + }); + + test('can throw in finalizer', () async { + var rq = MockHttpRequest('GET', Uri(path: 'wtf')) + ..headers.set('accept', 'application/json'); + (rq.close()); + (http.handleRequest(rq)); + await rq.response.toList(); + expect(rq.response.statusCode, 403); + expect(rq.response.headers.contentType.mimeType, 'application/json'); + }); + + test('can send html', () async { + var rq = MockHttpRequest('GET', Uri(path: 'wtf2')); + rq.headers.set('accept', 'text/html'); + (rq.close()); + (http.handleRequest(rq)); + await rq.response.toList(); + expect(rq.response.statusCode, 403); + expect(rq.response.headers.contentType?.mimeType, 'text/html'); + }); + }); +} + +class CustomCloseService extends Service { + int value = 2; + + @override + void close() { + value = 3; + super.close(); + } +} + +@Expose('/foo') +class FooController extends Controller { + @Expose('/bar') + bar() async => 'baz'; +} diff --git a/framework/test/service_map_test.dart b/framework/test/service_map_test.dart new file mode 100644 index 00000000..99346829 --- /dev/null +++ b/framework/test/service_map_test.dart @@ -0,0 +1,80 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:test/test.dart'; + +void main() { + MapService inner; + Service mapped; + + setUp(() { + inner = MapService(); + mapped = inner.map(Todo.fromMap, Todo.toMap); + }); + + test('create', () async { + var result = await mapped.create( + Todo(text: 'hello', complete: false), + ); + print(result); + expect( + result, + Todo(text: 'hello', complete: false), + ); + }); + + group('after create', () { + Todo result; + String id; + + setUp(() async { + result = await mapped.create(Todo(text: 'hello', complete: false)); + id = result.id; + }); + + test('index', () async { + expect(await mapped.index(), [result]); + }); + + test('modify', () async { + var newTodo = Todo(text: 'yes', complete: true); + expect(await mapped.update(id, newTodo), newTodo); + }); + + test('update', () async { + var newTodo = Todo(id: 'hmmm', text: 'yes', complete: true); + expect(await mapped.update(id, newTodo), newTodo); + }); + + test('read', () async { + expect(await mapped.read(id), result); + }); + + test('remove', () async { + expect(await mapped.remove(id), result); + }); + }); +} + +class Todo { + final String id, text; + final bool complete; + + Todo({this.id, this.text, this.complete}); + + static Todo fromMap(Map json) { + return Todo( + id: json['id'] as String, + text: json['text'] as String, + complete: json['complete'] as bool); + } + + static Map toMap(Todo model) { + return {'id': model.id, 'text': model.text, 'complete': model.complete}; + } + + @override + bool operator ==(other) => + other is Todo && other.text == text && other.complete == complete; + + @override + String toString() => '$id:$text($complete)'; +} diff --git a/framework/test/services_test.dart b/framework/test/services_test.dart new file mode 100644 index 00000000..e814a176 --- /dev/null +++ b/framework/test/services_test.dart @@ -0,0 +1,133 @@ +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +class Todo extends Model { + String text; + String over; +} + +main() { + Map headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + Angel app; + MapService service; + String url; + http.Client client; + + setUp(() async { + app = Angel(reflector: MirrorsReflector()) + ..use('/todos', service = MapService()) + ..errorHandler = (e, req, res) { + if (e.error != null) print('Whoops: ${e.error}'); + if (e.stackTrace != null) print(Chain.forTrace(e.stackTrace).terse); + }; + + var server = await AngelHttp(app).startServer(); + client = http.Client(); + url = "http://${server.address.host}:${server.port}"; + }); + + tearDown(() async { + await app.close(); + app = null; + url = null; + client.close(); + client = null; + }); + + group('memory', () { + test('can index an empty service', () async { + var response = await client.get("$url/todos/"); + print(response.body); + expect(response.body, equals('[]')); + print(response.body); + expect(json.decode(response.body).length, 0); + }); + + test('can create data', () async { + String postData = json.encode({'text': 'Hello, world!'}); + var response = await client.post("$url/todos", + headers: headers as Map, body: postData); + expect(response.statusCode, 201); + var jsons = json.decode(response.body); + print(jsons); + expect(jsons['text'], equals('Hello, world!')); + }); + + test('can fetch data', () async { + String postData = json.encode({'text': 'Hello, world!'}); + await client.post("$url/todos", + headers: headers as Map, body: postData); + var response = await client.get("$url/todos/0"); + expect(response.statusCode, 200); + var jsons = json.decode(response.body); + print(jsons); + expect(jsons['text'], equals('Hello, world!')); + }); + + test('can modify data', () async { + String postData = json.encode({'text': 'Hello, world!'}); + await client.post("$url/todos", + headers: headers as Map, body: postData); + postData = json.encode({'text': 'modified'}); + + var response = await client.patch("$url/todos/0", + headers: headers as Map, body: postData); + expect(response.statusCode, 200); + var jsons = json.decode(response.body); + print(jsons); + expect(jsons['text'], equals('modified')); + }); + + test('can overwrite data', () async { + String postData = json.encode({'text': 'Hello, world!'}); + await client.post("$url/todos", + headers: headers as Map, body: postData); + postData = json.encode({'over': 'write'}); + + var response = await client.post("$url/todos/0", + headers: headers as Map, body: postData); + expect(response.statusCode, 200); + var jsons = json.decode(response.body); + print(jsons); + expect(jsons['text'], equals(null)); + expect(jsons['over'], equals('write')); + }); + + test('readMany', () async { + var items = [ + await service.create({'foo': 'bar'}), + await service.create({'bar': 'baz'}), + await service.create({'baz': 'quux'}) + ]; + + var ids = items.map((m) => m['id'] as String).toList(); + expect(await service.readMany(ids), items); + }); + + test('can delete data', () async { + String postData = json.encode({'text': 'Hello, world!'}); + var created = await client + .post("$url/todos", + headers: headers as Map, body: postData) + .then((r) => json.decode(r.body)); + var response = await client.delete("$url/todos/${created['id']}"); + expect(response.statusCode, 200); + var json_ = json.decode(response.body); + print(json_); + expect(json_['text'], equals('Hello, world!')); + }); + + test('cannot remove all unless explicitly set', () async { + var response = await client.delete('$url/todos/null'); + expect(response.statusCode, 403); + }); + }); +} diff --git a/framework/test/streaming_test.dart b/framework/test/streaming_test.dart new file mode 100644 index 00000000..bb0a0777 --- /dev/null +++ b/framework/test/streaming_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show stderr; +import 'dart:io'; + +import 'package:angel_container/mirrors.dart'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:logging/logging.dart'; +import 'package:mock_request/mock_request.dart'; + +import 'package:test/test.dart'; + +import 'encoders_buffer_test.dart' show encodingTests; + +main() { + Angel app; + AngelHttp http; + + setUp(() { + app = Angel(reflector: MirrorsReflector()); + http = AngelHttp(app, useZone: true); + + app.logger = Logger('streaming_test') + ..onRecord.listen((rec) { + print(rec); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + + app.encoders.addAll( + { + 'deflate': zlib.encoder, + 'gzip': gzip.encoder, + }, + ); + + app.get('/hello', (req, res) { + return Stream>.fromIterable(['Hello, world!'.codeUnits]) + .pipe(res); + }); + + app.get('/write', (req, res) async { + await res.addStream( + Stream>.fromIterable(['Hello, world!'.codeUnits])); + res.write('bye'); + await res.close(); + }); + + app.get('/multiple', (req, res) async { + await res.addStream( + Stream>.fromIterable(['Hello, world!'.codeUnits])); + await res.addStream(Stream>.fromIterable(['bye'.codeUnits])); + await res.close(); + }); + + app.get('/overwrite', (req, res) async { + res.statusCode = 32; + await Stream>.fromIterable(['Hello, world!'.codeUnits]) + .pipe(res); + + var f = Stream>.fromIterable(['Hello, world!'.codeUnits]) + .pipe(res) + .then((_) => false) + .catchError((_) => true); + + expect(f, completion(true)); + }); + + app.get('/error', (req, res) => res.addError(StateError('wtf'))); + + app.errorHandler = (e, req, res) async { + stderr..writeln(e.error)..writeln(e.stackTrace); + }; + }); + + tearDown(() => http.close()); + + _expectHelloBye(String path) async { + var rq = MockHttpRequest('GET', Uri.parse(path)); + (rq.close()); + await http.handleRequest(rq); + var body = await rq.response.transform(utf8.decoder).join(); + expect(body, 'Hello, world!bye'); + } + + test('write after addStream', () => _expectHelloBye('/write')); + + test('multiple addStream', () => _expectHelloBye('/multiple')); + + test('cannot write after close', () async { + try { + var rq = MockHttpRequest('GET', Uri.parse('/overwrite')); + (rq.close()); + await http.handleRequest(rq); + var body = await rq.response.transform(utf8.decoder).join(); + + if (rq.response.statusCode != 32) { + throw 'overwrite should throw error; response: $body'; + } + } on StateError { + // Success + } + }); + + test('res => addError', () async { + try { + var rq = MockHttpRequest('GET', Uri.parse('/error')); + (rq.close()); + await http.handleRequest(rq); + var body = await rq.response.transform(utf8.decoder).join(); + throw 'addError should throw error; response: $body'; + } on StateError { + // Should throw error... + } + }); + + encodingTests(() => app); +} diff --git a/framework/test/view_generator_test.dart b/framework/test/view_generator_test.dart new file mode 100644 index 00000000..1df69acf --- /dev/null +++ b/framework/test/view_generator_test.dart @@ -0,0 +1,10 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:test/test.dart'; + +main() { + test('default view generator', () async { + var app = Angel(); + var view = await app.viewGenerator('foo', {'bar': 'baz'}); + expect(view, contains('No view engine')); + }); +} diff --git a/framework/tool/travis.sh b/framework/tool/travis.sh new file mode 100644 index 00000000..ac63f578 --- /dev/null +++ b/framework/tool/travis.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +pub run test --timeout 5s +ANGEL_ENV=production pub run test --timeout 5s \ No newline at end of file