Skip to content

Commit

Permalink
feat: add option for encoding and decoding (#46)
Browse files Browse the repository at this point in the history
* fix: better encoding of URL parts

* Add options to control encoding

* Add tests

* Move query params and encoding options to constructor

* Update README
  • Loading branch information
troch authored Jan 5, 2020
1 parent 81261fa commit eb8d728
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 91 deletions.
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,30 +69,46 @@ path.build({ id: '123' }) // => '/users/123'

## API

### Constructor

A path instance can be created two ways:

- `new Path(path: string, opts?: object): object`
- `Path.create(path: string, opts?: object): object`

Options available are:

- `'queryParams'`: [options for query parameters](https://github.com/troch/search-params#options)
- `'urlParamsEncoding`, to specify how URL parameters are encoded and decoded:
- `'default':`encodeURIComponent`and`decodeURIComponent`are used but some characters to encode and decode URL parameters, but some characters are preserved when encoding (sub-delimiters:`+`,`:`,`'`,`!`,`,`,`;`,`'\*'`).
- `'uriComponent'`: use `encodeURIComponent` and `decodeURIComponent`
for encoding and decoding URL parameters.
- `'uri'`: use `encodeURI` and `decodeURI for encoding amd decoding
URL parameters.
- `'none'`: no encoding or decoding is performed
- `'legacy'`: the approach for version 5.x and below (not recoomended)

### path.test(path: string, opts?: object): object | null;

Test if the provided path matches the defined path template. Options available are:

- `'caseSensitive'`: whether matching should be case sensitive or not (default to `false`)
- `'strictTrailingSlash'`: whether or not it should strictly match trailing slashes (default to `false`)
- `'queryParams'`: [options for query parameters](https://github.com/troch/search-params#options)

### path.partialTest(path: string, opts?: object): object | null;

Test if the provided path is partially matched (starts with) the defined path template. Options available are:

- `'caseSensitive'`: whether matching should be case sensitive or not (default to `false`)
- `'delimited'`: whether or not a partial match should only be successful if it reaches a delimiter (`/`, `?`, `.` and `;`). Default to `true`.
- `'queryParams'`: [options for query parameters](https://github.com/troch/search-params#options)

### path.build(params?: object, opts?: object): string;

Builds the defined path template with the provided parameters

- `'caseSensitive'`: whether matching should be case sensitive or not (default to `false`)
- `'ignoreConstraints'`: whether or not to ignore parameter constraints (default to `false`)
- `'ignoreSearch'`: whether or not to build query parameters (default to `false`)
- `'caseSensitive'`: whether matching should be case sensitive or not (default to `false`)
- `'queryParams'`: [options for query parameters](https://github.com/troch/search-params#options)

## Related modules

Expand Down
127 changes: 87 additions & 40 deletions src/Path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import {
IOptions,
parse as parseQueryParams
} from 'search-params'

import { URLParamsEncodingType, decodeParam, encodeParam } from './encoding'
import { defaultOrConstrained } from './rules'
import tokenise, { IToken } from './tokeniser'
import tokenise, { Token } from './tokeniser'

const identity = (_: any): any => _
export { URLParamsEncodingType }

const exists = (val: any) => val !== undefined && val !== null

Expand All @@ -30,12 +32,6 @@ const upToDelimiter = (source: string, delimiter?: boolean) => {
return /(\/)$/.test(source) ? source : source + '(\\/|\\?|\\.|;|$)'
}

const encodeSpatParam = (value: string) =>
value
.split('/')
.map(encodeURI)
.join('/')

const appendQueryParam = (
params: Record<string, any>,
param: string,
Expand All @@ -54,22 +50,50 @@ const appendQueryParam = (
return params
}

export interface IPartialTestOptions {
export interface PathOptions {
/**
* Query parameters buiding and matching options, see
* https://github.com/troch/search-params#options
*/
queryParams?: IOptions
/**
* Specifies the method used to encode URL parameters:
* - `'default': `encodeURIComponent` and `decodeURIComponent`
* are used but some characters to encode and decode URL parameters,
* but some characters are preserved when encoding
* (sub-delimiters: `+`, `:`, `'`, `!`, `,`, `;`, `'*'`).
* - `'uriComponent'`: use `encodeURIComponent` and `decodeURIComponent`
* for encoding and decoding URL parameters.
* - `'uri'`: use `encodeURI` and `decodeURI for encoding amd decoding
* URL parameters.
* - `'none'`: no encoding or decoding is performed
* - `'legacy'`: the approach for version 5.x and below (not recoomended)
*/
urlParamsEncoding?: URLParamsEncodingType
}

export interface InternalPathOptions {
queryParams?: IOptions
urlParamsEncoding: URLParamsEncodingType
}

const defaultOptions: InternalPathOptions = {
urlParamsEncoding: 'default'
}

export interface PathPartialTestOptions {
caseSensitive?: boolean
delimited?: boolean
queryParams?: IOptions
}

export interface ITestOptions {
export interface PathTestOptions {
caseSensitive?: boolean
strictTrailingSlash?: boolean
queryParams?: IOptions
}

export interface IBuildOptions {
export interface PathBuildOptions {
ignoreConstraints?: boolean
ignoreSearch?: boolean
queryParams?: IOptions
}

export type TestMatch<
Expand All @@ -78,28 +102,34 @@ export type TestMatch<

export class Path<T extends Record<string, any> = Record<string, any>> {
public static createPath<T extends Record<string, any> = Record<string, any>>(
path: string
path: string,
options?: PathOptions
) {
return new Path<T>(path)
return new Path<T>(path, options)
}

public path: string
public tokens: IToken[]
public tokens: Token[]
public hasUrlParams: boolean
public hasSpatParam: boolean
public hasMatrixParams: boolean
public hasQueryParams: boolean
public options: InternalPathOptions
public spatParams: string[]
public urlParams: string[]
public queryParams: string[]
public params: string[]
public source: string

constructor(path: string) {
constructor(path: string, options?: PathOptions) {
if (!path) {
throw new Error('Missing path in Path constructor')
}
this.path = path
this.options = {
...defaultOptions,
...options
}
this.tokens = tokenise(path)

this.hasUrlParams =
Expand Down Expand Up @@ -133,22 +163,26 @@ export class Path<T extends Record<string, any> = Record<string, any>> {
return this.spatParams.indexOf(name) !== -1
}

public test(path: string, opts?: ITestOptions): TestMatch<T> {
const options = { strictTrailingSlash: false, queryParams: {}, ...opts }
public test(path: string, opts?: PathTestOptions): TestMatch<T> {
const options = {
caseSensitive: false,
strictTrailingSlash: false,
...opts
} as const
// trailingSlash: falsy => non optional, truthy => optional
const source = optTrailingSlash(this.source, options.strictTrailingSlash)
// Check if exact match
const match = this.urlTest(
path,
source + (this.hasQueryParams ? '(\\?.*$|$)' : '$'),
opts?.caseSensitive
options.caseSensitive
)
// If no match, or no query params, no need to go further
if (!match || !this.hasQueryParams) {
return match
}
// Extract query params
const queryParams = parseQueryParams(path, options.queryParams)
const queryParams = parseQueryParams(path, this.options.queryParams)
const unexpectedQueryParams = Object.keys(queryParams).filter(
p => !this.isQueryParam(p)
)
Expand All @@ -166,12 +200,19 @@ export class Path<T extends Record<string, any> = Record<string, any>> {
return null
}

public partialTest(path: string, opts?: IPartialTestOptions): TestMatch<T> {
const options = { delimited: true, queryParams: {}, ...opts }
public partialTest(
path: string,
opts?: PathPartialTestOptions
): TestMatch<T> {
const options = {
caseSensitive: false,
delimited: true,
...opts
} as const
// Check if partial match (start of given path matches regex)
// trailingSlash: falsy => non optional, truthy => optional
const source = upToDelimiter(this.source, options.delimited)
const match = this.urlTest(path, source, opts?.caseSensitive)
const match = this.urlTest(path, source, options.caseSensitive)

if (!match) {
return match
Expand All @@ -181,7 +222,7 @@ export class Path<T extends Record<string, any> = Record<string, any>> {
return match
}

const queryParams = parseQueryParams(path, options.queryParams)
const queryParams = parseQueryParams(path, this.options.queryParams)

Object.keys(queryParams)
.filter(p => this.isQueryParam(p))
Expand All @@ -190,13 +231,14 @@ export class Path<T extends Record<string, any> = Record<string, any>> {
return match
}

public build(params: Record<string, any> = {}, opts?: IBuildOptions): string {
public build(params: T = {} as T, opts?: PathBuildOptions): string {
const options = {
ignoreConstraints: false,
ignoreSearch: false,
queryParams: {},
urlParamsEncoding: 'default',
...opts
}
} as const
const encodedUrlParams = Object.keys(params)
.filter(p => !this.isQueryParam(p))
.reduce<Record<string, any>>((acc, key) => {
Expand All @@ -205,18 +247,20 @@ export class Path<T extends Record<string, any> = Record<string, any>> {
}

const val = params[key]
const encode = this.isQueryParam(key)
? identity
: this.isSpatParam(key)
? encodeSpatParam
: encodeURI
const isSpatParam = this.isSpatParam(key)

if (typeof val === 'boolean') {
acc[key] = val
} else if (Array.isArray(val)) {
acc[key] = val.map(encode)
acc[key] = val.map(v =>
encodeParam(v, this.options.urlParamsEncoding, isSpatParam)
)
} else {
acc[key] = encode(val)
acc[key] = encodeParam(
val,
this.options.urlParamsEncoding,
isSpatParam
)
}

return acc
Expand Down Expand Up @@ -274,24 +318,24 @@ export class Path<T extends Record<string, any> = Record<string, any>> {
sparams[paramName] = params[paramName]
return sparams
}, {})
const searchPart = buildQueryParams(searchParams, options.queryParams)
const searchPart = buildQueryParams(searchParams, this.options.queryParams)

return searchPart ? base + '?' + searchPart : base
}

private getParams(type: string | RegExp): string[] {
const predicate =
type instanceof RegExp
? (t: IToken) => type.test(t.type)
: (t: IToken) => t.type === type
? (t: Token) => type.test(t.type)
: (t: Token) => t.type === type

return this.tokens.filter(predicate).map(t => t.val[0])
}

private urlTest(
path: string,
source: string,
caseSensitive = false
caseSensitive: boolean
): TestMatch<T> {
const regex = new RegExp('^' + source, caseSensitive ? '' : 'i')
const match = path.match(regex)
Expand All @@ -304,7 +348,10 @@ export class Path<T extends Record<string, any> = Record<string, any>> {
return match
.slice(1, this.urlParams.length + 1)
.reduce<Record<string, any>>((params, m, i) => {
params[this.urlParams[i]] = decodeURIComponent(m)
params[this.urlParams[i]] = decodeParam(
m,
this.options.urlParamsEncoding
)
return params
}, {}) as T
}
Expand Down
71 changes: 71 additions & 0 deletions src/encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* We encode using encodeURIComponent but we want to
* preserver certain characters which are commonly used
* (sub delimiters and ':')
*
* https://www.ietf.org/rfc/rfc3986.txt
*
* reserved = gen-delims / sub-delims
*
* gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
*
* sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
/ "*" / "+" / "," / ";" / "="
*/

const excludeSubDelimiters = /[^!$'()*+,;|:]/g

export type URLParamsEncodingType =
| 'default'
| 'uri'
| 'uriComponent'
| 'none'
| 'legacy'

export const encodeURIComponentExcludingSubDelims = (segment: string): string =>
segment.replace(excludeSubDelimiters, match => encodeURIComponent(match))

const encodingMethods: Record<
URLParamsEncodingType,
(param: string) => string
> = {
default: encodeURIComponentExcludingSubDelims,
uri: encodeURI,
uriComponent: encodeURIComponent,
none: val => val,
legacy: encodeURI
}

const decodingMethods: Record<
URLParamsEncodingType,
(param: string) => string
> = {
default: decodeURIComponent,
uri: decodeURI,
uriComponent: decodeURIComponent,
none: val => val,
legacy: decodeURIComponent
}

export const encodeParam = (
param: string | number | boolean,
encoding: URLParamsEncodingType,
isSpatParam: boolean
): string => {
const encoder =
encodingMethods[encoding] || encodeURIComponentExcludingSubDelims

if (isSpatParam) {
return String(param)
.split('/')
.map(encoder)
.join('/')
}

return encoder(String(param))
}

export const decodeParam = (
param: string,
encoding: URLParamsEncodingType
): string => (decodingMethods[encoding] || decodeURIComponent)(param)
Loading

0 comments on commit eb8d728

Please sign in to comment.