Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 84 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ NURL is a powerful URL manipulation library that extends the standard URL class.
### Basic Usage

```javascript
import { NURL } from 'nurl'
import {NURL} from 'nurl'

// Create URL from string
const url1 = new NURL('https://example.com/users/123?name=John')
Expand All @@ -36,9 +36,9 @@ const url2 = new NURL(standardUrl)

// Create URL from custom options object
const url3 = new NURL({
baseUrl: 'https://example.com',
pathname: '/users/:id',
query: { id: '123', name: 'John' }
baseUrl: 'https://example.com',
pathname: '/users/:id',
query: {id: '123', name: 'John'},
})

// Create empty URL
Expand All @@ -49,9 +49,9 @@ const url5 = NURL.create('https://example.com')

// The factory function also works with options object
const url6 = NURL.create({
baseUrl: 'https://example.com',
pathname: '/users/:id',
query: { id: '123', name: 'John' }
baseUrl: 'https://example.com',
pathname: '/users/:id',
query: {id: '123', name: 'John'},
})
```

Expand All @@ -61,13 +61,13 @@ NURL processes dynamic segments in the pathname and replaces them with values fr

```javascript
const url = new NURL({
baseUrl: 'https://api.example.com',
pathname: '/users/:a/posts/[b]/[c]',
query: {
a: '123',
b: '456',
format: 'json'
}
baseUrl: 'https://api.example.com',
pathname: '/users/:a/posts/[b]/[c]',
query: {
a: '123',
b: '456',
format: 'json',
},
})

console.log(url.href)
Expand All @@ -84,6 +84,59 @@ console.log(url.hostname) // xn--bj0bj06e.xn--hq1bm8jm9l
console.log(url.decodedHostname) // 한글.도메인 (in human-readable format)
```

### URL Pattern Matching

NURL supports `NURL.match(url, pattern)` static method to match a URL path against a pattern with dynamic segments:

```tsx
NURL.match('/v1/user/12345/info', '/v1/user/:userId/info')
// → { userId: '12345' }

NURL.match('/v1/friends/SENDMONEY/block/111/222', '/v1/friends/:serviceCode/block/:nidNo/:friendNidNo')
// → { serviceCode: 'SENDMONEY', nidNo: '111', friendNidNo: '222' }

NURL.match('/v1/user/12345', '/v1/admin/:id')
// → null (no match)
```

### Masking Path Parameters

NURL provides `NURL.mask(url, options)` static method to mask sensitive path parameters in a URL for logging purposes:

```tsx
// Default masking (**** with length 4)
NURL.mask('/v1/user/12345/info', {
patterns: ['/v1/user/:userId/info'],
sensitiveParams: ['userId'],
})
// → '/v1/user/****/info'

// Custom mask character and length
NURL.mask('/v1/user/12345/info', {
patterns: ['/v1/user/[userId]/info'],
sensitiveParams: ['userId'],
maskChar: 'X',
maskLength: 6,
})
// → '/v1/user/XXXXXX/info'

// Preserve original value length
NURL.mask('/v1/user/12345/info', {
patterns: ['/v1/user/:userId/info'],
sensitiveParams: ['userId'],
preserveLength: true,
})
// → '/v1/user/*****/info' (5 chars, same as '12345')

// Multiple sensitive params
NURL.mask('/v1/friends/SENDMONEY/block/12345/67890', {
patterns: ['/v1/friends/:serviceCode/block/[nidNo]/:friendNidNo'],
sensitiveParams: ['nidNo', 'friendNidNo'],
preserveLength: true,
})
// → '/v1/friends/SENDMONEY/block/*****/*****' (5 and 5 chars)
```

## API

### `constructor(input?: string | URL | URLOptions)`
Expand Down Expand Up @@ -113,6 +166,23 @@ NURL inherits all properties from the standard URL class:
- `toString()`: Returns the URL as a string
- `toJSON()`: Returns the URL as a JSON representation

### Static Methods

- `NURL.create(input?: string | URL | URLOptions): NURL`
- Factory function to create a NURL instance without the `new` keyword.
- `NURL.canParse(url: string): boolean`
- Checks if the given string can be parsed as a valid URL.
- `NURL.match(url: string, pattern: string): Record<string, string> | null`
- Matches a URL path against a pattern with dynamic segments and returns an object with extracted parameters or `null` if no match.
- `NURL.mask(url: string, options: MaskOptions): string`
- Masks sensitive path parameters in a URL based on the provided options.
- `MaskOptions`:
- `patterns: string[]`: Array of URL patterns with dynamic segments.
- `sensitiveParams: string[]`: Array of path parameters to be masked.
- `maskChar?: string`: Character used for masking (default: `'*'`).
- `maskLength?: number`: Length of the mask (default: `4`).
- `preserveLength?: boolean`: If true, mask length matches original value length (overrides `maskLength`).

## Important Notes

1. NURL's setter methods behave differently from the standard URL. They are designed to consider dynamic segment and query parameter replacement functionality.
Expand Down
135 changes: 134 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {describe, test, expect} from 'vitest'

import NURL from './nurl'
import NURL, {MaskOptions} from './nurl'

const compareNurlWithUrl = ({url, nurl}: {url: URL; nurl: NURL}) => {
expect(nurl.toString()).toBe(url.toString())
Expand Down Expand Up @@ -484,6 +484,139 @@ describe('NURL', () => {
expect(NURL.canParse(input)).toBe(expected)
})
})

describe('NURL.match', () => {
test.each([
['/v1/user/12345/info', '/v1/user/:userId/info', {userId: '12345'}],
['/v1/user/12345/info', '/v1/user/[userId]/info', {userId: '12345'}],
['/v1/user/12345/info', '/v1/user/:123test/info', {'123test': '12345'}],
['/v1/user/12345/info', '/v1/user/[한글]/info', {한글: '12345'}],
[
'/v1/friends/SENDMONEY/block/111/222',
'/v1/friends/:serviceCode/block/[nidNo]/:friendNidNo',
{serviceCode: 'SENDMONEY', nidNo: '111', friendNidNo: '222'},
],
[
'/articles/2023/08/15/my-article',
'/articles/:year/:month/:day/[title]',
{year: '2023', month: '08', day: '15', title: 'my-article'},
],
[
'https://files/documents/report.pdf',
'https://files/:folder/[filename]',
{folder: 'documents', filename: 'report.pdf'},
],
['/example.com/example/path/1234?q1=123&q2=aaa#hash', '/example.com/example/path/:id', {id: '1234'}],
['/example.com/example/path/1234', '/example.com/example/path/:id?q1=123&q2=aaa#hash', {id: '1234'}],
])('should match %s with pattern %s to extract %o', (url, pattern, expected) => {
const result = NURL.match(url, pattern)
expect(result).toEqual(expected)
})

test('should return null when there is no match', () => {
const result = NURL.match('/v1/user/12345/info', '/v1/admin/:adminId/dashboard')
expect(result).toBeNull()
})

test('should return empty object when there are no dynamic segments', () => {
const result = NURL.match('/no/dynamic/segments', '/no/dynamic/segments')
expect(result).toEqual({})
})
})

describe('NURL.mask', () => {
const friendApiPathPatterns = [
'/v1/friends/:serviceCode/:friendNo',
'/v1/friends/:friendNidNo',
'/v1/friends/:serviceCode/favorite',
'/v1/friends/close-friends',
]
const friendApiSensitiveParams = ['friendNo', 'friendNidNo']

test.each([
[
'/v1/user/12345/info',
{patterns: ['/v1/user/:userId/info'], sensitiveParams: ['userId']},
'/v1/user/****/info',
],
[
'/v1/user/12345/info',
{patterns: ['/v1/user/[userId]/info'], sensitiveParams: ['userId'], maskChar: 'X', maskLength: 6},
'/v1/user/XXXXXX/info',
],
[
'/v1/user/12345/info',
{patterns: ['/v1/user/:userId/info'], sensitiveParams: ['userId'], preserveLength: true},
'/v1/user/*****/info',
],
[
'/v1/friends/SENDMONEY/block/12345/67890',
{
patterns: ['/v1/friends/:serviceCode/block/:nidNo/:friendNidNo'],
sensitiveParams: ['nidNo', 'friendNidNo'],
preserveLength: true,
},
'/v1/friends/SENDMONEY/block/*****/*****',
],
[
'https://example.com/v1/friends/SENDMONEY/block/12345/67890?q=test#section',
{
patterns: ['/v1/friends/:serviceCode/block/:nidNo/:friendNidNo'],
sensitiveParams: ['nidNo', 'friendNidNo'],
preserveLength: true,
},
'https://example.com/v1/friends/SENDMONEY/block/*****/*****?q=test#section',
],
[
'/v1/friends/12345678',
{
patterns: friendApiPathPatterns,
sensitiveParams: friendApiSensitiveParams,
preserveLength: true,
},
'/v1/friends/********',
],
[
'/v1/friends/close-friends',
{
patterns: friendApiPathPatterns,
sensitiveParams: friendApiSensitiveParams,
preserveLength: true,
},
'/v1/friends/close-friends',
],
[
'/v1/friends/SENDMONEY/favorite',
{
patterns: friendApiPathPatterns,
sensitiveParams: friendApiSensitiveParams,
preserveLength: true,
},
'/v1/friends/SENDMONEY/favorite',
],
[
'/v1/friends/SENDMONEY/12345',
{
patterns: friendApiPathPatterns,
sensitiveParams: friendApiSensitiveParams,
preserveLength: true,
},
'/v1/friends/SENDMONEY/*****',
],
[
'/user/admin/profile',
{
patterns: ['/user/:id/profile', '/user/admin/:tab'],
sensitiveParams: ['id'],
preserveLength: true,
},
'/user/admin/profile',
],
])('should mask %s with options %o to %s', (url, options: MaskOptions, expected) => {
const result = NURL.mask(url, options)
expect(result).toBe(expected)
})
})
})

describe('Extended functionality', () => {
Expand Down
64 changes: 64 additions & 0 deletions src/nurl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
refineQueryWithPathname,
convertQueryToArray,
Query,
getPathPriority,
} from './utils'

interface URLOptions
Expand All @@ -31,6 +32,14 @@ interface URLOptions
basePath?: string
}

export interface MaskOptions {
patterns: string[]
sensitiveParams: string[]
maskChar?: string
maskLength?: number
preserveLength?: boolean
}

export default class NURL implements URL {
private _href: string = ''
private _protocol: string = ''
Expand Down Expand Up @@ -472,4 +481,59 @@ export default class NURL implements URL {
.map((segment) => decode(segment.replace(this.punycodePrefix, '')))
.join('.')
}

static match(url: string, pattern: string) {
if (!NURL.canParse(url) || !NURL.canParse(pattern)) {
return null
}

const urlSegments = url.split(/[?#]/)[0]?.split('/').filter(Boolean) || []
const patternSegments = pattern.split(/[?#]/)[0]?.split('/').filter(Boolean) || []

if (urlSegments.length !== patternSegments.length) {
return null
}

const params: Record<string, string> = {}

for (let i = 0; i < patternSegments.length; i++) {
const patternSegment = patternSegments[i]
const urlSegment = urlSegments[i]

if (isDynamicPath(patternSegment)) {
const pathKey = extractPathKey(patternSegment)
params[pathKey] = urlSegment
} else if (patternSegment !== urlSegment) {
return null
}
}

return params
}

static mask(
url: string,
{patterns, sensitiveParams, maskChar = '*', maskLength = 4, preserveLength = false}: MaskOptions,
) {
const sortedPatterns = [...patterns].sort((a, b) => (getPathPriority(b) > getPathPriority(a) ? 1 : -1))
for (const pattern of sortedPatterns) {
const urlObj = NURL.create(url)
const matchedParams = NURL.match(urlObj.pathname, pattern)
if (!matchedParams) {
continue
}
sensitiveParams.forEach((sensitiveParam) => {
if (sensitiveParam in matchedParams) {
const originalValue = matchedParams[sensitiveParam]
const lengthToMask = preserveLength ? originalValue.length : maskLength
matchedParams[sensitiveParam] = maskChar.repeat(lengthToMask)
}
})

urlObj.pathname = refinePathnameWithQuery(pattern, matchedParams)
return urlObj.toString()
}

return url
}
}
16 changes: 16 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,19 @@ export function convertQueryToArray(query: Query): string[][] {
return []
})
}

/**
* Get path priority representation, dynamic paths are represented as '1' and static paths as '2'.
* Used for matching the most specific route.
*
* @param {string} pathname
* @returns {string} path priority representation
*
* @example /user/:id/profile -> 212
* @example /user/admin/:tab -> 221
*/
export function getPathPriority(pathname: string): string {
const segments = pathname.split('/').filter(Boolean)

return segments.map((segment) => (isDynamicPath(segment) ? '1' : '2')).join('')
}
Loading