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(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)[https://github.com/mariussoutier/PlayBasics] with all the examples from above in both Java and Scala.

Reference

Play Wiki: Java Routes Play Wiki: Scala Routes

Share post

RSS-Feed

News posts directly in your newsreader.

Subscribe feed

Newsletter