superouter
Version:
superouter ----------
839 lines (719 loc) • 24.8 kB
JavaScript
const Valid = {
name: 'Valid'
,Y: value => ({ case: 'Y', type: 'Valid', value})
,N: value => ({ case: 'N', type: 'Valid', value})
,bifold: (N,Y) => o => ({
Y
,N
})[o.case](o.value)
,fold: ({ Y,N }) => o => ({ Y,N })[o.case](o.value)
,map: f => o => ({
Y: x => Valid.Y(f(x))
,N: () => o
})[o.case](o.value)
}
const PatternToken = {
name: 'PatternToken'
,Path: value =>
({ case: 'Path', value, type: 'PatternToken' })
,Part: value =>
({ case: 'Part', value, type: 'PatternToken' })
,Variadic: value =>
({ case: 'Variadic', value, type: 'PatternToken' })
,specificity: token => PatternToken.fold({
Path: () => 0b10
,Part: () => 0b01
,Variadic: () => 0b00
}) (token)
,groupSpecificity: tokens =>
tokens.map(PatternToken.specificity)
.reduce( (p,n) => p+n, 0)
,fold: ({
Path,
Part,
Variadic
}) => o => ({
Path
,Part
,Variadic
})[o.case](o.value)
,infer: segment =>
segment.startsWith(':')
? PatternToken.Part(segment.slice(1))
: segment.startsWith('...')
? PatternToken.Variadic(segment.split('...')[1])
: PatternToken.Path(segment)
,groupValidations: {
duplicateDef: allTokensPairs => {
// Pair (CaseName, PatternStr)
const patterns =
allTokensPairs.map(
([k,v]) => [k, PatternToken.toPattern(v)]
)
// StrMap (PatternStr, CaseName[])
const patternStrDupeSearch =
patterns.reduce(
(p,[caseName,pattern]) => {
p[pattern] = p[pattern] || []
p[pattern].push(caseName)
return p
}
, {}
)
// StrMap (CaseName, DupeMetaData)
// where
// DupeMetaData =
// { caseNames::CaseName[], patternStr::PatternStr }
const caseDupes =
Object.entries(patternStrDupeSearch)
.reduce(
(p, [patternStr, caseNames]) => {
if( caseNames.length > 1 ){
caseNames.forEach(
(caseName) => {
p[caseName] = {
caseNames, patternStr
}
}
)
}
return p
}
,{}
)
if ( Object.keys(caseDupes).length ){
return Valid.N(
// StrMap (CaseName, PatternToken.Error )
Object.entries(caseDupes)
.map(
([caseName, { caseNames, patternStr }]) =>
[ caseName
, PatternToken.Error.DuplicateDef({
caseNames, patternStr
})
]
)
)
} else {
return Valid.Y(
allTokensPairs
)
}
}
}
,singleValidations: {
variadicPosition: tokens => {
const index =
tokens.findIndex(PatternToken.isVariadic)
if (index > -1 && index != tokens.length - 1) {
return Valid.N(
PatternToken.Error.VariadicPosition({
tokens, index
})
)
} else {
return Valid.Y(tokens)
}
}
,variadicCount: tokens => {
const variadics =
tokens.filter(PatternToken.isVariadic)
if (variadics.length > 1) {
return Valid.N(
PatternToken.Error.VariadicCount({
variadics, tokens
})
)
} else {
return Valid.Y(tokens)
}
}
,duplicatePart: tokens => {
const dupeParts =
[
tokens.flatMap(
PatternToken.fold({
Path: () => []
,Part: x => [x]
,Variadic: x => [x]
})
)
.reduce(
(p,n) => {
p[n] = p[n] || 0
p[n] = p[n] + 1
return p
}
,{}
)
]
.flatMap(
o => Object.entries(o)
.flatMap( ([k,v]) => v > 1 ? [k] : [])
)
if (dupeParts.length) {
return Valid.N(
PatternToken.Error.DuplicatePart({
dupeParts
})
)
} else {
return Valid.Y(tokens)
}
}
}
,Error: {
name: 'PatternToken.Error'
,VariadicPosition({ tokens, index }){
return {
type: 'PatternToken.Error'
,case: 'VariadicPosition'
,value: new TypeError(
'Variadic ' + JSON.stringify(
PatternToken.toString(tokens[index])
)
+ ' found at position ' + index + ' of ' + tokens.length
+ ' of pattern ' + JSON.stringify(
PatternToken.toPattern(tokens)
) + '. '
+ 'Variadics can only be in the final position.'
)
}
}
,VariadicCount({ variadics, tokens }){
return {
type: 'PatternToken.Error'
,case: 'VariadicCount'
,value: new TypeError(
'Found ' + variadics.length + ' variadics in pattern '
+ PatternToken.toPattern(tokens) + '. '
+ 'A maxiumum of 1 variadic is allowed.'
)
}
}
,DuplicateDef({ caseNames, patternStr }){
return {
type: 'PatternToken.Error'
,case: 'DuplicateDef'
,value: new TypeError(
'Found duplicate pattern definitions for '
+ 'routes: '+caseNames.join(', ')+'. '
+ 'They all have equivalent pattern strings: '
+ patternStr + '. '
+ 'Duplicated patterns lead to ambiguous matches.'
)
}
}
,DuplicatePart({ dupeParts }){
return {
type: 'PatternToken.Error'
,case: 'DuplicatePart'
,value: new TypeError(
'Found duplicate variable bindings: '
+ dupeParts.join(', ')
+ '. Duplicated names lead to ambiguous bindings.'
)
}
}
}
,isVariadic: o => o.case === 'Variadic'
,validate(tokens){
const out =
Object.values(PatternToken.singleValidations).map(
f => f(tokens)
)
const invalids = out.filter( x => x.case === 'N')
.map( x => x.value )
return invalids.length > 0
? Valid.N(invalids)
: Valid.Y(tokens)
}
,validateGroup(allTokensGroup){
const validTokensGroup =
allTokensGroup.filter( ([,v]) => v.case === 'Y' )
const out =
Object.values(PatternToken.groupValidations)
.map(
f => f( validTokensGroup.map( ([k,v]) => [k, v.value] ) )
)
const invalids = out.filter( x => x.case === 'N')
.map( x => x.value )
return invalids.length > 0
? Valid.N(invalids)
: Valid.Y(allTokensGroup)
}
,toString: x => ({
Path: x => x
,Part: x => ':'+x
,Variadic: x => '...'+x
})[x.case](x.value)
,toPattern: xs => xs.map( PatternToken.toString ).join('/')
}
const URLToken = {
Path: value =>
({ case: 'Path', value, type: 'URLToken' })
,Part: ({ key, value }) =>
({ case: 'Part', value: { key, value }, type: 'URLToken' })
,Variadic: ({ key, value }) =>
({ case: 'Variadic', value: { key, value }, type: 'URLToken' })
,Unmatched: ({expected, actual}) =>
({ case: 'Unmatched', value: { expected, actual }, type: 'URLToken' })
,ExcessSegment: segment =>
({ case: 'ExcessSegment', value: segment, type: 'URLToken' })
,fold: ({
Path,Part,Variadic,Unmatched,ExcessSegment
}) => x => ({
Path,Part,Variadic,Unmatched,ExcessSegment
})[x.case](x.value)
,toString: x => URLToken.fold({
Path: x => x
,Part: ({ value: x }) => x
,Variadic: ({ value: x }) => x
,Unmatched: ({ actual: x }) => x
,ExcessSegment: x => x
}) (x)
,toURL: xs => xs.map( URLToken.toString ).join('/')
,toArgs: xs => xs.reduce(
(p,n) => URLToken.fold({
ExcessSegment: () => p
,Part: ({ key, value }) =>
Object.assign(p, { [key]: value })
,Path: () => p
,Unmatched: () => p
,Variadic: ({ key, value }) =>
Object.assign(p, { [key]: value })
}) (n),
{}
)
,fromPattern: o => segment => PatternToken.fold({
Path: expected =>
segment === expected
? URLToken.Path(segment)
: URLToken.Unmatched({ expected, actual: segment })
,Part: key =>
URLToken.Part({ key, value: segment })
,Variadic: key =>
URLToken.Variadic({ key, value: segment })
}) (o)
,isVariadic: o => o.case === 'Variadic'
,validations: {
excessPatterns: patternTokens => urlTokens => {
const numSegments =
urlTokens.length
const numPatterns =
patternTokens.length
const excessPatterns =
numPatterns > numSegments
? patternTokens.slice(numSegments)
: []
if( excessPatterns.length ){
return Valid.N(
URLToken.Error.ExcessPattern({
urlTokens, patternTokens
,excessPatterns
})
)
} else {
return Valid.Y(urlTokens)
}
}
,excessSegments: patternTokens => urlTokens => {
const extraSegments =
urlTokens.filter( x => x.case === 'ExcessSegment' )
if ( extraSegments.length
&& patternTokens.slice(-1)[0].case !== 'Variadic'
){
return Valid.N(
URLToken.Error.ExcessSegment({
patternTokens, extraSegments
})
)
} else {
return Valid.Y(urlTokens)
}
}
,unmatchedPaths: patternTokens => urlTokens => {
const unmatched =
urlTokens.filter( x => x.case === 'Unmatched' )
if( unmatched.length ){
return Valid.N(
URLToken.Error.UnmatchedPaths({ patternTokens, urlTokens })
)
} else {
return Valid.Y(urlTokens)
}
}
}
,Error: {
UnmatchedPaths({ patternTokens, urlTokens }){
return {
type: 'URLToken.Error'
,case: 'UnmatchedPaths'
,value:
new TypeError(
'Pattern '+PatternToken.toPattern(patternTokens)
+ ' could not match URL '
+ URLToken.toURL(urlTokens)
+ ' due to unmatched path segments: '
+ urlTokens.map(
x => x.case === 'Unmatched'
? URLToken.toString(x)
: '...'
)
.join('/')
)
}
}
,ExcessSegment({ patternTokens, extraSegments }){
return {
type: 'URLToken.Error'
,case: 'ExcessSegment'
,value: new TypeError(
'Excess tokens ('
+ JSON.stringify(
'/'+extraSegments.map( x => x.value).join('/')
)
+ ')'
+ ' were found and the URLPattern:'
+ JSON.stringify(
PatternToken.toPattern(patternTokens)
)
+ ' did not contain a variadic for the additional'
+ ' values.'
)
}
}
,ExcessPattern({ urlTokens, excessPatterns, patternTokens }){
return {
type: 'URLToken.Error'
,case: 'ExcessPattern'
,value: new TypeError(
'The URL '+ URLToken.toURL(urlTokens)
+' had excess patterns ('
+ PatternToken.toPattern(excessPatterns)
+ ')'
+ ' when parsed as part of pattern:'
+ ' ' +PatternToken.toPattern(patternTokens)
)
}
}
}
,transforms: {
collectVariadics: url => patternTokens => urlTokens => {
const extraSegments =
urlTokens.length > patternTokens.length
? urlTokens.slice( patternTokens.length )
: []
if ( extraSegments.length ){
const index =
urlTokens.findIndex(URLToken.isVariadic)
// Technically not possible because excess segments
// without variadics isn't valid and would be caught
// earlier. But typesafety 😐
/* istanbul ignore next */
if (index == -1) {
return urlTokens
} else {
const { key, value } = urlTokens[index].value
return urlTokens.slice(0, patternTokens.length-1).concat(
URLToken.Variadic(
{
key
, value: value
+ '/'
+ url.split('/')
.slice(patternTokens.length)
.join('/')
}
)
)
}
} else {
return urlTokens
}
}
}
,transform: (url, urlTokens, patternTokens) => {
return Object.values(URLToken.transforms).reduce(
(p, f) => f (url) (patternTokens) (p),
urlTokens
)
}
,validate(patternTokens, urlTokens){
const out =
Object.values(URLToken.validations).map(
f => f(patternTokens) (urlTokens)
)
const invalids = out.filter( x => x.case === 'N')
.map( x => x.value )
return invalids.length > 0
? Valid.N(invalids)
: Valid.Y(urlTokens)
}
}
function tokenizePattern(pattern) {
const patternTokens =
pattern.split('/').map(PatternToken.infer)
return [patternTokens]
.map(PatternToken.validate)
.shift()
}
// always include a trailing slash internally
// so final ...rest matches optionally
// see: https://gitlab.com/harth/superouter/issues/4
const trailingIfFinalIsVariadic = (patternTokens, url) => {
const lastTokenIsRest =
patternTokens.slice(-1)
.map(
PatternToken.isVariadic
)
.shift()
const isTrailing =
url[url.length -1] == '/'
return lastTokenIsRest && !isTrailing
? url + '/'
: url
}
function tokenizeURL(patternTokens, theirURL){
const url = trailingIfFinalIsVariadic(patternTokens, theirURL)
const urlTokens =
url.split('/').slice(0, patternTokens.length).map(
(segment, i) =>
URLToken.fromPattern (patternTokens[i]) (segment)
)
const segments =
url.split('/')
const numSegments =
segments.length
const numPatterns =
patternTokens.length
const excessSegments =
numSegments > numPatterns
? segments.slice(numPatterns).map(
URLToken.ExcessSegment
)
: []
const completeTokens =
urlTokens
.concat(excessSegments)
return [URLToken.validate(patternTokens, completeTokens)]
.map(
Valid.map(
() => URLToken.transform(url, completeTokens, patternTokens)
)
)
.shift()
}
function routeValidator({ tokenized }){
const groupInvalids =
[
PatternToken.validateGroup(
Object.entries(tokenized)
)
]
.flatMap(
Valid.bifold(
x => x, () => []
)
)
// Pair (CaseName, PatternToken.Error[])
const invalids =
Object.entries(tokenized).filter(
([, tokens]) => tokens.case === 'N'
)
.map( ([key, x]) => [key, x.value] )
.concat(groupInvalids.reduce( (p,n) => p.concat(n), []))
if( invalids.length ){
return Valid.N(
Object.entries(tokenized)
.map(([k]) => [k, []])
.concat( invalids )
.reduce( (p,[k,v]) => {
p[k] = p[k] || []
p[k] = p[k].concat(v)
return p
}, {})
)
} else {
return Valid.Y(tokenized)
}
}
function SafeRouteType({ typeName, tokenized }){
return Object.entries(tokenized).map(
([caseName, tokens]) => {
const keys =
tokens.value.reduce(
(p, n) =>
PatternToken.fold({
Path: () => p
,Part: key => p.concat(key)
,Variadic: key => p.concat(key)
}) (n),
[]
)
.sort()
function of(o) {
const foundKeys = Object.keys(o || {}).sort()
if( foundKeys.join('|') !== keys.join('|') ){
return Valid.N(
new TypeError(
'Property mismatch for '
+typeName+'.'+caseName
+ '. Expected: {'+keys.join(',')+'}'
+ ' but found: {'+foundKeys.join()+'}'
)
)
} else {
return Valid.Y(
{
type: typeName
,case: caseName
,value: o
}
)
}
}
return { [caseName]: of }
}
)
.reduce( (p,n) => Object.assign(p,n))
}
function RouteType({ typeName, safeRouteType }){
const Route =
Object.entries(safeRouteType).map(
([key, of]) => ({
[key]: o => Valid.fold({
Y: x => x
,N(err){
throw err
}
}) ( of(o) )
})
)
.reduce( (p,n) => Object.assign(p,n), { name: typeName })
return Route
}
const PatternMatches =
({ tokenized, url }) => {
const pairs =
Object.entries(tokenized).map(
([key, patternTokens]) =>
[key, tokenizeURL(patternTokens.value, url)]
)
const invalid =
pairs
.filter( ([,valid]) => valid.case === 'N' )
.map(
([key, { value }]) => ({ [key]: value})
)
.reduce( (p,n) => Object.assign(p,n), {})
const valid =
pairs
.filter( ([,valid]) => valid.case === 'Y' )
.sort(
([,{ value: a}], [,{ value: b}]) =>
PatternToken.groupSpecificity(b)
- PatternToken.groupSpecificity(a)
)
if( valid.length ) {
return Valid.Y(valid)
} else {
return Valid.N(invalid)
}
}
const Matches =
({ routeType }) => patternMatches => {
return Valid.bifold(
Valid.N,
xs =>
// xs being non empty is technically a precondition
// to being marked as valid
// but we check it in any case as the types make it possible
xs.length == 0
/* istanbul ignore next */
? Valid.N({})
: Valid.Y(
xs.map(
([caseName, { value }]) =>
routeType[caseName](
URLToken.toArgs(value)
)
)
)
) (patternMatches)
}
function type$safe(typeName, cases){
// StrMap (CaseName, Valid( N::PatternToken.Error[] | Y::PatternToken[] ) )
const tokenized =
Object.entries(cases).map(
([caseName, pattern]) => {
return { [caseName]: tokenizePattern(pattern) }
}
)
.reduce( (p,n) => Object.assign(p, n), {} )
const validated = routeValidator({ tokenized })
if( validated.case === 'N' ){
return validated
} else {
const safeRouteType =
SafeRouteType({ typeName, tokenized })
const routeType =
RouteType({ typeName, safeRouteType })
const fold = cases => o => cases[o.case](o.value)
const matches = url => {
const patternMatches = PatternMatches({tokenized, url})
return Matches({ routeType }) (patternMatches)
}
const matchOr = (otherwise, url) =>
[url]
.map(matches)
.flatMap(
Valid.bifold(
otherwise,
x => x
)
)
.slice(0,1)
.concat( otherwise({}) )
.shift()
const toURL = routeCase => {
return tokenized[routeCase.case].value.map(
PatternToken.fold({
Part: key => routeCase.value[key]
,Path: key => key
,Variadic: key => routeCase.value[key]
})
)
.join('/')
}
return Valid.Y(
{ safe: safeRouteType
, of: routeType
, fold
, matches
, matchOr
, toURL
}
)
}
}
const type =
(typename, cases) =>
Valid.bifold(
(errs) => {
throw Object.values(errs)
.flatMap( xs => xs )
.map( x => x.value)
.shift()
},
x => x
) (type$safe(typename, cases))
export {
tokenizePattern
, tokenizeURL
, PatternToken
, URLToken
, Valid
, type$safe
, type
}