From 3dc03998b5f5ac0ccda4029038ff00e46c14c99f Mon Sep 17 00:00:00 2001 From: Ryota Kameoka Date: Tue, 22 Feb 2022 02:17:40 +0900 Subject: [PATCH] Define `host`, `hostname`, and `origin` variable decoders --- src/VariableDecoder/host.test.ts | 84 ++++++++++++++++++++++++++++ src/VariableDecoder/host.ts | 26 +++++++++ src/VariableDecoder/hostname.test.ts | 55 ++++++++++++++++++ src/VariableDecoder/hostname.ts | 24 ++++++++ src/VariableDecoder/index.ts | 3 + src/VariableDecoder/origin.test.ts | 44 +++++++++++++++ src/VariableDecoder/origin.ts | 25 +++++++++ 7 files changed, 261 insertions(+) create mode 100644 src/VariableDecoder/host.test.ts create mode 100644 src/VariableDecoder/host.ts create mode 100644 src/VariableDecoder/hostname.test.ts create mode 100644 src/VariableDecoder/hostname.ts create mode 100644 src/VariableDecoder/origin.test.ts create mode 100644 src/VariableDecoder/origin.ts diff --git a/src/VariableDecoder/host.test.ts b/src/VariableDecoder/host.test.ts new file mode 100644 index 0000000..22e3496 --- /dev/null +++ b/src/VariableDecoder/host.test.ts @@ -0,0 +1,84 @@ +import * as fc from 'fast-check'; +import * as E from 'fp-ts/Either'; + +import { DecodeFailed } from '..'; +import { Variable } from '../Variable'; + +import { host } from './host'; + +describe(host, () => { + it('accepts an arbitrary host string', () => { + const decoder = host(); + + fc.assert( + fc.property( + fc + .tuple( + fc.webUrl(), + fc.nat().filter((n) => n < 65536), + ) + .map(([url, port]) => `${new URL(url).host}:${port}`), + (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual(E.right(str)); + }, + ), + ); + }); + + it('requires a port number', () => { + const decoder = host(); + + fc.assert( + fc.property( + fc.webUrl().map((url) => new URL(url).hostname), + (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual(E.left(new DecodeFailed(variable, 'must be a valid host'))); + }, + ), + ); + }); + + it('rejects an arbitrary URL string', () => { + const decoder = host(); + + fc.assert( + fc.property( + fc.webUrl().map((str) => { + const url = new URL(str); + url.port = '80'; + return url.href; + }), + (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual(E.left(new DecodeFailed(variable, 'must be a valid host'))); + }, + ), + ); + }); + + it('rejects the trailing pathname', () => { + const decoder = host(); + + fc.assert( + fc.property( + fc + .tuple( + fc.webUrl().filter((url) => new URL(url).pathname !== '/'), + fc.nat().filter((n) => n < 65536), + ) + + .map(([orig, port]) => { + const url = new URL(orig); + url.port = String(port); + return url.host + url.pathname; + }), + (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual(E.left(new DecodeFailed(variable, 'must be a valid host'))); + }, + ), + ); + }); +}); diff --git a/src/VariableDecoder/host.ts b/src/VariableDecoder/host.ts new file mode 100644 index 0000000..ad677cd --- /dev/null +++ b/src/VariableDecoder/host.ts @@ -0,0 +1,26 @@ +import { pipe } from 'fp-ts/function'; +import * as O from 'fp-ts/Option'; +import * as RE from 'fp-ts/ReaderEither'; + +import { ask, decodeFailed, validate, VariableDecoder } from './VariableDecoder'; + +/** + * Decodes a host. + * A host consists of a hostname and a port number. + */ +const host = (): VariableDecoder => + pipe( + RE.Do, + RE.bind('value', () => ask()), + RE.bind('url', ({ value }) => + pipe( + O.tryCatch(() => RE.of(new URL(`https://${value}/`))), + O.getOrElse(() => decodeFailed('must be a valid host')), + ), + ), + RE.chain(validate(({ url }) => url.port !== '', 'must be a valid host')), + RE.chain(validate(({ value, url }) => url.host === value, 'must be a valid host')), + RE.map(({ value }) => value), + ); + +export { host }; diff --git a/src/VariableDecoder/hostname.test.ts b/src/VariableDecoder/hostname.test.ts new file mode 100644 index 0000000..1febed7 --- /dev/null +++ b/src/VariableDecoder/hostname.test.ts @@ -0,0 +1,55 @@ +import * as fc from 'fast-check'; +import * as E from 'fp-ts/Either'; + +import { DecodeFailed } from '..'; +import { Variable } from '../Variable'; + +import { hostname } from './hostname'; + +describe(hostname, () => { + it('accepts an arbitrary URL string', () => { + const decoder = hostname(); + + fc.assert( + fc.property( + fc.webUrl().map((url) => new URL(url).hostname), + (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual(E.right(str)); + }, + ), + ); + }); + + it('rejects an arbitrary host string with a port number', () => { + const decoder = hostname(); + + fc.assert( + fc.property( + fc + .tuple( + fc.webUrl(), + fc.nat().filter((n) => n < 65536), + ) + .map(([url, port]) => `${new URL(url).hostname}:${port}`), + (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual( + E.left(new DecodeFailed(variable, 'must be a valid hostname')), + ); + }, + ), + ); + }); + + it('rejects an arbitrary URL string', () => { + const decoder = hostname(); + + fc.assert( + fc.property(fc.webUrl(), (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual(E.left(new DecodeFailed(variable, 'must be a valid hostname'))); + }), + ); + }); +}); diff --git a/src/VariableDecoder/hostname.ts b/src/VariableDecoder/hostname.ts new file mode 100644 index 0000000..3957658 --- /dev/null +++ b/src/VariableDecoder/hostname.ts @@ -0,0 +1,24 @@ +import { pipe } from 'fp-ts/function'; +import * as O from 'fp-ts/Option'; +import * as RE from 'fp-ts/ReaderEither'; + +import { ask, decodeFailed, validate, VariableDecoder } from './VariableDecoder'; + +/** + * Decodes a hostname. + */ +const hostname = (): VariableDecoder => + pipe( + RE.Do, + RE.bind('value', () => ask()), + RE.bind('url', ({ value }) => + pipe( + O.tryCatch(() => RE.of(new URL(`https://${value}:80/`))), + O.getOrElse(() => decodeFailed('must be a valid hostname')), + ), + ), + RE.chain(validate(({ value, url }) => url.hostname === value, 'must be a valid hostname')), + RE.map(({ value }) => value), + ); + +export { hostname }; diff --git a/src/VariableDecoder/index.ts b/src/VariableDecoder/index.ts index 5ffb234..b310691 100644 --- a/src/VariableDecoder/index.ts +++ b/src/VariableDecoder/index.ts @@ -2,11 +2,14 @@ export { base64 } from './base64'; export { bigInt } from './bigInt'; export { boolean } from './boolean'; export { hex } from './hex'; +export { host } from './host'; +export { hostname } from './hostname'; export { integer } from './integer'; export { keyOf } from './keyOf'; export { literal } from './literal'; export { natural } from './natural'; export { number } from './number'; +export { origin } from './origin'; export { port } from './port'; export { string } from './string'; export { url } from './url'; diff --git a/src/VariableDecoder/origin.test.ts b/src/VariableDecoder/origin.test.ts new file mode 100644 index 0000000..6dcf339 --- /dev/null +++ b/src/VariableDecoder/origin.test.ts @@ -0,0 +1,44 @@ +import * as fc from 'fast-check'; +import * as E from 'fp-ts/Either'; + +import { DecodeFailed } from '..'; +import { Variable } from '../Variable'; + +import { origin } from './origin'; + +describe(origin, () => { + it('accepts an arbitrary origin string', () => { + const decoder = origin(); + + fc.assert( + fc.property( + fc + .tuple( + fc.webUrl(), + fc.nat().filter((n) => n < 65536), + ) + .map(([url, port]) => `${new URL(url).origin}:${port}`), + (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual(E.right(str)); + }, + ), + ); + }); + + it('rejects the complete URL', () => { + const decoder = origin(); + + fc.assert( + fc.property( + fc.webUrl().filter((url) => new URL(url).pathname !== '/'), + (str) => { + const variable = new Variable('KEY', str); + expect(decoder(variable)).toStrictEqual( + E.left(new DecodeFailed(variable, 'must be a valid URL origin')), + ); + }, + ), + ); + }); +}); diff --git a/src/VariableDecoder/origin.ts b/src/VariableDecoder/origin.ts new file mode 100644 index 0000000..b78d896 --- /dev/null +++ b/src/VariableDecoder/origin.ts @@ -0,0 +1,25 @@ +import { pipe } from 'fp-ts/function'; +import * as O from 'fp-ts/Option'; +import * as RE from 'fp-ts/ReaderEither'; + +import { ask, decodeFailed, validate, VariableDecoder } from './VariableDecoder'; + +/** + * Decodes a URL origin (scheme + hostname + port). + */ +const origin = (): VariableDecoder => + pipe( + RE.Do, + RE.bind('value', () => ask()), + RE.bind('url', ({ value }) => + pipe( + O.tryCatch(() => RE.of(new URL(value))), + O.getOrElse(() => decodeFailed('must be a valid URL origin')), + ), + ), + RE.chain(validate(({ url }) => url.pathname !== '', 'must not have a pathname')), + RE.chain(validate(({ value, url }) => url.origin === value, 'must be a valid URL origin')), + RE.map(({ value }) => value), + ); + +export { origin };