├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── logo.png ├── package-lock.json ├── package.json ├── readme.md ├── src ├── index.js ├── parse-query.js ├── parse.js ├── stringify.js └── utils.js └── tests ├── benchmark-parse.js ├── index.test.js ├── parse-query.test.js ├── parse.test.js └── stringify.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: 'helmut' 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | lib 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msn0/ale-url-parser/84978f723443f4c9b9e852131b33771a6d6cd3c4/.npmignore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '8' 5 | - '9' 6 | - '10' 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Michał Jezierski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msn0/ale-url-parser/84978f723443f4c9b9e852131b33771a6d6cd3c4/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ale-url-parser", 3 | "version": "1.0.0", 4 | "description": "Fast url parser", 5 | "main": "lib/ale-url-parser.umd.js", 6 | "files": [ 7 | "lib/*" 8 | ], 9 | "scripts": { 10 | "test": "npm run unit && npm run lint", 11 | "unit": "ava --verbose", 12 | "lint": "eslint --fix .", 13 | "prepare": "microbundle -i ./src/index.js -o ./lib" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/msn0/ale-url-parser.git" 18 | }, 19 | "contributors": [ 20 | "Adrian Rydzyński", 21 | "Michał Jezierski " 22 | ], 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/msn0/ale-url-parser/issues" 26 | }, 27 | "homepage": "https://github.com/msn0/ale-url-parser#readme", 28 | "devDependencies": { 29 | "@babel/preset-env": "^7.5.5", 30 | "@babel/register": "^7.5.5", 31 | "ava": "^2.3.0", 32 | "babel-plugin-external-helpers": "^6.22.0", 33 | "benchmark": "^2.1.4", 34 | "eslint-config-helmut": "^4.1.0", 35 | "fast-url-parser": "^1.1.3", 36 | "microbundle": "^0.15.1", 37 | "query-string": "^6.8.2" 38 | }, 39 | "ava": { 40 | "powerAssert": false, 41 | "concurrency": 4, 42 | "require": [ 43 | "@babel/register" 44 | ], 45 | "files": [ 46 | "./tests/*.test.js" 47 | ], 48 | "babel": { 49 | "testOptions": { 50 | "presets": [ 51 | "@babel/preset-env" 52 | ] 53 | } 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | teti 4 |
5 |

6 | 7 | # ale-url-parser [![Build Status](https://travis-ci.org/msn0/ale-url-parser.svg?branch=master)](http://travis-ci.org/msn0/ale-url-parser) 8 | 9 | 🍺 Top fermented URL parser and stringifier built with performance and small size (1.6KB) in mind. 10 | 11 | ## Installation 12 | 13 | ```sh 14 | $ npm i ale-url-parser 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### parse :: String -> Object 20 | 21 | Parse url string and return url object. 22 | 23 | ```js 24 | const { parse } = require('ale-url-parser'); 25 | ``` 26 | 27 | #### Basic example 28 | 29 | ```js 30 | parse('http://domain.lol/lorem/ipsum?foo=1&bar=2#baz'); 31 | 32 | { 33 | protocol: 'http', 34 | host: 'domain.lol', 35 | path: ['lorem', 'ipsum'], 36 | query: { foo: '1', bar: '2' }, 37 | hash: 'baz' 38 | } 39 | ``` 40 | 41 | #### Preserve protocol 42 | 43 | ```js 44 | parse('//domain.lol'); 45 | 46 | { 47 | protocol: '', 48 | host: 'domain.lol', 49 | path: [], query: {}, hash: '' 50 | } 51 | ``` 52 | 53 | #### Http by default 54 | 55 | ```js 56 | parse('domain.lol'); 57 | 58 | { 59 | protocol: 'http', 60 | host: 'domain.lol', 61 | path: [], query: {}, hash: '' 62 | } 63 | ``` 64 | 65 | 66 | #### Multi-valued query parameters 67 | 68 | ```js 69 | parse('domain.lol?foo=1&foo=2&bar=3'); 70 | 71 | { 72 | protocol: 'http', 73 | host: 'domain.lol', 74 | path: [], 75 | query: { foo: ['1', '2'], bar: '3' }, 76 | hash: '' 77 | } 78 | ``` 79 | 80 | #### Parsing relative urls 81 | 82 | ```js 83 | parse('?foo=1'); 84 | 85 | { 86 | protocol: 'http', 87 | host: '', 88 | path: [], 89 | query: { foo: '1' }, 90 | hash: '' 91 | } 92 | ``` 93 | 94 | ### stringify :: Object -> String 95 | 96 | Stringify url object to url string. 97 | 98 | ```js 99 | const { stringify } = require('ale-url-parser'); 100 | ``` 101 | 102 | #### Basic example 103 | 104 | ```js 105 | stringify({ 106 | protocol: 'https', 107 | host: 'domain.lol', 108 | path: ['lorem', 'ipsum'], 109 | query: { foo: '1', bar: '2' }, 110 | hash: 'baz' 111 | }); 112 | 113 | "https://domain.lol/lorem/ipsum?foo=1&bar=2#baz" 114 | ``` 115 | 116 | #### Preserve protocol 117 | 118 | ```js 119 | stringify({ 120 | protocol: '', 121 | host: 'domain.lol' 122 | }); 123 | 124 | "//domain.lol" 125 | ``` 126 | 127 | #### Multi-valued query parameters 128 | 129 | ```js 130 | stringify({ 131 | protocol: 'https', 132 | host: 'domain.lol', 133 | query: { foo: ['1', '2'], bar: '/baz' } 134 | }); 135 | 136 | "https://domain.lol?foo=1&foo=2&bar=%2Fbaz" 137 | ``` 138 | 139 | #### Build relative urls 140 | 141 | ```js 142 | stringify({ 143 | path: ['lorem', 'ipsum'], 144 | query: { foo: '1', bar: '2' } 145 | }); 146 | 147 | "/lorem/ipsum?foo=1&bar=2" 148 | ``` 149 | 150 | #### Sort query params with custom compareFunction 151 | 152 | Sorting query params is disabled by default. You can define your own sorting method by passing `compareFunction`: 153 | 154 | ```js 155 | const order = ['first', 'second', 'third', 'fourth']; 156 | stringify({ 157 | host: 'domain.lol', 158 | query: { third: '3', first: '1', fourth: '4', second: '2' } 159 | }, { 160 | compareFunction: (a, b) => order.indexOf(a) > order.indexOf(b) 161 | }); 162 | 163 | "http://domain.lol?first=1&second=2&third=3&fourth=4" 164 | ``` 165 | 166 | ## Caveats 167 | 168 | `ale-url-parser` is limited to be used with `http` and `https` protocols though context-aware protocol guess is supported by passing an empty string to `stringify` function, i.e. `protocol: ''`. 169 | 170 | ## Benchmarks 171 | 172 | ```sh 173 | $ npm t && npm run prepare && node ./tests/benchmark-parse.js 174 | 175 | [simple] ale-url-parser x 124,203 ops/sec ±0.67% (91 runs sampled) 176 | [simple] url x 75,006 ops/sec ±1.03% (89 runs sampled) 177 | [simple] query-string x 47,283 ops/sec ±0.77% (86 runs sampled) 178 | [simple] fast-url-parser x 237,420 ops/sec ±0.66% (91 runs sampled) 179 | [simple] Fastest is fast-url-parser 180 | [complex] ale-url-parser x 16,846 ops/sec ±0.58% (89 runs sampled) 181 | [complex] url x 8,104 ops/sec ±0.71% (86 runs sampled) 182 | [complex] query-string x 5,884 ops/sec ±0.80% (87 runs sampled) 183 | [complex] fast-url-parser x 15,430 ops/sec ±0.92% (87 runs sampled) 184 | ``` 185 | https://jsperf.com/ale-url-parser-vs-new-url 186 | 187 | ## TypeScript definitions 188 | 189 | Type definitions for `ale-url-parser` are declared in `DefinitelyTyped` repository. We recommend installing `@types/ale-url-parser` for a better experience 190 | 191 | ```sh 192 | $ npm i @types/ale-url-parser -D 193 | ``` 194 | 195 | ## License 196 | 197 | MIT 198 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { stringify } from './stringify'; 2 | export { parse } from './parse'; 3 | -------------------------------------------------------------------------------- /src/parse-query.js: -------------------------------------------------------------------------------- 1 | import { indexOf } from './utils'; 2 | 3 | function decode(component) { 4 | try { 5 | return decodeURIComponent(component); 6 | } catch (e) { 7 | return component; 8 | } 9 | } 10 | 11 | export function parseQuery (queryString) { 12 | // TODO: compare with https://url.spec.whatwg.org/#concept-url-parser 13 | const splitted = queryString.split('&'); 14 | const acc = {}; 15 | 16 | for (let i = 0; i < splitted.length; i++) { 17 | const next = splitted[i]; 18 | const indexOfEqualsSign = indexOf('=', next); 19 | const param = []; 20 | if (indexOfEqualsSign !== -1) { 21 | param.push(next.substr(0, indexOfEqualsSign), next.substr(indexOfEqualsSign + 1)); 22 | } else { 23 | param.push(next); 24 | } 25 | 26 | if (param[1] && indexOf('+', param[1]) > -1) { 27 | param[1] = param[1].replace(/\+/g, ' '); 28 | } 29 | 30 | const name = indexOf('%', param[0]) !== -1 ? decode(param[0]) : param[0]; 31 | const value = param[1] ? (indexOf('%', param[1]) !== -1 ? decode(param[1]) : param[1]) : ''; 32 | if (Array.isArray(acc[name])) { 33 | acc[name].push(value); 34 | } else if (acc.hasOwnProperty(name)) { 35 | acc[name] = [ acc[name], value ]; 36 | } else { 37 | acc[name] = value; 38 | } 39 | } 40 | 41 | return acc; 42 | } 43 | -------------------------------------------------------------------------------- /src/parse.js: -------------------------------------------------------------------------------- 1 | import { parseQuery } from './parse-query'; 2 | import { indexOf } from './utils'; 3 | 4 | // https://jsperf.com/test-protocol-indexof-vs-regex 5 | function getProtocol(url) { 6 | if (indexOf('//', url) === 0) { 7 | return ''; 8 | } else if (indexOf('https://', url) === 0) { 9 | return 'https'; 10 | } 11 | 12 | return 'http'; 13 | } 14 | 15 | // https://jsperf.com/domain-path-parse-indexof-vs-regex 16 | function getHostAndPath(url) { 17 | const match = /([^/]*:?\/\/)?([^/^?]*)([^?]*)?/.exec(url); 18 | 19 | if (match) { 20 | const host = match[2]; 21 | const path = match[3] ? match[3].split('/').filter(p => p) : []; 22 | 23 | return [ host, path ]; 24 | } 25 | } 26 | 27 | function getQueryAndHash(url) { 28 | // https://jsperf.com/split-vs-indexof-question-mark-match 29 | const indexOfQuestionSign = indexOf('?', url); 30 | const source = indexOfQuestionSign !== -1 && url.slice(indexOfQuestionSign + 1); 31 | 32 | if (!source) { 33 | return [ {}, '' ]; 34 | } 35 | 36 | const indexOfHash = indexOf('#', source); 37 | if (indexOfHash === -1) { 38 | return [ parseQuery(source), '' ]; 39 | } 40 | 41 | const queryString = source.slice(0, indexOfHash); 42 | const hash = source.slice(indexOfHash + 1); 43 | return [ 44 | parseQuery(queryString), 45 | hash 46 | ]; 47 | } 48 | 49 | export function parse(url) { 50 | const protocol = getProtocol(url); 51 | const [ host, path ] = getHostAndPath(url); 52 | const [ query, hash ] = getQueryAndHash(url); 53 | const result = { 54 | protocol, 55 | host, 56 | path, 57 | query, 58 | hash 59 | }; 60 | 61 | return result; 62 | } 63 | -------------------------------------------------------------------------------- /src/stringify.js: -------------------------------------------------------------------------------- 1 | import { encode } from './utils'; 2 | 3 | export function stringify( 4 | { protocol = 'http', host = '', path = [], query = {}, hash }, 5 | options = {} 6 | ) { 7 | let result = ''; 8 | 9 | if (host) { 10 | if (protocol === '') { 11 | result += '//'; 12 | } else { 13 | result += protocol + '://'; 14 | } 15 | 16 | result += host; 17 | } 18 | 19 | if (path.length > 0) { 20 | result += '/' + path.join('/'); 21 | } 22 | 23 | const keys = Object.keys(query); 24 | if (keys.length > 0) { 25 | if (options.compareFunction) { 26 | keys.sort(options.compareFunction); 27 | } 28 | const queryString = keys 29 | .filter((k) => query[k] !== undefined) 30 | .map((key) => { 31 | const encodedKey = encode(key); 32 | if (Array.isArray(query[key])) { 33 | return query[key] 34 | .map((value) => `${encodedKey}=${encode(value)}`) 35 | .join('&'); 36 | } else if (query[key] === null) { 37 | return `${encodedKey}`; 38 | } 39 | return `${encodedKey}=${encode(query[key])}`; 40 | }) 41 | .join('&'); 42 | 43 | result += '?' + queryString; 44 | } 45 | 46 | if (hash) { 47 | result += '#' + hash; 48 | } 49 | 50 | return result; 51 | } 52 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function indexOf(string, context) { 2 | return context.indexOf(string); 3 | } 4 | 5 | export function encode(value) { 6 | return encodeURIComponent(value); 7 | } 8 | 9 | export function decode(value) { 10 | return decodeURIComponent(value); 11 | } 12 | -------------------------------------------------------------------------------- /tests/benchmark-parse.js: -------------------------------------------------------------------------------- 1 | const Benchmark = require('benchmark'); 2 | const ale = require('../lib/ale-url-parser.umd'); 3 | const url = require('url'); 4 | const queryString = require('query-string'); 5 | const fastUrlParser = require('fast-url-parser'); 6 | 7 | const urlsSimple = [ 8 | 'https://google.com/', 9 | '?foo=1&bar=2&foo=a', 10 | '/simple/path', 11 | 'https://github.com/msn0/ale-url-parser', 12 | 'http://domain.lol/foo/bar?foo=bar&baz=qux', 13 | 'http://domain.lol/?foo=bar#hash', 14 | '//some.domain/path?query', 15 | '/path/q/d/f?foo=1#test' 16 | ]; 17 | 18 | const urlsComplex = [ 19 | '/kategoria/drukarki-i-skanery-4578?string=Brother%20%22MFC-J4420DW%22%20(toner*%20tusz*%20b%C4%99ben)&' 20 | + 'utm_source=google&utm_medium=cpc&n=_onika%20-%20Komputery%20-%20Drukarki%20i%20Skanery&' 21 | + 'r=Elektronika%20-%20Komputery%20-%20Drukarki%20i%20Skanery%20-%20Tusze%20i%20tonery%20-%20Brother%20MFC-J4420DW' 22 | + '&n=Brother%20%2BMFC-J4420DW%20%2Btusz&gclid=Tw_kE&g=aw.s&d=L0A&order=d&h=ssce-ki-5-g-2-0328&price_from=40', 23 | 'https://www.adidas.pl/buty-gazelle-shoes/BB5480.html?pr=CUSTOMIZE_IMG_Buty%2520Gazelle%2520Shoes', 24 | 'https://www.google.pl/search?q=decodeURIComponent+is+slow&oq=decodeURIComponent+is+slow&aqs=chrome..69i57.1616j0j7&sourceid=chrome&ie=UTF-8', 25 | '?a=http%3A%2F%2Fdomain.lol%2Ffoo%2Fbar%3Ffoo%3D1&bar=2', 26 | '//domain.ninja/foo/bar/baz?route=https%3A%2F%2Fapi.domain.ninja%2Ffoo%3Fbar%5B%5D%3D1' 27 | + '&bar%5B%5D=2&bar%5B%5D=3&lorem=1&ipsu%3Dm=nothing#hash', 28 | 'https://allegro.pl/kategoria/ogrod-1532?order=m&stan=nowe&dostawa-kurier=1&price_from=11&price_to=22&freeShipping=1&super-sprzedawca=1&city=Gda%C5%84sk&startingTime=3', 29 | 'https://some.very.long.domain1.com/some/really/long/path/lorem/ipsum/dolor/sit/amet?foo=bar&baz=qux&order=m&stan=nowe&dostawa-kurier=1&price_from=11&price_to=22&freeShipping=1&super-sprzedawca=1&city=Gda%C5%84sk&startingTime=3&a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6&a[]=7&a[]=8&b[]=1&b%3d[]=2&b[]=3&b%3d[]=4&b[]=5&b%3d[]=6&b[]=7&b%3d[]=8&route=https://domain.lol/foo/bar/baz/?advert=1&route=some-other-route#hash-bash-mome-long-and-ugly____--%3F--ha-s-h', 30 | 'https://some.very.long.domain1.com/some/really/long/path/lorem/ipsum/dolor/sit/amet?foo=bar&baz=qux&order=m&stan=nowe&dostawa-kurier=1&price_from=11&price_to=22&freeShipping=1&super-sprzedawca=1&city=Gda%C5%84sk&startingTime=3&a%5B%5D=1&a%5B%5D=2&a%5B%5D=3&a%5B%5D=4&a%5B%5D=5&a%5B%5D=6&a%5B%5D=7&a%5B%5D=8&b%5B%5D=1&b%5B%5D=3&b%5B%5D=5&b%5B%5D=7&b%3D%5B%5D=2&b%3D%5B%5D=4&b%3D%5B%5D=6&b%3D%5B%5D=8&route=https%3A%2F%2Fdomain.lol%2Ffoo%2Fbar%2Fbaz%2F%3Fadvert%3D1&route=some-other-route#hash-bash-mome-long-and-ugly____--%3F--ha-s-h' 31 | ]; 32 | 33 | function runSuite(suite, urls, label) { 34 | return new Promise(resolve => { 35 | suite 36 | .add('ale-url-parser', () => urls.forEach(ale.parse)) 37 | .add('url', () => urls.forEach(u => url.parse(u, true))) 38 | .add('query-string', () => urls.forEach(queryString.parse)) 39 | .add('fast-url-parser', () => urls.forEach(u => fastUrlParser.parse(u, true))) 40 | .on('cycle', function(event) { 41 | console.log(`[${label}] ${String(event.target)}`); 42 | }) 43 | .on('complete', function() { 44 | console.log(`[${label}] Fastest is ${this.filter('fastest').map('name')}`); 45 | resolve(); 46 | }) 47 | .run({ 'async': true }); 48 | }); 49 | } 50 | 51 | runSuite(new Benchmark.Suite(), urlsSimple, 'simple').then(() => { 52 | runSuite(new Benchmark.Suite(), urlsComplex, 'complex'); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { parse, stringify } from '../src'; 3 | 4 | const testCases = [ 5 | { 6 | urlString: 'http://domain.ninja', 7 | urlObject: { 8 | protocol: 'http', 9 | host: 'domain.ninja', 10 | path: [], 11 | query: {}, 12 | hash: '' 13 | } 14 | }, 15 | { 16 | urlString: 'http://domain.ninja/foo/bar', 17 | urlObject: { 18 | protocol: 'http', 19 | host: 'domain.ninja', 20 | path: ['foo', 'bar'], 21 | query: {}, 22 | hash: '' 23 | } 24 | }, { 25 | urlString: 'https://domain.ninja/foo/bar?a=b%3Dc%3Dd%3Ffoo&bar=1&bar=2', 26 | urlObject: { 27 | protocol: 'https', 28 | host: 'domain.ninja', 29 | path: ['foo', 'bar'], 30 | query: { 31 | a: 'b=c=d?foo', 32 | bar: ['1', '2'] 33 | }, 34 | hash: '' 35 | } 36 | }, { 37 | urlString: '//domain.ninja/foo/bar/baz?route=https%3A%2F%2Fapi.domain.ninja%2Ffoo%3Fbar%5B%5D%3D1&bar%5B%5D=2&bar%5B%5D=3', 38 | urlObject: { 39 | protocol: '', 40 | host: 'domain.ninja', 41 | path: ['foo', 'bar', 'baz'], 42 | query: { 43 | route: 'https://api.domain.ninja/foo?bar[]=1', 44 | 'bar[]': ['2', '3'] 45 | }, 46 | hash: '' 47 | } 48 | }, { 49 | urlString: 'https://some.very.long.domain1.com/some/really/long/path/lorem/ipsum/dolor/sit/amet?foo=bar&baz=qux&order=m&stan=nowe&dostawa-kurier=1&price_from=11&price_to=22&freeShipping=1&super-sprzedawca=1&city=Gda%C5%84sk&startingTime=3&a%5B%5D=1&a%5B%5D=2&a%5B%5D=3&a%5B%5D=4&a%5B%5D=5&a%5B%5D=6&a%5B%5D=7&a%5B%5D=8&b%5B%5D=1&b%5B%5D=3&b%5B%5D=5&b%5B%5D=7&b%3D%5B%5D=2&b%3D%5B%5D=4&b%3D%5B%5D=6&b%3D%5B%5D=8&route=https%3A%2F%2Fdomain.lol%2Ffoo%2Fbar%2Fbaz%2F%3Fadvert%3D1&route=some-other-route#hash-bash-mome-long-and-ugly____--%3F--ha-s-h', 50 | urlObject: { 51 | protocol: 'https', 52 | host: 'some.very.long.domain1.com', 53 | path: ['some', 'really', 'long', 'path', 'lorem', 'ipsum', 'dolor', 'sit', 'amet'], 54 | query: { 55 | foo: 'bar', 56 | baz: 'qux', 57 | order: 'm', 58 | stan: 'nowe', 59 | 'dostawa-kurier': '1', 60 | price_from: '11', 61 | price_to: '22', 62 | freeShipping: '1', 63 | 'super-sprzedawca': '1', 64 | city: 'Gdańsk', 65 | startingTime: '3', 66 | 'a[]': ['1', '2', '3', '4', '5', '6', '7', '8'], 67 | 'b[]': ['1', '3', '5', '7'], 68 | 'b=[]': ['2', '4', '6', '8'], 69 | route: [ 70 | 'https://domain.lol/foo/bar/baz/?advert=1', 71 | 'some-other-route' 72 | ] 73 | }, 74 | hash: 'hash-bash-mome-long-and-ugly____--%3F--ha-s-h' 75 | } 76 | } 77 | ]; 78 | 79 | testCases.forEach(({ urlString, urlObject }, index) => { 80 | test(`should produce expected result #${index + 1}`, t => { 81 | t.deepEqual(parse(urlString), urlObject); 82 | t.is(stringify(urlObject), urlString); 83 | }); 84 | }); 85 | 86 | testCases.forEach(({ urlString, urlObject }, index) => { 87 | test(`should be inversible #${index + 1}`, t => { 88 | t.is(stringify(parse(urlString)), urlString); 89 | t.deepEqual(parse(stringify(urlObject)), urlObject); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/parse-query.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { parseQuery } from '../src/parse-query'; 3 | 4 | const testCases = [{ 5 | queryString: 'foo=bar&baz=qux', 6 | queryObject: { 7 | foo: 'bar', 8 | baz: 'qux' 9 | } 10 | }, { 11 | queryString: 'foo[]=bar&foo[]=baz&foo[]=qux', 12 | queryObject: { 13 | 'foo[]': ['bar', 'baz', 'qux'] 14 | } 15 | }, { 16 | queryString: 'a=b=c&d=e=f', 17 | queryObject: { 18 | a: 'b=c', 19 | d: 'e=f' 20 | } 21 | }, { 22 | queryString: 'a&b', 23 | queryObject: { 24 | a: '', 25 | b: '' 26 | } 27 | }, { 28 | queryString: 'a=b%3Dc', 29 | queryObject: { 30 | a: 'b=c' 31 | } 32 | }, { 33 | queryString: 'a=http://domain.lol/foo/bar?foo=1&bar=2', 34 | queryObject: { 35 | a: 'http://domain.lol/foo/bar?foo=1', 36 | bar: '2' 37 | } 38 | }, { 39 | queryString: 'a=http%3A%2F%2Fdomain.lol%2Ffoo%2Fbar%3Ffoo%3D1&bar=2', 40 | queryObject: { 41 | a: 'http://domain.lol/foo/bar?foo=1', 42 | bar: '2' 43 | } 44 | }, { 45 | queryString: 'a=%E2%99%A1&b=★', 46 | queryObject: { 47 | a: '♡', 48 | b: '★' 49 | } 50 | }, { 51 | queryString: 'a=http://domain.lol/foo/bar?foo=1&foo=2', 52 | queryObject: { 53 | a: 'http://domain.lol/foo/bar?foo=1', 54 | foo: '2' 55 | } 56 | }, { 57 | queryString: 'foo=http://domain.lol/foo/bar?foo=1&foo=2', 58 | queryObject: { 59 | foo: ['http://domain.lol/foo/bar?foo=1', '2'] 60 | } 61 | }, { 62 | queryString: 'a%3Db=c&f%E2%98%85%E2%98%85=bar&b★z=★&a=%3F&a=%3D', 63 | queryObject: { 64 | 'a=b': 'c', 65 | 'f★★': 'bar', 66 | 'b★z': '★', 67 | 'a': ['?', '='] 68 | } 69 | }, { 70 | queryString: 'foo=R%E9age', 71 | queryObject: { 72 | foo: 'R%E9age' 73 | } 74 | }, { 75 | queryString: '%EA=%E0&ê=à', 76 | queryObject: { 77 | '%EA': '%E0', 78 | 'ê': 'à' 79 | } 80 | }]; 81 | 82 | testCases.forEach(({ queryString, queryObject }, index) => { 83 | test(`shoud parse simple query #${index + 1}`, t => { 84 | t.deepEqual(parseQuery(queryString), queryObject); 85 | }); 86 | }); 87 | 88 | test('should handle + correctly', t => { 89 | t.deepEqual(parseQuery('a=b+c'), { 90 | a: 'b c' 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /tests/parse.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { parse } from '../src/parse'; 3 | 4 | test('should parse protocol', t => { 5 | t.is(parse('https://domain.lol/foo?bar').protocol, 'https'); 6 | }); 7 | 8 | test('should parse protocol and default to http when missing', t => { 9 | t.is(parse('domain.lol/foo?bar').protocol, 'http'); 10 | }); 11 | 12 | test('should return empty protocol for URL that starts with //', t => { 13 | t.is(parse('//domain.lol/foo?bar').protocol, ''); 14 | }); 15 | 16 | test.failing('should parse custom protocol', t => { 17 | t.is(parse('proto-star://domain.lol/foo?bar').protocol, 'proto-star'); 18 | }); 19 | 20 | test('should parse host', t => { 21 | t.is(parse('http://domain.lol/').host, 'domain.lol'); 22 | }); 23 | 24 | test('should parse path', t => { 25 | t.deepEqual(parse('http://domain.lol/foo/bar/baz').path, ['foo', 'bar', 'baz']); 26 | }); 27 | 28 | test('should parse empty path', t => { 29 | t.deepEqual(parse('http://domain.lol').path, []); 30 | }); 31 | 32 | // https://tools.ietf.org/html/rfc3986#section-3.4 33 | test('should allow unescaped reserved chars in query string values', t => { 34 | t.deepEqual(parse('https://domain.lol?route=http://foo.ninja/bar?baz=1#foobar'), { 35 | protocol: 'https', 36 | host: 'domain.lol', 37 | path: [], 38 | query: { 39 | route: 'http://foo.ninja/bar?baz=1' 40 | }, 41 | hash: 'foobar' 42 | }); 43 | }); 44 | 45 | // https://tools.ietf.org/html/rfc3986#section-3.4 46 | test('should allow escaped reserved chars in query string values', t => { 47 | t.deepEqual(parse('https://domain.lol?route=http%3A%2F%2Ffoo.ninja%2Fbar%3Fbaz%3D1#foobar'), { 48 | protocol: 'https', 49 | host: 'domain.lol', 50 | path: [], 51 | query: { 52 | route: 'http://foo.ninja/bar?baz=1' 53 | }, 54 | hash: 'foobar' 55 | }); 56 | }); 57 | 58 | test('should parse query string', t => { 59 | t.deepEqual(parse('http://domain.lol/games/wiedzmin?priceMin=300&price-max=500').query, { 60 | priceMin: '300', 61 | 'price-max': '500' 62 | }); 63 | }); 64 | 65 | test('should parse query string and decode query value', t => { 66 | t.deepEqual(parse('https://domain.lol?lorem=ipsum%20dolor%20/%20sit%20%26%20amet').query, { 67 | lorem: 'ipsum dolor / sit & amet' 68 | }); 69 | }); 70 | 71 | test('should parse query string and decode query name', t => { 72 | t.deepEqual(parse('https://domain.lol?foo%5B%5D=1&bar%5B%5D&baz%5B%5D=2&baz%5B%5D=3').query, { 73 | 'foo[]': '1', 74 | 'bar[]': '', 75 | 'baz[]': ['2', '3'] 76 | }); 77 | }); 78 | 79 | test('should parse query string and decode uri components', t => { 80 | t.deepEqual(parse('https://domain.lol?lorem=ipsum%20dolor%20/%20sit%20%26%20amet').query, { 81 | lorem: 'ipsum dolor / sit & amet' 82 | }); 83 | }); 84 | 85 | test('should parse boolean query string parameters', t => { 86 | t.deepEqual(parse('https://domain.lol?lorem').query, { 87 | lorem: '' 88 | }); 89 | }); 90 | 91 | test('should parse multi-value query string parameters', t => { 92 | t.deepEqual(parse('https://domain.lol?foo=1&foo=2').query, { 93 | foo: ['1', '2'] 94 | }); 95 | }); 96 | 97 | test('should parse empty query string', t => { 98 | t.deepEqual(parse('https://domain.lol/foo/bar/').query, {}); 99 | }); 100 | 101 | test('should parse hash', t => { 102 | t.deepEqual(parse('https://domain.lol/foo?bar=1#baz').hash, 'baz'); 103 | }); 104 | 105 | test('should parse empty hash', t => { 106 | t.deepEqual(parse('https://domain.lol/foo?bar=1').hash, ''); 107 | }); 108 | 109 | test('parse relative url with path', t => { 110 | t.deepEqual(parse('/foo/bar').path, ['foo', 'bar']); 111 | }); 112 | 113 | test('parse relative url with query', t => { 114 | t.deepEqual(parse('?foo=1&bar=2').query, { foo: '1', bar: '2' }); 115 | }); 116 | 117 | test('parse relative url with path and query', t => { 118 | t.deepEqual(parse('/foo/bar').path, ['foo', 'bar']); 119 | t.deepEqual(parse('?foo=1&bar=2').query, { foo: '1', bar: '2' }); 120 | }); 121 | 122 | test('parse +', t => { 123 | t.deepEqual(parse('?string=test+%2B+promo').query, { string: 'test + promo' }); 124 | }); 125 | -------------------------------------------------------------------------------- /tests/stringify.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { stringify } from '../src/stringify'; 3 | 4 | test('parse object with http protocol', (t) => { 5 | t.deepEqual( 6 | stringify({ 7 | protocol: 'http', 8 | host: 'domain.lol' 9 | }), 10 | 'http://domain.lol' 11 | ); 12 | }); 13 | 14 | test('parse object with https protocol', (t) => { 15 | t.deepEqual( 16 | stringify({ 17 | protocol: 'https', 18 | host: 'domain.lol' 19 | }), 20 | 'https://domain.lol' 21 | ); 22 | }); 23 | 24 | test('parse default to http protocol', (t) => { 25 | t.deepEqual( 26 | stringify({ 27 | host: 'domain.lol' 28 | }), 29 | 'http://domain.lol' 30 | ); 31 | }); 32 | 33 | test('parse object with empty protocol', (t) => { 34 | t.deepEqual( 35 | stringify({ 36 | protocol: '', 37 | host: 'domain.lol' 38 | }), 39 | '//domain.lol' 40 | ); 41 | }); 42 | 43 | test('should stringify custom protocol', (t) => { 44 | t.deepEqual( 45 | stringify({ 46 | protocol: 'proto-star', 47 | host: 'domain.lol' 48 | }), 49 | 'proto-star://domain.lol' 50 | ); 51 | }); 52 | 53 | test('parse object with path', (t) => { 54 | t.deepEqual( 55 | stringify({ 56 | host: 'domain.lol', 57 | path: ['foo', 'bar'] 58 | }), 59 | 'http://domain.lol/foo/bar' 60 | ); 61 | }); 62 | 63 | test('parse object with query', (t) => { 64 | t.deepEqual( 65 | stringify({ 66 | host: 'domain.lol', 67 | query: { 68 | priceMin: '300', 69 | priceMax: '500' 70 | } 71 | }), 72 | 'http://domain.lol?priceMin=300&priceMax=500' 73 | ); 74 | }); 75 | 76 | test('parse object with multiple value for single query key', (t) => { 77 | t.deepEqual( 78 | stringify({ 79 | host: 'domain.lol', 80 | query: { 81 | foo: ['bar1', 'bar2'] 82 | } 83 | }), 84 | 'http://domain.lol?foo=bar1&foo=bar2' 85 | ); 86 | }); 87 | 88 | test('parse object with query and path', (t) => { 89 | t.deepEqual( 90 | stringify({ 91 | host: 'domain.lol', 92 | path: ['games', 'wiedzmin'], 93 | query: { 94 | priceMin: '300', 95 | priceMax: '500' 96 | } 97 | }), 98 | 'http://domain.lol/games/wiedzmin?priceMin=300&priceMax=500' 99 | ); 100 | }); 101 | 102 | test('parse object with boolean query params', (t) => { 103 | t.deepEqual( 104 | stringify({ 105 | host: 'domain.lol', 106 | query: { 107 | foo: '', 108 | bar: '' 109 | } 110 | }), 111 | 'http://domain.lol?foo=&bar=' 112 | ); 113 | }); 114 | 115 | test('should encode query values', (t) => { 116 | t.deepEqual( 117 | stringify({ 118 | host: 'domain.lol', 119 | query: { 120 | foo: ['foo & bar', '☺'] 121 | } 122 | }), 123 | 'http://domain.lol?foo=foo%20%26%20bar&foo=%E2%98%BA' 124 | ); 125 | }); 126 | 127 | test('should encode query names', (t) => { 128 | t.deepEqual( 129 | stringify({ 130 | host: 'domain.lol', 131 | query: { 132 | 'foo[]': '1', 133 | 'bar[]': '', 134 | 'baz[]': ['2', '3'] 135 | } 136 | }), 137 | 'http://domain.lol?foo%5B%5D=1&bar%5B%5D=&baz%5B%5D=2&baz%5B%5D=3' 138 | ); 139 | }); 140 | 141 | test('parse object with hash', (t) => { 142 | t.deepEqual( 143 | stringify({ 144 | host: 'domain.lol', 145 | hash: 'test' 146 | }), 147 | 'http://domain.lol#test' 148 | ); 149 | }); 150 | 151 | test('should sort params using compareFunction if given', (t) => { 152 | const order = ['first', 'second', 'third', 'fourth']; 153 | const compareFunction = (a, b) => 154 | order.indexOf(a) - order.indexOf(b) > 0 ? 1 : -1; 155 | 156 | t.deepEqual( 157 | stringify( 158 | { 159 | host: 'domain.lol', 160 | query: { 161 | second: '2', 162 | third: '3', 163 | first: '1', 164 | fourth: '4' 165 | } 166 | }, 167 | { compareFunction } 168 | ), 169 | 'http://domain.lol?first=1&second=2&third=3&fourth=4' 170 | ); 171 | }); 172 | 173 | test('parse object with path to relative url', (t) => { 174 | t.deepEqual( 175 | stringify({ 176 | path: ['foo', 'bar'] 177 | }), 178 | '/foo/bar' 179 | ); 180 | }); 181 | 182 | test('parse object with query to relative url', (t) => { 183 | t.deepEqual( 184 | stringify({ 185 | query: { foo: '1', bar: '2' } 186 | }), 187 | '?foo=1&bar=2' 188 | ); 189 | }); 190 | 191 | test('parse object with path and query to relative url', (t) => { 192 | t.deepEqual( 193 | stringify({ 194 | path: ['foo', 'bar'], 195 | query: { foo: '1', bar: '2' } 196 | }), 197 | '/foo/bar?foo=1&bar=2' 198 | ); 199 | }); 200 | 201 | // https://tools.ietf.org/html/rfc3986#section-3.4 202 | test('should allow unescaped reserved chars in query string values', (t) => { 203 | t.deepEqual( 204 | stringify({ 205 | protocol: 'https', 206 | host: 'domain.lol', 207 | query: { 208 | route: 'http://foo.ninja/bar?baz=1' 209 | }, 210 | hash: 'foobar' 211 | }), 212 | 'https://domain.lol?route=http%3A%2F%2Ffoo.ninja%2Fbar%3Fbaz%3D1#foobar' 213 | ); 214 | }); 215 | 216 | // https://tools.ietf.org/html/rfc3986#section-3.4 217 | test.failing( 218 | 'should allow escaped reserved chars in query string values', 219 | (t) => { 220 | t.deepEqual( 221 | stringify({ 222 | protocol: 'https', 223 | host: 'domain.lol', 224 | query: { 225 | route: 'http%3A%2F%2Ffoo.ninja%2Fbar%3Fbaz%3D1' 226 | }, 227 | hash: 'foobar' 228 | }), 229 | 'https://domain.lol?route=http%3A%2F%2Ffoo.ninja%2Fbar%3Fbaz%3D1#foobar' 230 | ); 231 | } 232 | ); 233 | 234 | test.failing( 235 | 'allowed reserved chars should be stringified as unescaped', 236 | (t) => { 237 | t.deepEqual( 238 | stringify({ 239 | protocol: 'https', 240 | host: 'domain.lol', 241 | query: { 242 | route: 'http://foo.ninja/bar?baz=1' 243 | }, 244 | hash: 'foobar' 245 | }), 246 | 'https://domain.lol?route=http://foo.ninja/bar?baz=1#foobar' 247 | ); 248 | } 249 | ); 250 | 251 | test('should stringify %', (t) => { 252 | t.deepEqual( 253 | stringify({ 254 | host: 'domain.lol', 255 | query: { 256 | foo: '%' 257 | } 258 | }), 259 | 'http://domain.lol?foo=%25' 260 | ); 261 | }); 262 | 263 | test('should stringify malformed %', (t) => { 264 | t.deepEqual( 265 | stringify({ 266 | host: 'domain.lol', 267 | query: { 268 | foo: 'R%E9age' 269 | } 270 | }), 271 | 'http://domain.lol?foo=R%25E9age' 272 | ); 273 | }); 274 | 275 | test.failing('should stringify encoded query value', (t) => { 276 | t.deepEqual( 277 | stringify({ 278 | host: 'domain.lol', 279 | query: { 280 | foo: '%3D' 281 | } 282 | }), 283 | 'http://domain.lol?foo=%3D' 284 | ); 285 | }); 286 | 287 | test('should stringify non string values', (t) => { 288 | t.deepEqual( 289 | stringify({ 290 | host: 'domain.lol', 291 | query: { 292 | a: true, 293 | b: false, 294 | c: null, 295 | d: undefined, 296 | e: 0 297 | } 298 | }), 299 | 'http://domain.lol?a=true&b=false&c&e=0' 300 | ); 301 | }); 302 | --------------------------------------------------------------------------------