From 7429b6b79699a52a295f2603969d764de7845701 Mon Sep 17 00:00:00 2001 From: Dan Neumann Date: Sun, 9 Jul 2017 22:44:27 -0500 Subject: [PATCH] Refactor Lang/Locale impl --- README.md | 79 ++++ .../danneu/kog/negotiation/AcceptLanguage.kt | 78 ++++ .../kotlin/com/danneu/kog/negotiation/Lang.kt | 62 +++ .../com/danneu/kog/negotiation/Locale.kt | 126 ++++++ .../com/danneu/kog/negotiation/Negotiator.kt | 4 +- .../com/danneu/kog/negotiation/language.kt | 394 ------------------ .../kotlin/com/danneu/kog/NegotiatorTests.kt | 181 ++++++-- 7 files changed, 503 insertions(+), 421 deletions(-) create mode 100644 src/main/kotlin/com/danneu/kog/negotiation/AcceptLanguage.kt create mode 100644 src/main/kotlin/com/danneu/kog/negotiation/Lang.kt create mode 100644 src/main/kotlin/com/danneu/kog/negotiation/Locale.kt delete mode 100644 src/main/kotlin/com/danneu/kog/negotiation/language.kt diff --git a/README.md b/README.md index d3da93d..9c12ecf 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/src/main/kotlin/com/danneu/kog/negotiation/AcceptLanguage.kt b/src/main/kotlin/com/danneu/kog/negotiation/AcceptLanguage.kt new file mode 100644 index 0000000..f1592b4 --- /dev/null +++ b/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 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): 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 { + return header.split(",").map(String::trim).mapNotNull(this::parse) + } + + fun prioritize(xs: List): List { + return xs.sortedWith(compareBy( + { -it.q }, + // Wildcard comes last + { when (it.lang) { + Lang.Wildcard -> + 1 + else -> + 0 + }} + )) + + } + } +} + + diff --git a/src/main/kotlin/com/danneu/kog/negotiation/Lang.kt b/src/main/kotlin/com/danneu/kog/negotiation/Lang.kt new file mode 100644 index 0000000..7fcf67c --- /dev/null +++ b/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) +} diff --git a/src/main/kotlin/com/danneu/kog/negotiation/Locale.kt b/src/main/kotlin/com/danneu/kog/negotiation/Locale.kt new file mode 100644 index 0000000..82122f9 --- /dev/null +++ b/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) { + + //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()) +} diff --git a/src/main/kotlin/com/danneu/kog/negotiation/Negotiator.kt b/src/main/kotlin/com/danneu/kog/negotiation/Negotiator.kt index 9087be6..4a37ce6 100644 --- a/src/main/kotlin/com/danneu/kog/negotiation/Negotiator.kt +++ b/src/main/kotlin/com/danneu/kog/negotiation/Negotiator.kt @@ -33,7 +33,6 @@ 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 @@ -41,6 +40,7 @@ class Negotiator(val request: Request) { fun languages(): List { return allAcceptLanguages() .filter { it.q > 0 } + .distinctBy { it.lang } .map { it.lang } } @@ -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() } } diff --git a/src/main/kotlin/com/danneu/kog/negotiation/language.kt b/src/main/kotlin/com/danneu/kog/negotiation/language.kt deleted file mode 100644 index 8de3d5d..0000000 --- a/src/main/kotlin/com/danneu/kog/negotiation/language.kt +++ /dev/null @@ -1,394 +0,0 @@ -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. - -interface Locale { - val code: String - - object Codes { - const val Argentina = "ar" - const val Australia = "au" - const val Austria = "at" - const val Belgium = "be" - const val Belize = "bz" - const val Bolivia = "bo" - const val Brazil = "br" - const val Canada = "ca" - const val Caribbean = "cb" - const val Chile = "cl" - const val Colombia = "co" - const val CostaRica = "cr" - const val DominicanRepublic = "do" - const val Ecuador = "ec" - const val ElSalvador = "sv" - const val Finland = "fi" - const val France = "fr" - const val Germany = "de" - const val Guatemala = "gt" - const val Honduras = "hn" - const val Ireland = "ie" - const val Italy = "it" - const val Jamaica = "jm" - const val Liechtenstein = "li" - const val Luxembourg = "lu" - const val Mexico = "mx" - const val Monaco = "mc" - const val Netherlands = "nl" - const val NewZealand = "nz" - const val Nicaragua = "ni" - const val Norway = "no" - const val Panama = "pa" - const val Paraguay = "py" - const val Peru = "pe" - const val Philippines = "ph" - const val Portugal = "pt" - const val PuertoRico = "pr" - const val SouthAfrica = "za" - const val Spain = "es" - const val Sweden = "se" - const val Switzerland = "ch" - const val TrinidadAndTobago = "tt" - const val UnitedKingdom = "gb" - const val UnitedStates = "us" - const val Uruguay = "uy" - const val Venezuela = "ve" - const val Zimbabwe = "zw" - } - - enum class English(override val code: String) : Locale { - Australia(Codes.Australia), - Belize(Codes.Belize), - Canada(Codes.Canada), - Caribbean(Codes.Caribbean), - Ireland(Codes.Ireland), - Jamaica(Codes.Jamaica), - NewZealand(Codes.NewZealand), - Philippines(Codes.Philippines), - SouthAfrica(Codes.SouthAfrica), - TrinidadAndTobago(Codes.TrinidadAndTobago), - UnitedKingdom(Codes.UnitedKingdom), - UnitedStates(Codes.UnitedStates), - Zimbabwe(Codes.Zimbabwe); - - companion object { - fun fromCode(code: String?): English? = when (code?.toLowerCase()) { - Codes.Australia -> Australia - Codes.Belize -> Belize - Codes.Canada -> Canada - Codes.Caribbean -> Caribbean - Codes.Ireland -> Ireland - Codes.Jamaica -> Jamaica - Codes.NewZealand -> NewZealand - Codes.Philippines -> Philippines - Codes.SouthAfrica -> SouthAfrica - Codes.TrinidadAndTobago -> TrinidadAndTobago - Codes.UnitedKingdom -> UnitedKingdom - Codes.UnitedStates -> UnitedStates - Codes.Zimbabwe -> Zimbabwe - else -> null - } - } - } - - enum class French(override val code: String) : Locale { - Belgium(Codes.Belgium), - Canada(Codes.Canada), - France(Codes.France), - Luxembourg(Codes.Luxembourg), - Monaco(Codes.Monaco), - Switzerland(Codes.Switzerland); - - companion object { - fun fromCode(code: String?): French? = when (code?.toLowerCase()) { - Codes.Belgium -> Belgium - Codes.Canada -> Canada - Codes.France -> France - Codes.Luxembourg -> Luxembourg - Codes.Monaco -> Monaco - Codes.Switzerland -> Switzerland - else -> null - } - } - } - - enum class Dutch(override val code: String) : Locale { - Belgium(Codes.Belgium), - Netherlands(Codes.Netherlands); - - companion object { - fun fromCode(code: String?): Dutch? = when (code?.toLowerCase()) { - Codes.Belgium -> Belgium - Codes.Netherlands -> Netherlands - else -> null - } - } - } - - enum class Portuguese(override val code: String) : Locale { - Brazil(Codes.Brazil), - Portugal(Codes.Portugal); - - companion object { - fun fromCode(code: String?): Portuguese? = when (code?.toLowerCase()) { - Codes.Brazil -> Brazil - Codes.Portugal -> Portugal - else -> null - } - } - } - - enum class Sami(override val code: String) : Locale { - Finland(Codes.Finland), - Norway(Codes.Norway), - Sweden(Codes.Sweden); - - companion object { - fun fromCode(code: String?): Sami? = when (code?.toLowerCase()) { - Codes.Finland -> Finland - Codes.Norway -> Norway - Codes.Sweden -> Sweden - else -> null - } - } - } - - enum class German(override val code: String) : Locale { - Austria(Codes.Austria), - Switzerland(Codes.Switzerland), - Germany(Codes.Germany), - Liechtenstein(Codes.Liechtenstein), - Luxembourg(Codes.Luxembourg); - - companion object { - fun fromCode(code: String?): German? = when (code?.toLowerCase()) { - Codes.Austria -> Austria - Codes.Switzerland -> Switzerland - Codes.Germany -> Germany - Codes.Liechtenstein -> Liechtenstein - Codes.Luxembourg -> Luxembourg - else -> null - } - } - } - - enum class Italian(override val code: String) : Locale { - Switzerland(Codes.Switzerland), - Italy(Codes.Italy); - - companion object { - fun fromCode(code: String?): Italian? = when (code?.toLowerCase()) { - Codes.Switzerland -> Switzerland - Codes.Italy -> Italy - else -> null - } - } - } - - enum class Spanish(override val code: String) : Locale { - Argentina(Codes.Argentina), - Bolivia(Codes.Bolivia), - Chile(Codes.Chile), - Colombia(Codes.Colombia), - CostaRica(Codes.CostaRica), - DominicanRepublic(Codes.DominicanRepublic), - Ecuador(Codes.Ecuador), - ElSalvador(Codes.ElSalvador), - Guatemala(Codes.Guatemala), - Honduras(Codes.Honduras), - Mexico(Codes.Mexico), - Nicaragua(Codes.Nicaragua), - Panama(Codes.Panama), - Paraguay(Codes.Paraguay), - Peru(Codes.Peru), - PuertoRico(Codes.PuertoRico), - Spain(Codes.Spain), - Uruguay(Codes.Uruguay), - Venezuela(Codes.Venezuela); - - companion object { - fun fromCode(code: String?): Spanish? = when (code?.toLowerCase()) { - Codes.Argentina -> Argentina - Codes.Bolivia -> Bolivia - Codes.Chile -> Chile - Codes.Colombia -> Colombia - Codes.CostaRica -> CostaRica - Codes.DominicanRepublic -> DominicanRepublic - Codes.Ecuador -> Ecuador - Codes.ElSalvador -> ElSalvador - Codes.Guatemala -> Guatemala - Codes.Honduras -> Honduras - Codes.Mexico -> Mexico - Codes.Nicaragua -> Nicaragua - Codes.Panama -> Panama - Codes.Paraguay -> Paraguay - Codes.Peru -> Peru - Codes.PuertoRico -> PuertoRico - Codes.Spain -> Spain - Codes.Uruguay -> Uruguay - Codes.Venezuela -> Venezuela - else -> null - } - } - } -} - -sealed class Lang(val prefixCode: String, prettyName: String, val locale: Locale? = null) { - val code = prefixCode + if (locale != null) "-${locale.code.toUpperCase()}" else "" - val prettyName = prettyName + if (locale == null) "[*]" else "[${locale.code.toUpperCase()}]" - - // Special - object Wildcard: Lang("*", "*") - // Languages (without locales) - class Afrikaans: Lang(Codes.Afrikaans, "Afrikaans") - class Esperanto: Lang(Codes.Esperanto, "Esperanto") - class Finnish: Lang(Codes.Finnish, "Finnish") - class Klingon: Lang(Codes.Klingon, "Klingon") - class Norwegian: Lang(Codes.Norwegian, "Norwegian") - class Romanian: Lang(Codes.Romanian, "Romanian") - // Languages (with locales - class Dutch(locale: Locale.Dutch? = null) : Lang(Codes.Dutch, "Dutch", locale) - class English(locale: Locale.English? = null) : Lang(Codes.English, "English", locale) - class French(locale: Locale.French? = null) : Lang(Codes.French, "French", locale) - class German(locale: Locale.German? = null) : Lang(Codes.German, "German", locale) - class Italian(locale: Locale.Italian? = null) : Lang(Codes.Italian, "Italian", locale) - class Portuguese(locale: Locale.Portuguese? = null) : Lang(Codes.Portuguese, "Portuguese", locale) - class Sami(locale: Locale.Sami? = null) : Lang(Codes.Sami, "Sami", locale) - class Spanish(locale: Locale.Spanish? = null) : Lang(Codes.Spanish, "Spanish", locale) - - override fun equals(other: Any?): Boolean { - return when (other) { - is Lang -> this.code.toLowerCase() == other.code.toLowerCase() - else -> false - } - } - - override fun hashCode() = this.code.toLowerCase().hashCode() - - override fun toString() = prettyName - - object Codes { - // Special - val Wildcard = "*" - // Languages - val Afrikaans = "af" - val Dutch = "nl" - val English = "en" - val Esperanto = "eo" - val Finnish = "fi" - val French = "fr" - val German = "de" - val Italian = "it" - val Klingon = "tlh" - val Norwegian = "no" - val Portuguese = "pt" - val Romanian = "ro" - val Sami = "se" - val Spanish = "es" - } - - companion object { - fun fromCode(code: String): Lang? { - code.split("-").let { parts -> - return fromPrefix(parts[0], parts.getOrNull(1)) - } - } - - fun fromPrefix(prefix: String, localeCode: String? = null): Lang? { - return when (prefix.toLowerCase()) { - Codes.Wildcard -> Wildcard - Codes.Afrikaans -> Afrikaans() - Codes.Dutch -> Dutch(Locale.Dutch.fromCode(localeCode)) - Codes.English -> English(Locale.English.fromCode(localeCode)) - Codes.Esperanto -> Esperanto() - Codes.Finnish -> Finnish() - Codes.French -> French(Locale.French.fromCode(localeCode)) - Codes.German -> German(Locale.German.fromCode(localeCode)) - Codes.Italian -> Italian(Locale.Italian.fromCode(localeCode)) - Codes.Klingon -> Klingon() - Codes.Norwegian -> Norwegian() - Codes.Portuguese -> Portuguese(Locale.Portuguese.fromCode(localeCode)) - Codes.Romanian -> Romanian() - Codes.Sami -> Sami(Locale.Sami.fromCode(localeCode)) - Codes.Spanish -> Spanish(Locale.Spanish.fromCode(localeCode)) - else -> null - } - } - } -} - -// This class is just a pair of language and its q-value. -// -// TODO: Maybe AcceptLanguage should be Pair. Lang vs AcceptLanguage is confusing as top-level classes. -class AcceptLanguage(val lang: Lang, val q: Double = 1.0) { - override fun toString() = "${lang.code};q=$q" - - override fun equals(other: Any?) = when (other) { - is AcceptLanguage -> this.lang == other.lang && this.q == other.q - else -> false - } - - // 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): Boolean { - if (availableLang in excludedLangs) return false - // clientLang is * so everything is acceptable - if (clientLang == Lang.Wildcard) return true - // short-circuit if they are equal (en == en, en-gb == en-gb, en-gb !== en) - if (clientLang == availableLang) return true - if (clientLang.prefixCode == availableLang.prefixCode) return true - - return false - } - - /** Parses a single segment pair - */ - fun parse(string: String): AcceptLanguage? { - val parts = regex.find(string)?.groupValues?.drop(1) ?: return null - val lang = Lang.fromPrefix(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 { - return header.split(",").map(String::trim).mapNotNull(this::parse) - } - - fun prioritize(xs: List): List { - return xs.sortedWith(compareBy( - { -it.q }, - // Wildcard comes last - { when (it.lang) { - Lang.Wildcard -> - // TODO: Why doesn't -1 work? I would've thought this would put wildcards in the front. - 1 - else -> - 0 - }} - )) - - } - } -} - - diff --git a/src/test/kotlin/com/danneu/kog/NegotiatorTests.kt b/src/test/kotlin/com/danneu/kog/NegotiatorTests.kt index 25ed738..d302f6c 100644 --- a/src/test/kotlin/com/danneu/kog/NegotiatorTests.kt +++ b/src/test/kotlin/com/danneu/kog/NegotiatorTests.kt @@ -11,10 +11,7 @@ import com.danneu.kog.negotiation.Negotiator import org.junit.Assert.* import org.junit.Test - -fun codeList(vararg codes: String): List { - return codes.map { Lang.fromCode(it)!! } -} +// TODO: Finish getting rid of older codeList stuff. class LanguageTests { @Test @@ -24,6 +21,7 @@ class LanguageTests { val expected = listOf(AcceptLanguage(Lang.English()), AcceptLanguage(Lang.Wildcard)) assertEquals("wildcard is always moved to the end", expected, sorted) } + @Test fun testMissingHeader() { val neg = Negotiator(Request.toy(headers = mutableListOf())) @@ -76,16 +74,34 @@ class LanguageTests { } run { val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en-US, en;q=0.8"))) - assertEquals(codeList("en-US", "en"), neg.languages()) - assertEquals(codeList("en-US", "en"), neg.languages()) + assertEquals( + listOf( + Lang.English(Locale.UnitedStates), + Lang.English() + ), + neg.languages() + ) } run { val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en-US, en-GB"))) - assertEquals(codeList("en-US", "en-GB"), neg.languages()) + assertEquals( + listOf( + Lang.English(Locale.UnitedStates), + Lang.English(Locale.UnitedKingdom) + ), + neg.languages() + ) } run { val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en-US;q=0.8, es"))) - assertEquals(codeList("es", "en-US"), neg.languages()) + //assertEquals(codeList("es", "en-US"), neg.languages()) + assertEquals( + listOf( + Lang.Spanish(), + Lang.English(Locale.UnitedStates) + ), + neg.languages() + ) } run { val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "nl;q=0.5, fr, de, en, it, es, pt, no, se, fi, ro"))) @@ -135,8 +151,34 @@ class LanguageTests { val msg = "returns preferred languages" val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "*;q=0.8, en, es"))) assertEquals(msg, - codeList( "en", "es", "fr", "de", "it", "pt", "no", "se", "fi", "ro", "nl" ), - neg.acceptableLanguages(codeList("fr", "de", "en", "it", "es", "pt", "no", "se", "fi", "ro", "nl" )) + //codeList( "en", "es", "fr", "de", "it", "pt", "no", "se", "fi", "ro", "nl" ), + listOf( + Lang.English(), + Lang.Spanish(), + Lang.French(), + Lang.German(), + Lang.Italian(), + Lang.Portuguese(), + Lang.Norwegian(), + Lang.Sami(), + Lang.Finnish(), + Lang.Romanian(), + Lang.Dutch() + ), + // neg.acceptableLanguages(codeList("fr", "de", "en", "it", "es", "pt", "no", "se", "fi", "ro", "nl" )) + neg.acceptableLanguages(listOf( + Lang.French(), + Lang.German(), + Lang.English(), + Lang.Italian(), + Lang.Spanish(), + Lang.Portuguese(), + Lang.Norwegian(), + Lang.Sami(), + Lang.Finnish(), + Lang.Romanian(), + Lang.Dutch() + )) ) } run { @@ -149,10 +191,11 @@ class LanguageTests { run { val msg = "accepts en-US, preferring en over en-US" val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en"))) - assertEquals(msg, listOf(Lang.English(Locale.English.UnitedStates)), neg.acceptableLanguages(listOf(Lang.English(Locale.English.UnitedStates)))) - assertEquals(msg, listOf(Lang.English(Locale.English.UnitedStates)), neg.acceptableLanguages(listOf(Lang.English(Locale.English.UnitedStates)))) - assertEquals(msg, listOf(Lang.English(), Lang.English(Locale.English.UnitedStates)), neg.acceptableLanguages(listOf(Lang.English(Locale.English.UnitedStates), Lang.English()))) - assertEquals(msg, listOf(Lang.English(), Lang.English(Locale.English.UnitedStates)), neg.acceptableLanguages(listOf(Lang.English(), Lang.English(Locale.English.UnitedStates)))) + // TODO: Consider Locale.English.UnitedStates like I had originally + assertEquals(msg, listOf(Lang.English(Locale.UnitedStates)), neg.acceptableLanguages(listOf(Lang.English(Locale.UnitedStates)))) + assertEquals(msg, listOf(Lang.English(Locale.UnitedStates)), neg.acceptableLanguages(listOf(Lang.English(Locale.UnitedStates)))) + assertEquals(msg, listOf(Lang.English(), Lang.English(Locale.UnitedStates)), neg.acceptableLanguages(listOf(Lang.English(Locale.UnitedStates), Lang.English()))) + assertEquals(msg, listOf(Lang.English(), Lang.English(Locale.UnitedStates)), neg.acceptableLanguages(listOf(Lang.English(), Lang.English(Locale.UnitedStates)))) } run { val msg = "returns nothing" @@ -167,41 +210,129 @@ class LanguageTests { assertEquals(msg, listOf(Lang.Spanish(), Lang.English()), neg.acceptableLanguages(listOf(Lang.English(), Lang.Spanish()))) assertEquals(msg, listOf(Lang.Spanish(), Lang.English()), neg.acceptableLanguages(listOf(Lang.Spanish(), Lang.English()))) } - - +// +// run { val msg = "returns preferred languages" val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en;q=0.9, es;q=0.8, en;q=0.7"))) - assertEquals(msg, codeList("en"), neg.acceptableLanguages(codeList("en"))) + assertEquals(msg, listOf(Lang.English()), neg.acceptableLanguages(listOf(Lang.English()))) // TODO: apparently the later dupe overwrites the q of the earlier? //assertEquals(msg, codeList("es", "en"), neg.acceptableLanguages(codeList("en", "es"))) //assertEquals(msg, codeList("es", "en"), neg.acceptableLanguages(codeList("es", "en"))) } run { + val msg = "prefers en-US over en" val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en-US, en;q=0.8"))) - assertEquals("prefers en-US over en", codeList("en-US", "en"), neg.acceptableLanguages(codeList("en-US", "en"))) - assertEquals("prefers en-US over en", codeList("en-US", "en", "en-GB"), neg.acceptableLanguages(codeList("en-GB", "en-US", "en"))) + + //assertEquals("prefers en-US over en", codeList("en-US", "en"), neg.acceptableLanguages(codeList("en-US", "en"))) + assertEquals(msg, + listOf( + Lang.English(Locale.UnitedStates), + Lang.English() + ), + neg.acceptableLanguages(listOf( + Lang.English(Locale.UnitedStates), + Lang.English() + )) + ) + + //assertEquals("prefers en-US over en", codeList("en-US", "en", "en-GB"), neg.acceptableLanguages(codeList("en-GB", "en-US", "en"))) + assertEquals(msg, + listOf( + Lang.English(Locale.UnitedStates), + Lang.English(), + Lang.English(Locale.UnitedKingdom) + ), + neg.acceptableLanguages(listOf( + Lang.English(Locale.UnitedKingdom), + Lang.English(Locale.UnitedStates), + Lang.English() + )) + ) } run { val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en-US, en-GB"))) - assertEquals(codeList("en-US", "en-GB"), neg.acceptableLanguages(codeList("en-US", "en-GB"))) - assertEquals(codeList("en-US", "en-GB"), neg.acceptableLanguages(codeList("en-GB", "en-US"))) + //assertEquals(codeList("en-US", "en-GB"), neg.acceptableLanguages(codeList("en-US", "en-GB"))) + assertEquals( + listOf( + Lang.English(Locale.UnitedStates), + Lang.English(Locale.UnitedKingdom) + ), + neg.acceptableLanguages(listOf( + Lang.English(Locale.UnitedStates), + Lang.English(Locale.UnitedKingdom) + )) + ) + + //assertEquals(codeList("en-US", "en-GB"), neg.acceptableLanguages(codeList("en-GB", "en-US"))) + assertEquals( + listOf( + Lang.English(Locale.UnitedStates), + Lang.English(Locale.UnitedKingdom) + ), + neg.acceptableLanguages(listOf( + Lang.English(Locale.UnitedKingdom), + Lang.English(Locale.UnitedStates) + )) + ) } run { val msg = "prefers es over en-US" val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "en-US;q=0.8, es"))) - assertEquals(msg, codeList("es", "en"), neg.acceptableLanguages(codeList("en", "es"))) + + //assertEquals(msg, codeList("es", "en"), neg.acceptableLanguages(codeList("en", "es"))) + assertEquals(msg, + listOf( + Lang.Spanish(), + Lang.English() + ), + neg.acceptableLanguages(listOf( + Lang.English(), + Lang.Spanish() + )) + ) } run { val msg = "returns preferred languages" val neg = Negotiator(Request.toy(headers = mutableListOf(Header.AcceptLanguage to "nl;q=0.5, fr, de, en, it, es, pt, no, se, fi, ro"))) +// assertEquals(msg, +// codeList("fr", "de", "en", "it", "es", "pt", "no", "se", "fi", "ro", "nl"), +// neg.acceptableLanguages(codeList("fr", "de", "en", "it", "es", "pt", "no", "se", "fi", "ro", "nl")) +// ) + assertEquals(msg, - codeList("fr", "de", "en", "it", "es", "pt", "no", "se", "fi", "ro", "nl"), - neg.acceptableLanguages(codeList("fr", "de", "en", "it", "es", "pt", "no", "se", "fi", "ro", "nl")) +// codeList("fr", "de", "en", "it", "es", "pt", "no", "se", "fi", "ro", "nl"), + listOf( + Lang.French(), + Lang.German(), + Lang.English(), + Lang.Italian(), + Lang.Spanish(), + Lang.Portuguese(), + Lang.Norwegian(), + Lang.Sami(), + Lang.Finnish(), + Lang.Romanian(), + Lang.Dutch() + ), +// neg.acceptableLanguages(codeList("fr", "de", "en", "it", "es", "pt", "no", "se", "fi", "ro", "nl")) + neg.acceptableLanguages(listOf( + Lang.French(), + Lang.German(), + Lang.English(), + Lang.Italian(), + Lang.Spanish(), + Lang.Portuguese(), + Lang.Norwegian(), + Lang.Sami(), + Lang.Finnish(), + Lang.Romanian(), + Lang.Dutch() + )) ) } } @@ -459,4 +590,4 @@ class NegotiatorTests { "gzip", neg.acceptableEncoding(listOf("identity", "deflate", "gzip")) ) } -} + }