diff --git a/.github/workflows/bootzooka-ci.yml b/.github/workflows/bootzooka-ci.yml index 10ea39c48..dd080efb1 100644 --- a/.github/workflows/bootzooka-ci.yml +++ b/.github/workflows/bootzooka-ci.yml @@ -11,33 +11,33 @@ on: release: types: - released - paths-ignore: - - "helm/**" jobs: verify: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Check-out repository id: repo-checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Set up JDK 21 - id: jdk-setup - uses: actions/setup-java@v1 + - name: Set up JDK + uses: actions/setup-java@v4 with: - java-version: 21 + distribution: 'zulu' + java-version: '21' + cache: 'sbt' - - name: Cache SBT - id: cache-sbt - uses: actions/cache@v2 + - uses: sbt/setup-sbt@v1 + + - name: Set up Node.js + uses: actions/setup-node@v4 with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier - key: ${{ runner.os }}-sbt-${{ hashFiles('**/build.sbt') }} + node-version: 22 + + - name: Generate OpenAPI Spec + id: generate-openapi-spec + run: sbt "backend/generateOpenAPIDescription" - name: Run tests id: run-tests @@ -51,38 +51,31 @@ jobs: deploy: if: github.event_name != 'pull_request' && startsWith(github.ref, 'refs/tags/v') needs: [ verify ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Check-out repository id: repo-checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Set up JDK 21 - id: jdk-setup - uses: actions/setup-java@v1 + - name: Set up JDK + uses: actions/setup-java@v4 with: - java-version: 21 - - - name: Cache SBT - id: cache-sbt - uses: actions/cache@v2 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier - key: ${{ runner.os }}-sbt-${{ hashFiles('**/build.sbt') }} + distribution: 'zulu' + java-version: '21' + cache: 'sbt' - name: Login to DockerHub uses: docker/login-action@v1 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract version run: | version=${GITHUB_REF/refs\/tags\/v/} echo "VERSION=$version" >> $GITHUB_ENV + - name: Publish release notes uses: release-drafter/release-drafter@v5 with: @@ -93,5 +86,6 @@ jobs: version: "v${{ env.VERSION }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish docker image run: sbt backend/docker:publish diff --git a/.github/workflows/bootzooka-helm-ci.yaml b/.github/workflows/bootzooka-helm-ci.yaml index d267d5c36..2d98ca774 100644 --- a/.github/workflows/bootzooka-helm-ci.yaml +++ b/.github/workflows/bootzooka-helm-ci.yaml @@ -15,7 +15,7 @@ on: jobs: lint-chart: name: Lint Helm Chart - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Check-out repository id: repo-checkout @@ -34,7 +34,7 @@ jobs: needs: - lint-chart name: Install & Test Helm Chart - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: k8s: @@ -67,7 +67,7 @@ jobs: - lint-chart - install-test-chart name: Validate Helm Chart Docs - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Check-out repository id: repo-checkout @@ -91,7 +91,7 @@ jobs: - install-test-chart - validate-chart-docs name: Publish Helm Chart - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Check-out repository id: repo-checkout diff --git a/.github/workflows/scala-steward.yml b/.github/workflows/scala-steward.yml index 7b59b458e..32097f605 100644 --- a/.github/workflows/scala-steward.yml +++ b/.github/workflows/scala-steward.yml @@ -8,7 +8,7 @@ on: jobs: scala-steward: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index bcc0cf180..cab605c80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +/coverage + +# production +build +target + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +ui/.env + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +*.log +work.txt + +# generated code +/ui/src/api-client/openapi.d.ts + +# IDE *.iml *.ipr *.iws .idea/ -target/ -*.log -.DS_Store -data/ *.bloop *.metals +.vscode +metals.sbt .bsp metals.sbt diff --git a/.scala-steward.conf b/.scala-steward.conf index 007ff842a..e69de29bb 100644 --- a/.scala-steward.conf +++ b/.scala-steward.conf @@ -1,5 +0,0 @@ -updates.ignore = [ - {groupId = "org.scala-lang", artifactId = "scala-compiler", version = "2.12."}, - {groupId = "org.scala-lang", artifactId = "scala-compiler", version = "2.13."}, - {groupId = "org.scala-lang", artifactId = "scala-compiler", version = "3."} -] diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 000000000..25e991dbe --- /dev/null +++ b/.scalafix.conf @@ -0,0 +1,2 @@ +OrganizeImports.groupedImports = AggressiveMerge +OrganizeImports.targetDialect = Scala3 \ No newline at end of file diff --git a/README.md b/README.md index c97456d3e..adbbaea49 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,15 @@ ## Quick start +### Using Docker compose + +The fastest way to experiment with Bootzooka is using the provided Docker compose setup. It starts three images: +Bootzooka itself (either locally built or downloaded), PostgreSQL server and Graphana LGTM for observability. + ### Backend: PostgreSQL & API -In order to run Bootzooka, you'll need a running instance of PostgreSQL with a `bootzooka` database. You can spin -up one easily using docker: +To run Bootzooka's backend locally, you'll still need a running instance of PostgreSQL with a `bootzooka` database. +You can spin up one easily using docker: ```sh # use "bootzooka" as a password @@ -19,10 +24,12 @@ docker run --name bootzooka-postgres -p 5432:5432 -e POSTGRES_PASSWORD=bootzooka Then, you can start the backend: ```sh -export SQL_PASSWORD=bootzooka -./backend-start.sh +OTEL_SDK_DISABLED=true SQL_PASSWORD=bootzooka ./backend-start.sh ``` +Unless you've got an OpenTelemetry collector running, OpenTelemetry should be disabled to avoid telemetry export +exceptions. + ### Frontend: Yarn & webapp You will need the [yarn package manager](https://yarnpkg.com) to run the UI. Install it using your package manager or: diff --git a/backend/src/main/resources/application.conf b/backend/src/main/resources/application.conf index 56b470423..affefc457 100644 --- a/backend/src/main/resources/application.conf +++ b/backend/src/main/resources/application.conf @@ -28,8 +28,6 @@ db { migrate-on-start = ${?MIGRATE_ON_START} driver = "org.postgresql.Driver" - - connect-thread-pool-size = 32 } email { diff --git a/backend/src/main/resources/logback.xml b/backend/src/main/resources/logback.xml index 7b2f49e36..466f64d8f 100644 --- a/backend/src/main/resources/logback.xml +++ b/backend/src/main/resources/logback.xml @@ -1,35 +1,45 @@ - + + + true + + - + - - %d{HH:mm:ss.SSS}%boldYellow(%replace( [%X{cid}] ){' \[\] ', ' '})[%thread] %-5level %logger{5} - %msg%n%rEx - - + + + + + + - + + - - + + + - + + - + + - - + \ No newline at end of file diff --git a/backend/src/main/resources/psw4j.properties b/backend/src/main/resources/psw4j.properties new file mode 100644 index 000000000..4623e5fb2 --- /dev/null +++ b/backend/src/main/resources/psw4j.properties @@ -0,0 +1 @@ +global.salt.length=64 diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Dependencies.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Dependencies.scala index 12cd39a20..1b431f020 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/Dependencies.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/Dependencies.scala @@ -1,75 +1,73 @@ package com.softwaremill.bootzooka +import com.softwaremill.bootzooka.admin.VersionApi import com.softwaremill.bootzooka.config.Config +import com.softwaremill.bootzooka.email.EmailService import com.softwaremill.bootzooka.email.sender.EmailSender -import com.softwaremill.bootzooka.email.{EmailModel, EmailService, EmailTemplates} -import com.softwaremill.bootzooka.http.{Http, HttpApi} +import com.softwaremill.bootzooka.http.{HttpApi, HttpConfig} import com.softwaremill.bootzooka.infrastructure.{DB, SetCorrelationIdBackend} -import com.softwaremill.bootzooka.metrics.{Metrics, VersionApi} -import com.softwaremill.bootzooka.passwordreset.{PasswordResetApi, PasswordResetAuthToken, PasswordResetCodeModel, PasswordResetService} -import com.softwaremill.bootzooka.security.{ApiKeyAuthToken, ApiKeyModel, ApiKeyService, Auth} -import com.softwaremill.bootzooka.user.{UserApi, UserModel, UserService} +import com.softwaremill.bootzooka.metrics.Metrics +import com.softwaremill.bootzooka.passwordreset.{PasswordResetApi, PasswordResetAuthToken} +import com.softwaremill.bootzooka.security.{ApiKeyAuthToken, ApiKeyService, Auth} +import com.softwaremill.bootzooka.user.UserApi import com.softwaremill.bootzooka.util.{Clock, DefaultClock, DefaultIdGenerator, IdGenerator} +import com.softwaremill.macwire.{autowire, autowireMembersOf} import io.opentelemetry.api.OpenTelemetry -import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter -import io.opentelemetry.sdk.OpenTelemetrySdk -import io.opentelemetry.sdk.metrics.SdkMeterProvider -import io.opentelemetry.sdk.metrics.`export`.PeriodicMetricReader -import ox.{IO, Ox, tap, useCloseableInScope, useInScope} +import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender +import io.opentelemetry.instrumentation.runtimemetrics.java8.{Classes, Cpu, GarbageCollector, MemoryPools, Threads} +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk +import ox.{Ox, discard, tap, useCloseableInScope, useInScope} import sttp.client3.logging.slf4j.Slf4jLoggingBackend import sttp.client3.opentelemetry.OpenTelemetryMetricsBackend import sttp.client3.{HttpClientSyncBackend, SttpBackend} import sttp.shared.Identity +import sttp.tapir.AnyEndpoint -trait Dependencies(using Ox, IO): - // TODO use macwire/autowire once available for Scala3 - lazy val config: Config = Config.read.tap(Config.log) - lazy val otel: OpenTelemetry = createOtel() - lazy val metrics = new Metrics(otel) - lazy val sttpBackend: SttpBackend[Identity, Any] = - useInScope( +case class Dependencies(httpApi: HttpApi, emailService: EmailService) + +object Dependencies: + val endpointsForDocs: List[AnyEndpoint] = List(UserApi, PasswordResetApi, VersionApi).flatMap(_.endpointsForDocs) + + private case class Apis(userApi: UserApi, passwordResetApi: PasswordResetApi, versionApi: VersionApi): + def endpoints = List(userApi, passwordResetApi, versionApi).flatMap(_.endpoints) + + def create(using Ox): Dependencies = + val config = Config.read.tap(Config.log) + val otel = initializeOtel() + val sttpBackend = useInScope( Slf4jLoggingBackend(OpenTelemetryMetricsBackend(new SetCorrelationIdBackend(HttpClientSyncBackend()), otel), includeTiming = true) )(_.close()) - lazy val db: DB = useCloseableInScope(DB.createTestMigrate(config.db)) - lazy val idGenerator: IdGenerator = DefaultIdGenerator - lazy val clock: Clock = DefaultClock - lazy val http = new Http - lazy val emailTemplates = new EmailTemplates - lazy val emailModel = new EmailModel - lazy val emailSender: EmailSender = EmailSender.create(sttpBackend, config.email) - lazy val emailService = new EmailService(emailModel, idGenerator, emailSender, config.email, db, metrics) - lazy val apiKeyModel = new ApiKeyModel - lazy val apiKeyAuthToken = new ApiKeyAuthToken(apiKeyModel) - lazy val apiKeyService = new ApiKeyService(apiKeyModel, idGenerator, clock) - lazy val apiKeyAuth = new Auth(apiKeyAuthToken, db, clock) - lazy val passwordResetCodeModel = new PasswordResetCodeModel - lazy val passwordResetAuthToken = new PasswordResetAuthToken(passwordResetCodeModel) - lazy val passwordResetAuth = new Auth(passwordResetAuthToken, db, clock) - lazy val userModel = new UserModel - lazy val userService = new UserService(userModel, emailService, emailTemplates, apiKeyService, idGenerator, clock, config.user) - lazy val userApi = new UserApi(http, apiKeyAuth, userService, db, metrics) - lazy val passwordResetService = new PasswordResetService( - userModel, - passwordResetCodeModel, - emailService, - emailTemplates, - passwordResetAuth, - idGenerator, - config.passwordReset, - clock, - db - ) - lazy val passwordResetApi = new PasswordResetApi(http, passwordResetService, db) - lazy val versionApi = new VersionApi(http) - lazy val httpApi = - new HttpApi(http, userApi.endpoints ++ passwordResetApi.endpoints, List(versionApi.versionEndpoint), otel, config.api) + val db: DB = useCloseableInScope(DB.createTestMigrate(config.db)) + + create(config, otel, sttpBackend, db, DefaultClock) + + /** Create the service graph using the given infrastructure services & configuration. */ + def create(config: Config, otel: OpenTelemetry, sttpBackend: SttpBackend[Identity, Any], db: DB, clock: Clock): Dependencies = + autowire[Dependencies]( + autowireMembersOf(config), + otel, + sttpBackend, + db, + DefaultIdGenerator, + clock, + EmailSender.create, + (apis: Apis, otel: OpenTelemetry, httpConfig: HttpConfig) => + new HttpApi(apis.endpoints, Dependencies.endpointsForDocs, otel, httpConfig), + classOf[EmailService], + new Auth(_: ApiKeyAuthToken, _: DB, _: Clock), + new Auth(_: PasswordResetAuthToken, _: DB, _: Clock) + ) - private def createOtel(): OpenTelemetry = - // An exporter that sends metrics to a collector over gRPC - val grpcExporter = OtlpGrpcMetricExporter.builder().build() - // A metric reader that exports using the gRPC exporter - val metricReader: PeriodicMetricReader = PeriodicMetricReader.builder(grpcExporter).build() - // A meter registry whose meters are read by the above reader - val meterProvider: SdkMeterProvider = SdkMeterProvider.builder().registerMetricReader(metricReader).build() - // An instance of OpenTelemetry using the above meter registry - OpenTelemetrySdk.builder().setMeterProvider(meterProvider).build() + private def initializeOtel(): OpenTelemetry = + AutoConfiguredOpenTelemetrySdk + .initialize() + .getOpenTelemetrySdk() + .tap { otel => + // see https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/runtime-telemetry/runtime-telemetry-java8/library + Classes.registerObservers(otel) + Cpu.registerObservers(otel) + MemoryPools.registerObservers(otel) + Threads.registerObservers(otel) + GarbageCollector.registerObservers(otel).discard + } + .tap(OpenTelemetryAppender.install) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala index 4f4194708..d4c32e2dd 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala @@ -2,14 +2,19 @@ package com.softwaremill.bootzooka import com.softwaremill.bootzooka.logging.Logging import ox.logback.InheritableMDC -import ox.{IO, Ox, OxApp, never} +import ox.{Ox, OxApp, never} +import org.slf4j.bridge.SLF4JBridgeHandler object Main extends OxApp.Simple with Logging: + // route JUL to SLF4J + SLF4JBridgeHandler.removeHandlersForRootLogger() + SLF4JBridgeHandler.install() + InheritableMDC.init Thread.setDefaultUncaughtExceptionHandler((t, e) => logger.error("Uncaught exception in thread: " + t, e)) - override def run(using Ox, IO): Unit = - val deps = new Dependencies() {} + override def run(using Ox): Unit = + val deps = Dependencies.create deps.emailService.startProcesses() val binding = deps.httpApi.start() diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/OpenAPIDescription.scala b/backend/src/main/scala/com/softwaremill/bootzooka/OpenAPIDescription.scala new file mode 100644 index 000000000..fe68797e8 --- /dev/null +++ b/backend/src/main/scala/com/softwaremill/bootzooka/OpenAPIDescription.scala @@ -0,0 +1,15 @@ +package com.softwaremill.bootzooka + +import sttp.apispec.openapi.circe.yaml.* +import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter + +import java.nio.file.* + +object OpenAPIDescription: + val Title = "Bootzooka" + val Version = "1.0" + +@main def writeOpenAPIDescription(path: String): Unit = + val yaml = OpenAPIDocsInterpreter().toOpenAPI(Dependencies.endpointsForDocs, OpenAPIDescription.Title, OpenAPIDescription.Version).toYaml + Files.writeString(Paths.get(path), yaml) + println(s"OpenAPI description document written to: $path") diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/admin/VersionApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/admin/VersionApi.scala new file mode 100644 index 000000000..9bfe1d589 --- /dev/null +++ b/backend/src/main/scala/com/softwaremill/bootzooka/admin/VersionApi.scala @@ -0,0 +1,31 @@ +package com.softwaremill.bootzooka.admin + +import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec +import com.softwaremill.bootzooka.http.Http.* +import com.softwaremill.bootzooka.version.BuildInfo +import sttp.shared.Identity +import sttp.tapir.Schema +import sttp.tapir.server.ServerEndpoint +import com.softwaremill.bootzooka.http.EndpointsForDocs +import com.softwaremill.bootzooka.http.ServerEndpoints + +/** Defines an endpoint which exposes the current application version information. */ +class VersionApi extends ServerEndpoints: + import VersionApi._ + + private val versionServerEndpoint: ServerEndpoint[Any, Identity] = versionEndpoint.handleSuccess { _ => + Version_OUT(BuildInfo.lastCommitHash) + } + + override val endpoints = List(versionServerEndpoint) + +object VersionApi extends EndpointsForDocs: + private val AdminPath = "admin" + + private val versionEndpoint = baseEndpoint.get + .in(AdminPath / "version") + .out(jsonBody[Version_OUT]) + + override val endpointsForDocs = List(versionEndpoint).map(_.tag("admin")) + + case class Version_OUT(buildSha: String) derives ConfiguredJsonValueCodec, Schema diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/config/Config.scala b/backend/src/main/scala/com/softwaremill/bootzooka/config/Config.scala index 4ccf2604d..6444a55e2 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/config/Config.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/config/Config.scala @@ -31,7 +31,7 @@ object Config extends Logging: |----------------- |""".stripMargin - val info = TreeMap(BuildInfo.toMap.toSeq: _*).foldLeft(baseInfo) { case (str, (k, v)) => + val info = TreeMap(BuildInfo.toMap.toSeq*).foldLeft(baseInfo) { case (str, (k, v)) => str + s"$k: $v\n" } diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala index 789caed19..06074ad2a 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala @@ -6,7 +6,7 @@ import com.softwaremill.bootzooka.infrastructure.Magnum.* import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.metrics.Metrics import com.softwaremill.bootzooka.util.IdGenerator -import ox.{Fork, IO, Ox, discard, forever, fork, sleep} +import ox.{Fork, Ox, discard, forever, fork, sleep} import scala.util.control.NonFatal @@ -26,7 +26,7 @@ class EmailService( val id = idGenerator.nextId[Email]() emailModel.insert(Email(id, data)) - def sendBatch()(using IO): Unit = + def sendBatch(): Unit = val emails = db.transact(emailModel.find(config.batchSize)) if emails.nonEmpty then logger.info(s"Sending ${emails.size} emails") emails.map(_.data).foreach(emailSender.apply) @@ -35,7 +35,7 @@ class EmailService( /** Starts an asynchronous process which attempts to send batches of emails in defined intervals, as well as updates a metric which holds * the size of the email queue. */ - def startProcesses()(using Ox, IO): Unit = + def startProcesses()(using Ox): Unit = foreverPeriodically("Exception when sending emails") { sendBatch() } diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala index c687c8c27..8dde88eea 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala @@ -2,7 +2,6 @@ package com.softwaremill.bootzooka.email.sender import com.softwaremill.bootzooka.email.EmailData import com.softwaremill.bootzooka.logging.Logging -import ox.IO import java.util.concurrent.{BlockingQueue, LinkedBlockingQueue} import scala.jdk.CollectionConverters.* @@ -12,7 +11,7 @@ object DummyEmailSender extends EmailSender with Logging: def reset(): Unit = sentEmails.clear() - override def apply(email: EmailData)(using IO): Unit = + override def apply(email: EmailData): Unit = sentEmails.put(email) logger.info(s"Would send email, if this wasn't a dummy email service implementation: $email") diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala index d842cad24..2688d5944 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala @@ -1,12 +1,11 @@ package com.softwaremill.bootzooka.email.sender import com.softwaremill.bootzooka.email.{EmailConfig, EmailData} -import ox.IO import sttp.client3.SttpBackend import sttp.shared.Identity trait EmailSender: - def apply(email: EmailData)(using IO): Unit + def apply(email: EmailData): Unit object EmailSender: def create(sttpBackend: SttpBackend[Identity, Any], config: EmailConfig): EmailSender = if config.mailgun.enabled then diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/MailgunEmailSender.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/MailgunEmailSender.scala index 12500da32..86e4340c9 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/MailgunEmailSender.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/MailgunEmailSender.scala @@ -2,14 +2,13 @@ package com.softwaremill.bootzooka.email.sender import com.softwaremill.bootzooka.email.{EmailData, MailgunConfig} import com.softwaremill.bootzooka.logging.Logging -import ox.IO import sttp.client3.* /** Sends emails using the [[https://www.mailgun.com Mailgun]] service. The external http call is done using * [[sttp https://github.com/softwaremill/sttp]]. */ class MailgunEmailSender(config: MailgunConfig, sttpBackend: SttpBackend[Identity, Any]) extends EmailSender with Logging: - override def apply(email: EmailData)(using IO): Unit = + override def apply(email: EmailData): Unit = basicRequest.auth .basic("api", config.apiKey.value) .post(uri"${config.url}") diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/SmtpEmailSender.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/SmtpEmailSender.scala index 3e9d51ec8..b7d97b82d 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/SmtpEmailSender.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/sender/SmtpEmailSender.scala @@ -2,7 +2,7 @@ package com.softwaremill.bootzooka.email.sender import com.softwaremill.bootzooka.email.{EmailData, SmtpConfig} import com.softwaremill.bootzooka.logging.Logging -import ox.{IO, discard} +import ox.discard import java.util.{Date, Properties} import javax.mail.internet.{InternetAddress, MimeMessage} @@ -10,7 +10,7 @@ import javax.mail.{Address, Message, Session, Transport} /** Sends emails synchronously using SMTP. */ class SmtpEmailSender(config: SmtpConfig) extends EmailSender with Logging: - def apply(email: EmailData)(using IO): Unit = + def apply(email: EmailData): Unit = val emailToSend = new SmtpEmailSender.EmailDescription(List(email.recipient), email.content, email.subject) SmtpEmailSender.send( config.host, @@ -39,7 +39,7 @@ object SmtpEmailSender: from: String, encoding: String, emailDescription: EmailDescription - )(using IO): Unit = + ): Unit = val props = setupSmtpServerProperties(sslConnection, smtpHost, smtpPort, verifySSLCertificate) // Get a mail session @@ -91,16 +91,16 @@ object SmtpEmailSender: props.put("mail.smtp.port", smtpPort.toString) props - private def createSmtpTransportFrom(session: Session, sslConnection: Boolean)(using IO): Transport = + private def createSmtpTransportFrom(session: Session, sslConnection: Boolean): Transport = if (sslConnection) session.getTransport("smtps") else session.getTransport("smtp") - private def sendEmail(transport: Transport, m: MimeMessage)(using IO): Unit = + private def sendEmail(transport: Transport, m: MimeMessage): Unit = transport.sendMessage(m, m.getAllRecipients) - private def connectToSmtpServer(transport: Transport, smtpUsername: String, smtpPassword: String)(using IO): Unit = + private def connectToSmtpServer(transport: Transport, smtpUsername: String, smtpPassword: String): Unit = if smtpUsername != null && smtpUsername.nonEmpty then transport.connect(smtpUsername, smtpPassword) else transport.connect() - private def convertStringEmailsToAddresses(emails: Array[String])(using IO): Array[Address] = + private def convertStringEmailsToAddresses(emails: Array[String]): Array[Address] = emails.map(new InternetAddress(_)) case class EmailDescription( diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala b/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala index b431cbb4a..c2a782387 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala @@ -3,13 +3,13 @@ package com.softwaremill.bootzooka.http import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec import com.softwaremill.bootzooka.* import com.softwaremill.bootzooka.logging.Logging -import com.softwaremill.bootzooka.util.Strings.{asId, Id} +import com.softwaremill.bootzooka.util.Strings.{Id, asId} import sttp.model.StatusCode import sttp.tapir.json.jsoniter.TapirJsonJsoniter import sttp.tapir.{Codec, Endpoint, EndpointOutput, PublicEndpoint, Schema, SchemaType, Tapir} -/** Helper class for defining HTTP endpoints. Import the members of this class when defining an HTTP API using Tapir. */ -class Http extends Tapir with TapirJsonJsoniter with Logging: +/** Helper object for defining HTTP endpoints. Import as `Http.*` to gain access to Tapir's API and customizations. */ +object Http extends Tapir with TapirJsonJsoniter with Logging: private val internalServerError = (StatusCode.InternalServerError, "Internal server error") private val failToResponseData: Fail => (StatusCode, String) = { case Fail.NotFound(what) => (StatusCode.NotFound, what) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/http/HttpApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/http/HttpApi.scala index 6c5394780..a8b7b4dfc 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/http/HttpApi.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/http/HttpApi.scala @@ -1,10 +1,10 @@ package com.softwaremill.bootzooka.http +import com.softwaremill.bootzooka.OpenAPIDescription import com.softwaremill.bootzooka.infrastructure.CorrelationIdInterceptor import com.softwaremill.bootzooka.logging.Logging -import com.softwaremill.bootzooka.util.ServerEndpoints import io.opentelemetry.api.OpenTelemetry -import ox.{IO, Ox} +import ox.Ox import sttp.shared.Identity import sttp.tapir.* import sttp.tapir.files.{FilesOptions, staticResourcesGetServerEndpoint} @@ -17,18 +17,22 @@ import sttp.tapir.server.netty.sync.{NettySyncServer, NettySyncServerBinding, Ne import sttp.tapir.swagger.SwaggerUIOptions import sttp.tapir.swagger.bundle.SwaggerInterpreter -/** Exposes the endpoint descriptions (defined using tapir) using a Netty-based server, adding CORS, metrics, api docs support. +/** Exposes the endpoints (defined using Tapir) using a Netty-based server, adding CORS, metrics, docs support. * - * The following endpoints are exposed: - * - `/api/v1` - the main API - * - `/api/v1/docs` - swagger UI for the main API - * - `/admin` - admin API - * - `/` - serving frontend resources + * The following paths are exposed: + * - `/api/v1` - the API endpoints + * - `/api/v1/docs` - Swagger UI for the API + * - `/` - frontend resources + * + * @param serverEndpoints + * Endpoints to be exposed by the server + * @param endpointsForDocs + * Endpoints for which documentation is exposed. Should contain the same endpoints as [[serverEndpoints]], but possibly with additional + * metadata */ class HttpApi( - http: Http, - mainEndpoints: ServerEndpoints, - adminEndpoints: ServerEndpoints, + serverEndpoints: List[ServerEndpoint[Any, Identity]], + endpointsForDocs: List[AnyEndpoint], otel: OpenTelemetry, config: HttpConfig ) extends Logging: @@ -36,22 +40,20 @@ class HttpApi( private val serverOptions: NettySyncServerOptions = NettySyncServerOptions.customiseInterceptors .prependInterceptor(CorrelationIdInterceptor) - // all errors are formatted as JSON, and there are no other additional routes - .defaultHandlers(msg => ValuedEndpointOutput(http.jsonErrorOutOutput, Error_OUT(msg)), notFoundWhenRejected = true) + // all errors are formatted as JSON, and no additional routes are added to the server + .defaultHandlers(msg => ValuedEndpointOutput(Http.jsonErrorOutOutput, Error_OUT(msg)), notFoundWhenRejected = true) .corsInterceptor(CORSInterceptor.default[Identity]) .metricsInterceptor(OpenTelemetryMetrics.default[Identity](otel).metricsInterceptor()) .options val allEndpoints: List[ServerEndpoint[Any, Identity]] = { - // Creating the documentation using `mainEndpoints`. The /api/v1 context path is added using Swagger's options, not to the endpoints. + // The /api/v1 context path is added using Swagger's options, not to the endpoints. val docsEndpoints = SwaggerInterpreter(swaggerUIOptions = SwaggerUIOptions.default.copy(contextPath = apiContextPath)) - .fromServerEndpoints(mainEndpoints, "Bootzooka", "1.0") + .fromEndpoints[Identity](endpointsForDocs, OpenAPIDescription.Title, OpenAPIDescription.Version) // For /api/v1 requests, first trying the API; then the docs. Prepending the context path to each endpoint. val apiEndpoints = - (mainEndpoints ++ docsEndpoints).map(se => se.prependSecurityIn(apiContextPath.foldLeft(emptyInput: EndpointInput[Unit])(_ / _))) - - val allAdminEndpoints = adminEndpoints.map(_.prependSecurityIn("admin")) + (serverEndpoints ++ docsEndpoints).map(se => se.prependSecurityIn(apiContextPath.foldLeft(emptyInput: EndpointInput[Unit])(_ / _))) // For all other requests, first trying getting existing webapp resource (html, js, css files), from the /webapp // directory on the classpath. Otherwise, returning index.html. This is needed to support paths in the frontend @@ -63,8 +65,8 @@ class HttpApi( FilesOptions.default[Identity].defaultFile(List("index.html")) ) ) - apiEndpoints ++ allAdminEndpoints ++ webappEndpoints + apiEndpoints ++ webappEndpoints } - def start()(using Ox, IO): NettySyncServerBinding = + def start()(using Ox): NettySyncServerBinding = NettySyncServer(serverOptions, NettyConfig.default.host(config.host).port(config.port)).addEndpoints(allEndpoints).start() diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/http/package.scala b/backend/src/main/scala/com/softwaremill/bootzooka/http/package.scala new file mode 100644 index 000000000..e571a63f9 --- /dev/null +++ b/backend/src/main/scala/com/softwaremill/bootzooka/http/package.scala @@ -0,0 +1,17 @@ +package com.softwaremill.bootzooka.http + +import sttp.shared.Identity +import sttp.tapir.AnyEndpoint +import sttp.tapir.server.ServerEndpoint + +trait EndpointsForDocs: + /** The list of endpoints which should appear in the generated OpenAPI description (used for docs and to generate frontend code). + * + * Usually, each endpoint should have the same [[sttp.tapir.Endpoint.tag]], and corresponds to exactly one endpoint defined in + * [[ServerEndpoints.endpoints]]. + */ + def endpointsForDocs: List[AnyEndpoint] + +trait ServerEndpoints: + /** The list of server endpoints which should be exposed by the HTTP server. */ + def endpoints: List[ServerEndpoint[Any, Identity]] diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/CorrelationId.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/CorrelationId.scala index 3ccdc77e1..5f8f4e487 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/CorrelationId.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/CorrelationId.scala @@ -20,7 +20,7 @@ object CorrelationId: /** An sttp backend wrapper, which sets the current correlation id (from [[CorrelationId.forkLocal]]) on all outgoing requests. */ class SetCorrelationIdBackend[P](delegate: SttpBackend[Identity, P]) extends SttpBackend[Identity, P]: - override def send[T, R >: P with Effect[Identity]](request: Request[T, R]): Response[T] = + override def send[T, R >: P & Effect[Identity]](request: Request[T, R]): Response[T] = val request2 = Option(MDC.get(CorrelationIdInterceptor.MDCKey)) match { case Some(cid) => request.header(CorrelationIdInterceptor.HeaderName, cid) case None => request diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala index 82abd01c4..07d1e0940 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala @@ -10,7 +10,7 @@ import com.softwaremill.bootzooka.config.Sensitive import com.softwaremill.bootzooka.infrastructure.DB.LeftException import com.softwaremill.bootzooka.logging.Logging import com.zaxxer.hikari.{HikariConfig, HikariDataSource} -import ox.{IO, discard, sleep} +import ox.{discard, sleep} import java.io.Closeable import javax.sql.DataSource @@ -25,23 +25,23 @@ class DB(dataSource: DataSource & Closeable) extends Logging with AutoCloseable: ) /** Runs `f` in a transaction. The transaction is commited if the result is a [[Right]], and rolled back otherwise. */ - def transactEither[E, T](f: DbTx ?=> Either[E, T])(using IO): Either[E, T] = + def transactEither[E, T](f: DbTx ?=> Either[E, T]): Either[E, T] = try com.augustnagro.magnum.transact(transactor)(Right(f.fold(e => throw LeftException(e), identity))) - catch case e: LeftException[?] => Left(e.asInstanceOf[LeftException[E]].left) + catch case e: LeftException[E] @unchecked => Left(e.left) /** Runs `f` in a transaction. The result cannot be an `Either`, as then [[transactEither]] should be used. The transaction is commited if * no exception is thrown. */ - def transact[T](f: DbTx ?=> T)(using NotGiven[T <:< Either[_, _]], IO): T = + def transact[T](f: DbTx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T = com.augustnagro.magnum.transact(transactor)(f) - override def close(): Unit = IO.unsafe(dataSource.close()) + override def close(): Unit = dataSource.close() object DB extends Logging: private class LeftException[E](val left: E) extends RuntimeException with NoStackTrace /** Configures the database, setting up the connection pool and performing migrations. */ - def createTestMigrate(_config: DBConfig)(using IO): DB = + def createTestMigrate(_config: DBConfig): DB = val config: DBConfig = if (_config.url.startsWith("postgres://")) { val dbUri = URI.create(_config.url) @@ -68,11 +68,11 @@ object DB extends Logging: .dataSource(config.url, config.username, config.password.value) .load() - def migrate()(using IO): Unit = if config.migrateOnStart then flyway.migrate().discard + def migrate(): Unit = if config.migrateOnStart then flyway.migrate().discard def testConnection(ds: DataSource): Unit = connect(ds)(sql"SELECT 1".query[Int].run()).discard @tailrec - def connectAndMigrate(ds: DataSource)(using IO): Unit = + def connectAndMigrate(ds: DataSource): Unit = try migrate() testConnection(ds) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala index 092ae4051..cce3927b6 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala @@ -4,5 +4,4 @@ import com.softwaremill.bootzooka.config.Sensitive import pureconfig.ConfigReader import pureconfig.generic.derivation.default.* -case class DBConfig(username: String, password: Sensitive, url: String, migrateOnStart: Boolean, driver: String, connectThreadPoolSize: Int) - derives ConfigReader +case class DBConfig(username: String, password: Sensitive, url: String, migrateOnStart: Boolean, driver: String) derives ConfigReader diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala index e220577a1..5e3d36b09 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.infrastructure -import com.augustnagro.magnum.{DbCodec, DbCon, DbTx, Frag} +import com.augustnagro.magnum.{DbCodec, Frag} import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.util.Strings.* diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/metrics/VersionApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/metrics/VersionApi.scala deleted file mode 100644 index 232fc58d3..000000000 --- a/backend/src/main/scala/com/softwaremill/bootzooka/metrics/VersionApi.scala +++ /dev/null @@ -1,21 +0,0 @@ -package com.softwaremill.bootzooka.metrics - -import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec -import com.softwaremill.bootzooka.http.Http -import com.softwaremill.bootzooka.version.BuildInfo -import sttp.shared.Identity -import sttp.tapir.Schema -import sttp.tapir.server.ServerEndpoint - -/** Defines an endpoint which exposes the current application version information. */ -class VersionApi(http: Http): - import VersionApi._ - import http._ - - val versionEndpoint: ServerEndpoint[Any, Identity] = baseEndpoint.get - .in("version") - .out(jsonBody[Version_OUT]) - .handleSuccess { _ => Version_OUT(BuildInfo.lastCommitHash) } - -object VersionApi: - case class Version_OUT(buildSha: String) derives ConfiguredJsonValueCodec, Schema diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala index 0fad45cb5..2bde95a49 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala @@ -1,42 +1,49 @@ package com.softwaremill.bootzooka.passwordreset import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec -import com.softwaremill.bootzooka.http.Http +import com.softwaremill.bootzooka.http.Http.* import com.softwaremill.bootzooka.infrastructure.DB import com.softwaremill.bootzooka.infrastructure.Magnum.* -import com.softwaremill.bootzooka.util.ServerEndpoints -import ox.IO +import com.softwaremill.bootzooka.http.{EndpointsForDocs, ServerEndpoints} import sttp.tapir.Schema -class PasswordResetApi(http: Http, passwordResetService: PasswordResetService, db: DB)(using IO): +class PasswordResetApi(passwordResetService: PasswordResetService, db: DB) extends ServerEndpoints: import PasswordResetApi._ - import http._ + private val passwordResetServerEndpoint = passwordResetEndpoint.handle { data => + passwordResetService.resetPassword(data.code, data.password).map(_ => PasswordReset_OUT()) + } + + private val forgotPasswordServerEndpoint = forgotPasswordEndpoint.handleSuccess { data => + db.transact(passwordResetService.forgotPassword(data.loginOrEmail)) + ForgotPassword_OUT() + } + + override val endpoints = List( + passwordResetServerEndpoint, + forgotPasswordServerEndpoint + ) + +object PasswordResetApi extends EndpointsForDocs: private val PasswordResetPath = "passwordreset" private val passwordResetEndpoint = baseEndpoint.post .in(PasswordResetPath / "reset") .in(jsonBody[PasswordReset_IN]) .out(jsonBody[PasswordReset_OUT]) - .handle { data => - passwordResetService.resetPassword(data.code, data.password).map(_ => PasswordReset_OUT()) - } private val forgotPasswordEndpoint = baseEndpoint.post .in(PasswordResetPath / "forgot") .in(jsonBody[ForgotPassword_IN]) .out(jsonBody[ForgotPassword_OUT]) - .handleSuccess { data => - db.transact(passwordResetService.forgotPassword(data.loginOrEmail)) - ForgotPassword_OUT() - } - val endpoints: ServerEndpoints = List( + override val endpointsForDocs = List( passwordResetEndpoint, forgotPasswordEndpoint ).map(_.tag("passwordreset")) -object PasswordResetApi: + // + case class PasswordReset_IN(code: String, password: String) derives ConfiguredJsonValueCodec, Schema case class PasswordReset_OUT() derives ConfiguredJsonValueCodec, Schema diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala index b731c217c..48a612649 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala @@ -9,7 +9,7 @@ import com.softwaremill.bootzooka.security.Auth import com.softwaremill.bootzooka.user.{User, UserModel} import com.softwaremill.bootzooka.util.* import com.softwaremill.bootzooka.util.Strings.{Id, asId, toLowerCased} -import ox.{IO, either} +import ox.either import ox.either.* class PasswordResetService( @@ -47,7 +47,7 @@ class PasswordResetService( val resetLink = String.format(config.resetLinkPattern, code.id) emailTemplates.passwordReset(user.login, resetLink) - def resetPassword(code: String, newPassword: String)(using IO): Either[Fail, Unit] = either { + def resetPassword(code: String, newPassword: String): Either[Fail, Unit] = either { val userId = auth(code.asId[PasswordResetCode]).ok() logger.debug(s"Resetting password for user: $userId") db.transact(userModel.updatePassword(userId, User.hashPassword(newPassword))) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala index eff73c285..2d13f540c 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala @@ -7,8 +7,8 @@ import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.* import com.softwaremill.bootzooka.util.Strings.Id +import ox.sleep import ox.either.{fail, ok} -import ox.{IO, sleep} import java.security.SecureRandom import java.time.Instant @@ -22,7 +22,7 @@ class Auth[T](authTokenOps: AuthTokenOps[T], db: DB, clock: Clock) extends Loggi /** Authenticates using the given authentication token. If the token is invalid, a [[Fail.Unauthorized]] error is returned. Otherwise, * returns the id of the authenticated user . */ - def apply(id: Id[T])(using IO): Either[Fail.Unauthorized, Id[User]] = + def apply(id: Id[T]): Either[Fail.Unauthorized, Id[User]] = db.transact(authTokenOps.findById(id)) match { case None => logger.debug(s"Auth failed for: ${authTokenOps.tokenName} $id") diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserApi.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserApi.scala index ccc3ffa63..b2b04fbcc 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserApi.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserApi.scala @@ -1,22 +1,70 @@ package com.softwaremill.bootzooka.user import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec -import com.softwaremill.bootzooka.http.Http +import com.softwaremill.bootzooka.Fail +import com.softwaremill.bootzooka.http.Http.* import com.softwaremill.bootzooka.infrastructure.DB import com.softwaremill.bootzooka.infrastructure.Magnum.* import com.softwaremill.bootzooka.metrics.Metrics import com.softwaremill.bootzooka.security.{ApiKey, Auth} -import com.softwaremill.bootzooka.util.ServerEndpoints -import com.softwaremill.bootzooka.util.Strings.asId -import ox.IO -import sttp.tapir.Schema +import com.softwaremill.bootzooka.http.{EndpointsForDocs, ServerEndpoints} +import com.softwaremill.bootzooka.util.Strings.{Id, asId} +import sttp.tapir.* +import sttp.tapir.json.jsoniter.jsonBody import java.time.Instant import scala.concurrent.duration.* -class UserApi(http: Http, auth: Auth[ApiKey], userService: UserService, db: DB, metrics: Metrics)(using IO): +class UserApi(auth: Auth[ApiKey], userService: UserService, db: DB, metrics: Metrics) extends ServerEndpoints: import UserApi._ - import http._ + + // endpoint implementations + + private val registerUserServerEndpoint = registerUserEndpoint.handle { data => + val apiKeyResult = db.transactEither(userService.registerNewUser(data.login, data.email, data.password)) + metrics.registeredUsersCounter.add(1) + apiKeyResult.map(apiKey => Register_OUT(apiKey.id.toString)) + } + + private val loginServerEndpoint = loginEndpoint.handle { data => + val apiKeyResult = + db.transactEither(userService.login(data.loginOrEmail, data.password, data.apiKeyValidHours.map(h => Duration(h.toLong, HOURS)))) + apiKeyResult.map(apiKey => Login_OUT(apiKey.id.toString)) + } + + private def authedEndpoint[I, O](e: Endpoint[Id[ApiKey], I, Fail, O, Any]) = e.handleSecurity(authData => auth(authData)) + + private val logoutServerEndpoint = authedEndpoint(logoutEndpoint).handleSuccess { _ => data => + db.transactEither(Right(userService.logout(data.apiKey.asId[ApiKey]))) + Logout_OUT() + } + + private val changePasswordServerEndpoint = authedEndpoint(changePasswordEndpoint).handle { id => data => + val apiKeyResult = db.transactEither(userService.changePassword(id, data.currentPassword, data.newPassword)) + apiKeyResult.map(apiKey => ChangePassword_OUT(apiKey.id.toString)) + } + + private val getUserServerEndpoint = authedEndpoint(getUserEndpoint).handle { id => (_: Unit) => + val userResult = db.transactEither(userService.findById(id)) + userResult.map(user => GetUser_OUT(user.login, user.emailLowerCase, user.createdOn)) + } + + private val updateUserServerEndpoint = authedEndpoint(updateUserEndpoint).handle { id => data => + db.transactEither(userService.changeUser(id, data.login, data.email)).map(_ => UpdateUser_OUT()) + } + + override val endpoints = List( + registerUserServerEndpoint, + loginServerEndpoint, + logoutServerEndpoint, + changePasswordServerEndpoint, + getUserServerEndpoint, + updateUserServerEndpoint + ) +end UserApi + +object UserApi extends EndpointsForDocs: + // endpoint descriptions private val UserPath = "user" @@ -24,59 +72,32 @@ class UserApi(http: Http, auth: Auth[ApiKey], userService: UserService, db: DB, .in(UserPath / "register") .in(jsonBody[Register_IN]) .out(jsonBody[Register_OUT]) - .handle { data => - val apiKeyResult = db.transactEither(userService.registerNewUser(data.login, data.email, data.password)) - metrics.registeredUsersCounter.add(1) - apiKeyResult.map(apiKey => Register_OUT(apiKey.id.toString)) - } private val loginEndpoint = baseEndpoint.post .in(UserPath / "login") .in(jsonBody[Login_IN]) .out(jsonBody[Login_OUT]) - .handle { data => - val apiKeyResult = - db.transactEither(userService.login(data.loginOrEmail, data.password, data.apiKeyValidHours.map(h => Duration(h.toLong, HOURS)))) - apiKeyResult.map(apiKey => Login_OUT(apiKey.id.toString)) - } - private val authedEndpoint = secureEndpoint.handleSecurity(authData => auth(authData)) - - private val logoutEndpoint = authedEndpoint.post + private val logoutEndpoint = secureEndpoint[ApiKey].post .in(UserPath / "logout") .in(jsonBody[Logout_IN]) .out(jsonBody[Logout_OUT]) - .handleSuccess { _ => data => - db.transactEither(Right(userService.logout(data.apiKey.asId[ApiKey]))) - Logout_OUT() - } - private val changePasswordEndpoint = authedEndpoint.post + private val changePasswordEndpoint = secureEndpoint[ApiKey].post .in(UserPath / "changepassword") .in(jsonBody[ChangePassword_IN]) .out(jsonBody[ChangePassword_OUT]) - .handle { id => data => - val apiKeyResult = db.transactEither(userService.changePassword(id, data.currentPassword, data.newPassword)) - apiKeyResult.map(apiKey => ChangePassword_OUT(apiKey.id.toString)) - } - private val getUserEndpoint = authedEndpoint.get + private val getUserEndpoint = secureEndpoint[ApiKey].get .in(UserPath) .out(jsonBody[GetUser_OUT]) - .handle { id => (_: Unit) => - val userResult = db.transactEither(userService.findById(id)) - userResult.map(user => GetUser_OUT(user.login, user.emailLowerCase, user.createdOn)) - } - private val updateUserEndpoint = authedEndpoint.post + private val updateUserEndpoint = secureEndpoint[ApiKey].post .in(UserPath) .in(jsonBody[UpdateUser_IN]) .out(jsonBody[UpdateUser_OUT]) - .handle { id => data => - db.transactEither(userService.changeUser(id, data.login, data.email)).map(_ => UpdateUser_OUT()) - } - val endpoints: ServerEndpoints = List( + override val endpointsForDocs = List( registerUserEndpoint, loginEndpoint, logoutEndpoint, @@ -84,9 +105,9 @@ class UserApi(http: Http, auth: Auth[ApiKey], userService: UserService, db: DB, getUserEndpoint, updateUserEndpoint ).map(_.tag("user")) -end UserApi -object UserApi: + // + case class Register_IN(login: String, email: String, password: String) derives ConfiguredJsonValueCodec, Schema case class Register_OUT(apiKey: String) derives ConfiguredJsonValueCodec, Schema diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/util/BaseModule.scala b/backend/src/main/scala/com/softwaremill/bootzooka/util/BaseModule.scala deleted file mode 100644 index 0b13a4812..000000000 --- a/backend/src/main/scala/com/softwaremill/bootzooka/util/BaseModule.scala +++ /dev/null @@ -1,8 +0,0 @@ -package com.softwaremill.bootzooka.util - -import com.softwaremill.bootzooka.config.Config - -trait BaseModule: - def idGenerator: IdGenerator - def clock: Clock - def config: Config diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/util/package.scala b/backend/src/main/scala/com/softwaremill/bootzooka/util/package.scala deleted file mode 100644 index 5df2926dd..000000000 --- a/backend/src/main/scala/com/softwaremill/bootzooka/util/package.scala +++ /dev/null @@ -1,6 +0,0 @@ -package com.softwaremill.bootzooka.util - -import sttp.shared.Identity -import sttp.tapir.server.ServerEndpoint - -type ServerEndpoints = List[ServerEndpoint[Any, Identity]] diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala index ddc3bd760..05f2251ed 100644 --- a/backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala +++ b/backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala @@ -2,7 +2,6 @@ package com.softwaremill.bootzooka.email.sender import com.softwaremill.bootzooka.email.EmailData import com.softwaremill.bootzooka.test.BaseTest -import ox.IO.globalForTesting.given class DummyEmailSenderTest extends BaseTest: it should "send scheduled email" in { diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala index 02a687f68..33e160cb0 100644 --- a/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala +++ b/backend/src/test/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApiTest.scala @@ -5,7 +5,6 @@ import com.softwaremill.bootzooka.passwordreset.PasswordResetApi.{ForgotPassword import com.softwaremill.bootzooka.test.* import org.scalatest.concurrent.Eventually import sttp.model.StatusCode -import ox.IO.globalForTesting.given class PasswordResetApiTest extends BaseTest with Eventually with TestDependencies with TestSupport: diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestDependencies.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestDependencies.scala index 74d560b99..b8ca09b21 100644 --- a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestDependencies.scala +++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestDependencies.scala @@ -1,40 +1,25 @@ package com.softwaremill.bootzooka.test import com.softwaremill.bootzooka.Dependencies -import com.softwaremill.bootzooka.config.Config -import com.softwaremill.bootzooka.infrastructure.DB -import com.softwaremill.bootzooka.util.Clock -import org.scalatest.{Args, BeforeAndAfterAll, Status, Suite} -import ox.IO.globalForTesting.given -import ox.{Ox, supervised} +import io.opentelemetry.api.OpenTelemetry +import org.scalatest.{BeforeAndAfterAll, Suite} import sttp.client3.testing.SttpBackendStub import sttp.client3.{HttpClientSyncBackend, SttpBackend} import sttp.shared.Identity import sttp.tapir.server.stub.TapirStubInterpreter +import scala.compiletime.uninitialized + trait TestDependencies extends BeforeAndAfterAll with TestEmbeddedPostgres: - self: Suite with BaseTest => - var dependencies: Dependencies = _ + self: Suite & BaseTest => - private val stub: SttpBackendStub[Identity, Any] = HttpClientSyncBackend.stub - private var currentOx: Ox = _ + var dependencies: Dependencies = uninitialized - abstract override protected def runTests(testName: Option[String], args: Args): Status = - supervised { - currentOx = summon[Ox] - super.runTests(testName, args) - } + private val stub: SttpBackendStub[Identity, Any] = HttpClientSyncBackend.stub override protected def beforeAll(): Unit = { super.beforeAll() - - given Ox = currentOx - dependencies = new Dependencies { - override lazy val config: Config = TestConfig - override lazy val sttpBackend: SttpBackend[Identity, Any] = stub - override lazy val db: DB = currentDb - override lazy val clock: Clock = testClock - } + dependencies = Dependencies.create(TestConfig, OpenTelemetry.noop(), stub, currentDb, testClock) } private lazy val serverStub: SttpBackend[Identity, Any] = diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala index d6fbfb0f6..4182824b0 100644 --- a/backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala +++ b/backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala @@ -7,14 +7,15 @@ import com.softwaremill.bootzooka.logging.Logging import org.flywaydb.core.Flyway import org.postgresql.jdbc.PgConnection import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Suite} -import ox.IO.globalForTesting.given import ox.discard +import scala.compiletime.uninitialized + /** Base trait for tests which use the database. The database is cleaned after each test. */ trait TestEmbeddedPostgres extends BeforeAndAfterEach with BeforeAndAfterAll with Logging { self: Suite => - private var postgres: EmbeddedPostgres = _ - private var currentDbConfig: DBConfig = _ - var currentDb: DB = _ + private var postgres: EmbeddedPostgres = uninitialized + private var currentDbConfig: DBConfig = uninitialized + var currentDb: DB = uninitialized // diff --git a/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala b/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala index d3c6a4248..d60820528 100644 --- a/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala +++ b/backend/src/test/scala/com/softwaremill/bootzooka/user/UserApiTest.scala @@ -5,7 +5,6 @@ import com.softwaremill.bootzooka.test.{BaseTest, RegisteredUser, TestDependenci import com.softwaremill.bootzooka.user.UserApi.* import org.scalatest.concurrent.Eventually import sttp.model.StatusCode -import ox.IO.globalForTesting.given import scala.concurrent.duration.* diff --git a/build.sbt b/build.sbt index 013f2dc7f..0b4b1f32a 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,6 @@ import sbtbuildinfo.BuildInfoKey.action import sbtbuildinfo.BuildInfoKeys.{buildInfoKeys, buildInfoOptions, buildInfoPackage} import sbtbuildinfo.{BuildInfoKey, BuildInfoOption} -import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings import sbt._ import Keys._ @@ -12,8 +11,10 @@ import complete.DefaultParsers._ val password4jVersion = "1.8.2" val sttpVersion = "3.10.1" -val tapirVersion = "1.11.4" -val oxVersion = "0.3.9" +val tapirVersion = "1.11.6" +val oxVersion = "0.5.1" +val otelVersion = "1.43.0" +val otelInstrumentationVersion = "2.8.0-alpha" val dbDependencies = Seq( "com.augustnagro" %% "magnum" % "1.3.1", @@ -30,10 +31,13 @@ val httpDependencies = Seq( "com.softwaremill.sttp.tapir" %% "tapir-files" % tapirVersion ) -val monitoringDependencies = Seq( +val observabilityDependencies = Seq( "com.softwaremill.sttp.client3" %% "opentelemetry-metrics-backend" % sttpVersion, "com.softwaremill.sttp.tapir" %% "tapir-opentelemetry-metrics" % tapirVersion, - "io.opentelemetry" % "opentelemetry-exporter-otlp" % "1.43.0" + "io.opentelemetry" % "opentelemetry-exporter-otlp" % otelVersion, + "io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % otelVersion, + "io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java8" % otelInstrumentationVersion, + "io.opentelemetry.instrumentation" % "opentelemetry-logback-appender-1.0" % otelInstrumentationVersion ) val jsonDependencies = Seq( @@ -44,6 +48,7 @@ val jsonDependencies = Seq( val loggingDependencies = Seq( "ch.qos.logback" % "logback-classic" % "1.5.11", + "org.slf4j" % "jul-to-slf4j" % "1.7.36", // forward e.g. otel logs which use JUL to SLF4J "com.softwaremill.ox" %% "mdc-logback" % oxVersion, "org.slf4j" % "slf4j-jdk-platform-logging" % "2.0.7" % Runtime, "org.codehaus.janino" % "janino" % "3.1.12" % Runtime, @@ -56,7 +61,8 @@ val configDependencies = Seq( val baseDependencies = Seq( "com.softwaremill.ox" %% "core" % oxVersion, - "com.softwaremill.quicklens" %% "quicklens" % "1.9.9" + "com.softwaremill.quicklens" %% "quicklens" % "1.9.9", + "com.softwaremill.macwire" %% "macros" % "2.6.2" % Provided ) val apiDocsDependencies = Seq( @@ -71,25 +77,21 @@ val emailDependencies = Seq( "com.sun.mail" % "javax.mail" % "1.6.2" exclude ("javax.activation", "activation") ) -val scalatest = "org.scalatest" %% "scalatest" % "3.2.19" % Test - -val unitTestingStack = Seq(scalatest) - -val embeddedPostgres = "com.opentable.components" % "otj-pg-embedded" % "1.1.0" % Test -val dbTestingStack = Seq(embeddedPostgres) - -val commonDependencies = baseDependencies ++ unitTestingStack ++ loggingDependencies ++ configDependencies +val testingDependencies = Seq( + "org.scalatest" %% "scalatest" % "3.2.19" % Test, + "com.opentable.components" % "otj-pg-embedded" % "1.1.0" % Test +) lazy val uiProjectName = "ui" lazy val uiDirectory = settingKey[File]("Path to the ui project directory") lazy val updateYarn = taskKey[Unit]("Update yarn") lazy val yarnTask = inputKey[Unit]("Run yarn with arguments") lazy val copyWebapp = taskKey[Unit]("Copy webapp") +lazy val generateOpenAPIDescription = taskKey[Unit]("Generate the OpenAPI description for the HTTP API") -lazy val commonSettings = commonSmlBuildSettings ++ Seq( +lazy val commonSettings = Seq( organization := "com.softwaremill.bootzooka", - scalaVersion := "3.3.4", - libraryDependencies ++= commonDependencies, + scalaVersion := "3.5.0", uiDirectory := (ThisBuild / baseDirectory).value / uiProjectName, updateYarn := { streams.value.log("Updating npm/yarn dependencies") @@ -102,10 +104,7 @@ lazy val commonSettings = commonSmlBuildSettings ++ Seq( def runYarnTask() = Process(localYarnCommand, uiDirectory.value).! streams.value.log("Running yarn task: " + taskName) haltOnCmdResultError(runYarnTask()) - }, - autoCompilerPlugins := true, - addCompilerPlugin("com.softwaremill.ox" %% "plugin" % "0.3.9"), - Compile / scalacOptions += "-P:requireIO:javax.mail.MessagingException" + } ) lazy val buildInfoSettings = Seq( @@ -150,7 +149,7 @@ lazy val dockerSettings = Seq( Docker / packageName := "bootzooka", dockerUsername := Some("softwaremill"), dockerUpdateLatest := true, - Compile / packageBin := (Compile / packageBin).dependsOn(copyWebapp).value, + Docker / stage := (Docker / stage).dependsOn(copyWebapp).value, Docker / version := git.gitDescribedVersion.value.getOrElse(git.formattedShaVersion.value.getOrElse("latest")), git.uncommittedSignifier := Some("dirty"), ThisBuild / git.formattedShaVersion := { @@ -179,7 +178,9 @@ lazy val rootProject = (project in file(".")) lazy val backend: Project = (project in file("backend")) .settings( - libraryDependencies ++= dbDependencies ++ httpDependencies ++ jsonDependencies ++ apiDocsDependencies ++ monitoringDependencies ++ dbTestingStack ++ securityDependencies ++ emailDependencies, + libraryDependencies ++= baseDependencies ++ testingDependencies ++ loggingDependencies ++ + configDependencies ++ dbDependencies ++ httpDependencies ++ jsonDependencies ++ + apiDocsDependencies ++ observabilityDependencies ++ securityDependencies ++ emailDependencies, Compile / mainClass := Some("com.softwaremill.bootzooka.Main"), copyWebapp := { val source = uiDirectory.value / "build" @@ -187,9 +188,25 @@ lazy val backend: Project = (project in file("backend")) streams.value.log.info(s"Copying the webapp resources from $source to $target") IO.copyDirectory(source, target) }, - copyWebapp := copyWebapp.dependsOn(yarnTask.toTask(" build")).value, + copyWebapp := copyWebapp + .dependsOn( + Def + .sequential( + generateOpenAPIDescription, + yarnTask.toTask(" build") + ) + ) + .value, + generateOpenAPIDescription := Def.taskDyn { + val log = streams.value.log + val targetPath = ((Compile / target).value / "openapi.yaml").toString + Def.task { + (Compile / runMain).toTask(s" com.softwaremill.bootzooka.writeOpenAPIDescription $targetPath").value + } + }.value, // needed so that a ctrl+c issued when running the backend from the sbt console properly interrupts the application - run / fork := true + run / fork := true, + scalacOptions ++= List("-Wunused:all", "-Wvalue-discard") ) .enablePlugins(BuildInfoPlugin) .settings(commonSettings) diff --git a/docker-compose.yml b/docker-compose.yml index 556278365..7a6f0dc66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: bootzooka: image: 'softwaremill/bootzooka:latest' @@ -13,6 +12,11 @@ services: SQL_HOST: 'bootzooka-db' SQL_PORT: '5432' API_HOST: '0.0.0.0' + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://observability:4317' + OTEL_SERVICE_NAME: 'bootzooka' + OTEL_METRIC_EXPORT_INTERVAL: '500' # The default is 60s, in development it's useful to see the metrics faster + OTEL_RESOURCE_ATTRIBUTES: 'service.instance.id=local,service.version=latest' + bootzooka-db: image: 'postgres' ports: @@ -21,3 +25,9 @@ services: POSTGRES_USER: 'postgres' POSTGRES_PASSWORD: 'b00t200k4' POSTGRES_DB: 'bootzooka' + + # OpenTelemetry Collector, Prometheus, Loki, Tempo, Grafana + observability: + image: 'grafana/otel-lgtm' + ports: + - '3000:3000' # Grafana's UI diff --git a/docs/devtips.md b/docs/devtips.md index aa017fe9d..baf34e0ad 100644 --- a/docs/devtips.md +++ b/docs/devtips.md @@ -45,18 +45,13 @@ This will bring into scope custom [Magnum](https://github.com/AugustNagro/magnum ### HTTP API -If you are describing new endpoints, import all members of the current `Http` instance: +If you are describing new endpoints, import all members of `Http`: ```scala -import com.softwaremill.bootzooka.http.Http - -class UserApi(http: Http): - import http.* - - ... +import com.softwaremill.bootzooka.http.Http.* ``` -This will bring into scope Tapir builder methods and schemas for documentation. +This will bring into scope Tapir builder methods and schemas for documentation, along with Bootzooka-specific customizations. ### Logging diff --git a/docs/production.md b/docs/production.md index e01dcde2a..71d5c1b42 100644 --- a/docs/production.md +++ b/docs/production.md @@ -13,7 +13,7 @@ java -jar backend/target/scala-VERSION/bootzooka.jar ## Docker -To build a docker image, run `backend/docker:publishLocal`. This will create the `docker:latest` image. +To build a docker image, run `backend/Docker/publishLocal`. This will create the `docker:latest` image. You can test the image by using the provided `docker-compose.yml` file. diff --git a/docs/stack.md b/docs/stack.md index feed80f00..1f4941e42 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -21,6 +21,9 @@ And on the frontend: * [react](https://reactjs.org) * [Swagger](https://swagger.io) (interactive API docs) * [yarn](https://yarnpkg.com) (build tool) +* [formik](https://formik.org/) (forms) +* [yup](https://www.npmjs.com/package/yup/v/1.3.3) (validation) +* [openapi-codegen](https://github.com/fabien0102/openapi-codegen) (generate ui functions from OpenAPI specifications) ### Why Scala? @@ -43,3 +46,7 @@ Tapir defines a programmer-friendly API for describing HTTP endpoints which can ### Why SBT and Yarn? To put it simply, [SBT](https://www.scala-sbt.org) is the build tool of choice for Scala, yarn - for JavaScript. + +### Why openapi-codegen? + +This library is designed to generate TypeScript code from OpenAPI specifications, facilitating type-safe interactions with APIs in frontend applications. Codegen generates TypeScript types based on the OpenAPI schemas, ensuring that the data structures used in the application are type-safe and aligned with the API specifications. It also generates hooks for React Query and creates functions that can make requests to APIs while maintaining type safety, which helps prevent runtime errors due to type mismatches. diff --git a/project/plugins.sbt b/project/plugins.sbt index 0c753ee38..c1dab1f91 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,10 +2,10 @@ addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") -addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % "2.0.20") - addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.0") addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.10.4") addSbtPlugin("com.github.sbt" % "sbt-git" % "2.1.0") + +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 000000000..e4d2f9247 --- /dev/null +++ b/ui/.env.example @@ -0,0 +1 @@ +REACT_APP_BASE_URL = "http://localhost:3000/api/v1" diff --git a/ui/.gitignore b/ui/.gitignore deleted file mode 100644 index 4d29575de..000000000 --- a/ui/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# production -/build - -# misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* diff --git a/ui/README.md b/ui/README.md index 64e343e18..226ac0392 100644 --- a/ui/README.md +++ b/ui/README.md @@ -12,6 +12,21 @@ Open [http://localhost:3000](http://localhost:3000) to view it in the browser. The page will reload if you make edits.
You will also see any lint errors in the console. +#### API client & associated types + +Before running `yarn start`, make sure to run `sbt "backend/generateOpenAPIDescription"` in the project's root directory. This command will generate the `/backend/target/openapi.yaml` file. + +Type-safe React Query hooks are generated upon UI application start (`yarn start`), based on the `openapi.yaml` contents. + +These files are: + +- `src/api/{namespace}Fetcher.ts` - defines a function that will make requests to your API. +- `src/api/{namespace}Context.ts` - the context that provides `{namespace}Fetcher` to other components. +- `src/api/{namespace}Components.ts` - generated React Query components (if you selected React Query as part of initialization). +- `src/api/{namespace}Schemas.ts` - the generated Typescript types from the provided Open API schemas. + +A file watch is engaged, re-generating types on each change to the `/backend/target/openapi.yaml` file. + ### `yarn test` Launches the test runner in the interactive watch mode.
diff --git a/ui/openapi-codegen.config.ts b/ui/openapi-codegen.config.ts new file mode 100644 index 000000000..49892bfce --- /dev/null +++ b/ui/openapi-codegen.config.ts @@ -0,0 +1,38 @@ +import { generateSchemaTypes, generateReactQueryComponents } from "@openapi-codegen/typescript"; +import { defineConfig } from "@openapi-codegen/cli"; +export default defineConfig({ + apiFile: { + from: { + relativePath: "../backend/target/openapi.yaml", + source: "file", + }, + outputDir: "./src/api", + to: async (context) => { + const filenamePrefix = "api"; + const { schemasFiles } = await generateSchemaTypes(context, { + filenamePrefix, + }); + await generateReactQueryComponents(context, { + filenamePrefix, + schemasFiles, + }); + }, + }, + apiWeb: { + from: { + source: "url", + url: "http://localhost:8080/api/v1/docs/docs.yaml", + }, + outputDir: "./src/api", + to: async (context) => { + const filenamePrefix = "api"; + const { schemasFiles } = await generateSchemaTypes(context, { + filenamePrefix, + }); + await generateReactQueryComponents(context, { + filenamePrefix, + schemasFiles, + }); + }, + }, +}); diff --git a/ui/package.json b/ui/package.json index 854944bae..d8870d06f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,9 +4,10 @@ "private": true, "proxy": "http://localhost:8080", "engines": { - "node": ">=16" + "node": ">=22" }, "dependencies": { + "@tanstack/react-query": "^5.62.7", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.2", @@ -18,7 +19,6 @@ "@types/react-router-bootstrap": "^0.26.6", "@types/react-router-dom": "^5.3.3", "@types/yup": "^0.32.0", - "axios": "^1.6.5", "bootstrap": "^5.3.2", "eslint-config-prettier": "^9.1.0", "formik": "^2.4.5", @@ -28,21 +28,23 @@ "react-bootstrap": "^2.9.2", "react-dom": "^18.2.0", "react-icons": "^4.12.0", - "react-query": "^3.39.3", "react-router-bootstrap": "^0.26.2", "react-router-dom": "^6.21.1", "react-scripts": "^5.0.1", - "typescript": "5.1.6", + "typescript": "5.7.3", "yup": "^1.3.3" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", + "start": "yarn generate:openapi-types && concurrently \"react-scripts start\" \"yarn watch:openapi\"", + "build": "yarn generate:openapi-types && react-scripts build", "test": "react-scripts test", "test:coverage": "react-scripts test --coverage --watchAll=false", "test:ci": "CI=true react-scripts test --maxWorkers 2", "lint": "eslint --ext .ts,.tsx . && prettier --write ./src", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "generate:openapi-types": "npx openapi-codegen gen apiWeb --source file --relativePath ../backend/target/openapi.yaml", + "watch:openapi": "chokidar '../backend/target/openapi.yaml' -c 'yarn generate:openapi-types'", + "start:frontend": "yarn start" }, "eslintConfig": { "extends": [ @@ -56,9 +58,6 @@ "src/**/*.{ts,tsx}", "!src/index.tsx", "!src/serviceWorker.ts" - ], - "transformIgnorePatterns": [ - "node_modules/(?!(axios)/)" ] }, "browserslist": { @@ -72,5 +71,12 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@openapi-codegen/cli": "^2.0.2", + "@openapi-codegen/typescript": "^8.0.2", + "chokidar-cli": "^3.0.0", + "concurrently": "^8.2.2" } } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ac30405ed..ddda0edc5 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,7 +2,7 @@ import React from "react"; import { BrowserRouter } from "react-router-dom"; import { Main } from "main"; import { UserContextProvider } from "contexts"; -import { QueryClient, QueryClientProvider } from "react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient(); diff --git a/ui/src/api/apiComponents.ts b/ui/src/api/apiComponents.ts new file mode 100644 index 000000000..794ac08c7 --- /dev/null +++ b/ui/src/api/apiComponents.ts @@ -0,0 +1,403 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +import * as reactQuery from "@tanstack/react-query"; +import { useApiContext, ApiContext } from "./apiContext"; +import type * as Fetcher from "./apiFetcher"; +import { apiFetch } from "./apiFetcher"; +import type * as Schemas from "./apiSchemas"; +import type { ClientErrorStatus, ServerErrorStatus } from "./apiUtils"; + +export type PostUserRegisterError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type PostUserRegisterVariables = { + body: Schemas.RegisterIN; +} & ApiContext["fetcherOptions"]; + +export const fetchPostUserRegister = ( + variables: PostUserRegisterVariables, + signal?: AbortSignal, +) => + apiFetch< + Schemas.RegisterOUT, + PostUserRegisterError, + Schemas.RegisterIN, + {}, + {}, + {} + >({ url: "/user/register", method: "post", ...variables, signal }); + +export const usePostUserRegister = ( + options?: Omit< + reactQuery.UseMutationOptions< + Schemas.RegisterOUT, + PostUserRegisterError, + PostUserRegisterVariables + >, + "mutationFn" + >, +) => { + const { fetcherOptions } = useApiContext(); + return reactQuery.useMutation< + Schemas.RegisterOUT, + PostUserRegisterError, + PostUserRegisterVariables + >({ + mutationFn: (variables: PostUserRegisterVariables) => + fetchPostUserRegister({ ...fetcherOptions, ...variables }), + ...options, + }); +}; + +export type PostUserLoginError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type PostUserLoginVariables = { + body: Schemas.LoginIN; +} & ApiContext["fetcherOptions"]; + +export const fetchPostUserLogin = ( + variables: PostUserLoginVariables, + signal?: AbortSignal, +) => + apiFetch({ + url: "/user/login", + method: "post", + ...variables, + signal, + }); + +export const usePostUserLogin = ( + options?: Omit< + reactQuery.UseMutationOptions< + Schemas.LoginOUT, + PostUserLoginError, + PostUserLoginVariables + >, + "mutationFn" + >, +) => { + const { fetcherOptions } = useApiContext(); + return reactQuery.useMutation< + Schemas.LoginOUT, + PostUserLoginError, + PostUserLoginVariables + >({ + mutationFn: (variables: PostUserLoginVariables) => + fetchPostUserLogin({ ...fetcherOptions, ...variables }), + ...options, + }); +}; + +export type PostUserLogoutError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type PostUserLogoutVariables = { + body: Schemas.LogoutIN; +} & ApiContext["fetcherOptions"]; + +export const fetchPostUserLogout = ( + variables: PostUserLogoutVariables, + signal?: AbortSignal, +) => + apiFetch< + Schemas.LogoutOUT, + PostUserLogoutError, + Schemas.LogoutIN, + {}, + {}, + {} + >({ url: "/user/logout", method: "post", ...variables, signal }); + +export const usePostUserLogout = ( + options?: Omit< + reactQuery.UseMutationOptions< + Schemas.LogoutOUT, + PostUserLogoutError, + PostUserLogoutVariables + >, + "mutationFn" + >, +) => { + const { fetcherOptions } = useApiContext(); + return reactQuery.useMutation< + Schemas.LogoutOUT, + PostUserLogoutError, + PostUserLogoutVariables + >({ + mutationFn: (variables: PostUserLogoutVariables) => + fetchPostUserLogout({ ...fetcherOptions, ...variables }), + ...options, + }); +}; + +export type PostUserChangepasswordError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type PostUserChangepasswordVariables = { + body: Schemas.ChangePasswordIN; +} & ApiContext["fetcherOptions"]; + +export const fetchPostUserChangepassword = ( + variables: PostUserChangepasswordVariables, + signal?: AbortSignal, +) => + apiFetch< + Schemas.ChangePasswordOUT, + PostUserChangepasswordError, + Schemas.ChangePasswordIN, + {}, + {}, + {} + >({ url: "/user/changepassword", method: "post", ...variables, signal }); + +export const usePostUserChangepassword = ( + options?: Omit< + reactQuery.UseMutationOptions< + Schemas.ChangePasswordOUT, + PostUserChangepasswordError, + PostUserChangepasswordVariables + >, + "mutationFn" + >, +) => { + const { fetcherOptions } = useApiContext(); + return reactQuery.useMutation< + Schemas.ChangePasswordOUT, + PostUserChangepasswordError, + PostUserChangepasswordVariables + >({ + mutationFn: (variables: PostUserChangepasswordVariables) => + fetchPostUserChangepassword({ ...fetcherOptions, ...variables }), + ...options, + }); +}; + +export type GetUserError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type GetUserVariables = ApiContext["fetcherOptions"]; + +export const fetchGetUser = ( + variables: GetUserVariables, + signal?: AbortSignal, +) => + apiFetch({ + url: "/user", + method: "get", + ...variables, + signal, + }); + +export const useGetUser = ( + variables: GetUserVariables, + options?: Omit< + reactQuery.UseQueryOptions, + "queryKey" | "queryFn" | "initialData" + >, +) => { + const { fetcherOptions, queryOptions, queryKeyFn } = useApiContext(options); + return reactQuery.useQuery({ + queryKey: queryKeyFn({ path: "/user", operationId: "getUser", variables }), + queryFn: ({ signal }) => + fetchGetUser({ ...fetcherOptions, ...variables }, signal), + ...options, + ...queryOptions, + }); +}; + +export type PostUserError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type PostUserVariables = { + body: Schemas.UpdateUserIN; +} & ApiContext["fetcherOptions"]; + +export const fetchPostUser = ( + variables: PostUserVariables, + signal?: AbortSignal, +) => + apiFetch< + Schemas.UpdateUserOUT, + PostUserError, + Schemas.UpdateUserIN, + {}, + {}, + {} + >({ url: "/user", method: "post", ...variables, signal }); + +export const usePostUser = ( + options?: Omit< + reactQuery.UseMutationOptions< + Schemas.UpdateUserOUT, + PostUserError, + PostUserVariables + >, + "mutationFn" + >, +) => { + const { fetcherOptions } = useApiContext(); + return reactQuery.useMutation< + Schemas.UpdateUserOUT, + PostUserError, + PostUserVariables + >({ + mutationFn: (variables: PostUserVariables) => + fetchPostUser({ ...fetcherOptions, ...variables }), + ...options, + }); +}; + +export type PostPasswordresetResetError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type PostPasswordresetResetVariables = { + body: Schemas.PasswordResetIN; +} & ApiContext["fetcherOptions"]; + +export const fetchPostPasswordresetReset = ( + variables: PostPasswordresetResetVariables, + signal?: AbortSignal, +) => + apiFetch< + Schemas.PasswordResetOUT, + PostPasswordresetResetError, + Schemas.PasswordResetIN, + {}, + {}, + {} + >({ url: "/passwordreset/reset", method: "post", ...variables, signal }); + +export const usePostPasswordresetReset = ( + options?: Omit< + reactQuery.UseMutationOptions< + Schemas.PasswordResetOUT, + PostPasswordresetResetError, + PostPasswordresetResetVariables + >, + "mutationFn" + >, +) => { + const { fetcherOptions } = useApiContext(); + return reactQuery.useMutation< + Schemas.PasswordResetOUT, + PostPasswordresetResetError, + PostPasswordresetResetVariables + >({ + mutationFn: (variables: PostPasswordresetResetVariables) => + fetchPostPasswordresetReset({ ...fetcherOptions, ...variables }), + ...options, + }); +}; + +export type PostPasswordresetForgotError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type PostPasswordresetForgotVariables = { + body: Schemas.ForgotPasswordIN; +} & ApiContext["fetcherOptions"]; + +export const fetchPostPasswordresetForgot = ( + variables: PostPasswordresetForgotVariables, + signal?: AbortSignal, +) => + apiFetch< + Schemas.ForgotPasswordOUT, + PostPasswordresetForgotError, + Schemas.ForgotPasswordIN, + {}, + {}, + {} + >({ url: "/passwordreset/forgot", method: "post", ...variables, signal }); + +export const usePostPasswordresetForgot = ( + options?: Omit< + reactQuery.UseMutationOptions< + Schemas.ForgotPasswordOUT, + PostPasswordresetForgotError, + PostPasswordresetForgotVariables + >, + "mutationFn" + >, +) => { + const { fetcherOptions } = useApiContext(); + return reactQuery.useMutation< + Schemas.ForgotPasswordOUT, + PostPasswordresetForgotError, + PostPasswordresetForgotVariables + >({ + mutationFn: (variables: PostPasswordresetForgotVariables) => + fetchPostPasswordresetForgot({ ...fetcherOptions, ...variables }), + ...options, + }); +}; + +export type GetAdminVersionError = Fetcher.ErrorWrapper<{ + status: Exclude; + payload: Schemas.ErrorOUT; +}>; + +export type GetAdminVersionVariables = ApiContext["fetcherOptions"]; + +export const fetchGetAdminVersion = ( + variables: GetAdminVersionVariables, + signal?: AbortSignal, +) => + apiFetch({ + url: "/admin/version", + method: "get", + ...variables, + signal, + }); + +export const useGetAdminVersion = ( + variables: GetAdminVersionVariables, + options?: Omit< + reactQuery.UseQueryOptions, + "queryKey" | "queryFn" | "initialData" + >, +) => { + const { fetcherOptions, queryOptions, queryKeyFn } = useApiContext(options); + return reactQuery.useQuery({ + queryKey: queryKeyFn({ + path: "/admin/version", + operationId: "getAdminVersion", + variables, + }), + queryFn: ({ signal }) => + fetchGetAdminVersion({ ...fetcherOptions, ...variables }, signal), + ...options, + ...queryOptions, + }); +}; + +export type QueryOperation = + | { + path: "/user"; + operationId: "getUser"; + variables: GetUserVariables; + } + | { + path: "/admin/version"; + operationId: "getAdminVersion"; + variables: GetAdminVersionVariables; + }; diff --git a/ui/src/api/apiContext.ts b/ui/src/api/apiContext.ts new file mode 100644 index 000000000..571eff67c --- /dev/null +++ b/ui/src/api/apiContext.ts @@ -0,0 +1,94 @@ +import type { QueryKey, UseQueryOptions } from "@tanstack/react-query"; +import { QueryOperation } from "./apiComponents"; + +export type ApiContext = { + fetcherOptions: { + /** + * Headers to inject in the fetcher + */ + headers?: {}; + /** + * Query params to inject in the fetcher + */ + queryParams?: {}; + }; + queryOptions: { + /** + * Set this to `false` to disable automatic refetching when the query mounts or changes query keys. + * Defaults to `true`. + */ + enabled?: boolean; + }; + /** + * Query key manager. + */ + queryKeyFn: (operation: QueryOperation) => QueryKey; +}; + +/** + * Context injected into every react-query hook wrappers + * + * @param queryOptions options from the useQuery wrapper + */ +export function useApiContext< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(_queryOptions?: Omit, "queryKey" | "queryFn">): ApiContext { + return { + fetcherOptions: {}, + queryOptions: {}, + queryKeyFn, + }; +} + +export const queryKeyFn = (operation: QueryOperation) => { + const queryKey: unknown[] = hasPathParams(operation) + ? operation.path + .split("/") + .filter(Boolean) + .map((i) => resolvePathParam(i, operation.variables.pathParams)) + : operation.path.split("/").filter(Boolean); + + if (hasQueryParams(operation)) { + queryKey.push(operation.variables.queryParams); + } + + if (hasBody(operation)) { + queryKey.push(operation.variables.body); + } + + return queryKey; +}; +// Helpers +const resolvePathParam = (key: string, pathParams: Record) => { + if (key.startsWith("{") && key.endsWith("}")) { + return pathParams[key.slice(1, -1)]; + } + return key; +}; + +const hasPathParams = ( + operation: QueryOperation, +): operation is QueryOperation & { + variables: { pathParams: Record }; +} => { + return Boolean((operation.variables as any).pathParams); +}; + +const hasBody = ( + operation: QueryOperation, +): operation is QueryOperation & { + variables: { body: Record }; +} => { + return Boolean((operation.variables as any).body); +}; + +const hasQueryParams = ( + operation: QueryOperation, +): operation is QueryOperation & { + variables: { queryParams: Record }; +} => { + return Boolean((operation.variables as any).queryParams); +}; diff --git a/ui/src/api/apiFetcher.ts b/ui/src/api/apiFetcher.ts new file mode 100644 index 000000000..c02ada504 --- /dev/null +++ b/ui/src/api/apiFetcher.ts @@ -0,0 +1,93 @@ +import { ApiContext } from "./apiContext"; + +const baseUrl = process.env.REACT_APP_BASE_URL; + +export type ErrorWrapper = TError | { status: "unknown"; payload: string }; + +export type ApiFetcherOptions = { + url: string; + method: string; + body?: TBody; + headers?: THeaders; + queryParams?: TQueryParams; + pathParams?: TPathParams; + signal?: AbortSignal; +} & ApiContext["fetcherOptions"]; + +export async function apiFetch< + TData, + TError, + TBody extends {} | FormData | undefined | null, + THeaders extends {}, + TQueryParams extends {}, + TPathParams extends {}, +>({ + url, + method, + body, + headers, + pathParams, + queryParams, + signal, +}: ApiFetcherOptions): Promise { + try { + const requestHeaders: HeadersInit = { + "Content-Type": "application/json", + ...headers, + }; + + if (!requestHeaders.Authorization && localStorage.getItem("apiKey")) { + requestHeaders.Authorization = `Bearer ${localStorage.getItem("apiKey")}`; + } + + /** + * As the fetch API is being used, when multipart/form-data is specified + * the Content-Type header must be deleted so that the browser can set + * the correct boundary. + * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + */ + if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { + delete requestHeaders["Content-Type"]; + } + + const response = await window.fetch(`${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, { + signal, + method: method.toUpperCase(), + body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, + headers: requestHeaders, + }); + if (!response.ok) { + let error: ErrorWrapper; + try { + error = await response.json(); + } catch (e) { + error = { + status: "unknown" as const, + payload: e instanceof Error ? `Unexpected error (${e.message})` : "Unexpected error", + }; + } + + throw error; + } + + if (response.headers.get("content-type")?.includes("json")) { + return await response.json(); + } else { + // if it is not a json response, assume it is a blob and cast it to TData + return (await response.blob()) as unknown as TData; + } + } catch (e) { + let errorObject: Error = { + name: "unknown" as const, + message: e instanceof Error ? `Network error (${e.message})` : "Network error", + stack: e as string, + }; + throw errorObject; + } +} + +const resolveUrl = (url: string, queryParams: Record = {}, pathParams: Record = {}) => { + let query = new URLSearchParams(queryParams).toString(); + if (query) query = `?${query}`; + return url.replace(/\{\w*\}/g, (key) => pathParams[key.slice(1, -1)]) + query; +}; diff --git a/ui/src/api/apiSchemas.ts b/ui/src/api/apiSchemas.ts new file mode 100644 index 000000000..bd95222e5 --- /dev/null +++ b/ui/src/api/apiSchemas.ts @@ -0,0 +1,79 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +export type ChangePasswordIN = { + currentPassword: string; + newPassword: string; +}; + +export type ChangePasswordOUT = { + apiKey: string; +}; + +export type ErrorOUT = { + error: string; +}; + +export type ForgotPasswordIN = { + loginOrEmail: string; +}; + +export type ForgotPasswordOUT = Record; + +export type GetUserOUT = { + login: string; + email: string; + /** + * @format date-time + */ + createdOn: string; +}; + +export type LoginIN = { + loginOrEmail: string; + password: string; + /** + * @format int32 + */ + apiKeyValidHours?: number; +}; + +export type LoginOUT = { + apiKey: string; +}; + +export type LogoutIN = { + apiKey: string; +}; + +export type LogoutOUT = Record; + +export type PasswordResetIN = { + code: string; + password: string; +}; + +export type PasswordResetOUT = Record; + +export type RegisterIN = { + login: string; + email: string; + password: string; +}; + +export type RegisterOUT = { + apiKey: string; +}; + +export type UpdateUserIN = { + login: string; + email: string; +}; + +export type UpdateUserOUT = Record; + +export type VersionOUT = { + buildSha: string; +}; diff --git a/ui/src/api/apiUtils.ts b/ui/src/api/apiUtils.ts new file mode 100644 index 000000000..7f336fe11 --- /dev/null +++ b/ui/src/api/apiUtils.ts @@ -0,0 +1,15 @@ +type ComputeRange< + N extends number, + Result extends Array = [], +> = Result["length"] extends N + ? Result + : ComputeRange; + +export type ClientErrorStatus = Exclude< + ComputeRange<500>[number], + ComputeRange<400>[number] +>; +export type ServerErrorStatus = Exclude< + ComputeRange<600>[number], + ComputeRange<500>[number] +>; diff --git a/ui/src/components/FeedbackButton/FeedbackButton.tsx b/ui/src/components/FeedbackButton/FeedbackButton.tsx index 5c39958b2..036112a45 100644 --- a/ui/src/components/FeedbackButton/FeedbackButton.tsx +++ b/ui/src/components/FeedbackButton/FeedbackButton.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import { ReactElement } from "react"; import Button, { ButtonProps } from "react-bootstrap/Button"; import Spinner from "react-bootstrap/Spinner"; import Form from "react-bootstrap/Form"; @@ -6,27 +6,27 @@ import { IconType } from "react-icons"; import { BsExclamationCircle, BsCheck } from "react-icons/bs"; import useFormikValuesChanged from "./useFormikValuesChanged"; import { ErrorMessage } from "../"; -import { UseMutationResult } from "react-query"; +import { UseMutationResult } from "@tanstack/react-query"; -interface FeedbackButtonProps extends ButtonProps { +interface FeedbackButtonProps extends ButtonProps { label: string; Icon: IconType; - mutation: UseMutationResult; + mutation: UseMutationResult; successLabel?: string; } -export const FeedbackButton = ({ +export const FeedbackButton = ({ mutation, label, Icon, successLabel = "Success", ...buttonProps -}: FeedbackButtonProps): React.ReactElement => { +}: FeedbackButtonProps): ReactElement => { useFormikValuesChanged(() => { !mutation.isIdle && mutation.reset(); }); - if (mutation.isLoading) { + if (mutation.isPending) { return (