diff --git a/.changeset/smart-pens-march.md b/.changeset/smart-pens-march.md new file mode 100644 index 0000000..e814d89 --- /dev/null +++ b/.changeset/smart-pens-march.md @@ -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 diff --git a/README.md b/README.md index 2dddee0..a19e2d9 100644 --- a/README.md +++ b/README.md @@ -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') @@ -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 @@ -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'}, }) ``` @@ -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) @@ -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)` @@ -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 | 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. diff --git a/package.json b/package.json index fa0781b..9121931 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/index.test.ts b/src/index.test.ts index 0d17303..636ea59 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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()) @@ -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', () => { diff --git a/src/nurl.ts b/src/nurl.ts index 18aa041..de40308 100644 --- a/src/nurl.ts +++ b/src/nurl.ts @@ -1,4 +1,5 @@ import {decode, encode} from './punycode' +import {mask, MaskOptions, match as matchUrlPattern} from './utils' import { extractPathKey, getDynamicPaths, @@ -8,7 +9,7 @@ import { refineQueryWithPathname, convertQueryToArray, Query, -} from './utils' +} from './utils/internal' interface URLOptions extends Partial< @@ -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) + } } diff --git a/src/utils/external.ts b/src/utils/external.ts new file mode 100644 index 0000000..1e0240f --- /dev/null +++ b/src/utils/external.ts @@ -0,0 +1,65 @@ +import NURL from '../nurl' +import {extractPathKey, getPathPriority, isDynamicPath, refinePathnameWithQuery} from './internal' + +export const match = (url: string, pattern: string): Record | null => { + 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 = {} + + 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 +} + +export interface MaskOptions { + patterns: string[] + sensitiveParams: string[] + maskChar?: string + maskLength?: number + preserveLength?: boolean +} + +export const 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 = 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 +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..eea7f38 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from './external' diff --git a/src/utils.ts b/src/utils/internal.ts similarity index 82% rename from src/utils.ts rename to src/utils/internal.ts index 668f784..82128aa 100644 --- a/src/utils.ts +++ b/src/utils/internal.ts @@ -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('') +} diff --git a/vite.config.mts b/vite.config.mts index f66a3da..2ebc1d1 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -9,6 +9,7 @@ export default createViteConfig({ cwd: __dirname, entry: { index: './src/index.ts', + utils: './src/utils/index.ts', }, outputs: [ {format: 'es', dist: 'dist/esm'},