├── public
├── images
│ └── favicon.png
├── stylesheets
│ └── main.css
└── javascripts
│ └── hello.js
├── app
├── views
│ ├── index.scala.html
│ ├── main.scala.html
│ └── demo.scala.html
├── controllers
│ └── Application.scala
└── demo
│ ├── DemoCorrelationIdExtractor.scala
│ ├── DemoController.scala
│ ├── DemoUserExtractor.scala
│ └── DemoResultFormatter.scala
├── .gitignore
├── project
├── build.properties
└── plugins.sbt
├── conf
├── routes
├── logback.xml
└── application.conf
├── LICENSE
├── test
├── IntegrationSpec.scala
└── ApplicationSpec.scala
└── README.md
/public/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/playframework/play-quota-scala-example/master/public/images/favicon.png
--------------------------------------------------------------------------------
/app/views/index.scala.html:
--------------------------------------------------------------------------------
1 | @(message: String)
2 |
3 | @main("Welcome to Play") {
4 |
5 | @play20.welcome(message)
6 |
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | logs
2 | project/typesafe.properties
3 | target
4 | /.idea
5 | /.idea_modules
6 | /.classpath
7 | /.project
8 | /.settings
9 | /RUNNING_PID
10 |
--------------------------------------------------------------------------------
/public/stylesheets/main.css:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright © 2016 Lightbend, Inc. All rights reserved.
3 | * No information contained herein may be reproduced or transmitted in any form or
4 | * by any means without the express written permission of Lightbend, Inc.
5 | */
6 |
7 |
--------------------------------------------------------------------------------
/public/javascripts/hello.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright © 2016 Lightbend, Inc. All rights reserved.
3 | * No information contained herein may be reproduced or transmitted in any form or
4 | * by any means without the express written permission of Lightbend, Inc.
5 | */
6 |
7 | if (window.console) {
8 | console.log("Welcome to your Play application's JavaScript!");
9 | }
10 |
--------------------------------------------------------------------------------
/app/controllers/Application.scala:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | //#quota-action
4 | import javax.inject._
5 | import play.api._
6 | import play.api.mvc._
7 | import play.quota.sapi._
8 |
9 | class Application @Inject() (quotaAction: QuotaAction) extends Controller {
10 |
11 | def index = quotaAction {
12 | Ok(views.html.index("Hello world!"))
13 | }
14 |
15 | }
16 | //#quota-action
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | #
2 | # Copyright © 2016 Lightbend, Inc. All rights reserved.
3 | # No information contained herein may be reproduced or transmitted in any form or
4 | # by any means without the express written permission of Lightbend, Inc.
5 | #
6 |
7 | #Activator-generated Properties
8 | #Mon Aug 31 06:02:50 UTC 2015
9 | template.uuid=94021735-c151-41eb-b5ca-6965ec4e663c
10 | sbt.version=0.13.11
11 |
--------------------------------------------------------------------------------
/conf/routes:
--------------------------------------------------------------------------------
1 | # Routes
2 | # This file defines all application routes (Higher priority routes first)
3 | # ~~~~
4 |
5 | # Home page
6 | GET / controllers.Application.index
7 |
8 | GET /demo demo.DemoController.index
9 |
10 | # Map static resources from the /public folder to the /assets URL path
11 | GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
12 |
--------------------------------------------------------------------------------
/app/demo/DemoCorrelationIdExtractor.scala:
--------------------------------------------------------------------------------
1 | package demo
2 |
3 | import com.lightbend.correlation._
4 | import play.api.mvc.RequestHeader
5 | import play.quota.sapi.correlation.CorrelationIdExtractor
6 |
7 | class DemoCorrelationIdExtractor extends CorrelationIdExtractor {
8 | def correlationIdForRequest(rh: RequestHeader): CorrelationId = {
9 | val id: String = "[" + System.nanoTime + " " + rh.method + " " + rh.path + "]"
10 | new StringCorrelationId(id)
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/demo/DemoController.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright © 2016 Lightbend, Inc. All rights reserved.
3 | * No information contained herein may be reproduced or transmitted in any form or
4 | * by any means without the express written permission of Lightbend, Inc.
5 | */
6 | package demo
7 |
8 | import javax.inject._
9 | import play.api._
10 | import play.api.mvc._
11 | import play.quota.sapi._
12 |
13 | class DemoController @Inject() (@Named("demo") quota: QuotaAction) extends Controller {
14 |
15 | def index = quota { Ok("Demo Action Result") }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/app/views/main.scala.html:
--------------------------------------------------------------------------------
1 | @(title: String)(content: Html)
2 |
3 |
4 |
5 |
6 |
7 | @title
8 |
9 |
10 |
11 |
12 |
13 | @content
14 |
15 |
16 |
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | // The Play plugin
2 | //#plugin-import
3 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.4")
4 | //#plugin-import
5 |
6 | // web plugins
7 |
8 | addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0")
9 |
10 | addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.6")
11 |
12 | addSbtPlugin("com.typesafe.sbt" % "sbt-jshint" % "1.0.3")
13 |
14 | addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.7")
15 |
16 | addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.0")
17 |
18 | addSbtPlugin("com.typesafe.sbt" % "sbt-mocha" % "1.1.0")
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This software is licensed under the Apache 2 license, quoted below.
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project except in compliance with
4 | the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
5 |
6 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
7 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
8 | language governing permissions and limitations under the License.
--------------------------------------------------------------------------------
/test/IntegrationSpec.scala:
--------------------------------------------------------------------------------
1 | import org.specs2.mutable._
2 | import org.specs2.runner._
3 | import org.junit.runner._
4 |
5 | import play.api.test._
6 | import play.api.test.Helpers._
7 |
8 | /**
9 | * add your integration spec here.
10 | * An integration test will fire up a whole play application in a real (or headless) browser
11 | */
12 | @RunWith(classOf[JUnitRunner])
13 | class IntegrationSpec extends Specification {
14 |
15 | "Application" should {
16 |
17 | "work from within a browser" in new WithBrowser {
18 |
19 | browser.goTo("http://localhost:" + port)
20 |
21 | browser.pageSource must contain("Your new application is ready.")
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/test/ApplicationSpec.scala:
--------------------------------------------------------------------------------
1 | import org.specs2.mutable._
2 | import org.specs2.runner._
3 | import org.junit.runner._
4 |
5 | import play.api.test._
6 | import play.api.test.Helpers._
7 |
8 | /**
9 | * Add your spec here.
10 | * You can mock out a whole application including requests, plugins etc.
11 | * For more information, consult the wiki.
12 | */
13 | @RunWith(classOf[JUnitRunner])
14 | class ApplicationSpec extends Specification {
15 |
16 | "Application" should {
17 |
18 | "send 404 on a bad request" in new WithApplication{
19 | route(FakeRequest(GET, "/boum")) must beSome.which (status(_) == NOT_FOUND)
20 | }
21 |
22 | "render the index page" in new WithApplication{
23 | val home = route(FakeRequest(GET, "/")).get
24 |
25 | status(home) must equalTo(OK)
26 | contentType(home) must beSome.which(_ == "text/html")
27 | contentAsString(home) must contain ("Your new application is ready.")
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/demo/DemoUserExtractor.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright © 2016 Lightbend, Inc. All rights reserved.
3 | * No information contained herein may be reproduced or transmitted in any form or
4 | * by any means without the express written permission of Lightbend, Inc.
5 | */
6 | package demo
7 |
8 | import com.lightbend.correlation.CorrelationId
9 | import com.lightbend.quota.sapi.User
10 | import play.api.mvc.RequestHeader
11 | import play.quota.sapi.user.UserExtractor
12 |
13 | import scala.concurrent.Future
14 |
15 | final class DemoUserExtractor extends UserExtractor {
16 | def userForRequest(rh: RequestHeader)(implicit correlationId: CorrelationId): Future[Option[User]] = {
17 | val user = rh.queryString.get("username").flatMap(_.headOption) match {
18 | case Some(username) => User("username:"+username)
19 | case None => User("unknown")
20 | }
21 | println(s"$correlationId -- user: $user")
22 | Future.successful(Some(user))
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/conf/logback.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 | ${application.home:-.}/logs/application.log
12 |
13 | %date [%level] from %logger in %thread - %message%n%xException
14 |
15 |
16 |
17 |
18 |
19 | %coloredLevel %logger{15} - %message%n%xException{10}
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/conf/application.conf:
--------------------------------------------------------------------------------
1 | # This is the main configuration file for the application.
2 | # ~~~~~
3 |
4 | #quota-conf
5 | play.quota.action.default {
6 | requestCost = 1
7 |
8 | judge {
9 | # Use a local in-memory judge
10 | type = memory
11 | # We set a rate of 50 requests every 5 minutes. This should
12 | # block robots but not normal users.
13 | userQuotas {
14 | type = fixed
15 | quota {
16 | maxBalance = 50
17 | refillAmount = 50
18 | tickSize = 5 minutes
19 | }
20 | }
21 | }
22 |
23 | # Use IP addresses as users
24 | userExtractor {
25 | type = ipAddress
26 | }
27 |
28 | # Format like Twitter
29 | resultFormatter {
30 | type = rest
31 | }
32 |
33 | # Use flake ids
34 | correlationIdExtractor {
35 | type = flakeId
36 | }
37 | }
38 | #quota-conf
39 |
40 | play.quota.action.demo {
41 | requestCost = 1
42 | judge {
43 | type = named
44 | name = demo
45 | }
46 | userExtractor.type = demo.DemoUserExtractor
47 | resultFormatter.type = demo.DemoResultFormatter
48 | correlationIdExtractor.type = demo.DemoCorrelationIdExtractor
49 | }
50 |
51 | quota.judge.demo {
52 | type = memory
53 | userQuotas {
54 | type = rule
55 | defaultQuota.type = zero
56 | rule {
57 | matcher {
58 | type = regex
59 | regex = "^username:(.*)$"
60 | }
61 | quota {
62 | maxBalance = 5
63 | refillAmount = 5
64 | tickSize = 1 minute
65 | }
66 | }
67 | }
68 | }
69 |
70 | play.crypto.secret = "changeme"
71 | play.crypto.secret = ${?APPLICATION_SECRET}
72 |
73 | play.i18n.langs = [ "en" ]
74 |
--------------------------------------------------------------------------------
/app/views/demo.scala.html:
--------------------------------------------------------------------------------
1 | @import com.lightbend.quota.sapi.judge.{Access, AccessDenied, AccessGranted}
2 | @import com.lightbend.quota.sapi._
3 | @import com.lightbend.correlation.CorrelationId
4 | @import _root_.demo.DemoResultFormatter._
5 | @import java.time.Instant
6 |
7 | @(access: Access, user: User, requestCost: Int, quotaDisplay: QuotaDisplay, correlationId: CorrelationId)
8 |
9 | @main("Play User Quotas Demo") {
10 |
11 |
26 |
27 |
28 |
29 | | Access: | @access match {
30 | case AccessDenied => { DENIED }
31 | case AccessGranted => { GRANTED }
32 | } |
33 | | User: | @user.id |
34 | | Request cost: | @requestCost token |
35 | | Correlation ID: | @correlationId |
36 | @quotaDisplay match {
37 | case ZeroDisplay => {
38 | | Quota: | Zero ∅ |
39 | }
40 | case UnlimitedDisplay => {
41 | | Quota: | Unlimited (∞) |
42 | }
43 | case RateLimitedDisplay(secondsUntilRefill, tickSeconds, balance, maxBalance) => {
44 | | Quota: | Rate limited |
45 | | Balance: | @balance / @maxBalance |
46 | | Refill time: | @{secondsUntilRefill} / @{tickSeconds}s |
47 | }
48 | }
49 |
50 |
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/app/demo/DemoResultFormatter.scala:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright © 2016 Lightbend, Inc. All rights reserved.
3 | * No information contained herein may be reproduced or transmitted in any form or
4 | * by any means without the express written permission of Lightbend, Inc.
5 | */
6 | package demo
7 |
8 | import java.time.{Duration, Instant}
9 |
10 | import com.lightbend.correlation.CorrelationId
11 | import com.lightbend.quota.sapi.judge.{Access, AccessDenied, AccessGranted}
12 | import com.lightbend.quota.sapi._
13 | import play.api.mvc._
14 | import play.quota.sapi.format.ResultFormatter
15 |
16 | import scala.concurrent.Future
17 |
18 | class DemoResultFormatter extends ResultFormatter {
19 |
20 | override def formatGranted(now: Instant, user: User, requestCost: Int, balance: Option[Int], quota: Quota, result: Result)(implicit correlationId: CorrelationId): Future[Result] = {
21 | display(AccessGranted, now, user, requestCost, balance, quota, correlationId)
22 | }
23 |
24 | override def formatDenied(now: Instant, user: User, requestCost: Int, balance: Option[Int], quota: Quota)(implicit correlationId: CorrelationId): Future[Result] = {
25 | display(AccessDenied, now, user, requestCost, balance, quota, correlationId)
26 | }
27 |
28 | private def display(access: Access, now: Instant, user: User, requestCost: Int, balance: Option[Int], quota: Quota, correlationId: CorrelationId): Future[Result] = {
29 | import DemoResultFormatter._
30 | val quotaDisplay: QuotaDisplay = quota match {
31 | case Zero => ZeroDisplay
32 | case Unlimited => UnlimitedDisplay
33 | case rl: RateLimited =>
34 | assert(rl.maxBalance == rl.refillAmount)
35 | val tickSeconds = rl.tickSize / 1000 // Convert millis to seconds
36 | val nextRefill: Instant = rl.nextRefillTime(now)
37 | val secondsUntilRefill = Duration.between(now, nextRefill).getSeconds
38 | RateLimitedDisplay(secondsUntilRefill, tickSeconds, balance.get, rl.maxBalance)
39 |
40 | }
41 | Future.successful(Results.Ok(views.html.demo(access, user, requestCost, quotaDisplay, correlationId)))
42 | }
43 |
44 | }
45 |
46 | object DemoResultFormatter {
47 |
48 | sealed trait QuotaDisplay
49 | final case object ZeroDisplay extends QuotaDisplay
50 | final case object UnlimitedDisplay extends QuotaDisplay
51 | final case class RateLimitedDisplay(
52 | secondsUntilRefill: Long,
53 | tickSeconds: Long,
54 | balance: Int,
55 | maxBalance: Int
56 | ) extends QuotaDisplay
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Play User Quotas with Scala
2 |
3 | This is an example application that shows the use of Play User Quotas for a single Play instance.
4 |
5 | ## Installation
6 |
7 | To add Play User Quotas to a Play application, please add the following to `build.sbt`:
8 |
9 | ```
10 | libraryDependencies ++= Seq(
11 | "com.lightbend.quota" %% "play-quota" % ""
12 | )
13 | ```
14 |
15 | ## Default Quota Action to Controller
16 |
17 | The quota action for a Play application is configured through Guice using an injected QuotaAction:
18 |
19 | ``` scala
20 | import javax.inject._
21 | import play.api._
22 | import play.api.mvc._
23 | import play.quota.sapi._
24 |
25 | class Application @Inject() (quotaAction: QuotaAction) extends Controller {
26 |
27 | def index = quotaAction {
28 | Ok(views.html.index("Hello world!"))
29 | }
30 |
31 | }
32 | ```
33 |
34 | ## Default User Quotas Configuration
35 |
36 | The default Play User Quotas configuration is set with an in-memory judge, which has a fixed user quota defined. The example configuration is as follows:
37 |
38 | ```
39 | play.quota.action.default {
40 | requestCost = 1
41 |
42 | judge {
43 | # Use a local in-memory judge
44 | type = memory
45 | # We set a rate of 50 requests every 5 minutes. This should
46 | # block robots but not normal users.
47 | userQuotas {
48 | type = fixed
49 | quota {
50 | maxBalance = 50
51 | refillAmount = 50
52 | tickSize = 5 minutes
53 | }
54 | }
55 | }
56 |
57 | # Use IP addresses as users
58 | userExtractor {
59 | type = ipAddress
60 | }
61 |
62 | # Format like Twitter
63 | resultFormatter {
64 | type = rest
65 | }
66 |
67 | # Use flake ids
68 | correlationIdExtractor {
69 | type = flakeId
70 | }
71 | }
72 | ```
73 |
74 | ## Named Play User Quota Action
75 |
76 | You can define a custom quota action as follows:
77 |
78 | ```
79 | GET /demo demo.DemoController.index
80 | ```
81 |
82 | The DemoController is named directly through Guice with the `@Named("demo")` annotation.
83 |
84 | ``` scala
85 | class DemoController @Inject() (@Named("demo") quota: QuotaAction) extends Controller {
86 |
87 | def index = quota { Ok("Demo Action Result") }
88 |
89 | }
90 | ```
91 |
92 | The `demo` action is defined with custom names and types as follows:
93 |
94 | ```
95 | play.quota.action.demo {
96 | requestCost = 1
97 | judge {
98 | type = named
99 | name = demo
100 | }
101 | userExtractor.type = demo.DemoUserExtractor
102 | correlationIdExtractor.type = demo.CorrelationIdExtractor
103 | resultFormatter.type = demo.DemoResultFormatter
104 | }
105 |
106 | quota.judge.demo {
107 | type = memory
108 | userQuotas {
109 | type = rule
110 | defaultQuota.type = zero
111 | rule {
112 | matcher {
113 | type = regex
114 | regex = "^username:(.*)$"
115 | }
116 | quota {
117 | maxBalance = 5
118 | refillAmount = 5
119 | tickSize = 1 minute
120 | }
121 | }
122 | }
123 | }
124 | ```
125 |
126 | Because the types are defined explicitly for the userExtractor, correlationIdExtractor, and the resultFormatter, the `demo.DemoUserExtractor`, `demo.DemoCorrelationIdExtractor` and `demo.DemoResultFormatter` classes are used.
127 |
128 | ## Testing
129 |
130 | When you run your application, this action will have service quotas enforced.
131 |
132 | Using httpie (http://httpie.org) you can see the effect below:
133 |
134 | ```
135 | $ http --headers GET http://localhost:9000
136 | HTTP/1.1 200 OK
137 | ...
138 | X-Correlation-Id: 9ABIYFLjqjayoZWdtY
139 | X-Rate-Limit-Limit: 50
140 | X-Rate-Limit-Remaining: 49
141 | X-Rate-Limit-Reset: 1442719800
142 | ```
143 |
144 | When the limit is exceeded, instead of a 200 OK response, a 429 response is returned:
145 |
146 | ```
147 | $ http --headers GET http://localhost:9000
148 | HTTP/1.1 429 Too Many Requests
149 | ...
150 | X-Correlation-Id: 9ABIYV4bODOXa3dBPE
151 | X-Rate-Limit-Limit: 50
152 | X-Rate-Limit-Remaining: 0
153 | X-Rate-Limit-Reset: 1442722800
154 | ```
155 |
156 | These quotas are enforced for each IP address. Each IP address can make 50 requests every 5 minutes. Rate limiting information is presented to the user using typical REST headers.
157 |
--------------------------------------------------------------------------------