belgattitude/httpx

View on GitHub
packages/dsn-parser/README.md

Summary

Maintainability
Test Coverage
# @httpx/dsn-parser

DSN & JDBC string parser with query params support in a light ([~750B](#bundle-size)) and modern package.

[![npm](https://img.shields.io/npm/v/@httpx/dsn-parser?style=for-the-badge&label=Npm&labelColor=444&color=informational)](https://www.npmjs.com/package/@httpx/dsn-parser)
[![changelog](https://img.shields.io/static/v1?label=&message=changelog&logo=github&style=for-the-badge&labelColor=444&color=informational)](https://github.com/belgattitude/httpx/blob/main/packages/dsn-parser/CHANGELOG.md)
[![codecov](https://img.shields.io/codecov/c/github/belgattitude/httpx?logo=codecov&label=Unit&flag=httpx-dsn-parser-unit&style=for-the-badge&labelColor=444)](https://app.codecov.io/gh/belgattitude/httpx/tree/main/packages%2Fdsn-parser)
[![bundles](https://img.shields.io/static/v1?label=&message=cjs|esm@treeshake&logo=webpack&style=for-the-badge&labelColor=444&color=informational)](https://github.com/belgattitude/httpx/blob/main/packages/dsn-parser/.size-limit.cjs)
[![node](https://img.shields.io/static/v1?label=Node&message=18%2b&logo=node.js&style=for-the-badge&labelColor=444&color=informational)](#compatibility)
[![browserslist](https://img.shields.io/static/v1?label=Browser&message=%3E96%25&logo=googlechrome&style=for-the-badge&labelColor=444&color=informational)](#compatibility)
[![size](https://img.shields.io/bundlephobia/minzip/@httpx/dsn-parser@latest?label=Max&style=for-the-badge&labelColor=444&color=informational)](https://bundlephobia.com/package/@httpx/dsn-parser@latest)
[![downloads](https://img.shields.io/npm/dm/@httpx/dsn-parser?style=for-the-badge&labelColor=444)](https://www.npmjs.com/package/@httpx/dsn-parser)
[![license](https://img.shields.io/npm/l/@httpx/dsn-parser?style=for-the-badge&labelColor=444)](https://github.com/belgattitude/httpx/blob/main/LICENSE)

## Install

```bash
$ npm install @httpx/dsn-parser
$ yarn add @httpx/dsn-parser
$ pnpm add @httpx/dsn-parser
```

## Features

- 👉  Parse individual fields (ie: `driver`, `user`, `password`, `host`...)
- 🖖  Handle query string parameters and converts to boolean and numeric values.
- 🦄  Handle [special characters like](#why--in-password-matters) `/`, `:`... in the password (some libs won't).
- 🚀  Error with indicative message / reasons (discriminated union or throwable).
- 🛡️  Don't leak passwords in the error message.
- 🙏  Assertion and typeguard helpers
- 🤗  Ecosystem friendly (ie: [zod integration](#zod-integration-example)).

## Documentation

👉 [Official website](https://belgattitude.github.io/httpx/dsn-parser) or [Github Readme](https://github.com/belgattitude/httpx/tree/main/packages/dsn-parser#readme)

## Usage

### parseDsnOrThrow

Usage with exceptions

```typescript
import { parseDsnOrThrow } from "@httpx/dsn-parser";

const dsn = "redis://user:p@/ssword@localhost:6379/0?ssl=true";

try {
  const parsedDsn = parseDsnOrThrow(dsn);
  assert.deepEqual(parsedDsn, {
    driver: "redis",
    pass: "p@/ssword",
    host: "localhost",
    user: "user",
    port: 6379,
    db: "0",
    params: {
      ssl: true,
    },
  });
} catch (e) {
  // example:
  // e -> Error("Can't parse dsn: Invalid port: 12345678 (INVALID_PORT)")
}
```

### parseDsn

Usage with discriminated union.

```typescript
import { parseDsn } from "@httpx/dsn-parser";

const dsn = "redis://user:p@/ssword@localhost:6379/0?ssl=true";

const parsed = parseDsn(dsn);

if (parsed.success) {
  assert.deepEqual(parsed.value, {
    driver: "redis",
    pass: "p@/ssword",
    host: "localhost",
    user: "user",
    port: 6379,
    db: "0",
    params: {
      ssl: true,
    },
  });
} else {
  assert.deepEqual(parsed, {
    success: false,
    // Reasons might vary
    reason: "INVALID_PORT",
    message: "Invalid http port: 12345678",
  });
}
```

### Options

```typescript
import { parseDsn, type ParseDsnOptions } from "@httpx/dsn-parser";

const dsn = "mySql://localhost:6379/db";
const parsed = parseDsn(dsn, {
  lowercaseDriver: true,
  // Overrides, allows to force some values (ParseDsnOptions)
  overrides: {
    db: "db3",
    port: undefined,
  },
});

assert.deepEqual(parsed.value, {
  driver: "mysql",
  host: "localhost",
  db: "db3",
});
```

| Params            | Type                   | Description                               |
| ----------------- | ---------------------- | ----------------------------------------- |
| `lowercaseDriver` | `boolean`              | Driver name in lowercase, default `false` |
| `overrides`       | `ParseDsnOptions`      | Overrides allows to force specific values |

## Utilities

### Typeguard

```typescript
import { isParsableDsn, type ParsableDsn } from "@httpx/dsn-parser";

const dsn = "postgresql://localhost:6379/db";

if (isParsableDsn(dsn)) {
  // known to be ParsableDsn
}
```

### Assertion

```typescript
import { assertParsableDsn, ParsableDsn } from "@httpx/dsn-parser";

try {
  assertParsableDsn("redis:/");
  // Type is narrowed to string (ParsableDsn) if it
  // didn't throw.
} catch (e) {
  assert.equal(e.message, "Cannot parse DSN (PARSE_ERROR)");
}
```

### ParsableDsn

ParsableDsn is a weak opaque type.  

```typescript
declare const tag: unique symbol;
export type WeakOpaqueContainer<Token> = {
  readonly [tag]: Token;
};
export type ParsableDsn = string & WeakOpaqueContainer<'ParsableDsn'>;
```

It can be used to enforce that the `isParsableDsn` or `assertParsableDsn` have been
used before usage. 

```typescript
import type { ParsableDsn } from "./dsn-parser.type";
import { assertParsableDsn } from "./assert-parsable-dsn";

// to opt-in, just change the dsn param type to `ParsableDsn` instead of `string` 
const fnWithWeakOpaqueType = (dsn: ParsableDsn) => {
    // by explictly requiring a ParsableDsn, we can rely on typescript
    // to be sure that a validation has occured before (isParsableDsn or assertParsableDsn)  
}

fnWithWeakOpaqueType('redis://localhost');  // ❌ typescript will complain

const dsn = 'redis://localhost';
assertParsableDsn(dsn);
fnWithWeakOpaqueType(dsn);  // ✅ working cause it was checked before

```

> PS: WeakOpaque usage is totally optional, a nice to have if applicable

### convertJdbcToDsn

Utility to convert [jdbc](https://learn.microsoft.com/en-us/sql/connect/jdbc/building-the-connection-url?view=sql-server-ver15) dsn.
Useful for prisma using [sqlserver](https://www.prisma.io/docs/concepts/database-connectors/sql-server#connection-details).

```typescript
import { convertJdbcToDsn } from "@httpx/dsn-parser";

const jdbcDsn =
  "sqlserver://localhost:1433;database=my-db;authentication=default;user=sa;password=pass03$;encrypt=true;trustServerCertificate=true";

const dsn = convertJdbcToDsn(jdbc);

// -> 'sqlserver://localhost:1433?database=my-db&authentication=default&user=sa&password=pass03$&encrypt=true&trustServerCertificate=true'
```


## DSN parsing

### Requirements

The minimum requirement for dsn parsing is to have a **host** and
a **driver** _`(/[a-z0-9]+/i)`_ defined. All other options are optional.

```typescript
export type ParsedDsn = {
  driver: string;
  host: string;
  user?: string;
  pass?: string;
  port?: number;
  db?: string;
  /** Query params */
  params?: Record<string, number | string | boolean>;
};
```

### DSN support

Things like:

```typescript
const validExamples = [
  "postgresql://postgres:@localhost:5432/prisma-db",
  "redis://us_er-name:P@ass-_:?/ssw/rd@www.example.com:6379/0?cache=true",
  //...
];
```

should work.

### Query parameters

Simple query parameters are supported (no arrays, no nested). For convenience
it will cast `'true'` and `'false'` to **booleans**,
parse numeric string to **numbers** if possible. When a query
parameter does not contain a value, it will be returned as `true`.

```typescript
const dsn = "redis://host?index=1&compress=false&ssl";
const parsed = parseDsn(dsn);
assert.deepEqual(parsed.value.params, {
  index: 1,
  compress: false,
  ssl: true,
});
```

### Portability

`parseDsn` won't make any assumptions on default values _(i.e: default port for mysql...)_.

### Validation

`parseDsn` wraps its result in a [discriminated union](https://basarat.gitbook.io/typescript/type-system/discriminated-unions)
to allow the retrieval of validation errors. No `try... catch`needed and full typescript support.

Reason codes are guaranteed in semantic versions and messages does not
leak credentials

```typescript
const parsed = parseDsn("redis://localhost:65636");
assert.deepEqual(parsed, {
  success: false,
  reason: "INVALID_PORT",
  message: "Invalid port: 65636",
});
if (!parsed.success) {
  // `success: false` narrows the type to
  // {
  //   reason: 'PARSE_ERROR'|'INVALID_ARGUMENT'|...
  //   message: string
  // }
  log(parsed.reason);
}
```

| Reason               | Message                 | Comment         |
| -------------------- | ----------------------- | --------------- |
| `'PARSE_ERROR'`      | `Cannot parse DSN`      | _Regexp failed_ |
| `'INVALID_ARGUMENT'` | `DSN must be a string`  |                 |
| `'EMPTY_DSN'`        | `DSN cannot be empty`   |                 |
| `'INVALID_PORT'`     | `Invalid port: ${port}` | [1-65535]       |

## Ecosystem

### Zod integration example

The `isParsableDsn` can be easily plugged into zod custom validation. Example:

```typescript
import { z } from "zod";
import { isParsableDsn, type ParsableDsn } from "@httpx/dsn-parser";

export const serverEnvSchema = z.object({
  PRISMA_DATABASE_URL: z.custom<ParsableDsn>(
    (dsn) => isParsableDsn(dsn),
    "Invalid DSN format."
  ),
});

serverEnvSchema.parse(process.env);
```

## Faq

### Why '/' in password matters

Some libs (ioredis...) still might fail parsing a password containing '/',
unfortunately they're pretty convenient, i.e:

```bash
openssl rand 60 | openssl base64 -A

# YFUXIG9INIK7dFyE9aXtxLmjmnYL0zv6YluBJJbC6alKIBema/MwEGy3VUpx0oLAvWHUFGFMagAdLxrB
```

## Bundle size

Bundle size is tracked by a [size-limit configuration](https://github.com/belgattitude/httpx/blob/main/packages/dsn-parser/.size-limit.cjs)

| Scenario (esm)                                           | Size (compressed) |
|----------------------------------------------------------|------------------:|
| `import { parseDsn } from '@httpx/dsn-parser`            |            ~ 750B |


## Compatibility

| Level        | CI | Description                                                                                                                                                                                                                                                                                                                                  |
|--------------|----|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|  
| Node         | ✅  | CI for 18.x, 20.x & 22.x.                                                                                                                                                                                                                                                                                                                    |
| Browser      | ✅  | Tested with latest chrome (vitest/playwright)                                                                                                                                                                                                                                                                                            |
| Browserslist | ✅  | [> 95%](https://browserslist.dev/?q=ZGVmYXVsdHMsIGNocm9tZSA%2BPSA5NixmaXJlZm94ID49IDkwLGVkZ2UgPj0gMTksc2FmYXJpID49IDEyLGlvcyA%2BPSAxMixvcGVyYSA%2BPSA3Nw%3D%3D) on 12/2023. Mins to [Chrome 96+, Firefox 90+, Edge 19+, iOS 12+, Safari 12+, Opera 77+](https://github.com/belgattitude/httpx/blob/main/packages/dsn-parser/.browserslistrc) |
| Edge         | ✅  | Ensured on CI with [@vercel/edge-runtime](https://github.com/vercel/edge-runtime).                                                                                                                                                                                                                                                       | 
| Cloudflare   | ✅  | Ensured with @cloudflare/vitest-pool-workers (see [wrangler.toml](https://github.com/belgattitude/httpx/blob/main/devtools/vitest/wrangler.toml)                                                                                                                                                                                         |
| Typescript | ✅  | TS 5.0 + / [are-the-type-wrong](https://github.com/arethetypeswrong/arethetypeswrong.github.io) checks on CI.                                                                                                                                                                                                                                |
| ES2022     | ✅  | Dist files checked with [es-check](https://github.com/yowainwright/es-check)                                                                                                                                                                                                                                                                 |

> For _older_ browsers: most frontend frameworks can transpile the library (ie: [nextjs](https://nextjs.org/docs/app/api-reference/next-config-js/transpilePackages)...)

## Contributors

Contributions are warmly appreciated. Have a look to the [CONTRIBUTING](https://github.com/belgattitude/httpx/blob/main/CONTRIBUTING.md) document.

## Sponsors

If my OSS work brightens your day, let's take it to new heights together!
[Sponsor](<[sponsorship](https://github.com/sponsors/belgattitude)>), [coffee](<(https://ko-fi.com/belgattitude)>),
or star – any gesture of support fuels my passion to improve. Thanks for being awesome! 🙏❤️

### Special thanks to

<table>
  <tr>
    <td>
      <a href="https://www.jetbrains.com/?ref=belgattitude" target="_blank">
         <img width="65" src="https://asset.brandfetch.io/idarKiKkI-/id53SttZhi.jpeg" alt="Jetbrains logo" />
      </a>
    </td>
    <td>
      <a href="https://www.embie.be/?ref=belgattitude" target="_blank">
        <img width="65" src="https://avatars.githubusercontent.com/u/98402122?s=200&v=4" alt="Jetbrains logo" />    
      </a>
    </td>
  </tr>
  <tr>
    <td align="center">
      <a href="https://www.jetbrains.com/?ref=belgattitude" target="_blank">JetBrains</a>
    </td>
    <td align="center">
      <a href="https://www.embie.be/?ref=belgattitude" target="_blank">Embie.be</a>
    </td>
   </tr>
</table>

## License

MIT © [belgattitude](https://github.com/belgattitude) and contributors.