Initializing the ShortForm project.
ShortForm is an essay oriented discussion platform.
This commit is contained in:
commit
937f0fe10c
16 changed files with 821 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
project/project
|
||||||
|
project/target
|
||||||
|
target/
|
||||||
|
dist/
|
11
.pre-commit-config.yaml
Normal file
11
.pre-commit-config.yaml
Normal file
|
@ -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
|
71
.scalafmt.conf
Normal file
71
.scalafmt.conf
Normal file
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
38
Makefile
Normal file
38
Makefile
Normal file
|
@ -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
|
5
README.md
Normal file
5
README.md
Normal file
|
@ -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.
|
220
build.sbt
Normal file
220
build.sbt
Normal file
|
@ -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=<version>` 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=<commit>` 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")
|
19
modules/api/src/main/scala/gs/shortform/api/ApiMain.scala
Normal file
19
modules/api/src/main/scala/gs/shortform/api/ApiMain.scala
Normal file
|
@ -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
|
37
modules/crypto/src/main/scala/gs/shortform/crypto/Hash.scala
Normal file
37
modules/crypto/src/main/scala/gs/shortform/crypto/Hash.scala
Normal file
|
@ -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
|
12
modules/db/src/main/resources/changelog.xml
Normal file
12
modules/db/src/main/resources/changelog.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
|
||||||
|
xmlns:pro="http://www.liquibase.org/xml/ns/pro"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd
|
||||||
|
http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd">
|
||||||
|
<include file="./gs/shortform/schema/1.sql"/>
|
||||||
|
</databaseChangeLog>
|
9
modules/db/src/main/resources/gs/shortform/schema/1.sql
Normal file
9
modules/db/src/main/resources/gs/shortform/schema/1.sql
Normal file
|
@ -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;
|
|
@ -0,0 +1,5 @@
|
||||||
|
package gs.shortform.error
|
||||||
|
|
||||||
|
/** Base trait for all errors in ShortForm.
|
||||||
|
*/
|
||||||
|
trait ShortFormError
|
216
modules/uuid/src/main/java/gs/uuid/UUIDFormat.java
Normal file
216
modules/uuid/src/main/java/gs/uuid/UUIDFormat.java
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Render the given UUID as a 32-character string using lowercase
|
||||||
|
* hexadecimal without dashes.</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Render the given UUID as a 16-byte array.</p>
|
||||||
|
*
|
||||||
|
* @param uuid The UUID to render.
|
||||||
|
* @return 16-byte array.
|
||||||
|
*/
|
||||||
|
public static byte[] toBytes(final UUID uuid) {
|
||||||
|
return _asBytes(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
114
modules/uuid/src/main/scala/gs/uuid/UUID.scala
Normal file
114
modules/uuid/src/main/scala/gs/uuid/UUID.scala
Normal file
|
@ -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()
|
57
modules/uuid/src/test/scala/gs/uuid/UUIDTests.scala
Normal file
57
modules/uuid/src/test/scala/gs/uuid/UUIDTests.scala
Normal file
|
@ -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()
|
1
project/build.properties
Normal file
1
project/build.properties
Normal file
|
@ -0,0 +1 @@
|
||||||
|
sbt.version=1.9.7
|
2
project/plugins.sbt
Normal file
2
project/plugins.sbt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
//addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.9")
|
||||||
|
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.4")
|
Loading…
Add table
Reference in a new issue