코드 그라데이션

글에 글쓴이 추가하고, OAuth 뷰 구현, 테스트 코드 본문

SpringBoot [예제] 블로그 만들기/OAuth

글에 글쓴이 추가하고, OAuth 뷰 구현, 테스트 코드

완벽한 장면 2023. 10. 22. 00:24

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