Skip to content

Commit

Permalink
Refactor Lang/Locale impl
Browse files Browse the repository at this point in the history
  • Loading branch information
danneu committed Jul 10, 2017
1 parent 1c8c4b4 commit 7429b6b
Show file tree
Hide file tree
Showing 7 changed files with 503 additions and 421 deletions.
79 changes: 79 additions & 0 deletions README.md
Expand Up @@ -45,6 +45,7 @@ Server({ Response().text("hello world") }).listen(3000)
* [Request Cookies](#request-cookies)
* [Response Cookies](#response-cookies)
- [Content Negotiation](#content-negotiation)
* [Most acceptable language](#most-acceptable-language)
- [Included Middleware](#included-middleware)
* [Development Logger](#development-logger)
* [Static File Serving](#static-file-serving)
Expand Down Expand Up @@ -616,6 +617,84 @@ mediaTypes: [MediaType(type='application', subtype='json', q=1.0), MediaType(typ

Notice that values ("TEXT/*", "DeFLaTE") are always downcased for easy comparison.

### Most acceptable language

Given a list of languages that you want to support, the negotiator can return a list that filters and sorts your
available languages down in order of client preference, the first one being the client's highest preference.

```kotlin
import com.danneu.kog.Lang
import com.danneu.kog.Locale

// Request Accept-Language: "en-US, es"
request.negotiate.acceptableLanguages(listOf(
Lang.Spanish(),
Lang.English(Locale.UnitedStates)
)) == listOf(
Lang.English(Locale.UnitedStates),
Lang.Spanish()
)
```

Also, note that we don't have to provide a locale. If the client asks for `en-US`, then of course
`Lang.English()` without a locale should be acceptable if we have no more specific match.

```kotlin
// Request Accept-Language: "en-US, es"
request.negotiate.acceptableLanguages(listOf(
Lang.Spanish(),
Lang.English()
)) == listOf(
Lang.English(),
Lang.Spanish()
)
```

The singular form, `.acceptableLanguage()`, is a helper that returns the first result (the most preferred language
in common with the client).

```kotlin
// Request Accept-Language: "en-US, es"
request.negotiate.acceptableLanguage(listOf(
Lang.Spanish(),
Lang.English()
)) == Lang.English()
```

Here we write an extension function `Request#lang()` that returns the optimal lang between
our available langs and the client's requested langs.

We define an internal `OurLangs` enum so that we can exhaust it with `when` expressions in our routes
or middleware.

```kotlin
enum class OurLangs {
Spanish,
English
}

fun Request.lang(): OurLangs {
val availableLangs = listOf(
Lang.Spanish(),
Lang.English()
)

return when (this.negotiate.acceptableLanguage(availableLangs)) {
Lang.English() -> AvailableLang.English
else -> AvailableLang.Spanish
}
}

router.get("/", fun(): Handler = { request ->
return when (request.lang()) {
OurLangs.Spanish() ->
Response().text("Les servimos en espa帽ol")
OurLangs.English() ->
Response().text("We're serving you English")
}
})
```

## Included Middleware

The `com.danneu.kog.batteries` package includes some useful middleware.
Expand Down
78 changes: 78 additions & 0 deletions src/main/kotlin/com/danneu/kog/negotiation/AcceptLanguage.kt
@@ -0,0 +1,78 @@
package com.danneu.kog.negotiation

import kotlin.comparisons.compareBy

// A **goofy** wrapper around accept-language prefix/suffix pairs like en, en-GB, en-US.
//
// The implementation got a bit gnarly since I was reverse-engineering how it should work from
// other server test cases and letting TDD drive my impl like a black box in some parts.
//
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
//
// TODO: Incomplete, experimental.
// TODO: Finish language code list http://www.lingoes.net/en/translator/langcode.htm
// TODO: Maybe should keep it open-ended like any language being able to use any locale.
// TODO: Maybe should just simplify it into Pair("en", null), Pair("en", "US") style stuff.


// This class is just a pair of language and its q-value.
//
// TODO: Maybe AcceptLanguage should be Pair<Lang, QValue>. Lang vs AcceptLanguage is confusing as top-level classes.
class AcceptLanguage(val lang: Lang, val q: Double = 1.0) {
override fun toString() = "AcceptLanguage[lang=$lang, q=$q]"
override fun equals(other: Any?) = other is AcceptLanguage && this.lang == other.lang && this.q == other.q

// Generated by IDEA
override fun hashCode() = 31 * lang.hashCode() + q.hashCode()

companion object {
val regex = Regex("""^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$""")

// TODO: Test malformed header

fun acceptable(clientLang: Lang, availableLang: Lang, excludedLangs: Set<Lang>): Boolean {
if (availableLang in excludedLangs) return false
// clientLang is * so everything is acceptable
if (clientLang == Lang.Wildcard) return true
// if clientLang is "en" and "en-US" is available, it is acceptable
if (clientLang.locale == Locale.Wildcard && clientLang.name == availableLang.name) return true
// if clientLang is "en-US" and "en-US" is available
if (clientLang.name == availableLang.name) return true

return false
}

/** Parses a single segment pair
*/
fun parse(string: String): AcceptLanguage? {
val parts = regex.find(string)?.groupValues?.drop(1) ?: return null
// FIXME: temp hack since the refactor: rejoining parts with hyphen because that's what regex gives me
val lang = Lang.fromString("${parts[0]}-${parts[1]}") ?: return null
val q = QValue.parse(parts[2]) ?: 1.0
return AcceptLanguage(lang, q)
}


/** Parses comma-delimited string of types
*/
fun parseHeader(header: String): List<AcceptLanguage> {
return header.split(",").map(String::trim).mapNotNull(this::parse)
}

fun prioritize(xs: List<AcceptLanguage>): List<AcceptLanguage> {
return xs.sortedWith(compareBy(
{ -it.q },
// Wildcard comes last
{ when (it.lang) {
Lang.Wildcard ->
1
else ->
0
}}
))

}
}
}


62 changes: 62 additions & 0 deletions src/main/kotlin/com/danneu/kog/negotiation/Lang.kt
@@ -0,0 +1,62 @@
package com.danneu.kog.negotiation

// TODO: Finish implementing http://www.lingoes.net/en/translator/langcode.htm

sealed class Lang(val name: String, val locale: Locale = Locale.Wildcard) {
companion object {
fun fromString(input: String): Lang? {
val (prefix, localeString) = input.split("-", limit = 2)

val locale = if (localeString.isEmpty()) {
Locale.Wildcard
} else {
Locale.fromString(localeString)
}

// TODO: Decide how to handle languages with unknown locales. For now, they are skipped
locale ?: return null

return when (prefix) {
"*" -> Wildcard
"en" -> English(locale)
"es" -> Spanish(locale)
"de" -> German(locale)
"it" -> Italian(locale)
"fr" -> French(locale)
"pt" -> Portuguese(locale)
"no" -> Norwegian(locale)
"se" -> Sami(locale)
"fi" -> Finnish(locale)
"ro" -> Romanian(locale)
"nl" -> Dutch(locale)
else -> null
}
}
}

// Necessary for .distinctBy to work
override fun hashCode() = 31 * name.hashCode() + locale.hashCode()

override fun toString() = if (locale == Locale.Wildcard) {
"$name[*]"
} else {
"$name[$locale]"
}

override fun equals(other: Any?): Boolean {
return other is Lang && name == other.name && locale == other.locale
}

object Wildcard : Lang("Wildcard")
class English (locale: Locale = Locale.Wildcard) : Lang("English", locale)
class Spanish (locale: Locale = Locale.Wildcard) : Lang("Spanish", locale)
class German (locale: Locale = Locale.Wildcard) : Lang("German", locale)
class French (locale: Locale = Locale.Wildcard) : Lang("French", locale)
class Italian (locale: Locale = Locale.Wildcard) : Lang("Italian", locale)
class Portuguese (locale: Locale = Locale.Wildcard) : Lang("Portuguese", locale)
class Norwegian (locale: Locale = Locale.Wildcard) : Lang("Norwegian", locale)
class Sami (locale: Locale = Locale.Wildcard) : Lang("Sami", locale)
class Finnish (locale: Locale = Locale.Wildcard) : Lang("Finnish", locale)
class Romanian (locale: Locale = Locale.Wildcard) : Lang("Romanian", locale)
class Dutch (locale: Locale = Locale.Wildcard) : Lang("Dutch", locale)
}
126 changes: 126 additions & 0 deletions src/main/kotlin/com/danneu/kog/negotiation/Locale.kt
@@ -0,0 +1,126 @@
package com.danneu.kog.negotiation

import com.danneu.kog.Header
import com.danneu.kog.Request
import com.danneu.kog.toy

enum class Locale {
// Special
Wildcard,

// Locales
Germany,
Argentina,
Australia,
Belize,
Brazil,
Liechtenstein,
Luxembourg,
Switzerland,
Bolivia,
Canada,
Caribbean,
Chile,
Colombia,
CostaRica,
DominicanRepublic,
Ecuador,
ElSalvador,
Finland,
Guatemala,
Honduras,
Ireland,
Romania,
Jamaica,
Mexico,
NewZealand,
Nicaragua,
Panama,
Paraguay,
Peru,
Philippines,
Portugal,
PuertoRico,
SouthAfrica,
Spain,
Austria,
TrinidadAndTobago,
UnitedKingdom,
UnitedStates,
Uruguay,
Venezuela,
Zimbabwe
;

companion object {
fun fromString(input: String): Locale? = when (input.toUpperCase()) {
"AR" -> Argentina
"LU" -> Luxembourg
"AU" -> Australia
"BO" -> Bolivia
"BR" -> Brazil
"RO" -> Romania
"BZ" -> Belize
"CA" -> Canada
"CB" -> Caribbean
"CL" -> Chile
"CO" -> Colombia
"CR" -> CostaRica
"DO" -> DominicanRepublic
"EC" -> Ecuador
"ES" -> Spain
"GB" -> UnitedKingdom
"GT" -> Guatemala
"FI" -> Finland
"HN" -> Honduras
"IE" -> Ireland
"JM" -> Jamaica
"MX" -> Mexico
"NI" -> Nicaragua
"NZ" -> NewZealand
"PA" -> Panama
"PE" -> Peru
"PH" -> Philippines
"PR" -> PuertoRico
"PT" -> Portugal
"PY" -> Paraguay
"SV" -> ElSalvador
"TT" -> TrinidadAndTobago
"US" -> UnitedStates
"DE" -> Germany
"LI" -> Liechtenstein
"UY" -> Uruguay
"VE" -> Venezuela
"ZA" -> SouthAfrica
"ZW" -> Zimbabwe
"AT" -> Austria
"CH" -> Switzerland
else -> null
}
}
}

enum class AvailableLang {
Spanish,
English
}

fun Request.lang(): AvailableLang {
val availableLangs = listOf(
Lang.Spanish(),
Lang.English()
)

return when (this.negotiate.acceptableLanguage(availableLangs)) {
Lang.English() -> AvailableLang.English
else -> AvailableLang.Spanish
}
}

fun main(args: Array<String>) {

//val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en-US, es")))
val req = Request.toy(headers = mutableListOf(Header.AcceptLanguage to "es, en-US, es"))

println(req.lang())
}
4 changes: 2 additions & 2 deletions src/main/kotlin/com/danneu/kog/negotiation/Negotiator.kt
Expand Up @@ -33,14 +33,14 @@ class Negotiator(val request: Request) {
val header = request.getHeader(Header.AcceptLanguage) ?: return emptyList()
return AcceptLanguage.parseHeader(header)
.let { AcceptLanguage.prioritize(it) }
.distinctBy { it.lang.code } // TODO: why doesn't distinctBy { it.lang } work?
}

/** List of preferred languages sent by the client
*/
fun languages(): List<Lang> {
return allAcceptLanguages()
.filter { it.q > 0 }
.distinctBy { it.lang }
.map { it.lang }
}

Expand Down Expand Up @@ -74,7 +74,7 @@ class Negotiator(val request: Request) {
}
}

// TODO: Why do we have dupes?
// TODO: Why do we have dupes? EDIT: Do we still have dupes after I fixed distinctBy?
return (explicit + implicit).distinct()
}
}
Expand Down

0 comments on commit 7429b6b

Please sign in to comment.