From 3a6ccc415ae171c67396947e9bc734ad576454a1 Mon Sep 17 00:00:00 2001 From: Mads Mogensen Date: Thu, 4 Apr 2024 16:29:59 +0200 Subject: [PATCH] Implement ApiClient for all endpoints Change delete endpoints to only take the id of the object to delete. Change delete endpoints to use the HTTP DELETE method. Add protocol objects to the frontend for talking to the backend. Use kotlin coroutines instead of callbacks for the ApiClient. Create tests that connect to http://localhost:3000 and tests with the backend. --- .github/workflows/frontend.yml | 16 +- backend/src/handlers/tasks.rs | 6 +- backend/src/main.rs | 8 +- backend/src/protocol/devices.rs | 5 + backend/src/protocol/tasks.rs | 5 + .../schedulingfrontend/ApiButton.kt | 51 +++--- .../schedulingfrontend/api/ApiClient.kt | 12 +- .../schedulingfrontend/api/ApiService.kt | 68 +++++++- .../api/protocol/Accounts.kt | 12 ++ .../api/protocol/Devices.kt | 15 ++ .../schedulingfrontend/api/protocol/Tasks.kt | 18 +++ .../api/protocol/Timespan.kt | 7 + .../schedulingfrontend/ApiServiceTest.kt | 145 ++++++++++++++++++ .../schedulingfrontend/ExampleUnitTest.kt | 18 --- 14 files changed, 321 insertions(+), 65 deletions(-) create mode 100644 frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Accounts.kt create mode 100644 frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Devices.kt create mode 100644 frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Tasks.kt create mode 100644 frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Timespan.kt create mode 100644 frontend/app/src/test/java/dk/scheduling/schedulingfrontend/ApiServiceTest.kt delete mode 100644 frontend/app/src/test/java/dk/scheduling/schedulingfrontend/ExampleUnitTest.kt diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 0bde4de0..f6d6d8b3 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +env: + CARGO_TERM_COLOR: always + DATABASE_URL: sqlite://ci.db?mode=rwc + jobs: build: @@ -30,8 +34,17 @@ jobs: java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 + - uses: Swatinem/rust-cache@v2 + - name: Bootstrap System Under Test (SUT) + run: | + cargo install sqlx-cli --no-default-features --features sqlite + cargo sqlx db create + cargo sqlx migrate run + cargo build + cargo run & + working-directory: backend - name: Build with Gradle - run: ./gradlew build + run: ./gradlew build -x test - name: Test (local) run: ./gradlew test - name: Test (emulator) @@ -45,6 +58,7 @@ jobs: with: working-directory: "./frontend" show-skipped: "true" + if: ${{ always() }} - name: Lint run: | ./gradlew lint diff --git a/backend/src/handlers/tasks.rs b/backend/src/handlers/tasks.rs index e17e6477..e011a8d3 100644 --- a/backend/src/handlers/tasks.rs +++ b/backend/src/handlers/tasks.rs @@ -5,7 +5,7 @@ use crate::{ data_model::{task::Task, time::Timespan}, extractors::auth::Authentication, handlers::util::internal_error, - protocol::tasks::CreateTaskRequest, + protocol::tasks::{CreateTaskRequest, DeleteTaskRequest}, }; #[debug_handler] @@ -77,7 +77,7 @@ pub async fn create_task( pub async fn delete_task( State(pool): State, Authentication(account_id): Authentication, - Json(task): Json, + Json(delete_task_request): Json, ) -> Result<(), (StatusCode, String)> { sqlx::query!( r#" @@ -89,7 +89,7 @@ pub async fn delete_task( AND Devices.account_id == ? ) "#, - task.id, + delete_task_request.id, account_id ) .execute(&pool) diff --git a/backend/src/main.rs b/backend/src/main.rs index f99dce45..75592436 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -6,7 +6,7 @@ mod protocol; use std::error::Error; use axum::{ - routing::{get, post}, + routing::{delete, get, post}, Router, }; use dotenv::dotenv; @@ -17,7 +17,7 @@ use handlers::{accounts::*, devices::*, tasks::*}; #[tokio::main] async fn main() -> Result<(), Box> { - dotenv()?; + dotenv().ok(); let db_connection_string = std::env::var("DATABASE_URL")?; @@ -40,10 +40,10 @@ fn app(pool: SqlitePool) -> Router { Router::new() .route("/tasks/all", get(get_tasks)) .route("/tasks/create", post(create_task)) - .route("/tasks/delete", post(delete_task)) + .route("/tasks/delete", delete(delete_task)) .route("/devices/all", get(get_devices)) .route("/devices/create", post(create_device)) - .route("/devices/delete", post(delete_device)) + .route("/devices/delete", delete(delete_device)) .route("/accounts/register", post(register_account)) .route("/accounts/login", post(login_to_account)) .with_state(pool) diff --git a/backend/src/protocol/devices.rs b/backend/src/protocol/devices.rs index 153500fd..1443346e 100644 --- a/backend/src/protocol/devices.rs +++ b/backend/src/protocol/devices.rs @@ -4,3 +4,8 @@ use serde::{Deserialize, Serialize}; pub struct CreateDeviceRequest { pub effect: f64, } + +#[derive(Deserialize, Serialize)] +pub struct DeleteDeviceRequest { + pub id: i64, +} diff --git a/backend/src/protocol/tasks.rs b/backend/src/protocol/tasks.rs index 554a3455..a716093a 100644 --- a/backend/src/protocol/tasks.rs +++ b/backend/src/protocol/tasks.rs @@ -8,3 +8,8 @@ pub struct CreateTaskRequest { pub duration: Milliseconds, pub device_id: i64, } + +#[derive(Deserialize, Serialize)] +pub struct DeleteTaskRequest { + pub id: i64, +} diff --git a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/ApiButton.kt b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/ApiButton.kt index 82ac6a8e..f2aaf801 100644 --- a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/ApiButton.kt +++ b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/ApiButton.kt @@ -4,41 +4,34 @@ import android.util.Log import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import dk.scheduling.schedulingfrontend.api.ApiClient -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import androidx.compose.runtime.rememberCoroutineScope +import dk.scheduling.schedulingfrontend.api.getApiClient +import kotlinx.coroutines.launch @Composable fun ApiButton() { - Button(onClick = { - val call = ApiClient.apiService.test() + val coroutineScope = rememberCoroutineScope() + val apiService = getApiClient("http://10.0.2.2:2222") - call.enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - if (response.isSuccessful) { - // val post = response.body() - Log.i("testAPI", "we got a response") - // Handle the retrieved post data - } else { - Log.w("testAPI", "we did not get a successful response") - // Handle error - } - } + Button(onClick = { + coroutineScope.launch { + try { + val response = apiService.getTasks("NOT_VALID") - override fun onFailure( - call: Call, - t: Throwable, - ) { - // Handle failure - Log.e("testAPI", "could not get a response") + if (response.isSuccessful) { + // val post = response.body() + Log.i("testAPI", "we got a response") + // Handle the retrieved post data + } else { + Log.w("testAPI", "we did not get a successful response") + Log.w("testAPI", response.message()) + // Handle error } - }, - ) + } catch (e: Exception) { + Log.e("testApi", e.toString()) + throw e + } + } }) { Text("Test API") } diff --git a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/ApiClient.kt b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/ApiClient.kt index ef05a42e..314c82fb 100644 --- a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/ApiClient.kt +++ b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/ApiClient.kt @@ -4,19 +4,19 @@ import com.google.gson.GsonBuilder import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.create import java.security.SecureRandom import java.security.cert.X509Certificate import javax.net.ssl.SSLContext import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager -object RetrofitClient { +class RetrofitClient(val baseUrl: String) { /** * The base URL of the API * 10.0.2.2 is a special alias to your host loopback interface (127.0.0.1 on your development machine) * 2222 is the port where the server is running */ - private const val BASE_URL = "http://10.0.2.2:2222/" private val gson = GsonBuilder().setLenient().create() private val okHttpClient: OkHttpClient by lazy { val trustAllCertificates = @@ -48,15 +48,13 @@ object RetrofitClient { } val retrofit: Retrofit by lazy { Retrofit.Builder() - .baseUrl(BASE_URL) + .baseUrl(baseUrl) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create(gson)) .build() } } -object ApiClient { - val apiService: ApiService by lazy { - RetrofitClient.retrofit.create(ApiService::class.java) - } +fun getApiClient(baseUrl: String): ApiService { + return RetrofitClient(baseUrl).retrofit.create() } diff --git a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/ApiService.kt b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/ApiService.kt index ccb2fc6f..4adc0f7a 100644 --- a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/ApiService.kt +++ b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/ApiService.kt @@ -1,8 +1,70 @@ package dk.scheduling.schedulingfrontend.api -import retrofit2.Call +import dk.scheduling.schedulingfrontend.api.protocol.CreateDeviceRequest +import dk.scheduling.schedulingfrontend.api.protocol.CreateTaskRequest +import dk.scheduling.schedulingfrontend.api.protocol.DeleteDeviceRequest +import dk.scheduling.schedulingfrontend.api.protocol.DeleteTaskRequest +import dk.scheduling.schedulingfrontend.api.protocol.Device +import dk.scheduling.schedulingfrontend.api.protocol.RegisterOrLoginRequest +import dk.scheduling.schedulingfrontend.api.protocol.RegisterOrLoginResponse +import dk.scheduling.schedulingfrontend.api.protocol.Task +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST interface ApiService { - @GET("test") - fun test(): Call + /* + * Accounts + */ + @POST("accounts/register") + suspend fun registerAccount( + @Body registerOrLoginRequest: RegisterOrLoginRequest, + ): Response + + @POST("accounts/login") + suspend fun loginToAccount( + @Body registerOrLoginRequest: RegisterOrLoginRequest, + ): Response + + /* + * Devices + */ + @GET("devices/all") + suspend fun getDevices( + @Header("X-Auth-Token") authToken: String, + ): Response> + + @POST("devices/create") + suspend fun createDevice( + @Header("X-Auth-Token") authToken: String, + @Body createDeviceRequest: CreateDeviceRequest, + ): Response + + @DELETE("devices/delete") + suspend fun deleteDevice( + @Header("X-Auth-Token") authToken: String, + @Body deleteDeviceRequest: DeleteDeviceRequest, + ): Response + + /* + * Tasks + */ + @GET("tasks/all") + suspend fun getTasks( + @Header("X-Auth-Token") authToken: String, + ): Response> + + @POST("tasks/create") + suspend fun createTask( + @Header("X-Auth-Token") authToken: String, + @Body createTaskRequest: CreateTaskRequest, + ): Response + + @DELETE("tasks/delete") + suspend fun deleteTask( + @Header("X-Auth-Token") authToken: String, + @Body deleteTaskRequest: DeleteTaskRequest, + ): Response } diff --git a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Accounts.kt b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Accounts.kt new file mode 100644 index 00000000..d2aa5a38 --- /dev/null +++ b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Accounts.kt @@ -0,0 +1,12 @@ +package dk.scheduling.schedulingfrontend.api.protocol + +import java.util.UUID + +data class RegisterOrLoginRequest( + val username: String, + val password: String, +) + +data class RegisterOrLoginResponse( + val auth_token: UUID, +) diff --git a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Devices.kt b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Devices.kt new file mode 100644 index 00000000..f8187a9f --- /dev/null +++ b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Devices.kt @@ -0,0 +1,15 @@ +package dk.scheduling.schedulingfrontend.api.protocol + +data class Device( + val id: Long, + val effect: Double, + val account_id: Long, +) + +data class CreateDeviceRequest( + val effect: Double, +) + +data class DeleteDeviceRequest( + val id: Long, +) diff --git a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Tasks.kt b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Tasks.kt new file mode 100644 index 00000000..8dcaaedc --- /dev/null +++ b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Tasks.kt @@ -0,0 +1,18 @@ +package dk.scheduling.schedulingfrontend.api.protocol + +data class Task( + val id: Long, + val timespan: Timespan, + val duration: Long, + val device_id: Long, +) + +data class CreateTaskRequest( + val timespan: Timespan, + val duration: Long, + val device_id: Long, +) + +data class DeleteTaskRequest( + val id: Long, +) diff --git a/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Timespan.kt b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Timespan.kt new file mode 100644 index 00000000..e3149898 --- /dev/null +++ b/frontend/app/src/main/java/dk/scheduling/schedulingfrontend/api/protocol/Timespan.kt @@ -0,0 +1,7 @@ +package dk.scheduling.schedulingfrontend.api.protocol + +// TODO: This should not be strings +data class Timespan( + val start: String, + val end: String, +) diff --git a/frontend/app/src/test/java/dk/scheduling/schedulingfrontend/ApiServiceTest.kt b/frontend/app/src/test/java/dk/scheduling/schedulingfrontend/ApiServiceTest.kt new file mode 100644 index 00000000..70d631dc --- /dev/null +++ b/frontend/app/src/test/java/dk/scheduling/schedulingfrontend/ApiServiceTest.kt @@ -0,0 +1,145 @@ +package dk.scheduling.schedulingfrontend + +import dk.scheduling.schedulingfrontend.api.ApiService +import dk.scheduling.schedulingfrontend.api.getApiClient +import dk.scheduling.schedulingfrontend.api.protocol.CreateDeviceRequest +import dk.scheduling.schedulingfrontend.api.protocol.CreateTaskRequest +import dk.scheduling.schedulingfrontend.api.protocol.Device +import dk.scheduling.schedulingfrontend.api.protocol.RegisterOrLoginRequest +import dk.scheduling.schedulingfrontend.api.protocol.Task +import dk.scheduling.schedulingfrontend.api.protocol.Timespan +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import retrofit2.Response +import java.util.UUID +import kotlin.random.Random +import kotlin.random.nextULong + +class ApiServiceTest { + private lateinit var apiService: ApiService + + @Before + fun setup() { + apiService = getApiClient("http://localhost:3000") + } + + /* + * Accounts + */ + + @Test + fun testRegisterAccount() { + createAccount("register_test") + } + + @Test + fun testLoginToAccount() { + runBlocking { + val (_, username) = createAccount("login_test") + + val response = apiService.loginToAccount(RegisterOrLoginRequest(username, "test_password")) + assert(response.isSuccessful) { printErrorContext(response) } + + val loginResponse = response.body()!! + } + } + + /* + * Tasks + */ + + @Test + fun testCreateTask() { + runBlocking { + val (authToken, _) = createAccount("create_task_test") + + val device = createDevice(authToken) + + val task = createTask(authToken, device) + } + } + + @Test + fun testGetAllTasks() { + runBlocking { + val (authToken, _) = createAccount("get_all_tasks_test") + + val device = createDevice(authToken) + + val emptyResponse = + apiService.getTasks( + authToken.toString(), + ) + assert(emptyResponse.isSuccessful) { printErrorContext(emptyResponse) } + + val emptyTasks = emptyResponse.body()!! + + assert(emptyTasks.isEmpty()) + + val task = createTask(authToken, device) + + val taskResponse = apiService.getTasks(authToken.toString()) + assert(taskResponse.isSuccessful) { printErrorContext(taskResponse) } + + val fullTasks = taskResponse.body()!! + assert(fullTasks.size == 1) + + val gottenTask = fullTasks.single() + + assertEquals(task, gottenTask) + } + } + + private fun createAccount(username: String): Pair { + return runBlocking { + val randomNumber = Random.nextULong() + val randomUsername = username + randomNumber + val response = apiService.registerAccount(RegisterOrLoginRequest(randomUsername, "test_password")) + assert(response.isSuccessful) { printErrorContext(response) } + + val registerResponse = response.body()!! + return@runBlocking Pair(registerResponse.auth_token, randomUsername) + } + } + + private fun createDevice(authToken: UUID): Device { + return runBlocking { + val response = apiService.createDevice(authToken.toString(), CreateDeviceRequest(1000.0)) + assert(response.isSuccessful) { printErrorContext(response) } + + val device = response.body()!! + return@runBlocking device + } + } + + private fun createTask( + authToken: UUID, + device: Device, + ): Task { + return runBlocking { + val response = + apiService.createTask( + authToken.toString(), + CreateTaskRequest(Timespan("2024-04-04T14:13:14.587Z", "2024-04-04T15:13:14.587Z"), 30 * 60 * 1000, device.id), + ) + assert(response.isSuccessful) { printErrorContext(response) } + + val task = response.body()!! + return@runBlocking task + } + } + + private fun printErrorContext(response: Response): String { + val bodyString = + if (response.errorBody() == null) { + "NULL" + } else { + response.errorBody()!!.string() + } + return "Status: ${response.code()}\n" + + "Message: ${response.message()}\n" + + "Body: $bodyString" + } +} diff --git a/frontend/app/src/test/java/dk/scheduling/schedulingfrontend/ExampleUnitTest.kt b/frontend/app/src/test/java/dk/scheduling/schedulingfrontend/ExampleUnitTest.kt deleted file mode 100644 index 5ca1b192..00000000 --- a/frontend/app/src/test/java/dk/scheduling/schedulingfrontend/ExampleUnitTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -@file:Suppress("ktlint:standard:no-wildcard-imports") - -package dk.scheduling.schedulingfrontend - -import org.junit.Assert.* -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -}