Playframework Routes – Part 2, Advanced Use Cases

Play Routes – Part 2, Advanced Use Cases

Localized routes

There are different approaches to managing the user’s locale in a multi-language site. If most of the content is only available after log-in, a cookie backed by an entry in the User table should be enough. On your landing page you check for the Accept-Language header or check the IP address. Just make sure that Google Bot can index all publicly visible pages.

On content-heavy sites, one Google-compatible approach is to define the language in the URL. The first component of the URL should be the language (or, if really needed, both language and country). Using the first part of the URL not only to make it easier to differentiate for the user but also to makes it easier to manage the site in Webmaster Tools.

We have two options here. Either we define a regex, or we hard-code our supported languages. The second approach must be chosen when we optimize for Google (see section below).

# Map to language via RegEx
/$language< [a-z]{2}>/videos/:id   controllers.Video.showVideo(id: Long, language: String)
# Hard-code supported languages
/de/videos/:id   controllers.Video.showVideo(id: Long, language = "de")
/en/videos/:id   controllers.Video.showVideo(id: Long, language = "en")

In Java, you have to pass the language from the route down to the template (even if the language is marked implicit in the template).

public static Result showVideo(Long id, String language) {
  return ok(video.render(Video.forId(id), Lang.forCode(language)));
}

In Scala, this can be solved much more elegantly using Action composition. If you’re a Java-only developer, you might want to skip this part because you will get jealous.

First we define a trait Localized with an Action that accepts the language as a parameter.

def ActionWithLanguage(language: String)(f: LocalizedRequest[AnyContent] => Result): Action[AnyContent] = Action { implicit request =>
  f(new LocalizedRequest(request, (Lang(language))))
}

LocalizedRequest is just a wrapped request that makes it easier to use the resolved Lang in the controller.

case class LocalizedRequest[A](request: Request[A], lang: Lang) extends WrappedRequest(request)

All we need now is is to override the implicit language resolution method. We also do this inside the Localized trait.

trait Localized extends Controller {
  override implicit def lang(implicit request: RequestHeader): Lang = request match {
    case lr: LocalizedRequest[_] => lr.lang
    case _ => Lang("en") // or retrieve the Accept-Language header here
  }
}

A localized controller now looks like this:

object Video extends Controller with Localized {
  def showVideo(id: Long, language: String) = ActionWithLanguage(language) { implicit request =>
    Ok(views.html.video(Video.forId(id)))
  }
}

SEO-optimized routes

You can not only localize the route with a language prefix, but also use fixed parameters to translate the path itself. So instead of /de/help your German route would be /hilfe or /de/hilfe. It’s unclear at this point if you actually get a better Google ranking, but at the very least it improves click-through rates.

As shown in the first part, you can fix parameters for a given path:

/de/hilfe                   controllers.Help.show(language = "de")
/fr/aide                    controllers.Help.show(language = "fr")
# If English is your default or fall-back language
/help                       controllers.Help.show(language: String)
# If you want to enable new languages without touching each and every route (also avoids compiler warnings)
/$language< [a-z]{2}>/help   controllers.Help.show(language: String)

As mentioned in the first part, the reverse router will make sure that your SEO-optimized routes will resolve correctly, i.e. routes.Help.show(language = "de") will resolve to /de/hilfe.

A few more words on SEO: when you redirect pages that are relevant to Google from one route to another, (e.g. when a username has changed) don’t use redirect() (or Redirect() in Scala), but permanentlyMoved(). The former action’s HTTP status code is 302, which Google doesn’t like, while the latter’s is Google’s preferred 301.

Binders

Let’s say you don’t just want to localize your routes by language, but also by country if applicable. You could either define two Strings for each route, or rather reuse Play’s Lang class (play.i18n.Lang or play.api.i18n.Lang respectively) which already supports both language and country and offers some convenience methods. This also avoids having to convert from a String to a Lang in your actions or templates.

# Two parameters
GET  /$language< [a-z]{2}>/$country< [A-Z]{2}>/article/:artNo    controllers.Articles.showArticle(artNo: String, language: String, country: String)
# Instance of Lang
GET  /$locale< [a-z]{2}-[A-Z]{2}>/article/:artNo   controllers.Articles.showArticle(artNo: String, locale: Lang)

This example is a bit contrived because in reality, you’d probably deploy this app to multiple top-level domains. On the other hand, a .com domain is still the most powerful TLD, and, important for an upstarting business, it allows you to concentrate all link power to one domain.

Since Play cannot know how to bind this parameter to a Lang instance, we have to write a Binder. For this we have to implement a QueryStringBindable (used for query parameters) and/or a LangPathBindable (for path parameters). In Scala, we write implicit objects for this so that Play can resolve them automatically.

package extensions

object Binders {
  implicit object LangPathBindable extends PathBindable[Lang] {
    def bind(key: String, value: String) = try {
      Right(Lang(value))
    } catch {
      case e: Exception => Left("Cannot parse parameter '" + key + "' as Lang")
    }

    def unbind(key: String, value: Lang): String = value.code
  }
}

Lang’s apply(code: String) method does all the work of separating the input into language and country (or just language if no country is given).

Finally, we’ll tell Play to use the new Binder in project/Build.scala like this:

val main = PlayProject(appName, appVersion, appDependencies, mainLang = SCALA).settings(
  routesImport += "extensions.Binders._"
)

In our actions we can now use the Lang or pass it to an action method so we have it in scope in our templates.

Unfortunately, this doesn’t work well in Java as laid out in this Stackoverflow post. The best option is to use a Scala binder even in a Java project.

Summary

Congratulations, you have now learned a lot about routes. I’ve also set up a GitHub repo here with all the examples from above in both Java and Scala and some more.

Reference

Posted in Programming Tagged with: , ,
3 comments on “Playframework Routes – Part 2, Advanced Use Cases
  1. Malte says:

    Thanks for this tutorial! Everything works fine when I call Messages from within a Controller, but when I call it from within a Template I always get one single language. Do you know if this is a bug?

    • marius says:

      Are you sure you are passing the language to the template? It must be in implicit scope! If it doesn’t work, contact me directly and I can help you out.

  2. Gavin says:

    Very good article. Saved my day!

    For Play 2.3.x, do the routes import as follow.

    *build.sbt*
    import play.PlayImport.PlayKeys._

    routesImport ++= Seq(
    “extensions.Binders._”,
    “play.api.i18n.Lang”
    )

Leave a Reply

Your email address will not be published. Required fields are marked *

*


× eight = 64

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>