├── .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 | [](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 | 
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 |
--------------------------------------------------------------------------------