From a7ec05d69f321bdaa43a8a23f630842cdefbb5f1 Mon Sep 17 00:00:00 2001 From: Maxime <mpakula@takima.fr> Date: Tue, 11 Feb 2025 18:08:27 +0100 Subject: [PATCH] feat: create leaderboard api --- .gitignore | 37 +++ README.md | 167 ++++++++----- docker-compose.yml | 9 + pom.xml | 234 ++++++++++++++++++ src/main/kotlin/fr/takima/Application.kt | 22 ++ src/main/kotlin/fr/takima/Routing.kt | 17 ++ .../fr/takima/controller/LeaderboardRoutes.kt | 17 ++ .../fr/takima/controller/PlayerRoutes.kt | 95 +++++++ .../fr/takima/dto/PlayerCreationRequestDto.kt | 8 + .../takima/dto/PlayerCreationResponseDto.kt | 10 + .../fr/takima/dto/PlayerLeaderBoardDto.kt | 11 + .../takima/dto/PlayerRetrievalResponseDto.kt | 11 + .../fr/takima/dto/ScoreUpdateRequestDto.kt | 8 + .../fr/takima/dto/ScoreUpdateResponseDto.kt | 10 + .../kotlin/fr/takima/entity/PlayerEntity.kt | 11 + .../exceptions/PlayerNotFoundException.kt | 5 + .../kotlin/fr/takima/model/PlayerModel.kt | 10 + src/main/kotlin/fr/takima/plugins/CORS.kt | 17 ++ src/main/kotlin/fr/takima/plugins/DynamoDb.kt | 38 +++ .../fr/takima/plugins/ExceptionHandling.kt | 27 ++ src/main/kotlin/fr/takima/plugins/Koin.kt | 32 +++ .../kotlin/fr/takima/plugins/Serialization.kt | 12 + .../takima/repository/InitializeDatabase.kt | 14 ++ .../kotlin/fr/takima/repository/PlayerDao.kt | 43 ++++ .../service/DatabaseMigrationService.kt | 40 +++ .../kotlin/fr/takima/service/PlayerService.kt | 103 ++++++++ .../kotlin/fr/takima/utils/PlayerMappers.kt | 52 ++++ .../kotlin/fr/takima/utils/RequireParam.kt | 18 ++ src/main/resources/application.yaml | 10 + src/main/resources/http/demo.http | 80 ++++++ src/main/resources/http/leaderboardApi.http | 36 +++ src/main/resources/logback.xml | 12 + src/main/resources/openapi/documentation.yaml | 161 ++++++++++++ src/test/kotlin/fr/takima/BaseIntegration.kt | 17 ++ src/test/kotlin/fr/takima/TestApplication.kt | 15 ++ .../fr/takima/TestDynamoDbConfiguration.kt | 53 ++++ .../fr/takima/controller/PlayerRouteTest.kt | 48 ++++ src/test/kotlin/fr/takima/plugins/KoinTest.kt | 20 ++ .../fr/takima/service/PlayerServiceTest.kt | 155 ++++++++++++ .../fr/takima/utils/PlayerMapperTest.kt | 36 +++ src/test/resources/application-test.yaml | 6 + 41 files changed, 1670 insertions(+), 57 deletions(-) create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 pom.xml create mode 100644 src/main/kotlin/fr/takima/Application.kt create mode 100644 src/main/kotlin/fr/takima/Routing.kt create mode 100644 src/main/kotlin/fr/takima/controller/LeaderboardRoutes.kt create mode 100644 src/main/kotlin/fr/takima/controller/PlayerRoutes.kt create mode 100644 src/main/kotlin/fr/takima/dto/PlayerCreationRequestDto.kt create mode 100644 src/main/kotlin/fr/takima/dto/PlayerCreationResponseDto.kt create mode 100644 src/main/kotlin/fr/takima/dto/PlayerLeaderBoardDto.kt create mode 100644 src/main/kotlin/fr/takima/dto/PlayerRetrievalResponseDto.kt create mode 100644 src/main/kotlin/fr/takima/dto/ScoreUpdateRequestDto.kt create mode 100644 src/main/kotlin/fr/takima/dto/ScoreUpdateResponseDto.kt create mode 100644 src/main/kotlin/fr/takima/entity/PlayerEntity.kt create mode 100644 src/main/kotlin/fr/takima/exceptions/PlayerNotFoundException.kt create mode 100644 src/main/kotlin/fr/takima/model/PlayerModel.kt create mode 100644 src/main/kotlin/fr/takima/plugins/CORS.kt create mode 100644 src/main/kotlin/fr/takima/plugins/DynamoDb.kt create mode 100644 src/main/kotlin/fr/takima/plugins/ExceptionHandling.kt create mode 100644 src/main/kotlin/fr/takima/plugins/Koin.kt create mode 100644 src/main/kotlin/fr/takima/plugins/Serialization.kt create mode 100644 src/main/kotlin/fr/takima/repository/InitializeDatabase.kt create mode 100644 src/main/kotlin/fr/takima/repository/PlayerDao.kt create mode 100644 src/main/kotlin/fr/takima/service/DatabaseMigrationService.kt create mode 100644 src/main/kotlin/fr/takima/service/PlayerService.kt create mode 100644 src/main/kotlin/fr/takima/utils/PlayerMappers.kt create mode 100644 src/main/kotlin/fr/takima/utils/RequireParam.kt create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/http/demo.http create mode 100644 src/main/resources/http/leaderboardApi.http create mode 100644 src/main/resources/logback.xml create mode 100644 src/main/resources/openapi/documentation.yaml create mode 100644 src/test/kotlin/fr/takima/BaseIntegration.kt create mode 100644 src/test/kotlin/fr/takima/TestApplication.kt create mode 100644 src/test/kotlin/fr/takima/TestDynamoDbConfiguration.kt create mode 100644 src/test/kotlin/fr/takima/controller/PlayerRouteTest.kt create mode 100644 src/test/kotlin/fr/takima/plugins/KoinTest.kt create mode 100644 src/test/kotlin/fr/takima/service/PlayerServiceTest.kt create mode 100644 src/test/kotlin/fr/takima/utils/PlayerMapperTest.kt create mode 100644 src/test/resources/application-test.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7121e6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +.gradle +build/ +target/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index caea4bf..78b3404 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,146 @@ -# LeaderboardApi +# ๐ Leaderboard API + + + +## Introduction -## Getting started +This project is a **web API** designed to manage player rankings during a tournament. -To make it easy for you to get started with GitLab, here's a list of recommended next steps. +### Key Features -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! +- **Add a new player** +- **Update a player's score** +- **Retrieve player details** (username, points, and ranking) +- **Retrieve players sorted by rank** +- **Reset the tournament** (delete all players) -## Add your files +## โ๏ธ Technologies -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: +| Category | Technology | +|------------------------|-----------------| +| **Database** | DynamoDB | +| **Backend** | Kotlin (2.1.10) | +| **Framework** | Ktor (2.3.13) | +| **Dependency Injection** | Koin (4.0.2) | + +## โ๏ธ Technical choices + +**Exception handling** + +Exception handling with the `ktor-server-status-pages` dependency. Mapping of custom exceptions to HTTP codes. + +**Tests** + +Use of Junit 5. + +Use of MockK to facilitate mocking with Kotlin. + +Use of test containers with localstack to start databases on the fly for integration and end-to-end tests. + +**Logs** + +Use of `logback` and `slf4j` to provide consistent logging with low coupling. + +## ๐ป Run the app locally + +### Prerequisites + +To start the application locally, you need to have installed: + +- **Docker** +- **Maven** +- **Java** (version 11+) + +### Start the database + +Using the root folder of the project as the working directory, run the following command to start the database with Docker: + +```shell + docker compose up -d + ``` + +### Start the app + +Using the root folder of the project as the working directory, run the following command to start the app with Maven: +```shell +mvn clean compile exec:java ``` -cd existing_repo -git remote add origin https://gitlab.takima.io/test-technique/leaderboardapi.git -git branch -M main -git push -uf origin main + +### Explore the API + +If you are using **IntelliJ** as your IDE, you can test the API using the `.http` file located in `src/main/resources/http`. + +Alternatively, you can reference the HTTP requests from that file and use other tools like **Postman**, **Insomnia**, etc. + +## ๐งช Tests + +The tests can be run with the following command: + +```shell + mvn clean test ``` -## Integrate with your tools +## ๐งน Linting + +This project uses **Ktlint** for linting. + +### IntelliJ Setup + +If you are using **IntelliJ**, follow these steps: -- [ ] [Set up project integrations](https://gitlab.takima.io/test-technique/leaderboardapi/-/settings/integrations) +1. Install Ktlint via **`File` > `Settings` > `Plugins`**. +2. You may need to restart your IDE. +3. Once Ktlint is installed, go to **`File` > `Settings` > `Tools` > `Ktlint`**, then: + - Enable **Distract free** mode. + - Check **On save**. -## Collaborate with your team +## ๐ Before going to production -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) +**Better documentation** -## Test and Deploy +Adding an OpenAPI documentation would help to maintain the project. -Use the built-in continuous integration in GitLab. +**Improving performances** -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) +In the current implementation the backend retrieves all the players from the database to rank them. Better performances could be achieved by ranking players at the database level and adding pagination. -*** +**Achieving a greater test coverage** -# Editing this README +Adding more unit tests, integration tests and end-to-end tests to achieve a greater test coverage is crucial to build a robust application. -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. +**Handling transactions** -## Suggestions for a good README +Handling transactions to avoid unwanted behaviours is necessary before moving to production. -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. +**Using credentials with the database** -## Name -Choose a self-explaining name for your project. +Adding credentials to access the database is crucial to improve security. -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. +**Proper CORS handling** -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. +In the current implementation, CORS is configured with broad permissions, allowing all hosts. Refining CORS rules will ensure that only trusted sources can interact with the API, reducing potential security risks. -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. +**Release pipeline** -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. +A release pipeline would ensure a reliable and efficient release process by automatically incrementing version numbers, building and tagging artifacts and pushing them on a registry. This could be done with Gitlab CI/CD and the Gitlab Registry. -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. +**Setting up a development and a production environment** -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. +In order to go to production there is a need to configure a development and a production environment. AWS could be used as a cloud provider and Terraform could be used to provision the instances with the managed service `aws_dynamodb_table` for the database and an ECS or EKS for the backend. -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. +**Integration pipeline** -## Contributing -State if you are open to contributions and what your requirements are for accepting them. +A CI pipeline automates the process of building, testing, packaging, and deploying code, is crucial to ensure reliable integration and accelerate delivery. This could be done with Gitlab CI/CD. -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. +**Require authentication** -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. +In the current implementation, the API is open and does not require authentication, meaning anyone can send requests. However, to manage a leaderboard for a real tournament, it is necessary to implement authentication and role-based access control. This would allow for the designation of an administrator role and help prevent data manipulation. Typical tools would be the security dependency of Ktor. -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. +_Documentation on Ktor security configuration_ : [Authentication and authorization in Ktor Server on Ktor's official documentation](https://ktor.io/docs/server-auth.html) and [Ktor Server Auth on Maven Repository](https://mvnrepository.com/artifact/io.ktor/ktor-server-auth-jvm). -## License -For open source projects, say how it is licensed. +**Enabling alerting and monitoring** -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +Alerting and monitoring is crucial for proactive issue detection. The project would benefit from having such tools before going to production. Typical tools would be Sentry, Datadog, Grafana, etc. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..53dc08b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + dynamodb-local: + image: "amazon/dynamodb-local:latest" + container_name: dynamodb-local + ports: + - "8000:8000" + volumes: + - "./docker/dynamodb:/home/dynamodblocal/data" + working_dir: /home/dynamodblocal \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7fada62 --- /dev/null +++ b/pom.xml @@ -0,0 +1,234 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <groupId>fr.takima</groupId> + <artifactId>leaderboardapi</artifactId> + <version>alpha-0.0.1-SNAPSHOT</version> + <name>LeaderboardApi</name> + <description>LeaderboardApi</description> + <properties> + <kotlin.code.style>official</kotlin.code.style> + <kotlin_version>2.1.10</kotlin_version> + <ktor_version>2.3.13</ktor_version> + <koin_version>4.0.2</koin_version> + <dynamodb_version>2.30.14</dynamodb_version> + <logback_version>1.4.14</logback_version> + <slf4j_version>2.0.9</slf4j_version> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + <kotlin.compiler.incremental>true</kotlin.compiler.incremental> + <main.class>io.ktor.server.netty.EngineMain</main.class> + </properties> + <repositories> + </repositories> + <dependencies> + +<!-- Ktor--> + <dependency> + <groupId>io.ktor</groupId> + <artifactId>ktor-server-config-yaml-jvm</artifactId> + <version>${ktor_version}</version> + </dependency> + <dependency> + <groupId>io.ktor</groupId> + <artifactId>ktor-server-core-jvm</artifactId> + <version>${ktor_version}</version> + </dependency> + <dependency> + <groupId>io.ktor</groupId> + <artifactId>ktor-server-netty-jvm</artifactId> + <version>${ktor_version}</version> + </dependency> + +<!-- Dependency Injection--> + <dependency> + <groupId>io.insert-koin</groupId> + <artifactId>koin-ktor</artifactId> + <version>${koin_version}</version> + </dependency> + <dependency> + <groupId>io.insert-koin</groupId> + <artifactId>koin-test-core</artifactId> + <version>2.2.3</version> + </dependency> + +<!-- Serialization--> + <dependency> + <groupId>io.ktor</groupId> + <artifactId>ktor-serialization-kotlinx-json-jvm</artifactId> + <version>${ktor_version}</version> + </dependency> + +<!-- Content Negociation--> + <dependency> + <groupId>io.ktor</groupId> + <artifactId>ktor-server-content-negotiation-jvm</artifactId> + <version>${ktor_version}</version> + </dependency> + +<!-- Exception handling--> + <dependency> + <groupId>io.ktor</groupId> + <artifactId>ktor-server-status-pages-jvm</artifactId> + <version>${ktor_version}</version> + </dependency> + +<!-- Persitence--> + <dependency> + <groupId>software.amazon.awssdk</groupId> + <artifactId>dynamodb-enhanced</artifactId> + <version>${dynamodb_version}</version> + </dependency> + <dependency> + <groupId>software.amazon.awssdk</groupId> + <artifactId>dynamodb</artifactId> + <version>${dynamodb_version}</version> + </dependency> + <dependency> + <groupId>dev.andrewohara</groupId> + <artifactId>dynamokt</artifactId> + <version>1.0.0</version> + </dependency> + +<!-- Logs--> + <dependency> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-classic</artifactId> + <version>${logback_version}</version> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + <version>${slf4j_version}</version> + </dependency> + +<!-- Test--> + <dependency> + <groupId>io.ktor</groupId> + <artifactId>ktor-server-test-host-jvm</artifactId> + <version>${ktor_version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>io.mockk</groupId> + <artifactId>mockk-jvm</artifactId> + <version>1.13.2</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>localstack</artifactId> + <version>1.20.4</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.jetbrains.kotlin</groupId> + <artifactId>kotlin-test-junit</artifactId> + <version>${kotlin_version}</version> + <scope>test</scope> + </dependency> + +<!-- CORS--> + <dependency> + <groupId>io.ktor</groupId> + <artifactId>ktor-server-cors-jvm</artifactId> + <version>${ktor_version}</version> + </dependency> + + </dependencies> + <build> + <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory> + <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory> + <resources> + <resource> + <directory>${project.basedir}/src/main/resources</directory> + </resource> + </resources> + + <plugins> + <plugin> + <artifactId>kotlin-maven-plugin</artifactId> + <groupId>org.jetbrains.kotlin</groupId> + <version>${kotlin_version}</version> + <configuration> + <jvmTarget>1.8</jvmTarget> + <compilerPlugins> + <plugin>kotlinx-serialization</plugin> + </compilerPlugins> + </configuration> + <executions> + <execution> + <id>compile</id> + <phase>compile</phase> + <goals> + <goal>compile</goal> + </goals> + </execution> + <execution> + <id>test-compile</id> + <phase>test-compile</phase> + <goals> + <goal>test-compile</goal> + </goals> + </execution> + </executions> + <dependencies> + <dependency> + <groupId>org.jetbrains.kotlin</groupId> + <artifactId>kotlin-maven-serialization</artifactId> + <version>${kotlin_version}</version> + </dependency> + </dependencies> + </plugin> + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>exec-maven-plugin</artifactId> + <version>1.2.1</version> + <executions> + <execution> + <goals> + <goal>java</goal> + </goals> + </execution> + </executions> + <configuration> + <mainClass>${main.class}</mainClass> + </configuration> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-assembly-plugin</artifactId> + <version>2.6</version> + <configuration> + <descriptorRefs> + <descriptorRef>jar-with-dependencies</descriptorRef> + </descriptorRefs> + <archive> + <manifest> + <addClasspath>true</addClasspath> + <mainClass>${main.class}</mainClass> + </manifest> + </archive> + </configuration> + <executions> + <execution> + <id>assemble-all</id> + <phase>package</phase> + <goals> + <goal>single</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <version>3.13.0</version> + <configuration> + <source>11</source> + <target>11</target> + </configuration> + </plugin> + </plugins> + </build> +</project> \ No newline at end of file diff --git a/src/main/kotlin/fr/takima/Application.kt b/src/main/kotlin/fr/takima/Application.kt new file mode 100644 index 0000000..1e4df26 --- /dev/null +++ b/src/main/kotlin/fr/takima/Application.kt @@ -0,0 +1,22 @@ +package fr.takima + +import fr.takima.plugins.configureCors +import fr.takima.plugins.configureExceptionHandling +import fr.takima.plugins.configureKoin +import fr.takima.plugins.configureSerialization +import fr.takima.repository.migrateDatabase +import io.ktor.server.application.Application + +fun main(args: Array<String>) { + io.ktor.server.netty.EngineMain + .main(args) +} + +fun Application.module() { + configureKoin() + configureRouting() + configureSerialization() + configureExceptionHandling() + configureCors() + migrateDatabase() +} diff --git a/src/main/kotlin/fr/takima/Routing.kt b/src/main/kotlin/fr/takima/Routing.kt new file mode 100644 index 0000000..12cb653 --- /dev/null +++ b/src/main/kotlin/fr/takima/Routing.kt @@ -0,0 +1,17 @@ +package fr.takima + +import fr.takima.controller.playerRoutes +import fr.takima.fr.takima.controller.leaderboardRoutes +import fr.takima.service.PlayerService +import io.ktor.server.application.Application +import io.ktor.server.routing.routing +import org.koin.ktor.ext.inject + +fun Application.configureRouting() { + routing { + val playerService by inject<PlayerService>() + + playerRoutes(playerService) + leaderboardRoutes(playerService) + } +} diff --git a/src/main/kotlin/fr/takima/controller/LeaderboardRoutes.kt b/src/main/kotlin/fr/takima/controller/LeaderboardRoutes.kt new file mode 100644 index 0000000..925a50d --- /dev/null +++ b/src/main/kotlin/fr/takima/controller/LeaderboardRoutes.kt @@ -0,0 +1,17 @@ +package fr.takima.fr.takima.controller + +import fr.takima.service.PlayerService +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get + +fun Route.leaderboardRoutes(playerService: PlayerService) { + get("/leaderboard") { + call.respond( + status = HttpStatusCode.OK, + message = playerService.getLeaderboard(), + ) + } +} diff --git a/src/main/kotlin/fr/takima/controller/PlayerRoutes.kt b/src/main/kotlin/fr/takima/controller/PlayerRoutes.kt new file mode 100644 index 0000000..1ebf48f --- /dev/null +++ b/src/main/kotlin/fr/takima/controller/PlayerRoutes.kt @@ -0,0 +1,95 @@ +package fr.takima.controller + +import fr.takima.dto.PlayerCreationRequestDto +import fr.takima.dto.ScoreUpdateRequestDto +import fr.takima.service.PlayerService +import fr.takima.utils.requireBody +import fr.takima.utils.requirePathVariable +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.call +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import io.ktor.util.pipeline.PipelineContext +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger(Application::class.java) + +fun Route.playerRoutes(playerService: PlayerService) { + route("players") { + post { + createNewPlayer(playerService) + } + + delete { + deleteAllPlayers(playerService) + } + + get("{playerId}") { + retrievePlayer(playerService) + } + + put("{playerId}/score") { + updatePlayerScore(playerService) + } + } +} + +private suspend fun PipelineContext<Unit, ApplicationCall>.createNewPlayer(playerService: PlayerService) { + val playerCreationRequestDto = call.requireBody<PlayerCreationRequestDto>() ?: return + + logger.info("Creating a new player...") + + val playerCreationResponseDto = playerService.createPlayer(playerCreationRequestDto) + + logger.info("New player created with id ${playerCreationResponseDto.playerId}") + + call.respond( + status = HttpStatusCode.Created, + message = playerCreationResponseDto, + ) +} + +private suspend fun PipelineContext<Unit, ApplicationCall>.deleteAllPlayers(playerService: PlayerService) { + logger.info("Deleting all players.") + + playerService.deleteAll() + + call.respond( + HttpStatusCode.NoContent, + ) +} + +private suspend fun PipelineContext<Unit, ApplicationCall>.retrievePlayer(playerService: PlayerService) { + val playerId = call.requirePathVariable("playerId") ?: return + + logger.info("Getting player.") + + val playerRetrievalResponseDto = playerService.getByIdWithRank(playerId) + + call.respond( + status = HttpStatusCode.OK, + message = playerRetrievalResponseDto, + ) +} + +private suspend fun PipelineContext<Unit, ApplicationCall>.updatePlayerScore(playerService: PlayerService) { + val playerId = call.requirePathVariable("playerId") ?: return + + val scoreUpdateRequestDto = call.requireBody<ScoreUpdateRequestDto>() ?: return + + logger.info("Setting a new score for player with id $playerId: ${scoreUpdateRequestDto.score} points.") + + val playerUpdated = playerService.updateScore(playerId, scoreUpdateRequestDto.score) + + call.respond( + status = HttpStatusCode.OK, + message = playerUpdated, + ) +} diff --git a/src/main/kotlin/fr/takima/dto/PlayerCreationRequestDto.kt b/src/main/kotlin/fr/takima/dto/PlayerCreationRequestDto.kt new file mode 100644 index 0000000..22a7066 --- /dev/null +++ b/src/main/kotlin/fr/takima/dto/PlayerCreationRequestDto.kt @@ -0,0 +1,8 @@ +package fr.takima.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerCreationRequestDto( + val name: String, +) diff --git a/src/main/kotlin/fr/takima/dto/PlayerCreationResponseDto.kt b/src/main/kotlin/fr/takima/dto/PlayerCreationResponseDto.kt new file mode 100644 index 0000000..9a006d2 --- /dev/null +++ b/src/main/kotlin/fr/takima/dto/PlayerCreationResponseDto.kt @@ -0,0 +1,10 @@ +package fr.takima.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerCreationResponseDto( + val playerId: String, + val name: String, + val score: Long, +) diff --git a/src/main/kotlin/fr/takima/dto/PlayerLeaderBoardDto.kt b/src/main/kotlin/fr/takima/dto/PlayerLeaderBoardDto.kt new file mode 100644 index 0000000..4cbf338 --- /dev/null +++ b/src/main/kotlin/fr/takima/dto/PlayerLeaderBoardDto.kt @@ -0,0 +1,11 @@ +package fr.takima.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerLeaderBoardDto( + val playerId: String, + val name: String, + val score: Long, + val rank: Int, +) diff --git a/src/main/kotlin/fr/takima/dto/PlayerRetrievalResponseDto.kt b/src/main/kotlin/fr/takima/dto/PlayerRetrievalResponseDto.kt new file mode 100644 index 0000000..d6283a1 --- /dev/null +++ b/src/main/kotlin/fr/takima/dto/PlayerRetrievalResponseDto.kt @@ -0,0 +1,11 @@ +package fr.takima.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PlayerRetrievalResponseDto( + val playerId: String, + val name: String, + val score: Long, + val rank: Int, +) diff --git a/src/main/kotlin/fr/takima/dto/ScoreUpdateRequestDto.kt b/src/main/kotlin/fr/takima/dto/ScoreUpdateRequestDto.kt new file mode 100644 index 0000000..680b8b8 --- /dev/null +++ b/src/main/kotlin/fr/takima/dto/ScoreUpdateRequestDto.kt @@ -0,0 +1,8 @@ +package fr.takima.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ScoreUpdateRequestDto( + val score: Long, +) diff --git a/src/main/kotlin/fr/takima/dto/ScoreUpdateResponseDto.kt b/src/main/kotlin/fr/takima/dto/ScoreUpdateResponseDto.kt new file mode 100644 index 0000000..9b1c9a4 --- /dev/null +++ b/src/main/kotlin/fr/takima/dto/ScoreUpdateResponseDto.kt @@ -0,0 +1,10 @@ +package fr.takima.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ScoreUpdateResponseDto( + val playerId: String, + val name: String, + val score: Long, +) diff --git a/src/main/kotlin/fr/takima/entity/PlayerEntity.kt b/src/main/kotlin/fr/takima/entity/PlayerEntity.kt new file mode 100644 index 0000000..9f0f958 --- /dev/null +++ b/src/main/kotlin/fr/takima/entity/PlayerEntity.kt @@ -0,0 +1,11 @@ +package fr.takima.entity + +import dev.andrewohara.dynamokt.DynamoKtPartitionKey +import java.util.UUID + +data class PlayerEntity( + @DynamoKtPartitionKey + val playerId: UUID, + val name: String, + val score: Long, +) diff --git a/src/main/kotlin/fr/takima/exceptions/PlayerNotFoundException.kt b/src/main/kotlin/fr/takima/exceptions/PlayerNotFoundException.kt new file mode 100644 index 0000000..317de6c --- /dev/null +++ b/src/main/kotlin/fr/takima/exceptions/PlayerNotFoundException.kt @@ -0,0 +1,5 @@ +package fr.takima.exceptions + +class PlayerNotFoundException( + playerId: String, +) : RuntimeException("No player with id $playerId") diff --git a/src/main/kotlin/fr/takima/model/PlayerModel.kt b/src/main/kotlin/fr/takima/model/PlayerModel.kt new file mode 100644 index 0000000..505ba5d --- /dev/null +++ b/src/main/kotlin/fr/takima/model/PlayerModel.kt @@ -0,0 +1,10 @@ +package fr.takima.model + +import java.util.UUID + +data class PlayerModel( + val playerId: UUID, + val name: String, + val score: Long, + val rank: Int? = null, +) diff --git a/src/main/kotlin/fr/takima/plugins/CORS.kt b/src/main/kotlin/fr/takima/plugins/CORS.kt new file mode 100644 index 0000000..891d63e --- /dev/null +++ b/src/main/kotlin/fr/takima/plugins/CORS.kt @@ -0,0 +1,17 @@ +package fr.takima.plugins + +import io.ktor.http.HttpMethod +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.cors.routing.CORS + +fun Application.configureCors() { + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Put) + allowMethod(HttpMethod.Delete) + allowMethod(HttpMethod.Patch) + allowMethod(HttpMethod.Get) + anyHost() + } +} diff --git a/src/main/kotlin/fr/takima/plugins/DynamoDb.kt b/src/main/kotlin/fr/takima/plugins/DynamoDb.kt new file mode 100644 index 0000000..ea5bba9 --- /dev/null +++ b/src/main/kotlin/fr/takima/plugins/DynamoDb.kt @@ -0,0 +1,38 @@ +package fr.takima.plugins + +import io.ktor.server.config.ApplicationConfig +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import java.net.URI + +fun createDynamoDbClient(): DynamoDbAsyncClient { + val url = + ApplicationConfig("application.yaml") + .property("ktor.database.dynamodbUrl") + .getString() + val accessKeyId = + ApplicationConfig("application.yaml") + .property("ktor.database.accessKeyId") + .getString() + val secretAccessKey = + ApplicationConfig("application.yaml") + .property("ktor.database.secretAccessKey") + .getString() + + return DynamoDbAsyncClient + .builder() + .endpointOverride(URI(url)) + .region(Region.EU_WEST_1) + .credentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKeyId, secretAccessKey)), + ).build() +} + +fun createEnhancedDynamoDbClient(dynamoDbClient: DynamoDbAsyncClient): DynamoDbEnhancedAsyncClient = + DynamoDbEnhancedAsyncClient + .builder() + .dynamoDbClient(dynamoDbClient) + .build() diff --git a/src/main/kotlin/fr/takima/plugins/ExceptionHandling.kt b/src/main/kotlin/fr/takima/plugins/ExceptionHandling.kt new file mode 100644 index 0000000..136a49a --- /dev/null +++ b/src/main/kotlin/fr/takima/plugins/ExceptionHandling.kt @@ -0,0 +1,27 @@ +package fr.takima.plugins + +import fr.takima.exceptions.PlayerNotFoundException +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.install +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.response.respond + +fun Application.configureExceptionHandling() { + install(StatusPages) { + exception<Throwable> { call, cause -> + handle(call, cause) + } + } +} + +suspend fun handle( + call: ApplicationCall, + cause: Throwable, +) { + when (cause) { + is PlayerNotFoundException -> { + call.respond(status = io.ktor.http.HttpStatusCode.NotFound, message = "$cause") + } + } +} diff --git a/src/main/kotlin/fr/takima/plugins/Koin.kt b/src/main/kotlin/fr/takima/plugins/Koin.kt new file mode 100644 index 0000000..a0b038d --- /dev/null +++ b/src/main/kotlin/fr/takima/plugins/Koin.kt @@ -0,0 +1,32 @@ +package fr.takima.plugins + +import fr.takima.repository.PlayerDao +import fr.takima.service.DatabaseMigrationService +import fr.takima.service.PlayerService +import io.ktor.server.application.Application +import io.ktor.server.application.install +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin + +fun Application.configureKoin() { + install(Koin) { + modules(databaseModule) + modules(leaderBoardModule) + } +} + +val databaseModule = + module { + single { createDynamoDbClient() } + + single { createEnhancedDynamoDbClient(get()) } + } + +val leaderBoardModule = + module { + single { DatabaseMigrationService(get(), get()) } + + single { PlayerDao(get()) } + + single { PlayerService(get(), get()) } + } diff --git a/src/main/kotlin/fr/takima/plugins/Serialization.kt b/src/main/kotlin/fr/takima/plugins/Serialization.kt new file mode 100644 index 0000000..4918cd2 --- /dev/null +++ b/src/main/kotlin/fr/takima/plugins/Serialization.kt @@ -0,0 +1,12 @@ +package fr.takima.plugins + +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json() + } +} diff --git a/src/main/kotlin/fr/takima/repository/InitializeDatabase.kt b/src/main/kotlin/fr/takima/repository/InitializeDatabase.kt new file mode 100644 index 0000000..66ebffd --- /dev/null +++ b/src/main/kotlin/fr/takima/repository/InitializeDatabase.kt @@ -0,0 +1,14 @@ +package fr.takima.repository + +import fr.takima.service.DatabaseMigrationService +import io.ktor.server.application.Application +import kotlinx.coroutines.runBlocking +import org.koin.ktor.ext.inject + +fun Application.migrateDatabase() { + val databaseMigrationService: DatabaseMigrationService by inject<DatabaseMigrationService>() + + runBlocking { + databaseMigrationService.createTable() + } +} diff --git a/src/main/kotlin/fr/takima/repository/PlayerDao.kt b/src/main/kotlin/fr/takima/repository/PlayerDao.kt new file mode 100644 index 0000000..6416f2c --- /dev/null +++ b/src/main/kotlin/fr/takima/repository/PlayerDao.kt @@ -0,0 +1,43 @@ +package fr.takima.repository + +import dev.andrewohara.dynamokt.DataClassTableSchema +import fr.takima.entity.PlayerEntity +import kotlinx.coroutines.future.await +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient +import software.amazon.awssdk.enhanced.dynamodb.Key +import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest + +class PlayerDao( + private val enhancedClient: DynamoDbEnhancedAsyncClient, +) { + private val tableName = PlayerEntity::class.simpleName + private val tableSchema = DataClassTableSchema(PlayerEntity::class) + private val table = enhancedClient.table(tableName, tableSchema) + + suspend fun persist(playerEntity: PlayerEntity): PlayerEntity { + table + .putItemWithResponse( + PutItemEnhancedRequest + .builder(PlayerEntity::class.java) + .item(playerEntity) + .build(), + ).await() + return playerEntity + } + + suspend fun findById(playerId: String): PlayerEntity? = + table + .getItem( + Key.builder().partitionValue(playerId).build(), + ).await() + + // TODO ("Add pagination") + suspend fun findAll(): List<PlayerEntity> = + buildList { + table + .scan() + .items() + .subscribe { add(it) } + .await() + } +} diff --git a/src/main/kotlin/fr/takima/service/DatabaseMigrationService.kt b/src/main/kotlin/fr/takima/service/DatabaseMigrationService.kt new file mode 100644 index 0000000..8bdbf1c --- /dev/null +++ b/src/main/kotlin/fr/takima/service/DatabaseMigrationService.kt @@ -0,0 +1,40 @@ +package fr.takima.service + +import dev.andrewohara.dynamokt.DataClassTableSchema +import fr.takima.entity.PlayerEntity +import kotlinx.coroutines.future.await +import org.slf4j.LoggerFactory +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient + +class DatabaseMigrationService( + private val enhancedClient: DynamoDbEnhancedAsyncClient, + private val client: DynamoDbAsyncClient, +) { + private val logger = LoggerFactory.getLogger(DatabaseMigrationService::class.java) + + private val playerTableName = PlayerEntity::class.simpleName + private val playerTableSchema = DataClassTableSchema(PlayerEntity::class) + + suspend fun createTable() { + val existingTables = client.listTables().await().tableNames() + + if (existingTables.contains(playerTableName)) { + logger.info("Table '$playerTableName' already exists.") + } else { + enhancedClient.table(playerTableName, playerTableSchema).createTable().await() + logger.info("Table '$playerTableName' created successfully.") + } + } + + suspend fun deleteTable() { + val existingTables = client.listTables().await().tableNames() + + if (existingTables.contains(playerTableName)) { + enhancedClient.table(playerTableName, playerTableSchema).deleteTable().await() + logger.info("Table '$playerTableName' deleted successfully.") + } else { + logger.info("Table '$playerTableName' does not exist.") + } + } +} diff --git a/src/main/kotlin/fr/takima/service/PlayerService.kt b/src/main/kotlin/fr/takima/service/PlayerService.kt new file mode 100644 index 0000000..958c92e --- /dev/null +++ b/src/main/kotlin/fr/takima/service/PlayerService.kt @@ -0,0 +1,103 @@ +package fr.takima.service + +import fr.takima.dto.PlayerCreationRequestDto +import fr.takima.dto.PlayerCreationResponseDto +import fr.takima.dto.PlayerLeaderBoardDto +import fr.takima.dto.PlayerRetrievalResponseDto +import fr.takima.dto.ScoreUpdateResponseDto +import fr.takima.exceptions.PlayerNotFoundException +import fr.takima.model.PlayerModel +import fr.takima.repository.PlayerDao +import fr.takima.utils.toPlayerCreationResponseDto +import fr.takima.utils.toPlayerEntity +import fr.takima.utils.toPlayerLeaderboardDto +import fr.takima.utils.toPlayerModel +import fr.takima.utils.toPlayerRetrievalResponseDto +import fr.takima.utils.toScoreUpdateResponseDto +import java.util.UUID + +class PlayerService( + private val playerDao: PlayerDao, + private val databaseMigrationService: DatabaseMigrationService, +) { + // TODO("Handle transactions") + suspend fun createPlayer(playerCreationDto: PlayerCreationRequestDto): PlayerCreationResponseDto { + val playerModel = + PlayerModel( + playerId = UUID.randomUUID(), + name = playerCreationDto.name, + score = 0, + ) + return playerDao + .persist( + playerModel.toPlayerEntity(), + ).toPlayerModel() + .toPlayerCreationResponseDto() + } + + suspend fun getByIdWithRank(playerId: String): PlayerRetrievalResponseDto = + ( + computeLeaderboard() + .find { player -> player.playerId.toString() == playerId } ?: throw PlayerNotFoundException(playerId) + ).toPlayerRetrievalResponseDto() + + suspend fun getLeaderboard(): List<PlayerLeaderBoardDto> = + computeLeaderboard() + .map { player -> + player.toPlayerLeaderboardDto() + } + + // TODO("Handle transactions") + suspend fun updateScore( + playerId: String, + score: Long, + ): ScoreUpdateResponseDto { + val playerEntity = + ( + findById(playerId) + ?: throw PlayerNotFoundException(playerId) + ).copy(score = score) + .toPlayerEntity() + + return playerDao + .persist( + playerEntity, + ).toPlayerModel() + .toScoreUpdateResponseDto() + } + + // TODO("Handle transactions") + suspend fun deleteAll() { + databaseMigrationService.deleteTable() + databaseMigrationService.createTable() + } + + private suspend fun findById(playerId: String): PlayerModel? = + playerDao + .findById(playerId) + ?.toPlayerModel() + + private suspend fun computeLeaderboard(): List<PlayerModel> { + val sortedPlayers = + playerDao + .findAll() + .map { playerEntity -> playerEntity.toPlayerModel() } + .sortedByDescending { player -> player.score } + + if (sortedPlayers.isEmpty()) return sortedPlayers + + var currentRank = 1 + var previousPoint: Long = sortedPlayers.first().score + + return sortedPlayers + .mapIndexed { index, player -> + if (player.score != previousPoint) { + currentRank = index + 1 + } + previousPoint = player.score + player.copy( + rank = currentRank, + ) + } + } +} diff --git a/src/main/kotlin/fr/takima/utils/PlayerMappers.kt b/src/main/kotlin/fr/takima/utils/PlayerMappers.kt new file mode 100644 index 0000000..03b336e --- /dev/null +++ b/src/main/kotlin/fr/takima/utils/PlayerMappers.kt @@ -0,0 +1,52 @@ +package fr.takima.utils + +import fr.takima.dto.PlayerCreationResponseDto +import fr.takima.dto.PlayerLeaderBoardDto +import fr.takima.dto.PlayerRetrievalResponseDto +import fr.takima.dto.ScoreUpdateResponseDto +import fr.takima.entity.PlayerEntity +import fr.takima.model.PlayerModel + +fun PlayerModel.toPlayerEntity() = + PlayerEntity( + playerId = this.playerId, + name = this.name, + score = this.score, + ) + +fun PlayerModel.toPlayerCreationResponseDto() = + PlayerCreationResponseDto( + playerId = this.playerId.toString(), + name = this.name, + score = this.score, + ) + +fun PlayerEntity.toPlayerModel() = + PlayerModel( + playerId = this.playerId, + name = this.name, + score = this.score, + ) + +fun PlayerModel.toPlayerRetrievalResponseDto() = + PlayerRetrievalResponseDto( + playerId = this.playerId.toString(), + name = this.name, + score = this.score, + rank = this.rank ?: throw IllegalStateException("Player must have a rank"), + ) + +fun PlayerModel.toScoreUpdateResponseDto() = + ScoreUpdateResponseDto( + playerId = this.playerId.toString(), + name = this.name, + score = this.score, + ) + +fun PlayerModel.toPlayerLeaderboardDto() = + PlayerLeaderBoardDto( + playerId = this.playerId.toString(), + name = this.name, + score = this.score, + rank = this.rank ?: throw IllegalStateException("Player must have a rank"), + ) diff --git a/src/main/kotlin/fr/takima/utils/RequireParam.kt b/src/main/kotlin/fr/takima/utils/RequireParam.kt new file mode 100644 index 0000000..9f401a2 --- /dev/null +++ b/src/main/kotlin/fr/takima/utils/RequireParam.kt @@ -0,0 +1,18 @@ +package fr.takima.utils + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receiveNullable +import io.ktor.server.response.respond + +suspend fun ApplicationCall.requirePathVariable(paramName: String): String? = + this.parameters[paramName] ?: run { + this.respond(HttpStatusCode.BadRequest) + return null + } + +suspend inline fun <reified T> ApplicationCall.requireBody(): T? = + this.receiveNullable<T>() ?: run { + this.respond(HttpStatusCode.BadRequest) + return null + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..49d1db1 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,10 @@ +ktor: + application: + modules: + - fr.takima.ApplicationKt.module + deployment: + port: 8080 + database: + dynamodbUrl: "http://localhost:8000" + accessKeyId: "dummy" + secretAccessKey: "dummy" diff --git a/src/main/resources/http/demo.http b/src/main/resources/http/demo.http new file mode 100644 index 0000000..fba9915 --- /dev/null +++ b/src/main/resources/http/demo.http @@ -0,0 +1,80 @@ +### +# @name Create a new player +# @no-cookie-jar +# @no-log +POST http://0.0.0.0:8080/players +Content-Type: application/json + +{ + "name": "Alice" +} + +### +# @name Create a new player +# @no-cookie-jar +# @no-log +POST http://0.0.0.0:8080/players +Content-Type: application/json + +{ + "name": "Bob" +} + +### +# @name Create a new player +# @no-cookie-jar +# @no-log +POST http://0.0.0.0:8080/players +Content-Type: application/json + +{ + "name": "Charlie" +} + +### +# @name Get Charlie +# @no-cookie-jar +# @no-log +@charlieId = [FILL UUID] + +GET http://0.0.0.0:8080/players/{{charlieId}} +Content-Type: application/json + +### +# @name Update Charlie's score +# @no-cookie-jar +# @no-log +PUT http://0.0.0.0:8080/players/{{charlieId}}/score +Content-Type: application/json + +{ + "score" : 500 +} + +### +# @name Get Charlie +# @no-cookie-jar +# @no-log +GET http://0.0.0.0:8080/players/{{charlieId}} +Content-Type: application/json + +### +# @name Get leaderboard +# @no-cookie-jar +# @no-log +GET http://0.0.0.0:8080/leaderboard +Content-Type: application/json + +### +# @name Delete all players +# @no-cookie-jar +# @no-log +DELETE http://0.0.0.0:8080/players +Content-Type: application/json + +### +# @name Get leaderboard +# @no-cookie-jar +# @no-log +GET http://0.0.0.0:8080/leaderboard +Content-Type: application/json diff --git a/src/main/resources/http/leaderboardApi.http b/src/main/resources/http/leaderboardApi.http new file mode 100644 index 0000000..2005136 --- /dev/null +++ b/src/main/resources/http/leaderboardApi.http @@ -0,0 +1,36 @@ +# @no-cookie-jar + +@playerId = [INSERT A UUID HERE IF NECESSARY] + + +### Create a new player +POST http://0.0.0.0:8080/players +Content-Type: application/json + +{ + "name": "Alice" +} + + +### Get a player +GET http://0.0.0.0:8080/players/{{playerId}} +Content-Type: application/json + + +### Update a player's score +PUT http://0.0.0.0:8080/players/{{playerId}}/score +Content-Type: application/json + +{ + "score" : 500 +} + + +### Get the leaderboard +GET http://0.0.0.0:8080/leaderboard +Content-Type: application/json + + +### Delete all players +DELETE http://0.0.0.0:8080/players +Content-Type: application/json diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..aadef5d --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,12 @@ +<configuration> + <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> + </encoder> + </appender> + <root level="INFO"> + <appender-ref ref="STDOUT"/> + </root> + <logger name="org.eclipse.jetty" level="INFO"/> + <logger name="io.netty" level="INFO"/> +</configuration> \ No newline at end of file diff --git a/src/main/resources/openapi/documentation.yaml b/src/main/resources/openapi/documentation.yaml new file mode 100644 index 0000000..c69800f --- /dev/null +++ b/src/main/resources/openapi/documentation.yaml @@ -0,0 +1,161 @@ +openapi: "2.0.0" +info: + title: "LeaderboardApi API" + description: "LeaderboardApi API" + version: "1.0.0" +servers: +- url: "https://LeaderboardApi" +paths: + /leaderboard: + get: + description: "" + responses: + "200": + description: "OK" + content: + '*/*': + schema: + type: "array" + items: + $ref: "#/components/schemas/PlayerLeaderBoardDto" + /players: + delete: + description: "" + responses: + "204": + description: "No Content" + content: + '*/*': + schema: + type: "object" + post: + description: "" + responses: + "400": + description: "Bad Request" + content: + '*/*': + schema: + type: "object" + "201": + description: "Created" + content: + '*/*': + schema: + $ref: "#/components/schemas/PlayerCreationResponseDto" + /players/{playerId}: + get: + description: "" + parameters: + - name: "playerId" + in: "path" + required: true + schema: + type: "string" + responses: + "400": + description: "Bad Request" + content: + '*/*': + schema: + type: "object" + "404": + description: "Not Found" + content: + '*/*': + schema: + type: "object" + "200": + description: "OK" + content: + '*/*': + schema: + $ref: "#/components/schemas/PlayerRetrievalResponseDto" + /players/{playerId}/score: + put: + description: "" + parameters: + - name: "playerId" + in: "path" + required: true + schema: + type: "string" + responses: + "400": + description: "Bad Request" + content: + '*/*': + schema: + type: "object" + "200": + description: "OK" + content: + '*/*': + schema: + $ref: "#/components/schemas/ScoreUpdateResponseDto" +components: + schemas: + PlayerLeaderBoardDto: + type: "object" + properties: + playerId: + type: "string" + name: + type: "string" + score: + type: "integer" + format: "int64" + rank: + type: "integer" + format: "int32" + required: + - "playerId" + - "name" + - "score" + - "rank" + PlayerCreationResponseDto: + type: "object" + properties: + playerId: + type: "string" + name: + type: "string" + score: + type: "integer" + format: "int64" + required: + - "playerId" + - "name" + - "score" + PlayerRetrievalResponseDto: + type: "object" + properties: + playerId: + type: "string" + name: + type: "string" + score: + type: "integer" + format: "int64" + rank: + type: "integer" + format: "int32" + required: + - "playerId" + - "name" + - "score" + - "rank" + ScoreUpdateResponseDto: + type: "object" + properties: + playerId: + type: "string" + name: + type: "string" + score: + type: "integer" + format: "int64" + required: + - "playerId" + - "name" + - "score" \ No newline at end of file diff --git a/src/test/kotlin/fr/takima/BaseIntegration.kt b/src/test/kotlin/fr/takima/BaseIntegration.kt new file mode 100644 index 0000000..987035a --- /dev/null +++ b/src/test/kotlin/fr/takima/BaseIntegration.kt @@ -0,0 +1,17 @@ +package fr.takima + +import io.ktor.server.config.ApplicationConfig +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import org.koin.test.KoinTest + +open class IntegrationTestContext : KoinTest { + fun integrationTestApplication(integrationTest: suspend ApplicationTestBuilder.() -> Unit) = + testApplication { + environment { + config = ApplicationConfig("application-test.yaml") + } + // Execute the content of the integration test + integrationTest() + } +} diff --git a/src/test/kotlin/fr/takima/TestApplication.kt b/src/test/kotlin/fr/takima/TestApplication.kt new file mode 100644 index 0000000..491563a --- /dev/null +++ b/src/test/kotlin/fr/takima/TestApplication.kt @@ -0,0 +1,15 @@ +package fr.takima + +import fr.takima.plugins.configureExceptionHandling +import fr.takima.plugins.configureKoinTest +import fr.takima.plugins.configureSerialization +import fr.takima.repository.migrateDatabase +import io.ktor.server.application.Application + +fun Application.testModule() { + configureKoinTest() + configureRouting() + configureSerialization() + configureExceptionHandling() + migrateDatabase() +} diff --git a/src/test/kotlin/fr/takima/TestDynamoDbConfiguration.kt b/src/test/kotlin/fr/takima/TestDynamoDbConfiguration.kt new file mode 100644 index 0000000..b593427 --- /dev/null +++ b/src/test/kotlin/fr/takima/TestDynamoDbConfiguration.kt @@ -0,0 +1,53 @@ +package fr.takima + +import io.ktor.server.config.ApplicationConfig +import org.slf4j.LoggerFactory +import org.testcontainers.containers.localstack.LocalStackContainer +import org.testcontainers.utility.DockerImageName +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient + +object TestDynamoDbConfiguration { + private val logger = LoggerFactory.getLogger(TestDynamoDbConfiguration::class.java) + + private val localStack: LocalStackContainer = + LocalStackContainer( + DockerImageName.parse( + "localstack/localstack:latest", + ), + ).withServices(LocalStackContainer.Service.DYNAMODB) + val accessKeyId = + ApplicationConfig("application.yaml") + .property("ktor.database.accessKeyId") + .getString() + val secretAccessKey = + ApplicationConfig("application.yaml") + .property("ktor.database.secretAccessKey") + .getString() + + init { + logger.info("Starting LocalStack DynamoDB container for tests...") + localStack.start() + } + + fun createDynamoDbClient(): DynamoDbAsyncClient { + val endpoint = localStack.getEndpointOverride(LocalStackContainer.Service.DYNAMODB) + + return DynamoDbAsyncClient + .builder() + .endpointOverride(endpoint) + .region(Region.EU_WEST_1) + .credentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKeyId, secretAccessKey)), + ).build() + } + + fun createDataSource(dynamoDbAsyncClient: DynamoDbAsyncClient): DynamoDbEnhancedAsyncClient = + DynamoDbEnhancedAsyncClient + .builder() + .dynamoDbClient(dynamoDbAsyncClient) + .build() +} diff --git a/src/test/kotlin/fr/takima/controller/PlayerRouteTest.kt b/src/test/kotlin/fr/takima/controller/PlayerRouteTest.kt new file mode 100644 index 0000000..72fe6ab --- /dev/null +++ b/src/test/kotlin/fr/takima/controller/PlayerRouteTest.kt @@ -0,0 +1,48 @@ +package fr.takima.controller + +import fr.takima.IntegrationTestContext +import fr.takima.dto.PlayerCreationRequestDto +import fr.takima.dto.PlayerCreationResponseDto +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +class PlayerRouteTest : IntegrationTestContext() { + @Test + fun `should return 201`() = + integrationTestApplication { + // Given + val playerCreationRequestDto = + PlayerCreationRequestDto( + name = "Alice", + ) + val expectedPlayerCreationResponseDto = + PlayerCreationResponseDto( + playerId = "test", + name = "Alice", + score = 0, + ) + + // When + val response = + client.post("/players") { + contentType(ContentType.Application.Json) + setBody(Json.encodeToString(playerCreationRequestDto)) + } + val actualPlayerCreationResponseDto = Json.decodeFromString<PlayerCreationResponseDto>(response.bodyAsText()) + + // Then + Assertions.assertEquals(HttpStatusCode.Created, response.status) + Assertions.assertEquals( + expectedPlayerCreationResponseDto.copy(playerId = ""), + actualPlayerCreationResponseDto.copy(playerId = ""), + ) + } +} diff --git a/src/test/kotlin/fr/takima/plugins/KoinTest.kt b/src/test/kotlin/fr/takima/plugins/KoinTest.kt new file mode 100644 index 0000000..ad82c8d --- /dev/null +++ b/src/test/kotlin/fr/takima/plugins/KoinTest.kt @@ -0,0 +1,20 @@ +package fr.takima.plugins + +import fr.takima.TestDynamoDbConfiguration +import io.ktor.server.application.Application +import io.ktor.server.application.install +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin + +fun Application.configureKoinTest() { + install(Koin) { + modules(testDatabaseModule) + modules(leaderBoardModule) + } +} + +val testDatabaseModule = + module { + single { TestDynamoDbConfiguration.createDynamoDbClient() } + single { TestDynamoDbConfiguration.createDataSource(get()) } + } diff --git a/src/test/kotlin/fr/takima/service/PlayerServiceTest.kt b/src/test/kotlin/fr/takima/service/PlayerServiceTest.kt new file mode 100644 index 0000000..bf1d80b --- /dev/null +++ b/src/test/kotlin/fr/takima/service/PlayerServiceTest.kt @@ -0,0 +1,155 @@ +package fr.takima.service + +import fr.takima.dto.PlayerLeaderBoardDto +import fr.takima.dto.PlayerRetrievalResponseDto +import fr.takima.entity.PlayerEntity +import fr.takima.repository.PlayerDao +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertEquals + +class PlayerServiceTest { + private val playerDao: PlayerDao = mockk() + private val databaseMigrationService: DatabaseMigrationService = mockk() + private val playerService = PlayerService(playerDao, databaseMigrationService) + + private val playerId1: UUID = UUID.randomUUID() + private val playerId2: UUID = UUID.randomUUID() + private val playerId3: UUID = UUID.randomUUID() + private val playerId4: UUID = UUID.randomUUID() + private val playerEntity1 = + PlayerEntity( + playerId = playerId1, + name = "Alice", + score = 20, + ) + private val playerEntity2 = + PlayerEntity( + playerId = playerId2, + name = "Bob", + score = 60, + ) + private val playerEntity3 = + PlayerEntity( + playerId = playerId3, + name = "Charlie", + score = 40, + ) + private val playerEntity4 = + PlayerEntity( + playerId = playerId4, + name = "David", + score = 40, + ) + private val playerLeaderboard1 = + PlayerLeaderBoardDto( + playerId = playerId1.toString(), + name = "Alice", + score = 20, + rank = 4, + ) + private val playerLeaderboard2 = + PlayerLeaderBoardDto( + playerId = playerId2.toString(), + name = "Bob", + score = 60, + rank = 1, + ) + private val playerLeaderboard3 = + PlayerLeaderBoardDto( + playerId = playerId3.toString(), + name = "Charlie", + score = 40, + rank = 2, + ) + private val playerLeaderboard4 = + PlayerLeaderBoardDto( + playerId = playerId4.toString(), + name = "David", + score = 40, + rank = 2, + ) + + @Test + fun `Should return player with rank`() { + // Given + val playerEntityList = listOf(playerEntity1, playerEntity2, playerEntity3) + + coEvery { playerDao.findAll() }.returns(playerEntityList) + + val expected = + PlayerRetrievalResponseDto( + playerId = playerId3.toString(), + name = "Charlie", + score = 40, + rank = 2, + ) + + // When + val actual = runBlocking { playerService.getByIdWithRank(playerId3.toString()) } + + // Then + assertEquals(expected, actual) + } + + @Test + fun `Should return empty leaderboard with zero player`() { + // Given + val playerEntityList: List<PlayerEntity> = listOf() + + coEvery { playerDao.findAll() }.returns(playerEntityList) + + val expected: List<PlayerLeaderBoardDto> = listOf() + + // When + val actual = runBlocking { playerService.getLeaderboard() } + + // Then + assertEquals(expected, actual) + } + + @Test + fun `Should return leaderboard with one player`() { + // Given + val playerEntityList = listOf(playerEntity2) + + coEvery { playerDao.findAll() }.returns(playerEntityList) + + val expected = listOf(playerLeaderboard2) + + // When + val actual = runBlocking { playerService.getLeaderboard() } + + // Then + assertEquals(expected, actual) + } + + @Test + fun `Should return leaderboard with correct order when tow players have the same score`() { + // Given + val playerEntityList = listOf(playerEntity1, playerEntity2, playerEntity3, playerEntity4) + + coEvery { playerDao.findAll() }.returns(playerEntityList) + + val expected1 = playerLeaderboard1 + val expected2 = playerLeaderboard2 + val expected3 = playerLeaderboard3 + val expected4 = playerLeaderboard4 + + // When + val actual = runBlocking { playerService.getLeaderboard() } + val actual1 = actual.find { it.playerId == playerId1.toString() }!! + val actual2 = actual.find { it.playerId == playerId2.toString() }!! + val actual3 = actual.find { it.playerId == playerId3.toString() }!! + val actual4 = actual.find { it.playerId == playerId4.toString() }!! + + // Then + assertEquals(expected1, actual1) + assertEquals(expected2, actual2) + assertEquals(expected3, actual3) + assertEquals(expected4, actual4) + } +} diff --git a/src/test/kotlin/fr/takima/utils/PlayerMapperTest.kt b/src/test/kotlin/fr/takima/utils/PlayerMapperTest.kt new file mode 100644 index 0000000..bf3e9d0 --- /dev/null +++ b/src/test/kotlin/fr/takima/utils/PlayerMapperTest.kt @@ -0,0 +1,36 @@ +package fr.takima.utils + +import fr.takima.entity.PlayerEntity +import fr.takima.model.PlayerModel +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertEquals + +class PlayerMapperTest { + @Test + fun `Should map all Player fields`() { + // Given + val playerUuid = UUID.randomUUID() + val playerName = "Alice" + val playerScore: Long = 500 + val playerEntity = + PlayerEntity( + playerId = playerUuid, + name = playerName, + score = playerScore, + ) + val expected = + PlayerModel( + playerId = playerUuid, + name = playerName, + score = playerScore, + rank = null, + ) + + // When + val actual = playerEntity.toPlayerModel() + + // Then + assertEquals(expected, actual) + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..57a395e --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,6 @@ +ktor: + application: + modules: + - fr.takima.TestApplicationKt.testModule + deployment: + port: 8081 -- GitLab