코드 그라데이션
글에 글쓴이 추가하고, OAuth 뷰 구현, 테스트 코드 본문
OAuth 로직은 모두 완성되었고, 글에 글쓴이 추가하는 작업을 진행
01. Article.java에 author 변수 추가
Article.java
@Column(name = "author", nullable = false)
private String author;
@Builder // 빌더 패턴 객체 생성
public Article(String author, String title, String content) {
this.author = author;
this.title = title;
this.content = content;
}
02. DTO에 toEntity() 메서드 추가하고 author 값 추가 저장
AddArticleRequest.java
public Article toEntity(String author) {
return Article.builder()
.title(title)
.content(content)
.author(author) // 추가
.build();
}
03. BlogService.java의 save() 메서드 수정
BlogService.java
// AddArticleRequest를 사용하여 새 게시물을 생성하고,
// 지정된 사용자 이름으로 게시물을 저장
public Article save(AddArticleRequest request, String username) {
return blogRepository.save(request.toEntity(username));
}
04. BlogApiController에 현재 인증 정보를 가져오는 principal 객체를 파라미터로 추가
BlogApiController.java
@RequiredArgsConstructor
@RestController // HTTP Response Body에 객체 데이터를 JSON 형식으로 반환하는 컨트롤러
public class BlogApiController {
private final BlogService blogService;
@PostMapping("/api/articles")
//요청 본문 값 매핑
// HTTP POST 요청을 처리하여 블로그 글을 추가.
public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request, Principal principal) {
Article savedArticle = blogService.save(request, principal.getName());
// 요청한 자원이 성공적으로 생성되었으며 저장된 블로그 글 정보를 응답 객체에 담아 전송
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
}
05. 글 상세 페이지에서도 글쓴이의 정보가 보여야 하므로 ArticleViewResponse에 author 필드 추가
ArticleViewResponse.java
@NoArgsConstructor
@Getter
public class ArticleViewResponse {
private Long id;
private String title;
private String content;
private LocalDateTime createdAt;
private String author; // 추가
public ArticleViewResponse(Article article) {
this.id = article.getId();
this.title = article.getTitle();
this.content = article.getContent();
this.createdAt = article.getCreatedAt();
this.author = article.getAuthor(); // 추가
}
}
06. data.sql 수정
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목1', '내용1', 'user1', NOW(), NOW());
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목2', '내용2', 'user2', NOW(), NOW());
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목3', '내용3', 'user3', NOW(), NOW());
07. article.html 에 글쓴이 정보 가져오도록 수정
article.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<header class="mb-4">
<h1 class="fw-bolder mb-1" th:text="${article.title}"></h1> <!-- 블로그 글의 제목을 출력 -->
<!-- 글쓴이 정보를 알 수 있도록 록 코드 수정 -->
<div class="text-muted fst-italic mb-2" th:text="|Posted on ${#temporals.format(article.createdAt, 'yyyy-MM-dd HH:mm')} By ${article.author}|"></div>
</header>
</body>
OAuth 뷰 구현하기
01. UserViewController에 login() 메서드 리턴 수정
@Controller
public class UserViewController {
@GetMapping("/login")
public String login() {
return "oauthLogin"; // 요기 수정
}
@GetMapping("/signup")
public String signup() {
return "signup";
}
}
02. 버튼 이미지 다운 받아서 oauthLogin.html 구현
oauthLogin.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">
<style>
.gradient-custom {
background: #6a11cb;
background: -webkit-linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1));
background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
}
</style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
<div class="container-fluid row justify-content-center align-content-center">
<div class="card bg-dark" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h2 class="text-white">LOGIN</h2>
<p class="text-white-50 mt-2 mb-5">서비스 사용을 위해 로그인을 해주세요!</p>
<div class = "mb-2">
<a href="/oauth2/authorization/google">
<img src="/img/google.png">
</a>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
03. token.js 생성
const token = searchParam('token')
if (token) {
localStorage.setItem("access_token", token)
}
function searchParam(key) {
return new URLSearchParams(location.search).get(key);
}
04. articleList.html 에 js 코드 추가
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글 목록</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>
<div class="container">
<button class="btn btn-danger btn-sm mb-3" id="create-btn"
th:onclick="|location.href='@{/new-article}'|"
type="button">글 등록
</button>
<div class="row-6" th:each="item : ${articles}"> <!--article 개수만큼 반복 -->
<div class="card">
<div class="card-header" th:text="${item.id}"> <!-- item의 id 출력 -->
</div>
<div class="card-body">
<h5 class="card-title" th:text="${item.title}"></h5>
<p class="card-text" th:text="${item.content}"></p>
<a class="btn btn-warning" th:href="@{/articles/{id}(id=${item.id})}">상세내용</a>
</div>
</div>
<br>
</div>
<button class="btn btn-secondary" onclick="location.href='/logout'" type="button">로그아웃</button>
</div>
<script src="/js/token.js"></script> <!--여기 -->
<script src="/js/article.js"></script>
</body>
05. article.js에 토큰 기반 코드 수정
const createButton = document.getElementById('create-btn'); // 생성 버튼을 가져옵니다.
if (createButton) { // 생성 버튼이 존재하는 경우에만 이벤트 리스너를 추가합니다.
createButton.addEventListener('click', event => { // 생성 버튼이 클릭되었을 때 실행할 함수를 등록합니다.
// 입력된 제목과 내용을 JSON 형식으로 생성하여 body 변수에 저장합니다.
let body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
});
function success() { // 성공했을 때 실행할 함수를 정의합니다.
alert('등록 완료되었습니다.'); // 등록 완료 메시지를 표시합니다.
location.replace('/articles'); // 게시물 목록 페이지로 이동합니다.
};
function fail() { // 실패했을 때 실행할 함수를 정의합니다.
alert('등록 실패했습니다.'); // 등록 실패 메시지를 표시합니다.
location.replace('/articles'); // 게시물 목록 페이지로 이동합니다.
};
// httpRequest 함수를 호출하여 HTTP POST 요청을 보냅니다.
// 요청 URL은 '/api/articles'로 설정되며, 성공 또는 실패에 따라 success 또는 fail 함수가 호출됩니다.
httpRequest('POST', '/api/articles', body, success, fail);
});
}
// 쿠키를 가져오는 함수
function getCookie(key) {
var result = null;
var cookie = document.cookie.split(';'); // 쿠키 문자열을 세미콜론을 기준으로 나눕니다.
cookie.some(function (item) { // 배열에서 조건을 만족하는 요소를 찾기 위해 some 함수를 사용합니다.
item = item.replace(' ', ''); // 공백을 제거합니다.
var dic = item.split('='); // '='을 기준으로 쿠키 이름과 값을 나눕니다.
if (key === dic[0]) { // 주어진 키와 일치하는 쿠키를 찾으면 해당 값을 result 변수에 저장하고 반복을 중단합니다.
result = dic[1];
return true;
}
});
return result; // 찾은 쿠키의 값을 반환합니다.
}
// HTTP 요청을 보내는 함수
function httpRequest(method, url, body, success, fail) {
fetch(url, { // fetch를 사용하여 HTTP 요청을 보냅니다.
method: method, // HTTP 메서드를 지정합니다. (GET, POST, PUT, DELETE 등)
headers: { // 요청 헤더를 설정합니다.
Authorization: 'Bearer ' + localStorage.getItem('access_token'), // 액세스 토큰을 헤더에 추가합니다.
'Content-Type': 'application/json', // 요청 본문의 데이터 형식을 JSON으로 지정합니다.
},
body: body, // 요청 본문을 설정합니다.
}).then(response => { // 서버 응답을 처리합니다.
if (response.status === 200 || response.status === 201) { // 성공적인 응답인 경우
return success(); // success 함수를 호출합니다.
}
const refresh_token = getCookie('refresh_token'); // 쿠키에서 refresh_token을 가져옵니다.
if (response.status === 401 && refresh_token) { // 인증 오류(401)가 발생하고 refresh_token이 있는 경우
fetch('/api/token', { // 새로운 액세스 토큰을 요청하기 위해 '/api/token'으로 요청을 보냅니다.
method: 'POST', // POST 요청을 보냅니다.
headers: {
Authorization: 'Bearer ' + localStorage.getItem('access_token'), // 액세스 토큰을 헤더에 추가합니다.
'Content-Type': 'application/json', // 요청 본문의 데이터 형식을 JSON으로 지정합니다.
},
body: JSON.stringify({
refreshToken: getCookie('refresh_token'), // refresh_token을 요청 본문에 추가합니다.
}),
})
.then(res => {
if (res.ok) {
return res.json();
}
})
.then(result => { // 액세스 토큰 재발급이 성공하면
localStorage.setItem('access_token', result.accessToken); // 새로운 액세스 토큰으로 교체합니다.
httpRequest(method, url, body, success, fail); // 이전 요청을 다시 보냅니다.
})
.catch(error => fail()); // 재발급 실패 시 fail 함수를 호출합니다.
} else {
return fail(); // 다른 오류 상태 코드인 경우 fail 함수를 호출합니다.
}
});
}
글 수정, 삭제, 글쓴이 확인 로직 추가하기
BlogService.java
@RequiredArgsConstructor // final이 붙거나 @NotNull이 붙은 필드의 생성자 추가
@Service
public class BlogService {
private final BlogRepository blogRepository;
// 이거
// 게시글을 작성한 유저인지 확인
private static void authorizeArticleAuthor(Article article) {
// 현재 사용자 이름을 가져와 게시물의 작성자와 비교하고, 다른 경우에 예외 던짐
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
if (!article.getAuthor().equals(userName)) {
throw new IllegalArgumentException("not authorized");
}
}
// 이거
// 주어진 'id'로 게시물을 조회하고, 게시물이 없으면 예외 던짐
public void delete(long id) {
Article article = blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
// 게시물의 작성자를 확인하고, 작성자와 현재 사용자가 다르면 예외 던짐
authorizeArticleAuthor(article);
// 게시물 삭제
blogRepository.delete(article);
}
// 이거
@Transactional
public Article update(Long id, UpdateArticleRequest request) {
// 주어진 'id'로 게시물을 조회하고, 게시물이 없으면 예외 던짐
Article article = blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found: " + id));
// 게시물의 작성자를 확인하고, 작성자와 현재 사용자가 다르면 예외 던짐
authorizeArticleAuthor(article);
// 게시물의 내용을 업데이트하고 업데이트된 게시물을 반환
article.update(request.getTitle(), request.getContent());
return article;
}
}
테스트 코드 전체적 수정
BlogApiControllerTest.java
@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
@Autowired
BlogRepository blogRepository;
@Autowired
UserRepository userRepository;
User user;
@BeforeEach
public void mockMvcSetUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.build();
blogRepository.deleteAll();
}
@BeforeEach
void setSecurityContext() {
userRepository.deleteAll();
user = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication(new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()));
}
@DisplayName("addArticle: 아티클 추가에 성공한다.")
@Test
public void addArticle() throws Exception {
// given
final String url = "/api/articles";
final String title = "title";
final String content = "content";
final AddArticleRequest userRequest = new AddArticleRequest(title, content);
final String requestBody = objectMapper.writeValueAsString(userRequest);
Principal principal = Mockito.mock(Principal.class);
Mockito.when(principal.getName()).thenReturn("username");
// when
ResultActions result = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.principal(principal)
.content(requestBody));
// then
result.andExpect(status().isCreated());
List<Article> articles = blogRepository.findAll();
assertThat(articles.size()).isEqualTo(1);
assertThat(articles.get(0).getTitle()).isEqualTo(title);
assertThat(articles.get(0).getContent()).isEqualTo(content);
}
@DisplayName("findAllArticles: 아티클 목록 조회에 성공한다.")
@Test
public void findAllArticles() throws Exception {
// given
final String url = "/api/articles";
Article savedArticle = createDefaultArticle();
// when
final ResultActions resultActions = mockMvc.perform(get(url)
.accept(MediaType.APPLICATION_JSON));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].content").value(savedArticle.getContent()))
.andExpect(jsonPath("$[0].title").value(savedArticle.getTitle()));
}
@DisplayName("findArticle: 아티클 단건 조회에 성공한다.")
@Test
public void findArticle() throws Exception {
// given
final String url = "/api/articles/{id}";
Article savedArticle = createDefaultArticle();
// when
final ResultActions resultActions = mockMvc.perform(get(url, savedArticle.getId()));
// then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").value(savedArticle.getContent()))
.andExpect(jsonPath("$.title").value(savedArticle.getTitle()));
}
@DisplayName("deleteArticle: 아티클 삭제에 성공한다.")
@Test
public void deleteArticle() throws Exception {
// given
final String url = "/api/articles/{id}";
Article savedArticle = createDefaultArticle();
// when
mockMvc.perform(delete(url, savedArticle.getId()))
.andExpect(status().isOk());
// then
List<Article> articles = blogRepository.findAll();
assertThat(articles).isEmpty();
}
@DisplayName("updateArticle: 아티클 수정에 성공한다.")
@Test
public void updateArticle() throws Exception {
// given
final String url = "/api/articles/{id}";
Article savedArticle = createDefaultArticle();
final String newTitle = "new title";
final String newContent = "new content";
UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent);
// when
ResultActions result = mockMvc.perform(put(url, savedArticle.getId())
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(request)));
// then
result.andExpect(status().isOk());
Article article = blogRepository.findById(savedArticle.getId()).get();
assertThat(article.getTitle()).isEqualTo(newTitle);
assertThat(article.getContent()).isEqualTo(newContent);
}
private Article createDefaultArticle() {
return blogRepository.save(Article.builder()
.title("title")
.author(user.getUsername())
.content("content")
.build());
}
}
728x90
'SpringBoot [예제] 블로그 만들기 > OAuth' 카테고리의 다른 글
[사전 지식] OAuth (0) | 2023.10.20 |
---|
Comments