diff --git a/API.md b/API.md index ac9e6de..2ed25f7 100644 --- a/API.md +++ b/API.md @@ -1,6 +1,6 @@ -Scooter uses the [useragent] package to provide user-agent information. For -more details of what information scooter provides, please see the [useragent](https://www.npmjs.org/package/useragent). +Scooter uses the [my-ua-parser](https://www.npmjs.com/package/my-ua-parser) package to provide user-agent information. For +more details of what information scooter provides, please see the [my-ua-parser](https://www.npmjs.com/package/my-ua-parser) documentation. # Usage @@ -17,7 +17,7 @@ const start = async () => { path: '/user-agent', handler: (request, h) => { - return request.plugins.scooter; + return request.userAgent(); } }); @@ -28,3 +28,39 @@ const start = async () => { start(); ``` + +## `request.userAgent()` + +A convenience decoration added to every request. Returns the same parsed user-agent object as `request.plugins.scooter`. + +```javascript +// Both are equivalent +request.userAgent() +request.plugins.scooter +``` + +The returned object has the following shape: + +``` +{ + family: string, // browser name, e.g. 'Chrome' + major: string, // major version, e.g. '91' + minor: string, // minor version, e.g. '0' + patch: string, // patch version, e.g. '4472' + source: string, // original user-agent header value + os: { + family: string, // OS name, e.g. 'Windows' + major: string, + minor: string, + patch: string + }, + device: { + family: string, // device model or type, e.g. 'iPhone' + brand: string, // device vendor, e.g. 'Apple' + model: string // device model, e.g. 'iPhone' + } +} +``` + +Unknown values default to `'Other'` for family fields and `'0'` for version fields. + diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..78b3c2e --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,37 @@ +import { Plugin } from '@hapi/hapi'; + +export interface ScooterVersionInfo { + family: string; + major: string; + minor: string; + patch: string; +} + +export interface DeviceInfo { + family: string; + brand: string | undefined; + model: string | undefined; +} + +export interface ScooterResult { + family: string; + major: string; + minor: string; + patch: string; + source: string | undefined; + os: ScooterVersionInfo; + device: DeviceInfo; +} + +declare module '@hapi/hapi' { + + interface PluginsStates { + scooter: ScooterResult; + } + + interface Request { + userAgent(): ScooterResult; + } +} + +export const plugin: Plugin; diff --git a/lib/index.js b/lib/index.js index 4d8c239..b92efb8 100755 --- a/lib/index.js +++ b/lib/index.js @@ -1,12 +1,44 @@ 'use strict'; -const Useragent = require('useragent'); +const Useragent = require('my-ua-parser'); -// Requires semver be installed -require('useragent/features'); // Enhances Useragent +const internals = {}; -const internals = {}; +internals.parseVersion = function (version, index) { + + if (!version) { + return '0'; + } + + const parts = version.split('.'); + return parts[index] || '0'; +}; + + +internals.parseUserAgent = function (userAgentString) { + + const result = Useragent(userAgentString || ''); + + return { + family: result.browser.name || 'Other', + major: internals.parseVersion(result.browser.version, 0), + minor: internals.parseVersion(result.browser.version, 1), + patch: internals.parseVersion(result.browser.version, 2), + source: userAgentString, + os: { + family: result.os.name || 'Other', + major: internals.parseVersion(result.os.version, 0), + minor: internals.parseVersion(result.os.version, 1), + patch: internals.parseVersion(result.os.version, 2) + }, + device: { + family: result.device.model || result.device.type || 'Other', + brand: result.device.vendor, + model: result.device.model + } + }; +}; exports.plugin = { @@ -18,12 +50,17 @@ exports.plugin = { register: function (server, options) { server.ext('onRequest', internals.onRequest); + + server.decorate('request', 'userAgent', function () { + + return this.plugins.scooter; + }); } }; internals.onRequest = function (request, h) { - request.plugins.scooter = Useragent.lookup(request.headers['user-agent']); + request.plugins.scooter = internals.parseUserAgent(request.headers['user-agent']); return h.continue; }; diff --git a/package.json b/package.json index cdee850..bd45a9b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "7.0.0", "repository": "git://github.com/hapijs/scooter", "main": "lib/index.js", + "types": "lib/index.d.ts", "files": [ "lib" ], @@ -18,8 +19,7 @@ ] }, "dependencies": { - "semver": "^7.3.8", - "useragent": "^2.3.0" + "my-ua-parser": "^2.0.4" }, "devDependencies": { "@hapi/code": "^9.0.0", diff --git a/test/esm.js b/test/esm.js index 5a40361..6da72ce 100644 --- a/test/esm.js +++ b/test/esm.js @@ -19,7 +19,7 @@ describe('import()', () => { it('exposes all methods and classes as named imports', () => { - expect(Object.keys(Scooter)).to.equal([ + expect(Object.keys(Scooter)).to.contain([ 'default', 'plugin' ]); diff --git a/test/index.js b/test/index.js index fd4d5fb..60cea51 100755 --- a/test/index.js +++ b/test/index.js @@ -32,4 +32,283 @@ describe('scooter', () => { const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 5_0 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9A334 Safari/7534.48.3' } }); expect(res.result).to.equal('iOS'); }); + + it('parses browser family from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); + expect(res.result.family).to.equal('Chrome'); + }); + + it('parses browser major version from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); + expect(res.result.major).to.equal('91'); + }); + + it('parses browser minor version from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); + expect(res.result.minor).to.equal('0'); + }); + + it('parses browser patch version from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); + expect(res.result.patch).to.equal('4472'); + }); + + it('includes source user-agent string in result', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': userAgent } }); + expect(res.result.source).to.equal(userAgent); + }); + + it('parses OS family from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); + expect(res.result.os.family).to.equal('Windows'); + }); + + it('parses OS major version from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); + expect(res.result.os.major).to.equal('10'); + }); + + it('parses OS minor version from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); + expect(res.result.os.minor).to.equal('0'); + }); + + it('parses OS patch version from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); + expect(res.result.os.patch).to.equal('0'); + }); + + it('parses device family from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15' } }); + expect(res.result.device.family).to.equal('iPhone'); + }); + + it('parses device brand from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36' } }); + expect(res.result.device.brand).to.equal('Samsung'); + }); + + it('parses device model from user-agent string', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36' } }); + expect(res.result.device.model).to.equal('SM-G973F'); + }); + + it('handles user-agent with missing browser information', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': '' } }); + expect(res.result.family).to.equal('Other'); + expect(res.result.major).to.equal('0'); + expect(res.result.minor).to.equal('0'); + expect(res.result.patch).to.equal('0'); + }); + + it('handles user-agent with missing OS information', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': '' } }); + expect(res.result.os.family).to.equal('Other'); + expect(res.result.os.major).to.equal('0'); + expect(res.result.os.minor).to.equal('0'); + expect(res.result.os.patch).to.equal('0'); + }); + + it('handles user-agent with missing device information', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0' } }); + expect(res.result.device.family).to.equal('Other'); + expect(res.result.device.brand).to.be.undefined(); + expect(res.result.device.model).to.be.undefined(); + }); + + it('parses device type when model is unavailable', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36' } }); + expect(res.result.device.family).to.exist(); + }); + + it('handles missing user-agent header', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/' }); + expect(res.result.family).to.equal('Other'); + expect(res.result.major).to.equal('0'); + }); + + it('passes empty string to parser when user-agent header is empty', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': '' } }); + expect(res.result.source).to.equal(''); + expect(res.result.family).to.equal('Other'); + expect(res.result.major).to.equal('0'); + expect(res.result.minor).to.equal('0'); + expect(res.result.patch).to.equal('0'); + expect(res.result.os.family).to.equal('Other'); + expect(res.result.device.family).to.equal('Other'); + }); + + it('handles browser version with only major version', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)' } }); + expect(res.result.major).to.exist(); + expect(res.result.minor).to.equal('0'); + expect(res.result.patch).to.equal('0'); + }); + + it('parses Firefox user-agent with complete version information', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0' } }); + expect(res.result.family).to.equal('Firefox'); + expect(res.result.major).to.equal('89'); + expect(res.result.os.family).to.equal('Linux'); + }); + + it('parses Safari user-agent with complete information', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15' } }); + expect(res.result.family).to.exist(); + expect(res.result.os.family).to.equal('Mac OS'); + expect(res.result.os.major).to.equal('10'); + expect(res.result.os.minor).to.equal('15'); + expect(res.result.os.patch).to.equal('7'); + }); + + it('provides userAgent() decoration returning parsed user-agent data', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.userAgent() }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0' } }); + expect(res.result.family).to.equal('Firefox'); + expect(res.result.major).to.equal('89'); + expect(res.result.os.family).to.equal('Linux'); + }); + + it('userAgent() decoration returns same object as request.plugins.scooter', async () => { + + const server = Hapi.server(); + await server.register(Scooter); + + server.route({ method: 'GET', path: '/', handler: (request, h) => request.userAgent() === request.plugins.scooter }); + + const res = await server.inject({ method: 'GET', url: '/' }); + expect(res.result).to.be.true(); + }); });