Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .changeset/smart-pens-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@naverpay/nurl": minor
---

feat: add `NURL.match()` and `NURL.mask()` static methods

- `NURL.match(url, pattern)`: Match URL path against a pattern with dynamic segments and extract parameters
- `NURL.mask(url, options)`: Mask sensitive path parameters in a URL for logging purposes
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
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
"default": "./dist/cjs/index.js"
}
},
"./utils": {
"import": {
"types": "./dist/esm/utils.d.mts",
"default": "./dist/esm/utils/index.mjs"
},
"require": {
"types": "./dist/cjs/utils.d.ts",
"default": "./dist/cjs/utils/index.js"
}
},
"./package.json": "./package.json"
},
"files": [
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
11 changes: 10 additions & 1 deletion src/nurl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {decode, encode} from './punycode'
import {mask, MaskOptions, match as matchUrlPattern} from './utils'
import {
extractPathKey,
getDynamicPaths,
Expand All @@ -8,7 +9,7 @@ import {
refineQueryWithPathname,
convertQueryToArray,
Query,
} from './utils'
} from './utils/internal'

interface URLOptions
extends Partial<
Expand Down Expand Up @@ -472,4 +473,12 @@ export default class NURL implements URL {
.map((segment) => decode(segment.replace(this.punycodePrefix, '')))
.join('.')
}

static match(url: string, pattern: string) {
return matchUrlPattern(url, pattern)
}

static mask(url: string, options: MaskOptions) {
return mask(url, options)
}
}
Loading