All files / src urlUtils.ts

100% Statements 44/44
100% Branches 28/28
100% Functions 8/8
100% Lines 43/43

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160            9x   9x 9x 9x                 98x       86x   86x 155x 4x     151x 7x 17x 2x   15x     144x       86x   86x             90x                     90x             90x   90x 90x   4x     86x 5x     81x 11x     70x       87x   36x   1x   49x   1x                                                                                   107x 107x 90x     107x         107x 107x 107x   107x 1x     85x    
import { Region } from './types'
import { version } from '../package.json'
import { paths } from './generatedApiTypes'
import { SdkError } from './errors/apiErrors'
import { toError } from './errors/toError'
 
const apiVersion = 'v4'
 
const euRegionUrl = 'https://eu.api.fpjs.io/'
const apRegionUrl = 'https://ap.api.fpjs.io/'
const globalRegionUrl = 'https://api.fpjs.io/'
 
type QueryStringScalar = string | number | boolean | null | undefined
 
type QueryStringParameters = Record<string, QueryStringScalar | QueryStringScalar[]> & {
  ii: string
}
 
export function getIntegrationInfo() {
  return `fingerprint-pro-server-node-sdk/${version}`
}
 
function serializeQueryStringParams(params: QueryStringParameters): string {
  const entries: [string, string][] = []
 
  for (const [key, value] of Object.entries(params)) {
    if (value === null || value === undefined) {
      continue
    }
 
    if (Array.isArray(value)) {
      for (const v of value) {
        if (v === null || v === undefined) {
          continue
        }
        entries.push([key, String(v)])
      }
    } else {
      entries.push([key, String(value)])
    }
  }
 
  const urlSearchParams = new URLSearchParams(entries)
 
  return urlSearchParams.toString()
}
 
/**
 * `event_id` in the URL template is the `eventId` argument on the client method.
 */
function argumentName(placeholder: string): string {
  return placeholder.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())
}
 
/**
 * Confines a value to a single URL path segment. `.` is deliberately left unencoded because
 * the Server API does not decode path parameters, and valid parameter values can contain dots.
 *
 * A value of `.` or `..` is rejected: `new URL()` drops such a segment even when the dots are
 * encoded, so it cannot be expressed. See https://url.spec.whatwg.org/#double-dot-path-segment
 */
function encodePathParam(placeholder: string, value: unknown): string {
  const name = argumentName(placeholder)
  // Coerce before comparing, because an untyped caller can pass something that is not a string
  // but stringifies to one. Both conversions throw on values only such a caller could pass:
  // `String` when the value has no primitive representation, `encodeURIComponent` on a lone
  // surrogate. Wrap either as `SdkError` so it does not escape as its own error type.
  let param: string
  let encoded: string
  try {
    // eslint-disable-next-line @typescript-eslint/no-base-to-string -- runtime validation
    param = String(value ?? '')
    encoded = encodeURIComponent(param)
  } catch (cause) {
    throw new SdkError(`${name} is not valid`, undefined, toError(cause))
  }
 
  if (param === '') {
    throw new SdkError(`${name} is not set`)
  }
 
  if (param === '.' || param === '..') {
    throw new SdkError(`${name} is not valid: ${param}`)
  }
 
  return encoded
}
 
function getServerApiUrl(region: Region): string {
  switch (region) {
    case Region.EU:
      return euRegionUrl
    case Region.AP:
      return apRegionUrl
    case Region.Global:
      return globalRegionUrl
    default:
      throw new SdkError('Unsupported region')
  }
}
 
export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace'
 
export interface GetRequestPathOptions {
  path: keyof paths
  method: HttpMethod
  pathParams?: string[]
  queryParams?: Record<string, QueryStringScalar | QueryStringScalar[]>
  region?: Region
}
 
/**
 * Formats a URL for the Fingerprint Server API by replacing placeholders and
 * appending query string parameters.
 *
 * @internal
 *
 * @param {GetRequestPathOptions} options
 * @param {keyof paths} options.path - The path of the API endpoint
 * @param {string[]} [options.pathParams] - Path parameters to be replaced in the path
 * @param {GetRequestPathOptions["queryParams"]} [options.queryParams] - Query string
 *   parameters to be appended to the URL
 * @param {Region} options.region - The region of the API endpoint
 * @param {HttpMethod} options.method - The method of the API endpoint
 *
 * @returns {string} The formatted URL with parameters replaced and query string
 *   parameters appended
 */
export function getRequestPath({
  path,
  pathParams,
  queryParams,
  region,
  // method mention here so that it can be referenced in JSDoc
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  method: _,
}: GetRequestPathOptions): string {
  // Replace each `{placeholder}` with its path parameter. The replacement is a function so
  // that `$&` and friends in a parameter are not read as replacement patterns.
  let index = 0
  const formattedPath = `${apiVersion}${path}`.replace(/{(.*?)}/g, (_, placeholder: string) =>
    encodePathParam(placeholder, pathParams?.[index++])
  )
 
  const queryStringParameters: QueryStringParameters = {
    ...(queryParams ?? {}),
    ii: getIntegrationInfo(),
  }
 
  const url = new URL(getServerApiUrl(region ?? Region.Global))
  url.pathname = formattedPath
  url.search = serializeQueryStringParams(queryStringParameters)
 
  if (url.pathname !== `/${formattedPath}`) {
    throw new SdkError('Invalid path: path changed during normalization')
  }
 
  return url.toString()
}