Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unified withParams utility to replace individual path, query, and host utilities #447

Merged
merged 13 commits into from
Jan 30, 2025
4 changes: 2 additions & 2 deletions src/guards/routes.spec-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { expectTypeOf, test } from 'vitest'
import { isRoute } from '@/guards/routes'
import { createRoute } from '@/services/createRoute'
import { createRouter } from '@/services/createRouter'
import { query } from '@/services/query'
import { component } from '@/utilities/testHelpers'
import { withParams } from '@/services/withParams'

test('router route can be narrowed', () => {
const parentA = createRoute({
Expand All @@ -15,7 +15,7 @@ test('router route can be narrowed', () => {
parent: parentA,
name: 'childA',
path: '[?foo]',
query: query('bar=[?bar]', { bar: Boolean }),
query: withParams('bar=[?bar]', { bar: Boolean }),
component,
})

Expand Down
32 changes: 32 additions & 0 deletions src/services/combineHash.spec.ts
stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from 'vitest'
import { DuplicateParamsError } from '@/errors/duplicateParamsError'
import { combineHash } from '@/services/combineHash'
import { withParams } from '@/services/withParams'

test('given 2 hash, returns new Hash joined together', () => {
const aHash = withParams('/foo', {})
const bHash = withParams('/bar', {})

const response = combineHash(aHash, bHash)

expect(response.value).toBe('/foo/bar')
})

test('given 2 hash with params, returns new Hash joined together with params', () => {
const aHash = withParams('/[foz]', { foz: String })
const bHash = withParams('/[?baz]', { baz: Number })

const response = combineHash(aHash, bHash)

expect(response.value).toBe('/[foz]/[?baz]')
expect(Object.keys(response.params)).toMatchObject(['foz', '?baz'])
})

test('given 2 hash with params that include duplicates, throws DuplicateParamsError', () => {
const aHash = withParams('/[foz]', { foz: String })
const bHash = withParams('/[foz]', { foz: String })

const action: () => void = () => combineHash(aHash, bHash)

expect(action).toThrow(DuplicateParamsError)
})
20 changes: 8 additions & 12 deletions src/services/combineHash.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the combinePath naming doesn't make sense now that its used for all the different pieces. Its also kinda interesting that since it accepts WithParams that there's no type safety preventing us from accidentally doing WithParams<TParent['path'], TQuery> 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we have that protection today, but it might be worth exploring. Something like assigning an internal kind (tagged type) to ensure wires don't get crossed

Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { hash } from '@/services/hash'
import { Hash, ToHash } from '@/types/hash'
import { WithParams } from '@/services/withParams'
import { combinePath, CombinePath } from '@/services/combinePath'

export type CombineHash<
TParent extends Hash,
TChild extends Hash
> = ToHash<TParent> extends { value: infer TParentHash extends string }
? ToHash<TChild> extends { value: infer ChildHash extends string }
? Hash<`${TParentHash}${ChildHash}`>
: TParent
: Hash
TParent extends WithParams,
TChild extends WithParams
> = CombinePath<TParent, TChild>

export function combineHash<TParentHash extends Hash, TChildHash extends Hash>(parentHash: TParentHash, childHash: TChildHash): CombineHash<TParentHash, TChildHash>
export function combineHash(parentHash: Hash, childHash: Hash): Hash {
return hash(`${parentHash.value ?? ''}${childHash.value ?? ''}`)
export function combineHash<TParentHash extends WithParams, TChildHash extends WithParams>(parentHash: TParentHash, childHash: TChildHash): CombineHash<TParentHash, TChildHash>
export function combineHash(parentHash: WithParams, childHash: WithParams): WithParams {
return combinePath(parentHash, childHash)
}
14 changes: 7 additions & 7 deletions src/services/combinePath.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { expect, test } from 'vitest'
import { DuplicateParamsError } from '@/errors/duplicateParamsError'
import { combinePath } from '@/services/combinePath'
import { path } from '@/services/path'
import { withParams } from '@/services/withParams'

test('given 2 paths, returns new Path joined together', () => {
const aPath = path('/foo', {})
const bPath = path('/bar', {})
const aPath = withParams('/foo', {})
const bPath = withParams('/bar', {})

const response = combinePath(aPath, bPath)

expect(response.value).toBe('/foo/bar')
})

test('given 2 paths with params, returns new Path joined together with params', () => {
const aPath = path('/[foz]', { foz: String })
const bPath = path('/[?baz]', { baz: Number })
const aPath = withParams('/[foz]', { foz: String })
const bPath = withParams('/[?baz]', { baz: Number })

const response = combinePath(aPath, bPath)

Expand All @@ -23,8 +23,8 @@ test('given 2 paths with params, returns new Path joined together with params',
})

test('given 2 paths with params that include duplicates, throws DuplicateParamsError', () => {
const aPath = path('/[foz]', { foz: String })
const bPath = path('/[foz]', { foz: String })
const aPath = withParams('/[foz]', { foz: String })
const bPath = withParams('/[foz]', { foz: String })

const action: () => void = () => combinePath(aPath, bPath)

Expand Down
39 changes: 22 additions & 17 deletions src/services/combinePath.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
import { RemoveLeadingQuestionMarkFromKeys } from '@/types/params'
import { Path, PathParamsWithParamNameExtracted, ToPath } from '@/types/path'
import { checkDuplicateParams } from '@/utilities/checkDuplicateKeys'
import { ParamsWithParamNameExtracted, ToWithParams, withParams, WithParams } from '@/services/withParams'
import { StringHasValue } from '@/utilities/guards'

type CombinePathString<TParent extends string | undefined, TChild extends string | undefined> =
StringHasValue<TParent> extends true
? StringHasValue<TChild> extends true
? `${TParent}${TChild}`
: TParent
: TChild

export type CombinePath<
TParent extends Path,
TChild extends Path
> = ToPath<TParent> extends { value: infer TParentPath extends string, params: infer TParentParams extends Record<string, unknown> }
? ToPath<TChild> extends { value: infer TChildPath extends string, params: infer TChildParams extends Record<string, unknown> }
? RemoveLeadingQuestionMarkFromKeys<TParentParams> & RemoveLeadingQuestionMarkFromKeys<TChildParams> extends PathParamsWithParamNameExtracted<`${TParentPath}${TChildPath}`>
? Path<`${TParentPath}${TChildPath}`, RemoveLeadingQuestionMarkFromKeys<TParentParams> & RemoveLeadingQuestionMarkFromKeys<TChildParams>>
: Path<'', {}>
: Path<'', {}>
: Path<'', {}>
TParent extends WithParams,
TChild extends WithParams
> = ToWithParams<TParent> extends { value: infer TParentPath extends string | undefined, params: infer TParentParams extends Record<string, unknown> }
? ToWithParams<TChild> extends { value: infer TChildPath extends string | undefined, params: infer TChildParams extends Record<string, unknown> }
? RemoveLeadingQuestionMarkFromKeys<TParentParams> & RemoveLeadingQuestionMarkFromKeys<TChildParams> extends ParamsWithParamNameExtracted<CombinePathString<TParentPath, TChildPath>>
stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved
? WithParams<CombinePathString<TParentPath, TChildPath>, RemoveLeadingQuestionMarkFromKeys<TParentParams> & RemoveLeadingQuestionMarkFromKeys<TChildParams>>
: WithParams<undefined, {}>
: WithParams<undefined, {}>
: WithParams<undefined, {}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we gain something from the undefined vs {} change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we just assume hash '' (empty string) is the same as undefined we could remove all the toStrings(). See if you can remember why hash was special and expected undefined separately from empty string


export function combinePath<TParentPath extends Path, TChildPath extends Path>(parentPath: TParentPath, childPath: TChildPath): CombinePath<TParentPath, TChildPath>
export function combinePath(parentPath: Path, childPath: Path): Path {
export function combinePath<TParentPath extends WithParams, TChildPath extends WithParams>(parentPath: TParentPath, childPath: TChildPath): CombinePath<TParentPath, TChildPath>
export function combinePath(parentPath: WithParams, childPath: WithParams): WithParams {
checkDuplicateParams(parentPath.params, childPath.params)

const newPathString = `${parentPath.value}${childPath.value}`
const newPathString = `${parentPath}${childPath}`

return {
value: newPathString,
params: { ...parentPath.params, ...childPath.params },
}
return withParams(newPathString, { ...parentPath.params, ...childPath.params })
}
14 changes: 7 additions & 7 deletions src/services/combineQuery.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { expect, test } from 'vitest'
import { DuplicateParamsError } from '@/errors/duplicateParamsError'
import { combineQuery } from '@/services/combineQuery'
import { query } from '@/services/query'
import { withParams } from '@/services/withParams'

test('given 2 queries, returns new Query joined together', () => {
const aQuery = query('foo=ABC', {})
const bQuery = query('bar=123', {})
const aQuery = withParams('foo=ABC', {})
const bQuery = withParams('bar=123', {})

const response = combineQuery(aQuery, bQuery)

expect(response.value).toBe('foo=ABC&bar=123')
})

test('given 2 queries with params, returns new Query joined together with params', () => {
const aQuery = query('foo=[foz]', { foz: String })
const bQuery = query('bar=[?baz]', { baz: Number })
const aQuery = withParams('foo=[foz]', { foz: String })
const bQuery = withParams('bar=[?baz]', { baz: Number })

const response = combineQuery(aQuery, bQuery)

Expand All @@ -23,8 +23,8 @@ test('given 2 queries with params, returns new Query joined together with params
})

test('given 2 queries with params that include duplicates, throws DuplicateParamsError', () => {
const aQuery = query('foo=[foz]', { foz: String })
const bQuery = query('foo=[foz]', { foz: String })
const aQuery = withParams('foo=[foz]', { foz: String })
const bQuery = withParams('foo=[foz]', { foz: String })

const action: () => void = () => combineQuery(aQuery, bQuery)

Expand Down
40 changes: 19 additions & 21 deletions src/services/combineQuery.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,33 @@
import { RemoveLeadingQuestionMarkFromKeys } from '@/types/params'
import { Query, QueryParamsWithParamNameExtracted, ToQuery } from '@/types/query'
import { checkDuplicateParams } from '@/utilities/checkDuplicateKeys'
import { StringHasValue, stringHasValue } from '@/utilities/guards'
import { ParamsWithParamNameExtracted, ToWithParams, withParams, WithParams } from '@/services/withParams'

type CombineQueryString<TParent extends string | undefined, TChild extends string | undefined> = StringHasValue<TParent> extends true
? StringHasValue<TChild> extends true
? `${TParent}&${TChild}`
: TParent
: TChild
type CombineQueryString<TParent extends string | undefined, TChild extends string | undefined> =
StringHasValue<TParent> extends true
? StringHasValue<TChild> extends true
? `${TParent}&${TChild}`
: TParent
: TChild

export type CombineQuery<
TParent extends Query,
TChild extends Query
> = ToQuery<TParent> extends { value: infer TParentQuery extends string, params: infer TParentParams extends Record<string, unknown> }
? ToQuery<TChild> extends { value: infer TChildQuery extends string, params: infer TChildParams extends Record<string, unknown> }
? RemoveLeadingQuestionMarkFromKeys<TParentParams> & RemoveLeadingQuestionMarkFromKeys<TChildParams> extends QueryParamsWithParamNameExtracted<CombineQueryString<TParentQuery, TChildQuery>>
? Query<CombineQueryString<TParentQuery, TChildQuery>, RemoveLeadingQuestionMarkFromKeys<TParentParams> & RemoveLeadingQuestionMarkFromKeys<TChildParams>>
: Query<'', {}>
: Query<'', {}>
: Query<'', {}>
TParent extends WithParams,
TChild extends WithParams
> = ToWithParams<TParent> extends { value: infer TParentQuery extends string, params: infer TParentParams extends Record<string, unknown> }
? ToWithParams<TChild> extends { value: infer TChildQuery extends string, params: infer TChildParams extends Record<string, unknown> }
? RemoveLeadingQuestionMarkFromKeys<TParentParams> & RemoveLeadingQuestionMarkFromKeys<TChildParams> extends ParamsWithParamNameExtracted<CombineQueryString<TParentQuery, TChildQuery>>
? WithParams<CombineQueryString<TParentQuery, TChildQuery>, RemoveLeadingQuestionMarkFromKeys<TParentParams> & RemoveLeadingQuestionMarkFromKeys<TChildParams>>
: WithParams<undefined, {}>
: WithParams<undefined, {}>
: WithParams<undefined, {}>

export function combineQuery<TParentQuery extends Query, TChildQuery extends Query>(parentQuery: TParentQuery, childQuery: TChildQuery): CombineQuery<TParentQuery, TChildQuery>
export function combineQuery(parentQuery: Query, childQuery: Query): Query {
export function combineQuery<TParentQuery extends WithParams, TChildQuery extends WithParams>(parentQuery: TParentQuery, childQuery: TChildQuery): CombineQuery<TParentQuery, TChildQuery>
export function combineQuery(parentQuery: WithParams, childQuery: WithParams): WithParams {
checkDuplicateParams(parentQuery.params, childQuery.params)

const newQueryString = [parentQuery.value, childQuery.value]
.filter(stringHasValue)
.join('&')

return {
value: newQueryString,
params: { ...parentQuery.params, ...childQuery.params },
}
return withParams(newQueryString, { ...parentQuery.params, ...childQuery.params })
}
75 changes: 75 additions & 0 deletions src/services/createExternalRoute.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { expect, test } from 'vitest'
import { createExternalRoute } from '@/services/createExternalRoute'
import { DuplicateParamsError } from '@/errors/duplicateParamsError'
import { withParams } from '@/services/withParams'

test('given parent, path is combined', () => {
const parent = createExternalRoute({
host: 'https://kitbag.dev',
path: '/parent',
})

const child = createExternalRoute({
parent: parent,
path: withParams('/child/[id]', { id: Number }),
})

expect(child.path).toMatchObject({
value: '/parent/child/[id]',
params: {
id: Number,
},
})
})

test('given parent, query is combined', () => {
const parent = createExternalRoute({
host: 'https://kitbag.dev',
query: 'static=123',
})

const child = createExternalRoute({
parent: parent,
query: withParams('sort=[sort]', { sort: Boolean }),
})

expect(child.query).toMatchObject({
value: 'static=123&sort=[sort]',
params: {
sort: Boolean,
},
})
})

test('given parent and child without meta, meta matches parent', () => {
const parent = createExternalRoute({
host: 'https://kitbag.dev',
meta: {
foo: 123,
},
})

const child = createExternalRoute({
parent: parent,
})

expect(child.meta).toMatchObject({
foo: 123,
})
})

test.each([
['https://[foo].dev', '/[foo]', 'foo=[zoo]', '[bar]'],
['https://[zoo].dev', '/[foo]', 'foo=[bar]', '[foo]'],
['https://[zoo].dev', '/[bar]', 'foo=[foo]', '[foo]'],
['https://[zoo].dev', '/[bar]', 'foo=[foo]', '[foo]'],
])('given duplicate params across different parts of the route, throws DuplicateParamsError', (host, path, query, hash) => {
stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved
const action: () => void = () => createExternalRoute({
host,
path,
query,
hash,
})

expect(action).toThrow(DuplicateParamsError)
})
33 changes: 15 additions & 18 deletions src/services/createExternalRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,40 @@ import { CombinePath } from '@/services/combinePath'
import { CombineQuery } from '@/services/combineQuery'
import { createRouteId } from '@/services/createRouteId'
import { combineRoutes, CreateRouteOptions, isWithHost, isWithParent, WithHost, WithoutHost, WithoutParent, WithParent } from '@/types/createRouteOptions'
import { Hash, toHash, ToHash } from '@/types/hash'
import { Host, toHost, ToHost } from '@/types/host'
import { toName, ToName } from '@/types/name'
import { Path, toPath, ToPath } from '@/types/path'
import { Query, toQuery, ToQuery } from '@/types/query'
import { RouteMeta } from '@/types/register'
import { Route } from '@/types/route'
import { checkDuplicateParams } from '@/utilities/checkDuplicateKeys'
import { toWithParams, ToWithParams, withParams, WithParams } from '@/services/withParams'

export function createExternalRoute<
const THost extends string | Host,
const THost extends string | WithParams,
const TName extends string | undefined = undefined,
const TPath extends string | Path | undefined = undefined,
const TQuery extends string | Query | undefined = undefined,
const THash extends string | Hash | undefined = undefined,
const TPath extends string | WithParams | undefined = undefined,
const TQuery extends string | WithParams | undefined = undefined,
const THash extends string | WithParams | undefined = undefined,
const TMeta extends RouteMeta = RouteMeta
>(options: CreateRouteOptions<TName, TPath, TQuery> & WithHost<THost> & WithoutParent):
Route<ToName<TName>, ToHost<THost>, ToPath<TPath>, ToQuery<TQuery>, ToHash<THash>, TMeta>
Route<ToName<TName>, ToWithParams<THost>, ToWithParams<TPath>, ToWithParams<TQuery>, ToWithParams<THash>, TMeta>

export function createExternalRoute<
const TParent extends Route,
const TName extends string | undefined = undefined,
const TPath extends string | Path | undefined = undefined,
const TQuery extends string | Query | undefined = undefined,
const THash extends string | Hash | undefined = undefined,
const TPath extends string | WithParams | undefined = undefined,
const TQuery extends string | WithParams | undefined = undefined,
const THash extends string | WithParams | undefined = undefined,
const TMeta extends RouteMeta = RouteMeta
>(options: CreateRouteOptions<TName, TPath, TQuery> & WithoutHost & WithParent<TParent>):
Route<ToName<TName>, Host<'', {}>, CombinePath<TParent['path'], ToPath<TPath>>, CombineQuery<TParent['query'], ToQuery<TQuery>>, CombineHash<TParent['hash'], ToHash<THash>>, CombineMeta<TMeta, TParent['meta']>>
Route<ToName<TName>, WithParams<undefined, {}>, CombinePath<TParent['path'], ToWithParams<TPath>>, CombineQuery<TParent['query'], ToWithParams<TQuery>>, CombineHash<TParent['hash'], ToWithParams<THash>>, CombineMeta<TMeta, TParent['meta']>>

export function createExternalRoute(options: CreateRouteOptions): Route {
const id = createRouteId()
const name = toName(options.name)
const path = toPath(options.path)
const query = toQuery(options.query)
const hash = toHash(options.hash)
const path = toWithParams(options.path)
const query = toWithParams(options.query)
const hash = toWithParams(options.hash)
const meta = options.meta ?? {}
const host = isWithHost(options) ? toHost(options.host) : toHost('')
const host = isWithHost(options) ? toWithParams(options.host) : withParams()
stackoverfloweth marked this conversation as resolved.
Show resolved Hide resolved
const rawRoute = markRaw({ id, meta: {}, state: {}, ...options })

const route = {
Expand All @@ -60,7 +57,7 @@ export function createExternalRoute(options: CreateRouteOptions): Route {

const merged = isWithParent(options) ? combineRoutes(options.parent, route) : route

checkDuplicateParams(merged.path.params, merged.query.params, merged.host.params)
checkDuplicateParams(merged.path.params, merged.query.params, merged.host.params, merged.hash.params)

return merged
}
Loading
Loading