Использование линз http4k #

Васильев Андрей Михайлович, 2023

Версии презентации

Работа со сложными структурами данных #

При работе со сложными вложенными структурами приходится решать вопрос по получению и изменению свойств вложенных элементов

val structure = listOf(
    mapOf(
        "lang" to "kotlin",
        "name" to "Веб-программирование",
    )
)

Для редактирования структуры необходимо сначала получить доступ к элементу массива, а затем уже к свойству словаря

Для сильно вложенной структуры данных цепочка может быть гораздо длиннее. Это несёт следующие проблемы:

  • Надо корректно указать путь к данным несколько раз
    • Возможны технические ошибки
    • Необходимо написать много кода
  • Код начинает зависеть от всех промежуточных объектов, усложняется его дальнейшее изменение

Механизм линз #

Линза — специальный объект, который позволяет осуществлять операции чтения и записи для конкретного вложенного свойства

Предположим, что мы создали линзу для получения строковой переменной из пути:

val parameterLens = Path.nonEmptyString().of("parameter")

Для получения параметра:

val parameter: String = parameterLens(request)

Потенциально для установки значения можно было бы написать:

val newRequest = parameterLens("newData", request)

Запись данных может быть полезна в рамках модульных тестов

Шаблон проектирования строитель #

Для создания некоторых объектов пользователю необходимо указать множество аргументов конструктора, причём каждый из них тоже может быть сложным

Чтобы упросить создание конкретного компонента в строитель выделяют:

  • логику по сбору всех аргументов для создания объекта
  • метод для создания объекта целевого класса

При использовании строителя создание объекта выглядит следующим образом:

  1. Создать объект-строитель
  2. Вызвать несколько методов, формирующие набор данных для создания объекта
  3. Вызвать метод по созданию целевого объекта

Линзы http4k #

Объект Начальный тип данных Применимо для объектов Несколько Ограничитель требований
Параметры запроса String Request Один или много Обязательный или необязательный
Заголовок String Request / Response Один или много Обязательный или необязательный
Переменная пути String Request Один Обязательный
Поле формы String WebForm Один или много Обязательный или необязательный
Тело String Request / Response Один Обязательный

Настройка линзы #

При создании линзы необходимо указать:

  • Объект, для которого формируется линза
  • Настройка линзы, обычно состоит из определения типа данных для преобразования
  • Терминатор (если есть обязательные или необязательные параметры)

Указание объекта #

  • Параметры запроса: Query
  • Заголовок: Header
  • Переменные пути: Path
  • Поле формы: FormField
  • Тело: Body

Настройка типа данных линзы #

Данный этап можно пропустить, если необходима строка, однако лучше всегда явно указывать тип для преобразования

http4k предлагает большой набор функций расширения, с помощью которых можно настраивать преобразование данных. Данные приходят от пользователя в строковом формате и их далее необходимо преобразовать в корректный внутренний формат

Рассмотрим список преобразований типов для объекта-запроса Query

  • enum() — преобразование в перечисление
  • dateTime() — преобразование переданных данных в тип LocalDate
  • int() — преобразование в целочисленный вариант
  • nonEmptyString() — проверка данных на пустоту
  • uuid() — преобразование в тип данных UUID

Линзы могут быть преобразованы в любой формат с помощью метода map()

Указание необходимости параметра #

Уровни необходимости параметра описываются в рамках класса LensSpec

  • defaulted() — нужно указать название элемента, значение по умолчанию и описание
  • optional() — параметр является необязательным, необходимо указать название
  • required() — параметр является обязательным, необходимо указать название

Ключевое отличие — поведение при отсутствии целевого значения

  • defaulted() возвращает значение по умолчанию
  • optional() возвращает null-тип, но выбрасывает исключение, если параметра нет
  • required() выбрасывает исключение LensFailure

Терминатор создаёт на основе спецификации (строителя, LensSpec) нужную линзу

Линзы для параметров запроса #

Рассмотрим пример линзы, которая извлекает целое число из параметров запроса

val queryDataLens = Query.int().defaulted("data", 15)
  • Целью выступает класс Query
  • Далее указываем преобразование параметров из строки в целое число, метод int()
  • Затем указываем, что значением по умолчанию является число 1
val request = Request(GET, Uri.of("http://server.ru"))
val requestWithQuery = request.query("data", "42")
val data = queryDataLens(requestWithQuery) // 42

Линза, к сожалению, выбросит исключение, если параметра нет в запросе:

val request = Request(GET, Uri.of("http://server.ru"))
val data = queryDataLens(request) // Будет выброшено исключение

Обёртки для линз #

К сожалению базовый интерфейс линз не позволяет обработать некорректные значения, а также частично заполненные данные. Для решения подобных проблем можно воспользоваться следующими методами-обёртками.

import org.http4k.lens.Lens
import org.http4k.lens.LensFailure

fun <IN : Any, OUT>lensOrNull(lens: Lens<IN, OUT?>, value: IN): OUT? =
    try {
        lens.invoke(value)
    } catch (_: LensFailure) {
        null
    }

fun <IN : Any, OUT>lensOrDefault(lens: Lens<IN, OUT?>, value: IN, default: OUT): OUT =
    try {
        lens.invoke(value) ?: default
    } catch (_: LensFailure) {
        default
    }

Вариант вызова данных функций-обёрток:

lensOrNull(fromLens, request)

HTML-формы #

Для данных от пользователя к веб-приложению используются HTML-формы

sequenceDiagram Клиент ->> Сервер: GET-запрос страницы с формой Сервер ->> Клиент: HTML-документ с формой alt POST-запрос Клиент ->> Сервер: POST-запрос с данными формы в виде данных документа Сервер ->> Клиент: перенаправление на страницу с результатами Клиент ->> Сервер: GET-запрос на страницу с результатами else GET-запрос Клиент ->> Сервер: GET-запрос с данными формы в виде параметров запроса end

Отображение форм #

Формы являются частью языка HTML, для их описания используются следующие элементы:

  • Form — базовый контейнер для всех полей ввода, расположенных на форме
  • Input — элемент для описания интерактивных элементов
  • Textarea — элемент для ввода многострочного текста

CSS-фреймворк Zurb поддерживает стилизацию форм

<form method="POST">
    <input type="text" name="start">
    <textarea name="description">
    <input type="submit" value="Отправить">
</form>

Обработка данных от формы #

От клиента к серверу все данные передаются в виде строк, причём нет возможности для любого поля гарантировать, что оно будет заполнено

Рассмотрим обработку данных от формы на низком уровне

val parameters: Map<String, List<String?>> = request.formAsMap()
val firstValue: String? = parameters.findSingle("start")
  • Функция formAsMap возвращает Map<String, List<String?>>
  • Согласно стандарту HTML в рамках формы у одного параметра может быть несколько значений
  • Для поиска одного значения в данной структуре данных удобно воспользоваться функцией findSingle

Перенаправление HTTP-клиента #

HTML-формы зачастую направлены на изменение данных на стороне сервера:

  • Добавление новых элементов в коллекци
  • Редактирование существующих элементов коллекции
  • Удаление существующих элементов коллекций

Для этих случаев используется не-GET-запрос. На такие запросы HTTP-сервер должен вернуть Redirect-ответ с указанием, куда следует сделать GET-запрос

val strings =  mutableListOf<String>()
val formHandler: HttpHandler = { request ->
    val form = request.form()
    val newString = form.findSingle("text").orEmpty()
    strings.add(newString)
    Response(FOUND).header("Location", "/strings")
}

Обработка HTML-форм #

Предыдущий рассмотренный сценарий обработки форм не удовлетворяет требованиям по обработке данных:

  • Работает только в случае передачи корректных данных
  • Падает с сообщением внутренних проблем при получении данных
  • Теряет данные, которые были введены пользователем

Корректный подход к обработке форм #

sequenceDiagram autonumber actor Пользователь participant Браузер participant Сервер Пользователь ->> Браузер: Нажимает на ссылку к странице формы Браузер ->> Сервер: Отправляет GET-запрос по адресу документа Сервер ->> Браузер: HTML-документ, содержащий форму Браузер ->> Пользователь: Показывает документ с формой Пользователь ->> Браузер: Заполняет интерактивные элементы на форме Пользователь ->> Браузер: Нажимает на кнопку отправки запроса Браузер ->> Сервер: POST-запрос с содержимым формы alt Форма не содержит ошибок Сервер ->> Браузер: Ответ со статусом 302, FOUND else Сервер ->> Браузер: HTML-документ, содержащий форму
с данными пользователя и сообщениями об ошибках, 3 end

Описание линз для формы #

HTML форма представляет собой набор полей, которые пользователь заполняет

В рамках http4k используются следующие типы данных:

  • FormField — описание требований к конкретному полю формы
  • Body.webForm — описание спецификации линзы для всей формы
  • WebForm — структура для хранения результатов проверки формы
val ageField = FormField.int().required("age")
val nameField = FormField.map(::Name, Name::value).optional("name")
val feedbackFormBody = Body
    .webForm(Validator.Feedback, nameField, ageField).toLens()
// Отправка пустой формы
val invalidRequest = Request(GET, "/")
    .with(Header.CONTENT_TYPE of ContentType.APPLICATION_FORM_URLENCODED)
val invalidForm = feedbackFormBody(invalidRequest)
println(invalidForm.errors)
// Отправка заполненной формы
val webForm = WebForm().with(ageField of 55, nameField of Name("rita"))
val validRequest = Request(GET, "/").with(feedbackFormBody of webForm)
val validForm = strictFormBody(validRequest)
val age = ageField(validForm)

Отображение результатов проверки в форме #

  • При первом показе форма должна отображать пустые поля ввода
  • После неудачи форма должна отображать данные, введённые пользователем

Удобно использовать объекты класса WebForm в обоих случаях

data class WebForm constructor(
    val fields: Map<String, List<String>> = emptyMap(),
    val errors: List<Failure> = emptyList()
)

Ограничение данного формата — нельзя связать ошибку с полем ввода

Для проверки наличия данных в форме можно использовать следующие конструкции:

{% if model.form.errors is not empty %}
    Присутствуют ошибки
{% endif %}
{% if model.form.fields contains "field" %}
    Данные поля field: {{ model.form.fields["field"] | first }}
{% endif %}

Использование макросов Pebble #

Для уменьшения количества повторений внутри шаблонов Pebble можно воспользоваться макросами, которые можно несколько раз вызывать внутри шаблона

{% macro input(type="text", name, placeholder="", form) %}
    <input
        type="{{ type }}"
        name="{{ name }}"
        placeholder="{{ placeholder }}"
        value="{{ form.fields[name] | first }}"
    >
{% endmacro %}

{{
    input(
        name="count",
        type="number",
        placeholder="Число работников",
        form=model.form
    )
}}

Указание типа документа #

Согласно протоколу HTTP серверу желательно сообщать клиенту тип документа с помощью MIME типов. Также поступает и клиент

Для указания типа документа используется заголовок Content-Type

В http4k для решения этой задачи можно либо воспользоваться фильтрами:

val SetHtmlContentType = Filter { next ->
    { next(it).with(CONTENT_TYPE of TEXT_HTML) }
}
val app = routes(
    return "/create" bind POST to SetHtmlContentType.then someHandler
)

Либо использовать линзы:

val renderer = PebbleTemplates().HotReload("src/main/resources")
val htmlView = Body.viewModel(renderer, TEXT_HTML).toLens()
val handler: HttpHandler {
    Response(OK).with(htmlView of SomeViewModel(42))
}