diff --git a/docker-compose.yml b/docker-compose.yml
index ffff7c7..0d224f6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -39,9 +39,6 @@ services:
image: confluentinc/cp-kafka:7.5.0
container_name: kafka
restart: unless-stopped
- depends_on:
- zookeeper:
- condition: service_healthy
ports:
- "9092:9092"
- "9093:9093"
@@ -58,6 +55,13 @@ services:
interval: 10s
timeout: 10s
retries: 5
+ redis:
+ image: redis:8.2.1
+ restart: on-failure
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
user-service:
image: user-service:latest
build:
@@ -77,13 +81,6 @@ services:
- KAFKA_BOOTSTRAP_SERVERS=kafka:9093
env_file:
- .env
- redis:
- image: redis:8.2.1
- restart: on-failure
- ports:
- - "6379:6379"
- volumes:
- - redis_data:/data
volumes:
pgdata:
redis_data:
diff --git a/frontend/app.js b/frontend/app.js
new file mode 100644
index 0000000..9dc18af
--- /dev/null
+++ b/frontend/app.js
@@ -0,0 +1,201 @@
+const API_BASE_URL = 'http://localhost:8080/users';
+
+const dom = {
+ usersBody: document.querySelector('#users-body'),
+ listStatus: document.querySelector('#list-status'),
+ getForm: document.querySelector('#get-form'),
+ getId: document.querySelector('#get-id'),
+ getStatus: document.querySelector('#get-status'),
+ getResult: document.querySelector('#get-result'),
+ createForm: document.querySelector('#create-form'),
+ createName: document.querySelector('#create-name'),
+ createEmail: document.querySelector('#create-email'),
+ createAge: document.querySelector('#create-age'),
+ createStatus: document.querySelector('#create-status'),
+ updateForm: document.querySelector('#update-form'),
+ updateId: document.querySelector('#update-id'),
+ updateName: document.querySelector('#update-name'),
+ updateEmail: document.querySelector('#update-email'),
+ updateAge: document.querySelector('#update-age'),
+ updateStatus: document.querySelector('#update-status'),
+ deleteForm: document.querySelector('#delete-form'),
+ deleteId: document.querySelector('#delete-id'),
+ deleteStatus: document.querySelector('#delete-status'),
+ refreshBtn: document.querySelector('#refresh-btn')
+};
+
+const defaultHeaders = {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+};
+
+const setStatus = (el, message, isError = false) => {
+ if (!el) {
+ return;
+ }
+ el.style.color = isError ? '#ef4444' : '#38bdf8';
+ el.textContent = message ?? '';
+};
+
+const request = async (path = '', options = {}) => {
+ const response = await fetch(`${API_BASE_URL}${path}`, {
+ ...options,
+ headers: {
+ ...defaultHeaders,
+ ...(options.headers ?? {})
+ }
+ });
+
+ if (!response.ok) {
+ let errorMessage = `Ошибка ${response.status}`;
+ try {
+ const errorBody = await response.json();
+ if (errorBody?.message) {
+ errorMessage += `: ${errorBody.message}`;
+ }
+ } catch {
+ // ignore parsing errors
+ }
+ throw new Error(errorMessage);
+ }
+
+ if (response.status === 204) {
+ return null;
+ }
+
+ return response.json();
+};
+
+const renderUsers = (users = []) => {
+ if (!Array.isArray(users) || !users.length) {
+ dom.usersBody.innerHTML = `
+
+ | Нет данных. Создайте нового пользователя или обновите список. |
+
+ `;
+ return;
+ }
+
+ dom.usersBody.innerHTML = users.map((user) => `
+
+ | ${user.id} |
+ ${user.name} |
+ ${user.email} |
+ ${user.age} |
+
+ `).join('');
+};
+
+const refreshUsers = async () => {
+ setStatus(dom.listStatus, 'Загрузка пользователей...');
+ try {
+ const users = await request('');
+ renderUsers(users);
+ setStatus(dom.listStatus, `Получено записей: ${users.length}`);
+ } catch (error) {
+ renderUsers([]);
+ setStatus(dom.listStatus, error.message);
+ }
+};
+
+const handleGetUser = async (event) => {
+ event.preventDefault();
+ const id = dom.getId.value.trim();
+ if (!id) {
+ setStatus(dom.getStatus, 'Укажите ID пользователя', true);
+ return;
+ }
+
+ setStatus(dom.getStatus, 'Запрос пользователя...');
+ dom.getResult.textContent = '';
+ try {
+ const user = await request(`/${id}`);
+ dom.getResult.textContent = JSON.stringify(user, null, 2);
+ setStatus(dom.getStatus, 'Готово');
+ } catch (error) {
+ dom.getResult.textContent = '';
+ setStatus(dom.getStatus, error.message, true);
+ }
+};
+
+const handleCreateUser = async (event) => {
+ event.preventDefault();
+ const payload = {
+ name: dom.createName.value.trim(),
+ email: dom.createEmail.value.trim(),
+ age: Number(dom.createAge.value)
+ };
+
+ if (!payload.name || !payload.email || Number.isNaN(payload.age)) {
+ setStatus(dom.createStatus, 'Заполните все поля', true);
+ return;
+ }
+
+ setStatus(dom.createStatus, 'Создание пользователя...');
+ try {
+ const created = await request('', {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ });
+ dom.createForm.reset();
+ setStatus(dom.createStatus, `Пользователь создан (ID: ${created.id})`);
+ await refreshUsers();
+ } catch (error) {
+ setStatus(dom.createStatus, error.message, true);
+ }
+};
+
+const handleUpdateUser = async (event) => {
+ event.preventDefault();
+ const id = dom.updateId.value.trim();
+ const payload = {
+ name: dom.updateName.value.trim(),
+ email: dom.updateEmail.value.trim(),
+ age: Number(dom.updateAge.value)
+ };
+
+ if (!id || !payload.name || !payload.email || Number.isNaN(payload.age)) {
+ setStatus(dom.updateStatus, 'Заполните все поля', true);
+ return;
+ }
+
+ setStatus(dom.updateStatus, 'Обновление пользователя...');
+ try {
+ await request(`/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload)
+ });
+ setStatus(dom.updateStatus, 'Пользователь обновлён');
+ await refreshUsers();
+ } catch (error) {
+ setStatus(dom.updateStatus, error.message, true);
+ }
+};
+
+const handleDeleteUser = async (event) => {
+ event.preventDefault();
+ const id = dom.deleteId.value.trim();
+ if (!id) {
+ setStatus(dom.deleteStatus, 'Укажите ID пользователя', true);
+ return;
+ }
+
+ setStatus(dom.deleteStatus, 'Удаление пользователя...');
+ try {
+ await request(`/${id}`, { method: 'DELETE' });
+ dom.deleteForm.reset();
+ setStatus(dom.deleteStatus, 'Пользователь удалён');
+ await refreshUsers();
+ } catch (error) {
+ setStatus(dom.deleteStatus, error.message, true);
+ }
+};
+
+dom.refreshBtn?.addEventListener('click', refreshUsers);
+dom.getForm?.addEventListener('submit', handleGetUser);
+dom.createForm?.addEventListener('submit', handleCreateUser);
+dom.updateForm?.addEventListener('submit', handleUpdateUser);
+dom.deleteForm?.addEventListener('submit', handleDeleteUser);
+
+// Попытаться получить пользователей сразу, но ошибки не показывать пользователю
+refreshUsers().catch(() => {});
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..990eeb9
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,223 @@
+
+
+
+
+
+ User Service UI
+
+
+
+
+
+ User Service CRUD
+ Набор простых форм для проверки API: создавайте, просматривайте, обновляйте и удаляйте пользователей.
+
+
+
+
+
+
+
+ | ID |
+ Имя |
+ Email |
+ Возраст |
+
+
+
+
+ | Нажмите «Загрузить всех пользователей», чтобы получить данные. |
+
+
+
+
+
+
+ Найти пользователя
+
+
+
+
+
+ Создать пользователя
+
+
+
+
+ Обновить пользователя
+
+
+
+
+ Удалить пользователя
+
+
+
+
+
+
+
diff --git a/user-service/user-service-impl/src/main/java/ru/project/iakov/userservice/configuration/CorsConfig.java b/user-service/user-service-impl/src/main/java/ru/project/iakov/userservice/configuration/CorsConfig.java
new file mode 100644
index 0000000..83a63c6
--- /dev/null
+++ b/user-service/user-service-impl/src/main/java/ru/project/iakov/userservice/configuration/CorsConfig.java
@@ -0,0 +1,23 @@
+package ru.project.iakov.userservice.configuration;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Enables CORS for local frontend testing.
+ *
+ * @author Iakov Lysenko
+ */
+@Configuration
+public class CorsConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*")
+ .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+ .allowedHeaders("*")
+ .allowCredentials(false);
+ }
+}