From 937f0fe10cef6f0c7cb7742f18ca73cfd1e014da Mon Sep 17 00:00:00 2001 From: pfm Date: Sun, 26 Nov 2023 10:19:48 -0600 Subject: [PATCH] Initializing the ShortForm project. ShortForm is an essay oriented discussion platform. --- .gitignore | 4 + .pre-commit-config.yaml | 11 + .scalafmt.conf | 71 ++++++ Makefile | 38 +++ README.md | 5 + build.sbt | 220 ++++++++++++++++++ .../main/scala/gs/shortform/api/ApiMain.scala | 19 ++ .../main/scala/gs/shortform/crypto/Hash.scala | 37 +++ modules/db/src/main/resources/changelog.xml | 12 + .../main/resources/gs/shortform/schema/1.sql | 9 + .../gs/shortform/error/ShortFormError.scala | 5 + .../src/main/java/gs/uuid/UUIDFormat.java | 216 +++++++++++++++++ .../uuid/src/main/scala/gs/uuid/UUID.scala | 114 +++++++++ .../src/test/scala/gs/uuid/UUIDTests.scala | 57 +++++ project/build.properties | 1 + project/plugins.sbt | 2 + 16 files changed, 821 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .scalafmt.conf create mode 100644 Makefile create mode 100644 README.md create mode 100644 build.sbt create mode 100644 modules/api/src/main/scala/gs/shortform/api/ApiMain.scala create mode 100644 modules/crypto/src/main/scala/gs/shortform/crypto/Hash.scala create mode 100644 modules/db/src/main/resources/changelog.xml create mode 100644 modules/db/src/main/resources/gs/shortform/schema/1.sql create mode 100644 modules/error/src/main/scala/gs/shortform/error/ShortFormError.scala create mode 100644 modules/uuid/src/main/java/gs/uuid/UUIDFormat.java create mode 100644 modules/uuid/src/main/scala/gs/uuid/UUID.scala create mode 100644 modules/uuid/src/test/scala/gs/uuid/UUIDTests.scala create mode 100644 project/build.properties create mode 100644 project/plugins.sbt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9143d5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +project/project +project/target +target/ +dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..18cd427 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace +# - repo: https://git.garrity.co/gs/gs-pre-commit-scala +# rev: v0.1.2 +# hooks: +# - id: scalafmt diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..8552831 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,71 @@ +// See: https://github.com/scalameta/scalafmt/tags for the latest tags. +version = 3.7.14 +runner.dialect = scala3 +maxColumn = 80 + +rewrite { + rules = [RedundantBraces, RedundantParens, Imports, SortModifiers] + imports.expand = true + imports.sort = scalastyle + redundantBraces.ifElseExpressions = true + redundantBraces.stringInterpolation = true +} + +indent { + main = 2 + callSite = 2 + defnSite = 2 + extendSite = 4 + withSiteRelativeToExtends = 2 + commaSiteRelativeToExtends = 2 +} + +align { + preset = more + openParenCallSite = false + openParenDefnSite = false +} + +newlines { + implicitParamListModifierForce = [before,after] + topLevelStatementBlankLines = [ + { + blanks = 1 + } + ] + afterCurlyLambdaParams = squash +} + +danglingParentheses { + defnSite = true + callSite = true + ctrlSite = true + exclude = [] +} + +verticalMultiline { + atDefnSite = true + arityThreshold = 2 + newlineAfterOpenParen = true +} + +comments { + wrap = standalone +} + +docstrings { + style = "SpaceAsterisk" + oneline = unfold + wrap = yes + forceBlankLineBefore = true +} + +project { + excludePaths = [ + "glob:**target/**", + "glob:**.metals/**", + "glob:**.bloop/**", + "glob:**.bsp/**", + "glob:**metals.sbt" + ] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2ba3b17 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +SHA = $(shell git rev-parse --short=7 HEAD) +VERSION = $(shell echo "$$(date +'%Y.%-m.%-d')-$(SHA)") + +default: test + +# Emit the calculated shortform version. Note that this version does not account +# for pre-release suffixes (-SNAPSHOT) -- it's the "raw" version. +version: + @echo $(VERSION) + +# Clean up the shortform build. Note that this does NOT delete any releases from +# the `dist` directory. That should be managed manually. +clean: + sbt clean + +# Compile all shortform source code. +compile: + sbt -Dversion="$(VERSION)" compile + +# Run all unit tests. +test: + sbt -Dversion="$(VERSION)" test + +# Run all integration tests. +integration: + sbt -Dversion="$(VERSION)" "db-integration-tests/test" + +# Create a new release distribution of the shortform API. +# On disk, a versioned, compressed tarball will be produced in the `dist` +# directory. +api: + @echo "Releasing shortform-api: $(VERSION)" + @mkdir -p dist + sbt -Drelease=true -Dversion="$(VERSION)" "api / Universal / packageZipTarball" + @cp ./modules/api/target/universal/shortform-api-$(VERSION).tgz dist/ + +# TODO: Build container image using buildkit. +release: api diff --git a/README.md b/README.md new file mode 100644 index 0000000..52bfe04 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Short Form + +The Short Form (`shortform`) project is a web application designed for +presenting and facilitating discussion for essays, articles, or general user to +user interactions. diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..e4e19ae --- /dev/null +++ b/build.sbt @@ -0,0 +1,220 @@ +import java.time.LocalDate +val scala3: String = "3.3.1" + +ThisBuild / organizationName := "garrity software" +ThisBuild / organization := "gs" +ThisBuild / organizationHomepage := Some(url("https://garrity.co/")) +ThisBuild / scalaVersion := scala3 + +//externalResolvers := Seq( +// "Garrity Software Releases" at "https://artifacts.garrity.co/releases" +//) + +val ProjectName: String = "shortform" +val Description: String = "Presentation and discussion platform." + +// Helper for getting properties from `-Dprop=value`. +def getProp[A]( + name: String, + conv: String => A +): Option[A] = + Option(System.getProperty(name)).map(conv) + +// Use `sbt -Dversion=` to provide the version, minus the SNAPSHOT +// modifier. This is the typical approach for producing releases. +val VersionProperty: String = "version" + +def getVersion(): Option[String] = + getProp(VersionProperty, identity) + +// Use `sbt -Drelease=true` to trigger a release build. +val ReleaseProperty: String = "release" + +def getModifier(): String = + if (getProp(ReleaseProperty, _.toBoolean).getOrElse(false)) "" + else "-SNAPSHOT" + +// Use `sbt -Dsha=` to provide a commit SHA. +val ShaProperty: String = "sha" + +def getSha(): String = + getProp(ShaProperty, identity).map(sha => s"-$sha").getOrElse("") + +// Basis of CalVer, used if version is not supplied. +val Today: LocalDate = LocalDate.now() + +// This is the base version of the published artifact. If the build is not a +// release, "-SNAPSHOT" will be appended. +val shortformVersion: String = + getVersion() + .map(v => s"$v${getModifier()}") + .getOrElse( + s"${Today.getYear()}.${Today.getMonthValue()}.${Today + .getDayOfMonth()}${getSha()}${getModifier()}" + ) + +// shortform does not publish any code artifacts. +val sharedSettings = Seq( + scalaVersion := scala3, + version := shortformVersion, + publish / skip := true, + publishLocal / skip := true, + publishArtifact := false +) + +// All tests fork. These settings define all shared test dependencies. +lazy val testSettings = Seq( + Test / fork := true, + libraryDependencies ++= Seq( + "org.scalameta" %% "munit" % "1.0.0-M10" % Test + ) +) + +lazy val deps = new { + val SkunkCore: ModuleID = + "org.tpolecat" %% "skunk-core" % "1.0.0-M1" + + val ScalacCompatAnnotation: ModuleID = + "org.typelevel" %% "scalac-compat-annotation" % "0.1.2" + + val CatsEffect: ModuleID = + "org.typelevel" %% "cats-effect" % "3.5.2" +} + +lazy val testDeps = new { + val TestContainersMunit: ModuleID = + "com.dimafeng" %% "testcontainers-scala-munit" % "0.41.0" % Test + + val TestContainersPostgresql: ModuleID = + "com.dimafeng" %% "testcontainers-scala-postgresql" % "0.41.0" % Test + + val Liquibase: ModuleID = + "org.liquibase" % "liquibase-core" % "4.24.0" % Test + + val Postgresql: ModuleID = + "org.postgresql" % "postgresql" % "42.6.0" % Test +} + +lazy val shortform = (project in file(".")) + .aggregate( + uuid, + error, + crypto, + model, + db, + api + ) + .settings(sharedSettings) + .settings(name := "shortform") + +lazy val uuid = project + .in(file("modules/uuid")) + .settings(name := s"$ProjectName-uuid") + .settings(sharedSettings) + .settings(testSettings) + .settings( + libraryDependencies ++= Seq( + "com.fasterxml.uuid" % "java-uuid-generator" % "4.1.1" + ) + ) + +lazy val error = project + .in(file("modules/error")) + .settings(name := s"$ProjectName-error") + .settings(sharedSettings) + .settings(testSettings) + .settings( + libraryDependencies ++= Seq( + ) + ) + +lazy val crypto = project + .in(file("modules/crypto")) + .dependsOn(uuid, error) + .settings(name := s"$ProjectName-crypto") + .settings(sharedSettings) + .settings(testSettings) + .settings( + libraryDependencies ++= Seq( + ) + ) + +lazy val model = project + .in(file("modules/model")) + .dependsOn(uuid, error, crypto) + .settings(name := s"$ProjectName-model") + .settings(sharedSettings) + .settings(testSettings) + .settings( + libraryDependencies ++= Seq( + ) + ) + +lazy val db = project + .in(file("modules/db")) + .dependsOn(uuid, error, crypto, model) + .settings(name := s"$ProjectName-db") + .settings(sharedSettings) + .settings(testSettings) + .settings( + libraryDependencies ++= Seq( + deps.SkunkCore, + deps.ScalacCompatAnnotation + ) + ) + +// Note: This task should NOT be aggregated at the top level. All integration +// tests should be manually invoked. +lazy val `db-integration-tests` = project + .in(file("modules/db-integration-tests")) + .dependsOn(db) + .settings(name := s"$ProjectName-db-integration-tests") + .settings(sharedSettings) + .settings(testSettings) + .settings( + scalacOptions := integrationScalacOptions, + libraryDependencies ++= Seq( + testDeps.TestContainersMunit, + testDeps.TestContainersPostgresql, + testDeps.Liquibase, + testDeps.Postgresql + ) + ) + +lazy val api = project + .in(file("modules/api")) + .enablePlugins(JavaServerAppPackaging) + .dependsOn(uuid, error, crypto, model, db) + .settings(name := s"$ProjectName-api") + .settings(sharedSettings) + .settings(testSettings) + .settings( + run / fork := true, + libraryDependencies ++= Seq( + deps.CatsEffect + ) + ) + +// Set Scala compiler option defaults. +ThisBuild / scalacOptions ++= allScalacOptions + +lazy val allScalacOptions: Seq[String] = Seq( + "-encoding", + "utf8", // Set source file character encoding. + "-deprecation", // Emit warning and location for usages of deprecated APIs. + "-feature", // Emit warning and location for usages of features that should be imported explicitly. + "-explain", // Explain errors in more detail. + "-unchecked", // Enable additional warnings where generated code depends on assumptions. + "-explain-types", // Explain type errors in more detail. + "-Xfatal-warnings", // Fail the compilation if there are any warnings. + "-language:strictEquality", // Enable multiversal equality (require CanEqual) + "-Wunused:implicits", // Warn if an implicit parameter is unused. + "-Wunused:explicits", // Warn if an explicit parameter is unused. + "-Wunused:imports", // Warn if an import selector is not referenced. + "-Wunused:locals", // Warn if a local definition is unused. + "-Wunused:privates", // Warn if a private member is unused. + "-Ysafe-init" // Enable the experimental safe initialization check. +) + +lazy val integrationScalacOptions = allScalacOptions + .filterNot(_ == "-Ysafe-init") diff --git a/modules/api/src/main/scala/gs/shortform/api/ApiMain.scala b/modules/api/src/main/scala/gs/shortform/api/ApiMain.scala new file mode 100644 index 0000000..f0e068c --- /dev/null +++ b/modules/api/src/main/scala/gs/shortform/api/ApiMain.scala @@ -0,0 +1,19 @@ +package gs.shortform.api + +import cats.effect.ExitCode +import cats.effect.IO +import cats.effect.IOApp + +object ApiMain extends IOApp: + + /** Run ShortForm. + * + * @param args + * Command line arguments. + * @return + * 0 if successful, an integer error code otherwise. + */ + def run(args: List[String]): IO[ExitCode] = + IO(println("Running ShortForm API")).as(ExitCode.Success) + +end ApiMain diff --git a/modules/crypto/src/main/scala/gs/shortform/crypto/Hash.scala b/modules/crypto/src/main/scala/gs/shortform/crypto/Hash.scala new file mode 100644 index 0000000..286e4d6 --- /dev/null +++ b/modules/crypto/src/main/scala/gs/shortform/crypto/Hash.scala @@ -0,0 +1,37 @@ +package gs.shortform.crypto + +import java.util.Base64 + +/** Represents any hashed value. All hashed values are Base64 encoded. + * + * @param value + * The Base64 encoded value of the hash. + */ +opaque type Hash = String + +object Hash: + + /** Base64 encode some raw hashed data and store it as a `Hash`. + * + * @param raw + * The raw data. + * @return + */ + def encode(raw: Array[Byte]): Hash = + Base64.getEncoder().encodeToString(raw) + + /** Base64 decode some encoded, hashed data and return the raw hashed bytes. + * + * @param hash + * The hash to decode. + * @return + * The raw hashed bytes. + */ + def decode(hash: Hash): Array[Byte] = + Base64.getDecoder().decode(hash) + + given CanEqual[Hash, Hash] = CanEqual.derived + + extension (hash: Hash) def str: String = hash + +end Hash diff --git a/modules/db/src/main/resources/changelog.xml b/modules/db/src/main/resources/changelog.xml new file mode 100644 index 0000000..b0ed06c --- /dev/null +++ b/modules/db/src/main/resources/changelog.xml @@ -0,0 +1,12 @@ + + + + diff --git a/modules/db/src/main/resources/gs/shortform/schema/1.sql b/modules/db/src/main/resources/gs/shortform/schema/1.sql new file mode 100644 index 0000000..cff9f01 --- /dev/null +++ b/modules/db/src/main/resources/gs/shortform/schema/1.sql @@ -0,0 +1,9 @@ +--liquibase formatted sql + +--changeset pfm:1 +--CREATE TABLE projects( +-- id BIGSERIAL PRIMARY KEY, +-- unique_slug TEXT NOT NULL UNIQUE, +-- display_name TEXT NOT NULL +--); +--rollback DROP TABLE IF EXISTS projects; diff --git a/modules/error/src/main/scala/gs/shortform/error/ShortFormError.scala b/modules/error/src/main/scala/gs/shortform/error/ShortFormError.scala new file mode 100644 index 0000000..87ef4b9 --- /dev/null +++ b/modules/error/src/main/scala/gs/shortform/error/ShortFormError.scala @@ -0,0 +1,5 @@ +package gs.shortform.error + +/** Base trait for all errors in ShortForm. + */ +trait ShortFormError diff --git a/modules/uuid/src/main/java/gs/uuid/UUIDFormat.java b/modules/uuid/src/main/java/gs/uuid/UUIDFormat.java new file mode 100644 index 0000000..48f0d1d --- /dev/null +++ b/modules/uuid/src/main/java/gs/uuid/UUIDFormat.java @@ -0,0 +1,216 @@ +package gs.uuid; + +import java.util.UUID; +import java.util.Arrays; + +/** + * UUID serialization and deserialization. This is a direct copy of Jackson + * Databind (also under the Apache 2.0 license at time of writing) with + * extremely minor modifications to remove dashes from the output and to + * likewise support parsing with/without dashes. + */ +public final class UUIDFormat { + private UUIDFormat() {} + + private final static char[] HEX_CHARS = "0123456789abcdef".toCharArray(); + + + private final static int[] HEX_DIGITS = new int[127]; + + static { + Arrays.fill(HEX_DIGITS, -1); + for (int i = 0; i < 10; ++i) { HEX_DIGITS['0' + i] = i; } + for (int i = 0; i < 6; ++i) { + HEX_DIGITS['a' + i] = 10 + i; + HEX_DIGITS['A' + i] = 10 + i; + } + } + + /** + *

Render the given UUID as a 32-character string using lowercase + * hexadecimal without dashes.

+ * + * @param uuid The UUID to render. + * @return Hexadecimal representation of the UUID. + */ + public static String toHex(final UUID uuid) { + final char[] ch = new char[32]; + + // Example: + // 9bbe7b63-7928-49c8-a14f-67098b6e4642 + final long msb = uuid.getMostSignificantBits(); + + // Handle the first 8 characters (9bbe7b63) + _appendInt((int) (msb >> 32), ch, 0); + + int i = (int) msb; + // Handle the next 4 characters (7928) (Section 2) + _appendShort(i >>> 16, ch, 8); + + // Handle the next 4 characters (49c8) (Section 3) + _appendShort(i, ch, 12); + + final long lsb = uuid.getLeastSignificantBits(); + + // Handle the next 4 characters (a14ff) (Section 4) + _appendShort((int) (lsb >>> 48), ch, 16); + + // Handle the next 4 characters (6709) (Section 5) + _appendShort((int) (lsb >>> 32), ch, 20); + + // Handle the final 8 characters (8b6e4642) (Section 5) + _appendInt((int) lsb, ch, 24); + + return new String(ch, 0, 32); + } + + /** + *

Render the given UUID as a 16-byte array.

+ * + * @param uuid The UUID to render. + * @return 16-byte array. + */ + public static byte[] toBytes(final UUID uuid) { + return _asBytes(uuid); + } + + /** + *

Parse the given hexadecimal string as a UUID. This method supports + * both 32-character (no dash) and 36-character (dashes) representations, + * and will automatically choose based on input length.

+ * + * @param id The string representation to parse. + * @return The parsed UUID. + */ + public static UUID fromHex(final String id) { + final int len = id.length(); + + if (len == 32) { + // Deserialize without dashes. + + // Get the first 8 characters from index 0 + long l1 = intFromChars(id, 0); + l1 <<= 32; + + // Get the second 4 characters from index 8 + long l2 = ((long) shortFromChars(id, 8)) << 16; + + // Get the third 4 characters from index 12 + l2 |= shortFromChars(id, 12); + long hi = l1 + l2; + + // Get the next two sets of 4 characters from indexes 16 and 20 + // respectively. + int i1 = (shortFromChars(id, 16) << 16) | shortFromChars(id, 20); + l1 = i1; + l1 <<= 32; + + // Get the final 8 characters from index 24 + l2 = intFromChars(id, 24); + l2 = (l2 << 32) >>> 32; + long lo = l1 | l2; + + return new UUID(hi, lo); + } else if (len == 36) { + // Deserialize with dashes. + if ((id.charAt(8) != '-') || (id.charAt(13) != '-') + || (id.charAt(18) != '-') || (id.charAt(23) != '-')) { + throw new IllegalArgumentException("Malformed UUID: 36-character representation does not contain correct dashes."); + } + long l1 = intFromChars(id, 0); + l1 <<= 32; + long l2 = ((long) shortFromChars(id, 9)) << 16; + l2 |= shortFromChars(id, 14); + long hi = l1 + l2; + + int i1 = (shortFromChars(id, 19) << 16) | shortFromChars(id, 24); + l1 = i1; + l1 <<= 32; + l2 = intFromChars(id, 28); + l2 = (l2 << 32) >>> 32; // sign removal, Java-style. Ugh. [Note: Retained this comment from Jackson :) ] + long lo = l1 | l2; + + return new UUID(hi, lo); + } else { + throw new IllegalArgumentException("UUID hexadecimal strings must be either 32 characters or 36 characters long."); + } + } + + public static UUID fromBytes(final byte[] bytes) { + return _fromBytes(bytes); + } + + private static void _appendShort(int bits, char[] ch, int offset) { + ch[offset] = HEX_CHARS[(bits >> 12) & 0xF]; + ch[++offset] = HEX_CHARS[(bits >> 8) & 0xF]; + ch[++offset] = HEX_CHARS[(bits >> 4) & 0xF]; + ch[++offset] = HEX_CHARS[bits & 0xF]; + } + + private static void _appendInt(int bits, char[] ch, int offset) { + _appendShort(bits >> 16, ch, offset); + _appendShort(bits, ch, offset+4); + } + + private final static void _appendInt(int value, byte[] buffer, int offset) { + buffer[offset] = (byte) (value >> 24); + buffer[++offset] = (byte) (value >> 16); + buffer[++offset] = (byte) (value >> 8); + buffer[++offset] = (byte) value; + } + + private final static byte[] _asBytes(UUID uuid) { + byte[] buffer = new byte[16]; + long hi = uuid.getMostSignificantBits(); + long lo = uuid.getLeastSignificantBits(); + _appendInt((int) (hi >> 32), buffer, 0); + _appendInt((int) hi, buffer, 4); + _appendInt((int) (lo >> 32), buffer, 8); + _appendInt((int) lo, buffer, 12); + return buffer; + } + + private static int intFromChars(String str, int index) { + return (byteFromChars(str, index) << 24) + + (byteFromChars(str, index+2) << 16) + + (byteFromChars(str, index+4) << 8) + + byteFromChars(str, index+6); + } + + private static int shortFromChars(String str, int index) { + return (byteFromChars(str, index) << 8) + byteFromChars(str, index+2); + } + + private static int byteFromChars(String str, int index) { + final char c1 = str.charAt(index); + final char c2 = str.charAt(index+1); + + if (c1 <= 127 && c2 <= 127) { + int hex = (HEX_DIGITS[c1] << 4) | HEX_DIGITS[c2]; + if (hex >= 0) { + return hex; + } + } + + throw new IllegalArgumentException("Invalid hexadecimal character detected in byte at index " + index); + } + + private static UUID _fromBytes(byte[] bytes) { + if (bytes.length != 16) { + throw new IllegalArgumentException("Can only construct UUIDs from byte[16]; got " + bytes.length + " bytes"); + } + return new UUID(_long(bytes, 0), _long(bytes, 8)); + } + + private static long _long(byte[] b, int offset) { + long l1 = ((long) _int(b, offset)) << 32; + long l2 = _int(b, offset+4); + // faster to just do it than check if it has sign + l2 = (l2 << 32) >>> 32; // to get rid of sign + return l1 | l2; + } + + private static int _int(byte[] b, int offset) { + return (b[offset] << 24) | ((b[offset+1] & 0xFF) << 16) | ((b[offset+2] & 0xFF) << 8) | (b[offset+3] & 0xFF); + } +} diff --git a/modules/uuid/src/main/scala/gs/uuid/UUID.scala b/modules/uuid/src/main/scala/gs/uuid/UUID.scala new file mode 100644 index 0000000..afcbe30 --- /dev/null +++ b/modules/uuid/src/main/scala/gs/uuid/UUID.scala @@ -0,0 +1,114 @@ +package gs.uuid + +import com.fasterxml.uuid.Generators + +/** Alias for the `java.util.UUID` type, which represents a 128-bit value. + * + * ## ID Generation + * + * This library provides generator implementations for the following types of + * UUID: + * + * - Type 4 + * - Type 7 + * + * These implementations are provided by JUG. + * + * ## Serialization + * + * This library uses a custom variant of the JDK 17 implementation that removes + * dashes from the output and is likewise capable of parsing those values. + * + * {{{ + * val example: UUID = UUID(java.util.UUID.randomUUID()) + * val serialized = example.str() // or example.withoutDashes() + * // example value = 899efa6f40ed45189efa6f40ed9518ed + * }}} + */ +opaque type UUID = java.util.UUID + +object UUID: + /** Express any `java.util.UUID` as a Meager UUID. + * + * @param uuid + * The input UUID. + * @return + * The aliased value. + */ + def apply(uuid: java.util.UUID): UUID = uuid + + given CanEqual[UUID, UUID] = CanEqual.derived + + /** Generate a new UUID. + * + * @param G + * The [[Generator]] type class instance. + * @return + * The new UUID. + */ + def generate( + )( + using + G: Generator + ): UUID = G.next() + + /** Parse the given string as a UUID. + * + * @param str + * The UUID, which is expected to be in a hexadecimal format with no + * dashes. + * @return + * The parsed UUID value, or `None` if the value does not represent a UUID. + */ + def parse(str: String): Option[UUID] = fromString(str) + + def fromString(str: String): Option[UUID] = + scala.util + .Try(UUIDFormat.fromHex(str)) + .map(uuid => Some(apply(uuid))) + .getOrElse(None) + + extension (uid: UUID) + def toUUID(): java.util.UUID = uid + + def str(): String = withoutDashes() + + def withoutDashes(): String = UUIDFormat.toHex(uid) + + def lsb(): Long = uid.getLeastSignificantBits() + + def msb(): Long = uid.getMostSignificantBits() + + def isZero(): Boolean = lsb() == 0L && msb() == 0L + + /** Type class for UUID generation. + */ + trait Generator: + /** Generate a new UUID. + */ + def next(): UUID + + object Generator: + /** Instantiate a new Type 4 generator. + */ + def version4: Generator = new Version4 + + /** Instantiate a new Type 7 generator. + */ + def version7: Generator = new Version7 + + /** Type 4 (Random) implementation of a UUID generator. + */ + final class Version4 extends Generator: + private val gen = Generators.randomBasedGenerator() + override def next(): UUID = gen.generate() + + /** Type 7 (Unix Epoch Time + Random) implementation of a UUID generator. + * Consider using this rather than Type 1 or Type 6. + * + * This type is defined in [IETF New UUID Formats + * Draft](https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7) + */ + final class Version7 extends Generator: + private val gen = Generators.timeBasedEpochGenerator() + override def next(): UUID = gen.generate() diff --git a/modules/uuid/src/test/scala/gs/uuid/UUIDTests.scala b/modules/uuid/src/test/scala/gs/uuid/UUIDTests.scala new file mode 100644 index 0000000..b25ed7f --- /dev/null +++ b/modules/uuid/src/test/scala/gs/uuid/UUIDTests.scala @@ -0,0 +1,57 @@ +package gs.uuid + +class UUIDTests extends munit.FunSuite: + private val v4 = UUID.Generator.version4 + private val v7 = UUID.Generator.version7 + given CanEqual[java.util.UUID, java.util.UUID] = CanEqual.derived + + test( + "should instantiate a type 4 UUID, serialize it, and parse the result" + ) { + val base = v4.next() + val str = base.str() + val parsed = UUID.parse(str) + assert(parsed == Some(base)) + } + + test( + "should instantiate a type 7 UUID, serialize it, and parse the result" + ) { + val base = v7.next() + val str = base.str() + val parsed = UUID.parse(str) + assert(parsed == Some(base)) + } + + test("should instantiate from any java.util.UUID") { + val raw = java.util.UUID.randomUUID() + val base = UUID(raw) + val str = base.str() + val parsed = UUID.fromString(str) + assert(parsed == Some(base)) + assert(parsed.map(_.toUUID()) == Some(raw)) + } + + test("should successfully parse a UUID with dashes") { + val base = java.util.UUID.randomUUID() + assert(UUID.parse(base.toString()) == Some(UUID(base))) + } + + test("should fail to parse a non-hex string") { + val input = "ghijklmnoped45189efa6f40ed9518ed" + assert(UUID.parse(input) == None) + } + + test("should generate using an available type class instance") { + given UUID.Generator = v7 + val base = doGen + val str = base.str() + val parsed = UUID.parse(str) + assert(parsed == Some(base)) + } + + private def doGen( + using + UUID.Generator + ): UUID = + UUID.generate() diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..e8a1e24 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.7 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..29763dc --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +//addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.9") +addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.4")