Skip to content

Commit

Permalink
Merge pull request #38 from jvalerodev/main
Browse files Browse the repository at this point in the history
feat: convert ULID to UUID and vice versa
  • Loading branch information
perry-mitchell authored Feb 5, 2024
2 parents 9814998 + 780095f commit fc2ec64
Show file tree
Hide file tree
Showing 8 changed files with 203 additions and 5 deletions.
4 changes: 4 additions & 0 deletions source/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
export const MAX_ULID = "7ZZZZZZZZZZZZZZZZZZZZZZZZZ";
export const MIN_ULID = "00000000000000000000000000";

export const ULID_REGEX = /^[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}$/;
export const UUID_REGEX = /^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/;
export const B32_CHARACTERS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
58 changes: 58 additions & 0 deletions source/crockford.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Code from https://github.com/devbanana/crockford-base32/blob/develop/src/index.ts
import { B32_CHARACTERS } from "./constants.js";

export function crockfordEncode(input: Uint8Array): string {
const output: number[] = [];
let bitsRead = 0;
let buffer = 0;

const reversedInput = new Uint8Array(input.slice().reverse());

for (const byte of reversedInput) {
buffer |= byte << bitsRead;
bitsRead += 8;

while (bitsRead >= 5) {
output.unshift(buffer & 0x1f);
buffer >>>= 5;
bitsRead -= 5;
}
}

if (bitsRead > 0) {
output.unshift(buffer & 0x1f);
}

return output.map(byte => B32_CHARACTERS.charAt(byte)).join("");
}

export function crockfordDecode(input: string): Uint8Array {
const sanitizedInput = input.toUpperCase().split("").reverse().join("");

const output: number[] = [];
let bitsRead = 0;
let buffer = 0;

for (const character of sanitizedInput) {
const byte = B32_CHARACTERS.indexOf(character);

if (byte === -1) {
throw new Error(`Invalid base 32 character found in string: ${character}`);
}

buffer |= byte << bitsRead;
bitsRead += 5;

while (bitsRead >= 8) {
output.unshift(buffer & 0xff);
buffer >>>= 8;
bitsRead -= 8;
}
}

if (bitsRead >= 5 || buffer > 0) {
output.unshift(buffer & 0xff);
}

return new Uint8Array(output);
}
4 changes: 3 additions & 1 deletion source/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ export {
detectPRNG,
isValid,
monotonicFactory,
ulid
ulid,
ulidToUUID,
uuidToULID
} from "./ulid.js";
export * from "./constants.js";
export * from "./types.js";
2 changes: 2 additions & 0 deletions source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export type PRNG = () => number;
export type ULID = string;

export type ULIDFactory = (seedTime?: number) => ULID;

export type UUID = string;
48 changes: 47 additions & 1 deletion source/ulid.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import crypto from "node:crypto";
import { Layerr } from "layerr";
import { PRNG, ULID, ULIDFactory } from "./types.js";
import { PRNG, ULID, ULIDFactory, UUID } from "./types.js";
import { crockfordDecode, crockfordEncode } from "./crockford.js";
import { ULID_REGEX, UUID_REGEX } from "./constants.js";

// These values should NEVER change. The values are precisely for
// generating ULIDs.
Expand Down Expand Up @@ -265,3 +267,47 @@ export function ulid(seedTime?: number, prng?: PRNG): ULID {
const seed = isNaN(seedTime) ? Date.now() : seedTime;
return encodeTime(seed, TIME_LEN) + encodeRandom(RANDOM_LEN, currentPRNG);
}

export function ulidToUUID(ulid: string): UUID {
const isValid = ULID_REGEX.test(ulid);

if (!isValid) {
throw new Layerr({ info: { code: "INVALID_ULID", ...ERROR_INFO } }, "Invalid ULID");
}

const uint8Array = crockfordDecode(ulid);
let uuid = Array.from(uint8Array)
.map(byte => byte.toString(16).padStart(2, "0"))
.join("");

uuid =
uuid.substring(0, 8) +
"-" +
uuid.substring(8, 12) +
"-" +
uuid.substring(12, 16) +
"-" +
uuid.substring(16, 20) +
"-" +
uuid.substring(20);

return uuid;
}

export function uuidToULID(uuid: string): ULID {
const isValid = UUID_REGEX.test(uuid);

if (!isValid) {
throw new Layerr({ info: { code: "INVALID_UUID", ...ERROR_INFO } }, "Invalid UUID");
}

const uint8Array = new Uint8Array(
uuid
.replace(/-/g, "")
.match(/.{1,2}/g)
.map(byte => parseInt(byte, 16))
);
const ulid = crockfordEncode(uint8Array);

return ulid;
}
30 changes: 29 additions & 1 deletion test/browser-cjs/index.spec.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ const sinon = require("sinon");
const {
MAX_ULID,
MIN_ULID,
ULID_REGEX,
UUID_REGEX,
decodeTime,
detectPRNG,
encodeTime,
fixULIDBase32,
isValid,
monotonicFactory,
ulid
ulid,
ulidToUUID,
uuidToULID
} = require("../../dist/browser/index.cjs");

describe("ulid", function() {
Expand Down Expand Up @@ -225,4 +229,28 @@ describe("ulid", function() {
expect(MAX_ULID).to.equal("7ZZZZZZZZZZZZZZZZZZZZZZZZZ");
});
});

describe("ulid to uuid", function() {
it("should return a valid uuid", function() {
expect(ulidToUUID(ulid())).to.match(UUID_REGEX);
});

it("should throw an error if an invalid ulid is provided", function() {
expect(() => {
ulidToUUID("whatever");
}).to.throw(/Invalid ULID/);
});
});

describe("uuid to ulid", function() {
it("should return a valid ulid", function() {
expect(uuidToULID("8299b5fe-3824-45b6-9bc9-b13ec3b3656c")).to.match(ULID_REGEX);
});

it("should throw an error if an invalid uuid is provided", function() {
expect(() => {
uuidToULID("whatever");
}).to.throw(/Invalid UUID/);
});
});
});
31 changes: 30 additions & 1 deletion test/node-cjs/index.spec.cjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
const { expect } = require("chai");
const sinon = require("sinon");
const { randomUUID } = require("node:crypto");
const {
MAX_ULID,
MIN_ULID,
ULID_REGEX,
UUID_REGEX,
decodeTime,
detectPRNG,
encodeTime,
fixULIDBase32,
isValid,
monotonicFactory,
ulid
ulid,
ulidToUUID,
uuidToULID
} = require("../../dist/node/index.cjs");

describe("ulid", function() {
Expand Down Expand Up @@ -225,4 +230,28 @@ describe("ulid", function() {
expect(MAX_ULID).to.equal("7ZZZZZZZZZZZZZZZZZZZZZZZZZ");
});
});

describe("ulid to uuid", function() {
it("should return a valid uuid", function() {
expect(ulidToUUID(ulid())).to.match(UUID_REGEX);
});

it("should throw an error if an invalid ulid is provided", function() {
expect(() => {
ulidToUUID("whatever");
}).to.throw(/Invalid ULID/);
});
});

describe("uuid to ulid", function() {
it("should return a valid ulid", function() {
expect(uuidToULID(randomUUID())).to.match(ULID_REGEX);
});

it("should throw an error if an invalid uuid is provided", function() {
expect(() => {
uuidToULID("whatever");
}).to.throw(/Invalid UUID/);
});
});
});
31 changes: 30 additions & 1 deletion test/node-esm/index.spec.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { expect } from "chai";
import sinon from "sinon";
import { randomUUID } from "node:crypto";
import {
MAX_ULID,
MIN_ULID,
ULID_REGEX,
UUID_REGEX,
decodeTime,
detectPRNG,
encodeTime,
fixULIDBase32,
isValid,
monotonicFactory,
ulid
ulid,
ulidToUUID,
uuidToULID
} from "../../dist/node/index.js";

describe("ulid", function () {
Expand Down Expand Up @@ -225,4 +230,28 @@ describe("ulid", function () {
expect(MAX_ULID).to.equal("7ZZZZZZZZZZZZZZZZZZZZZZZZZ");
});
});

describe("ulid to uuid", function () {
it("should return a valid uuid", function () {
expect(ulidToUUID(ulid())).to.match(UUID_REGEX);
});

it("should throw an error if an invalid ulid is provided", function () {
expect(() => {
ulidToUUID("whatever");
}).to.throw(/Invalid ULID/);
});
});

describe("uuid to ulid", function () {
it("should return a valid ulid", function () {
expect(uuidToULID(randomUUID())).to.match(ULID_REGEX);
});

it("should throw an error if an invalid uuid is provided", function () {
expect(() => {
uuidToULID("whatever");
}).to.throw(/Invalid UUID/);
});
});
});

0 comments on commit fc2ec64

Please sign in to comment.