Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
248 changes: 144 additions & 104 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.web.bind.annotation.*;
import ru.yandex.practicum.filmorate.model.Director;
import ru.yandex.practicum.filmorate.service.DirectorService;

import java.util.List;

@Slf4j
Expand All @@ -27,7 +28,7 @@ public List<Director> getDirectors() {
}

@GetMapping("/{id}")
public Director getDirectorById(@PathVariable Long id) {
public Director getDirectorById(@PathVariable long id) {
log.info("GET /directors/{}", id);
return directorService.getDirectorById(id);
}
Expand All @@ -45,9 +46,8 @@ public Director updateDirector(@Valid @RequestBody Director director) {
}

@DeleteMapping("/{id}")
public void deleteDirector(@PathVariable Long id) {
public void deleteDirector(@PathVariable long id) {
log.info("DELETE /directors/{}", id);
directorService.deleteDirector(id);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public Collection<Film> getCommon(@RequestParam long userId, @RequestParam long

@GetMapping("/director/{directorId}")
public Collection<Film> getFilmsByDirector(@PathVariable long directorId,
@RequestParam (defaultValue = "likes") String sortBy) {
@RequestParam(defaultValue = "likes") String sortBy) {
log.info("GET /films/director/{}?sortBy={}", directorId, sortBy);
return filmService.findByDirectorSorted(directorId, sortBy);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.util.Map;
import java.util.stream.Collectors;

@RestControllerAdvice
@Slf4j
Expand All @@ -27,18 +34,70 @@ public Map<String, String> handleNotFoundException(NotFoundException e) {
return Map.of("error", e.getMessage());
}

// Нарушены аннотации валидации (@NotBlank, @Positive и т.п.): в ответе — поле и что с ним не так
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
public Map<String, String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("400: {}", message);
return Map.of("error", message);
}

// Нет обязательного параметра, например GET /films/search без by
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleMissingParameter(MissingServletRequestParameterException e) {
log.warn("400: {}", e.getMessage());
return Map.of("error", e.getMessage());
return Map.of("error", "Не указан обязательный параметр '" + e.getParameterName() + "'");
}

// Параметр не того типа, например GET /films/abc вместо числового id
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
log.warn("400: {}", e.getMessage());
return Map.of("error", "Некорректное значение параметра '" + e.getName() + "': " + e.getValue());
}

// Тело запроса не читается как JSON нужной структуры
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleNotReadableBody(HttpMessageNotReadableException e) {
log.warn("400: {}", e.getMessage());
return Map.of("error", "Некорректное тело запроса");
}

// Запрос на адрес, которого нет в API
@ExceptionHandler
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, String> handleNoResource(NoResourceFoundException e) {
log.warn("404: {}", e.getMessage());
return Map.of("error", "Адрес /" + e.getResourcePath() + " не найден");
}

// Адрес есть, но с другим HTTP-методом, например POST /films/1
@ExceptionHandler
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public Map<String, String> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
log.warn("405: {}", e.getMessage());
return Map.of("error", "Метод " + e.getMethod() + " для этого адреса не поддерживается");
}

// Тело пришло не в JSON, например с Content-Type: text/plain
@ExceptionHandler
@ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
public Map<String, String> handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) {
log.warn("415: {}", e.getMessage());
return Map.of("error", "Тело запроса должно быть в формате JSON");
}

// Непредвиденная ошибка: подробности только в лог, клиенту — общее сообщение без SQL и стектрейсов
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, String> handleException(Exception e) {
log.error("500: {}", e.getMessage(), e);
return Map.of("error", e.getMessage());
return Map.of("error", "Внутренняя ошибка сервера");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;

// Равенство по id, как у Genre и Mpa: дубль режиссёра в запросе схлопнется в один
@Data
@EqualsAndHashCode(of = "id")
public class Director {
private Long id;

@NotBlank(message = "Имя режиссера не может быть пустым")
@NotBlank(message = "Имя режиссёра не может быть пустым")
private String name;
}
15 changes: 0 additions & 15 deletions src/main/java/ru/yandex/practicum/filmorate/model/Film.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
package ru.yandex.practicum.filmorate.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.Data;

import java.time.LocalDate;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;

Expand All @@ -34,18 +32,5 @@ public class Film {

private Mpa mpa;
private Set<Genre> genres = new LinkedHashSet<>();

@JsonIgnore
private Set<Long> likes = new HashSet<>();

private Set<Director> directors = new LinkedHashSet<>();

public void addLike(Long userId) {
likes.add(userId);
}

public boolean removeLike(Long userId) {
return likes.remove(userId);
}

}
14 changes: 0 additions & 14 deletions src/main/java/ru/yandex/practicum/filmorate/model/User.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
package ru.yandex.practicum.filmorate.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import lombok.Data;

import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;

@Data
public class User {
Expand All @@ -27,15 +24,4 @@ public class User {
@NotNull(message = "Дата рождения не может быть пустой")
@PastOrPresent(message = "Дата рождения не может быть в будущем")
private LocalDate birthday;

@JsonIgnore
private Set<Long> friends = new HashSet<>();

public void addFriend(Long friendId) {
friends.add(friendId);
}

public boolean removeFriend(Long friendId) {
return friends.remove(friendId);
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package ru.yandex.practicum.filmorate.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Director;
import ru.yandex.practicum.filmorate.storage.director.DirectorStorage;

import java.util.List;

@Service
public class DirectorService {

private final DirectorStorage directorStorage;

@Autowired
public DirectorService(@Qualifier("directorDbStorage") DirectorStorage directorStorage) {
this.directorStorage = directorStorage;
}
Expand All @@ -20,6 +25,9 @@ public Director addDirector(Director director) {
}

public Director updateDirector(Director director) {
if (director.getId() == null) {
throw new ValidationException("Не указан id режиссёра");
}
return directorStorage.updateDirector(director);
}

Expand All @@ -29,11 +37,10 @@ public void deleteDirector(long id) {

public Director getDirectorById(long id) {
return directorStorage.findDirectorById(id)
.orElseThrow(() -> new NotFoundException("Режиссер с id " + id + " не найден"));
.orElseThrow(() -> new NotFoundException("Режиссёр с id=" + id + " не найден"));
}

public java.util.List<Director> getAllDirectors() {
public List<Director> getAllDirectors() {
return directorStorage.findAllDirectors();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.yandex.practicum.filmorate.exception.NotFoundException;
import ru.yandex.practicum.filmorate.exception.ValidationException;
import ru.yandex.practicum.filmorate.model.Director;
Expand All @@ -18,6 +19,7 @@
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

@Service
Expand Down Expand Up @@ -64,6 +66,8 @@ private void getUserOrThrow(long id) {
.orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден"));
}

// Лайк и событие в ленте пишутся вместе: либо оба, либо ни одного
@Transactional
public Film addLike(long filmId, long userId) {
Film film = getFilmOrThrow(filmId);
getUserOrThrow(userId); // проверка существования пользователя — иначе 404
Expand All @@ -74,6 +78,7 @@ public Film addLike(long filmId, long userId) {
return film;
}

@Transactional
public Film removeLike(long filmId, long userId) {
Film film = getFilmOrThrow(filmId);
getUserOrThrow(userId); // проверка существования пользователя — иначе 404
Expand Down Expand Up @@ -129,6 +134,9 @@ private void validateReleaseDate(Film film) {
// Рейтинг и жанры из запроса должны существовать в справочниках — иначе 404
private void validateMpaAndGenres(Film film) {
if (film.getMpa() != null) {
if (film.getMpa().getId() == null) {
throw new ValidationException("Не указан id рейтинга MPA");
}
mpaService.findById(film.getMpa().getId()); // бросит NotFoundException
}
if (film.getGenres() == null || film.getGenres().isEmpty()) {
Expand All @@ -140,23 +148,28 @@ private void validateMpaAndGenres(Film film) {
knownIds.add(genre.getId());
}
for (Genre genre : film.getGenres()) {
if (genre.getId() == null) {
throw new ValidationException("Не указан id жанра");
}
if (!knownIds.contains(genre.getId())) {
throw new NotFoundException("Жанр с id=" + genre.getId() + " не найден");
}
}
}

// Режиссёры из запроса должны существовать — иначе 404
private void validateDirectors(Film film) {

if (film.getDirectors() == null || film.getDirectors().isEmpty()) {
return;
}

Set<Long> knownIds = new HashSet<>();
for (Director director : directorService.getAllDirectors()) {
knownIds.add(director.getId());
}
for (Director director : film.getDirectors()) {
if (director.getId() == null) {
throw new ValidationException("Не указан id режиссёра");
}
if (!knownIds.contains(director.getId())) {
throw new NotFoundException("Режиссёр с id=" + director.getId() + " не найден");
}
Expand All @@ -171,6 +184,9 @@ public Film add(Film film) {
}

public Film update(Film film) {
if (film.getId() == null) {
throw new ValidationException("Не указан id фильма");
}
getFilmOrThrow(film.getId()); // проверка существования — иначе 404
validateReleaseDate(film);
validateMpaAndGenres(film);
Expand All @@ -192,9 +208,14 @@ public Film findById(long id) {
return getFilmOrThrow(id);
}

// Сортировка без учёта регистра: year или likes, остальное — 400
public Collection<Film> findByDirectorSorted(long directorId, String sortBy) {
String sort = sortBy.toLowerCase(Locale.ROOT);
if (!sort.equals("year") && !sort.equals("likes")) {
throw new ValidationException("Сортировка возможна только по year или likes, получено: " + sortBy);
}
directorService.getDirectorById(directorId); // проверка существования режиссёра — иначе 404
return filmStorage.findFilmByDirector(directorId, sortBy);
return filmStorage.findFilmByDirector(directorId, sort);
}

private void validateSearchParameters(String query, List<String> by) {
Expand All @@ -213,6 +234,8 @@ private void validateSearchParameters(String query, List<String> by) {

public Collection<Film> searchFilms(String query, List<String> by) {
validateSearchParameters(query, by);
return filmStorage.searchFilms(query, by);
// Проверка выше не учитывает регистр, а хранилище ждёт title и director строчными
List<String> fields = by.stream().map(field -> field.toLowerCase(Locale.ROOT)).toList();
return filmStorage.searchFilms(query, fields);
}
}
Loading
Loading