Skip to content

Commit

Permalink
refactor: simplify matcher interfaces
Browse files Browse the repository at this point in the history
  • Loading branch information
posva committed Dec 9, 2024
1 parent d462758 commit 1ca2a13
Show file tree
Hide file tree
Showing 6 changed files with 438 additions and 165 deletions.
6 changes: 6 additions & 0 deletions packages/router/src/new-route-resolver/matcher-location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ import type { MatcherName } from './matcher'
*/
export type MatcherParamsFormatted = Record<string, unknown>

/**
* Empty object in TS.
*/
export type EmptyParams = Record<PropertyKey, never>

export interface MatcherLocationAsNamed {
name: MatcherName
// FIXME: should this be optional?
params: MatcherParamsFormatted
query?: LocationQueryRaw
hash?: string
Expand Down
250 changes: 132 additions & 118 deletions packages/router/src/new-route-resolver/matcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,136 @@
import { describe, expect, it } from 'vitest'
import { MatcherPatternImpl, MatcherPatternPath } from './matcher-pattern'
import { createCompiledMatcher } from './matcher'
import { MatcherPatternImpl } from './matcher-pattern'
import { createCompiledMatcher, NO_MATCH_LOCATION } from './matcher'
import {
MatcherPatternParams_Base,
MatcherPattern,
MatcherPatternPath,
MatcherPatternQuery,
} from './new-matcher-pattern'
import { miss } from './matchers/errors'
import { EmptyParams } from './matcher-location'

function createMatcherPattern(
...args: ConstructorParameters<typeof MatcherPatternImpl>
) {
return new MatcherPatternImpl(...args)
}

const EMPTY_PATH_PATTERN_MATCHER = {
match: (path: string) => ({}),
parse: (params: {}) => ({}),
serialize: (params: {}) => ({}),
buildPath: () => '/',
} satisfies MatcherPatternPath
const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ pathMatch: string }> = {
match(path) {
return { pathMatch: path }
},
build({ pathMatch }) {
return pathMatch
},
}

const EMPTY_PATH_PATTERN_MATCHER: MatcherPatternPath<EmptyParams> = {
match: path => {
if (path !== '/') {
throw miss()
}
return {}
},
build: () => '/',
}

const USER_ID_PATH_PATTERN_MATCHER: MatcherPatternPath<{ id: number }> = {
match(value) {
const match = value.match(/^\/users\/(\d+)$/)
if (!match?.[1]) {
throw miss()
}
const id = Number(match[1])
if (Number.isNaN(id)) {
throw miss()
}
return { id }
},
build({ id }) {
return `/users/${id}`
},
}

const PAGE_QUERY_PATTERN_MATCHER: MatcherPatternQuery<{ page: number }> = {
match: query => {
const page = Number(query.page)
return {
page: Number.isNaN(page) ? 1 : page,
}
},
build: params => ({ page: String(params.page) }),
} satisfies MatcherPatternQuery<{ page: number }>

const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base<
string,
{ hash: string | null }
> = {
match: hash => ({ hash: hash ? hash.slice(1) : null }),
build: ({ hash }) => (hash ? `#${hash}` : ''),
}

const EMPTY_PATH_ROUTE = {
name: 'no params',
path: EMPTY_PATH_PATTERN_MATCHER,
} satisfies MatcherPattern

const USER_ID_ROUTE = {
name: 'user-id',
path: USER_ID_PATH_PATTERN_MATCHER,
} satisfies MatcherPattern

describe('Matcher', () => {
describe('adding and removing', () => {
it('add static path', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(EMPTY_PATH_ROUTE)
})

it('adds dynamic path', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(USER_ID_ROUTE)
})
})

describe('resolve()', () => {
describe('absolute locationss as strings', () => {
it('resolves string locations with no params', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(
createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER)
)
matcher.addRoute(EMPTY_PATH_ROUTE)

expect(matcher.resolve('/foo?a=a&b=b#h')).toMatchObject({
path: '/foo',
expect(matcher.resolve('/?a=a&b=b#h')).toMatchObject({
path: '/',
params: {},
query: { a: 'a', b: 'b' },
hash: '#h',
})
})

it('resolves a not found string', () => {
const matcher = createCompiledMatcher()
expect(matcher.resolve('/bar?q=1#hash')).toEqual({
...NO_MATCH_LOCATION,
fullPath: '/bar?q=1#hash',
path: '/bar',
query: { q: '1' },
hash: '#hash',
matched: [],
})
})

it('resolves string locations with params', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(
// /users/:id
createMatcherPattern(Symbol('foo'), {
match: (path: string) => {
const match = path.match(/^\/foo\/([^/]+?)$/)
if (!match) throw new Error('no match')
return { id: match[1] }
},
parse: (params: { id: string }) => ({ id: Number(params.id) }),
serialize: (params: { id: number }) => ({ id: String(params.id) }),
buildPath: params => `/foo/${params.id}`,
})
)

expect(matcher.resolve('/foo/1?a=a&b=b#h')).toMatchObject({
path: '/foo/1',
matcher.addRoute(USER_ID_ROUTE)

expect(matcher.resolve('/users/1?a=a&b=b#h')).toMatchObject({
path: '/users/1',
params: { id: 1 },
query: { a: 'a', b: 'b' },
hash: '#h',
})
expect(matcher.resolve('/foo/54?a=a&b=b#h')).toMatchObject({
path: '/foo/54',
expect(matcher.resolve('/users/54?a=a&b=b#h')).toMatchObject({
path: '/users/54',
params: { id: 54 },
query: { a: 'a', b: 'b' },
hash: '#h',
Expand All @@ -64,21 +139,16 @@ describe('Matcher', () => {

it('resolve string locations with query', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(
createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER, {
match: query => ({
id: Array.isArray(query.id) ? query.id[0] : query.id,
}),
parse: (params: { id: string }) => ({ id: Number(params.id) }),
serialize: (params: { id: number }) => ({ id: String(params.id) }),
})
)

expect(matcher.resolve('/foo?id=100&b=b#h')).toMatchObject({
params: { id: 100 },
matcher.addRoute({
path: ANY_PATH_PATTERN_MATCHER,
query: PAGE_QUERY_PATTERN_MATCHER,
})

expect(matcher.resolve('/foo?page=100&b=b#h')).toMatchObject({
params: { page: 100 },
path: '/foo',
query: {
id: '100',
page: '100',
b: 'b',
},
hash: '#h',
Expand All @@ -87,94 +157,37 @@ describe('Matcher', () => {

it('resolves string locations with hash', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(
createMatcherPattern(
Symbol('foo'),
EMPTY_PATH_PATTERN_MATCHER,
undefined,
{
match: hash => hash,
parse: hash => ({ a: hash.slice(1) }),
serialize: ({ a }) => '#a',
}
)
)
matcher.addRoute({
path: ANY_PATH_PATTERN_MATCHER,
hash: ANY_HASH_PATTERN_MATCHER,
})

expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({
hash: '#bar',
params: { a: 'bar' },
params: { hash: 'bar' },
path: '/foo',
query: { a: 'a', b: 'b' },
})
})

it('returns a valid location with an empty `matched` array if no match', () => {
it('combines path, query and hash params', () => {
const matcher = createCompiledMatcher()
expect(matcher.resolve('/bar')).toMatchInlineSnapshot(
{
hash: '',
matched: [],
params: {},
path: '/bar',
query: {},
},
`
{
"fullPath": "/bar",
"hash": "",
"matched": [],
"name": Symbol(no-match),
"params": {},
"path": "/bar",
"query": {},
}
`
)
})
matcher.addRoute({
path: USER_ID_PATH_PATTERN_MATCHER,
query: PAGE_QUERY_PATTERN_MATCHER,
hash: ANY_HASH_PATTERN_MATCHER,
})

it('resolves string locations with all', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(
createMatcherPattern(
Symbol('foo'),
{
buildPath: params => `/foo/${params.id}`,
match: path => {
const match = path.match(/^\/foo\/([^/]+?)$/)
if (!match) throw new Error('no match')
return { id: match[1] }
},
parse: params => ({ id: Number(params.id) }),
serialize: params => ({ id: String(params.id) }),
},
{
match: query => ({
id: Array.isArray(query.id) ? query.id[0] : query.id,
}),
parse: params => ({ q: Number(params.id) }),
serialize: params => ({ id: String(params.q) }),
},
{
match: hash => hash,
parse: hash => ({ a: hash.slice(1) }),
serialize: ({ a }) => '#a',
}
)
)

expect(matcher.resolve('/foo/1?id=100#bar')).toMatchObject({
hash: '#bar',
params: { id: 1, q: 100, a: 'bar' },
expect(matcher.resolve('/users/24?page=100#bar')).toMatchObject({
params: { id: 24, page: 100, hash: 'bar' },
})
})
})

describe('relative locations as strings', () => {
it('resolves a simple relative location', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(
createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER)
)
matcher.addRoute({ path: ANY_PATH_PATTERN_MATCHER })

expect(
matcher.resolve('foo', matcher.resolve('/nested/'))
Expand Down Expand Up @@ -206,9 +219,10 @@ describe('Matcher', () => {
describe('named locations', () => {
it('resolves named locations with no params', () => {
const matcher = createCompiledMatcher()
matcher.addRoute(
createMatcherPattern('home', EMPTY_PATH_PATTERN_MATCHER)
)
matcher.addRoute({
name: 'home',
path: EMPTY_PATH_PATTERN_MATCHER,
})

expect(matcher.resolve({ name: 'home', params: {} })).toMatchObject({
name: 'home',
Expand Down
4 changes: 2 additions & 2 deletions packages/router/src/new-route-resolver/matcher.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, expectTypeOf, it } from 'vitest'
import { NEW_LocationResolved, createCompiledMatcher } from './matcher'
import { NEW_LocationResolved, RouteResolver } from './matcher'

describe('Matcher', () => {
const matcher = createCompiledMatcher()
const matcher: RouteResolver<unknown, unknown> = {} as any

describe('matcher.resolve()', () => {
it('resolves absolute string locations', () => {
Expand Down
Loading

0 comments on commit 1ca2a13

Please sign in to comment.