diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index c416d68..38d403c 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -22,16 +22,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Set up JDK 21
+ - name: Set up JDK 17
uses: actions/setup-java@v4
with:
- java-version: '21'
+ java-version: '17'
distribution: 'temurin'
# Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies.
# See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md
- name: Setup Gradle
uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x ./gradlew
- name: Build with Gradle Wrapper
run: ./gradlew build
@@ -55,10 +58,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Set up JDK 21
+ - name: Set up JDK 17
uses: actions/setup-java@v4
with:
- java-version: '21'
+ java-version: '17'
distribution: 'temurin'
# Generates and submits a dependency graph, enabling Dependabot Alerts for all project dependencies.
diff --git a/.gitignore b/.gitignore
index 30e9ba9..48c20ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,7 @@ migrations/
Challenge Backend..pdf
data/
+
+PR_DESCRIPTION.md
+
+PULL_REQUEST.md
diff --git a/Dockerfile b/Dockerfile
index 7a5a04a..5802079 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,31 +1,40 @@
# Use a base image with a JDK (Java Development Kit)
-FROM openjdk:21-jdk-slim AS build
+FROM openjdk:17-jdk-slim AS build
# Set the working directory in the container
WORKDIR /app
COPY build.gradle settings.gradle gradlew /app/
COPY gradle /app/gradle
+
+# Fix line ending issues with gradlew (convert CRLF to LF)
+RUN apt-get update && apt-get install -y dos2unix && dos2unix ./gradlew && chmod +x ./gradlew
RUN ./gradlew build || return 0
# Copy the source code of your Spring Boot application into the container
COPY . /app
+# Fix line endings again for the copied files
+RUN find . -type f -name "*.sh" -o -name "gradlew" | xargs dos2unix
+RUN chmod +x ./gradlew
+
# Build the Spring Boot application inside the container
-RUN ./gradlew build -x test
+RUN ./gradlew bootJar -x test
+
+# Verify the JAR file was created and find its location
+RUN find /app/build -name "*.jar" | sort
# Use a base image with a JRE (Java Runtime Environment)
-FROM openjdk:21-jdk-slim
+FROM openjdk:17-jdk-slim
# Set the working directory in the container
WORKDIR /app
# Copy the compiled Spring Boot JAR file into the container from the build stage
-COPY --from=build /app/build/libs/split-travel-0.0.1-SNAPSHOT.jar /app/split-travel-application.jar
-
+COPY --from=build /app/build/libs/twitter-challenge-espenia-0.0.1-SNAPSHOT.jar /app/application.jar
# Expose the port your Spring Boot application is running on (default is 8080)
EXPOSE 8080
# Command to run your Spring Boot application
-CMD ["java", "-jar", "/app/split-travel-application.jar"]
+CMD ["java", "-jar", "/app/application.jar"]
diff --git a/README.md b/README.md
index dccfb75..25de75e 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,10 @@ Repositorio para el BackEnd de la plataforma challange twitter "Challange Twitte
* [Integrantes](#integrantes)
+* [Arquitectura](#arquitectura)
+
+* [Docker](#docker)
+
* [Dependencias](#dependencias)
* [Wiki](#wiki)
@@ -28,11 +32,105 @@ Repositorio para el BackEnd de la plataforma challange twitter "Challange Twitte
+# Arquitectura
+
+El proyecto sigue una arquitectura de Clean Architecture con separación clara de responsabilidades:
+
+## Capas de la Aplicación
+
+1. **Entrypoint (Controllers)**: Maneja las solicitudes HTTP y respuestas.
+ - Ubicación: `src/main/java/twitter/challenge/espenia/entrypoint/`
+ - Componentes: `UserController`, `TweetController`
+
+2. **Core (Dominio)**: Contiene la lógica de negocio y modelos de dominio.
+ - Ubicación: `src/main/java/twitter/challenge/espenia/core/`
+ - Subdirectorios principales:
+ - `domain`: Entidades principales (`User`, `Tweet`)
+ - `usecase`: Servicios y lógica de negocio
+ - `gateway`: Interfaces para acceso a datos
+ - `exception`: Excepciones de dominio
+
+3. **Infraestructura**: Implementa mecanismos de persistencia y servicios externos.
+ - Ubicación: `src/main/java/twitter/challenge/espenia/infra/`
+ - Componentes:
+ - `mongodb`: Configuración y documentos de MongoDB
+ - `gateway`: Implementaciones de las interfaces del gateway
+
+## Flujo de Datos
+
+1. Las peticiones HTTP llegan a los controladores en la capa Entrypoint
+2. Los controladores delegan en casos de uso (Core)
+3. Los casos de uso utilizan gateways para acceder a los datos
+4. La capa de infraestructura implementa estos gateways para interactuar con MongoDB
+
+
+
+# Docker
+
+El proyecto incluye configuración para despliegue con Docker, facilitando su ejecución en cualquier entorno.
+
+## Requisitos Previos
+
+- Docker
+- Docker Compose
+
+## Configuración
+
+### Dockerfile
+
+El proyecto utiliza un enfoque de construcción multi-etapa:
+
+1. **Etapa de construcción**: Compila la aplicación usando JDK 17
+2. **Etapa de ejecución**: Ejecuta la aplicación con una imagen liviana
+
+### Docker Compose
+
+El archivo `docker-compose.yml` configura:
+
+- **Servicio principal**: La aplicación Spring Boot
+- **MongoDB**: Base de datos NoSQL para almacenamiento
+- **Servicio de inicialización**: Configura usuarios y colecciones en MongoDB
+
+## Uso
+
+Para iniciar la aplicación con Docker:
+
+```bash
+docker-compose up -d
+```
+
+Para detener la aplicación:
+
+```bash
+docker-compose down
+```
+
+Para ver los logs:
+
+```bash
+docker-compose logs -f
+```
+
+## Variables de Entorno
+
+El servicio principal utiliza las siguientes variables de entorno que pueden ser modificadas según necesidad:
+
+- `SCOPE_SUFFIX`: Perfil activo (por defecto: `prod`)
+- `MONGODB_URI`: URI de conexión a MongoDB
+- `MONGODB_DATABASE`: Nombre de la base de datos
+- `SERVER_PORT`: Puerto de la aplicación (por defecto: 8090)
+
+
+
# Dependencias
| Dependencia | Versión | Descripción |
|:------------------------------------------------------------------|:------------------------:|:------------------------------------------------------------------------------------:|
| [Spring Boot](https://spring.io/projects/spring-boot) | 3.3.5 | La librería base sobre la que se construyen interacciones con el server del BackEnd. |
+| [MongoDB](https://www.mongodb.com/) | Latest | Base de datos NoSQL para almacenamiento de usuarios y tweets. |
+| [MapStruct](https://mapstruct.org/) | 1.6.3 | Framework para mapeo de objetos entre capas de dominio y persistencia. |
+| [Lombok](https://projectlombok.org/) | 1.18.30 | Reduce el código repetitivo mediante anotaciones. |
+| [Spring Validation](https://spring.io/guides/gs/validating-form-input/) | 3.3.5 | Validación de datos en los requests. |
@@ -42,6 +140,22 @@ Si bien la documentación del [código fuente](./CONTRIBUTING.md#código-fuente)
el uso de los _end points_ para interactuar con el FrontEnd viene mejor explicado en la
[wiki](https://github.com/espenia/split-travel-be/wiki) del proyecto.
+## API Endpoints
+
+### Usuarios
+
+- **GET /api/users/{id}**: Obtiene un usuario por ID
+- **GET /api/users/username/{username}**: Obtiene un usuario por nombre de usuario
+- **POST /api/users**: Crea un nuevo usuario
+- **PATCH /api/users/{id}**: Actualiza un usuario existente
+- **DELETE /api/users/{id}**: Elimina un usuario
+
+### Tweets
+
+- **POST /api/tweets**: Crea un nuevo tweet
+
+La documentación completa de la API está disponible en formato Swagger en `docs/specs/swagger.yaml`.
+
# Convenciones
@@ -51,3 +165,30 @@ Las convenciones utilizadas en el proyecto, como las utilizadas para el
[*pull requests*](./CONTRIBUTING.md#pull-requests) o la formación de
[*issues*](./CONTRIBUTING.md#issues) se encuentran en el [archivo](./CONTRIBUTING.md)
correspondiente.
+
+
+
+# CI/CD
+
+El proyecto utiliza GitHub Actions para integración continua.
+
+## Workflow de CI
+
+El flujo de trabajo de CI está definido en `.github/workflows/gradle.yml` y se ejecuta automáticamente en:
+- Push a la rama `main`
+- Pull Requests dirigidas a `main`
+
+### Etapas del Pipeline
+
+1. **Checkout**: Obtiene el código fuente
+2. **Setup JDK**: Configura Java 17
+3. **Setup Gradle**: Configura Gradle con caché para dependencias
+4. **Build**: Compila y ejecuta pruebas
+
+### Ejecución Local
+
+Para ejecutar el build localmente antes de hacer push:
+
+```bash
+./gradlew build
+```
diff --git a/build.gradle b/build.gradle
index 4907f77..7f7d850 100644
--- a/build.gradle
+++ b/build.gradle
@@ -45,7 +45,8 @@ dependencies {
implementation("com.newrelic.agent.java:newrelic-api:$newRelicVersion")
implementation("commons-io:commons-io:$commonsIoVersion")
implementation("jakarta.servlet:jakarta.servlet-api:$jakartaServletApiVersion")
- implementation("javax.xml.bind:jaxb-api:$jaxbApiVersion")
+ implementation("javax.xml.bind:jaxb-api:$jaxbApiVersion")
+ implementation("org.hibernate.validator:hibernate-validator:8.0.0.Final")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
implementation("org.springframework.boot:spring-boot-starter-jetty")
@@ -82,3 +83,8 @@ jar {
}
}
+bootJar {
+ archiveBaseName = 'twitter-challenge-espenia'
+ archiveVersion = '0.0.1-SNAPSHOT'
+}
+
diff --git a/business.txt b/business.txt
index e69de29..d142682 100644
--- a/business.txt
+++ b/business.txt
@@ -0,0 +1 @@
+to accelerate development i used a local mongo database, in memory needs to be researched
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..a39ad38
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,52 @@
+version: '3.8'
+
+services:
+ twitter-challenge:
+ build:
+ context: . # Use the current directory as the build context
+ dockerfile: Dockerfile # Use the existing Dockerfile
+ ports:
+ - "8090:8090" # Map container port 8090 to host port 8090
+
+ environment:
+ - SCOPE_SUFFIX=prod # Set the active profile to local
+ - MONGODB_URI=mongodb://mongodb
+ - MONGODB_DATABASE=challenge-twitter
+ - MONGODB_USERNAME=user
+ - MONGODB_PASSWORD=user
+ - MONGODB_PORT=27017
+ - SERVER_PORT=8090
+ restart: unless-stopped
+ container_name: twitter-challenge-app
+ volumes:
+ - ./data:/app/data # Mount the data directory for persistence if needed
+ depends_on:
+ - mongo-init
+
+ mongodb:
+ image: mongo:latest
+ container_name: mongodb
+ ports:
+ - "27017:27017"
+ volumes:
+ - mongodb_data:/data/db # Persist MongoDB data
+ environment:
+ - MONGO_INITDB_ROOT_USERNAME=root
+ - MONGO_INITDB_ROOT_PASSWORD=rootpassword
+ - MONGO_INITDB_DATABASE=challenge-twitter
+ restart: unless-stopped
+ command: mongod --auth
+
+ mongo-init:
+ image: mongo:latest
+ depends_on:
+ - mongodb
+ restart: on-failure
+ command: >
+ bash -c "sleep 10 && mongosh --host mongodb -u root -p rootpassword --authenticationDatabase admin --eval '
+ db = db.getSiblingDB(\"challenge-twitter\");
+ db.createUser({user: \"user\", pwd: \"user\", roles: [{role: \"readWrite\", db: \"challenge-twitter\"}]});
+ '"
+
+volumes:
+ mongodb_data: # Named volume for MongoDB data persistence
\ No newline at end of file
diff --git a/docs/specs/swagger.yaml b/docs/specs/swagger.yaml
index 281aed3..fb322fc 100644
--- a/docs/specs/swagger.yaml
+++ b/docs/specs/swagger.yaml
@@ -10,18 +10,20 @@
"description" : "Scope local"
} ],
"paths" : {
- "/api/users/{id}" : {
- "get" : {
+ "/api/users" : {
+ "post" : {
"tags" : [ "user-controller" ],
- "operationId" : "getUser",
- "parameters" : [ {
- "name" : "id",
- "in" : "path",
- "required" : true,
- "schema" : {
- "type" : "string"
- }
- } ],
+ "operationId" : "createUser",
+ "requestBody" : {
+ "content" : {
+ "application/json" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/UserRequest"
+ }
+ }
+ },
+ "required" : true
+ },
"responses" : {
"200" : {
"description" : "OK",
@@ -34,28 +36,48 @@
}
}
}
- },
- "put" : {
- "tags" : [ "user-controller" ],
- "operationId" : "updateUser",
- "parameters" : [ {
- "name" : "id",
- "in" : "path",
- "required" : true,
- "schema" : {
- "type" : "string"
- }
- } ],
+ }
+ },
+ "/api/tweets" : {
+ "post" : {
+ "tags" : [ "tweet-controller" ],
+ "operationId" : "createTweet",
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
- "$ref" : "#/components/schemas/UserUpdateRequest"
+ "$ref" : "#/components/schemas/TweetRequest"
}
}
},
"required" : true
},
+ "responses" : {
+ "200" : {
+ "description" : "OK",
+ "content" : {
+ "*/*" : {
+ "schema" : {
+ "$ref" : "#/components/schemas/TweetResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/users/{id}" : {
+ "get" : {
+ "tags" : [ "user-controller" ],
+ "operationId" : "getUser",
+ "parameters" : [ {
+ "name" : "id",
+ "in" : "path",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
"responses" : {
"200" : {
"description" : "OK",
@@ -85,17 +107,23 @@
"description" : "OK"
}
}
- }
- },
- "/api/users" : {
- "post" : {
+ },
+ "patch" : {
"tags" : [ "user-controller" ],
- "operationId" : "createUser",
+ "operationId" : "updateUser",
+ "parameters" : [ {
+ "name" : "id",
+ "in" : "path",
+ "required" : true,
+ "schema" : {
+ "type" : "string"
+ }
+ } ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
- "$ref" : "#/components/schemas/UserRequest"
+ "$ref" : "#/components/schemas/UserUpdateRequest"
}
}
},
@@ -162,14 +190,23 @@
},
"components" : {
"schemas" : {
- "UserUpdateRequest" : {
+ "UserRequest" : {
+ "required" : [ "bio", "display_name", "email", "password", "username" ],
"type" : "object",
"properties" : {
+ "username" : {
+ "maxLength" : 50,
+ "minLength" : 3,
+ "type" : "string"
+ },
"display_name" : {
"maxLength" : 100,
"minLength" : 0,
"type" : "string"
},
+ "email" : {
+ "type" : "string"
+ },
"password" : {
"maxLength" : 100,
"minLength" : 8,
@@ -199,34 +236,53 @@
},
"bio" : {
"type" : "string"
+ }
+ }
+ },
+ "TweetRequest" : {
+ "required" : [ "content", "user_id" ],
+ "type" : "object",
+ "properties" : {
+ "user_id" : {
+ "type" : "string"
+ },
+ "content" : {
+ "maxLength" : 280,
+ "minLength" : 1,
+ "type" : "string"
+ }
+ }
+ },
+ "TweetResponse" : {
+ "type" : "object",
+ "properties" : {
+ "id" : {
+ "type" : "string"
+ },
+ "user_id" : {
+ "type" : "string"
+ },
+ "content" : {
+ "type" : "string"
},
"created_at" : {
"type" : "string",
"format" : "date-time"
},
- "updated_at" : {
- "type" : "string",
- "format" : "date-time"
+ "like_count" : {
+ "type" : "integer",
+ "format" : "int64"
}
}
},
- "UserRequest" : {
- "required" : [ "bio", "display_name", "email", "password", "username" ],
+ "UserUpdateRequest" : {
"type" : "object",
"properties" : {
- "username" : {
- "maxLength" : 50,
- "minLength" : 3,
- "type" : "string"
- },
"display_name" : {
"maxLength" : 100,
"minLength" : 0,
"type" : "string"
},
- "email" : {
- "type" : "string"
- },
"password" : {
"maxLength" : 100,
"minLength" : 8,
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index ae04661..a595206 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..598cfa9
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'challenge-twitter-espenia'
diff --git a/src/main/java/twitter/challenge/espenia/core/domain/Tweet.java b/src/main/java/twitter/challenge/espenia/core/domain/Tweet.java
new file mode 100644
index 0000000..95b872c
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/core/domain/Tweet.java
@@ -0,0 +1,18 @@
+package twitter.challenge.espenia.core.domain;
+
+import lombok.*;
+import java.util.Date;
+
+@EqualsAndHashCode(of = "id", callSuper = false)
+@Getter
+@ToString
+@Builder
+public class Tweet {
+ private final String id;
+ private final String userId;
+ private final String content;
+ private final Date createdAt;
+
+ @Setter
+ private long likeCount;
+}
diff --git a/src/main/java/twitter/challenge/espenia/core/gateway/TweetGateway.java b/src/main/java/twitter/challenge/espenia/core/gateway/TweetGateway.java
new file mode 100644
index 0000000..c8a63c0
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/core/gateway/TweetGateway.java
@@ -0,0 +1,7 @@
+package twitter.challenge.espenia.core.gateway;
+
+import twitter.challenge.espenia.core.domain.Tweet;
+
+public interface TweetGateway {
+ Tweet create(Tweet tweet);
+}
diff --git a/src/main/java/twitter/challenge/espenia/core/result/TweetResponse.java b/src/main/java/twitter/challenge/espenia/core/result/TweetResponse.java
new file mode 100644
index 0000000..ebe543b
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/core/result/TweetResponse.java
@@ -0,0 +1,13 @@
+package twitter.challenge.espenia.core.result;
+
+import lombok.Builder;
+import java.util.Date;
+
+@Builder
+public record TweetResponse(
+ String id,
+ String userId,
+ String content,
+ Date createdAt,
+ Long likeCount
+) {}
diff --git a/src/main/java/twitter/challenge/espenia/core/usecase/CreateTweet.java b/src/main/java/twitter/challenge/espenia/core/usecase/CreateTweet.java
new file mode 100644
index 0000000..922f898
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/core/usecase/CreateTweet.java
@@ -0,0 +1,42 @@
+package twitter.challenge.espenia.core.usecase;
+
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import twitter.challenge.espenia.core.domain.Tweet;
+import twitter.challenge.espenia.core.gateway.TweetGateway;
+import twitter.challenge.espenia.core.gateway.UserGateway;
+import twitter.challenge.espenia.core.result.TweetResponse;
+import twitter.challenge.espenia.core.usecase.request.TweetRequest;
+
+@Service
+@RequiredArgsConstructor
+public class CreateTweet {
+
+ private final TweetGateway tweetGateway;
+ private final UserGateway userGateway;
+
+ @Transactional
+ public TweetResponse execute(final TweetRequest tweetRequest) {
+ // Verify user exists
+ userGateway.findById(tweetRequest.userId());
+
+ // Create tweet
+ final Tweet tweet = Tweet.builder()
+ .userId(tweetRequest.userId())
+ .content(tweetRequest.content())
+ .build();
+
+ return mapToTweetResponse(tweetGateway.create(tweet));
+ }
+
+ private TweetResponse mapToTweetResponse(final Tweet tweet) {
+ return TweetResponse.builder()
+ .id(tweet.getId())
+ .userId(tweet.getUserId())
+ .content(tweet.getContent())
+ .createdAt(tweet.getCreatedAt())
+ .likeCount(tweet.getLikeCount())
+ .build();
+ }
+}
diff --git a/src/main/java/twitter/challenge/espenia/core/usecase/request/TweetRequest.java b/src/main/java/twitter/challenge/espenia/core/usecase/request/TweetRequest.java
new file mode 100644
index 0000000..5cb10ae
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/core/usecase/request/TweetRequest.java
@@ -0,0 +1,13 @@
+package twitter.challenge.espenia.core.usecase.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record TweetRequest(
+ @NotBlank(message = "User ID is required")
+ String userId,
+
+ @NotBlank(message = "Content is required")
+ @Size(max = 280, message = "Tweet content must be between 1 and 280 characters")
+ String content
+) {}
diff --git a/src/main/java/twitter/challenge/espenia/entrypoint/TweetController.java b/src/main/java/twitter/challenge/espenia/entrypoint/TweetController.java
new file mode 100644
index 0000000..85c6809
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/entrypoint/TweetController.java
@@ -0,0 +1,35 @@
+package twitter.challenge.espenia.entrypoint;
+
+import jakarta.validation.Valid;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import twitter.challenge.espenia.core.usecase.CreateTweet;
+import twitter.challenge.espenia.core.usecase.request.TweetRequest;
+import twitter.challenge.espenia.core.result.TweetResponse;
+
+@RestController
+@RequestMapping("/api/tweets")
+public class TweetController {
+
+ private final CreateTweet createTweet;
+
+ @Autowired
+ public TweetController(final CreateTweet createTweet) {
+ this.createTweet = createTweet;
+ }
+
+ /**
+ * Create a new tweet
+ *
+ * @param tweetRequest The tweet data
+ * @return The created tweet
+ */
+ @PostMapping
+ public ResponseEntity createTweet(@Valid @RequestBody final TweetRequest tweetRequest) {
+ TweetResponse createdTweet = createTweet.execute(tweetRequest);
+ return new ResponseEntity<>(createdTweet, HttpStatus.CREATED);
+ }
+}
diff --git a/src/main/java/twitter/challenge/espenia/entrypoint/handler/ControllerExceptionHandler.java b/src/main/java/twitter/challenge/espenia/entrypoint/handler/ControllerExceptionHandler.java
index d586cd3..ee7bc46 100644
--- a/src/main/java/twitter/challenge/espenia/entrypoint/handler/ControllerExceptionHandler.java
+++ b/src/main/java/twitter/challenge/espenia/entrypoint/handler/ControllerExceptionHandler.java
@@ -1,6 +1,7 @@
package twitter.challenge.espenia.entrypoint.handler;
import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestValueException;
import twitter.challenge.espenia.core.exception.BaseAPIException;
import twitter.challenge.espenia.entrypoint.exception.response.dto.ApiError;
@@ -121,4 +122,39 @@ protected ResponseEntity handleUnknownException(final Exception e) {
HttpStatus.INTERNAL_SERVER_ERROR.value());
return ResponseEntity.status(apiError.getStatus()).body(apiError);
}
+
+ /**
+ * Handler for validation errors.
+ *
+ * @param ex the exception thrown when validation fails.
+ * @return {@link ResponseEntity} with 422 status code and the validation error message.
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationException(final MethodArgumentNotValidException ex) {
+ String fieldName = "";
+ String errorMessage = "";
+
+ if (ex.getBindingResult().getFieldError() != null) {
+ fieldName = ex.getBindingResult().getFieldError().getField();
+ errorMessage = ex.getBindingResult().getFieldError().getDefaultMessage();
+ }
+
+ final Set validation = new HashSet<>();
+ validation.add(new UnproccesableEntityApiError.Validation(fieldName, errorMessage));
+
+ UnproccesableEntityApiError apiError = new UnproccesableEntityApiError("validation_error", validation);
+
+ log.info(
+ LOG_FLAGS,
+ apiError.getMessage(),
+ apiError.getError(),
+ apiError.getStatus());
+
+ ApiError simpleError = new ApiError(
+ "validation_error",
+ errorMessage,
+ HttpStatus.UNPROCESSABLE_ENTITY.value());
+
+ return ResponseEntity.status(apiError.getStatus()).body(simpleError);
+ }
}
diff --git a/src/main/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImpl.java b/src/main/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImpl.java
new file mode 100644
index 0000000..bc9fe0c
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImpl.java
@@ -0,0 +1,24 @@
+package twitter.challenge.espenia.infra.gateway;
+
+import org.springframework.stereotype.Component;
+import twitter.challenge.espenia.core.domain.Tweet;
+import twitter.challenge.espenia.core.gateway.TweetGateway;
+import twitter.challenge.espenia.infra.gateway.mapper.TweetMapper;
+import twitter.challenge.espenia.infra.mongodb.document.TweetDocument;
+import twitter.challenge.espenia.infra.mongodb.repository.TweetRepository;
+
+@Component
+public class TweetGatewayImpl extends BaseMongoGateway implements TweetGateway {
+
+ private final TweetMapper tweetMapper;
+
+ public TweetGatewayImpl(final TweetRepository repository, final TweetMapper tweetMapper) {
+ super(repository);
+ this.tweetMapper = tweetMapper;
+ }
+
+ @Override
+ public Tweet create(Tweet tweet) {
+ return tweetMapper.toDomain(repository.save(tweetMapper.toEntity(tweet)));
+ }
+}
diff --git a/src/main/java/twitter/challenge/espenia/infra/gateway/mapper/TweetMapper.java b/src/main/java/twitter/challenge/espenia/infra/gateway/mapper/TweetMapper.java
new file mode 100644
index 0000000..45be138
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/infra/gateway/mapper/TweetMapper.java
@@ -0,0 +1,9 @@
+package twitter.challenge.espenia.infra.gateway.mapper;
+
+import org.mapstruct.Mapper;
+import twitter.challenge.espenia.core.domain.Tweet;
+import twitter.challenge.espenia.infra.mongodb.document.TweetDocument;
+
+@Mapper(componentModel = "spring")
+public interface TweetMapper extends BaseMapper {
+}
diff --git a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/BaseDocument.java b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/BaseDocument.java
index d1af9ee..d0ace27 100644
--- a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/BaseDocument.java
+++ b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/BaseDocument.java
@@ -1,27 +1,27 @@
package twitter.challenge.espenia.infra.mongodb.document;
-import java.util.Date;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
-import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.domain.Persistable;
-import lombok.Getter;
-import lombok.Setter;
import lombok.experimental.SuperBuilder;
-@Getter
-@Setter
+import java.util.Date;
+
@SuperBuilder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public abstract class BaseDocument implements Persistable {
-
- @CreatedDate
- private Date createdAt;
-
+ public abstract String getId();
+
@LastModifiedDate
private Date updatedAt;
+
+ @Override
+ public boolean isNew() {
+ return getId() == null || getId().isEmpty();
+ }
+
}
diff --git a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/TweetDocument.java b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/TweetDocument.java
new file mode 100644
index 0000000..b82cfc1
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/TweetDocument.java
@@ -0,0 +1,39 @@
+package twitter.challenge.espenia.infra.mongodb.document;
+
+import jakarta.validation.constraints.Min;
+import lombok.*;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.mongodb.core.index.Indexed;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import lombok.experimental.SuperBuilder;
+
+import java.util.Date;
+
+@EqualsAndHashCode(of = "id", callSuper = false)
+@Getter
+@Setter
+@SuperBuilder(toBuilder = true)
+@Document(collection = "tweets")
+@NoArgsConstructor
+@AllArgsConstructor
+public class TweetDocument extends BaseDocument {
+
+ @Id
+ private String id;
+
+ @Indexed
+ private String userId;
+
+ private String content;
+
+ @Indexed
+ @CreatedDate
+ private Date createdAt;
+
+ @Min(0)
+ private long likeCount;
+
+}
diff --git a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/UserDocument.java b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/UserDocument.java
index 147f595..06d6c55 100644
--- a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/UserDocument.java
+++ b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/UserDocument.java
@@ -1,7 +1,12 @@
package twitter.challenge.espenia.infra.mongodb.document;
import lombok.*;
+
+import java.util.Date;
+
+import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
+import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@@ -31,8 +36,7 @@ public class UserDocument extends BaseDocument {
private String bio;
- @Override
- public boolean isNew() {
- return id == null || id.isEmpty();
- }
+ @CreatedDate
+ private Date createdAt;
+
}
diff --git a/src/main/java/twitter/challenge/espenia/infra/mongodb/repository/TweetRepository.java b/src/main/java/twitter/challenge/espenia/infra/mongodb/repository/TweetRepository.java
new file mode 100644
index 0000000..cabbd6d
--- /dev/null
+++ b/src/main/java/twitter/challenge/espenia/infra/mongodb/repository/TweetRepository.java
@@ -0,0 +1,7 @@
+package twitter.challenge.espenia.infra.mongodb.repository;
+
+import org.springframework.data.mongodb.repository.MongoRepository;
+import twitter.challenge.espenia.infra.mongodb.document.TweetDocument;
+
+public interface TweetRepository extends MongoRepository {
+}
diff --git a/src/test/java/twitter/challenge/espenia/core/usecase/CreateTweetTest.java b/src/test/java/twitter/challenge/espenia/core/usecase/CreateTweetTest.java
new file mode 100644
index 0000000..12d1440
--- /dev/null
+++ b/src/test/java/twitter/challenge/espenia/core/usecase/CreateTweetTest.java
@@ -0,0 +1,62 @@
+package twitter.challenge.espenia.core.usecase;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import twitter.challenge.espenia.core.domain.Tweet;
+import twitter.challenge.espenia.core.exception.NotFoundException;
+import twitter.challenge.espenia.core.gateway.TweetGateway;
+import twitter.challenge.espenia.core.gateway.UserGateway;
+import twitter.challenge.espenia.core.result.TweetResponse;
+import twitter.challenge.espenia.core.usecase.request.TweetRequest;
+import twitter.challenge.espenia.util.Factory;
+import twitter.challenge.espenia.util.UnitTest;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+public class CreateTweetTest extends UnitTest {
+ @InjectMocks
+ private CreateTweet tweetService;
+
+ @Mock
+ private TweetGateway tweetGateway;
+
+ @Mock
+ private UserGateway userGateway;
+
+
+ @Test
+ void testCreateTweet() {
+ // Given
+ TweetRequest tweetRequest = Factory.sampleTweetCreateRequest();
+ when(userGateway.findById(Factory.USERID)).thenReturn(Factory.sampleUser());
+ when(tweetGateway.create(any(Tweet.class))).thenReturn(Factory.sampleTweet());
+
+ // When
+ TweetResponse response = tweetService.execute(tweetRequest);
+
+ // Then
+ assertNotNull(response);
+ assertEquals(Factory.TWEET_ID, response.id());
+ assertEquals(Factory.USERID, response.userId());
+ assertEquals(Factory.TWEET_CONTENT, response.content());
+ assertEquals(0, response.likeCount());
+
+ verify(userGateway).findById(Factory.USERID);
+ verify(tweetGateway).create(any(Tweet.class));
+ }
+
+ @Test
+ void testCreateTweetWithInvalidUser() {
+ // Given
+ TweetRequest tweetRequest = Factory.sampleTweetCreateRequest();
+ when(userGateway.findById(Factory.USERID)).thenThrow(new NotFoundException("User not found"));
+
+ // When & Then
+ assertThrows(NotFoundException.class, () -> tweetService.execute(tweetRequest));
+ verify(userGateway).findById(Factory.USERID);
+ verify(tweetGateway, never()).create(any(Tweet.class));
+ }
+}
diff --git a/src/test/java/twitter/challenge/espenia/entrypoint/BaseControllerTest.java b/src/test/java/twitter/challenge/espenia/entrypoint/BaseControllerTest.java
index 9608402..801bfcb 100644
--- a/src/test/java/twitter/challenge/espenia/entrypoint/BaseControllerTest.java
+++ b/src/test/java/twitter/challenge/espenia/entrypoint/BaseControllerTest.java
@@ -2,13 +2,18 @@
import org.junit.jupiter.api.BeforeEach;
+import org.springframework.beans.factory.annotation.Autowired;
import twitter.challenge.espenia.infra.config.ObjectMapperConfig;
+import twitter.challenge.espenia.infra.mongodb.repository.TweetRepository;
import twitter.challenge.espenia.infra.mongodb.repository.UserRepository;
import twitter.challenge.espenia.integration.ControllerTest;
import twitter.challenge.espenia.util.Factory;
public abstract class BaseControllerTest extends ControllerTest {
protected final UserRepository userRepository;
+
+ @Autowired
+ protected TweetRepository tweetRepository;
public BaseControllerTest(final UserRepository userRepository) {
super(ObjectMapperConfig.getDefaultObjectMapper());
@@ -17,6 +22,7 @@ public BaseControllerTest(final UserRepository userRepository) {
@BeforeEach
public void setUp() {
+ tweetRepository.deleteAll();
userRepository.deleteAll();
}
diff --git a/src/test/java/twitter/challenge/espenia/entrypoint/TweetControllerTest.java b/src/test/java/twitter/challenge/espenia/entrypoint/TweetControllerTest.java
new file mode 100644
index 0000000..4c5d952
--- /dev/null
+++ b/src/test/java/twitter/challenge/espenia/entrypoint/TweetControllerTest.java
@@ -0,0 +1,69 @@
+package twitter.challenge.espenia.entrypoint;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import twitter.challenge.espenia.core.result.TweetResponse;
+import twitter.challenge.espenia.core.usecase.request.TweetRequest;
+import twitter.challenge.espenia.entrypoint.exception.response.dto.ApiError;
+import twitter.challenge.espenia.infra.mongodb.repository.TweetRepository;
+import twitter.challenge.espenia.infra.mongodb.repository.UserRepository;
+import twitter.challenge.espenia.util.Factory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class TweetControllerTest extends BaseControllerTest {
+
+ @Autowired
+ public TweetControllerTest(final UserRepository userRepository) {
+ super(userRepository);
+ }
+
+ @Test
+ void testCreateTweet() {
+ // Setup user first since tweet requires valid user
+ basicEntitiesSetup();
+
+ // Create a tweet request
+ HttpEntity requestEntity =
+ new HttpEntity<>(Factory.sampleTweetCreateRequest(), this.getDefaultHeaders());
+
+ // Make the request
+ ResponseEntity responseEntity =
+ this.testRestTemplate.exchange(
+ "/api/tweets",
+ HttpMethod.POST, requestEntity, TweetResponse.class);
+
+ // Assert the response
+ assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode());
+ assertNotNull(responseEntity.getBody());
+ asserEqualsButId(responseEntity.getBody(), Factory.sampleTweetResponse());
+ }
+
+ @Test
+ void testCreateTweetWithTooLongContent() {
+ // Setup user first
+ basicEntitiesSetup();
+
+ // Create a tweet with content that exceeds 280 characters
+ String longContent = "a".repeat(281);
+ TweetRequest invalidRequest = new TweetRequest(Factory.USERID, longContent);
+
+ HttpEntity requestEntity =
+ new HttpEntity<>(invalidRequest, this.getDefaultHeaders());
+
+ // Make the request
+ ResponseEntity responseEntity =
+ this.testRestTemplate.exchange(
+ "/api/tweets",
+ HttpMethod.POST, requestEntity, ApiError.class);
+
+ // Assert that the request is rejected with a bad request status
+ assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, responseEntity.getStatusCode());
+ assertEquals("Tweet content must be between 1 and 280 characters", responseEntity.getBody().getMessage());
+ }
+}
diff --git a/src/test/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImplTest.java b/src/test/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImplTest.java
new file mode 100644
index 0000000..3790095
--- /dev/null
+++ b/src/test/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImplTest.java
@@ -0,0 +1,54 @@
+package twitter.challenge.espenia.infra.gateway;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import twitter.challenge.espenia.core.domain.Tweet;
+import twitter.challenge.espenia.infra.mongodb.repository.TweetRepository;
+import twitter.challenge.espenia.infra.mongodb.repository.UserRepository;
+import twitter.challenge.espenia.util.Factory;
+import twitter.challenge.espenia.util.UnitTest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class TweetGatewayImplTest extends UnitTest {
+ @Autowired
+ private TweetGatewayImpl tweetGateway;
+
+ @Autowired
+ private TweetRepository tweetRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @BeforeEach
+ void setUp() {
+ tweetRepository.deleteAll();
+ userRepository.deleteAll();
+
+ // Create user first since we need a valid userId
+ userRepository.save(Factory.sampleUserDocument());
+ tweetRepository.save(Factory.sampleTweetDocument());
+ }
+
+ @Test
+ void testCreateTweet() {
+ // Given
+ Tweet newTweet = Tweet.builder()
+ .userId(Factory.USERID)
+ .content("This is a new test tweet")
+ .likeCount(0)
+ .build();
+
+ // When
+ Tweet createdTweet = tweetGateway.create(newTweet);
+
+ // Then
+ assertNotNull(createdTweet);
+ assertNotNull(createdTweet.getId());
+ assertEquals(Factory.USERID, createdTweet.getUserId());
+ assertEquals("This is a new test tweet", createdTweet.getContent());
+ assertEquals(0, createdTweet.getLikeCount());
+ }
+}
diff --git a/src/test/java/twitter/challenge/espenia/util/Factory.java b/src/test/java/twitter/challenge/espenia/util/Factory.java
index a611529..04cb8eb 100644
--- a/src/test/java/twitter/challenge/espenia/util/Factory.java
+++ b/src/test/java/twitter/challenge/espenia/util/Factory.java
@@ -6,6 +6,10 @@
import twitter.challenge.espenia.core.usecase.request.UserRequest;
import twitter.challenge.espenia.core.usecase.request.UserUpdateRequest;
import twitter.challenge.espenia.infra.mongodb.document.UserDocument;
+import twitter.challenge.espenia.infra.mongodb.document.TweetDocument;
+import twitter.challenge.espenia.core.domain.Tweet;
+import twitter.challenge.espenia.core.usecase.request.TweetRequest;
+import twitter.challenge.espenia.core.result.TweetResponse;
import java.time.ZonedDateTime;
import java.util.Date;
@@ -15,6 +19,8 @@ public final class Factory {
public static final String USERNAME = "espenia";
public static final String USERID = "12345";
public static final String EMAIL = "tatas323@gmail.com";
+ public static final String TWEET_ID = "67890";
+ public static final String TWEET_CONTENT = "This is a test tweet";
public static User sampleUser() {
return User.builder()
@@ -89,4 +95,38 @@ public static UserResponse sampleUserUpdatedResponse() {
.bio("This is my new bio")
.build();
}
+
+ public static Tweet sampleTweet() {
+ return Tweet.builder()
+ .id(TWEET_ID)
+ .userId(USERID)
+ .content(TWEET_CONTENT)
+ .createdAt(new Date())
+ .likeCount(0)
+ .build();
+ }
+
+ public static TweetDocument sampleTweetDocument() {
+ return TweetDocument.builder()
+ .id(TWEET_ID)
+ .userId(USERID)
+ .content(TWEET_CONTENT)
+ .createdAt(new Date())
+ .likeCount(0)
+ .build();
+ }
+
+ public static TweetRequest sampleTweetCreateRequest() {
+ return new TweetRequest(USERID, TWEET_CONTENT);
+ }
+
+ public static TweetResponse sampleTweetResponse() {
+ return TweetResponse.builder()
+ .id(TWEET_ID)
+ .userId(USERID)
+ .content(TWEET_CONTENT)
+ .createdAt(new Date())
+ .likeCount(0L)
+ .build();
+ }
}