diff --git a/.dockerignore b/.dockerignore index c085d8a..064c511 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ *.gch .cache target +*.o **/target/** project/project diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e4e313..42084ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,21 +31,11 @@ jobs: path: | ~/Library/Caches/sbt-vcpkg/vcpkg ~/.cache/sbt-vcpkg/vcpkg + ~/.cache/sbt-vcpkg/vcpkg-install key: ${{ runner.os }}-sbt-vcpkg - name: Setup for Scala Native - run: | - sudo apt update - sudo apt install lsb-release wget software-properties-common - wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - - sudo add-apt-repository "deb http://apt.llvm.org/focal/ llvm-toolchain-focal-13 main" - sudo apt-get update - sudo apt-get install clang-13 lldb-13 libclang-13-dev llvm-13-dev lld-13 - sudo curl --output /usr/share/keyrings/nginx-keyring.gpg https://unit.nginx.org/keys/nginx-keyring.gpg - sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/debian/ bullseye unit" >> /etc/apt/sources.list.d/unit.list' - sudo sh -c 'echo "deb-src [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/debian/ bullseye unit" >> /etc/apt/sources.list.d/unit.list' - sudo apt update - sudo apt install -y unit-dev + run: sudo ./scripts/setup-debian.sh - name: Run tests run: sbt app/nativeLink test diff --git a/.gitignore b/.gitignore index a38eb6f..5062ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,9 @@ *.gch .cache target +*.o **/target/** project/project project/metals.sbt build/** - diff --git a/Dockerfile b/Dockerfile index 7b53fd3..5253df2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,16 @@ FROM eclipse-temurin:17-focal as builder -RUN apt update && apt install -y curl && \ - curl -Lo /usr/local/bin/sbt https://raw.githubusercontent.com/sbt/sbt/1.8.x/sbt && \ - chmod +x /usr/local/bin/sbt && \ - curl -Lo llvm.sh https://apt.llvm.org/llvm.sh && \ - chmod +x llvm.sh && \ - apt install -y lsb-release wget software-properties-common gnupg && \ - ./llvm.sh 13 && \ - apt update && \ - apt install -y zip unzip tar make cmake autoconf pkg-config libclang-13-dev git && \ - # install Unit, OpenSSL and Unit development headers - curl --output /usr/share/keyrings/nginx-keyring.gpg \ - https://unit.nginx.org/keys/nginx-keyring.gpg && \ - echo "deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/debian/ bullseye unit" >> /etc/apt/sources.list.d/unit.list && \ - echo "deb-src [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/debian/ bullseye unit" >> /etc/apt/sources.list.d/unit.list && \ - apt update && \ - apt install -y unit-dev +COPY scripts /scripts +RUN /scripts/setup-debian.sh ENV LLVM_BIN "/usr/lib/llvm-13/bin" ENV CC "/usr/lib/llvm-13/bin/clang" - ENV SN_RELEASE "fast" ENV CI "true" COPY . /sources -RUN cd /sources && sbt clean app/test buildApp +RUN cd /sources && sbt clean buildApp FROM nginx/unit:1.27.0-minimal as runtime_deps diff --git a/app/src/main/scala/api.helpers.scala b/app/src/main/scala/api.helpers.scala index fd15186..ad2e9a3 100644 --- a/app/src/main/scala/api.helpers.scala +++ b/app/src/main/scala/api.helpers.scala @@ -1,8 +1,6 @@ package twotm8 -import trail.* import snunit.* -import snunit.routes.* import scala.util.Try import scala.util.Failure import scala.util.Success @@ -23,125 +21,10 @@ trait ApiHelpers: scribe.error(s"Failed request at <${req.method.name} ${req.path}>", exc) req.serverError("Something broke yo") - inline def builder(routes: (Route[?], ArgsHandler[?])*): Handler = - routes.foldRight[Handler](_.notFound()) { case ((r, a), acc) => - RouteHandler( - r.asInstanceOf[Route[Any]], - a.asInstanceOf[ArgsHandler[Any]], - acc - ) - } - - inline def api(methods: (Method, Handler)*): Handler = - val mp = methods.toMap - - request => - mp.get(request.method) match - case None => request.notFound() - case Some(h) => - h.handleRequest(request) - end api - - import upickle.default.{Reader, read} - - inline def json[T: upickle.default.Reader](inline request: Request)( - inline f: T => Unit - ) = - val payload = Try(upickle.default.read[T](request.contentRaw)) - - payload match - case Success(p) => f(p) - case Failure(ex) => - scribe.error( - s"Error handling JSON request at <${request.method.name}> ${request.path}", - ex - ) - request.badRequest("Invalid payload") - end json - extension (r: Request) - inline def sendJson[T: upickle.default.Writer]( - status: StatusCode, - content: T, - headers: Map[String, String] = Map.empty - ) = - r.send( - statusCode = status, - content = upickle.default.writeJs(content).render(), - headers = headers.updated("Content-type", "application/json").toSeq - ) - inline def badRequest( - content: String, - headers: Map[String, String] = Map.empty - ) = - r.send( - statusCode = StatusCode.BadRequest, - content = content, - headers = Seq.empty - ) - inline def redirect(location: String) = - r.send( - StatusCode.TemporaryRedirect, - content = "", - headers = Seq("Location" -> location) - ) - - inline def noContent() = - r.send(StatusCode.NoContent, "", Seq.empty) - - inline def unauthorized(inline msg: String = "Unathorized") = - r.send(StatusCode.Unauthorized, msg, Seq.empty) - - inline def notFound(inline msg: String = "Not found") = - r.send(StatusCode.NotFound, msg, Seq.empty) - inline def serverError(inline msg: String = "Something broke yo") = r.send(StatusCode.InternalServerError, msg, Seq.empty) end extension - - given Codec[UUID] with - def encode(t: UUID) = Some(t.toString) - def decode(v: Option[String]) = - v match - case None => None - case Some(str) => - try Some(UUID.fromString(str)) - catch case exc => None - - case class Cookie( - name: String, - value: String, - params: Map[String, Option[String]] - ): - def serialize = - name + "=" + value + - "; " + - params.toList - .sortBy(_._1) - .map((key, value) => key + value.map("=" + _).getOrElse("")) - .mkString("; ") - end Cookie - - object Cookie: - def read(raw: String): Option[Cookie] = - try - val params = - raw - .split(";") - .map(pair => - val split = pair.split("=") - if split.size == 1 then split(0).trim -> Option.empty - else split(0).trim -> Option(split(1)) - ) - - params.headOption.map { case (name, valueO) => - Cookie(name, valueO.getOrElse(""), params.tail.toMap) - } - catch - case ex => - scribe.error("Failed to parse cookies", ex) - None - end Cookie end ApiHelpers object ApiHelpers extends ApiHelpers diff --git a/app/src/main/scala/api.scala b/app/src/main/scala/api.scala index aea9de2..be675e2 100644 --- a/app/src/main/scala/api.scala +++ b/app/src/main/scala/api.scala @@ -3,226 +3,172 @@ package api import roach.RoachException import roach.RoachFatalException -import snunit.* -import snunit.routes.* -import trail.* +import snunit.tapir.SNUnitInterpreter.* +import sttp.tapir.server.* +import sttp.tapir.* -import json.* import java.util.UUID -object Payload: - case class Login(nickname: Nickname, password: Password) - case class Register(nickname: Nickname, password: Password) - case class Create(text: Text) - case class Uwotm8(twot_id: TwotId) - case class Follow(thought_leader: AuthorId) - case class Authenticated[A](auth: AuthContext, value: A) class Api(app: App): import ApiHelpers.{*, given} - inline def routes = handleException( - api( - Method.GET -> - builder( - Root / "api" / "thought_leaders" / "me" -> protect(get_me), - Root / "api" / "thought_leaders" / Arg[String] -> - optionalAuth(get_thought_leader), - Root / "api" / "twots" / "wall" -> protect(get_wall), - Root / "api" / "health" -> get_health - ), - Method.POST -> - builder( - Root / "api" / "auth" / "login" -> login, - Root / "api" / "twots" / "create" -> protect(create_twot) - ), - Method.PUT -> - builder( - Root / "api" / "auth" / "register" -> register, - Root / "api" / "twots" / "uwotm8" -> protect(add_uwotm8), - Root / "api" / "thought_leaders" / "follow" -> protect(add_follower) - ), - Method.DELETE -> - builder( - Root / "api" / "thought_leaders" / "follow" -> protect( - delete_follower - ), - Root / "api" / "twots" / "uwotm8" -> protect(delete_uwotm8), - Root / "api" / "twots" / Arg[UUID] -> protect(delete_twot) - ) - ) + val routes = List( + endpoints.get_me + .serverSecurityLogic[AuthContext, Id](validateBearer) + .serverLogic(get_me), + endpoints.get_thought_leader + .serverSecurityLogicSuccess[Either[ErrorInfo, AuthContext], Id] { + case Some(bearer) => validateBearer(bearer) + case None => Left(ErrorInfo.Unauthorized()) + } + .serverLogic(get_thought_leader), + endpoints.get_wall + .serverSecurityLogic[AuthContext, Id](validateBearer) + .serverLogicSuccess(get_wall), + endpoints.get_health.serverLogic[Id](get_health), + endpoints.login.serverLogic[Id](login), + endpoints.create_twot + .serverSecurityLogic[AuthContext, Id](validateBearer) + .serverLogic(create_twot), + endpoints.register.serverLogic[Id](register), + endpoints.add_uwotm8 + .serverSecurityLogic[AuthContext, Id](validateBearer) + .serverLogicSuccess(add_uwotm8), + endpoints.add_follower + .serverSecurityLogic[AuthContext, Id](validateBearer) + .serverLogic(add_follower), + endpoints.delete_follower + .serverSecurityLogic[AuthContext, Id](validateBearer) + .serverLogicSuccess(delete_follower), + endpoints.delete_uwotm8 + .serverSecurityLogic[AuthContext, Id](validateBearer) + .serverLogicSuccess(delete_uwotm8), + endpoints.delete_twot + .serverSecurityLogic[AuthContext, Id](validateBearer) + .serverLogicSuccess(delete_twot) ) - inline def extractAuth(request: Request): Either[String, AuthContext] = - val auth = request.headers.find(_._1.equalsIgnoreCase("Authorization")) - - auth match - case None => Left("Unauthorized") - case Some((_, value)) => - if !value.startsWith("Bearer ") then Left("Invalid bearer") - else - val jwt = JWT(value.drop("Bearer ".length)) - app.validate(jwt) match - case None => - Left(s"Invalid token") - case Some(auth) => - Right(auth) - end match - end extractAuth - - inline def optionalAuth[A]( - unprotected: ArgsHandler[Either[A, Authenticated[A]]] - ): ArgsHandler[A] = - (request, a) => - extractAuth(request) match - case Left(msg) => - unprotected.handleRequest(request, Left(a)) - case Right(auth) => - unprotected.handleRequest(request, Right(Authenticated(auth, a))) - end optionalAuth - - inline def protect[A]( - inline unprotected: ArgsHandler[Authenticated[A]] - ): ArgsHandler[A] = - (request, a) => - extractAuth(request) match - case Left(msg) => - request.unauthorized(msg) - case Right(auth) => - unprotected.handleRequest(request, Authenticated(auth, a)) - - private val add_uwotm8: ArgsHandler[Authenticated[Unit]] = (req, i) => - import twotm8.json.codecs.given - json[Payload.Uwotm8](req) { uwot => - req.sendJson(StatusCode.OK, app.add_uwotm8(i.auth.author, uwot.twot_id)) - } - private val delete_uwotm8: ArgsHandler[Authenticated[Unit]] = (req, i) => - import twotm8.json.codecs.given - json[Payload.Uwotm8](req) { uwot => - req.sendJson( - StatusCode.OK, - app.delete_uwotm8(i.auth.author, uwot.twot_id) - ) - } - - private val add_follower: ArgsHandler[Authenticated[Unit]] = (req, i) => - import twotm8.json.codecs.given - json[Payload.Follow](req) { follow => - if i.auth.author == follow.thought_leader then - req.badRequest("You cannot follow yourself") - else - app.add_follower( - follower = i.auth.author.into(Follower), - leader = follow.thought_leader - ) - req.noContent() - } - - private val delete_follower: ArgsHandler[Authenticated[Unit]] = (req, i) => - import twotm8.json.codecs.given - json[Payload.Follow](req) { follow => - app.delete_follower( - follower = i.auth.author.into(Follower), + private def validateBearer(bearer: String): Either[ErrorInfo, AuthContext] = + val jwt = JWT(bearer) + app.validate(jwt) match + case None => + Left(ErrorInfo.Unauthorized()) + case Some(auth) => + Right(auth) + end validateBearer + + private def add_uwotm8(auth: AuthContext)(uwot: Payload.Uwotm8) = + app.add_uwotm8(auth.author, uwot.twot_id) + + private def delete_uwotm8(auth: AuthContext)(uwot: Payload.Uwotm8) = + app.delete_uwotm8(auth.author, uwot.twot_id) + + private def add_follower(auth: AuthContext)(follow: Payload.Follow) = + if auth.author == follow.thought_leader then + Left(ErrorInfo.BadRequest("You cannot follow yourself")) + else + app.add_follower( + follower = auth.author.into(Follower), leader = follow.thought_leader ) - req.noContent() - } - - private val get_wall: ArgsHandler[Authenticated[Unit]] = (req, i) => - import twotm8.json.codecs.given - val twots = app.get_wall(i.auth.author) - req.sendJson(StatusCode.OK, twots) - - private val get_me: ArgsHandler[Authenticated[Unit]] = (req, i) => - import twotm8.json.codecs.given - app.get_thought_leader(i.auth.author) match - case None => req.unauthorized() - case Some(tl) => - req.sendJson(StatusCode.OK, tl) - - private val create_twot: ArgsHandler[Authenticated[Unit]] = (req, i) => - import twotm8.json.codecs.given - json[Payload.Create](req) { createPayload => - val text = createPayload.text.update(_.trim) - if (text.raw.length == 0) then req.badRequest("Twot cannot be empty") - else if (text.raw.length > 128) then - req.badRequest( + Right(()) + + private def delete_follower(auth: AuthContext)(follow: Payload.Follow) = + app.delete_follower( + follower = auth.author.into(Follower), + leader = follow.thought_leader + ) + + private def get_wall(auth: AuthContext)(unit: Unit) = + val twots = app.get_wall(auth.author) + twots + + private def get_me(auth: AuthContext)( + unit: Unit + ): Either[ErrorInfo, ThoughtLeader] = + app.get_thought_leader(auth.author) match + case None => Left(ErrorInfo.Unauthorized()) + case Some(tl) => Right(tl) + + private def create_twot(auth: AuthContext)(createPayload: Payload.Create) = + val text = createPayload.text.update(_.trim) + if (text.raw.length == 0) then + Left(ErrorInfo.BadRequest("Twot cannot be empty")) + else if (text.raw.length > 128) then + Left( + ErrorInfo.BadRequest( s"Twot cannot be longer than 128 characters (you have ${text.raw.length})" ) - else - app.create_twot(i.auth.author, text.update(_.toUpperCase)) match - case None => - req.badRequest("Something went wrong, and it's probably your fault") - case Some(_) => - req.noContent() - end if - } - - private val delete_twot: ArgsHandler[Authenticated[UUID]] = (req, uuid) => - val authorId = uuid.auth.author - val twotId = TwotId(uuid.value) + ) + else + app.create_twot(auth.author, text.update(_.toUpperCase)) match + case None => + Left( + ErrorInfo.BadRequest( + "Something went wrong, and it's probably your fault" + ) + ) + case Some(_) => + Right(()) + end if + end create_twot + + private def delete_twot(auth: AuthContext)(uuid: UUID): Unit = + val authorId = auth.author + val twotId = TwotId(uuid) app.delete_twot(authorId, twotId) - req.noContent() - private val get_health: ArgsHandler[Unit] = (req, i) => - import twotm8.json.codecs.given + private def get_health(unit: Unit): Either[ErrorInfo, Health] = val health = app.healthCheck() - if health.good then req.sendJson(StatusCode.OK, health) - else req.sendJson(StatusCode.InternalServerError, health) - - private val register: ArgsHandler[Unit] = (req, i) => - import twotm8.json.codecs.given - json[Payload.Register](req) { reg => - val length = reg.password.process(_.length) - val hasWhitespace = reg.password.process(_.exists(_.isWhitespace)) - val nicknameHasWhitespace = - reg.nickname.raw.exists(_.isWhitespace) - - if hasWhitespace then - req.badRequest("Password cannot contain whitespace symbols") - else if length == 0 then req.badRequest("Password cannot be empty") - else if length < 8 then - req.badRequest("Password cannot be shorter than 8 symbols") - else if length > 64 then - req.badRequest("Password cannot be longer than 64 symbols") - else if reg.nickname.raw.length < 4 then - req.badRequest("Nickname cannot be shorter than 4 symbols") - else if reg.nickname.raw.length > 32 then - req.badRequest("Nickname cannot be longer that 32 symbols") - else if nicknameHasWhitespace then - req.badRequest("Nickname cannot have whitespace in it") - else - app.register(reg.nickname, reg.password) match - case None => - req.badRequest("This nickname is already taken") - case Some(_) => - req.noContent() - end if - } - - private val get_thought_leader - : ArgsHandler[Either[String, Authenticated[String]]] = - (req, i) => - import twotm8.json.codecs.given - val nickname = i match - case Left(name) => name - case Right(Authenticated(_, name)) => name - - val watcher = i.toOption.map(_.auth.author) - - app.get_thought_leader(Nickname(nickname), watcher) match + if health.good then Right(health) + else Left(ErrorInfo.ServerError()) + + private def register(reg: Payload.Register) = + val length = reg.password.process(_.length) + val hasWhitespace = reg.password.process(_.exists(_.isWhitespace)) + val nicknameHasWhitespace = + reg.nickname.raw.exists(_.isWhitespace) + + if hasWhitespace then + Left(ErrorInfo.BadRequest("Password cannot contain whitespace symbols")) + else if length == 0 then + Left(ErrorInfo.BadRequest("Password cannot be empty")) + else if length < 8 then + Left(ErrorInfo.BadRequest("Password cannot be shorter than 8 symbols")) + else if length > 64 then + Left(ErrorInfo.BadRequest("Password cannot be longer than 64 symbols")) + else if reg.nickname.raw.length < 4 then + Left(ErrorInfo.BadRequest("Nickname cannot be shorter than 4 symbols")) + else if reg.nickname.raw.length > 32 then + Left(ErrorInfo.BadRequest("Nickname cannot be longer that 32 symbols")) + else if nicknameHasWhitespace then + Left(ErrorInfo.BadRequest("Nickname cannot have whitespace in it")) + else + app.register(reg.nickname, reg.password) match case None => - req.notFound() - case Some(tl) => - req.sendJson(StatusCode.OK, tl) - - private val login: ArgsHandler[Unit] = (req, _) => - import twotm8.json.codecs.given - json[Payload.Login](req) { login => - app.login(login.nickname, login.password) match - case None => req.badRequest("Invalid credentials") - case Some(tok) => req.sendJson(StatusCode.OK, tok) - } + Left(ErrorInfo.BadRequest("This nickname is already taken")) + case Some(_) => + Right(()) + end if + end register + + private def get_thought_leader(auth: Either[ErrorInfo, AuthContext])( + nickname: String + ): Either[ErrorInfo, ThoughtLeader] = + val watcher = auth.toOption.map(_.author) + + app.get_thought_leader(Nickname(nickname), watcher) match + case None => + Left(ErrorInfo.NotFound()) + case Some(tl) => + Right(tl) + end get_thought_leader + + private def login(login: Payload.Login): Either[ErrorInfo, Token] = + app.login(login.nickname, login.password) match + case None => Left(ErrorInfo.BadRequest("Invalid credentials")) + case Some(tok) => Right(tok) end Api diff --git a/app/src/main/scala/auth.scala b/app/src/main/scala/auth.scala index 1670d0d..60f9457 100644 --- a/app/src/main/scala/auth.scala +++ b/app/src/main/scala/auth.scala @@ -15,8 +15,6 @@ import java.{util as ju} import scala.util.Try import openssl.OpenSSL -case class AuthContext(author: AuthorId) - object Auth: def validate( jwt: JWT diff --git a/app/src/main/scala/json.scala b/app/src/main/scala/json.scala deleted file mode 100644 index 92f7a62..0000000 --- a/app/src/main/scala/json.scala +++ /dev/null @@ -1,44 +0,0 @@ -package twotm8 -package json - -import upickle.default.{ReadWriter, Reader} - -object codecs: - inline def opaqValue[T, X](obj: OpaqueValue[T, X])(using - rw: ReadWriter[X] - ): ReadWriter[T] = - rw.bimap(obj.value(_), obj.apply(_)) - - // primitive types - given ReadWriter[AuthorId] = opaqValue(AuthorId) - given ReadWriter[Follower] = opaqValue(Follower) - given ReadWriter[Nickname] = opaqValue(Nickname) - given ReadWriter[TwotId] = opaqValue(TwotId) - given ReadWriter[Text] = opaqValue(Text) - given ReadWriter[JWT] = opaqValue(JWT) - given ReadWriter[Uwotm8Count] = opaqValue(Uwotm8Count) - given ReadWriter[Uwotm8Status] = opaqValue(Uwotm8Status) - - given ReadWriter[Twot] = upickle.default.macroRW[Twot] - given ReadWriter[Token] = upickle.default.macroRW[Token] - given ReadWriter[ThoughtLeader] = upickle.default.macroRW[ThoughtLeader] - given Reader[Password] = - summon[Reader[String]].map(Password(_)) - - // Payloads - import upickle.default.{Reader, macroR} - - given Reader[api.Payload.Login] = - macroR[api.Payload.Login] - given Reader[api.Payload.Create] = - macroR[api.Payload.Create] - given Reader[api.Payload.Uwotm8] = - macroR[api.Payload.Uwotm8] - given Reader[api.Payload.Register] = - macroR[api.Payload.Register] - given Reader[api.Payload.Follow] = - macroR[api.Payload.Follow] - - given ReadWriter[Health.DB] = opaqValue(Health.DB) - given ReadWriter[Health] = upickle.default.macroRW[Health] -end codecs diff --git a/app/src/main/scala/server.scala b/app/src/main/scala/server.scala index edd085d..25f6153 100644 --- a/app/src/main/scala/server.scala +++ b/app/src/main/scala/server.scala @@ -6,6 +6,8 @@ import scribe.format.Formatter import scribe.handler.LogHandler import scribe.writer.SystemErrWriter import snunit.* +import snunit.tapir.* +import snunit.tapir.SNUnitInterpreter.* import twotm8.db.DB import scala.concurrent.duration.* @@ -51,7 +53,7 @@ def connection_string() = val app = App(DB.postgres(pgConnection)) val routes = api.Api(app).routes - SyncServerBuilder.build(routes).listen() + SyncServerBuilder.build(toHandler(routes)).listen() } } end launch diff --git a/app/src/test/scala/TestApp.scala b/app/src/test/scala/TestApp.scala deleted file mode 100644 index 0bbdfd4..0000000 --- a/app/src/test/scala/TestApp.scala +++ /dev/null @@ -1,96 +0,0 @@ -package twotm8 - -import org.junit.Assert.* -import org.junit.Test - -import scala.scalanative.unsafe.* -import scala.scalanative.unsigned.* - -import ApiHelpers.* -import verify.* -import snunit.Request -import snunit.Method -import scala.collection.mutable -import snunit.StatusCode -import java.util.UUID - -case class Response( - code: StatusCode, - content: String, - headers: Seq[(String, String)] -) - -case class SimpleRequest( - path: String = "/", - method: Method = Method.GET, - query: String = "", - headers: Seq[(String, String)] = Seq.empty, - contentRaw: Array[Byte] = Array.emptyByteArray -)(using into: mutable.Map[SimpleRequest, Response]) - extends Request: - override def send( - statusCode: StatusCode, - content: Array[Byte], - headers: Seq[(String, String)] - ) = - into.update(this, Response(statusCode, new String(content), headers)) -end SimpleRequest - -object TestApiHelpers extends verify.BasicTestSuite: - test("route builder") { - import snunit.routes.* - import trail.* - - val composed = builder( - Root / "hello" / "world" / Arg[String] -> { (req: Request, arg: String) => - req.send(StatusCode.OK, s"${arg == "bla"}", Seq.empty) - }, - (Root / "hello" / "test" / Arg[String] / Arg[Int]) -> { - (req: Request, arg: (String, Int)) => - req.send( - StatusCode.OK, - s"${arg._1 == "bla"} && ${arg._2 == 25}", - Seq.empty - ) - } - ) - - given reg: mutable.Map[SimpleRequest, Response] = mutable.Map.empty - - val req1 = SimpleRequest("/hello/world/bla") - val req2 = SimpleRequest("/hello/test/bla/25") - - composed.handleRequest(req1) - composed.handleRequest(req2) - - assert(reg(req1).content == "true") - assert(reg(req2).content == "true && true") - - } - - test("cookie roundtrip") { - val cookie = "id=hello; HttpOnly; Max-Age=2592000; SameSite=strict" - - val parsed = Cookie.read(cookie).get - val expected = Cookie( - "id", - "hello", - Map( - "Max-Age" -> Some("2592000"), - "SameSite" -> Some("strict"), - "HttpOnly" -> None - ) - ) - - assert(parsed == expected) - assert(expected.serialize == cookie) - - val noAttributesCookie = "test=world" - - val read = Cookie.read(noAttributesCookie).get - - assert(read.name == "test") - assert(read.value == "world") - } - -end TestApiHelpers diff --git a/build.sbt b/build.sbt index 1deafe4..eaa47d2 100644 --- a/build.sbt +++ b/build.sbt @@ -8,7 +8,8 @@ Global / onChangedBuildSource := ReloadOnSourceChanges val Versions = new { val Scala = "3.2.0" - val SNUnit = "0.0.15" + val SNUnit = "0.0.24" + val Tapir = "1.0.6" val upickle = "2.0.0" val scribe = "3.10.3" val Laminar = "0.14.2" @@ -17,7 +18,7 @@ val Versions = new { val scalacss = "1.0.0" } -lazy val root = project.in(file(".")).aggregate(frontend, app, demoApp) +lazy val root = project.in(file(".")).aggregate(frontend, app) lazy val manage = project @@ -33,6 +34,17 @@ lazy val manage = libraryDependencies += "com.lihaoyi" %%% "upickle" % Versions.upickle ) +lazy val shared = + crossProject(NativePlatform, JSPlatform) + .crossType(CrossType.Pure) + .settings( + scalaVersion := Versions.Scala, + libraryDependencies ++= Seq( + "com.softwaremill.sttp.tapir" %%% "tapir-json-upickle" % Versions.Tapir, + "com.softwaremill.sttp.tapir" %%% "tapir-core" % Versions.Tapir + ) + ) + lazy val frontend = project .in(file("frontend")) @@ -58,40 +70,16 @@ lazy val app = .settings(vcpkgNativeConfig()) .settings( scalaVersion := Versions.Scala, - vcpkgDependencies := Set("libpq", "openssl"), + vcpkgDependencies := Set("libpq", "openssl", "libidn2"), + libraryDependencies += "com.softwaremill.sttp.model" %%% "core" % "1.5.2", libraryDependencies += "com.outr" %%% "scribe" % Versions.scribe, libraryDependencies += "com.lihaoyi" %%% "upickle" % Versions.upickle, - libraryDependencies += "com.github.lolgab" %%% "snunit" % Versions.SNUnit, + libraryDependencies += "com.github.lolgab" %%% "snunit-tapir" % Versions.SNUnit, libraryDependencies += "com.eed3si9n.verify" %%% "verify" % "1.0.0" % Test, libraryDependencies += "com.github.lolgab" %%% "scala-native-crypto" % "0.0.3" % Test, - testFrameworks += new TestFramework("verify.runner.Framework"), - libraryDependencies += ( - "com.github.lolgab" %%% "snunit-routes" % Versions.SNUnit cross CrossVersion.for3Use2_13 - ).excludeAll( - ExclusionRule("org.scala-native"), - ExclusionRule("com.github.lolgab", "snunit_native0.4_2.13") - ) - ) - -lazy val demoApp = - project - .in(file("demo-app")) - .enablePlugins(ScalaNativePlugin, VcpkgPlugin) - .dependsOn(bindings) - .settings(environmentConfiguration) - .settings(vcpkgNativeConfig()) - .settings( - scalaVersion := Versions.Scala, - vcpkgDependencies := Set("libpq", "openssl"), - libraryDependencies += "com.outr" %%% "scribe" % Versions.scribe, - libraryDependencies += "com.lihaoyi" %%% "upickle" % Versions.upickle, - libraryDependencies += "com.github.lolgab" %%% "snunit" % Versions.SNUnit, - libraryDependencies += ( - "com.github.lolgab" %%% "snunit-routes" % Versions.SNUnit cross CrossVersion.for3Use2_13 - ).excludeAll( - ExclusionRule("com.github.lolgab", "snunit_native0.4_2.13") - ) + testFrameworks += new TestFramework("verify.runner.Framework") ) + .dependsOn(shared.native) lazy val environmentConfiguration = Seq(nativeConfig := { val conf = nativeConfig.value @@ -152,11 +140,21 @@ buildBackend := { } def restartLocalUnit = { - val f = new File("/opt/homebrew/var/run/unit/control.sock") + // `unitd --help` prints the default unix socket + val unixSocketPath = process + .Process(Seq("unitd", "--help")) + .!! + .linesIterator + .find(_.contains("unix:")) + .get + .replaceAll(".+unix:", "") + .stripSuffix("\"") + + val f = new File(unixSocketPath) if (f.exists()) { val cmd = - "curl --unix-socket /opt/homebrew/var/run/unit/control.sock http://localhost/control/applications/app/restart" + s"curl --unix-socket $unixSocketPath http://localhost/control/applications/app/restart" println(process.Process(cmd).!!) } diff --git a/project/plugins.sbt b/project/plugins.sbt index 5baa00e..e1d1c04 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -14,3 +14,5 @@ resolvers += Resolver.sonatypeRepo("snapshots") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") addSbtPlugin("com.indoorvivants.vcpkg" % "sbt-vcpkg" % VcpkgVersion) +addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.2.0") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.2.0") diff --git a/scripts/setup-debian.sh b/scripts/setup-debian.sh new file mode 100755 index 0000000..a8809f7 --- /dev/null +++ b/scripts/setup-debian.sh @@ -0,0 +1,18 @@ +#!/usr/bin/sh + +apt update && apt install -y curl && \ + curl -Lo /usr/local/bin/sbt https://raw.githubusercontent.com/sbt/sbt/1.8.x/sbt && \ + chmod +x /usr/local/bin/sbt && \ + curl -Lo llvm.sh https://apt.llvm.org/llvm.sh && \ + chmod +x llvm.sh && \ + apt install -y lsb-release wget software-properties-common gnupg autopoint && \ + ./llvm.sh 13 && \ + apt update && \ + apt install -y zip unzip tar make cmake autoconf pkg-config libclang-13-dev git && \ + # install Unit, OpenSSL and Unit development headers + curl --output /usr/share/keyrings/nginx-keyring.gpg \ + https://unit.nginx.org/keys/nginx-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/debian/ bullseye unit" >> /etc/apt/sources.list.d/unit.list && \ + echo "deb-src [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/debian/ bullseye unit" >> /etc/apt/sources.list.d/unit.list && \ + apt update && \ + apt install -y unit-dev diff --git a/shared/src/main/scala/AuthContext.scala b/shared/src/main/scala/AuthContext.scala new file mode 100644 index 0000000..9cc7c6f --- /dev/null +++ b/shared/src/main/scala/AuthContext.scala @@ -0,0 +1,3 @@ +package twotm8 + +case class AuthContext(author: AuthorId) diff --git a/shared/src/main/scala/ErrorInfo.scala b/shared/src/main/scala/ErrorInfo.scala new file mode 100644 index 0000000..dbe308e --- /dev/null +++ b/shared/src/main/scala/ErrorInfo.scala @@ -0,0 +1,10 @@ +package twotm8 +package api + +sealed trait ErrorInfo +object ErrorInfo: + case class NotFound(message: String = "Not Found") extends ErrorInfo + case class BadRequest(message: String = "Bad Request") extends ErrorInfo + case class Unauthorized(message: String = "Unauthorized") extends ErrorInfo + case class ServerError(message: String = "Something broke yo") + extends ErrorInfo diff --git a/shared/src/main/scala/Payload.scala b/shared/src/main/scala/Payload.scala new file mode 100644 index 0000000..27d80b8 --- /dev/null +++ b/shared/src/main/scala/Payload.scala @@ -0,0 +1,9 @@ +package twotm8 +package api + +object Payload: + case class Login(nickname: Nickname, password: Password) + case class Register(nickname: Nickname, password: Password) + case class Create(text: Text) + case class Uwotm8(twot_id: TwotId) + case class Follow(thought_leader: AuthorId) diff --git a/app/src/main/scala/domain.scala b/shared/src/main/scala/domain.scala similarity index 99% rename from app/src/main/scala/domain.scala rename to shared/src/main/scala/domain.scala index 5314b35..64a37af 100644 --- a/app/src/main/scala/domain.scala +++ b/shared/src/main/scala/domain.scala @@ -24,7 +24,6 @@ case class Health( dbOk: Health.DB ): def good = dbOk == Health.DB.Yes - object Health: opaque type DB = Boolean object DB extends YesNo[DB] diff --git a/shared/src/main/scala/endpoints.scala b/shared/src/main/scala/endpoints.scala new file mode 100644 index 0000000..f4df094 --- /dev/null +++ b/shared/src/main/scala/endpoints.scala @@ -0,0 +1,93 @@ +package twotm8 + +import twotm8.api.* +import twotm8.json.codecs.{*, given} +import sttp.model.StatusCode +import sttp.tapir.* +import sttp.tapir.json.upickle.* +import scala.util.chaining.* + +import java.util.UUID + +object endpoints: + private val baseEndpoint = endpoint.errorOut( + oneOf[ErrorInfo]( + oneOfVariant( + statusCode(StatusCode.NotFound).and(plainBody[ErrorInfo.NotFound]) + ), + oneOfVariant( + statusCode(StatusCode.BadRequest).and(plainBody[ErrorInfo.BadRequest]) + ), + oneOfVariant( + statusCode(StatusCode.Unauthorized) + .and(plainBody[ErrorInfo.Unauthorized]) + ), + oneOfVariant( + statusCode(StatusCode.InternalServerError).and( + plainBody[ErrorInfo.ServerError] + ) + ) + ) + ) + + private val secureEndpoint = baseEndpoint + .securityIn(auth.bearer[String]()) + + val get_me = secureEndpoint.get + .in("api" / "thought_leaders" / "me") + .out(jsonBody[ThoughtLeader]) + + val get_thought_leader = baseEndpoint + .securityIn(auth.bearer[Option[String]]()) + .get + .in("api" / "thought_leaders" / path[String]) + .out(jsonBody[ThoughtLeader]) + + val get_wall = secureEndpoint.get + .in("api" / "twots" / "wall") + .out(jsonBody[Vector[Twot]]) + + val get_health = baseEndpoint.get + .in("api" / "health") + .out(jsonBody[Health]) + + val login = baseEndpoint.post + .in("api" / "auth" / "login") + .in(jsonBody[Payload.Login]) + .out(jsonBody[Token]) + + val create_twot = secureEndpoint.post + .in("api" / "twots" / "create") + .in(jsonBody[Payload.Create]) + .out(statusCode(StatusCode.NoContent)) + + val register = baseEndpoint.put + .in("api" / "auth" / "register") + .in(jsonBody[Payload.Register]) + .out(statusCode(StatusCode.NoContent)) + + val add_uwotm8 = secureEndpoint.put + .in("api" / "twots" / "uwotm8") + .in(jsonBody[Payload.Uwotm8]) + .out(jsonBody[Uwotm8Status]) + + val add_follower = secureEndpoint.put + .in("api" / "thought_leaders" / "follow") + .in(jsonBody[Payload.Follow]) + .out(statusCode(StatusCode.NoContent)) + + val delete_follower = secureEndpoint.delete + .in("api" / "thought_leaders" / "follow") + .in(jsonBody[Payload.Follow]) + .out(statusCode(StatusCode.NoContent)) + + val delete_uwotm8 = secureEndpoint.delete + .in("api" / "twots" / "uwotm8") + .in(jsonBody[Payload.Uwotm8]) + .out(jsonBody[Uwotm8Status]) + + val delete_twot = secureEndpoint.delete + .in("api" / "twots" / path[UUID]) + .out(statusCode(StatusCode.NoContent)) + +end endpoints diff --git a/shared/src/main/scala/json.scala b/shared/src/main/scala/json.scala new file mode 100644 index 0000000..b1898dc --- /dev/null +++ b/shared/src/main/scala/json.scala @@ -0,0 +1,83 @@ +package twotm8 +package json + +import sttp.tapir.* +import upickle.default.* + +object codecs: + inline def opaqValue[T, X](obj: OpaqueValue[T, X])(using + rw: ReadWriter[X] + ): ReadWriter[T] = + rw.bimap(obj.value(_), obj.apply(_)) + + // primitive types + given ReadWriter[AuthorId] = opaqValue(AuthorId) + given ReadWriter[Follower] = opaqValue(Follower) + given ReadWriter[Nickname] = opaqValue(Nickname) + given ReadWriter[TwotId] = opaqValue(TwotId) + given ReadWriter[Text] = opaqValue(Text) + given ReadWriter[JWT] = opaqValue(JWT) + given ReadWriter[Uwotm8Count] = opaqValue(Uwotm8Count) + given ReadWriter[Uwotm8Status] = opaqValue(Uwotm8Status) + + given ReadWriter[Twot] = upickle.default.macroRW[Twot] + given ReadWriter[Token] = upickle.default.macroRW[Token] + given ReadWriter[ThoughtLeader] = upickle.default.macroRW[ThoughtLeader] + // We never use the `Writer` but tapir always needs a `ReadWriter` + // even if we are only reading + given ReadWriter[Password] = + upickle.default.readwriter[String].bimap(_.toString, Password(_)) + + // Payloads + + given ReadWriter[api.Payload.Login] = + upickle.default.macroRW[api.Payload.Login] + given ReadWriter[api.Payload.Create] = + upickle.default.macroRW[api.Payload.Create] + given ReadWriter[api.Payload.Uwotm8] = + upickle.default.macroRW[api.Payload.Uwotm8] + given ReadWriter[api.Payload.Register] = + upickle.default.macroRW[api.Payload.Register] + given ReadWriter[api.Payload.Follow] = + upickle.default.macroRW[api.Payload.Follow] + + given Codec.PlainCodec[api.ErrorInfo.NotFound] = + Codec.string.map(api.ErrorInfo.NotFound(_))(_.message) + given Codec.PlainCodec[api.ErrorInfo.BadRequest] = + Codec.string.map(api.ErrorInfo.BadRequest(_))(_.message) + given Codec.PlainCodec[api.ErrorInfo.Unauthorized] = + Codec.string.map(api.ErrorInfo.Unauthorized(_))(_.message) + given Codec.PlainCodec[api.ErrorInfo.ServerError] = + Codec.string.map(api.ErrorInfo.ServerError(_))(_.message) + + given ReadWriter[Health.DB] = opaqValue(Health.DB) + given ReadWriter[Health] = upickle.default.macroRW[Health] + + given Schema[Health.DB] = Schema.schemaForBoolean.as[Health.DB] + given Schema[Health] = Schema.derived + given Schema[Uwotm8Status] = Schema.schemaForBoolean.as[Uwotm8Status] + given Schema[Nickname] = Schema.schemaForString.as[Nickname] + given Schema[Uwotm8Count] = Schema.schemaForInt.as[Uwotm8Count] + given Schema[Text] = Schema.schemaForString.as[Text] + given Schema[Follower] = Schema.schemaForUUID.as[Follower] + given Schema[AuthorId] = Schema.schemaForUUID.as[AuthorId] + given Schema[TwotId] = Schema.schemaForUUID.as[TwotId] + given Schema[Twot] = Schema.derived + given Schema[JWT] = Schema.schemaForString.as[JWT] + given Schema[Token] = Schema.derived + given Schema[ThoughtLeader] = Schema.derived + + given Schema[Password] = + Schema.schemaForString.map(p => Some(Password(p)))(_.toString) + given Schema[api.Payload.Login] = Schema.derived + given Schema[api.Payload.Create] = Schema.derived + given Schema[api.Payload.Uwotm8] = Schema.derived + given Schema[api.Payload.Register] = Schema.derived + given Schema[api.Payload.Follow] = Schema.derived + + given Schema[api.ErrorInfo.NotFound] = Schema.derived + given Schema[api.ErrorInfo.BadRequest] = Schema.derived + given Schema[api.ErrorInfo.Unauthorized] = Schema.derived + given Schema[api.ErrorInfo.ServerError] = Schema.derived + given Schema[api.ErrorInfo] = Schema.derived +end codecs