Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
135ae8f
feat: add likes field to Film and friends field to User models
ksupyl May 29, 2026
5b73b1d
feat: add storage layer with FilmStorage and UserStorage interfaces
ksupyl May 29, 2026
228c960
feat: add FilmService and UserService with business logic
ksupyl May 29, 2026
da04739
feat: refactor controllers to use service layer, add new REST endpoints
ksupyl May 29, 2026
4a65a82
feat: add ErrorHandler for centralized exception handling
ksupyl May 29, 2026
d00b71a
feat: add logbook-spring-boot-starter for HTTP logging
ksupyl May 29, 2026
d3c6652
fix: handle MethodArgumentNotValidException with 400 status in ErrorH…
ksupyl May 29, 2026
9bd72ee
fix: restore release date validation in InMemoryFilmStorage
ksupyl May 29, 2026
140024d
test: update FilmControllerTest and UserControllerTest for new archit…
ksupyl May 29, 2026
35c1c62
fix: restore missing validation calls in InMemoryUserStorage and InMe…
ksupyl May 29, 2026
ba21fd3
docs: update README with sprint 2 architecture and new API endpoints
ksupyl May 29, 2026
cffa019
refactor: move validateReleaseDate from InMemoryFilmStorage to FilmSe…
ksupyl May 29, 2026
5f2989b
refactor: move validateLogin from InMemoryUserStorage to UserService
ksupyl May 29, 2026
30926fb
test: add FilmServiceTest and UserServiceTest with business logic cov…
ksupyl May 29, 2026
ab390dd
style: replace wildcard import with explicit imports in FilmService
ksupyl May 29, 2026
80e5f41
refactor: encapsulate likes mutation in Film model methods
ksupyl May 29, 2026
f6da980
refactor: encapsulate friends mutation in User model methods
ksupyl May 29, 2026
d175b96
refactor: move name-blank default logic from storage to UserService
ksupyl May 29, 2026
86849cf
fix: use UserService to create users in FilmServiceTest
ksupyl May 29, 2026
05ebd50
fix: add count validation in FilmService.getPopular
ksupyl May 30, 2026
8c83aa9
feat: add findPopular method to FilmStorage with stream-based sorting
ksupyl May 30, 2026
dbcde93
fix: delegate sorting to storage in FilmService
ksupyl May 30, 2026
1a883f8
test: add test for invalid count in FilmService.getPopular
ksupyl May 30, 2026
bdc674f
fix: add self-friendship validation in UserService friend methods and…
ksupyl May 30, 2026
f99ba91
test: add tests for self-friendship validation in UserService
ksupyl May 30, 2026
cc83599
refactor: extract getFilmOrThrow and getUserOrThrow helper methods in…
ksupyl May 30, 2026
1c63dd8
fix: handle Optional in delete methods and remove existence check fro…
ksupyl May 30, 2026
4c572b9
style: replace Collectors.toList() with Stream.toList() in findPopular
ksupyl May 30, 2026
6eb6271
style: replace Collectors.toList() with Stream.toList() in findPopular
ksupyl May 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 63 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
# java-filmorate

Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами, оценками пользователей и возврата топ-5 фильмов, рекомендованных к просмотру.
Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами,
оценками пользователей и возврата топ фильмов, рекомендованных к просмотру.

Проект развивается поэтапно. На текущем этапе реализован базовый каркас Spring Boot приложения, REST API для управления пользователями и фильмами, in-memory хранилище и строгая валидация данных.
Проект развивается поэтапно. На текущем этапе реализованы слоистая архитектура
(controller → service → storage), социальные функции (друзья, лайки)
и полноценный REST API.

## Автор
**Ксения Пылькина** *(Ksenia Pylkina)*

## Ключевые возможности на данном этапе

- CRUD-операции для `Film` и `User` (создание, обновление, получение списка).
- CRUD-операции для `Film` и `User` (создание, обновление, получение по ID и списком).
- Хранение данных в оперативной памяти (`HashMap`).
- Автоматическая генерация уникальных идентификаторов.
- Строгая валидация входящих данных через `spring-boot-starter-validation` (`@Valid`, `@NotBlank`, `@Email`, `@Positive` и др.).
- Кастомная бизнес-валидация (проверка даты релиза кино, запрет пробелов в логине, автозамена пустого имени на логин).
- Обработка ошибок через кастомное исключение `ValidationException`.
- Логирование всех ключевых операций и ошибок с помощью `SLF4J`.
- Покрытие бизнес-логики и граничных условий unit-тестами.
- Социальные функции: добавление/удаление друзей, список общих друзей.
- Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков.
- Слоистая архитектура: контроллер → сервис → хранилище.
- Внедрение зависимостей через конструктор (`@Autowired`).
- Строгая валидация входящих данных через `spring-boot-starter-validation`.
- Кастомная бизнес-валидация (дата релиза, пробелы в логине, автозамена имени на логин).
- Централизованная обработка ошибок (`@RestControllerAdvice`): 400, 404, 500.
- Логирование HTTP-запросов и ответов через Logbook.
- Покрытие бизнес-логики unit-тестами.

## Технологии

- **Java 21**
- **Spring Boot 3.2.2**
- `spring-boot-starter-web` (REST API)
- `spring-boot-starter-validation` (Валидация данных)
- **Lombok** (Генерация boilerplate-кода и логгеров)
- **JUnit 5** (Unit-тестирование)
- **Maven** (Сборка проекта и управление зависимостями)
- **Checkstyle** (Контроль качества и стиля кода)
- `spring-boot-starter-validation` (валидация данных)
- **Logbook 3.7.2** (логирование HTTP-запросов)
- **Lombok** (генерация boilerplate-кода и логгеров)
- **JUnit 5** (unit-тестирование)
- **Maven** (сборка проекта)
- **Checkstyle** (контроль стиля кода)

## Запуск проекта

Expand All @@ -38,40 +45,66 @@

После запуска сервер будет доступен на порту **8080**.

Для проверки HTTP-запросов можно использовать **Postman** (в корне проекта или в материалах курса доступна готовая коллекция тестов `postman.json`).
Для проверки запросов можно использовать **Postman**
(коллекция `postman.json` доступна в материалах курса).

## Основные HTTP-эндпоинты

### Фильмы (`/films`)
- `GET /films` — получить список всех фильмов.
- `GET /films/{id}` — получить фильм по ID.
- `POST /films` — добавить новый фильм.
- `PUT /films` — обновить существующий фильм.
- `PUT /films/{id}/like/{userId}` — поставить лайк фильму.
- `DELETE /films/{id}/like/{userId}` — убрать лайк.
- `GET /films/popular?count={count}` — топ-N фильмов по лайкам (по умолчанию 10).

### Пользователи (`/users`)
- `GET /users` — получить список всех пользователей.
- `GET /users/{id}` — получить пользователя по ID.
- `POST /users` — создать нового пользователя.
- `PUT /users` — обновить данные существующего пользователя.
- `PUT /users` — обновить данные пользователя.
- `PUT /users/{id}/friends/{friendId}` — добавить в друзья.
- `DELETE /users/{id}/friends/{friendId}` — удалить из друзей.
- `GET /users/{id}/friends` — список друзей пользователя.
- `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем.

## Реализация по этапам

<details open>
<details>
<summary><strong>Этап 1 — Каркас Spring Boot приложения и базовый REST API</strong></summary>

На этом этапе был заложен фундамент будущего сервиса рекомендаций фильмов. Проект переведен на рельсы Spring Boot, реализованы базовые модели данных и контроллеры.
На этом этапе был заложен фундамент будущего сервиса. Проект переведён на Spring Boot,
реализованы базовые модели данных и контроллеры.

**Что сделано:**
- Настроен `pom.xml`, подключены необходимые стартеры Spring Boot, Lombok и плагин Checkstyle.
- Созданы модели данных `Film` и `User` с использованием аннотаций Lombok (`@Data`).
- Настроен `pom.xml`, подключены стартеры Spring Boot, Lombok и плагин Checkstyle.
- Созданы модели `Film` и `User` с аннотациями Lombok (`@Data`).
- Реализованы REST-контроллеры `FilmController` и `UserController`.
- Настроено in-memory хранение данных с ручной генерацией ID.
- Добавлена валидация данных через аннотации `jakarta.validation.constraints`.
- Реализована сложная бизнес-валидация (например, дата релиза фильма не может быть раньше 28 декабря 1895 года).
- Настроено логирование запросов и ошибок.
- Написаны unit-тесты для проверки логики валидации контроллеров.

**Ключевые решения:**
- Использование `spring-boot-starter-validation` позволило значительно сократить количество ручных проверок `if-else` в контроллерах и сделать код чище.
- Выделение кастомного исключения `ValidationException` подготовило почву для будущего создания глобального обработчика ошибок (`@ControllerAdvice`).
- Разделение логики валидации и сохранения данных внутри контроллеров делает код читаемым и легко тестируемым.
- In-memory хранение данных с ручной генерацией ID.
- Валидация через аннотации `jakarta.validation.constraints`.
- Кастомная бизнес-валидация (дата релиза, пробелы в логине).
- Логирование запросов и ошибок.
- Unit-тесты для проверки логики валидации.

</details>

<details open>
<summary><strong>Этап 2 — Слоистая архитектура, социальные функции, полный REST</strong></summary>

На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки,
API доведён до соответствия REST.

**Что сделано:**
- Созданы интерфейсы `FilmStorage` и `UserStorage` и их реализации
`InMemoryFilmStorage` и `InMemoryUserStorage` (`@Component`).
- Созданы `FilmService` (`@Service`) и `UserService` (`@Service`)
с бизнес-логикой лайков и друзей.
- Контроллеры переработаны: зависят от сервисов, внедрение через конструктор.
- Сервисы зависят от интерфейсов хранилищ — легко заменить реализацию в будущем.
- Добавлены все новые эндпоинты (друзья, лайки, топ фильмов, получение по ID).
- Централизованная обработка ошибок через `@RestControllerAdvice`: 400, 404, 500.
- Подключён Logbook для логирования HTTP-запросов и ответов.
- Unit-тесты обновлены под новую архитектуру.

</details>
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-spring-boot-starter</artifactId>
<version>3.7.2</version>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,73 +2,65 @@

import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Film;
import ru.yandex.practicum.filmorate.service.FilmService;

import java.time.LocalDate;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/films")
@Slf4j
public class FilmController {

// Хранилище фильмов в памяти
private final Map<Long, Film> films = new HashMap<>();
private final FilmService filmService;

private long currentId = 1;

// Константа: дата первого релиза (день рождения кино)
private static final LocalDate CINEMA_BIRTHDAY = LocalDate.of(1895, 12, 28);
@Autowired
public FilmController(FilmService filmService) {
this.filmService = filmService;
}

@GetMapping
public Collection<Film> findAll() {
log.info("Получен запрос на получение списка всех фильмов");
return films.values();
log.info("GET /films");
return filmService.findAll();
}

@GetMapping("/{id}")
public Film findById(@PathVariable long id) {
log.info("GET /films/{}", id);
return filmService.findById(id);
}

@PostMapping
public Film create(@Valid @RequestBody Film film) {
log.info("Получен запрос на создание фильма: {}", film);

validateFilm(film);

film.setId(getNextId());
films.put(film.getId(), film);

log.info("Фильм успешно добавлен. Присвоен ID: {}", film.getId());
return film;
log.info("POST /films : {}", film.getName());
return filmService.add(film);
}

@PutMapping
public Film update(@Valid @RequestBody Film film) {
log.info("Получен запрос на обновление фильма с ID: {}", film.getId());

if (film.getId() == null || !films.containsKey(film.getId())) {
log.error("Ошибка обновления: фильм с ID {} не найден", film.getId());
throw new ValidationException("Фильм с таким ID не найден");
}

validateFilm(film);
log.info("PUT /films : id={}", film.getId());
return filmService.update(film);
}

films.put(film.getId(), film);
log.info("Фильм с ID {} успешно обновлен", film.getId());
return film;
@PutMapping("/{id}/like/{userId}")
public Film addLike(@PathVariable long id, @PathVariable long userId) {
log.info("PUT /films/{}/like/{}", id, userId);
return filmService.addLike(id, userId);
}

// Вспомогательный метод для валидации
private void validateFilm(Film film) {
if (film.getReleaseDate().isBefore(CINEMA_BIRTHDAY)) {
log.error("Ошибка валидации: дата релиза {} раньше 28 декабря 1895 года", film.getReleaseDate());
throw new ValidationException("Дата релиза фильма не может быть раньше 28 декабря 1895 года");
}
@DeleteMapping("/{id}/like/{userId}")
public Film removeLike(@PathVariable long id, @PathVariable long userId) {
log.info("DELETE /films/{}/like/{}", id, userId);
return filmService.removeLike(id, userId);
}

// Вспомогательный метод генерации id
private long getNextId() {
return currentId++;
@GetMapping("/popular")
public Collection<Film> getPopular(
@RequestParam(defaultValue = "10") int count) {
log.info("GET /films/popular?count={}", count);
return filmService.getPopular(count);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,72 @@

import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.User;
import ru.yandex.practicum.filmorate.service.UserService;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/users")
@Slf4j
public class UserController {

// Хранилище пользователей в памяти
private final Map<Long, User> users = new HashMap<>();
private final UserService userService;

private long currentId = 1;
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}

@GetMapping
public Collection<User> findAll() {
log.info("Получен запрос на получение списка всех пользователей");
return users.values();
log.info("GET /users");
return userService.findAll();
}

@GetMapping("/{id}")
public User findById(@PathVariable long id) {
log.info("GET /users/{}", id);
return userService.findById(id);
}

@PostMapping
public User create(@Valid @RequestBody User user) {
log.info("Получен запрос на создание пользователя: {}", user.getLogin());

validateUser(user);

user.setId(getNextId());
users.put(user.getId(), user);

log.info("Пользователь успешно добавлен. Присвоен ID: {}", user.getId());
return user;
log.info("POST /users : {}", user.getLogin());
return userService.add(user);
}

@PutMapping
public User update(@Valid @RequestBody User user) {
log.info("Получен запрос на обновление пользователя с ID: {}", user.getId());

if (user.getId() == null || !users.containsKey(user.getId())) {
log.error("Ошибка обновления: пользователь с ID {} не найден", user.getId());
throw new ValidationException("Пользователь с таким ID не найден");
}

validateUser(user);
log.info("PUT /users : id={}", user.getId());
return userService.update(user);
}

users.put(user.getId(), user);
log.info("Пользователь с ID {} успешно обновлен", user.getId());
return user;
@PutMapping("/{id}/friends/{friendId}")
public User addFriend(@PathVariable long id, @PathVariable long friendId) {
log.info("PUT /users/{}/friends/{}", id, friendId);
return userService.addFriend(id, friendId);
}

// Вспомогательный метод для валидации и бизнес-логики
private void validateUser(User user) {
// Проверка на пробелы в логине
if (user.getLogin().contains(" ")) {
log.error("Ошибка валидации: логин '{}' содержит пробелы", user.getLogin());
throw new ValidationException("Логин не может содержать пробелы");
}
@DeleteMapping("/{id}/friends/{friendId}")
public User removeFriend(@PathVariable long id, @PathVariable long friendId) {
log.info("DELETE /users/{}/friends/{}", id, friendId);
return userService.removeFriend(id, friendId);
}

// Если имя пустое = использование логина
if (user.getName() == null || user.getName().isBlank()) {
log.info("Имя пользователя не указано. Используем логин: {}", user.getLogin());
user.setName(user.getLogin());
}
@GetMapping("/{id}/friends")
public Collection<User> getFriends(@PathVariable long id) {
log.info("GET /users/{}/friends", id);
return userService.getFriends(id);
}

// Вспомогательный метод для генерации ID
private long getNextId() {
return currentId++;
@GetMapping("/{id}/friends/common/{otherId}")
public Collection<User> getCommonFriends(
@PathVariable long id,
@PathVariable long otherId) {
log.info("GET /users/{}/friends/common/{}", id, otherId);
return userService.getCommonFriends(id, otherId);
}
}
}
Loading
Loading