Skip to content

Commit

Permalink
Merge pull request #252 from woowacourse-teams/feat/domain_update
Browse files Browse the repository at this point in the history
템플릿 CRUD에 태그 및 카테고리 추가, 카테고리 CRUD
  • Loading branch information
HoeSeong123 authored Aug 6, 2024
2 parents bd19df5 + 05cd1b4 commit 0dba693
Show file tree
Hide file tree
Showing 44 changed files with 1,404 additions and 114 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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;

@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
) {
Long createdCategoryId = categoryService.create(createCategoryRequest);
return ResponseEntity.created(URI.create("/categories/" + createdCategoryId))
.build();
}

@GetMapping
public ResponseEntity<FindAllCategoriesResponse> getCategories() {
return ResponseEntity.ok(categoryService.findAll());
}

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

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCategory(@PathVariable Long id) {
categoryService.deleteById(id);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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 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);

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

@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);

@Operation(summary = "카테고리 삭제", description = "해당하는 식별자의 카테고리를 삭제합니다.")
@ApiResponse(responseCode = "204", description = "카테고리 삭제 성공")
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = {
@ErrorCase(description = "삭제하려는 카테고리에 템플릿이 존재하는 경우",
exampleMessage = "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."),
})
ResponseEntity<Void> deleteCategory(Long id);
}
32 changes: 32 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,32 @@
package codezap.category.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

import codezap.global.auditing.BaseTimeEntity;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Category extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, unique = true)
private String name;

public Category(String name) {
this.name = name;
}

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,17 @@
package codezap.category.repository;

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

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

public interface CategoryRepository extends JpaRepository<Category, Long> {

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

boolean existsByName(String name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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.template.repository.TemplateRepository;

@Service
public class CategoryService {

private static final long DEFAULT_CATEGORY = 1L;
private final CategoryRepository categoryRepository;
private final TemplateRepository templateRepository;

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

@Transactional
public Long create(CreateCategoryRequest createCategoryRequest) {
String categoryName = createCategoryRequest.name();
validateDuplicatedCategory(categoryName);
Category category = new Category(categoryName);
return categoryRepository.save(category).getId();
}

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

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

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

public void deleteById(Long id) {
assertNoTemplates(id);
assertDefaultCategory(id);
categoryRepository.deleteById(id);
}

private void assertNoTemplates(Long id) {
if (templateRepository.existsByCategoryId(id)) {
throw new CodeZapException(HttpStatus.BAD_REQUEST, "템플릿이 존재하는 카테고리는 삭제할 수 없습니다.");
}
}

private static void assertDefaultCategory(Long id) {
if (id == DEFAULT_CATEGORY) {
throw new CodeZapException(HttpStatus.BAD_REQUEST, "기본 카테고리는 삭제할 수 없습니다.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class WebCorsConfiguration implements WebMvcConfigurer {
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("*");
.allowedMethods("*")
.exposedHeaders("*");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package codezap.global.validation;

public class ValidationGroups {
public interface NotNullGroup {}

public interface SnippetOrdinalGroup {}

public interface SnippetCountGroup {}

public interface SizeCheckGroup {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package codezap.global.validation;

import jakarta.validation.GroupSequence;

import codezap.global.validation.ValidationGroups.NotNullGroup;
import codezap.global.validation.ValidationGroups.SizeCheckGroup;
import codezap.global.validation.ValidationGroups.SnippetCountGroup;
import codezap.global.validation.ValidationGroups.SnippetOrdinalGroup;

@GroupSequence({
NotNullGroup.class,
SizeCheckGroup.class,
SnippetCountGroup.class,
SnippetOrdinalGroup.class})
public interface ValidationSequence {
}
3 changes: 2 additions & 1 deletion backend/src/main/java/codezap/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

@Id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import codezap.template.dto.request.CreateTemplateRequest;
import codezap.template.dto.request.UpdateTemplateRequest;
import codezap.template.dto.response.FindAllTemplatesResponse;
import codezap.template.dto.response.FindTemplateByIdResponse;
import codezap.template.dto.response.FindTemplateResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -21,7 +21,7 @@ public interface SpringDocTemplateController {

@Operation(summary = "템플릿 생성", description = """
새로운 템플릿을 생성합니다. \n
템플릿의 제목, 썸네일 스니펫의 순서, 스니펫 목록이 필요합니다. \n
템플릿의 제목, 썸네일 스니펫의 순서, 스니펫 목록, 카테고리 ID, 태그 목록이 필요합니다. \n
스니펫 목록은 파일 이름, 소스 코드, 해당 스니펫의 순서가 필요합니다. \n
* 썸네일 스니펫은 1로 고정입니다. (2024.07.15 기준) \n
* 모든 스니펫 순서는 1부터 시작합니다. \n
Expand All @@ -31,7 +31,7 @@ public interface SpringDocTemplateController {
@Header(name = "생성된 템플릿의 API 경로", example = "/templates/1")})
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates", errorCases = {
@ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "템플릿 이름 null 입니다."),
@ErrorCase(description = "제목 또는 스니펫 파일명이 255자를 초과한 경우", exampleMessage = "제목은 최대 255자까지 입력 가능합니다."),
@ErrorCase(description = "제목 또는 스니펫 파일 또는 태그 이름이 255자를 초과한 경우", exampleMessage = "제목은 최대 255자까지 입력 가능합니다."),
@ErrorCase(description = "썸네일 스니펫의 순서가 1이 아닌 경우", exampleMessage = "썸네일 스니펫의 순서가 잘못되었습니다."),
@ErrorCase(description = "스니펫 순서가 잘못된 경우", exampleMessage = "스니펫 순서가 잘못되었습니다."),
@ErrorCase(description = "스니펫 내용 65,535 byte를 초과한 경우", exampleMessage = "파일 내용은 최대 65,535 byte까지 입력 가능합니다.")
Expand All @@ -49,7 +49,7 @@ public interface SpringDocTemplateController {
@ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/templates/1", errorCases = {
@ErrorCase(description = "해당하는 id 값인 템플릿이 없는 경우", exampleMessage = "식별자 1에 해당하는 템플릿이 존재하지 않습니다."),
})
ResponseEntity<FindTemplateByIdResponse> getTemplateById(Long id);
ResponseEntity<FindTemplateResponse> getTemplateById(Long id);

@Operation(summary = "템플릿 수정", description = "해당하는 식별자의 템플릿을 수정합니다.")
@ApiResponse(responseCode = "200", description = "템플릿 수정 성공")
Expand Down
Loading

0 comments on commit 0dba693

Please sign in to comment.