diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..cf45f65 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,23 @@ +name: push + +on: + pull_request: + push: + +jobs: + + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 19 + cache: sbt + + - name: Run sbt + run: sbt -v compile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21dfbf8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,246 @@ +### Emacs ### +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +.idea +*.iml +*.ipr + +# CMake +cmake-build-*/ + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + + +### Linux ### + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Metals ### +.metals/ +.bloop/ +project/**/metals.sbt + +### SBT ### +# Simple Build Tool +# http://www.scala-sbt.org/release/docs/Getting-Started/Directories.html#configuring-version-control + +dist/* +target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ +.history +.cache +.lib/ + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.ionide + +# Support for Project snippet scope +.vscode/*.code-snippets + +# Ignore code-workspaces +*.code-workspace + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# scala/IDEs +.bsp +*.semanticdb + +# direnv +.env +.envrc.* + +# tellerops/teller +.teller.yml + +# LibreOffice +.~lock* + +# Local analysis files +/*.py +/*.csv + +# HTTP client files +*.private.* +/reports/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5722a60 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Reproducer for https://github.com/Iltotore/iron/issues/190 \ No newline at end of file diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..74afa31 --- /dev/null +++ b/build.sbt @@ -0,0 +1,18 @@ +inThisBuild( + scalaVersion := "3.3.1" +) + +lazy val api = Project("api", file("modules/api")) + .settings( + libraryDependencies ++= Seq( + "org.typelevel" %% "cats-core" % "2.10.0", + "com.softwaremill.sttp.tapir" %% "tapir-core" % "1.8.5", + "com.softwaremill.sttp.tapir" %% "tapir-iron" % "1.8.5", + "io.circe" %% "circe-core" % "0.14.6", + "org.scodec" %% "scodec-core" % "2.2.2", + "org.scodec" %% "scodec-bits" % "1.1.38", + "io.github.iltotore" %% "iron" % "2.3.0", + "io.github.iltotore" %% "iron-cats" % "2.3.0", + "io.github.iltotore" %% "iron-circe" % "2.3.0" + ) + ) diff --git a/modules/api/src/main/scala/common/circe/codecs.scala b/modules/api/src/main/scala/common/circe/codecs.scala new file mode 100644 index 0000000..1416608 --- /dev/null +++ b/modules/api/src/main/scala/common/circe/codecs.scala @@ -0,0 +1,11 @@ +package common.circe + +import io.circe.Codec +import io.circe.Decoder +import io.circe.Encoder +import sttp.model.Uri + +object codecs: + given Codec[Int] = Codec.from(Decoder.decodeInt, Encoder.encodeInt) + given Codec[String] = Codec.from(Decoder.decodeString, Encoder.encodeString) + given Codec[Uri] = Codec.from(Decoder[String].emap(Uri.parse), Encoder[String].contramap(_.toString)) diff --git a/modules/api/src/main/scala/common/iron/constraint/all.scala b/modules/api/src/main/scala/common/iron/constraint/all.scala new file mode 100644 index 0000000..c747b92 --- /dev/null +++ b/modules/api/src/main/scala/common/iron/constraint/all.scala @@ -0,0 +1,5 @@ +package common.iron.constraint + +object all: + export scodec.* + export scodec.given diff --git a/modules/api/src/main/scala/common/iron/constraint/scodec.scala b/modules/api/src/main/scala/common/iron/constraint/scodec.scala new file mode 100644 index 0000000..d949877 --- /dev/null +++ b/modules/api/src/main/scala/common/iron/constraint/scodec.scala @@ -0,0 +1,20 @@ +package common.iron.constraint + +import _root_.scodec.bits.ByteVector +import io.github.iltotore.iron.* +import io.github.iltotore.iron.compileTime.* +import io.github.iltotore.iron.constraint.all.* +import scala.compiletime.summonInline + +object scodec: + + type MinLengthL[V <: Long] = Length[GreaterEqual[V]] `DescribedAs` "Should have a minimum length of " + V + type MaxLengthL[V <: Long] = Length[LessEqual[V]] `DescribedAs` "Should have a maximum length of " + V + type FixedLengthL[V <: Long] = Length[StrictEqual[V]] `DescribedAs` "Should have an exact length of " + V + + class LengthByteVector[C, Impl <: Constraint[Long, C]](using Impl) extends Constraint[ByteVector, Length[C]]: + override inline def test(value: ByteVector): Boolean = summonInline[Impl].test(value.length) + override inline def message: String = "Length: (" + summonInline[Impl].message + ")" + + inline given lengthByteVector[C, Impl <: Constraint[Long, C]](using inline impl: Impl): LengthByteVector[C, Impl] = + new LengthByteVector diff --git a/modules/api/src/main/scala/common/scodec/codecs.scala b/modules/api/src/main/scala/common/scodec/codecs.scala new file mode 100644 index 0000000..c389032 --- /dev/null +++ b/modules/api/src/main/scala/common/scodec/codecs.scala @@ -0,0 +1,36 @@ +package common.scodec + +import cats.Show +import common.circe.codecs.given +import common.tapir.syntax.* +import io.circe.Codec as CirceCodec +import io.circe.KeyDecoder as CirceKeyDecoder +import io.circe.KeyEncoder as CirceKeyEncoder +import scala.language.adhocExtensions +import scodec.bits.ByteVector +import sttp.tapir +import sttp.tapir.Codec.PlainCodec as TapirPlainCodec +import sttp.tapir.Codec as TapirCodec +import sttp.tapir.Schema as TapirSchema + +object codecs: + + trait ByteVectorCodecs: + protected val decode: String => Either[String, ByteVector] + protected val encode: ByteVector => String + + given CirceKeyDecoder[ByteVector] = decode(_).toOption + given CirceKeyEncoder[ByteVector] = encode(_) + given CirceCodec[ByteVector] = CirceCodec[String].iemap(decode)(encode) + given TapirPlainCodec[ByteVector] = TapirCodec.string.iemap(decode)(encode) + given TapirSchema[ByteVector] = TapirSchema.string + given Show[ByteVector] = encode(_) + + object ByteVectorCodecs: + trait Hex extends ByteVectorCodecs: + override final val decode: String => Either[String, ByteVector] = ByteVector.fromHexDescriptive(_) + override final val encode: ByteVector => String = _.toHex + + trait Base64 extends ByteVectorCodecs: + override final val decode: String => Either[String, ByteVector] = ByteVector.fromBase64Descriptive(_) + override final val encode: ByteVector => String = _.toBase64 diff --git a/modules/api/src/main/scala/common/tapir/syntax.scala b/modules/api/src/main/scala/common/tapir/syntax.scala new file mode 100644 index 0000000..2c2fef9 --- /dev/null +++ b/modules/api/src/main/scala/common/tapir/syntax.scala @@ -0,0 +1,11 @@ +package common.tapir + +import _root_.sttp.tapir.* +import cats.syntax.all.* +import cats.Show + +object syntax: + + extension [L, H: Show, CF <: CodecFormat](codec: Codec[L, H, CF]) + def iemap[HH](f: H => Either[String, HH])(g: HH => H): Codec[L, HH, CF] = + codec.mapDecode(s => DecodeResult.fromEitherString(s.show, f(s)))(g) diff --git a/modules/api/src/main/scala/common/types.scala b/modules/api/src/main/scala/common/types.scala new file mode 100644 index 0000000..8550c4e --- /dev/null +++ b/modules/api/src/main/scala/common/types.scala @@ -0,0 +1,38 @@ +package common + +import _root_.scodec.bits +import _root_.scodec.bits.ByteVector +import cats.syntax.all.* +import cats.Order +import cats.Show +import common.circe.codecs.given +import common.iron.constraint.all.* +import common.iron.constraint.all.given +import common.scodec.codecs.ByteVectorCodecs +import common.scodec.codecs.ByteVectorCodecs.Base64 +import common.tapir.syntax.* +import io.circe.Codec as CirceCodec +import io.github.iltotore.iron.* +import io.github.iltotore.iron.constraint.all.* +import sttp.tapir +import sttp.tapir.codec.iron.TapirCodecIron +import sttp.tapir.Codec.PlainCodec as TapirPlainCodec +import sttp.tapir.Codec as TapirCodec +import sttp.tapir.Schema as TapirSchema + +private type PayloadConstraints = DescribedAs[MaxLengthL[1048576L], "Payload must be 1MB max"] +opaque type Payload = ByteVector :| PayloadConstraints +object Payload extends RefinedTypeOps[ByteVector, PayloadConstraints, Payload], Base64, TapirCodecIron: + given CirceCodec[Payload] = summon[CirceCodec[ByteVector]].iemap(either(_))(_.value) + given TapirPlainCodec[Payload] = summon[TapirPlainCodec[ByteVector]].iemap(either(_))(_.value) + given TapirSchema[Payload] = ironTypeSchema[ByteVector, PayloadConstraints] + given Show[Payload] = summon[Show[ByteVector]].contramap(_.value) + +private type VersionConstraints = DescribedAs[GreaterEqual[0], "Version must be positive"] +opaque type Version = Int :| VersionConstraints +object Version extends RefinedTypeOps[Int, VersionConstraints, Version], TapirCodecIron: + given CirceCodec[Version] = summon[CirceCodec[Int]].iemap(either(_))(_.value) + given TapirPlainCodec[Version] = summon[TapirPlainCodec[Int]].iemap(either(_))(_.value) + given TapirSchema[Version] = ironTypeSchema[Int, VersionConstraints] + given Order[Version] = Order.fromOrdering + given Show[Version] = summon[Show[Int]].contramap(_.value) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..e8a1e24 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.7