Skip to content

Commit

Permalink
Merge pull request #304 from woowacourse-teams/dev/be
Browse files Browse the repository at this point in the history
[BE] 3차 데모데이 - 인증인가, 카테고리, 태그, 검색, 로깅
  • Loading branch information
kyum-q authored Aug 6, 2024
2 parents 5cac478 + c29c24f commit 9cc2ad7
Show file tree
Hide file tree
Showing 80 changed files with 3,567 additions and 214 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package codezap.category.controller;

import java.net.URI;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import codezap.category.dto.request.CreateCategoryRequest;
import codezap.category.dto.request.UpdateCategoryRequest;
import codezap.category.dto.response.FindAllCategoriesResponse;
import codezap.category.service.CategoryService;
import codezap.global.validation.ValidationSequence;
import codezap.member.configuration.BasicAuthentication;
import codezap.member.dto.MemberDto;

@RestController
@RequestMapping("/categories")
public class CategoryController implements SpringDocCategoryController {

private final CategoryService categoryService;

public CategoryController(CategoryService categoryService) {
this.categoryService = categoryService;
}

@PostMapping
public ResponseEntity<Void> createCategory(
@Validated(ValidationSequence.class) @RequestBody CreateCategoryRequest createCategoryRequest,
@BasicAuthentication MemberDto memberDto
) {
Long createdCategoryId = categoryService.create(createCategoryRequest, memberDto);
return ResponseEntity.created(URI.create("/categories/" + createdCategoryId))
.build();
}

@GetMapping
public ResponseEntity<FindAllCategoriesResponse> getCategories(@BasicAuthentication MemberDto memberDto) {
return ResponseEntity.ok(categoryService.findAllByMember(memberDto));
}

@PutMapping("/{id}")
public ResponseEntity<Void> updateCategory(
@PathVariable Long id,
@Validated(ValidationSequence.class) @RequestBody UpdateCategoryRequest updateCategoryRequest,
@BasicAuthentication MemberDto memberDto
) {
categoryService.update(id, updateCategoryRequest, memberDto);
return ResponseEntity.ok().build();
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCategory(@PathVariable Long id, @BasicAuthentication MemberDto memberDto) {
categoryService.deleteById(id, memberDto);
return ResponseEntity.noContent()
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package codezap.category.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import codezap.category.dto.request.CreateCategoryRequest;
import codezap.category.dto.request.UpdateCategoryRequest;
import codezap.category.dto.response.FindAllCategoriesResponse;
import codezap.global.swagger.error.ApiErrorResponse;
import codezap.global.swagger.error.ErrorCase;
import codezap.member.dto.MemberDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "카테고리 CRUD API", description = "카테고리 생성, 목록 조회, 삭제, 수정 API")
public interface SpringDocCategoryController {

@Operation(summary = "카테고리 생성", description = """
새로운 카테고리를 생성합니다. \n
새로운 카테고리의 이름이 필요합니다. \n
""")
@ApiResponse(responseCode = "201", description = "카테고리 생성 성공", headers = {
@Header(name = "생성된 카테고리의 API 경로", example = "/categories/1")})
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories", errorCases = {
@ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "카테고리 이름이 null 입니다."),
@ErrorCase(description = "카테고리 이름이 255자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 255자까지 입력 가능합니다."),
@ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다.")
})
ResponseEntity<Void> createCategory(CreateCategoryRequest createCategoryRequest, MemberDto memberDto);

@Operation(summary = "카테고리 목록 조회", description = "생성된 모든 카테고리를 조회합니다.")
@ApiResponse(responseCode = "200", description = "조회 성공",
content = {@Content(schema = @Schema(implementation = FindAllCategoriesResponse.class))})
ResponseEntity<FindAllCategoriesResponse> getCategories(MemberDto memberDto);

@Operation(summary = "카테고리 수정", description = "해당하는 식별자의 카테고리를 수정합니다.")
@ApiResponse(responseCode = "200", description = "카테고리 수정 성공")
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = {
@ErrorCase(description = "해당하는 id 값인 카테고리가 없는 경우",
exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."),
@ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우",
exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다.")
})
ResponseEntity<Void> updateCategory(Long id, UpdateCategoryRequest updateCategoryRequest, MemberDto memberDto);

@Operation(summary = "카테고리 삭제", description = "해당하는 식별자의 카테고리를 삭제합니다.")
@ApiResponse(responseCode = "204", description = "카테고리 삭제 성공")
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = {
@ErrorCase(description = "삭제하려는 카테고리에 템플릿이 존재하는 경우",
exampleMessage = "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."),
})
ResponseEntity<Void> deleteCategory(Long id, MemberDto memberDto);
}
62 changes: 62 additions & 0 deletions backend/src/main/java/codezap/category/domain/Category.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package codezap.category.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

import codezap.global.auditing.BaseTimeEntity;
import codezap.member.domain.Member;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Table(
uniqueConstraints={
@UniqueConstraint(
name="name_with_member",
columnNames={"member_id", "name"}
)
}
)
public class Category extends BaseTimeEntity {

private static final String DEFAULT_CATEGORY_NAME = "카테고리 없음";

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Member member;

@Column(nullable = false)
private String name;

@Column(nullable = false)
private Boolean isDefault;

public Category(String name, Member member) {
this.name = name;
this.member = member;
this.isDefault = false;
}

public static Category createDefaultCategory(Member member) {
return new Category(null, member, DEFAULT_CATEGORY_NAME, true);
}

public void updateName(String name) {
this.name = name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package codezap.category.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import codezap.global.validation.ValidationGroups.NotNullGroup;
import codezap.global.validation.ValidationGroups.SizeCheckGroup;
import io.swagger.v3.oas.annotations.media.Schema;

public record CreateCategoryRequest(
@Schema(description = "카테고리 이름", example = "Spring")
@NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class)
@Size(max = 255, message = "카테고리 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class)
String name
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package codezap.category.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import codezap.global.validation.ValidationGroups.NotNullGroup;
import codezap.global.validation.ValidationGroups.SizeCheckGroup;
import io.swagger.v3.oas.annotations.media.Schema;

public record UpdateCategoryRequest(
@Schema(description = "카테고리 이름", example = "Spring")
@NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class)
@Size(max = 255, message = "카테고리 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class)
String name
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package codezap.category.dto.response;

import java.util.List;

import codezap.category.domain.Category;
import io.swagger.v3.oas.annotations.media.Schema;

public record FindAllCategoriesResponse(
@Schema(description = "카테고리 목록")
List<FindCategoryResponse> categories
) {
public static FindAllCategoriesResponse from(List<Category> categories) {
return new FindAllCategoriesResponse(
categories.stream()
.map(FindCategoryResponse::from)
.toList()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package codezap.category.dto.response;

import codezap.category.domain.Category;
import io.swagger.v3.oas.annotations.media.Schema;

public record FindCategoryResponse(
@Schema(description = "카테고리 식별자", example = "1")
Long id,
@Schema(description = "카테고리 이름", example = "Spring")
String name
) {
public static FindCategoryResponse from(Category category) {
return new FindCategoryResponse(category.getId(), category.getName());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package codezap.category.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.HttpStatus;

import codezap.category.domain.Category;
import codezap.global.exception.CodeZapException;
import codezap.member.domain.Member;

@SuppressWarnings("unused")
public interface CategoryJpaRepository extends CategoryRepository, JpaRepository<Category, Long> {

default Category fetchById(Long id) {
return findById(id).orElseThrow(
() -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 카테고리가 존재하지 않습니다."));
}

List<Category> findAllByMember(Member member);

boolean existsByNameAndMember(String categoryName, Member member);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package codezap.category.repository;

import java.util.List;

import codezap.category.domain.Category;
import codezap.member.domain.Member;

public interface CategoryRepository {

Category fetchById(Long id);

List<Category> findAllByMember(Member member);

List<Category> findAll();

boolean existsByNameAndMember(String categoryName, Member member);

Category save(Category category);

void deleteById(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package codezap.category.service;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import codezap.category.domain.Category;
import codezap.category.dto.request.CreateCategoryRequest;
import codezap.category.dto.request.UpdateCategoryRequest;
import codezap.category.dto.response.FindAllCategoriesResponse;
import codezap.category.repository.CategoryRepository;
import codezap.global.exception.CodeZapException;
import codezap.member.domain.Member;
import codezap.member.dto.MemberDto;
import codezap.member.repository.MemberJpaRepository;
import codezap.member.repository.MemberRepository;
import codezap.template.repository.TemplateRepository;

@Service
public class CategoryService {

private final CategoryRepository categoryRepository;
private final TemplateRepository templateRepository;
private final MemberRepository memberJpaRepository;

public CategoryService(CategoryRepository categoryRepository, TemplateRepository templateRepository,
MemberJpaRepository memberJpaRepository
) {
this.categoryRepository = categoryRepository;
this.templateRepository = templateRepository;
this.memberJpaRepository = memberJpaRepository;
}

@Transactional
public Long create(CreateCategoryRequest createCategoryRequest, MemberDto memberDto) {
String categoryName = createCategoryRequest.name();
Member member = memberJpaRepository.fetchById(memberDto.id());
validateDuplicatedCategory(categoryName, member);
Category category = new Category(categoryName, member);
return categoryRepository.save(category).getId();
}

public FindAllCategoriesResponse findAllByMember(MemberDto memberDto) {
Member member = memberJpaRepository.fetchById(memberDto.id());
return FindAllCategoriesResponse.from(categoryRepository.findAllByMember(member));
}

public FindAllCategoriesResponse findAll() {
return FindAllCategoriesResponse.from(categoryRepository.findAll());
}

@Transactional
public void update(Long id, UpdateCategoryRequest updateCategoryRequest, MemberDto memberDto) {
Member member = memberJpaRepository.fetchById(memberDto.id());
validateDuplicatedCategory(updateCategoryRequest.name(), member);
Category category = categoryRepository.fetchById(id);
validateAuthorizeMember(category, member);
category.updateName(updateCategoryRequest.name());
}

private void validateDuplicatedCategory(String categoryName, Member member) {
if (categoryRepository.existsByNameAndMember(categoryName, member)) {
throw new CodeZapException(HttpStatus.CONFLICT, "이름이 " + categoryName + "인 카테고리가 이미 존재합니다.");
}
}

public void deleteById(Long id, MemberDto memberDto) {
Member member = memberJpaRepository.fetchById(memberDto.id());
Category category = categoryRepository.fetchById(id);
validateAuthorizeMember(category, member);

if (templateRepository.existsByCategoryId(id)) {
throw new CodeZapException(HttpStatus.BAD_REQUEST, "템플릿이 존재하는 카테고리는 삭제할 수 없습니다.");
}
if (category.getIsDefault()) {
throw new CodeZapException(HttpStatus.BAD_REQUEST, "기본 카테고리는 삭제할 수 없습니다.");
}
categoryRepository.deleteById(id);
}

private void validateAuthorizeMember(Category category, Member member) {
if (!category.getMember().equals(member)) {
throw new CodeZapException(HttpStatus.UNAUTHORIZED, "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다.");
}
}
}
Loading

0 comments on commit 9cc2ad7

Please sign in to comment.