Привет, Хабр! Следующих этап изучения нового языка это старый добрый todo list только не простой а с загрузкой и скачиванием картинок чтобы научится работе с базой данных и файловой системой. За подробностями добро пожаловать под кат.

Содержание



Ссылки


Исходники
Образы docker image

API


Описание апи в swagger и эндпойнты я сделал с помощью Tapir. Он позволяет своим DSL описать API которое мы хотим реализовать.

//Пытаемся выполнить IO и если случается ошибка то бросаем http код 500
  def withStatus[A](f: IO[A]): IO[Either[(StatusCode, String), A]] =
    f.attempt.map(x => x match {
      case Right(value) => Right(value)
      case Left(value) => Left(StatusCode.InternalServerError, value.getMessage)
    })

//Описываем что все наши эндпоинты буду начинаться с api/v1 и возвращать в случае ошибки http код и текст ошибки
val baseEndpoint = endpoint
    .in("api" / "v1")
    .errorOut(statusCode.and(stringBody))

//Описываем что все эндпоинты для изображений будут по адресу api/v1/images
//и иметь тег Imgage.
//По тегам идет группировка в Swagger. Например на КДПВ эндпойнты собраны в две группы
//Images и Todos
private val baseImageEndpoint = baseEndpoint
    .in("images")
    .tag("Images")

  private val download = baseImageEndpoint
    .summary("Скачать картинку")
    .description("Скачивает картинку по ее идентификатору")
//Эндпоинт будет реагировать на GET запрос
    .get
// Эндпоинт будет брать переменную id из пути т.е для lacalhost:8080/api/v1/images/2 он передаст в id = 2
    .in(path[Long]("id"))
//Запрос будет возвращать в ответе хедер ContentLengsh
    .out(header[Long](HeaderNames.ContentLength))
//Запрос будет возвращать в ответ бинарный файл
    .out(streamBody[Stream[IO, Byte]](schemaFor[File], CodecFormat.OctetStream()))
//Логика обработки запроса. Тут мы просто вызываем метод нашего сервиса
    .serverLogic(x => withStatus(imagesService.download(x)))

на основе коллекции таких эндпойнтов создаются роуты, а на основе них документация Swagger

    endpoints = todosController.endpoints ::: imagesController.endpoints
    routes = endpoints.toRoutes;
    docs = endpoints.toOpenAPI("The Scala Todo List", "0.0.1")
    yml: String = docs.toYaml
    appRoutes = routes <+> new SwaggerHttp4s(yml, "swagger").routes[IO]

Server


В качестве сервера Tapir поддерживает несколько бекендов. Я использовал http4s

    httpApp = Router(
      "/" -> appRoutes
    ).orNotFound
    blazeServer <- BlazeServerBuilder[IO](serverEc)
      .bindHttp(settings.host.port, settings.host.host)
      .withHttpApp(httpApp)
      .resource

Работа с файлами и стримы


Для работы с файлами я использовал стримы из fs2

import fs2.{Stream, io}

  def get(path: Path): Stream[IO, Byte] =
    io.file.readAll[IO](path, blocker, 4096)

Работа с базой данных


Для работы с БД я использовал doobie и он мне чертовски понравился потому что напомнил старый добрый Dapper ORM. Позволяет маппить DTO и выполнять SQL запросы.

  def add(image: Image): IO[Long] = sql"""
         INSERT INTO images (hash, file_path)
         VALUES (${image.hash}, ${image.filePath})""".update
//Запрос будет возвращать созданный id новой записи в БД
    .withUniqueGeneratedKeys[Long]("id")
    .transact(xa)


Для миграций подключил Flyway
   val flyway = Flyway
        .configure()
        .dataSource(settings.db.url, settings.db.user, settings.db.password)
        .load()
      flyway.migrate()

CREATE TABLE IF NOT EXISTS IMAGES (
    id        SERIAL PRIMARY KEY,
    hash      VARCHAR NOT NULL UNIQUE,
    file_path VARCHAR NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS TODOS (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR NOT NULL UNIQUE,
    image_id BIGINT REFERENCES images,
    created  TIMESTAMP
);

Сборка и упаковка в образ Docker


Я захотел собрать все в один единственный файл как например делает это Go или .NET Core с нужными настройками поэтому использовал sbt-native-packager и плагин к нему sbt-assembly. Собранный файл можно запустить с помощью команды

java -jar <имя файла>

Потом сделал DockerFile для запуска этого образа в контейнере

FROM hseeberger/scala-sbt:11.0.2-oraclelinux7_1.3.12_2.13.3 AS base
COPY . /root
WORKDIR /root
RUN sbt universal:packageZipTarball
RUN sbt test

FROM openjdk:15-alpine as final
COPY --from=base /root/target/scala-2.13/scala-todo-api.jar /root
WORKDIR /root
EXPOSE 8080
ENTRYPOINT ["java","-jar","scala-todo-api.jar"]

Собранный образ автоматом отправляется в Registry гитлаба через его встроенный CI/CD

Настройки


Настройки сервера загружаю с помощью библиотеки PureConfig и потом так как я использую Docker дополняю их из переменных окружения. Файл application.conf:

db {
//Два раза повторяем переменную потому что если в переменной окружения TODO_API_DB_URL будет пусто то будет использован первый вариант иначе его перезапишет переменная окружения.
  url = "jdbc:postgresql://localhost:5432/todos_db"
  url = ${?TODO_API_DB_URL}
  user = "postgres"
  user = ${?TODO_API_DB_USER}
  password = "postgres"
  password = ${?TODO_API_DB_PASSWORD}
}
host {
  port = 8080
  port = ${?TODO_API_HOSTING_PORT}
  host = "0.0.0.0"
  host = ${?TODO_API_HOSTING_HOST}
}

val config = ConfigSource.default.load[AppSettings]