Лабораторная работа 10. Вводный практикум по AJAX
JavaScript и AJAX: получение и отправка данных без перезагрузки страницы
Цель: подключить веб-страницу к существующему Symfony API и научиться обновлять её содержимое без полной перезагрузки.
Результат: учебная страница, которая получает список клиентов и создаёт клиента через API.
3.1. Как устроено взаимодействие
В этом упражнении используется следующая цепочка:
Действие пользователя в браузере → JavaScript отправляет HTTP-запрос через fetch() → Symfony проверяет запрос и права доступа → серверный код обращается к MySQL через существующий слой данных → Symfony возвращает JSON → JavaScript обновляет часть HTML-страницы
Браузер не подключается к MySQL напрямую. Пароль базы данных, SQL-запросы и серверные секреты не размещаются в JavaScript.
AJAX — способ обмениваться данными с сервером и обновлять страницу без её полной перезагрузки. В этом практикуме используется браузерный fetch(), а формат обмена — JSON. Устанавливать jQuery или JavaScript-фреймворк не требуется.
3.2. Минимум JavaScript перед запросами
Откройте Console / Консоль в инструментах разработчика браузера и выполните:
const customers = [ { id: 1, name: 'Анна', email: 'anna@example.test' }, { id: 2, name: 'Иван', email: 'ivan@example.test' } ]; function formatCustomer(customer) { return `${customer.id}: ${customer.name}`; } for (const customer of customers) { console.log(formatCustomer(customer)); }
Здесь const объявляет переменную без последующего переназначения, [] создаёт массив, {} — объект, а customer.name получает свойство объекта. function объявляет функцию; for...of перебирает элементы массива. В JavaScript перед именем переменной не ставится $.
Для работы со страницей используются DOM — объектное представление HTML-документа — и обработчики событий:
const heading = document.querySelector('h1'); heading.textContent = 'Учебная страница AJAX';
querySelector() ищет элемент, а textContent изменяет его текст. Перезагрузите страницу, чтобы отменить экспериментальное изменение.
3.3. Подготовка API и учебной страницы
Выполняйте этот блок после реализации API основной лабораторной работы.
Для примера необходимы:
GET /api/customers?search=...&page=1&limit=10 POST /api/customers
Список должен возвращаться в конверте data и meta, предусмотренном лабораторной VIII:
{ "data": [ { "id": 1, "name": "Анна Смирнова", "email": "anna@example.test" } ], "meta": { "page": 1, "limit": 10, "total": 1 } }
Этот формат и параметры списка уже входят в опубликованное задание.
Дополнительное соглашение для данной вставки: минимальное тело создания клиента содержит name и email. Остальные поля клиента должны быть необязательными для этого запроса либо должны быть добавлены в учебную форму в соответствии с вашим DTO.
{ "name": "Новый клиент", "email": "new@example.test" }
Используйте локальную учебную БД. Создание клиента в этом упражнении — реальная операция сохранения, а не имитация.
Для браузерного упражнения используйте сессионную аутентификацию из лабораторной VI: страница и API должны работать в одном Symfony-приложении с одной пользовательской сессией. Не отключайте действующие проверки ролей и прав.
Создайте:
bizflow/ ├── src/ │ └── Controller/ │ └── FrontendSandboxController.php ├── templates/ │ └── sandbox/ │ └── ajax.html.twig └── public/ └── js/ └── frontend-basics.js
3.4. Контроллер учебной страницы
В src/Controller/FrontendSandboxController.php добавьте:
<?php declare(strict_types=1); namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; final class FrontendSandboxController extends AbstractController { #[Route('/sandbox/ajax', name: 'sandbox_ajax', methods: ['GET'])] #[IsGranted('ROLE_SALES')] public function index(): Response { $response = $this->render('sandbox/ajax.html.twig'); $response->headers->set('Cache-Control', 'private, no-store'); return $response; } }
Этот контроллер только открывает страницу. Список клиентов и сохранение по-прежнему выполняются через существующие API-контроллеры.
Для проверки войдите под учебной учётной записью с правами на необходимые операции. Наличие доступа к самой странице не заменяет проверки доступа в API. Атрибут IsGranted относится к механизмам авторизации Symfony.
3.5. CSRF-защита создания клиента
Для этого сессионного браузерного сценария передавайте CSRF-токен в заголовке X-CSRF-Token. Используйте идентификатор токена:
ajax_customer_create
Для него оставьте сессионный режим хранения токена.
В существующем методе POST /api/customers, до изменения данных, добавьте проверку:
// Фрагмент существующего метода контроллера. // В метод должен быть внедрён Request $request. if (!$this->isCsrfTokenValid( 'ajax_customer_create', $request->headers->get('X-CSRF-Token') )) { return $this->json([ 'type' => 'about:blank', 'title' => 'Запрос отклонён', 'status' => 403, 'detail' => 'Недействительный CSRF-токен.', 'errors' => [], ], Response::HTTP_FORBIDDEN); }
Фрагмент предназначен для контроллера, использующего методы AbstractController; классы Request и Response должны быть импортированы. Не создавайте второй маршрут POST /api/customers.
После проверки токена сохраните существующие проверки прав, обработку DTO, валидацию и сохранение клиента. Для запросов чтения GET этот токен не нужен. Генерация токена в Twig и проверка через isCsrfTokenValid() предусмотрены Symfony для форм, которые обрабатываются вручную.
Обновите описание браузерного сценария в OpenAPI и соответствующие API-тесты: положительный тест создания теперь должен получать действительный токен в той же сессии.
3.6. HTML-интерфейс AJAX
В templates/sandbox/ajax.html.twig добавьте:
{% extends 'base.html.twig' %} {% block title %}BizFlow — AJAX{% endblock %} {% block javascripts %} {{ parent() }} <script src="/js/frontend-basics.js" defer></script> {% endblock %} {% block body %} <div class="container" data-turbo="false"> <section class="card"> <h1>Клиенты: запрос к API</h1> <form id="search-form" class="form-grid" action="/api/customers" method="get" > <div class="field"> <label for="search">Поиск клиента</label> <input id="search" name="search" type="search"> </div> <div class="actions"> <button type="submit" disabled>Загрузить клиентов</button> </div> </form> <p id="list-message" class="message" role="status"></p> <ul id="customer-list"></ul> </section> <section class="card"> <h2>Создание клиента</h2> <form id="create-form" class="form-grid" action="/api/customers" method="post" data-csrf-token="{{ csrf_token('ajax_customer_create') }}" > <div class="field"> <label for="customer-name">Имя</label> <input id="customer-name" name="name" type="text" required > </div> <div class="field"> <label for="customer-email">Email</label> <input id="customer-email" name="email" type="email" required > </div> <div class="actions"> <button type="submit" disabled>Создать клиента</button> </div> </form> <p id="create-message" class="message" role="status"></p> </section> <noscript> <p> Для этого упражнения включите JavaScript. Обычный интерфейс доступен на странице <a href="/customers">клиентов</a>. </p> </noscript> </div> {% endblock %}
Кнопки первоначально отключены и будут включены после подключения обработчиков JavaScript.
Атрибут data-turbo="false" исключает перехват этих форм Turbo, если он подключён в проекте: отправкой в данном упражнении управляет ваш JavaScript.
3.7. JavaScript: получение и создание клиентов
В public/js/frontend-basics.js добавьте:
'use strict'; const searchForm = document.querySelector('#search-form'); const createForm = document.querySelector('#create-form'); const searchButton = searchForm.querySelector('button'); const createButton = createForm.querySelector('button'); const customerList = document.querySelector('#customer-list'); const listMessage = document.querySelector('#list-message'); const createMessage = document.querySelector('#create-message'); function showMessage(element, text, isError = false) { element.textContent = text; element.classList.toggle('message--error', isError); } // Общая проверка ответа API. async function readApiResponse(response) { if (response.redirected) { throw new Error('Сервер перенаправил запрос. Войдите и обновите страницу.'); } if (response.status >= 500) { throw new Error(`Ошибка сервера: HTTP ${response.status}`); } if (response.status === 204) { return null; } const contentType = response.headers.get('Content-Type') ?? ''; if ( !contentType.includes('application/json') && !contentType.includes('application/problem+json') ) { throw new Error(`Ожидался JSON. Получен ответ HTTP ${response.status}.`); } const data = await response.json(); if (!response.ok) { const detail = data && typeof data.detail === 'string' ? data.detail : 'Запрос отклонён. Проверьте введённые данные.'; throw new Error(`HTTP ${response.status}: ${detail}`); } return data; } // Получение списка клиентов. searchForm.addEventListener('submit', async (event) => { event.preventDefault(); if (searchButton.disabled) { return; } searchButton.disabled = true; customerList.replaceChildren(); showMessage(listMessage, 'Загрузка...'); try { const fields = new FormData(searchForm); const query = new URLSearchParams({ search: String(fields.get('search') ?? '').trim(), page: '1', limit: '10' }); const response = await fetch(`${searchForm.action}?${query}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }); const data = await readApiResponse(response); if (!data || !Array.isArray(data.data)) { throw new Error('API вернул неверный формат списка.'); } for (const customer of data.data) { const item = document.createElement('li'); // Пользовательские данные вставляются как текст, а не HTML. item.textContent = `${customer.name} — ${customer.email}`; customerList.append(item); } showMessage( listMessage, data.data.length === 0 ? 'Клиенты не найдены.' : `Показано клиентов: ${data.data.length}.` ); } catch (error) { showMessage( listMessage, error instanceof Error ? error.message : 'Ошибка сети.', true ); } finally { searchButton.disabled = false; } }); // Создание клиента. createForm.addEventListener('submit', async (event) => { event.preventDefault(); if (createButton.disabled || !createForm.reportValidity()) { return; } createButton.disabled = true; showMessage(createMessage, 'Сохранение...'); try { const fields = new FormData(createForm); const payload = { name: String(fields.get('name') ?? '').trim(), email: String(fields.get('email') ?? '').trim() }; const response = await fetch(createForm.action, { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-Token': createForm.dataset.csrfToken }, body: JSON.stringify(payload) }); await readApiResponse(response); createForm.reset(); showMessage( createMessage, 'Клиент создан. Нажмите «Загрузить клиентов», чтобы обновить список.' ); } catch (error) { showMessage( createMessage, error instanceof Error ? error.message : 'Ошибка сети.', true ); } finally { createButton.disabled = false; } }); // Обработчики установлены — интерфейс готов к работе. searchButton.disabled = false; createButton.disabled = false;
3.8. Разбор отправки запроса
| Конструкция | Что она делает |
|---|---|
addEventListener('submit', ...)
|
Назначает обработчик отправки формы. |
event.preventDefault()
|
Отменяет обычную отправку с переходом на другую страницу. |
new FormData(form)
|
Собирает значения полей формы по их атрибутам name.
|
new URLSearchParams(...)
|
Формирует и кодирует параметры строки запроса. |
fetch(...)
|
Отправляет HTTP-запрос. |
async и await
|
Позволяют последовательно записать действия, ожидающие асинхронного результата. |
JSON.stringify(payload)
|
Преобразует JavaScript-объект в строку JSON для отправки. |
response.json()
|
Читает JSON-ответ и преобразует его в JavaScript-значение. |
try, catch, finally
|
Разделяют основное действие, обработку ошибки и завершающие действия. |
FormData собирает поля по name, URLSearchParams отвечает за параметры URL, а response.json() возвращает результат разбора JSON, а не исходную строку ответа.
Проверка response.ok обязательна: получение HTTP 404 или 422 само по себе не означает, что fetch() выбросит исключение. Свойство ok истинно для ответов с кодами 200–299. Поэтому код отдельно проверяет HTTP-статус.
Используйте textContent для отображения пользовательских строк. Например, имя, содержащее <b>Анна</b>, должно отображаться как текст. Подстановка ответа через innerHTML может привести к выполнению нежелательной разметки и XSS.
3.9. Запуск и проверка
Остановите сервер отдельной HTML/CSS-песочницы, если он больше не нужен. Запустите основное Symfony-приложение способом, использованным в предыдущих лабораторных.
Войдите в приложение и откройте /sandbox/ajax прямым вводом адреса в браузере. Для повторной проверки выполняйте полную перезагрузку страницы.
Страница и /api/customers должны иметь одинаковые протокол, хост и порт. Не открывайте эту страницу через file:// и не отправляйте запросы из песочницы на другом порту. В данном упражнении межсайтовый доступ не нужен; не пытайтесь исправлять ошибки установкой mode: 'no-cors'.
Откройте Network / Сеть, выберите фильтр Fetch/XHR и выполните проверки:
| Проверка | Ожидаемый результат |
|---|---|
| Нажать «Загрузить клиентов» | GET-запрос и появление списка без полной перезагрузки страницы. |
| Ввести поисковую строку без совпадений | Сообщение «Клиенты не найдены». |
| Создать клиента с новым email | Успешный ответ API; клиент появляется при повторной загрузке списка и сохраняется в БД. |
| Повторить создание с тем же email | Ошибка API отображается на странице; лишняя запись не появляется. |
| Удалить или изменить CSRF-токен через инструменты разработчика |
Создание отклоняется с 403; запись не сохраняется.
|
| Проверить запрос без необходимых прав | Доступ отклоняется действующими правилами Symfony. |
| Остановить сервер и выполнить запрос | Пользователь видит сообщение об ошибке, а кнопка снова становится доступной. |
Отдельно изучите у запроса создания клиента Request Method, Request Headers, Request Payload, HTTP-статус и тело ответа.
При сетевой ошибке во время POST не повторяйте создание автоматически: сначала проверьте список клиентов, поскольку сервер мог сохранить запись, а ответ — не дойти до браузера.
Для следующей лабораторной работы с файлами учитывайте различие: JSON отправляется через JSON.stringify(), а файлы — обычно через FormData. При отправке FormData заголовок Content-Type вручную не устанавливают: браузер формирует его вместе с разделителем частей запроса.
3.10. Работа с ИИ и результат
Ты — разработчик JavaScript и Symfony. Проверь мою страницу AJAX и существующие API-методы. Проверь: GET-параметры и JSON-тело POST; обработку HTTP-ошибок и сетевых ошибок; пустой список; вывод пользовательских данных через textContent; серверную валидацию; сессионную аутентификацию; проверку CSRF-токена; сохранение действующих правил авторизации. Не отключай защиту ради успешного запроса. Не добавляй jQuery, React, Vue и другие фреймворки. Не создавай дубли существующих API-маршрутов. Сначала покажи найденные проблемы, затем предложи минимальный патч. Не утверждай, что тест пройден, если он не был запущен.
Сохраните контроллер, Twig-шаблон, JavaScript и изменения защиты существующего API-метода. В docs/ai/lab-08.md добавьте запросы к ИИ и фактические результаты проверок.
