From: Eduardo San Martin Morote Date: Tue, 26 Aug 2025 13:50:19 +0000 (+0200) Subject: refactor: improve param parser types to also parse null and accept a raw type X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=d9c63f1a8c16ea238b619796dc0d8fe14e70d20f;p=thirdparty%2Fvuejs%2Frouter.git refactor: improve param parser types to also parse null and accept a raw type --- diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts index 62938385..a0a650c3 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts @@ -40,18 +40,6 @@ describe('MatcherPatternQueryParam', () => { ) 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', () => { @@ -98,50 +86,6 @@ describe('MatcherPatternQueryParam', () => { }) }) - 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', () => { @@ -203,40 +147,6 @@ describe('MatcherPatternQueryParam', () => { 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', () => { @@ -323,14 +233,27 @@ describe('MatcherPatternQueryParam', () => { }) 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', () => { @@ -419,14 +342,15 @@ describe('MatcherPatternQueryParam', () => { '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', () => { @@ -444,22 +368,6 @@ describe('MatcherPatternQueryParam', () => { 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', () => { @@ -498,22 +406,6 @@ describe('MatcherPatternQueryParam', () => { 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'], - }) - }) }) }) }) diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts index 18306517..327be6a9 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts @@ -6,6 +6,7 @@ import { 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 @@ -65,7 +66,7 @@ export class MatcherPatternQueryParam // 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 @@ -86,9 +87,16 @@ export class MatcherPatternQueryParam } } + // 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 } diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts index 75608196..79e796f5 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts @@ -4,6 +4,8 @@ import { 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()', () => { @@ -415,4 +417,48 @@ describe('MatcherPatternPathDynamic', () => { 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') + }) + }) }) diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts index 56a9a483..42df251a 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts @@ -1,8 +1,8 @@ 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', () => { @@ -11,7 +11,6 @@ describe('MatcherPatternPathDynamic', () => { { userId: [PATH_PARAM_PARSER_DEFAULTS] }, ['users', 1] ) - expectTypeOf(matcher.match('/users/123')).toEqualTypeOf<{ userId: string | string[] | null }>() @@ -49,11 +48,33 @@ describe('MatcherPatternPathDynamic', () => { }) 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() + expectTypeOf(numberParser.set(0)).toEqualTypeOf() + expectTypeOf(numberParser.set(null)).toEqualTypeOf() + 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, ], }, diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts index 8505d1a6..b45e6ee6 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts @@ -2,13 +2,14 @@ import { identityFn } from '../../../utils' 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. @@ -18,7 +19,8 @@ import { ParamParser } from './param-parsers/types' */ 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. @@ -27,7 +29,7 @@ export interface MatcherPattern< * @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 @@ -37,7 +39,7 @@ export interface MatcherPattern< * @param value - params value to parse * @returns serialized params value */ - build(params: TOut): TIn + build(params: TParamsRaw): TIn } /** @@ -47,7 +49,8 @@ export interface MatcherPattern< 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 {} + TParamsRaw extends MatcherParamsFormatted = TParams, +> extends MatcherPattern {} /** * Allows matching a static path. @@ -69,7 +72,7 @@ export class MatcherPatternPathStatic */ private pathi: string - constructor(private path: string) { + constructor(readonly path: string) { this.pathi = path.toLowerCase() } @@ -89,13 +92,14 @@ export class MatcherPatternPathStatic * 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, + parser?: ParamParser, /** * Is tha param a repeatable param and should be converted to an array @@ -115,9 +119,20 @@ export type MatcherPatternPathDynamic_ParamOptions< type ExtractParamTypeFromOptions = { [K in keyof TParamsOptions]: TParamsOptions[K] extends MatcherPatternPathDynamic_ParamOptions< any, - infer TOut + infer TParam, + any + > + ? TParam + : never +} + +type ExtractLocationParamTypeFromOptions = { + [K in keyof TParamsOptions]: TParamsOptions[K] extends MatcherPatternPathDynamic_ParamOptions< + any, + any, + infer TParamRaw > - ? TOut + ? TParamRaw : never } @@ -136,7 +151,11 @@ export class MatcherPatternPathDynamic< // TODO: | EmptyObject ? // TParamsOptions extends Record, // TParams extends MatcherParamsFormatted = ExtractParamTypeFromOptions -> implements MatcherPatternPath> +> implements + MatcherPatternPath< + ExtractParamTypeFromOptions, + ExtractLocationParamTypeFromOptions + > { /** * Cached keys of the {@link params} object. @@ -158,7 +177,7 @@ export class MatcherPatternPathDynamic< this.paramsKeys = Object.keys(this.params) as Array } - match(path: string): ExtractParamTypeFromOptions { + match(path: string): Simplify> { if ( this.trailingSlash != null && this.trailingSlash === !path.endsWith('/') @@ -196,7 +215,9 @@ export class MatcherPatternPathDynamic< return params } - build(params: ExtractParamTypeFromOptions): string { + build( + params: Simplify> + ): string { let paramIndex = 0 let paramName: keyof TParamsOptions let parser: (TParamsOptions & @@ -290,6 +311,10 @@ export type EmptyParams = Record // TODO: move to matcher-pa /** * Possible values for query params in a matcher. */ -export type MatcherQueryParamsValue = string | null | Array +export type MatcherQueryParamsValue = + | string + | null + | undefined + | Array export type MatcherQueryParams = Record diff --git a/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.spec.ts b/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.spec.ts index 8d597ed6..3d24152a 100644 --- a/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.spec.ts +++ b/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.spec.ts @@ -1,136 +1,108 @@ 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([]) }) }) }) diff --git a/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts b/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts index d1e3dc11..58c8c74f 100644 --- a/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts +++ b/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts @@ -1,9 +1,12 @@ 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() @@ -17,28 +20,22 @@ export const PARAM_BOOLEAN_SINGLE = { throw miss() }, - set: (value: boolean | null) => String(!!value), -} satisfies ParamParser - -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 - -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 + +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 -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 - /** * Native Param parser for booleans. * @@ -53,4 +50,4 @@ export const PARAM_PARSER_BOOL = { Array.isArray(value) ? PARAM_BOOLEAN_REPEATABLE.set(value) : PARAM_BOOLEAN_SINGLE.set(value), -} satisfies ParamParser +} satisfies ParamParser diff --git a/packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.spec.ts b/packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.spec.ts index ca0727b3..2c7fb5e1 100644 --- a/packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.spec.ts +++ b/packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.spec.ts @@ -1,245 +1,131 @@ 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([]) }) }) diff --git a/packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.ts b/packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.ts index 814b63cd..79720483 100644 --- a/packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.ts +++ b/packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.ts @@ -1,7 +1,7 @@ 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)) { @@ -12,25 +12,11 @@ export const PARAM_INTEGER_SINGLE = { set: (value: number) => String(value), } satisfies ParamParser -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 - -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 -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 - /** * Native Param parser for integers. * diff --git a/packages/router/src/experimental/route-resolver/matchers/param-parsers/types.ts b/packages/router/src/experimental/route-resolver/matchers/param-parsers/types.ts index 59aea004..0c051d2b 100644 --- a/packages/router/src/experimental/route-resolver/matchers/param-parsers/types.ts +++ b/packages/router/src/experimental/route-resolver/matchers/param-parsers/types.ts @@ -7,13 +7,67 @@ import { MatcherQueryParamsValue } from '../matcher-pattern' * @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) => TOut - set?: (value: NoInfer) => TIn + get?: (value: NoInfer) => 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>) { + 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>) { + 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 = ParamParser diff --git a/packages/router/src/types/utils.ts b/packages/router/src/types/utils.ts index 34881aca..e3e77655 100644 --- a/packages/router/src/types/utils.ts +++ b/packages/router/src/types/utils.ts @@ -25,7 +25,7 @@ export type _Awaitable = T | PromiseLike /** * @internal */ -export type _Simplify = { [K in keyof T]: T[K] } +export type Simplify = { [K in keyof T]: T[K] } & {} /** * @internal