├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── docs └── pagelets-tree-vis.png ├── project ├── build.properties └── plugins.sbt ├── src ├── main │ └── scala │ │ └── org │ │ └── splink │ │ └── pagelets │ │ ├── Binders.scala │ │ ├── Exceptions.scala │ │ ├── FunctionMacros.scala │ │ ├── LeafBuilder.scala │ │ ├── PageBuilder.scala │ │ ├── Pagelet.scala │ │ ├── PageletActionBuilder.scala │ │ ├── PageletActions.scala │ │ ├── PageletId.scala │ │ ├── PageletModule.scala │ │ ├── PageletResult.scala │ │ ├── Pagelets.scala │ │ ├── PageletsAssembly.scala │ │ ├── RequestId.scala │ │ ├── Resource.scala │ │ ├── ResourceActions.scala │ │ ├── Resources.scala │ │ ├── TreeTools.scala │ │ ├── Visualizer.scala │ │ └── twirl │ │ ├── HtmlStream.scala │ │ └── TwirlCombiners.scala └── test │ ├── resources │ ├── logback-test.xml │ └── public │ │ ├── a.css │ │ ├── a.js │ │ └── b.js │ └── scala │ ├── helpers │ └── FutureHelper.scala │ └── org │ └── splink │ └── pagelets │ ├── BindersTest.scala │ ├── FunctionMacrosTest.scala │ ├── LeafBuilderTest.scala │ ├── PageBuilderTest.scala │ ├── PageletActionBuilderTest.scala │ ├── PageletActionsTest.scala │ ├── PageletTest.scala │ ├── RequestIdTest.scala │ ├── ResourceActionsTest.scala │ ├── ResourceTest.scala │ ├── ResourcesTest.scala │ ├── TreeToolsTest.scala │ └── VisualizerTest.scala └── version.sbt /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | /.idea 3 | /.idea_modules 4 | /.classpath 5 | /.project 6 | /.settings 7 | release-steps 8 | *.iml 9 | *.sc 10 | .bsp 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | scala: 4 | - 2.13.1 5 | 6 | jdk: 7 | - openjdk14 8 | 9 | cache: 10 | directories: 11 | - $HOME/.ivy2/cache 12 | - $HOME/.sbt/boot/ 13 | before_cache: 14 | - find $HOME/.ivy2 -name "ivydata-*.properties" -delete 15 | - find $HOME/.sbt -name "*.lock" -delete 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/splink/pagelets.svg?branch=master)](https://travis-ci.org/splink/pagelets) 2 | 3 | # Pagelets 4 | A Module for the Play Framework to build modular applications in an elegant and concise manner. 5 | 6 | Check out the [sample project](https://github.com/splink/pagelets-seed) to see a sample application based on Play Pagelets. 7 | 8 | ### Idea 9 | The idea behind the Pagelets Module is to split a web page into small, composable units. Such a unit is called a pagelet. 10 | In terms of the Play Framework a pagelet is just a simple Action[AnyContent]. That means that a pagelet is basically a (small) 11 | web page. Pagelets can be arranged in a page tree. So, if a user requests a page, the page is constructed according to it's page tree. It is also possible to serve any part of the tree down to a single pagelet individually. 12 | The ordinary pagelet consists of a view, resources (JavaScript, Css), a controller action and a service to fetch data. 13 | 14 | ![Pagelets](docs//pagelets-tree-vis.png) 15 | 16 | Pagelets are particularly useful if you want to serve tailor-made pages to your visitors. For instance you can easily 17 | serve a slightly different page to users from different countries (i18n), or perform A/B testing, or fine-tune the page 18 | based on the user (logged-in, gender, other preferences, ...). 19 | 20 | Pagelets comes in two flavours: 21 | *Async* and *Streaming*. *Async* composes the complete page on the server side and sends it back to the 22 | client, as soon as all pagelets are complete. *Streaming* on the other hand, begins to send the page immediately to the 23 | client and pagelets appear sequentially as soon as they complete. 24 | 25 | ### Traits 26 | - **composable**: multiple pagelets can be composed into a page. A page is just a tree of pagelets. Any part of the pagelet tree can be served to the user. 27 | - **resilient**: if a pagelet fails, a fallback is served. Other pagelets are not affected by the failure of one or more pagelets. 28 | - **simple**: to create a pagelet is simple compared to a whole page, because of its limited scope. To compose a page from pagelets is simple. 29 | - **modular**: any pagelet can be easily swapped with another pagelet, removed or added to a page at runtime. 30 | 31 | 32 | Pagelets are non invasive and not opinionated: You can stick to your code style and apply the patterns you prefer. Use your favorite dependency injection mechanism and template engine. You don't need to apply the goodness of pagelets everywhere, only employ pagelets where you need them. Pagelets also do not introduce additional dependencies to your project. 33 | 34 | ### Quickstart 35 | To get the idea how Pagelets look in code, read on and check out the [play pagelets seed project](https://github.com/splink/pagelets-seed) afterwards. 36 | > The Pagelets Module depends on the Play Framework. 37 | 38 | Add the following lines to your build.sbt file: 39 | 40 | #### Play 2.8 (Scala 2.12 | Scala 2.13) 41 | ~~~scala 42 | libraryDependencies += "org.splink" %% "pagelets" % "0.0.11 43 | ~~~ 44 | 45 | ##### For older Play/Scala versions: 46 | ###### Play 2.5 (Scala 2.11) 47 | ~~~scala 48 | libraryDependencies += "org.splink" %% "pagelets" % "0.0.3 49 | ~~~ 50 | 51 | ###### Play 2.6 (Scala 2.11 | Scala 2.12) 52 | ~~~scala 53 | libraryDependencies += "org.splink" %% "pagelets" % "0.0.8 54 | ~~~ 55 | 56 | ~~~scala 57 | routesImport += "org.splink.pagelets.Binders._" 58 | ~~~ 59 | 60 | If you want to use streaming, you will also need: 61 | ~~~scala 62 | TwirlKeys.templateFormats ++= Map("stream" -> "org.splink.pagelets.twirl.HtmlStreamFormat") 63 | TwirlKeys.templateImports ++= Vector("org.splink.pagelets.twirl.HtmlStream", "org.splink.pagelets.twirl.HtmlStreamFormat") 64 | ~~~ 65 | this adds streaming capabilities to the Twirl template engine. To use the streaming template format, you must name your 66 | templates *name.scala.stream* instead of *name.scala.html* 67 | 68 | Now add the following line to your application.conf file, to enable the Pagelets module: 69 | ~~~ 70 | play.modules.enabled += org.splink.pagelets.pageletModule 71 | ~~~ 72 | Create a standard Play controller and inject a *Pagelets* instance. In this example Guice is used as DI framework, but 73 | any DI mechanism works. 74 | ~~~scala 75 | @Singleton 76 | class HomeController @Inject()(pagelets: Pagelets)(implicit m: Materializer, e: Environment) extends InjectedController 77 | ~~~ 78 | 79 | Bring pagelets into scope 80 | ~~~scala 81 | import pagelets._ 82 | ~~~ 83 | 84 | To use the Play's Twirl template engine, import TwirlConversions 85 | ~~~scala 86 | import org.splink.pagelets.twirl.TwirlCombiners._ 87 | ~~~ 88 | To use Streaming, additionally import HtmlStreamOps 89 | ~~~scala 90 | import org.splink.pagelets.twirl.HtmlStreamOps._ 91 | ~~~ 92 | 93 | Now create the main template inside the *views* folder. 94 | Name the file *wrapper.scala.html* or, if you want to use streaming, name it *wrapper.scala.stream* 95 | ~~~scala 96 | @(resourceRoute: String => Call)(page: org.splink.pagelets.Page) 97 | 98 | 99 | 100 | 101 | @page.head.title 102 | 103 | 104 | 105 | @page.head.metaTags.map { tag => 106 | 107 | } 108 | 109 | @page.head.css.map { css => 110 | 111 | } 112 | 113 | 114 | @page.head.js.map { js => 115 | 116 | } 117 | 118 | 119 | @Html(page.body) 120 | 121 | @page.js.map { js => 122 | 123 | } 124 | 125 | 126 | ~~~ 127 | The main template receives a resource route which is needed to reference the JavaScript and Css resources for the page. 128 | The template is also provided with a *Page* instance which contains all parts necessary to render the page: HTML body, 129 | JavaScript, Css and Meta Tags. 130 | 131 | Create a simple pagelet template inside the views folder: 132 | ~~~scala 133 | @(name: String) 134 |
@name
135 | ~~~ 136 | 137 | Create a simple pagelet inside the controller: 138 | ~~~scala 139 | def pagelet(name: String)() = Action { 140 | Ok(views.html.pagelet(name)) 141 | } 142 | ~~~ 143 | 144 | Define the page composition: 145 | ~~~scala 146 | def tree(r: RequestHeader) = { 147 | val tree = Tree("root".id, Seq( 148 | Leaf("header".id, pagelet("header") _).withJavascript(Javascript("header.min.js")).setMandatory(true), 149 | Tree("content".id, Seq( 150 | Leaf("carousel".id, pagelet("carousel") _).withFallback(pagelet("Carousel") _).withCss(Css("carousel.min.css")), 151 | Leaf("text".id, pagelet("text") _).withFallback(pagelet("Text") _) 152 | )), 153 | Leaf("footer".id, pagelet("footer") _).withCss(Css("footer.min.css")) 154 | )) 155 | 156 | if(messagesApi.preferred(r).lang.language == "de") tree.skip("carousel".id) else tree 157 | } 158 | ~~~ 159 | There are 2 different kinds of pagelets: Leaf pagelets and Tree pagelets. A Leaf pagelet references an actual Action, while 160 | a Tree pagelet combines its children into one. When a request arrives, the tree of pagelets is constructed. 161 | All Leaf pagelets are executed in parallel and as soon a the children of a Tree pagelet complete, they are combined. 162 | This process continues, until just the root pagelet remains. 163 | 164 | Resources and fallbacks can be defined per pagelet. If a pagelet fails to render, its fallback pagelet is rendered. 165 | Resources are assembled and combined by type and references are later provided to the main template. 166 | 167 | The *skip* and *replace* operations are available on instances of *Tree*. They allow to change the tree at runtime. 168 | For instance, the tree can be changed based on the language of an incoming request. Note that the resources for the skipped 169 | pagelet are also excluded. If the request language is "de", the carousel pagelet and all its resource dependencies are 170 | left out. 171 | 172 | In this example the header pagelet is declared as mandatory, so if the header fails, the user is redirected to an (error) 173 | page. Note that Tree pagelets can't fail or depend on resources. 174 | 175 | The carousel pagelet depends on *carousel.min.css* and the footer pagelet depends on *footer.min.css*. If the tree is 176 | constructed, both *carousel.min.css* and *footer.min.css* are concatenated into one file whose name is the fingerprint of 177 | its contents. This sole Css file which consists of carousel and footer styles is then served under its fingerprint. 178 | 179 | 180 | Now add an index Action to the controller to render the complete page. 181 | If you want to use *async*, add: 182 | ~~~scala 183 | def index = PageAction.async(routes.HomeController.errorPage)(_ => "Page Title", tree) { (request, page) => 184 | views.html.wrapper(routes.HomeController.resourceFor)(page) 185 | } 186 | ~~~ 187 | 188 | If you prefer to use *streaming*, add: 189 | ~~~scala 190 | def index = PageAction.stream(_ => "Page Title", tree) { (request, page) => 191 | views.html.wrapper(routes.HomeController.resourceFor)(page) 192 | } 193 | ~~~ 194 | 195 | Both flavours require the page title, the pagelet tree configuration and a function which receives the request and page 196 | as arguments. In the *async* case the function must return a *Writeable* and in the *streaming* case a 197 | *Source[Writeable,_]*. A *Writeable* is just a type class which is capable of transforming the wrapped class eventually to a 198 | HTTP response. 199 | *errorPage* is only required in the *async* case. It is called, if a mandatory pagelet and its fallback fail to render. 200 | The *streaming* case can't redirect to another page in case some mandatory pagelet failed, because at the time, the 201 | pagelet fails, parts of the page are already streaming to the client, thus it's too late. 202 | 203 | 204 | Finally add the route to conf/routes 205 | ~~~scala 206 | GET / controllers.HomeController.index 207 | GET /resource/:fingerprint controllers.HomeController.resourceFor(fingerprint: String) 208 | ~~~ 209 | 210 | ### Details 211 | 212 | #### Advantages 213 | - Resilient: if one part of the page fails, the other pagelets remain unaffected. A fallback can be defined per pagelet. 214 | If a fallback fails, the pagelet is simply left out. If a pagelet is declared as mandatory and its fallback also fails, 215 | the request is redirected to a configurable error page. 216 | 217 | - Modular: a pagelet is an isolated and independent unit. Assets like JavaScript and Stylesheets are defined on a per pagelet 218 | basis so a pagelet is completely autonomous. A pagelet can be easily reused on any page. 219 | 220 | - Flexible: a page can be composed with very little code, and the composition can be changed at runtime. Specific 221 | pagelets can be replaced with others, removed or new pagelets can be added anywhere in the page with just a line of code. 222 | This is quite handy to conduct A/B tests or to serve a different page based on the user properties like locale, user-role, ... 223 | 224 | - Simple: to create a pagelet is much simpler then to create a complete page, because the scope of a pagelet is small. 225 | The composition of a page from pagelets is just a bit of configuration code and thus also simple. So all steps 226 | required to build a page are simple. 227 | 228 | - Logs: Detailed logs help to gain useful insights on the performance and to find bottlenecks quickly. 229 | 230 | - Performant #1: all pagelets in a page tree are executed in parallel, so splitting a page into paglets induces no 231 | perceptible overhead. 232 | 233 | - Performant #2: Resources are automatically concatenated and hashed as well as served with far future expiration dates. 234 | Therefore browsers need to make only few requests, and - as long as the resources haven't changed - can pull them from 235 | the local cache. 236 | 237 | - Performant #3: A page can optionally be streamed which effectively reduces the time to first byte to milliseconds and 238 | enables the browser to start loading resources immediately. 239 | 240 | - Separation of concerns: by using pagelets, you automagically end up with a clean and flexible application design. 241 | 242 | 243 | #### Fallbacks 244 | Each pagelet can define a fallback. A fallback is just another pagelet. If the main pagelet fails, its fallback is executed. 245 | If the fallback fails too, then the pagelet is simply left out. But if the pagelet was declared mandatory, then the 246 | request is redirected to another (error) page. If the main pagelet has no fallback and fails, it's left out - unless 247 | the pagelet was declared mandatory. 248 | 249 | #### Resources 250 | All resources declared by the pagelets of a page (JavaScript, Css) are de-duplicated, aggregated and combined during the construction of 251 | the page. A hash is then computed for each combined resource type. Correspondingly, *script* and *link* tags which reference 252 | the combined resources by their hash are injected into the page. These resources are served with far future expiration dates. 253 | So, if the resources haven't changed, browsers can just pull them from the cache. As soon as the resources change, browsers 254 | are presented with a fresh hash value und thus fetch the new resources. This reduces the amount of requests a browser has 255 | to make to render a page to a bare minimum. This system also makes sure that only the resources which are actually needed 256 | on a page are served. 257 | 258 | #### Cookies & Meta Tags 259 | Each pagelet can set Cookies and Meta Tags. Just as with the resources, Cookies and Meta Tags are de-duplicated, aggregated 260 | and combined during the construction of the page. 261 | 262 | #### Async vs. Streaming 263 | 264 | ##### Async 265 | When a page is rendered in *async* mode, all pagelets are rendered and then assembled on the server into the final page. 266 | Once complete, the complete page is sent to the client. 267 | 268 | ##### Streaming 269 | In *streaming* mode, the page is streamed immediately to the client. As soon the next pagelet is ready, the pagelet is 270 | streamed to the client. This is repeated until all pagelets have been streamed. 271 | Streaming seems quite advantageous because the client receives the first parts of the page immediately. Within these first 272 | parts is the HTML head, which includes references to the JavaScript and Css which is needed to render the page. This means 273 | that the browser can start loading external resources while the HTML is still streaming. This parallelization reduces the 274 | overall load time of the page. But even more perceptible is the extremely short amount of time until the first byte is 275 | received by the client, it takes only a few milliseconds. So from the perspective of the user, the page appears 276 | immediately and completes rendering progressively. 277 | 278 | But there are also downsides to the *streaming* approach: 279 | - HTTP Headers are sent first. As the headers contain the HTTP Status Code, the code is always set to 200/Ok, even though 280 | at the time the header is constructed, it is too early to safely assume that the page can be rendered correctly. So you 281 | need to make sure that you have appropriate fallbacks in place in case a pagelet fails to render. 282 | - If a page is cached, the Cache will certainly cache a page with a status code of 200/Ok. This means that fallbacks might 283 | end up being cached. 284 | - Only non-httpOnly Cookies can be set. Each pagelet can set Cookies. But only when all pagelets are complete, the Cookies 285 | to set are all present. So, it's simply not possible to set the Cookies as usual via HTTP headers, because the HTTP headers 286 | are sent first to the client. So Cookies are set with a piece of Javascript code at the end of the Html body. As setting 287 | Cookies relies on JavaScript, the Cookies can't be Http-only. 288 | 289 | ##### When to choose streaming 290 | Choose the *streaming* if: 291 | - all users have JavaScript enabled or the page does not use Cookies 292 | - the page does not rely on httpOnly Cookies 293 | - pages are not cached or it's very unlikely that some pagelets fail or it does not matter if a fallback is cached 294 | 295 | otherwise choose the caveat-free *async* mode. 296 | 297 | 298 | 299 | > Big thanks to [brikis98](https://github.com/brikis98) who originally had the idea to port [Facebook's BigPipe](https://www.facebook.com/notes/facebook-engineering/bigpipe-pipelining-web-pages-for-high-performance/389414033919/) to Play and 300 | did a lot of the groundwork with his [brilliant talks](https://www.youtube.com/watch?v=4b1XLka0UIw) and [ping-play repo](https://github.com/brikis98/ping-play) 301 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := """pagelets""" 2 | 3 | import ReleaseTransformations._ 4 | 5 | lazy val root = (project in file(".")). 6 | settings(Seq( 7 | organization := "org.splink", 8 | scalaVersion := "2.13.6", 9 | libraryDependencies ++= Seq( 10 | "org.scala-lang" % "scala-reflect" % scalaVersion.value, 11 | "commons-codec" % "commons-codec" % "1.9", 12 | "org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test, 13 | "org.scalamock" %% "scalamock" % "4.4.0" % Test, 14 | "org.mockito" % "mockito-core" % "1.10.19" % Test, 15 | "ch.qos.logback" % "logback-classic" % "1.1.7" % Test, 16 | "com.typesafe.play" %% "play" % "2.8.8" 17 | ), 18 | scalacOptions ++= Seq( 19 | "-unchecked", 20 | "-deprecation", 21 | "-feature", 22 | "-language:implicitConversions", 23 | "-language:higherKinds", 24 | "-language:existentials") 25 | ) ++ publishSettings) 26 | 27 | lazy val publishSettings = Seq( 28 | releaseCrossBuild := true, 29 | crossScalaVersions := Seq("2.12.15", "2.13.6"), 30 | publishMavenStyle := true, 31 | pomIncludeRepository := { _ => false }, 32 | publishTo := { 33 | val nexus = "https://oss.sonatype.org/" 34 | if (isSnapshot.value) 35 | Some("snapshots" at nexus + "content/repositories/snapshots") 36 | else 37 | Some("releases" at nexus + "service/local/staging/deploy/maven2") 38 | }, 39 | licenses := Seq("Apache2 License" -> url("https://www.apache.org/licenses/LICENSE-2.0")), 40 | homepage := Some(url("https://github.com/splink/pagelets")), 41 | scmInfo := Some(ScmInfo(url("https://github.com/splink/pagelets"), "scm:git:git@github.com:splink/pagelets.git")), 42 | pomExtra := 43 | 44 | 45 | splink 46 | Max Kugland 47 | http://splink.org 48 | 49 | , 50 | releaseProcess := Seq[ReleaseStep]( 51 | checkSnapshotDependencies, 52 | inquireVersions, 53 | runClean, 54 | releaseStepCommandAndRemaining("+test"), 55 | setReleaseVersion, 56 | commitReleaseVersion, 57 | tagRelease, 58 | releaseStepCommandAndRemaining("+publishSigned"), 59 | setNextVersion, 60 | commitNextVersion, 61 | releaseStepCommandAndRemaining("sonatypeReleaseAll"), 62 | pushChanges 63 | ) 64 | ) 65 | -------------------------------------------------------------------------------- /docs/pagelets-tree-vis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splink/pagelets/2322de307ab17690eb52f09facf5e3f41133f228/docs/pagelets-tree-vis.png -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.2 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0") 2 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.10") 3 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.1") 4 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/Binders.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import play.api.mvc.PathBindable 4 | 5 | object Binders { 6 | 7 | implicit object PathBindablePageletId extends PathBindable[PageletId] { 8 | def bind(key: String, value: String) = try { 9 | Right(PageletId(value)) 10 | } catch { 11 | case _: Throwable => 12 | Left(s"Can't create a PageletId from '$key'") 13 | } 14 | 15 | def unbind(key: String, value: PageletId): String = value.name 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/Exceptions.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | object Exceptions { 4 | 5 | class PageletException(val msg: String) extends RuntimeException(msg) 6 | 7 | case class TypeException(override val msg: String) extends PageletException(msg) 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/FunctionMacros.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | 4 | import play.api.mvc.Action 5 | 6 | import scala.annotation.implicitNotFound 7 | import scala.language.experimental.macros 8 | import scala.reflect.macros._ 9 | 10 | case class FunctionInfo[T](fnc: T, types: List[(String, String)] = Nil) 11 | trait Fnc[T] 12 | 13 | object FunctionMacros { 14 | 15 | implicit def materialize[T]: Fnc[T] = macro materializeImpl[T] 16 | 17 | def materializeImpl[T](c: whitebox.Context)(implicit tag: c.WeakTypeTag[T]): c.Expr[Fnc[T]] = { 18 | val fncs = (0 to 22).map { i => 19 | c.universe.definitions.FunctionClass(i) 20 | } 21 | 22 | if (fncs.contains(tag.tpe.typeSymbol)) { 23 | c.universe.reify { 24 | new Fnc[T] {} 25 | } 26 | } else { 27 | c.abort(c.macroApplication.pos, "Sorry, but this is not a function") 28 | } 29 | } 30 | 31 | @implicitNotFound("You must supply a function") 32 | implicit def signature[T](f: T)(implicit fnc: Fnc[T]): FunctionInfo[T] = macro signatureImpl[T] 33 | 34 | def signatureImpl[T](c: blackbox.Context)(f: c.Expr[T])(fnc: c.Expr[Fnc[T]])(implicit tag: c.WeakTypeTag[T]) = { 35 | import c.universe._ 36 | 37 | if(!tag.tpe.contains(typeOf[Action[_]].typeSymbol)) { 38 | c.abort(c.macroApplication.pos, "Sorry, but you need to provide a function which returns Action[_]") 39 | } 40 | 41 | val pairs = f.tree.filter(_.isDef).collect { 42 | case ValDef(_, name, typ, _) => 43 | name.decodedName.toString -> typ.tpe.typeSymbol.fullName.replaceAll("\\$", "") 44 | } 45 | 46 | q"FunctionInfo[$tag]($f, $pairs)" 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/LeafBuilder.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.stream.scaladsl.Source 4 | import play.api.mvc._ 5 | 6 | import scala.concurrent.{ExecutionContext, Future} 7 | import scala.util.{Failure, Success, Try} 8 | 9 | trait LeafBuilder { 10 | def leafBuilderService: LeafBuilderService 11 | 12 | trait LeafBuilderService { 13 | def build(leaf: Leaf[_, _], args: Seq[Arg], requestId: RequestId)(implicit r: Request[AnyContent]): PageletResult 14 | } 15 | } 16 | 17 | trait LeafBuilderImpl extends LeafBuilder { 18 | self: PageletActionBuilder with BaseController => 19 | 20 | override val leafBuilderService = new LeafBuilderService { 21 | val log = play.api.Logger("LeafBuilder").logger 22 | implicit val ec: ExecutionContext = defaultExecutionContext 23 | 24 | override def build(leaf: Leaf[_, _], args: Seq[Arg], requestId: RequestId)(implicit r: Request[AnyContent]) = { 25 | log.info(s"$requestId Invoke pagelet ${leaf.id}") 26 | 27 | def stacktraceFor(t: Throwable) = t.getStackTrace.map(" " + _).mkString("\n") 28 | 29 | def messageFor(t: Throwable) = if (Option(t.getMessage).isDefined) { 30 | t.getMessage + "\n" + stacktraceFor(t) 31 | } else "No message\n" + stacktraceFor(t) 32 | 33 | def mandatory = if(leaf.isMandatory) "mandatory" else "" 34 | 35 | val startTime = System.currentTimeMillis() 36 | 37 | actionService.execute(leaf.id, leaf.info, args).fold(t => { 38 | log.warn(s"$requestId TypeException in $mandatory pagelet ${leaf.id} '${messageFor(t)}'") 39 | PageletResult.empty.copy(mandatoryFailedPagelets = Seq(Future.successful(leaf.isMandatory))) 40 | }, action => { 41 | 42 | def lastFallback = 43 | if (leaf.isMandatory) Action(Results.InternalServerError) else Action(Results.Ok) 44 | 45 | def fallbackFnc = 46 | leaf.fallback.getOrElse(FunctionInfo(() => lastFallback, Nil)) 47 | 48 | def fallbackAction = actionService.execute(leaf.id, fallbackFnc, args).fold(t => { 49 | log.warn(s"$requestId TypeException in $mandatory pagelet fallback ${leaf.id} '${messageFor(t)}'") 50 | // fallback failed 51 | lastFallback 52 | }, action => 53 | action 54 | ) 55 | 56 | val eventualResult = Try { 57 | action(r).recoverWith { case t => 58 | log.warn(s"$requestId Exception in async pagelet ${leaf.id} '${messageFor(t)}'") 59 | fallbackAction(r).recoverWith { case _ => 60 | log.warn(s"$requestId Exception in $mandatory async pagelet fallback ${leaf.id} '${messageFor(t)}'") 61 | lastFallback(r) 62 | } 63 | } 64 | } match { 65 | case Failure(t) => 66 | log.warn(s"$requestId Exception in pagelet ${leaf.id} '${messageFor(t)}'") 67 | Try(fallbackAction(r)) match { 68 | case Success(result) => result 69 | case Failure(_) => 70 | log.warn(s"$requestId Exception in $mandatory pagelet fallback ${leaf.id} '${messageFor(t)}'") 71 | lastFallback(r) 72 | } 73 | case Success(result) => result 74 | } 75 | 76 | val bodySource = Source.future(eventualResult.map { result => 77 | log.info(s"$requestId Finish pagelet ${leaf.id} took ${System.currentTimeMillis() - startTime}ms") 78 | result.body.dataStream 79 | }).flatMapConcat(identity) 80 | 81 | val results = eventualResult.map { result => 82 | (result.newFlash, result.newSession, result.newCookies) 83 | } 84 | 85 | val hasMandatoryPageletFailed = Seq(eventualResult.map(_.header.status == Results.InternalServerError.header.status)) 86 | 87 | PageletResult(bodySource, 88 | leaf.javascript, 89 | leaf.javascriptTop, 90 | leaf.css, 91 | Seq(results), 92 | leaf.metaTags, 93 | hasMandatoryPageletFailed 94 | ) 95 | }) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/PageBuilder.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import play.api.mvc.{AnyContent, Request} 4 | 5 | trait PageBuilder { 6 | def builder: PageBuilderService 7 | 8 | trait PageBuilderService { 9 | def build(pagelet: Pagelet, args: Arg*)(implicit r: Request[AnyContent]): PageletResult 10 | } 11 | 12 | } 13 | 14 | trait PageBuilderImpl extends PageBuilder { 15 | self: LeafBuilder => 16 | 17 | override val builder = new PageBuilderService { 18 | import akka.stream.stage.{GraphStage, GraphStageLogic, InHandler, OutHandler} 19 | import akka.stream.{Attributes, FlowShape, Inlet, Outlet} 20 | 21 | val log = play.api.Logger("PageBuilder") 22 | 23 | override def build(pagelet: Pagelet, args: Arg*)(implicit r: Request[AnyContent]) = { 24 | val start = System.currentTimeMillis() 25 | val requestId = RequestId.create 26 | 27 | def rec(p: Pagelet): PageletResult = p match { 28 | case Tree(_, children, combiner) => 29 | combiner(children.map(rec)) 30 | case l: Leaf[_, _] => 31 | leafBuilderService.build(l, args, requestId) 32 | } 33 | 34 | val result = rec(pagelet) 35 | result.copy(body = result.body.via(new Completion(start, requestId, pagelet))) 36 | } 37 | 38 | 39 | private class Completion[A](start: Long, requestId: RequestId, pagelet: Pagelet) extends GraphStage[FlowShape[A, A]] { 40 | val in = Inlet[A]("Completion.in") 41 | val out = Outlet[A]("Completion.out") 42 | 43 | val shape = FlowShape.of(in, out) 44 | 45 | override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = 46 | new GraphStageLogic(shape) { 47 | setHandler(in, new InHandler { 48 | override def onPush(): Unit = push(out, grab(in)) 49 | 50 | override def onUpstreamFinish(): Unit = { 51 | log.info(s"$requestId Finish page ${pagelet.id} took ${System.currentTimeMillis() - start}ms") 52 | complete(out) 53 | } 54 | 55 | }) 56 | setHandler(out, new OutHandler { 57 | override def onPull(): Unit = pull(in) 58 | }) 59 | } 60 | } 61 | 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/Pagelet.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.stream.scaladsl.{Concat, Source} 4 | 5 | case class Arg(name: String, value: Any) 6 | 7 | 8 | sealed trait Pagelet { 9 | def id: PageletId 10 | } 11 | 12 | case class Leaf[A, B] private(id: PageletId, info: FunctionInfo[A], 13 | fallback: Option[FunctionInfo[B]] = None, 14 | css: Seq[Css] = Seq.empty, 15 | javascript: Seq[Javascript] = Seq.empty, 16 | javascriptTop: Seq[Javascript] = Seq.empty, 17 | metaTags: Seq[MetaTag] = Seq.empty, 18 | isMandatory: Boolean = false) extends Pagelet { 19 | 20 | def withFallback(fallback: FunctionInfo[B]) = copy(fallback = Some(fallback)) 21 | def withJavascript(js: Javascript*) = copy(javascript = Seq(js:_*)) 22 | def withJavascriptTop(js: Javascript*) = copy(javascriptTop = Seq(js:_*)) 23 | def withCss(css: Css*) = copy(css = Seq(css:_*)) 24 | def withMetaTags(tags: MetaTag*) = copy(metaTags = Seq(tags:_*)) 25 | def setMandatory(value: Boolean) = copy(isMandatory = value) 26 | override def toString = s"Leaf(${id.name})" 27 | } 28 | 29 | object Tree { 30 | def combine(results: Seq[PageletResult]): PageletResult = 31 | results.foldLeft(PageletResult.empty) { (acc, next) => 32 | PageletResult( 33 | Source.combine(acc.body, next.body)(Concat.apply), 34 | acc.js ++ next.js, 35 | acc.jsTop ++ next.jsTop, 36 | acc.css ++ next.css, 37 | acc.results ++ next.results, 38 | (acc.metaTags ++ next.metaTags).distinct, 39 | acc.mandatoryFailedPagelets ++ next.mandatoryFailedPagelets) 40 | } 41 | } 42 | 43 | case class Tree private(id: PageletId, children: Seq[Pagelet], 44 | combine: Seq[PageletResult] => PageletResult = Tree.combine) extends Pagelet { 45 | 46 | override def equals(that: Any): Boolean = 47 | that match { 48 | case that: Tree => this.hashCode == that.hashCode 49 | case _ => false 50 | } 51 | 52 | override def hashCode: Int = 31 * (31 + id.hashCode) + children.hashCode 53 | 54 | override def toString = s"Tree(${id.name}\n ${children.map(_.toString)})" 55 | } 56 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/PageletActionBuilder.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import org.splink.pagelets.Exceptions.{PageletException, TypeException} 4 | import play.api.mvc._ 5 | 6 | trait PageletActionBuilder { 7 | 8 | def actionService: ActionService 9 | 10 | trait ActionService { 11 | def execute(id: PageletId, fi: FunctionInfo[_], args: Seq[Arg]): Either[PageletException, Action[AnyContent]] 12 | } 13 | 14 | } 15 | trait PageletActionBuilderImpl extends PageletActionBuilder { 16 | 17 | override def actionService: ActionService = new ActionServiceImpl 18 | 19 | class ActionServiceImpl extends ActionService { 20 | type R = Action[AnyContent] 21 | 22 | case class ArgError(msg: String) 23 | 24 | override def execute(id: PageletId, fi: FunctionInfo[_], args: Seq[Arg]): Either[PageletException, Action[AnyContent]] = 25 | values(fi, args).fold( 26 | err => Left(TypeException(s"$id: ${err.msg}")), { 27 | case Nil => 28 | Right(fi.fnc.asInstanceOf[() => R]()) 29 | case a :: Nil => 30 | Right(fi.fnc.asInstanceOf[Any => R](a)) 31 | case a :: b :: Nil => 32 | Right(fi.fnc.asInstanceOf[(Any, Any) => R](a, b)) 33 | case a :: b :: c :: Nil => 34 | Right(fi.fnc.asInstanceOf[(Any, Any, Any) => R](a, b, c)) 35 | case a :: b :: c :: d :: Nil => 36 | Right(fi.fnc.asInstanceOf[(Any, Any, Any, Any) => R](a, b, c, d)) 37 | case a :: b :: c :: d :: e :: Nil => 38 | Right(fi.fnc.asInstanceOf[(Any, Any, Any, Any, Any) => R](a, b, c, d, e)) 39 | case a :: b :: c :: d :: e :: f :: Nil => 40 | Right(fi.fnc.asInstanceOf[(Any, Any, Any, Any, Any, Any) => R](a, b, c, d, e, f)) 41 | case a :: b :: c :: d :: e :: f :: g :: Nil => 42 | Right(fi.fnc.asInstanceOf[(Any, Any, Any, Any, Any, Any, Any) => R](a, b, c, d, e, f, g)) 43 | case a :: b :: c :: d :: e :: f :: g :: h :: Nil => 44 | Right(fi.fnc.asInstanceOf[(Any, Any, Any, Any, Any, Any, Any, Any) => R](a, b, c, d, e, f, g, h)) 45 | case a :: b :: c :: d :: e :: f :: g :: h :: i :: Nil => 46 | Right(fi.fnc.asInstanceOf[(Any, Any, Any, Any, Any, Any, Any, Any, Any) => R](a, b, c, d, e, f, g, h, i)) 47 | case a :: b :: c :: d :: e :: f :: g :: h :: i :: j :: Nil => 48 | Right(fi.fnc.asInstanceOf[(Any, Any, Any, Any, Any, Any, Any, Any, Any, Any) => R](a, b, c, d, e, f, g, h, i, j)) 49 | case xs => 50 | Left(TypeException(s"$id: too many arguments: ${xs.size}")) 51 | } 52 | ) 53 | 54 | def values[T](info: FunctionInfo[T], args: Seq[Arg]): Either[ArgError, Seq[Any]] = eitherSeq { 55 | def predicate(name: String, typ: String, arg: Arg) = 56 | name == arg.name && typ == scalaClassNameFor(arg.value) 57 | 58 | info.types.map { case (name, typ) => 59 | args.find(arg => predicate(name, typ, arg)).map { a => 60 | Right(a.value) 61 | }.getOrElse { 62 | val msg = args.map(arg => s"${arg.name}:${scalaClassNameFor(arg.value)}").mkString(",") 63 | Left(ArgError(s"'$name:$typ' not found in Arguments($msg)")) 64 | } 65 | } 66 | } 67 | 68 | def scalaClassNameFor(v: Any) = Option((v match { 69 | case _: Int => Int.getClass 70 | case _: Double => Double.getClass 71 | case _: Float => Float.getClass 72 | case _: Long => Long.getClass 73 | case _: Short => Short.getClass 74 | case _: Byte => Byte.getClass 75 | case _: Boolean => Boolean.getClass 76 | case _: Char => Char.getClass 77 | case _: Some[_] => Option.getClass 78 | case None => Option.getClass 79 | case x: Any => x.getClass 80 | }).getCanonicalName).map(_.replaceAll("\\$", "")).getOrElse("undefined") 81 | 82 | def eitherSeq[A, B](e: Seq[Either[A, B]]): Either[A, Seq[B]] = 83 | e.foldRight(Right(Seq.empty): Either[A, Seq[B]]) { 84 | (next, acc) => for (xs <- acc; x <- next) yield xs.+:(x) 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/PageletActions.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.stream.Materializer 4 | import akka.stream.scaladsl.{Concat, Source} 5 | import akka.util.ByteString 6 | import play.api.http.Writeable 7 | import play.api.mvc._ 8 | import play.api.{Environment, Logger} 9 | 10 | import scala.concurrent.{ExecutionContext, Future} 11 | 12 | 13 | case class Head(title: String, 14 | metaTags: Seq[MetaTag] = Seq.empty, 15 | js: Option[Fingerprint] = None, 16 | css: Option[Fingerprint] = None) 17 | 18 | case class PageStream(head: Head, 19 | body: Source[ByteString, _], 20 | js: Option[Fingerprint] = None) 21 | 22 | case class Page(head: Head, 23 | body: String, 24 | js: Option[Fingerprint] = None) 25 | 26 | trait PageletActions { 27 | 28 | def PageAction: PageActions 29 | def PageletAction: PageletActions 30 | 31 | trait PageActions { 32 | def async[T: Writeable](onError: => Call)(title: RequestHeader => String, tree: RequestHeader => Pagelet, args: Arg*)(template: (Request[_], Page) => T)( 33 | implicit m: Materializer, env: Environment): Action[AnyContent] 34 | 35 | def stream[T: Writeable](title: RequestHeader => String, tree: RequestHeader => Pagelet, args: Arg*)(template: (Request[_], PageStream) => Source[T, _])( 36 | implicit m: Materializer, env: Environment): Action[AnyContent] 37 | } 38 | 39 | trait PageletActions { 40 | def async[T: Writeable](onError: => Call)(tree: RequestHeader => Tree, id: PageletId)(template: (Request[_], Page) => T)( 41 | implicit m: Materializer, env: Environment): Action[AnyContent] 42 | } 43 | 44 | } 45 | 46 | trait PageletActionsImpl extends PageletActions { 47 | self: BaseController with PageBuilder with TreeTools with Resources => 48 | 49 | override val PageAction = new PageActions { 50 | val log = Logger("PageletActions") 51 | 52 | implicit val ec: ExecutionContext = defaultExecutionContext 53 | 54 | override def async[T: Writeable](onError: => Call)(title: RequestHeader => String, tree: RequestHeader => Pagelet, args: Arg*)(template: (Request[_], Page) => T)( 55 | implicit m: Materializer, env: Environment) = Action.async { implicit request => 56 | val result = builder.build(tree(request), args: _*) 57 | 58 | (for { 59 | page <- mkPage(title(request), result) 60 | res <- Future.sequence(result.results) 61 | mandatoryPageletFailed <- Future.sequence(result.mandatoryFailedPagelets) 62 | } yield { 63 | if(mandatoryPageletFailed.forall(!_)) { 64 | val initial = (Option.empty[Flash], Option.empty[Session], Seq.empty[Cookie]) 65 | val (maybeFlash, maybeSession, cookies) = res.foldLeft(initial) { case ((flash, session, cookies), next) => 66 | (flash.orElse(next._1), session.orElse(next._2), (cookies ++ next._3).distinct) 67 | } 68 | 69 | val nakedResult = Ok(template(request, page)) 70 | (for { 71 | r1 <- maybeFlash.map(nakedResult.flashing).orElse(Some(nakedResult)) 72 | r2 <- maybeSession.map(r1.withSession).orElse(Some(r1)) 73 | r3 <- Some(if(cookies.nonEmpty) r2.withCookies(cookies:_*) else r2) 74 | } yield r3) 75 | .getOrElse(nakedResult).bakeCookies() 76 | 77 | } else { 78 | Redirect(onError, TEMPORARY_REDIRECT) 79 | } 80 | 81 | }).recover { 82 | case e: Throwable => 83 | log.error(s"$e") 84 | Redirect(onError, TEMPORARY_REDIRECT) 85 | } 86 | } 87 | 88 | override def stream[T: Writeable](title: RequestHeader => String, tree: RequestHeader => Pagelet, args: Arg*)(template: (Request[_], PageStream) => Source[T, _])( 89 | implicit m: Materializer, env: Environment) = Action { implicit request => 90 | 91 | val result = builder.build(tree(request), args: _*) 92 | val page = mkPageStream(title(request), result) 93 | 94 | Ok.chunked(template(request, page)) 95 | } 96 | 97 | def mkPage(title: String, result: PageletResult)(implicit r: RequestHeader, env: Environment, m: Materializer) = { 98 | val (jsFinger, jsTopFinger, cssFinger) = updateResources(result) 99 | 100 | val eventualBody = result.body.runFold("")(_ + _.utf8String) 101 | 102 | eventualBody.map { body => 103 | Page(Head(title, result.metaTags, jsTopFinger, cssFinger), body, jsFinger) 104 | } 105 | } 106 | 107 | def updateResources(result: PageletResult)(implicit env: Environment) = { 108 | val jsFinger = resources.update(result.js) 109 | val jsTopFinger = resources.update(result.jsTop) 110 | val cssFinger = resources.update(result.css) 111 | (jsFinger, jsTopFinger, cssFinger) 112 | } 113 | 114 | def bodySourceWithCookies(result: PageletResult) = { 115 | def cookieJs(cookies: Seq[Cookie]) = { 116 | val calls = cookies.map { c => 117 | s"""setCookie('${c.name}', '${c.value}', ${c.maxAge.getOrElse(0)}, '${c.path}', '${c.domain.getOrElse("")}');""" 118 | }.mkString("\n") 119 | 120 | if (calls.nonEmpty) ByteString( 121 | s"""|""".stripMargin) else ByteString.empty 126 | } 127 | 128 | val cookies = Future.sequence(result.results).map(cookies => cookieJs(cookies.flatMap(_._3))) 129 | 130 | Source.combine(result.body, Source.future(cookies))(Concat.apply).filter(_.nonEmpty) 131 | } 132 | 133 | def mkPageStream(title: String, result: PageletResult)(implicit r: RequestHeader, env: Environment, m: Materializer) = { 134 | val (jsFinger, jsTopFinger, cssFinger) = updateResources(result) 135 | 136 | PageStream(Head(title, result.metaTags, jsTopFinger, cssFinger), 137 | bodySourceWithCookies(result), 138 | jsFinger) 139 | } 140 | } 141 | 142 | override val PageletAction = new PageletActions { 143 | override def async[T: Writeable](onError: => Call)( 144 | tree: RequestHeader => Tree, id: PageletId)(template: (Request[_], Page) => T)( 145 | implicit m: Materializer, env: Environment) = Action.async { request => 146 | tree(request).find(id).map { p => 147 | val args = request.queryString.map { case (key, values) => 148 | Arg(key, values.head) 149 | }.toSeq 150 | 151 | PageAction.async(onError)(_ => id.name, _ => p, args: _*)(template).apply(request) 152 | }.getOrElse { 153 | Future.successful(NotFound(s"$id does not exist")) 154 | } 155 | } 156 | } 157 | 158 | 159 | } 160 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/PageletId.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | case class PageletId(name: String) { 4 | override def toString = name 5 | } 6 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/PageletModule.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import play.api.inject.Module 4 | import play.api.{Configuration, Environment} 5 | 6 | class PageletModule extends Module { 7 | def bindings(environment: Environment, configuration: Configuration) = 8 | Seq(bind[Pagelets].to[InjectedPageletsAssembly]) 9 | } -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/PageletResult.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.stream.scaladsl.Source 4 | import akka.util.ByteString 5 | import play.api.mvc.{Cookie, Flash, Session} 6 | 7 | import scala.concurrent.Future 8 | import scala.language.implicitConversions 9 | 10 | object PageletResult { 11 | val empty = PageletResult(Source.empty[ByteString]) 12 | } 13 | 14 | case class FailedPagelet(id: PageletId, t: Throwable) 15 | 16 | case class PageletResult(body: Source[ByteString, _], 17 | js: Seq[Javascript] = Seq.empty, 18 | jsTop: Seq[Javascript] = Seq.empty, 19 | css: Seq[Css] = Seq.empty, 20 | results: Seq[Future[(Option[Flash], Option[Session], Seq[Cookie])]] = Seq.empty, 21 | metaTags: Seq[MetaTag] = Seq.empty, 22 | mandatoryFailedPagelets: Seq[Future[Boolean]] = Seq.empty) { 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/Pagelets.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import play.api.mvc._ 4 | 5 | trait Pagelets 6 | extends BaseController 7 | with PageletActions 8 | with PageBuilder 9 | with ResourceActions 10 | with Visualizer 11 | with TreeTools { 12 | 13 | import scala.language.experimental.macros 14 | 15 | implicit def materialize[T]: Fnc[T] = macro FunctionMacros.materializeImpl[T] 16 | 17 | implicit def signature[T](f: T)(implicit fnc: Fnc[T]): FunctionInfo[T] = macro FunctionMacros.signatureImpl[T] 18 | 19 | implicit class PageletIdOps(s: String) { 20 | def id: PageletId = PageletId(s) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/PageletsAssembly.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import javax.inject.Inject 4 | import play.api.mvc.{AbstractController, ControllerComponents, CookieHeaderEncoding, DefaultCookieHeaderEncoding} 5 | 6 | class InjectedPageletsAssembly @Inject() (cc: ControllerComponents) extends AbstractController(cc) with PageletsAssembly 7 | 8 | trait PageletsAssembly extends Pagelets 9 | with PageletActionsImpl 10 | with PageBuilderImpl 11 | with LeafBuilderImpl 12 | with PageletActionBuilderImpl 13 | with TreeToolsImpl 14 | with ResourceActionsImpl 15 | with ResourcesImpl 16 | with VisualizerImpl { 17 | protected def controllerComponents: ControllerComponents 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/RequestId.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | object RequestId { 4 | private val rnd = new scala.util.Random() 5 | 6 | def create = RequestId("[" + (0 to 5).map { _ => 7 | (rnd.nextInt(90 - 65) + 65).toChar 8 | }.mkString + "]") 9 | } 10 | 11 | case class RequestId(id: String) { 12 | override def toString = id 13 | } -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/Resource.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | sealed trait Resource { 4 | def src: String 5 | } 6 | 7 | object Javascript { 8 | val name: String = "js" 9 | val nameTop: String = "jsTop" 10 | } 11 | 12 | case class Javascript(src: String) extends Resource 13 | 14 | object Css { 15 | val name: String = "css" 16 | } 17 | 18 | case class Css(src: String) extends Resource 19 | 20 | object MetaTag { 21 | val name: String = "meta" 22 | } 23 | 24 | case class MetaTag(name: String, content: String) 25 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/ResourceActions.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import java.time.format.DateTimeFormatter 4 | import java.time.{ZoneId, ZonedDateTime} 5 | import java.time.temporal.ChronoUnit 6 | 7 | import play.api.mvc._ 8 | 9 | import scala.concurrent.duration._ 10 | 11 | trait ResourceActions { 12 | def ResourceAction(fingerprint: String, validFor: Duration = 365.days): Action[AnyContent] 13 | } 14 | 15 | trait ResourceActionsImpl extends ResourceActions { self: Resources with BaseController => 16 | override def ResourceAction(fingerprint: String, validFor: Duration = 365.days) = EtagAction { _ => 17 | resources.contentFor(Fingerprint(fingerprint)).map { content => 18 | Ok(content.body).as(content.mimeType.name).withHeaders(CacheHeaders(fingerprint, validFor): _*) 19 | }.getOrElse { 20 | BadRequest 21 | } 22 | } 23 | 24 | def EtagAction(f: Request[AnyContent] => Result) = Action { request => 25 | request.headers.get(IF_NONE_MATCH).map { etag => 26 | if (resources.contains(Fingerprint(etag.replaceAll(""""""", "")))) NotModified else f(request) 27 | }.getOrElse { 28 | f(request) 29 | } 30 | } 31 | 32 | def CacheHeaders(fingerprint: String, validFor: Duration = 365.days) = { 33 | def format(zdt: ZonedDateTime) = 34 | DateTimeFormatter.RFC_1123_DATE_TIME.format(zdt) 35 | 36 | val now = ZonedDateTime.now(ZoneId.of("GMT")) 37 | val future = now.plusDays(validFor.toDays) 38 | 39 | def elapsed = ChronoUnit.SECONDS.between(now, future) 40 | 41 | Seq( 42 | DATE -> format(now), 43 | LAST_MODIFIED -> format(now), 44 | EXPIRES -> format(future), 45 | ETAG -> s""""$fingerprint"""", 46 | CACHE_CONTROL -> s"public, max-age: ${elapsed.toString}") 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/Resources.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import org.apache.commons.codec.digest.DigestUtils 4 | import play.api.{Environment, Logger, Mode} 5 | 6 | import scala.io.Source 7 | 8 | trait Resources { 9 | def resources: ResourceProvider 10 | 11 | trait ResourceProvider { 12 | def contains(fingerprint: Fingerprint): Boolean 13 | def contentFor(fingerprint: Fingerprint): Option[ResourceContent] 14 | def update[T <: Resource](resources: Seq[T])(implicit e: Environment): Option[Fingerprint] 15 | } 16 | 17 | } 18 | 19 | trait ResourcesImpl extends Resources { 20 | 21 | override val resources = new ResourceProviderImpl 22 | 23 | class ResourceProviderImpl extends ResourceProvider { 24 | var cache = Map[Fingerprint, ResourceContent]() 25 | var itemCache = Map[Fingerprint, ResourceContent]() 26 | val log = Logger("Resources") 27 | 28 | val BasePath = "public/" 29 | 30 | override def contentFor(fingerprint: Fingerprint) = cache.get(fingerprint) 31 | 32 | override def contains(fingerprint: Fingerprint) = cache.contains(fingerprint) 33 | 34 | def clear() = { 35 | cache = cache.empty 36 | itemCache = cache.empty 37 | } 38 | 39 | override def update[T <: Resource](resources: Seq[T])(implicit e: Environment) = synchronized { 40 | if (resources.nonEmpty) { 41 | val content = assemble(resources) 42 | val hash = Fingerprint(DigestUtils.md5Hex(content.body)) 43 | cache = cache + (hash -> content) 44 | Some(hash) 45 | } else None 46 | } 47 | 48 | def assemble[T <: Resource](resources: Seq[T])(implicit e: Environment) = { 49 | resources.distinct.foldLeft(ResourceContent.empty) { (acc, next) => 50 | maybeCachedContent(next).map { content => 51 | acc + content 52 | }.getOrElse { 53 | load(next).map { content => 54 | itemCache = itemCache + (Fingerprint(next.src) -> content) 55 | acc + content 56 | }.getOrElse { 57 | log.warn(s"Missing ${mimeTypeFor(next)} resource: ${next.src}") 58 | acc 59 | } 60 | } 61 | } 62 | } 63 | 64 | def maybeCachedContent(resource: Resource)(implicit e: Environment) = for { 65 | content <- itemCache.get(Fingerprint(resource.src)) if e.mode == Mode.Prod 66 | } yield content 67 | 68 | def load(resource: Resource)(implicit e: Environment) = { 69 | log.debug(s"Load resource '${BasePath + resource.src}'") 70 | e.resourceAsStream(BasePath + resource.src).map(Source.fromInputStream(_).mkString).map { text => 71 | ResourceContent(text + "\n", mimeTypeFor(resource)) 72 | } 73 | } 74 | } 75 | 76 | private def mimeTypeFor(resource: Resource) = resource match { 77 | case _: Javascript => JsMimeType 78 | case _: Css => CssMimeType 79 | } 80 | 81 | } 82 | 83 | case class Fingerprint(s: String) { 84 | override def toString = s 85 | } 86 | 87 | case object ResourceContent { 88 | val empty = ResourceContent("", PlainMimeType) 89 | } 90 | 91 | case class ResourceContent(body: String, mimeType: MimeType) { 92 | override def toString = body 93 | 94 | def +(that: ResourceContent) = copy(body = body + that.body, mimeType = that.mimeType) 95 | } 96 | 97 | sealed trait MimeType { 98 | def name: String 99 | } 100 | 101 | case object PlainMimeType extends MimeType { 102 | override def name = "plain/text" 103 | } 104 | 105 | case object CssMimeType extends MimeType { 106 | override def name = "text/css" 107 | } 108 | 109 | case object JsMimeType extends MimeType { 110 | override def name = "text/javascript" 111 | } 112 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/TreeTools.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import play.api.mvc.{BaseController, Results} 4 | 5 | import scala.language.implicitConversions 6 | 7 | trait TreeTools { 8 | implicit def treeOps(tree: Tree): TreeOps 9 | 10 | trait TreeOps { 11 | def skip(id: PageletId): Tree 12 | def replace(id: PageletId, other: Pagelet): Tree 13 | def find(id: PageletId): Option[Pagelet] 14 | def filter(f: Pagelet => Boolean): Tree 15 | } 16 | } 17 | 18 | trait TreeToolsImpl extends TreeTools { self: BaseController => 19 | override implicit def treeOps(tree: Tree): TreeOps = new TreeOpsImpl(tree) 20 | 21 | class TreeOpsImpl(tree: Tree) extends TreeOps { 22 | val log = play.api.Logger("TreeTools") 23 | 24 | override def find(id: PageletId): Option[Pagelet] = { 25 | def rec(p: Pagelet): Option[Pagelet] = p match { 26 | case _ if p.id == id => Some(p) 27 | case Tree(_, children_, _) => children_.flatMap(rec).headOption 28 | case _ => None 29 | } 30 | rec(tree) 31 | } 32 | 33 | override def skip(id: PageletId) = { 34 | def f = Action(Results.Ok) 35 | log.debug(s"skip $id") 36 | replace(id, Leaf(id, FunctionInfo(() => f, Nil))) 37 | } 38 | 39 | override def replace(id: PageletId, other: Pagelet): Tree = { 40 | def rec(p: Pagelet): Pagelet = p match { 41 | case b@Tree(_, childs, _) if childs.exists(_.id == id) => 42 | val idx = childs.indexWhere(_.id == id) 43 | b.copy(children = childs.updated(idx, other)) 44 | 45 | case b@Tree(_, childs, _) => 46 | b.copy(children = childs.map(rec)) 47 | 48 | case any => 49 | any 50 | } 51 | 52 | if (id == tree.id) { 53 | other match { 54 | case t: Tree => t 55 | case l: Leaf[_, _] => 56 | log.debug(s"replace with a new Tree $id") 57 | Tree(id, Seq(l), tree.combine) 58 | } 59 | } else { 60 | log.debug(s"replace $id") 61 | rec(tree).asInstanceOf[Tree] 62 | } 63 | } 64 | 65 | def filter(f: Pagelet => Boolean): Tree = { 66 | def rec(next: Pagelet): Pagelet = next match { 67 | case t: Tree => 68 | t.copy(children = t.children.filter(f).map(rec)) 69 | case l: Leaf[_, _] => l 70 | } 71 | 72 | rec(tree).asInstanceOf[Tree] 73 | } 74 | 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/Visualizer.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | trait Visualizer { 4 | def visualize(p: Pagelet): String 5 | } 6 | 7 | trait VisualizerImpl extends Visualizer { 8 | override def visualize(p: Pagelet) = { 9 | def rec(p: Pagelet, layer: Int = 0): String = p match { 10 | case t: Tree => 11 | val a = space(layer) + t.id.name + "\n" 12 | a + t.children.map(c => rec(c, layer + 1)).mkString 13 | case Leaf(id, fnc, _, _, _, _, _, _) => 14 | space(layer) + id.name + mkArgsString(fnc) + "\n" 15 | } 16 | 17 | rec(p) 18 | } 19 | 20 | def space(layer: Int) = (1 to layer).map(_ => "-").mkString 21 | 22 | def mkArgsString(fnc: FunctionInfo[_]) = 23 | if (fnc.types.isEmpty) "" 24 | else "(" + fnc.types.map { case (name, typ) => 25 | val index = typ.lastIndexOf(".") 26 | name + ":" + (if (index > -1) typ.substring(index + 1) else typ) 27 | }.mkString(", ") + ")" 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/twirl/HtmlStream.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets.twirl 2 | 3 | import akka.stream.scaladsl.{Concat, Source} 4 | import org.splink.pagelets.{Fingerprint, Head, PageStream} 5 | import play.twirl.api.{Appendable, Format, Html, HtmlFormat} 6 | 7 | 8 | case class HtmlPageStream(head: Head, body: HtmlStream, js: Option[Fingerprint] = None) 9 | 10 | class HtmlStream(val source: Source[Html, _]) extends Appendable[HtmlStream] 11 | 12 | case object HtmlStream { 13 | def apply(source: Source[Html, _]) = new HtmlStream(source) 14 | } 15 | 16 | object HtmlStreamFormat extends Format[HtmlStream] { 17 | def raw(text: String): HtmlStream = 18 | new HtmlStream(Source.single(Html(text))) 19 | 20 | def escape(text: String): HtmlStream = 21 | raw(HtmlFormat.escape(text).body) 22 | 23 | def empty: HtmlStream = raw("") 24 | 25 | def fill(elements: scala.collection.immutable.Seq[HtmlStream]): HtmlStream = 26 | if (elements.isEmpty) HtmlStreamFormat.empty else elements.reduce((acc, next) => 27 | HtmlStream { 28 | Source.combine(acc.source, next.source)(Concat.apply) 29 | }) 30 | } 31 | 32 | object HtmlStreamOps { 33 | implicit def toSource(stream: HtmlStream): Source[Html, _] = stream.source.filter(_.body.nonEmpty) 34 | 35 | implicit def toHtmlStream(source: Source[Html, _]): HtmlStream = HtmlStream(source) 36 | 37 | 38 | implicit def pageStream2HtmlPageStream(page: PageStream): HtmlPageStream = 39 | HtmlPageStream(page.head, HtmlStream(page.body.map(b => Html(b.utf8String))), page.js) 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/main/scala/org/splink/pagelets/twirl/TwirlCombiners.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets.twirl 2 | 3 | import akka.stream.Materializer 4 | import akka.stream.scaladsl.Source 5 | import akka.util.ByteString 6 | import org.splink.pagelets._ 7 | import play.api.Logger 8 | import play.api.mvc.{Cookie, Flash, Session} 9 | import play.twirl.api.{Html, HtmlFormat} 10 | 11 | import scala.concurrent.{ExecutionContext, Future} 12 | import scala.language.implicitConversions 13 | import scala.util.Try 14 | 15 | object TwirlCombiners { 16 | private val log = Logger("TwirlConversions") 17 | 18 | def combine(results: Seq[PageletResult])(template: Seq[Html] => Html)( 19 | implicit ec: ExecutionContext, m: Materializer): PageletResult = { 20 | 21 | val htmls = Future.traverse(results) { r => 22 | r.body.runFold(HtmlFormat.empty)((acc, next) => Html(acc.toString + next.utf8String)) 23 | } 24 | 25 | val source = Source.future(htmls.map(xs => ByteString(template(xs).body))) 26 | 27 | combineAssets(results)(source) 28 | } 29 | 30 | def combineStream(results: Seq[PageletResult])(template: Seq[HtmlStream] => HtmlStream)( 31 | implicit ec: ExecutionContext, m: Materializer): PageletResult = { 32 | 33 | def toHtmlStream(results: Seq[PageletResult]) = 34 | results.map(r => HtmlStream(r.body.map(b => Html(b.utf8String)))) 35 | 36 | val stream = template(toHtmlStream(results)) 37 | val source = stream.source.map(s => ByteString(s.body)) 38 | 39 | combineAssets(results)(source) 40 | } 41 | 42 | private def combineAssets(results: Seq[PageletResult]): Source[ByteString, _] => PageletResult = { 43 | val (js, jsTop, css, res, metaTags, failedPagelets) = results.foldLeft( 44 | Seq.empty[Javascript], 45 | Seq.empty[Javascript], 46 | Seq.empty[Css], 47 | Seq.empty[Future[(Option[Flash], Option[Session], Seq[Cookie])]], 48 | Seq.empty[MetaTag], 49 | Seq.empty[Future[Boolean]]) { (acc, next) => 50 | (acc._1 ++ next.js, 51 | acc._2 ++ next.jsTop, 52 | acc._3 ++ next.css, 53 | acc._4 ++ next.results, 54 | (acc._5 ++ next.metaTags).distinct, 55 | acc._6 ++ next.mandatoryFailedPagelets) 56 | } 57 | 58 | PageletResult(_, js, jsTop, css, res, metaTags, failedPagelets) 59 | } 60 | 61 | implicit def adapt[A, B](f: A => B): Seq[A] => B = 62 | (s: Seq[A]) => handle(Try(f(s.head)), s, expectedSize = 1) 63 | 64 | implicit def adapt[A, B](f: (A, A) => B): Seq[A] => B = 65 | (s: Seq[A]) => handle(Try(f(s.head, s(1))), s, expectedSize = 2) 66 | 67 | implicit def adapt[A, B](f: (A, A, A) => B): Seq[A] => B = 68 | (s: Seq[A]) => handle(Try(f(s.head, s(1), s(2))), s, expectedSize = 3) 69 | 70 | implicit def adapt[A, B](f: (A, A, A, A) => B): Seq[A] => B = 71 | (s: Seq[A]) => handle(Try(f(s.head, s(1), s(2), s(3))), s, expectedSize = 4) 72 | 73 | implicit def adapt[A, B](f: (A, A, A, A, A) => B): Seq[A] => B = 74 | (s: Seq[A]) => handle(Try(f(s.head, s(1), s(2), s(3), s(4))), s, expectedSize = 5) 75 | 76 | implicit def adapt[A, B](f: (A, A, A, A, A, A) => B): Seq[A] => B = 77 | (s: Seq[A]) => handle(Try(f(s.head, s(1), s(2), s(3), s(4), s(5))), s, expectedSize = 6) 78 | 79 | implicit def adapt[A, B](f: (A, A, A, A, A, A, A) => B): Seq[A] => B = 80 | (s: Seq[A]) => handle(Try(f(s.head, s(1), s(2), s(3), s(4), s(5), s(6))), s, expectedSize = 7) 81 | 82 | implicit def adapt[A, B](f: (A, A, A, A, A, A, A, A) => B): Seq[A] => B = 83 | (s: Seq[A]) => handle(Try(f(s.head, s(1), s(2), s(3), s(4), s(5), s(6), s(7))), s, expectedSize = 8) 84 | 85 | implicit def adapt[A, B](f: (A, A, A, A, A, A, A, A, A) => B): Seq[A] => B = 86 | (s: Seq[A]) => handle(Try(f(s.head, s(1), s(2), s(3), s(4), s(5), s(6), s(7), s(8))), s, expectedSize = 9) 87 | 88 | private def handle[A, B](t: Try[B], s: Seq[A], expectedSize: Int) = { 89 | if (s.size < expectedSize) 90 | throw new RuntimeException(s"Not enough children beneath the tree: (${s.mkString(",")})") 91 | else if (s.size > expectedSize) 92 | log.warn(s"Found too many children beneath the tree: (${s.mkString(",")})") 93 | 94 | t.getOrElse(throw new RuntimeException("Error while rendering the template")) 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %logger{15} - %message%n%xException{10} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/test/resources/public/a.css: -------------------------------------------------------------------------------- 1 | body { 2 | text-align: center; 3 | } -------------------------------------------------------------------------------- /src/test/resources/public/a.js: -------------------------------------------------------------------------------- 1 | console.log("a"); -------------------------------------------------------------------------------- /src/test/resources/public/b.js: -------------------------------------------------------------------------------- 1 | console.log("b"); -------------------------------------------------------------------------------- /src/test/scala/helpers/FutureHelper.scala: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import akka.stream.Materializer 4 | import akka.stream.scaladsl.Source 5 | import akka.util.ByteString 6 | 7 | import scala.concurrent.duration._ 8 | import scala.concurrent.{Await, Future} 9 | 10 | trait FutureHelper { 11 | 12 | implicit class FutureOps[T](f: Future[T]) { 13 | def futureValue(implicit timeout: FiniteDuration = 1.second) = Await.result(f, timeout) 14 | 15 | def futureTry(implicit timeout: FiniteDuration = 1.second) = Await.ready(f, timeout).value.get 16 | } 17 | 18 | implicit class SourceConsumer(src: Source[ByteString, _])(implicit m: Materializer) { 19 | def consume = src.runFold("")((acc, next) => acc + next.utf8String).futureValue 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/BindersTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.flatspec.AnyFlatSpec 5 | 6 | class BindersTest extends AnyFlatSpec with Matchers { 7 | 8 | import Binders._ 9 | 10 | "PathBindablePageletId" should "bind a String to a PageletId" in { 11 | PathBindablePageletId.bind("one", "oneValue").toOption.get should equal(PageletId("oneValue")) 12 | } 13 | 14 | it should "bind a String which begins with an Int to a PageletId" in { 15 | PathBindablePageletId.bind("one", "1").toOption.get should equal(PageletId("1")) 16 | } 17 | 18 | it should "unbind a String from a PageletId" in { 19 | PathBindablePageletId.unbind("one", PageletId("oneValue")) should equal("oneValue") 20 | } 21 | 22 | it should "unbind a String which begins with an Int from a PageletId" in { 23 | PathBindablePageletId.unbind("one", PageletId("1")) should equal("1") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/FunctionMacrosTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | import org.scalatest.matchers.should.Matchers 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.splink.pagelets.FunctionMacros._ 5 | import play.api.mvc.{Action, Results} 6 | import play.api.test.StubControllerComponentsFactory 7 | 8 | class FunctionMacrosTest extends AnyFlatSpec with Matchers with StubControllerComponentsFactory { 9 | 10 | val Action = stubControllerComponents().actionBuilder 11 | 12 | object TestFunctions { 13 | case class Complex(s: String) 14 | 15 | def f1 = Action(Results.Ok("f1()")) 16 | def f2(s: String) = Action(Results.Ok(s"f2($s)")) 17 | def f3(s: String, i: Int) = Action(Results.Ok(s"f2($s, $i)")) 18 | def f4(c: Complex) = Action(Results.Ok(s"f4($c)")) 19 | val f5: (String, Int) => Action[_] = (s: String, _: Int) => Action(Results.Ok(s"f5($s)")) 20 | } 21 | 22 | "A function without parameters" should "not yield any types" in { 23 | val result = signature(() => TestFunctions.f1) 24 | result.types should be (empty) 25 | } 26 | 27 | "A function with one parameter" should "yield the name and type of the parameter" in { 28 | val result = signature(TestFunctions.f2 _) 29 | result.types.head should be ("s" -> "java.lang.String") 30 | } 31 | 32 | "A function with two parameters" should "yield the names and types of these parameters" in { 33 | val result = signature(TestFunctions.f3 _) 34 | result.types should be (List("s" -> "java.lang.String", "i" -> "scala.Int")) 35 | } 36 | 37 | "A function with a complex parameter" should "yield the name and type of the parameter" in { 38 | val result = signature(TestFunctions.f4 _) 39 | result.types.head should be ("c" -> "org.splink.pagelets.FunctionMacrosTest.TestFunctions.Complex") 40 | } 41 | 42 | "A function literal" should "not yield any type info, because it is impossible to determine it's parameter name(s)" in { 43 | val result = signature(() => TestFunctions.f5) 44 | result.types should be (empty) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/LeafBuilderTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.actor.ActorSystem 4 | import akka.stream.scaladsl.Source 5 | import akka.util.ByteString 6 | import helpers.FutureHelper 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatest.flatspec.AnyFlatSpec 9 | import play.api.mvc._ 10 | import play.api.test.{FakeRequest, StubControllerComponentsFactory} 11 | 12 | import scala.concurrent.Future 13 | import scala.language.implicitConversions 14 | 15 | class LeafBuilderTest extends AnyFlatSpec with Matchers with FutureHelper with StubControllerComponentsFactory { 16 | implicit val system = ActorSystem() 17 | implicit val ec = system.dispatcher 18 | implicit val request = FakeRequest() 19 | 20 | val Action = stubControllerComponents().actionBuilder 21 | 22 | val successAction = Action(Results.Ok("action")) 23 | 24 | case class TestException(msg: String) extends RuntimeException 25 | 26 | val failedAction = Action { _ => 27 | throw TestException("sync fail") 28 | } 29 | 30 | val failedAsyncAction = Action.async(Future { 31 | throw TestException("async fail") 32 | }) 33 | 34 | def mkResult(body: String) = PageletResult( 35 | body = Source.future(Future.successful(ByteString(body))), 36 | results = Seq(Future((None, None, Seq.empty))) 37 | ) 38 | 39 | val builder = new LeafBuilderImpl with BaseController with PageletActionBuilderImpl { 40 | override def controllerComponents: ControllerComponents = stubControllerComponents() 41 | } 42 | 43 | val requestId = RequestId("RequestId") 44 | 45 | def build[T](info: FunctionInfo[T], isMandatory: Boolean): PageletResult = 46 | builder.leafBuilderService.build(Leaf(PageletId("one"), info, isMandatory = isMandatory), Seq.empty, requestId) 47 | 48 | def buildWithFallback[T, U](info: FunctionInfo[T], fallback: FunctionInfo[U], isMandatory: Boolean): PageletResult = 49 | builder.leafBuilderService.build(Leaf(PageletId("one"), info, isMandatory = isMandatory).withFallback(fallback), Seq.empty, requestId) 50 | 51 | /** 52 | * Without fallback 53 | */ 54 | 55 | "LeafBuilder#build (mandatory without fallback)" should "yield the body of the result" in { 56 | val result = build(FunctionInfo(() => successAction), isMandatory = true) 57 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 58 | result.body.consume should equal("action") 59 | } 60 | 61 | it should "yield an empty body if an Action fails" in { 62 | val result = build(FunctionInfo(() => failedAction), isMandatory = true) 63 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(true) 64 | result.body.consume should equal("") 65 | } 66 | 67 | it should "yield an empty body if an async Action fails" in { 68 | val result = build(FunctionInfo(() => failedAsyncAction), isMandatory = true) 69 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(true) 70 | result.body.consume should equal("") 71 | } 72 | 73 | 74 | "LeafBuilder#build (not mandatory without fallback)" should "yield the body of the result" in { 75 | val result = build(FunctionInfo(() => successAction), isMandatory = false) 76 | result.body.consume should equal("action") 77 | } 78 | 79 | it should "yield an empty body if an Action fails" in { 80 | val result = build(FunctionInfo(() => failedAction), isMandatory = false) 81 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 82 | result.body.consume should equal("") 83 | } 84 | 85 | it should "yield an empty body if an async Action fails" in { 86 | val result = build(FunctionInfo(() => failedAsyncAction), isMandatory = false) 87 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 88 | result.body.consume should equal("") 89 | } 90 | 91 | /** 92 | * With fallback 93 | */ 94 | 95 | // Not root node: Successful fallback 96 | 97 | "LeafBuilder#build (mandatory with successful fallback)" should "yield the body of the result" in { 98 | val result = buildWithFallback(FunctionInfo(() => successAction), FunctionInfo(() => failedAction), isMandatory = true) 99 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 100 | result.body.consume should equal("action") 101 | } 102 | 103 | it should "yield the fallback if an Action fails" in { 104 | val result = buildWithFallback(FunctionInfo(() => failedAction), FunctionInfo(() => successAction), isMandatory = true) 105 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 106 | result.body.consume should equal("action") 107 | } 108 | 109 | it should "yield the fallback if an async Action fails" in { 110 | val result = buildWithFallback(FunctionInfo(() => failedAsyncAction), FunctionInfo(() => successAction), isMandatory = true) 111 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 112 | result.body.consume should equal("action") 113 | } 114 | 115 | // mandatory node: Successful fallback 116 | 117 | "LeafBuilder#build (not mandatory with successful fallback)" should "yield the body of the result" in { 118 | val result = buildWithFallback(FunctionInfo(() => successAction), FunctionInfo(() => failedAction), isMandatory = false) 119 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 120 | result.body.consume should equal("action") 121 | } 122 | 123 | it should "yield the the fallback, if an Action fails" in { 124 | val result = buildWithFallback(FunctionInfo(() => failedAction), FunctionInfo(() => successAction), isMandatory = false) 125 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 126 | result.body.consume should equal("action") 127 | } 128 | 129 | it should "yield yield the fallback, if an async Action fails" in { 130 | val result = buildWithFallback(FunctionInfo(() => failedAsyncAction), FunctionInfo(() => successAction), isMandatory = false) 131 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 132 | result.body.consume should equal("action") 133 | } 134 | 135 | // Sync: default and fallback fail 136 | 137 | "LeafBuilder#build (mandatory with failing fallback)" should "yield an empty body if the default and fallback Actions fail" in { 138 | val result = buildWithFallback(FunctionInfo(() => failedAction), FunctionInfo(() => failedAction), isMandatory = true) 139 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(true) 140 | result.body.consume should equal("") 141 | } 142 | 143 | "LeafBuilder#build (not mandatory with failing fallback)" should "yield an empty body if the default and fallback Actions fail" in { 144 | val result = buildWithFallback(FunctionInfo(() => failedAction), FunctionInfo(() => failedAction), isMandatory = false) 145 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 146 | result.body.consume should equal("") 147 | } 148 | 149 | // Async: default and fallback fail 150 | 151 | "LeafBuilder#build (mandatory with failing fallback)" should "yield an empty body if the default and fallback async Actions fail" in { 152 | val result = buildWithFallback(FunctionInfo(() => failedAsyncAction), FunctionInfo(() => failedAsyncAction), isMandatory = true) 153 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(true) 154 | result.body.consume should equal("") 155 | } 156 | 157 | "LeafBuilder#build (not mandatory with failing fallback)" should "yield an empty body if the default and fallback async Actions fail" in { 158 | val result = buildWithFallback(FunctionInfo(() => failedAsyncAction), FunctionInfo(() => failedAsyncAction), isMandatory = false) 159 | result.mandatoryFailedPagelets.map(_.futureValue).head should be(false) 160 | result.body.consume should equal("") 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/PageBuilderTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.actor.ActorSystem 4 | import akka.stream.scaladsl.Source 5 | import akka.util.ByteString 6 | import helpers.FutureHelper 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatest.flatspec.AnyFlatSpec 9 | import play.api.mvc._ 10 | import play.api.test.{FakeRequest, StubControllerComponentsFactory} 11 | 12 | 13 | class PageBuilderTest extends AnyFlatSpec with Matchers with FutureHelper with StubControllerComponentsFactory { 14 | 15 | import FunctionMacros._ 16 | 17 | implicit val system = ActorSystem() 18 | implicit val ec = system.dispatcher 19 | implicit val request = FakeRequest() 20 | 21 | val Action = stubControllerComponents().actionBuilder 22 | 23 | def action(s: String) = () => Action(Results.Ok(s)) 24 | 25 | val tree = Tree(PageletId("root"), Seq( 26 | Leaf(PageletId("one"), action("one")), 27 | Tree(PageletId("two"), Seq( 28 | Leaf(PageletId("three"), action("three")), 29 | Leaf(PageletId("four"), action("four")) 30 | )) 31 | )) 32 | 33 | def mkResult(body: String) = PageletResult(Source.single(ByteString(body))) 34 | 35 | val builder = new PageBuilderImpl with LeafBuilder { 36 | override val leafBuilderService = new LeafBuilderService { 37 | override def build(leaf: Leaf[_, _], args: Seq[Arg], requestId: RequestId)(implicit r: Request[AnyContent]) = 38 | mkResult(leaf.id.name) 39 | } 40 | }.builder 41 | 42 | def opsify(t: Tree) = new TreeToolsImpl with BaseController { 43 | override def controllerComponents: ControllerComponents = stubControllerComponents() 44 | }.treeOps(t) 45 | 46 | "PageBuilder#builder" should "build a complete tree" in { 47 | builder.build(tree).body.consume should equal("onethreefour") 48 | } 49 | 50 | it should "build a subtree" in { 51 | builder.build(opsify(tree).find(PageletId("two")).get).body.consume should equal("threefour") 52 | } 53 | 54 | it should "build a leaf" in { 55 | builder.build(opsify(tree).find(PageletId("four")).get).body.consume should equal("four") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/PageletActionBuilderTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.actor.ActorSystem 4 | import helpers.FutureHelper 5 | import org.scalamock.scalatest.MockFactory 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatest.flatspec.AnyFlatSpec 8 | import org.scalatest.EitherValues 9 | import org.splink.pagelets.Exceptions.TypeException 10 | import play.api.mvc.Results 11 | import play.api.test.Helpers._ 12 | import play.api.test.{FakeRequest, StubControllerComponentsFactory} 13 | 14 | class PageletActionBuilderTest extends AnyFlatSpec with Matchers with FutureHelper with EitherValues with MockFactory with StubControllerComponentsFactory { 15 | 16 | implicit val system = ActorSystem() 17 | implicit val ec = system.dispatcher 18 | implicit val request = FakeRequest() 19 | 20 | val tools = new PageletActionBuilderImpl {} 21 | val Action = stubControllerComponents().actionBuilder 22 | 23 | "ActionService#execute" should 24 | "produce an Action if FunctionInfo's types fit the args with primitive args" in { 25 | 26 | def fnc(s: String) = Action(Results.Ok(s)) 27 | 28 | val info = FunctionInfo(fnc _, ("s", "java.lang.String") :: Nil) 29 | 30 | val args = Seq(Arg("s", "Hello!")) 31 | 32 | val action = tools.actionService.execute(PageletId("someId"), info, args).toOption.get 33 | contentAsString(action(request)) should equal("Hello!") 34 | } 35 | 36 | it should "produce an Action if FunctionInfo's types fit the args with optional args" in { 37 | 38 | def fnc(o: Option[String]) = Action(Results.Ok(o.toString)) 39 | 40 | val info = FunctionInfo(fnc _, ("o", "scala.Option") :: Nil) 41 | 42 | val args = Seq(Arg("o", Some("optional"))) 43 | 44 | val action = tools.actionService.execute(PageletId("someId"), info, args).toOption.get 45 | contentAsString(action(request)) should equal("Some(optional)") 46 | } 47 | 48 | it should "produce an Action if FunctionInfo's types fit the args with multiple different args" in { 49 | 50 | def fnc(i: Int, o: Option[String], custom: Test2) = Action(Results.Ok(i.toString + o.toString + custom.toString)) 51 | 52 | val info = FunctionInfo(fnc _, ("i", "scala.Int") :: ("o", "scala.Option") :: ("custom", "org.splink.pagelets.PageletActionBuilderTest.Test2") :: Nil) 53 | 54 | val args = Seq(Arg("i", 1), Arg("o", Some("optional")), Arg("custom", Test2("custom"))) 55 | 56 | val action = tools.actionService.execute(PageletId("someId"), info, args).toOption.get 57 | contentAsString(action(request)) should equal("1Some(optional)Test2(custom)") 58 | } 59 | 60 | it should "produce a TypeException if FunctionInfo's types do not fit the supplied args" in { 61 | 62 | def fnc(s: String) = Action(Results.Ok(s)) 63 | 64 | val info = FunctionInfo(fnc _, ("s", "java.lang.String") :: Nil) 65 | 66 | val result = tools.actionService.execute(PageletId("someId"), info, Seq.empty).swap.toOption.get 67 | 68 | result shouldBe a[TypeException] 69 | result.getMessage should include("someId") 70 | result.getMessage should include("s:java.lang.String' not found in Arguments()") 71 | } 72 | 73 | it should "produce a TypeException if FunctionInfo.fnc requires more arguments then the execute function supports" in { 74 | 75 | def fnc(s0: String, s1: String, s2: String, s3: String, s4: String, s5: String, 76 | s6: String, s7: String, s8: String, s9: String, s10: String) = Action(Results.Ok(s0)) 77 | 78 | val info = FunctionInfo(fnc _, 79 | ("s0", "java.lang.String") :: 80 | ("s1", "java.lang.String") :: 81 | ("s2", "java.lang.String") :: 82 | ("s3", "java.lang.String") :: 83 | ("s4", "java.lang.String") :: 84 | ("s5", "java.lang.String") :: 85 | ("s6", "java.lang.String") :: 86 | ("s7", "java.lang.String") :: 87 | ("s8", "java.lang.String") :: 88 | ("s9", "java.lang.String") :: 89 | ("s10", "java.lang.String") :: Nil) 90 | 91 | val args = Seq(Arg("s0", "s0"), Arg("s1", "s1"), Arg("s2", "s2"), Arg("s3", "s3"), Arg("s4", "s4"), 92 | Arg("s5", "s5"), Arg("s6", "s6"), Arg("s7", "s7"), Arg("s8", "s8"), Arg("s9", "s9"), Arg("s10", "s10")) 93 | 94 | val result = tools.actionService.execute(PageletId("someId"), info, args).swap.toOption.get 95 | 96 | result shouldBe a[TypeException] 97 | result.getMessage should include("someId") 98 | result.getMessage should include("too many arguments: 11") 99 | } 100 | 101 | def actionService = tools.actionService.asInstanceOf[PageletActionBuilderImpl#ActionServiceImpl] 102 | 103 | "ActionService#values" should "extract the Arg values if the FunctionInfo.types align with the args" in { 104 | def fnc(s: String, d: Double) = s + d 105 | 106 | val info = FunctionInfo(fnc _, ("s", "java.lang.String") ::("d", "scala.Double") :: Nil) 107 | val args = Seq(Arg("s", "hello"), Arg("d", 1d)) 108 | 109 | actionService.values(info, args).toOption.get should equal(Seq("hello", 1d)) 110 | } 111 | 112 | it should "extract the Arg values if there are more args then types" in { 113 | def fnc(s: String, d: Double) = s + d 114 | 115 | val info = FunctionInfo(fnc _, ("s", "java.lang.String") ::("d", "scala.Double") :: Nil) 116 | val args = Seq(Arg("s", "hello"), Arg("d", 1d), Arg("i", 1)) 117 | 118 | actionService.values(info, args).toOption.get should equal(Seq("hello", 1d)) 119 | } 120 | 121 | it should "yield an ArgError if the types do not match" in { 122 | def fnc(s: String, d: Double) = s + d 123 | 124 | val info = FunctionInfo(fnc _, ("s", "java.lang.String") ::("d", "scala.Int") :: Nil) 125 | val args = Seq(Arg("s", "hello"), Arg("d", 1d)) 126 | 127 | actionService.values(info, args).left.value.msg should equal( 128 | "'d:scala.Int' not found in Arguments(s:java.lang.String,d:scala.Double)") 129 | } 130 | 131 | it should "yield an ArgError if the names do not match" in { 132 | def fnc(s: String, d: Double) = s + d 133 | 134 | val info = FunctionInfo(fnc _, ("s", "java.lang.String") ::("i", "scala.Double") :: Nil) 135 | val args = Seq(Arg("s", "hello"), Arg("d", 1d)) 136 | 137 | actionService.values(info, args).left.value.msg should equal( 138 | "'i:scala.Double' not found in Arguments(s:java.lang.String,d:scala.Double)" 139 | ) 140 | } 141 | 142 | it should "yield an ArgError if args are missing" in { 143 | def fnc(s: String, d: Double) = s + d 144 | 145 | val info = FunctionInfo(fnc _, ("s", "java.lang.String") ::("d", "scala.Double") :: Nil) 146 | val args = Seq(Arg("s", "hello")) 147 | 148 | actionService.values(info, args).left.value.msg should equal( 149 | "'d:scala.Double' not found in Arguments(s:java.lang.String)" 150 | ) 151 | } 152 | 153 | "ActionService#scalaClassNameFor" should "return the classname for Int" in { 154 | val name = actionService.scalaClassNameFor(1) 155 | name should equal("scala.Int") 156 | } 157 | 158 | it should "return the classname for String" in { 159 | val name = actionService.scalaClassNameFor("123") 160 | name should equal("java.lang.String") 161 | } 162 | 163 | it should "return the classname for Double" in { 164 | val name = actionService.scalaClassNameFor(1d) 165 | name should equal("scala.Double") 166 | } 167 | 168 | it should "return the classname for Float" in { 169 | val name = actionService.scalaClassNameFor(1f) 170 | name should equal("scala.Float") 171 | } 172 | 173 | it should "return the classname for Long" in { 174 | val name = actionService.scalaClassNameFor(1L) 175 | name should equal("scala.Long") 176 | } 177 | 178 | it should "return the classname for Short" in { 179 | val name = actionService.scalaClassNameFor(1.toShort) 180 | name should equal("scala.Short") 181 | } 182 | 183 | it should "return the classname for Byte" in { 184 | val name = actionService.scalaClassNameFor(1.toByte) 185 | name should equal("scala.Byte") 186 | } 187 | 188 | it should "return the classname for Boolean" in { 189 | val name = actionService.scalaClassNameFor(true) 190 | name should equal("scala.Boolean") 191 | } 192 | 193 | it should "return the classname for Char" in { 194 | val name = actionService.scalaClassNameFor('a') 195 | name should equal("scala.Char") 196 | } 197 | 198 | it should "return the classname for PageletId" in { 199 | val name = actionService.scalaClassNameFor(PageletId("someId")) 200 | name should equal("org.splink.pagelets.PageletId") 201 | } 202 | 203 | it should "return 'undefined' for any local class without a canonical name" in { 204 | case class Test(name: String) 205 | val name = actionService.scalaClassNameFor(Test("yo")) 206 | name should equal("undefined") 207 | } 208 | 209 | case class Test2(name: String) 210 | 211 | it should "return the classname for any custom class" in { 212 | val name = actionService.scalaClassNameFor(Test2("yo")) 213 | name should equal("org.splink.pagelets.PageletActionBuilderTest.Test2") 214 | } 215 | 216 | it should "return the Option classname Some[_]" in { 217 | val name = actionService.scalaClassNameFor(Option("yo")) 218 | name should equal("scala.Option") 219 | } 220 | 221 | it should "return the Option classname for None" in { 222 | val name = actionService.scalaClassNameFor(None) 223 | name should equal("scala.Option") 224 | } 225 | 226 | "ActionService#eitherSeq" should "convert the whole Seq if there are no Left" in { 227 | val xs = Seq(Right("One"), Right("Two"), Right("Three")) 228 | 229 | val result = actionService.eitherSeq(xs) 230 | result should equal(Right(Seq("One", "Two", "Three"))) 231 | } 232 | 233 | it should "produce the last Left if the given Seq contains one" in { 234 | val xs = Seq(Right("One"), Left("Oops"), Left("Oops2"), Right("four")) 235 | 236 | val result = actionService.eitherSeq(xs) 237 | result should equal(Left("Oops2")) 238 | } 239 | 240 | } 241 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/PageletActionsTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.actor.ActorSystem 4 | import akka.stream.scaladsl.Source 5 | import akka.util.ByteString 6 | import org.scalamock.scalatest.MockFactory 7 | import org.scalatestplus.play.PlaySpec 8 | import org.scalatestplus.play.guice.GuiceOneAppPerSuite 9 | import play.api.Environment 10 | import play.api.mvc._ 11 | import play.api.test.{FakeRequest, StubControllerComponentsFactory} 12 | import play.api.test.Helpers._ 13 | import play.twirl.api.Html 14 | 15 | import scala.concurrent.Future 16 | import scala.language.{implicitConversions, reflectiveCalls} 17 | 18 | class PageletActionsTest extends PlaySpec with GuiceOneAppPerSuite with MockFactory with StubControllerComponentsFactory { 19 | implicit val system = ActorSystem() 20 | implicit val env = Environment.simple() 21 | implicit val request = FakeRequest() 22 | 23 | def actions = new PageletActionsImpl with BaseController with PageBuilder with TreeTools with Resources { 24 | 25 | override def controllerComponents: ControllerComponents = stubControllerComponents() 26 | 27 | override val builder: PageBuilderService = mock[PageBuilderService] 28 | 29 | val opsMock = mock[TreeOps] 30 | override implicit def treeOps(tree: Tree): TreeOps = opsMock 31 | 32 | override val resources: ResourceProvider = mock[ResourceProvider] 33 | (resources.update(_: Seq[Resource])(_: Environment)).expects(*, *). 34 | returning(Some(Fingerprint("print"))). 35 | anyNumberOfTimes() 36 | } 37 | 38 | def leaf = Leaf(PageletId("id"), null) 39 | def tree(r: RequestHeader) = Tree(PageletId("id"), Seq.empty) 40 | def title(r: RequestHeader) = "Title" 41 | 42 | def mkResult(body: String) = PageletResult(Source.single(ByteString(body))) 43 | 44 | def buildMock(service: PageBuilder#PageBuilderService)(result: PageletResult) = 45 | (service.build(_: Pagelet, _: Arg)(_: Request[AnyContent])).expects(*, *, *).returning(result).anyNumberOfTimes() 46 | 47 | val onError = Call("get", "error") 48 | 49 | "PageletAction" should { 50 | "return a Pagelet if the tree contains the pagelet for the given id" in { 51 | val a = actions 52 | (a.opsMock.find _).expects(PageletId("one")).returning(Some(leaf)) 53 | 54 | buildMock(a.builder)(mkResult("body")) 55 | 56 | val action = a.PageletAction.async(onError)(tree, PageletId("one")) { (_, page) => 57 | Html(s"${page.body}") 58 | } 59 | 60 | val result = action(request) 61 | 62 | status(result) must equal(OK) 63 | contentAsString(result) must equal("body") 64 | } 65 | 66 | "return NotFound if the tree does not contain a pagelet for the given id" in { 67 | val a = actions 68 | (a.opsMock.find _).expects(PageletId("one")).returning(None) 69 | buildMock(a.builder)(mkResult("body")) 70 | 71 | val action = a.PageletAction.async(onError)(tree, PageletId("one")) { (_, page) => 72 | Html(s"${page.body}") 73 | } 74 | 75 | val result = action(request) 76 | status(result) must equal(NOT_FOUND) 77 | contentAsString(result) must include("one") 78 | contentAsString(result) must include("does not exist") 79 | } 80 | 81 | "redirect if a pagelet declared as mandatory fails" in { 82 | val a = actions 83 | (a.opsMock.find _).expects(PageletId("one")).returning(Some(leaf)) 84 | buildMock(a.builder)(mkResult("").copy(mandatoryFailedPagelets = Seq(Future.successful(true)))) 85 | 86 | val action = a.PageletAction.async(onError)(tree, PageletId("one")) { (_, page) => 87 | Html(s"${page.body}") 88 | } 89 | 90 | val result = action(request) 91 | status(result) must equal(TEMPORARY_REDIRECT) 92 | } 93 | 94 | } 95 | 96 | "PageAction#async" should { 97 | "return a Page" in { 98 | val a = actions 99 | buildMock(a.builder)(mkResult("body")) 100 | 101 | val action = a.PageAction.async(onError)(title, tree) { (request, page) => 102 | Html(s"${page.body}${title(request)}") 103 | } 104 | 105 | val result = action(request) 106 | 107 | status(result) must equal(OK) 108 | contentAsString(result) must equal("bodyTitle") 109 | } 110 | 111 | "invoke the error template if a pagelet declared as mandatory fails" in { 112 | val a = actions 113 | buildMock(a.builder)(mkResult("").copy(mandatoryFailedPagelets = Seq(Future.successful(true)))) 114 | 115 | val action = a.PageAction.async(onError)(title, tree) { (request, page) => 116 | Html(s"${page.body}${title(request)}") 117 | } 118 | 119 | val result = action(request) 120 | status(result) must equal(TEMPORARY_REDIRECT) 121 | } 122 | 123 | "return the corresponding headers alongside the Page" in { 124 | val a = actions 125 | buildMock(a.builder)( 126 | mkResult("body").copy( 127 | results = Seq( 128 | Future.successful((None, None, Seq(Cookie("name", "value")))), 129 | Future.successful((None, None, Seq(Cookie("name1", "value")))) 130 | ))) 131 | 132 | val action = a.PageAction.async(onError)(title, tree) { (request, page) => 133 | Html(s"${page.body}${title(request)}") 134 | } 135 | 136 | val result = action(request) 137 | 138 | status(result) must equal(OK) 139 | contentAsString(result) must equal("bodyTitle") 140 | cookies(result) must contain theSameElementsAs(Cookies(Seq(Cookie("name", "value"), Cookie("name1", "value")))) 141 | } 142 | 143 | } 144 | 145 | "PageAction#stream" should { 146 | "return a Page" in { 147 | val a = actions 148 | buildMock(a.builder)(mkResult("body")) 149 | 150 | val action = a.PageAction.stream(title, tree) { (request, page) => 151 | page.body.map(b => Html(b.utf8String + title(request))) 152 | } 153 | 154 | val result = action(request) 155 | status(result) must equal(OK) 156 | contentAsString(result) must equal("bodyTitle") 157 | } 158 | 159 | // when the page is streamed, it's too late to redirect 160 | "return a Page if a pagelet declared as mandatory fails" in { 161 | val a = actions 162 | buildMock(a.builder)(mkResult("body").copy(mandatoryFailedPagelets = Seq(Future.successful(true)))) 163 | 164 | val action = a.PageAction.stream(title, tree) { (request, page) => 165 | page.body.map(b => Html(b.utf8String + title(request))) 166 | } 167 | 168 | val result = action(request) 169 | status(result) must equal(OK) 170 | contentAsString(result) must equal("bodyTitle") 171 | } 172 | 173 | "return a Page with Cookies" in { 174 | val a = actions 175 | buildMock(a.builder)( 176 | mkResult("body").copy( 177 | results = Seq(Future.successful((None, None, Seq(Cookie("name", "value"))))))) 178 | 179 | val action = a.PageAction.stream(title, tree) { (request, page) => 180 | page.body.map(b => Html(b.utf8String + title(request))) 181 | } 182 | 183 | val result = action(request) 184 | status(result) must equal(OK) 185 | contentAsString(result) must include("bodyTitle") 186 | contentAsString(result) must include("setCookie('name', 'value'") 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/PageletTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.actor.ActorSystem 4 | import akka.stream.scaladsl.Source 5 | import akka.util.ByteString 6 | import helpers.FutureHelper 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatest.flatspec.AnyFlatSpec 9 | import play.api.mvc.{Cookie, Flash, Session} 10 | 11 | import scala.concurrent.Future 12 | 13 | class PageletTest extends AnyFlatSpec with Matchers with FutureHelper { 14 | implicit val system = ActorSystem() 15 | 16 | "Leaf#equals" should "identify equal Leaf nodes" in { 17 | val fnc = () => "someFunction" 18 | val a = Leaf(PageletId("one"), FunctionInfo(fnc, Nil)) 19 | val b = Leaf(PageletId("one"), FunctionInfo(fnc, Nil)) 20 | 21 | a should equal(b) 22 | } 23 | 24 | it should "identify unequal Leaf nodes" in { 25 | val fnc = () => "someFunction" 26 | val a = Leaf(PageletId("one"), FunctionInfo(fnc, Nil)) 27 | val b = Leaf(PageletId("two"), FunctionInfo(fnc, Nil)) 28 | 29 | a should not equal b 30 | } 31 | 32 | "Tree#equals" should "identify equal Tree nodes" in { 33 | val a = Tree(PageletId("one"), Seq.empty, Tree.combine) 34 | val b = Tree(PageletId("one"), Seq.empty, Tree.combine) 35 | 36 | a should equal(b) 37 | } 38 | 39 | it should "identify unequal Tree nodes" in { 40 | val a = Tree(PageletId("one"), Seq.empty, Tree.combine) 41 | val b = Tree(PageletId("two"), Seq.empty, Tree.combine) 42 | 43 | a should not equal b 44 | } 45 | 46 | it should "identify equal Tree nodes when nested" in { 47 | val fnc = () => "someFunction" 48 | val l1 = Leaf(PageletId("one"), FunctionInfo(fnc, Nil)) 49 | val l2 = Leaf(PageletId("two"), FunctionInfo(fnc, Nil)) 50 | 51 | val a = Tree(PageletId("one"), Seq(l1, l2)) 52 | val b = Tree(PageletId("one"), Seq(l1, l2)) 53 | 54 | a should equal(b) 55 | } 56 | 57 | it should "identify unequal Tree nodes when nested" in { 58 | val fnc = () => "someFunction" 59 | val l1 = Leaf(PageletId("one"), FunctionInfo(fnc, Nil)) 60 | val l2 = Leaf(PageletId("two"), FunctionInfo(fnc, Nil)) 61 | 62 | val a = Tree(PageletId("one"), Seq(l1, l2)) 63 | val b = Tree(PageletId("one"), Seq(l1)) 64 | 65 | a should not equal b 66 | } 67 | 68 | it should "identify unequal Tree nodes when nested (2)" in { 69 | val fnc = () => "someFunction" 70 | val l1 = Leaf(PageletId("one"), FunctionInfo(fnc, Nil)) 71 | val l2 = Leaf(PageletId("two"), FunctionInfo(fnc, Nil)) 72 | val l3 = Leaf(PageletId("three"), FunctionInfo(fnc, Nil)) 73 | 74 | val a = Tree(PageletId("one"), Seq(l1, l2)) 75 | val b = Tree(PageletId("one"), Seq(l1, l3)) 76 | 77 | a should not equal b 78 | } 79 | 80 | "Tree#copy" should "copy the whole tree" in { 81 | def combine(results: Seq[PageletResult]) = Tree.combine(results) 82 | val combineFnc = combine _ 83 | 84 | val a = Tree(PageletId("one"), Seq.empty, combineFnc) 85 | val b = a.copy(id = PageletId("two")) 86 | 87 | a.id should equal(PageletId("one")) 88 | b.id should equal(PageletId("two")) 89 | b.children should equal(Seq.empty) 90 | b.combine should equal(combineFnc) 91 | } 92 | 93 | "Tree#combine" should "combine all PageResult properties" in { 94 | val r1 = PageletResult( 95 | Source.single(ByteString("body")), 96 | Seq(Javascript("src.js")), 97 | Seq(Javascript("src-top.js")), 98 | Seq(Css("src.css")), 99 | Seq(Future.successful { 100 | (Some(Flash(Map("f" -> "g"))), Some(Session(Map("a" -> "b", "a" -> "c"))), Seq(Cookie("name", "value"))) 101 | }), 102 | Seq(MetaTag("name", "content")), 103 | Seq(Future.successful(true))) 104 | 105 | val r2 = PageletResult( 106 | Source.single(ByteString("body2")), 107 | Seq(Javascript("src2.js")), 108 | Seq(Javascript("src2-top.js")), 109 | Seq(Css("src2.css")), 110 | Seq(Future.successful { 111 | (None, Some(Session(Map("a" -> "b1", "b" -> "c"))), Seq(Cookie("name2", "value"))) 112 | }), 113 | Seq(MetaTag("name2", "content")), 114 | Seq(Future.successful(false))) 115 | 116 | val result = Tree.combine(Seq(r1, r2)) 117 | result.js should equal(Seq(Javascript("src.js"), Javascript("src2.js"))) 118 | result.jsTop should equal(Seq(Javascript("src-top.js"), Javascript("src2-top.js"))) 119 | result.css should equal(Seq(Css("src.css"), Css("src2.css"))) 120 | 121 | result.results.map(_.futureValue) should equal { 122 | Seq((Some(Flash(Map("f" -> "g"))), Some(Session(Map("a" -> "b", "a" -> "c"))), Seq(Cookie("name", "value"))), 123 | (None, Some(Session(Map("a" -> "b1", "b" -> "c"))), Seq(Cookie("name2", "value")))) 124 | } 125 | 126 | result.metaTags should equal(Seq(MetaTag("name", "content"), MetaTag("name2", "content"))) 127 | result.mandatoryFailedPagelets.map(_.futureValue) should equal(Seq(true, false)) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/RequestIdTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.flatspec.AnyFlatSpec 5 | 6 | class RequestIdTest extends AnyFlatSpec with Matchers { 7 | "RequestId.create" should "create a 6 char request id wrapped in brackets" in { 8 | RequestId.create.id should (startWith ("[") and endWith ("]") and have length 8) 9 | } 10 | 11 | "RequestId.toString" should "return the same as RequestId.id" in { 12 | val requestId = RequestId.create 13 | requestId.id should equal(requestId.toString) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/ResourceActionsTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import org.scalamock.scalatest.MockFactory 4 | import org.scalatestplus.play._ 5 | import play.api.Environment 6 | import play.api.http.HeaderNames 7 | import play.api.mvc.{BaseController, ControllerComponents} 8 | import play.api.test.{FakeRequest, StubControllerComponentsFactory} 9 | import play.api.test.Helpers._ 10 | 11 | import scala.language.reflectiveCalls 12 | 13 | class ResourceActionsTest extends PlaySpec with MockFactory with StubControllerComponentsFactory { 14 | implicit val env = Environment.simple() 15 | 16 | def actions = new ResourceActionsImpl with Resources with BaseController { 17 | override def controllerComponents: ControllerComponents = stubControllerComponents() 18 | override val resources: ResourceProvider = mock[ResourceProvider] 19 | } 20 | 21 | val print = Fingerprint("hash") 22 | val request = FakeRequest() 23 | 24 | "ResourceAction" should { 25 | "return the resource with status Ok for a known fingerprint" in { 26 | val a = actions 27 | 28 | (a.resources.contentFor _).expects(print).returning { 29 | Some(ResourceContent("""console.log("a");""", JsMimeType)) 30 | } 31 | 32 | val result = a.ResourceAction(print.toString)(request) 33 | 34 | status(result) must equal(OK) 35 | contentType(result) must equal(Some(JsMimeType.name)) 36 | contentAsString(result) must equal("""console.log("a");""") 37 | } 38 | 39 | "return BadRequest if the fingerprint is unknown" in { 40 | val a = actions 41 | (a.resources.contentFor _).expects(*).returning(None) 42 | 43 | val result = a.ResourceAction("something")(request) 44 | status(result) must equal(BAD_REQUEST) 45 | } 46 | 47 | "return headers with etag" in { 48 | val a = actions 49 | (a.resources.contentFor _).expects(print).returning { 50 | Some(ResourceContent("""console.log("a");""", JsMimeType)) 51 | } 52 | 53 | val result = a.ResourceAction(print.toString)(request) 54 | 55 | header(HeaderNames.ETAG, result) must equal(Some(s""""$print"""")) 56 | } 57 | 58 | "return NotModified if the server holds a resource for the fingerprint in the etag (IF_NONE_MATCH) header" in { 59 | val a = actions 60 | (a.resources.contains _).expects(print).returning(true) 61 | 62 | val rwh = request.withHeaders(HeaderNames.IF_NONE_MATCH -> print.toString) 63 | val result = a.ResourceAction(print.toString)(rwh) 64 | status(result) must equal(NOT_MODIFIED) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/ResourceTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.flatspec.AnyFlatSpec 5 | 6 | class ResourceTest extends AnyFlatSpec with Matchers { 7 | 8 | "Javascript.name" should "return 'js'" in { 9 | Javascript.name should equal("js") 10 | } 11 | 12 | "Javascript.nameTop" should "return 'jsTop'" in { 13 | Javascript.nameTop should equal("jsTop") 14 | } 15 | 16 | "Css.name" should "return 'css'" in { 17 | Css.name should equal("css") 18 | } 19 | 20 | "MetaTag.name" should "return 'meta'" in { 21 | MetaTag.name should equal("meta") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/ResourcesTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.flatspec.AnyFlatSpec 5 | import org.scalatest.BeforeAndAfter 6 | import play.api.{Environment, Mode} 7 | 8 | import scala.language.reflectiveCalls 9 | 10 | class ResourcesTest extends AnyFlatSpec with Matchers with BeforeAndAfter { 11 | 12 | implicit val env = Environment.simple() 13 | 14 | val resources = new ResourcesImpl {}.resources 15 | 16 | before { 17 | resources.clear() 18 | } 19 | 20 | def mkFingerprint = resources.update(Seq(Javascript("a.js"), Javascript("b.js"))) 21 | val expectedPrint = Fingerprint(mkFingerprint.get.toString) 22 | 23 | "Resources#update" should "return a fingerprint for a Set of resources" in { 24 | mkFingerprint shouldBe Some(expectedPrint) 25 | } 26 | 27 | it should "return a fingerprint for a Set of resources, even if one of the resources is missing" in { 28 | val print = resources.update(Seq(Javascript("a.js"), Javascript("b.js"), Javascript("missing.js"))) 29 | print shouldBe Some(expectedPrint) 30 | } 31 | 32 | "Resources#contains" should "return true, if there is an assembled Resource for the fingerprint" in { 33 | mkFingerprint 34 | resources.contains(expectedPrint) shouldBe true 35 | } 36 | 37 | it should "return false, if there is no resource for the fingerprint" in { 38 | resources.contains(expectedPrint) shouldBe false 39 | } 40 | 41 | "Resources.assemble" should "combine Javascript resources and filter duplicates" in { 42 | val s = Seq(Javascript("a.js"), Javascript("a.js"), Javascript("b.js")) 43 | resources.assemble(s) shouldBe ResourceContent( 44 | """console.log("a"); 45 | |console.log("b"); 46 | |""".stripMargin, JsMimeType) 47 | } 48 | 49 | it should "combine Css resources and filter duplicates" in { 50 | val s = Seq(Css("a.css"), Css("a.css")) 51 | resources.assemble(s) shouldBe ResourceContent( 52 | """body { 53 | | text-align: center; 54 | |} 55 | |""".stripMargin, CssMimeType) 56 | } 57 | 58 | "Resources.contentFor" should "return the content and mime type, if there is an assembled Resource for the fingerprint" in { 59 | mkFingerprint 60 | resources.contentFor(expectedPrint) shouldBe Some(ResourceContent( 61 | """console.log("a"); 62 | |console.log("b"); 63 | |""".stripMargin, JsMimeType)) 64 | } 65 | 66 | it should "return None if there is no assembled resource for the fingerprint" in { 67 | resources.contentFor(expectedPrint) shouldBe None 68 | } 69 | 70 | it should "return None, if there is no resource for the fingerprint" in { 71 | resources.contentFor(expectedPrint) shouldBe None 72 | } 73 | 74 | "Resources#load" should "load an existing Javascript resource and detect it's mime type" in { 75 | resources.load(Javascript("a.js")) shouldBe Some( 76 | ResourceContent( 77 | """console.log("a"); 78 | |""".stripMargin, JsMimeType)) 79 | } 80 | 81 | it should "return None if the requested resource does not exist" in { 82 | resources.load(Javascript("missing.js")) shouldBe None 83 | } 84 | 85 | it should "load an existing Css resource and detect it's mime type" in { 86 | resources.load(Css("a.css")) shouldBe Some( 87 | ResourceContent( 88 | """body { 89 | | text-align: center; 90 | |} 91 | |""".stripMargin, CssMimeType)) 92 | } 93 | 94 | "Resources#maybeCachedContent" should "return from cache if the resource is cached and we're in prod mode" in { 95 | val e = Environment.simple(mode = Mode.Prod) 96 | mkFingerprint 97 | resources.maybeCachedContent(Javascript("a.js"))(e) shouldBe Some( 98 | ResourceContent( 99 | """console.log("a"); 100 | |""".stripMargin, JsMimeType)) 101 | } 102 | 103 | it should "not return from cache if we're not in prod mode" in { 104 | val e = Environment.simple(mode = Mode.Dev) 105 | mkFingerprint 106 | resources.maybeCachedContent(Javascript("a.js"))(e) shouldBe None 107 | } 108 | 109 | it should "not return from cache if the item is not cached" in { 110 | val e = Environment.simple(mode = Mode.Prod) 111 | resources.maybeCachedContent(Javascript("a.js"))(e) shouldBe None 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/TreeToolsTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import akka.actor.ActorSystem 4 | import helpers.FutureHelper 5 | import org.scalatest.matchers.should.Matchers 6 | import org.scalatest.flatspec.AnyFlatSpec 7 | import play.api.mvc._ 8 | import play.api.test.{FakeRequest, StubControllerComponentsFactory} 9 | 10 | class TreeToolsTest extends AnyFlatSpec with Matchers with FutureHelper with StubControllerComponentsFactory { 11 | 12 | import FunctionMacros._ 13 | 14 | implicit val system = ActorSystem() 15 | implicit val ec = system.dispatcher 16 | 17 | val Action = stubControllerComponents().actionBuilder 18 | 19 | val action = () => Action(Results.Ok("action")) 20 | 21 | def treeOps = opsify(Tree( 22 | PageletId("root"), Seq( 23 | Leaf(PageletId("child1"), action), 24 | Leaf(PageletId("child2"), action), 25 | Tree(PageletId("child3"), Seq( 26 | Leaf(PageletId("child31"), action) 27 | )) 28 | ) 29 | )) 30 | 31 | def opsify(t: Tree) = new TreeToolsImpl with BaseController { 32 | override def controllerComponents: ControllerComponents = stubControllerComponents() 33 | }.treeOps(t) 34 | 35 | "TreeTools#find" should "find a Leaf in the tree" in { 36 | treeOps.find(PageletId("child31")) should equal( 37 | Some( 38 | Leaf(PageletId("child31"), action) 39 | ) 40 | ) 41 | } 42 | 43 | it should "find a Tree in the tree" in { 44 | treeOps.find(PageletId("child3")) should equal( 45 | Some( 46 | Tree(PageletId("child3"), Seq( 47 | Leaf(PageletId("child31"), action) 48 | )) 49 | ) 50 | ) 51 | } 52 | 53 | it should "find the root of the tree" in { 54 | treeOps.find(PageletId("root")) should equal( 55 | Some( 56 | Tree( 57 | PageletId("root"), Seq( 58 | Leaf(PageletId("child1"), action), 59 | Leaf(PageletId("child2"), action), 60 | Tree(PageletId("child3"), Seq( 61 | Leaf(PageletId("child31"), action) 62 | )) 63 | ) 64 | ) 65 | ) 66 | ) 67 | } 68 | 69 | "TreeTools#skip" should "replace the part with the given id with a Leaf with contains a call to an empty Action" in { 70 | def bodyOf(action: Action[AnyContent]) = { 71 | val result = action(FakeRequest()).futureValue 72 | val body = result.body.consumeData.map(_.utf8String).futureValue 73 | body 74 | } 75 | 76 | def actionFor(t: Tree)(id: PageletId) = opsify(t).find(id).map { part => 77 | part.asInstanceOf[Leaf[_, _]].info.fnc.asInstanceOf[() => Action[AnyContent]]() 78 | } 79 | 80 | val newTree = treeOps.skip(PageletId("child3")) 81 | val body = actionFor(newTree)(PageletId("child3")).map(bodyOf) 82 | 83 | body should equal(Some("")) 84 | } 85 | 86 | "TreeTools#replace" should "replace the part with the given id with another Tree" in { 87 | val newTree = treeOps.replace(PageletId("child3"), Tree(PageletId("new"), Seq( 88 | Leaf(PageletId("newChild1"), action), 89 | Leaf(PageletId("newChild2"), action) 90 | ))) 91 | 92 | opsify(newTree).find(PageletId("new")) should equal( 93 | Some( 94 | Tree(PageletId("new"), Seq( 95 | Leaf(PageletId("newChild1"), action), 96 | Leaf(PageletId("newChild2"), action) 97 | )) 98 | ) 99 | ) 100 | } 101 | 102 | it should "replace the root with a different Tree" in { 103 | val newTree = treeOps.replace(PageletId("root"), Tree(PageletId("new"), Seq( 104 | Leaf(PageletId("newChild1"), action), 105 | Leaf(PageletId("newChild2"), action) 106 | ))) 107 | 108 | opsify(newTree).find(PageletId("root")) shouldBe None 109 | 110 | opsify(newTree).find(PageletId("new")) should equal( 111 | Some( 112 | Tree(PageletId("new"), Seq( 113 | Leaf(PageletId("newChild1"), action), 114 | Leaf(PageletId("newChild2"), action) 115 | )) 116 | ) 117 | ) 118 | } 119 | 120 | it should "return a Tree with one Leaf when asked to replace the root with a Leaf" in { 121 | // root must be a Tree, only then one can chain TreeTools function like tree.replace(...).skip(..).replace( 122 | val fnc = () => "someFunction" 123 | val newTree = treeOps.replace(PageletId("root"), Leaf(PageletId("new"), FunctionInfo(fnc, Nil))) 124 | 125 | opsify(newTree).find(PageletId("new")) should equal( 126 | Some( 127 | Leaf(PageletId("new"), FunctionInfo(fnc, Nil) 128 | )) 129 | ) 130 | 131 | opsify(newTree).find(PageletId("root")) should equal( 132 | Some( 133 | Tree(PageletId("root"), Seq( 134 | Leaf(PageletId("new"), FunctionInfo(fnc, Nil)) 135 | )) 136 | ) 137 | ) 138 | } 139 | 140 | "TreeTools#filter" should "filter all pagelets and their children for the given ids" in { 141 | treeOps.filter(_.id != PageletId("child3")) should equal( 142 | Tree(PageletId("root"), Seq( 143 | Leaf(PageletId("child1"), action), 144 | Leaf(PageletId("child2"), action) 145 | )) 146 | ) 147 | } 148 | 149 | it should "filter a single pagelet leaf for the given id" in { 150 | treeOps.filter(_.id != PageletId("child2")) should equal( 151 | Tree(PageletId("root"), Seq( 152 | Leaf(PageletId("child1"), action), 153 | Tree(PageletId("child3"), Seq( 154 | Leaf(PageletId("child31"), action) 155 | )) 156 | )) 157 | ) 158 | } 159 | 160 | it should "not filter the root node" in { 161 | treeOps.filter(_.id != PageletId("root")) should equal( 162 | Tree(PageletId("root"), Seq( 163 | Leaf(PageletId("child1"), action), 164 | Leaf(PageletId("child2"), action), 165 | Tree(PageletId("child3"), Seq( 166 | Leaf(PageletId("child31"), action) 167 | )) 168 | )) 169 | ) 170 | } 171 | 172 | it should "filter multiple nodes" in { 173 | treeOps.filter(!_.id.name.startsWith("child")) should equal( 174 | Tree(PageletId("root"), Seq.empty) 175 | ) 176 | } 177 | 178 | } 179 | -------------------------------------------------------------------------------- /src/test/scala/org/splink/pagelets/VisualizerTest.scala: -------------------------------------------------------------------------------- 1 | package org.splink.pagelets 2 | 3 | import org.scalatest.matchers.should.Matchers 4 | import org.scalatest.flatspec.AnyFlatSpec 5 | import play.api.mvc.Results 6 | import play.api.test.StubControllerComponentsFactory 7 | 8 | class VisualizerTest extends AnyFlatSpec with Matchers with StubControllerComponentsFactory { 9 | 10 | import FunctionMacros._ 11 | 12 | val Action = stubControllerComponents().actionBuilder 13 | 14 | def action(s: String) = () => Action(Results.Ok(s)) 15 | def action2(s: String, i: Int) = Action(Results.Ok(s + i)) 16 | 17 | val tree = Tree(PageletId("root"), Seq( 18 | Leaf(PageletId("one"), action("one")), 19 | Tree(PageletId("two"), Seq( 20 | Leaf(PageletId("three"), action2 _), 21 | Leaf(PageletId("four"), action("four")) 22 | )) 23 | )) 24 | 25 | val visualizer = new VisualizerImpl {} 26 | 27 | "Visualizer#visualize" should "visualize a tree" in { 28 | visualizer.visualize(tree) should equal( 29 | """root 30 | |-one 31 | |-two 32 | |--three(s:String, i:Int) 33 | |--four 34 | |""".stripMargin) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / version := "0.0.12-SNAPSHOT" 2 | --------------------------------------------------------------------------------