diff --git a/middleware/requestLanguage.js b/middleware/requestLanguage.js new file mode 100644 index 00000000..c7532fc9 --- /dev/null +++ b/middleware/requestLanguage.js @@ -0,0 +1,109 @@ + +const _ = require('lodash'); +const logger = require( 'pelias-logger' ).get( 'api' ); + +/** + this middleware is responsible for negotiating HTTP locales for incoming + browser requests by reading the querystring param 'lang' or 'Accept-Language' request headers. + + the preferred language will then be available on the $req object: + eg. for '?lang=fr' or 'Accept-Language: fr': + ``` + console.log( req.language ); + + { + name: 'French', + type: 'living', + scope: 'individual', + iso6393: 'fra', + iso6392B: 'fre', + iso6392T: 'fra', + iso6391: 'fr', + defaulted: false + } + ``` + + for configuration options see: + https://github.com/florrain/locale +**/ + +const locale = require('locale'); + +/** + BCP47 language tags can contain three parts: + 1. A language subtag (en, zh). + 2. A script subtag (Hant, Latn). + 3. A region subtag (US, CN). + + at time of writing we will only be concerned with 1. (the language subtag) with + the intention of being compatible with the language standard of whosonfirst data. + + whosonfirst data is in ISO 639-3 format so we will need to configure the library + to support all ISO 639-1 (2 char) codes and convert them to 639-1 (3-char) codes. + + see: https://github.com/whosonfirst/whosonfirst-names +**/ +const iso6393 = require('iso-639-3'); + +// create a dictionary which maps the ISO 639-1 language subtags to a map +// of it's represenation in several different standards. +const language = {}; +iso6393.filter( i => !!i.iso6391 ).forEach( i => language[ i.iso6391 ] = i ); + +// a pre-processed locale list of language subtags we support (all of them). +const allLocales = new locale.Locales( Object.keys( language ) ); + +// return the middleware +module.exports = function middleware( req, res, next ){ + + // init an object to store clean (sanitized) input parameters if not initialized + req.clean = req.clean || {}; + + // init warnings array if not initialized + req.warnings = req.warnings || []; + + // set defaults + var lang = language.en; + var locales, best, via = 'default'; + + // input language via query param + if( via === 'default' && req.query && req.query.lang ){ + locales = new locale.Locales( req.query.lang ); + best = locales.best( allLocales ); + if( best.defaulted ){ + req.warnings.push( 'invalid language provided via querystring' ); + } else { + lang = language[ best.language ]; + via = 'querystring'; + } + } + + // input language via request headers + if( via === 'default' && req.headers && req.headers['accept-language'] ){ + locales = new locale.Locales( req.headers['accept-language'] ); + best = locales.best( allLocales ); + if( best.defaulted ){ + req.warnings.push( 'invalid language provided via header' ); + } else { + lang = language[ best.language ]; + via = 'header'; + } + } + + // set $req.language property + req.language = _.clone( lang ); + req.language.defaulted = ( via === 'default' ); + + // set $req.clean property in order to print language info in response header + req.clean.lang = { + name: req.language.name, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + defaulted: req.language.defaulted + }; + + // logging + logger.info( '[lang] \'%s\' via \'%s\'', lang.iso6391, via ); + + next(); +}; diff --git a/package.json b/package.json index e92c0018..98ad08e0 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,10 @@ "geojson": "^0.4.0", "geojson-extent": "^0.3.1", "geolib": "^2.0.18", + "iso-639-3": "^1.0.0", "iso3166-1": "^0.3.0", "joi": "^10.1.0", + "locale": "^0.1.0", "lodash": "^4.5.0", "markdown": "0.5.0", "morgan": "1.8.1", @@ -62,6 +64,7 @@ "pelias-text-analyzer": "1.7.2", "predicates": "^1.0.1", "retry": "^0.10.1", + "request": "^2.79.0", "stats-lite": "^2.0.4", "superagent": "^3.2.1", "through2": "^2.0.3" @@ -74,7 +77,6 @@ "nsp": "^2.2.0", "precommit-hook": "^3.0.0", "proxyquire": "^1.7.10", - "request": "^2.79.0", "semantic-release": "^6.3.2", "source-map": "^0.5.6", "tap-dot": "1.0.5", diff --git a/routes/v1.js b/routes/v1.js index 60e48172..8ee4173f 100644 --- a/routes/v1.js +++ b/routes/v1.js @@ -18,7 +18,8 @@ var sanitizers = { /** ----------------------- middleware ------------------------ **/ var middleware = { - calcSize: require('../middleware/sizeCalculator') + calcSize: require('../middleware/sizeCalculator'), + requestLanguage: require('../middleware/requestLanguage') }; /** ----------------------- controllers ----------------------- **/ @@ -108,6 +109,7 @@ function addRoutes(app, peliasConfig) { ]), search: createRouter([ sanitizers.search.middleware, + middleware.requestLanguage, middleware.calcSize(), // 3rd parameter is which query module to use, use fallback/geodisambiguation // first, then use original search strategy if first query didn't return anything @@ -131,6 +133,7 @@ function addRoutes(app, peliasConfig) { ]), structured: createRouter([ sanitizers.structured_geocoding.middleware, + middleware.requestLanguage, middleware.calcSize(), controllers.search(peliasConfig.api, esclient, queries.structured_geocoding, not(hasResponseDataOrRequestErrors)), postProc.trimByGranularityStructured(), @@ -150,6 +153,7 @@ function addRoutes(app, peliasConfig) { ]), autocomplete: createRouter([ sanitizers.autocomplete.middleware, + middleware.requestLanguage, controllers.search(peliasConfig.api, esclient, queries.autocomplete, not(hasResponseDataOrRequestErrors)), postProc.distances('focus.point.'), postProc.confidenceScores(peliasConfig.api), @@ -165,6 +169,7 @@ function addRoutes(app, peliasConfig) { ]), reverse: createRouter([ sanitizers.reverse.middleware, + middleware.requestLanguage, middleware.calcSize(), controllers.coarse_reverse(pipService, coarse_reverse_should_execute), controllers.search(peliasConfig.api, esclient, queries.reverse, original_reverse_should_execute), @@ -184,6 +189,7 @@ function addRoutes(app, peliasConfig) { ]), nearby: createRouter([ sanitizers.nearby.middleware, + middleware.requestLanguage, middleware.calcSize(), controllers.search(peliasConfig.api, esclient, queries.reverse, not(hasResponseDataOrRequestErrors)), postProc.distances('point.'), @@ -202,6 +208,7 @@ function addRoutes(app, peliasConfig) { ]), place: createRouter([ sanitizers.place.middleware, + middleware.requestLanguage, controllers.place(peliasConfig.api, esclient), postProc.accuracy(), postProc.localNamingConventions(), diff --git a/test/ciao/autocomplete/language_default.coffee b/test/ciao/autocomplete/language_default.coffee new file mode 100644 index 00000000..3e39e8db --- /dev/null +++ b/test/ciao/autocomplete/language_default.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/autocomplete?text=example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/autocomplete/language_header_invalid.coffee b/test/ciao/autocomplete/language_header_invalid.coffee new file mode 100644 index 00000000..56941e3c --- /dev/null +++ b/test/ciao/autocomplete/language_header_invalid.coffee @@ -0,0 +1,38 @@ + +#> language +path: '/v1/autocomplete?text=example' +headers: 'Accept-Language': 'example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +json.geocoding.warnings.should.eql [ 'invalid language provided via header' ] + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/autocomplete/language_header_valid.coffee b/test/ciao/autocomplete/language_header_valid.coffee new file mode 100644 index 00000000..aea8c28c --- /dev/null +++ b/test/ciao/autocomplete/language_header_valid.coffee @@ -0,0 +1,38 @@ + +#> language +path: '/v1/autocomplete?text=example' +headers: 'Accept-Language': 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + defaulted: false, + iso6391: 'fr', + iso6393: 'fra', + name: 'French' +} diff --git a/test/ciao/autocomplete/language_querystring_invalid.coffee b/test/ciao/autocomplete/language_querystring_invalid.coffee new file mode 100644 index 00000000..65ead1fc --- /dev/null +++ b/test/ciao/autocomplete/language_querystring_invalid.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/autocomplete?lang=example&text=example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +json.geocoding.warnings.should.eql [ 'invalid language provided via querystring' ] + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/autocomplete/language_querystring_valid.coffee b/test/ciao/autocomplete/language_querystring_valid.coffee new file mode 100644 index 00000000..4b267ff2 --- /dev/null +++ b/test/ciao/autocomplete/language_querystring_valid.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/autocomplete?lang=fr&text=example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + defaulted: false, + iso6391: 'fr', + iso6393: 'fra', + name: 'French' +} diff --git a/test/ciao/place/language_default.coffee b/test/ciao/place/language_default.coffee new file mode 100644 index 00000000..444b6254 --- /dev/null +++ b/test/ciao/place/language_default.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/place?ids=geonames:venue:1' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/place/language_header_invalid.coffee b/test/ciao/place/language_header_invalid.coffee new file mode 100644 index 00000000..142464d6 --- /dev/null +++ b/test/ciao/place/language_header_invalid.coffee @@ -0,0 +1,38 @@ + +#> language +path: '/v1/place?ids=geonames:venue:1' +headers: 'Accept-Language': 'example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +json.geocoding.warnings.should.eql [ 'invalid language provided via header' ] + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/place/language_header_valid.coffee b/test/ciao/place/language_header_valid.coffee new file mode 100644 index 00000000..639d3b72 --- /dev/null +++ b/test/ciao/place/language_header_valid.coffee @@ -0,0 +1,38 @@ + +#> language +path: '/v1/place?ids=geonames:venue:1' +headers: 'Accept-Language': 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + defaulted: false, + iso6391: 'fr', + iso6393: 'fra', + name: 'French' +} diff --git a/test/ciao/place/language_querystring_invalid.coffee b/test/ciao/place/language_querystring_invalid.coffee new file mode 100644 index 00000000..3783aa4c --- /dev/null +++ b/test/ciao/place/language_querystring_invalid.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/place?lang=example&ids=geonames:venue:1' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +json.geocoding.warnings.should.eql [ 'invalid language provided via querystring' ] + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/place/language_querystring_valid.coffee b/test/ciao/place/language_querystring_valid.coffee new file mode 100644 index 00000000..3192e754 --- /dev/null +++ b/test/ciao/place/language_querystring_valid.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/place?lang=fr&ids=geonames:venue:1' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + defaulted: false, + iso6391: 'fr', + iso6393: 'fra', + name: 'French' +} diff --git a/test/ciao/reverse/language_default.coffee b/test/ciao/reverse/language_default.coffee new file mode 100644 index 00000000..659aa5c2 --- /dev/null +++ b/test/ciao/reverse/language_default.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/reverse?point.lat=1&point.lon=2' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/reverse/language_header_invalid.coffee b/test/ciao/reverse/language_header_invalid.coffee new file mode 100644 index 00000000..35cd83d1 --- /dev/null +++ b/test/ciao/reverse/language_header_invalid.coffee @@ -0,0 +1,38 @@ + +#> language +path: '/v1/reverse?point.lat=1&point.lon=2' +headers: 'Accept-Language': 'example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +json.geocoding.warnings.should.eql [ 'invalid language provided via header' ] + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/reverse/language_header_valid.coffee b/test/ciao/reverse/language_header_valid.coffee new file mode 100644 index 00000000..bca45c1a --- /dev/null +++ b/test/ciao/reverse/language_header_valid.coffee @@ -0,0 +1,38 @@ + +#> language +path: '/v1/reverse?point.lat=1&point.lon=2' +headers: 'Accept-Language': 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + defaulted: false, + iso6391: 'fr', + iso6393: 'fra', + name: 'French' +} diff --git a/test/ciao/reverse/language_querystring_invalid.coffee b/test/ciao/reverse/language_querystring_invalid.coffee new file mode 100644 index 00000000..af1bff84 --- /dev/null +++ b/test/ciao/reverse/language_querystring_invalid.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/reverse?lang=example&point.lat=1&point.lon=2' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +json.geocoding.warnings.should.eql [ 'invalid language provided via querystring' ] + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/reverse/language_querystring_valid.coffee b/test/ciao/reverse/language_querystring_valid.coffee new file mode 100644 index 00000000..45f22e1b --- /dev/null +++ b/test/ciao/reverse/language_querystring_valid.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/reverse?lang=fr&point.lat=1&point.lon=2' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + defaulted: false, + iso6391: 'fr', + iso6393: 'fra', + name: 'French' +} diff --git a/test/ciao/search/language_default.coffee b/test/ciao/search/language_default.coffee new file mode 100644 index 00000000..eb85265c --- /dev/null +++ b/test/ciao/search/language_default.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/search?text=example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/search/language_header_invalid.coffee b/test/ciao/search/language_header_invalid.coffee new file mode 100644 index 00000000..0035956c --- /dev/null +++ b/test/ciao/search/language_header_invalid.coffee @@ -0,0 +1,38 @@ + +#> language +path: '/v1/search?text=example' +headers: 'Accept-Language': 'example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +json.geocoding.warnings.should.eql [ 'invalid language provided via header' ] + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/search/language_header_valid.coffee b/test/ciao/search/language_header_valid.coffee new file mode 100644 index 00000000..4f3b958d --- /dev/null +++ b/test/ciao/search/language_header_valid.coffee @@ -0,0 +1,38 @@ + +#> language +path: '/v1/search?text=example' +headers: 'Accept-Language': 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + defaulted: false, + iso6391: 'fr', + iso6393: 'fra', + name: 'French' +} diff --git a/test/ciao/search/language_querystring_invalid.coffee b/test/ciao/search/language_querystring_invalid.coffee new file mode 100644 index 00000000..cdfe0195 --- /dev/null +++ b/test/ciao/search/language_querystring_invalid.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/search?lang=example&text=example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +json.geocoding.warnings.should.eql [ 'invalid language provided via querystring' ] + +#? language +json.geocoding.query['lang'].should.eql { + name: 'English', + iso6391: 'en', + iso6393: 'eng', + defaulted: true +} diff --git a/test/ciao/search/language_querystring_valid.coffee b/test/ciao/search/language_querystring_valid.coffee new file mode 100644 index 00000000..8b389efd --- /dev/null +++ b/test/ciao/search/language_querystring_valid.coffee @@ -0,0 +1,37 @@ + +#> language +path: '/v1/search?lang=fr&text=example' + +#? 200 ok +response.statusCode.should.be.equal 200 +response.should.have.header 'charset', 'utf8' +response.should.have.header 'content-type', 'application/json; charset=utf-8' + +#? valid geocoding block +should.exist json.geocoding +should.exist json.geocoding.version +should.exist json.geocoding.attribution +should.exist json.geocoding.query +should.exist json.geocoding.engine +should.exist json.geocoding.engine.name +should.exist json.geocoding.engine.author +should.exist json.geocoding.engine.version +should.exist json.geocoding.timestamp + +#? valid geojson +json.type.should.be.equal 'FeatureCollection' +json.features.should.be.instanceof Array + +#? expected errors +should.not.exist json.geocoding.errors + +#? expected warnings +should.not.exist json.geocoding.warnings + +#? language +json.geocoding.query['lang'].should.eql { + defaulted: false, + iso6391: 'fr', + iso6393: 'fra', + name: 'French' +} diff --git a/test/unit/middleware/requestLanguage.js b/test/unit/middleware/requestLanguage.js new file mode 100644 index 00000000..6252e589 --- /dev/null +++ b/test/unit/middleware/requestLanguage.js @@ -0,0 +1,322 @@ + +var middleware = require('../../../middleware/requestLanguage'); +module.exports.tests = {}; + +var DEFAULTS = { + defaulted: true, + iso6391: 'en', + iso6392B: 'eng', + iso6392T: 'eng', + iso6393: 'eng', + name: 'English', + scope: 'individual', + type: 'living' +}; + +module.exports.tests.defaults = function(test, common) { + test('default language', function(t) { + + var req = {}; + + middleware(req, {}, function () { + t.deepEqual( req.language, DEFAULTS, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, []); + + t.end(); + }); + }); + test('both querystring & header invalid', function(t) { + + var req = { + headers: { 'accept-language': 'foobar' }, + query: { 'lang': 'foobar' } + }; + + middleware(req, {}, function () { + t.deepEqual( req.language, DEFAULTS, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, [ + 'invalid language provided via querystring', + 'invalid language provided via header' + ]); + + t.end(); + }); + }); +}; + +module.exports.tests.invalid = function(test, common) { + test('headers: invalid language', function(t) { + + var req = { headers: { + 'accept-language': 'invalid language' + }}; + + middleware(req, {}, function () { + t.deepEqual( req.language, DEFAULTS, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, [ + 'invalid language provided via header' + ]); + + t.end(); + }); + }); + test('query: invalid language', function(t) { + + var req = { query: { + lang: 'invalid language' + }}; + + middleware(req, {}, function () { + t.deepEqual( req.language, DEFAULTS, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, [ + 'invalid language provided via querystring' + ]); + + t.end(); + }); + }); +}; + +module.exports.tests.valid = function(test, common) { + test('headers: valid language - french', function(t) { + + var req = { headers: { + 'accept-language': 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5' + }}; + + var expected = { + defaulted: false, + iso6391: 'fr', + iso6392B: 'fre', + iso6392T: 'fra', + iso6393: 'fra', + name: 'French', + scope: 'individual', + type: 'living' + }; + + middleware(req, {}, function () { + t.deepEqual( req.language, expected, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, []); + + t.end(); + }); + }); + test('query: valid language - french', function(t) { + + var req = { query: { + lang: 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5' + }}; + + var expected = { + defaulted: false, + iso6391: 'fr', + iso6392B: 'fre', + iso6392T: 'fra', + iso6393: 'fra', + name: 'French', + scope: 'individual', + type: 'living' + }; + + middleware(req, {}, function () { + t.deepEqual( req.language, expected, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, []); + + t.end(); + }); + }); + + test('headers: valid language - english', function(t) { + + var req = { headers: { + 'accept-language': 'en' + }}; + + var expected = { + defaulted: false, + iso6391: 'en', + iso6392B: 'eng', + iso6392T: 'eng', + iso6393: 'eng', + name: 'English', + scope: 'individual', + type: 'living' + }; + + middleware(req, {}, function () { + t.deepEqual( req.language, expected, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, []); + + t.end(); + }); + }); + test('query: valid language - english', function(t) { + + var req = { query: { + lang: 'en' + }}; + + var expected = { + defaulted: false, + iso6391: 'en', + iso6392B: 'eng', + iso6392T: 'eng', + iso6393: 'eng', + name: 'English', + scope: 'individual', + type: 'living' + }; + + middleware(req, {}, function () { + t.deepEqual( req.language, expected, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, []); + + t.end(); + }); + }); +}; + +module.exports.tests.precedence = function(test, common) { + test('precedence: query has precedence over headers', function(t) { + + var req = { + headers: { 'accept-language': 'fr' }, + query: { 'lang': 'es' } + }; + + var expected = { + defaulted: false, + iso6391: 'es', + iso6392B: 'spa', + iso6392T: 'spa', + iso6393: 'spa', + name: 'Spanish', + scope: 'individual', + type: 'living' + }; + + middleware(req, {}, function () { + t.deepEqual( req.language, expected, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, []); + + t.end(); + }); + }); + test('precedence: invalid querystring but valid header', function(t) { + + var req = { + headers: { 'accept-language': 'fr' }, + query: { 'lang': 'foobar' } + }; + + var expected = { + defaulted: false, + iso6391: 'fr', + iso6392B: 'fre', + iso6392T: 'fra', + iso6393: 'fra', + name: 'French', + scope: 'individual', + type: 'living' + }; + + middleware(req, {}, function () { + t.deepEqual( req.language, expected, '$req.language set' ); + + t.deepEqual( req.clean.lang, { + defaulted: req.language.defaulted, + iso6391: req.language.iso6391, + iso6393: req.language.iso6393, + name: req.language.name + }, '$req.clean.lang set' ); + + t.deepEqual( req.warnings, [ + 'invalid language provided via querystring' + ]); + + t.end(); + }); + }); +}; + +module.exports.all = function (tape, common) { + function test(name, testFunction) { + return tape('[middleware] requestLanguage: ' + name, testFunction); + } + for( var testCase in module.exports.tests ){ + module.exports.tests[testCase](test, common); + } +}; diff --git a/test/unit/run.js b/test/unit/run.js index a16fb18e..ad358e30 100644 --- a/test/unit/run.js +++ b/test/unit/run.js @@ -39,6 +39,7 @@ var tests = [ require('./middleware/normalizeParentIds'), require('./middleware/trimByGranularity'), require('./middleware/trimByGranularityStructured'), + require('./middleware/requestLanguage'), require('./query/autocomplete'), require('./query/autocomplete_defaults'), require('./query/search_defaults'),