Skip to content

Commit

Permalink
feat: simplify common redirect url pattern (#33)
Browse files Browse the repository at this point in the history
* feat: simplify common redirect url pattern
  • Loading branch information
zhongliang02 authored Nov 20, 2024
1 parent 5c57c19 commit a4a0a72
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 19 deletions.
26 changes: 19 additions & 7 deletions apps/docs/examples/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,32 @@ Validating a post-login redirect URL provided in a query parameter:
```javascript
import { UrlValidator } from '@opengovsg/starter-kitty-validators/url'

const validator = new RelUrlValidator(window.location.origin)
```

```javascript
const fallbackUrl = '/home'
window.location.pathname = validator.parsePathname(redirectUrl, fallbackUrl)

// alternatively
router.push(validator.parsePathname(redirectUrl, fallbackUrl))
```

For more control you can create the UrlValidator instance yourself and invoke .parse

```javascript
import { UrlValidator } from '@opengovsg/starter-kitty-validators/url'

const validator = new UrlValidator({
whitelist: {
protocols: ['http', 'https', 'mailto'],
hosts: ['open.gov.sg'],
},
})
```

```javascript
try {
router.push(validator.parse(redirectUrl))
} catch (error) {
router.push('/home')
}
...

validator.parse(userInput)
```

Using the validator as part of a Zod schema to validate the URL and fall back to a default URL if the URL is invalid:
Expand Down
14 changes: 14 additions & 0 deletions etc/starter-kitty-validators.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export interface PathValidatorOptions {
basePath: string;
}

// @public
export class RelUrlValidator extends UrlValidator {
constructor(origin: string | URL);
}

// @public
export class UrlValidationError extends Error {
constructor(message: string);
Expand All @@ -45,7 +50,16 @@ export class UrlValidationError extends Error {
// @public
export class UrlValidator {
constructor(options?: UrlValidatorOptions);
parse<T extends string | URL>(url: string, fallbackUrl: T): URL | T;
// (undocumented)
parse(url: string): URL;
// (undocumented)
parse(url: string, fallbackUrl: undefined): URL;
parsePathname<T extends string | URL>(url: string, fallbackUrl: T): string;
// (undocumented)
parsePathname(url: string): string;
// (undocumented)
parsePathname(url: string, fallbackUrl: undefined): string;
}

// @public
Expand Down
2 changes: 1 addition & 1 deletion packages/validators/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opengovsg/starter-kitty-validators",
"version": "1.2.8",
"version": "1.2.9",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
Expand Down
84 changes: 83 additions & 1 deletion packages/validators/src/__tests__/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'

import { OptionsError } from '@/common/errors'
import { createUrlSchema, UrlValidator } from '@/index'
import { createUrlSchema, RelUrlValidator, UrlValidator } from '@/index'
import { UrlValidationError } from '@/url/errors'

describe('UrlValidator with default options', () => {
Expand Down Expand Up @@ -171,6 +171,88 @@ describe('UrlValidator with invalid options', () => {
})
})

describe('RelUrlValidator with string origin', () => {
const validator = new RelUrlValidator('https://a.com')

it('should parse a valid absolute URL', () => {
const url = validator.parse('https://a.com/hello')
expect(url).toBeInstanceOf(URL)
})

it('should throw an error on invalid URL', () => {
expect(() => validator.parse('https://b.com/hello')).toThrow(UrlValidationError)
})

it('should parse a valid relative URL', () => {
const url = validator.parse('hello')
expect(url).toBeInstanceOf(URL)
expect(url.href).toStrictEqual('https://a.com/hello')
})

it('should parse a valid relative URL', () => {
const url = validator.parse('/hello')
expect(url).toBeInstanceOf(URL)
expect(url.href).toStrictEqual('https://a.com/hello')
})

it('should parse a valid relative URL', () => {
const url = validator.parse('/hello?q=3')
expect(url).toBeInstanceOf(URL)
expect(url.href).toStrictEqual('https://a.com/hello?q=3')
})

it('should throw an error when the protocol is not http or https', () => {
expect(() => validator.parse('ftp://a.com')).toThrow(UrlValidationError)
})
})

describe('UrlValidatorOptions.parsePathname', () => {
const validator = new RelUrlValidator('https://a.com')

it('should extract the pathname of a valid URL', () => {
const pathname = validator.parsePathname('hello')
expect(pathname).toStrictEqual('/hello')
})

it('should extract the pathname of a valid URL', () => {
const pathname = validator.parsePathname('/hello')
expect(pathname).toStrictEqual('/hello')
})

it('should extract the pathname of a valid URL', () => {
const pathname = validator.parsePathname('/hello?q=3#123')
expect(pathname).toStrictEqual('/hello')
})

it('should extract the pathname of a valid URL', () => {
const pathname = validator.parsePathname('/hello?q=3#123/what')
expect(pathname).toStrictEqual('/hello')
})

it('should extract the pathname of a valid URL', () => {
const pathname = validator.parsePathname('https://a.com/hello?q=3#123/what')
expect(pathname).toStrictEqual('/hello')
})

it('should extract the pathname of a valid URL', () => {
const pathname = validator.parsePathname('https://a.com/hello/world')
expect(pathname).toStrictEqual('/hello/world')
})

it('should throw an error when the URL is on a different domain', () => {
expect(() => validator.parsePathname('https://b.com/hello/')).toThrow(UrlValidationError)
})

it('should throw an error when the path is a NextJS dynamic path', () => {
expect(() => validator.parsePathname('https://a.com/hello/[id]?id=3')).toThrow(UrlValidationError)
})

it('should fallback to fallbackUrl if it is provided', () => {
const pathname = validator.parsePathname('https://b.com/hello', 'bye')
expect(pathname).toStrictEqual('/bye')
})
})

describe('createUrlSchema', () => {
it('should create a schema with default options', () => {
const schema = createUrlSchema()
Expand Down
87 changes: 77 additions & 10 deletions packages/validators/src/url/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,34 +34,101 @@ export class UrlValidator {
* @public
*/
constructor(options: UrlValidatorOptions = defaultOptions) {
const result = optionsSchema.safeParse({ ...defaultOptions, ...options })
if (result.success) {
this.schema = toSchema(result.data)
return
}
throw new OptionsError(fromError(result.error).toString())
this.schema = createUrlSchema(options)
}

/**
* Parses a URL string.
* Parses a URL string
*
* @param url - The URL to validate
* @throws {@link UrlValidationError} If the URL is invalid
* @throws {@link UrlValidationError} if the URL is invalid.
* @returns The URL object if the URL is valid
*
* @public
* @internal
*/
parse(url: string): URL {
#parse(url: string): URL {
const result = this.schema.safeParse(url)
if (result.success) {
return result.data
}
if (result.error instanceof ZodError) {
throw new UrlValidationError(fromError(result.error).toString())
} else {
// should only be UrlValidationError
throw result.error
}
}

/**
* Parses a URL string with a fallback option.
*
* @param url - The URL to validate
* @param fallbackUrl - The fallback URL to return if the URL is invalid.
* @throws {@link UrlValidationError} if the URL is invalid and fallbackUrl is not provided.
* @returns The URL object if the URL is valid, else the fallbackUrl (if provided).
*
* @public
*/
parse(url: string, fallbackUrl: string | URL): URL
parse(url: string): URL
parse(url: string, fallbackUrl: undefined): URL
parse(url: string, fallbackUrl?: string | URL): URL {
try {
return this.#parse(url)
} catch (error) {
if (error instanceof UrlValidationError && fallbackUrl !== undefined) {
// URL validation failed, return the fallback URL
return this.#parse(fallbackUrl instanceof URL ? fallbackUrl.href : fallbackUrl)
}
// otherwise rethrow
throw error
}
}

/**
* Parses a URL string and returns the pathname with a fallback option.
*
* @param url - The URL to validate and extract pathname from
* @param fallbackUrl - The fallback URL to use if the URL is invalid.
* @throws {@link UrlValidationError} if the URL is invalid and fallbackUrl is not provided.
* @returns The pathname of the URL or the fallback URL
*
* @public
*/
parsePathname(url: string, fallbackUrl: string | URL): string
parsePathname(url: string): string
parsePathname(url: string, fallbackUrl: undefined): string
parsePathname(url: string, fallbackUrl?: string | URL): string {
const parsedUrl = fallbackUrl ? this.parse(url, fallbackUrl) : this.parse(url)
if (parsedUrl instanceof URL) return parsedUrl.pathname
return parsedUrl
}
}

/**
* Parses URLs according to WHATWG standards and validates against a given origin.
*
* @public
*/
export class RelUrlValidator extends UrlValidator {
/**
* Creates a new RelUrlValidator instance which only allows relative URLs.
*
* @param origin - The base origin against which relative URLs will be resolved. Must be a valid absolute URL (e.g., 'https://example.com').
* @throws TypeError If the provided origin is not a valid URL.
*
* @public
*/
constructor(origin: string | URL) {
const urlObject = new URL(origin)
super({
baseOrigin: urlObject.origin,
whitelist: {
protocols: ['http', 'https'],
hosts: [urlObject.host],
},
})
}
}

/**
Expand Down

0 comments on commit a4a0a72

Please sign in to comment.