Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

⭐️3단계 - 즐겨찾기 기능 구현 #462

Open
wants to merge 16 commits into
base: boradol
Choose a base branch
from
Open
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
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,42 @@

## 요구사항
### 기능 요구사항
- [x] 깃허브를 이용한 로그인 구현(토큰 발행)
>- [x] 깃허브를 이용한 로그인 구현(토큰 발행)
> - [x] `AuthAcceptanceTest` 테스트 만들기
- [x] 가입이 되어있지 않은 경우 회원 가입으로 진행 후 토큰 발행
>- [x] 가입이 되어있지 않은 경우 회원 가입으로 진행 후 토큰 발행

### 프로그래밍 요구사항
>- [x] GitHub 로그인을 검증할 수 있는 인수 테스트 구현(실제 GitHub에 요청을 하지 않아도 됨)

### 리뷰 요구사항
>- [x] 인수테스트 추가하기
>- [x] api호출 로직과 db호출로직 분리
>- [x] 에러 메세지 변경
---


# 🚀 3단계 - 즐겨찾기 기능 구현

## 요구사항
### 기능 요구사항
>- [x] 요구사항 설명에서 제공되는 추가된 요구사항을 기반으로 즐겨 찾기 기능을 리팩터링하세요.
> - [x] 생성
> - [x] 조회
> - [x] 삭제
>- 추가된 요구사항을 정의한 인수 조건을 도출하세요.
> - [x] 내 정보 관리 / 즐겨 찾기 기능은 로그인 된 상태에서만 가능
> - [x] `FavoriteAcceptanceTest` 인수 테스트 만들기
> - 예외 케이스에 대한 검증도 포함하세요.
> - [x] 로그인이 필요한 API 요청 시 유효하지 않은 경우 401 응답 내려주기
Comment on lines +55 to +65

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요구사항 정리 👍


### 프로그래밍 요구사항
>- [x] 인수 테스트 주도 개발 프로세스에 맞춰서 기능을 구현하세요.
> - 요구사항 설명을 참고하여 인수 조건을 정의
> - 인수 조건을 검증하는 인수 테스트 작성
> - 인수 테스트를 충족하는 기능 구현
>- [x] 인수 조건은 인수 테스트 메서드 상단에 주석으로 작성하세요.
> - 뼈대 코드의 인수 테스트를 참고
>- [x] 인수 테스트 이후 기능 구현은 TDD로 진행하세요.
> - [x] 도메인 레이어 테스트는 필수
> - [ ] 서비스 레이어 테스트는 선택
3 changes: 2 additions & 1 deletion src/main/java/nextstep/config/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public WebConfig(AuthenticationInterceptor authenticationInterceptor, AuthMember
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authenticationInterceptor)
.addPathPatterns("/members/me");
.addPathPatterns("/members/me")
.addPathPatterns("/favorites/**");
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package nextstep.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.BAD_REQUEST)
public class SubwayIllegalArgumentException extends IllegalArgumentException {
public SubwayIllegalArgumentException(String message){
super(message);
}
}
50 changes: 50 additions & 0 deletions src/main/java/nextstep/favorite/FavoriteController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package nextstep.favorite;

import nextstep.favorite.application.FavoriteService;
import nextstep.favorite.domain.Favorite;
import nextstep.favorite.dto.FavoriteRequestDto;
import nextstep.favorite.dto.FavoriteResponseDto;
import nextstep.filter.PreAuthorize;
import nextstep.member.application.dto.MemberResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/favorites")
public class FavoriteController {
private final FavoriteService favoriteService;

public FavoriteController(FavoriteService favoriteService) {
this.favoriteService = favoriteService;
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<Void> createFavorite(@PreAuthorize MemberResponse member, @RequestBody FavoriteRequestDto favoriteRequestDto) {
Favorite favorite = favoriteService.create(member.getEmail(), favoriteRequestDto);
return ResponseEntity
.created(URI.create("/favorites/" + favorite.getId()))
.build();
}

@GetMapping
public ResponseEntity<List<FavoriteResponseDto>> getListFavorite(@PreAuthorize MemberResponse member) {
List<FavoriteResponseDto> favorites = favoriteService.getList(member.getEmail())
.stream()
.map(FavoriteResponseDto::of)
.collect(Collectors.toUnmodifiableList());
return ResponseEntity.ok(favorites);
}

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public ResponseEntity<Void> deleteFavorite(@PreAuthorize MemberResponse member, @PathVariable Long id) {
favoriteService.deleteById(member.getEmail(), id);
return ResponseEntity.noContent().build();
}
}
57 changes: 57 additions & 0 deletions src/main/java/nextstep/favorite/application/FavoriteService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package nextstep.favorite.application;

import nextstep.favorite.domain.Favorite;
import nextstep.favorite.domain.FavoriteRepository;
import nextstep.favorite.dto.FavoriteRequestDto;
import nextstep.member.application.MemberService;
import nextstep.member.domain.Member;
import nextstep.subway.applicaion.StationService;
import nextstep.subway.domain.Station;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class FavoriteService {
private final FavoriteRepository favoriteRepository;
private final MemberService memberService;
private final StationService stationService;

public FavoriteService(FavoriteRepository favoriteRepository, MemberService memberService, StationService stationService) {
this.favoriteRepository = favoriteRepository;
this.memberService = memberService;
this.stationService = stationService;
}

@Transactional
public Favorite create(String email, FavoriteRequestDto favoriteRequestDto) {
Member member = findMember(email);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인증이 된 member를 전달한다면 findMember가 없어도 되지 않을까요? 🤔

Station sourceStation = stationService.findById(favoriteRequestDto.getSource());
Station targetStation = stationService.findById(favoriteRequestDto.getTarget());
Favorite favorite = new Favorite(member, sourceStation, targetStation);
return favoriteRepository.save(favorite);
}

@Transactional(readOnly = true)
public List<Favorite> getList(String email) {
Member member = findMember(email);
return favoriteRepository.findAllByMember(member);
}

private Member findMember(String email) {
return memberService.findByEmail(email);
}

@Transactional
public void deleteById(String email, Long id) {
Member member = findMember(email);
if(existsByIdAndMember(id, member)){
favoriteRepository.deleteById(id);
}
}

private boolean existsByIdAndMember(Long id, Member member) {
return favoriteRepository.existsByIdAndMember(id, member);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exist가 아닌 findById를 통하여 favorite을 찾고, 찾는 favorite의 memberId가 같은지 틀린지 validation을 해주는 건 어떨까요?

}
}
66 changes: 66 additions & 0 deletions src/main/java/nextstep/favorite/domain/Favorite.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package nextstep.favorite.domain;

import nextstep.exception.SubwayIllegalArgumentException;
import nextstep.member.domain.Member;
import nextstep.subway.domain.Station;

import javax.persistence.*;

@Entity
public class Favorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "source_id")
private Station source;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "target_id")
private Station target;
Comment on lines +15 to +25

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

직접 참조가 아닌 간접 참조로 변경해볼까요?

변경해보고 차이점을 알려주세요!


public Favorite() {
}

public Favorite(Member member, Station source, Station target) {
validateStation(source, target);
validateEqualStartAndDestination(source, target);
this.member = member;
this.source = source;
this.target = target;
}

private void validateEqualStartAndDestination(Station source, Station target) {
if (source == target) {
throw new SubwayIllegalArgumentException("출발역과 도착역이 같을 수 없습니다.");
}
}

private void validateStation(Station source, Station target) {
if (source == null || target == null) {
throw new SubwayIllegalArgumentException("출발역과, 도착역 둘다 입력해줘야 합니다.");
}
}


public Long getId() {
return id;
}

public Member getMember() {
return member;
}

public Station getSource() {
return source;
}

public Station getTarget() {
return target;
}
}
12 changes: 12 additions & 0 deletions src/main/java/nextstep/favorite/domain/FavoriteRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package nextstep.favorite.domain;


import nextstep.member.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
List<Favorite> findAllByMember(Member member);
boolean existsByIdAndMember(Long id, Member member);
}
19 changes: 19 additions & 0 deletions src/main/java/nextstep/favorite/dto/FavoriteRequestDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package nextstep.favorite.dto;

public class FavoriteRequestDto {
private final Long source;
private final Long target;

public FavoriteRequestDto(Long source, Long target) {
this.source = source;
this.target = target;
}

public Long getSource() {
return source;
}

public Long getTarget() {
return target;
}
}
39 changes: 39 additions & 0 deletions src/main/java/nextstep/favorite/dto/FavoriteResponseDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package nextstep.favorite.dto;

import nextstep.favorite.domain.Favorite;
import nextstep.subway.applicaion.dto.StationResponse;

public class FavoriteResponseDto {
private Long id;
private StationResponse source;
private StationResponse target;

protected FavoriteResponseDto() {
}

public FavoriteResponseDto(Long id, StationResponse source, StationResponse target) {
this.id = id;
this.source = source;
this.target = target;
}

public static FavoriteResponseDto of(Favorite favorite) {
return new FavoriteResponseDto(
favorite.getId(),
StationResponse.of(favorite.getSource()),
StationResponse.of(favorite.getTarget())
);
}

public Long getId() {
return id;
}

public StationResponse getSource() {
return source;
}

public StationResponse getTarget() {
return target;
}
}
9 changes: 5 additions & 4 deletions src/main/java/nextstep/member/application/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import nextstep.member.domain.Member;
import nextstep.member.domain.MemberRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuthService {
Expand All @@ -31,12 +32,12 @@ public TokenResponse login(final TokenRequest tokenRequest) {
return new TokenResponse(jwtTokenProvider.createToken(member.getEmail(), member.getRoles()));
}

@Transactional
public TokenResponse loginByGithub(final LoginRequest loginRequest) {
String accessTokenFromGithub = githubClient.getAccessTokenFromGithub(loginRequest.getCode());
GithubProfileResponse githubProfileFromGithub = githubClient.getGithubProfileFromGithub(accessTokenFromGithub);
GithubProfileResponse profile = githubClient.callLoginApi(loginRequest);

Member member = memberRepository.findByEmail(githubProfileFromGithub.getEmail())
.orElse(memberRepository.save(new Member(githubProfileFromGithub.getEmail(), "password", 20)));
Member member = memberRepository.findByEmail(profile.getEmail())
.orElse(memberRepository.save(new Member(profile.getEmail(), "password", 20)));
return new TokenResponse(jwtTokenProvider.createToken(member.getEmail(), member.getRoles()));
}

Expand Down
10 changes: 8 additions & 2 deletions src/main/java/nextstep/member/application/GithubClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import nextstep.member.application.dto.GithubAccessTokenRequest;
import nextstep.member.application.dto.GithubAccessTokenResponse;
import nextstep.member.application.dto.GithubProfileResponse;
import nextstep.member.application.dto.LoginRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
Expand All @@ -24,6 +25,11 @@ public class GithubClient {
@Value("${github.url.profile}")
private String profileUrl;

public GithubProfileResponse callLoginApi(LoginRequest loginRequest) {
String accessTokenFromGithub = getAccessTokenFromGithub(loginRequest.getCode());
return getGithubProfileFromGithub(accessTokenFromGithub);
}

public String getAccessTokenFromGithub(String code) {
GithubAccessTokenRequest githubAccessTokenRequest = new GithubAccessTokenRequest(
code,
Expand All @@ -44,7 +50,7 @@ public String getAccessTokenFromGithub(String code) {
.getBody();

if (response == null) {
throw new RuntimeException("아무것도 없엉...");
throw new IllegalStateException("Github에서 토큰정보를 가져오는데 실패했습니다.");
}
return response.getAccessToken();
}
Expand All @@ -61,7 +67,7 @@ public GithubProfileResponse getGithubProfileFromGithub(String accessToken) {
.exchange(profileUrl, HttpMethod.GET, httpEntity, GithubProfileResponse.class)
.getBody();
} catch (HttpClientErrorException e) {
throw new RuntimeException();
throw new IllegalStateException("GitHub 에서 회원의 프로필을 가져오는데 실패했습니다.");
}
}
}
4 changes: 2 additions & 2 deletions src/main/java/nextstep/member/application/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ public MemberResponse findMemberByEmail(String email) {
return MemberResponse.of(findByEmail(email));
}

private Member findByEmail(String email) {
public Member findByEmail(String email) {
return memberRepository.findByEmail(email)
.orElseThrow(MemberNotFoundException::new);
}
}
}
Loading