├── .gitignore ├── LICENSE.md ├── activator.properties ├── build.sbt ├── project ├── build.properties ├── play-fork-run.sbt ├── plugins.sbt └── sbt-ui.sbt ├── src ├── main │ ├── resources │ │ ├── css │ │ │ ├── bootstrap.css │ │ │ ├── font-awesome.min.css │ │ │ ├── style.css │ │ │ └── timeline.css │ │ ├── fonts │ │ │ ├── FontAwesome.otf │ │ │ ├── fontawesome-webfont.eot │ │ │ ├── fontawesome-webfont.svg │ │ │ ├── fontawesome-webfont.ttf │ │ │ ├── fontawesome-webfont.woff │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.svg │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ └── glyphicons-halflings-regular.woff │ │ ├── img │ │ │ ├── imgS1.jpg │ │ │ └── imgS2.jpg │ │ ├── index.html │ │ └── js │ │ │ ├── bootstrap.js │ │ │ ├── bootstrap.min.js │ │ │ ├── jquery-1.10.2.js │ │ │ └── main.js │ └── scala │ │ └── reactive │ │ ├── Main.scala │ │ └── tweets │ │ ├── domain │ │ └── Domain.scala │ │ ├── incoming │ │ ├── TweetActor.scala │ │ └── TweetActorManager.scala │ │ ├── marshalling │ │ └── TweetJsonProtocol.scala │ │ └── outgoing │ │ ├── TweetFlow.scala │ │ └── TweetPublisher.scala └── test │ ├── resources │ └── application.conf │ └── scala │ └── reactive │ ├── ActorTestUtils.scala │ ├── MainRoutingSpec.scala │ └── tweets │ ├── incoming │ └── TweetActorSpec.scala │ └── outgoing │ └── TweetFlowSpec.scala └── tutorial ├── img ├── flow.png ├── ordina-logo-big.png ├── ordina-logo-small.png └── ready-set-go.jpg └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### SBT ### 4 | # Simple Build Tool 5 | # http://www.scala-sbt.org/release/docs/Getting-Started/Directories.html#configuring-version-control 6 | 7 | target/ 8 | lib_managed/ 9 | src_managed/ 10 | project/boot/ 11 | .history 12 | .cache 13 | .sbtserver/ 14 | project/.sbtserver 15 | project/.sbtserver.lock 16 | 17 | ### Intellij ### 18 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 19 | 20 | *.iml 21 | 22 | ## Directory-based project format: 23 | .idea/ 24 | # if you remove the above rule, at least ignore the following: 25 | 26 | # User-specific stuff: 27 | # .idea/workspace.xml 28 | # .idea/tasks.xml 29 | # .idea/dictionaries 30 | 31 | # Sensitive or high-churn files: 32 | # .idea/dataSources.ids 33 | # .idea/dataSources.xml 34 | # .idea/sqlDataSources.xml 35 | # .idea/dynamic.xml 36 | # .idea/uiDesigner.xml 37 | 38 | # Gradle: 39 | # .idea/gradle.xml 40 | # .idea/libraries 41 | 42 | # Mongo Explorer plugin: 43 | # .idea/mongoSettings.xml 44 | 45 | ## File-based project format: 46 | *.ipr 47 | *.iws 48 | 49 | ## Plugin-specific files: 50 | 51 | # IntelliJ 52 | /out/ 53 | 54 | # mpeltonen/sbt-idea plugin 55 | .idea_modules/ 56 | 57 | # JIRA plugin 58 | atlassian-ide-plugin.xml 59 | 60 | # Crashlytics plugin (for Android Studio and IntelliJ) 61 | com_crashlytics_export_strings.xml 62 | crashlytics.properties 63 | crashlytics-build.properties 64 | 65 | 66 | ### NetBeans ### 67 | nbproject/private/ 68 | build/ 69 | nbbuild/ 70 | dist/ 71 | nbdist/ 72 | nbactions.xml 73 | nb-configuration.xml 74 | .nb-gradle/ 75 | 76 | 77 | ### Eclipse ### 78 | *.pydevproject 79 | .metadata 80 | .gradle 81 | bin/ 82 | tmp/ 83 | *.tmp 84 | *.bak 85 | *.swp 86 | *~.nib 87 | local.properties 88 | .settings/ 89 | .loadpath 90 | 91 | # Eclipse Core 92 | .project 93 | 94 | # External tool builders 95 | .externalToolBuilders/ 96 | 97 | # Locally stored "Eclipse launch configurations" 98 | *.launch 99 | 100 | # CDT-specific 101 | .cproject 102 | 103 | # JDT-specific (Eclipse Java Development Tools) 104 | .classpath 105 | 106 | # PDT-specific 107 | .buildpath 108 | 109 | # sbteclipse plugin 110 | .target 111 | 112 | # TeXlipse plugin 113 | .texlipse 114 | 115 | # Akka Persistence 116 | journal/ 117 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Put your Activator template license in here, for example... 2 | 3 | Copyright 2013 Typesafe, Inc. 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /activator.properties: -------------------------------------------------------------------------------- 1 | # The name of your template. 2 | name=akka-http-websocket-reactive-streams 3 | # A descriptive name for your template 4 | title=Akka Http with Websockets and Reactive Streams 5 | # A description of what your template does. Be detailed! 6 | description=An Activator template to show how Akka Http can be used with Websockets, Akka Persistence and Reactive Streams. This template consists of two parts, a theoretical and a hands-on part. In the theoretical part you will learn about the techniques mentioned above by means of a fully working example. In the hands-on part you will test-drive your own flow. 7 | # A comma-separated list of tags to associate with your template 8 | tags=akka,akka-persistence,akka-http,reactive-streams,streams,websocket,websockets,reactive-platform 9 | 10 | authorName=Ordina 11 | authorLink=http://www.ordina.com 12 | authorLogo=https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/master/tutorial/img/ordina-logo-big.png 13 | authorBio=We do cool stuff with Scala 14 | authorTwitter=Ordina 15 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "akka-http-websocket-activator-template" 2 | organization := "Ordina" 3 | version := "1.0" 4 | scalaVersion := "2.11.6" 5 | 6 | libraryDependencies ++= { 7 | val akkaV = "2.3.11" 8 | val akkaStreamV = "1.0-RC4" 9 | Seq( 10 | "com.typesafe.akka" %% "akka-actor" % akkaV, 11 | "com.typesafe.akka" %% "akka-stream-experimental" % akkaStreamV, 12 | "com.typesafe.akka" %% "akka-http-experimental" % akkaStreamV, 13 | "com.typesafe.akka" %% "akka-http-spray-json-experimental" % akkaStreamV, 14 | "com.typesafe.akka" %% "akka-http-xml-experimental" % akkaStreamV, 15 | "com.typesafe.akka" %% "akka-persistence-experimental" % akkaV, 16 | 17 | //test deps 18 | "com.typesafe.akka" %% "akka-http-testkit-experimental" % akkaStreamV % Test, 19 | "com.typesafe.akka" %% "akka-stream-testkit-experimental" % akkaStreamV % Test, 20 | "com.migesok" %% "akka-persistence-in-memory-snapshot-store" % "0.1.1" % Test, 21 | "org.scalatest" %% "scalatest" % "2.2.5" % Test, 22 | "junit" % "junit" % "4.10" % Test 23 | ) 24 | } 25 | 26 | fork in run := true 27 | 28 | resolvers += "migesok at bintray" at "http://dl.bintray.com/migesok/maven" 29 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 0.13.8 -------------------------------------------------------------------------------- /project/play-fork-run.sbt: -------------------------------------------------------------------------------- 1 | // This plugin adds forked run capabilities to Play projects which is needed for Activator. 2 | 3 | addSbtPlugin("com.typesafe.play" % "sbt-fork-run-plugin" % "2.3.9") -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | logLevel := Level.Warn -------------------------------------------------------------------------------- /project/sbt-ui.sbt: -------------------------------------------------------------------------------- 1 | // This plugin represents functionality that is to be added to sbt in the future 2 | 3 | addSbtPlugin("org.scala-sbt" % "sbt-core-next" % "0.1.1") -------------------------------------------------------------------------------- /src/main/resources/css/font-awesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.0.3 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */ 5 | 6 | @font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.0.1');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.0.1') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff?v=4.0.1') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.0.1') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.0.1#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font-family:FontAwesome;font-style:normal;font-weight:normal;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} 7 | .fa-lg{font-size:1.3333333333333333em;line-height:.75em;vertical-align:-15%} 8 | .fa-2x{font-size:2em} 9 | .fa-3x{font-size:3em} 10 | .fa-4x{font-size:4em} 11 | .fa-5x{font-size:5em} 12 | .fa-fw{width:1.2857142857142858em;text-align:center} 13 | .fa-ul{padding-left:0;margin-left:2.142857142857143em;list-style-type:none}.fa-ul>li{position:relative} 14 | .fa-li{position:absolute;left:-2.142857142857143em;width:2.142857142857143em;top:.14285714285714285em;text-align:center}.fa-li.fa-lg{left:-1.8571428571428572em} 15 | .fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em} 16 | .pull-right{float:right} 17 | .pull-left{float:left} 18 | .fa.pull-left{margin-right:.3em} 19 | .fa.pull-right{margin-left:.3em} 20 | .fa-spin{-webkit-animation:spin 2s infinite linear;-moz-animation:spin 2s infinite linear;-o-animation:spin 2s infinite linear;animation:spin 2s infinite linear} 21 | @-moz-keyframes spin{0%{-moz-transform:rotate(0deg)} 100%{-moz-transform:rotate(359deg)}}@-webkit-keyframes spin{0%{-webkit-transform:rotate(0deg)} 100%{-webkit-transform:rotate(359deg)}}@-o-keyframes spin{0%{-o-transform:rotate(0deg)} 100%{-o-transform:rotate(359deg)}}@-ms-keyframes spin{0%{-ms-transform:rotate(0deg)} 100%{-ms-transform:rotate(359deg)}}@keyframes spin{0%{transform:rotate(0deg)} 100%{transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)} 22 | .fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)} 23 | .fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)} 24 | .fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-moz-transform:scale(-1, 1);-ms-transform:scale(-1, 1);-o-transform:scale(-1, 1);transform:scale(-1, 1)} 25 | .fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-moz-transform:scale(1, -1);-ms-transform:scale(1, -1);-o-transform:scale(1, -1);transform:scale(1, -1)} 26 | .fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle} 27 | .fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center} 28 | .fa-stack-1x{line-height:inherit} 29 | .fa-stack-2x{font-size:2em} 30 | .fa-inverse{color:#fff} 31 | .fa-glass:before{content:"\f000"} 32 | .fa-music:before{content:"\f001"} 33 | .fa-search:before{content:"\f002"} 34 | .fa-envelope-o:before{content:"\f003"} 35 | .fa-heart:before{content:"\f004"} 36 | .fa-star:before{content:"\f005"} 37 | .fa-star-o:before{content:"\f006"} 38 | .fa-user:before{content:"\f007"} 39 | .fa-film:before{content:"\f008"} 40 | .fa-th-large:before{content:"\f009"} 41 | .fa-th:before{content:"\f00a"} 42 | .fa-th-list:before{content:"\f00b"} 43 | .fa-check:before{content:"\f00c"} 44 | .fa-times:before{content:"\f00d"} 45 | .fa-search-plus:before{content:"\f00e"} 46 | .fa-search-minus:before{content:"\f010"} 47 | .fa-power-off:before{content:"\f011"} 48 | .fa-signal:before{content:"\f012"} 49 | .fa-gear:before,.fa-cog:before{content:"\f013"} 50 | .fa-trash-o:before{content:"\f014"} 51 | .fa-home:before{content:"\f015"} 52 | .fa-file-o:before{content:"\f016"} 53 | .fa-clock-o:before{content:"\f017"} 54 | .fa-road:before{content:"\f018"} 55 | .fa-download:before{content:"\f019"} 56 | .fa-arrow-circle-o-down:before{content:"\f01a"} 57 | .fa-arrow-circle-o-up:before{content:"\f01b"} 58 | .fa-inbox:before{content:"\f01c"} 59 | .fa-play-circle-o:before{content:"\f01d"} 60 | .fa-rotate-right:before,.fa-repeat:before{content:"\f01e"} 61 | .fa-refresh:before{content:"\f021"} 62 | .fa-list-alt:before{content:"\f022"} 63 | .fa-lock:before{content:"\f023"} 64 | .fa-flag:before{content:"\f024"} 65 | .fa-headphones:before{content:"\f025"} 66 | .fa-volume-off:before{content:"\f026"} 67 | .fa-volume-down:before{content:"\f027"} 68 | .fa-volume-up:before{content:"\f028"} 69 | .fa-qrcode:before{content:"\f029"} 70 | .fa-barcode:before{content:"\f02a"} 71 | .fa-tag:before{content:"\f02b"} 72 | .fa-tags:before{content:"\f02c"} 73 | .fa-book:before{content:"\f02d"} 74 | .fa-bookmark:before{content:"\f02e"} 75 | .fa-print:before{content:"\f02f"} 76 | .fa-camera:before{content:"\f030"} 77 | .fa-font:before{content:"\f031"} 78 | .fa-bold:before{content:"\f032"} 79 | .fa-italic:before{content:"\f033"} 80 | .fa-text-height:before{content:"\f034"} 81 | .fa-text-width:before{content:"\f035"} 82 | .fa-align-left:before{content:"\f036"} 83 | .fa-align-center:before{content:"\f037"} 84 | .fa-align-right:before{content:"\f038"} 85 | .fa-align-justify:before{content:"\f039"} 86 | .fa-list:before{content:"\f03a"} 87 | .fa-dedent:before,.fa-outdent:before{content:"\f03b"} 88 | .fa-indent:before{content:"\f03c"} 89 | .fa-video-camera:before{content:"\f03d"} 90 | .fa-picture-o:before{content:"\f03e"} 91 | .fa-pencil:before{content:"\f040"} 92 | .fa-map-marker:before{content:"\f041"} 93 | .fa-adjust:before{content:"\f042"} 94 | .fa-tint:before{content:"\f043"} 95 | .fa-edit:before,.fa-pencil-square-o:before{content:"\f044"} 96 | .fa-share-square-o:before{content:"\f045"} 97 | .fa-check-square-o:before{content:"\f046"} 98 | .fa-move:before{content:"\f047"} 99 | .fa-step-backward:before{content:"\f048"} 100 | .fa-fast-backward:before{content:"\f049"} 101 | .fa-backward:before{content:"\f04a"} 102 | .fa-play:before{content:"\f04b"} 103 | .fa-pause:before{content:"\f04c"} 104 | .fa-stop:before{content:"\f04d"} 105 | .fa-forward:before{content:"\f04e"} 106 | .fa-fast-forward:before{content:"\f050"} 107 | .fa-step-forward:before{content:"\f051"} 108 | .fa-eject:before{content:"\f052"} 109 | .fa-chevron-left:before{content:"\f053"} 110 | .fa-chevron-right:before{content:"\f054"} 111 | .fa-plus-circle:before{content:"\f055"} 112 | .fa-minus-circle:before{content:"\f056"} 113 | .fa-times-circle:before{content:"\f057"} 114 | .fa-check-circle:before{content:"\f058"} 115 | .fa-question-circle:before{content:"\f059"} 116 | .fa-info-circle:before{content:"\f05a"} 117 | .fa-crosshairs:before{content:"\f05b"} 118 | .fa-times-circle-o:before{content:"\f05c"} 119 | .fa-check-circle-o:before{content:"\f05d"} 120 | .fa-ban:before{content:"\f05e"} 121 | .fa-arrow-left:before{content:"\f060"} 122 | .fa-arrow-right:before{content:"\f061"} 123 | .fa-arrow-up:before{content:"\f062"} 124 | .fa-arrow-down:before{content:"\f063"} 125 | .fa-mail-forward:before,.fa-share:before{content:"\f064"} 126 | .fa-resize-full:before{content:"\f065"} 127 | .fa-resize-small:before{content:"\f066"} 128 | .fa-plus:before{content:"\f067"} 129 | .fa-minus:before{content:"\f068"} 130 | .fa-asterisk:before{content:"\f069"} 131 | .fa-exclamation-circle:before{content:"\f06a"} 132 | .fa-gift:before{content:"\f06b"} 133 | .fa-leaf:before{content:"\f06c"} 134 | .fa-fire:before{content:"\f06d"} 135 | .fa-eye:before{content:"\f06e"} 136 | .fa-eye-slash:before{content:"\f070"} 137 | .fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"} 138 | .fa-plane:before{content:"\f072"} 139 | .fa-calendar:before{content:"\f073"} 140 | .fa-random:before{content:"\f074"} 141 | .fa-comment:before{content:"\f075"} 142 | .fa-magnet:before{content:"\f076"} 143 | .fa-chevron-up:before{content:"\f077"} 144 | .fa-chevron-down:before{content:"\f078"} 145 | .fa-retweet:before{content:"\f079"} 146 | .fa-shopping-cart:before{content:"\f07a"} 147 | .fa-folder:before{content:"\f07b"} 148 | .fa-folder-open:before{content:"\f07c"} 149 | .fa-resize-vertical:before{content:"\f07d"} 150 | .fa-resize-horizontal:before{content:"\f07e"} 151 | .fa-bar-chart-o:before{content:"\f080"} 152 | .fa-twitter-square:before{content:"\f081"} 153 | .fa-facebook-square:before{content:"\f082"} 154 | .fa-camera-retro:before{content:"\f083"} 155 | .fa-key:before{content:"\f084"} 156 | .fa-gears:before,.fa-cogs:before{content:"\f085"} 157 | .fa-comments:before{content:"\f086"} 158 | .fa-thumbs-o-up:before{content:"\f087"} 159 | .fa-thumbs-o-down:before{content:"\f088"} 160 | .fa-star-half:before{content:"\f089"} 161 | .fa-heart-o:before{content:"\f08a"} 162 | .fa-sign-out:before{content:"\f08b"} 163 | .fa-linkedin-square:before{content:"\f08c"} 164 | .fa-thumb-tack:before{content:"\f08d"} 165 | .fa-external-link:before{content:"\f08e"} 166 | .fa-sign-in:before{content:"\f090"} 167 | .fa-trophy:before{content:"\f091"} 168 | .fa-github-square:before{content:"\f092"} 169 | .fa-upload:before{content:"\f093"} 170 | .fa-lemon-o:before{content:"\f094"} 171 | .fa-phone:before{content:"\f095"} 172 | .fa-square-o:before{content:"\f096"} 173 | .fa-bookmark-o:before{content:"\f097"} 174 | .fa-phone-square:before{content:"\f098"} 175 | .fa-twitter:before{content:"\f099"} 176 | .fa-facebook:before{content:"\f09a"} 177 | .fa-github:before{content:"\f09b"} 178 | .fa-unlock:before{content:"\f09c"} 179 | .fa-credit-card:before{content:"\f09d"} 180 | .fa-rss:before{content:"\f09e"} 181 | .fa-hdd-o:before{content:"\f0a0"} 182 | .fa-bullhorn:before{content:"\f0a1"} 183 | .fa-bell:before{content:"\f0f3"} 184 | .fa-certificate:before{content:"\f0a3"} 185 | .fa-hand-o-right:before{content:"\f0a4"} 186 | .fa-hand-o-left:before{content:"\f0a5"} 187 | .fa-hand-o-up:before{content:"\f0a6"} 188 | .fa-hand-o-down:before{content:"\f0a7"} 189 | .fa-arrow-circle-left:before{content:"\f0a8"} 190 | .fa-arrow-circle-right:before{content:"\f0a9"} 191 | .fa-arrow-circle-up:before{content:"\f0aa"} 192 | .fa-arrow-circle-down:before{content:"\f0ab"} 193 | .fa-globe:before{content:"\f0ac"} 194 | .fa-wrench:before{content:"\f0ad"} 195 | .fa-tasks:before{content:"\f0ae"} 196 | .fa-filter:before{content:"\f0b0"} 197 | .fa-briefcase:before{content:"\f0b1"} 198 | .fa-fullscreen:before{content:"\f0b2"} 199 | .fa-group:before{content:"\f0c0"} 200 | .fa-chain:before,.fa-link:before{content:"\f0c1"} 201 | .fa-cloud:before{content:"\f0c2"} 202 | .fa-flask:before{content:"\f0c3"} 203 | .fa-cut:before,.fa-scissors:before{content:"\f0c4"} 204 | .fa-copy:before,.fa-files-o:before{content:"\f0c5"} 205 | .fa-paperclip:before{content:"\f0c6"} 206 | .fa-save:before,.fa-floppy-o:before{content:"\f0c7"} 207 | .fa-square:before{content:"\f0c8"} 208 | .fa-reorder:before{content:"\f0c9"} 209 | .fa-list-ul:before{content:"\f0ca"} 210 | .fa-list-ol:before{content:"\f0cb"} 211 | .fa-strikethrough:before{content:"\f0cc"} 212 | .fa-underline:before{content:"\f0cd"} 213 | .fa-table:before{content:"\f0ce"} 214 | .fa-magic:before{content:"\f0d0"} 215 | .fa-truck:before{content:"\f0d1"} 216 | .fa-pinterest:before{content:"\f0d2"} 217 | .fa-pinterest-square:before{content:"\f0d3"} 218 | .fa-google-plus-square:before{content:"\f0d4"} 219 | .fa-google-plus:before{content:"\f0d5"} 220 | .fa-money:before{content:"\f0d6"} 221 | .fa-caret-down:before{content:"\f0d7"} 222 | .fa-caret-up:before{content:"\f0d8"} 223 | .fa-caret-left:before{content:"\f0d9"} 224 | .fa-caret-right:before{content:"\f0da"} 225 | .fa-columns:before{content:"\f0db"} 226 | .fa-unsorted:before,.fa-sort:before{content:"\f0dc"} 227 | .fa-sort-down:before,.fa-sort-asc:before{content:"\f0dd"} 228 | .fa-sort-up:before,.fa-sort-desc:before{content:"\f0de"} 229 | .fa-envelope:before{content:"\f0e0"} 230 | .fa-linkedin:before{content:"\f0e1"} 231 | .fa-rotate-left:before,.fa-undo:before{content:"\f0e2"} 232 | .fa-legal:before,.fa-gavel:before{content:"\f0e3"} 233 | .fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"} 234 | .fa-comment-o:before{content:"\f0e5"} 235 | .fa-comments-o:before{content:"\f0e6"} 236 | .fa-flash:before,.fa-bolt:before{content:"\f0e7"} 237 | .fa-sitemap:before{content:"\f0e8"} 238 | .fa-umbrella:before{content:"\f0e9"} 239 | .fa-paste:before,.fa-clipboard:before{content:"\f0ea"} 240 | .fa-lightbulb-o:before{content:"\f0eb"} 241 | .fa-exchange:before{content:"\f0ec"} 242 | .fa-cloud-download:before{content:"\f0ed"} 243 | .fa-cloud-upload:before{content:"\f0ee"} 244 | .fa-user-md:before{content:"\f0f0"} 245 | .fa-stethoscope:before{content:"\f0f1"} 246 | .fa-suitcase:before{content:"\f0f2"} 247 | .fa-bell-o:before{content:"\f0a2"} 248 | .fa-coffee:before{content:"\f0f4"} 249 | .fa-cutlery:before{content:"\f0f5"} 250 | .fa-file-text-o:before{content:"\f0f6"} 251 | .fa-building:before{content:"\f0f7"} 252 | .fa-hospital:before{content:"\f0f8"} 253 | .fa-ambulance:before{content:"\f0f9"} 254 | .fa-medkit:before{content:"\f0fa"} 255 | .fa-fighter-jet:before{content:"\f0fb"} 256 | .fa-beer:before{content:"\f0fc"} 257 | .fa-h-square:before{content:"\f0fd"} 258 | .fa-plus-square:before{content:"\f0fe"} 259 | .fa-angle-double-left:before{content:"\f100"} 260 | .fa-angle-double-right:before{content:"\f101"} 261 | .fa-angle-double-up:before{content:"\f102"} 262 | .fa-angle-double-down:before{content:"\f103"} 263 | .fa-angle-left:before{content:"\f104"} 264 | .fa-angle-right:before{content:"\f105"} 265 | .fa-angle-up:before{content:"\f106"} 266 | .fa-angle-down:before{content:"\f107"} 267 | .fa-desktop:before{content:"\f108"} 268 | .fa-laptop:before{content:"\f109"} 269 | .fa-tablet:before{content:"\f10a"} 270 | .fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"} 271 | .fa-circle-o:before{content:"\f10c"} 272 | .fa-quote-left:before{content:"\f10d"} 273 | .fa-quote-right:before{content:"\f10e"} 274 | .fa-spinner:before{content:"\f110"} 275 | .fa-circle:before{content:"\f111"} 276 | .fa-mail-reply:before,.fa-reply:before{content:"\f112"} 277 | .fa-github-alt:before{content:"\f113"} 278 | .fa-folder-o:before{content:"\f114"} 279 | .fa-folder-open-o:before{content:"\f115"} 280 | .fa-expand-o:before{content:"\f116"} 281 | .fa-collapse-o:before{content:"\f117"} 282 | .fa-smile-o:before{content:"\f118"} 283 | .fa-frown-o:before{content:"\f119"} 284 | .fa-meh-o:before{content:"\f11a"} 285 | .fa-gamepad:before{content:"\f11b"} 286 | .fa-keyboard-o:before{content:"\f11c"} 287 | .fa-flag-o:before{content:"\f11d"} 288 | .fa-flag-checkered:before{content:"\f11e"} 289 | .fa-terminal:before{content:"\f120"} 290 | .fa-code:before{content:"\f121"} 291 | .fa-reply-all:before{content:"\f122"} 292 | .fa-mail-reply-all:before{content:"\f122"} 293 | .fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"} 294 | .fa-location-arrow:before{content:"\f124"} 295 | .fa-crop:before{content:"\f125"} 296 | .fa-code-fork:before{content:"\f126"} 297 | .fa-unlink:before,.fa-chain-broken:before{content:"\f127"} 298 | .fa-question:before{content:"\f128"} 299 | .fa-info:before{content:"\f129"} 300 | .fa-exclamation:before{content:"\f12a"} 301 | .fa-superscript:before{content:"\f12b"} 302 | .fa-subscript:before{content:"\f12c"} 303 | .fa-eraser:before{content:"\f12d"} 304 | .fa-puzzle-piece:before{content:"\f12e"} 305 | .fa-microphone:before{content:"\f130"} 306 | .fa-microphone-slash:before{content:"\f131"} 307 | .fa-shield:before{content:"\f132"} 308 | .fa-calendar-o:before{content:"\f133"} 309 | .fa-fire-extinguisher:before{content:"\f134"} 310 | .fa-rocket:before{content:"\f135"} 311 | .fa-maxcdn:before{content:"\f136"} 312 | .fa-chevron-circle-left:before{content:"\f137"} 313 | .fa-chevron-circle-right:before{content:"\f138"} 314 | .fa-chevron-circle-up:before{content:"\f139"} 315 | .fa-chevron-circle-down:before{content:"\f13a"} 316 | .fa-html5:before{content:"\f13b"} 317 | .fa-css3:before{content:"\f13c"} 318 | .fa-anchor:before{content:"\f13d"} 319 | .fa-unlock-o:before{content:"\f13e"} 320 | .fa-bullseye:before{content:"\f140"} 321 | .fa-ellipsis-horizontal:before{content:"\f141"} 322 | .fa-ellipsis-vertical:before{content:"\f142"} 323 | .fa-rss-square:before{content:"\f143"} 324 | .fa-play-circle:before{content:"\f144"} 325 | .fa-ticket:before{content:"\f145"} 326 | .fa-minus-square:before{content:"\f146"} 327 | .fa-minus-square-o:before{content:"\f147"} 328 | .fa-level-up:before{content:"\f148"} 329 | .fa-level-down:before{content:"\f149"} 330 | .fa-check-square:before{content:"\f14a"} 331 | .fa-pencil-square:before{content:"\f14b"} 332 | .fa-external-link-square:before{content:"\f14c"} 333 | .fa-share-square:before{content:"\f14d"} 334 | .fa-compass:before{content:"\f14e"} 335 | .fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"} 336 | .fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"} 337 | .fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"} 338 | .fa-euro:before,.fa-eur:before{content:"\f153"} 339 | .fa-gbp:before{content:"\f154"} 340 | .fa-dollar:before,.fa-usd:before{content:"\f155"} 341 | .fa-rupee:before,.fa-inr:before{content:"\f156"} 342 | .fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"} 343 | .fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"} 344 | .fa-won:before,.fa-krw:before{content:"\f159"} 345 | .fa-bitcoin:before,.fa-btc:before{content:"\f15a"} 346 | .fa-file:before{content:"\f15b"} 347 | .fa-file-text:before{content:"\f15c"} 348 | .fa-sort-alpha-asc:before{content:"\f15d"} 349 | .fa-sort-alpha-desc:before{content:"\f15e"} 350 | .fa-sort-amount-asc:before{content:"\f160"} 351 | .fa-sort-amount-desc:before{content:"\f161"} 352 | .fa-sort-numeric-asc:before{content:"\f162"} 353 | .fa-sort-numeric-desc:before{content:"\f163"} 354 | .fa-thumbs-up:before{content:"\f164"} 355 | .fa-thumbs-down:before{content:"\f165"} 356 | .fa-youtube-square:before{content:"\f166"} 357 | .fa-youtube:before{content:"\f167"} 358 | .fa-xing:before{content:"\f168"} 359 | .fa-xing-square:before{content:"\f169"} 360 | .fa-youtube-play:before{content:"\f16a"} 361 | .fa-dropbox:before{content:"\f16b"} 362 | .fa-stack-overflow:before{content:"\f16c"} 363 | .fa-instagram:before{content:"\f16d"} 364 | .fa-flickr:before{content:"\f16e"} 365 | .fa-adn:before{content:"\f170"} 366 | .fa-bitbucket:before{content:"\f171"} 367 | .fa-bitbucket-square:before{content:"\f172"} 368 | .fa-tumblr:before{content:"\f173"} 369 | .fa-tumblr-square:before{content:"\f174"} 370 | .fa-long-arrow-down:before{content:"\f175"} 371 | .fa-long-arrow-up:before{content:"\f176"} 372 | .fa-long-arrow-left:before{content:"\f177"} 373 | .fa-long-arrow-right:before{content:"\f178"} 374 | .fa-apple:before{content:"\f179"} 375 | .fa-windows:before{content:"\f17a"} 376 | .fa-android:before{content:"\f17b"} 377 | .fa-linux:before{content:"\f17c"} 378 | .fa-dribbble:before{content:"\f17d"} 379 | .fa-skype:before{content:"\f17e"} 380 | .fa-foursquare:before{content:"\f180"} 381 | .fa-trello:before{content:"\f181"} 382 | .fa-female:before{content:"\f182"} 383 | .fa-male:before{content:"\f183"} 384 | .fa-gittip:before{content:"\f184"} 385 | .fa-sun-o:before{content:"\f185"} 386 | .fa-moon-o:before{content:"\f186"} 387 | .fa-archive:before{content:"\f187"} 388 | .fa-bug:before{content:"\f188"} 389 | .fa-vk:before{content:"\f189"} 390 | .fa-weibo:before{content:"\f18a"} 391 | .fa-renren:before{content:"\f18b"} 392 | .fa-pagelines:before{content:"\f18c"} 393 | .fa-stack-exchange:before{content:"\f18d"} 394 | .fa-arrow-circle-o-right:before{content:"\f18e"} 395 | .fa-arrow-circle-o-left:before{content:"\f190"} 396 | .fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"} 397 | .fa-dot-circle-o:before{content:"\f192"} 398 | .fa-wheelchair:before{content:"\f193"} 399 | .fa-vimeo-square:before{content:"\f194"} 400 | .fa-turkish-lira:before,.fa-try:before{content:"\f195"} 401 | -------------------------------------------------------------------------------- /src/main/resources/css/style.css: -------------------------------------------------------------------------------- 1 |  2 | 3 | /*==================================== 4 | Free To Use For Personal And Commercial Usage 5 | Author: http://binarytheme.com 6 | License: Open source - MIT 7 | Please visit http://opensource.org/licenses/MIT for more Full Deatils 8 | Share Us if You Like Us and Enjoy Our Codes For Free Always. 9 | ======================================*/ 10 | 11 | 12 | /*======================================= 13 | GENERAL STYLES 14 | ==================================================*/ 15 | body { 16 | background-color:#4FD2E4; 17 | font-family:'Open Sans', sans-serif; 18 | font-size:14px; 19 | } 20 | 21 | .nav a { 22 | color:#ffffff !important; 23 | } 24 | .navbar-header a { 25 | color:#ffffff !important; 26 | padding-right:100px; 27 | } 28 | 29 | .text-center { 30 | text-align:center; 31 | } 32 | 33 | .top-pad { 34 | padding-top:70px; 35 | } 36 | .top-margin { 37 | margin-top:90px; 38 | } 39 | 40 | h1, h2, h3, h4, h5, h6 { 41 | font-family:'Open Sans', sans-serif; 42 | } 43 | h1 { 44 | 45 | font-weight:700; 46 | font-size:50px; 47 | color:#1fa51f; /* green color code */ 48 | } 49 | 50 | p { 51 | font-weight:400; 52 | line-height:29px; 53 | padding-bottom:50px; 54 | } 55 | .margin-bot { 56 | margin-bottom:50px; 57 | } 58 | 59 | -------------------------------------------------------------------------------- /src/main/resources/css/timeline.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * AdminLTE v1.0 3 | * Author: AlmsaeedStudio.com 4 | * License: Open source - MIT 5 | * Please visit http://opensource.org/licenses/MIT for more information 6 | !*/ 7 | /* 8 | 9 | /*==================================== 10 | --- TIMELINE LIKE ABOUT SECTION CSS ---- 11 | ======================================*/ 12 | 13 | .timeline { 14 | margin: 0 0 30px 0; 15 | padding: 0; 16 | list-style: none; 17 | } 18 | .timeline:before { 19 | content: ''; 20 | position: absolute; 21 | top: 0px; 22 | bottom: 0; 23 | width: 5px; 24 | background: #ddd; 25 | left: 45px; 26 | border: 1px solid #eee; 27 | margin: 0; 28 | -webkit-border-radius: 2px; 29 | -moz-border-radius: 2px; 30 | border-radius: 2px; 31 | } 32 | .timeline > li { 33 | position: relative; 34 | margin-right: 10px; 35 | margin-bottom: 15px; 36 | } 37 | .timeline > li:before, 38 | .timeline > li:after { 39 | display: table; 40 | content: " "; 41 | } 42 | .timeline > li:after { 43 | clear: both; 44 | } 45 | .timeline > li > .timeline-item { 46 | margin-top: 10px; 47 | border: 0px solid #dfdfdf; 48 | background: #fff; 49 | color: #555; 50 | margin-left: 60px; 51 | margin-right: 15px; 52 | padding: 5px; 53 | position: relative; 54 | box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.1); 55 | } 56 | .timeline > li > .timeline-item > .time { 57 | color: #999; 58 | float: right; 59 | margin: 2px 0 0 0; 60 | } 61 | .timeline > li > .timeline-item > .timeline-header { 62 | margin: 0; 63 | color: #555; 64 | border-bottom: 1px solid #f4f4f4; 65 | padding: 5px; 66 | font-size: 16px; 67 | line-height: 1.1; 68 | } 69 | .timeline > li > .timeline-item > .timeline-header > a { 70 | font-weight: 600; 71 | } 72 | .timeline > li > .timeline-item > .timeline-body, 73 | .timeline > li > .timeline-item > .timeline-footer { 74 | padding: 10px; 75 | } 76 | .timeline > li.time-label > span { 77 | font-weight: 600; 78 | padding: 5px; 79 | display: inline-block; 80 | background-color: #fff; 81 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); 82 | -webkit-border-radius: 4px; 83 | -moz-border-radius: 4px; 84 | border-radius: 4px; 85 | } 86 | .timeline > li > .fa, 87 | .timeline > li > .glyphicon, 88 | .timeline > li > .ion { 89 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); 90 | width: 30px; 91 | height: 30px; 92 | font-size: 15px; 93 | line-height: 30px; 94 | position: absolute; 95 | color: #666; 96 | background: #eee; 97 | border-radius: 50%; 98 | text-align: center; 99 | left: 18px; 100 | top: 0; 101 | } 102 | 103 | 104 | /*==================================== 105 | --- BACKGROUND COLORS OPTIONS ---- 106 | ======================================*/ 107 | 108 | .bg-red, 109 | .bg-yellow, 110 | .bg-aqua, 111 | .bg-blue, 112 | .bg-light-blue, 113 | .bg-green, 114 | .bg-navy, 115 | .bg-teal, 116 | .bg-olive, 117 | .bg-lime, 118 | .bg-orange, 119 | .bg-fuchsia, 120 | .bg-purple, 121 | .bg-maroon, 122 | .bg-black { 123 | color: #f9f9f9 !important; 124 | } 125 | .bg-gray { 126 | background-color: #eaeaec !important; 127 | } 128 | .bg-black { 129 | background-color: #222222 !important; 130 | } 131 | .bg-red { 132 | background-color: #f56954 !important; 133 | } 134 | .bg-yellow { 135 | background-color: #f39c12 !important; 136 | } 137 | .bg-aqua { 138 | background-color: #00c0ef !important; 139 | } 140 | .bg-blue { 141 | background-color: #37AFFF !important; 142 | } 143 | .bg-light-blue { 144 | background-color: #3c8dbc !important; 145 | } 146 | .bg-green { 147 | background-color: #00a65a !important; 148 | } 149 | .bg-navy { 150 | background-color: #001f3f !important; 151 | } 152 | .bg-teal { 153 | background-color: #39cccc !important; 154 | } 155 | .bg-olive { 156 | background-color: #3d9970 !important; 157 | } 158 | .bg-lime { 159 | background-color: #01ff70 !important; 160 | } 161 | .bg-orange { 162 | background-color: #ff851b !important; 163 | } 164 | .bg-fuchsia { 165 | background-color: #f012be !important; 166 | } 167 | .bg-purple { 168 | background-color: #932ab6 !important; 169 | } 170 | .bg-maroon { 171 | background-color: #85144b !important; 172 | } 173 | 174 | /*==================================== 175 | --- TEXT COLORS OPTIONS ---- 176 | ======================================*/ 177 | 178 | .text-red { 179 | color: #f56954 !important; 180 | } 181 | .text-yellow { 182 | color: #f39c12 !important; 183 | } 184 | .text-aqua { 185 | color: #00c0ef !important; 186 | } 187 | .text-blue { 188 | color: #0073b7 !important; 189 | } 190 | .text-light-blue { 191 | color: #3c8dbc !important; 192 | } 193 | .text-green { 194 | color: #00a65a !important; 195 | } 196 | .text-navy { 197 | color: #001f3f !important; 198 | } 199 | .text-teal { 200 | color: #39cccc !important; 201 | } 202 | .text-olive { 203 | color: #3d9970 !important; 204 | } 205 | .text-lime { 206 | color: #01ff70 !important; 207 | } 208 | .text-orange { 209 | color: #ff851b !important; 210 | } 211 | .text-fuchsia { 212 | color: #f012be !important; 213 | } 214 | .text-purple { 215 | color: #932ab6 !important; 216 | } 217 | .text-maroon { 218 | color: #85144b !important; 219 | } -------------------------------------------------------------------------------- /src/main/resources/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /src/main/resources/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /src/main/resources/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /src/main/resources/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /src/main/resources/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /src/main/resources/fonts/glyphicons-halflings-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | -------------------------------------------------------------------------------- /src/main/resources/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /src/main/resources/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /src/main/resources/img/imgS1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/img/imgS1.jpg -------------------------------------------------------------------------------- /src/main/resources/img/imgS2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/src/main/resources/img/imgS2.jpg -------------------------------------------------------------------------------- /src/main/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Ordina Tweetstream 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 49 | 50 |
51 |
52 |
53 | 78 |
79 |
80 |
81 |
82 |
83 |
84 | 85 | 86 |
87 |
88 | 89 | 90 |
91 | 92 |
93 |
94 |
95 |
96 |
97 |
98 |
    99 |
  • 100 | Timeline 101 | 102 |
    103 |
    104 |
  • 105 |
  • 106 | 107 | 108 |
    109 | 23 June 110 | 111 |

    112 | Ordina. Thanks for using this template. #welcome 113 |

    114 |
    115 |
  • 116 |
  • 117 | 118 |
  • 119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /src/main/resources/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.0 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap requires jQuery");+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one(a.support.transition.end,function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b()})}(jQuery),+function(a){"use strict";var b='[data-dismiss="alert"]',c=function(c){a(c).on("click",b,this.close)};c.prototype.close=function(b){function c(){f.trigger("closed.bs.alert").remove()}var d=a(this),e=d.attr("data-target");e||(e=d.attr("href"),e=e&&e.replace(/.*(?=#[^\s]*$)/,""));var f=a(e);b&&b.preventDefault(),f.length||(f=d.hasClass("alert")?d:d.parent()),f.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(f.removeClass("in"),a.support.transition&&f.hasClass("fade")?f.one(a.support.transition.end,c).emulateTransitionEnd(150):c())};var d=a.fn.alert;a.fn.alert=function(b){return this.each(function(){var d=a(this),e=d.data("bs.alert");e||d.data("bs.alert",e=new c(this)),"string"==typeof b&&e[b].call(d)})},a.fn.alert.Constructor=c,a.fn.alert.noConflict=function(){return a.fn.alert=d,this},a(document).on("click.bs.alert.data-api",b,c.prototype.close)}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d),this.isLoading=!1};b.DEFAULTS={loadingText:"loading..."},b.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",f.resetText||d.data("resetText",d[e]()),d[e](f[b]||this.options[b]),setTimeout(a.proxy(function(){"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},b.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}a&&this.$element.toggleClass("active")};var c=a.fn.button;a.fn.button=function(c){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof c&&c;e||d.data("bs.button",e=new b(this,f)),"toggle"==c?e.toggle():c&&e.setState(c)})},a.fn.button.Constructor=b,a.fn.button.noConflict=function(){return a.fn.button=c,this},a(document).on("click.bs.button.data-api","[data-toggle^=button]",function(b){var c=a(b.target);c.hasClass("btn")||(c=c.closest(".btn")),c.button("toggle"),b.preventDefault()})}(jQuery),+function(a){"use strict";var b=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,"hover"==this.options.pause&&this.$element.on("mouseenter",a.proxy(this.pause,this)).on("mouseleave",a.proxy(this.cycle,this))};b.DEFAULTS={interval:5e3,pause:"hover",wrap:!0},b.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},b.prototype.getActiveIndex=function(){return this.$active=this.$element.find(".item.active"),this.$items=this.$active.parent().children(),this.$items.index(this.$active)},b.prototype.to=function(b){var c=this,d=this.getActiveIndex();return b>this.$items.length-1||0>b?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){c.to(b)}):d==b?this.pause().cycle():this.slide(b>d?"next":"prev",a(this.$items[b]))},b.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},b.prototype.next=function(){return this.sliding?void 0:this.slide("next")},b.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},b.prototype.slide=function(b,c){var d=this.$element.find(".item.active"),e=c||d[b](),f=this.interval,g="next"==b?"left":"right",h="next"==b?"first":"last",i=this;if(!e.length){if(!this.options.wrap)return;e=this.$element.find(".item")[h]()}if(e.hasClass("active"))return this.sliding=!1;var j=a.Event("slide.bs.carousel",{relatedTarget:e[0],direction:g});return this.$element.trigger(j),j.isDefaultPrevented()?void 0:(this.sliding=!0,f&&this.pause(),this.$indicators.length&&(this.$indicators.find(".active").removeClass("active"),this.$element.one("slid.bs.carousel",function(){var b=a(i.$indicators.children()[i.getActiveIndex()]);b&&b.addClass("active")})),a.support.transition&&this.$element.hasClass("slide")?(e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),d.one(a.support.transition.end,function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger("slid.bs.carousel")},0)}).emulateTransitionEnd(1e3*d.css("transition-duration").slice(0,-1))):(d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger("slid.bs.carousel")),f&&this.cycle(),this)};var c=a.fn.carousel;a.fn.carousel=function(c){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c),g="string"==typeof c?c:f.slide;e||d.data("bs.carousel",e=new b(this,f)),"number"==typeof c?e.to(c):g?e[g]():f.interval&&e.pause().cycle()})},a.fn.carousel.Constructor=b,a.fn.carousel.noConflict=function(){return a.fn.carousel=c,this},a(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",function(b){var c,d=a(this),e=a(d.attr("data-target")||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"")),f=a.extend({},e.data(),d.data()),g=d.attr("data-slide-to");g&&(f.interval=!1),e.carousel(f),(g=d.attr("data-slide-to"))&&e.data("bs.carousel").to(g),b.preventDefault()}),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var b=a(this);b.carousel(b.data())})})}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d),this.transitioning=null,this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};b.DEFAULTS={toggle:!0},b.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},b.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b=a.Event("show.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.$parent&&this.$parent.find("> .panel > .in");if(c&&c.length){var d=c.data("bs.collapse");if(d&&d.transitioning)return;c.collapse("hide"),d||c.data("bs.collapse",null)}var e=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[e](0),this.transitioning=1;var f=function(){this.$element.removeClass("collapsing").addClass("collapse in")[e]("auto"),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return f.call(this);var g=a.camelCase(["scroll",e].join("-"));this.$element.one(a.support.transition.end,a.proxy(f,this)).emulateTransitionEnd(350)[e](this.$element[0][g])}}},b.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse").removeClass("in"),this.transitioning=1;var d=function(){this.transitioning=0,this.$element.trigger("hidden.bs.collapse").removeClass("collapsing").addClass("collapse")};return a.support.transition?void this.$element[c](0).one(a.support.transition.end,a.proxy(d,this)).emulateTransitionEnd(350):d.call(this)}}},b.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()};var c=a.fn.collapse;a.fn.collapse=function(c){return this.each(function(){var d=a(this),e=d.data("bs.collapse"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c);!e&&f.toggle&&"show"==c&&(c=!c),e||d.data("bs.collapse",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.collapse.Constructor=b,a.fn.collapse.noConflict=function(){return a.fn.collapse=c,this},a(document).on("click.bs.collapse.data-api","[data-toggle=collapse]",function(b){var c,d=a(this),e=d.attr("data-target")||b.preventDefault()||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,""),f=a(e),g=f.data("bs.collapse"),h=g?"toggle":d.data(),i=d.attr("data-parent"),j=i&&a(i);g&&g.transitioning||(j&&j.find('[data-toggle=collapse][data-parent="'+i+'"]').not(d).addClass("collapsed"),d[f.hasClass("in")?"addClass":"removeClass"]("collapsed")),f.collapse(h)})}(jQuery),+function(a){"use strict";function b(b){a(d).remove(),a(e).each(function(){var d=c(a(this)),e={relatedTarget:this};d.hasClass("open")&&(d.trigger(b=a.Event("hide.bs.dropdown",e)),b.isDefaultPrevented()||d.removeClass("open").trigger("hidden.bs.dropdown",e))})}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}var d=".dropdown-backdrop",e="[data-toggle=dropdown]",f=function(b){a(b).on("click.bs.dropdown",this.toggle)};f.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(''}),b.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),b.prototype.constructor=b,b.prototype.getDefaults=function(){return b.DEFAULTS},b.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content")[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},b.prototype.hasContent=function(){return this.getTitle()||this.getContent()},b.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},b.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")},b.prototype.tip=function(){return this.$tip||(this.$tip=a(this.options.template)),this.$tip};var c=a.fn.popover;a.fn.popover=function(c){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof c&&c;(e||"destroy"!=c)&&(e||d.data("bs.popover",e=new b(this,f)),"string"==typeof c&&e[c]())})},a.fn.popover.Constructor=b,a.fn.popover.noConflict=function(){return a.fn.popover=c,this}}(jQuery),+function(a){"use strict";function b(c,d){var e,f=a.proxy(this.process,this);this.$element=a(a(c).is("body")?window:c),this.$body=a("body"),this.$scrollElement=this.$element.on("scroll.bs.scroll-spy.data-api",f),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||(e=a(c).attr("href"))&&e.replace(/.*(?=#[^\s]+$)/,"")||"")+" .nav li > a",this.offsets=a([]),this.targets=a([]),this.activeTarget=null,this.refresh(),this.process()}b.DEFAULTS={offset:10},b.prototype.refresh=function(){var b=this.$element[0]==window?"offset":"position";this.offsets=a([]),this.targets=a([]);{var c=this;this.$body.find(this.selector).map(function(){var d=a(this),e=d.data("target")||d.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[b]().top+(!a.isWindow(c.$scrollElement.get(0))&&c.$scrollElement.scrollTop()),e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){c.offsets.push(this[0]),c.targets.push(this[1])})}},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.$scrollElement[0].scrollHeight||this.$body[0].scrollHeight,d=c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(b>=d)return g!=(a=f.last()[0])&&this.activate(a);if(g&&b<=e[0])return g!=(a=f[0])&&this.activate(a);for(a=e.length;a--;)g!=f[a]&&b>=e[a]&&(!e[a+1]||b<=e[a+1])&&this.activate(f[a])},b.prototype.activate=function(b){this.activeTarget=b,a(this.selector).parentsUntil(this.options.target,".active").removeClass("active");var c=this.selector+'[data-target="'+b+'"],'+this.selector+'[href="'+b+'"]',d=a(c).parents("li").addClass("active");d.parent(".dropdown-menu").length&&(d=d.closest("li.dropdown").addClass("active")),d.trigger("activate.bs.scrollspy")};var c=a.fn.scrollspy;a.fn.scrollspy=function(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.scrollspy.Constructor=b,a.fn.scrollspy.noConflict=function(){return a.fn.scrollspy=c,this},a(window).on("load",function(){a('[data-spy="scroll"]').each(function(){var b=a(this);b.scrollspy(b.data())})})}(jQuery),+function(a){"use strict";var b=function(b){this.element=a(b)};b.prototype.show=function(){var b=this.element,c=b.closest("ul:not(.dropdown-menu)"),d=b.data("target");if(d||(d=b.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),!b.parent("li").hasClass("active")){var e=c.find(".active:last a")[0],f=a.Event("show.bs.tab",{relatedTarget:e});if(b.trigger(f),!f.isDefaultPrevented()){var g=a(d);this.activate(b.parent("li"),c),this.activate(g,g.parent(),function(){b.trigger({type:"shown.bs.tab",relatedTarget:e})})}}},b.prototype.activate=function(b,c,d){function e(){f.removeClass("active").find("> .dropdown-menu > .active").removeClass("active"),b.addClass("active"),g?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu")&&b.closest("li.dropdown").addClass("active"),d&&d()}var f=c.find("> .active"),g=d&&a.support.transition&&f.hasClass("fade");g?f.one(a.support.transition.end,e).emulateTransitionEnd(150):e(),f.removeClass("in")};var c=a.fn.tab;a.fn.tab=function(c){return this.each(function(){var d=a(this),e=d.data("bs.tab");e||d.data("bs.tab",e=new b(this)),"string"==typeof c&&e[c]()})},a.fn.tab.Constructor=b,a.fn.tab.noConflict=function(){return a.fn.tab=c,this},a(document).on("click.bs.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"]',function(b){b.preventDefault(),a(this).tab("show")})}(jQuery),+function(a){"use strict";var b=function(c,d){this.options=a.extend({},b.DEFAULTS,d),this.$window=a(window).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(c),this.affixed=this.unpin=this.pinnedOffset=null,this.checkPosition()};b.RESET="affix affix-top affix-bottom",b.DEFAULTS={offset:0},b.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(b.RESET).addClass("affix");var a=this.$window.scrollTop(),c=this.$element.offset();return this.pinnedOffset=c.top-a},b.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},b.prototype.checkPosition=function(){if(this.$element.is(":visible")){var c=a(document).height(),d=this.$window.scrollTop(),e=this.$element.offset(),f=this.options.offset,g=f.top,h=f.bottom;"top"==this.affixed&&(e.top+=d),"object"!=typeof f&&(h=g=f),"function"==typeof g&&(g=f.top(this.$element)),"function"==typeof h&&(h=f.bottom(this.$element));var i=null!=this.unpin&&d+this.unpin<=e.top?!1:null!=h&&e.top+this.$element.height()>=c-h?"bottom":null!=g&&g>=d?"top":!1;if(this.affixed!==i){this.unpin&&this.$element.css("top","");var j="affix"+(i?"-"+i:""),k=a.Event(j+".bs.affix");this.$element.trigger(k),k.isDefaultPrevented()||(this.affixed=i,this.unpin="bottom"==i?this.getPinnedOffset():null,this.$element.removeClass(b.RESET).addClass(j).trigger(a.Event(j.replace("affix","affixed"))),"bottom"==i&&this.$element.offset({top:c-h-this.$element.height()}))}}};var c=a.fn.affix;a.fn.affix=function(c){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof c&&c;e||d.data("bs.affix",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.affix.Constructor=b,a.fn.affix.noConflict=function(){return a.fn.affix=c,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var b=a(this),c=b.data();c.offset=c.offset||{},c.offsetBottom&&(c.offset.bottom=c.offsetBottom),c.offsetTop&&(c.offset.top=c.offsetTop),b.affix(c)})})}(jQuery); -------------------------------------------------------------------------------- /src/main/resources/js/main.js: -------------------------------------------------------------------------------- 1 | var tweetHtml = 2 | '
  • \ 3 | \ 4 |
    \ 5 | Now \ 6 |

    __USERNAME__ __TWEET__

    \ 7 |
    \ 8 |
  • '; 9 | 10 | $(document).ready(function() { 11 | var user = getUrlParameter('user'); 12 | var latestTweetsPath = (user && '/users/' + user) || "/all" ; 13 | 14 | if (user) { 15 | $.ajax({ 16 | url: "http://localhost:8080/resources/tweets" + latestTweetsPath 17 | }).then(function (tweets) { 18 | tweets.reverse().forEach(function(tweet) { 19 | appendTweet(tweet); 20 | }); 21 | }); 22 | } 23 | 24 | $("#post-tweet").submit(function(event) { 25 | var json = { 26 | user: { name: $("#user").val() }, 27 | text: $("#tweet").val() 28 | }; 29 | 30 | $.ajax({ 31 | url: 'http://localhost:8080/resources/tweets', 32 | method: 'POST', 33 | contentType: "application/json", 34 | data: JSON.stringify(json) 35 | }); 36 | $("#post-tweet")[0].reset(); 37 | event.preventDefault(); 38 | }); 39 | 40 | var socket = getWebsocket(); 41 | 42 | socket.onmessage = function (msg) { 43 | var tweet = JSON.parse(msg.data); 44 | appendTweet(tweet); 45 | } 46 | }); 47 | 48 | function appendTweet(tweet) { 49 | $("#tweets li:first-child").after(tweetHtml.replace("__USERNAME__", tweet.user.name).replace("__TWEET__", tweet.text)); 50 | } 51 | 52 | function getWebsocket() { 53 | var path = "ws://localhost:8080/ws/tweets/"; 54 | 55 | if (getUrlParameter('user')) { 56 | path += "users/" + getUrlParameter('user'); 57 | } else if (getUrlParameter('hashtag')) { 58 | path += "hashtag/" + getUrlParameter('hashtag'); 59 | } else { 60 | path += "all"; 61 | } 62 | 63 | return new WebSocket(path); 64 | } 65 | 66 | function getUrlParameter(sParam) { 67 | var sPageURL = window.location.search.substring(1); 68 | var sURLVariables = sPageURL.split('&'); 69 | for (var i = 0; i < sURLVariables.length; i++) 70 | { 71 | var sParameterName = sURLVariables[i].split('='); 72 | if (sParameterName[0] == sParam) 73 | { 74 | return sParameterName[1]; 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /src/main/scala/reactive/Main.scala: -------------------------------------------------------------------------------- 1 | package reactive 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.Http 5 | import akka.http.scaladsl.marshalling.ToResponseMarshallable.apply 6 | import akka.http.scaladsl.model.StatusCodes 7 | import akka.http.scaladsl.server.Directive.{addByNameNullaryApply, addDirectiveApply} 8 | import akka.http.scaladsl.server.Directives._ 9 | import akka.http.scaladsl.server.PathMatcher.segmentStringToPathMatcher 10 | import akka.http.scaladsl.server.PathMatchers.Segment 11 | import akka.http.scaladsl.server.Route 12 | import akka.http.scaladsl.server.RouteResult.route2HandlerFlow 13 | import akka.pattern.ask 14 | import akka.stream.ActorMaterializer 15 | import akka.util.Timeout 16 | import reactive.tweets.domain.{Tweet, User} 17 | import reactive.tweets.incoming.TweetActor.{GetLastTen, LastTenResponse} 18 | import reactive.tweets.incoming.TweetActorManager 19 | import reactive.tweets.outgoing.TweetFlow 20 | 21 | import scala.concurrent.ExecutionContext 22 | import scala.concurrent.duration.DurationInt 23 | 24 | object Main extends App with TweetFlow { 25 | implicit val system = ActorSystem("webapi") 26 | implicit val executor = system.dispatcher 27 | implicit val timeout = Timeout(1000.millis) 28 | 29 | implicit val materializer = ActorMaterializer() 30 | val serverBinding = Http().bindAndHandle(interface = "0.0.0.0", port = 8080, handler = mainFlow) 31 | 32 | def mainFlow(implicit system: ActorSystem, timeout: Timeout, executor: ExecutionContext): Route = { 33 | val tweetActorManager = system.actorOf(TweetActorManager.props) 34 | 35 | def getLatestTweetsOfUser = (pathPrefix("users") & path(Segment)) { userName => 36 | complete { 37 | (tweetActorManager ? GetLastTen(User(userName))) 38 | .mapTo[LastTenResponse] 39 | .map(_.lastTen) 40 | } 41 | } 42 | 43 | def tweetsOfUserSocket = (pathPrefix("users") & path(Segment)) { userName => 44 | handleWebsocketMessages(tweetFlowOfUser(userName)) 45 | } 46 | 47 | def allTweetsSocket = path("all") { 48 | handleWebsocketMessages(tweetFlowOfAll) 49 | } 50 | 51 | // TODO Add implementation (Part 2 of tutorial) 52 | def tweetsWithHashTagSocket = ??? 53 | 54 | def addTweet = { 55 | post { 56 | entity(as[Tweet]) { tweet => 57 | complete { 58 | (tweetActorManager ? tweet).map(_ => StatusCodes.NoContent) 59 | } 60 | } 61 | } 62 | } 63 | 64 | // Frontend 65 | def index = (path("") | pathPrefix("index.htm")) { 66 | getFromResource("index.html") 67 | } 68 | def css = (pathPrefix("css") & path(Segment)) { resource => getFromResource(s"css/$resource") } 69 | def fonts = (pathPrefix("fonts") & path(Segment)) { resource => getFromResource(s"fonts/$resource") } 70 | def img = (pathPrefix("img") & path(Segment)) { resource => getFromResource(s"img/$resource") } 71 | def js = (pathPrefix("js") & path(Segment)) { resource => getFromResource(s"js/$resource") } 72 | 73 | get { 74 | index ~ css ~ fonts ~ img ~ js 75 | } ~ 76 | // REST endpoints 77 | pathPrefix("resources") { 78 | pathPrefix("tweets") { 79 | get { 80 | getLatestTweetsOfUser 81 | } ~ 82 | addTweet 83 | } 84 | } ~ 85 | // Websocket endpoints 86 | pathPrefix("ws") { 87 | pathPrefix("tweets") { 88 | get { 89 | allTweetsSocket ~ 90 | tweetsOfUserSocket 91 | // TODO Call hash tag functionality from here (Part 2 of tutorial) 92 | } 93 | } 94 | } 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/main/scala/reactive/tweets/domain/Domain.scala: -------------------------------------------------------------------------------- 1 | package reactive.tweets.domain 2 | 3 | case class Tweet(user: User, text: String) 4 | 5 | case class User(name: String) 6 | -------------------------------------------------------------------------------- /src/main/scala/reactive/tweets/incoming/TweetActor.scala: -------------------------------------------------------------------------------- 1 | package reactive.tweets.incoming 2 | 3 | import akka.actor.{Props, Status, actorRef2Scala} 4 | import akka.persistence.{PersistentActor, SnapshotOffer} 5 | import reactive.tweets.domain.{Tweet, User} 6 | import reactive.tweets.incoming.TweetActor.{GetLastTen, LastTenResponse} 7 | import akka.persistence.SaveSnapshotSuccess 8 | 9 | object TweetActor { 10 | def props(user: User): Props = Props(new TweetActor(user)) 11 | 12 | case class GetLastTen(user: User) 13 | case class LastTenResponse(lastTen: List[Tweet]) 14 | } 15 | 16 | class TweetActor(val user: User) extends PersistentActor { 17 | override def persistenceId = user.name 18 | var latestTweets = List.empty[Tweet] 19 | 20 | override def receiveCommand = { 21 | case tweet: Tweet => 22 | persist(tweet) { event => 23 | sender ! Status.Success 24 | context.system.eventStream.publish(tweet) 25 | addToLatest(tweet) 26 | } 27 | 28 | case GetLastTen(_) => 29 | sender ! LastTenResponse(latestTweets.take(10)) 30 | 31 | case _: SaveSnapshotSuccess => // Ignore 32 | 33 | case msg => 34 | throw new UnsupportedOperationException(s"received unexpected message $msg from ${sender}") 35 | } 36 | 37 | override def receiveRecover = { 38 | case tweet: Tweet => 39 | addToLatest(tweet) 40 | case SnapshotOffer(_, latest: List[Tweet] @unchecked) => 41 | latestTweets = latest 42 | case _ => 43 | } 44 | 45 | private def addToLatest(tweet: Tweet) { 46 | latestTweets = tweet :: latestTweets 47 | 48 | if (latestTweets.length > 100) { 49 | latestTweets = latestTweets.take(10) 50 | saveSnapshot(latestTweets) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/scala/reactive/tweets/incoming/TweetActorManager.scala: -------------------------------------------------------------------------------- 1 | package reactive.tweets.incoming 2 | 3 | import akka.actor.{ Actor, Props } 4 | import reactive.tweets.domain.{ Tweet, User } 5 | import reactive.tweets.incoming.TweetActor.GetLastTen 6 | 7 | class TweetActorManager extends Actor { 8 | 9 | override def receive = { 10 | case tweet: Tweet => forward(tweet, tweet.user.name) 11 | 12 | case lastTen: GetLastTen => forward(lastTen, lastTen.user.name) 13 | 14 | case msg => throw new UnsupportedOperationException(s"received unexpected message $msg from ${sender}") 15 | } 16 | 17 | def forward(message: Any, userName: String) = { 18 | def createTweetActor = context.actorOf(TweetActor.props(User(userName)), userName) 19 | val tweetPublisherActor = context.child(userName).getOrElse(createTweetActor) 20 | tweetPublisherActor forward message 21 | } 22 | } 23 | 24 | object TweetActorManager { 25 | def props: Props = Props[TweetActorManager] 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/reactive/tweets/marshalling/TweetJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | package reactive.tweets.marshalling 2 | 3 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport 4 | import akka.http.scaladsl.marshalling._ 5 | import akka.http.scaladsl.model.ws.{Message, TextMessage} 6 | import akka.http.scaladsl.unmarshalling._ 7 | import akka.stream.Materializer 8 | import reactive.tweets.domain.{Tweet, User} 9 | import spray.json._ 10 | 11 | trait TweetJsonProtocol extends DefaultJsonProtocol { 12 | implicit val userFormat = jsonFormat1(User.apply) 13 | implicit val tweetFormat = jsonFormat2(Tweet.apply) 14 | 15 | implicit val tweetMarshaller: ToEntityMarshaller[Tweet] = SprayJsonSupport.sprayJsonMarshaller[Tweet] 16 | implicit val tweetListMarshaller: ToEntityMarshaller[List[Tweet]] = SprayJsonSupport.sprayJsonMarshaller[List[Tweet]] 17 | implicit def tweetUnmarshaller(implicit materializer: Materializer): FromEntityUnmarshaller[Tweet] = 18 | SprayJsonSupport.sprayJsonUnmarshaller[Tweet] 19 | 20 | def toMessage(tweet: Tweet): Message = TextMessage.Strict(tweet.toJson.compactPrint) 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/reactive/tweets/outgoing/TweetFlow.scala: -------------------------------------------------------------------------------- 1 | package reactive.tweets.outgoing 2 | 3 | import akka.actor.ActorRef 4 | import akka.http.scaladsl.model.ws.Message 5 | import akka.stream.scaladsl._ 6 | import reactive.tweets.domain.Tweet 7 | import reactive.tweets.marshalling.TweetJsonProtocol 8 | 9 | trait TweetFlow extends TweetJsonProtocol { 10 | private val tweetSource: Source[Tweet, ActorRef] = Source.actorPublisher[Tweet](TweetPublisher.props) 11 | type TweetFilter = Tweet => Boolean 12 | 13 | private def tweetFlow(tweetFilter: TweetFilter): Flow[Message, Message, Unit] = 14 | Flow.wrap(Sink.ignore, tweetSource filter tweetFilter map toMessage)(Keep.none) 15 | 16 | def tweetFlowOfUser(userName: String) = tweetFlow(_.user.name.equalsIgnoreCase(userName)) 17 | 18 | def tweetFlowOfAll = tweetFlow(_ => true) 19 | 20 | def tweetFlowWithHashTag(hashTag: String): Flow[Message, Message, Unit] = ??? 21 | } -------------------------------------------------------------------------------- /src/main/scala/reactive/tweets/outgoing/TweetPublisher.scala: -------------------------------------------------------------------------------- 1 | package reactive.tweets.outgoing 2 | 3 | import akka.actor._ 4 | import akka.stream.actor.ActorPublisher 5 | import reactive.tweets.domain.Tweet 6 | 7 | class TweetPublisher extends ActorPublisher[Tweet] { 8 | 9 | override def preStart = { 10 | context.system.eventStream.subscribe(self, classOf[Tweet]) 11 | } 12 | 13 | override def receive = { 14 | case tweet: Tweet => 15 | //We do not send tweets if a client is not reading from the stream fast enough. 16 | if (isActive && totalDemand > 0) 17 | onNext(tweet) 18 | } 19 | 20 | } 21 | 22 | object TweetPublisher { 23 | def props: Props = Props(new TweetPublisher()) 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka{ 2 | log-dead-letters = off 3 | log-dead-letters-during-shutdown = off 4 | 5 | persistence{ 6 | 7 | journal{ 8 | plugin = "akka.persistence.journal.inmem" 9 | } 10 | 11 | snapshot-store{ 12 | plugin = "in-memory-snapshot-store" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/test/scala/reactive/ActorTestUtils.scala: -------------------------------------------------------------------------------- 1 | package reactive 2 | 3 | import scala.reflect.ClassTag 4 | 5 | import org.scalatest.BeforeAndAfterAll 6 | import org.scalatest.FlatSpecLike 7 | import org.scalatest.Matchers 8 | 9 | import akka.actor.ActorSystem 10 | import akka.actor.Status 11 | import akka.testkit.DefaultTimeout 12 | import akka.testkit.ImplicitSender 13 | import akka.testkit.TestKit 14 | 15 | class ActorTestUtils extends TestKit(ActorTestUtils.actorSystem()) 16 | with DefaultTimeout with ImplicitSender 17 | with FlatSpecLike with Matchers with BeforeAndAfterAll { 18 | 19 | override protected def afterAll = shutdown() 20 | 21 | def expectFailure[A <: Exception: ClassTag] = { 22 | expectMsgPF() { 23 | case Status.Failure(e: A) => true 24 | } 25 | } 26 | } 27 | 28 | object ActorTestUtils { 29 | def actorSystem() = ActorSystem("TestKitActorSystem") 30 | } 31 | -------------------------------------------------------------------------------- /src/test/scala/reactive/MainRoutingSpec.scala: -------------------------------------------------------------------------------- 1 | package reactive 2 | 3 | import akka.http.scaladsl.model.StatusCodes.{ NoContent, OK, SwitchingProtocols } 4 | import akka.http.scaladsl.model.headers.{ CustomHeader, Upgrade, UpgradeProtocol } 5 | import akka.http.scaladsl.model.ContentTypes.`application/json` 6 | import akka.http.scaladsl.model.ws.{ Message, UpgradeToWebsocket } 7 | import akka.http.scaladsl.model.{ HttpRequest, HttpResponse, StatusCodes } 8 | import akka.http.scaladsl.testkit.ScalatestRouteTest 9 | import akka.stream.Materializer 10 | import akka.stream.scaladsl.Flow 11 | import akka.util.Timeout 12 | import org.scalatest.{ FlatSpec, Matchers } 13 | import reactive.tweets.domain.{ Tweet, User } 14 | import reactive.tweets.marshalling.TweetJsonProtocol 15 | 16 | import scala.concurrent.duration.DurationInt 17 | 18 | class MainRoutingSpec extends FlatSpec with Matchers with ScalatestRouteTest with TweetJsonProtocol { 19 | implicit val timeout = Timeout(1000.millis) 20 | 21 | "Main" should "serve the index page on /" in { 22 | Get("/") ~> Main.mainFlow ~> check { 23 | status shouldBe OK 24 | } 25 | } 26 | 27 | it should "allow to post a tweet for a user" in { 28 | Post("/resources/tweets", Tweet(User("test"), "Some tweet")) ~> Main.mainFlow ~> check { 29 | status shouldBe NoContent 30 | } 31 | } 32 | 33 | it should "serve tweets of a user on /resources/tweets/users/test" in { 34 | Get("/resources/tweets/users/test") ~> Main.mainFlow ~> check { 35 | status shouldBe OK 36 | contentType shouldBe `application/json` 37 | entityAs[String] should include regex ("Some tweet") 38 | } 39 | } 40 | 41 | it should "handle websocket requests for tweets" in { 42 | Get("/ws/tweets/all") ~> Upgrade(List(UpgradeProtocol("websocket"))) ~> emulateHttpCore ~> Main.mainFlow ~> check { 43 | status shouldEqual SwitchingProtocols 44 | } 45 | } 46 | 47 | it should "handle websocket requests for users" in { 48 | Get("/ws/tweets/users/test") ~> Upgrade(List(UpgradeProtocol("websocket"))) ~> emulateHttpCore ~> Main.mainFlow ~> check { 49 | status shouldEqual SwitchingProtocols 50 | } 51 | } 52 | 53 | /** 54 | * TODO Make this test succeed (Part 2 of tutorial) 55 | */ 56 | it should "handle websocket requests for hash tags" in { 57 | Get("/ws/tweets/hashtag/test") ~> Upgrade(List(UpgradeProtocol("websocket"))) ~> emulateHttpCore ~> Main.mainFlow ~> check { 58 | status shouldEqual SwitchingProtocols 59 | } 60 | } 61 | 62 | /** Only checks for upgrade header and then adds UpgradeToWebsocket mock header */ 63 | private def emulateHttpCore(req: HttpRequest): HttpRequest = 64 | req.header[Upgrade] match { 65 | case Some(upgrade) if upgrade.hasWebsocket => req.copy(headers = req.headers :+ upgradeToWebsocketHeaderMock) 66 | case _ => req 67 | } 68 | 69 | private def upgradeToWebsocketHeaderMock: UpgradeToWebsocket = 70 | new CustomHeader() with UpgradeToWebsocket { 71 | override def requestedProtocols = Nil 72 | override def name = "dummy" 73 | override def value = "dummy" 74 | 75 | override def handleMessages(handlerFlow: Flow[Message, Message, Any], subprotocol: Option[String]): HttpResponse = 76 | HttpResponse(SwitchingProtocols) 77 | } 78 | } -------------------------------------------------------------------------------- /src/test/scala/reactive/tweets/incoming/TweetActorSpec.scala: -------------------------------------------------------------------------------- 1 | package reactive.tweets.incoming 2 | 3 | import akka.actor.Status 4 | import reactive.ActorTestUtils 5 | import reactive.tweets.domain.{ Tweet, User } 6 | import scala.concurrent.duration.DurationInt 7 | import reactive.tweets.incoming.TweetActor.GetLastTen 8 | import reactive.tweets.incoming.TweetActor.LastTenResponse 9 | 10 | class TweetActorSpec extends ActorTestUtils { 11 | 12 | def tweetActorManager = system.actorOf(TweetActorManager.props) 13 | 14 | val tweet = Tweet(User("test"), "Hello World!") 15 | val tweetLatest = Tweet(User("test"), "Hello World! again") 16 | 17 | "The actor manager" should "forward the tweet to the persistent actor" in { 18 | within(500.millis) { 19 | tweetActorManager ! tweet 20 | expectMsg(Status.Success) 21 | expectNoMsg() 22 | } 23 | } 24 | 25 | "The persistent actor" should "broadcast a successfully saved tweet" in { 26 | within(500.millis) { 27 | system.eventStream.subscribe(testActor, classOf[Tweet]) 28 | 29 | tweetActorManager ! tweet 30 | expectMsg(Status.Success) 31 | expectMsg(tweet) 32 | expectNoMsg() 33 | 34 | system.eventStream.unsubscribe(testActor) 35 | } 36 | } 37 | 38 | it should "save the latest tweets" in { 39 | within(500.millis) { 40 | 41 | tweetActorManager ! tweetLatest 42 | expectMsg(Status.Success) 43 | tweetActorManager ! GetLastTen(tweet.user) 44 | 45 | expectMsg(LastTenResponse(List(tweetLatest, tweet, tweet))) 46 | } 47 | } 48 | 49 | it should "save only the latest ten tweets" in { 50 | within(500.millis) { 51 | 52 | for (i <- 1 to 100) yield { 53 | tweetActorManager ! tweetLatest 54 | expectMsg(Status.Success) 55 | } 56 | 57 | tweetActorManager ! GetLastTen(tweet.user) 58 | 59 | expectMsg(LastTenResponse((1 to 10).map(_ => tweetLatest).toList)) 60 | } 61 | } 62 | 63 | it should "recover with the latest messages" in { 64 | within(500.millis) { 65 | 66 | val user = system.actorOf(TweetActor.props(tweet.user)) 67 | system.stop(user) 68 | tweetActorManager ! GetLastTen(tweet.user) 69 | 70 | expectMsg(LastTenResponse((1 to 10).map(_ => tweetLatest).toList)) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/test/scala/reactive/tweets/outgoing/TweetFlowSpec.scala: -------------------------------------------------------------------------------- 1 | package reactive.tweets.outgoing 2 | 3 | import akka.http.scaladsl.model.ws.{Message, TextMessage} 4 | import akka.stream.ActorMaterializer 5 | import akka.stream.testkit.scaladsl.{TestSink, TestSource} 6 | import reactive.ActorTestUtils 7 | import reactive.tweets.domain.{Tweet, User} 8 | 9 | import scala.concurrent.duration.DurationInt 10 | 11 | class TweetFlowSpec extends ActorTestUtils with TweetFlow { 12 | implicit val materializer = ActorMaterializer() 13 | private val noMessageTimeout = 100.millis 14 | 15 | "The flow for tweets" should "ignore incoming messages" in { 16 | val sut = tweetFlowOfAll.runWith(TestSource.probe[Message], TestSink.probe[Message]) 17 | val (mockSource, mockSink) = sut 18 | 19 | mockSource.sendNext(TextMessage.Strict("Should be ignored")) 20 | 21 | mockSink.request(1) 22 | mockSink.expectNoMsg(noMessageTimeout) 23 | } 24 | 25 | it should "forward all tweets published to the event stream" in { 26 | val sut = tweetFlowOfAll.runWith(TestSource.probe[Message], TestSink.probe[Message]) 27 | val (_, mockSink) = sut 28 | 29 | val tweet = Tweet(User("test"), "Hello World!") 30 | system.eventStream.publish(tweet) 31 | 32 | mockSink.request(1) 33 | mockSink.expectNext() 34 | mockSink.expectNoMsg(noMessageTimeout) 35 | } 36 | 37 | "The flow for tweets with user" should "only forward tweets with matching user name" in { 38 | val userName = "test" 39 | val sut = tweetFlowOfUser(userName).runWith(TestSource.probe[Message], TestSink.probe[Message]) 40 | val (_, mockSink) = sut 41 | 42 | val tweet = Tweet(User(userName), s"Hello World!") 43 | system.eventStream.publish(tweet) 44 | 45 | mockSink.request(1) 46 | mockSink.expectNext() 47 | mockSink.expectNoMsg(noMessageTimeout) 48 | } 49 | 50 | it should " not forward tweets form users with a different name" in { 51 | val sut = tweetFlowOfUser("different").runWith(TestSource.probe[Message], TestSink.probe[Message]) 52 | val (_, mockSink) = sut 53 | 54 | val tweet = Tweet(User("test"), "Hello World!") 55 | system.eventStream.publish(tweet) 56 | 57 | mockSink.request(1) 58 | mockSink.expectNoMsg(noMessageTimeout) 59 | } 60 | 61 | /** 62 | * TODO Make this test succeed (Part 2 of tutorial) 63 | */ 64 | "The flow for tweets with hash tag" should "only forward tweets with matching hash tag" in { 65 | val hashTag = "shouldMatch" 66 | val sut = tweetFlowWithHashTag(hashTag).runWith(TestSource.probe[Message], TestSink.probe[Message]) 67 | val (_, mockSink) = sut 68 | 69 | val tweet = Tweet(User("test"), s"Hello World! #${hashTag}") 70 | system.eventStream.publish(tweet) 71 | 72 | mockSink.request(1) 73 | mockSink.expectNext() 74 | mockSink.expectNoMsg(noMessageTimeout) 75 | } 76 | 77 | it should "not forward tweets without matching hash tag" in { 78 | val sut = tweetFlowWithHashTag("shouldNotMatch").runWith(TestSource.probe[Message], TestSink.probe[Message]) 79 | val (_, mockSink) = sut 80 | 81 | val tweet = Tweet(User("test"), "Hello World! #otherhashtag") 82 | system.eventStream.publish(tweet) 83 | 84 | mockSink.request(1) 85 | mockSink.expectNoMsg(noMessageTimeout) 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /tutorial/img/flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/tutorial/img/flow.png -------------------------------------------------------------------------------- /tutorial/img/ordina-logo-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/tutorial/img/ordina-logo-big.png -------------------------------------------------------------------------------- /tutorial/img/ordina-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/tutorial/img/ordina-logo-small.png -------------------------------------------------------------------------------- /tutorial/img/ready-set-go.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Technologies/akka-http-websocket-activator-template/4153b17c16df75656db406b8a44b1dc9fed4d522/tutorial/img/ready-set-go.jpg -------------------------------------------------------------------------------- /tutorial/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Akka HTTP to Persistent Actor to Akka Stream to Websocket 4 | 5 | 6 |
    7 |

    Getting started with this template

    8 | 9 |

    10 | In this template you will learn some of the basics of 11 | 12 |

    18 | 19 |

    20 |

    21 | This template consists of two parts, a theoretical and a hands-on part. 22 | In the theoretical part you will learn about the techniques mentioned above by means of a fully working example. 23 | In the hands-on part you will test-drive your own flow. 24 | So don't mind the failing tests for now, you will fix them soon enough ;) 25 |

    26 |

    27 | Next: An Overview 28 |

    29 |
    30 | 31 |
    32 |

    Part 1: An Overview

    33 | 34 |

    35 | In this tutorial we will use a simple Twitter like application to demonstrate the different techniques. You can 36 | post a tweet as a user and monitor a reactive timeline that changes whenever a new tweet is posted. We 37 | simplified 38 | the functionality a bit to keep the focus on the essentials. 39 |

    40 | 41 |

    Posting tweets

    42 | 43 |

    44 | You can post tweets via a simple REST endpoint. A tweet consists of a username and the message. We use 45 | Akka Persistence to keep track of the tweets for each user and be able to recover the 46 | timeline for a user after a restart of the application. All incoming tweets are put on the event bus that comes 47 | with Akka, after they are persisted. 48 |

    49 | 50 |

    Streaming tweets via Websockets

    51 | 52 |

    53 | On the other side an actor is subscribed to the event bus. This means the actor will be notified every time a 54 | new tweet is put on the event bus. Since Akka implements the 56 | Reactive Streams SPI we can turn the actor into a Publisher which we can then turn into a 57 | Source so we can use it in an Akka Http Flow. 58 |

    59 | 60 |

    The same story but now in a nice diagram

    61 | The complete flow from posting a tweet to it being pushed on the websocket 62 | The complete flow from posting a tweet to it being pushed on the websocket 63 |

    64 | Next: Posting a Tweet 65 |

    66 |
    67 | 68 |
    69 |

    Part 1: Posting a Tweet

    70 | 71 |

    72 | We start in Main.scala which defines the 73 | routes. The route (in this case a REST endpoint) for posting a tweet is defined in the following method: 74 | 75 |

    def addTweet = {
     76 |   post {
     77 |     entity(as[Tweet]) { tweet =>
     78 |       complete {
     79 |         (system.actorOf(TweetActorManager.props) ? tweet).map(_ => StatusCodes.NoContent)
     80 |       }
     81 |     }
     82 |   }
     83 | }
    84 | 85 | The interesting part resides within the complete directive. There we create an actor (TweetActorManager) 88 | and send it the tweet. For simplicity we ignore failure and transform a successful completion to an appropriate 89 | HTTP status code. 90 |

    91 |

    92 | Next: Using Akka Persistence 93 |

    94 |
    95 | 96 |
    97 |

    Part 1: Using Akka Persistence

    98 | 99 |

    Forwarding tweets to persistent actors

    100 | 101 |

    102 | All posted tweets end up at the TweetActorManager. 104 | This actor forwards the tweet to a persistent actor based on the user that posted the tweet. Because we 105 | want to keep track of the last tweets of a user we associate each user with a dedicated persistent actor. 106 | The job of the TweetActorManager 107 | is to ensure the tweets end up at the correct persistent actor. 108 |

    109 | 110 |

    Persisting and recovering tweets

    111 | 112 |

    113 | It is the job of the TweetActor 114 | to persist the tweet and put it on the event bus. It also knows how to recover the tweets for a user after a 115 | system failure. 116 | 117 |

    case tweet: Tweet =>
    118 |   persist(tweet) { event =>
    119 |     sender() ! Status.Success
    120 |     context.system.eventStream.publish(tweet)
    121 |   }
    122 | 123 | After successful storage the callback is triggered. We send out a Success message and publish the tweet 124 | on the event bus. 125 |

    126 |

    127 | Next: Reactive Streams 128 |

    129 |
    130 | 131 |
    132 |

    Part 1: Reactive Streams

    133 | 134 |

    135 | If we want to stream the tweets via websockets to our users we need to get them from somewhere. That place is 136 | the 137 | event bus. However, we need to perform some transformations before Akka Http is able to use the 138 | bus as a source. The first step is hook up an actor to the event bus and expose that actor as a 139 | Publisher. 140 |

    141 | 142 |

    143 | The Reactive 144 | Streams Specification defines a Publisher as
    145 |

    146 | 147 |
    a provider of a potentially unbounded number of sequenced elements, publishing them according to the 148 | demand received from its Subscriber(s).
    149 | 150 |

    151 | Akka implements the Reactive Streams SPI and fortunately the code to turn an actor into a Publisher is pretty 152 | straightforward: 153 | we just have to extend ActorPublisher which exposes (among others) the onNext which we 154 | can use to push a new tweet to our subscribers when one arrives. Only thing left is to subscribe to the event 155 | bus when the 156 | actor is started. The code can be found in TweetPublisher.scala 158 | and should look like this: 159 |

    160 | 161 |
    class TweetPublisher extends ActorPublisher[Tweet] {
    162 | 
    163 |   override def preStart = {
    164 |     context.system.eventStream.subscribe(self, classOf[Tweet])
    165 |   }
    166 | 
    167 |   override def receive = {
    168 |     case tweet: Tweet => onNext(tweet)
    169 |       //We do not send tweets if a client is not reading from the stream fast enough.
    170 |       if (isActive && totalDemand > 0)
    171 |         onNext(tweet)
    172 |   }
    173 | 
    174 | }
    175 | 176 |

    177 | note: You should not call onNext if the stream is not active or there is no demand.
    178 | Next: Running the Flow 179 |

    180 |
    181 |
    182 |

    Part 1: Running the Flow

    183 | 184 |

    185 | In the previous section we described how to define an actor as a Reactive Streams Publisher. To start streaming 186 | we still need to instantiate the actor and - in one go - transform it into a Source. This is done in TweetFlow.scala. 188 | 189 |

    private val tweetSource: Source[Tweet, ActorRef] =
    190 |         Source.actorPublisher[Tweet](TweetPublisher.props)
    191 | 192 | Now we've got our hands on a proper Source we can use it to construct a Flow: 193 | 194 |
    def tweetFlow(tweetFilter: TweetFilter): Flow[Message, Message, Unit] =
    195 |             Flow.wrap(Sink.ignore, tweetSource filter tweetFilter map toMessage)(Keep.none)
    196 | 197 | Note: ignore the tweetFilter for now, this is just a convenience parameter to be able to construct specific streams, 198 | like the stream of tweets for a specific user, or the stream of tweets that contain a specific hash tag. 199 |

    200 | 201 |

    202 | We are only interested in one way communication with our Websocket. We push new tweets to the client on their 203 | arrival. Theoretically we could also receive messages over the same channel from the client. We just ignore 204 | those. 205 | So what does this look like in terms of a Flow? 206 |

    207 | 208 |
    209 |         +-----------------------------+
    210 |         | Our Websocket Flow          |
    211 |         |                             |
    212 |         |  +--------+      +------+   |
    213 |         |   \       |      |       \  |
    214 |     In ~~>   | SINK |      | SOURCE |~~> Out
    215 |         |   /       |      |       /  |
    216 |         |  +--------+      +------+   |
    217 |         +-----------------------------+
    218 |     
    219 | 220 |

    221 | The important thing to note is that the inner parts of the Flow (the Sink and the Source) have no connection 222 | with each other. Incoming messages from the Websocket are ignored by routing them to the Ignore Sink 223 | while on the other hand the outgoing channel will be serviced by our transformed actor that is listening to the 224 | event bus: 225 | 226 |

    Flow.wrap(Sink.ignore, tweetSource filter tweetFilter map toMessage)(Keep.none)
    227 |

    228 |

    229 | Next: The Websocket 230 |

    231 |
    232 | 233 |
    234 |

    Part 1: The Websocket

    235 | 236 |

    237 | So we have a nice Flow, the next step is plugging it into Akka Http.
    238 | The most notable part is the handleWebsocketMessages directive, which comes out of the box with Akka Http. 239 |

    def tweetsOfUserSocket = (pathPrefix("users") & path(Segment)) { userName =>
    240 |   handleWebsocketMessages(tweetFlowOfUser(userName))
    241 | }
    242 | 243 | The part below describes the path at which the Websocket for this Flow can be found: /ws/tweets/users/[userName]. 244 | 245 |
    pathPrefix("ws") {
    246 |   pathPrefix("tweets") {
    247 |     get {
    248 |       tweetsOfUserSocket
    249 |     }
    250 |   }
    251 | }
    252 | Now we have explained all the pieces you can run the application and see everything in action.
    253 |

    254 |

    255 | In the next part you will extend the application with a flow containing only tweets with a certain hash tag. 256 |

    257 |

    258 | Next: Building your own Flow 259 |

    260 |
    261 |
    262 |

    Part 2: Building your own Flow

    263 |

    Implementing a Websocket for tweets with a certain hash tag

    264 |

    265 | It's time to get your hands dirty. Tweets for a user are nice to have, but having a different channel to only follow 266 | tweets with a certain hash tag would be even nicer, don't you agree?

    267 |

    268 | In this second part we will create this feature step by step. Each step will have a failing test and an unimplemented 269 | method to help you on the way. 270 |

    271 | Go! 272 |

    273 | Next: Creating a New Flow 274 |

    275 |
    276 |
    277 |

    Part 2: Creating a New Flow

    278 | 279 |

    280 | Before we can expose a stream of filtered tweets on hash tag to the outside world, we'll first need something to 281 | actually expose. This basically comes down to the fact that we need an extra flow, besides the flow for all tweets 282 | and the flow for tweets of a user. Let's start by looking at the failing tests in TweetFlowSpec.scala. 284 |

    285 |
    "The flow for tweets with hash tag" should "only forward tweets with matching hash tag" in {
    286 |   val hashTag = "shouldMatch"
    287 |   val sut = tweetFlowWithHashTag(hashTag).runWith(TestSource.probe[Message], TestSink.probe[Message])
    288 |   val (_, mockSink) = sut
    289 | 
    290 |   val tweet = Tweet(User("test"), s"Hello World! #${hashTag}")
    291 |   system.eventStream.publish(tweet)
    292 | 
    293 |   mockSink.request(1)
    294 |   mockSink.expectNext()
    295 |   mockSink.expectNoMsg(noMessageTimeout)
    296 | }
    297 |

    298 | We mock out the Source and Sink to be able to control the input and output for a flow, 299 | which makes testing a lot easier. We expect, given a flow that filters on hash tag X and putting a tweet on the 300 | event bus with that same tag X, that we will actually receive that tweet in our mock sink. Because we only put 301 | one tweet on the bus, we expect no more message after our first request. 302 |

    303 |

    304 | There is another test to test the opposite scenario (putting a tweet on the bus with a non matching tag and 305 | expecting not to see it arrive), but we'll not discuss since it is analogous to the test described above. Open 306 | TweetFlow.scala and 307 | add an implementation on the spot marked with ???. Of course your implementation should satisfy both 308 | failing tests in TweetFlowSpec.scala. 310 |

    311 |

    312 | Next: The Solution for this challenge (spoiler alert!) 313 |

    314 |
    315 |
    316 |

    Part 2: Solution New Flow

    317 |

    Drumroll...

    318 | def tweetFlowWithHashTag(hashTag: String) = tweetFlow(_.text contains hashTag) 319 |

    320 | Your method should look something like this. Yes, it was that easy. Sorry. 321 |

    322 |

    323 | Now we have a Flow we can expose it via a new Websocket route. 324 |

    325 |

    326 | Next: Hash Tag Websocket 327 |

    328 |
    329 |
    330 |

    Part 2: Hash Tag Websocket

    331 |

    Let's take a look at the failing test in MainRoutingSpec.scala first.

    333 |
    it should "handle websocket requests for hash tags" in {
    334 |   Get("/ws/tweets/hashtag/test") ~> Upgrade(List(UpgradeProtocol("websocket"))) ~> emulateHttpCore ~> Main.mainFlow ~> check {
    335 |     status shouldEqual SwitchingProtocols
    336 |   }
    337 | }
    338 | 
    339 |

    340 | The test should make clear on which path it expects the stream to be exposed. You can implement the functionality in 341 | Main.scala. Look for the ??? 342 | and comments with TODO. 343 |

    344 |

    345 | Next: The Solution for this challenge (spoiler alert!) 346 |

    347 |
    348 |
    349 |

    Part 2: Solution Websocket

    350 |
    def tweetsWithHashTagSocket = (pathPrefix("hashtag") & path(Segment)) { hashTag =>
    351 |   handleWebsocketMessages(tweetFlowWithHashTag(hashTag))
    352 | }
    353 | 
    354 |

    We can then use this method in our main routing declaration:

    355 |
    // Websocket endpoints
    356 | pathPrefix("ws") {
    357 |   pathPrefix("tweets") {
    358 |     get {
    359 |       allTweetsSocket ~
    360 |       tweetsOfUserSocket ~
    361 |       tweetsWithHashTagSocket
    362 |     }
    363 |   }
    364 | }
    365 | 
    366 |

    367 | Et voilà! We just extended our application with a nice flow to monitor tweet streams for certain hash tags. And 368 | all that with only a few lines of extra code. Run the application and enjoy the fruits 369 | of your hard labour. 370 |

    371 |

    372 | Next: Time to wrap it all up 373 |

    374 |
    375 |
    376 |

    Conclusion

    377 |

    378 | In this Activator template we explored the following techniques: 379 | 380 |

    386 | 387 | 388 | We hope you enjoyed this tutorial. Feedback and suggestions are always appreciated.
    389 | So if you want to get in touch 390 | contact us @ Github. 391 |

    392 |

    393 | Next: Not tired yet? 394 |

    395 |
    396 |
    397 |

    Further Explorations

    398 |

    399 | So, still not tired? Try implementing some (or all) of the following functionality: 400 | 401 |

    407 | 408 |

    409 |
    410 | 411 | 412 | --------------------------------------------------------------------------------