From 9abef2d8ceb415fa1d56a13ab261817a2ab57d11 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Wed, 29 Apr 2026 22:49:06 -0500 Subject: [PATCH] Some multi-module action. --- build.sbt | 56 +++++- .../src/gs/std/v0/core/Base64Decoder.scala | 36 ++++ .../src/gs/std/v0/core/Base64Encoder.scala | 43 +++++ .../main/scala/src/gs/std/v0/core/Blob.scala | 49 ++++++ .../scala/src/gs/std/v0/core/ByteCount.scala | 138 +++++++++++++++ .../scala/src/gs/std/v0/core/CreatedAt.scala | 37 ++++ .../scala/src/gs/std/v0/core/Decoder.scala | 13 ++ .../scala/src/gs/std/v0/core/Encode.scala | 58 +++++++ .../src/gs/std/v0/core/EncodedString.scala | 162 ++++++++++++++++++ .../scala/src/gs/std/v0/core/Encoder.scala | 44 +++++ .../scala/src/gs/std/v0/core/HexDecoder.scala | 23 +++ .../scala/src/gs/std/v0/core/HexEncoder.scala | 13 ++ .../main/scala/src/gs/std/v0/core/MD5.scala | 108 ++++++++++++ .../main/scala/src/gs/std/v0/core/Nat.scala | 84 +++++++++ .../scala/src/gs/std/v0/core/SHA256.scala | 112 ++++++++++++ .../main/scala/src/gs/std/v0/core/Size.scala | 102 +++++++++++ .../scala/src/gs/std/v0/core/UpdatedAt.scala | 37 ++++ src/main/scala/gs/std/v0/Base64Decoder.scala | 2 +- src/main/scala/gs/std/v0/Base64Encoder.scala | 2 +- src/main/scala/gs/std/v0/Blob.scala | 2 +- src/main/scala/gs/std/v0/ByteCount.scala | 2 +- src/main/scala/gs/std/v0/CreatedAt.scala | 2 +- src/main/scala/gs/std/v0/Decoder.scala | 2 +- src/main/scala/gs/std/v0/Encode.scala | 2 +- src/main/scala/gs/std/v0/EncodedString.scala | 2 +- src/main/scala/gs/std/v0/Encoder.scala | 2 +- src/main/scala/gs/std/v0/HexDecoder.scala | 2 +- src/main/scala/gs/std/v0/HexEncoder.scala | 2 +- src/main/scala/gs/std/v0/MD5.scala | 2 +- src/main/scala/gs/std/v0/Nat.scala | 2 +- src/main/scala/gs/std/v0/SHA256.scala | 2 +- src/main/scala/gs/std/v0/Size.scala | 2 +- src/main/scala/gs/std/v0/UpdatedAt.scala | 2 +- 33 files changed, 1130 insertions(+), 17 deletions(-) create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/Base64Decoder.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/Base64Encoder.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/Blob.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/ByteCount.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/CreatedAt.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/Decoder.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/Encode.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/EncodedString.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/Encoder.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/HexDecoder.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/HexEncoder.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/MD5.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/Nat.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/SHA256.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/Size.scala create mode 100644 modules/core/src/main/scala/src/gs/std/v0/core/UpdatedAt.scala diff --git a/build.sbt b/build.sbt index f2d5856..d9b5543 100644 --- a/build.sbt +++ b/build.sbt @@ -10,6 +10,10 @@ ThisBuild / licenses := Seq( ) ) +val noPublishSettings = Seq( + publish := {} +) + val sharedSettings = Seq( scalaVersion := scala3, version := semVerSelected.value, @@ -18,14 +22,64 @@ val sharedSettings = Seq( coverageMinimumBranchTotal := 100 ) +val Deps = new { + + val Cats = new { + val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0" + val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.7.0" + } + + val Fs2 = new { + val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.13.0" + val IO: ModuleID = "co.fs2" %% "fs2-io" % "3.13.0" + } + + val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.3.0" % Test +} + lazy val testSettings = Seq( libraryDependencies ++= Seq( - "org.scalameta" %% "munit" % "1.3.0" % Test + Deps.MUnit ) ) lazy val `gs-std` = project .in(file(".")) + .aggregate(core, io, effect, stream) .settings(sharedSettings) .settings(testSettings) .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") + +lazy val core = project + .in(file("modules/core")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-core-v${semVerMajor.value}") + +lazy val io = project + .in(file("modules/io")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-io-v${semVerMajor.value}") + .settings( + libraryDependencies ++= Seq( + Deps.Cats.Core, + Deps.Cats.Effect, + Deps.Fs2.Core, + Deps.Fs2.IO + ) + ) + +lazy val effect = project + .in(file("modules/effect")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-effect-v${semVerMajor.value}") + .settings(libraryDependencies ++= Seq(Deps.Cats.Core, Deps.Cats.Effect)) + +lazy val stream = project + .in(file("modules/stream")) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-stream-v${semVerMajor.value}") + .settings(libraryDependencies ++= Seq(Deps.Fs2.Core)) diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/Base64Decoder.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Base64Decoder.scala new file mode 100644 index 0000000..7cc8c1c --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/Base64Decoder.scala @@ -0,0 +1,36 @@ +package gs.std.v0.core + +import java.{util => ju} + +/** Implementation of [[Decoder]] for Base64 strings. + * + * Supports base64-url decoding as well. + */ +object Base64Decoder extends Decoder[B64]: + private lazy val d: ju.Base64.Decoder = ju.Base64.getDecoder() + private lazy val du: ju.Base64.Decoder = ju.Base64.getUrlDecoder() + + /** @inheritDocs + */ + override def decode(input: B64): Array[Byte] = + d.decode(input.data) + + /** Decode some arbitrary string data. + * + * @param input + * The data to decode. + * @return + * The decoded bytes. + */ + def decodeUnsafe(input: String): Array[Byte] = + d.decode(input) + + /** Decode the base64-url encoded input. + * + * @param input + * The base64-url encoded data. + * @return + * The decoded bytes. + */ + def decodeUrl(input: B64Url): Array[Byte] = + du.decode(input.data) diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/Base64Encoder.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Base64Encoder.scala new file mode 100644 index 0000000..9c99fa1 --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/Base64Encoder.scala @@ -0,0 +1,43 @@ +package gs.std.v0.core + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.Base64 + +/** Implementation of [[Encoder]] for Base64. + * + * Supports base64-url encoding as well. + */ +object Base64Encoder extends Encoder[B64]: + private lazy val e: Base64.Encoder = Base64.getEncoder() + private lazy val eu: Base64.Encoder = Base64.getUrlEncoder() + + /** @inheritDocs + */ + override def encode(input: Array[Byte]): B64 = + B64(e.encodeToString(input)) + + /** Encode the given bytes using base64-url. + * + * @param input + * The input data. + * @return + * The base64-url-encoded string. + */ + def encodeUrl(input: Array[Byte]): B64Url = + B64Url(eu.encodeToString(input)) + + /** Encode the given bytes using base64-url. + * + * @param input + * The input data. + * @param charset + * The character set of the input data. + * @return + * The base64-url-encoded string. + */ + def encodeUrl( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): B64Url = + encodeUrl(input.getBytes(charset)) diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/Blob.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Blob.scala new file mode 100644 index 0000000..b51d956 --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/Blob.scala @@ -0,0 +1,49 @@ +package gs.std.v0.core + +import java.util.Base64 + +/** Represents a blob -- some array of bytes. + * + * @param data + * The underlying data. + */ +final class Blob(private val data: Array[Byte]) extends IndexedSeq[Byte]: + + /** @inheritDocs + */ + override def apply(i: Int): Byte = byteAt(i) + + /** @inheritDocs + */ + override def length: Int = data.length + + /** @return + * The number of bytes in this blob, expressed as a count. + */ + def numberOfBytes: ByteCount = ByteCount(data.length) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: Blob => data.sameElements(other.data) + case other: Array[Byte] => data.sameElements(other) + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** Retrieve the byte at the given index. + * + * @param index + * The index. + * @return + * The byte stored at the given index. + */ + def byteAt(index: Int): Byte = data.apply(index) + + /** @return + * This byte array, encoded as a base64 string. + */ + def base64(): String = Base64.getEncoder().encodeToString(data) diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/ByteCount.scala b/modules/core/src/main/scala/src/gs/std/v0/core/ByteCount.scala new file mode 100644 index 0000000..c59033d --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/ByteCount.scala @@ -0,0 +1,138 @@ +package gs.std.v0.core + +/** Opaque type for some number of bytes (>= 0). + */ +opaque type ByteCount = Long + +/** Opaque type for some number of bytes (>= 0). + */ +object ByteCount: + + /** 0 bytes. + */ + final val Zero: ByteCount = 0 + + /** 1,000 bytes. + */ + final val OneKilobyte: ByteCount = 1000 + + /** 1,000,000 bytes. + */ + final val OneMegabyte: ByteCount = 1000000 + + /** 1,000,000,000 bytes. + */ + final val OneGigabyte: ByteCount = 1000000000 + + /** Express the given number as a byte count. All values are normalized to the + * absolute value -- negative values are coerced to positive. + * + * @param value + * The input integer. + * @return + * The [[ByteCount]] instance. + */ + def apply(value: Long): ByteCount = Math.abs(value) + + /** 1 kilobyte = 1,000 bytes + * + * @param kb + * The number of kilobytes. + * @return + * The number of bytes. + */ + def fromKilobytes(kb: Long): ByteCount = + Math.abs(kb) * 1000L + + /** 1 megabyte = 1,000,000 bytes + * + * @param mb + * The number of megabytes. + * @return + * The number of bytes. + */ + def fromMegabytes(mb: Long): ByteCount = + Math.abs(mb) * 1000000L + + /** 1 gigabyte = 1,000,000,000 bytes + * + * @param mb + * The number of gigabytes. + * @return + * The number of bytes. + */ + def fromGigabytes(gb: Long): ByteCount = + Math.abs(gb) * 1000000000 + + given CanEqual[ByteCount, ByteCount] = CanEqual.derived + + given Ordering[ByteCount] with + + /** @inheritDocs + */ + def compare( + x: ByteCount, + y: ByteCount + ): Int = + if x > y then 1 else if x == y then 0 else -1 + + extension (byteCount: ByteCount) + /** @return + * The underlying `Long`. + */ + def unwrap(): Long = byteCount + + /** @return + * The underlying `Long`. + */ + def toLong(): Long = byteCount + + /** 1 kilobyte = 1,000 bytes + * + * @return + * The number of kilobytes represented by this count. + */ + def toKilobytes(): Double = byteCount / 1000.0 + + /** 1 megabyte = 1,000,000 bytes. + * + * @return + * The number of megabytes represented by this count. + */ + def toMegabytes(): Double = byteCount / 1000000.0 + + /** 1 gigabyte = 1,000,000,000 bytes. + * + * @return + * The number of gigabytes represented by this count. + */ + def toGigabytes(): Double = byteCount / 1000000000.0 + + /** Add some count to this one. + * + * @param that + * The number to add. + * @return + * The sum of the numbers. + */ + def +(that: ByteCount): ByteCount = byteCount + that + + /** Multiply this count by some other count. + * + * @param that + * The number to multiply by. + * @return + * The product of the numbers. + */ + def *(that: ByteCount): ByteCount = byteCount * that + + /** Check if this value is the same as some number. + * + * @param value + * The number to compare against. + * @return + * True if the values are equal, false otherwise. + */ + def equal(value: Int): Boolean = byteCount == value.toLong + +end ByteCount diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/CreatedAt.scala b/modules/core/src/main/scala/src/gs/std/v0/core/CreatedAt.scala new file mode 100644 index 0000000..16c0a6b --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/CreatedAt.scala @@ -0,0 +1,37 @@ +package gs.std.v0.core + +import java.time.Instant + +/** Opaque type that represents the instant something was created. + */ +opaque type CreatedAt = Instant + +/** Opaque type that represents the instant something was created. + */ +object CreatedAt: + + /** Instantiate a new [[CreatedAt]] from the given `java.time.Instant`. + * + * @param value + * The value to semantically represent. + * @return + * The new [[CreatedAt]]. + */ + def apply(value: Instant): CreatedAt = value + + given CanEqual[CreatedAt, CreatedAt] = CanEqual.derived + + given Ordering[CreatedAt] = Ordering[Instant] + + extension (createdAt: Instant) + /** @return + * The underlying `java.time.Instant`. + */ + def unwrap(): Instant = createdAt + + /** @return + * The underlying `java.time.Instant`. + */ + def toInstant(): Instant = createdAt + +end CreatedAt diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/Decoder.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Decoder.scala new file mode 100644 index 0000000..3ebd2a4 --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/Decoder.scala @@ -0,0 +1,13 @@ +package gs.std.v0.core + +/** Interface for byte decoding from encoded String formats. + */ +trait Decoder[-A <: EncodedString]: + /** Decode an input string to an array of bytes. + * + * @param input + * The input to decode. + * @return + * The decoded byte array. + */ + def decode(input: A): Array[Byte] diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/Encode.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Encode.scala new file mode 100644 index 0000000..0059eeb --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/Encode.scala @@ -0,0 +1,58 @@ +package gs.std.v0.core + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** Helper functions for encoding data. + */ +object Encode: + + /** Encode an array of bytes using base64. + * + * @param input + * The bytes to encode. + * @return + * The base64 string. + */ + def base64(input: Array[Byte]): B64 = Base64Encoder.encode(input) + + /** Encode a string using base64. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The base64 string. + */ + def base64( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): B64 = + Base64Encoder.encode(input, charset) + + /** Encode an array of bytes using hexadecimal. + * + * @param input + * The bytes to encode. + * @return + * The hexadecimal string. + */ + def hex(input: Array[Byte]): Hex = HexEncoder.encode(input) + + /** Encode a string using hexadecimal. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The hexadecimal string. + */ + def hex( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): Hex = + HexEncoder.encode(input, charset) + +end Encode diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/EncodedString.scala b/modules/core/src/main/scala/src/gs/std/v0/core/EncodedString.scala new file mode 100644 index 0000000..372b7e5 --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/EncodedString.scala @@ -0,0 +1,162 @@ +package gs.std.v0.core + +/** Represents string-encoded data. + * + * See: + * - [[B64]] + * - [[Hex]] + */ +trait EncodedString: + /** @return + * The encoded data (expressed as a string). + */ + def data: String + + /** @return + * Decode the data to a byte array. + */ + def decode(): Array[Byte] + + /** Represents Base64-encoded data. + * + * @param data + * The encoded data. + */ + +/** Represents Base64-encoded data. + * + * @param data + * The encoded data. + */ +final class B64( + val data: String +) extends EncodedString: + /** @inheritDocs + */ + def decode(): Array[Byte] = Base64Decoder.decode(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: B64 => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object B64: + + /** Instantiate [[B64]] from the given string. Assumes that the input is + * base64-encoded. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[B64]] instance. + */ + def apply( + data: String + ): B64 = new B64(data) + + given CanEqual[B64, B64] = CanEqual.derived + +end B64 + +/** Represents Base64-url-encoded data. + * + * @param data + * The encoded data. + */ +final class B64Url( + val data: String +) extends EncodedString: + /** @inheritDocs + */ + def decode(): Array[Byte] = Base64Decoder.decodeUrl(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: B64Url => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object B64Url: + + /** Instantiate [[B64Url]] from the given string. Assumes that the input is + * base64-encoded. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[B64Url]] instance. + */ + def apply( + data: String + ): B64Url = new B64Url(data) + + given CanEqual[B64Url, B64Url] = CanEqual.derived + +end B64Url + +/** Represents Hex-encoded data. + * + * @param data + * The encoded data. + */ +final class Hex( + val data: String +) extends EncodedString: + /** @inheritDocs + */ + def decode(): Array[Byte] = HexDecoder.decode(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: Hex => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object Hex: + + /** Instantiate [[Hex]] from the given string. Assumes that the input is + * hex-encoded. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[Hex]] instance. + */ + def apply( + data: String + ): Hex = new Hex(data) + + given CanEqual[Hex, Hex] = CanEqual.derived + +end Hex diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/Encoder.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Encoder.scala new file mode 100644 index 0000000..1278aeb --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/Encoder.scala @@ -0,0 +1,44 @@ +package gs.std.v0.core + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** Interface for byte encoding to String formats. + */ +trait Encoder[+A <: EncodedString]: + /** Encode an array of bytes as a string. + * + * @param input + * The bytes to encode. + * @return + * The encoded string. + */ + def encode(input: Array[Byte]): A + + /** Encode a string as a string. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The encoded string. + */ + def encode( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): A = encode(input.getBytes(charset)) + +object Encoder: + + /** @return + * The [[Base64Encoder]], typed to `Encoder[Encoded]`. + */ + def base64(): Encoder[EncodedString] = Base64Encoder + + /** @return + * The [[HexEncoder]], typed to `Encoder[Encoded]`. + */ + def hex(): Encoder[EncodedString] = HexEncoder + +end Encoder diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/HexDecoder.scala b/modules/core/src/main/scala/src/gs/std/v0/core/HexDecoder.scala new file mode 100644 index 0000000..c48c1e3 --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/HexDecoder.scala @@ -0,0 +1,23 @@ +package gs.std.v0.core + +import java.util.HexFormat + +/** Implementation of [[Decoder]] for Hexadecimal strings. + */ +object HexDecoder extends Decoder[Hex]: + private lazy val h: HexFormat = HexFormat.of() + + /** @inheritDocs + */ + override def decode(input: Hex): Array[Byte] = + h.parseHex(input.data) + + /** Decode some arbitrary string data. + * + * @param input + * The data to decode. + * @return + * The decoded bytes. + */ + def decodeUnsafe(input: String): Array[Byte] = + h.parseHex(input) diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/HexEncoder.scala b/modules/core/src/main/scala/src/gs/std/v0/core/HexEncoder.scala new file mode 100644 index 0000000..7430199 --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/HexEncoder.scala @@ -0,0 +1,13 @@ +package gs.std.v0.core + +import java.util.HexFormat + +/** Implementation of [[Encoder]] for Hexadecimal strings. + */ +object HexEncoder extends Encoder[Hex]: + private lazy val h: HexFormat = HexFormat.of() + + /** @inheritDocs + */ + override def encode(input: Array[Byte]): Hex = + Hex(h.formatHex(input)) diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/MD5.scala b/modules/core/src/main/scala/src/gs/std/v0/core/MD5.scala new file mode 100644 index 0000000..e4866d5 --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/MD5.scala @@ -0,0 +1,108 @@ +package gs.std.v0.core + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** Opaque type representing a MD5 hash. + */ +opaque type MD5 = Array[Byte] + +/** Opaque type representing a MD5 hash. + */ +object MD5: + + /** MD5 hashes are exactly 16 bytes. + */ + final val NumberOfBytes: ByteCount = ByteCount(16) + + /** The algorithm name is "MD5". + */ + final val Algorithm: String = "MD5" + + /** Instantiate a [[MD5]] from the given byte array. This function does not + * know whether the array is actually a calculated hash. + * + * Typically used for loading pre-validated hashes (e.g. from a database). + * + * @param bytes + * The bytes - must contain exactly 16 bytes. + * @return + * The new [[MD5]] instance. + */ + def fromBytes(bytes: Array[Byte]): MD5 = + if NumberOfBytes.equal(bytes.length) then bytes + else + throw IllegalArgumentException( + s"MD5 values must be exactly $NumberOfBytes bytes." + ) + + /** Calculate the MD5 hash for the given byte array. + * + * @param data + * The byte array. + * @return + * The calculated [[MD5]]. + */ + def calculate(data: Array[Byte]): MD5 = + MessageDigest.getInstance(Algorithm).digest(data) + + /* Calculate the MD5 hash for the given string. + * + * @param data The string data. + * @param charset The character set of the string. Defaults to UTF-8. + * @return The calculated [[MD5]]. */ + def calculate( + data: String, + charset: Charset = StandardCharsets.UTF_8 + ): MD5 = + calculate(data.getBytes(charset)) + + extension (md5: MD5) + /** @return + * The underlying byte array. + */ + def unwrap(): Array[Byte] = md5 + + /** @return + * The underlying byte array. + */ + def toBytes(): Array[Byte] = md5 + + /** Get the byte at the given index (0 to 15). + * + * Throws an exception if an out-of-bound index is given. + * + * @param index + * The byte index (0 to 15). + * @return + * The byte at the specified index. + */ + def getByte(index: Int): Byte = + if index < 0 || index >= NumberOfBytes.unwrap() then + throw IndexOutOfBoundsException( + s"Index $index out of MD5 bound of $NumberOfBytes bytes." + ) + else md5.apply(index) + + /** Determine if this hash is the same as some other hash. Compares each + * byte in order. + * + * @param other + * The [[MD5]] to compare against. + * @return + * True if the hashes are identical, false otherwise. + */ + def isSame(other: MD5): Boolean = md5.sameElements(other) + + /** @return + * This hash encoded to a Base64 string. + */ + def base64(): EncodedString = Base64Encoder.encode(md5) + + /** @return + * This hash encoded to a Hexadecimal string. + */ + def hex(): EncodedString = HexEncoder.encode(md5) + +end MD5 diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/Nat.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Nat.scala new file mode 100644 index 0000000..d3a586c --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/Nat.scala @@ -0,0 +1,84 @@ +package gs.std.v0.core + +/** Opaque type for the natural numbers (including 0). + */ +opaque type Nat = Int + +/** Opaque type for the natural numbers (including 0). + */ +object Nat: + + sealed trait Invalid + object Invalid extends Invalid + + /** The number 0. + */ + final val Zero: Nat = 0 + + /** The number 1. + */ + final val One: Nat = 1 + + /** Express the given integer as a natural number. + * + * Throws an `IllegalArgumentException` if a negative value is given as + * input. + * + * @param value + * The input integer. + * @return + * The [[Nat]] instance. + */ + def apply(value: Int): Nat = + if value >= 0 then value + else throw new IllegalArgumentException("Nat values must be 0 or greater.") + + def validate(value: Int): Either[Invalid, Nat] = + if value >= 0 then Right(value) else Left(Invalid) + + given CanEqual[Nat, Nat] = CanEqual.derived + + given Ordering[Nat] with + + /** @inheritDocs + */ + def compare( + x: Nat, + y: Nat + ): Int = x - y + + extension (nat: Nat) + /** @return + * The underlying integer. + */ + def unwrap(): Int = nat + + /** @return + * The next integer. + */ + def next(): Nat = nat + 1 + + /** @return + * The next integer. + */ + def increment(): Nat = nat + 1 + + /** Add some natural number to this one. + * + * @param that + * The number to add. + * @return + * The sum of the numbers. + */ + def +(that: Nat): Nat = nat + that + + /** Multiply this natural number by some other natural number. + * + * @param that + * The number to multiply by. + * @return + * The product of the numbers. + */ + def *(that: Nat): Nat = nat * that + +end Nat diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/SHA256.scala b/modules/core/src/main/scala/src/gs/std/v0/core/SHA256.scala new file mode 100644 index 0000000..89fb61f --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/SHA256.scala @@ -0,0 +1,112 @@ +package gs.std.v0.core + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** Opaque type representing a SHA-256 hash. + */ +opaque type SHA256 = Array[Byte] + +/** Opaque type representing a SHA-256 hash. + */ +object SHA256: + + /** SHA-256 hashes are exactly 32 bytes. + */ + final val NumberOfBytes: ByteCount = ByteCount(32) + + /** The algorithm name is "SHA-256". + */ + final val Algorithm: String = "SHA-256" + + /** Instantiate a [[SHA256]] from the given byte array. This function does not + * know whether the array is actually a calculated hash. + * + * Typically used for loading pre-validated hashes (e.g. from a database). + * + * @param bytes + * The bytes - must contain exactly 32 bytes. + * @return + * The new [[SHA256]] instance. + */ + def fromBytes(bytes: Array[Byte]): SHA256 = + if NumberOfBytes.equal(bytes.length) then bytes + else + throw IllegalArgumentException( + s"SHA-256 values must be exactly $NumberOfBytes bytes." + ) + + /** Calculate the SHA-256 hash for the given byte array. + * + * @param data + * The byte array. + * @return + * The calculated [[SHA256]]. + */ + def calculate(data: Array[Byte]): SHA256 = + MessageDigest.getInstance(Algorithm).digest(data) + + /** Calculate the SHA-256 hash for the given string. + * + * @param data + * The string data. + * @param charset + * The character set of the string. Defaults to UTF-8. + * @return + * The calculated [[SHA256]]. + */ + def calculate( + data: String, + charset: Charset = StandardCharsets.UTF_8 + ): SHA256 = + calculate(data.getBytes(charset)) + + extension (sha: SHA256) + /** @return + * The underlying byte array. + */ + def unwrap(): Array[Byte] = sha + + /** @return + * The underlying byte array. + */ + def toBytes(): Array[Byte] = sha + + /** Get the byte at the given index (0 to 31). + * + * Throws an exception if an out-of-bound index is given. + * + * @param index + * The byte index (0 to 31). + * @return + * The byte at the specified index. + */ + def getByte(index: Int): Byte = + if index < 0 || index >= NumberOfBytes.unwrap() then + throw IndexOutOfBoundsException( + s"Index $index out of SHA-256 bound of $NumberOfBytes bytes." + ) + else sha.apply(index) + + /** Determine if this hash is the same as some other hash. Compares each + * byte in order. + * + * @param other + * The [[SHA256]] to compare against. + * @return + * True if the hashes are identical, false otherwise. + */ + def isSame(other: SHA256): Boolean = sha.sameElements(other) + + /** @return + * This hash encoded to a Base64 string. + */ + def base64(): EncodedString = Base64Encoder.encode(sha) + + /** @return + * This hash encoded to a Hexadecimal string. + */ + def hex(): EncodedString = HexEncoder.encode(sha) + +end SHA256 diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/Size.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Size.scala new file mode 100644 index 0000000..c00cbec --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/Size.scala @@ -0,0 +1,102 @@ +package gs.std.v0.core + +/** Opaque type for collection sizes. Values are guaranteed to be 0 or greater. + */ +opaque type Size = Int + +/** Opaque type for collection sizes. Values are guaranteed to be 0 or greater. + */ +object Size: + + sealed trait Invalid + object Invalid extends Invalid + + /** The size 0. + */ + final val Zero: Size = 0 + + /** The size 1. + */ + final val One: Size = 1 + + /** Express the given integer as a size. + * + * Throws an `IllegalArgumentException` if a negative value is given as + * input. + * + * @param value + * The input integer. + * @return + * The [[Size]] instance. + */ + def apply(value: Int): Size = + if value >= 0 then value + else throw new IllegalArgumentException("Size values must be 0 or greater.") + + def validate(value: Int): Either[Invalid, Size] = + if value >= 0 then Right(value) else Left(Invalid) + + /** Express the size of any collection. + * + * @param iter + * The collection. + * @return + * The size of the collection. + */ + def of(iter: Iterable[?]): Size = iter.size + + /** Express the size of any array. + * + * @param arr + * The array. + * @return + * The size of the array. + */ + def of(arr: Array[?]): Size = arr.length + + given CanEqual[Size, Size] = CanEqual.derived + + given Ordering[Size] with + + /** @inheritDocs + */ + def compare( + x: Size, + y: Size + ): Int = x - y + + extension (size: Size) + /** @return + * The underlying integer. + */ + def unwrap(): Int = size + + /** @return + * The next value. + */ + def next(): Size = size + 1 + + /** @return + * The next value. + */ + def increment(): Size = size + 1 + + /** Add some size to this one. + * + * @param that + * The number to add. + * @return + * The sum of the numbers. + */ + def +(that: Size): Size = size + that + + /** Multiply this size by some other size. + * + * @param that + * The number to multiply by. + * @return + * The product of the numbers. + */ + def *(that: Size): Size = size * that + +end Size diff --git a/modules/core/src/main/scala/src/gs/std/v0/core/UpdatedAt.scala b/modules/core/src/main/scala/src/gs/std/v0/core/UpdatedAt.scala new file mode 100644 index 0000000..8244602 --- /dev/null +++ b/modules/core/src/main/scala/src/gs/std/v0/core/UpdatedAt.scala @@ -0,0 +1,37 @@ +package gs.std.v0.core + +import java.time.Instant + +/** Opaque type that represents the instant something was updated. + */ +opaque type UpdatedAt = Instant + +/** Opaque type that represents the instant something was updated. + */ +object UpdatedAt: + + /** Instantiate a new [[UpdatedAt]] from the given `java.time.Instant`. + * + * @param value + * The value to semantically represent. + * @return + * The new [[UpdatedAt]]. + */ + def apply(value: Instant): UpdatedAt = value + + given CanEqual[UpdatedAt, UpdatedAt] = CanEqual.derived + + given Ordering[UpdatedAt] = Ordering[Instant] + + extension (updatedAt: Instant) + /** @return + * The underlying `java.time.Instant`. + */ + def unwrap(): Instant = updatedAt + + /** @return + * The underlying `java.time.Instant`. + */ + def toInstant(): Instant = updatedAt + +end UpdatedAt diff --git a/src/main/scala/gs/std/v0/Base64Decoder.scala b/src/main/scala/gs/std/v0/Base64Decoder.scala index f789979..7cc8c1c 100644 --- a/src/main/scala/gs/std/v0/Base64Decoder.scala +++ b/src/main/scala/gs/std/v0/Base64Decoder.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.{util => ju} diff --git a/src/main/scala/gs/std/v0/Base64Encoder.scala b/src/main/scala/gs/std/v0/Base64Encoder.scala index 935cdfd..9c99fa1 100644 --- a/src/main/scala/gs/std/v0/Base64Encoder.scala +++ b/src/main/scala/gs/std/v0/Base64Encoder.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.nio.charset.Charset import java.nio.charset.StandardCharsets diff --git a/src/main/scala/gs/std/v0/Blob.scala b/src/main/scala/gs/std/v0/Blob.scala index 59697ec..b51d956 100644 --- a/src/main/scala/gs/std/v0/Blob.scala +++ b/src/main/scala/gs/std/v0/Blob.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.util.Base64 diff --git a/src/main/scala/gs/std/v0/ByteCount.scala b/src/main/scala/gs/std/v0/ByteCount.scala index d97b54f..c59033d 100644 --- a/src/main/scala/gs/std/v0/ByteCount.scala +++ b/src/main/scala/gs/std/v0/ByteCount.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core /** Opaque type for some number of bytes (>= 0). */ diff --git a/src/main/scala/gs/std/v0/CreatedAt.scala b/src/main/scala/gs/std/v0/CreatedAt.scala index abaeb0d..16c0a6b 100644 --- a/src/main/scala/gs/std/v0/CreatedAt.scala +++ b/src/main/scala/gs/std/v0/CreatedAt.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.time.Instant diff --git a/src/main/scala/gs/std/v0/Decoder.scala b/src/main/scala/gs/std/v0/Decoder.scala index 740a955..3ebd2a4 100644 --- a/src/main/scala/gs/std/v0/Decoder.scala +++ b/src/main/scala/gs/std/v0/Decoder.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core /** Interface for byte decoding from encoded String formats. */ diff --git a/src/main/scala/gs/std/v0/Encode.scala b/src/main/scala/gs/std/v0/Encode.scala index f1d1222..0059eeb 100644 --- a/src/main/scala/gs/std/v0/Encode.scala +++ b/src/main/scala/gs/std/v0/Encode.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.nio.charset.Charset import java.nio.charset.StandardCharsets diff --git a/src/main/scala/gs/std/v0/EncodedString.scala b/src/main/scala/gs/std/v0/EncodedString.scala index 9262589..372b7e5 100644 --- a/src/main/scala/gs/std/v0/EncodedString.scala +++ b/src/main/scala/gs/std/v0/EncodedString.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core /** Represents string-encoded data. * diff --git a/src/main/scala/gs/std/v0/Encoder.scala b/src/main/scala/gs/std/v0/Encoder.scala index 89dbc6d..1278aeb 100644 --- a/src/main/scala/gs/std/v0/Encoder.scala +++ b/src/main/scala/gs/std/v0/Encoder.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.nio.charset.Charset import java.nio.charset.StandardCharsets diff --git a/src/main/scala/gs/std/v0/HexDecoder.scala b/src/main/scala/gs/std/v0/HexDecoder.scala index d5159a6..c48c1e3 100644 --- a/src/main/scala/gs/std/v0/HexDecoder.scala +++ b/src/main/scala/gs/std/v0/HexDecoder.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.util.HexFormat diff --git a/src/main/scala/gs/std/v0/HexEncoder.scala b/src/main/scala/gs/std/v0/HexEncoder.scala index c6960c1..7430199 100644 --- a/src/main/scala/gs/std/v0/HexEncoder.scala +++ b/src/main/scala/gs/std/v0/HexEncoder.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.util.HexFormat diff --git a/src/main/scala/gs/std/v0/MD5.scala b/src/main/scala/gs/std/v0/MD5.scala index 1510c0d..e4866d5 100644 --- a/src/main/scala/gs/std/v0/MD5.scala +++ b/src/main/scala/gs/std/v0/MD5.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.nio.charset.Charset import java.nio.charset.StandardCharsets diff --git a/src/main/scala/gs/std/v0/Nat.scala b/src/main/scala/gs/std/v0/Nat.scala index 1726aa1..d3a586c 100644 --- a/src/main/scala/gs/std/v0/Nat.scala +++ b/src/main/scala/gs/std/v0/Nat.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core /** Opaque type for the natural numbers (including 0). */ diff --git a/src/main/scala/gs/std/v0/SHA256.scala b/src/main/scala/gs/std/v0/SHA256.scala index 26f4d37..89fb61f 100644 --- a/src/main/scala/gs/std/v0/SHA256.scala +++ b/src/main/scala/gs/std/v0/SHA256.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.nio.charset.Charset import java.nio.charset.StandardCharsets diff --git a/src/main/scala/gs/std/v0/Size.scala b/src/main/scala/gs/std/v0/Size.scala index cf96fc8..c00cbec 100644 --- a/src/main/scala/gs/std/v0/Size.scala +++ b/src/main/scala/gs/std/v0/Size.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core /** Opaque type for collection sizes. Values are guaranteed to be 0 or greater. */ diff --git a/src/main/scala/gs/std/v0/UpdatedAt.scala b/src/main/scala/gs/std/v0/UpdatedAt.scala index 1584715..8244602 100644 --- a/src/main/scala/gs/std/v0/UpdatedAt.scala +++ b/src/main/scala/gs/std/v0/UpdatedAt.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.time.Instant