Repository: sindresorhus/got (ts)

Issue:
Document the behavior of the `baseUrl` option
in common senarios, we used to deploy our api with a url `{host}/api`, so we may use `got` like this:

```js
const client = got.extend({
  baseUrl: 'http://xxx.com/api/v1',
});

client.get('/stuffs')
```

somehow, cuz the `baseUrl` just support a [WHATWG URL](https://nodejs.org/api/url.html#url_class_url) currently, we have to add a `/api/v1` prefix for each url and it really sucks for tons of long urls.
Document the `response.retryCount` property
Looks like the [`response` documentation](https://github.com/sindresorhus/got#goturl-options) became outdated with the 9.0 release because the new [`retryCount` property](https://github.com/sindresorhus/got/blob/8f30f1fdefaa96ad55758a352730d00697862952/source/request-as-event-emitter.js#L51) isn't documented.

Pull request description:
Normalize the URL in the `baseUrl` option
Fixes #562 

Patch:
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index c53c0a5..0000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-environment:
-  matrix:
-    - nodejs_version: '10'
-    - nodejs_version: '8'
-install:
-  - ps: Install-Product node $env:nodejs_version
-  - npm install --global npm@latest
-  - set PATH=%APPDATA%\npm;%PATH%
-  - npm install
-matrix:
-  fast_finish: true
-build: off
-shallow_clone: true
-test_script:
-  - node --version
-  - npm --version
-  - npm test
-cache:
-  - '%APPDATA%\npm-cache'
diff --git a/readme.md b/readme.md
index 4651e6a..6785acc 100644
--- a/readme.md
+++ b/readme.md
@@ -13,7 +13,7 @@
 
 > Simplified HTTP requests
 
-[![Build Status: Linux](https://travis-ci.org/sindresorhus/got.svg?branch=master)](https://travis-ci.org/sindresorhus/got) [![Build status: Windows](https://ci.appveyor.com/api/projects/status/a9fgfqojj8mf5upf/branch/master?svg=true)](https://ci.appveyor.com/project/sindresorhus/got/branch/master) [![Coverage Status](https://coveralls.io/repos/github/sindresorhus/got/badge.svg?branch=master)](https://coveralls.io/github/sindresorhus/got?branch=master) [![Downloads](https://img.shields.io/npm/dm/got.svg)](https://npmjs.com/got) [![Install size](https://packagephobia.now.sh/badge?p=got)](https://packagephobia.now.sh/result?p=got)
+[![Build Status: Linux](https://travis-ci.org/sindresorhus/got.svg?branch=master)](https://travis-ci.org/sindresorhus/got) [![Coverage Status](https://coveralls.io/repos/github/sindresorhus/got/badge.svg?branch=master)](https://coveralls.io/github/sindresorhus/got?branch=master) [![Downloads](https://img.shields.io/npm/dm/got.svg)](https://npmjs.com/got) [![Install size](https://packagephobia.now.sh/badge?p=got)](https://packagephobia.now.sh/result?p=got)
 
 Got is a human-friendly and powerful HTTP request library.
 
@@ -122,6 +122,19 @@ Very useful when used with `got.extend()` to create niche-specific Got instances
 
 Can be a string or a [WHATWG `URL`](https://nodejs.org/api/url.html#url_class_url).
 
+Backslash at the end of `baseUrl` and at the beginning of the `url` argument is optional:
+
+```js
+await got('hello', {baseUrl: 'https://example.com/v1'});
+//=> 'https://example.com/v1/hello'
+
+await got('/hello', {baseUrl: 'https://example.com/v1/'});
+//=> 'https://example.com/v1/hello'
+
+await got('/hello', {baseUrl: 'https://example.com/v1'});
+//=> 'https://example.com/v1/hello'
+```
+
 ###### headers
 
 Type: `Object`<br>
diff --git a/source/create.js b/source/create.js
index 1761c53..cbe32d0 100644
--- a/source/create.js
+++ b/source/create.js
@@ -21,6 +21,7 @@ const aliases = [
 
 const create = defaults => {
 	defaults = merge({}, defaults);
+	defaults.options = normalizeArguments.preNormalize(defaults.options);
 	if (!defaults.handler) {
 		defaults.handler = next;
 	}
diff --git a/source/merge-instances.js b/source/merge-instances.js
index 94ab001..9899c95 100644
--- a/source/merge-instances.js
+++ b/source/merge-instances.js
@@ -12,17 +12,11 @@ module.exports = (instances, methods) => {
 		options = merge({}, options, instance.defaults.options);
 
 		const instanceHooks = instance.defaults.options.hooks;
-		if (instanceHooks) {
-			for (const name of knownHookEvents) {
-				if (!instanceHooks[name]) {
-					continue;
-				}
-
-				if (hooks[name]) {
-					hooks[name] = hooks[name].concat(instanceHooks[name]);
-				} else {
-					hooks[name] = [...instanceHooks[name]];
-				}
+		for (const name of knownHookEvents) {
+			if (hooks[name]) {
+				hooks[name] = hooks[name].concat(instanceHooks[name]);
+			} else {
+				hooks[name] = [...instanceHooks[name]];
 			}
 		}
 	}
diff --git a/source/normalize-arguments.js b/source/normalize-arguments.js
index e26547f..554c428 100644
--- a/source/normalize-arguments.js
+++ b/source/normalize-arguments.js
@@ -13,6 +13,49 @@ const knownHookEvents = require('./known-hook-events');
 
 const retryAfterStatusCodes = new Set([413, 429, 503]);
 
+// `preNormalize` handles things related to static options, like `baseUrl`, `followRedirect`, `hooks`, etc.
+// While `normalize` does `preNormalize` + handles things related to dynamic options, like URL, headers, body, etc.
+const preNormalize = options => {
+	options = {
+		headers: {},
+		...options
+	};
+
+	if (options.baseUrl && !options.baseUrl.toString().endsWith('/')) {
+		options.baseUrl += '/';
+	}
+
+	if (is.undefined(options.followRedirect)) {
+		options.followRedirect = true;
+	}
+
+	if (is.nullOrUndefined(options.hooks)) {
+		options.hooks = {};
+	}
+	if (is.object(options.hooks)) {
+		for (const hookEvent of knownHookEvents) {
+			const hooks = options.hooks[hookEvent];
+			if (is.nullOrUndefined(hooks)) {
+				options.hooks[hookEvent] = [];
+			} else if (is.array(hooks)) {
+				for (const [index, hook] of hooks.entries()) {
+					if (!is.function(hook)) {
+						throw new TypeError(
+							`Parameter \`hooks.${hookEvent}[${index}]\` must be a function, not ${is(hook)}`
+						);
+					}
+				}
+			} else {
+				throw new TypeError(`Parameter \`hooks.${hookEvent}\` must be an array, not ${is(hooks)}`);
+			}
+		}
+	} else {
+		throw new TypeError(`Parameter \`hooks\` must be an object, not ${is(options.hooks)}`);
+	}
+
+	return options;
+};
+
 module.exports = (url, options, defaults) => {
 	if (Reflect.has(options, 'url') || (is.object(url) && Reflect.has(url, 'url'))) {
 		throw new TypeError('Parameter `url` is not an option. Use got(url, options)');
@@ -22,8 +65,14 @@ module.exports = (url, options, defaults) => {
 		throw new TypeError(`Parameter \`url\` must be a string or object, not ${is(url)}`);
 	}
 
+	options = preNormalize(options);
+
 	if (is.string(url)) {
 		if (options.baseUrl) {
+			if (url.toString().startsWith('/')) {
+				url = url.toString().slice(1);
+			}
+
 			url = urlToOptions(new URLGlobal(url, options.baseUrl));
 		} else {
 			url = url.replace(/^unix:/, 'http://$&');
@@ -45,7 +94,6 @@ module.exports = (url, options, defaults) => {
 
 	options = {
 		path: '',
-		headers: {},
 		...url,
 		protocol: url.protocol || 'http:', // Override both null/undefined with default protocol
 		...options
@@ -59,22 +107,17 @@ module.exports = (url, options, defaults) => {
 		get: () => baseUrl
 	});
 
-	if (options.stream && options.json) {
-		options.json = false;
-	}
-
-	if (options.decompress && is.undefined(options.headers['accept-encoding'])) {
-		options.headers['accept-encoding'] = 'gzip, deflate';
-	}
-
 	const {query} = options;
-
 	if (!is.empty(query) || query instanceof URLSearchParamsGlobal) {
 		const queryParams = new URLSearchParamsGlobal(query);
 		options.path = `${options.path.split('?')[0]}?${queryParams.toString()}`;
 		delete options.query;
 	}
 
+	if (options.stream && options.json) {
+		options.json = false;
+	}
+
 	if (options.json && is.undefined(options.headers.accept)) {
 		options.headers.accept = 'application/json';
 	}
@@ -125,6 +168,10 @@ module.exports = (url, options, defaults) => {
 
 	options.method = options.method.toUpperCase();
 
+	if (options.decompress && is.undefined(options.headers['accept-encoding'])) {
+		options.headers['accept-encoding'] = 'gzip, deflate';
+	}
+
 	if (options.hostname === 'unix') {
 		const matches = /(.+?):(.+)/.exec(options.path);
 
@@ -164,6 +211,15 @@ module.exports = (url, options, defaults) => {
 		}
 	}
 
+	if (is.number(options.timeout) || is.object(options.timeout)) {
+		if (is.number(options.timeout)) {
+			options.gotTimeout = {request: options.timeout};
+		} else {
+			options.gotTimeout = options.timeout;
+		}
+		delete options.timeout;
+	}
+
 	if (!is.function(options.gotRetry.retries)) {
 		const {retries} = options.gotRetry;
 
@@ -197,42 +253,7 @@ module.exports = (url, options, defaults) => {
 		};
 	}
 
-	if (is.undefined(options.followRedirect)) {
-		options.followRedirect = true;
-	}
-
-	if (is.number(options.timeout) || is.object(options.timeout)) {
-		if (is.number(options.timeout)) {
-			options.gotTimeout = {request: options.timeout};
-		} else {
-			options.gotTimeout = options.timeout;
-		}
-		delete options.timeout;
-	}
-
-	if (is.nullOrUndefined(options.hooks)) {
-		options.hooks = {};
-	}
-	if (is.object(options.hooks)) {
-		for (const hookEvent of knownHookEvents) {
-			const hooks = options.hooks[hookEvent];
-			if (is.nullOrUndefined(hooks)) {
-				options.hooks[hookEvent] = [];
-			} else if (is.array(hooks)) {
-				for (const [index, hook] of hooks.entries()) {
-					if (!is.function(hook)) {
-						throw new TypeError(
-							`Parameter \`hooks.${hookEvent}[${index}]\` must be a function, not ${is(hook)}`
-						);
-					}
-				}
-			} else {
-				throw new TypeError(`Parameter \`hooks.${hookEvent}\` must be an array, not ${is(hooks)}`);
-			}
-		}
-	} else {
-		throw new TypeError(`Parameter \`hooks\` must be an object, not ${is(options.hooks)}`);
-	}
-
 	return options;
 };
+
+module.exports.preNormalize = preNormalize;


Test patch:
diff --git a/test/arguments.js b/test/arguments.js
index 1a3faaf..ff8b2c3 100644
--- a/test/arguments.js
+++ b/test/arguments.js
@@ -22,6 +22,10 @@ test.before('setup', async () => {
 		response.end(request.url);
 	});
 
+	s.on('/test/foobar', (request, response) => {
+		response.end(request.url);
+	});
+
 	s.on('/?test=it’s+ok', (request, response) => {
 		response.end(request.url);
 	});
@@ -194,6 +198,30 @@ test('allows extra keys in `hooks`', async t => {
 	await t.notThrowsAsync(() => got(`${s.url}/test`, {hooks: {extra: {}}}));
 });
 
+test('baseUrl works', async t => {
+	const instanceA = got.extend({baseUrl: `${s.url}/test`});
+	const {body} = await instanceA('/foobar');
+	t.is(body, `/test/foobar`);
+});
+
+test('accepts WHATWG URL as the baseUrl option', async t => {
+	const instanceA = got.extend({baseUrl: new URL(`${s.url}/test`)});
+	const {body} = await instanceA('/foobar');
+	t.is(body, `/test/foobar`);
+});
+
+test('backslash in the end of `baseUrl` is optional', async t => {
+	const instanceA = got.extend({baseUrl: `${s.url}/test/`});
+	const {body} = await instanceA('/foobar');
+	t.is(body, `/test/foobar`);
+});
+
+test('backslash in the beginning of `url` is optional when using baseUrl', async t => {
+	const instanceA = got.extend({baseUrl: `${s.url}/test`});
+	const {body} = await instanceA('foobar');
+	t.is(body, `/test/foobar`);
+});
+
 test('throws when trying to modify baseUrl after options got normalized', async t => {
 	const instanceA = got.create({
 		methods: [],
diff --git a/test/create.js b/test/create.js
index 4e688b8..a11aa33 100644
--- a/test/create.js
+++ b/test/create.js
@@ -97,7 +97,7 @@ test('extend keeps the old value if the new one is undefined', t => {
 test('extend merges URL instances', t => {
 	const a = got.extend({baseUrl: new URL('https://example.com')});
 	const b = a.extend({baseUrl: '/foo'});
-	t.is(b.defaults.options.baseUrl.toString(), 'https://example.com/foo');
+	t.is(b.defaults.options.baseUrl.toString(), 'https://example.com/foo/');
 });
 
 test('create', async t => {
@@ -128,7 +128,7 @@ test('no tampering with defaults', t => {
 	const instance = got.create({
 		handler: got.defaults.handler,
 		options: got.mergeOptions(got.defaults.options, {
-			baseUrl: 'example'
+			baseUrl: 'example/'
 		})
 	});
 
@@ -142,8 +142,8 @@ test('no tampering with defaults', t => {
 		instance.defaults.options.baseUrl = 'http://google.com';
 	});
 
-	t.is(instance.defaults.options.baseUrl, 'example');
-	t.is(instance2.defaults.options.baseUrl, 'example');
+	t.is(instance.defaults.options.baseUrl, 'example/');
+	t.is(instance2.defaults.options.baseUrl, 'example/');
 });
 
 test('only plain objects are freezed', async t => {