Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~20 мин +50 XP

Шаблоны HTML

Stylesheet из прошлого урока просится на настоящую страницу — значит, пора рендерить HTML на сервере. В gin шаблоны подключаются двумя вызовами: LoadHTMLGlob или LoadHTMLFiles собирают набор html/template, а c.HTML отдаёт страницу с вашими данными. Разберём, как gin парсит шаблоны и чем debug-режим отличается от release, зачем SetFuncMap звать до загрузки файлов, как менять разделители, если {{ занял Vue, и как gin-contrib/multitemplate держит две раскладки в одном приложении. JSON-конверты из [урока про ответы](/courses/gin/lessons/05-json-otvety/) остаются для /api — теперь у трекера появится и человеческое лицо.

Содержание урока · 13 разделов

gin шаблоны: HTML вместо JSON#

Пока наш трекер отвечал только JSON: Envelope для API, NOT_FOUND для промахов, статика из прошлого урока обслуживала файлы, но не страницы. Пришло время gin шаблонов — серверного HTML, который собирается на лету из данных. Сценарий знакомый: страница со списком задач, где каждая строка приходит из хендлера, а разметка живёт в отдельном файле. Gin не пишет свой шаблонизатор — он оборачивает html/template из стандартной библиотеки, тот самый, с auto-escaping: любая строка из данных вставляется в разметку безопасно, теги и скрипты экранируются автоматически. Если вы уже работали с html/template — всё знакомо; если нет — разберём по ходу, для gin-специфики достаточно трёх действий. Первое: загрузить шаблоны один раз при старте — LoadHTMLGlob или LoadHTMLFiles. Второе: отдавать страницы через c.HTML(код, имя, данные) — собрат c.JSON, который вместо конверта рендерит файл. Третье: при необходимости зарегистрировать свои функции шаблонов через SetFuncMap — даты, плюрализация, любые преобразования прямо в разметке. Архитектурно это выглядит так: хендлеры /api/v1/todos продолжают отдавать JSON из прошлого урока, а новые маршруты — /, /tasks, /tasks/:id — рендерят страницы. Один движок, два формата: JSON для машин, HTML для людей — и вся связка html gin держится на трёх действиях из начала урока. В этом уроке мы соберём страницу трекера на gin.H и структуре, разберём экранирование на живом примере, подключим кастомные функции и раскладки, а на выходе получим приложение, у которого есть и API, и интерфейс.

Первая страница: LoadHTMLGlob и c.HTML

GO
r := gin.Default()
r.LoadHTMLGlob("templates/*")

r.GET("/", func(c *gin.Context) {
        tasks := []Todo{
                {Title: "Купить хлеб", Done: false},
                {Title: "Полить цветы", Done: true},
        }
        c.HTML(http.StatusOK, "index.html", gin.H{
                "title": "Задачи на сегодня",
                "tasks": tasks,
        })
})

if err := r.Run(":8080"); err != nil {
        panic(err)
}

LoadHTMLGlob("templates/*") разбирает все файлы каталога templates один раз при старте. c.HTML берёт три аргумента: статус, имя шаблона и данные. Имя — это базовое имя файла: index.html. Точка в разметке — это gin.H, переданный вторым: {{ .title }} читает ключ title. Папка templates — относительно рабочей директории процесса, как и статика из прошлого урока.

LoadHTMLGlob и LoadHTMLFiles: как gin парсит шаблоны#

Заглянем в gin.go — там два метода, и они ближе к стандартной библиотеке, чем кажется. LoadHTMLGlob(pattern) делает ровно одно: template.Must(template.New("").Delims(...).Funcs(engine.FuncMap).ParseGlob(pattern)) — это чистый html/template: ParseGlob находит файлы по маске через filepath.Glob и парсит каждый, а template.Must паникует, если файл не читается или разметка битая. То есть опечатка в шаблоне роняет приложение на старте, до первого запроса — и это хорошо: битая разметка должна ловиться при запуске, а не в проде у пользователя. Имена шаблонов — базовые имена файлов: templates/index.html становится index.html, и c.HTML ищет именно по этому имени. LoadHTMLFiles делает то же самое по явному списку: r.LoadHTMLFiles("templates/index.html", "templates/form.html") — удобно, когда каталог замусорен частями, которые не должны попадать в набор. Раскладки и подпапки требуют блока define: маска templates/ не заходит в подкаталоги, а маска templates// (две звёздочки в filepath.Glob — это просто один уровень) собирает файлы из подпапок, но тогда каждое имя объявляют вручную: {{ define "posts/index.html" }} в начале файла и соответствующее имя в c.HTML — так делают в официальных примерах gin. Теперь главное, что почти все упускают: результат парсинга зависит от gin.Mode(). В release-режиме отрендеренный набор кладётся в HTMLProduction — один раз спарсенный, навсегда закэшированный. А в debug-режиме — режиме по умолчанию — gin ставит HTMLDebug, который перечитывает и перепарсивает шаблоны на каждый запрос: правка файла видна по F5, без перезапуска. Это удобно в разработке и незаметно дорого в бою: перепарс на каждый запрос плюс паники template.Must уже внутри обслуживания. Отсюда правило: gin.SetMode(gin.ReleaseMode) перед LoadHTMLGlob в проде — и шаблоны спарсятся ровно один раз.

Проверка: правка шаблона после старта

Приложение собрано с gin.SetMode(gin.ReleaseMode), LoadHTMLGlob отработал, сервер крутится. Вы правили templates/index.html, не перезапуская процесс. Что увидит следующий запрос?

Передача данных: gin.H, структуры и экранирование#

Данные в шаблон попадают любым значением — c.HTML принимает any. Для быстрого прототипа используют gin.H — это псевдоним map[string]any, тот же тип, что и в c.JSON: gin.H{"title": "Задачи", "tasks": tasks} — и точка в разметке ходит по ключам карты. Для боевого кода лучше структуры: поля типизированы, поля подсказывают в IDE, а json-теги, которые мы расставляли в уроке про привязку, работают и здесь — только в шаблонах тегом по умолчанию считается form, а имена полей html/template берёт по-другому. Ключевое правило: шаблон видит только экспортированные поля. Структура Todo с полем title (со строчной) отдаст пустоту — интерполяция {{ .title }} или {{ .Title }} вернёт нулевое значение; с полем Title — всё отрисуется. Методы тоже работают, если они экспортированы: {{ .DueFmt }} вызовет метод с таким именем — это законный способ класть вычисления рядом с данными вместо размножения функций. А вот экранирование — главная причина, почему html/template, а не text/template. Контекстный анализатор понимает, где именно вставляется значение: в HTML-тексте экранирует &lt;b&gt;, в атрибуте — кавычки, в JavaScript-блоке — спецсимволы строк, в URL — пробелы и кавычки. Практический тест: передайте в title строку с <script>alert(1)</script> — на странице отобразится текстом ровно то, что ввёл пользователь, а в HTML-исходнике будут экранированные сущности. XSS-векторы через данные гибнут на ровном месте. Побег есть всегда — кроме одного: значение типа template.HTML html/template вставит как есть, без экранирования, потому что вы явно заявили «это уже HTML». Такой тип — осознанный костыль для заранее sanitized-контента; если он добирается до шаблона из пользовательского ввода, вы только что собрали XSS своими руками, поэтому держите template.HTML подальше от форм.

Что окажется в HTML при вводе тега

Прочитайте код и выберите, что он выведет:

r := gin.Default()
r.LoadHTMLGlob("templates/*")

r.GET("/echo", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{
                "title": "<b>Срочно</b> и <script>alert(1)</script>",
        })
})

// <h1>{{ .title }}</h1>
// curl localhost:8080/echo

Кастомные функции шаблонов: SetFuncMap#

Рано или поздно разметке нужны вычисления: дата в человеческом формате, правильная форма слова «задача/задачи/задач», цена с копейками, урезанный текст. В html/template это решается функциями: FuncMap — карта имя-функция, и шаблон вызывает их как {{ fmtDate .Due }}. В gin за это отвечает r.SetFuncMap(template.FuncMap{...}) — метод движка, который кладёт карту в engine.FuncMap, и дальше её подхватывают оба загрузчика. Обратите внимание на порядок — это самая частая ошибка темы: SetFuncMap должен быть вызван до LoadHTMLGlob и LoadHTMLFiles. Причина в механике парсинга: при разборе шаблона имена функций фиксируются, и если функции ещё не зарегистрированы, ParseGlob упадёт с «function "fmtDate" not defined» — template.Must превратит это в панику на старте. Зарегистрируете после — та же паника, только в запутанной формулировке; держите SetFuncMap первой строкой после gin.Default() и не отделяйте его от загрузки шаблонов. Что класть в карту: функции чистые, быстрые и про представление — форматирование дат через t.Format, плюрализация по правилам русского языка (n%10, группы исключений), обрезка строк с многоточием, экранирование уже не нужно — оно контекстное. Что класть не стоит: обращения к базе и любые I/O — шаблон должен собирать строку из готовых данных, а не ходить за ними; готовьте значения в хендлере или сервисном слое. Функции в карте типизированы по-обычному: fmtDate func(time.Time) string, plural func(int, string) string — компилятор проверит сигнатуры, а шаблон просто вызовет по имени.

FuncMap: дата и плюрализация

GO
func fmtDate(t time.Time) string {
        return t.Format("02.01.2006")
}

func plural(n int, forms string) string {
        // forms: "задача,задачи,задач" — формы через запятую.
        parts := strings.Split(forms, ",")
        idx := 2
        if n%10 == 1 && n%100 != 11 {
                idx = 0
        } else if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
                idx = 1
        }
        return fmt.Sprintf("%d %s", n, parts[idx])
}

func main() {
        gin.SetMode(gin.ReleaseMode)
        r := gin.Default()

        r.SetFuncMap(template.FuncMap{
                "fmtDate": fmtDate,
                "plural":  plural,
        })
        r.LoadHTMLGlob("templates/*") // функции уже зарегистрированы
        // ...
}

SetFuncMap перед LoadHTMLGlob — иначе ParseGlob не найдёт fmtDate и plural и упадёт в template.Must. В разметке: {{ fmtDate .Due }} и {{ plural .OpenCount "задача,задачи,задач" }}. Строковый аргумент в кавычках — литерал шаблона. Функции чистые: никаких обращений к базе внутри разметки.

Проверка: порядок инициализации

В какой момент нужно вызвать r.SetFuncMap, чтобы шаблоны видели ваши функции?

Delims: когда двойные фигурные скобки заняты#

Штатные разделители {{ }} — источник конфликтов с фронтенд-шаблонизаторами. Vue, Alpine.js и целый зоопарк мини-фреймворков используют те же двойные фигурные: страница собирается на сервере gin-ом, а затем «оживает» на клиенте, и обе машины претендуют на одни скобки. Реакция предсказуема: Vue выдернет из {{ .title }} значение, получит пустую строку и швырнёт warning в консоль, а ваша серверная интерполяция окажется съеденной. Решение в gin — r.Delims("[[", "]]"): метод движка меняет разделители для всех загружаемых после него шаблонов, и в разметке пишете [[ .title ]]. Обратите внимание на два требования. Первое: Delims тоже до загрузки — парсер расставляет границы по разделителям, менять их после ParseGlob поздно. Второе: возвращаемое значение — Engine, метод цепочный: r.Delims("[[", "]]").LoadHTMLGlob("templates/") — хотя читается такая цепочка тяжеловато, лучше отдельной строкой. Бэкенд и фронтенд расходятся по зонам: серверные интерполяции в [[ ]], клиентские {{ }} остаются Vue — конфликт снят без escaping-танцев. Из той же серии — точечная гигиена: если скобки нужны в тексте как литералы (фрагменты кода в статье, документация по шаблонам), вынесите их в данные и вставляйте значением — или используйте пользовательские функции, возвращающие нужный текст. И помните про predict ниже: смена Delims делает старый синтаксис просто текстом, и никакой ошибки парсинга не случится — файл с {{ .title }} при Delims("[[", "]]") спарсится тихо, а на странице отобразится фигурная запись дословно. Проверяйте визуально после миграции, а не по логам.

Старые скобки после смены разделителей

Прочитайте код и выберите, что он выведет:

r := gin.Default()
r.Delims("[[", "]]")
r.LoadHTMLGlob("templates/*")

r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{"title": "План"})
})

// index.html: <h1>{{ .title }}</h1>
// curl localhost:8080/

Раскладки и частичные шаблоны: define и template#

Одна страница — редкость; хочется общего каркаса: head с stylesheet, шапка с навигацией, подвал. В html/template это делается блоками define и template, и gin здесь не добавляет ничего — это чистая стандартная библиотека. Блок {{ define "head" }}...{{ end }} объявляет именованный фрагмент внутри файла, а {{ template "head" . }} вставляет его в точке вызова, передавая те же данные — точка в конце не опечатка, это передача контекста. Классическая раскладка: layout.html держит каркас с двумя template-вставками — head и footer, — а page-файлы объявляют эти блоки своим содержимым. Порядок загрузки в gin: LoadHTMLGlob("templates/*") соберёт все файлы в один набор, и имена блоков станут общими для набора — template ищет блок по имени во всём наборе, а не только в своём файле. Отсюда следствие, которое стоит проговорить: имена define уникальны в рамках набора — два файла с {{ define "head" }} перезапишут друг друга без предупреждений; добавьте зону в имя: {{ define "site/head" }}, {{ define "admin/head" }}. Для вложенных страниц из подпапок действует правило из прошлого блока: {{ define "posts/index.html" }} и c.HTML с тем же именем — маска с двумя звёздочками собирает подпапки, define даёт им предсказуемые имена. И тонкость передачи данных: {{ template "head" . }} передаёт весь контекст, а {{ template "head" .Title }} — только значение; внутри блока точка уже будет строкой, а не картой. Это удобно для узких фрагментов, но ловит на рефакторинге: поменяли контекст — блоки молча отрисуют пустоту. Держите передачу контекста явной и одинаковой во всех раскладках проекта.

Multitemplate: две раскладки в одном приложении#

Загрузчики gin дают один набор шаблонов на приложение — и этого хватает, пока все страницы живут в общем каркасе. Как только появляются принципиально разные раскладки — админка с боковой панелью и публичные страницы с меню, письмо-шаблон, лендинг, — наборный подход трещит: имена define расползаются, общий head переопределяется, layout-логика превращается в условия. Официальное решение — HTMLRender интерфейс: у движка есть поле HTMLRender с методом Instance(name string, data any) Render, и c.HTML просто делегирует ему. Подмените HTMLRender — и c.HTML начнёт отдавать то, что вы решите. Пакет gin-contrib/multitemplate делает подмену за вас: multitemplate.NewRenderer() создаёт рендерер, методы AddFromFiles и AddFromGlob регистрируют именованные наборы — каждый со своим каркасом и своими файлами, — а присвоение r.HTMLRender = renderer включает его. Дальше c.HTML(http.StatusOK, "user", data) рендерит набор user с раскладкой пользователя, c.HTML(..., "admin", data) — набор admin с панелью администратора; JSON-API это не трогает. Собирайте рендерер фабричной функцией — createRender() — и передавайте движку до старта: HTMLRender назначается один раз, как и FuncMap. Плата очевидна и мала: каждый набор парсится отдельно, дубли общих фрагментов можно разрулить общими define-блоками с зональными именами. Альтернатива без внешнего пакета — свой тип с тем же методом Instance поверх html/template: интерфейс крошечный, а понимание, что за ним стоит, у вас теперь есть. В трекере multitemplate закрывает ровно одну развилку — публичная страница и будущая админка — но решает её чисто, без условий в разметке.

Две раскладки через gin-contrib/multitemplate

GO
import (
        "github.com/gin-contrib/multitemplate"
        "github.com/gin-gonic/gin"
)

func createRender() multitemplate.Renderer {
        r := multitemplate.NewRenderer()
        // Каркас + страница для каждого набора.
        r.AddFromFiles("user", "templates/layout.html", "templates/user.html")
        r.AddFromFiles("admin", "templates/layout.html", "templates/admin.html")
        r.AddFromGlob("letter", "templates/letters/*.html")
        return r
}

func main() {
        r := gin.Default()
        r.HTMLRender = createRender()

        r.GET("/", func(c *gin.Context) {
                c.HTML(http.StatusOK, "user", gin.H{"title": "Мои задачи"})
        })

        r.GET("/admin", func(c *gin.Context) {
                c.HTML(http.StatusOK, "admin", gin.H{"title": "Админка"})
        })
}

Имена наборов заменяют имена файлов: c.HTML ищет не index.html, а user или admin. AddFromFiles парсит файлы пачкой — каркас и страница; AddFromGlob берёт набор из маски. Присвоение r.HTMLRender до первого запроса: подмена на лету с живым трафиком — гонка. Пакет ставится классически: go get github.com/gin-contrib/multitemplate.

Проверка: Content-Type страницы

Хендлер отдал c.HTML(http.StatusOK, "index.html", data). Какой Content-Type придёт в ответе?

Что выбрать для ответа#

ИнструментContent-TypeДанныеКогда брать
c.HTMLtext/html; charset=utf-8шаблон + gin.H/структурастраницы, отчёты, HTML-письма
c.JSONapplication/json; charset=utf-8конверт EnvelopeAPI для фронтенда и клиентов
c.Data(code, type, bytes)тот, что передалиготовые байтыSVG-иконки, CSV, свои форматы
Static из урока 11по расширению файлафайлы на диске/embedнеизменяемые ассеты: CSS, JS, картинки
NoRoute + c.Fileпо расширениюфайлSPA-фолбэк на промахи

HTML или JSON: когда что рендерить#

Теперь у трекера два лица, и стоит зафиксировать критерий выбора. JSON — когда потребителя нельзя предсказать: мобильное приложение, чужой скрипт, публичный API; контракт из урока про ответы — Envelope с полем Error — остаётся сетевым стандартом проекта. HTML — когда потребитель человек и SEO важнее интерактивности: публичные страницы, документация, панель со списком задач — серверный рендер даёт готовую разметку без ожидания и запросов данных с клиента, а поисковик индексирует её без выкручиваний. Смешивать — нормально и типично: страницы рендерятся на /, /tasks и /tasks/:id через c.HTML, а под группой /api/v1 живут JSON-эндпоинты того же трекера — группы маршрутов из прошлого урока как раз разводят эти зоны, и middleware на группе API не мешает страницам. Опасная зона одна: не начинайте выдавать HTML за JSON-клиента. У API нет шаблонов: если фронтенд — SPA на webpack/vite, ваши c.HTML-страницы никому не нужны, а полноценный HTML-слой удвоит работу. Признак, что серверный рендер вам показан: ссылки должны открываться с нуля, поиск должен видеть контент, интерактив минимален. Признак против: страница — это приложение с состоянием, список фильтруется без перезагрузки, офлайн-режим. В учебном трекере скучно ни то ни другое, поэтому берём обе стороны: страница списка — HTML, операции — JSON. Так проект честно упражняет оба режима ответа, и на собеседовании вы не будете делать вид, что «HTML для веба не нужен».

Шесть правил шаблонов gin#

  • Порядок инициализации: gin.SetMode, SetFuncMap, Delims, LoadHTMLGlob/Files, маршруты, Run — всё, что влияет на парсинг, до загрузки шаблонов.
  • Имя шаблона — базовое имя файла; для подпапок — явный {{ define "posts/index.html" }} и то же имя в c.HTML.
  • Шаблон видит только экспортированные поля структуры; методы тоже доступны, если они экспортированы.
  • Экранирование контекстное и обязательное; template.HTML — сознательный обход для проверенного контента, не для строк из форм.
  • В debug-режиме шаблоны перечитываются на каждый запрос (HTMLDebug), в release парсятся один раз (HTMLProduction) — для прода ставьте gin.SetMode(gin.ReleaseMode).
  • Разные раскладки — gin-contrib/multitemplate или свой рендерер поверх HTMLRender: именованные наборы вместо условий в разметке.

Соберите инициализацию страниц

Расставьте вызовы по порядку: функции шаблонов, разбор каталога и отдача страницы.

func buildPages(r *gin.Engine) { // 1. Функции шаблонов — ДО загрузки файлов. r.(template.{ "fmtDate": fmtDate, "plural": plural, }) // 2. Разбор всех шаблонов из каталога templates. r.("templates/*") // 3. Страница списка задач. r.GET("/", func(c *gin.Context) { c.(http.StatusOK, "index.html", gin.H{ "title": "Задачи на сегодня", "tasks": tasks, }) }) }

Соберите путь запроса страницы

GET / при release-режиме и LoadHTMLGlob: разложите события от curl до строки лога.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: шаблоны HTML

Термин

LoadHTMLGlob и LoadHTMLFiles

нажмите, чтобы перевернуть
Определение

Оба собирают набор html/template: первый — по маске filepath.Glob, второй — по явному списку файлов. Имя шаблона — базовое имя файла. Паника template.Must при битой разметке — на старте, до первого запроса. Пути — от рабочей директории процесса.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • LoadHTMLGlob/Files собирают набор html/template один раз при старте; имена — базовые имена файлов, битая разметка паникует на старте через template.Must.
  • Режим решает: в debug шаблоны перечитываются на каждый запрос (HTMLDebug), в release закэшированы в HTMLProduction — прод стартует с gin.SetMode(gin.ReleaseMode).
  • Данные — gin.H или структура: шаблон видит только экспортированные поля и методы; экранирование контекстное, template.HTML — сознательный обход.
  • SetFuncMap до загрузки шаблонов: имена функций фиксируются при парсинге; функции — про представление, без I/O.
  • Delims меняет скобки до парсинга — способ разойтись с Vue и Alpine; старый синтаксис после смены — просто текст.
  • Раскладки — define/template с уникальными именами; разные каркасы — gin-contrib/multitemplate с r.HTMLRender = createRender() и именованными наборами.

Домашнее задание

Соберите HTML-этаж трекера: LoadHTMLGlob("templates/*"), SetFuncMap с fmtDate и plural до загрузки, страницу / со списком задач через c.HTML и gin.H — карточка задачи показывает Done флагом {{ if }}, дату через fmtDate, счётчик через plural. Рядом оставьте /api/v1/todos в JSON — группы из прошлого урока разводят зоны. Подставьте в данные строку с тегами и убедитесь в экранировании. Затем добавьте multitemplate: каркас layout.html, наборы user и admin, r.HTMLRender = createRender() и /admin с другой шапкой. Прогоните оба режима gin и зафиксируйте разницу в правках шаблона без рестарта.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Шаблоны HTML)

Вопросы и ответы по уроку

Почему шаблоны не видят поля структуры со строчной буквы?

html/template работает через рефлексию и видит только экспортированные поля — верхний регистр первой буквы. Поле title для шаблона пусто, поле Title отрисуется. Методы — то же правило: интерполяция {{ .DueFmt }} вызовет экспортированный метод без аргументов.

Как грузить шаблоны из подпапок?

Маска LoadHTMLGlob("templates/**/*") в filepath.Glob означает один уровень вложенности и соберёт файлы подпапок; имена объявляйте явно через {{ define "posts/index.html" }} и указывайте то же имя в c.HTML. Вложеннее двух уровней — несколько вызовов LoadHTMLGlob с разными масками или multitemplate.

Можно ли перезагружать шаблоны без рестарта в проде?

Штатно — только оставаясь в debug-режиме, где HTMLDebug перепарсивает на каждый запрос; в проде это не рекомендуется: лишняя работа и паники в момент обслуживания. Правильный путь — релиз с новым бинарником; фанатам hot-reload — свой рендерер поверх HTMLRender с проверкой mtime и мьютексом.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Echo25 уроков~13 чпрактика25 уроков уже доступны

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию