Лабораторная работа X. Вводный практикум по AJAX

Лабораторная работа 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 добавьте запросы к ИИ и фактические результаты проверок.

Автор: к.п.н., Румянцев Сергей Александрович, доцент Финансового университета при Правительстве РФ; доцент ОЧУВО Международного инновационного университета; Консалтинг, управление разработкой ПО; системный и бизнес анализ; менеджмент; аналитиз данных; управление ИТ. Телефон для связи +79269444818 (мессенджеры)   Короткая ссылка:

Лабораторная работа 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 добавьте запросы к ИИ и фактические результаты проверок.

https://webprogr.ru/~wdvNY
Короткая ссылка на новость:https://webprogr.ru/~wdvNY


Последние новости

Лабораторная работа X. Вводный практикум по AJAX

Лабораторная работа 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 добавьте запросы к ИИ и фактические результаты проверок.

Рейтинг@Mail.ru