diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml
index aaef3a5..741cec5 100644
--- a/.github/workflows/test-report.yml
+++ b/.github/workflows/test-report.yml
@@ -16,6 +16,12 @@ jobs:
test:
runs-on: ubuntu-latest
+ services:
+ redis:
+ image: redis:latest
+ ports:
+ - 6380:6379
+
steps:
- name: Check out
uses: actions/checkout@v3
@@ -26,6 +32,16 @@ jobs:
distribution: 'temurin'
java-version: '17'
+ - name: Copy application.yml
+ run: |
+ mkdir ./src/main/resources
+ touch ./src/main/resources/application.yml
+ echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application.yml
+ touch ./src/main/resources/application-test.yml
+ echo "${{ secrets.APPLICATION_TEST }}" > ./src/main/resources/application-test.yml
+ touch ./src/main/resources/application-release.yml
+ echo "${{ secrets.APPLICATION_RELEASE }}" > ./src/main/resources/application-release.yml
+
- name: Cache Gradle packages
uses: actions/cache@v3
with:
@@ -55,5 +71,5 @@ jobs:
title: Test Coverage Report
paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml
token: ${{ secrets.ACCESS_TOKEN }}
- min-coverage-overall: 80
- min-coverage-changed-files: 50
+ min-coverage-overall: 70
+ min-coverage-changed-files: 40
diff --git a/build.gradle b/build.gradle
index 279ab39..0876b4b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,38 +1,86 @@
plugins {
- id 'java'
- id 'org.springframework.boot' version '3.2.3'
- id 'io.spring.dependency-management' version '1.1.4'
+ id 'java'
+ id 'org.springframework.boot' version '3.2.3'
+ id 'io.spring.dependency-management' version '1.1.4'
+ id 'org.asciidoctor.jvm.convert' version '3.3.2'
+ id 'jacoco'
}
group = 'site'
version = '0.0.1-SNAPSHOT'
java {
- sourceCompatibility = '17'
+ sourceCompatibility = '17'
}
configurations {
- compileOnly {
- extendsFrom annotationProcessor
- }
+ compileOnly {
+ extendsFrom annotationProcessor
+ }
+
+ asciidoctorExt
}
repositories {
- mavenCentral()
+ mavenCentral()
}
dependencies {
- implementation 'org.springframework.boot:spring-boot-starter-validation'
- implementation 'org.springframework.boot:spring-boot-starter-web'
- compileOnly 'org.projectlombok:lombok'
- annotationProcessor 'org.projectlombok:lombok'
- testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+ asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
+ testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
- useJUnitPlatform()
+ useJUnitPlatform()
+
+ finalizedBy jacocoTestReport
+}
+
+ext {
+ snippetsDir = file('build/generated-snippets')
+}
+
+asciidoctor {
+ inputs.dir snippetsDir
+ configurations 'asciidoctorExt'
+
+ sources {
+ include("**/index.adoc")
+ }
+
+ baseDirFollowsSourceFile()
+ dependsOn test
+}
+
+bootJar {
+ dependsOn asciidoctor
+
+ from("${asciidoctor.outputDir}") {
+ into 'static/docs'
+ }
+}
+
+jacoco {
+ toolVersion = "0.8.8"
+}
+
+jacocoTestReport {
+ dependsOn test
+
+ reports {
+ xml.required = true
+ html.required = true
+ }
+
+ finalizedBy 'jacocoTestCoverageVerification'
}
jar {
- enabled = false
+ enabled = false
}
diff --git a/src/docs/asciidoc/api/room.adoc b/src/docs/asciidoc/api/room.adoc
new file mode 100644
index 0000000..9248ebd
--- /dev/null
+++ b/src/docs/asciidoc/api/room.adoc
@@ -0,0 +1,45 @@
+[[Room-API]]
+== Room API
+
+[[create-room-success]]
+=== 방 생성 성공
+
+==== HTTP Request
+
+include::{snippets}/create-room-success/http-request.adoc[]
+include::{snippets}/create-room-success/request-fields.adoc[]
+
+==== HTTP Response
+
+include::{snippets}/create-room-success/http-response.adoc[]
+include::{snippets}/create-room-success/response-fields.adoc[]
+
+{nbsp}
+
+[[create-room-fail-room-setting-error]]
+=== 방 생성 실패: 요청 데이터 오류가 발생했습니다
+
+==== HTTP Request
+
+include::{snippets}/create-room-fail-room-setting-error/http-request.adoc[]
+include::{snippets}/create-room-fail-room-setting-error/request-fields.adoc[]
+
+==== HTTP Response
+
+include::{snippets}/create-room-fail-room-setting-error/http-response.adoc[]
+include::{snippets}/create-room-fail-room-setting-error/response-fields.adoc[]
+
+{nbsp}
+
+[[create-room-fail-single-room-participant-violation]]
+=== 방 생성 실패: 다수의 방에 참가할 수 없습니다
+
+==== HTTP Request
+
+include::{snippets}/create-room-fail-single-room-participant-violation/http-request.adoc[]
+include::{snippets}/create-room-fail-single-room-participant-violation/request-fields.adoc[]
+
+==== HTTP Response
+
+include::{snippets}/create-room-fail-single-room-participant-violation/http-response.adoc[]
+include::{snippets}/create-room-fail-single-room-participant-violation/response-fields.adoc[]
diff --git a/src/docs/asciidoc/docinfo.html b/src/docs/asciidoc/docinfo.html
new file mode 100644
index 0000000..f979079
--- /dev/null
+++ b/src/docs/asciidoc/docinfo.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc
new file mode 100644
index 0000000..93638dc
--- /dev/null
+++ b/src/docs/asciidoc/index.adoc
@@ -0,0 +1,14 @@
+ifndef::snippets[]
+:snippets: ../../build/generated-snippets
+endif::[]
+
+= YouTogether API Document
+:doctype: book
+:icons: font
+:source-highlighter: highlightjs
+:toc: left
+:toclevels: 2
+:sectlinks:
+:docinfo: shared
+
+include::api/room.adoc[]
diff --git a/src/main/java/site/youtogether/config/PropertiesConfig.java b/src/main/java/site/youtogether/config/PropertiesConfig.java
new file mode 100644
index 0000000..70a1468
--- /dev/null
+++ b/src/main/java/site/youtogether/config/PropertiesConfig.java
@@ -0,0 +1,14 @@
+package site.youtogether.config;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import site.youtogether.config.property.CookieProperties;
+
+@Configuration
+@EnableConfigurationProperties(value = {
+ CookieProperties.class
+})
+public class PropertiesConfig {
+
+}
diff --git a/src/main/java/site/youtogether/config/RedisConfig.java b/src/main/java/site/youtogether/config/RedisConfig.java
new file mode 100644
index 0000000..b731bb3
--- /dev/null
+++ b/src/main/java/site/youtogether/config/RedisConfig.java
@@ -0,0 +1,38 @@
+package site.youtogether.config;
+
+import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import lombok.RequiredArgsConstructor;
+
+@Configuration
+@RequiredArgsConstructor
+public class RedisConfig {
+
+ private final RedisProperties redisProperties;
+
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory() {
+ RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort());
+ redisStandaloneConfiguration.setPassword(redisProperties.getPassword());
+
+ return new LettuceConnectionFactory(redisStandaloneConfiguration);
+ }
+
+ @Bean
+ public RedisTemplate redisStringTemplate() {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setConnectionFactory(redisConnectionFactory());
+ redisTemplate.setKeySerializer(new StringRedisSerializer());
+ redisTemplate.setValueSerializer(new StringRedisSerializer());
+
+ return redisTemplate;
+ }
+
+}
diff --git a/src/main/java/site/youtogether/config/WebConfig.java b/src/main/java/site/youtogether/config/WebConfig.java
new file mode 100644
index 0000000..f49cd9d
--- /dev/null
+++ b/src/main/java/site/youtogether/config/WebConfig.java
@@ -0,0 +1,23 @@
+package site.youtogether.config;
+
+import java.util.List;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import lombok.RequiredArgsConstructor;
+import site.youtogether.resolver.AddressArgumentResolver;
+
+@Configuration
+@RequiredArgsConstructor
+public class WebConfig implements WebMvcConfigurer {
+
+ private final AddressArgumentResolver addressArgumentResolver;
+
+ @Override
+ public void addArgumentResolvers(List resolvers) {
+ resolvers.add(addressArgumentResolver);
+ }
+
+}
diff --git a/src/main/java/site/youtogether/config/property/CookieProperties.java b/src/main/java/site/youtogether/config/property/CookieProperties.java
new file mode 100644
index 0000000..26671c3
--- /dev/null
+++ b/src/main/java/site/youtogether/config/property/CookieProperties.java
@@ -0,0 +1,19 @@
+package site.youtogether.config.property;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@ConfigurationProperties("cookie")
+@RequiredArgsConstructor
+@Getter
+public class CookieProperties {
+
+ private final String name;
+ private final String domain;
+ private final String path;
+ private final String sameSite;
+ private final int expiry;
+
+}
diff --git a/src/main/java/site/youtogether/exception/CustomException.java b/src/main/java/site/youtogether/exception/CustomException.java
new file mode 100644
index 0000000..d491267
--- /dev/null
+++ b/src/main/java/site/youtogether/exception/CustomException.java
@@ -0,0 +1,17 @@
+package site.youtogether.exception;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.Getter;
+
+@Getter
+public class CustomException extends RuntimeException {
+
+ private final HttpStatus status;
+
+ public CustomException(ErrorType errorType) {
+ super(errorType.getMessage());
+ this.status = errorType.getStatus();
+ }
+
+}
diff --git a/src/main/java/site/youtogether/exception/ErrorType.java b/src/main/java/site/youtogether/exception/ErrorType.java
new file mode 100644
index 0000000..4f60eb3
--- /dev/null
+++ b/src/main/java/site/youtogether/exception/ErrorType.java
@@ -0,0 +1,21 @@
+package site.youtogether.exception;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.Getter;
+
+@Getter
+public enum ErrorType {
+
+ // Room
+ SINGLE_ROOM_PARTICIPATION_VIOLATION(HttpStatus.BAD_REQUEST, "하나의 방에만 참가할 수 있습니다");
+
+ private final HttpStatus status;
+ private final String message;
+
+ ErrorType(HttpStatus status, String message) {
+ this.status = status;
+ this.message = message;
+ }
+
+}
diff --git a/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java b/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..38f01bb
--- /dev/null
+++ b/src/main/java/site/youtogether/exception/GlobalExceptionHandler.java
@@ -0,0 +1,51 @@
+package site.youtogether.exception;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.springframework.context.support.DefaultMessageSourceResolvable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.BindException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import site.youtogether.util.api.ApiResponse;
+import site.youtogether.util.api.ResponseResult;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(BindException.class)
+ public ResponseEntity> handleBindException(BindException exception) {
+ return ResponseEntity.badRequest()
+ .body(ApiResponse.of(HttpStatus.BAD_REQUEST, ResponseResult.EXCEPTION_OCCURRED,
+ exception.getBindingResult().getFieldErrors().stream()
+ .collect(Collectors.groupingBy(FieldError::getField))
+ .entrySet().stream()
+ .map(error -> {
+ Map fieldError = new LinkedHashMap<>();
+ fieldError.put("type", error.getKey());
+ fieldError.put("message", error.getValue().stream()
+ .map(DefaultMessageSourceResolvable::getDefaultMessage)
+ .collect(Collectors.joining(", ")));
+ return fieldError;
+ })
+ )
+ );
+ }
+
+ @ExceptionHandler(CustomException.class)
+ public ResponseEntity> handleCustomException(CustomException customException) {
+ Map error = new LinkedHashMap<>(2);
+ error.put("type", customException.getClass().getSimpleName());
+ error.put("message", customException.getMessage());
+
+ return ResponseEntity.badRequest()
+ .body(ApiResponse.of(customException.getStatus(), ResponseResult.EXCEPTION_OCCURRED, List.of(error)));
+ }
+
+}
diff --git a/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java b/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java
new file mode 100644
index 0000000..47834bf
--- /dev/null
+++ b/src/main/java/site/youtogether/exception/room/SingleRoomParticipationViolationException.java
@@ -0,0 +1,12 @@
+package site.youtogether.exception.room;
+
+import site.youtogether.exception.CustomException;
+import site.youtogether.exception.ErrorType;
+
+public class SingleRoomParticipationViolationException extends CustomException {
+
+ public SingleRoomParticipationViolationException() {
+ super(ErrorType.SINGLE_ROOM_PARTICIPATION_VIOLATION);
+ }
+
+}
diff --git a/src/main/java/site/youtogether/resolver/Address.java b/src/main/java/site/youtogether/resolver/Address.java
new file mode 100644
index 0000000..acc6793
--- /dev/null
+++ b/src/main/java/site/youtogether/resolver/Address.java
@@ -0,0 +1,11 @@
+package site.youtogether.resolver;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Address {
+}
diff --git a/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java b/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java
new file mode 100644
index 0000000..3c58b8e
--- /dev/null
+++ b/src/main/java/site/youtogether/resolver/AddressArgumentResolver.java
@@ -0,0 +1,32 @@
+package site.youtogether.resolver;
+
+import org.springframework.core.MethodParameter;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebDataBinderFactory;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.method.support.ModelAndViewContainer;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+@Component
+public class AddressArgumentResolver implements HandlerMethodArgumentResolver {
+
+ @Override
+ public boolean supportsParameter(MethodParameter parameter) {
+ boolean hasAddressAnnotation = parameter.hasParameterAnnotation(Address.class);
+ boolean isStringType = String.class.isAssignableFrom(parameter.getParameterType());
+
+ return hasAddressAnnotation && isStringType;
+ }
+
+ @Override
+ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
+ WebDataBinderFactory binderFactory) throws Exception {
+ HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
+
+ assert request != null;
+ return request.getRemoteAddr();
+ }
+
+}
diff --git a/src/main/java/site/youtogether/room/Room.java b/src/main/java/site/youtogether/room/Room.java
new file mode 100644
index 0000000..02f943a
--- /dev/null
+++ b/src/main/java/site/youtogether/room/Room.java
@@ -0,0 +1,43 @@
+package site.youtogether.room;
+
+import static site.youtogether.util.AppConstants.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.springframework.data.annotation.Id;
+import org.springframework.data.redis.core.RedisHash;
+
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import site.youtogether.user.User;
+import site.youtogether.util.RandomUtil;
+
+@RedisHash(value = "room")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Room {
+
+ @Id
+ private String code;
+
+ private int capacity;
+ private String title;
+ private String password;
+ private User host;
+ private Map participants = new HashMap<>(10);
+
+ @Builder
+ public Room(String title, int capacity, String password, User host) {
+ this.code = RandomUtil.generateRandomCode(ROOM_CODE_LENGTH);
+ this.capacity = capacity;
+ this.title = title;
+ this.password = password;
+ this.host = host;
+
+ participants.put(host.getSessionCode(), host);
+ }
+
+}
diff --git a/src/main/java/site/youtogether/room/application/RoomService.java b/src/main/java/site/youtogether/room/application/RoomService.java
new file mode 100644
index 0000000..fa0333d
--- /dev/null
+++ b/src/main/java/site/youtogether/room/application/RoomService.java
@@ -0,0 +1,43 @@
+package site.youtogether.room.application;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+import site.youtogether.room.Room;
+import site.youtogether.room.dto.RoomCode;
+import site.youtogether.room.dto.RoomSettings;
+import site.youtogether.room.infrastructure.RoomStorage;
+import site.youtogether.user.Role;
+import site.youtogether.user.User;
+import site.youtogether.user.infrastructure.UserStorage;
+import site.youtogether.util.RandomUtil;
+
+@Service
+@RequiredArgsConstructor
+public class RoomService {
+
+ private final RoomStorage roomStorage;
+ private final UserStorage userStorage;
+
+ public RoomCode create(String sessionCode, String address, RoomSettings roomSettings) {
+ User host = User.builder()
+ .sessionCode(sessionCode)
+ .address(address)
+ .nickname(RandomUtil.generateUserNickname())
+ .role(Role.HOST)
+ .build();
+
+ Room room = Room.builder()
+ .capacity(roomSettings.getCapacity())
+ .title(roomSettings.getTitle())
+ .password(roomSettings.getPassword())
+ .host(host)
+ .build();
+
+ userStorage.save(host);
+ roomStorage.save(room);
+
+ return new RoomCode(room);
+ }
+
+}
diff --git a/src/main/java/site/youtogether/room/dto/RoomCode.java b/src/main/java/site/youtogether/room/dto/RoomCode.java
new file mode 100644
index 0000000..19bd203
--- /dev/null
+++ b/src/main/java/site/youtogether/room/dto/RoomCode.java
@@ -0,0 +1,17 @@
+package site.youtogether.room.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import site.youtogether.room.Room;
+
+@AllArgsConstructor
+@Getter
+public class RoomCode {
+
+ private final String roomCode;
+
+ public RoomCode(Room room) {
+ this.roomCode = room.getCode();
+ }
+
+}
diff --git a/src/main/java/site/youtogether/room/dto/RoomSettings.java b/src/main/java/site/youtogether/room/dto/RoomSettings.java
new file mode 100644
index 0000000..c9f26ec
--- /dev/null
+++ b/src/main/java/site/youtogether/room/dto/RoomSettings.java
@@ -0,0 +1,33 @@
+package site.youtogether.room.dto;
+
+import org.hibernate.validator.constraints.Range;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+@Getter
+public class RoomSettings {
+
+ @NotBlank(message = "공백이 아닌 문자를 1개 이상 입력해 주세요")
+ @Size(min = 1, max = 30, message = "제목은 {min}자 이상 {max}자 이하로 입력해 주세요")
+ private String title;
+
+ @Range(min = 2, max = 10, message = "정원은 {min}명 이상 {max}명 이하로 입력해 주세요")
+ private int capacity;
+
+ @Pattern(regexp = "^[0-9a-zA-Z]{5,10}$", message = "비밀번호는 5자 이상 10자 이하의 영문 또는 숫자로 입력해 주세요")
+ private String password;
+
+ @Builder
+ public RoomSettings(String title, int capacity, String password) {
+ this.title = title;
+ this.capacity = capacity;
+ this.password = password;
+ }
+
+}
diff --git a/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java b/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java
new file mode 100644
index 0000000..8ab5572
--- /dev/null
+++ b/src/main/java/site/youtogether/room/infrastructure/RoomStorage.java
@@ -0,0 +1,11 @@
+package site.youtogether.room.infrastructure;
+
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+import site.youtogether.room.Room;
+
+@Repository
+public interface RoomStorage extends CrudRepository {
+
+}
diff --git a/src/main/java/site/youtogether/room/presentation/RoomController.java b/src/main/java/site/youtogether/room/presentation/RoomController.java
new file mode 100644
index 0000000..8e2ba32
--- /dev/null
+++ b/src/main/java/site/youtogether/room/presentation/RoomController.java
@@ -0,0 +1,64 @@
+package site.youtogether.room.presentation;
+
+import static site.youtogether.util.AppConstants.*;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseCookie;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.CookieValue;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import site.youtogether.config.property.CookieProperties;
+import site.youtogether.exception.room.SingleRoomParticipationViolationException;
+import site.youtogether.resolver.Address;
+import site.youtogether.room.application.RoomService;
+import site.youtogether.room.dto.RoomCode;
+import site.youtogether.room.dto.RoomSettings;
+import site.youtogether.util.RandomUtil;
+import site.youtogether.util.api.ApiResponse;
+import site.youtogether.util.api.ResponseResult;
+
+@RestController
+@RequiredArgsConstructor
+public class RoomController {
+
+ private final CookieProperties cookieProperties;
+ private final RoomService roomService;
+
+ @PostMapping("/rooms")
+ public ResponseEntity> createRoom(@CookieValue(value = SESSION_COOKIE_NAME, required = false) Cookie sessionCookie,
+ @Address String address, @Valid @RequestBody RoomSettings roomSettings, HttpServletResponse response) {
+ // Check if a session cookie already exists.
+ if (sessionCookie != null) {
+ throw new SingleRoomParticipationViolationException();
+ }
+
+ // Generate a new session code and set it as a cookie.
+ ResponseCookie cookie = ResponseCookie.from(cookieProperties.getName(), RandomUtil.generateRandomCode(SESSION_CODE_LENGTH))
+ .domain(cookieProperties.getDomain())
+ .path(cookieProperties.getPath())
+ .sameSite(cookieProperties.getSameSite())
+ .maxAge(cookieProperties.getExpiry())
+ .httpOnly(true)
+ .secure(true)
+ .build();
+
+ // Create a new room with the generated session code.
+ RoomCode roomCode = roomService.create(cookie.getValue(), address, roomSettings);
+
+ // Add the cookie to the response header.
+ response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
+
+ // Return a response indicating successful room creation.
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(ApiResponse.created(ResponseResult.ROOM_CREATION_SUCCESS, roomCode));
+ }
+
+}
diff --git a/src/main/java/site/youtogether/user/Role.java b/src/main/java/site/youtogether/user/Role.java
new file mode 100644
index 0000000..a1dc456
--- /dev/null
+++ b/src/main/java/site/youtogether/user/Role.java
@@ -0,0 +1,7 @@
+package site.youtogether.user;
+
+public enum Role {
+
+ HOST, MANAGER, EDITOR, GUEST, VIEWER
+
+}
diff --git a/src/main/java/site/youtogether/user/User.java b/src/main/java/site/youtogether/user/User.java
new file mode 100644
index 0000000..93f92e4
--- /dev/null
+++ b/src/main/java/site/youtogether/user/User.java
@@ -0,0 +1,28 @@
+package site.youtogether.user;
+
+import org.springframework.data.annotation.Id;
+import org.springframework.data.redis.core.RedisHash;
+
+import lombok.Builder;
+import lombok.Getter;
+
+@RedisHash(value = "user")
+@Getter
+public class User {
+
+ @Id
+ private final String sessionCode;
+
+ private final String address;
+ private final String nickname;
+ private final Role role;
+
+ @Builder
+ public User(String sessionCode, String address, String nickname, Role role) {
+ this.sessionCode = sessionCode;
+ this.address = address;
+ this.nickname = nickname;
+ this.role = role;
+ }
+
+}
diff --git a/src/main/java/site/youtogether/user/infrastructure/UserStorage.java b/src/main/java/site/youtogether/user/infrastructure/UserStorage.java
new file mode 100644
index 0000000..d3b797a
--- /dev/null
+++ b/src/main/java/site/youtogether/user/infrastructure/UserStorage.java
@@ -0,0 +1,11 @@
+package site.youtogether.user.infrastructure;
+
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.stereotype.Repository;
+
+import site.youtogether.user.User;
+
+@Repository
+public interface UserStorage extends CrudRepository {
+
+}
diff --git a/src/main/java/site/youtogether/util/AppConstants.java b/src/main/java/site/youtogether/util/AppConstants.java
new file mode 100644
index 0000000..463cd5c
--- /dev/null
+++ b/src/main/java/site/youtogether/util/AppConstants.java
@@ -0,0 +1,16 @@
+package site.youtogether.util;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class AppConstants {
+
+ public static final String PARTICIPANTS_KEY = "participants";
+ public static final String ROOM_KEY_PREFIX = "room:";
+ public static final String USER_KEY_PREFIX = "user:";
+ public static final String SESSION_COOKIE_NAME = "YTSession";
+ public static final int ROOM_CODE_LENGTH = 10;
+ public static final int SESSION_CODE_LENGTH = 20;
+
+}
diff --git a/src/main/java/site/youtogether/util/RandomUtil.java b/src/main/java/site/youtogether/util/RandomUtil.java
new file mode 100644
index 0000000..b072174
--- /dev/null
+++ b/src/main/java/site/youtogether/util/RandomUtil.java
@@ -0,0 +1,40 @@
+package site.youtogether.util;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ThreadLocalRandom;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class RandomUtil {
+
+ /**
+ * generate random code
+ * using a-z A-Z 0-9
+ */
+ public static String generateRandomCode(int length) {
+ String randomString = UUID.randomUUID().toString().replaceAll("-", "");
+
+ return randomString.substring(0, length);
+ }
+
+ /**
+ * generate random user nickname
+ * sample list size is 20
+ */
+ public static String generateUserNickname() {
+ List samples = List.of(
+ "MysticTiger", "SilverPhoenix", "ElectricWanderer", "CrimsonDragon", "EmeraldSpecter",
+ "MidnightRider", "VelvetWhisperer", "CosmicStrider", "SolarGoddess", "ArcticShadow",
+ "EnigmaticSphinx", "ScarletSorcerer", "CelestialWatcher", "LunarJester", "SapphireDreamer",
+ "GoldenGlider", "CrimsonFalcon", "EchoingWhisper", "EmberPhoenix", "RadiantRebel"
+ );
+
+ int randomIndex = ThreadLocalRandom.current().nextInt(samples.size());
+
+ return samples.get(randomIndex);
+ }
+
+}
diff --git a/src/main/java/site/youtogether/util/api/ApiResponse.java b/src/main/java/site/youtogether/util/api/ApiResponse.java
new file mode 100644
index 0000000..fc58825
--- /dev/null
+++ b/src/main/java/site/youtogether/util/api/ApiResponse.java
@@ -0,0 +1,34 @@
+package site.youtogether.util.api;
+
+import org.springframework.http.HttpStatus;
+
+import lombok.Getter;
+
+@Getter
+public class ApiResponse {
+
+ private final int code;
+ private final String status;
+ private final String result;
+ private final T data;
+
+ public ApiResponse(HttpStatus status, ResponseResult result, T data) {
+ this.code = status.value();
+ this.status = status.getReasonPhrase();
+ this.result = result.getDescription();
+ this.data = data;
+ }
+
+ public static ApiResponse of(HttpStatus status, ResponseResult result, T data) {
+ return new ApiResponse<>(status, result, data);
+ }
+
+ public static ApiResponse ok(ResponseResult result, T data) {
+ return new ApiResponse<>(HttpStatus.OK, result, data);
+ }
+
+ public static ApiResponse created(ResponseResult result, T data) {
+ return new ApiResponse<>(HttpStatus.CREATED, result, data);
+ }
+
+}
diff --git a/src/main/java/site/youtogether/util/api/ResponseResult.java b/src/main/java/site/youtogether/util/api/ResponseResult.java
new file mode 100644
index 0000000..c615ec1
--- /dev/null
+++ b/src/main/java/site/youtogether/util/api/ResponseResult.java
@@ -0,0 +1,20 @@
+package site.youtogether.util.api;
+
+import lombok.Getter;
+
+@Getter
+public enum ResponseResult {
+
+ // Common
+ EXCEPTION_OCCURRED("예외가 발생했습니다"),
+
+ // Room
+ ROOM_CREATION_SUCCESS("방 생성에 성공했습니다");
+
+ private final String description;
+
+ ResponseResult(String description) {
+ this.description = description;
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
deleted file mode 100644
index 8b13789..0000000
--- a/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/test/java/site/youtogether/IntegrationTestSupport.java b/src/test/java/site/youtogether/IntegrationTestSupport.java
new file mode 100644
index 0000000..3c28942
--- /dev/null
+++ b/src/test/java/site/youtogether/IntegrationTestSupport.java
@@ -0,0 +1,10 @@
+package site.youtogether;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest
+@ActiveProfiles("test")
+public abstract class IntegrationTestSupport {
+
+}
diff --git a/src/test/java/site/youtogether/RestDocsSupport.java b/src/test/java/site/youtogether/RestDocsSupport.java
new file mode 100644
index 0000000..ff65587
--- /dev/null
+++ b/src/test/java/site/youtogether/RestDocsSupport.java
@@ -0,0 +1,36 @@
+package site.youtogether;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.web.servlet.MockMvc;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import site.youtogether.config.PropertiesConfig;
+import site.youtogether.config.property.CookieProperties;
+import site.youtogether.room.application.RoomService;
+import site.youtogether.room.presentation.RoomController;
+
+@WebMvcTest(controllers = {
+ RoomController.class
+})
+@AutoConfigureRestDocs
+@Import(PropertiesConfig.class)
+public abstract class RestDocsSupport {
+
+ @Autowired
+ protected MockMvc mockMvc;
+
+ @Autowired
+ protected ObjectMapper objectMapper;
+
+ @Autowired
+ protected CookieProperties cookieProperties;
+
+ @MockBean
+ protected RoomService roomService;
+
+}
diff --git a/src/test/java/site/youtogether/YouTogetherApplicationTests.java b/src/test/java/site/youtogether/YouTogetherApplicationTests.java
deleted file mode 100644
index fd8b01b..0000000
--- a/src/test/java/site/youtogether/YouTogetherApplicationTests.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package site.youtogether;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class YouTogetherApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
diff --git a/src/test/java/site/youtogether/room/application/RoomServiceTest.java b/src/test/java/site/youtogether/room/application/RoomServiceTest.java
new file mode 100644
index 0000000..7a8ac5a
--- /dev/null
+++ b/src/test/java/site/youtogether/room/application/RoomServiceTest.java
@@ -0,0 +1,69 @@
+package site.youtogether.room.application;
+
+import static org.assertj.core.api.Assertions.*;
+import static site.youtogether.util.AppConstants.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import site.youtogether.IntegrationTestSupport;
+import site.youtogether.room.Room;
+import site.youtogether.room.dto.RoomCode;
+import site.youtogether.room.dto.RoomSettings;
+import site.youtogether.room.infrastructure.RoomStorage;
+import site.youtogether.user.Role;
+import site.youtogether.user.User;
+import site.youtogether.user.infrastructure.UserStorage;
+
+class RoomServiceTest extends IntegrationTestSupport {
+
+ @Autowired
+ private RoomService roomService;
+
+ @Autowired
+ private RoomStorage roomStorage;
+
+ @Autowired
+ private UserStorage userStorage;
+
+ @BeforeEach
+ void clean() {
+ roomStorage.deleteAll();
+ userStorage.deleteAll();
+ }
+
+ @Test
+ @DisplayName("새로운 방과 해당 방의 HOST를 생성할 수 있다")
+ void createSuccess() {
+ // given
+ String sessionCode = "7644a835e52e45dfa385";
+ String address = "127.0.0.1";
+ RoomSettings roomSettings = RoomSettings.builder()
+ .capacity(10)
+ .title("재밌는 쇼츠 같이 보기")
+ .password(null)
+ .build();
+
+ // when
+ RoomCode roomCode = roomService.create(sessionCode, address, roomSettings);
+
+ // then
+ Room room = roomStorage.findById(roomCode.getRoomCode()).get();
+ User user = userStorage.findById(sessionCode).get();
+
+ assertThat(roomCode.getRoomCode()).hasSize(ROOM_CODE_LENGTH);
+ assertThat(roomCode.getRoomCode()).isEqualTo(room.getCode());
+ assertThat(room.getCapacity()).isEqualTo(10);
+ assertThat(room.getTitle()).isEqualTo("재밌는 쇼츠 같이 보기");
+ assertThat(room.getPassword()).isNull();
+ assertThat(room.getHost().getSessionCode()).isEqualTo(sessionCode);
+ assertThat(room.getParticipants()).hasSize(1);
+ assertThat(user.getSessionCode()).isEqualTo(sessionCode);
+ assertThat(user.getAddress()).isEqualTo(address);
+ assertThat(user.getNickname()).isNotBlank();
+ assertThat(user.getRole()).isEqualTo(Role.HOST);
+ }
+
+}
diff --git a/src/test/java/site/youtogether/room/presentation/RoomControllerTest.java b/src/test/java/site/youtogether/room/presentation/RoomControllerTest.java
new file mode 100644
index 0000000..e1bf9f2
--- /dev/null
+++ b/src/test/java/site/youtogether/room/presentation/RoomControllerTest.java
@@ -0,0 +1,167 @@
+package site.youtogether.room.presentation;
+
+import static org.mockito.BDDMockito.*;
+import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*;
+import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*;
+import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
+import static org.springframework.restdocs.payload.PayloadDocumentation.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+import static site.youtogether.exception.ErrorType.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.restdocs.payload.JsonFieldType;
+
+import jakarta.servlet.http.Cookie;
+import site.youtogether.RestDocsSupport;
+import site.youtogether.exception.room.SingleRoomParticipationViolationException;
+import site.youtogether.room.dto.RoomCode;
+import site.youtogether.room.dto.RoomSettings;
+import site.youtogether.util.api.ResponseResult;
+
+class RoomControllerTest extends RestDocsSupport {
+
+ @Test
+ @DisplayName("방 생성 성공")
+ void createRoomSuccess() throws Exception {
+ // given
+ // Setting up request data for creating a room
+ RoomSettings roomSettings = RoomSettings.builder()
+ .capacity(10)
+ .title("재밌는 쇼츠 같이 보기")
+ .password(null)
+ .build();
+
+ // Setting up response data for the created room
+ RoomCode roomCode = new RoomCode("1e7050f7d7");
+ given(roomService.create(anyString(), anyString(), any(RoomSettings.class)))
+ .willReturn(roomCode);
+
+ // when / then
+ String cookieName = cookieProperties.getName();
+
+ mockMvc.perform(post("/rooms")
+ .content(objectMapper.writeValueAsString(roomSettings))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(status().isCreated())
+ .andExpect(cookie().exists(cookieName))
+ .andExpect(cookie().domain(cookieName, cookieProperties.getDomain()))
+ .andExpect(cookie().path(cookieName, cookieProperties.getPath()))
+ .andExpect(cookie().sameSite(cookieName, cookieProperties.getSameSite()))
+ .andExpect(cookie().maxAge(cookieName, cookieProperties.getExpiry()))
+ .andExpect(cookie().httpOnly(cookieName, true))
+ .andExpect(cookie().secure(cookieName, true))
+ .andExpect(jsonPath("$.code").value(HttpStatus.CREATED.value()))
+ .andExpect(jsonPath("$.status").value(HttpStatus.CREATED.getReasonPhrase()))
+ .andExpect(jsonPath("$.result").value(ResponseResult.ROOM_CREATION_SUCCESS.getDescription()))
+ .andExpect(jsonPath("$.data.roomCode").value(roomCode.getRoomCode()))
+ .andDo(document("create-room-success",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ requestFields(
+ fieldWithPath("capacity").type(JsonFieldType.NUMBER).description("정원"),
+ fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
+ fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호").optional()
+ ),
+ responseFields(
+ fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"),
+ fieldWithPath("status").type(JsonFieldType.STRING).description("상태"),
+ fieldWithPath("result").type(JsonFieldType.STRING).description("결과"),
+ fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"),
+ fieldWithPath("data.roomCode").type(JsonFieldType.STRING).description("방 식별 코드")
+ )
+ ));
+ }
+
+ @Test
+ @DisplayName("방 생성 실패: 요청 데이터 오류가 발생했습니다")
+ void createRoomFail_RoomSettingError() throws Exception {
+ // given
+ // Setting up request data for creating a room
+ RoomSettings roomSettings = RoomSettings.builder()
+ .capacity(11)
+ .title(" ")
+ .password("a1b2")
+ .build();
+
+ // when / then
+ mockMvc.perform(post("/rooms")
+ .content(objectMapper.writeValueAsString(roomSettings))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andDo(print())
+ .andExpect(status().isBadRequest())
+ .andExpect(cookie().doesNotExist(cookieProperties.getName()))
+ .andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value()))
+ .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.getReasonPhrase()))
+ .andExpect(jsonPath("$.result").value(ResponseResult.EXCEPTION_OCCURRED.getDescription()))
+ .andExpect(jsonPath("$.data").isArray())
+ .andDo(document("create-room-fail-room-setting-error",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ requestFields(
+ fieldWithPath("capacity").type(JsonFieldType.NUMBER).description("정원"),
+ fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
+ fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호").optional()
+ ),
+ responseFields(
+ fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"),
+ fieldWithPath("status").type(JsonFieldType.STRING).description("상태"),
+ fieldWithPath("result").type(JsonFieldType.STRING).description("결과"),
+ fieldWithPath("data").type(JsonFieldType.ARRAY).description("응답 데이터"),
+ fieldWithPath("data[].type").type(JsonFieldType.STRING).description("오류 타입"),
+ fieldWithPath("data[].message").type(JsonFieldType.STRING).description("오류 메시지")
+ )
+ ));
+ }
+
+ @Test
+ @DisplayName("방 생성 실패: 다수의 방에 참가할 수 없습니다")
+ void createRoomFail_SingleRoomParticipantViolation() throws Exception {
+ // given
+ // Setting up session cookie and request data for creating a room
+ // This indicates that a session cookie is already present, implying participation in a room
+ Cookie sessionCookie = new Cookie(cookieProperties.getName(), "a85192c998454a1ea055");
+ RoomSettings roomSettings = RoomSettings.builder()
+ .capacity(10)
+ .title("재밌는 쇼츠 같이 보기")
+ .password(null)
+ .build();
+
+ // when / then
+ mockMvc.perform(post("/rooms")
+ .content(objectMapper.writeValueAsString(roomSettings))
+ .contentType(MediaType.APPLICATION_JSON)
+ .cookie(sessionCookie))
+ .andDo(print())
+ .andExpect(status().isBadRequest())
+ .andExpect(cookie().doesNotExist(cookieProperties.getName()))
+ .andExpect(jsonPath("$.code").value(SINGLE_ROOM_PARTICIPATION_VIOLATION.getStatus().value()))
+ .andExpect(jsonPath("$.status").value(SINGLE_ROOM_PARTICIPATION_VIOLATION.getStatus().getReasonPhrase()))
+ .andExpect(jsonPath("$.result").value(ResponseResult.EXCEPTION_OCCURRED.getDescription()))
+ .andExpect(jsonPath("$.data").isArray())
+ .andExpect(jsonPath("$.data[0].type").value(SingleRoomParticipationViolationException.class.getSimpleName()))
+ .andExpect(jsonPath("$.data[0].message").value(SINGLE_ROOM_PARTICIPATION_VIOLATION.getMessage()))
+ .andDo(document("create-room-fail-single-room-participant-violation",
+ preprocessRequest(prettyPrint()),
+ preprocessResponse(prettyPrint()),
+ requestFields(
+ fieldWithPath("capacity").type(JsonFieldType.NUMBER).description("정원"),
+ fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
+ fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호").optional()
+ ),
+ responseFields(
+ fieldWithPath("code").type(JsonFieldType.NUMBER).description("코드"),
+ fieldWithPath("status").type(JsonFieldType.STRING).description("상태"),
+ fieldWithPath("result").type(JsonFieldType.STRING).description("결과"),
+ fieldWithPath("data").type(JsonFieldType.ARRAY).description("응답 데이터"),
+ fieldWithPath("data[].type").type(JsonFieldType.STRING).description("오류 타입"),
+ fieldWithPath("data[].message").type(JsonFieldType.STRING).description("오류 메시지")
+ )
+ ));
+ }
+
+}
diff --git a/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet
new file mode 100644
index 0000000..16960f2
--- /dev/null
+++ b/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet
@@ -0,0 +1,14 @@
+==== Request Fields
+|===
+|Path|Type|Optional|Description
+
+{{#fields}}
+
+|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
+|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
+|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}}
+|{{#tableCellContent}}{{description}}{{/tableCellContent}}
+
+{{/fields}}
+
+|===
diff --git a/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet b/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet
new file mode 100644
index 0000000..80008b7
--- /dev/null
+++ b/src/test/resources/org/springframework/restdocs/templates/response-fields.snippet
@@ -0,0 +1,14 @@
+==== Response Fields
+|===
+|Path|Type|Optional|Description
+
+{{#fields}}
+
+|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
+|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
+|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}}
+|{{#tableCellContent}}{{description}}{{/tableCellContent}}
+
+{{/fields}}
+
+|===