├── 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 | 33 | 34 | 35 | 36 | @quotaDisplay match { 37 | case ZeroDisplay => { 38 | 39 | } 40 | case UnlimitedDisplay => { 41 | 42 | } 43 | case RateLimitedDisplay(secondsUntilRefill, tickSeconds, balance, maxBalance) => { 44 | 45 | 46 | 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 | --------------------------------------------------------------------------------
Access:@access match { 30 | case AccessDenied => { DENIED } 31 | case AccessGranted => { GRANTED } 32 | }
User:@user.id
Request cost:@requestCost token
Correlation ID:@correlationId
Quota:Zero ∅
Quota:Unlimited (∞)
Quota:Rate limited
Balance:@balance / @maxBalance
Refill time:@{secondsUntilRefill} / @{tickSeconds}s