diff --git a/README.md b/README.md index bebab0c..8598879 100644 --- a/README.md +++ b/README.md @@ -3,52 +3,70 @@ [GS Open Source](https://garrity.co/oss.html) | [License (Apache 2.0)](./LICENSE) -Garrity Software standard types and operations. Provides a zero-dependency -collection of basic tools. +Garrity Software standard types and operations with minimal dependencies. - [Usage](#usage) - - [Dependency](#dependency) -- [Donate](#donate) +- [Core Module](#core-module) +- [I/O Module](#io-module) +- [Effect Module](#effect-module) ## Usage -### Dependency - This artifact is available in the Garrity Software Maven repository. ```scala externalResolvers += "Garrity Software Releases" at "https://maven.garrity.co/releases" -val GsStd: ModuleID = - "gs" %% "gs-std-v0" % "$VERSION" +// No Dependencies +val GsStdCore: ModuleID = + "gs" %% "gs-std-core-v0" % "$VERSION" + +// Depends on Cats Effect and Fs2 +val GsStdIO: ModuleID = + "gs" %% "gs-std-io-v0" % "$VERSION" + +// Depends on Cats Effect and Fs2 +val GsStdEffect: ModuleID = + "gs" %% "gs-std-effect-v0" % "$VERSION" ``` -## Types +## Core Module -- `Nat` -- `Size` -- `ByteCount` -- `Blob` -- `CreatedAt` -- `UpdatedAt` -- `SHA256` -- `MD5` -- `EncodedString` -- `B64` -- `B64Url` -- `Hex` +Provides standard types, wrappers, and tools. -## Tools +- Common semantic types (e.g. `CreatedAt`). +- Encoding and representing encoded data. +- Hashing algorithms and representing hashes. +- Reasoning about bytes and blobs. -- `Encoder` -- `Base64Encoder` -- `HexEncoder` -- `Decoder` -- `Base64Decoder` -- `HexDecoder` +## I/O Module -## Donate +### File Helpers -Enjoy this project or want to help me achieve my [goals](https://garrity.co)? -Consider [Donating to Pat on Ko-fi](https://ko-fi.com/gspfm). +`Files`: These provide support for reading and writing files. Provides wrapping +around `java.nio.file.Files`. + +Note that for streaming operations, `fs2.io` provides everything conveniently +and does not benefit from wrapping. + +### Resource Helpers + +These provide support for reading files from packaged resource directories +(e.g. `src/main/resources`). + +## Effect Module + +This module relies on the [Core Module](#core-module) and takes advantage of the +types defined therein. + +### Random Numbers + +`Rng[F[_]]`: Effectful random number generator with creation/testing +helpers. + +### Dates and Times + +`DateTimeProvider[F[_]]`: Effectful provider for dates and times. Provides an +implementation based on clock injection. Helps to decouple date/time use from +static methods. diff --git a/build.sbt b/build.sbt index f2d5856..fed8107 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,61 @@ 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) .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")) + .dependsOn(core) + .dependsOn(io % "test->compile") + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-effect-v${semVerMajor.value}") + .settings( + libraryDependencies ++= Seq(Deps.Cats.Core, Deps.Cats.Effect, Deps.Fs2.Core) + ) diff --git a/src/main/scala/gs/std/v0/Base64Decoder.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Base64Decoder.scala similarity index 97% rename from src/main/scala/gs/std/v0/Base64Decoder.scala rename to modules/core/src/main/scala/src/gs/std/v0/core/Base64Decoder.scala index f789979..7cc8c1c 100644 --- a/src/main/scala/gs/std/v0/Base64Decoder.scala +++ b/modules/core/src/main/scala/src/gs/std/v0/core/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/modules/core/src/main/scala/src/gs/std/v0/core/Base64Encoder.scala similarity index 97% rename from src/main/scala/gs/std/v0/Base64Encoder.scala rename to modules/core/src/main/scala/src/gs/std/v0/core/Base64Encoder.scala index 935cdfd..9c99fa1 100644 --- a/src/main/scala/gs/std/v0/Base64Encoder.scala +++ b/modules/core/src/main/scala/src/gs/std/v0/core/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/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/src/main/scala/gs/std/v0/Encode.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Encode.scala similarity index 98% rename from src/main/scala/gs/std/v0/Encode.scala rename to modules/core/src/main/scala/src/gs/std/v0/core/Encode.scala index f1d1222..0059eeb 100644 --- a/src/main/scala/gs/std/v0/Encode.scala +++ b/modules/core/src/main/scala/src/gs/std/v0/core/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/modules/core/src/main/scala/src/gs/std/v0/core/EncodedString.scala similarity index 96% rename from src/main/scala/gs/std/v0/EncodedString.scala rename to modules/core/src/main/scala/src/gs/std/v0/core/EncodedString.scala index 840b031..372b7e5 100644 --- a/src/main/scala/gs/std/v0/EncodedString.scala +++ b/modules/core/src/main/scala/src/gs/std/v0/core/EncodedString.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core /** Represents string-encoded data. * @@ -51,8 +51,8 @@ final class B64( object B64: - /** Instantiate [[B64]] from the given string. Assumes that the input is - * base64-encoded. + /** Instantiate [[B64]] from the given string. Assumes that the input is + * base64-encoded. * * This function does NOT validate the input. * @@ -97,8 +97,8 @@ final class B64Url( object B64Url: - /** Instantiate [[B64Url]] from the given string. Assumes that the input is - * base64-encoded. + /** Instantiate [[B64Url]] from the given string. Assumes that the input is + * base64-encoded. * * This function does NOT validate the input. * @@ -143,8 +143,8 @@ final class Hex( object Hex: - /** Instantiate [[Hex]] from the given string. Assumes that the input is - * hex-encoded. + /** Instantiate [[Hex]] from the given string. Assumes that the input is + * hex-encoded. * * This function does NOT validate the input. * diff --git a/src/main/scala/gs/std/v0/Encoder.scala b/modules/core/src/main/scala/src/gs/std/v0/core/Encoder.scala similarity index 97% rename from src/main/scala/gs/std/v0/Encoder.scala rename to modules/core/src/main/scala/src/gs/std/v0/core/Encoder.scala index 89dbc6d..1278aeb 100644 --- a/src/main/scala/gs/std/v0/Encoder.scala +++ b/modules/core/src/main/scala/src/gs/std/v0/core/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/modules/core/src/main/scala/src/gs/std/v0/core/HexDecoder.scala similarity index 95% rename from src/main/scala/gs/std/v0/HexDecoder.scala rename to modules/core/src/main/scala/src/gs/std/v0/core/HexDecoder.scala index d5159a6..c48c1e3 100644 --- a/src/main/scala/gs/std/v0/HexDecoder.scala +++ b/modules/core/src/main/scala/src/gs/std/v0/core/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/modules/core/src/main/scala/src/gs/std/v0/core/HexEncoder.scala similarity index 92% rename from src/main/scala/gs/std/v0/HexEncoder.scala rename to modules/core/src/main/scala/src/gs/std/v0/core/HexEncoder.scala index c6960c1..7430199 100644 --- a/src/main/scala/gs/std/v0/HexEncoder.scala +++ b/modules/core/src/main/scala/src/gs/std/v0/core/HexEncoder.scala @@ -1,4 +1,4 @@ -package gs.std.v0 +package gs.std.v0.core import java.util.HexFormat 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/modules/effect/src/main/scala/src/gs/std/v0/effect/ClockDateTimeProvider.scala b/modules/effect/src/main/scala/src/gs/std/v0/effect/ClockDateTimeProvider.scala new file mode 100644 index 0000000..3074720 --- /dev/null +++ b/modules/effect/src/main/scala/src/gs/std/v0/effect/ClockDateTimeProvider.scala @@ -0,0 +1,87 @@ +package gs.std.v0.effect + +import cats.effect.Sync +import cats.syntax.all.* +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZonedDateTime +import java.time.ZoneId + +/** Implementation of [[DateTimeProvider]] based on a specific + * `java.time.Clock`. All dates and times are calculated using this clock. + * + * @param clock + * The clock used to calculate dates and times. + */ +final class ClockDateTimeProvider[F[_]: Sync]( + val clock: Clock +) extends DateTimeProvider[F]: + + /** @inheritDocs + */ + override def timestamp(): F[Instant] = + Sync[F].delay(Instant.now(clock)) + + /** @inheritDocs + */ + override def nowInstant(): F[Instant] = + Sync[F].delay(Instant.now(clock)) + + /** @inheritDocs + */ + override def nowZoned(): F[ZonedDateTime] = + Sync[F].delay(ZonedDateTime.now(clock)) + + /** @inheritDocs + */ + override def nowOffset(): F[OffsetDateTime] = + Sync[F].delay(OffsetDateTime.now(clock)) + + /** @inheritDocs + */ + override def today(): F[LocalDate] = + Sync[F].delay(LocalDate.now(clock)) + + /** @inheritDocs + */ + override def yesterday(): F[LocalDate] = + today().map(_.minusDays(1L)) + + /** @inheritDocs + */ + override def tomorrow(): F[LocalDate] = + today().map(_.plusDays(1L)) + +object ClockDateTimeProvider: + + /** Instantiate a new [[ClockDateTimeProvider]]. + * + * @param clock + * The clock to use for all date/time calculations. + * @return + * The new [[DateTimeProvider]] instance. + */ + def apply[F[_]: Sync](clock: Clock): DateTimeProvider[F] = + new ClockDateTimeProvider[F](clock) + + /** @return + * A new [[ClockDateTimeProvider]] based on the system's default zone using + * the system's clock. + */ + def system[F[_]: Sync]: DateTimeProvider[F] = + new ClockDateTimeProvider[F](Clock.systemDefaultZone()) + + /** Use a system clock aligned to the given zone. + * + * @param zoneId + * The identifier of the zone to target. + * @return + * A new [[ClockDateTimeProvider]] based on the specified zone using the + * system's clock. + */ + def forZone[F[_]: Sync](zoneId: ZoneId): DateTimeProvider[F] = + new ClockDateTimeProvider[F](Clock.system(zoneId)) + +end ClockDateTimeProvider diff --git a/modules/effect/src/main/scala/src/gs/std/v0/effect/DateTimeProvider.scala b/modules/effect/src/main/scala/src/gs/std/v0/effect/DateTimeProvider.scala new file mode 100644 index 0000000..e95066b --- /dev/null +++ b/modules/effect/src/main/scala/src/gs/std/v0/effect/DateTimeProvider.scala @@ -0,0 +1,44 @@ +package gs.std.v0.effect + +import java.time.Instant +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZonedDateTime + +/** Provides date and time values. + */ +trait DateTimeProvider[F[_]]: + /** @return + * The current timestamp (`java.time.Instant`). + */ + def timestamp(): F[Instant] + + /** @return + * The current `java.time.Instant`. + */ + def nowInstant(): F[Instant] + + /** @return + * The current date and time in the configured zone. + */ + def nowZoned(): F[ZonedDateTime] + + /** @return + * The current date and time in the configured offset. + */ + def nowOffset(): F[OffsetDateTime] + + /** @return + * The current date. + */ + def today(): F[LocalDate] + + /** @return + * The date before today. + */ + def yesterday(): F[LocalDate] + + /** @return + * The date after today. + */ + def tomorrow(): F[LocalDate] diff --git a/modules/effect/src/main/scala/src/gs/std/v0/effect/Rng.scala b/modules/effect/src/main/scala/src/gs/std/v0/effect/Rng.scala new file mode 100644 index 0000000..813fce8 --- /dev/null +++ b/modules/effect/src/main/scala/src/gs/std/v0/effect/Rng.scala @@ -0,0 +1,388 @@ +package gs.std.v0.effect + +import cats.Applicative +import cats.effect.Sync +import cats.syntax.all.* +import fs2.Stream +import java.security.SecureRandom +import java.util.Random + +/** Random number generator. + */ +trait Rng[F[_]]: + + /** Explicitly set or update the seed using the given 8 bytes. + * + * @param seed + * The seed value to assign or augment. + * @return + * This [[Rng]] instance. + */ + def updateSeed(seed: Long): F[Rng[F]] + + /** @return + * The next (unbounded) random `Int`. + */ + def nextInt(): F[Int] + + /** Calculate a number bounded inclusively within 0 and some bound. + * + * @param bound + * The bound. + * @return + * The calculated number. + */ + def nextInt(bound: Int): F[Int] + + /** Calculate a number bounded inclusively within some origin and some bound. + * + * @param origin + * The origin. + * @param bound + * The bound. + * @return + * The calculated number. + */ + def nextInt( + origin: Int, + bound: Int + ): F[Int] + + /** @return + * The next (unbounded) random `Long`. + */ + def nextLong(): F[Long] + + /** Calculate a number bounded inclusively within 0 and some bound. + * + * @param bound + * The bound. + * @return + * The calculated number. + */ + def nextLong(bound: Long): F[Long] + + /** Calculate a number bounded inclusively within some origin and some bound. + * + * @param origin + * The origin. + * @param bound + * The bound. + * @return + * The calculated number. + */ + def nextLong( + origin: Long, + bound: Long + ): F[Long] + + /** @return + * The next (unbounded) random `Double`. + */ + def nextDouble(): F[Double] + + /** Calculate a number bounded inclusively within 0 and some bound. + * + * @param bound + * The bound. + * @return + * The calculated number. + */ + def nextDouble(bound: Double): F[Double] + + /** Calculate a number bounded inclusively within some origin and some bound. + * + * @param origin + * The origin. + * @param bound + * The bound. + * @return + * The calculated number. + */ + def nextDouble( + origin: Double, + bound: Double + ): F[Double] + + /** @return + * The next randomly selected `true` or `false` value. + */ + def nextBoolean(): F[Boolean] + + /** Produce an infinite stream of `Int` bounded inclusively within some origin + * and some bound. + * + * @param origin + * The origin. + * @param bound + * The bound. + * @return + * The infinite stream of values. + */ + def nextInts( + origin: Int, + bound: Int + ): Stream[F, Int] + + /** Produce an infinite stream of `Long` bounded inclusively within some + * origin and some bound. + * + * @param origin + * The origin. + * @param bound + * The bound. + * @return + * The infinite stream of values. + */ + def nextLongs( + origin: Long, + bound: Long + ): Stream[F, Long] + + /** Produce an infinite stream of `Double` bounded inclusively within some + * origin and some bound. + * + * @param origin + * The origin. + * @param bound + * The bound. + * @return + * The infinite stream of values. + */ + def nextDoubles( + origin: Double, + bound: Double + ): Stream[F, Double] + +object Rng: + + /** Instantiate [[Rng]] using a new `java.util.Random` instance. + * + * For secure random number generation, please refer to the `secure[F]` + * function. + * + * @param random + * The `java.util.Random` instance. + * @return + * The new [[Rng]]. + */ + def default[F[_]: Sync]: Rng[F] = + default[F](new Random()) + + /** Instantiate [[Rng]] using the given random number generator. + * + * @param random + * The `java.util.Random` instance. + * @return + * The new [[Rng]]. + */ + def default[F[_]: Sync](random: Random): Rng[F] = + new JavaRandom[F](random) + + /** Instantiate [[Rng]] using a new `java.security.SecureRandom` instance. + * + * @param random + * The `java.security.SecureRandom` instance. + * @return + * The new [[Rng]]. + */ + def secure[F[_]: Sync]: Rng[F] = + secure[F](new SecureRandom()) + + /** Instantiate [[Rng]] using the given secure random number generator. + * + * @param random + * The `java.security.SecureRandom` instance. + * @return + * The new [[Rng]]. + */ + def secure[F[_]: Sync](random: SecureRandom): Rng[F] = + new JavaRandom[F](random) + + /** @return + * New instance of [[Rng]] that always returns `0` or `false`. + */ + def zero[F[_]: Applicative]: Rng[F] = + new Zero[F] + + /** Implementation of [[Rng]] based on any given `java.util.Random` instance. + * + * @param random + * The underlying random number generator. + */ + final class JavaRandom[F[_]: Sync](random: Random) extends Rng[F]: + + /** @inheritDocs + */ + override def updateSeed(seed: Long): F[Rng[F]] = + Sync[F].delay(random.setSeed(seed)).as(this) + + /** @inheritDocs + */ + override def nextInt(): F[Int] = + Sync[F].delay(random.nextInt()) + + /** @inheritDocs + */ + override def nextInt(bound: Int): F[Int] = + Sync[F].delay(random.nextInt(bound)) + + /** @inheritDocs + */ + override def nextInt( + origin: Int, + bound: Int + ): F[Int] = + Sync[F].delay(random.nextInt(origin, bound)) + + /** @inheritDocs + */ + override def nextLong(): F[Long] = + Sync[F].delay(random.nextLong()) + + /** @inheritDocs + */ + override def nextLong(bound: Long): F[Long] = + Sync[F].delay(random.nextLong(bound)) + + /** @inheritDocs + */ + override def nextLong( + origin: Long, + bound: Long + ): F[Long] = + Sync[F].delay(random.nextLong(origin, bound)) + + /** @inheritDocs + */ + override def nextDouble(): F[Double] = + Sync[F].delay(random.nextDouble()) + + /** @inheritDocs + */ + override def nextDouble(bound: Double): F[Double] = + Sync[F].delay(random.nextDouble(bound)) + + /** @inheritDocs + */ + override def nextDouble( + origin: Double, + bound: Double + ): F[Double] = + Sync[F].delay(random.nextDouble(origin, bound)) + + /** @inheritDocs + */ + override def nextBoolean(): F[Boolean] = + Sync[F].delay(random.nextBoolean()) + + /** @inheritDocs + */ + override def nextInts( + origin: Int, + bound: Int + ): Stream[F, Int] = + Stream.repeatEval(nextInt(origin, bound)) + + /** @inheritDocs + */ + override def nextLongs( + origin: Long, + bound: Long + ): Stream[F, Long] = + Stream.repeatEval(nextLong(origin, bound)) + + /** @inheritDocs + */ + override def nextDoubles( + origin: Double, + bound: Double + ): Stream[F, Double] = + Stream.repeatEval(nextDouble(origin, bound)) + + end JavaRandom + + /** Implementation of [[Rng]] that always returns `0` or `false`. + */ + final class Zero[F[_]: Applicative] extends Rng[F]: + + /** @inheritDocs + */ + override def updateSeed(seed: Long): F[Rng[F]] = Applicative[F].pure(this) + + /** @inheritDocs + */ + override def nextInt(): F[Int] = Applicative[F].pure(0) + + /** @inheritDocs + */ + override def nextInt(bound: Int): F[Int] = Applicative[F].pure(0) + + /** @inheritDocs + */ + override def nextInt( + origin: Int, + bound: Int + ): F[Int] = Applicative[F].pure(0) + + /** @inheritDocs + */ + override def nextLong(): F[Long] = Applicative[F].pure(0) + + /** @inheritDocs + */ + override def nextLong(bound: Long): F[Long] = Applicative[F].pure(0) + + /** @inheritDocs + */ + override def nextLong( + origin: Long, + bound: Long + ): F[Long] = Applicative[F].pure(0) + + /** @inheritDocs + */ + override def nextDouble(): F[Double] = Applicative[F].pure(0.0) + + /** @inheritDocs + */ + override def nextDouble(bound: Double): F[Double] = Applicative[F].pure(0.0) + + /** @inheritDocs + */ + override def nextDouble( + origin: Double, + bound: Double + ): F[Double] = Applicative[F].pure(0.0) + + /** @inheritDocs + */ + override def nextBoolean(): F[Boolean] = Applicative[F].pure(false) + + /** @inheritDocs + */ + override def nextInts( + origin: Int, + bound: Int + ): Stream[F, Int] = + Stream.repeatEval(Applicative[F].pure(0)) + + /** @inheritDocs + */ + override def nextLongs( + origin: Long, + bound: Long + ): Stream[F, Long] = + Stream.repeatEval(Applicative[F].pure(0)) + + /** @inheritDocs + */ + override def nextDoubles( + origin: Double, + bound: Double + ): Stream[F, Double] = + Stream.repeatEval(Applicative[F].pure(0.0)) + + end Zero + +end Rng diff --git a/modules/effect/src/test/resources/sha256-input b/modules/effect/src/test/resources/sha256-input new file mode 100644 index 0000000..9fd0447 Binary files /dev/null and b/modules/effect/src/test/resources/sha256-input differ diff --git a/modules/io/src/main/scala/src/gs/std/v0/io/Files.scala b/modules/io/src/main/scala/src/gs/std/v0/io/Files.scala new file mode 100644 index 0000000..8b7c6bd --- /dev/null +++ b/modules/io/src/main/scala/src/gs/std/v0/io/Files.scala @@ -0,0 +1,118 @@ +package gs.std.v0.io + +import cats.effect.Resource +import cats.effect.Sync +import java.io.InputStream +import java.io.OutputStream +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.nio.file.OpenOption +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import scala.jdk.CollectionConverters.* + +/** Common operations on files. + * + * Includes wrappers around some `java.nio.file.Files` functionality. + * + * For streaming operations, just use the `fs2.io` library. + */ +object Files: + + def readFileAsString[F[_]: Sync]( + path: Path, + charset: Charset = StandardCharsets.UTF_8 + ): F[String] = + Sync[F].delay(java.nio.file.Files.readString(path, charset)) + + def readFileAsBytes[F[_]: Sync](path: Path): F[Array[Byte]] = + Sync[F].delay(java.nio.file.Files.readAllBytes(path)) + + def readFileAsLines[F[_]: Sync]( + path: Path, + charset: Charset = StandardCharsets.UTF_8 + ): F[List[String]] = + Sync[F].delay( + java.nio.file.Files.readAllLines(path, charset).asScala.toList + ) + + def overwriteFileWithString[F[_]: Sync]( + path: Path, + data: String + ): F[Unit] = + Sync[F].delay( + java.nio.file.Files.writeString( + path, + data, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + ) + ) + + def overwriteFileWithBytes[F[_]: Sync]( + path: Path, + data: Array[Byte] + ): F[Unit] = + Sync[F].delay( + java.nio.file.Files.write( + path, + data, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + ) + ) + + def appendFileWithString[F[_]: Sync]( + path: Path, + data: String + ): F[Unit] = + Sync[F].delay( + java.nio.file.Files.writeString( + path, + data, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND + ) + ) + + def appendFileWithBytes[F[_]: Sync]( + path: Path, + data: Array[Byte] + ): F[Unit] = + Sync[F].delay( + java.nio.file.Files + .write(path, data, StandardOpenOption.WRITE, StandardOpenOption.APPEND) + ) + + def isRegularFile[F[_]: Sync](path: Path): F[Boolean] = + Sync[F].delay(java.nio.file.Files.isRegularFile(path)) + + def isReadable[F[_]: Sync](path: Path): F[Boolean] = + Sync[F].delay(java.nio.file.Files.isReadable(path)) + + def isWritable[F[_]: Sync](path: Path): F[Boolean] = + Sync[F].delay(java.nio.file.Files.isWritable(path)) + + def isDirectory[F[_]: Sync](path: Path): F[Boolean] = + Sync[F].delay(java.nio.file.Files.isDirectory(path)) + + def isExecutable[F[_]: Sync](path: Path): F[Boolean] = + Sync[F].delay(java.nio.file.Files.isExecutable(path)) + + def openInputStream[F[_]: Sync]( + path: Path, + openOptions: OpenOption* + ): Resource[F, InputStream] = + Resource.make( + Sync[F].delay(java.nio.file.Files.newInputStream(path, openOptions*)) + )(inputStream => Sync[F].delay(inputStream.close())) + + def openOutputStream[F[_]: Sync]( + path: Path, + openOptions: OpenOption* + ): Resource[F, OutputStream] = + Resource.make( + Sync[F].delay(java.nio.file.Files.newOutputStream(path, openOptions*)) + )(outputStream => Sync[F].delay(outputStream.close())) + +end Files diff --git a/src/main/scala/gs/std/v0/Blob.scala b/src/main/scala/gs/std/v0/Blob.scala deleted file mode 100644 index d18a759..0000000 --- a/src/main/scala/gs/std/v0/Blob.scala +++ /dev/null @@ -1,44 +0,0 @@ -package gs.std.v0 - -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/src/main/scala/gs/std/v0/ByteCount.scala b/src/main/scala/gs/std/v0/ByteCount.scala deleted file mode 100644 index 8a95565..0000000 --- a/src/main/scala/gs/std/v0/ByteCount.scala +++ /dev/null @@ -1,132 +0,0 @@ -package gs.std.v0 - -/** - * 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/src/main/scala/gs/std/v0/CreatedAt.scala b/src/main/scala/gs/std/v0/CreatedAt.scala deleted file mode 100644 index 4e47dcc..0000000 --- a/src/main/scala/gs/std/v0/CreatedAt.scala +++ /dev/null @@ -1,38 +0,0 @@ -package gs.std.v0 - -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/src/main/scala/gs/std/v0/Decoder.scala b/src/main/scala/gs/std/v0/Decoder.scala deleted file mode 100644 index 2570ab8..0000000 --- a/src/main/scala/gs/std/v0/Decoder.scala +++ /dev/null @@ -1,13 +0,0 @@ -package gs.std.v0 - -/** - * 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/src/main/scala/gs/std/v0/MD5.scala b/src/main/scala/gs/std/v0/MD5.scala deleted file mode 100644 index fbe5f60..0000000 --- a/src/main/scala/gs/std/v0/MD5.scala +++ /dev/null @@ -1,105 +0,0 @@ -package gs.std.v0 - -import java.security.MessageDigest -import java.nio.charset.Charset -import java.nio.charset.StandardCharsets - -/** - * 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/src/main/scala/gs/std/v0/Nat.scala b/src/main/scala/gs/std/v0/Nat.scala deleted file mode 100644 index 07b5d78..0000000 --- a/src/main/scala/gs/std/v0/Nat.scala +++ /dev/null @@ -1,81 +0,0 @@ -package gs.std.v0 - -/** - * 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/src/main/scala/gs/std/v0/SHA256.scala b/src/main/scala/gs/std/v0/SHA256.scala deleted file mode 100644 index c14df04..0000000 --- a/src/main/scala/gs/std/v0/SHA256.scala +++ /dev/null @@ -1,105 +0,0 @@ -package gs.std.v0 - -import java.security.MessageDigest -import java.nio.charset.Charset -import java.nio.charset.StandardCharsets - -/** - * 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/src/main/scala/gs/std/v0/Size.scala b/src/main/scala/gs/std/v0/Size.scala deleted file mode 100644 index 0de45fa..0000000 --- a/src/main/scala/gs/std/v0/Size.scala +++ /dev/null @@ -1,97 +0,0 @@ -package gs.std.v0 - -/** - * 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/src/main/scala/gs/std/v0/UpdatedAt.scala b/src/main/scala/gs/std/v0/UpdatedAt.scala deleted file mode 100644 index cf32ef8..0000000 --- a/src/main/scala/gs/std/v0/UpdatedAt.scala +++ /dev/null @@ -1,39 +0,0 @@ -package gs.std.v0 - -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 -