)
expect(matcher.match({ user_id: null })).toEqual({ userId: null })
})
-
- it('handles missing query param', () => {
- const matcher = new MatcherPatternQueryParam(
- 'userId',
- 'user_id',
- 'value',
- PARAM_PARSER_DEFAULTS
- )
- expect(matcher.match({})).toEqual({
- userId: undefined,
- })
- })
})
describe('match() - format: array', () => {
})
})
- describe('match() - format: both', () => {
- it('preserves single string value', () => {
- const matcher = new MatcherPatternQueryParam(
- 'data',
- 'value',
- 'both',
- PARAM_PARSER_DEFAULTS
- )
- expect(matcher.match({ value: 'single' })).toEqual({ data: 'single' })
- })
-
- it('preserves array value', () => {
- const matcher = new MatcherPatternQueryParam(
- 'data',
- 'values',
- 'both',
- PARAM_PARSER_DEFAULTS
- )
- expect(matcher.match({ values: ['a', 'b'] })).toEqual({
- data: ['a', 'b'],
- })
- })
-
- it('preserves null', () => {
- const matcher = new MatcherPatternQueryParam(
- 'data',
- 'value',
- 'both',
- PARAM_PARSER_DEFAULTS
- )
- expect(matcher.match({ value: null })).toEqual({ data: null })
- })
-
- it('handles missing query param', () => {
- const matcher = new MatcherPatternQueryParam(
- 'data',
- 'value',
- 'both',
- PARAM_PARSER_DEFAULTS
- )
- expect(matcher.match({})).toEqual({ data: undefined })
- })
- })
-
describe('build()', () => {
describe('format: value', () => {
it('builds query from single value', () => {
expect(matcher.build({ tags: ['vue'] })).toEqual({ tag: ['vue'] })
})
})
-
- describe('format: both', () => {
- it('builds query from single value', () => {
- const matcher = new MatcherPatternQueryParam(
- 'data',
- 'value',
- 'both',
- PARAM_PARSER_DEFAULTS
- )
- expect(matcher.build({ data: 'single' })).toEqual({ value: 'single' })
- })
-
- it('builds query from array value', () => {
- const matcher = new MatcherPatternQueryParam(
- 'data',
- 'values',
- 'both',
- PARAM_PARSER_DEFAULTS
- )
- expect(matcher.build({ data: ['a', 'b'] })).toEqual({
- values: ['a', 'b'],
- })
- })
-
- it('builds query from null value', () => {
- const matcher = new MatcherPatternQueryParam(
- 'data',
- 'value',
- 'both',
- PARAM_PARSER_DEFAULTS
- )
- expect(matcher.build({ data: null })).toEqual({ value: null })
- })
- })
})
describe('default values', () => {
})
describe('missing query parameters', () => {
- it('returns undefined when query param missing with parser and no default', () => {
+ it('handles missing query param with default', () => {
const matcher = new MatcherPatternQueryParam(
- 'count',
- 'c',
+ 'userId',
+ 'user_id',
'value',
- PARAM_PARSER_INT
+ PARAM_PARSER_DEFAULTS,
+ 'default'
)
- expect(matcher.match({ other: 'value' })).toEqual({ count: undefined })
+ expect(matcher.match({})).toEqual({
+ userId: 'default',
+ })
+ })
+
+ it('throws if a required param is missing and no default', () => {
+ const matcher = new MatcherPatternQueryParam(
+ 'userId',
+ 'user_id',
+ 'value',
+ PARAM_PARSER_DEFAULTS
+ )
+ expect(() => matcher.match({})).toThrow(MatchMiss)
})
it('uses default when query param missing', () => {
'test',
'test_param',
'value',
- {}
+ {},
+ 'default'
)
// Should use PARAM_PARSER_DEFAULTS.get which returns value ?? null
expect(matcher.match({ test_param: 'value' })).toEqual({
test: 'value',
})
expect(matcher.match({ test_param: null })).toEqual({ test: null })
- expect(matcher.match({})).toEqual({ test: undefined })
+ expect(matcher.match({})).toEqual({ test: 'default' })
})
it('should handle array format with missing get method', () => {
test: ['single'],
})
})
-
- it('should handle both format with missing get method', () => {
- const matcher = new MatcherPatternQueryParam(
- 'test',
- 'test_param',
- 'both',
- {}
- )
- // Should use PARAM_PARSER_DEFAULTS.get which returns value ?? null
- expect(matcher.match({ test_param: 'value' })).toEqual({
- test: 'value',
- })
- expect(matcher.match({ test_param: ['a', 'b'] })).toEqual({
- test: ['a', 'b'],
- })
- })
})
describe('build', () => {
test_param: ['1', 'true'],
})
})
-
- it('should handle both format with missing set method', () => {
- const matcher = new MatcherPatternQueryParam(
- 'test',
- 'test_param',
- 'both',
- {}
- )
- // Should use PARAM_PARSER_DEFAULTS.set
- expect(matcher.build({ test: 'value' })).toEqual({
- test_param: 'value',
- })
- expect(matcher.build({ test: ['a', 'b'] })).toEqual({
- test_param: ['a', 'b'],
- })
- })
})
})
})
MatcherQueryParams,
} from './matcher-pattern'
import { ParamParser, PARAM_PARSER_DEFAULTS } from './param-parsers'
+import { miss } from './errors'
/**
* Handles the `query` part of a URL. It can transform a query object into an
// if we have no values, we want to fall back to the default value
if (
- (this.format === 'both' || this.defaultValue !== undefined) &&
+ this.defaultValue !== undefined &&
(value as unknown[]).length === 0
) {
value = undefined
}
}
+ // miss if there is no default and there was no value in the query
+ if (value === undefined) {
+ if (this.defaultValue === undefined) {
+ throw miss()
+ }
+ value = toValue(this.defaultValue)
+ }
+
return {
- [this.paramName]:
- value === undefined ? toValue(this.defaultValue) : value,
+ [this.paramName]: value,
// This is a TS limitation
} as Record<ParamName, T>
}
MatcherPatternPathDynamic,
} from './matcher-pattern'
import { MatcherPatternPathStar } from './matcher-pattern-path-star'
+import { miss } from './errors'
+import { definePathParamParser } from './param-parsers/types'
describe('MatcherPatternPathStatic', () => {
describe('match()', () => {
expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/')
expect(pattern.build({ teamId: [] })).toBe('/teams/')
})
+
+ describe('custom param parsers', () => {
+ const doubleParser = definePathParamParser({
+ get: (v: string | null) => {
+ const value = Number(v) * 2
+ if (!Number.isFinite(value)) {
+ throw miss()
+ }
+ return value
+ },
+ set: (v: number | null) => (v == null ? null : String(v / 2)),
+ })
+
+ it('single regular param', () => {
+ const pattern = new MatcherPatternPathDynamic(
+ /^\/teams\/([^/]+?)$/i,
+ {
+ teamId: [doubleParser],
+ },
+ ['teams', 1]
+ )
+
+ expect(pattern.match('/teams/123')).toEqual({ teamId: 246 })
+ expect(() => pattern.match('/teams/abc')).toThrow()
+ expect(pattern.build({ teamId: 246 })).toBe('/teams/123')
+ })
+
+ it('can transform optional params', () => {
+ const pattern = new MatcherPatternPathDynamic(
+ /^\/teams(?:\/([^/]+?))?$/i,
+ {
+ teamId: [doubleParser, false, true],
+ },
+ ['teams', 1]
+ )
+
+ expect(pattern.match('/teams')).toEqual({ teamId: 0 })
+ expect(pattern.match('/teams/123')).toEqual({ teamId: 246 })
+ expect(() => pattern.match('/teams/abc')).toThrow()
+ expect(pattern.build({ teamId: 246 })).toBe('/teams/123')
+ expect(pattern.build({ teamId: 0 })).toBe('/teams/0')
+ expect(pattern.build({ teamId: null })).toBe('/teams')
+ })
+ })
})
import { describe, expectTypeOf, it } from 'vitest'
import { MatcherPatternPathDynamic } from './matcher-pattern'
-import { PARAM_INTEGER_SINGLE } from './param-parsers/integers'
import { PATH_PARAM_PARSER_DEFAULTS } from './param-parsers'
import { PATH_PARAM_SINGLE_DEFAULT } from './param-parsers'
+import { definePathParamParser } from './param-parsers/types'
describe('MatcherPatternPathDynamic', () => {
it('can be generic', () => {
{ userId: [PATH_PARAM_PARSER_DEFAULTS] },
['users', 1]
)
-
expectTypeOf(matcher.match('/users/123')).toEqualTypeOf<{
userId: string | string[] | null
}>()
})
it('can be a custom type', () => {
+ // naive number parser but types should be good
+ const numberParser = definePathParamParser({
+ get: value => {
+ return Number(value)
+ },
+ set: (value: number | null) => {
+ return String(value ?? 0)
+ },
+ })
+
+ expectTypeOf(numberParser.get('0')).toEqualTypeOf<number>()
+ expectTypeOf(numberParser.set(0)).toEqualTypeOf<string>()
+ expectTypeOf(numberParser.set(null)).toEqualTypeOf<string>()
+ numberParser.get(
+ // @ts-expect-error: must be a string
+ null
+ )
+ numberParser.set(
+ // @ts-expect-error: must be a number or null
+ '0'
+ )
+
const matcher = new MatcherPatternPathDynamic(
/^\/profiles\/([^/]+)$/i,
{
userId: [
- PARAM_INTEGER_SINGLE,
+ numberParser,
// parser: PATH_PARAM_DEFAULT_PARSER,
],
},
import { decode, encodeParam, encodePath } from '../../../encoding'
import { warn } from '../../../warning'
import { miss } from './errors'
-import { ParamParser } from './param-parsers/types'
+import type { ParamParser } from './param-parsers/types'
+import type { Simplify } from '../../../types/utils'
/**
* Base interface for matcher patterns that extract params from a URL.
*
* @template TIn - type of the input value to match against the pattern
- * @template TOut - type of the output value after matching
+ * @template TParams - type of the output value after matching
*
* In the case of the `path`, the `TIn` is a `string`, but in the case of the
* query, it's the object of query params.
*/
export interface MatcherPattern<
TIn = string,
- TOut extends MatcherParamsFormatted = MatcherParamsFormatted,
+ TParams extends MatcherParamsFormatted = MatcherParamsFormatted,
+ TParamsRaw extends MatcherParamsFormatted = TParams,
> {
/**
* Matches a serialized params value against the pattern.
* @throws {MatchMiss} if the value doesn't match
* @returns parsed params object
*/
- match(value: TIn): TOut
+ match(value: TIn): TParams
/**
* Build a serializable value from parsed params. Should apply encoding if the
* @param value - params value to parse
* @returns serialized params value
*/
- build(params: TOut): TIn
+ build(params: TParamsRaw): TIn
}
/**
export interface MatcherPatternPath<
// TODO: should we allow to not return anything? It's valid to spread null and undefined
TParams extends MatcherParamsFormatted = MatcherParamsFormatted, // | null // | undefined // | void // so it might be a bit more convenient
-> extends MatcherPattern<string, TParams> {}
+ TParamsRaw extends MatcherParamsFormatted = TParams,
+> extends MatcherPattern<string, TParams, TParamsRaw> {}
/**
* Allows matching a static path.
*/
private pathi: string
- constructor(private path: string) {
+ constructor(readonly path: string) {
this.pathi = path.toLowerCase()
}
* Options for param parsers in {@link MatcherPatternPathDynamic}.
*/
export type MatcherPatternPathDynamic_ParamOptions<
- TIn extends string | string[] | null = string | string[] | null,
- TOut = string | string[] | null,
-> = [
+ TUrlParam extends string | string[] | null = string | string[] | null,
+ TParam = string | string[] | null,
+ TParamRaw = TParam,
+> = readonly [
/**
* Param parser to use for this param.
*/
- parser?: ParamParser<TOut, TIn>,
+ parser?: ParamParser<TParam, TUrlParam, TParamRaw>,
/**
* Is tha param a repeatable param and should be converted to an array
type ExtractParamTypeFromOptions<TParamsOptions> = {
[K in keyof TParamsOptions]: TParamsOptions[K] extends MatcherPatternPathDynamic_ParamOptions<
any,
- infer TOut
+ infer TParam,
+ any
+ >
+ ? TParam
+ : never
+}
+
+type ExtractLocationParamTypeFromOptions<TParamsOptions> = {
+ [K in keyof TParamsOptions]: TParamsOptions[K] extends MatcherPatternPathDynamic_ParamOptions<
+ any,
+ any,
+ infer TParamRaw
>
- ? TOut
+ ? TParamRaw
: never
}
// TODO: | EmptyObject ?
// TParamsOptions extends Record<string, MatcherPatternPathCustomParamOptions>,
// TParams extends MatcherParamsFormatted = ExtractParamTypeFromOptions<TParamsOptions>
-> implements MatcherPatternPath<ExtractParamTypeFromOptions<TParamsOptions>>
+> implements
+ MatcherPatternPath<
+ ExtractParamTypeFromOptions<TParamsOptions>,
+ ExtractLocationParamTypeFromOptions<TParamsOptions>
+ >
{
/**
* Cached keys of the {@link params} object.
this.paramsKeys = Object.keys(this.params) as Array<keyof TParamsOptions>
}
- match(path: string): ExtractParamTypeFromOptions<TParamsOptions> {
+ match(path: string): Simplify<ExtractParamTypeFromOptions<TParamsOptions>> {
if (
this.trailingSlash != null &&
this.trailingSlash === !path.endsWith('/')
return params
}
- build(params: ExtractParamTypeFromOptions<TParamsOptions>): string {
+ build(
+ params: Simplify<ExtractLocationParamTypeFromOptions<TParamsOptions>>
+ ): string {
let paramIndex = 0
let paramName: keyof TParamsOptions
let parser: (TParamsOptions &
/**
* Possible values for query params in a matcher.
*/
-export type MatcherQueryParamsValue = string | null | Array<string | null>
+export type MatcherQueryParamsValue =
+ | string
+ | null
+ | undefined
+ | Array<string | null>
export type MatcherQueryParams = Record<string, MatcherQueryParamsValue>
import { describe, expect, it } from 'vitest'
-import {
- PARAM_BOOLEAN_SINGLE,
- PARAM_BOOLEAN_OPTIONAL,
- PARAM_BOOLEAN_REPEATABLE,
- PARAM_PARSER_BOOL,
-} from './booleans'
+import { PARAM_PARSER_BOOL } from './booleans'
-describe('PARAM_BOOLEAN_SINGLE', () => {
- describe('get()', () => {
+describe('PARAM_PARSER_BOOL', () => {
+ describe('get() - Single Values', () => {
it('parses true values', () => {
- expect(PARAM_BOOLEAN_SINGLE.get('true')).toBe(true)
- expect(PARAM_BOOLEAN_SINGLE.get('TRUE')).toBe(true)
- expect(PARAM_BOOLEAN_SINGLE.get('True')).toBe(true)
+ expect(PARAM_PARSER_BOOL.get('true')).toBe(true)
+ expect(PARAM_PARSER_BOOL.get('TRUE')).toBe(true)
+ expect(PARAM_PARSER_BOOL.get('True')).toBe(true)
+ expect(PARAM_PARSER_BOOL.get('TrUe')).toBe(true)
+ expect(PARAM_PARSER_BOOL.get('tRUE')).toBe(true)
})
it('parses false values', () => {
- expect(PARAM_BOOLEAN_SINGLE.get('false')).toBe(false)
- expect(PARAM_BOOLEAN_SINGLE.get('FALSE')).toBe(false)
- expect(PARAM_BOOLEAN_SINGLE.get('False')).toBe(false)
+ expect(PARAM_PARSER_BOOL.get('false')).toBe(false)
+ expect(PARAM_PARSER_BOOL.get('FALSE')).toBe(false)
+ expect(PARAM_PARSER_BOOL.get('False')).toBe(false)
+ expect(PARAM_PARSER_BOOL.get('FaLsE')).toBe(false)
+ expect(PARAM_PARSER_BOOL.get('fALSE')).toBe(false)
})
- it('throws for invalid values', () => {
- expect(() => PARAM_BOOLEAN_SINGLE.get('yes')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('no')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('1')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('0')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('on')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('off')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('maybe')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('invalid')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('true1')).toThrow()
- expect(() => PARAM_BOOLEAN_SINGLE.get('falsy')).toThrow()
+ it('returns true for null values (param present without value)', () => {
+ expect(PARAM_PARSER_BOOL.get(null)).toBe(true)
})
- it('returns false for null or empty values', () => {
- expect(PARAM_BOOLEAN_SINGLE.get(null)).toBe(false)
- expect(PARAM_BOOLEAN_SINGLE.get('')).toBe(false)
+ it('returns undefined for undefined values (param missing)', () => {
+ expect(PARAM_PARSER_BOOL.get(undefined)).toBe(undefined)
})
- })
- describe('set()', () => {
- it('converts boolean to string', () => {
- expect(PARAM_BOOLEAN_SINGLE.set(true)).toBe('true')
- expect(PARAM_BOOLEAN_SINGLE.set(false)).toBe('false')
- })
-
- it('converts null to false string', () => {
- expect(PARAM_BOOLEAN_SINGLE.set(null)).toBe('false')
+ it('throws for invalid string values', () => {
+ expect(() => PARAM_PARSER_BOOL.get('')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('yes')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('no')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('1')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('0')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('on')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('off')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('maybe')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('invalid')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('true1')).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get('falsy')).toThrow()
})
})
-})
-describe('PARAM_BOOLEAN_OPTIONAL', () => {
- describe('get()', () => {
- it('returns null for null input', () => {
- expect(PARAM_BOOLEAN_OPTIONAL.get(null)).toBe(null)
+ describe('get() - Array Values', () => {
+ it('parses arrays of valid boolean values', () => {
+ expect(PARAM_PARSER_BOOL.get(['true', 'false'])).toEqual([true, false])
+ expect(PARAM_PARSER_BOOL.get(['TRUE', 'FALSE'])).toEqual([true, false])
+ expect(PARAM_PARSER_BOOL.get(['True', 'False'])).toEqual([true, false])
+ expect(PARAM_PARSER_BOOL.get(['true', 'false', 'TRUE', 'FALSE'])).toEqual(
+ [true, false, true, false]
+ )
})
- it('parses valid values', () => {
- expect(PARAM_BOOLEAN_OPTIONAL.get('true')).toBe(true)
- expect(PARAM_BOOLEAN_OPTIONAL.get('false')).toBe(false)
+ it('handles empty arrays', () => {
+ expect(PARAM_PARSER_BOOL.get([])).toEqual([])
})
- it('throws for invalid values', () => {
- expect(() => PARAM_BOOLEAN_OPTIONAL.get('invalid')).toThrow()
+ it('handles arrays with null values (converts null to true)', () => {
+ expect(PARAM_PARSER_BOOL.get([null])).toEqual([true])
+ expect(PARAM_PARSER_BOOL.get(['true', null, 'false'])).toEqual([
+ true,
+ true,
+ false,
+ ])
+ expect(PARAM_PARSER_BOOL.get([null, null])).toEqual([true, true])
})
- })
- describe('set()', () => {
- it('returns null for null input', () => {
- expect(PARAM_BOOLEAN_OPTIONAL.set(null)).toBe(null)
+ it('throws for arrays with invalid values', () => {
+ expect(() => PARAM_PARSER_BOOL.get(['true', 'invalid'])).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get(['invalid'])).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get(['true', ''])).toThrow()
+ expect(() => PARAM_PARSER_BOOL.get(['yes', 'no'])).toThrow()
})
+ })
- it('converts boolean to string', () => {
- expect(PARAM_BOOLEAN_OPTIONAL.set(true)).toBe('true')
- expect(PARAM_BOOLEAN_OPTIONAL.set(false)).toBe('false')
+ describe('set() - Single Values', () => {
+ it('converts boolean values to strings', () => {
+ expect(PARAM_PARSER_BOOL.set(true)).toBe('true')
+ expect(PARAM_PARSER_BOOL.set(false)).toBe('false')
})
- })
-})
-describe('PARAM_BOOLEAN_REPEATABLE', () => {
- describe('get()', () => {
- it('parses array of boolean values', () => {
- expect(
- PARAM_BOOLEAN_REPEATABLE.get(['true', 'false', 'TRUE', 'FALSE'])
- ).toEqual([true, false, true, false])
+ it('preserves null values', () => {
+ expect(PARAM_PARSER_BOOL.set(null)).toBe(null)
})
- it('throws for invalid values in array', () => {
- expect(() => PARAM_BOOLEAN_REPEATABLE.get(['true', 'invalid'])).toThrow()
+ it('preserves undefined values', () => {
+ expect(PARAM_PARSER_BOOL.set(undefined)).toBe(undefined)
})
})
- describe('set()', () => {
- it('converts array of booleans to strings', () => {
- expect(PARAM_BOOLEAN_REPEATABLE.set([true, false, true])).toEqual([
+ describe('set() - Array Values', () => {
+ it('converts arrays of booleans to arrays of strings', () => {
+ expect(PARAM_PARSER_BOOL.set([true])).toEqual(['true'])
+ expect(PARAM_PARSER_BOOL.set([false])).toEqual(['false'])
+ expect(PARAM_PARSER_BOOL.set([true, false])).toEqual(['true', 'false'])
+ expect(PARAM_PARSER_BOOL.set([true, false, true])).toEqual([
'true',
'false',
'true',
])
})
- })
-})
-
-describe('PARAM_PARSER_BOOL', () => {
- describe('get()', () => {
- it('handles single values', () => {
- expect(PARAM_PARSER_BOOL.get('true')).toBe(true)
- expect(PARAM_PARSER_BOOL.get('false')).toBe(false)
- })
-
- it('handles null values', () => {
- expect(PARAM_PARSER_BOOL.get(null)).toBe(false)
- })
-
- it('handles array values', () => {
- expect(PARAM_PARSER_BOOL.get(['true', 'false'])).toEqual([true, false])
- })
- })
- describe('set()', () => {
- it('handles single values', () => {
- expect(PARAM_PARSER_BOOL.set(true)).toBe('true')
- expect(PARAM_PARSER_BOOL.set(false)).toBe('false')
- })
-
- it('handles null values', () => {
- expect(PARAM_PARSER_BOOL.set(null)).toBe('false')
- })
-
- it('handles array values', () => {
- expect(PARAM_PARSER_BOOL.set([true, false])).toEqual(['true', 'false'])
+ it('handles empty arrays', () => {
+ expect(PARAM_PARSER_BOOL.set([])).toEqual([])
})
})
})
import { miss } from '../errors'
import { ParamParser } from './types'
-export const PARAM_BOOLEAN_SINGLE = {
- get: (value: string | null) => {
- if (!value) return false
+const PARAM_BOOLEAN_SINGLE = {
+ get: (value: string | null | undefined) => {
+ // we want to differentiate between the absence of a value
+ if (value === undefined) return undefined
+
+ if (value == null) return true
const lowercaseValue = value.toLowerCase()
throw miss()
},
- set: (value: boolean | null) => String(!!value),
-} satisfies ParamParser<boolean, string | null>
-
-export const PARAM_BOOLEAN_OPTIONAL = {
- get: (value: string | null) =>
- value == null ? null : PARAM_BOOLEAN_SINGLE.get(value),
- set: (value: boolean | null) =>
- value != null ? PARAM_BOOLEAN_SINGLE.set(value) : null,
-} satisfies ParamParser<boolean | null, string | null>
-
-export const PARAM_BOOLEAN_REPEATABLE = {
- get: (value: (string | null)[]) => value.map(PARAM_BOOLEAN_SINGLE.get),
- set: (value: boolean[]) => value.map(PARAM_BOOLEAN_SINGLE.set),
+ set: (value: boolean | null | undefined) =>
+ value == null ? value : String(value),
+} satisfies ParamParser<boolean | null | undefined, string | null | undefined>
+
+const PARAM_BOOLEAN_REPEATABLE = {
+ get: (value: (string | null)[]) =>
+ value.map(v => {
+ const result = PARAM_BOOLEAN_SINGLE.get(v)
+ // Filter out undefined values to ensure arrays only contain booleans
+ return result === undefined ? false : result
+ }),
+ set: (value: boolean[]) =>
+ // since v is always a boolean, set always returns a string
+ value.map(v => PARAM_BOOLEAN_SINGLE.set(v) as string),
} satisfies ParamParser<boolean[], (string | null)[]>
-export const PARAM_BOOLEAN_REPEATABLE_OPTIONAL = {
- get: (value: string[] | null) =>
- value == null ? null : PARAM_BOOLEAN_REPEATABLE.get(value),
- set: (value: boolean[] | null) =>
- value != null ? PARAM_BOOLEAN_REPEATABLE.set(value) : null,
-} satisfies ParamParser<boolean[] | null, string[] | null>
-
/**
* Native Param parser for booleans.
*
Array.isArray(value)
? PARAM_BOOLEAN_REPEATABLE.set(value)
: PARAM_BOOLEAN_SINGLE.set(value),
-} satisfies ParamParser<boolean | boolean[] | null>
+} satisfies ParamParser<boolean | boolean[] | null | undefined>
import { describe, expect, it } from 'vitest'
-import {
- PARAM_INTEGER_SINGLE,
- PARAM_INTEGER_OPTIONAL,
- PARAM_INTEGER_REPEATABLE,
- PARAM_INTEGER_REPEATABLE_OPTIONAL,
- PARAM_PARSER_INT,
-} from './integers'
-
-describe('PARAM_INTEGER_SINGLE', () => {
- describe('get()', () => {
- it('parses valid integers', () => {
- expect(PARAM_INTEGER_SINGLE.get('0')).toBe(0)
- expect(PARAM_INTEGER_SINGLE.get('1')).toBe(1)
- expect(PARAM_INTEGER_SINGLE.get('42')).toBe(42)
- expect(PARAM_INTEGER_SINGLE.get('-1')).toBe(-1)
- expect(PARAM_INTEGER_SINGLE.get('-999')).toBe(-999)
- expect(PARAM_INTEGER_SINGLE.get('2147483647')).toBe(2147483647)
- })
-
- it('throws for decimal numbers', () => {
- expect(() => PARAM_INTEGER_SINGLE.get('1.5')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('3.14159')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('0.1')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('-2.5')).toThrow()
- })
+import { PARAM_PARSER_INT } from './integers'
- it('throws for non-numeric strings', () => {
- expect(() => PARAM_INTEGER_SINGLE.get('abc')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('12abc')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('abc12')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('true')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('false')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('NaN')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('Infinity')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('-Infinity')).toThrow()
+describe('PARAM_PARSER_INT', () => {
+ describe('get() - Single Values', () => {
+ it('parses valid integer strings', () => {
+ expect(PARAM_PARSER_INT.get('0')).toBe(0)
+ expect(PARAM_PARSER_INT.get('1')).toBe(1)
+ expect(PARAM_PARSER_INT.get('42')).toBe(42)
+ expect(PARAM_PARSER_INT.get('-1')).toBe(-1)
+ expect(PARAM_PARSER_INT.get('-999')).toBe(-999)
+ expect(PARAM_PARSER_INT.get('2147483647')).toBe(2147483647)
})
- it('throws for empty strings', () => {
- expect(() => PARAM_INTEGER_SINGLE.get('')).toThrow()
+ it('parses numbers with leading/trailing whitespace', () => {
+ expect(PARAM_PARSER_INT.get(' 42')).toBe(42)
+ expect(PARAM_PARSER_INT.get('42 ')).toBe(42)
+ expect(PARAM_PARSER_INT.get(' 42 ')).toBe(42)
})
it('parses whitespace strings as zero', () => {
- expect(PARAM_INTEGER_SINGLE.get(' ')).toBe(0)
- expect(PARAM_INTEGER_SINGLE.get(' ')).toBe(0)
- expect(PARAM_INTEGER_SINGLE.get('\n')).toBe(0)
- expect(PARAM_INTEGER_SINGLE.get('\t')).toBe(0)
- })
-
- it('throws for null', () => {
- expect(() => PARAM_INTEGER_SINGLE.get(null)).toThrow()
- })
-
- it('parses numbers with leading/trailing whitespace', () => {
- expect(PARAM_INTEGER_SINGLE.get(' 42')).toBe(42)
- expect(PARAM_INTEGER_SINGLE.get('42 ')).toBe(42)
- expect(PARAM_INTEGER_SINGLE.get(' 42 ')).toBe(42)
+ expect(PARAM_PARSER_INT.get(' ')).toBe(0)
+ expect(PARAM_PARSER_INT.get(' ')).toBe(0)
+ expect(PARAM_PARSER_INT.get('\n')).toBe(0)
+ expect(PARAM_PARSER_INT.get('\t')).toBe(0)
})
it('parses valid scientific notation as integers', () => {
- expect(PARAM_INTEGER_SINGLE.get('1e5')).toBe(100000)
- expect(PARAM_INTEGER_SINGLE.get('1e2')).toBe(100)
- })
-
- it('parses scientific notation that results in large integers', () => {
- expect(PARAM_INTEGER_SINGLE.get('2.5e10')).toBe(25000000000)
- expect(PARAM_INTEGER_SINGLE.get('1.5e2')).toBe(150)
+ expect(PARAM_PARSER_INT.get('1e5')).toBe(100000)
+ expect(PARAM_PARSER_INT.get('1e2')).toBe(100)
+ expect(PARAM_PARSER_INT.get('2.5e10')).toBe(25000000000)
+ expect(PARAM_PARSER_INT.get('1.5e2')).toBe(150)
})
- it('throws for scientific notation that results in decimals', () => {
- expect(() => PARAM_INTEGER_SINGLE.get('1e-1')).toThrow()
- expect(() => PARAM_INTEGER_SINGLE.get('1e-2')).toThrow()
- })
- })
-
- describe('set()', () => {
- it('converts integers to strings', () => {
- expect(PARAM_INTEGER_SINGLE.set(0)).toBe('0')
- expect(PARAM_INTEGER_SINGLE.set(1)).toBe('1')
- expect(PARAM_INTEGER_SINGLE.set(42)).toBe('42')
- expect(PARAM_INTEGER_SINGLE.set(-1)).toBe('-1')
- expect(PARAM_INTEGER_SINGLE.set(-999)).toBe('-999')
- expect(PARAM_INTEGER_SINGLE.set(2147483647)).toBe('2147483647')
- })
- })
-})
-
-describe('PARAM_INTEGER_OPTIONAL', () => {
- describe('get()', () => {
- it('returns null for null input', () => {
- expect(PARAM_INTEGER_OPTIONAL.get(null)).toBe(null)
+ it('returns null for null values', () => {
+ expect(PARAM_PARSER_INT.get(null)).toBe(null)
})
- it('parses valid integers', () => {
- expect(PARAM_INTEGER_OPTIONAL.get('0')).toBe(0)
- expect(PARAM_INTEGER_OPTIONAL.get('42')).toBe(42)
- expect(PARAM_INTEGER_OPTIONAL.get('-1')).toBe(-1)
+ it('throws for decimal numbers', () => {
+ expect(() => PARAM_PARSER_INT.get('1.5')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('3.14159')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('0.1')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('-2.5')).toThrow()
})
- it('throws for invalid values', () => {
- expect(() => PARAM_INTEGER_OPTIONAL.get('invalid')).toThrow()
- expect(() => PARAM_INTEGER_OPTIONAL.get('1.5')).toThrow()
- expect(() => PARAM_INTEGER_OPTIONAL.get('')).toThrow()
+ it('throws for scientific notation that results in decimals', () => {
+ expect(() => PARAM_PARSER_INT.get('1e-1')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('1e-2')).toThrow()
})
- })
- describe('set()', () => {
- it('returns null for null input', () => {
- expect(PARAM_INTEGER_OPTIONAL.set(null)).toBe(null)
+ it('throws for non-numeric strings', () => {
+ expect(() => PARAM_PARSER_INT.get('abc')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('12abc')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('abc12')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('true')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('false')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('NaN')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('Infinity')).toThrow()
+ expect(() => PARAM_PARSER_INT.get('-Infinity')).toThrow()
})
- it('converts integers to strings', () => {
- expect(PARAM_INTEGER_OPTIONAL.set(0)).toBe('0')
- expect(PARAM_INTEGER_OPTIONAL.set(42)).toBe('42')
- expect(PARAM_INTEGER_OPTIONAL.set(-1)).toBe('-1')
+ it('throws for empty strings', () => {
+ expect(() => PARAM_PARSER_INT.get('')).toThrow()
})
})
-})
-describe('PARAM_INTEGER_REPEATABLE', () => {
- describe('get()', () => {
- it('parses array of integer values', () => {
- expect(
- PARAM_INTEGER_REPEATABLE.get(['0', '1', '42', '-1', '-999'])
- ).toEqual([0, 1, 42, -1, -999])
+ describe('get() - Array Values', () => {
+ it('parses arrays of valid integer strings', () => {
+ expect(PARAM_PARSER_INT.get(['0', '1', '42', '-1', '-999'])).toEqual([
+ 0, 1, 42, -1, -999,
+ ])
+ expect(PARAM_PARSER_INT.get(['2147483647'])).toEqual([2147483647])
})
- it('handles empty array', () => {
- expect(PARAM_INTEGER_REPEATABLE.get([])).toEqual([])
+ it('handles empty arrays', () => {
+ expect(PARAM_PARSER_INT.get([])).toEqual([])
})
- it('throws for invalid values in array', () => {
- expect(() => PARAM_INTEGER_REPEATABLE.get(['42', 'invalid'])).toThrow()
- expect(() => PARAM_INTEGER_REPEATABLE.get(['1', '2.5'])).toThrow()
- expect(() => PARAM_INTEGER_REPEATABLE.get(['1', ''])).toThrow()
+ it('throws for arrays with decimal numbers', () => {
+ expect(() => PARAM_PARSER_INT.get(['42', '1.5'])).toThrow()
+ expect(() => PARAM_PARSER_INT.get(['3.14159'])).toThrow()
})
- it('throws if any element is null', () => {
- expect(() => PARAM_INTEGER_REPEATABLE.get(['1', null, '3'])).toThrow()
+ it('throws for arrays with non-numeric strings', () => {
+ expect(() => PARAM_PARSER_INT.get(['42', 'invalid'])).toThrow()
+ expect(() => PARAM_PARSER_INT.get(['1', '12abc'])).toThrow()
+ expect(() => PARAM_PARSER_INT.get(['true', 'false'])).toThrow()
})
- })
- describe('set()', () => {
- it('converts array of integers to strings', () => {
- expect(PARAM_INTEGER_REPEATABLE.set([0, 1, 42, -1, -999])).toEqual([
- '0',
- '1',
- '42',
- '-1',
- '-999',
- ])
+ it('throws for arrays with empty strings', () => {
+ expect(() => PARAM_PARSER_INT.get(['1', ''])).toThrow()
+ expect(() => PARAM_PARSER_INT.get(['', '2'])).toThrow()
})
- it('handles empty array', () => {
- expect(PARAM_INTEGER_REPEATABLE.set([])).toEqual([])
+ it('throws for arrays with null values', () => {
+ expect(() => PARAM_PARSER_INT.get(['1', null, '3'])).toThrow()
+ expect(() => PARAM_PARSER_INT.get([null])).toThrow()
})
})
-})
-
-describe('PARAM_INTEGER_REPEATABLE_OPTIONAL', () => {
- describe('get()', () => {
- it('returns null for null input', () => {
- expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.get(null)).toBe(null)
- })
-
- it('parses array of integer values', () => {
- expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.get(['0', '42', '-1'])).toEqual([
- 0, 42, -1,
- ])
- })
- it('handles empty array', () => {
- expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.get([])).toEqual([])
+ describe('set() - Single Values', () => {
+ it('converts integers to strings', () => {
+ expect(PARAM_PARSER_INT.set(0)).toBe('0')
+ expect(PARAM_PARSER_INT.set(1)).toBe('1')
+ expect(PARAM_PARSER_INT.set(42)).toBe('42')
+ expect(PARAM_PARSER_INT.set(-1)).toBe('-1')
+ expect(PARAM_PARSER_INT.set(-999)).toBe('-999')
+ expect(PARAM_PARSER_INT.set(2147483647)).toBe('2147483647')
})
- it('throws for invalid values in array', () => {
- expect(() =>
- PARAM_INTEGER_REPEATABLE_OPTIONAL.get(['42', 'invalid'])
- ).toThrow()
+ it('returns null for null values', () => {
+ expect(PARAM_PARSER_INT.set(null)).toBe(null)
})
})
- describe('set()', () => {
- it('returns null for null input', () => {
- expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.set(null)).toBe(null)
- })
-
- it('converts array of integers to strings', () => {
- expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.set([0, 42, -1])).toEqual([
+ describe('set() - Array Values', () => {
+ it('converts arrays of integers to arrays of strings', () => {
+ expect(PARAM_PARSER_INT.set([0, 1, 42, -1, -999])).toEqual([
'0',
+ '1',
'42',
'-1',
+ '-999',
])
+ expect(PARAM_PARSER_INT.set([2147483647])).toEqual(['2147483647'])
})
- it('handles empty array', () => {
- expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.set([])).toEqual([])
- })
- })
-})
-
-describe('PARAM_PARSER_INT', () => {
- describe('get()', () => {
- it('handles single integer values', () => {
- expect(PARAM_PARSER_INT.get('0')).toBe(0)
- expect(PARAM_PARSER_INT.get('42')).toBe(42)
- expect(PARAM_PARSER_INT.get('-1')).toBe(-1)
- })
-
- it('handles null values', () => {
- expect(PARAM_PARSER_INT.get(null)).toBe(null)
- })
-
- it('handles array values', () => {
- expect(PARAM_PARSER_INT.get(['0', '42', '-1'])).toEqual([0, 42, -1])
- expect(PARAM_PARSER_INT.get([])).toEqual([])
- })
-
- it('throws for invalid single values', () => {
- expect(() => PARAM_PARSER_INT.get('invalid')).toThrow()
- expect(() => PARAM_PARSER_INT.get('1.5')).toThrow()
- })
-
- it('throws for invalid array values', () => {
- expect(() => PARAM_PARSER_INT.get(['1', 'invalid'])).toThrow()
- expect(() => PARAM_PARSER_INT.get(['1', '2.5'])).toThrow()
- })
- })
-
- describe('set()', () => {
- it('handles single integer values', () => {
- expect(PARAM_PARSER_INT.set(0)).toBe('0')
- expect(PARAM_PARSER_INT.set(42)).toBe('42')
- expect(PARAM_PARSER_INT.set(-1)).toBe('-1')
- })
-
- it('handles null values', () => {
- expect(PARAM_PARSER_INT.set(null)).toBe(null)
- })
-
- it('handles array values', () => {
- expect(PARAM_PARSER_INT.set([0, 42, -1])).toEqual(['0', '42', '-1'])
+ it('handles empty arrays', () => {
expect(PARAM_PARSER_INT.set([])).toEqual([])
})
})
import { miss } from '../errors'
import { ParamParser } from './types'
-export const PARAM_INTEGER_SINGLE = {
+const PARAM_INTEGER_SINGLE = {
get: (value: string | null) => {
const num = Number(value)
if (value && Number.isInteger(num)) {
set: (value: number) => String(value),
} satisfies ParamParser<number, string | null>
-export const PARAM_INTEGER_OPTIONAL = {
- get: (value: string | null) =>
- value == null ? null : PARAM_INTEGER_SINGLE.get(value),
- set: (value: number | null) =>
- value != null ? PARAM_INTEGER_SINGLE.set(value) : null,
-} satisfies ParamParser<number | null, string | null>
-
-export const PARAM_INTEGER_REPEATABLE = {
+const PARAM_INTEGER_REPEATABLE = {
get: (value: (string | null)[]) => value.map(PARAM_INTEGER_SINGLE.get),
set: (value: number[]) => value.map(PARAM_INTEGER_SINGLE.set),
} satisfies ParamParser<number[], (string | null)[]>
-export const PARAM_INTEGER_REPEATABLE_OPTIONAL = {
- get: (value: string[] | null) =>
- value == null ? null : PARAM_INTEGER_REPEATABLE.get(value),
- set: (value: number[] | null) =>
- value != null ? PARAM_INTEGER_REPEATABLE.set(value) : null,
-} satisfies ParamParser<number[] | null, string[] | null>
-
/**
* Native Param parser for integers.
*
* @see MatcherPattern
*/
export interface ParamParser<
- TOut = MatcherQueryParamsValue,
- TIn extends MatcherQueryParamsValue = MatcherQueryParamsValue,
+ // type of the param after parsing as exposed in `route.params`
+ TParam = MatcherQueryParamsValue,
+ // this is the most permissive type that can be passed to get and set, it's from the query
+ // path params stricter as they do not allow `null` within an array or `undefined`
+ TUrlParam = MatcherQueryParamsValue,
+ // the type that can be passed as a location when navigating: `router.push({ params: { }})`
+ // it's sometimes for more permissive than TParam, for example allowing nullish values
+ TParamRaw = TParam,
> {
- get?: (value: NoInfer<TIn>) => TOut
- set?: (value: NoInfer<TOut>) => TIn
+ get?: (value: NoInfer<TUrlParam>) => TParam
+ set?: (value: TParamRaw) => TUrlParam
}
+/**
+ * Defines a path param parser.
+ *
+ * @param parser - the parser to define. Will be returned as is.
+ *
+ * @see {@link defineQueryParamParser}
+ * @see {@link defineParamParser}
+ */
+/*! #__NO_SIDE_EFFECTS__ */
+export function definePathParamParser<
+ TParam,
+ // path params are parsed by the router as these
+ // we use extend to allow infering a more specific type
+ TUrlParam extends string | string[] | null,
+ // we can allow pushing with extra values
+ TParamRaw,
+>(parser: Required<ParamParser<TParam, TUrlParam, TParamRaw>>) {
+ return parser
+}
+
+/**
+ * Defines a query param parser. Note that query params can also be used as
+ * path param parsers.
+ *
+ * @param parser - the parser to define. Will be returned as is.
+ *
+ * @see {@link definePathParamParser}
+ * @see {@link defineParamParser}
+ */
+/*! #__NO_SIDE_EFFECTS__ */
+export function defineQueryParamParser<
+ TParam,
+ // we can allow pushing with extra values
+ TParamRaw = TParam,
+>(parser: Required<ParamParser<TParam, MatcherQueryParamsValue, TParamRaw>>) {
+ return parser
+}
+
+/**
+ * Alias for {@link defineQueryParamParser}. Implementing a param parser like this
+ * works for path, query, and hash params.
+ *
+ * @see {@link defineQueryParamParser}
+ * @see {@link definePathParamParser}
+ */
+/*! #__NO_SIDE_EFFECTS__ */
+export const defineParamParser = defineQueryParamParser
+
// TODO: I wonder if native param parsers should follow this or similar
// these parsers can be used for both query and path params
// export type ParamParserBoth<T> = ParamParser<T | T[] | null>
/**
* @internal
*/
-export type _Simplify<T> = { [K in keyof T]: T[K] }
+export type Simplify<T> = { [K in keyof T]: T[K] } & {}
/**
* @internal